Skip to content

Reminders

Status: 🟡 shipped behind the paused auth work · ADR: ADR-0016 · Tracker: QRS-209, QRS-210, QRS-211, QRS-215

What this feature is

The things a small business must not forget — GST filings, stock reorders, licence renewals, staff rosters — grouped Overdue · Today · Upcoming · Done. It is framework, not vertical: the design project independently identifies reminders as one of the surfaces that is "largely identical across all business domains".

The model in one paragraph

The database stores a recurrence rule. Occurrences are computed on read by a pure function in @qrsetu/domain, and an occurrence row is persisted only when its state diverges from the rule (completed or skipped). Absence of a row means "pending, exactly as scheduled" — which is why the exceptions table does not grow for an untouched recurring reminder. Full reasoning, alternatives and the accepted trade-off: ADR-0016.

Why the pre-existing schema was replaced

A reminders table already existed in the baseline squash. It was not adopted, and the reason generalises to any inherited table:

It carried is_recurring, recurrence_pattern and recurrence_end_date beside a singledue_date, a single is_completed and a single notification_sent. A series has N occurrences each with its own state, so completing one occurrence had to complete the entire series or nothing. That is a modelling error, not a missing constraint.

The live data agreed: all 11 production rows carried is_recurring = false withrecurrence_pattern = 'daily' — the column default leaking, because nothing constrained the two fields to agree.

Two further facts from that table shaped this build rather than merely justifying the rewrite:

FindingWhat changed because of it
6 of 11 rows are duplicates, three pairs created 0.7 s / 1.1 s / 7.7 s apart by double-taps (QRS-210)An idempotency key is required on every mutation, not optional
reminder_categories.color stored raw hex (DEFAULT '#3B82F6')Categories store a design-token key, never a colour value

Layers

LayerWhereNotes
Rule + exception tables20260727150100_reminders_model.sql12 RLS policies, every one TO authenticated
Readget_reminders RPC (20260727150200)Keyset pagination on (starts_at, id); explicit column projection, never row_to_json
Writemanage-reminder Edge Function (Type A)create/update/complete/skip/delete; idempotency + rate limit + quota
Replay ledgeridempotency_keys (20260727150500)One table serves both idempotency and the rate limit
Pure logicpackages/domain/src/reminders/Recurrence, DST, bucketing, notification budget — 67 unit tests
Contractspackages/schemas/src/reminders.tsZod, using the exact DB column names
Service seampackages/data/src/reminders/Interface + stub; the stub models idempotency and quota too
UIapps/mobile/src/tiers/user/features/reminders/26 component tests

Order of guards in the Edge Function

1. replay check   → a retry costs one indexed lookup and touches nothing else
2. rate limit     → cheap, rejects abuse before any domain work
3. quota          → create only; needs a count, so it follows the cheap guards
4. the mutation
5. remember       → store the response so step 1 can replay it verbatim

Putting the replay check first is what makes a retry safe rather than merely tolerated: a client whose connection dropped after the commit gets the original result back, so it cannot conclude "that failed" and retry with different data.

Correctness: the two properties that actually matter

Local time survives DST. "Every day at 09:00" is not a repeating instant — it is a repeating wall-clock time. startsAt + n × 86400000 drifts to 08:00 across a transition and stays drifted. So recurrence steps the local calendar date and re-resolves each step to an instant. Both DST edge cases are decided explicitly and unit-tested:

  • Ambiguous (fall-back, 01:30 happens twice) → the earlier instant.
  • Nonexistent (spring-forward, 02:30 never happens) → pushed forward past the gap. The alternative, skipping the occurrence, would mean a daily 09:00 reminder silently having no alert on one day of the year.

Monthly clamps and anchors: Jan 31 → Feb 28 → back to Mar 31, not the Feb 28 → Mar 28 → Apr 28 drift that iterative clamping produces.

iOS caps pending local notifications at 64 per app, silently. No error, no rejected promise. A "reconcile every reminder" scheduler therefore fails at scale in the least visible way possible. The design is a rolling horizon — the soonest N occurrences within a budget of 50, re-armed on launch and foreground — and because selection is a pure function, 80 reminders → exactly the soonest 50 is a unit test rather than a discovery on a device.

No date library is used. Intl already ships the tz database inside Hermes and every browser, so ~120 lines of arithmetic over Intl.DateTimeFormat replaces Luxon (~70 KB) at zero dependency weight.

Dates are formatted without locale data, deliberately

