Skip to content

Screen Blueprints — Records

Status: Active Last reviewed: 2026-09-11

Current-state blueprints for the records 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.


Records (기록)

  • Route: /(tabs)/(records)
  • Screen: RecordsScreenpackages/features/records/src/records-screen.tsx
  • Shell: MainTabShell · title t().common.records ("기록") · testID tab.records
  • Design: unreviewed
  • Verified: 2026-09-16

Purpose. The player's personal analytics home — tier/ELO, activity rings, and scoped win-rate/partner/ELO-trend breakdowns, with footer links into head-to-head, full history, and the leaderboard.

Anatomy.

├─ [MainTabShell] testID=tab.records title=기록
├─ [ScrollView] contentContainerStyle=SCROLL_CONTENT.list
├─ [QueryBoundary] queries=[...player.queries, myClubsQ] skipDisabled
│  loading ▸ [SkeletonHero] + 3×[SkeletonCard]
├─ [Sentry.TimeToFullDisplay record] — TTFD marker, content branch only
├─ ── merged scope row (D8, 2026-09-08) ──
│  ├─ [PillNav] value=scope items=[우리 클럽, 친구, 지역, 전국]
│  └─ [ScopeFilterTrigger] badge=matchScopeActiveCount → opens MatchHistoryScopeSheet
├─ [HeroCard] elo, matchesPlayed, activityRings={sessions/mo, attendanceRate, winRate}
│  └─ tap tier badge → TierInfoSheet
├─ ▸ recordsEmptyKind === 'noFriends' → [EmptyState] compact ✦ 친구 찾기 → /profile/friends/add
├─ ▸ recordsEmptyKind === 'noRegion' → [EmptyState] compact ✦ 프로필 편집 → /settings/edit-profile
├─ ▸ recordsEmptyKind === 'noFriendMatches' → [EmptyState] compact, no CTA
├─ ▸ recordsEmptyKind === 'filteredEmpty' → [EmptyState] full ✦ reset matchScope (never the "never played" CTA)
├─ ▸ recordsEmptyKind === 'scopeEmpty' → [EmptyState] full — names the active scope chip, counts the matches 전국 holds ✦ 전국 범위로 보기 → setScope('national') (the scope hid matches the player has elsewhere; never the "never played" CTA)
├─ ▸ recordsEmptyKind === 'zeroMatches' → [EmptyState] full ✦ 번개 찾기 → discoverPickups
└─ ▸ else (content) ──
   ├─ [PersonalRecordsSection] matches=scopedMatches
   ├─ [ActivityHeatmapCard] 12wk×7d heatmap, matches=scopedMatches
   ├─ [SectionBlock title=winRateByFormat] → [FormatStatsCard] (`Card`, no header of its own) — per-format win-rate bars
   ├─ [SectionBlock title=bestPartnersTitle] → [TopPartnersCard] (`Card`, no header of its own) — top-3 medal rows
   ├─ [SectionBlock title=eloTrendTitle] → [EloTrendCard] (`Card`, no header of its own) — 10-match SparkLine
   ├─ [LinkCard] Swords icon → 라이벌 분석 (routes.analysisRivals)
   ├─ [LinkCard] Trophy icon → 전적 보기 (routes.recordsHistory)
   └─ [LinkCard] Crown icon → 리더보드 보기 (routes.recordsLeaderboard)
[TierInfoSheet] open=tierSheetOpen
[MatchHistoryScopeSheet] open=scopeFilterOpen — format/nature/membership picker

States.

  • Loading: unified SkeletonHero + 3×SkeletonCard until every Protocol-G query (player queries + myClubsQ) settles — no cascading reveal.
  • Empty branches are selected by selectRecordsEmptyState() — exactly one of noFriends / noRegion / noFriendMatches / filteredEmpty / zeroMatches renders, never a stacked pair.
  • filteredEmpty is distinct from zeroMatches: the sub-filter (format/nature/membership sheet), not the scope chip, excluded everything — offers a filter reset, never the "go find a pickup" CTA.
  • noRegion only fires on the 지역 scope with no profile region set (no silent 서울 fallback).
  • Content branch renders only once matches exist in the active scope+filter.

Actions.

  • Scope chip tap (club/friends/regional/national) — urgent state, not deferred.
  • ScopeFilterTriggerMatchHistoryScopeSheet (format/nature/membership); active-facet count badges the trigger.
  • Tier badge tap → TierInfoSheet.
  • Empty-state CTAs: 친구 찾기 → routes.profileFriendsAdd; 프로필 편집 → routes.editProfile; filter reset → setMatchScope(DEFAULT_MATCH_HISTORY_SCOPE); 번개 찾기 → routes.activityPickups.
  • Footer LinkCards → routes.analysisRivals, routes.recordsHistory, routes.recordsLeaderboard.
  • Pull-to-refresh → player.retry.

