Screen Blueprints — Matches
Status: Active Last reviewed: 2026-09-11
Current-state blueprints for the matches 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.
Match Result Share Preview
- Route:
/m/[matchId] - Screen:
MatchResultRoute(default export) —apps/mobile/app/m/[matchId].tsx; rendersPublicMatchSharePreviewin-file - Shell: DetailShell · title = session title (dynamic) · headerRight: none
- Design: unreviewed
- Verified: 2026-09-13
Purpose. Universal-link receiver for match-result share links (twomore://m/<matchId>, https://twomore.app/m/<matchId>) — resolves the match's session/club and redirects an authorized viewer to the canonical club session detail route (where the match board shows the relevant match), or renders a public preview otherwise.
Anatomy.
├─ loading → [YStack] padded + [SkeletonHero]
├─ public preview available → [PublicMatchSharePreview]
│ └─ [DetailShell] title=preview.sessionTitle subtitle=preview.clubName
│ └─ [FlatColumn] > [FactList] single row: match score (`team1Score-team2Score`)
├─ match not found → centered plain text (`t().share.matchNotFound`)
├─ session not found → centered plain text (`t().share.sessionNotFound`)
└─ session accessible → silent `Redirect` → routes.clubSession(clubId, sessionId)States.
- loading (match query, then conditional session query)
- public share preview (unauthenticated or no-session-access viewer)
- match not found / session not found
- redirect (viewer with session access) — this route never renders full session content in that case
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions.
- back (
DetailShellonBack, public-preview branch only)
Gaps. none found.
Match Board
- Route:
/match-board— opened with a ?sessionId= query param - Screen:
MatchBoardScreen—packages/features/sessions/src/match-board-screen.tsx - Shell: DetailShell · title
sessionTitle(session)(dynamic) · headerRight: none - Design: ratified
- Verified: 2026-09-17
Purpose. Full session controller for match play: round generation, live round editing/team rearrangement, score correction on completed matches, and session lifecycle actions (start/end).
Anatomy.
├─ [SessionLiveAnchor] page-level "진행 중" band ▸ isLive — first child, above TypeHero (this
│ screen's first-ever live signal); decided 2026-09-17
├─ [TypeHero] title = venue name ▸ session has one, else the generated session title
│ facts=[📅 date → 👥 confirmed player count → 🎾 format (only when the title didn't already
│ state it) → 🏷 court count] leading=[CapacityRing] completed/total ▸ hasMatches
├─ [FlatColumn]
│ ├─ [UnsettledPoolNotice] Callout tone="warning" ▸ unsettledPlayerIds.length > 0 —
│ │ settle-before-play: confirmed participants excluded from matches pending payment
│ ├─ [RoundActionsRow] ▸ hasMatches — round tabs + 라운드 추가 (canAddRound) + 팀 재배치 (canEditLiveRound)
│ ├─ [MatchListSection] active round's [MatchCard]s (sanctioned figure) or a [Well] empty/
│ │ no-round-matches notice; score correction available on completed matches
│ └─ [AllDoneBanner] Well tone="primary" ▸ allDone && isLive && canManageLifecycle — "경기 종료" CTA
└─ footer (outside scroll body):
├─ [BottomCtaBand] [StartSessionButton] "경기 시작" ▸ isOpen && canManageLifecycle — disabled +
│ a caption reason (`matchBoard.startNeedsDraw`) until `canStartMatches` (match-lifecycle.
│ rules.ts) is true: a startable session status AND a `scheduled` match already exists
│ (usability audit 2026-09-16 U-16b — was gated on session status alone, so a host could
│ start with no draw generated)
└─ [MatchBoardOverlays] add-round sheet / live team-edit sheet / tier-promotion sheet (portaled)States.
- error →
EmptyState variant="full"+ retry - offline →
OfflineEmptyState - loading →
SkeletonFlatPage kind="type" - no matches yet (
Wellempty notice, noRoundActionsRow) - matches exist, live round in progress
- all rounds done, host can end session (
AllDoneBanner) - session not yet started, host can start (pinned CTA)
- missing record:
screenReadysettled but no session row (bad/deleted sessionId) →NotFoundState(2026-09-16 bug-class fix — this used to silently render an empty, generically-titled board shell instead)
Actions.
- 경기 시작 (host/lifecycle-manager only; disabled until a confirmed draw exists —
canStartMatches) - 라운드 추가 (opens generation sheet)
- 팀 재배치 (live round edit sheet)
- round-tab selection (switch active round)
- 경기 종료
- score correction on completed matches (via
MatchListSection)
Gaps. none found — the file's own header comment documents a 2026-09-08 owner ruling superseding an earlier (2026-09-05) hero anatomy note, and the current TypeHero/GlyphFactRow shape confirmed above matches the live code.
Tournament Board
- Route:
/tournament-board - Screen:
TournamentBoardScreen—packages/features/sessions/src/tournament-board-screen.tsx - Shell: DetailShell · title
토너먼트(t().common.natureTournament) · subtitle: live (in_progress→ 이어보기-stylecontinueChipLabel; elseformatLabelalone) · titleLeading:PulseDot▸in_progress· headerRight: status text (Text role="cardMeta") ▸ status !==in_progress(owner 2026-09-14: status is a trailing header caption, never glued onto the subtitle) - Design: unreviewed
- Verified: 2026-09-17
- **Notes:**2026-09-17: person/club name
ListRows passtitleLines={NAME_LINES}(U-13, two-line proper names)
Purpose. Minimal, inspectable tournament surface reached from Session Detail's 경기 방식 선택 (토너먼트 만들기/이어보기) — standings, the current round's matches, and host-only winner-pick advancement. Deliberately not pixel-final score entry.
Anatomy.
├─ [QueryBoundary] tournament + profiles → loading=[SkeletonHero]+[SkeletonCard]×2
└─ [FlatColumn]
├─ [Well] "완료" ▸ isCompleted
├─ [SectionBlock 순위] [RowList] of [ListRow] rank/name/wins-draws-losses (+ Buchholz ▸ Swiss family)
│ or "TBD" text ▸ standings.length === 0
├─ [SectionBlock 현재 라운드 N] ▸ currentRoundMatches.length > 0
│ └─ [RowList dividers] of [TournamentMatchRow] × court: [MatchScoreboard] + host winner-pick [ActionButton] pair
│ (+ a third 무승부 pick ▸ supportsDraw: format is swiss or round_robin only)
└─ [SectionBlock 위험 구역 tone="danger"] ▸ isHost && !isCompleted
└─ [ActionButton destructive] 토너먼트 취소 (hard delete, confirm-gated)
└─ footer: [BottomCtaBand] [ActionButton] "다음 라운드" ▸ isHost && !isCompleted && currentRoundMatches.length > 0
(disabled until every pending match in the round has a pick — `allWinnersPicked`)States.
A tournament row whose
stateis not one of the engine shapes (a seeded{}, a half-written create) renders as "no rounds yet" —Tournament.stateisTournamentState | nullat the read model, and the mapper (tournament.supabase.ts) validates the persisted JSONB viaisTournamentStateShapeat the boundary, nulling anything unrecognized (bug-class fix, 2026-09-16, superseding the QA 2026-09-15 screen-level guard). The screen just readstournament?.state ?? null; it never throws.loading (
QueryBoundary)error (genuine, via
QueryBoundary's own error leg) →QueryErrorState+ retrymissing record: settled but no tournament row (bad tournamentId) →
NotFoundState(2026-09-16 bug-class fix — this used to fall through to the generic error/retryEmptyState, which offered a retry for something retrying can't fix)completed (standings/history read-only, no advance CTA, no danger zone)
live round with pending picks vs all picked (advance CTA enabled)
Actions.
- pick a winner (or 무승부, Swiss/round-robin only) per match
- 다음 라운드 (advance, confirm-gated — irreversible once the next round generates)
- 토너먼트 취소 (host-only recovery escape hatch, destructive confirm,
appRouter.back()on success)
Gaps. none found — this route has no prior blueprint entry (only a passing mention of its header treatment in the old docs/architecture/screen-blueprint.md, line ~811); this is a first full write-up from code.