Skip to content

Screen Blueprints — Sessions ​

Status: Active Last reviewed: 2026-09-18

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: ActivityScreen — packages/features/activity/src/activity-screen.tsx
  • Shell: MainTabShell chrome="tabs" · title 경기 (t().common.games) · actions: 검색 · the kit TabBar stands over the panes
  • Design: unreviewed
  • Verified: 2026-11-12

Purpose. The 경기 tab root: the member's own upcoming sessions (예정), the open pickups to join (매치 찾기), and the tennis-court directory (테니스장), three panes under flat v2 Tabs, each a Feed with no Module above it; built W4 batch 6 to flat-v2-list-a#gm-ideal (L1-03). A tagged session's Feed item reads its opponent (C13, gm-challenge).

Now (2026-11-08, W4 batch 6). 예정 is the default pane. ?tab= opens a pane (shared/activity-tabs.ts): pickup_finder → 매치 찾기, courts → 테니스장 (the links the app makes: routes.activityPickups, routes.activityVenues), and the old my_matches → 예정, saved → 예정 on 보기 저장됨 (nothing links them today; 저장됨 is a value of 예정's filter, never a pane). ?tab=saved is read when 예정 mounts. The Header's 검색 is the one search: on 예정 and 매치 찾기 the field replaces the title and one mixed list shows the loaded sessions and the directory's venues; on 테니스장 the icon opens that pane's own takeover (VenueExplorePane searchOpen). Rows are SessionFeedItem (the kit Feed item): thumb the venue's static map, else the court tile; overline the kind (매치 / 일정) and its source (the host for a pickup, the club for a club session, two bulk reads); title the venue; facts the time and the one deciding fact as a Tag (the viewer's state, 진행 중 without a round count, 마감, N자리 남음 at two or fewer; coloured by meaning through the colour canon); a who row (format, composition when it restricts, level, courts from sessions.used_courts as where.usedCourts: 3~4번 코트, 1, 3번 코트, 코트 N면 when the numbers are unknown). No bookmark on a row; a row opens routes.clubSession (예정) or routes.sessionPreview (매치 찾기, search). The filters are one value (PickupFiltersState) edited in the kit FilterSheet; the saved region is written only when the Sheet applies a changed 지역.

Anatomy.

├─ [MainTabShell chrome="tabs"] title=common.games · actions=[검색] · search={field} while searching
│  tabs=[예정 (common.upcoming), 매치 찾기 (common.findPickup), 테니스장 (courts.navCourt)] · renderTab (user, day) · preload visited
├─ 예정 (UpcomingPane) → [SessionFeedList] day-sectioned (useSessionFeedRows → useDaySectionedSessionList)
│  ├─ header → FeedHead (24) → [FilterHead] 내 일정 · 필터 N · removable Choices (보기 저장됨, 날짜, 지역, 종목, 경기 유형, 경기 방식, 코트 유형, 수준, 참가비)
│  ├─ read → useMyParticipatingSessions (보기 전체) | useMySavedUpcomingSessions (보기 저장됨, upcoming only, by day)
│  ├─ ▸[SessionDayHeader] 오늘 / 내일 / 모레 / N일 뒤 + [SessionFeedItem] × N (who: format, courts)
│  ├─ footer → [FeedEnd doors=[매치 찾기]]
│  └─ no FAB
├─ 매치 찾기 (PickupPane) → [SessionFeedList] infinite
│  ├─ header → FeedHead → [FilterHead] 열린 매치 · the saved region is a Choice counted in 필터 N
│  ├─ read → useOpenPickupsInfinite minus the sessions the member is in (useMyRsvps); filters narrow the loaded pages; useFetchWhileEmpty keeps loading while a filter leaves none
│  ├─ ▸[SessionDayHeader] + [SessionFeedItem] × N (who: format, composition, level, courts)
│  ├─ footer → next page: [FeedItemSkeleton who n=2] · nothing left: [FeedEnd doors=[테니스장 찾기]]
│  └─ [Fab placed="tab" label=매치 aria=매치 만들기] → routes.createSession(PICKUP_CLUB_SENTINEL), collapses on scroll
├─ 테니스장 → [VenueExplorePane active searchOpen onSearchClose]  (lane B, `venues/venue-explore-pane.tsx`)
├─ Header search (fieldOpen) → [ActivitySearch] one FeedList: [SectionHead 검색 결과] + session rows (SessionFeedItem) + venue rows (FeedItem, 테니스장 · district)
└─ Sheets: [FilterSheet] (groups 보기?, 날짜, 지역, 종목, 경기 유형, 경기 방식, 코트 유형, 수준 LevelBand, 참가비 BandSlider) · [RegionPickerSheet] · [DateFilterSheet]

Tagged to a 대항전 (batch C):

overline 대항전 · the venue; title "vs 송파 베이스라인"; facts and who row as any session. The opponent comes from useSessionCardModels through useSessionOpponents into buildSessionInfoModel (kind.opponent); the row's title is sessionTitle(kind).

