kanji-buddy
Push notifications
Hourly notifications to the phone when reviews or lessons become available — and the two decisions behind them that weren't obvious.
Two things it is not
It is not the email nudges, and it is not a Vercel cron. Both look like the obvious answer and both are wrong, for different reasons. Everything else here follows from those two.
Decision 1: Expo Push, not FCM directly
Firebase Cloud Messaging is free — genuinely, with no per-message cost and no cap. But the mobile app is Expo, and Expo’s own push service wraps FCM (Android) and APNs (iOS) behind a single HTTPS endpoint. Same price, far fewer moving parts: no Firebase project, no service-account JSON in the environment, no OAuth token minting.
POST https://exp.host/--/api/v2/push/send
[{ to: "ExponentPushToken[…]", title, body, badge, data }]The endpoint takes no credentials at all — an Expo push token is itself the capability. That means there is no secret to leak, and equally nothing standing between a stolen token and someone’s notifications. Acceptable because a token addresses exactly one install of one app, and Expo retires it the moment the app is uninstalled.
Free, except for iOS
Push to an iPhone needs APNs keys, which come from an Apple Developer account at $99/year — and you need that to install the app on a real device anyway. FCM and Expo are both free; iOS is not. Android is free end to end.
Decision 2: the schedule lives outside Vercel
Vercel’s Hobby plan allows one cron job, running once a day, and that budget was already spent on the nudge emails. A “your reviews are ready” notification delivered a day late is not a notification, so the schedule had to go somewhere else.
| Option | Free tier | Trade-off |
|---|---|---|
| GitHub Actions | 2,000 min/month on private repos; an hourly curl costs ~120 | Delayed 3–10 min under load, occasionally skipped, and the schedule is disabled after 60 days of repo inactivity. |
| cron-job.org | Free, down to 1-minute intervals | More reliable; one more account and one more place to look when something breaks. |
| Cloudflare Worker cron | Cron triggers included on the free plan | The most reliable of the three; a separate deploy target for three lines of code. |
| Vercel Pro | $20/month | Unlimited crons and nothing new to run — but not free. |
GitHub Actions was chosen, on the grounds that the repo already exists, the schedule lives beside the code it triggers, and it adds no new service. The endpoint is deliberately scheduler-agnostic — guarded by the same shared secret as the nudge cron and caring nothing about who calls it — so switching to any of the others is a matter of pointing them at the same URL with the same bearer. Nothing in the server would change.
Why the flakiest option is still fine
GitHub delays and sometimes drops scheduled workflows, which would normally rule it out for anything time-sensitive. It doesn’t here because the run holds no state of its own: it compares live counts against a stored baseline, so a missed hour is covered by the next run rather than lost, and a double-fire is absorbed by the minimum-gap gate. That property was designed in to make the free option viable, not discovered afterwards.
Why hourly is exactly right
Not a compromise between noise and latency — availableAtForStage floors every review time to the hour, so no item can come due between two hourly checks. Running more often would find nothing new; running less often would sit on work that is already waiting.
This is not the nudge policy
The two features look like the same thing and are opposites. Copying the gates across would have produced a notification arriving days after the thing it announces.
| Email nudge | Push | |
|---|---|---|
| Fires because | you have been away | work arrived |
| Gates on | idle days, backlog age, item count | did the count go up |
| Cadence | at most one a week | at most one an hour |
| Silent when | you are studying regularly | nothing new since last time |
Aggregation is a count, not a timer
users.last_pushed_count is what you were last told was waiting. A push is warranted only when more than that has arrived — so a trickle of items can’t produce a trickle of notifications.
The half that is easy to miss is that the baseline also falls when you study:
Quiet hours (22:00–08:00 in users.timezone) hold a push rather than dropping it: the baseline stays put, so the same items are still new at 08:00 and you get told in the morning instead of never.
Setting it up
| # | Step | Where |
|---|---|---|
| 1 | Apply migration 0004 — push_tokens, users.timezone, users.last_pushed_count | npm run db:migrate:prod |
| 2 | CRON_SECRET as a repository secret, same value as Vercel's | GitHub → Settings → Secrets → Actions |
| 3 | API_BASE_URL as a repository variable (optional) | defaults to https://kanjibuddy.mblrc.me |
| 4 | Set your time zone, so quiet hours know when your night is | /settings → Notifications |
| 5 | Register the device from the app | POST /api/v1/push_tokens |
Until step 5 every run reports no_devices, which is the correct no-op rather than a failure. Rehearse the whole thing without sending anything:
curl -s -H "Authorization: Bearer $CRON_SECRET" \
"https://kanjibuddy.mblrc.me/api/v1/internal/push?dry_run=1"dry_run=1 runs the same code path and decides every user, so it is a real rehearsal rather than a second implementation that can drift.
The app side
The server is done; the client is four steps. Expo reissues a push token whenever the app is reinstalled or restored to a new device, which is why registration belongs on every launch rather than once at first run — the endpoint upserts on the token, so calling it repeatedly costs one row.
1 npx expo install expo-notifications expo-device
2 ask permission Notifications.requestPermissionsAsync()
3 get the token Notifications.getExpoPushTokenAsync({ projectId })
4 register on launch POST /api/v1/push_tokens { token, platform }The notification carries data.url — /reviews or /lessons — so a tap can route straight to the work rather than dropping you on the dashboard. The badge is set to the total waiting, which is what makes the number on the app icon mean something.
Retiring dead devices
Expo answers with DeviceNotRegistered for an install that no longer exists, and that never recovers. The run marks those tokens revoked rather than deleting them, so a later reinstall is still an upsert and a delivery failure leaves a visible record instead of a row that quietly vanished.
A bug the test suite cannot catch
The whole suite runs on pglite, Postgres compiled to WASM. A Date interpolated into a raw sql fragment binds happily there and throws under postgres.js against real Neon:
TypeError: The "string" argument must be of type string…
Received an instance of DateIt surfaced only when the job was first pointed at the real database. The cutoff is now an ISO string with an explicit ::timestamptz cast, and the comment beside it says that no test covers it — the same driver divergence the nudge query hit with its asDate() normaliser. Worth remembering as a class of bug rather than an incident: anything passed into a raw sql template should be checked against Neon before it is trusted.
The daily email job it is deliberately unlike is described in Email nudges; the settings page that carries the time zone is in Web app & quiz.