Skip to content

Screen Blueprints — Sessions

Status: Active Last reviewed: 2026-09-11

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


Activity (경기)

  • Route: /(tabs)/(sessions)
  • Screen: ActivityScreenpackages/features/activity/src/activity-screen.tsx
  • Shell: MainTabShell · title 경기 (t().common.games) · headerRight: none
  • Design: ratified
  • Verified: 2026-09-16

Purpose. The 경기 tab hub: the viewer's own RSVP'd sessions, open pickup games to browse, bookmarked sessions, and a court directory, as four shell-owned sub-tabs.

Anatomy.

├─ [MainTabShell] title=common.games tabs=[내 경기, 번개 찾기, 저장됨, 테니스장 찾기]
│  (`ActiveTab`; `?tab=` route param opens a pane directly, e.g. routes.activityPickups)
├─ 내 경기 (MyMatchesTab) → [FeedList] useMyParticipatingSessions
│  ├─ header → [ActivityListControls] format/date filters + [SectionHeader title=activityScreen.sectionMyMatches (다가오는 일정 — what the pane holds, never the tab label again) variant="list"]
│  └─ item → [SessionCardV2] × N (useSessionCardModels) → routes.clubSession
├─ 번개 찾기 (PickupFinderTab) → [FeedList] infinite, useOpenPickupsInfinite
│  ├─ header → [ActivityListControls] filters + GPS-defaulted region (dismissible hint) + [SectionHeader title=common.membershipPickup variant="list"]
│  ├─ excludes sessions the viewer already RSVP'd (useMyRsvps)
│  └─ item → [SessionCardV2] × N → routes.sessionPreview
├─ 저장됨 (SavedSessionsTab) → [FeedList] useSavedSessionIds → useSessionsByIds
│  ├─ header → [SectionHeader title=activityScreen.sectionSaved variant="list"]
│  └─ item → [SessionCardV2] × N → routes.clubSession
└─ 테니스장 찾기 (courts) → [VenueDirectoryContent] — same shared body as the `/courts` route
   (one implementation, two mount points; see the Court Directory entry below)

Bottom CTA: [BottomCtaBand][Button lg primary] "번개 만들기" → routes.createSession('pickup'), rendered ONLY while activeTab === 'pickup_finder'.

States.

  • loading: SkeletonCard/SkeletonRow stack per tab
  • empty: genuinely empty vs filtered-to-empty (filtered adds a "필터 초기화" action); 저장됨 has no filter-aware branch
  • error: QueryErrorState with retry
  • offline: OfflineEmptyState
  • populated list per tab, usePullToRefresh

Actions.

  • switch tab (내 경기 / 번개 찾기 / 저장됨 / 테니스장 찾기)
  • tap a SessionCardV2 → session detail (내 경기/저장됨) or pickup preview (번개 찾기)
  • 번개 만들기 (band CTA, pickup_finder only) → create-session wizard
  • 필터 초기화 (filtered-empty action)
  • dismiss the GPS-defaulted region hint (번개 찾기)

Gaps. none found.


Court Directory (테니스장)

  • Route: /courts
  • Screen: CourtsDirectoryScreenpackages/features/sessions/src/courts-directory-screen.tsx
  • Shell: DetailShell · title 테니스장 (t().courts.title) · headerRight: none
  • Design: ratified
  • Verified: 2026-09-17

Purpose. Nationwide tennis-venue directory search/browse with region/facility/indoor filters; the same body also mounts inside the 경기 tab's 테니스장 찾기 segment.

Anatomy.

