A tiny personal homework manager: a Cloudflare Worker polls a Schoology calendar feed — and, optionally, a Spond soccer team and a Google family calendar — every 30 minutes, stores assignments in KV, diffs each sync (so late-night postings show up under New), and serves a Progressive Web App that gives a daily study plan, an upcoming-due-dates list across all classes, and per-class views with check-offs.
Design background and the hosting/PWA comparison are in docs/design-notes.md.
src/worker.js fetch handler (/api/*) + scheduled handler (cron sync)
src/ics.js dependency-free iCalendar parser (Schoology + general calendars)
src/recur.js RRULE expansion, for calendars that repeat events by rule
src/spond.js Spond client (unofficial API) for the soccer schedule
src/courses.js class tagging: manual tags + keyword rules (feed has no course field)
src/planner.js earliest-deadline-first study planner
public/ the PWA (index.html, app.js, styles.css, manifest, sw.js, icons)
test/ node:test unit tests → npm test
wrangler.jsonc Cloudflare config (KV binding, cron, static assets)
The Schoology iCal URL is a bearer secret: anyone who has it can read the calendar. It lives only in Cloudflare secrets (ICS_URL) and in the git-ignored .dev.vars for local dev. Never paste it into code, wrangler.jsonc, an issue, or a commit. This repo is public on GitHub.
If you enable the Spond sync, the Spond account password is a Cloudflare secret too (SPOND_PASSWORD). The Google Calendar secret iCal address (GCAL_ICS_URL) is likewise a bearer credential — anyone holding it can read the family calendar. Same rule for all of them: never in a file, never in a commit.
The app itself is gated by a passphrase (APP_TOKEN). The static HTML shell is public; all data endpoints under /api/* require the passphrase.
- Node ≥ 20 (you have 25) and
npm installin this folder (already done — installswrangler). - A Cloudflare account: https://dash.cloudflare.com/sign-up (free plan; no card needed).
! npx wrangler loginA browser tab opens; click Allow. Verify with npx wrangler whoami.
npx wrangler kv namespace create SCHOOLOGY_KVIt prints something like:
{ binding = "SCHOOLOGY_KV", id = "0123abcd…" }
Copy the id into wrangler.jsonc, replacing REPLACE_WITH_KV_ID.
npx wrangler secret put ICS_URLPaste the feed URL with https:// instead of webcal:// (from your private note), press Enter.
npx wrangler secret put APP_TOKENType a passphrase she'll enter once on her phone (something memorable but long, e.g. four words). Note: secret put needs the Worker to exist — if it complains, run step 4 first, then come back.
npm run deployOutput ends with the public URL, e.g. https://schoology-homework.<your-subdomain>.workers.dev. The cron trigger (*/30 * * * *) is registered automatically.
The cron runs at the next :00/:30. To sync immediately, open the app in a browser, enter the passphrase, go to ⚙︎ → Sync now, or:
curl -X POST -H "authorization: Bearer YOUR_PASSPHRASE" https://schoology-homework.<sub>.workers.dev/api/sync
curl -H "authorization: Bearer YOUR_PASSPHRASE" https://schoology-homework.<sub>.workers.dev/api/healthhealth should show itemCount in the hundreds and lastError: null. To watch the cron live: npm run tail.
- Open the URL in Safari (must be Safari, not Chrome, for home-screen install).
- Enter the passphrase. It's stored on the phone only.
- Tap Share → Add to Home Screen → Add. The "Homework" icon appears; it launches full-screen like an app.
- Optional: also add the same iCal URL to Apple Calendar (Settings → Calendar → Accounts → Add Subscribed Calendar) so due dates show up in the phone's native calendar too.
- Nicer URL: if you own a domain on Cloudflare, add a route in the dashboard (Workers → your worker → Settings → Domains & Routes).
- Stronger gate: Cloudflare Zero Trust → Access (free for up to 50 users) can put a Google-login wall in front of the whole site. Then the passphrase becomes a second layer. Not needed to start.
Spond has no iCal feed and no public API — their only calendar feature is a one-way push into the phone's native calendar, and only for events you're personally invited to. So src/spond.js talks to the same undocumented endpoints the Spond app uses:
POST https://api.spond.com/core/v1/auth2/login {email, password} -> {accessToken:{token, expiration}}
GET https://api.spond.com/core/v1/sponds/?… Bearer token -> [event, …]
Caveats worth knowing before you turn it on:
- Unofficial and unversioned. Spond can change or block it without notice.
- 2FA breaks it. If the account has two-factor auth enabled, login fails; there's no TOTP flow.
- It needs the account password as a Cloudflare secret. Use the parent account that already sees her team.
- Only events that account is invited to are visible — same limit as Spond's own calendar sync.
If any of that goes bad, nothing else breaks: each source syncs independently, a failing source keeps its last-known items, and the error shows up per-source in ⚙︎ Settings → Sync.
npx wrangler secret put SPOND_EMAIL
npx wrangler secret put SPOND_PASSWORD
npm run deployOptionally pin it to one team, so you don't pick up every group the account belongs to. List the ids:
curl -H "authorization: Bearer YOUR_PASSPHRASE" https://schoology-homework.<sub>.workers.dev/api/spond/groupsthen set the one you want:
npx wrangler secret put SPOND_GROUP_IDSPOND_LABEL (a plain var, not a secret) is the chip label used when an event carries no group name; it defaults to Soccer.
Spond events are stored with kind: "event" and source: "spond", which means:
- They appear in Today and Tomorrow as a calendar card above the study plan, and in Upcoming alongside school events — with start–end time, location, and a link to the event in Spond.
- Cancelled events are struck through and marked, and a cancellation lands in the Changes log like any other edit.
- The planner ignores them. Daily study capacity is unchanged by practices and games (
src/planner.jsskipskind === "event"), and they never appear in the Classes tab.
Google's secret iCal address is a private feed URL, so this needs no OAuth and no API key — it reuses the same iCal path as Schoology.
- Google Calendar on the web → hover the family calendar in the left sidebar → ⋮ → Settings and sharing.
- Scroll to Integrate calendar.
- Copy Secret address in iCal format (it ends in
/basic.ics). Use the secret one, not the public one — the calendar isn't publicly shared.
Treat it like a password: anyone with that URL can read the calendar. If it ever leaks, Reset it on that same settings page.
npx wrangler secret put GCAL_ICS_URL
npm run deployGCAL_LABEL (a plain var, default Family) is the chip label, and GCAL_DAYS (default 120) is how far ahead to expand.
- Times come as wall clock in a named zone (
DTSTART;TZID=America/New_York:20260903T170000) rather than UTC.zonedToIsoinsrc/ics.jsresolves those, re-checking the offset so a 5pm event stays at 5pm on both sides of a daylight-saving change. - Repeating events are sent once, with an
RRULE— Google does not list each occurrence.src/recur.jsexpands them across the sync window, honouringINTERVAL,COUNT,UNTIL,BYDAY(including ordinals like3TU),BYMONTHDAY,EXDATE, and the separateRECURRENCE-IDevents Google emits for a single moved instance. Without this a weekly dinner would appear exactly once, on the day it was created.
Like soccer, they're kind: "event" — visible on the Today/Tomorrow calendar card and in Upcoming, invisible to the study planner and the Classes tab.
They deliberately don't appear in Changes. Occurrences are generated into a rolling window, so each day one enters and one leaves; logging that would bury real homework postings under daily churn (LOGGED_SOURCES in src/worker.js).
cp .dev.vars.example .dev.vars # then fill in ICS_URL and a dev APP_TOKEN
npm run dev # http://localhost:8787
npm run sync # trigger the cron locally
npm testwrangler dev uses a local KV simulator, so nothing touches production.
- Sync (
src/worker.js→sync): every 30 minutes the cron trigger runs each configured source — Schoology (.ics→parseSchoologyFeed), plus Spond (pullSpond) and Google Calendar (parseCalendarFeed) if their secrets are set — merges the results, compares them to the previous snapshot byuid, appendsnew/changed/cancelled/removedentries to the change log, and storesitems+metain KV. Sources are isolated: one failing leaves the other's items alone and is diffed as nothing-changed rather than mass deletion, so an outage never floods the log with false removals. The very first sync doesn't flood the log either. - Items carry
source(schoology/spond/gcal),kind(assignment/assessment/discussion/event, from the Schoology URL),course,title,due({date, allDay:true}or{iso, allDay:false}), plus the rawsummaryanddescription. - Planner (
src/planner.js): open, undone work items sorted by due date; each day gets a capacity (weekday/weekend minutes from Settings); big items are spread in chunks with the remainder allowed near the due date; anything that can't fit before its due date is listed as "Doesn't fit". - State (check-offs, per-item effort estimates, settings) is stored in KV so it syncs across devices.
- PWA:
sw.jscaches the shell for instant/offline launch;app.jscaches the last API responses inlocalStorageso the app still shows data when offline.
The Schoology feed carries no course information for assignments — just title, description, and a link (verified against real entries on 2026-08-28; the assignment pages themselves sit behind Google sign-in). So the app tags classes itself, in two layers (src/courses.js):
- Manual tag — tap the class chip on any assignment and pick a class (or "+ New class…"). Stored in KV (
state.courseOf), so it syncs across devices. - Keyword rules — in ⚙︎ Settings → Classes, give each class comma-separated keywords (e.g.
social studies, silk route). Any assignment whose title or description contains a keyword is tagged automatically. Manual tags always win.
Anything unmatched shows as Unassigned, listed first in the Classes tab.
- Push notifications for new/changed assignments (Web Push with VAPID keys from the Worker; works on iOS 16.4+ for home-screen PWAs).
- LLM pass for effort estimation from titles / backward planning for tests.
- Weekly "what got done" retro view.