Skip to content

Notifications — the derived action feed

Status: implemented (P4), Web PWA verified · native device pass outstanding. Route: /notifications · Code: apps/mobile/src/tiers/user/features/notifications/Derivation: packages/domain/src/notifications/derive.ts (25 unit tests) Related: ADR-0016 — Reminders domain model · Reminders

What it is

One ranked, bounded list of what needs the merchant's attention now — and nothing else. Four item kinds:

KindToneSourceWhere it routes
reminder_overdueurgentreminder occurrences, missed within the last 7 days/reminders
reminder_dueattentionreminder occurrences due within the next 3 days/reminders
setu_card_offlineattentionDashboardSummary.setuCardLive/profile
profile_incompleteinfoDashboardSummary.profileCompletion/profile

Grouped New · Earlier. The empty state ("you're all caught up") is the good state and says so, rather than manufacturing filler to look busy.

Screen controls (QRS-224, revised by QRS-227)

The screen shipped with a bare title. Its only action sat one level down, inside an opened reminder — the controls were below the screen that needed them. They now live in the shared @/uiScreenHeader, rendered outside the ScrollView so they are present on arrival and stay put however long the feed grows.

ControlWhat it doesWhy this and not something else
BackcanGoBack() ? back() : push('/dashboard')A pushed route with no exit but the OS gesture is a dead end on the web PWA. Same idiom as Settings and Profile (QRS-226)
SettingsRoutes to /settingsThe one action always relevant on this screen (whether alerts fire at all). Each row already navigates to where its own work is done

Two things are deliberately absent. A mark all read button would be a no-op — the screen already marks everything read on open, and a control that cannot change anything is worse than its absence. And there is no source filter: an All · Reminders · Setup segmented control shipped here for one iteration and was removed (QRS-227), because a control inside Notifications whose segments name other features advertises that those features live in here.

This feature does not contain Reminders

The feed derives from reminder occurrences — that is ADR-0016's model — and a notification about a reminder necessarily links to /reminders. That is what a notification is. What made it look like containment was that Reminders had no other entry point: a grep for /reminders outside the two features returned only its own route file. It now has a dedicated icon in the console header beside the bell (QRS-227). Anything that would re-imply containment — a segment named after another feature, a nested list, a "Reminders" tab in here — belongs outside this screen.

The chrome itself is one primitive shared with reminders, which is the actual fix for the class: the two screens were composing their headers independently, so an improvement to one could not reach the other. Design provenance (components/app-shell/AppShell.jsx) and the deliberate divergences are on the drift ledger.

The feed is computed, not stored

There is no notifications table and no server feed in R1. Every item is derived from state the app already holds.

This is a decision, not a shortcut. A stored feed needs a writer for every event source, and the first thing that writer would do is duplicate the derivation — so the stored version costs a table, a set of triggers or Edge Functions, a backfill, and a second definition of "overdue" that can disagree with the reminders screen. Deriving costs one pure function.

What we give up, stated plainly: a server-side digest (an email or a push saying "you have 3 overdue items") cannot be produced without materialising the feed, because the server cannot compute it. That is the same trade ADR-0016 already made for reminder occurrences, and the migration path is the same: additive.

Why there is no NotificationsService

The P4 plan asked for the feed to sit "behind a NotificationsService-shaped seam". It does not, deliberately — recorded here rather than left as an unexplained deviation.

Such a service would have exactly one implementation, returning []. That is a speculative abstraction (ruled out by CLAUDE.md) and a misleading one: it would imply a fetch where there is a computation, and the next person would look for a backend that does not exist.

The seam that matters exists and is honest about its shape: useNotificationFeed is the single place the feed is assembled. When server-side notifications (announcements, system messages) eventually land, they arrive as one more source merged into deriveNotifications — additive, no call-site churn, and the service appears at that point because it will then have something to do.

Boundedness, and why each bound exists

BoundValueWhy
Overdue lookback7 daysA daily reminder started a year ago has 365 misses. A feed that lists them is unusable.
Due horizon3 daysBeyond that it is a plan, not a notification.
Reminder slice20 itemsReserved, so a crowded reminder list cannot evict the nudges.
Feed limit30 itemsTotal ceiling.
Read receipts200Persisted state is the only thing here that can leak.

The reservation is the subtle one. A single global cap sorted by urgency would let 30 overdue reminders push out "your card isn't live" — the feed would look full and say nothing that unblocks the merchant. Nudges are therefore appended after the reminder slice, and a unit test asserts it.

Item ids embed their kind — and that is the point

Ids are content-derived (reminder_overdue:<reminderId>@<dueAt>), because read-state is stored against them and a positional id would make the feed permanently unread.

They also embed the kind, so the same occurrence has a different id as reminder_due and as reminder_overdue. An item read while it was merely due comes back unread once it is missed.

That is intended. Becoming overdue is new information about an obligation, and it is the single moment this screen exists to catch. The alternative — one id per occurrence — would let a glance at "due at 6pm" permanently suppress "you missed 6pm". The profile nudge embeds its percentage for the same reason: 60% → 80% resurfaces once, so one dismissal cannot silence it for good.

