Skip to content

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; renders PublicMatchSharePreview in-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) → OfflineEmptyState with retry, via QueryBoundary — never the empty state (2026-09-11 offline sweep).

Actions.

  • back (DetailShell onBack, public-preview branch only)

Gaps. none found.


Match Board

  • Route: /match-board — opened with a ?sessionId= query param
  • Screen: MatchBoardScreenpackages/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 (Well empty notice, no RoundActionsRow)
  • 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: screenReady settled 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: TournamentBoardScreenpackages/features/sessions/src/tournament-board-screen.tsx
  • Shell: DetailShell · title 토너먼트 (t().common.natureTournament) · subtitle: live (in_progress → 이어보기-style continueChipLabel; else formatLabel alone) · titleLeading: PulseDotin_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 pass titleLines={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 state is not one of the engine shapes (a seeded {}, a half-written create) renders as "no rounds yet" — Tournament.state is TournamentState | null at the read model, and the mapper (tournament.supabase.ts) validates the persisted JSONB via isTournamentStateShape at the boundary, nulling anything unrecognized (bug-class fix, 2026-09-16, superseding the QA 2026-09-15 screen-level guard). The screen just reads tournament?.state ?? null; it never throws.

  • loading (QueryBoundary)

  • error (genuine, via QueryBoundary's own error leg) → QueryErrorState + retry

  • missing record: settled but no tournament row (bad tournamentId) → NotFoundState (2026-09-16 bug-class fix — this used to fall through to the generic error/retry EmptyState, 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.

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