Skip to content

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 + 8hcompleted; open|locked past endTime + 4hcompleted (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/match in_progress, score disputed
  • success (green) — session/match completed, RSVP confirmed, score verified, dues paid, session-payment paid
  • accent (teal — "available / joinable", deliberately distinct from success-green) — session open ("RSVP available")
  • warning (amber) — RSVP no_show, dues unpaid|partial, score unverified, session-payment pending (incl. pending+submitted, see below)
  • info (purple) — RSVP waitlisted
  • neutral (true gray) — session locked, match scheduled, format/style/tier metadata chips
  • muted (faded gray) — session draft|cancelled, match cancelled, RSVP cancelled, dues waived, session-payment waived Adding a new domain status: add the mapping in status-badges.tsx, the label in i18n/{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 (dues unpaid+partial=warning "owes money"; consent withdrawn+refused=muted "not granted"; session draft+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 *_VARIANT map in status-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, via eloDeltaVariant(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.tsSTATUS_BADGE_LIGHT/STATUS_BADGE_DARK) and spread into all 4 runtime themes' badge block — 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-blue info==accent; classic-grass dark accent==success). The WCAG-AA gate (themes.test.tspackages/app/src/config/__tests__/themes.test.ts) validates every text-on-bg pair. Runtime loads themes from packages/app/src/config/themes/* — NOT the deleted packages/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 ($badgeError bg + $badgeErrorText fg, 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 the warning amber "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 Badge literals stay neutral/warning/live/surface" subset).
  • CLAUDE.md — orchestration core + the pointer index back to this doc.

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