Hermes ships a trimmed ICU and whether hi/mr month names are present is unverified on device. A missing locale falls back silently, and differently per platform — invisible to any web-only gate. So labels are built from numeric parts (27/07 · 16:00, 24-hour), which needs no locale data and therefore cannot diverge. Switching to Intl for a nicer long-form date is a small contained change after the data is verified on a device.

Security

ControlWhere
RLS owner-scoped, TO authenticated on all 12 policiesthe three new tables
RPC hardening: SECURITY DEFINER + SET search_path + explicit auth.uid() check + revoked from PUBLIC and anonget_reminders
Idempotency key (UNIQUE (user_id, scope, key))manage-reminder
Per-user rate limit, 30/60 smanage-reminder
Entitlement-driven quota, degrading to a default rather than 500ingresolveQuota
Ownership re-checked in the EF because the service role bypasses RLSevery action
Denormalised user_id derived by a BEFORE trigger, never trusted from the clientreminder_occurrences
Reminder text never in EF logs, Sentry breadcrumbs, or the default lock-screen previewscheduler + logging

The RPC hardening was measured, not assumed

get_reminders shipped anon-executable despite carrying CLAUDE.md's prescribed REVOKE ALL … FROM PUBLIC. A function is reachable by anon through two independent channels and that idiom closes only one. See QRS-214 — it turned into a project-wide security fix, and check:sql now has a rule for the shape.

Cross-platform parity

Structural parity holds by construction: one RN codebase, and every date decision is a pure function shared by all three surfaces.

SurfaceStatus
Web PWA/reminders in both Playwright matrices — part of the 477-assertion run (4 widths × en/hi × light/dark)
Android native⏳ on-device pass outstanding
iOS native⏳ on-device pass outstanding

One divergence seam — local notifications — with a decided answer per surface:

SurfaceBehaviour
Androidexpo-notifications. OEMs clear alarms on reboot, so the first foreground re-arms everything
iOSexpo-notifications. 64 pending max, silently discarded past that → the horizon is budgeted to 50
Web PWAunsupported, deliberately — only an installed PWA on iOS 16.4+ can deliver while closed, and that is not reliably detectable. The port reports false and the UI says so, rather than scheduling alerts that never arrive

The scheduler is a diff, not cancel-all: cancel-all leaves a window in which the app can be suspended with no pending alerts, churns the OS scheduler on every foreground, and destroys identifiers for occurrences that did not change. Identity is (reminderId, dueAt) — including dueAt is what makes "edit the time → exactly one alert at the new time" true, and dropping it from the key is a mutation the tests catch.

Accessibility. The checkbox is the primary affordance — always visible, 44×44, accessibilityRole="checkbox" with accessibilityState.checked, asserted in tests. Secondary actions are labelled buttons in a sheet, never a hidden gesture, so everything is reachable by touch, by keyboard on web, and by TalkBack/VoiceOver.

Notifications actually firing (QRS-236, QRS-237)

Four independent defects each kept Android alerts silent, and all four were ours — the merged manifest ships POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, WAKE_LOCK and NotificationsService, so the native plumbing was never the problem:

  1. A permission grant re-armed nothing. The reconcile callback did not depend on permission, so the merchant tapped Allow and no pass ran. requestPermission now re-arms on success.
  2. The Android channel did not exist. app.json's defaultChannel writes only the FCM remote-push default, which has no effect on a local notification. A runtime setNotificationChannelAsync is required on Android 8+; it now runs via port.prepare() at HIGH importance, with the name from @qrsetu/i18n because the merchant reads it in system settings.
  3. No foreground handler, so an alert coming due with the app open produced nothing at all.
  4. The blocked state was silent — the copy existed and was rendered nowhere.

Those four were not the whole story. The alerts were re-reported as still never firing on 2026-07-29, against a build made five minutes after that fix, and a code read found four more independent causes.

The reconciler only existed while one screen was open

useReminderNotifications was mounted in exactly one place, RemindersScreen. Since a cold launch resolves to /dashboard, the app could start, run and be used all day having armed nothing at all. Leaving the screen also unmounted the AppState listener, so the rolling-horizon re-arm (alerts fire and fall out of a 50-item window) and the post-reboot re-arm that several Android OEMs make mandatory both stopped happening too.

It is now a session-scoped ReminderAlertsProvider in the root layout, and the screen is a pure consumer. Two details are load-bearing:

  • Not a (user)/_layout.tsx, which was the first design and reads better: it nests a navigator, and QRS-203/206/207 were all structural changes that were right on the surface they were tested on and wrong on another. A headless provider changes no routing at all.
  • The session gate is a conditional child, not a boolean prop. enabled: false means the merchant's preference is off, which correctly cancels everything armed; since email is briefly null on every cold start before rehydration, a boolean would have wiped the horizon on every launch.