Gaps. none found.


Record Detail

  • Route: /records/[period]
  • Screen: RecordDetailScreenpackages/features/records/src/record-detail-screen.tsx
  • Shell: DetailShell · title periodLabel (e.g. "4/14 — 4/20" week, "4월" month) · headerRight: none
  • Design: ratified
  • Verified: 2026-09-16

Purpose. Week/month drill-down opened from a home hero card — win-rate/ELO stats, an activity calendar, opponent breakdown, and the full filtered match list for that period.

Anatomy.

├─ [DetailShell] title=periodLabel perfLabel=record-detail-{period} refreshControl
├─ [QueryBoundary] queries=[matchHistoryQ, profileQ] loading=[SkeletonFlatPage kind=type]
├─ ▸ periodEntries.length === 0 → [EmptyState] full — noMatches / noMatchesSubtitle
└─ else ──
   ├─ [TypeHero] title=heroTitle (win-rate % or match count) leading=[OutcomeRing]
   │  facts=[📅 periodLabel, 🎾 W-L-D record] — no eyebrow (owner ruling 2026-09-08: it
   │  used to repeat periodLabel, which now lives only in the 📅 fact)
   └─ [FlatColumn]
      lead ▸ [MatchHistoryScopeSelector] + ▸ recap.matchCount>0 → [StatStrip]
        cells=[ELO 변동 (tone by sign), 활동일 (activeDays/periodDays), 티어|연승]
      ├─ ▸ recap.matchCount === 0 → [EmptyState] compact
      ├─ else ──
      │  ├─ [SectionBlock variant=flat] title=활동 right=[ActivityMetricSelector]
      │  │  └─ [ActivityMetricCalendar] onDayPress → routes.recordDay(date, scope)
      │  └─ ▸ opponents.length > 0 → [SectionBlock variant=flat] title=상대 전적
      │     └─ [OpponentContextCard] expand ▸ opponentExpanded
      └─ [SectionBlock variant=flat] title=경기 기록 right=[MatchListControlsTrigger]
         ├─ per group: label + count [Badge] + [RowList dividers] of [MatchTimelineRow]
         └─ ▸ matchGroups.length === 0 → [EmptyState] compact
[MatchListControlsSheet] open=controls.open — group (날짜별 + the shared categorical modes)/sort/result/status; format·nature·membership come from the scope chip strip

States.

  • Loading: SkeletonFlatPage kind="type".
  • Empty period (zero matches at all): full-page EmptyState, gates everything below.
  • Empty filtered (recap.matchCount === 0 after scope/sub-filter): compact EmptyState, shown independently in both the activity+opponents zone and the match-list zone.
  • Opponents SectionBlock renders only when opponents.length > 0.
  • StatStrip's third cell is 티어 when a tier label applies, else 연승 (streakValue).

Actions.

  • MatchHistoryScopeSelector chip change → handleMatchHistoryScopeChange.
  • ActivityMetricSelector toggle → local activityMetric state (labels: common.elo/common.resultLabel + recordDetail.activityControls, read by the calendar itself).
  • Day-cell tap → routes.recordDay(date, {format, nature, membership}).
  • 상대 전적 "더 보기" → expands OpponentContextCard.
  • MatchListControlsTrigger → opens MatchListControlsSheet; reset clears filters. Pill sections and the group-bucket titles come from one vocabulary (MATCH_LIST_BUCKETS in shared/match-list-controls.tsx; format/nature/membership via matchHistoryScopeValueLabel), so a bucket is always named like the chip that filters it.
  • Pull-to-refresh → matchHistoryQ.refetch.

Gaps. none found.


Head-to-Head (상대 전적)

  • Route: /records/analysis/rivals
  • Screen: HeadToHeadScreenpackages/features/records/src/head-to-head-screen.tsx
  • Shell: DetailShell · title t().headToHead.title ("상대 전적") · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-17
  • **Notes:**2026-09-17: person/club name ListRows pass titleLines={NAME_LINES} (U-13, two-line proper names)

Purpose. Opponent-by-opponent record drill-down — surfaces 라이벌/천적/최다 대결/최고 승률 callouts plus every opponent the player has faced.

Anatomy.

