Skip to content

U1 — Home + Notification Center — Adversarial Audit

Status: Archived Date: 2026-07-11 Scope: packages/features/home/src/** (home-feed-screen, temporal panes 일/주/월, HomeAdminAttentionStrip, HomeDisputedAlertCard, bell/header, notification-center-screen + admin rows), plus the notification-delivery chain the block depends on (use-notification-handler.ts, supabase/functions/send-push/index.ts, signal-resolver.ts, signal-routes.ts) since a push-tap dead-end lives at the boundary between "home/bell" and "notification pipeline." The notification center was rebuilt in commit 5dec3455 (2026-07-11) — verified as integration, not redesigned.

Method: read-only. Traced import chains (home-feed-screen → useMyAdminClubAttention / useUnreadSignalCount → registry ports; push payload construction in the edge fn → client destructuring). No code modified.


Going well (evidence)

  1. Tamagui-only, zero drift. Grep across all 21 files in packages/features/home/src for StyleSheet.create, className, inline style={{}}, and raw hex (#[0-9a-f]{3,6}) — zero hits. Every spacing prop uses $-tokens.
  2. Card tone discipline. Every Card in scope declares tone="default" or tone="flat" (empty state only, home-today-view.tsx:379) — no ad-hoc tones.
  3. Badge variant discipline. Every Badge usage in scope sticks to canonical variants (neutral/warning/live/surface/success) — verified in notification-center-screen.tsx (SignalRow/GroupRow), home-admin-attention-strip.tsx, home-disputed-alert-banner.tsx (variant="live", line 117), home-today-view.tsx recap chips.
  4. No CTAs in cards. Every card in scope (home-nearby-pickups-card, home-club-discovery-card, home-admin-attention-strip, home-disputed-alert-banner, notification SignalRow/GroupRow/ AdminAttentionRow) uses the whole-card Pressable variant="card" pattern with a chevron/badge affordance — no embedded ActionButton. The one real button (markAllRead) lives in DetailShell's headerRight, not inside a card.
  5. sessionTitle()/canonical SessionCard. SessionCard (packages/app/src/presentation/components/sessions/session-card.tsx:41, 155, 408) is the only session-rendering primitive reached from all three temporal views, LiveSessionStack, and home-nearby-pickups-card — home never forks its own session row, and session-card.tsx:155 calls sessionTitle(session), never session.title.
  6. Protocol A (bulk siblings) fully honored. Every temporal view (today/week/month) does exactly 3 bulk pre-fetches (useMyRsvps+buildRsvpMap, useMySessionPayments(ids), useSessionsRsvps(ids), useSessionsWeather(inputs)) instead of per-card N+1 — the comment literally says "3 requests total instead of 3N per rendered card" in all three view files (home-today-view.tsx:190, home-week-view.tsx:92, home-month-view.tsx:102). The notification center bulk-resolves actor display names (useProfilesByIds(actorIds), notification-center-screen.tsx:418) and T3-group club names (useClubsByIds(groupClubIds), line 491) instead of per-row lookups.
  7. No persistent realtime, well-justified. home-feed-screen.tsx:52-83 carries an explicit, dated (2026-06-01) rationale for removing 3 always-on realtime channels in favor of staleTime + push-bust + foreground catch-up + mutation invalidation. Nothing in scope opens a subscription that would need useFocusedEffect gating (Protocol L is satisfied by absence, correctly).
  8. Loading/empty/error triage, not binary. home-today-view.tsx:212-219 distinguishes "still loading" vs "load failed" vs "genuinely nothing today" via .isLoading/.isError (never .isFetching), with an explicit comment: "the most-viewed screen never shows a misleading empty state." NotificationCenterScreen wires loading/error/errorState/ hasMore/loadingMore through dedicated FeedList props (notification-center-screen.tsx:657-669).
  9. Cross-surface consistency confirmed, not assumed. Diffed packages/features/home/src/shared/session-header.tsx and matchup-item.tsx against their packages/app counterparts — they are 2-line re-export shims (export { SessionHeader } from '@twomore/app'), not forks. No drift.
  10. 3-band model is pure, tested domain logic. signalBand and groupEarlierBandSignals (signal-resolver.ts:378-468) are pure functions with dedicated test coverage added in the same commit (signal-resolver.test.ts +141 lines) — the rendering layer (notification-center-screen.tsx:496-538) just partitions pre-computed bands, no logic duplicated in the component.
  11. Overflow-safe counters. Bell badge caps at "9+" (home-feed-screen.tsx:90); filter-chip counts cap at "99+" (notification-center-screen.tsx:136) — both sized for their fixed circular/pill containers.

Broken or drifted (severity-ranked, evidence)

1. [CRITICAL] Push-notification tap navigation is a dead end for every category except chat/DM

supabase/functions/send-push/index.ts:369-380 builds the Expo push payload as:

ts
data: {
  signalId: row.id,
  type: row.type,
  category: row.category,
  severity: row.severity,
  context: row.context ?? {},   // <-- IDs nested HERE
  ...extraData,                  // only category==='chat' adds {screen:'dm', threadId}
},

packages/app/src/presentation/hooks/use-notification-handler.ts:113-119 and :168-174 both destructure the tap/foreground payload as:

ts
const { screen, clubId, sessionId, postId, threadId } = data as {...};

reading data.clubId/data.sessionId/data.postId at the top level — never data.context.clubId. Since only category === 'chat' gets a top-level threadId + explicit screen: 'dm' special-case (send-push/index.ts:360-363), every other category (session, matchup, rsvp, dues, club, social, progression, rating, trust, payment, interclub, system) sends a push whose data.screen is always undefined and whose data.clubId/data.sessionId/data.postId are always undefined. Both handleNotificationNavigation and refreshDomainData's switch (screen) therefore always hit default (logger.info('Unknown notification screen:', screen) / no-op) for ~12 of 13 signal categories. Tapping the OS push notification for a session reminder, dues overdue, matchup result, payment hold, disputed score, etc. does nothing — the app opens to whatever screen was already showing.

This is a live instance of the standing owner complaint ("texts that lead nowhere") and is the single biggest finding in this block: it affects the entire push-delivery half of the notification system, not just the 9 new alert types from this commit. Contrast: the in-app notification center list works correctly (getSignalRoute, signal-routes.ts, reads signal.context.* properly) — so a user who manually opens the bell gets working links; a user who taps the OS push gets nothing. Neither use-notification-handler.ts nor send-push/index.ts has any test coverage (find … -iname "*notification-handler*test*" / send-push -iname "*test*" both empty), so nothing would have caught this.

2. [HIGH] Admin-attention badge count means three different things on three surfaces

  • home-feed-screen.tsx:89: bellCount = unreadSignalCount + adminExceptionItems.lengthclub count (e.g. admin of 3 clubs → +3).
  • home-admin-attention-strip.tsx:39-44: total sums item.model.total across all admin clubs — item count (e.g. 5+3+7 = 15 across those same 3 clubs).
  • notification-center-screen.tsx:382: "관리" filter chip count = adminAttentionItems.length — club count again (matches the bell).

So the same underlying data reads "+3" on the bell badge, "15건 확인 필요" on the Home strip headline, and "3" on the notification center's 관리 chip. A user who taps the strip expecting to resolve "15 things" lands on a bell/chip that says "3." This is a genuine coherence break, not a one-off typo — the strip was deliberately built (R5, per its own doc comment) to show the truer item-level total, but the bell/chip were never updated to match.

3. [MEDIUM] signalMatchesScreen has no screen mapping for 6 of 13 categories

signal-resolver.ts:218-258 — the screenBanner/embeddedHero projection only special-cases club_detail, club_dues, session_detail, match_board (plus the home/notifications catch-alls). payment, chat, progression, rating, trust, interclub, system have no dedicated ScreenContext.screen value at all, so a critical-severity payment-hold-expiring signal can only ever surface via the general feed/bell — never as the home embeddedHero override (home is the one screen it would reach, and only if it's the single highest-severity signal). Adjacent to U1 scope (resolver is shared infrastructure) but directly gates Home's embedded-hero behavior, so flagged here.

4. [LOW] session_payment_confirmed/submitted render with the same red icon as a payment failure

packages/app/src/config/visual-accents.ts:210: payment: { Icon: CreditCard, color: '$error' } is the accent for the entire payment category. SignalRow (notification-center-screen.tsx:165) uses this category-level accent for every payment signal type, including positive ones (session_payment_confirmed = 참가 확정, a good-news event) —rendered with the identical red icon bubble as subscription_payment_failed. Toss-grade apps reserve red strictly for failure/urgency; a confirmed payment reading as visually alarming is a semantic mismatch.

5. [LOW] useMyAdminClubAttention runs twice, independently, per Home+Center mount

useMyAdminClubAttention(userId) is called once in home-feed-screen.tsx:49 and again in notification-center-screen.tsx:361. Each call independently fans out useQueries (1 useMyClubs + N membership + M snapshot queries) and runs its own ack-store observe effect. React Query dedupes the network requests by shared queryKey when both screens are mounted, but this is still two independent recomputation instances of the same aggregate derivation (filter/map over rawItems) rather than one shared source — the hook's own doc explicitly justifies its existence as distinct from useHomeData's capped AggregateRole, but doesn't address the dual-mount case.


Adversarial probes run

  • 0 clubs: HomeClubDiscoveryCardContent renders (FTUE nudge); HomeAdminAttentionStrip/admin rows correctly return null (items.length === 0 guard) — no ghost UI.
  • Non-admin user: adminExceptionItems = [] end-to-end (hasAdminExceptions = false); the entire home-header block collapses to null when also no disputed matches (home-feed-screen.tsx:113).
  • 20+ admin clubs: useMyAdminClubAttention has no cap — unlike useHomeData's explicit "first 3 clubs" AggregateRole sampling (use-home-data.ts:144-147), this hook fans out 1 + N (membership) + M (snapshot, admin-only) queries with no ceiling. For a 20-club power-admin that's ~40 parallel queries on Home mount. Not necessarily broken (all batched via useQueries, cached, staleTime.frequent) but an unbounded fan-out where a sibling hook in the same file family deliberately caps — worth a documented ceiling or an explicit "intentional, no cap" note.
  • Huge unread counts: bell caps at "9+", chip caps at "99+" — verified, no overflow risk.
  • Long Korean club/session names: numberOfLines={1|2} applied consistently on every title/body Text role in scope (SignalRow, GroupRow, AdminAttentionRow, HomeAdminAttentionStrip, PickupRow) — grepped, no un-truncated long-string surface found.
  • Offline/error paths: all three temporal views wire QueryErrorState with retry for their primary query; NotificationCenterScreen wires error/errorState through FeedList.
  • Push-tap dead end: traced the full payload construction (send-push/index.ts) through to the client destructuring (use-notification-handler.ts) — confirmed the ID-nesting mismatch (see Broken #1) by reading both sides, not by running the app.
  • Badge-count consistency: traced adminExceptionItems/total usage across all three consuming files — confirmed the 3-way inconsistency (see Broken #2) via direct code comparison.

Canon violations

  • getWindowEndDate() reinvents local-date math with a UTC unsafe pattern (use-home-recommendations.ts:25-29): new Date(); d.setDate(d.getDate() + 14); d.toISOString().slice(0,10). The project's own convention (stated in date-format.ts's DateSelection section) is "Local-TZ throughout … never UTC," and addDays(getLocalToday(), N) already exists and is used elsewhere in this same package (home-today-view.tsx). This is both a "never UTC" violation and a "single source of truth" violation (reinventing addDays). Near the KST midnight boundary this can silently shift the 14-day nearby-pickups window by a day.

No other Tamagui/styling/Card/Badge/Pressable/ModalPanel-vs-Sheet/FeedList violations found in scope (see Going Well #1-4, #9).


Improvement candidates

  1. Fix push-tap navigation — one shared mapping instead of two divergent switches. _Design-spec seed: either flatten context._into the push payload's top-leveldatainsend-push/index.ts, or update use-notification-handler.tsto destructure fromdata.context; then route through getSignalRoute (already correct for the in-app center) instead of the bespoke screen-name switch, so push-tap and center-tap share one implementation.*
  2. Unify admin-attention count semantics — pick item-count everywhere. Design-spec seed: bellCount = unreadSignalCount + adminExceptionItems.reduce((s,i) => s + i.model.total, 0); mirror the same sum for the notification center's 관리 chip count, matching what the Home strip already shows.
  3. Cap or document useMyAdminClubAttention's fan-out. Design-spec seed: either cap to top-N clubs by urgency/recency with a "+N more" affordance in the strip (mirroring useHomeData's 3-club cap), or add an explicit doc note that the fan-out is intentionally unbounded because admin counts are rare/small in practice.
  4. Route getWindowEndDate() through addDays(getLocalToday(), 14).Design-spec seed: one-line swap, no visual change, removes the KST-midnight edge case.
  5. Give positive payment signals a non-red accent. Design-spec seed: keep SIGNAL_CATEGORY_ACCENTS.payment as the category default ($error, correct for holds/failures/urgency), but have SignalRow special-case signal.type ∈ {session_payment_confirmed, session_payment_submitted} to a $success CheckCircle accent — same per-type override pattern already implicitly needed elsewhere in the category.

Executive summary (relayed to caller)

3 broken/drifted findings ranked HIGH+ (1 critical, 1 high, 1 medium) + 2 low-severity + 1 canon violation + 11 going-well items + 5 improvement candidates. Top finding: push-notification tap navigation is dead for ~12 of 13 signal categories (only chat/DM works) due to an ID-nesting mismatch between send-push/index.ts's data.context.* payload shape and use-notification-handler.ts's top-level data.clubId/sessionId/postId destructuring — a live, untested instance of the "texts that lead nowhere" complaint, distinct from the in-app center (which correctly uses getSignalRoute). Second: admin-attention counts show 3 different numbers across bell/strip/chip for the same data. The rebuilt 3-band notification center itself (severity bands, T3 grouping, bulk actor/club-name resolution) is clean, canon-compliant, and well-tested — no redesign needed.

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