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)
- Tamagui-only, zero drift. Grep across all 21 files in
packages/features/home/srcforStyleSheet.create,className, inlinestyle={{}}, and raw hex (#[0-9a-f]{3,6}) — zero hits. Every spacing prop uses$-tokens. - Card tone discipline. Every
Cardin scope declarestone="default"ortone="flat"(empty state only,home-today-view.tsx:379) — no ad-hoc tones. - Badge variant discipline. Every
Badgeusage in scope sticks to canonical variants (neutral/warning/live/surface/success) — verified innotification-center-screen.tsx(SignalRow/GroupRow),home-admin-attention-strip.tsx,home-disputed-alert-banner.tsx(variant="live", line 117),home-today-view.tsxrecap chips. - No CTAs in cards. Every card in scope (
home-nearby-pickups-card,home-club-discovery-card,home-admin-attention-strip,home-disputed-alert-banner, notificationSignalRow/GroupRow/AdminAttentionRow) uses the whole-cardPressable variant="card"pattern with a chevron/badge affordance — no embeddedActionButton. The one real button (markAllRead) lives inDetailShell'sheaderRight, not inside a card. sessionTitle()/canonicalSessionCard.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, andhome-nearby-pickups-card— home never forks its own session row, andsession-card.tsx:155callssessionTitle(session), neversession.title.- 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. - No persistent realtime, well-justified.
home-feed-screen.tsx:52-83carries an explicit, dated (2026-06-01) rationale for removing 3 always-on realtime channels in favor ofstaleTime+ push-bust + foreground catch-up + mutation invalidation. Nothing in scope opens a subscription that would needuseFocusedEffectgating (Protocol L is satisfied by absence, correctly). - Loading/empty/error triage, not binary.
home-today-view.tsx:212-219distinguishes "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."NotificationCenterScreenwiresloading/error/errorState/hasMore/loadingMorethrough dedicatedFeedListprops (notification-center-screen.tsx:657-669). - Cross-surface consistency confirmed, not assumed. Diffed
packages/features/home/src/shared/session-header.tsxandmatchup-item.tsxagainst theirpackages/appcounterparts — they are 2-line re-export shims (export { SessionHeader } from '@twomore/app'), not forks. No drift. - 3-band model is pure, tested domain logic.
signalBandandgroupEarlierBandSignals(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. - 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:
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:
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.length— club count (e.g. admin of 3 clubs → +3).home-admin-attention-strip.tsx:39-44:totalsumsitem.model.totalacross 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:
HomeClubDiscoveryCardContentrenders (FTUE nudge);HomeAdminAttentionStrip/admin rows correctlyreturn null(items.length === 0guard) — no ghost UI. - Non-admin user:
adminExceptionItems = []end-to-end (hasAdminExceptions = false); the entire home-header block collapses tonullwhen also no disputed matches (home-feed-screen.tsx:113). - 20+ admin clubs:
useMyAdminClubAttentionhas no cap — unlikeuseHomeData's explicit "first 3 clubs"AggregateRolesampling (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 viauseQueries, 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/bodyTextrole in scope (SignalRow,GroupRow,AdminAttentionRow,HomeAdminAttentionStrip,PickupRow) — grepped, no un-truncated long-string surface found. - Offline/error paths: all three temporal views wire
QueryErrorStatewith retry for their primary query;NotificationCenterScreenwireserror/errorStatethroughFeedList. - 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/totalusage 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 indate-format.ts'sDateSelectionsection) is "Local-TZ throughout … never UTC," andaddDays(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 (reinventingaddDays). 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
- 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 updateuse-notification-handler.tsto destructure fromdata.context; then route throughgetSignalRoute(already correct for the in-app center) instead of the bespoke screen-name switch, so push-tap and center-tap share one implementation.* - 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. - 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 (mirroringuseHomeData'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. - Route
getWindowEndDate()throughaddDays(getLocalToday(), 14).Design-spec seed: one-line swap, no visual change, removes the KST-midnight edge case. - Give positive payment signals a non-red accent. Design-spec seed: keep
SIGNAL_CATEGORY_ACCENTS.paymentas the category default ($error, correct for holds/failures/urgency), but haveSignalRowspecial-casesignal.type ∈ {session_payment_confirmed, session_payment_submitted}to a$successCheckCircle 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.