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:
RecordsScreen—packages/features/records/src/records-screen.tsx - Shell: MainTabShell · title
t().common.records("기록") · testIDtab.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 pickerStates.
- Loading: unified
SkeletonHero+ 3×SkeletonCarduntil every Protocol-G query (player queries +myClubsQ) settles — no cascading reveal. - Empty branches are selected by
selectRecordsEmptyState()— exactly one ofnoFriends/noRegion/noFriendMatches/filteredEmpty/zeroMatchesrenders, never a stacked pair. filteredEmptyis distinct fromzeroMatches: the sub-filter (format/nature/membership sheet), not the scope chip, excluded everything — offers a filter reset, never the "go find a pickup" CTA.noRegiononly 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. ScopeFilterTrigger→MatchHistoryScopeSheet(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:
RecordDetailScreen—packages/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 stripStates.
- Loading:
SkeletonFlatPage kind="type". - Empty period (zero matches at all): full-page
EmptyState, gates everything below. - Empty filtered (
recap.matchCount === 0after scope/sub-filter): compactEmptyState, shown independently in both the activity+opponents zone and the match-list zone. - Opponents
SectionBlockrenders only whenopponents.length > 0. StatStrip's third cell is 티어 when a tier label applies, else 연승 (streakValue).
Actions.
MatchHistoryScopeSelectorchip change →handleMatchHistoryScopeChange.ActivityMetricSelectortoggle → localactivityMetricstate (labels:common.elo/common.resultLabel+recordDetail.activityControls, read by the calendar itself).- Day-cell tap →
routes.recordDay(date, {format, nature, membership}). - 상대 전적 "더 보기" → expands
OpponentContextCard. MatchListControlsTrigger→ opensMatchListControlsSheet; reset clears filters. Pill sections and the group-bucket titles come from one vocabulary (MATCH_LIST_BUCKETSinshared/match-list-controls.tsx; format/nature/membership viamatchHistoryScopeValueLabel), 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:
HeadToHeadScreen—packages/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 passtitleLines={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:
QueryErrorStatereplaces the whole body. - Loading:
SkeletonFlatPage kind="type". - No data: full
EmptyStatewhendata.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) →
OfflineEmptyStatewith retry, viaQueryBoundary— 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:
RecordDayDetailScreen—packages/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.tsximportsRecordDayDetailScreenfrom@twomore/records, which is re-exported fromrecord-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 inrecord-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-sortedStates.
- Loading:
SkeletonFlatPage kind="type". - Empty: full
EmptyStatewhen 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 invaliddateparam or a missinguserIdtriggersrouter.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:
RecordsHistoryScreen—packages/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/statusStates.
- Loading:
SkeletonCard+ 3×SkeletonRow. - Error:
QueryErrorStatewith retry. - Two distinct empty states distinguished by whether
matchHistoryDataitself is empty ("never played," no CTA) vs. the active filters excluded everything ("filtered empty," reset CTA) — mirrorsRecordDetailScreen'snoFilteredMatchespattern. - Content: sections grouped by month (default
groupMode), sortable by newest/oldest/ELO gain/ELO loss/score margin. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+offlineState— never the empty state (2026-09-11 offline sweep).
Actions.
- Row tap →
routes.clubSession(entry.clubId, entry.sessionId). MatchListControlsTrigger→ opensMatchListControlsSheet(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 canonicalMATCH_HISTORY_SCOPE_*_VALUESorder, the membership facet reads 출처 (클럽/게스트/번개) like the detail screen, and result buckets group as 승리/패배/무승부.- Filter reset — in the filtered-empty
EmptyStateand inside the sheet.
Gaps. none found.
Records Leaderboard (리더보드)
- Route:
/records/leaderboard - Screen:
RecordsLeaderboardScreen—packages/features/records/src/records-leaderboard-screen.tsx - Shell: DetailShell · title
t().leaderboardScreen.pageTitle("리더보드") · scroll=false · headerRight:HeaderIconButtonShare2 ▸ 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!==nullStates.
- Per-tab loading:
LeaderboardSkeleton(club/global) or aSkeleton+3×SkeletonRowstack (region). - Error:
QueryErrorStatewith retry, per tab. clubtab:EmptyState(noClubTitle) when the viewer has no primary club;EmptyState(notReadyTitle) when the club has zero rated entries.regiontab:EmptyState(noRegionTitle, action → edit profile) when the profile has no region set;EmptyState(notReadyTitle/notReadySubtitleRegion) when the region has zero entries.globaltab:EmptyState(notReadyTitle) when the global board is empty.MyRankRowfooter 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) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — 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.