Skip to content

Screen Blueprints — Home

Status: Active Last reviewed: 2026-09-11

Current-state blueprints for the home screens, read from the components. Conventions and the screen index live in screen-blueprint.md. History lives in git and rebuild-log.md — not here.


Home (일간/주간/월간 브리핑)

  • Route: /(tabs)/(home)
  • Screen: HomeFeedScreenpackages/features/home/src/home-feed-screen.tsx
  • Shell: MainTabShell · title homeTitle.day|week|month (tracks selected granularity: 일간 브리핑/주간 브리핑/월간 브리핑) · headerRight: NotificationBell
  • Design: ratified
  • Verified: 2026-09-13

Purpose. The default landing tab — shows the user's PRESENT across three temporal slices (오늘/이번 주/이번 달), deliberately never a list of all future scheduled sessions (that belongs to the 경기 tab).

Anatomy. MainTabShell's tabs prop drives a 일/주/월 pill-nav (getPillItems()); renderTab swaps between three view components via renderTemporalContent(slice)HomeTodayView, HomeWeekView, HomeMonthView from ./views/*, each wrapped in <FeedList> (one {kind:'temporal', slice} row) with a shared cross-pane header/footer:

├─ [MainTabShell] tabs=[일,주,월] tabValue=granularity onTabChange=setGranularity
│  ├─ header (cross-pane, only when hasDisputedMatches) ▸
│  │  └─ [HomeFeedSection] tone="danger" title=needsAttention → [HomeDisputedAlertCard]
│  ├─ [FeedList] one row per pane, renderItem → [PerfProfiler id="home-feed"] → renderTemporalContent(slice)
│  │  ├─ slice="day"   → [HomeTodayView]  (views/home-today-view.tsx)
│  │  ├─ slice="week"  → [HomeWeekView]   (views/home-week-view.tsx)
│  │  └─ slice="month" → [HomeMonthView]  (views/home-month-view.tsx)
│  └─ footer (cross-pane) → [HomeRecommendationsSection]