States.

  • loading: five Feed item skeletons under the real head and Choices; the next page: two
  • first-run: 예정 예정된 일정이 없어요 with 매치 찾기 (primary); 매치 찾기 열린 매치가 없어요, no button (the FAB is the action); 저장됨 저장한 일정이 없어요, no button (the Choice's × is the way back)
  • filtered-empty: 조건에 맞는 일정이 없어요 with 필터 초기화, the Choices staying; 초기화 returns to the saved region (else the one-shot location result), never starts a pickup
  • error: QueryErrorState what=일정, the head staying; offline (cold): OfflineEmptyState, the head staying; the offline strip is the app's root NetworkBanner, the pages draw none, and a cached list stays under it (owner decision 16, 2026-11-10)
  • search: nothing typed draws nothing; no match 검색 결과가 없어요; the directory leg failing offers a retry, paused offline shows the offline state
  • end of list: the Feed end with its door, drawn only when the Feed has rows and nothing is left to load

(Batch C.) tagged with the opponent read · tagged while the challenge or the opponent club cannot be read (the venue stays the title) · no session in the list tagged: no challenge and no club read at all (one challenges read per distinct hosting club otherwise, and one club-name read).

Actions.

  • switch pane (Tabs); the Feed ends switch it too (예정 → 매치 찾기, 매치 찾기 → 테니스장)
  • 검색 → the Header field (예정, 매치 찾기) or the venue takeover (테니스장); 취소 returns to the panes as they were
  • 필터 ▾ → FilterSheet (draft, 결과 N개 보기 applies, 초기화 returns the draft to the defaults and stays soft while it is there) → 지역 Cell → RegionPickerSheet, 날짜 Cell → DateFilterSheet
  • a Choice's × clears its own facet for this visit and never writes the saved region
  • a session row → 예정 /sessions/[id], 매치 찾기 and search /sessions/[id]/preview; a venue row (search) → routes.directoryVenue
  • FAB 매치 (매치 찾기 only) → 일정 만들기 for a pickup

(Batch C.) unchanged (the row opens the session page).

Gaps.

  • Search reads only what is loaded (the member's upcoming sessions and the open pickups so far); a session search RPC is a later batch if completeness is wanted. The venue leg is the ranked directory search.
  • Filters are client-side over the loaded 20-row pages: the Sheet's 결과 N개 보기 counts the loaded matches so far, and the pane keeps loading while a filter leaves none.
  • The venue-filtered entry from 장소 상세 (E8) waits for L2-03 and L2-04.
  • 수준 and 참가비 keep the old facets' semantics: a session with no tier band always passes the level filter; a fee range includes its upper stop (100,000원 reads 100,000원 and under unless the range is the whole band).
  • ?tab=saved selects 저장됨 when 예정 mounts only; a second push to a mounted tab with saved switches the pane but leaves the view as it was.
  • On the web the FAB is not hidden where the navigation carries 매치 만들기 (the kit Fab has no coveredByWebNav).
  • The overlay tab bar with Fab placed="tab" (Android touch, pin height) is unverified on a device.
  • The 테니스장 search rows in the mixed list are drawn here with a minimal local mapping (kind, district, courts); switch to lane B's venueFeedItemModel when it is exported.

(Batch C.) None new.

The 테니스장 pane (L1-03c). Find a venue, nearest first or by 시/도: one Feed of venue Feed items under a FilterHead, one FilterSheet where 가까운 순 and 저장됨 are filters like 종류, 지역 and 시설, and the Header's 검색 for the Search takeover; built W4 batch 6 to flat-v2-list-b#vn-ideal (L1-03c).

테니스장: now. The explore surface is forked out of VenueFinder (V1): VenueFinder is pick-only (create session, session edit, club venues, club create, owner claim, event place) and VenueDirectoryContent is a transitional shim over this pane. The pane reads the whole directory in one fetch-all read (useVenueDirectoryAll, ~1,900 rows) for the applied server filters (region, kind, the facets the server owns); 저장됨 (the viewer's saved ids) and the two client-only facets (영업 중, 무료) are applied to the loaded rows, so the 시/도 counts and the Sheet's 결과 N개 보기 are exact. State is exploreReducer (utils/venue-explore-filters.ts); rows are venueFeedItemModel and buildExploreFeed (utils/venue-feed-item-model.ts); the Search takeover's rows are buildSearchRows (utils/venue-explore-search-model.ts).

테니스장: anatomy.

VenueExplorePane (active, searchOpen, onSearchClose)
├─ VenueExploreFeed  ▸ FeedList (FlatList, no recycling; pull to refresh)
│  ├─ header
│  │  ├─ 24 gap, or 12 + [Notice pin 위치 권한이 꺼져 있어요 · 설정 열기] ▸ the ask was refused
│  │  └─ [FilterHead] title = 가까운 테니스장 (nearest first) | the first 시/도 + N곳 · trigger 필터 | 필터 N
│  │     └─ one removable Choice per active value: 가까운 순 · 저장됨 · 지역 (one) · 종류 · each 시설
│  ├─ rows: [FeedItem] (VenueFeedItem) · [SectionHead title count N곳] per later 시/도
│  │     FeedItem: thumb = static map, else court tile · overline [kind, district] · title (2 lines)
│  │     facts [distance when nearest first, 코트 N면, surface, 야외/실내] (a shop: hours, phone)
│  │     who [영업 중 | 영업 종료, 평일 N원~ | 무료, ★ rating]
│  ├─ loading ▸ [FeedItemSkeleton who n=5]
│  ├─ error ▸ [QueryErrorState what=테니스장] · cold offline ▸ [OfflineEmptyState]
│  ├─ empty ▸ [EmptyState filtered 조건에 맞는 테니스장이 없어요 · 필터 초기화]
│  └─ footer ▸ settled and not empty: [FeedEnd doors=[목록에 없는 테니스장 추가]]
├─ VenueExploreFilters
│  ├─ [FilterSheet] 보기 (가까운 순, 저장됨) · 지역 (Picker Cell) · 종류 (single) · 시설 (the kind's set; none for 샵) · 초기화 · 결과 N개 보기
│  └─ [RegionPickerSheet] 시/도, then 시/군/구 (several)
└─ VenueExploreSearch (Modal, mounted while open)
   └─ [SearchTakeover header.search 취소]
      ├─ empty field ▸ 최근 검색 [TextRow control × "{term} 삭제"]
      ├─ 2+ characters ▸ 등록된 테니스장 N곳 [FeedItem]; after the keyboard's search key ▸ 네이버 검색 결과
      │  [MediaRow kind=venue control bookmark "{name} 저장"] · [CaptionRow 네이버 제공] · 찾는 곳이 없나요? [TaskCell 목록에 없는 테니스장 추가]
      └─ short query · no result · loading · error · offline states in the list's slots

테니스장: states.

  • Ideal: 가까운 순 on (the location was already granted), nearest first, one Choice under the head.
  • Provinces: nothing on (no granted location, or 가까운 순 removed): the first 시/도 and its count lead, a head per later 시/도.
  • Denied: turning 가까운 순 on asked once and was refused: it stays off and one Notice says why (설정 열기 opens the device settings).
  • 저장됨: the saved venues alone, uncapped, in place. Filtered: 필터 N and one Choice per value; filtered-empty: the one cause, 필터 초기화 returns to the default (가까운 순 on).
  • Loading (five skeleton items under the head), error (one retry), cold offline (the kit's state, under the head); end (the Feed end and its door, only once the read settled and there are rows).
  • Search: recents, typing (registered only), results (registered, Naver, the 네이버 제공 line, the door), no result (the door is the way out, no reset), loading, error, offline, short query.
  • The pane releases its list after two minutes hidden (useHiddenPaneRelease).

테니스장: actions.

  • A row → 장소 상세 (routes.directoryVenue(id); the L2-04 batch settles the route). In the takeover a registered row closes it first.
  • 필터 ▾ → the FilterSheet; a Choice's × clears only its value; the Sheet's 초기화 puts the draft to the default and 결과 N개 보기 applies it; 지역 opens the region Sheet over it.
  • 가까운 순 turned on without a fix asks for foreground location once; granted → the fix is read and the list leads nearest first; refused → off, the Notice.
  • Header 검색 (the screen's) → searchOpen; 취소 or the Android back closes the takeover. A recent search or the keyboard's search key submits (the Naver leg waits for it; the query joins the per-device recents). A Naver result's bookmark adopts the place and saves it; it then leaves the Naver group and appears among the registered venues.
  • The Feed-end door and the takeover's door → routes.newVenueContribution() (L4-07). 제안 내역 left this pane for 설정's 보낸 제안.

테니스장: gaps.

  • No list photo exists (the Thumb cascade is map tile, else court tile; a cover photo is a data-pipeline follow-up).
  • The price is the stored 평일 N원~ / 무료: the unit is not stored, so the board's 시간당 is not said until the scraper proves it.
  • The whole directory is one read (fetch-all): the "next page" skeleton of the board is the first load; a paged nearest-first and a grouped-count RPC are follow-ups if the corpus passes ~5k rows or the load is slow on device (unmeasured).
  • 저장됨 with 종류 전체 lists the saved venues the directory read returns, which leaves out shops (the read lists shops only when 종류 is 테니스 샵); the saved-by-ids read of the old 즐겨찾기 chip did not.
  • The Sheet's live count runs the same read for the draft's server filters (cached per filter set); a draft with a new region or facet fetches once.
  • The ask for location when 초기화 turns 가까운 순 back on follows "turning it on asks once".
  • The Search takeover matches the venue directory only (the sessions leg of the Header's 검색 on the other panes is lane A's, E6). The "loaded only" limit of session search is not this pane's.
  • Unverified on a device: the takeover's Modal rise and keyboard focus (onShow focus plus iOS autoFocus), the 설정 열기 jump on Android, FeedItem recycling off on a 1,900-row list.

Create Session Wizard ​

  • Route: /create-session
  • Screen: CreateSessionScreen — packages/features/sessions/src/create-session-screen.tsx (re-export shim) → packages/features/sessions/src/create-session/create-session-screen.tsx
  • Shell: DetailShell (the review page: Header back · 일정 만들기 · sub) or WizardShell (the first-timer steps: title per step, progress of 7, × on step 1)
  • Design: unreviewed
  • Verified: 2026-11-12

Purpose. Schedule a club session (?clubId) or host a pickup (no club: 매치), built W4 batch 4 to flat-v2-forms#cs-ideal (L4-01). A host with something to copy (a 정기 일정, a past session) or a draft saved on the review page edits a review page of value Cells, each opening its own Sheet; a host with nothing to copy walks the same editors as seven steps. A club session can be tagged to a running 클럽 대항전 (C11): the 대항전 choice of 경기 유형 lists the club's accepted challenges whose period holds the session's date, and the session then shows its opponent.

Now (2026-11-08, W4 batch 4). One page, two surfaces decided on open (use-surface-flow.ts; rulings D1–D12 in the decision log): a fresh draft resumes in its surface with the Toast 쓰던 내용이 있어요 · 처음부터 쓰기; otherwise nothing to copy opens the wizard and something to copy opens the entry MenuSheet over the review skeleton. Every answer is one editor (create-session/editors/*, EDITORS): the same Body renders in a review-page Sheet (EditorSheet, 완료 soft until the editor's fields change, a dirty dismissal asks 수정을 그만둘까요?) and live in a wizard step. The failure of a submit is the danger helper under the button; neither create mutation toasts.

Anatomy.

├─ [MenuSheet] 어떤 일정인가요? (only when something can be copied and no draft resumes)
│  ├─ group 정기 일정: one row per active meet (when · where) → seeds the draft (prefillSource meet)
│  └─ group 다른 방법 (untitled without meets): 지난 일정 불러오기 → [MenuSheet] 지난 일정 (≤ 3 past
│     sessions: a club's own via useClubSessions, a host's pickups) → seeds (past) · 새로 만들기 (defaults)
├─ the review page (review/review-page.tsx)
│  ├─ [Header] back · 일정 만들기 · sub = club | 정기 일정에서 / 지난 일정에서 / 클럽 기본값 (pickup: 매치)
│  ├─ [Notice attention] 입금 계좌를 등록하면 참가비를 받을 수 있어요 · 계좌 등록 (canManageClub) — a club
│  │  session with a fee and no account (useClubBankAccount); the primary is soft while it shows
│  ├─ [Notice attention, SOFT] 입금 계좌가 아직 없어요 / 일정 수정에서도 넣을 수 있어요 · 계좌 등록 — a 매치 with a
│  │  fee whose draft holds no account (`cs-pickup-noaccount`); the primary stays READY (a 매치 can be
│  │  made without one); the action opens the 참가비 Sheet
│  ├─ [ReviewGroups] three [SectionHead] + [CellGroup] in the session record's order:
│  │  일시와 장소: 일시 * (date + time, one row) · 반복 · 응답 기한 · 장소 * (→ the venue takeover) · 코트 (once a venue)
│  │  경기: 종목 · 한 경기 시간 · 경기 규칙 (value Cell → /create-session/match-rules) · 수준 · 구성
│  │  참석: 최대 인원 (최소 on line 2) · 참가비 (a paid 매치 with its account: line 2 = 카카오뱅크 ••••4567,
│  │  the last four digits, `cs-pickup-paid`) · 공지
│  │  an unanswered 일시, 장소 or 종목 reads 선택 with its asterisk; a press's line sits under its Cell
│  └─ [CtaBand] 일정 만들기 — a press validates (draftIssues) and scrolls the first issue's group into
│     view; 만드는 중... while submitting; disabled offline; the danger helper on failure
├─ the Sheets (editors/*): 일시 (Calendar with the club's session days dotted + the 시간 band, range
│  mode, whole-band Stepper; a day with a session asks the duplicate confirm) · 반복 (반복 안 함 | 매주 |
│  4주마다, nested 반복 끝 기간 설정 (주) | 계속 반복) · 응답 기한 (없음 | 시간 전 | 일 전 over one count row,
│  the moment read under it; a moment already past is refused) · 코트 (the venue's court numbers, or
│  사용 코트 수) · 종목 (format, 경기 유형 for a club, 경기 방식 (친선 | 경쟁, with the line ELO 미반영 / ELO 반영 under it); 혼복 ⇒ 혼성) · 한 경기 시간 (5–120,
│  미정 allowed) · 수준 (클럽 기준 그대로 | 범위 설정 over the LevelBand; pickup 수준 무관) · 구성 (남성 |
│  여성 | 혼성 | 무관) · 최대 인원 (+ 최소, the code's bounds by format) · 참가비 (무료 | 유료, the amount
│  nested; on a 매치 the 입금 계좌 group nests under the amount: head 입금 계좌 with the note 참석이 확정된
  분께만 보여요, 은행 [PickerCell], 계좌번호 and 예금주 [Field], `cs-ed-fee-pickup`; the bank list takes the
  Sheet's place while choosing, `cs-ed-fee-pickup-bank`, a Sheet never opens over a Sheet) · 공지 (0/500)
├─ the venue takeover (editors/venue-takeover.tsx → VenueSearchTakeover, staged: a row stages, 이 테니스장으로
│  commits; an empty result offers the directory's 찾는 곳이 없나요? row → the contribution form)
└─ the first-timer wizard (steps/wizard-host.tsx, WizardShell, testID create-session.wizard)
   1 언제 만나나요? (일시; 날짜를 골라 주세요) · 2 어디서 하나요? ([PickerCell] 장소 * with the
   PlaceMapFace, then 코트) · 3 어떻게 치나요? (종목 * then the rest) · 4 몇 명이서 뛰나요? · 5 추가 옵션
   (참가비 [+ a paid 매치's 입금 계좌, its bank list over the page, `cs-w5-pickup`], 응답 기한 [PickerCell] →
   its Sheet, 반복, 공지; 건너뛰기) · 6 경기 규칙 (the rules Cell and its
   facts in one line; 이대로 진행) · 7 확인해 주세요 ([ReviewGroups], pencils detour to their step: back 확인,
   footer 확인 화면으로 돌아가기)

Tagged to a 대항전 (batch C):

├─ 종목 Sheet / wizard step 3, 경기 유형 [ChoiceSet] 정기 | 대회 | 리그 | 대항전 (대항전 only when a running challenge holds the date)
│  └─ [Nested] under 대항전: the running challenges as radio [CheckRow]s, "vs 클럽" over the rule (challengeRuleLabel)
│     (cs-ed-run); the Sheet leaves none picked and 완료 waits, the live step picks the first (cs-w3-challenge);
│     once one is picked [CaptionRow] 상대 클럽 멤버도 바로 참석할 수 있어요 (cs-ed-run-chosen)
├─ 일시 Sheet: a day outside a tagged challenge's period is refused above the button with 대항전 기간은 9월 14일부터
│  28일까지예요 and the Sheet stays (cs-ed-date-out); the live step drops the tag and the 대항전 type and says
│  날짜가 대항전 기간(9월 14일~28일) 밖이라 대항전 표시를 지웠어요
├─ the review's 종목 Cell: facts 복식, 대항전 with "vs 클럽" on its second line (cs-challenge)

States. deciding (the review skeleton: three groups of Cell skeletons, no band) · entry sheet · past

  • Offline (F1c, the app's root strip says it): offline 일정 만들기 on the review waits (the kit holds the band); 다음 and 이전 only navigate and stay live; no page offline flag. sheet · review (answered, unanswered, pressed lines, the account gate, the 매치's soft account line, a paid 매치 with its account masked, submitting, failure helper, offline) · wizard (unset and answered steps with sequential reveal, the soft primary of steps 1–3 and 5, armed detour, resume Toast) · the leave guard 나갈까요? (나가기 resets the draft).

(Batch C.) none running: 경기 유형 offers three types, never a bare 대항전 (cs-ed-run-none, cs-w3-none) · 대항전 chosen with none picked (Sheet, 완료 soft) · picked · date out of the period · a refused tag at submit (session_not_a_side, challenge_not_open, session_out_of_period, challenge_settled, session_has_scored_matches): its own line in the danger helper under the button, not reported as a fault · a saved draft keeps the tag (the challenge's id only, no name) and re-checks it on resume: once the club's challenges have loaded, a tag no longer running for the draft's date is removed with the 대항전 type, and the same line as the date-out case says the period (Toast; nothing is removed while the read has not settled or failed).

Actions. A Cell → its Sheet (완료 commits to the draft) · 장소 → the takeover · 경기 규칙 → the rules draft page (its saved rules come back on focus) · 계좌 등록 → 입금 계좌 (L4-21) · 일정 만들기 → create_session_series (club: useCreateSession, replace to 일정 상세; pickup: useHostPickupGame, replace to 호스트 관리) · back → 나갈까요?

(Batch C.) 대항전 → a radio row picks the tag (the draft keeps challengeId) · 일정 만들기 sends challengeId; a tagged session goes out on 자동 승인 · a copied past 대항전 session copies as 정기 (a new date has no challenge behind it; accepted).

A 매치's own account (owner decision 7, F2 cut 2). A paid 매치 asks its host for an account of its own (은행, 계좌번호, 예금주) under the amount, shown only to people holding a seat (session_deposit_accounts, 00753). The three answers live in the draft's memory only: draft-persist.ts never maps them into the store, the retry fingerprint (persisted with the attempt) is built from the input WITHOUT them, and nothing logs them, so a resumed draft asks again. A blank account is the soft path (the Notice, the 매치 is made, the host adds it on 일정 수정); a half-given or malformed one holds the 참가비 row (the first wrong answer's line) and is refused by the Sheet's 완료 on its own answers. The account travels in create_session_series' input as deposit_account (hostPickupGame → the repository's series argument), never in the session row.

Gaps. The club picker of the hub's entry (several clubs, 클럽 없이 매치로) waits for the hub wave; the entry's 정기 일정 row is one plain line (a MenuRow label is a string); the board's 계좌 입력 / 입력 are the app's 계좌 등록 / 추가 (the near-duplicate gate); a draft resumed after a restart asks for the 매치's account again (it is never written to the device); the Maestro flow 10-club-create-first-session-cta.yaml must expect the entry sheet when its new club has a 정기 일정; nothing ran on a device or a browser.

(Batch C.) A tagged session approves the other club's members automatically with no manual choice (recorded gap; pv-challenge-manual stays reachable when the server says manual); a weekly series of a tagged session is guarded only by the server (session_out_of_period, which has its line); nothing ran on a device.


Session Invite Share Preview ​

  • Route: /s/[sessionId]
  • Screen: SessionSharePreviewScreen — packages/features/sessions/src/share-receivers/session-share-preview-screen.tsx (route file apps/mobile/app/s/[sessionId].tsx only passes the param)
  • Shell: DetailShell · title empty (back-only Header) · pinned CtaBand footer only when the apply band shows
  • Design: unreviewed
  • Verified: 2026-11-13

Purpose. What a shared 매치 link opens to a person who is not a member, host or approved guest: what, when, where, level, seats and terms, never who (no host, names, faces or initials). The Hero title is the linked venue's name, else its district, else the title derived from the format and kind (migration 00758); the stored title, the typed place and coordinates are not in the payload, so a session with a typed place and no linked venue shows its derived title. One action, 참석 신청하기, which opens 매치 미리보기 where the apply states live.

Anatomy.

├─ Header (back, no title)
├─ closed → [Notice neutral] 마감된 일정이에요 · action 매치 찾기 (24 under the header; no band)
├─ [Hero] eyebrow = club (a pickup has none) · title = venue · fact 📅 day + hours
├─ [SectionHead] 정보
├─ [CellGroup] 자리 · 종목 · 대상 · 경기 방식 · 참가비 · 참석 방식 · 신청 마감 (a null value is no row)
│  └─ nav [Cell] 주소 ▸ 장소 상세 (only with a linked venue; the address from the venue read)
└─ [CtaBand] primary 참석 신청하기 ▸ routes.sessionPreview(id) (open and taking applications only)

States.

  • Offline (F1c, the app's root strip says it): offline a warm link keeps the cached preview; 참석 신청하기 waits (the board disables it) (rc-s-stale).

  • loading → Hero and four Cell skeletons under the back-only Header

  • ideal / long names (club and venue wrap whole)

  • closed (completed, cancelled or past the deadline) → the Notice, no band

  • takes no applications (publicInviteEnabled not true) → no band and no Notice

  • not found → 이 일정을 찾을 수 없어요 / 삭제됐거나 주소가 바뀌었어요 · 홈으로 가기

  • offline cold start → the offline EmptyState with retry, never not found

  • error → the error EmptyState naming 일정, with retry

  • a member, the host or an approved guest → replaced to routes.clubSession (skeleton while it runs); a signed-in stranger of a public 매치 sees the preview

  • 2026-11-13 (UX-7, the state helper): Offline, error and loading from the one PageStateView.

Actions.

  • back (Header)
  • 참석 신청하기 (band) → 매치 미리보기
  • 주소 (nav Cell) → 장소 상세
  • 매치 찾기 (closed Notice) → 경기 › 매치 찾기
  • retry (offline / error); 홈으로 가기 (not found)

Gaps. A signed-out visitor on mobile meets sign-in first and the path is kept (the stack mounts after sign-in); the web share pages (L0-07) are not rebuilt; D6 (a host Cell) is not built by ruling E5; the 주소 Cell shows no value until the venue read lands; an old-shaped payload (with title, location, latitude, longitude) still parses and none of the four is kept; nothing ran on a device.



Session Attendance (참석 확인, L3-36) ​

  • Route: /sessions/[sessionId]/attendance
  • Screen: SessionAttendanceScreen — packages/features/sessions/src/session-attendance-screen.tsx (a thin entry; the page is under attendance-check/)
  • Shell: DetailShell titled 참석 확인 with the club and the day as its sub line and the session tools switcher (참석자 | 참석 확인) in its fixed subhead row
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST2, 2026-11-13), States: PageStateView (offline, error, loading, not found with 경기 보기, no-permission); 대진 짜기 keeps its own ended and blocked walls; 참석 확인 keeps its quiet states; 호스트 관리 withholds the applicant list offline.

Purpose. Players check themselves in from 일정 상세, so the host sees only the exceptions: who has not come, who has and waits for the host, who the host has confirmed. A row carries its own answer (no band); the session's host and the club's officials only. Built W4 batch 9 to flat-v2-list-a.jsx#attend-check (L3-36).

Now. useAttendancePage(sessionId) runs every hook, then attendancePageState picks the one state:

  • Reads: useLiveSessionData (session, rsvps with each window and stamp, names), useSessionDetailBundle (the club's name and attendanceTrackingEnabled, the attendance records), useSessionToolAccess(session).canRunAttendance (host, owner, admin, match director), useNowTicking (30 s). No query of its own; the 일정 상세 관리 door counts from the same bundle.
  • Rules (attendance-check-model.ts, pure, the clock injected): deriveAttendanceCheck(rsvps, records, session, now) → 확인할 사람 (unstamped, own window start + LATE_THRESHOLD_MINUTES passed, most overdue first), 도착 (stamped, no host record, newest event first), 확인됨 (a host attended record: confirmed_by admin or mutual). A player the host marked 불참 is in no group. The 관리 door's count is askToCheck.length of the same selector (session-header-facts.ts).
  • Opening: the page shows rows from 30 minutes before the start (isCheckInOpen, the stamp window of 00707); live (session started) gates 도착 확인, 모두 확인 and 불참.
  • Words: 도착, 시작, 떠나는 시간 and the window picks are read from matchBoard, sessionOverlays; the rest are attendanceCheck.
  • Answers (use-attendance-actions.ts): 도착 and 도착 확인 → useConfirmArrival; 되돌리기 → useUnconfirmArrival (00717: the host verdict goes, the stamp stays); 모두 확인 → useConfirmArrivalsStamped and a Toast "참석 N명을 확인했어요" (none when it confirmed no one); 불참 → useMarkNoShow, preceded by useClearArrival for a player who has a stamp (the server refuses an absence on a stamped player). No Toast and no Dialog for a single 도착.

Anatomy.

├─ [DetailShell] title=참석 확인 sub=[club, day] onBack
│  ├─ subhead ▸ [SessionToolsSwitcher active=attendance]   (staff of a club that tracks attendance only)
│  └─ body   ▸ [AttendanceGroups] 24 gap
│       ├─ [SectionHead first] 확인할 사람 · "N명"   (absent while nobody is overdue)
│       │    [PersonRow] name · "{start} 시작" "{m}분 지남" · [AnswerPair 도착 | 불참]
│       ├─ [SectionHead] 도착 · "N명" · bulk 모두 확인 (from the start)
│       │    [PersonRow] name · "{t} 도착" "{m}분 늦음" | "{t} 먼저 감" · [Button 도착 확인] (from the start)
│       │    first 8 rows, then [ExpandToggle] "N명 더보기"
│       └─ [SectionHead] 확인됨 · "N명"   (once a row is confirmed)
│            [PersonRow] same facts · [Button 되돌리기]; first 8 rows, then [ExpandToggle]
├─ [AttendancePersonSheet] title=참석 확인 · name row (→ 공개 프로필) · 시간 바꾸기 · 불참으로 표시 (danger)
├─ [WindowSheet] "{이름}님의 참석 시간" · 일정 {from}~{to} · [WindowBar] · 간단히 고르기 · [PickerCell 도착 | 떠나는 시간] · 저장
│    └─ [WindowWheel] the kit TimeRangeSheet as the Sheet's second step
└─ [AbsentDialog] "{이름}님을 불참으로 표시할까요?" · "표시하면 신뢰 점수에서 4점이 빠져요" · 닫기 | 불참 표시 (danger)

States.

  • Write failures (ruling EP-3): the window and the per-person marks tell a failure in a Dialog (sessionRefusal by hint); never a Toast.

  • Ready: the groups above; a group with nobody is not drawn.

  • Loading: 24 gap and eight person skeletons, no switcher (the viewer is not yet known to be staff).

  • Offline (cold): EmptyState offline with a retry; error: EmptyState error for 참석 확인 with a retry; never an empty roster.

  • Not found: the session read settled with no session.

  • Wall: EmptyState permission "호스트와 운영진만 볼 수 있어요" for a viewer who is not session staff; no switcher.

  • Untracked club (H4): staff of a club that does not track attendance, or a pickup, meet EmptyState "이 클럽은 참석 확인을 쓰지 않아요"; no switcher. The page, its 관리 door and the switcher tab exist only where attendanceTrackingEnabled; there is nowhere else to record a verdict.

  • Empty: no confirmed player: "확인할 참석자가 없어요".

  • Not open: before 30 minutes ahead of the start: "참석 확인이 아직 열리지 않았어요".

  • Waiting: open, nobody has arrived or is overdue: "아직 도착한 사람이 없어요".

  • Before the start: stamps show in 도착 with no 도착 확인 and no 모두 확인.

  • 2026-11-13 (UX-7, the state helper): PageStateView as above; 참석 확인 keeps its quiet states.

Actions.

  • A row's tap → the person Sheet; its name row → that player's 공개 프로필 (L2-06).
  • 시간 바꾸기 → the window Sheet → 저장 (useAttendanceWindow, staff may set anyone's window at any time); the Picker opens the wheel.
  • 도착 / 도착 확인 / 되돌리기 / 모두 확인 / 불참 as above; 불참 always asks.
  • The switcher: 참석자 → 참석자 관리 (L3-35), replacing the route. Back → 일정 상세.
  • Door in: L2-02's 관리 sheet › 참석 확인 (count Tag) and L3-35's switcher.

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).

  • 불참 on a player with a stamp is two writes (clear the arrival, then mark): a failure between them leaves the player unstamped (and in 확인할 사람 once overdue), not corrupt; the Dialog stays open to retry.
  • The 4점 in the 불참 Dialog is written in the words; private.trust_score_rules holds the number and no client mirror or parity gate exists.
  • Opening time is the stamp window (30 minutes before the start), not the board's 2 hours (ruling H8). The door in 일정 상세 is still gated on in_progress|completed (host-tools-gates.ts).
  • The jest renderer cannot show 200% text; the wrapping of the pair and the buttons is by the kit's rules, unverified on a device.
  • The window Sheet repeats the player's own 참석 시간 Sheet (session-detail/overlays/times-sheet.tsx) with a title and the WindowBar; a title prop and the bar on that Sheet would let both use one.
  • The switcher is not drawn while loading, offline or on error (the viewer's standing is unknown); the board draws it there.

Pickup Host Dashboard (호스트 관리, L3-37) ​

  • Route: /sessions/[sessionId]/host
  • Screen: PickupHostDashboardScreen — packages/features/sessions/src/pickup-host-dashboard-screen.tsx (a thin entry; the page is under host-applicants/)
  • Shell: DetailShell scroll={false} titled 호스트 관리 with the pickup's sub line (venue, then day), over one FeedList
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST2, 2026-11-13), States: PageStateView (offline, error, loading, not found with 경기 보기, no-permission); 대진 짜기 keeps its own ended and blocked walls; 참석 확인 keeps its quiet states; 호스트 관리 withholds the applicant list offline.

Purpose. The pickup host decides who may join, applicant by applicant, in the row; only the person who created the pickup sees the rows, anyone else meets a wall. Built W4 batch 9 to flat-v2-list-a.jsx#host (L3-37).

Now. useHostApplicants(sessionId) runs every hook before any state is chosen and hands the page one model:

  • Reads: useLiveSessionData (the session), useSessionToolAccess(session) (isHost is the wall's rule, createdBy === viewer; canDecideApplication decides whether a row carries the pair), useSessionApplications(sessionId, { enabled: isHost }) (a non-creator never reads the list) through useCompositeNetwork, and one bulk useProfilesByIds for the names (Protocol A).
  • Rules (host-applicants-model.ts, pure): hostPageState (the one state: loading, error, offline, not-found, wall, empty, ready; a failed or cold-offline read is never empty), pendingApplicants (pending only, newest first), pickupSub (the venue then the day; the day alone for a free-text venue, courtVenueId null), applicantRows, answerState.
  • Words: the title, 승인, 거절, 닫기, the 거절 question and the count are read from common; the time line, the Dialog body, the spoken answer and the error subject from sessionParticipants.appliedAgo, sessionParticipants.declineBody, attendanceCheck.answerNamed, joinReview.backToRequests; the rest are hostApplicants.
  • Deciding (use-applicant-decision.ts): not optimistic. One decision at a time; the tapped answer shows loading and every other answer is disabled until the write is done and the applications were read again; only then 승인 says its Toast "{이름}님을 승인했어요". The mutation hook's own toast is silenced (mutateAsync(…, { silent: true })), so the page owns the moment. 거절 asks a Dialog naming the person first and says no Toast (the row leaving is the answer). A refusal (DomainError with a hint: decide_not_pending, session_not_recruiting, decide_insufficient_role) says its reason in a one-button Dialog; any other failure propagates.

Anatomy.

├─ [DetailShell] title=호스트 관리 sub=[venue?, day] onBack scroll=false
│  └─ [FeedList] perfLabel=host-applicants
│       header ▸ 12 gap (ready and loading only)
│       head   ▸ [SectionHead first] 신청 대기 · "N명" (pinned; ready only)
│       rows   ▸ [PersonRow context=applicants] name · "{when} 신청" · [AnswerPair 승인 | 거절]   → row tap = 공개 프로필 (L2-06)
│       empty slot ▸ [HostApplicantsStateView]  loading · offline · error · not-found · wall · empty
├─ [DeclineDialog]  "{이름}님의 신청을 거절할까요?" · body · 닫기 | 거절 (danger, committing while the write runs)
└─ [RefusalDialog]  the refusal's reason · 닫기

States.

  • Ready: the head with its count, one row per pending applicant (three or one are the same list). No band, no bulk.
  • Loading: 12, four person skeletons (the session read or the applications read or the names in flight).
  • Empty: EmptyState first "신청자가 없어요" / "매치에 신청한 사람이 여기에 보여요", only after the applications were read.
  • Error: EmptyState error for 신청 목록 with a retry that refetches both reads; a failed session read is the same state.
  • Offline (cold): EmptyState offline with a retry; never an empty list.
  • Wall: EmptyState permission "호스트만 볼 수 있어요" for anyone but the creator; the list is not read.
  • Not found: the session read settled with no session.
  • A creator who may not decide (canDecideApplication false): the rows without the pair.
  • 2026-11-13 (UX-7, the state helper): PageStateView as above; 호스트 관리 withholds the applicant list offline.

Actions.

  • A row → the applicant's 공개 프로필 (L2-06), the row's default press.
  • 승인 (one tap) → decide_guest_application approved → the re-read → Toast.
  • 거절 → DeclineDialog → decide_guest_application rejected → the re-read → the Dialog closes.
  • Back → the page it came from (L2-02 or L4-01's replace).
  • Doors in: L2-02's 관리 sheet › 호스트 관리 (count Tag) and the host's Notice 신청 N건 (gate: isHost && publicInviteEnabled, open or locked), and L4-01's band 일정 만들기 for a pickup (replace).

Gaps.

  • The board draws no frame for a refused decision; the reason stands in a Dialog, not a Toast, because a failure is never a Toast (library "Toast", rule 2.9, no-error-toast).
  • The mutation hook (use-decide-guest-application.ts) still carries successMessage; the page silences it on every call. The hook's toast words can go with the next pass over that file.
  • A pickup session that approves guests also lists its applicants on 참석자 관리 (L3-35, 신청); the two read the one useSessionApplications cache key. A club session's applicants are answered there by the officials, not here.
  • canDecideApplication comes from the club role, so a creator who is not yet known as a club owner or admin (the role still loading) sees the rows without the pair until it resolves.
  • No English or 200% frame is drawn on the board; the English words are the build's.

Session Detail (일정 상세, L2-02) ​

  • Route: /sessions/[sessionId]
  • Screen: packages/features/sessions/src/session-detail-screen.tsx (SessionDetailScreen)
  • Shell: DetailShell header="collapsing" collapsingLook="circles" (the MediaBand under the bar; a bare back-only DetailShell for error, offline cold and not found; a plain DetailShell titled 일정 수정 in edit mode)
  • Design: unreviewed
  • Verified: 2026-11-15
  • Push prompt: the "경기 알림 받으시겠어요?" dialog opens once, after a won free seat (or 예비 seat), only when the permission status is undetermined; an unread status (null), denied, granted and unavailable (the browser build, which has no push) open nothing.
  • Batch 58 (ST2, 2026-11-13), States: Loading, error, offline (cold) and not found are the app's one PageStateView inside a bare Header; offline wins over error when both hold; not found offers 경기 보기.

Purpose. The object page of one session: the map, what is wrong or waiting (one Notice), the venue, day and fee with the viewer's own state, who is coming, the matches once a draw exists, the facts and their doors, and the one action that fits the viewer and the moment; built W4 batch 8 to flat-v2-detail.jsx#sd (L2-02). A session tagged to a 클럽 대항전 names the club played against (C13); a host tags or untags it in the edit state; the other club's member who has a seat sees the page with only their own row.

Ask after a late cancel (follow-up batch E). A late cancel is scored (불참 0.5 or 1); once it went through the person may ask at once for it to be excused, in the same Sheet as the record's request (ExcuseRequestSheet). useExcuseAsk (in useSessionHeader) watches the page's one cancel mutation, reads useAttendanceExcuseState(sessionId, { enabled }) only after a cancel that reached the server (a press queued offline is recognised by isQueuedCancelRsvp, the one reader of the id use-rsvp builds, and never asks), and opens the Sheet only when the server gives askableUntil and the policy (useTrustRecord().rules) names the rule's points. A club without attendance tracking, a free cancel, an offline press, a failed cancel, a record nobody could answer and a still-loading policy show nothing. Closing the Sheet loses nothing (the record's own Sheet keeps the button for the seven days) and covers one cancel only (the mutation's submittedAt). The paid player's cancel Dialogs close on their press before the cancel runs, so the ask follows them as the only modal; a fee-kept cancel has no refund Notice. Anatomy: [ExcuseRequestSheet] with the Header's overlays, no back arrow, sub "{club}, {day}" (a 매치: "{day}, {place}"), a neutral Notice "12~24시간 전 취소, 2점 감점" | "12시간 안에 취소, 4점 감점" with "N일 안에 기록에서도 요청할 수 있어요", the reason ChoiceSet, 메모, the disclosure line, footer 요청 보내기 (soft until a category is chosen). States: opens · sent (Toast 요청을 보냈어요, the Sheet closes) · refused (a line over the footer, the Sheet stays) · dismissed (gone until the next cancel). Gap: the page's band does not show 확인 중 after the ask (the state lives on 신뢰 기록's row); the deduction line needs one extra policy read per late cancel.

Now. One screen composition, useSessionPage(sessionId, userId), runs every hook before any early return and hands the screen a SessionPage:

  • Data: useSessionDetailModel is five small hooks over ONE bundle subscription (Protocol N; no per-item query, Protocol A): useSessionLive (useLiveSessionData + useSessionDetailBundle), useSessionMoney (the viewer's hold row, sessionPaymentGateState, the hold deadline, the attestation write), useSessionWrites (every write the band and Dialogs make), useSessionRoles (host, manager, capabilities, the pending-application counts) and useSessionInfo (weather, profile, friends-going, the gating group's name and buildSessionInfoModel, the one model, S12). The 경기 rows read the bulk useSessionMatchSets (Protocol A).
  • Clock (S11): useNowTicking once per render, every second while a seat is held (the countdown) and every 30 seconds otherwise; every rule takes the injected now.
  • Rules (lane V, session-detail/view/): buildSessionBand (first match wins: failed RSVP, cancelled, approved guest, check-in, then the phase by role and seat), buildSessionNotice (one at a time by priority), deriveArrivalCell, isGameDay. The page view (page/session-page-view.ts) joins them with the content builders: Hero facts and the viewer's Tag, roster faces and head counts, the three match rows, the 정보 rows, the tier gate (the app's isTierRsvpGateBlocked). The seat is derived from refundState alone (refundSeatOf in derive-page-input.ts): pending on a cancelled event or a cancelled seat is refund; acknowledged on a cancelled seat of a standing event is refunded; a paid cancelled seat with no refund (a late cancel, or a row an old build left) is none, and the page asks nothing of it. The paid player's 참석 취소 Dialog (use-session-dialogs.ts, session-dialog-specs.ts) asks refundDueOnCancel(sessionStart, now) with the injected clock: a refund due says "{9월 18일 (금) 09:00}까지 취소하면 참가비 10,000원을 환불받아요", too late says "환불 기한인 {moment}이 지나 참가비 10,000원은 돌려받지 못해요" (the moment is refundDeadline through the app's formatDate and formatHm, never "24시간"); a free, waived or unpaid seat keeps today's Dialog. The Dialog sends no flag: the server's clock at the cancel decides (00752).
  • Words: the Notice and the band map a spec to words in notice-view.ts and band-view.ts; a word another page already says is read from where it lives, the rest are sessionPage.
  • Presses: lane B's useSessionBandHandlers and useSessionNoticeHandlers (RSVP writes, the Dialogs, the doors) over lane C's overlay controller (입금 안내, the arrival stamp with its Toast, 참석 시간, 매너 태그). The draw is one seam, openDraw (S2): a push to 대진 짜기 (L3-47, routes.sessionDraw); the 관리 sheet's row is a route door to it. The push soft prompt opens once after a seat is won, on a session with no fee.
  • Money doors (S3): 정산하기, 참가비 관리 and the Notice 입금 확인 N건 open routes.sessionPayments until L3-35 carries fee confirmation. The Notice 입금 확인 N건 + 확인하기 is drawn for the host and for a dues manager (deriveSessionToolAccess().canSeeSessionMoney, the one predicate the host tools share) on an open session; the count is other people's submitted rows (never the viewer's own); an official without dues authority and a member see none.
  • A 매치's account (owner decision 7, F2 cut 2): a 매치 (no club) keeps its own account: the host enters it on create or on 일정 수정 and only the host and a confirmed seat read it (get_session_deposit_account; a seat inside its payment hold is confirmed; a waitlisted viewer, a cancelled seat and an outsider never call the read, and it is never drawn from the disk cache). Every place that shows "where do I send the fee" reads the ONE useDepositAccount(session, { holdsSeat }): the club's account for a club session, the 매치's own for a pickup. The viewer holds a seat when myRsvp.status === 'confirmed'. The [CountdownNotice] for a held seat carries the account as its body (SessionNotice takes the session, not the club id; an expired hold passes holdsSeat: false). 입금 안내 (Sheet) for a 매치 shows the same rows (금액, 은행, 계좌번호 with copy, 예금주; holder = the host); with none entered: 호스트가 아직 계좌를 알려주지 않았어요 / 단톡방에서 호스트에게 물어보세요 + 단톡방 열기 (sd-o-pay-pickup, sd-o-pay-pickup-none; the club's words stay 총무에게 문의해 주세요). A waitlisted viewer of a 매치 sees the fee line and the position Tag only (sd-wait-pickup).
  • Edit mode (the host of a paid 매치): under the record's rows a group [SectionHead 입금 계좌, note 참석이 확정된 분께만 보여요] + [CellGroup] 은행 [PickerCell → ValueSheet of the twelve banks] · 계좌번호 [Cell edit → Edit Sheet] · 예금주 [Cell edit → Edit Sheet], an empty answer reading 추가 (sd-edit-pickup, sd-edit-pickup-none). The Cells edit a pending copy; 완료 writes the session's fields first and then the account (set_session_deposit_account, or clear_session_deposit_account when the form is emptied or the fee went free); a failed account write is a danger helper under the group and the mode stays open. When seats reported a transfer and the stored account changes, 완료 asks first: 입금 계좌를 바꿀까요? / 입금을 알린 N명에게 계좌가 바뀌었다고 알려 드려요 · 닫기 · 계좌 바꾸기 (sd-edit-account-confirm; the notice the server sends carries no number). A half-given or malformed account shows its lines under the answers and 완료 writes nothing.

Anatomy.

├─ [DetailShell] title=venue onBack header=collapsing collapsingLook=circles refreshControl perfLabel=session-detail
│    actions ▸ viewer [공유][저장][⋯ 신고] · host or official [공유][저장][관리 gear + badge]  (lane B, useSessionHeader)
│    aside ▸ [SessionActionPane] (desktop supporting pane, 840+)   footer ▸ band
│  ├─ [MediaBand] state={loading | error (no coordinates: court tile, no door) | ready} pin media=[RemoteImage the hero map]
│  │             door ▸ ready & venue page → [장소 보기] → /venues/[id] (L2-04)
│  ├─ ▸ a state applies → 24 + [Notice | CountdownNotice] testID=session-detail.notice
│  ├─ [Hero] title=venue facts=[calendar: day · hours][pin: address · surface + courts][card: fee + viewer Tag]
│  ├─ [SectionHead 참석 count=[6/8, 2자리 남음, 예비 2명] action=전체 보기] → participants (L3-35)
│  │  └─ [Roster] ≤4 faces (host, 나, the rest) or 3 + the door · door → participants · a face → 공개 프로필 (L2-06)
│  ├─ ▸ a draw exists & not cancelled → [SectionHead 경기 action=전체 보기] → scorecard (L3-38)
│  │  └─ ≤3 rows ▸ scored [ResultRow] · to play, on court or unscored [Block match]
│  ├─ [SectionHead 정보]
│  │  ├─ [CellGroup] 클럽 nav (not a pickup) → /clubs/[id] (L2-01) · 호스트 nav (plain on the host's page) → 공개 프로필 (L2-06)
│  │  │              종목 · 대상 · 코트 · 신청 마감 · 취소 규정 · 날씨   (a fact not stated is no row)
│  │  ├─ ▸ four more → [ExpandToggle "4개 더 보기"] → 코트 종류 · 경기 방식 · 최대 인원 · 공지
│  │  └─ [CellGroup] ▸ a confirmed player, from check-in's opening → [Cell value 참석 시간] → 참석 시간 Sheet
│  │                 ▸ a person who is in, or a manager → [Cell nav 단톡방 열기] → the session's room
│  ├─ 40 tail
│  ├─ {header node} 관리 sheet · 신고 · Dialogs   {overlay node} 입금 안내 · 도착 Toast · 참석 시간 · 매너 태그
│  └─ [SessionDetailOverlays] push soft prompt  (behind promptPush)
└─ footer ▸ a band applies → [CtaBand] one primary (+ a danger line only for a failed RSVP or the press of a grey button)

Tagged to a 대항전 (batch C):

├─ 정보 group: 클럽 · 대항전 ▸ (the opponent's name, nav [Cell] to its 클럽 홈; sd-challenge) · 호스트 · …
├─ the edit state (sd-edit): a 경기 유형 row after 종목 (value = the type; line 2 "vs 클럽" when tagged; none for a 매치)
│  └─ [EditControlModal] 경기 유형 with the wizard's own Body (cs-ed-run, -run-chosen, -run-none): the club's running
│     challenges for the draft's date; a session whose date holds none does not offer 대항전, a tagged one still shows its tag
└─ under the rows: [HelperLine danger] the line of a refused save (never a Toast)

States.

  • Write failures (ruling EP-3): 취소, 삭제, 몰수, 종료, 환불 확인 and 시작, the arrival stamps, the times Sheet and the deposit Sheet tell a failure in a Dialog in the app's words (sessionRefusal); ending with unscored matches keeps its own alert; never a Toast.

  • Offline (F1c, the app's root strip says it): offline a cached session stays (sd-stale); 참석, 참석 취소, 입금 알리기 and 시작 queue durably and stay live, 도착했어요 and 환불 확인 wait; the manage door shows no 입금 확인 count.

  • Loading: the band's gray ground, Hero loading, Roster loading, four Cell skeletons, no actions. Error: EmptyState kind=error ("일정을 불러오지 못했어요" through the kit's copy) with retry. Offline cold (no cached session, the query paused): EmptyState kind=offline with retry. Not found: "일정을 찾을 수 없어요" + "삭제됐거나 주소가 바뀌었어요" + 경기 보기 → 경기 tab.

  • Notice, one at a time: 환불 대기 {amount} + 호스트가 보내면 환불받았어요를 눌러 주세요 (a refund open for the viewer: a cancelled event's, or a cancel the player made with the window left, on an open or a live event; board sd-refund, sd-refund-open) · 환불을 받았어요 + 환불이 열린 일정에는 다시 참석할 수 없어요 (the refund received on an event that stands; neutral; sd-refund-done) · 미납 with the clock and the club's account, or 시간이 지나 자리가 풀렸어요 + 다시 신청하기 · 확인 대기 중이에요 · 참석 시간을 정하지 않았어요 + 시간 설정 (a joined player within two hours before the start) · 입력할 점수가 N개 있어요 + 점수 입력, or for the host 처리할 점수가 있어요 + 확인하기 with its counts in the body · 진행 중 · 취소된 일정이에요 · 신청 N건 + 확인하기 (the host's applicants, then an official's) · 입금 확인 N건 + 확인하기 · 끝난 일정이에요 (a finished event the viewer did not play).

  • Refund open (live or open event): Tag 환불 대기, Notice with the amount, band 환불받았어요 (online only, as before). Refund received: neutral Notice, no band. Fee kept: nothing special; 참석하기 is offered again on the standing paid row.

  • Band by phase, role and seat: open: host 초대 (game day: 대진 짜기, then 경기 시작 once a draw is saved); joined 참석 취소 (tonal; none once stamped); waitlisted 예비 취소; a held seat 입금했다고 알리기 (expired: 참석하기); a tier or cool-down gate a grey 참석하기 whose press shows the danger line; else 참석하기; a failed RSVP keeps 참석하기 with its danger line; the server's refund_after_cancel refusal (the typed hint on DomainError) says 환불이 열린 일정에는 다시 참석할 수 없어요 instead of the generic line. Closed: host 대진 짜기 / 경기 시작, joined 참석 취소, waitlisted 예비 취소, else 예비 등록. Check-in (30 minutes before the start until stamped, a member only): 도착했어요 first. Live: host 스코어카드 보기, joined 실시간 스코어카드 보기. Finished: host 정산, joined 매너 태그 남기기. A refund open for the viewer, in any phase: 환불받았어요 (the payer's witness; there is no host "refunded" button). A refund received: none, so a seat whose refund opened is not retaken. Cancelled otherwise: none. An approved guest: 참석 확정하기. Loading and editing: none.

  • Hero Tag: the host none; else 참석 확정 (free), 완납 (paid), 미납 (a hold), 확인 대기 (deposit sent), 환불 대기 (a refund open), or the waitlist place; a received refund and a kept fee draw no Tag.

  • Edit mode (the host or a manager of an open session): the bar becomes 취소 · 완료 under 일정 수정, the page is the session record's rows (pencil, or a lock giving its reason) with the existing field editors; no Hero, roster or band; leaving with changes asks 수정을 그만둘까요?.

  • English labels, 200% text (the Roster is two columns, Cells stack) and dark are the kit's.

(Batch C.) tagged (sd-challenge) · untagged or opponent not read: no 대항전 row · the other club's member without a seat or with a pending, rejected or cancelled application: the route is replaced by 매치 미리보기 (a member, a member of both clubs, the host, a seat holder and an approved guest keep this page) · a confirmed seat of the other club's member: the hold Notice carries the host club's account; a waitlisted or unapproved viewer never asks for it · edit: a date outside the period, or any date move of a settled challenge's session, is told in the validate Dialog before the save (a cancelled challenge's session moves freely) · a refused save: the tag hints (challengeRefusalMessage), a fee locked by holds, or the plain 저장하지 못했어요 line under the rows; useUpdateSession names errorSurface: 'helper' and its only caller is this page.

  • 2026-11-13 (UX-7, the state helper): Loading, error, offline (cold) and not found are the app's one PageStateView inside a bare Header; offline wins over error when both hold; not found offers 경기 보기.

Actions.

  • Band and Notice presses: lane B's handlers (band/use-session-band-handlers.ts): 참석하기 / 예비 등록 write the RSVP (a won seat on a paid session opens 입금 안내, else the push prompt once); 참석 취소 asks a Dialog; 입금했다고 알리기 and 도착했어요 and 매너 태그 남기기 and 시간 설정 are lane C's Sheets; 대진 짜기 is openDraw (a push to /sessions/[id]/draw, L3-47); 경기 시작 asks a Dialog; 초대 shares; 스코어카드 보기 → /match-board (L3-39), 실시간 스코어카드 보기 and 점수 입력 → /sessions/[id]/scorecard (L3-38), 정산 and 입금 확인 → /sessions/[id]/payments (S3), the host's 확인하기 on applicants → /sessions/[id]/host (L3-37), an official's → participants.
  • Doors from the page: 장소 보기 → L2-04; 클럽 → L2-01; 호스트 and a roster face → L2-06; 참석 head and the Roster's door → L3-35; 경기 head and a row → L3-38; 단톡방 열기 → the session's room (reopened, or composed with the confirmed roster). The 관리 sheet's doors (L3-35/36/37, 경기 규칙 L4-06, 참가비 관리) and 신고 are lane B's (header/).
  • 공유 is the native share sheet and 저장 toggles; neither is a ⋯ row. Pull to refresh refetches the bundle.

(Batch C.) 대항전 row → routes.club(opponentId) · 경기 유형 row → its modal; 완료 applies into the edit draft, the page's 완료 sends eventType and, when it changed, challengeId (null unties; the server refuses once a match is scored).

Gaps.

  • 모집 인원 in edit mode still opens the legacy MaxPlayersEditModal; the board's count Sheet (the capacityEditor Body from create-session/editors) needs a WizardDraft adapter for the edit draft.
  • The Match row (upcoming, playing and enter states as rows) is not in the kit (MatchShell is internal); until it is exported, unscored matches draw as the match Block and scored ones as ResultRow.
  • The 코트 fact reads 코트 N면 until usedCourtsLabel (the 3~4번 코트 runs) is exported from @twomore/app.
  • The Hero's 확인 대기 and 환불 대기 Tags are neutral (no status wrapper or info tone exists for them); the money words are one set in the app and on the board: 미납 / 확인 대기 / 완납 (batch 63).
  • The Dialog's sentence is decided by the device clock and the device's zone (the start is combined as local time); the server decides with its own clock and the session's zone at the instant of the cancel, so a cancel made in the last seconds before the moment, or by a viewer in another zone, can be refunded or kept against what the Dialog said. A cancelled paid event's host refund Notice is on 참석자 관리 (L3-35), not here. The board's 환불받았어요 is written 환불받았어요 in the band and the Notice; the key was 환불 받았어요 and now matches.
  • A 매치 has no explicit 계좌 지우기 control (the board draws none): its account clears when the fee goes free, or when the host empties the number and the holder AND the bank is blank, but the bank Cell cannot be blanked, so in practice only a fee change clears it (reported to the owner). The board's 입력 is the app's 추가.
  • A group-gated or guest-gated waitlisted viewer is not told why (the board draws no line for it).
  • The old tournament 이어보기 chip and the 경기 방식 row left the page with the old 참석 section: a tournament's matches read their group in the 경기 rows (the board retires /tournament-board into the scorecard), and the draw is reached through openDraw (L3-47).
  • L3-35 fee confirmation and the 단톡방 room reuse today's flows (S3); L3-47 is its own page.

(Batch C.) The page's title and the group chat's name keep the format + type word; the other club's member's roster is only themselves (the server's rule).


Match Rules Editor (경기 규칙) ​

  • Route: /sessions/[sessionId]/match-rules (a saved session) and /create-session/match-rules (the session being made, over the wizard's store)
  • Screen: MatchRulesScreen — packages/features/sessions/src/match-rules-screen.tsx (also MatchRulesDraftScreen; the body and both hosts are under match-rules/)
  • Shell: DetailShell · title t().common.matchRules · sub the club and the day (the draft: the club alone) · actions: the ⋯ (one row, 기본값으로 되돌리기) · footer CtaBand 저장
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST2, 2026-11-13), States: PageStateView inside the page Header; the wall is the helper's no-permission; not found offers 경기 보기.
  • Notes: 2026-11-02 (W4 batch 2, flat-v2-forms#mr-ideal): rebuilt as one body in two hosts. The scorecard's per-match override keeps its own Sheet and editor (components/match-rules-editor.tsx, match-rules-editor-sheet.tsx, untouched): a recorded divergence, since that Sheet cannot hold the 세트 수 Picker Cell this page uses.

Purpose. Where a session's match rules are set, as one list of value rows (library § 1.22, rule 0). The saved host (match-rules/saved-host.tsx) reads the session and writes through useUpdateSessionRules; gated on canEditMatchRules (useSessionCapabilities, domain/rules/session-capabilities.rules.ts) = the server RPC's own set, club owner/admin/match_director (update_session_rules, 00147/00152), so a 경기이사 reaches it; a pickup session's host is the owner of their personal pickup club (hostPickupGame, 00011/00672), so they reach it too. The draft host (match-rules/draft-host.tsx) is pushed from step 5 of 일정 만들기 (a Picker Cell cannot open from a Sheet) and reads and writes the wizard store's formData.matchRules and matchRulesEdited; there is no wall (the wizard route is role-gated), and with no clubId in the store (no wizard underneath) it is the not-found frame. The wizard adopts the saved rules when it regains focus (create-session/adopt-stored-rules.ts; on web the push unmounts the wizard and its resume path restores them).

Anatomy.

└─ [DetailShell] title=경기 규칙 · sub=[club, day] · actions=[⋯ → 기본값으로 되돌리기] · footer=[CtaBand] 저장
   ├─ [SectionHead first] 경기 방식 → [CellGroup]
   │   ├─ [PickerCell] 세트 수 → [ValueSheet] the registry's six formats (a pick writes formatKey + the legacy format)
   │   └─ freeform single set only: [CountRow] 한 세트 게임 수 (2–20) · [CountRow] 차이로 승리 (게임) (1–3) · [Cell edit] 게임 방식 → [EditSheet done] ChoiceSet 듀스 | 노애드
   ├─ [SectionHead] 점수 → [CellGroup]
   │   ├─ [CountRow] 시작 점수 (양쪽 게임) (0–5)
   │   └─ freeform only: [Cell edit] 타이브레이크 (value "5-5에서 7점, 2점 차" | 안 함) → [EditSheet done] [CheckRow check] + expand: ChoiceSet 시작 시점 (higher first) · [CountRow] 점수 (점) (5–15) · [CountRow] 점수 차 (점) (1–3)
   └─ [CellGroup] [Cell disclosure] 고급 설정 (label never changes) → open: [Cell edit] 시간 제한 ("60분, 서든데스 1게임") → [EditSheet done] [CheckRow check] + expand: [CountRow] 제한 시간 (분) (10–180) · radio rows 시간이 지나면; [Cell edit] 무승부 허용 (허용 | 안 함) → [EditSheet done] one check row

CountRow (match-rules/count-row.tsx) is a choice Cell with a typed Stepper as its trailing node, the unit in the label; the Stepper is named by the label without the unit. A preset format hides the four set rows and keeps their stored values.

States.

  • Write failures (ruling EP-3): a refused save is the rules Sheet's saveError line; never a Toast.

  • Offline (F1c, the app's root strip says it): offline the form stays and 저장 waits.

  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry (SessionOfflineGate)

  • error → EmptyState kind="error" + retry

  • loading → two groups of Cell skeletons under the same Header; no ⋯ and no CTA band

  • missing record (settled, no session row) → EmptyState kind="notfound" 일정을 찾을 수 없어요 / 삭제됐거나 주소가 바뀌었어요 + 돌아가기

  • wall: non-canEditMatchRules → EmptyState kind="permission" 운영진만 바꿀 수 있어요 / 경기 규칙은 운영진과 경기이사가 바꿀 수 있어요 (a deep link only)

  • clean → 저장 soft; dirty (!matchRulesEqual(draft, saved); rules the schema refuses count as dirty) → 저장 ready; leaving a dirty page asks the edit guard (수정을 그만둘까요?) through useLeaveGuard, held while saving; a save releases it before going back

  • a value outside its Stepper's bounds (a game count of 24 saved by an earlier version; tiebreak points outside 5–15, a limit outside 10–180) → shown as it is, with its row's line naming the bound (one key per row), the primary stays enabled, nothing is written; a line inside 고급 설정 opens it

  • saving → 저장 중...; offline → 저장 disabled; failed write (errorSurface: 'helper') or rules the schema refuses → the band's danger helper 저장하지 못했어요. 다시 시도해 주세요

  • the draft host: 저장 → store write + back + Toast 경기 규칙이 저장됐어요; the header's subtitle is the club alone

  • 2026-11-13 (UX-7, the state helper): PageStateView inside the page Header; the wall is the helper's no-permission; not found offers 경기 보기.

Actions.

  • 세트 수 → format Sheet; the three counts step or type in place; 게임 방식, 타이브레이크, 시간 제한, 무승부 허용 → their Edit Sheet (× in the title, one 완료 that waits for a change; the row then shows the new value)
  • 고급 설정 → draws its rows (chevron turns)
  • ⋯ → 기본값으로 되돌리기 (the Korean defaults; 저장 is soft again only when the saved rules already are the defaults)
  • 저장 → MatchRulesSchema guard → useUpdateSessionRules → back (saved host) | updateFormData({ matchRules, matchRulesEdited: true }) → back (draft host)
  • back

2026-11-02 (what is gone). The "players will be notified" helper beside 저장 and useSessionRsvps with it (no board slot; owner-visible, recorded in the rebuild log); the underlined reset link; the try/catch + captureCriticalError of the old save (a safe parse and the row lines replace it); step 5's summary Card and its Sheet.

Gaps. The board draws no frame for the draft host (recorded as a ruling). The wizard's step 5 value reads 한국 표준 규칙 or 이전에 사용한 규칙 whether or not the person edited the rules; the facts line under it is what says what the rules are now.


Session Participants (참석자 관리, L3-35) ​

  • Route: /sessions/[sessionId]/participants
  • Screen: ParticipantsScreen — packages/features/sessions/src/participants-screen.tsx (a thin entry; the page is under participants/)
  • Shell: DetailShell scroll={false} titled 참석자 관리 (staff) or 참석자 (payer, member), sub line the club then the day, over one FeedList; staff of a club that tracks attendance get the switcher in the shell's subhead; a payer gets a pinned band; 정산 나누기 is a second shell of the same route
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST2, 2026-11-13), States: PageStateView (offline, error, loading, not found with 경기 보기, no-permission); 대진 짜기 keeps its own ended and blocked walls; 참석 확인 keeps its quiet states; 호스트 관리 withholds the applicant list offline.

Purpose. Everyone in the session on one page of PersonRows, sectioned by what the viewer must do, money first: the host confirms and reminds in the rows, a member who owes sees where to pay, every other member sees names. Built W4 batch 9 to flat-v2-list-a.jsx#participants (L3-35). The Payments entry (/sessions/[sessionId]/payments, SessionPaymentsScreen) is removed: its roster, 납부확인 and 면제, refund badges and cost split live here, and every door that led to it (일정 상세's 관리 sheet and band, the push for a submitted transfer and an unsettled refund, the unsettled-pool Notice) opens this page.

Now. useParticipantsPage(sessionId) runs every hook before any state is chosen and hands the page one state (error, offline, loading, missing, ready) with a PageView:

  • Reads: useLiveSessionData with management profiles (the session, the roster, the names), useSessionPayments (the fees; a failed read is the error state, never an empty roster), useSessionToolAccess + useSessionCapabilities + useClubRole (the rights), useSessionApplications (only for whoever may decide an application, on a session that takes guests), useSessionFirstTimers (staff only, the server refuses anyone else), useClub (the name, attendanceTrackingEnabled), useClubBankAccount (a payer's account), useNowTicking every 30 s for staff (the undo window).
  • Rules (pure, each tested): buildPageView (page-view.ts: staff, payer or member; the title; 정산 나누기 only for owner or admin of a free session with no rows; the switcher only for staff of a club that tracks attendance), buildRosterSections (roster-sections.ts: 확인 대기, 미납, 참석 확정, 예비, 신청, 취소됨; the refund regroup of a cancelled session), fee-facts.ts (the line 2 of a fee: "{일시} 입금 알림", "오늘 20:00까지", "오늘 입금 확인", 참가비 면제, and a cancelled seat's fee read from refundState alone: 환불 대기 (pending), 환불 확인됨 (acknowledged), 참가비 유지 (a paid seat cancelled with no refund opened, which an old build's row also means); refundNoticeOf: the Notice that leads, a cancelled session's or a live one's N refunds to send; refund-grant.ts: useRefundGrant, the host's exception behind useOpenSessionRefund, silent, a typed refusal worded by grantRefusalMessage), isFeeUndoable (10 minutes, the recorder, a manual paid row), personActions (the Sheet's commands from the row and the rights), buildPayerGuide, settlement-model.ts, listItemsOf.
  • Verbs (all existing hooks): 입금 확인 useMarkPaymentPaid in one tap (no Dialog, no Toast); 되돌리기 useUnsettleSessionPayment; 알림 and 모두 알리기 useRemindSessionFee; 참가비 면제 useMarkPaymentWaived behind a Dialog; 내보내기 useCancelRsvp behind a Dialog; 참석 확정으로 올리기 useAdmitWaitlistedRsvp; 승인 / 거절 useDecideGuestApplication; 입금했다고 알리기 useAttestSessionPayment; 정산 내역 만들기 useGeneratePayments. A write in flight is keyed (useBusyKeys): its control is inert and a second tap is refused.
  • Authority (brief H10): money is the host or a dues manager, never canManageSession; 내보내기 is owner or admin; 승인 / 거절 is owner, admin or match director; a Sheet omits what the viewer cannot do, never a disabled row, and a row with nothing to offer opens the profile. Nothing on the viewer's own row (the server refuses a payer confirming their own fee, 00536).
  • Words: sessionParticipants for what is new; the rest read from where it lives (common, clubDues, sessionDetail, sessionOverlays, clubBankAccount, costSplitting, sessionPage, createSessionEditors.feeUnit).

Anatomy.

├─ [DetailShell] title=참석자 관리 | 참석자  sub=[club, day]  onBack  scroll=false
│  ├─ actions   ▸ ⋯ → 정산 나누기 (owner/admin, free session, no rows yet; otherwise no ⋯)
│  ├─ subhead   ▸ [SessionToolsSwitcher active=participants]  (staff of a club that tracks attendance)
│  ├─ [FeedList] perfLabel=session-participants
│  │    header ▸ staff: 24 · member: 12 · refund: Notice 환불 확인이 아직 남았어요 (a cancelled session) or 환불할 참가비가 N건 있어요 + 보낸 뒤 환불받았다는 확인을 받아 주세요 (a live session with N refunds to send) · payer: PayerGuideView
│  │              (head 입금 안내 · Strip [낼 금액 | 기한 + D-day] · 은행 / 계좌번호 (copy) / 예금주)
│  │    head   ▸ [SectionHead] 확인 대기 · 미납 (bulk 모두 알리기) · 참석 확정 · 예비 · 신청 · 취소됨   (or 환불 대기 · 환불 확인됨)
│  │             취소됨 rows (staff who handle money): 환불 대기 (resting), 환불 확인됨 (resting), 참가비 유지 (a tap opens the person's Sheet: 참석자 · name row · 환불 대기로 바꾸기 → [AskDialog] "{이름}님의 참가비를 돌려줄까요?" · "늦은 취소라 받은 참가비 10,000원이 환불 대기로 바뀌고, {이름}님께 알림이 가요" · 닫기 | 환불 대기로 바꾸기). The command is offered to exactly who may confirm a payment (deriveSessionToolAccess().canSeeSessionMoney, through personActions' money flag), never to the viewer's own row or the host's own seat, and is a server write (write, not offlineOk).
│  │    rows   ▸ [PersonRow context=fees]      name · fee state · Tag 호스트 / 게스트 / 첫 참석 · [입금 확인 | 알림 | 되돌리기]
│  │             [PersonRow context=waitlist]  name · 예비 N번
│  │             [PersonRow context=applicants] name · "{ago} 신청" · [AnswerPair 승인 | 거절]
│  │    empty  ▸ [EmptyState first] 아직 참석자가 없어요
│  ├─ footer    ▸ payer only: [CtaBand] 입금했다고 알리기 (rests once reported or when the hold ran out)
│  ├─ [PersonActionSheet]  참석자 · name row → 공개 프로필 · 입금 확인 | 참석 확정으로 올리기 · (destructive) 참가비 면제 · 내보내기
│  └─ [AskDialog]  내보내기 / 참가비 면제 / 거절 · names the person and the consequence · 닫기 | danger
└─ 정산 나누기: [DetailShell] same title · [SectionHead first] 정산 · Cells 총 비용 / 참석 인원 / 1인당 금액 · [Field 1인당 금액] · footer [CtaBand] 정산 내역 만들기

States.

  • Write failures (ruling EP-3): reminders, 되돌리기, payment generation, waitlist admission and marking paid or waived tell a failure in a Dialog (a queueable write only on its final failure); never a Toast.

  • Offline (F1c, the app's root strip says it): offline the cached roster stays; 입금 확인, 참가비 면제 and 내보내기 queue durably and stay live, 올리기 waits.

  • Staff, ideal: all six sections; 확인 대기 is absent when nobody reported; 미납 is absent when nobody owes; with both absent all of the confirmed are in 참석 확정 (fe-quiet).

  • Just confirmed: the row moves to 참석 확정 first, saying "오늘 입금 확인", with its own 되돌리기 for 10 minutes; after the window the line reads "9월 18일 입금 확인" and the button is gone.

  • Refunds: a cancelled session with paid fees regroups by refund state under a task Notice, no CTA; on a live session the 취소됨 rows carry the three fee states and the Notice counts the refunds to send (fe-refund-live, fe-refund-granted); with nothing left to send the Notice goes. The host has no "refunded" button: the payer's 환불받았어요 on 일정 상세 closes it. A late canceller's kept fee is the host's exception (fe-refund-menu, fe-refund-grant): the payer is told by the server, the seat can no longer be retaken (refund_after_cancel).

  • The deposit account is the one useDepositAccount(session, { holdsSeat }) (a 매치's own account or the club's), with holdsSeat true for the viewer's confirmed seat; the host's read is on by being the host.

  • Member: 참석 확정 and 예비 only, names and tags, rows open the profile; no fees, no verbs, no 취소됨.

  • Payer: 입금 안내 first, then the people; the band reports the transfer; no account on file says so in a Notice and the band rests.

  • Loading: one skeleton at the person pitch (eight) under the Header; Empty: calm text, no button; Offline (cold): the offline state, never an empty roster; Error: the error state with a retry that refetches the roster and the fees; Missing: the shared not-found state.

  • A manager without money rights (경기이사) sees the people sections and no fee state or verbs.

  • 2026-11-13 (UX-7, the state helper): PageStateView (offline, error, loading, not found with 경기 보기, no-permission).

Actions.

  • A row → the person's Sheet when the viewer has a command for that person, else 공개 프로필 (L2-06); the Sheet's name row → 공개 프로필.
  • 입금 확인 → settle_manual_session_payment paid → the row moves; 되돌리기 → unsettle_manual_session_payment; 알림 / 모두 알리기 → remind_session_fee (the hook's own Toast); 참가비 면제 and 내보내기 and 거절 → AskDialog first; 승인 and 참석 확정으로 올리기 at once.
  • Switcher 참석 확인 → L3-36 (replace, so Back returns to 일정 상세).
  • 환불 대기로 바꾸기 (a 참가비 유지 row's Sheet) → Dialog → useOpenSessionRefund({ paymentId, sessionId }) silent; a refusal (refund_not_authorized, refund_not_open, refund_payment_not_found, any other) is a one-button Dialog over the roster, never a Toast.
  • ⋯ → 정산 나누기 (an in-place mode; Back returns to the roster) → 정산 내역 만들기 → the rows exist and the page shows them.
  • Doors in: 일정 상세's 관리 sheet (참석자 관리 and 참가비 관리, one page), the band 정산하기 and 입금 확인 N건, the push for a submitted transfer and for an unsettled refund (the host and dues managers), the unsettled-pool Notice on 대진 짜기.

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 알림 Toast is the hook's ("1명에게 알림을 보냈어요"), not the board's "{이름}님에게 알림을 보냈어요": the hook's successMessage does not know the name. The next pass over use-session-payment-actions.ts can pass it.
  • A single cancellation now records its obligation at the cancel (00752): a player's own cancel with at least the window left, a removal at any hour, and a member who leaves or is removed from the club open a refund; a later self-cancel keeps the fee (참가비 유지) until the host chooses otherwise. A kept fee on a session that is later cancelled by the host is refunded by that cancel and leaves the exception.
  • 정산 나누기 is offered only to owner or admin: session_payments has one INSERT policy (00145), so a host who is not one of them would be refused. A free completed session's band 정산하기 lands on the roster; the split is behind ⋯.
  • A paid no-show Tag (the old roster's 불참 flag) is not on the board and was dropped; 참석 확인 holds the attendance.
  • A cached roster read offline stays under the root strip: the kit disables the writes that do not queue; the fee and attendance writes that queue durably stay live.
  • A member's own window line ("19:30~21:00") is readable by staff only (00707), so a plain member's rows draw none.
  • The applications section repeats 호스트 관리's rows for a session that approves guests (useSessionApplications is one cache key); the row and the Dialog are built twice (participants/ and host-applicants/) until the two lanes' rows are folded into one.


Match Preview (매치 미리보기, L2-03) ​

  • Route: /sessions/[sessionId]/preview
  • Screen: packages/features/sessions/src/public-pickup-preview-screen.tsx (PublicPickupPreviewScreen)
  • Shell: DetailShell header="collapsing" collapsingLook="circles" (the MediaBand under the bar; a bare back-only DetailShell for error, offline cold and not found)
  • Design: unreviewed
  • Verified: 2026-11-15
  • Batch 58 (ST2, 2026-11-13), States: Same set; not found offers 매치 찾기.

Purpose. What a non-member or a signed-out visitor sees before applying: the map, the venue, day and fee, how many are coming (counts, never names), who hosts it, and the one apply verb that follows the approval mode; built W4 batch 8 to flat-v2-detail.jsx#pv (L2-03). The page the other club's member of a tagged session applies from (C14): the existing application path, so capacity, the level band, the cool-down and the fee apply as for any applicant.

Now. One composition, useMatchPreview(sessionId) (match-preview/), runs every hook before any early return:

  • Reads: useSessionQuery, usePublicSessionAttendance (the public aggregate only), useMyApplicationForSession, useVenue, useClub (a club's public session names its club; a pickup's synthetic club is not shown), useProfile (the host's name), useClubRole. Writes: useApplyToSession, useUpdateApplicationStatus (withdraw).
  • Clock: useNowTicking once a minute, passed to buildSessionInfoModel (the deadline); no builder reads the clock.
  • Rules (pure, preview-rules.ts, preview-band.ts): previewPhase (error, offline cold, loading, not found, ready; a failed refetch keeps its data), shouldReplaceWithDetail (not public, the host, a club member, an approved guest), previewAttendance (unavailable stays unknown), derivePreviewBand (first match wins: closed, signed out, pending, rejected, else the apply verb of the approval mode).
  • View (preview-view.ts): the Hero is 일정 상세's buildHeroFacts with the eyebrow 매치 (일정 for a club's public session); the 정보 rows are 일정 상세's buildInfoRows (club, host, 종목, 대상, 코트, 신청 마감) plus 참석 방식 and the venue's 운영 시간 and 시설 (preview-info.ts).
  • Shared with 일정 상세: SessionMediaBand, SessionHero (new optional eyebrow), SessionInfoGroup (arrival, chat and times props now optional), BareShell and SessionOffline, buildSessionHeaderActions, sessionDayTitle, ReportModal.
  • Words: the screen's own are matchPreview; 바로 참석하기, 로그인하고 신청하기, 신청 취소, 거절됨, 신청 마감 and the failure line are read from sessions, common and sessionPage.

Anatomy.

├─ [DetailShell] title=venue onBack header=collapsing collapsingLook=circles perfLabel=match-preview
│    actions ▸ [공유 (disabled until the club loads)] [저장 (signed in)] [⋯ 신고 (signed in)]
│  ├─ [MediaBand] state={loading | error (no coordinates) | ready} pin · door ▸ [장소 보기] → /venues/[id] (L2-04)
│  ├─ ▸ a pending request & not closed → 24 + [Notice neutral "승인 대기 중이에요"]
│  ├─ [Hero] eyebrow={매치 | 일정} title=venue facts=[calendar: day · hours][pin: address · surface + courts][card: fee]
│  ├─ [SectionHead 참석 count=[4/8, 4자리 남음]]  (no 전체 보기: the list is members')
│  │  └─ [anonymous faces] ≤6 silhouettes, one group "참석 4명, 자리 4개 남음"  ▸ nobody yet (0) → the line `common.noParticipantsYet`, no faces  ▸ count unavailable → one line, no claim
│  ├─ [SectionHead 정보]
│  │  └─ [CellGroup] 클럽 nav (a club's public session) → /clubs/[id] (L2-01) · 호스트 nav → 공개 프로필 (L2-06)
│  │                 참석 방식 · 종목 · 대상 · 코트 · 신청 마감 · 운영 시간 · 시설   (a fact not stated is no row)
│  └─ 40 tail   {header node} 신고 Sheet
└─ footer ▸ [CtaBand] one primary (+ a danger line only after its write failed)

Tagged to a 대항전 (batch C):

├─ [CellGroup] 클럽 ▸ · 대항전 ▸ (the opponent) · 호스트 ▸ · 참석 방식 (바로 참석 | 승인 후 참석) · …

States.

  • Write failures (ruling EP-3): applying and the host's decisions say their refusal (rate_limited_applications) on the band's helper; the 메시지 failure is a Dialog on the host card; never a Toast.

  • Loading: the band's gray ground, Hero loading, 정보 head and four Cell skeletons, no actions, no roster. Error: EmptyState kind=error naming 매치, with retry. Offline cold (no cached session, the query paused): EmptyState kind=offline with retry. Not found: "매치를 찾을 수 없어요" + "삭제됐거나 주소가 바뀌었어요" + 매치 찾기 → 매치 tab.

  • Band, first match wins: closed (cancelled, completed or past the deadline) disabled 신청 마감 with no Notice; signed out 로그인하고 신청하기; pending tonal 신청 취소 with the Notice; rejected disabled 거절됨; else 참석 신청하기 (host approves) or 바로 참석하기 (auto).

  • Replaced to 일정 상세, never drawn: a session that is not public, its host, a member of its club, an approved guest.

  • English labels, 200% text and dark are the kit's.

(Batch C.) tagged, 자동 승인: 바로 참석하기 (pv-challenge) · tagged, 수동 승인: 참석 신청하기 and 승인 후 참석 (pv-challenge-manual) · tagged, pending: the Notice and 신청 취소 in tonal (pv-challenge-pending).

  • 2026-11-13 (UX-7, the state helper): Same set; not found offers 매치 찾기.

Actions.

  • 참석 신청하기 / 바로 참석하기 sends the application (applicantId is the viewer, clubId the session's); a second tap during the write is ignored; a failed write keeps the verb and shows "참석을 신청하지 못했어요. 다시 시도해 주세요" under the band.
  • 로그인하고 신청하기 → routes.loginReturnTo(routes.sessionPreview(id)).
  • 신청 취소 → Dialog "신청을 취소할까요?" (닫기 / 신청 취소 danger) → withdraws; a failed write shows "신청을 취소하지 못했어요. 다시 시도해 주세요".
  • Doors: 장소 보기 → L2-04; 호스트 → L2-06; 클럽 → L2-01. 공유 is the native share sheet (the card states the public count); 저장 toggles; ⋯ 신고 reports the host as a user (S4).

(Batch C.) unchanged: the band applies to the session's club or withdraws the request; a member, the host and an approved guest are still replaced by 일정 상세.

Gaps.

  • The kit has no AvatarRow: the anonymous faces are Avatar anonymous in a row owned by preview-roster-section.tsx.
  • The mutation hooks still toast their errors (createMutationHook); the SPEC draws none, so a failure shows both until the hook takes a silent option.
  • A pending applicant whose event closes sees 신청 마감 and no 신청 취소 (the board's order); today's page kept the cancel.
  • A club the viewer cannot read (RLS) draws as a pickup: no 클럽 row, eyebrow 매치.
  • 시설 has no word for a shop, so it is left out of the row; the English eyebrow for a club's session reads "Events" (the shared plural word).
  • The "같은 장소 매치" count and the host's message button left the page (the board retires them; the profile has 메시지 보내기).
  • L0-06 (/s/[sessionId], the non-member band that should open this page) is the entry batch's.

(Batch C.) None new.


Spectator Scorecard (스코어카드, L3-38) ​

  • Route: /sessions/[sessionId]/scorecard
  • Screen: SpectatorScorecardScreen — packages/features/sessions/src/spectator-scorecard-screen.tsx, a thin export of ScorecardPage (packages/features/sessions/src/scorecard-page/scorecard-page.tsx) with PLAYER_LAYER; the same page, with the operator layer, is L3-39 (batch 12)
  • Shell: DetailShell · title 스코어카드 (layer.title) · sub the venue and the day · subhead the Tabs strip (fixed under the Header) · actions layer.headerActions (none for a player) · footer layer.band (none for a player) · refreshControl; a single match has no strip; loading, error, offline and not found are the same frame
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST2, 2026-11-13), States: PageStateView (offline, error, loading, not found with 경기 보기, no-permission); 대진 짜기 keeps its own ended and blocked walls; 참석 확인 keeps its quiet states; 호스트 관리 withholds the applicant list offline.

Purpose. Everyone's page for a session: what is in play now court by court (LIVE), the schedule, the draw, the standings, and for a player their own match with the door that fits it (점수 입력, 점수 고치기 for ten minutes after they entered it, 점수 검토 요청, 노트 남기기); built W4 batch 11 to flat-v2-detail#sc-ideal (L3-38).

Now. One page with two roles (the board's DtlScPage): useScorecardPageData(sessionId) reads once and ScorecardPage chooses what to draw; a role adds itself only through ScorecardRoleLayer slots (types.ts), so the operator view never forks it.

  • Data: one ScorecardPageData from the live bundle (useLiveSessionData, focus-gated 10 s refresh, no Realtime), the bulk sets (useSessionMatchSets), the open reviews (useSessionScoreReviews, 00718) and the viewer's note moods (useMatchNoteMoods), each one read per kind of data (Protocol A). Kind, tabs and phase are the one rule (scorecard-kind.rules.ts). The page state is loading, error, offline (nothing cached), not found or ready; a read that failed while it still holds data does not take the page down.
  • Tabs: scorecardTabs for the kind (LIVE, 일정, 순위 for a rotation and a league; LIVE, 일정, 대진표, 순위 for a tournament; 코트, 일정, 순위 for the climb; none for a single match), worded once (page-tabs.ts, held to the kit's FVK_SCORE_TAB_LABELS). The page opens on defaultScorecardTab and keeps the viewer's choice through refreshes. LIVE carries the number of matches in play, the viewer's own counted only while it is play.
  • LIVE (tabs/live-tab.tsx, rulings L5, L6): the pure courts model (model/courts.ts) lists the courts the matches are on, the viewer's court first (a resting viewer's next court first), each with the match in play as the live Block, its queue as Match rows (header: the round or group and round, then "HH:mm 시작" or "앞 경기 후"), and, after the courts, the matches with no court yet under one 대기 head. Nothing in play anywhere is the empty state with the tabs kept. Minutes tick on the client (useNowTicking), never by refetching.
  • The viewer's own match (own-match.tsx, ruling L15): the Block per the pure state rule (model/own-match-state.ts: play, overtime, enter, entered, enteredB, review, stopped, void), its door and press (model/own-match-view.ts; the entrant's 점수 고치기 replaces 점수 검토 요청 while canSelfCorrectScore(match, viewerId, now) holds, read on useCorrectionClock, which re-reads the time once when the window ends), and under a result that stands the note door as a task Cell (노트 남기기, or 노트 보기 once a mood exists). The same component draws the single match and the climb's 코트 tab.
  • Recap (ruling L4): once the session has ended, the viewer's 오늘의 기록 sits above the tab the page opens on (recap-block.tsx; the record, games and ELO from the matches, the sets and the rating impact), door 공유 → 결과 공유; its card variant is what that Sheet shows.
  • Overlays (lane P3, useScorecardOverlays): the page passes actions down and renders element once; tabs and Blocks only call the actions.
  • Level-up (F1b, level-up-mount.tsx, the shared LevelUpDialog of @twomore/app): right after the viewer's rated match the kit Dialog opens once, title "NTRP 3.5 달성", body 멋진 활약이에요, buttons 다음에 | 공유, only when the pending level-up's match is on this page and the viewer played it. No element on the match Block. Either button marks the level seen (useAcknowledgeLevelUp); a failed mark is silent and the next view shows it again. 공유 shares the NTRP card (use-share-level-up.ts); where sharing is unsupported nothing is drawn.
  • 점수 고치기 (F3a, correct_own_score, 00741): the person who entered a score (matches.score_entered_by) may replace it for 10 minutes (SCORE_SELF_CORRECTION_WINDOW_MS, parity-tested against the migration); the ratings replay and the other players are told. openCorrection opens the score Sheet in the kit's host look (title 점수 고치기, the stored sets in the cells, 점수 저장, no 경기 중단). It sends expectedRevision always and waits as long as the call runs (the band's busy state; no client timeout). corrected closes the Sheet with the Toast 저장됐어요 and no action. The server's other answers are Dialogs over the open Sheet (overlays/correction-dialog.ts): 고칠 수 있는 시간이 지났어요 (닫기 | 점수 검토 요청, which turns the Sheet into the review request), 점수가 바뀌었어요 (확인 closes the Sheet; also not_entrant, match_not_settled, stopped_match, and the page reads again), 검토 중인 경기예요 (확인). A typed failure is the helper under the band (본선이 만들어져서 고칠 수 없어요, the rate limit, the entry sheet's own validation line); an untyped one propagates. Online only: the kit's write gate disables the band's primary offline.
  • Role layer: title, headerActions, notice (under the strip: 12 + Notice + 12, else 24), band, rowControl (a Match row's sm Button), onBlockPress (a Block that opens something), emptyAction (대진 짜기 in the no-draw state).

Anatomy.

├─ [DetailShell] title=스코어카드 sub=[venue, 9월 19일 (토)] onBack actions=layer.headerActions refreshControl footer=layer.band
│  │  subhead ▸ tabbed page → [Tabs filter] LIVE (count) · 일정 · 대진표 ▸ tournament · 순위 ▸ has a table   (코트 first for the climb)
│  ├─ ▸ layer.notice → 12 + [Notice] + 12      else 24
│  ├─ ▸ ended & the viewer has a record & this is the opening tab → [Block elo] 오늘의 기록 · 3승 1패 · 게임 22-15 · ELO +18 · door 공유
│  ├─ ▸ no draw → [EmptyState first compact calendar] 아직 대진이 없어요 · 호스트가 대진을 짜면 여기에 보여요 · ▸ layer.emptyAction
│  ├─ [LiveTab] per court:
│  │   ├─ [SectionHead first? "N번 코트" count ▸ free → 비어 있어요]
│  │   ├─ ▸ the viewer's → [OwnMatch] [Block match]  door 점수 입력 | 점수 고치기 ▸ entrant, minutes 0-10 | 점수 검토 요청 | none ▸ + [CellGroup top=false][TaskCell 노트 남기기 | 노트 보기]
│  │   │   ▸ another's → [Block match playing=N분 viewer=null]
│  │   └─ queue ▸ [MatchRow upcoming header=[round, words]] ▸ layer.rowControl → sm Button
│  │   then ▸ matches with no court → [SectionHead 대기] + [MatchRow upcoming header=[group round | knockout round, words]]
│  │   nothing in play → [EmptyState first compact zap] 지금 진행 중인 경기가 없어요 · 경기가 시작되면 코트별로 보여요
│  └─ [ScheduleTab | BracketTab | StandingsTab | LadderTab]   (lane P2)
├─ single match ▸ [DetailShell] (no strip) → 8 → [OwnMatch] headed by its court
└─ {overlays.element}   score entry · 경기 중단 · 점수 고치기 (+ its answer Dialogs) · 점수 검토 요청 · 노트 Sheets · 결과 공유 · Toast   (lane P3)

States.

  • Loading: the Header (title, no sub) over two Block bars and two person bars. Error: EmptyState kind=error what=스코어카드 with a retry that refetches every read. Offline cold: EmptyState kind=offline with retry. Not found: a Header with no title or sub, "스코어카드를 찾을 수 없어요" / "삭제됐거나 주소가 바뀌었어요" and the door 경기 보기 (→ 경기 tab, replace), 돌아가기 under it.
  • No draw: the strip stays, 일정 chosen, the body says so; with the operator's emptyAction it adds 대진 짜기.
  • The viewer's Block by state: in play and inside its time (Tag 진행 중 N분 and the live ring; the Block itself opens score entry, spoken 점수 입력) · past its time or the round's other matches all settled (door 점수 입력) · a score stands (door 점수 검토 요청, or 점수 고치기 for the person who entered it during the first 10 minutes; the viewer's side first, the winner emphasised; a recorded stop never has the 고치기 door) · a review is open (Tag 검토 중, no door) · stopped (the stop Tag, the games so far, no door) · cancelled or not counted (기록 제외, no numerals). A visitor to the match reads the same Block with no door and no note door.
  • A spectator, and a resting viewer (their next match is the first queue row of its court, marked (나)), have no Block of their own.
  • English labels, 200% text and dark are the kit's.
  • 2026-11-13 (UX-7, the state helper): PageStateView (offline, error, loading, not found with 경기 보기, no-permission).

Actions.

  • Switch tabs; pull to refresh (user-gesture; the data polls independently every 10 s while focused).
  • The viewer's Block → score entry (in play, or past the round), → 점수 고치기 (the entrant, minutes 0 to 10), → 점수 검토 요청 (a score stands); 노트 남기기 / 노트 보기 → the note Sheet; 공유 on the recap → 결과 공유.
  • A name in 순위 or the group tables → 공개 프로필 (L2-06, lane P2). Not found 경기 보기 → L1-03.
  • Operator layer (batch 12): the 할 일 and ⋯ header actions, the Notice and band, a row's sm Button, a Block's press.

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 kit's match Block and elo Block require an onPress, so a Block that opens nothing (another court's, a player's result under review, the share card) is given a no-op press and reads as a button; onPress should be optional on MatchBlockProps and EloBlockProps (BlockFrame already draws a group without it).
  • The queue is every scheduled match of a court, not the next one: a session that drew many rounds at once shows them all under LIVE.
  • A court is a court a match has been put on, never an invented one: the board's court 4 with nothing ever on it is not drawn.
  • The viewer's settled Block keeps its court only while no other match is in play on that court; the next round's match takes the court's place.
  • A suspended match (it stays in play) reads as in play; the stop Tag for it is a later row of the state rule.
  • Minutes tick on useNowTicking (15 s): the app's useSharedTicker is not exported from @twomore/app.
  • The Block's spoken name while in play is composed here (the kit's helper is internal); no profile photos reach the Blocks (initials), and the sub's venue is the session's location text, not the venue record.
  • An unavailable rating impact leaves the ELO fact out of the recap; it does not hold the recap back.
  • The 대기 head's Korean word is the same as the 대기 Tag of a held match (matchResult.held); the English words differ (Waiting / Held).
  • 이미지 저장 on the share card waits for a native build (W5); the league has no structure of its own and takes the rotation's tabs.
  • The level-up Dialog is shown at once (ruling L2), so a score correction inside its 10 minutes can take back a level the person was already shown. The board reads 나중에; the app's common.later is 다음에.
  • The correction Sheet's cells follow the kit's host kind (slot order); the board's caption says "my team first". The sc-correct-saved Toast reads the app's 저장됐어요 (common.saved), the board draws 저장했어요.
  • The Sheet's primary is disabled while the digits equal the stored score (nothing to save). A match with no registry format (a legacy single score) cannot be corrected here: the server refuses it, and the sheet names the changed digits as it does for the review request.
  • The correction door waits on scoreRevision and scoreEnteredBy: a cached row from before 00741 has neither and keeps 점수 검토 요청.

Draw (대진 짜기, L3-47) ​

  • Route: /sessions/[sessionId]/draw
  • Screen: DrawScreen — packages/features/sessions/src/draw-screen.tsx, the thin entry over draw/ (useDrawPage → DrawPageView or DrawStateView)
  • Shell: DetailShell · title 대진 짜기 / N라운드 대진 짜기 / 대진 다시 짜기, and 대진 미리보기 / N라운드 미리보기 on the preview · sub the place and the day · footer the CTA band of the step (DrawBandView) · back on every step (the shell takes no close glyph yet)
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST2, 2026-11-13), States: PageStateView (offline, error, loading, not found with 경기 보기, no-permission); 대진 짜기 keeps its own ended and blocked walls; 참석 확인 keeps its quiet states; 호스트 관리 withholds the applicant list offline.

Purpose. The session staff's one page for making a draw: the first draw (rotation or 대회), the next round, or the draw again before round 1, in one pipeline (대상 → 구성 → 생성 → 검토 → 확정); built W4 batch 13 to flat-v2-detail#gen-ideal (L3-47).

Now. useDrawPage(sessionId, replace) reads the session the way 일정 상세 does (one model, one bundle subscription) and returns a DrawPage: a state, or the ready page with its mode (first, next, replace), its step (구성, 미리보기, 수동 구성) and every string worded already. DrawScreen draws it; the views decide nothing.

  • Who may: the session staff (host, owner, admin, match director: canRunAttendance) on an open, locked or live session (D1); anyone else, or an ended session, gets an EmptyState with the way back.
  • Mode from the data (D2): no match yet is the first draw; a round settled with none drawn after it is N라운드 대진 짜기; the replace flag before round 1 starts is 대진 다시 짜기, saved with replace_unplayed_rounds (00719). A replace of a started draw, or of nothing, is blocked with its reason.
  • 구성 is lane B's DrawConfigView (대상, 대진 방식 with its nests, 고급 설정, the one Notice); 수동 구성 is DrawManualView. 미리보기 is draw/preview/: the rest Cell (전원 출전, or 이번 판 휴식 with the names), the round's SectionHead with its count, and one upcoming MatchRow per court (singles one name a side; a 대회 match with no court yet reads 앞 경기 후).
  • 저장 is one call (useGenerateRound or useReplaceUnplayedRounds, D8: the round and the choice that made it in one transaction); the page then replaces itself with 스코어카드 운영 (routes.matchBoard), which shows the Toast 경기 배정이 완료됐어요. A refusal keeps the draft on 미리보기 and says why in the band's danger line.
  • 진출 인원 (F3b, D1 and D2): a 대회 with groups gets a typed count row under 조별 인원 in 고급 설정, 1 to 2, its range from advancersAllowed(groupCount, smallestGroupSize) (one group advances two; 2, 4 or 8 groups one or two; never more than the smallest group less one); a choice the shape does not allow is clamped (model/advancers.ts). The sentence under the chosen method says 상위 N팀이 본선에 올라요 (gen-tn-knobs, gen-tn-one) and 토너먼트 만들기 sends the count. A shape the server still refuses (tournament_config_invalid) is the band's danger line 이 조 구성으로는 본선을 만들 수 없어요.
  • Who plays (F3b, D4 to D7): the 참석 row opens a Sheet of two choices, 참석자 전원 and 선택한 사람만 (gen-pool-sheet); the first writes everyone (only when a list was saved) and closes, the second opens the people picker (PeoplePickerSheet: the picked faces, 이름으로 검색, the attendees as check rows, the footer N명 선택; gen-pool-pick) on the saved list, or on everyone the first time. The write is sessions.generation_pool_mode and generation_pool_user_ids, online only (the footer carries write); a refused write shows the picker's own error line. The pool feeds useDrawInputs: exactly the picked people who are still going and settled (a picked person who cancelled drops out; a later answer is not added and shows unticked next time; the next round reuses the saved list); 참석 reads 선택한 N명 over 참석 M명 중 (gen-pool); under the format's minimum the existing Notice 최소 N명이 필요해요 shows and the band is soft (gen-pool-short). With the pool control on the page, 늦게 오는 선수 is always drawn (값 없음 when nobody is late) and opens the arrival roster the 참석 row no longer opens.
  • Back: the Header's back and Android's hardware back (useStepBack, the app's useWizardBackHandler, focus-gated) step 미리보기 and 수동 구성 back to 구성 with the choices kept; on 구성 they leave the page.
  • Entries: 일정 상세's band 대진 짜기 (useDrawFlow.openDraw) and the 관리 sheet's row (a route door) push the route; 스코어카드 운영's band step, the empty state's 대진 짜기 and the ⋯ row 라운드 추가 push it, and ⋯ 대진 다시 짜기 pushes it with { replace: true } (scorecard-operator/operator-draw.ts).

Anatomy.

├─ [DetailShell] title=대진 짜기 | N라운드 대진 짜기 | 대진 다시 짜기 | (대진|N라운드) 미리보기 · sub=[place, day] · onBack · footer=[DrawBandView]
│  ├─ step 구성 → [DrawConfigView]      (lane B: Notice · 대상 · 대진 방식 · 고급 설정)
│  ├─ step 수동 구성 → [DrawManualView] (lane B)
│  └─ step 미리보기 → [DrawPreviewView]
│      ├─ 8 + [Cell 전원 출전 | 이번 판 휴식 = names]
│      ├─ [SectionHead "N라운드" count="M경기"]
│      ├─ ▸ per court → [MatchRow upcoming header=[N번 코트 | 앞 경기 후] teams]
│      └─ 24
└─ band: 구성 [대진 미리보기 | 토너먼트 만들기] · 미리보기 [다시 생성][저장] · 수동 구성 [저장]

States.

  • Write failures (ruling EP-3): round and tournament creation are the draw band's danger helper; the arrival round tells a failure in a Dialog; never a Toast.

  • 구성, a group stage that cannot be formed: a 대회 on 조별리그 (or 토너먼트 with a field too big for a straight bracket) whose groups no advancing count fits (groupStageFits(plan) false, the same advancersAllowed arithmetic as the server's assert_tournament_shape: two teams at any group size, and 7, 13, 14 or 15 teams at a group size of 3) draws the one Notice 이 조 구성으로는 본선을 만들 수 없어요 (after the minimum and before the waiting scores) and the band's 토너먼트 만들기 soft; the press makes nothing. Changing the method or the 조별 인원 clears it. The server's tournament_config_invalid refusal keeps its own danger line under the band (a stale view or a direct call).

  • Offline (F1c, the app's root strip says it): offline a cached session stays; 대진 미리보기 (local) works, 저장 waits (the kit holds it); no page strip and no page offline flag.

  • Loading: the Header over a Cell skeleton, no band. Error: EmptyState kind=error whose action refetches. Offline cold: OfflineEmptyState with retry. Not found: "일정을 찾을 수 없어요" (NotFoundState).

  • Forbidden: the permission wall "대진은 일정을 운영하는 사람만 짤 수 있어요" with 돌아가기. Ended: "끝난 일정이에요" with 돌아가기.

  • Blocked (each with 돌아가기): a started draw cannot be replaced; no draw to replace; a 대회 round still being played; 본선 대진 not made yet (made on 스코어카드 운영); no 대회 matches yet; every 대회 match finished.

  • 미리보기: ideal (전원 출전), bench (이번 판 휴식 and names), singles, the next round (N라운드 미리보기), a refused save (the band's danger line: 정산이 끝나지 않은 참석자가 있어요 …, or 이미 시작한 경기가 있어서 …), offline (저장 soft, nothing written). A draw the generator cannot make stays on 구성 with 라운드를 만들지 못했어요.

  • English labels, 200% text and dark are the kit's.

  • 2026-11-13 (UX-7, the state helper): PageStateView as above; 대진 짜기 keeps its own ended and blocked walls.

Actions.

  • 참석 → the pool Sheet (선택한 사람만 → the people picker, 저장 writes the pool); 늦게 오는 선수 → the arrival roster.
  • 대진 미리보기 / 토너먼트 만들기 → the preview (a 대회 with groups, Swiss and Compass are made at once); 다시 생성 makes another draw; 저장 → writes and replaces to 스코어카드 운영 (L3-39); back → 구성.
  • The Notice's 참석자 관리 → L3-35; 경기 규칙 under KDK and 순위매칭전 → L4-06.

Gaps.

  • The Header shows the back glyph on 구성 too: DetailShell takes no onClose, and the board draws × there.
  • The browser's own back on the web leaves the page (the web target has no navigator to intercept); Android's hardware back steps.
  • The board's offline strip is the app's global network banner; the page adds only the soft 저장.
  • The board draws no frame for a picked person who cancelled (the count drops, the picker omits them); the page behaves so and a test holds it.
  • A refused pool write (set_session_generation_pool, 00749: pool_* hints, session_not_active) shows as the picker's own error line (PeoplePickerSheet error); an untyped failure propagates. The pool write is online only: the picker's footer and the 전원 option carry write.
  • The board's 진출 방식 and the planned 고급 설정 rows (만드는 시점, 자동 만들기, 기록) wait for a consumer (D6).
  • A 조별리그 of two teams is also senseless as a method: the page holds it with the shape Notice but still lets the host pick it (a straight bracket is the recommendation); a sharper line ("조별리그는 3팀부터 만들 수 있어요") or turning the row off below 3 teams like 전원부활전 is an owner copy choice, not built.
  • groupStageFits is held to the server's rule by the shared case table (group-stage-shape-cases.ts, the pgTAP copy group_stage_shape_parity.test.sql, check:group-stage-shape-parity), plus the oracle test over 2 to 32 teams x group sizes 3 to 8 in draw-tournament.rules.test.ts.

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