Status & Recruitment Conventions
Status: Active Last reviewed: 2026-07-19
How TwoMore renders an entity's lifecycle state — session/match/RSVP/dues/score/payment status, the derived recruitment state, and the surfaces that adapt to them. These are the canonical, single-source rules; feature code consumes the wrappers below and never re-derives status, color, or joinability ad-hoc. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (COMP-2).
Session status invariants
Session status is open|locked|in_progress|completed|cancelled. in_progress means actually playing right now (tennis sessions can legitimately overrun endTime — the clock is not authoritative). Drift is auto-corrected by private.auto_advance_session_statuses() (migration 00137, hourly cron at :15): in_progress past endTime + 8h → completed; open|locked past endTime + 4h → completed (with RSVPs) or cancelled (none); cascade match→session completion when every match in an in_progress session reaches a terminal state. The cascade also fires per-match via the cascade_match_completion_to_session trigger for instant response. Don't paper over stale states in the UI — fix them in the DB.
Status-badge intent palette
Domain status enums (Session, Match, RSVP, Dues, Score, SessionPayment) render via the canonical status-badge components from @twomore/app: SessionStatusBadge, MatchStatusBadge, RsvpStatusBadge, DuesStatusBadge, ScoreStatusBadge, SessionPaymentStatusBadge. Each maps the status value to a Badge variant + Korean/English label centrally — never compose <Badge variant="..." label="..." /> ad-hoc per screen. SessionPaymentStatusBadge is submittedAt-aware (2026-07-19): pass the payment row's submittedAt and a pending status with a set timestamp renders 확인 대기 (still warning-toned) instead of the generic 결제 대기 — the ONE member-facing "awaiting host confirm" label. This is deliberately member-facing only: the HOST roster (session-payments-screen.tsx) keeps rendering its own 송금 완료 badge and must NOT pass submittedAt into this wrapper. The status→variant table (single source of truth in presentation/components/status/status-badges.tsx):
live(red, pairs with PulseDot) — session/matchin_progress, scoredisputedsuccess(green) — session/matchcompleted, RSVPconfirmed, scoreverified, duespaid, session-paymentpaidaccent(teal — "available / joinable", deliberately distinct from success-green) — sessionopen("RSVP available")warning(amber) — RSVPno_show, duesunpaid|partial, scoreunverified, session-paymentpending(incl.pending+submitted, see below)info(purple) — RSVPwaitlistedneutral(true gray) — sessionlocked, matchscheduled, format/style/tier metadata chipsmuted(faded gray) — sessiondraft|cancelled, matchcancelled, RSVPcancelled, dueswaived, session-paymentwaivedAdding a new domain status: add the mapping instatus-badges.tsx, the label ini18n/{ko,en}/status.ts, done. Don't extend Badge variants for one-off statuses; pick from the existing 8.- The 8 variants are a fixed SEMANTIC-INTENT palette (Radix-grounded: step-3 bg + step-11 text, AA by construction): done=
success(green), needs-attention=warning(amber), urgent/now=live(red), available=accent(teal), informational/waitlist=info(purple), passive=neutral(gray), faded/closed=muted,premium=reserved. Color is the at-a-glance TRIAGE axis; the LABEL carries the exact value. So two same-triage statuses deliberately SHARE a variant (duesunpaid+partial=warning "owes money"; consentwithdrawn+refused=muted "not granted"; sessiondraft+cancelled=muted "not active") — the label/amount differentiates; do NOT invent a new hue for these. Give a distinct hue only when two statuses that co-occur in ONE view need at-a-glance separation. - Beyond the core domain-status set, the same wrapper discipline covers computed (non-persisted-enum) badges — each still maps a bounded value to a variant via a
*_VARIANTmap instatus-variant-maps.ts, never an ad-hoc literal. Five canonical wrappers added 2026-07-19 (drift-audit consolidation):ApprovalModeBadge(session/pickup join-review mode:auto→accent "바로 참가 가능" /manual→neutral host-review),AchievementStateBadge(gallery card:unlocked→success 달성 /comingSoon→neutral 준비 중 /locked→muted 잠김),ConfidenceTierBadge(venue-correction editor per-field confidence:high→success multi-source /medium|low→warning single-source,null→muted no-data-yet),ConsentRequirementBadge(PIPA §37 consent-management purpose row:required→neutral /optional→accent),EloDeltaBadge(record-detail match-timeline rating change: positive→success / negative→warning / zero→neutral, viaeloDeltaVariant(delta)). Same rule as the domain wrappers: never compose<Badge variant={...}>ad-hoc for these values in feature code — call the wrapper. - Status colors are THEME-INVARIANT. The 8 badge values are defined ONCE in
status-badge-palette.ts(packages/app/src/config/themes/status-badge-palette.ts—STATUS_BADGE_LIGHT/STATUS_BADGE_DARK) and spread into all 4 runtime themes'badgeblock — success is always green, "available" always teal, regardless of brand skin. Brand expression lives in$primary/$primarySubtle(buttons, links, tier chips), NEVER in status badges. This is why the 4 themes can't drift into per-theme collisions (the old bug: melbourne-blueinfo==accent; classic-grass darkaccent==success). The WCAG-AA gate (themes.test.ts—packages/app/src/config/__tests__/themes.test.ts) validates every text-on-bg pair. Runtime loads themes frompackages/app/src/config/themes/*— NOT the deletedpackages/ui/src/themes.ts(that dead duplicate caused a shipped-but-inert color fix; always verify the runtime path before editing colors). Exception — unread notification count badge (home bell): uses platform-conventional red ($badgeErrorbg +$badgeErrorTextfg, the AA-passing contrast-gated pair) because red = "you have unseen items" is a universal OS convention, not a status-tier. This is DISTINCT from thewarningamber "needs-attention" tier — do not unify them.
@twomore/bounded-variant-enums now also rejects NON-LITERAL Badge variant values (2026-07-19). Previously only bare/braced string literals were checked; now a variant={cond ? 'a' : 'b'} ternary has BOTH branches validated as literals against the 4-value feature-code set, and any other computed shape — a bare identifier, a *_VARIANT[status] map lookup, a local fooBadgeVariant(x) helper call — is reported outright, closing the exact leak class this doc's COMP-2 rule always intended: a computed variant painting a color outside the literal set while staying invisible to a literal-only static check. status-badges.tsx itself is exempt (it IS the destination the rule routes other files toward). 12 pre-existing call sites needed a documented // eslint-disable-next-line @twomore/bounded-variant-enums -- <reason> waiver instead of a dedicated wrapper (locally-scoped variant computations not worth a persisted-enum wrapper) — see packages/eslint-plugin/docs/rules/bounded-variant-enums.md for the full waiver list; each is migrate-on-touch, same discipline as an eslint.ratchet.config.mjs entry.
Status chips are invariant
Status chips on session/match cards are INVARIANT. Every SessionCard, SessionHeader, and match-row surface (ScoreRow, MatchupItem, scorecard rows) must render its status chip — never gate on status != null. The Session and Match entities guarantee a status; if a render path lacks one, the data is wrong upstream and the bug should surface as a typecheck failure (the relevant prop must be required, not optional). Drift between cache and reality should be visible as a wrong badge, not invisible because the chip was conditionally hidden.
Recruitment status (derived, config-driven)
Recruitment status is DERIVED, never the raw session status (config-driven, edge-safe). A recruiting session's joinability state comes from the single source deriveRecruitmentStatus(input) in domain/utils/recruitment-status.ts (packages/app/src/domain/utils/recruitment-status.ts) — NEVER the naive session.status === 'open' → 모집 중 (which wrongly showed 모집 중 when full/overbooked). It returns recruiting | needsQuorum | almostFull | waitlist | closed for open/locked sessions (null for in_progress/completed/cancelled/draft → those keep SessionStatusBadge). Precedence-ordered (closed → waitlist → needsQuorum → almostFull → recruiting), quorum-aware (below minPlayers = 성사 대기, checked BEFORE almost-full), capacity-relative (almost-full needs maxPlayers ≥ 4 AND spotsLeft ≤ ~20%, OR deadline-soon). EDGE-SAFE for the "두 명 더" namesake: a host seeking ≤2 reads 성사 대기 (never 마감 임박); a singles (max 2) reads 모집 중 → 마감 (never 마감 임박); overbooked → 대기 모집; locked → 마감 (the deliberate host-edit path). Consolidates the old scattered ALMOST_FULL_SPOTS/ALMOST_FULL_RATIO banner constants. Render via RecruitmentStatusBadge (state → variant via RECRUITMENT_VARIANT: recruiting/needsQuorum → accent, almostFull → warning, waitlist → info, closed → neutral; labels in i18n statusBadges.recruitment) — never ad-hoc <Badge>. The recruitment COLOR connects card↔detail with NO card fill: CapacityRing (card) + ProgressBar (detail participation hero) take an optional fillColor (default $primary) set from recruitmentFillColor(theme, state) (amber 마감 임박 / muted 마감); the chip carries the label. sessionRecruitmentState(session, confirmedCount) computes the deadline booleans + calls the derivation. 26-case edge-matrix test. Adding a recruitment state: extend the derivation + RECRUITMENT_VARIANT + i18n — never hand-set on a session. Dev-gated location-first card variant: on the sessionSummaryPolishMode dev flag, SessionHeader Row 1 replaces the DateTile with a VenueHero map overlay (surface · booked courts — "하드 · 2면", CARD-NO-OVERFLOW: court info in flex={1} minWidth={0} numberOfLines={1}) and the date/time renders as an intentional pill on its own row (NOT a classification chip — date is the primary identity, orthogonal to the format/style/tier chip strip). This is purely an exploratory polish variant; the production layout (status-colored DateTile + classification chip strip) remains the default until the flag is promoted.
Status-adaptive surfaces
Status-adaptive surfaces — the screen/card ADAPTS to the entity's lifecycle status (render as a function of status, not boolean patches). Evidence-grounded (Carbon/Material/NN-G status patterns + order-tracking IA + the discriminated-union "make invalid states impossible" practice). Three rules: (1) the single primary CTA points at the most-common next action FOR THAT STATE — e.g. an in_progress session's pinned CTA is 라이브 스코어카드 보기 (the #1 live intent), NOT the rare 기권하기 (forfeit), which moves to the gear's destructive group; no market analogue uses a destructive action as the live CTA. (2) Active states carry status via the chip + header live PulseDot; TERMINAL states get a full-width status BANNER at the top of the detail scroll, because a chip can't carry "this is over/void": SessionStatusBanner (packages/app/src/presentation/components/sessions/session-status-banner.tsx — completed → $badgeSuccessBg CheckCircle2 "종료된 일정이에요"; cancelled → $badgeMutedBg Ban "취소된 일정이에요") + ClubStatusBanner (packages/features/clubs/src/shared/club-status-banner.tsx — archived → muted Ban "보관된 클럽이에요"). Both self-gate to null for non-terminal states. Status = color + icon + text, never color alone (WCAG/Carbon). (3) The COMPACT card equivalent of a terminal banner is MUTING (opacity={0.55}) so void/closed items recede in lists without losing their status chip — SessionCard (cancelled), MyClubCard (archived). Card and detail share ONE status language: the chip/badge on the card is the compact form of the banner/CTA on the detail. Joinability is a club's viewer-relative status and is its own canonical status badge — ClubJoinabilityBadge (in status-badges.tsx with CLUB_JOINABILITY_VARIANT: open→accent "가입 가능" / approval→info "승인 후 가입" / invite_only→neutral "초대 전용") on the discover card + preview, so a non-member sees their join path before tapping. Adding a new status-adaptive surface: terminal/void states get a banner (detail) + muting (card); the per-state CTA points at the next action; new joinability/status enums become a map-driven wrapper in status-badges.tsx (NEVER a raw <Badge variant="accent"> in feature code — COMP-2 only allows neutral/warning/live/surface as literals; the intent palette is sanctioned only via the map-driven wrappers).
Status dots & status-colored date tiles
Per-status session dot → SessionStatusDot + sessionStatusDotColor. Use these when a status dot leads a date or title inline (canonical use: club-card upcoming session row). sessionStatusDotColor(status, myRsvpStatus?) returns the full palette — open=teal / locked=gray / completed=green / cancelled·draft=muted + live=red (renders as PulseDot) + confirmed=green — coordinated with SessionStatusBadge so color is never re-stated differently across surfaces. The card LEFT-STRIP (the thin colored indicator on SessionCard) stays minimal: it only signals live (red PulseDot) or confirmed-participant (green dot) via sessionStatusStripColor — it is NOT a full-status palette and should never grow to one. Choose: full-palette dot (auxiliary date/title leading a session reference inside another card) vs minimal strip (the session card itself).
Status-colored DateTile on session-PRIMARY cards — sessionStatusDateTone. SessionHeader (used by SessionCard + every 경기-list row) leads with a LEFT status-colored DateTile (month/day + start-time caption) instead of the old leading dot + date text. The tile's strip color is driven by sessionStatusDateTone(status, myRsvpStatus?) from the status barrel, which returns a {bg, text} token pair sourced directly from the status-badge palette: live=red, confirmed=brand-green ($primarySubtle/$primary), open=teal, locked=gray, completed=success-green, cancelled·draft=muted. Pass the pair as DateTile stripBackgroundColor={tone.bg} stripTextColor={tone.text}. Surface rule (2026-06-13): session-PRIMARY surfaces (SessionCard, 경기 list rows — where the session IS the content) always use a status-colored DateTile; club cards (where the session is AUXILIARY next-meet content) use SessionCompactRow dateStyle='dot' (SessionStatusDot + text date) because a heavy tile competes with the club identity for visual weight. Never put a raw date string or an un-toned DateTile inside SessionHeader.
See also
- Screen Blueprint — where these badges/dots/tiles appear on each screen.
- AGENTS.md — COMP-2 (the machine-enforced "feature
Badgeliterals stayneutral/warning/live/surface" subset). - CLAUDE.md — orchestration core + the pointer index back to this doc.