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 입금 확인 중 (was 확인 대기, still warning-toned) instead of the generic 입금 필요 (was 결제 대기) — 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 입금 완료 (was 송금 완료) badge and must NOT pass submittedAt into this wrapper. Fee-transfer copy is 입금 vernacular (club-ledger alignment, owner 2026-07-29): sessionPayment renders 입금 필요 / 입금 확인 중 / 입금 완료 / 면제 — migrated in i18n/{ko,en}/status.ts on 2026-09-11; the i18n terminology test bans 송금, 결제 대기 and 결제 완료 in product copy (결제 is reserved for the PortOne card CTA 결제하기). 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 (label 예비, was 대기 — 2026-07-29 vernacular migration, see docs/design/terminology-guide.md)
  • 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 SessionCardV2 (via its closing-row SessionStateInline — the band retired 2026-09-07, except live, which gets SessionLiveBand back per the 2026-09-08 ruling) and match-row surface (MatchRowHeader, MatchScoreboard, 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 마감 임박 — 성사 대기 is quorum-process-queuing, not the RSVP waitlist, so it keeps 대기 under the 2026-07-29 vernacular migration); a singles (max 2) reads 모집 중 → 마감 (never 마감 임박); overbooked → 예비 모집 (was 대기 모집 — RSVP waitlist context, so it moves to 예비 vernacular); 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 treatment, because a chip can't carry "this is over/void". For sessions (2026-09-07, F1): active states render the strip's lead chip as a SessionStripChipBadge in the detail hero's badge row and inline on the card's closing row; a cancelled/completed session resolves an ended strip via deriveSessionStrip and the detail screen renders a full-width Banner (the lead chip's icon + label) at the top of the scroll — the band renderer is gone; the standalone SessionStatusBanner component this used to be (session-status-banner.tsx) has been deleted — the strip absorbed its job ("SessionStripView covers what the old hand-rolled SessionStatusBanner trailing conditional used to derive ad-hoc," per session-detail-screen.tsx). Clubs still use a dedicated banner — ClubStatusBanner (packages/features/clubs/src/shared/club-status-banner.tsx — archived → muted Ban "보관된 클럽이에요") — self-gating to null for non-archived clubs. Status = color + icon + text, never color alone (WCAG/Carbon). (3) The COMPACT card equivalent of a terminal state is MUTING (opacity={0.55}) so void/closed items recede in lists without losing their status chip — SessionCardV2 (cancelled, via the strip's ended band), 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 (HISTORICAL — mechanism retired)

This entire mechanism is gone (2026-07-20 → 2026-07-29). SessionStatusDot, sessionStatusDotColor, sessionStatusStripColor, and sessionStatusDateTone have all been deleted — none of them exist in the tree, and neither does the SessionHeader component or the SessionCompactRow dateStyle prop this section used to describe. Do not resurrect any of these names. Current mechanism:

  • The canonical list card (SessionCardV2) signals status/relationship on its CLOSING ROW (SessionStateInline: the ranked attention lead AND the viewer's standing render as canonical SessionStripChipBadges — never a bare colored word) and the ALWAYS-VISIBLE bookmark toggle on the venue line — the top band was retired 2026-09-07 (flat-layout plan F1) EXCEPT for live (owner ruling 2026-09-08 carves that one case back in — SessionLiveBand revives the full-solid ribbon for a playing-right-now session; every other state stays bandless, closing row only); never a title-leading dot or a left color-strip. A lead's structured sub composes into the SAME badge only when it's a fact the label doesn't already say (payment_due/closing's countdown, waitlisted_standing's queue position) — starting_soon/rain drop theirs, since their label already names the moment. A live lead renders NO badge on the closing row at all (the band said it) — the standing badge (호스트/참석/예비, always surface) is what's left there. The bookmark — outline $textTertiary unsaved / filled $primary saved — is visible on every card, not saved-only, and IS the save toggle (SessionSaveButton) whenever the caller threads a session + viewer id.
  • SessionCompactRow (v3, 2026-07-20) carries NO status dot and NO DateTile at all anymore — it is title + right-aligned date·time, then VenueLink, then RecruitmentMeter (whose leading status word — 모집 중 / 마감 임박 / 마감 — is the row's only status signal). See Conventions › Session Surfaces § Compact session reference.
  • DateTile (packages/ui/src/date-tile.tsx) still exists and is exported from @twomore/ui, but as of this pass has zero live JSX render sites in packages/app/packages/featuressession-identity-card.tsx explicitly dropped it ("No DateTile, no FormatGlyph, no chips") and club-vitals.ts's buildDateTileProps has no external caller either. It no longer takes a status-derived strip color. Treat it as an orphaned primitive, not an active pattern — verify a real call site before citing it as canon anywhere.

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.