├─ [DetailShell] scroll={false}
└─ [VenueDirectoryContent] (thin wrapper) → [VenueFinder intent="explore"]
   ├─ [VenueFinderHeader] search bar + region/district cascade + capability/indoor filters
   │  + 전체/코트/연습장/샵/즐겨찾기 nav-chip row (accessibilityRole="tab")
   ├─ query too short → [EmptyState] compact
   ├─ search mode → [VenueFinderSearchResults] dual-source: spine-curated groups + Naver tail
   │  (naverTailEnabled) rows, each → [DirectoryVenueCard] → routes.directoryVenue
   └─ browse mode → [VenueFinderBrowseList] rows grouped into 시/도 [SectionHeader] sections
      (canonical province order, whole filtered result set) → [DirectoryVenueCard]
      (static-map thumbnail, [VenueTrustBadge] only for an owner-certified venue, [VenueSaveButton], distance/kind/district/fact
      glyphs) → routes.directoryVenue

States.

  • browse mode (province-grouped) vs search mode (flattened, paginated)
  • empty — one inclusive result set (no trust filter, no 미확인 badge — canon/venue-data.md public-venue identity, identical in explore and pick intent), so the empty state has one cause: no venue matches the location/facility filters
  • releaseBrowseBody: after 2+ minutes hidden (useHiddenPaneRelease), the browse list's mounted rows release to a bare placeholder, remounting from cache on return

Actions.

  • search, filter by region/district/capability/indoor
  • switch nav chip (전체/코트/연습장/샵/즐겨찾기)
  • tap a card → Venue Directory Detail (routes.directoryVenue)
  • save/unsave a venue

Gaps. none found.

Notes. 2026-09-17: the 미확인-포함 toggle, its trust-gated empty state and the selectVenueDirectoryEmptyCause utility are gone (VENUE-RESIDUALS); the 경기 tab's 테니스장 찾기 pane is the in-app entry, /courts the web/deep-link address of the same body.


Create Session Wizard

  • Route: /create-session
  • Screen: CreateSessionScreenpackages/features/sessions/src/create-session-screen.tsx (re-export shim) → packages/features/sessions/src/create-session/create-session-screen.tsx
  • Shell: WizardShell · title: per-step (dynamic, e.g. 언제 만나나요?) · headerRight: none (progress dots + close X own the header, per wizard-nav law)
  • Design: unreviewed
  • Verified: 2026-09-17

Purpose. 7-step wizard for scheduling a club session or hosting a pickup (번개) game — collects when/where/format/headcount/options/match-rules, then reviews and submits.

Anatomy.

