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.

lib/pusher.ts
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.

OptionFree tierTrade-off
GitHub Actions2,000 min/month on private repos; an hourly curl costs ~120Delayed 3–10 min under load, occasionally skipped, and the schedule is disabled after 60 days of repo inactivity.
cron-job.orgFree, down to 1-minute intervalsMore reliable; one more account and one more place to look when something breaks.
Cloudflare Worker cronCron triggers included on the free planThe most reliable of the three; a separate deploy target for three lines of code.
Vercel Pro$20/monthUnlimited 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 nudgePush
Fires becauseyou have been awaywork arrived
Gates onidle days, backlog age, item countdid the count go up
Cadenceat most one a weekat most one an hour
Silent whenyou are studying regularlynothing 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:

told about 20 baseline 20 you clear all live 0 → baseline 0 (silent, but re-armed) 5 arrive live 5 → 5 > 0, push ✓ without the reset: told about 20 baseline 20 you clear all live 0 → baseline 20 (stuck) 5 arrive live 5 → 5 < 20, silent ✗ …and silent until 21

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

#StepWhere
1Apply migration 0004 — push_tokens, users.timezone, users.last_pushed_countnpm run db:migrate:prod
2CRON_SECRET as a repository secret, same value as Vercel'sGitHub → Settings → Secrets → Actions
3API_BASE_URL as a repository variable (optional)defaults to https://kanjibuddy.mblrc.me
4Set your time zone, so quiet hours know when your night is/settings → Notifications
5Register the device from the appPOST /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.

kanji-buddy-rn
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 Date

It 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.