Appearance
Scheduling & Reminders
The calendar (/calendar) tracks three things: personal events (including OA dates), tasks, and the notifications the system generates about them. Data lives per-user in users/{uid}/events, tasks, and notifications; useEvents / useTasks / useNotifications keep the UI live via onSnapshot. EventModal is where OA events get their reminder windows.
Reminder windows
An OA event opts into any of four reminder emails: 7 days, 3 days, 1 day, and 1 hour before start (d7 / d3 / d1 / h1). Sent state is recorded per-window on the event (remindersSent flags), so each window fires at most once.
Why a Worker, not GitHub Actions
The first implementation ran scripts/send-reminders.mjs on a GitHub Actions cron. GitHub's scheduled workflows are best-effort — under load they start late or silently skip, which is disqualifying for an "OA starts in 1 hour" email. The system was rebuilt as a dedicated Cloudflare Worker (worker-reminders/) on a per-minute cron trigger, which is reliable.
The Node script still exists and shares field conventions with the Worker — useful for manual backfills and local testing — but production sending is Worker-only.
Exactly-once under concurrency
A per-minute cron means overlapping invocations are possible (slow run + next tick). Deduplication is transactional:
- The Worker queries upcoming events whose windows are due and unsent.
- For each candidate it writes an optimistic claim (
reminderClaims) using a Firestore precondition — only one invocation wins a given event+window. - The winner sends via Resend, then sets the
remindersSent.<window>flag. - A send failure releases the claim so the next tick retries; a success is permanent.
The Worker talks to Firestore over the REST API with a service-account JWT (no SDK weight in a Worker) — the auth flow is documented in Cloudflare Workers.
The email-sync bug worth remembering
Reminders were silently skipping some users. Root cause: the Worker reads the recipient address from the users/{uid} profile document, but AuthContext only wrote email there on profile creation — accounts created before that field existed, or users who changed their auth email, had profiles with no/stale email, and the Worker (correctly) refused to send to nothing. Two-part fix:
AuthContextnow syncsemailon every sign-in, not just first creation.scripts/backfill-emails.mjswas run once to patch existing profiles.
The lesson generalizes: any consumer that reads a mirrored field must ask "what keeps this mirror fresh?" — a one-time copy is a time bomb.
Dashboard integration
Today's events and due tasks surface on the dashboard's schedule widget; OA-type events also deep-link into the OA Simulator so "reminded → practicing" is one click.