Status & Recruitment Conventions
Status: Active Last reviewed: 2026-11-09
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 Tag tone + Korean/English label centrally (W2a, 2026-10-19: the v2 Tag replaced the Badge in every wrapper) — never compose <Tag tone="..." label="..." /> ad-hoc per screen. SessionPaymentStatusBadge is the ONE badge of a session fee (W4 batch 10). It draws five states: the payment's own status (pending 미납, paid 완납, waived 면제) and the two open states a surface derives from it and passes in, submitted (확인 대기: the transfer was reported, the host has not confirmed; derived with sessionPaymentGateState(fee, status, submittedAt)) and refund (환불 대기: refundState is pending). A cancelled session never shows 미납 or 확인 대기 (isSessionPaymentOutstanding); its rows are refund business. A session fee says the dues' money words (library § 1.19, owner 2026-10-25): 미납 / 확인 대기 / 완납 — migrated in i18n/{ko,en}/status.ts with 참가비 내역 (L3-16); the retired state words are 입금 필요, 입금 확인 중 and 입금 완료 (before them 송금 …). 입금 stays the verb of the transfer (입금 확인, 입금했다고 알리기); the i18n tests ban 송금, 결제 대기 and 결제 완료 in product copy (결제 is reserved for the PortOne card CTA 결제하기) and hold the badge's words. A surface that draws a fee's state reads this badge and never a Tag of its own (일정 상세's Hero and 참가비 내역 do). The status→tone table (single source of truth in presentation/components/status/status-variant-maps.ts, the *_TONE maps; the W2 research's D1, logged for the owner's veto):
live— session/match/challengein_progress(the Live tag with its 6 dot pulsing 5 s per mount; its own colour, the S2$liverole, off the error red: owner review 50, 2026-10-30)danger— scoredisputedsuccess— session/matchcompleted, RSVPconfirmed(success weak; solid only over photos and heroes), scoreverified, duespaid, session-paymentpaidbrand(ball weak, an invitation) — sessionopen("RSVP available"), recruitmentrecruiting|needsQuorum, club joinabilityopenwarning(amber) — RSVPno_show, duesunpaid|partial, scoreunverified, session-paymentpending|submitted(미납, 확인 대기)info— club joinabilityapproval(a step is needed), session-paymentrefund(환불 대기: thewaitingmeaning, the refund waits on someone else)neutral— every closed or passive state: sessiondraft|locked|cancelled, matchscheduled|cancelled, RSVPwaitlisted(예비, as the boards draw it) andcancelled, recruitmentwaitlist|closed, dues and session-paymentwaived, a loss and a draw (the library's ResultRow), format/style/tier metadata Adding a new domain status: add the mapping instatus-variant-maps.ts, the label ini18n/{ko,en}/status.ts, done. Never add a tone for a one-off status: pick from the six.- Before W2a (history; the Tag's six tones replace it): the 8 Badge variants were 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)).ChallengeStatusBadgeadded 2026-09-23 (A9, screen-audit-2026-09-23.md): rendersderiveChallengeDisplayStatus'sChallengeDisplayStatus(domain/rules/challenge-status.ts) — a challenge's state word is DERIVED from its own dates, never the rawInterClubChallenge.statuscolumn, since no server-side cron auto-advancesinter_club_challenges.statusthe way sessions get auto-advanced; a challenge leftproposed/accepted/in_progresspast its ownendDatecollapses to the derived'ended'(종료), while the three terminal statuses (completed/declined/cancelled) pass through unchanged. 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 SessionCardV2 (via its title-seat SessionLeadBadge — the folded card, 2026-09-18; 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/rules/recruitment-status.ts (packages/app/src/domain/rules/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-soon boolean + calls the derivation. 26-case edge-matrix test. Adding a recruitment state: extend the derivation + RECRUITMENT_VARIANT + i18n — never hand-set on a session.
RSVP-DEADLINE decision (2026-09-24, docs/design/open-issues-plan-2026-09-24.md §4) — a passed RSVP deadline never closes recruitment. RecruitmentStatusInput has no isPastDeadline field: closed is reachable ONLY through status === 'locked' (the host's deliberate lock). This corrects a prior bug where deriveRecruitmentStatus also returned closed once session.rsvpDeadline was behind now — but no server path ever rejects a member's RSVP after the deadline (private.rsvps_enforce_guest_gate, migration 00481, waitlists only GUESTS before it and admits them normally after; members always pass), so the client was lying. The deadline is an ANSWER-BY time, not a cutoff: members answer by it for priority, and once it passes any still-open seats also become available to guests. deadlineSoon keeps its role as the almostFull urgency trigger, but wherever it drives its OWN copy (the not-yet-joined viewer's closing strip chip, common.closingSoon; the rsvp_deadline_soon push/signal) that copy is an answer-by nudge ("응답 기한 임박" / "참석 여부를 알려 주세요"), never a closing claim. The session detail's 응답 기한 fact row (SessionFactsSection) is omitted entirely once the deadline has passed (EMPTY-ONCE — nothing left to act on) instead of stating a past "…마감됨" time.
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 render the same terminal-state treatment as a plain Banner tone="muted" (Ban icon, t().common.clubArchived text) — folded into Banner directly rather than a bespoke ClubStatusBanner (no component of that name exists any more), gated inline at the call site (club.archivedAt != null) and passed into ClubPlaceHeader's notice prop in club-detail-screen.tsx. 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 with ONE chip in its TITLE SEAT (SessionLeadBadge, 2026-09-18 — the folded card,/wireframes/card-continuity: the strip's ranked lead renders as a canonicalSessionStripChipBadge— an attention chip when one fires, else the viewer's role standing the strip already defaults to — never a bare colored word, never a second standing badge) and the ALWAYS-VISIBLE bookmark toggle on the same line — the top band was retired 2026-09-07 (flat-layout plan F1) EXCEPT forlive(owner ruling 2026-09-08 carves that one case back in —SessionLiveBandrevives the full-solid ribbon for a playing-right-now session; every other state stays bandless); never a title-leading dot or a left color-strip; the 2026-09-08 closing-row lead+standing pair is retired (the closing line is the meter alone). 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/raindrop theirs, since their label already names the moment. A live lead renders NO chip of its own (the band said it) — the title seat falls back to the standing badge (호스트/참석/예비, alwayssurface) if there is one. The bookmark — outline$textTertiaryunsaved / filled$primarysaved — 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; deleted 2026-11-02, K5b: the compact row isSessionMediaRow, whose trailing is the count or the status badge and which has no meter;RecruitmentMeterlives on insessions/recruitment-meter.tsxfor the card and the detail) carried NO status dot and NODateTileat all anymore — it is title + right-aligned date·time, thenVenueLink, thenRecruitmentMeter(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; deleted 2026-11-13, nothing drew it) still existed and is exported from@twomore/ui, but as of this pass has zero live JSX render sites inpackages/app/packages/features— the deletedSessionIdentityCard(retired in the flat-page rewrite; its fields were absorbed intoinfo-section.tsx) was the last consumer, andclub-vitals.ts'sbuildDateTilePropshas 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
Badgeliterals stayneutral/warning/live/surface" subset). - CLAUDE.md — orchestration core + the pointer index back to this doc.