HomeTodayView (day pane): PeriodNavPanel (prev/next + DayCalendarPicker scrubber) → ▸ [HomeFeedSection] "진행 중" → LiveSessionStack (one SessionCardV2 per in_progress session anchored today) → ▸ [HomeFeedSection] "오늘 일정" → SessionCardV2×N (host-first, open|locked + endTime>now) → ▸ [HomeFeedSection] "오늘 복기" → SessionCardV2×N each carrying its post-game note row inside the card (RecapNoteBadge scoreIssue/needsAction/complete + EloDeltaBadge when the viewer's rating moved) for today's completed sessions the viewer RSVP'd to → else (all three empty) SkeletonCard | QueryErrorState | a Pressable "오늘 예정된 경기가 없어요" → routes.activity, wrapped in AttachStep for a first-idle FAB SpotlightTour coachmark (zero-club users only, MMKV-gated via useCoachmarkSeen('fab')).

HomeWeekView/HomeMonthView (structurally identical, ranged to week/month): PeriodNavPanel (WeekMonthPicker/MonthYearPicker scrubber) → [HomeFeedSection] recap → RecapSummaryCard (skeleton/error/populated-or-ghosted) → ▸ [HomeFeedSection] upcoming → SessionCardV2×N host-first (unbounded, live sessions excluded by design — those only ever show in the day pane).

States.

  • Cross-pane: hasDisputedMatches header banner shown/hidden; pull-to-refresh (usePullToRefresh(handleRefresh)); HomeRecommendationsSection hidden entirely unless clubDiscovery.shouldShow || nearbyPickups.shouldShow.
  • Day pane: loading (no dedicated skeleton until !hasContent, then SkeletonCard) vs fetch-error (QueryErrorState variant="compact", checks both sessionsQ.isError and rsvpdIsError) vs empty-CTA vs any-of-live/scheduled/recap populated; zero-club + empty + unseen + 3s-idle → FAB coachmark fires once.
  • Week/month panes: recap sub-loading SkeletonCard / recap fetch-error QueryErrorState / RecapSummaryCard populated / RecapSummaryCard ghosted (ranged query returns zero matches); upcoming section independently loading/error/populated/hidden-when-empty.
  • No always-on realtime: three ambient realtime channels were removed by owner decision (2026-06-01) after RLS cost measurement — freshness now carried by staleTime (60s), focus/reconnect refetch, push-triggered invalidation, and foreground catch-up (see file header comment).

Actions.

  • NotificationBell tap → routes.notifications (badge tone attention (amber) when an admin obligation is open, else unread (red), else none — via deriveBellState).
  • Pill tap → setGranularity (resets to day default view logic per store).
  • PeriodNavPanel ‹›/step(±1); center tap toggles the scrubber; picker day/week/month tap → pickAnchor.
  • HomeDisputedAlertCard tap → routes.scorecard(sessionId) of the first disputed match's session.
  • Any SessionCardV2 tap (any partition, any pane) → routes.clubSession(clubId, sessionId).
  • Day-pane empty-state tap ("오늘 예정된 경기가 없어요") → routes.activity.
  • Pull-to-refresh → handleRefresh.

Gaps. none found.


Notification Center (알림)

  • Route: /home/notifications
  • Screen: NotificationCenterScreenpackages/features/home/src/notification-center-screen.tsx
  • Shell: DetailShell · title t().notifications.title (알림) · scroll=false · headerRight: HeaderIconButton Settings → routes.signalPreferences
  • Design: ratified
  • Verified: 2026-09-16

Purpose. The one place behind the bell: every active signal for the viewer plus the admin-attention rows for clubs they run, filtered by chip and grouped into 할 일 / 새 소식 / 이전 알림 bands.

Anatomy.

├─ [DetailShell] title=알림 · headerRight=[HeaderIconButton Settings → 알림 설정]
└─ [FeedList] rows = union of band labels, admin rows, signal rows, group rows (getItemType per kind)
   ├─ header: [XStack flexWrap] [FilterChip] × buildFilters() — wraps to a second row, never a horizontal scroll
   ├─ [SectionHeader] band 할 일 ▸ any admin-attention item or todo-band signal
   │  ├─ [AdminAttentionRow] × visibleAdminItems — one per admin club, dismissible, → that club's 관리 tab
   │  └─ [MemoSignalRow] × signals with signalBand()==='todo'
   ├─ [SectionHeader] band 새 소식 (trailing 모두 읽음 text action ▸ unreadSignalCount > 0) ▸ any fresh signal
   │  └─ [MemoSignalRow] × fresh
   └─ [SectionHeader] band 이전 알림 ▸ any earlier signal or group
      ├─ [MemoGroupRow] × groupEarlierBandSignals() units — title={club name alone} (via useClubsByIds), body=[새 글 N개, …latest signal's facts] rendered as MetaSegments, never glued
      └─ [MemoSignalRow] × earlier — body is a single clause (Text) or several distinct facts (`readonly string[]`, MetaSegments) — never a middle-dot-glued string

States.

  • Loading (activeSignals.isLoading || signalPreferences.isLoading) → three SkeletonCards in the list's loadingState.
  • Error → QueryErrorState with retry (activeSignals.refetch).
  • Empty copy branches on the active chip: 안 읽음 → emptyUnread ("caught up"), a category chip → emptyCategory(label), otherwise notifications.empty; all render EmptyState icon=Bell variant=compact.
  • Infinite feed: hasNextPage/isFetchingNextPageonEndReached loads more; pull-to-refresh via usePullToRefresh refetching signals + preferences + admin attention.
  • Signals hidden by the viewer's preferences (buildPreferenceMapresolveSignals) never render.
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions.

  • Chip tap → setFilter (all / unread / admin / per-category).
  • Signal row tap → acknowledges if unread (useAcknowledgeSignals) then appRouter.push(getSignalRoute(signal)).
  • Signal row dismiss → useDismissSignal.
  • 모두 읽음 (새 소식 band header) → useAcknowledgeVisibleUnreadSignals.
  • Admin row tap → the club's 관리 tab; admin row dismiss → local dismiss from useMyAdminClubAttention.
  • Settings icon → routes.signalPreferences.
  • Back → appRouter.back() when there is history, else replace(routes.home) (deep-link entry).

Gaps. none found.

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