The header bell: one source of truth

DashboardSummary.hasNotifications was removed from the read model. It was a server-supplied boolean that the stub hardcoded to true, so the bell's unread dot was permanently lit regardless of whether anything was pending.

A derived list and a server flag are two sources of truth for one dot, and the flag is the one that cannot be right. Removed rather than left unused, following the profiles.reminder_count precedent (QRS-211): a cached signal with no maintainer is guaranteed to drift, and the drift is invisible because the value always looks plausible.

The bell now reads useUnreadNotificationCount, the same derivation the screen renders.

The cost of that, stated rather than glossed: deriving the dot requires the reminders list, so the Home screen now performs two reads instead of one (get_dashboard_summary + get_reminders). Accepted for R1 for three reasons — it is 2, not the ≥3 parallel read RPCs CLAUDE.md warns about; the payload is bounded by the 50-reminder entitlement quota; and it is not wasted work, because it warms the cache the Reminders tab reads next. The optimisation path when it matters is the one already planned for this screen: fold an unread count into the composite get_dashboard_summary RPC (ADR-0010). Note that this would be a count derived server-side from the same rules, not a resurrection of the stored boolean — the objection to hasNotifications was never "it came from the server", it was "nothing maintained it".

Read-state: local, capped, and not synced

  • Stored in stores/notificationsStore.ts as id → readAt, persisted via stores/storage.ts (which already solves the window is not defined prerender trap).
  • Capped at 200 with oldest-first eviction. An evicted id belongs to something long past; the worst case is one item re-appearing as unread.
  • markRead is a no-op when nothing is fresh. Load-bearing: the screen calls it from an effect on every feed change, and returning a new state object each time would re-notify every subscriber forever — a render loop that reads as a performance bug.
  • STATED LIMITATION: read-state does not sync across devices or surfaces. Read a notification on the phone and it is still unread on the web PWA. Accepted for R1; syncing needs a server-side notifications table with per-user read receipts.

Store compliance: no upgrade CTA, ever

The plan's P4 sketch said the feed composes "reminders + profile completion + plan/card status". Card status is in; plan status is deliberately out.

An "upgrade to unlock…" row inside the native app is an in-app-purchase CTA under Apple 3.1.3(d) — a store-review risk, not merely a product choice. The merchant app is a free companion and upgrades convert on web/email (ADR-0002). Separately, telling a free-tier merchant they are on the free tier is pure noise, which the anti-noise guardrail rules out on its own.

Both a unit test and a screen test assert no upsell reaches the feed, so a future change fails a gate rather than a store review.

Proactive-value gate

  • Proactive value? Yes — it converts state the merchant would have to go looking for into a ranked list of next actions.
  • Meaningful business action? Every row navigates to where the action completes.
  • Engagement/retention? The overdue-resurfacing rule catches the one transition that matters, without adding notification volume — this is in-app surfacing only, no push.
  • More intelligent? The bounded windows are the intelligence: an item appears precisely while acting on it still matters.
  • Differentiating? A conventional business app has a notifications list of things that already happened. This one lists things that still need doing.
  • Never fabricates: if a source cannot support a claim, the row is absent. Derivation waits for both queries before running, so a half-loaded state cannot briefly tell a merchant whose card is live that it is not.

Parity

Nothing in this feature touches a native module or a browser-only API, so it has no divergence seam (the local-alert seam belongs to reminders).

SurfaceStatus
Web PWA (RNW)⚠️ functionally verified — Playwright at 4 widths × en/hi × light/dark, plus a driven read of the real export in both themes/locales. Known defect: the heading keeps light-theme ink in dark mode (1.54:1), QRS-218 — pre-existing and systemic (also /reminders, /settings), fixed separately
Android native⏳ outstanding — needs expo prebuild first (P3 added a native module)
iOS native⏳ outstanding — Simulator + physical iPhone

A green web gate is not parity. jest mocks Reanimated's createAnimatedComponent to identity and npm run e2e is the web bundle only. This feature is pure composition over already-verified @/ui primitives and adds no press/gesture or theme plumbing, which is why the web pass carries real weight here — but it is not a substitute for looking at the native builds.

Tests

LayerCoverage
node --test (@qrsetu/domain)25 tests — ordering, bounds, nudge reservation, read-state survival, the due→overdue resurfacing rule, no-upsell
jest (screen)17 tests — mount, empty/all-clear, each item kind, New/Earlier snapshot, mark-read-on-open, no-upsell, plus the header: present on arrival, outside the scroll container, back control, settings routing, and a guard that no source filter returns
jest (@/ui)7 tests on ScreenHeader — title, both slots, the border-subtle rule, the space[6] gutter, title truncation, back absent by default, back invokes the caller's handler
jest (store)5 tests — eviction order, cap, no-op on re-mark, reset
Playwright/notifications layout invariants + theme consistency