Skip to content

PS8 — Notification & Signal Surfacing

Status: Archived Date: 2026-07-11 Draws from: U1-home-notifications.md, U7-auth-profile-dm.md (finding #2, signal_preferences has zero UI — cited for the model-sufficiency note in §5, not designed here). Already shipped, cited only: 00385_alert_model_wiring.sql (9 new signal types, severity audit, DM rename, 3-band notification-center model) — U1's own scope note confirms this was "verified as integration, not redesigned." This doc does not touch the type ledger, severities, band model, or emitters that pass verification below. It closes the surfacing gaps left behind: the push notification cannot navigate anywhere for ~12 of 13 categories, three surfaces disagree about what "N items need admin attention" means, the resolver's screen-coverage table is incomplete, and one pre-existing (pre-00385) emitter writes its deep-link id to the wrong JSONB column. Boundary with PS7: PS7 owns the 알림 설정 settings screen (editing signal_preferences rows). This doc verifies the existing signal_preferences/signal_consent_log model already has everything PS8's fixes need — no schema change is proposed here (§5).


1. Problem statement

Live-traced against HEAD 00387 (2026-07-11 alert-model-wiring baseline). Six independently-confirmed defects, plus one new finding this pass surfaced that neither source audit flagged (§1.7) — a real duplicate-notification bug caused by a second, older delivery path the audit didn't trace into.

  1. CRITICAL — push-notification tap navigation is a dead end for ~12 of 13 signal categories (U1 #1). supabase/functions/send-push/index.ts:369-380 builds the Expo payload as data: { ..., context: row.context ?? {}, ...extraData } — every id lives nested under data.context.*. packages/app/src/presentation/hooks/use-notification-handler.ts:113-119,168-174 destructures data.clubId/data.sessionId/data.postId at the top level and switches on data.screen — a field the edge fn only ever sets for category === 'chat' (screen: 'dm', line 362). Every other category's switch (screen) hits default and does nothing. Tapping a push for a session reminder, a payment hold, a disputed score, an overdue-dues alert — nothing happens.
  2. HIGH — admin-attention count means three different things on three surfaces (U1 #2). Bell badge (home-feed-screen.tsx:89) and the notification center's 관리 chip (notification-center-screen.tsx:382) both add adminExceptionItems.length — a club count (admin of 3 clubs with any outstanding work → +3). The Home strip headline (home-admin-attention-strip.tsx:38-40) sums item.model.total across those same clubs — an item count (5+3+7 = 15). A user tapping "15건 확인 필요" lands on a bell/chip reading "3."
  3. MEDIUM (latent, not currently live-firing) — signalMatchesScreen has no branch for 7 of 13 categories, and 2 of its own declared screen ids are unimplemented (U1 #3, refined below). payment, chat, progression, rating, trust, interclub, system have zero entries in the club_detail/club_dues/session_detail/match_board branch set; ScreenContext.screen also declares 'profile' and 'activity' as valid screen ids but signalMatchesScreen has no case for either — both silently fall through to the return false default. See §2.3 for why this is currently dormant rather than live-broken, and why it still needs fixing.
  4. MEDIUM — the DM/chat emitter writes its deep-link id to the wrong JSONB column (new finding, §2.4). SignalContext.threadId exists in the domain type and getSignalRoute's chat branch reads context.threadId — but private.notify_dm_recipient (00226_dm_message_push.sql:109-132, a pre-00385 trigger, unaffected by the alert-model-wiring pass) inserts threadId into payload only; the signals row's context column is never set (defaults to {}). Both in-app tap (getSignalRoute) and — once §4.1 ships — push tap fall back to routes.dmInbox instead of the specific thread for every chat notification.
  5. HIGH (new finding, §2.5) — session/RSVP reminders fire from two uncoordinated systems, doubling notifications for the same fact. use-schedule-notifications.ts (client-side, expo-notifications local scheduling, live-wired into use-rsvp.ts's RSVP-confirm mutation) schedules a 24h-before and 2h-before session reminder and a 48h-before-deadline RSVP reminder — entirely independent of, and with zero preference/quiet-hours gating compared to, the server-side session_reminder_day_before (18:00 day-before), session_starting_soon (T-2h) and rsvp_deadline_soon (T-24h) signal-cron emitters 00385 just shipped. A member who RSVPs today gets both a local device notification and a server push for the same reminder — the exact anti-pattern signal-system.md §12 #3 warns against ("don't hard-code a banner/notification into a screen … if it doesn't fit an existing signal type, add one" — here the type already exists and fires independently of the legacy path).
  6. LOW — session_payment_confirmed renders with the same red "failure" icon as an actual payment failure (U1 #4). SIGNAL_CATEGORY_ACCENTS.payment (visual-accents.ts:211) is { Icon: CreditCard, color: '$error' }, applied uniformly by category in SignalRow (notification-center-screen.tsx:165,210) — a ₩ confirmed-and-done event reads as visually alarming.
  7. Two related LOW items are noted but explicitly not slice-worthy on their own (folded into the slices above where they share a file): U1 #5 (useMyAdminClubAttention mounted twice, independently, by Home + the notification center — React Query dedupes the network calls but not the recomputation) is a documented, accepted cost per the hook's own header comment, not re-litigated here. The getWindowEndDate() UTC-unsafe date-math canon violation (U1, "Canon violations") is unrelated to signal surfacing and is left for whichever pass owns use-home-recommendations.ts.

2. Research

Expo's notification data payload is an arbitrary JSON object the app receives verbatim in both the foreground listener and the tap-response listener — Expo prescribes no shape, only that it round-trips through the OS notification tray unmodified. The design freedom is entirely ours. Two shapes compete in this codebase today:

  • Signal-backed push (send-push/index.ts): { signalId, type, category, severity, context: {...} } context mirrors SignalContext exactly, because it's forwarded verbatim from the signals row (context: row.context ?? {}).
  • Client-scheduled local reminder (use-schedule-notifications.ts, §2.5): { screen: 'session', clubId, sessionId } — flat, hand-built at the call site, invented before the signal system existed.

use-notification-handler.ts's existing switch was written for the second shape and never updated when the first (signal-backed, now the dominant path) arrived — this is the actual root cause the audit didn't have visibility into. Which side moves: the signal-backed side (send-push) is correct today — the DB's context column IS SignalContext, and mutating it to flatten would duplicate data the client already has the exact right domain type + a tested route function for (getSignalRoute, packages/app/src/presentation/utils/__tests__/signal-routes.test.ts, 13/13 categories covered). The local-reminder side moves: reshape its payload from {screen, clubId, sessionId} to { category: 'session', context: { clubId, sessionId } } — the same shape as a real signal, with no type (not needed; getSignalRoute's session branch never reads type). This converges the whole app on one payload shape and one route-derivation function (getSignalRoute), which is the explicit ask in U1's own improvement candidate #1 ("route through getSignalRoute … instead of the bespoke screen-name switch"). use-notification-handler.ts then has a single resolveNavigationTarget(data) that works for both origins, and the dead screen-keyed switch (session/session-detail/match/dues/club/board-post/dm/profile/activity/ default) is deleted outright rather than patched.

2.2 Admin-count — the strip's own framing is the correct one, confirmed by the model it aggregates

ClubAdminRowModel.total (club-admin-attention.ts:31-41) already sums exactly five actionable per-club counters (pendingJoinRequestsCount, submittedTransfersCount, overdueDuesCount, openReportsCount, pendingGuestApplicationsCount) with a comment explicitly excluding non-actionable counters (unpaidHoldsCount, attendanceFlaggedCount, upcomingSessionsCount). This is the same granularity every other count in the notification system uses — unreadCount, the category filter chips, the bell badge's own unreadSignalCount term — all count items, not sources. Club count is the odd one out, and it's an accident of .length being the easiest thing to write against an array of {club, model} pairs, not a deliberate design choice (the strip headline, built one release later per its own R5 comment, already corrected this independently and was never back-ported to the bell/chip). Adopted: item-count (Σ item.model.total) everywhere, matching U1's own improvement-candidate seed. To prevent the 3-way drift from recurring the moment a 4th consumer is added, the sum itself becomes a named, tested, single-source-of-truth function co-located with ClubAdminRowModel — the file that already carries the header comment "Single source of truth: do not re-implement this in a feature package."

2.3 signalMatchesScreen coverage — real but currently dormant, still worth completing

Grepped every call site of resolveSignals (the only function that invokes signalMatchesScreen) across packages/ and apps/: exactly onenotification-center-screen.tsx:372-376, with currentScreen: { screen: 'notifications', scope: {} }. 'notifications' is one of the two hard-coded catch-alls (signal-resolver.ts:227-230, returns true unconditionally) — so the category-branch coverage gap has zero effect on any screen that exists today. Home (home-feed-screen.tsx) does not call resolveSignals at all; it reads useUnreadSignalCount/useMyAdminClubAttention directly, matching signal-system.md §9.1's own note that the home-state-machine embeddedHero integration point was superseded by independent feed cards when Home was rebuilt (2026-04-17). This is exactly the kind of thing the project's own research methodology exists to catch — the audit's framing ("home is the one screen it would reach") implicitly assumed resolveSignals is wired into Home; tracing the actual call graph shows it isn't, for any category, not just the seven uncovered ones. Still worth fixing, for two reasons: (a) resolver is domain-layer, pure, unit-tested infrastructure that other screens will legitimately want to adopt later (a session_detail embedded hero for a payment hold, a club_dues banner, a profile banner for a tier_promoted — these are real, plausible future asks that shouldn't hit a silent gap when someone wires them up); (b) ScreenContext.screen already promises 'profile'/'activity' as valid values with a completely unimplemented resolver branch — that's a live footgun for the next person who wires a screen to resolveSignals and gets false for everything, silently. Wiring resolveSignals into new screens (session_detail, club_dues, profile) as a live embedded-hero surface is explicitly OUT of this doc's scope — it's a UI/UX layout decision (new banner placement, new visual chrome) that needs wireframe-first design per this repo's own UI/UX rule, not a "surfacing gap" fix. This doc closes the mapping so the infrastructure is correct and tested; using it on a new screen is a separate, later, product decision.

2.4 DM/chat context gap — SignalContext.threadId exists; one 9-migration-old emitter never wrote it

packages/app/src/domain/entities/signal.entity.ts:168-180 declares SignalContext.threadId with the comment "DM thread id — chat_message_received routes here" — added specifically for getSignalRoute's chat branch (signal-routes.ts:59-61: return threadId ? routes.dmThread(threadId) : routes.dmInbox;). But private.notify_dm_recipient (migration 00226, predates the domain-type addition and the 00385 alert-model pass entirely) only ever inserts payload: jsonb_build_object( 'senderId', NEW.sender_id, 'threadId', NEW.thread_id) — no context key in the INSERT column list at all, so signals.context defaults to '{}'::jsonb for every DM signal ever emitted. signalMapper.toDomain (adapters/supabase/mappers/signal.mapper.ts:33-49) reads row.context verbatim into Signal.context with no fold-in from payload — confirmed by its own test fixture (signal.mapper.test.ts, "maps context object when present" only exercises the context column). Net effect: getSignalRoute({category:'chat', context: {}}) always returns routes.dmInbox, never the specific thread — for every chat notification tapped in-app today, not just future push taps. payload.threadId still has a legitimate, separate job (send-push's resolveBody fetches the latest message text keyed on it, gated by dm_push_preview_enabled) — it stays. The fix is additive: also write threadId into context, matching every other 00385-era emitter's pattern of putting deep-link ids in context and template-interpolation data in payload.

2.5 Duplicate reminder delivery — a legacy client-local path the alert-model pass didn't know to retire

Traced data: { screen: 'session', ... } producers beyond send-push (per §2.1's shape audit) and found use-schedule-notifications.ts — confirmed live (not dead code) via grep -rn "useScheduleSessionReminder": wired into use-rsvp.ts:92,262 (schedule on RSVP-confirm) and :309,398 (cancel on RSVP-cancel). It calls notifications.scheduleLocal (Expo's on-device local scheduling, no server round-trip) for: 24h-before session start, 2h-before session start, and 48h-before-RSVP-deadline — three notifications per RSVP, entirely client-timed, gated only by the blunt prefs.notificationsEnabled/prefs.notifySessionReminders booleans (no signal_preferences category/severity/quiet-hours gate at all, since these never touch the signals table). 00385 (same-day migration, different workstream) independently rewrote generate_session_reminders to emit session_reminder_day_before (18:00 the day before, server cron) and added session_starting_soon (15-min cadence, [2h, 2h15m) window) and rsvp_deadline_soon (daily, deadline within 24h) — covering the same three facts through the canonical, preference-respecting, quiet-hours-respecting, dedup-keyed pipeline. Neither workstream's author had visibility into the other's file. Today a member who RSVPs gets both: e.g. a local "2시간 전이에요" device notification AND (once §4.1 ships and tap-through works) a server "곧 경기가 시작돼요" push, for the identical session. This is a direct, live instance of the alert-fatigue anti-pattern the signal system's own design doc names as the #1 reason Korean users disable notifications (signal-system.md §7.5 preamble). Correct fix: retire the client-local scheduling — the signal-cron equivalents are strictly better (respect prefs, quiet hours, dedup, and match the rest of the system's timing conventions) and already ship. RSVP-deadline timing shifts from 48h-before to 24h-before-deadline as a result — an intentional, already-made decision from the same-day alert-model-contract.md severity table, not a regression introduced here.

2.6 Positive-payment accent — refining the audit's own suggested fix with severity/band reasoning

U1's improvement candidate proposes overriding both session_payment_confirmed AND session_payment_submitted to a $success accent. Checking alert-model-contract.md's type ledger: session_payment_submitted is high severity, recipient = the session host, and represents "a member sent money — go confirm receipt" — a todo-band, action-required state for its recipient, not a completed-and-done one (the row already gets the shared $badgeWarningBg todo-band tint regardless of icon color, per SignalRow's existing isTodo branch). Overriding it to a green checkmark would visually say "done" about a state that explicitly isn't. Only session_payment_confirmed (medium severity, "참가 확정," genuinely terminal-positive for its recipient) gets the override — narrower than the audit's own seed, grounded in the severity/band semantics alert-model-contract.md already defines rather than treating "positive-sounding type name" as the criterion.


3. Solution design

3.1 One route-derivation, two producers converge on it

packages/app/src/presentation/hooks/use-notification-handler.ts — replace handleNotificationNavigation's screen-keyed switch with:

ts
import { getSignalRoute } from '@/presentation/utils/signal-routes';
import type { SignalCategory, SignalContext, SignalType } from '@/domain';

function resolveNavigationTarget(data: Record<string, unknown> | undefined): string | null {
  if (!data || typeof data.category !== 'string') return null;
  return getSignalRoute({
    category: data.category as SignalCategory,
    type: (data.type as SignalType | undefined) ?? ('' as SignalType),
    context: (data.context as SignalContext | undefined) ?? {},
  });
}

function handleNotificationNavigation(data: Record<string, unknown> | undefined): void {
  const destination = resolveNavigationTarget(data);
  if (!destination) {
    logger.info('No route for notification payload:', data);
    return;
  }
  try {
    appRouter.push(destination as never);
  } catch (error) {
    captureCriticalError(/* unchanged */);
  }
}

refreshDomainData (query-cache freshness) mirrors the same category-keyed shape instead of the dead screen-keyed one — it does NOT need getSignalRoute's route string, just the same normalized {category, context} to decide which keys to bust:

ts
function refreshDomainData(
  queryClient: QueryClient,
  data: Record<string, unknown> | undefined
): void {
  if (!data || typeof data.category !== 'string') return;
  const category = data.category as SignalCategory;
  const context = (data.context as SignalContext | undefined) ?? {};
  const { clubId, sessionId, postId, threadId } = context;
  const invalidate = (queryKey: readonly unknown[]): void =>
    void queryClient.invalidateQueries({ queryKey });

  switch (category) {
    case 'session':
    case 'rsvp':
    case 'matchup':
      if (sessionId) {
        invalidate(sessionKeys.detail(sessionId));
        invalidate(matchKeys.bySession(sessionId));
        invalidate(rsvpKeys.bySession(sessionId));
      }
      break;
    case 'dues':
      invalidate(duesKeys.all);
      break;
    case 'club':
    case 'interclub':
      if (clubId) {
        invalidate(clubKeys.detail(clubId));
        invalidate(clubKeys.members(clubId));
      }
      break;
    case 'social':
      if (postId) {
        invalidate(postKeys.detail(postId));
        invalidate(postKeys.comments(postId));
      }
      if (clubId) invalidate(postKeys.byClub(clubId));
      break;
    case 'payment':
      if (sessionId) invalidate(sessionPaymentKeys.bySession(sessionId));
      break;
    case 'chat':
      invalidate(dmKeys.threads());
      invalidate(dmKeys.unreadCount());
      if (threadId) invalidate(dmKeys.messages(threadId));
      break;
    case 'progression':
    case 'rating':
    case 'trust':
      invalidate(profileKeys.all);
      break;
    case 'system':
      break; // no per-entity cache to bust
  }
}

This mirrors getSignalRoute's own category grouping (session+rsvp+matchup together, club+interclub together) — the two functions read as companions, not independent switches that can drift again.

Producer #2 converges on the same shape. use-schedule-notifications.ts's three notifications.scheduleLocal({ data: {...} }) call sites change from { screen: 'session', clubId: session.clubId, sessionId: session.id } to { category: 'session', context: { clubId: session.clubId, sessionId: session.id } } — no other change to the scheduling logic. (§4.2 below explains why this producer is retired outright rather than kept alive alongside the fix — the reshape is listed here only in case §4.2 is sequenced later than §4.1 in a given rollout.)

send-push/index.ts cleanup. The category === 'chat' && payload.threadId ? {screen:'dm', threadId} : {} special-case (index.ts:358-363) becomes dead once the client reads context directly and §4.3 fixes the DM emitter to populate context.threadId — delete extraData entirely, the payload becomes uniformly { signalId, type, category, severity, context } for every category, no special cases. Requires an edge-fn redeploy (supabase functions deploy send-push --no-verify-jwt).

Rollout note (not a blocker, documented for awareness): local reminders already scheduled on-device before this ships carry the OLD flat shape and will fire with it for up to ~48h post-OTA (their own triggerAt horizon) before draining. resolveNavigationTarget returns null for a payload with no category field (the old flat shape has none) — same "tap does nothing" outcome these already have today for every non-chat category, so this is a no-regression transient window, not a new failure mode.

3.2 Single-source admin-attention total

packages/app/src/presentation/utils/club-admin-attention.ts — add, alongside buildClubAdminRowModel:

ts
/** Canonical "how many admin-attention items across all clubs" total — the
 *  single sum every surface (bell badge, Home strip, notification-center
 *  관리 chip) must use. Item-count, not club-count — see PS8 §2.2. */
export function sumAdminAttentionTotal(items: ReadonlyArray<{ model: ClubAdminRowModel }>): number {
  return items.reduce((sum, item) => sum + item.model.total, 0);
}

Three call sites converge on it:

  • home-feed-screen.tsx:89const bellCount = useUnreadSignalCount(userId ?? null) + sumAdminAttentionTotal(adminExceptionItems);
  • home-admin-attention-strip.tsx:37-40 — replace the inline let total = 0; for (...) total += item.model.total; loop with const total = sumAdminAttentionTotal(items); (this surface already had the right number; it now shares the derivation instead of independently reinventing it).
  • notification-center-screen.tsx:382buildFilters(projections.feedItems, unreadSignalCount, sumAdminAttentionTotal(adminAttentionItems)).

3.3 signalMatchesScreen — full category→screen map, table-driven

packages/app/src/domain/rules/signal-resolver.ts:

ts
/** Screens (beyond the 'home'/'notifications' catch-alls) where a category
 *  is a banner/hero candidate. A category absent here is intentionally
 *  feed/bell-only — see the inline note for 'chat'. */
const CATEGORY_SCREENS: Partial<Record<SignalCategory, ScreenContext['screen'][]>> = {
  session: ['club_detail', 'session_detail'],
  matchup: ['session_detail', 'match_board'],
  rsvp: ['session_detail'],
  dues: ['club_detail', 'club_dues'],
  club: ['club_detail'],
  social: ['club_detail'],
  interclub: ['club_detail'],
  payment: ['session_detail'],
  progression: ['profile'],
  rating: ['profile'],
  trust: ['profile'],
  system: ['profile'],
  // chat: deliberately absent. severity is down-regulated by design
  // (signal-system.md §6: "chat has its own UI") — feed/bell is sufficient,
  // it never needs a screen-level banner/hero.
};

export function signalMatchesScreen(signal: Signal, screen: ScreenContext): boolean {
  const { scope, screen: screenId } = screen;
  const { context, category } = signal;

  if (screenId === 'home' || screenId === 'notifications') return true;
  if (!CATEGORY_SCREENS[category]?.includes(screenId)) return false;

  switch (screenId) {
    case 'club_detail':
    case 'club_dues':
      return !!scope.clubId && context.clubId === scope.clubId;
    case 'session_detail':
    case 'match_board':
      return !!scope.sessionId && context.sessionId === scope.sessionId;
    case 'profile':
      return true; // no ID scope on profile — category gate above is sufficient
    default:
      return false;
  }
}

Identical behavior for the 4 previously-implemented screens (club_detail/club_dues/ session_detail/match_board — same category sets, same scope-id equality checks, just table-driven instead of four hand-written if blocks); adds paymentsession_detail, interclubclub_detail, progression/rating/trust/systemprofile. 'activity' stays declared-but-unmatched (no category owns a generic activity-log screen today — noted, not fixed, see §5).

Prevention (CLAUDE.md "every fix includes prevention"): a completeness test asserts every member of ALL_CATEGORIES is either a CATEGORY_SCREENS key or 'chat' (the one documented exception) — so a 14th category added later without a screen decision fails CI instead of silently returning false forever, closing the exact class of gap this slice fixes.

3.4 DM emitter — write threadId into context, not just payload

private.notify_dm_recipient (migration 00226) — additive INSERT column:

sql
INSERT INTO public.signals (
  type, category, severity, recipient_user_id, dedup_key, title_key,
  context, payload, expires_at
)
VALUES (
  'chat_message_received',  -- 00385 already renamed the literal; this migration only adds `context`
  'chat'::public.signal_category, 'high'::public.signal_severity, v_recipient_id,
  'dm:' || NEW.thread_id::text, 'signal.chat.messageReceived.title',
  jsonb_build_object('threadId', NEW.thread_id),
  jsonb_build_object('senderId', NEW.sender_id, 'threadId', NEW.thread_id),
  pg_catalog.now() + INTERVAL '7 days'
)
ON CONFLICT (dedup_key) DO UPDATE SET
  context = EXCLUDED.context, payload = EXCLUDED.payload,
  created_at = pg_catalog.now(), acknowledged_at = NULL, resolved_at = NULL;

(The ON CONFLICT DO UPDATE replaces the current DO NOTHING — matching the grouping contract's standard upsert shape from signal-system.md §7.5, which this one legacy trigger never adopted; the existing "one active signal per thread" dedup semantics are unchanged, this only makes a re-fire refresh context/created_at the same way every other grouped emitter already does.) threadId stays in payload too — send-push's resolveBody DM-preview lookup keys off it there; no reason to move that usage, only to add the missing one.

3.5 Positive-payment accent override

packages/app/src/config/visual-accents.ts — add beside getSignalCategoryAccent:

ts
const SIGNAL_TYPE_ACCENT_OVERRIDES: Partial<Record<SignalType, VisualAccent>> = {
  session_payment_confirmed: { Icon: CheckCircle2, color: '$success' },
};

export function getSignalVisualAccent(signal: Pick<Signal, 'category' | 'type'>): VisualAccent {
  return SIGNAL_TYPE_ACCENT_OVERRIDES[signal.type] ?? SIGNAL_CATEGORY_ACCENTS[signal.category];
}

notification-center-screen.tsx:165const accent = getSignalCategoryAccent(signal.category); becomes const accent = getSignalVisualAccent(signal);. CheckCircle2/$success already used elsewhere in this same accent table (rsvp), so no new token or icon import.

3.6 Retire the client-local reminder scheduler

use-schedule-notifications.ts — delete the three notifications.scheduleLocal call sites and cancelReminder's corresponding cancellation logic; use-rsvp.ts:92,262,309,398 drops its useScheduleSessionReminder import and both call sites. The server-side session_reminder_day_before / session_starting_soon / rsvp_deadline_soon crons (already shipped, 00385) are the sole reminder path going forward. UserPreferences.notifySessionReminders — check whether this boolean has any other consumer before deleting the field itself; if it's now orphaned, leave it as a harmless unused column rather than a migration (out of this slice's blast radius — flagged, not required).


4. Slices

SliceTypeFilesBlast radiusDepends on
4.1 Push-tap routing unification (§3.1)client-OTA + edge-fn redeployuse-notification-handler.ts, use-schedule-notifications.ts (payload reshape only), send-push/index.ts (delete extraData)Every push-tap and every local-reminder tap; no server schema changenone (works standalone; chat routing is fully correct only after 4.4)
4.2 Retire client-local reminder scheduler (§3.6)client-OTAuse-schedule-notifications.ts, use-rsvp.tsRSVP-confirm/cancel mutation side-effects onlyindependent of 4.1, but ship together — 4.1's payload-reshape work in this file becomes moot once 4.2 deletes the call sites, so do 4.2 first and skip the reshape half of 4.1
4.3 Admin-count single source (§3.2)client-OTAclub-admin-attention.ts, home-feed-screen.tsx, home-admin-attention-strip.tsx, notification-center-screen.tsx3 read surfaces, no data-shape changenone
4.4 DM emitter context fixmigration 00405private.notify_dm_recipient (00226)chat_message_received signals onlynone (independent; 4.1 works for the other 12 categories without it)
4.5 signalMatchesScreen full coverage (§3.3)client-OTA (domain layer)signal-resolver.tsCurrently zero live callers beyond notifications catch-all (§2.3) — no behavior change on any shipped screen today; pure completeness + preventionnone
4.6 Positive-payment accent (§3.5)client-OTAvisual-accents.ts, notification-center-screen.tsxSignalRow icon color for one typenone

Verification per slice

  • 4.1: new use-notification-handler.test.ts (currently absent, U1's own gap) — mock Notifications.addNotificationResponseReceivedListener/getLastNotificationResponseAsync, assert appRouter.push is called with the correct route for one signal per category (13 cases, reusing signal-routes.test.ts's fixtures) plus the reshaped local-reminder payload plus a malformed/category-less payload (asserts no-op, no throw). Manual: on a dev build, RSVP to a session, background the app, trigger a dues_overdue test signal via run-scenario.mjs, tap the push — lands on /clubs/:id/dues.
  • 4.2: assert use-rsvp.ts's RSVP-confirm mutation no longer calls notifications.scheduleLocal (existing use-rsvp.test.ts mutation tests, minus the now-removed scheduling assertions if any existed). Manual: RSVP to a session, confirm no local device notification appears at T-24h in addition to the server push.
  • 4.3: unit test for sumAdminAttentionTotal (trivial reduce, 2-3 cases including empty). Seed a user admining 2 clubs with different non-zero totals via run-scenario.mjs; assert bell badge, Home strip headline, and notification-center 관리 chip all show the identical number.
  • 4.4: signal.mapper.test.ts already covers context mapping generically — add one fixture confirming a chat_message_received row with context: {threadId: '...'} round-trips; local psql probe (seed a DM message, SELECT context FROM signals WHERE category='chat', assert context->>'threadId' is non-null) confirms the trigger fix. signal-routes.test.ts's existing chat test already asserts routes.dmThread(threadId) given a populated context — no new client test needed, it starts passing against real data.
  • 4.5: extend signal-resolver.test.ts with one signalMatchesScreen case per newly-mapped category (payment→session_detail, interclub→club_detail, progression/rating/trust/system→profile) plus the completeness assertion (ALL_CATEGORIESObject.keys(CATEGORY_SCREENS) ∪ {'chat'}).
  • 4.6: extend visual-accents.test.tsgetSignalVisualAccent({category:'payment', type:'session_payment_confirmed'})$success/CheckCircle2; every other payment type → unchanged category default.

All six slices are yarn check-verified (typecheck + the test additions above); none require an EAS build — no native module, no app.json change (Android notification channels are unaffected, this is payload-shape and routing logic only).


5. Boundary notes

PS8 / PS7 (signal_preferences settings UI, U7 #2). Every fix in this doc operates entirely downstream of the existing preference gates (shouldPush, passesMinSeverity, isInAppEnabled in signal-resolver.ts — unchanged) — routing, counting, and screen-coverage are all post-preference concerns. Confirmed no schema addition to signal_preferences/signal_consent_log is needed for anything designed here: the 13-category/in-app/push/min-severity/quiet-hours/critical-bypass model already covers every type this doc touches (including the two 00385-new categories, payment and the renamed chat). PS7's settings-screen work is purely a UI surface over data that already exists and already gates correctly.

Deferred, no slice assigned:

  • Wiring resolveSignals's embeddedHero/screenBanner into session_detail, club_dues, or profile as a live banner surface (§2.3) — infrastructure is now correct and tested, but adding a new visible banner to any of those screens is a UI/UX layout decision requiring wireframe-first design per this repo's own rule, not a surfacing-gap fix.
  • 'activity' screen id — declared in ScreenContext.screen, zero real consumers, zero category mapping. Low-risk dead-code cleanup candidate (remove from the union, or map a category to it once a real activity-log screen exists) — not blocking, not included as a slice.
  • U1 #5 (useMyAdminClubAttention double-mount) — accepted cost per the hook's own documented reasoning; not re-litigated.
  • UserPreferences.notifySessionReminders orphaned-column check (§3.6) — a one-line grep at implementation time, not a design decision.

Markdown remains the source of truth. Run yarn docs:check before handoff.