├─ [DetailShell] title=상대 전적 onBack
├─ ▸ isError → [QueryErrorState] onRetry=refetch
├─ ▸ isLoading → [SkeletonFlatPage kind=type]
├─ ▸ !data or data.records.length===0 → [EmptyState] full — noData
└─ else ──
   [FlatColumn]
   lead ▸ [StatStrip] cells=[승률 (tone=primary), 전적 (W-L-D), 상대 수]
   ├─ ▸ calloutItems.length > 0 → [RowList] of [ListRow]
   │  (leading=[Icon Swords|Flame|Users|Trophy], title=opponentName,
   │   sub=[label, "W-L-D 승률%"]) — 라이벌 / 천적 / 최다 대결 / 최고 승률,
   │  each rendered only when that record exists (buildCalloutItems)
   └─ [SectionBlock variant=flat] title=주요 상대
      └─ [RowList] of [ListRow] per opponent — leading=[AvatarBubble],
         title=opponentName, sub=W-L-D, trailing=[WinResultBadge mode=dot]×N
         (recent-form dots) → onPress routes.userProfile(opponentId)

States.

  • Error: QueryErrorState replaces the whole body.
  • Loading: SkeletonFlatPage kind="type".
  • No data: full EmptyState when data.records.length === 0.
  • Content: callouts render 0–4 items depending on which of rival/nemesis/mostPlayed/bestWinRate exist; the full opponent list always renders below.
  • Overall win rate excludes draws from the denominator (wins / (wins + losses)), policy-commented in the file.
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via QueryBoundary — never the empty state (2026-09-11 offline sweep).

Actions.

  • Opponent row tap → routes.userProfile(opponentId).
  • Back → appRouter.back().

Gaps. none found.


Record Day Detail (일일 요약)

  • Route: /records/day
  • Screen: RecordDayDetailScreenpackages/features/records/src/record-detail/record-day-detail-screen.tsx
  • Shell: DetailShell · title t().recordDetail.dayDetail.pageTitle ("일일 요약") · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-13

Purpose. Single-day, read-only drill-down opened from a day cell on Record Detail's activity calendar.

apps/mobile/app/records/day.tsx imports RecordDayDetailScreen from @twomore/records, which is re-exported from record-detail-screen.tsx — but that file only re-exports it; the actual component (and its own <FlatColumn>, split out per the one-FlatColumn-per-file rule) lives in record-detail/record-day-detail-screen.tsx, confirmed above.

Anatomy.

├─ [DetailShell] title=일일 요약 perfLabel=record-day-detail refreshControl onBack
├─ [QueryBoundary] queries=[matchHistoryQ] loading=[SkeletonFlatPage kind=type]
├─ ▸ scopedDayEntries.length === 0 → [EmptyState] full — noMatches/noMatchesSubtitle
└─ else ──
   ├─ [TypeHero] title=dayLabel (formatted date) leading=[OutcomeRing]
   │  facts=[🎾 W-L-D record] — no 📅 fact (owner ruling 2026-09-08: the date
   │  is already the hero title, a 📅 fact here would just restate it)
   └─ [FlatColumn]
      ├─ [StatStrip] cells=[ELO 변동 (tone by sign), 최종 레이팅, 경기 수]
      └─ [RowList dividers] of [MatchTimelineRow] — that day's matches, newest-sorted

States.

  • Loading: SkeletonFlatPage kind="type".
  • Empty: full EmptyState when the day has zero matches after scope filtering.
  • Content: the day's match list, no further branching.
  • Route guard (in apps/mobile/app/records/day.tsx, not this component): an invalid date param or a missing userId triggers router.back() before this screen ever mounts.

Actions.

  • Back only. No CTA band, no scope selector, no filters — a strictly passive read view.
  • Pull-to-refresh → matchHistoryQ.refetch.

Gaps. none found.


Records History (전적)

  • Route: /records/history
  • Screen: RecordsHistoryScreenpackages/features/records/src/records-history-screen.tsx
  • Shell: DetailShell · title t().common.matchHistory ("경기 기록") · scroll=false
  • Design: ratified
  • Verified: 2026-09-16

Purpose. The full, filterable match-history list — scope + sort + result + format + nature + membership + status filters, grouped by month, each row linking back to its originating session.

Anatomy.

├─ [DetailShell] title=경기 기록 onBack scroll=false
└─ [GroupedFeedList] perfLabel=records-history sections=groupByMonth(filteredMatches)
   ├─ header → [SectionHeader] title=common.matchResults ("경기 결과") variant="list" right=[MatchListControlsTrigger]
   │  + "showing N of M" [Text]
   ├─ per-section header → month label
   ├─ item → [MatchHistoryRow] leading=[AvatarStack], matchup primary/secondary text,
   │  [WinResultBadge], score, format [Badge], membership/nature/disputed chips,
   │  ELO delta, trailing [Icon ChevronRight] → routes.clubSession(clubId, sessionId)
   │  (onPressIn prefetches the session)
   ├─ ▸ loading → [SkeletonCard] + 3×[SkeletonRow]
   ├─ ▸ error → [QueryErrorState] onRetry=player.retry
   └─ ▸ empty ──
      ├─ matchHistoryData.length > 0 → [EmptyState] full ✦ reset (filtered-empty)
      └─ else → [EmptyState] full, no CTA (never played)
