Session Surface Conventions
Status: Active Last reviewed: 2026-11-02
How TwoMore renders session data across every surface — the canonical LIST card
SessionCardV2, the SessionAttention model, the location-firstSessionCard(now a rollback artifact — see below), the chip strip and status/RSVP chip logic, viewer-context threading, theSessionInfoModelprojection system, theSessionCompactRowcompact reference, and the session-detail screen IA. These are the canonical, single-source rules; feature code consumes the components and models below and never re-derives layout, chip order, or gate values ad-hoc. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md.
SessionCardV2 — the canonical session LIST card
SessionCardV2 (packages/app/src/presentation/components/sessions/session-card-v2.tsx, exported via @twomore/app) is the canonical card for every session LIST surface — Home's 진행 중/오늘 일정/오늘 복기/이번 주·달 예정, Club Home Tab's next-session preview + upcoming list, Club Session List's 모집 중/진행 중/완료 panes, and the 경기 (Activity) tab — graduated 2026-07-27 (d21f1fba), replacing the location-first SessionCard documented below on every list render site (10 sites across 9 screen files). SessionCard remains in the tree only as a rollback artifact (zero live JSX render sites as of this graduation); see session-card-v2-decision.md for the migration/decision packet this shipped.
Zero-data-hooks projection. SessionCardV2 takes model: SessionInfoModel plus bulk-resolved venue / weatherLabel / saved props and renders them — it never fetches its own data (the one exception is VenueTile's local useState for image-load-failure, local UI state not a data hook). The canonical per-list props builder is useSessionCardModels(sessions, userId) (packages/app/src/presentation/hooks/use-session-card-models.ts): one bulk useVenuesByIds + useSessionsRsvps (confirmed roster) + useMyRsvps (viewer's own RSVP) + useSavedSessionIds call for the WHOLE list, then buildSessionInfoModel per session — the missing Protocol A sibling that fixed the incumbent SessionCard's per-card hook storm (6+ hooks/card in production, zero of 15 consumers passing the bulk props that already existed).
Composition — THE HERO, FOLDED (owner decision 2026-09-18, /wireframes/card-continuity; supersedes the 2026-09-08 composition): the list card speaks the session-detail hero's grammar (session-hero-section.tsx: place title + ONE titleBadge + one GlyphFactRow), folded into the three lines its square tile is bound to. The entire card is one Pressable tap target → SessionLiveBand across the top ONLY when the strip's lead is live (the ONE exception to F1's bandless rule) → a square venue tile (VenueTile: static map → glyph fallback, 1px $borderSubtle hairline; alignSelf="stretch" aspectRatio={1} on native, a measured columnHeight square on web where CSS cannot transfer a stretched cross size) beside a three-line column — L1 the venue name (ONE line: the tile is aspect-bound to this column, so a second line squeezes every sibling — the documented exception to U-13's NAME_LINES, text-lines.ts) — the title alone owns the line (owner 2026-09-21, via the Gemini session: the lead chip left L1 so a long venue name never truncates against it) — + the bookmark on the right pole (ALWAYS visible, outline $textTertiary unsaved / filled $primary saved; the real SessionSaveButton variant="card" toggle when the caller threads sessionId+viewerId, else a static glyph) / L2 two of the hero's facts at GlyphFactRow's inline density (density="inline": the same glyph + label pair as a bare cardMeta text segment, the glyph IS the category): ⏰ the time range, then 🎾 the format·type fact (the SAME sessionTitle() call the hero makes — "복식", "복식 대회"; the one shrinkable segment on this line) — the DATE is never on the card (DATE-SECTIONS law, owner 2026-09-18 pm, D10 in session-event-type-plan.md): every list of session cards is grouped by day under SessionDayHeader (SectionHeader variant="list", formatDate title + 오늘/내일 meta), built by the one groupSessionsByDay partition (useDaySectionedSessionList for a FeedList of cards; home's HomePaneRow day-title row renders the same header); a single-day surface (home 오늘 pane, the live stack) is its own frame and renders no header; home's 주/월 panes carry NO period title (이번 주/이번 달 예정 — owner 2026-09-18 pm: dates are enough); ONE rhythm on every list — card→card 12 (SEAM.block, the host's separator or gap row), day boundary 16 (that 12 + the header's own SEAM.element paddingTop), header→first card 8 (the header's own SectionHeader list paddingBottom; the host adds nothing after a header — useDaySectionedSessionList's itemSeparator, home's skipped gap row); the card's accessibilityLabel still speaks the date / L3 the who/cost facts, inline: 🎯 (🔒 with $textTertiary numbers when the viewer's REAL ELO is confirmed outside the band — never a guess, never greyed or hidden) leading the tier band at TierRangeBadge density="inline" (TEXT ONLY — the numbers, no "NTRP " prefix since every band is NTRP; the tier-colored dots that led each bound were removed as noise, owner 2026-09-19 — TierInfoSheet, the legend, keeps its colours because there they are explained), ALONE on L3. L2 and L3 read as one fact group (owner 2026-09-26: the gap between the two meta rows was too wide): SEAM.element (4px) between them, SEAM.item (8px) from L1 above — the seam ladder's own rule that parts of one item sit a rung closer than the item's neighbours (session-card-v2.tsx). → a closing line, the participation band (owner 2026-09-21 via the Gemini session — REVERSES the 2026-09-19 FEE-OFF-CARD ruling, reversal #2 on this card): left, ONE chip, SessionLeadBadge (session-strip-ui.tsx: the strip's lead as a canonical SessionStripChipBadge — an attention chip when one fires, else the viewer's role standing; when live the band said it and the seat falls back to the standing badge; absent when the strip has no chips), then — ONLY for a viewer who is neither host nor confirmed (their fee is settled or waived, their standing chip already sits here) — the fee as quiet cardMeta text (formatWonOrFree(model.cost.perPerson ?? 0); SESSION_SURFACE_PROJECTION.card lists cost again); right, RecruitmentMeter (session-compact-row.tsx) flex-filling the rest, bar + count — its showStatusWord only when the strip is empty (the state word lives in exactly ONE place per card) — present whenever model.who.maxPlayers > 0. Its count is what becomes the hero's 확정 인원 stat. No weather on the card (the strip's rain chip leads when rain is a real risk; the hero states the forecast — useSessionCardModels still bundles the bulk forecast for conditions.weather, it just no longer formats a glance label). An optional note slot (2026-09-12) renders a caller-supplied post-game row INSIDE the card under the closing line — home's 오늘 복기 uses it for RecapNoteBadge + EloDeltaBadge; the card adds no hooks for it and every other list omits it.
Event type (2026-09-18, docs/architecture/session-event-type-plan.md). sessions.event_type (TEXT + CHECK, SessionEventType) carries five values: regular (default) · pickup · tournament · league · interclub. Two triggers keep it honest without every writer having to set it correctly itself: a pickup club's session is never anything but pickup (BEFORE INSERT backstop), and attaching a tournament bracket stamps tournament (AFTER INSERT on tournaments). A parity gate (check:session-event-type-parity) mirrors the five values between the TS union and the SQL CHECK constraint so they can't drift. sessionTitle() (session-title.ts) appends the event-type word to the format — "복식", "복식 번개", "복식 대회" — and no longer appends 친선/경쟁, which moved to the detail info row (경기 방식) and the filter panel. league/interclub are host-declared labels only — no backing structure exists for either yet (R7 stays held); they filter and label exactly like the other three, with no separate card branch until their own info model actually diverges from the rest.
Rendering since 2026-09-07 (F1): the strip MODEL is unchanged; the BAND is gone — EXCEPT live (owner ruling 2026-09-08 carves that one case back in). deriveSessionStrip still resolves role → lead chip → class → sub → bookmark; presentation renders the LEAD as the card's ONE title-seat chip (SessionLeadBadge, session-strip-ui.tsx, via the canonical SessionStripChipBadge — 2026-09-18; the 2026-09-08 closing-row lead+standing pair is retired) and, on the detail screen, as badges in the hero's badge row (same wrapper, status-badges.tsx) — terminal ended strips render a full-width Banner at the top of the detail scroll instead. A live lead is the sole exception to "no band": SessionLiveBand (session-strip-ui.tsx) revives the pre-F1 full-solid ribbon across the card's top edge, and the title seat falls back to the standing badge only for that one state. Everything below about ribbons, pale bands, STRIP_BAND_TINT, the band-derived bandFor visuals and the right-pole bookmark indicator (beyond the live exception above) describes the retired presentation and is kept as the model's history; bandFor still runs (the detail Banner keys on band === 'ended'), the bookmark token still feeds accessibility text, and the visual bookmark — ALWAYS visible now, not saved-only — lives on the venue line as the real save toggle.
SessionStrip is the role-first, ALL-CHIPS model (v2, shipped 2026-07-29 — ba8d0128 + 82b5b915; domain source of truth packages/app/src/domain/session-strip.ts; visual spec docs/twomore_design_system/wireframes/strip-map.jsx). The strip is a ROW OF CHIPS — no free text, no punctuation. It replaces the earlier two-pole "actionable lead LEFT / quiet status ledger RIGHT" model: v2 has no ledger of durable marks at all — a viewer's relationship to the session (host/confirmed/waitlisted) is now the ROLE the whole resolution runs under, not a right-pole badge repeated beside the lead. deriveSessionStrip(model, saved) resolves the strip on THREE marked axes, no hardcoding (owner directive):
- ROLE (
resolveSessionRole,host > confirmed > waitlisted > browse) — messages are role-relevant, never universal. A host never sees recruitment status (they aren't applying); a confirmed viewer sees 참석 확정, not 모집 중; ONLY a browse viewer (no RSVP — including a session the viewer's relationship to is over, e.g. cancelled/no_show) sees recruitment state at all.sessionCompleted(eligibility.status === 'completed') lets presentation soften a confirmed viewer's standing copy to 참석 완료 (past tense) instead of 참석 확정 — attendance is a separate axis from RSVP, not a fifth role. - KIND (
StripChipKind, 15 values) — WHAT the chip says. Universal interrupts (cancelled·live) preempt every role's stack; each role's own ranked messages come fromderiveSessionAttention's 9-kind precedence table (below), mapped 1:1 to a chip kind viaCHIP_KIND_BY_ATTENTION; when nothing interrupts, the role's STANDING chip is the default —host_standing,confirmed_standing(orpayment_verifyingwhen the viewer's fee payment is submitted-awaiting-confirm),waitlisted_standing, or a browse viewer'srecruitment_closed/waitlist_open, then (2026-09-24)friends_goingwhen the recruitment gate is still open ANDwho.friendsGoing > 0, or otherwise nothing at all — the meter already says 모집 중, so silence IS the signal. - CLASS (
STRIP_CHIP_CLASS, the ONE table insession-strip.ts) — every kind is classified on two orthogonal axes, and presentation derives ALL visuals from this classification alone, never a per-kind switch:emphasis— the chip-color principle (owner 2026-07-29):urgent(a clock is running or it's happening now:live·payment_due·closing·starting_soon) andfinal(terminal:cancelled) render as a FULLY SATURATED RIBBON — the whole band takes the palette's deep badge-TEXT-token fill with$textInversebare content (the Toss critical-banner / M3 container+on-container / airline gate-change pattern; bare-ribbon model, owner 2026-07-29 — no pill chrome anywhere on the strip, ever: a pale band + solid same-hue chip is tone-on-tone collapse and reads as background).warning(should-know, no clock:new_applications·rain·score_confirm) keeps a PALE tint band;progress(payment_verifying) andstanding(host_standing·confirmed_standing·waitlisted_standing·recruitment_closed) sit on the calm$surfaceTertiaryband — in all non-saturated cases content is bare text/icon whose semantics live in the color alone. RENDERER RULE: band'act'is shared by urgent (payment_due/closing) and warning (rain/new_applications) chips — saturation derives from the lead chip'semphasis, never fromstrip.banddirectly. The BOOKMARK is a bare glyph INDICATOR always at the strip's end (filled = saved viastrip.right, outline = unsaved; white on ribbons with the outline at ~0.7 opacity,$primary/$textSecondaryon calm) — a bandless browse card carries no indicator (no-mark IS the unsaved default) and the TOGGLE lives solely in the session-detail title bar (SessionSaveButton variant="header"), never on the thumbnail.palette— the semantic color family (error·warning·success·neutral), mapping 1:1 to the existing status-badge token pairs ($badgeErrorBg/Text, etc.) — never a bespoke hue.
Structured StripSub qualifiers render as a FUSED sub-segment inside the same chip — never a second chip, never label text. A countdown (time_left), a start-in (starts_in), a waitlist queue position (position), a rain probability (probability), the waitlist-open fact (waitlist_open), or a friends-going count (friends_count) is DATA, not copy — presentation formats it and renders it after a 1px hairline divider (content color at ~35% opacity) inside the SAME bare content run, at IDENTICAL text weight/size to the main label (B안, owner 2026-07-29 — there is no separate "sub" typographic treatment). The hairline alone is the separator: no punctuation, no middle dots, ever.
friends_going (2026-09-24 — friends-going decision). Evidence: who.friendsGoing/urgency.friendsGoing were computed on session detail (use-session-detail-model.ts, useFriends ∩ confirmed rsvps) and threaded into SessionInfoModel, but only ever used to sort the roster — never shown; Facebook Events' "N friends going" and Meetup/Luma attendee avatars are the strongest social-proof subtype, and the card canon deliberately keeps participant FACES off SessionCardV2 (below), so this ships as a chip, not a face pile. Browse-role only (standingChip's browse case) — host/confirmed/waitlisted keep their own standing; a participating viewer's relationship to the session already outranks who else is going. Fires when who.friendsGoing > 0 AND the browse recruitment gate is still open (i.e. deriveRecruitmentStatus resolved to something other than closed/waitlist, which keep their own recruitment_closed chip above this one, and other than null, which covers cancelled/completed/draft/in_progress) — precedence-wise this sits below every interrupt/attention chip and recruitment_closed, above browse's silent default. Class { emphasis: 'standing', palette: 'success' }; label 친구 참석 / "Friends going", sub the bare count via common.peopleCount ("친구 참석 | 2명"). countFriendsGoing (domain/rules/rsvp-selectors.ts) is the ONE confirmed-RSVP ∩ friend-id-set function both useSessionCardModels (list, Protocol A — one list-scope useFriends) and useSessionDetailModel call, so card and detail can never resolve a different count for the same session. The session detail screen itself has no rendering surface for this chip today (sessionStrip there only backs the cancelled terminal banner) — it is presently a card-list-only signal; on the detail, friends lead the roster with a 친구 badge. It shows only where the viewer can already read the roster: on 번개 찾기 a non-member cannot read a pickup's rsvps (RLS), so the chip never appears there — surfacing it would need a count-only RPC and a privacy ruling (open).
The band is DERIVED from the lead chip's class, never set by hand (bandFor in session-strip.ts): final → ended; urgent → by palette (error → now, success → soon, else → act); warning → act (a white chip still needs the band to carry the "act" signal, since the chip itself is quiet); progress/standing → calm. A browse viewer on an open, non-full session with no friends confirmed has no lead chip at all (standingChip returns null) and no bookmark — the strip is null and the band collapses to 0px; with who.friendsGoing > 0 the lead is friends_going instead (still standing → calm, same 0-drama band).
bookmarked is the SOLE right-pole token (SessionStrip.right), added whenever the caller's saved flag is true, on every band — but it visually YIELDS (hides) on any non-calm band, EXCEPT cancelled: a dead session's other state is already terminal and 취소됨 is short, so there's still room, and unsaving stays a live, actionable choice there. The accessibility label always speaks the bookmark regardless of the visual yield (sessionStripAccessibilityText).
Vocabulary (natural-Korean audit, owner 2026-07-29 — packages/app/src/config/i18n/ko/home.ts strip block; vocabulary target updated by the 2026-07-29 vernacular migration, see below). States read ~중 (라이브, 예비, 입금 확인 중), results read ~됨/확정 (취소됨, 참석 확정), actions read ~필요 (입금 필요, 점수 확인 필요). new_applications reads 새 신청 N건 (never a bare count). Countdown subs read N 남음 (time remaining, time_left) vs N 뒤 (future start, starts_in) — never N 전 ("N ago"), which reads past-tense as a standalone segment (구어체 뒤, not the literary 후 — the Toss register). A full session with the waitlist open fuses 모집 마감 + the waitlist_open sub 예비 가능 into ONE chip, never two and never a middle dot — 접수 is the RECEIVING side's verb (the host's), so the viewer-facing chip uses the viewer's own verb, 예비 가능. waitlisted_standing reads 예비 (with a 예비 N번 sub carrying queue position) — 대기 is retired from the waitlist relationship entirely (community-vernacular alignment: 밴드 참석/불참/예비 culture) and now reserved for process-queuing only; payment queuing reads 입금 필요/입금 확인 중 (club-ledger 입금 vernacular, was 송금 필요/송금 확인 중), never 대기, closing the four-way 대기 overload (송금 대기 · 성사 대기 · 신청 대기 · 대기 접수) the pre-v2 copy had. 2026-07-29 vernacular migration: RSVP verb is 참석/불참 (참가 kept only for 참가비 + tournament entry), waitlist vocabulary is 예비, fee-transfer copy is 입금, match-gen CTA is 대진 짜기 — see docs/design/terminology-guide.md. packages/app/src/config/i18n/ko/home.ts carries this copy (migrated; the i18n terminology test bans the retired 송금 forms).
GATE-1c (rain) is rain-driven and outdoor-only, never playability-driven (fixed 2026-07-29, 82b5b915). ConditionsInfo.rainRisk (session-info-model.ts, feeding the rain chip) now fires ONLY on an actual precipitation signal for the session window — the forecast condition is rain/snow/thunderstorm, OR the precipitation probability is ≥ 60 (the "likely" line in both KMA's 강수확률 convention and the US NWS ladder) — AND the venue is not explicitly indoor (where.isOutdoor !== false; an unknown venue stays conservative and still gates). It no longer ORs in a poor/unplayable playabilityLevel, which used to surface the rain chip on a merely cold or windy day at a misleadingly low percentage; playability stays the weather glance's own signal, never a rain proxy.
SessionAttention feeds the strip's interrupt and ranked role-message chips (packages/app/src/domain/session-attention.ts, deriveSessionAttention) — a pure, config-table resolver mirroring deriveRecruitmentStatus's style but answering "what does THIS VIEWER need to know about THIS session right now," a viewer-scoped fact, not a session-wide one. 9 ranked, audience-gated kinds, fixed precedence (first match wins): cancelled (rank 0, everyone) → live (10, everyone, pulsing dot) → payment_hold (20, self with an unsubmitted fee hold) → score_confirm (30, participant with an unresolved score call) → host_action (40, host with pending applications) → closing (50, NOT-yet-joined viewers only — recruitment pressure isn't a roster member's information, the strip-v2 role principle that recruitment state is browse-only) → rain (60, participant, outdoor rain risk) → waitlist (70, waitlisted self — this IS the waitlisted role's standing chip, carrying the position sub) → starting_soon (80, confirmed self, within the 2h window shared with the session_starting_soon push cron, migrations 00385/00421). SessionInfoModel carries the resolved attention (+ viewerIsHost) so the card never re-derives it ad-hoc.
Type-budget addendum (2026-08-04, commits 311342d1/fa9aff95 — a fix batch that rode along with the club-card corners-law iteration). ChipsLine's format/style/tier-fallback chips moved to Badge variant="surface" — the theme $surfaceTertiary bg / $textSecondary text family (fill stepped up from $surfaceSecondary in the 2026-08-05 accessibility pass — the secondary tint was near-invisible against the white card body; TierRangeBadge matches the same fill), matching the club card's corners-law chips (the prior badge-palette gray clashed hue-wise against adjacent theme text). The tier-less fallback chip's label is 실력 무관 (t().sessionHeader.open, shared with the club card's corner chip — '오픈' was rejected: 오픈부 names the elite amateur division). The card's own type budget: cardTitle (16/24 600) is reserved for the venue name ONLY — the one heavy weight on the card; the weather label, date/time, and fee text all dropped their per-line fontWeight overrides and now render at cardMeta's own 500 default, so the ramp reads "one heavy title + role-weight everywhere else" — the same discipline docs/canon/clubs.md's corners-law entry documents for club cards. Body-row rhythm follows the club card's own $2 spacing law. The closing RecruitmentMeter IS gated on model.who.maxPlayers > 0 in SessionCardV2 (final fix batch 2026-08-05 — an unbounded session would otherwise render a meaningless 0-denominator bar), mirroring SessionCompactRow's own showCapacity gate; SessionCompactRow's ProgressBar math is additionally divide-by-zero guarded.
Known deferrals (2026-07-27, payment-status chip closed 2026-07-28 — input parity; score_confirm list/detail parity closed 2026-07-30). The list card shows no weather glance beyond the plain weatherLabel prop passed in (no per-card useSessionWeather fetch — no bulk weather source is bundled into useSessionCardModels yet). Payment-status GAP-FILL is now bundled (useMySessionPayments + useHostedPendingApplicationCounts, Protocol A) — see the SessionStrip contract section below. useSessionCardModels still passes DEFAULT_ELO for both singles/doubles (no consumer threads real per-viewer ELO into it yet — only session-detail does), so eligibility.viewerEligibleForTier is a known-coarse placeholder at list scope. viewer.scoreConfirmPending (the score_confirm attention's trigger, rank 30) is now wired on both surfaces. Detail (useSessionDetailModel) still bulk-fetches the session's active score calls via useSessionMatchScoreCalls(sessionId) (Protocol A, one query) and derives the viewer's pending-confirm boolean via the pure hasPendingScoreConfirm(matches, scoreCallsMap, userId) selector (domain/rules/score-confirm-selectors.ts) over the screen's already-loaded matches. List scope CANNOT run that same selector — cards never load a session's Match[] — so useSessionCardModels instead bundles useSessionsPendingScoreConfirm(sessionIds, userId) (presentation/hooks/queries/use-match-score-call.ts), a dedicated bulk hook backed by a new adapter query (MatchScoreCallRepositoryPort.findPendingConfirmSessionIds) that joins matches→match_score_calls server-side and returns the viewer's unresolved-call session ids directly, instead of loading every match. The hook is scoped to participatingSessionIds — the subset of the list where the viewer has a CONFIRMED rsvp (derived from the already-fetched myRsvpsMap, no extra query) — since a pending call can only exist for a session that reached in_progress/completed with the viewer in a generated match; this keeps the query cheap and mirrors how hostedSessionIds scopes useHostedPendingApplicationCounts above. A score_confirm attention now renders identically on card and detail for the same session.
The SessionStrip contract has ONE card renderer, SessionLeadBadge (2026-09-18; SessionStateInline 2026-09-07 → retired, SessionStripView before it), and one badge wrapper for heroes, SessionStripChipBadge. (Historical text follows — size="card"/size="detail" chrome density no longer exists.) deriveSessionStrip(model, saved) (domain/session-strip.ts) resolves the strip; SessionStripView({ strip, model, size }) (presentation/components/sessions/session-strip-ui.tsx, exported via @twomore/app) renders it — SessionCardV2 calls it size="card", the detail screen calls it size="detail" with its own deriveSessionStrip(sessionModel, saved) — never a second hand-rolled host/payment/rsvp conditional, and never a second component. size (STRIP_SIZE_CONFIG) changes ONLY chrome density: $2 padding / cardMeta text role (card) vs $3 padding / cardBody text role (detail) — never color, chip classification, or copy. Renders null on BOTH surfaces when strip is null — the band's absence on an open, no-relationship session is itself the signal on both surfaces.
The shared label/tint vocabulary (STRIP_BAND_TINT, the private chipLabel/chipSubText i18n mappers, sessionStripAccessibilityText) lives alongside SessionStripView in session-strip-ui.tsx, so card and detail can never drift again. Input parity: useSessionCardModels (use-session-card-models.ts) bundles useMySessionPayments + useHostedPendingApplicationCounts + useSessionsPendingScoreConfirm (all bulk, Protocol A) to feed the SAME paymentStatus/paymentSubmittedAt/paymentHoldExpiresAt/pendingApplicationCount/scoreConfirmPending viewer GAP-FILL the detail screen threads from its own per-session queries — a payment_hold/score_confirm/host_action attention or a submitted-payment 입금 확인 중 (was 송금 확인 중) standing chip now resolves identically on card and detail for the same session.
SessionCard layout (HISTORICAL — deleted, no rollback path)
As of the SessionCardV2 graduation (2026-07-27, d21f1fba) this section documented the session-card.tsx subtree, initially kept only for rollback. That subtree — session-card.tsx, session-header.tsx, host-badge.tsx, session-top-banner.tsx, session-status-banner.tsx, session-meta-section.tsx, session-match-detail.tsx, session-weather-glance.tsx — has since been fully DELETED (2026-07-29); there is no code left to roll back to. SessionCardV2 above is canonical, and sole, for every list surface. The section below is left intact as pure historical prose — what the retired card used to do — not a spec for anything that exists in the tree today.
SessionCard layout — location-first (GRADUATED to the production default 2026-07-18; experiment series 2026-06-24→30). The full-size (size="default") SessionCard leads with WHERE and renders, top to bottom inside SessionHeader layout="locationFirst":
- Status strip (live / cancelled / deadline / almost-full — one at a time, precedence-ordered) at the very top of the card, above the hero.
- Title row — the location TITLE (broad region, space-joined e.g.
서울 강남구; fallbacks: venue name → generatedsessionTitle) + the top-rightheaderAction(HostBadge for the host,SessionSaveButtonotherwise). - Venue hero (
VenueHero: static map → photo → surface tile) — the specific venue name + our booked court info (e.g.클레이 2면) in the hero's bottom overlay bar, the weather glance (SessionWeatherGlance) top-right. No-hero fallback: a session without a resolvablecourtVenueId(free-text location, fetch miss) renders the classicVenueLink+ weather row in the hero's place — the WHERE/conditions facts never disappear with the map. - WHEN band — a soft full-width
$surfaceSecondarystrip (44px, NOT a nested card): date left, time-range right, one naturally-spoken a11y node. Date/time live OUTSIDE the map, never as a hero overlay. - Two-tier chip strip (
chipRowTwoTier, below). - Optional
notechip, then the participation section.
Participation section (shared by both layouts): a thin <Divider /> separates metadata from participation. A borderless CapacityRing direction="column" (ring over the N/M count, NO box, 48px cell) leads participant faces (ParticipationCluster — the AvatarStack alone, no ring) with the status/RSVP chip pinned FAR RIGHT. Both the ring AND the faces render for recruiting/live AND completed (PARTICIPANTS_VISIBLE_STATUSES): the ring shows the FILL while recruiting and the FINAL attendance record ("8/8 played") on a completed card. FILL_VISIBLE_STATUSES only gates the recruiting-only "아직 참가자가 없어요" empty label. RSVPs are fetched ONCE at the SessionCard level and passed to BOTH the ring (count) and SessionMetaSection (faces) — one network call per card. The row is the expand/collapse toggle for the lazy match-summary well (SessionMatchDetail).
Classic layout (layout="default") — the status-colored DateTile (48px) beside the title/where/chips block — remains ONLY for compact (size="compact") cards, which render the meta bare full-width with no divider, rail, hero, or WHEN band. The venue fetch (useVenue) runs for every full-size card with a courtVenueId (the hero needs coords even when venueSurfaceType was prop-drilled); compact cards never fetch.
(Historical — no live caller, file deleted.) SessionCard accepted an optional footer?: React.ReactNode — rendered inside the card surface below SessionHeader; used to inject match rows without nesting cards. SessionHeader itself is deleted too (2026-07-29) — there is no remaining direct-usage carve-out; any new hero/detail layout composes SessionCardV2/SessionStripView pieces instead.
Status / RSVP chip logic
The status/RSVP chip is NOT in the classification strip — it is pinned to the far right of the participation meta row (faces on left, status chip on right). Status-chip behavior depends on session.status and viewer participation:
session.status === 'in_progress'→SessionStatusBadge(진행 중, live red + PulseDot) always renders. LIVE is a temporal state independent of participation; if the viewer is also confirmed/waitlisted,RsvpStatusBadgerenders ADDITIVELY beside it. Same precedent as the payment chip — additive, never replaces.- Other statuses + viewer confirmed/waitlisted →
RsvpStatusBadgeREPLACESSessionStatusBadge.모집 중/마감됨is irrelevant to someone already in — show their state, not the room's. - Other statuses + viewer not participating → for open/locked (recruiting) sessions,
RecruitmentStatusBadgeviasessionRecruitmentState(the derived 모집 중 / 성사 대기 / 마감 임박 / 대기 모집 / 마감);SessionStatusBadgeonly for non-recruiting statuses (live/completed/cancelled). The payment chip (SessionPaymentStatusBadge) is ADDITIVE and renders only whenmyRsvp.status === 'confirmed'AND aSessionPaymentrow exists for the viewer — it goes in the classification strip, not the meta row.
SessionCard chip strip — the canonical two-tier flow (HISTORICAL — the deleted SessionCard's layout)
This whole section describes the deleted SessionCard's two-tier chipRowTwoTier, not SessionCardV2. SessionCardV2 has no chip strip since 2026-09-18 — format·type is the 🎾 inline fact on L2 and the tier band renders inline on L3 (see Composition above) — and before that (2026-08-04 → 2026-09-18) a SINGLE row of three chips [format] [style] [tier], never the two-row, gate-symmetric layout below. What's still true on both: the ordering is grounded in the same SessionInfoModel gate taxonomy (eligibility → kind → cost; see SessionInfoModel projections), and the fee chip reads the canonical session.participationFee (the legacy costTotal-split cost chip stays retired). The two-tier mechanics below are left as historical reference only:
The full-size SessionCard renders the two-tier chipRowTwoTier, whose order is grounded in the SessionInfoModel gate taxonomy (eligibility → kind → cost). (Graduated from the sessionSummaryPolishMode experiment 2026-07-18; the single wrapping classic chipRow — format → style → tier → payment → approval — survives ONLY on compact cards. The legacy costTotal-split cost chip is retired; the fee chip below reads the canonical session.participationFee.)
Invariant layout — each row leads with explanatory metadata and ends with a decision GATE; both row ends ALWAYS carry a chip (left↔right symmetry):
- Row 1 — identity → eligibility gate:
[type] [format] ──spacer── [tier]type(번개/클럽,t().sessions.pickupBadge/clubBadge) ALWAYS leads — the eligibility frame, the most identity-defining chip. Null only for league/tournament.format(복식…) — explanatorykind.tierband — the skill GATE, right-pinned (always present).
- Row 2 — kind detail → cost gate:
[style] [rotation?] [approval?] [payment?] ──spacer── [fee]style(친선/경쟁) — the always-present left chip (moved here from Row 1 to guarantee a Row 2 left chip).rotation(로테이션 N분, whenrotationMinutes),approval,payment— explanatory / join details in the middle.fee— the cost GATE, ALWAYS right-pinned for pickup/club:참가비 N원when paid,무료when free.
Consistency over suppression: the type chip never moves and the fee chip never hides, so every session reads the same flow (what it is → what kind → the skill + cost gates) and symmetry holds in all states with no special cases. The two decision gates (tier, fee) sit at the right edges; everything else is explanatory on the left/middle.
Gating (번개 vs 클럽 × free/paid × member/guest), session-fields only (never a per-viewer membership query):
참가승인(approval) —자동 승인(auto → accent) /승인 필요(manual → neutral), frompublicInviteApproval. Shown whenorigin === 'pickup'OR (origin === 'club'ANDpublicInviteEnabled).publicInviteEnabledis the semantic gate: approval only exists when there's a public/guest join, so member-only club sessions hide it.참가비(fee) — always shown for pickup/club. A member-only free club session reads[클럽][복식]…[tier]/[친선]…[무료].
Adding a chip: place it by gate (eligibility/identity → Row 1 left · kind/logistics → Row 2 left/middle · a new decision gate → a right edge), keep both row ends filled, and gate visibility on session fields only.
Viewer context threading
HISTORICAL (deleted SessionCard): SessionCard used to receive viewer context via the userId prop and internally derive it — useMyRsvp(userId, sessionId) + useMySessionPayment(sessionId, userId), cache-shared with the (also deleted) SessionMetaSection's useSessionRsvps. Current reality: SessionCardV2 takes NO userId prop and does zero internal viewer-context derivation — it is a pure SessionInfoModel-projection component (see SessionCardV2 — the canonical session LIST card above); the viewer context is folded into the model by the CALLER's bulk builder (useSessionCardModels(sessions, userId)) before the card ever renders. Every authenticated list render path threads userId into that bulk builder from useAuth() — no exceptions.
SessionInfoModel projections
Session surfaces are projections of SessionInfoModel. Every session surface (summary card, compact row, detail page, share card) is a DIFFERENTLY-CONFIGURED PROJECTION of one canonical model — the same gate dimensions, in the same canonical order, expressed at a different depth. The single resolver is buildSessionInfoModel(input) (packages/app/src/domain/session-info-model.ts); surfaces consume its output and render their projection — they never re-derive status / spots / cost / eligibility ad-hoc. Gate taxonomy + canonical order (abort-risk descending, SESSION_GATE_ORDER): eligibility (GATE 0 — can I get in + where do I stand) → when (GATE 1a — date/time/duration) → where (GATE 1b — venue / surface) → conditions (GATE 1c — weather / rain verdict) → kind (GATE 2 — format / style / ELO-impact / tier band) → who (GATE 3 — host / roster / will-it-happen) → cost (GATE 4 — per-person cost + cancellation commitment) → urgency (GATE 5 — deadline / fill / FOMO) → action (surface-owned CTA, not in the model). SESSION_SURFACE_PROJECTION declares which gates each surface shows: card (tight projection — eligibility / when / where / conditions / kind / who / cost / urgency / action); compact (minimal — eligibility / when / where / who / action); detail (full projection — all gates). The summary card is the tight projection: highest-signal item per gate, one row for cost (참가비 chip — the gate-4 field SessionCardV2 has always rendered; the table's card row omitted the 'cost' key until session-info-model.ts was corrected, 2026-07-31). The projection table is DESCRIPTIVE, not runtime-enforced — no surface reads it to filter which gates it renders, so it can drift silently; session-info-model.test.ts hand-lists the gates SessionCardV2 actually reads and asserts card is a superset, which is the only mechanical guard today. The detail page is the full projection ordered by the gate model — LOGISTICS surface first (when/kind/cost/conditions → venue → weather) then PEOPLE surface (host → who → matchups). Adding a new session surface: map it to a SessionSurface constant, declare its gates in SESSION_SURFACE_PROJECTION, and consume buildSessionInfoModel — never re-derive the gate values inline. Deferred: where.distanceKm (viewer→court distance) — the model has the slot (GAP-FILL) but the viewer's stored coordinates are not yet wired; populate once location permission is persisted.
Compact session reference
Compact session reference → SessionMediaRow (K5b 2026-11-02: the ui MediaRow kind session, compact; was SessionCompactRow, deleted — line 1 the title, line 2 the date with its weekday and the start time, the one trailing the confirmed/capacity count or the status badge on a completed list; the venue line and the meter are gone, the card and the detail say where and how full). The v3 text that follows is the history of the old row. It carries the session minimum set even when squeezed inside another card. v3 (owner direction 2026-07-20, replaces the date-tile layout — DateTile made the row tall and let the "time · venue" line wrap; the old dateStyle: 'tile' | 'dot' prop and SessionStatusDot are gone, no card uses DateTile inside this row anymore): THREE rows, no status dot at all — Row 1: title (truncates) + right-aligned tabular date·time; Row 2: VenueLink (venue name + surface glyph, self-hides with no location); Row 3: RecruitmentMeter (status word + fill bar + N/M count, self-hides when capacity is unknown — also exported for reuse by SessionCardV2's meter line, one implementation). The whole row is a Pressable → session detail when given a sessionId (nested under any embedding card; the inner VenueLink is a non-interactive tag, no nested tap); surface-less with no sessionId. DateTile (packages/ui/src/date-tile.tsx) still exists as a @twomore/ui export but has zero live JSX render sites in the app today (the retired SessionIdentityCard dropped it — its fields now live in session-detail/sections/info-section.tsx; club-vitals.ts's buildDateTileProps has no external caller) — it is simply no longer part of SessionCompactRow, and shouldn't be cited as an active pattern elsewhere without verifying a real call site first.
Session detail screen IA
일정 상세 (L2-02) is its board, built W4 batch 8 (packages/features/sessions/src/session-detail/; board flat-v2-detail.jsx sd; blueprint screen-blueprints/sessions.md § Session Detail). Top to bottom: the MediaBand (the map with its pin and the 장소 보기 door; the bar over it is the shell's collapsingLook="circles", at most three actions: a viewer 공유 · 저장 · ⋯, a host or official 공유 · 저장 · the 관리 gear), one Notice (or the hold's CountdownNotice), the Hero (the venue and three facts, the viewer's state as one Tag beside the fee), 참석 with its Roster, 경기 (three rows) while a draw exists, the 정보 CellGroup (클럽, 호스트, 종목, 대상, 코트, 신청 마감, 취소 규정, 날씨; the rest behind ExpandToggle; 참석 시간 and 단톡방 for a joined player), and the CtaBand.
Rules, not JSX, decide the band and the Notice.
view/build-band.ts(buildSessionBand: the board's precedence, first match wins) andview/build-notice.ts(buildSessionNotice: the board's priority list) return specs with action ids;view/derive-page-input.tsderives their inputs from the one page model and an injected clock (view/arrival-rules.ts: check-in opens 30 minutes before the start and closes at the stamp; the times prompt from 2 hours before). Labels live in the page's words; handlers inband/use-session-band-handlers.ts. A new state is a new row in a rule with its test, never a branch in a component.Check-in stamps the server's now (00707): 도착했어요 records the tap with a Toast and 되돌리기; a player never types an arrival, a host corrects it on 참석 확인. The 참석 시간 Sheet plans the arrival and leave before the start and offers 먼저 가기 after it.
The 관리 sheet (
header/session-manage-doors.ts) is the session's doors registry, like the club's: each row only when the viewer can do it (derived from the session capabilities), counts as Tags, 일정 종료 / 취소 / 삭제 as the destructive group. The ⋯ holds 신고 (the host as a user) and, while live, 경기에서 빠지기 for a confirmed player.Overlays sit behind one controller (
overlays/use-session-overlays.tsx): 입금 안내 (attest only; the account throughCopyableValue, read by the ONEuseDepositAccount(session, { holdsSeat }): a 매치's own account for a pickup, the club's otherwise; neveruseClubBankAccountdirectly for a session), the arrival stamp, 참석 시간, 매너 태그 (one co-player at a time).대진 짜기 is its own page (L3-47,
/sessions/[id]/draw): the band, the 관리 sheet and 스코어카드 운영 push the route; 정산하기 / 참가비 관리 open the payments roster until 참석자 관리 (L3-35) confirms fees.매치 미리보기 (L2-03) reads the same media band, Hero and 정보 Cells (
match-preview/); a member, the host and an approved guest are replaced to 일정 상세.The session RECORD — one field list for the wizard review and the edit state (
packages/features/sessions/src/session-record/, owner 2026-09-27: "merge the edit pages"). One order (the read block's questions: 날짜 · 시간 · 반복 · 응답 기한 · 장소 · 코트 · 종목 · 한 경기 시간 · 경기 규칙 · 실력 · 구성 · 최대 인원 · 참가비 · 취소 규정 · 공지), one label, glyph (SESSION_FACT_GLYPH) and value format per field (record-values.ts), rendered as editableFactRows (components.md) by BOTH the create wizard's 확인 step (record-from-wizard.ts: every row back to the step that asks it; 반복 only there) and this edit state (record-from-session.ts). Fields a session keeps once made — 종목, 한 경기 시간, 응답 기한 (isFixedAfterCreate) — say so on the review's rows and lock with the same words here; 참가비 locks once payments exist, 취소 규정 is the club's rule, 경기 규칙 opens its own screen (a chevron) or locks for a host without the role. Never a second list of a session's fields for another editing surface — add the field to the record. The description field's one name is 공지 (wizard and page), bounded bySESSION_DESCRIPTION_MAX.
Capabilities mirror the server (2026-09-21 audit — packages/app/src/domain/rules/session-capabilities.rules.ts, useSessionCapabilities for standalone screens, the detail model calls deriveSessionCapabilities on its own bundle role). A screen never shows a control the server would refuse and never walls a viewer the server would serve: canManageSession (host OR manage_schedule) gates the host's ⋯-menu tools and the roster's MANAGEMENT view; canRemoveParticipant = rsvps_update (00080, club admin/owner — the host's 00426 policy is availability-scoped by 00433); canAdmitWaitlist = admit_waitlisted_rsvp (00478, host OR admin/owner); canEditMatchRules = update_session_rules (00147/00152, owner/admin/match_director — a pickup session's host qualifies as the OWNER of their own personal clubs.type='pickup' club, handle_new_club 00011, never as a special-cased "host"; a non-admin host of a REAL club is still refused, so neither the 경기 규칙 row, the rules screen nor the tournament chooser's 경기 시간 hint offers it to one; 00672 additionally refuses a completed/cancelled session server-side, and widens signals_on_session_update to notify confirmed/waitlisted participants on a rules or friendly/competitive change — see the notification note below). Reading the roster is not a capability — RLS decided it when the detail loaded, so ParticipantsScreen (전체 보기 / 외 N명 / 참가자 관리 all land there) has NO access wall: a viewer sees 확정 + 예비 with each row navigating to the profile; canManageSession adds the title 참석자 관리, the manage subtitle and the 취소 section; canRemoveParticipant/canAdmitWaitlist add the row actions. Before 2026-09-21 the whole screen was walled behind canManageSchedule, so every plain member who tapped 전체 보기 met 접근 권한 없음.
A session-field edit participants should hear about fires session_updated, never a new signal type (signals_on_session_update, a plain AFTER UPDATE trigger on sessions — fires on any writer, not only a specific RPC). The watched-column clause, widened twice: date / start_time / end_time / location / participation_fee (00649, U-06 — the fee joined once it could legally change post-creation, pre-payment) plus match_rules and is_friendly (00672 — a rules edit, or a friendly/competitive flip, notifies the same way; is_friendly decides ELO impact, the exact axis Playtomic locks once a session goes live, guard_session_friendly_lock 00468). Recipients are every confirmed + waitlisted rsvp — never the host, who holds no rsvp on their own session; the signal expires in 48h; the copy (signal.session.updated.title/body) names only the club and the session title, generic on purpose, so widening the watched set never needs a copy change. Adding a field to the "participants should know" set: add its column to this same IS DISTINCT FROM clause — never a new signal type, never a second emit path.
Live anchor band
일정 상세 no longer draws it (W4 batch 8: the live state is its Notice 진행 중이에요, the board's session.in_progress meaning); the band stays on 스코어카드 and 스코어카드 운영 until their pages are rebuilt. A page-level "진행 중" band on the three live-session surfaces (decided 2026-09-17, docs/twomore_design_system/wireframes/live-anchor.jsx). SessionLiveAnchor (packages/app/src/presentation/components/sessions/session-live-anchor.tsx, exported via @twomore/app) promotes SessionLiveBand's full-solid ribbon grammar (session-strip-ui.tsx — PulseDot + 진행 중 in $textInverse on the palette's deep fill) from CARD scope to PAGE scope, widened with a trailing slot carrying three facts on the same row, MetaBreak-divided: elapsed time since play started (ElapsedClock + formatElapsedDurationKo), the round currently being played of how many total (t().sessionLive.round(current, total)), and how many courts are live right now (t().common.courtCountText(n) — the same '코트 N면' formatter the hero's own booked-court-count fact uses, not a duplicate key). A fact renders only when its value resolves (non-null / non-zero); the label itself reuses common.live (already '진행 중') via the canonical live StripChip, so no new key duplicates that value. Protocol B, not a second fetch (fixed 2026-09-17, PF-01): a component reading the SAME composer as its parent screen accepts the composer's output (session/matches) as props instead of calling useSessionLiveFacts itself — every mount site already holds both from its own useSessionDetailModel/useMatchBoardData/useLiveSessionData call, so threading them down skips mounting useLiveSessionData's backfill effect + 10s poll interval a second time per screen. useSessionLiveFacts (still useLiveSessionData under Protocol A — TanStack Query dedupes the underlying fetch) remains only as the fallback for a standalone caller with no composer of its own; both paths resolve through the same pure deriveSessionLiveFacts. Mounted as the absolute first flow child on (until W4 batch 8, also session-detail-screen.tsx) match-board-screen.tsx (above TypeHero — its first live signal ever), and spectator-scorecard-screen.tsx (above SegmentedTabs, inside the screen's scroll={false} body — pinned for free, since only the active tab's own pane scrolls).
Home 일 (today) composition
HOME-TODAY + PICKUP-TODAY (owner ruling, improvement plan 2026-09-23, W4 H1 / W5) — 일 (home's daily pane) lists TODAY's sessions from the member's own clubs, answered or not, plus the member's own confirmed sessions (including pickups with no club), deduplicated, host first. Before this, "my confirmed upcoming sessions" was derived from the CLUBS' upcoming list, so a confirmed pickup (no club) could never appear on 일, the home recommendations, the period recap, or the calendar dots (PICKUP-TODAY, found by the home lane while building HOME-TODAY) — it is now derived from the viewer's own RSVPs, the same id query 내 경기 uses (one cached request), via a pure, tested selector: selectRsvpdUpcoming (packages/app/src/presentation/utils/rsvpd-upcoming.ts, consumed by useRsvpdUpcomingSessions, packages/app/src/presentation/hooks/queries/use-rsvpd-upcoming-sessions.ts — the RSVP-filtered sibling of useClubUpcomingSessions, see data-and-hooks.md). Discovery (browsing sessions the viewer hasn't RSVP'd to) stays out of 일 entirely — it lives in 번개 찾기. Evidence: decision log (ROUND 3 — home 일).
See also
- Screen Blueprint — where these surfaces appear on each screen.
- Status & Recruitment Conventions — status-badge intent palette, recruitment derivation, status-adaptive surfaces, and status dots/tiles.
- AGENTS.md — machine-enforced component constraints.
- CLAUDE.md — orchestration core + the pointer index back to this doc.
Attendance window and stamps (00707, 2026-11-02)
- A player's window is
rsvps.arrives_at..leaves_at(wall-clock, what people set, on the 참석 시간 Sheet or by the host); the roundsavailable_from_round/available_until_roundare derived on write (private.round_from_time) and are what the engine reads. - The stamp is the server's time, never typed:
stamp_arrival/stamp_exitwritechecked_in_at/checked_out_at=now()for the caller's own rsvp from 30 minutes before the start until the end;undo_arrival_stampwithin five minutes while no official has answered. L2-02's late-arrival picks are not built: L3-36's rule (lateness is computed) wins. - Staff (the session host, club owner/admin, match_director) answer the exceptions through
set_attendance_window,set_arrival_round,confirm_arrival(stamps the person's own window start, on time by construction; never over a real stamp),confirm_arrivals_all(확인할 사람),confirm_arrivals_stamped(00716: 참석 확인's 모두 확인 on 도착, every stamped player without a host record, one transaction, the count),unconfirm_arrival(00717: 되돌리기 on 확인됨 — the host verdict goes, the player's stamp stays),clear_arrival(the stamp and the host record, only before marking a stamped player 불참),mark_no_show; 참석 확인 (L3-36) measures lateness withattendance-check.rules.ts(LATE_THRESHOLD_MINUTES, held to the policy row bycheck:attendance-policy), and 참석자 관리 (L3-35) marks 첫 참석 fromget_session_first_timers(00716);confirm_attendanceis widened to the host. Every write is an RPC: the six window/stamp columns are closed to client INSERT/UPDATE, and the five window/stamp columns to client SELECT — the detail bundle serves the viewer's own window always and every window to staff; the card bundle none. - A cancel or re-join clears the window and stamps; the completion trigger keeps a host-confirmed attended record and counts a stamped player as having come; forfeit and the substitute pick ask one
private.rsvp_in_window. - Scores:
submit_match_scoretakesp_expected_revision; a stale one returns{refused: 'score_changed', stored}as a normal result (never an error), after the authority check; any of the match's four players, the host or an official may enter; the first write wins; retired and defaulted (withstopped_side) rate as a decisive win for the other side; suspended keeps the match in play, cancelled ends it, both unrated.
The live pages: one page, two roles (W4 batch 11, 2026-11-09)
스코어카드 (L3-38, /sessions/[sessionId]/scorecard) and 스코어카드 운영 (L3-39, /match-board) are one page (packages/features/sessions/src/scorecard-page/): it reads one ScorecardPageData (the live bundle and its bulk siblings), opens every Sheet through ScorecardOverlayActions, and a role adds itself only through ScorecardRoleLayer slots (title, header actions, a state Notice, the band, a row's control, a Block's press, the no-draw action). The operator layer fills the slots; it never forks the page.
- Kind, tabs, phase: one rule,
domain/rules/scorecard-kind.rules.ts. A group or knockout stage is a tournament; theking_of_courtstyle is the 코트승급전 (its live tab is 코트); a pickup with exactly one match and no stage is a single match (no tabs); theleagueevent type takes the rotation's tabs; everything else is a rotation.SCORE_TABScopies the kit'sFVK_SCORE_TABS(a test reads the kit); 순위 shows only where a table exists (a score-driven style, a group, swiss or compass stage). The page opens on 순위 once ended or once the groups are done (일정 without 순위), on the live tab while a match is in play, on 일정 otherwise. - Places are worded once (
presentation/utils/match-place.ts): 3라운드 2번 코트, A조 2번 코트, D조 2라운드, 8강 1번 코트, 8강 4경기; the eyebrow leaves the court out. A tournament's waiting matches (court 0) sit under one 대기 head after the courts. - Minutes in play are counted on the device from
startedAt; the bundle's focus-gated refresh is the only polling. - A player's own match: in play inside its time, the Block itself opens score entry; past its time (
rotation_minutes, else the pace rule) or with the rest of its round settled, the door is 점수 입력; once scored, the door is 점수 검토 요청 and the note door sits under it. - 점수 검토 요청 (00718):
match_score_reviewsis an RPC-only table.request_score_review(one of the match's four players; a completed match; until 7 days after the session's end,private.session_end_at; an optional proposal checked byprivate.validate_match_sets; no reason) keeps one open review per match (a second ask answersreview_open);get_session_score_reviewsreturns the open reviews inside the window to session readers, so a review left past it stands. A review never holds the next match. Resolution (keep, change with a rating replay, exclude) belongs to the operator layer. - A score is corrected by its entrant for 10 minutes, then through 점수 검토 요청:
submit_match_scorerates at entry and records who entered it (matches.score_entered_by; NULL for older rows, seeds and service writes).correct_own_score(00741) lets only that person replace a played score with another played score whileprivate.score_self_correction_window()(10 minutes, mirrored bySCORE_SELF_CORRECTION_WINDOW_MINUTESwith a parity test) is open, with the revision checked and the ratings replayed; the other players are told. The client's clock only shows the door 점수 고치기 (canSelfCorrectScore); the server decides, and its refusals are results the Sheet words (closed window, changed score, review open), never errors. A recorded stop, a score with no registry format (formatIdnull: a legacy single score), an open review, a locked group stage and everyone else keep 점수 검토 요청. The page presents the call's failures itself (refusalOf): a typed refusal is the Sheet's helper line, anything else rejects out of the press and is reported, never swallowed. There is no undo timer; see Ratings: replay, score entrant, level mark for the replay and the level-up that a correction can take back. - The operator layer (W4 batch 12):
/match-boardrendersOperatorScorecardScreen, which sends anyone who is not staff (private.is_session_staff; the client mirror isderiveSessionToolAccess().canRunAttendance) to the scorecard and gives staffScorecardPagewithuseOperatorLayer(the page calls its role's layer hook with its data). The rules are pure (domain/rules/scorecard-operator.rules.ts: who plays a match, the action a match offers, ready / held, the band step, the 할 일 rows, the state Notice, the ⋯ rows), and one rule says when a match owes a score (domain/rules/score-owed.rules.ts), read by the player's Block, the schedule rows and 할 일 alike. A staff member who plays a match is a player on it, and never resolves its review. - Staff writes (00719, 00720):
start_session_play(roster locks, round 1's ready matches start),start_round(a round's ready matches: a court and every player's earlier matches settled),start_queued_match(a court-0 match on a free court),complete_session(…, p_cancel_unsettled)(cancels matches nobody scored, never a scored one),replace_unplayed_rounds(one transaction; round 1 only before anything starts),swap_match_players,resolve_score_review(keep, change with a rating replay, exclude),exclude_match/reopen_match(the match readscancelledwithexcluded_at/by/outcomekept, ratings replayed both ways). Staff change a settled score only through a review. - The manage counts (00747, F4).
get_session_manage_counts(session_ids)is the one bulk read of a list row's two figures:fees_to_confirm(입금 확인 N건: NULL unless the caller may confirm the session's fees, 0 for a completed or cancelled session, else other people's submitted rows, never the caller's own) andgames_played(N경기: completed, not excluded matches, for anyone who may read the session). A session the caller may not read has no row; over 200 ids is refused (too_many_sessions).useSessionManageCounts(ids)reads it at parent scope; its root is persist-denied, so a cold offline start draws neither figure. The fee count's authority has one spelling per side: SQLprivate.can_confirm_session_fees, appderiveSessionToolAccess().canSeeSessionMoney(the 일정 상세 Notice shares it); the count need not equalget_club_admin_snapshot's figure, which counts differently.
Tagging a session to a 클럽 대항전 (follow-up batch C, 2026-11-12)
- THE TAG.
sessions.challenge_idis chosen in 경기 유형 (the 대항전 choice of the create wizard and of 일정 수정, one editor Body); it lists the club'sacceptedchallenges whose period holds the date, and with none running the choice is not offered. A trigger onsessionsis the validator (the wizard inserts the row directly): the session's club is one of the two, the challenge isaccepted, the date is inside the period, and the type becomesinterclub. Moving the date out of the period, tagging a cancelled or settled challenge, and untagging once a match is completed are refused with a named hint (session_not_a_side,challenge_not_open,session_out_of_period,challenge_settled,session_has_scored_matches). A bareinterclubsession with no tag stays valid and untouched. - THE OPPONENT. A tagged session reads "vs {club}" (
sessionTitle()of the info model'sopponent; the Feed item, the session page's 정보 row and 매치 미리보기); the opponent comes from the club's challenges through the bulk hookuseSessionOpponents(one read per distinct hosting club, none when no session is tagged). - THE OTHER CLUB'S MEMBER, AND ONLY THEY.
private.is_challenge_counterpart_member(an active member of the challenge's other club, not of the session's club, the challengeaccepted) is used by exactly two policies: the read of a taggedsessionsrow andguest_applications_insert(the session bundles are invokers, so the read policy is their access check). They apply through the ordinary application path, so capacity, the level band, the cooldown, the fee and the refund rule apply as to any applicant; a tagged session is not forced public and the host keeps the approval mode (the editor defaults a tagged session to 자동 승인). Once seated they keep what the seat needs through narrow policies (00757): their own RSVP rows, the tagged session whatever the challenge's status, the matches they play in, and the host club's bank account only while they hold a CONFIRMED seat in a paid, uncancelled tagged session dated at most 14 days ago. The roster they see is themselves alone.