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(zones inpackages/features/records/src/tab/) - Shell: MainTabShell · title
t().common.records("기록") · testIDtab.records·actions= [내 성적표 공유] once the profile is known (no tabs) - Design: unreviewed
- Verified: 2026-11-15
- 총 경기 (batch 66, 2026-11-15): the Strip's 총 경기 and 승률 are one population, the server's
recordover EVERY finished match (friendlies included,usePlayerResults(userId, { limit: 1 }), no 200-match window). The stage gate still readsplayedMatches, the rated matches of all three rating tracks together (ratedMatchesPlayed); friendly matches are not rated and are not in it. 연승 reads the newest 200 matches as before. - Batch 58 (ST2, 2026-11-13), States: Offline, error and loading from the one PageStateView.
Purpose. Your rating and what it is made of: the ELO Block, the 전적 zone (Strip and the month Heatmap), then the people Shelves (순위, 최고의 파트너, 천적) and the 경기 기록 Shelf, each head with a door to its whole list; built W4 batch 6 to flat-v2-hub.jsx#rec-ideal (L1-04).
Now. Four zones, each with its own reads, loading and failure (use-records-tab.ts composes them; a zone reads only at the stage the profile says it is drawn in).
- Stage comes from the profile's counters (
eloMatchesPlayed + singlesMatchesPlayed):zero(no match),unrated(under five,MIN_RATED_MATCHES),rated. - ELO Block:
useProfile+useEloHistory;eloBlockModel(figure, NTRP floor of the sub-tier in the eyebrow, door 티어 안내 = the whole Block presses toTierInfoSheet, meta = the trailing 30-day change fromeloWindowandcommon.eloToor 최고 티어, spark = the rating at the window start then after each row). Unrated: the sentence 순위 전 and the matches still missing. - 전적: Strip 총 경기 / 승률 from the record over every finished match (
recordofusePlayerResults, migration 00776 leaves it unrestricted for the subject's own read) and 연승 over the newest 200 matches (useMatchHistorylimit 200, the cache the profile stats share);currentWinStreakcounts wins from the newest, a loss or a draw ends it. The month Heatmap reads its own range (useMatchHistoryfrom/to, one day either side of the month),heatmapMonthtallies local days, ‹ › step, › is off on the current month, a played day opens 하루 기록 (kitHeatmaponDayPress+dayLabel). - People Shelves (rated only): 순위 =
rankWindowoveruseClubRatingRanks(primaryClubId ?? first club), five faces around the viewer with N위 and the ranked count in the head; 최고의 파트너 / 천적 =bestPartners/nemesesoverusePartnerRecords/usePlayerMatchupsByFormatsummed per person across clubs and disciplines, at least five decided matches, three at most, win rate in whole percent; faces and names in oneuseProfilesByIds. - 경기 기록: the five newest matches, sets from the bulk
useMatchSetsByMatchIds(portmatchSets.findByMatchIds, batchedmatch_sets.match_id in (...)), cards through the kit compact Shelf whose Tag takes ameaning(승 =win, 패 =loss, a draw has none) through the colour canon. - Share:
useShareStatCard(text card with 총 경기 and 승률 from the same record as the tiles; the line is left out until the record is read). - Level-up (F1b): while one is pending (
usePendingLevelUp), a neutral Notice (RecordsLevelUpNotice,tab/records-level-up-notice.tsx) sits above the ELO Block: title "NTRP 3.5 달성", body 최근 경기에서 올랐어요, action 보기. 보기 opens the sameLevelUpDialogas the scorecard; either button marks the level seen, which clears the Notice here and the Dialog on the scorecard. Loading, none or offline: no Notice and no gap.
Anatomy.
├─ [MainTabShell] testID=tab.records title=기록 actions=[share 내 성적표 공유]
├─ [ScrollView + RefreshControl] SCROLL_CONTENT.list — pull refetches the drawn zones
├─ ▸ Block failed/paused → [EmptyState kind=error what=기록 | kind=offline] (page-level; only the Block's reads)
├─ ▸ profile loading → loading frame: [Block elo loading] [SectionHead 전적] [Strip loading] [Shelf cell loading 순위] [Shelf compact loading 경기 기록]
└─ ▸ profile ready (RecordsTabContent by stage)
├─ [Block kind=elo] eyebrow [ELO, NTRP n] · door 티어 안내 · figure · spark · meta ▸ unrated: state=empty sentence 순위 전
├─ ▸ zero: [EmptyState kind=first trophy 아직 경기 기록이 없어요 ✦ 매치 찾기 primary]
├─ ▸ unrated | rated: 전적 zone
│ ├─ [SectionHead first 전적]
│ ├─ [Strip face=tiles 총 경기 · 승률 · 연승] (loading ▸ bars; failed ▸ [Notice error + 다시 시도])
│ └─ ▸ rated: 16 · [Heatmap month ‹ › onDayPress] (loading ▸ block; failed ▸ [Notice])
├─ ▸ rated: people zone (loading ▸ [Shelf cell loading]; failed ▸ [Notice])
│ ├─ [Shelf cell 순위 · N명 · door 순위표] 5 faces, viewer "(나)"
│ ├─ 4
│ ├─ [Shelf cell 최고의 파트너 · door 선수별 전적] ≤ 3
│ └─ [Shelf cell 천적] ≤ 3, no door
└─ ▸ unrated | rated: [Shelf compact 경기 기록 · door 전체 보기] ≤ 5 cards (loading ▸ bars; failed ▸ [Notice])
[TierInfoSheet] open=tierOpen currentElo matchesPlayedStates.
Offline (F1c, the app's root strip says it): offline with cached reads the page stays (rec-stale); nothing on it writes.
Loading: the board's frame while the profile reads; after it, each zone draws its own bars (Block until profile and rating history settle; Strip; Heatmap block; people Shelf bars; card bars).
Error / offline cold: the page
EmptyStateonly when the Block's reads (profile, rating history) fail; a missing profile is the same error. Any other zone failing leaves the page and shows a dangerNoticewith 다시 시도 in the zone's place. Offline with data: the page stays under the rootNetworkBannerstrip (rec-stale).Unrated (under five matches): the unranked Block, 전적 (Strip, no Heatmap) and 경기 기록 only. No match: the unranked Block and 매치 찾기.
A Shelf with nothing to say is absent (no club, no partner or opponent with five decided matches, no match).
200% text: the kit Shelf stacks compact cards full width, at most three.
2026-11-13 (UX-7, the state helper): Offline, error and loading from the one
PageStateView.
Actions.
- Block →
TierInfoSheet(local state). Header 내 성적표 공유 →useShareStatCard. - ‹ › step the Heatmap month (state is the screen's); a played day →
routes.recordDay('YYYY-MM-DD'). - A face in 순위 / 최고의 파트너 / 천적 →
routes.userProfile(id); 순위표 →routes.recordsLeaderboard(no scope); 선수별 전적 →routes.analysisRivals. - A card →
routes.clubSession(clubId, sessionId); 전체 보기 →routes.record('month', {start, end})for the current month. - First run 매치 찾기 →
routes.activityPickups. Pull-to-refresh → every drawn zone's reads.
Gaps. A cached money, attendance or score list does not say when it was read: the kit has no as-of slot on a list page (metaWithAsOf exists only inside a Block's offline state), so it needs a drawn piece (docs/design/flat-v2-open-issues.md).
- 전체 보기 opens 기간 기록 for the current month only; the whole history waits for that page (
period=all, owed a frame on the board)./records/historyis retired to a redirect onto 기록 (R1, the lead's call for the owner's veto, 2026-11-10). - The 30-day change and spark read the newest 50 rating rows (
useEloHistorydefault); a month with more than 50 rows understates the change. The win rate and streak read the newest 200 matches. - The 순위 count is the club's ranked members (doubles track); the viewer's national rank, friends and region scopes are 순위표's batch with its schema.
- A match card's set order is read from the last set and the sets won because
MatchHistoryEntrydoes not keep the viewer's team; amyTeamfield on the entry would remove the inference. - The share card is text; the image card is owed. Partners and nemeses take the lowest or highest win rate with five decided matches, with no cut at 50% (a nemesis you beat more than you lose to can show).
- The seam between the unrated Strip and the 경기 기록 Shelf is 8 where the board has 6 (off the 4px grid).
Record Detail (기간 기록)
- Route:
/records/[period] - Screen:
RecordDetailScreen—packages/features/records/src/record-detail-screen.tsx(pieces inrecord-detail/) - Shell: DetailShell · title
t().recordPeriod.title(기간 기록) · canvas ·sub: the person's name on another person's page · pull to refresh - Design: unreviewed
- Verified: 2026-11-15
- Batch 66 (CALL-5, 2026-11-15): the viewer's own page states 경쟁 and 친선 as two static Cells (
KindRows,kind-rows.ts) under the tiles, from the server's one definition: 경쟁 =ratedRecordfor the period shown, 친선 =recordminusratedRecord(everything finished that did not count for the rating, a walkover included); each "N경기, 승률 R%", "0경기, 승률 –" with none. A set filter scopes the tiles, so it hides the rows. Another person's page is read rated only by the server, says 경쟁 경기만 보여요 once (CompetitiveOnlyLine) and its 성격 filter offers no 친선. 하루 기록 is the viewer's own page only and is unchanged. - Batch 58 (ST2, 2026-11-13), States: PageStateView; a missing person or invalid period offers 기록.
- Notes: 2026-11-10 (W4 batch 21a): rebuilt to the
#rpboard. One read path for everyone, the viewer included:usePlayerResults(migration 00704) over the shown range, so another person's page is the same page read with?userId=. The TypeHero, the OutcomeRing, the opponent card, the chips, the sort/group/result/status Sheet anduseMatchHistoryleft this page.
Purpose. A week or a month of one person's matches as a summary page: the range with the page's one filter, the record and its figures, the days played, the opponents, every match by day. The viewer's own page, or another person's as far as their setting lets the viewer read.
Anatomy.
├─ [DetailShell] title=기간 기록 sub=[person] (other only)
└─ body by the read (offline → error → loading → notfound → permission → totals → detail)
├─ ▸ detail, no match in the period: [EmptyState first glyph=trophy] 이 기간엔 경기가 없어요 (no button)
└─ ▸ detail:
├─ [SectionHead first] the range "9월 14일~20일" + [Choice chevron] 필터 / 필터 N → FilterSheet
├─ ▸ the filter leaves none: [EmptyState filtered compact action=필터 초기화]
└─ else ──
├─ [Strip tiles] 전적 across the width · 8 · [Strip tiles] 승률 · ELO 변화 · 최고 연승
├─ ▸ own: 24 · [CellGroup] [Cell static] 경쟁 "N경기, 승률 R%" · [Cell static] 친선 (same)
├─ ▸ other: [HelperLine] 경쟁 경기만 보여요
├─ ▸ own:
│ ├─ [SectionHead] 활동 · "7일 중 3일" (+ [Choice chevron] measure on the month only) → ValueSheet
│ ├─ [Calendar] week (Monday first, no dots) or month (dots by the measure); ‹ › step the period; a day → 하루 기록
│ └─ ▸ any known opponent: 24 · [CellGroup] [Cell nav] 상대 "N명" → 선수별 전적
├─ ▸ other: [SectionHead] 종목별 + [Strip tiles] the formats played (no calendar, no 상대, no day door)
└─ [SectionHead] 경기 기록 · per day [DayHeader] + [ResultRow] (faces by one bulk read; other: neutral, person first)States.
- Loading: the head (filter disabled), both Strips as bars and, on the own page, the 활동 head over a chart bar (
rp-loading); stepping to a period not yet read shows it again. - Cold offline:
EmptyState kind=offline; error:EmptyState kind=error what=기록; both with the retry (rp-offline,rp-error). - A period other than week or month, a malformed
userId, a deleted or missing person:EmptyState kind=notfoundunder the Header. The person'saccess:noneisEmptyState kind=permission;totalsis the head and the 전적 tile alone (no filter, no list, the server's whole-range record). - The period holds no match (
rp-empty): a first run with no button, no head. The filter leaves none (rp-filtered): the head reads 필터 N, the state carries 필터 초기화. - A co-player the viewer may not see reads the name the server sent (
비공개) as plain text on the row; the row opens no profile and the name is never sent to the faces read. - 2026-11-13 (UX-7, the state helper):
PageStateView; a missing person or invalid period offers 기록.
Actions.
- Back →
appRouter.back(). 필터 → FilterSheet (종목, 성격 as the app's 경기 유형, 구분 as its 출처); its primary applies the draft, 초기화 resets the draft; 필터 초기화 on the empty state clears the applied scope. - The 활동 Choice (month) → the measure Sheet (경기 수, 승리, ELO 변화); ‹ › move the week or month (not past the one holding today).
- A calendar day →
routes.recordDay(date, scope); the 상대 Cell →routes.analysisRivals; a result row →routes.clubSession('', sessionId)(the session page, whatever the club). - Pull to refresh refetches the results and the profile.
Gaps.
- The kit week strip draws no dots, so the 활동 Choice shows on the month only (ruling R5); the ELO 변화 measure marks a gaining day with series 0 and a losing day with series 1, a mapping the kit does not define and the board does not draw.
- An empty week or month the viewer stepped into draws the first-run state without the calendar, so the arrows are gone until they go back; the board has no frame for it.
- The server cuts a read at 500 rows (
truncated); a month fits, and the page does not say when a range would not. - No per-row signal says whether the viewer may open another person's session: every row opens the session page, which is its own wall.
- 종목별 for the format head and 경기 유형 / 출처 for two filter groups are the app's existing wording (one concept, one wording); the board says 형식별, 성격 and 구분.
- The first-run body "경기를 기록하면 여기에 쌓여요" is the viewer's wording; on another person's page it reads oddly.
Head-to-Head (선수별 전적)
- Route:
/records/analysis/rivals - Screen:
HeadToHeadScreen—packages/features/records/src/head-to-head-screen.tsx(views and rules inhead-to-head/) - Shell: DetailShell · title
t().headToHead.title("선수별 전적"), ort().clubPanes.peekVersusMe("나와의 전적") with?with=· headerRight: none - Design: unreviewed
- Verified: 2026-11-11
- Notes: 2026-11-10 (W4 batch 21a, boards
#rvandrw-*): rebuilt fromuseMatchHistory(userId, { limit: 'all' })alone (the viewer's own matches, friendly games included);useHeadToHeadis no longer read. Partners, formats, form, callouts and the per-person records are derived by the pure modules inhead-to-head/.?with={userId}filters to matches with or against that person; awithequal to the viewer is ignored. The new kit pieceFormStripdraws 최근 5경기.
Purpose. Everyone the viewer has played with or against, both records per person (같은 편 and 상대), and filtered to one person (?with=, from 공개 프로필's 자세히) the two records, the form and the matches together.
Anatomy.
├─ [DetailShell] title=선수별 전적 | 나와의 전적 onBack
├─ ▸ loading → RivalsSkeleton | WithSkeleton (one skeleton per zone)
├─ ▸ error / offline cold → QueryBoundary (QueryErrorState / OfflineEmptyState)
├─ ▸ no match at all → [EmptyState] kind=first glyph=users — emptyTitle, emptyBody
├─ ▸ no ?with= → RivalsView
│ ├─ [SectionHead first] 전체 전적 + format [Choice] → [ValueSheet] 종목 (전체 · 단식 · 복식)
│ ├─ [Strip] 승률, 전적
│ ├─ ▸ form → [SectionHead] 최근 5경기 + [FormStrip]
│ ├─ ▸ callouts → [SectionHead] 자주 함께한 선수 + [PersonRow context=people]×≤4
│ │ (최고의 파트너, 라이벌, 천적, 가장 많이 만난 상대 — only those that exist)
│ └─ ▸ everyone → [SectionHead] 모든 선수 + count; [PersonRow]×4; ▸ more → [ExpandToggle] N개 더 보기
└─ ▸ ?with=person → WithView
├─ [PersonRow] the person → 공개 프로필
├─ [SectionHead] 전적 + format [Choice]
├─ [Strip] 같은 편 (only if partnered) · 상대, each with 승률 n%
├─ ▸ form → [SectionHead] 최근 5경기 + [FormStrip]
├─ [SectionHead] 함께 친 경기 (+ count while it hides matches)
├─ per day: [DayHeader] + [ResultRow]×(3 matches at most until opened)
└─ ▸ more than 3 → [ExpandToggle] N개 더 보기
▸ no match with the person in the chosen format → format head + [EmptyState] (함께 친 적이 없어요)States.
Offline (F1c, the app's root strip says it): offline with cached reads the page stays (rv-stale); nothing on it writes.
Loading: skeletons per zone. Error:
QueryErrorStatewith retry. Offline cold:OfflineEmptyStateviaQueryBoundary.First run (no match): the empty state with what builds the record.
단식 drops the partner record and the 최고의 파트너 callout; 복식 keeps doubles and mixed doubles.
Callouts are drawn only when they exist; a person claims one callout; 천적 needs five decided matches and a losing record.
Draws are out of every rate and out of the form strip; they count toward the matches played.
?with=never met in the chosen format: the empty state under the format head.
Actions.
- Person row →
routes.userProfile(personId). Result row →routes.clubSession(clubId, sessionId)(the one session-detail URL). - Format Choice → Sheet 종목; N개 더 보기 expands in place (and folds).
Gaps. A cached money, attendance or score list does not say when it was read: the kit has no as-of slot on a list page (metaWithAsOf exists only inside a Block's offline state), so it needs a drawn piece (docs/design/flat-v2-open-issues.md).
- The error and offline frames use
QueryBoundary's generic copy; the board'swhat=전적wording is not passed. - Row and Strip layout at 200% text is held by the kit pieces' own tests, not by this screen's test.
- Text on the board reads
N명 더 보기andN경기 더 보기; the page readscommon.moreCount(N개 더 보기) to satisfy the house-style gate.
Record Day Detail (하루 기록)
- Route:
/records/day - Screen:
RecordDayDetailScreen—packages/features/records/src/record-detail/record-day-detail-screen.tsx - Shell: DetailShell · title
t().recordDay.title(하루 기록) · canvas · pull to refresh - Design: unreviewed
- Verified: 2026-11-13
- Batch 58 (ST2, 2026-11-13), States: Offline, error and loading from the one PageStateView.
- Notes: 2026-11-10 (W4 batch 21a): rebuilt to the
#dyboard. The viewer's own matches of the date throughusePlayerResults(one day); the page title is 하루 기록 (was 일일 요약) and the last rating reads 이날 마지막 ELO. The doubles-pool rule for the last rating (BE-1) moved tolastRatingOfover a person's matches.
Purpose. One day of the viewer's own matches as a summary page: the day, its record, win rate, rating change and last rating as tiles, then each match. Own only: no person variant.
Anatomy.
├─ [DetailShell] title=하루 기록
└─ body (invalid date → offline → error → loading → quiet day → day)
├─ ▸ invalid date: [EmptyState notfound action=기간 기록] 열 수 없는 날짜예요
├─ ▸ quiet day: [EmptyState first glyph=calendar action=기간 기록] 이날은 경기가 없어요
└─ ▸ day:
├─ [SectionHead first] "9월 18일 (금)"
├─ [Strip tiles] 전적 across the width · 8 · [Strip tiles] 승률 · ELO 변화 · 이날 마지막 ELO
└─ [SectionHead] 경기 기록 · [ResultRow] × n, newest first (no day header: one day)States.
- Loading: the head, both Strips as bars, 경기 기록 and three person bars (
dy-loading). Cold offline:EmptyState kind=offline; error:EmptyState kind=error what=기록(dy-offline,dy-error). - An invalid or missing
dateis answered by the screen (dy-invalid), reading nothing. - The URL's record scope (format, nature, membership) still filters the day, silently; the tiles state the filtered day.
- A figure with nothing to state (no decided match, no rating) reads –.
- 2026-11-13 (UX-7, the state helper): Offline, error and loading from the one
PageStateView.
Actions.
- Back →
appRouter.back(). 기간 기록 (quiet or invalid day) →routes.record('week', range of the week holding the day, scope), this week for no valid day. - A result row →
routes.clubSession('', sessionId). - Pull to refresh refetches the day and the profile.
Gaps.
- A day opened from a filtered period shows the scoped record, which may differ from the unfiltered day; the page states no scope.
- The 200% text frame (
dy-200) is the kit's Strip reflow, tested in the kit; the page test checks only that the record keeps a Strip of its own.
Records Leaderboard (순위표)
- Route:
/records/leaderboard - Screen:
RecordsLeaderboardScreen—packages/features/records/src/records-leaderboard-screen.tsx(reads, rules and parts inpackages/features/records/src/leaderboard/) - Shell: DetailShell · title
t().common.leaderboard("순위표") · scroll=false ·actions= [순위 공유] only in 클럽 with ranked rows - Design: unreviewed
- Verified: 2026-11-13
- Batch 58 (ST2, 2026-11-13), States: Offline, error and loading from the one PageStateView.
Purpose. Where the viewer stands: a Podium for the top three, then ranked rows by club, friends, region or nation, over all time, this week or this month; the scope is the Feed head's one Choice, never Tabs; built W4 batch 7 to flat-v2-list-b#leaderboard (L3-17).
Now. One screen state { scope: 'club' | 'friends' | 'region' | 'national', period: 'all' | 'week' | 'month' } (opens on 클럽 and 전체, no route params); useLeaderboard(view, since) reads only the active scope and returns the page model.
- Period: 이번 주 starts Monday 00:00 and 이번 달 the 1st 00:00 on the device's calendar (
periodStart(period, now), the screen's one clock read), passed to the reads as an ISO timestamp; 전체 passes null. A period keeps the players with a rated doubles match since its start, ordered by current rating; ties share a rank (1, 2, 2, 4); the client never sorts or re-ranks. - 클럽: the primary club's table (the profile's primary club, else the first) from
useClubRatingRanks(clubId, 'doubles', since)withuseClubProfiles; ranked members only, in the server's order, at most 50. 친구:useFriendRatingRanks(since)(you and your accepted friends) withuseProfilesByIds. 지역:useRatingLeaderboard(region, since)(top 50 of the viewer's 시/도,profile.region) anduseMyRatingRank. 전국: the same two reads with no region. - Rows:
useRankingRowFacts(ids)once for the people shown and the viewer: the all-time record (where the person's results setting lets the viewer read it) and the 7-day change of the doubles rating. 클럽 and 친구 print the change (▲/▼, none at 0); 지역 and 전국 name the person's primary club (useClubsByIds, where the viewer may read it) in its place. - The rank column is as wide as the widest rank on the page (default, 32, 40); the level Tag shows only where a level run starts (the NTRP numeral of the sub-tier) and always on the Podium; the viewer reads "(나)" after the name and the spoken name starts with the rank ("4위, 정우진, ELO 1,688, NTRP 3.5, 18승 12패, 7일간 40점 상승").
- 지역 draws 내 순위 N위 · 상위 N% (rank ÷ ranked players, rounded up) · 전체 인원 N명 above the head; a viewer with no rank in the pool gets 전체 인원 alone (Strip of one). A viewer below the 50th place gets a 내 순위 section with their row; the end line 순위는 상위 50명까지 보여요 shows only when the scope holds more than 50 ranked players.
- Share:
useShareLeaderboardwith the club's top five. Tier: the Info hint (?) opensTierInfoSheetmarked at the viewer's rating. Pull to refresh refetches the active scope's reads.
Anatomy.
├─ [DetailShell] title=순위표 onBack scroll=false actions ▸ 클럽 & ready → [순위 공유]
│ └─ [FeedList] perfLabel=leaderboard itemSeparator=null refreshing/onRefresh
│ ├─ header ▸ 지역 & ready & ranked → [Strip face=tiles lead] 내 순위 · 상위 · 전체 인원
│ │ ▸ else → 12 spacer
│ ├─ head (pinned under the Header; stuck → the one hairline)
│ │ └─ [SectionHead first={no tiles} stuck title=scopeTitle info=티어 choice=[Choice chevron selected={period set} label=scope]]
│ ├─ ▸ ready: items
│ │ ├─ [PodiumBlock] [Podium entries=1..3 → 공개 프로필, prefetch on press-in] + 16
│ │ └─ [PersonRow context=ranking rankWidth figureWidth=44]×N (rank 4 to 50)
│ ├─ ▸ ready: footer
│ │ ├─ ▸ viewer below the cut → [SectionHead 내 순위] [PersonRow]
│ │ ├─ ▸ more than 50 ranked → end line [Text caption]
│ │ └─ [FeedEnd]
│ └─ ▸ not ready: emptyState → [LeaderboardStateView] (below)
├─ [LeaderboardScopeSheet] ValueSheet 순위 범위: group 대상 (클럽 · 친구 · 지역 · 전국), group 기간 (전체 · 이번 주 · 이번 달); each group its own check; a tap applies and closes
└─ [TierInfoSheet] open=tier currentElo=viewer ratingStates.
Offline (F1c, the app's root strip says it): offline with cached reads the page stays (lb-stale); nothing on it writes.
Loading: the real head,
Podium state=loadingand five person skeletons. Error:EmptyState kind=error("순위를 불러오지 못했어요") with retry. Offline cold:EmptyState kind=offlinewith retry (offline with data: the rootNetworkBanneris the strip). The head and its Choice stay in every state.No club (클럽): "가입한 클럽이 없어요" + 클럽 찾기 →
routes.discoverClubs. No region (지역): "지역을 설정해 주세요" + 지역 설정 →routes.editProfile. No friends (친구, the read returns only you): "아직 친구가 없어요" + 친구 찾기 →routes.profileFriendsAdd. Nobody ranked over all time: "아직 순위가 없어요", no button, no share.A period that empties the scope: "{이번 주|이번 달} 경기한 {멤버|친구|선수}가 없어요" + 전체 기간 보기 (sets 전체); the Choice is ink while a period is set.
Fewer than three ranked: the Podium seats one or two. A person whose profile was not returned is left out; the scope's ranked count is the server's.
200% text: rows stack and Podium names wrap (kit behaviour).
2026-11-13 (UX-7, the state helper): Offline, error and loading from the one
PageStateView.
Actions.
- Choice → the scope Sheet (local state). Info hint (티어) →
TierInfoSheet. 순위 공유 → the system share sheet (text, top five of the club). - A Podium seat or a row →
routes.profileDetail(userId)(the row through the appPersonRow, prefetching on press-in). - 클럽 찾기 →
routes.discoverClubs; 지역 설정 →routes.editProfile; 친구 찾기 →routes.profileFriendsAdd; 전체 기간 보기 → period back to 전체. Pull to refresh → the active scope's reads.
Gaps. A cached money, attendance or score list does not say when it was read: the kit has no as-of slot on a list page (metaWithAsOf exists only inside a Block's offline state), so it needs a drawn piece (docs/design/flat-v2-open-issues.md).
- The spoken label and line 2 carry the club by
useClubsByIds, which has no loading or error state: a club the viewer may not read is simply not named. - A friend's or club member's profile read for names runs after the rank read for 친구 (ids come from the ranks): one extra round trip there.
- The period start is read once per period change (
new Date()in the screen); a page left open across midnight Monday keeps the week it opened in until the period changes. - English words follow the shared keys where the board draws its own:
common.all"All" (board "All time"),dues.totalMembers"Total" ("Players"),sessionDetail.factAudienceLabel"Who" ("Scope"). The scope readscommon.club"Club" since the tab took its owncommon.clubsTab"Clubs"; the titlecommon.leaderboardstays "Leaderboard" in English (the house rule: no "Ranking(s)" in English copy, which also names the Sheet "Leaderboard range").