A color-coded homework and schedule tracker for students. A static frontend on Firebase Hosting that reads and writes Firestore directly from the browser.
Sign in with Google, organize your classes into custom spaces, add assignments with deadlines, and access your schedule from any device.
https://studyflow-38a6b.web.app
- Frontend — Vanilla HTML / CSS / JS, Firebase compat SDK loaded via
<script>tags - Data — Firestore, accessed straight from the client; per-user access enforced by
firestore.rules - Files — Cloud Storage for assignment attachments, scoped per-user by
storage.rules - Hosting — Firebase Hosting serving
public/as-is - Reminders — a GitHub Actions cron job (
notifier/), the only thing that runs outside the browser
There is no backend and no build step. Every read and write in the app goes
through the api object at the top of public/js/app.js, which wraps the
Firestore client SDK. Security lives entirely in the rules files.
The one exception is push notifications: something has to be awake when the browser is closed. That job is a scheduled GitHub Actions workflow rather than a server — see Push Notifications.
History: this app used to run an Express REST API as a Cloud Function, with the frontend calling
/api/**. The project moved from the Blaze plan to Spark, which disables Cloud Functions, so every one of those calls started returning 503. The Express backend (server.js,src/,functions/) was ported into the client and then deleted — keeping it around only invited edits to code that could never run. To bring it back you would need Blaze again, plus:git log --diff-filter=D -- functions/ # find the commit that removed it git checkout <that-sha>^ -- functions src server.js…and restoring the
functionsblock and the/api/**rewrite infirebase.json.
make serve # http://localhost:5000This serves public/ against the real Firestore project, so you are editing live
data. There is nothing to install and no npm install step.
make deploy # hosting + firestore rules + storage rules
make rules # rules only, much fasterNever run a bare firebase deploy. It attempts a Cloud Functions deploy,
which fails on the Spark plan and aborts the rest of the release.
Do not add a database key to the firestore block in firebase.json.
Firestore's default database is named (default), parentheses included. Setting
it to "default" makes the CLI publish to a release for a database that does
not exist — it still uploads the ruleset and still prints
✔ released rules firestore.rules to cloud.firestore, so nothing looks wrong,
but the live database keeps serving whatever it served before. That typo sat in
this repo from April to August 2026 and quietly discarded every rules change in
between. If a rules edit doesn't seem to take effect, check the Rules tab in the
Firebase Console against firestore.rules before touching the rules themselves.
StudyFlow/
├── public/
│ ├── index.html
│ ├── js/
│ │ ├── app.js # All frontend logic; Firestore access in the `api` object
│ │ └── config.js # Firebase web config (public by design)
│ ├── css/style.css
│ └── sw.js # Service worker — push notification display only
├── notifier/
│ └── send-reminders.js # Scheduled reminder sender; runs on GitHub Actions
├── .github/workflows/
│ └── reminders.yml # Cron that runs the sender every 15 minutes
├── firebase.json # Hosting config + cache headers
├── firestore.rules # Per-user read/write scoping
├── firestore.indexes.json
├── storage.rules # Per-user attachment scoping
└── Makefile # serve / deploy / rules
Each signed-in user's data lives under their own UID:
users/{uid}/tabs/{tabId} # "spaces" in the UI
users/{uid}/classes/{classId} # "groups" in the UI
users/{uid}/folders/{folderId} # "folders" — mini-groups inside a group
users/{uid}/homework/{hwId} # "assignments" in the UI
userPrefs/{uid} # notifyBefore, shared across devices
pushSubscriptions/{subId} # { uid, endpoint, keys, notifyBefore }
Every collection carries an integer order field driving drag-to-reorder.
Attachments are stored at users/{uid}/hw-attachments/... in Cloud Storage and
referenced from the homework document.
A folder holds a subset of one group's assignments — { classId, name, order }
— and an assignment claims membership through an optional folderId. Nothing is
nested in Firestore; the tree is entirely reconstructed from those two fields.
A group's top level interleaves two collections: its folders, and the
assignments with no folderId. Both draw positions from one shared order
sequence, so groupChildren() in app.js merges them and renumberClasses()
writes 0..n-1 back across both. The assignments inside a folder number
themselves 0..n-1 within that folder, so an assignment's order means "position
in my folder" or "position in my group" depending on whether folderId is set.
Because the two halves of a group's top level live in different collections,
neither one's indices are contiguous — which is why placement goes through
writeOrders() (explicit { id, order } entries) rather than writeOrder()
(position = array index). writeOrder is now a thin wrapper over it, still used
for spaces and the settings-panel reorders, where one collection owns the whole
list.
Folder ids are client-generated, unlike every other document here. Undoing a
folder delete has to bring the same id back, because the assignments restored
alongside it still point at that id through folderId — so
api.folders.create() takes the id to write to.
Deletes cascade in the browser: removing a group deletes its folders and their assignments, removing a space deletes everything under all of its groups. There is no server-side trigger to do it (see Known Limitations).
Firestore rules ensure users can only read and write their own documents.
The browser subscribes and saves the subscription to pushSubscriptions, and
public/sw.js displays whatever arrives. Between those two ends, something has
to be awake at the right moment to actually send the push — that used to be a
Cloud Task per assignment, scheduled by a Cloud Function. Those died with the
move to the Spark plan and were deleted, which left subscriptions being written
to a collection nothing ever read.
notifier/send-reminders.js replaces that design with a periodic scan, run by
.github/workflows/reminders.yml every 15 minutes. Each run reads
pushSubscriptions, and for every user that has one, checks their assignments
for reminders whose time has come. There is no scheduling state to fall out of
sync — a missed run is picked up by the next one.
Four repository secrets (Settings → Secrets and variables → Actions):
| Secret | Value |
|---|---|
FIREBASE_SERVICE_ACCOUNT |
The entire contents of serviceAccountKey.json |
VAPID_PUBLIC_KEY |
Must match VAPID_PUBLIC_KEY in public/js/app.js |
VAPID_PRIVATE_KEY |
The private half of that pair |
VAPID_EMAIL |
mailto:you@example.com — push services require a contact |
If the private key has been lost, generate a fresh pair with
npx web-push generate-vapid-keys. The public half must then also be pasted
into public/js/app.js and redeployed, and every device has to toggle
notifications off and back on — existing subscriptions are bound to the old
key and will start failing with 403. The sender deletes subscriptions the push
service reports as expired (404/410), but a key mismatch is not one of those.
Actions → Send assignment reminders → Run workflow:
mode: testpushes "Notifications are working!" to every subscribed device — the replacement for the old/api/notifications/testendpoint.dry_run: truelogs what would be sent without sending or writing anything.
Two environment variables in the workflow adjust behavior. MAX_LATE_MINUTES
(default 360) is how stale a reminder may be and still be worth sending; past
it, the assignment is marked as reminded without a push, so a run after an
outage doesn't dump a backlog. DEFAULT_TIMEZONE (default
America/Los_Angeles) only matters for assignments saved before the client
started storing deadlineMs.
- GitHub queues scheduled workflows on a best-effort basis and can run them several minutes late under load, so treat the lead time as approximate.
- Scheduled workflows are disabled automatically after 60 days without a push to the repo. If reminders quietly stop, check that first.
- A reminder whose time has already passed when the assignment is created never fires — the same behavior the Cloud Tasks version had. Adding an assignment due in 12 hours with a "1 day before" reminder gets you nothing.
- A date with no time is treated as 23:59 on that date — "due sometime that
day" — which is also what the sort comparators in
app.jsassume. It used to be midnight, which put the whole day in the past and made a "1 hour before" reminder arrive at 11pm the night before.
- Completed assignments are pruned in the browser, not on a schedule. The
scheduled
cleanupsweep went away with the Functions backend, sopruneOldCompleted()inapp.jsre-applies the same 30-day policy on load. It only runs while someone has the app open — a dormant account never prunes. - Cascading deletes run in the browser. Deleting a space deletes its groups and their assignments client-side; closing the tab mid-delete can orphan documents.
- Free tier ceilings — 50k document reads and 20k writes per day, 1 GiB stored.