Alarms were inexact on Android 12+, and the fix was a store-compliance decision

expo-notifications' ExpoSchedulingDelegate.kt:106 branches on SDK_INT < S || alarmManager.canScheduleExactAlarms() and falls back to setAndAllowWhileIdle, and the library declares no exact-alarm permission. Confirmed from the merged manifest of a real release APK, so on every API 31+ device every reminder was deferrable.

We now declare SCHEDULE_EXACT_ALARM. We deliberately do not declare USE_EXACT_ALARM, which is auto-granted and needs no settings trip but is restricted by Google Play to apps whose core function genuinely requires precise timing, with apps that declare it otherwise not permitted on the store. Both are asserted, in both directions, in notifications-build-config.test.ts.

AndroidBehaviour
≤ 11Exact, no permission involved
12 · 13SCHEDULE_EXACT_ALARM pre-granted on install, no merchant step
14+Not pre-granted for apps targeting SDK 33+ (we target 36) → one settings trip, offered by an "Alert timing" banner

There is no JS route to canScheduleExactAlarms() — the library exposes none, and PermissionsAndroid.check() is actively misleading for a special app-op permission, reporting GRANTED whenever it is merely declared. So the UI offers an action, never a status claim.

Tapping an alert went nowhere

No response listener existed anywhere in the app. ReminderTapRouter now routes to /reminders?focus=<id>&due=<ms>, handling both the warm listener and the cold-start response, since the app being closed is the entire premise of scheduling an alert (QRS-243).

Two things a device can hide from you

A created reminder does not survive a process kill, because the reminders service is still the in-memory stub. Not a product bug, but it does mean the "create 80 reminders, background, confirm the soonest still fire" check cannot be run honestly until the Supabase client is wired.

OEM battery optimisation suspends background alarms on Xiaomi/Oppo/Vivo/Samsung independently of everything above, which is why "it works on a Pixel" proves little. It is a device-setup step in Android + iOS build & test, not an in-app prompt: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is itself Play-policy restricted.

One reported cause turned out not to exist

I also reported that permission was never requested at the moment of creating a reminder. That was wrong — the screen has prompted contextually after the first successful save since P3, with its own sheet before the OS dialog so a "not now" costs nothing. It is recorded in QRS-237 rather than deleted, because a defect list that quietly loses an entry is not auditable.

Deliberately not built

Recorded in QRS-215 so these are auditable rather than folklore: un-completing a done occurrence (means deleting the exception row — a distinct EF action, not a toggle); swipe-to-complete, which is blocked on that undo action rather than on the gesture; the category picker; and the pagination UI (the RPC and stub both paginate and return next_cursor; the feed requests one 100-row page).

Correction (2026-07-28)

This page previously said swipe was deferred because ReanimatedSwipeable is "absent from gesture-handler 2.32". That was wrong — it ships as the react-native-gesture-handler/ReanimatedSwipeable subpath; only the root barrel omits it. The reason that survives is that manage-reminder has no action that deletes an exception row, so completion is irreversible, and a swipe is the lowest-friction commit in the app. The jest Gesture stub does leave a pan with no unit coverage, but Sheet's pull-down dismiss already shipped on that basis, so it argues for a device/Maestro test, not for deferral. Full correction in QRS-215.

One unsolved problem, stated plainly: idempotency_keys has no retention mechanism and grows monotonically. Zero rows today; the natural fix is pg_cron, which is not yet replicated to Dev.

Verification

Automated — all green: 79 domain unit tests (both DST directions, monthly clamping, the 64-cap regression, the reconcile diff) · 29 Deno tests on the EF · 37 component tests (26 feed + 11 scheduler) · 477 Playwright assertions including /reminders · type-check, lint, format:check, check:sql, check:parity, check:readmes, check:design, test:hooks.

Two behaviours were mutation-tested rather than merely asserted — removing the iOS budget cap fails 3 tests, and dropping dueAt from the notification identity key fails the edit-the-time test. A test that cannot fail reports safety it does not provide.

Outstanding: pgTAP for RLS isolation / RPC projection / grants (blocked — the local Docker engine died mid-session, so the DB layer was validated against Dev instead, which is the correct first promotion target regardless) · the on-device Android and iOS passes, including the alert-fires-in-background matrix and a re-measure of APK/IPA size after expo-notifications (~1.0–1.5 MB/ABI against a ~51 MB baseline).

Promotion: all six migrations are applied and verified on Dev. Prod is pending the owner's decision on the archived legacy rows — see QRS-209.