├─ [WizardShell] stepIndex=nav.step totalSteps=7 onClose=nav.handleDismiss onBack=nav.armedOnBack
│  primaryLabel per step (nextLabel/createButton/backToReview) secondaryLabel="건너뛰기" (step 4 only)
├─ [StepRenderer] switch on nav.step, delegating to per-step files:
│  0 언제 만나나요? → Step1 (create-session/step1/…, + 정기 일정/지난 일정 prefill sections)
│  1 어디서 하나요? → Step2 (create-session/step2/…, venue search/pick + 사용 코트 수)
│  2 어떻게 치나요? → Step3 (구성 → 형식 → 실력(TierRangeSelector, plain 구력 line + credential disclosure) → 성격, converges onto create-club-step4.tsx)
│  3 몇 명이서 뛰나요? → StepPlayers (경기 수 → 경기 시간 → suggested 모집/최소 인원)
│  4 추가 옵션 → Step4 (참가비 + RSVP 마감 + 반복, skippable via 건너뛰기)
│  5 경기 규칙 → Step5 — summary card IS the edit affordance → MatchRulesEditorSheet
│  6 확인해 주세요 → Step6 review — [Card] of [WizardReviewRow]s, then a [Callout tone="info"]
│    restating the locked-fields note (same sentence as Session Edit's field map) + the
│    auto-cancel threshold (same sentence as StepPlayers' 최소 인원 caption, shown only when
│    draft.minPlayers is set) — usability audit 2026-09-16 U-06/U-20 — ✦ "일정 만들기"
│    (edit-from-review jumps back into any step's scope)

States.

  • fresh draft (no resume) vs resumed draft (mount-time silent restore, per-step validity gates)
  • edit-from-review detour armed (jumped back from step 7)
  • field-edit scope open (per-step contextual edit buffer — gates skip/back, no 건너뛰기 while open)
  • submitting (primaryLoading on the last step)
  • destructive-exit confirm (step 1 close/back)

Actions.

  • primary CTA per step → advance (nav.handleNext)
  • 건너뛰기 (step 4 only, hidden while a field-edit scope is open)
  • 뒤로가기 to review (armed edit-from-review return)
  • 생성하기 (final submit, step 6) → useHostPickupGame (clubId === 'pickup') or useCreateSession
  • tap 경기 규칙 summary card → MatchRulesEditorSheet (저장 commits without advancing)
  • step-pill tap → jump to step (forward jumps capped at firstFailingGatedStep)
  • header X / hardware back on step 1 → destructive-exit confirm dialog

Gaps. none found.


Session Invite Share Preview

  • Route: /s/[sessionId]
  • Screen: SessionInviteRoute (default export) — apps/mobile/app/s/[sessionId].tsx; renders PublicSessionSharePreview in-file
  • Shell: DetailShell · title = session/preview title (dynamic) · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-13

Purpose. Universal-link receiver for session invites (twomore://s/<id>, https://twomore.app/s/<id>) — redirects a member/authed viewer straight to club session detail, or renders a curated read-only preview for a non-member/unauthenticated visitor.

Anatomy.

├─ loading → bare [YStack] + [SkeletonHero]
├─ public preview available → [PublicSessionSharePreview]
│  └─ [DetailShell] title=preview.title subtitle=preview.clubName
│     └─ [FlatColumn] > [FactList] 시간 (formatTimeLine) · 장소 ▸ preview.location present
├─ not found (neither session nor preview resolves) → centered plain text (`t().share.sessionNotFound`)
└─ authenticated/member → silent `Redirect` → routes.clubSession(clubId, sessionId)

States.

  • loading (session query + conditional public-preview query both in flight)
  • public preview (unauthenticated or non-member, session exists)
  • not found
  • authenticated/member redirect (no visible state)
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via QueryBoundary — never the empty state (2026-09-11 offline sweep).

Actions.

  • back (DetailShell header, public-preview branch only)
  • no other interactive affordances — read-only redirect/preview page

Gaps. none found.


Session Attendance (출석 확인)

  • Route: /sessions/[sessionId]/attendance
  • Screen: SessionAttendanceScreenpackages/features/sessions/src/session-attendance-screen.tsx
  • Shell: DetailShell · title 출석 확인 · subtitle 참석한 멤버를 확인하고 불참 시에는 X 버튼을 눌러 주세요 · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-16

Purpose. 총무-only roster for correcting session attendance — completion auto-records every confirmed RSVP from match data first, so this screen is mainly for reviewing/fixing exceptions.

Anatomy.

├─ [DetailShell scroll={false}]
├─ [FeedList] header=[Well] summary: attendedCount/participants.length + [ProgressBar] +
│  no-show exception count + "이미 기록됨" hint text (attendanceAutoRecordedHint)
├─ body: [ParticipantRow variant="attendance"] × N — flat row (no Card, U-29), ✓ 참석 / ✗ 불참 per row, per-row pending
└─ [BottomCtaBand] single ActionButton — disabled + unresolved-count label while unresolvedCount > 0,
   else 닫기/closeout CTA (appRouter.back())

States.

  • error → EmptyState + retry
  • loading → SkeletonFlatPage kind="type"
  • permission-gated: non-canManageClubShieldAlert EmptyState
  • empty roster (no confirmed RSVPs) → EmptyState
  • populated, unresolved marks present (CTA disabled) vs all resolved (CTA enabled)
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).
  • missing record: settled but no session row (bad/deleted sessionId) → NotFoundState (2026-09-16 bug-class fix — !ready || !session used to pin the loading skeleton forever instead)

Actions.

  • mark a participant 참석 or 불참
  • pull to refresh
  • closeout CTA (appRouter.back(), disabled until every confirmed participant is resolved)

Gaps. none found.


Session Edit (일정 수정)

  • Route: /sessions/[sessionId]/edit
  • Screen: EditSessionScreenpackages/features/sessions/src/edit-session-screen.tsx
  • Shell: DetailShell · title 일정 수정 (strings.editDetails) · headerRight: none
  • Design: ratified
  • Verified: 2026-09-17

Purpose. Config-only edit form for a session's date/time/location/max-players/description, reached from Session Detail's 호스트 도구 (일정 수정) — reachable only while the session is open. U-06 (2026-09-17): 참가비 joined the editable set too, but CONDITIONALLY — open until the session's first session_payments row, then locked (mirrors the DB trigger trg_block_fee_edit_with_holds, 00404/00649).

Anatomy.

├─ [FlatColumn]
│  └─ [SectionBlock variant="flat" title=설정]
│     └─ [ConfigForm] — per-field [EditFieldRow]s (own dividers, no Card), tap opens a focused
│        editor modal: [TimeEditModal] · [MaxPlayersEditModal] · [CourtsEditModal] ·
│        [CompositionEditModal] · [TierEditModal] (TierRangeSelector, plain 구력 line + credential
        disclosure — same pattern as club-settings' InfoRow) · [FeeEditModal] (WonAmountField,
        U-06) — its [EditFieldRow] renders `disabled` + a locked caption once `useSessionPayments`
        shows any row (or is still loading)
└─ [BottomCtaBand] "저장" ("저장 중" while pending) — errorText/onSave reported up via
   onFooterStateChange (ref-trampolined bridge, not rendered inline by ConfigForm)

States.

  • error → EmptyState + retry
  • loading → SkeletonHero
  • locked: session.status !== 'open'EmptyState "수정할 수 없어요" + 뒤로가기, no form rendered
  • editable form
  • saving (footer disabled, "저장 중" label, ConfigForm-reported error text)
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).
  • missing record: settled but no session row (bad/deleted sessionId) → NotFoundState (2026-09-16 bug-class fix — isLoading || !session used to pin the loading skeleton forever instead)

Actions.

  • tap a field row → opens that field's edit modal (참가비's row is a no-op tap once locked — EditFieldRow's own disabled, U-06)
  • 저장 (commits the whole payload via the reported onSave; participationFee is included only when that row was actually touched this visit)
  • back

Gaps. none found. (U-06 note: editLockedFieldsNote/the fee policy is shared verbatim with the create-session wizard's own review step — sessionDetail.editLockedFieldsNote is the same key create-session-step6.tsx reads, so both surfaces state the identical rule.)


Pickup Host Dashboard (호스트 관리)

  • Route: /sessions/[sessionId]/host
  • Screen: PickupHostDashboardScreenpackages/features/sessions/src/pickup-host-dashboard-screen.tsx
  • Shell: DetailShell · title 호스트 관리 · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-17

Purpose. Full-screen management surface for a pickup game's host: decide pending guest applications, plus a danger zone to cancel the whole pickup. Only the session's creator (created_by) may view it.

Anatomy.

├─ [FlatColumn]
│  ├─ [SectionBlock variant="flat" title="신청 대기 (N)"] right=[ActionButton "전체 승인"] ▸ 2+ pending
│  │  ├─ loading → [SkeletonHero]
│  │  ├─ empty → [Well] > [EmptyState variant="compact"]
│  │  └─ populated → [RowList dividers] of [ApplicationRow] (avatar, name, relative time,
│  │     거절/승인 buttons, each confirm-gated)
│  └─ [SectionBlock variant="flat" tone="danger" title="번개 취소하기"]
│     └─ [ActionButton destructive] "번개 취소하기" → appRouter.replace(routes.clubSession)

States.

  • error → EmptyState + retry
  • loading → SkeletonHero
  • missing record: settled but no session row → NotFoundState (2026-09-16: converted from a bespoke Text+ActionButton to the shared component)
  • permission-denied (session.createdBy !== userId) → ShieldAlert EmptyState
  • no pending applications vs pending-applications list
  • 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.

  • 승인 / 거절 per applicant (confirm dialog first)
  • 전체 승인 (bulk-approve, visible at 2+ pending, confirm dialog first, sequential mutations)
  • 번개 취소하기 (destructive, confirm dialog)

Gaps. none found.

Notes. 2026-09-17: application-row hairline is the shared Divider (PD-05 sweep) — pixel-identical.


Session Detail

  • Route: /sessions/[sessionId]
  • Screen: SessionDetailScreenpackages/features/sessions/src/session-detail-screen.tsx
  • Shell: DetailShell · title sessionTitle(session) (dynamic) · header="collapsing" ▸ a venue/location hero exists, else "default" · headerRight: SessionSaveButton (bookmark toggle) + HeaderActionsMenu (share / group-chat, each conditional)
  • Design: ratified
  • Verified: 2026-09-17

Purpose. The canonical hub for a single session: logistics, participation, matches, and every host tool, with one state-precedence-aware pinned CTA covering the whole lifecycle (RSVP through settlement). This full order is ratified law (2026-09-17, decision log SD-IA, docs/canon/sessions.md); MatchesSection is now gated at the call site (showMatches), matching every sibling section (drift fix — it was the one section still self-gating alone).

Anatomy.

├─ [SessionLiveAnchor] page-level "진행 중" band ▸ isLive (session.status === 'in_progress') — the
│  absolute first flow child, decided 2026-09-17
├─ terminal-state [Banner tone="neutral"] ▸ sessionStrip.band === 'ended' (e.g. cancelled)
├─ [SessionHeroSection] full-bleed [MediaHero] (venue static map) or [TypeHero] fallback,
│  carrying the strip's lead + standing chips in its badge row
├─ [FlatColumn] lead=[StatStrip] 확정 인원 · 내 참가비 · 참석 마감
│  ├─ [LiveParticipantBar] Banner tone="error" ▸ showLiveParticipantBar (in_progress)
│  ├─ [RefundBanners] ▸ showRefundBanners
│  ├─ [NoticeSection] Well tone="primary" ▸ showNotice (session.description)
│  ├─ [PaymentGateBanner] ▸ showPaymentGateBanner (송금 완료 attest + dev-only PortOne button)
│  ├─ [ParticipationSection] 참여: hero count + ProgressBar, expandable roster, 이어보기
│  │  tournament chip, "경기 방식 선택" → FormatChooserSheet
│  ├─ [MatchesSection] 경기 ▸ showMatches (not cancelled): viewer's match rows, 점수 수정 pencil
│  │  pill, "전체 대진표 보기" link
│  ├─ [InfoSection] 정보: FactList of session facts
│  ├─ [HostCalloutSection] host identity card / pending-application banner (always renders)
│  ├─ [HostToolsSection] 호스트 도구 ▸ showHostTools: 일정 수정 · 참가자 관리 · 경기 규칙 ·
│  │  호스트 관리 (badge=pending count) · 출석 확인 · 경기 종료
│  └─ [DangerZoneSection] 위험 구역 tone="danger" ▸ showDangerZone: 일정 취소 · 일정 삭제
└─ [BottomCtaBand] footer — one state-precedence CTA: refund ack (highest) > 초대하기 (open+host)
   > RSVP/cancel (open+participant) > 경기 시작 (locked+host) > cancel/waitlist (locked+participant)
   > 경기 보드 (in_progress+host) > 라이브 스코어보드 (in_progress+confirmed) > 정산
   (completed+host+paid) > 매너 태그 (completed+confirmed) > none

States.

  • error → EmptyState + retry; offline-paused → OfflineEmptyState; loading → SkeletonFlatPage kind="map"; missing record → NotFoundState (2026-09-16: session-detail-screen.tsx is the canonical shape every other by-id screen's not-found branch now matches)
  • open/locked/in_progress/completed × host/participant, each driving a different band CTA (23-branch primaryAction)
  • refund-pending overrides every other CTA state

Actions.

  • RSVP / cancel RSVP / join waitlist; 초대하기 (host, open); 경기 시작 (host, locked, confirm-gated)
  • 경기 방식 선택 → FormatChooserSheet (round or tournament)
  • 점수 수정 (completed match, canCorrect); "전체 대진표 보기" → Spectator Scorecard
  • GPS 체크인 / 경기에서 빠지기 or 기권하기 (live participant utilities, in_progress + confirmed)
  • 송금 완료 (manual attest) / 결제하기 (PortOne, dev-only)
  • 호스트 도구 rows; 일정 취소 / 일정 삭제 (destructive, gated); 정산; 매너 태그 (ModalPanel)
  • share / group-chat (find-or-create thread, migration 00309) from header overflow

Gaps. none found.


Match Rules Editor (경기 규칙)

  • Route: /sessions/[sessionId]/match-rules
  • Screen: MatchRulesScreenpackages/features/sessions/src/match-rules-screen.tsx
  • Shell: DetailShell · title 경기 규칙 (t().matchRules.title) · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-16

Purpose. Full-screen editor for a session's default match rules (admin-only) — uses the same shared MatchRulesEditor body as the per-match override sheet, so the field set never drifts between the two entry points.

Anatomy.

├─ [FlatColumn]
│  ├─ [MatchRulesEditor] value/onChange/validationError — no SectionBlock title (whole-page content)
│  └─ [Pressable] "기본값으로 초기화" → resets to KOREAN_DEFAULT_MATCH_RULES
└─ [BottomCtaBand] "저장" → MatchRulesSchema.parse → useUpdateSessionRules → appRouter.back()

States.

  • error → EmptyState + retry
  • loading → SkeletonFlatPage kind="type"
  • permission-gated: non-canManageClubShieldAlert EmptyState
  • editor (validation error text shown on a failed parse)
  • saving (footer disabled)
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).
  • missing record: settled but no session row (bad/deleted sessionId) → NotFoundState (2026-09-16 bug-class fix — isLoading || !session used to pin the loading skeleton forever instead)

Actions.

  • edit any match-rule field
  • 기본값으로 초기화 (reset to Korean default)
  • 저장 (validates, saves, pops back)

Gaps. none found.


Session Participants (참석자 관리)

  • Route: /sessions/[sessionId]/participants
  • Screen: ParticipantsScreenpackages/features/sessions/src/participants-screen.tsx
  • Shell: DetailShell · title 참석자 관리 · subtitle 확정, 예비, 취소 상태를 한눈에 확인해요 · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-16

Purpose. Full-screen participant management (replaces the former ParticipantsSheet): confirmed / waitlisted / cancelled rosters, with admin remove and host/club-manager waitlist-admit actions.

Anatomy.

├─ [DetailShell scroll={false}]
└─ [GroupedFeedList] sections: 참석 확정 (N) / 예비 (N) / 취소 (N)
   ├─ [ParticipantRow variant="rsvp"] × N per section — flat row (no Card, no per-row status
   │  badge — the section title already names it, U-29): name, waitlist position/gate badge
   │  (only shown where it varies), admit action ▸ canAdmit, [RemoveIconButton] (built-in confirm)
   └─ [ParticipantEmptyRow] (Well) — pseudo-item so a zero-count section still shows its header

States.

  • error → EmptyState + retry
  • loading → SkeletonFlatPage kind="type"
  • permission-gated: non-canManageScheduleShieldAlert EmptyState
  • populated (three grouped sections, any of which may render the zero-count Well placeholder)
  • 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.

  • 제거 (remove a participant) — RemoveIconButton's own built-in ConfirmSheet, not a screen-owned confirm.show() (usability audit 2026-09-16, U-29)
  • admit a waitlisted row (canAdmit = isHost || canManageClub — deliberately narrower than the screen's own canManageSchedule visibility gate, mirroring migration 00478's server RPC exactly)
  • pull to refresh

Gaps. none found.


Session Payments (결제 현황 / 비용 정산)

  • Route: /sessions/[sessionId]/payments
  • Screen: SessionPaymentsScreenpackages/features/sessions/src/session-payments-screen.tsx
  • Shell: DetailShell · title t().common.fee (참가비) — ONE title for both branches (U-14, 2026-09-17) · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-17

Purpose. Per-session cost-settlement management for 총무/대표 (or the session host): generate/track per-participant payments and confirm or waive them.

Anatomy. (U-14, 2026-09-17: unified the DetailShell title to 참가비 in both branches; the roster section below is retitled 입금 현황 in both branches too; the legacy cost-split section is retitled 정산 with a new rounding-explainer sentence; the pending-row CTA is relabeled 입금 확인, distinct from dues' 납부 확인.)

├─ [QueryBoundary] session/rsvps/payments/profiles → loading=[SkeletonHero]
├─ permission-gated → [EmptyState] ShieldAlert
├─ no payments + fee session → [EmptyState] (payments auto-generate on RSVP, host just waits)
├─ no payments + legacy/free session →
│  [FlatColumn] > [SectionBlock 정산] [FactList] 총 비용/확정 인원/인당 금액
│  + [Text] splitExplainer (rounding-unit sentence, via `t().common.won`) + [LabeledField] amount [StyledInput]
└─ payments exist →
   [FeedList] header=(fee session: [MetaSegments] paid-count + pending-count, no middle-dot glue;
   legacy/free: [Text] 납부 현황 + count) + [ProgressBar] + [SectionHeader] 입금 현황 (both branches)
   └─ [PaymentRow] × N — name, amount, status [Badge] (via sessionPaymentGateState/
      SessionPaymentStatusBadge, never inline color logic), 입금 확인/면제 buttons on pending rows
└─ [BottomCtaBand] footer computed ONCE outside the branch switch: 정산 내역 만들기 (generate) OR
   closeout CTA (disabled while any row pending) OR none

States.

  • loading (QueryBoundary skeleton)
  • permission-gated (!canManageDues && !isSessionHost)
  • no payments — fee session (auto-generated on RSVP) vs legacy/free session (editable amount + generate CTA)
  • payments exist (roster list, closeout CTA disabled while any pending)

Actions.

  • edit the per-person amount before generating (legacy/free only)
  • 정산 내역 만들기 (generate for all confirmed RSVPs)
  • 입금 확인 (mark paid) / 면제 (mark waived), per-row pending Set so one mutation doesn't disable the whole roster
  • closeout CTA (appRouter.back(), disabled until all settled)
  • pull to refresh

Gaps. none found.


Public Pickup Preview

  • Route: /sessions/[sessionId]/preview
  • Screen: PublicPickupPreviewScreenpackages/features/sessions/src/public-pickup-preview-screen.tsx
  • Shell: DetailShell · title = session title (dynamic) · subtitle = date label · headerRight: none
  • Design: unreviewed
  • Verified: 2026-09-16

Purpose. The curated view a non-member/unauthenticated visitor sees when tapping a pickup card from discovery — enough to decide whether to apply, with an apply-CTA state machine adapting to auth/application status. Bounces to full session detail once the viewer is a member, the host, or approved.

Anatomy.

├─ [QueryBoundary] session/rsvps → loading=[SkeletonFlatPage kind="type"]
├─ [TypeHero] title = venue/place name (fallback: location text, then sessionTitle) titleBadge=
│  [ApprovalModeBadge] facts=[🎾 format/style · 🏷 tier range · 📅 date · ⏰ time]
└─ [FlatColumn]
   ├─ [SectionBlock 정보] [FactList] 시작 시간 / 최대 인원 / 참가비
   ├─ [SectionBlock 호스트] → [PickupHostCard] (flat block, no Card, since the Hub-vs-detail law — shared
   │  with host-callout-section.tsx; the SectionBlock title is its only frame)
   ├─ [SilhouetteStrip] (Well) grayscale placeholder circles ≤6 + overflow count + [MetaSegments]
   │  attending-count + spots-left (no middle-dot glue)
   ├─ [LocationTile] [VenueCard] (backing venue row exists) or [VenueRow variant="plain"] fallback
   └─ apply-notice text

Bounce rules (useEffect, top of body): not a pickup, host, club member, or already-approved application → appRouter.replace to full session detail — this screen only renders outside those conditions.

States.

  • loading; session not found (EmptyState)
  • closed session (cancelled/completed) → disabled "마감된 일정이에요"
  • unauthenticated → "로그인하고 신청하기"
  • rejected → disabled "거절됨"
  • pending → amber pending text + disabled button + 신청 취소 ghost action
  • no application/cancelled → primary apply CTA (verb varies by auto vs manual approval mode)

Actions.

  • tap apply CTA (verb: 바로 참가하기 / 참가 신청)
  • 신청 취소 (cancel a pending application)
  • 로그인하고 신청하기 → login route
  • back

Gaps. none found.


Spectator Scorecard (경기 스코어카드)

  • Route: /sessions/[sessionId]/scorecard
  • Screen: SpectatorScorecardScreenpackages/features/sessions/src/spectator-scorecard-screen.tsx
  • Shell: DetailShell · title sessionTitle(session) (dynamic; loading fallback 경기 스코어카드, t().scorecard.title) · headerRight: none · no subtitle (date/location moved off the header into a body MetaSegments row, 2026-09-14 no-middle-dot sweep — DetailShell.subtitle is a plain string with no line-array slot)
  • Design: ratified
  • Verified: 2026-09-17

Purpose. Read-only-for-spectators view of a session's match state (draws/live/rounds, plus standings/ladder for score-driven formats), plus an admin-only per-match rules override editor.

Anatomy.

├─ [DetailShell scroll={false}]
├─ [SessionLiveAnchor] page-level "진행 중" band ▸ isLive — first child, above the tabs (pinned
│  for free: no ScrollView at this level, only each tab's own pane scrolls); decided 2026-09-17
├─ ▸ [MetaSegments] date + location facts (no middle-dot glue) — only when either is present
└─ [SegmentedTabs preload="all"] items dynamic:
   대진표 (always) · LIVE (always, badge=live count) · 라운드 결과 (always) ·
   순위 ▸ isScoreDrivenStyle · 킹코트 ▸ generationStyleId === 'king_of_court'
   ├─ [DrawsTab] scheduled matches by round; admin row-tap → per-match [MatchRulesEditorSheet]
   ├─ [LiveTab] in_progress matches with current scores
   ├─ [RoundsTab] completed matches by round, winner highlighted
   ├─ [StandingsTab] ▸ score-driven only
   └─ [KingOfCourtLadder] ▸ king_of_court only

Default tab derived once from match state (getDefaultTab): live (any in_progress) → rounds (any completed) → draws.

States.

  • error → EmptyState + retry
  • offline-paused with no cache → WifiOff "연결이 없어요" paper-backup EmptyState
  • loading → SkeletonCard stack
  • per-tab empty states (no draws / no in-progress / no completed rounds)
  • per-match rules editor open (admin) vs session-default rules
  • missing record: settled but no session row (bad/deleted sessionId) → NotFoundState (2026-09-16 bug-class fix — resolvedSession used to fall back to a placeholder Session shape and render the full scorecard scaffold around it)

Actions.

  • switch tabs
  • admin: tap a scheduled match row → per-match rules editor (save / clear-to-session-default / reset)
  • pull to refresh (user-gesture only — data itself polls independently via useLiveSessionData)
  • tap a match row → MatchProfilesSheet

Gaps. none found.

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