[MatchListControlsSheet] open=controls.open — group/sort/scope/result/format/nature/membership/status

States.

  • Loading: SkeletonCard + 3×SkeletonRow.
  • Error: QueryErrorState with retry.
  • Two distinct empty states distinguished by whether matchHistoryData itself is empty ("never played," no CTA) vs. the active filters excluded everything ("filtered empty," reset CTA) — mirrors RecordDetailScreen's noFilteredMatches pattern.
  • Content: sections grouped by month (default groupMode), sortable by newest/oldest/ELO gain/ELO loss/score margin.
  • 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.

  • Row tap → routes.clubSession(entry.clubId, entry.sessionId).
  • MatchListControlsTrigger → opens MatchListControlsSheet (group/sort/scope/result/format/nature/membership/status, each a pill section). Sections come from the shared vocabulary (matchListControlSection(controls, dimension)): the nature/membership pills follow @twomore/app's canonical MATCH_HISTORY_SCOPE_*_VALUES order, the membership facet reads 출처 (클럽/게스트/번개) like the detail screen, and result buckets group as 승리/패배/무승부.
  • Filter reset — in the filtered-empty EmptyState and inside the sheet.

Gaps. none found.


Records Leaderboard (리더보드)

  • Route: /records/leaderboard
  • Screen: RecordsLeaderboardScreenpackages/features/records/src/records-leaderboard-screen.tsx
  • Shell: DetailShell · title t().leaderboardScreen.pageTitle ("리더보드") · scroll=false · headerRight: HeaderIconButton Share2 ▸ club scope with a valid club and ≥1 leaderboard entry
  • Design: ratified
  • Verified: 2026-09-13

Purpose. Compare the viewer against club, regional, and national ELO standings across three scope tabs.

Anatomy.

├─ [DetailShell] title=리더보드 onBack scroll=false
│  headerRight ▸ scope==='club' && primaryClub && leaderboardEntries.length>0
│  → [HeaderIconButton Share2] → handleShareLeaderboard
├─ [YStack] subtitle [Text] pageSubtitle
└─ [SegmentedTabs] value=scope items=getScopeItems() preload=all
   renderTab(scope) →
   ├─ 'club' → [ClubTab] — [FeedList]
   │  header=club-name label + [PodiumRow] top3 + spacer
   │  body=[RankRow]×N (ranks 4+)
   │  footer ▸ myRank>50 → [MyRankRow]
   ├─ 'region' → [RegionTab] — [FeedList]
   │  header=[Card] percentile hero (MapPin icon, "top N%", totalPlayers) +
   │  [SectionHeader] + [PodiumRow] + spacer ▸ region set
   │  body=[RankRow]×N, footer ▸ myRank>50 → [MyRankRow]
   │  ▸ no region → [EmptyState] compact ✦ 프로필 편집 → routes.editProfile
   └─ 'global' → [GlobalTab] — [FeedList]
      header="전체 ELO" label + [PodiumRow] top3 + spacer
      body=[RankRow]×N, footer ▸ myRank>50 → [MyRankRow]
[TierInfoSheet] open=tierSheetElo!==null

States.

  • Per-tab loading: LeaderboardSkeleton (club/global) or a Skeleton+3×SkeletonRow stack (region).
  • Error: QueryErrorState with retry, per tab.
  • club tab: EmptyState (noClubTitle) when the viewer has no primary club; EmptyState (notReadyTitle) when the club has zero rated entries.
  • region tab: EmptyState (noRegionTitle, action → edit profile) when the profile has no region set; EmptyState (notReadyTitle/notReadySubtitleRegion) when the region has zero entries.
  • global tab: EmptyState (notReadyTitle) when the global board is empty.
  • MyRankRow footer renders only when the viewer's rank falls outside the rendered top-50 (computed client-side from already-fetched data — no extra query).
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).

Actions.

  • Scope tab switch (우리 클럽/지역/전국) via SegmentedTabs.
  • Share icon (club scope only) → useShareLeaderboard() with the top-5 club entries.
  • Tier badge tap (in PodiumRow/RankRow) → TierInfoSheet.
  • 지역 empty-state action → routes.editProfile.

Gaps. none found.

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