Home State Model — Specification
Status: Archived Date: 2026-04-14 Code: src/presentation/components/home/user-state-engine.ts (classifier), src/domain/entities/home-signals.entity.ts (modifier inputs), supabase/migrations/00103_get_home_signals_rpc.sql (aggregation RPC), src/presentation/components/home/build-anchor.ts + build-context.ts (to be ported to buildHomePair)
Purpose
The home screen is a focused, state-driven experience. On every visit, the user sees exactly one hero ("main") answering what matters right now?, paired with at most one supporting element answering what should I do or know about it?. This document is the single source of truth for how the app decides which state to render, which content fills each slot, and how the two slots relate to each other.
It serves three audiences:
- Designers who need a complete state catalog before writing copy or flows
- Engineers who need a mechanical translation from state → render
- Future screen authors who can clone this structure for club home, profile, etc.
Principles
Hero dominance. In focused mode, the main element owns ~60% of the viewport. Supporting elements complement, never compete. If a supporting element needs equal weight, it's actually a second main — and that means the state was mis-classified.
Typed relationship. Every state declares a
(main.role, supp.role)pair from a fixed taxonomy. Two actions side-by-side (act → act) is forbidden. Two decorations side-by-side is forbidden. The relationship is a design invariant, not a suggestion.Single job per slot. Main answers one question. Supp answers one question. Neither contains a list-of-things. If you'd need a scroll view inside a slot, split it into a different state.
Classification is domain logic. State selection, modifier resolution, and relationship choice live in
domain/. Presentation renders a typed payload — it does not decide. This is the hex-arch invariant applied to screen logic.Modifiers augment payload, never shape. A modifier changes a string, swaps an icon, adjusts a CTA target, tints an accent. It never changes which card shape renders or which relationship applies. If a signal needs to do that, it's a new state or a new override — not a modifier.
Two urgency tiers: baseline + override. No third "banner" tier. Soft urgency lives as a chip inside the persistent greeting belt; hard urgency promotes to a full override that replaces the hero. Live always beats all overrides.
Overrides mutually exclusive. At most one override fires. Priority order is explicit. Baseline runs only if no override matched.
Vocabulary
- Baseline state — default state the user is in, selected from a fixed set of 12. Always exactly one fires.
- Override state — an urgency-promoted state that replaces the baseline's hero + supp pair. Three defined.
- Main — the dominant hero card.
- Supp — the single supporting element below main.
- Belt — the persistent identity strip at the top of the home screen (
home-header.tsx). Carries greeting, notification bell, identity row, and optionally one urgency sub-chip. - Shape — the card variant that renders main or supp (e.g.
sessionHero,resultCard,participantsRow). - Role — what the element does:
view,act, oremote. - Modifier — a signal that mutates a payload within a state.
- Signal — a raw value read from
UserStateorhomeSignals(the aggregation RPC).
Baseline state taxonomy (12)
Each row defines one state. Classification runs top-down; the first matching row wins.
| # | State | Trigger | Notes | | --- | ---------------- | -------------------------------------------------------- | ----------------------------------------------- | ---- | --------------------------------------------------- | | 1 | live | Any session with status = in_progress | Beats every override. Sacred. | | 2 | imminent | Session today, open/locked, < 2h until startTime | Urgency tone. | | 3 | game_day | Session today, open/locked, ≥ 2h until startTime | Anticipation tone. | | 4 | prep | Session tomorrow (any hour on D-1) | Recently fixed — 8pm gate removed. | | 5 | near_week | Session D+2..D+7, open/locked | D+7 inclusive (bug fix). | | 6 | post_game | Completed session, 0–4h since endTime | Absolute Date diff (cross-midnight fixed). | | 7 | recap | Completed session, 4–24h since endTime | Reflection window. | | 8 | rest_returning | daysSinceLastSession > 14 | Priority over streak/milestone — stale emotion. | | 9 | rest_streak | | currentStreak | ≥ 3 | Win or loss — streakPolarity modifier picks tone. | | 10 | rest_milestone | totalMatches ∈ {3,5,10,25,50,100} | Just hit; payload includes next target. | | 11 | rest_pure | None of the above | Hero is "create next session" action. | | 12 | new_user | maturity = brand_new, no clubs | FTUE layout. |
Rest priority rule
For rows 8–11, the rule is returning > streak > milestone > pure. Rationale: a 14-day gap invalidates the freshness of streak and milestone emotions; the gap itself is the most recent story. This is stricter than alphabetical or "most specific wins" — it encodes the intent that the user's identity narrative always respects time.
Time boundaries (verified)
| Transition | Trigger | Notes |
|---|---|---|
imminent → live | session.status flips to in_progress | At startTime if admin auto-starts, else explicit |
live → post_game | session.status flips to completed | Depends on admin completing — gap case known |
post_game → recap | (now - endTime) > 4h | Absolute diff, handles cross-midnight |
recap → rest_* | (now - endTime) > 24h | Reflection window closes; rest classifier runs |
game_day → imminent | (startTime - now) < 2h | At the 2h mark |
| rest_* internal | Priority rule re-evaluated | No transition animations between rest variants |
Override taxonomy (3 full overrides)
Overrides replace the entire (main, supp) pair. Classification checks them before baseline. Priority is explicit and mutually exclusive — at most one fires.
| Priority | Override | Trigger | Replaces |
|---|---|---|---|
| 1 (sacred) | none | live baseline running | Live is untouchable |
| 2 | session_cancelled | homeSignals.recentCancelledSession != null | Hero becomes "today's session was cancelled" + alternatives CTA |
| 3 | dues_urgent | overdueDuesCount ≥ 2 OR overdueDuesMaxAgeDays > 14 | Hero becomes "회비 N건 미납" + resolve CTA |
| 4 | score_verification | pendingScoreVerificationCount ≥ 2 | Full-page: user must verify scores before seeing the home |
Live + override interaction
live baseline beats all overrides. A user actively playing a match sees the scoreboard, not a dues card. The belt chip (next section) may still surface soft urgency during live, but the main+supp pair stays live.
Lower-severity counterparts
The baseline counterparts of override triggers live as belt chips (soft tier), so the user still sees the signal without having the screen hijacked:
| Signal | Belt chip threshold | Override threshold |
|---|---|---|
pendingScoreVerificationCount | = 1 | ≥ 2 |
overdueDuesCount | = 1 AND maxAgeDays ≤ 14 | ≥ 2 OR maxAgeDays > 14 |
| Recent cancellation | (not a chip) | recentCancelledSession != null |
Belt urgency chips (soft tier)
The persistent greeting belt already carries: greeting text, notification bell, identity row (avatar + tier + ELO). We extend it with one urgency chip below the identity row when a soft signal is present.
Maximum one chip visible. Priority resolves ties:
| Priority | Signal | Chip copy pattern | Tap target |
|---|---|---|---|
| 1 | pendingScoreVerificationCount = 1 | "점수 확인 {n}건" | Match verification screen |
| 2 | rsvpDeadlineSoon < 12h AND rsvpStatus = pending | "RSVP 마감 임박" | Session detail / RSVP sheet |
| 3 | pendingFeedbackCount ≥ 1 | "피드백 남기기 {n}건" | Feedback screen |
| 4 | overdueDuesCount = 1 AND maxAgeDays ≤ 14 | "회비 미납 1건" | Dues screen |
Chip dismissal is by action, not by tap. Tapping navigates to resolution; the chip disappears on next query refresh if the signal resolved. No "seen" timestamp, no swipe-to-dismiss. Integrity signals keep reminding until the user acts.
Color coding: orange for action-required (score, rsvp), neutral for reminders (feedback, dues 1-count). Red reserved for overrides, not chips.
Modifier namespace (20)
Modifiers augment a state's payload without changing its shape. Each modifier has a canonical name, value domain, data source, and whitelist of states that consume it.
Modifier policy
A modifier must satisfy all three: (a) concrete data source that exists today or is explicitly flagged for the next RPC wave, (b) at least one baseline state's payload resolver opts into it, (c) does not introduce a new card shape or change the relationship type. If a proposed signal fails any, it's either a new state, a new override, or it doesn't belong.
Identity modifiers (3) — globally available
| Modifier | Values | Source |
|---|---|---|
maturity | brand_new | joined | beginner | regular | veteran | UserState.maturity |
role | player | admin | owner | UserState.role |
timeOfDay | morning | afternoon | evening | night | Device clock |
Session-context modifiers (7) — session-anchored states only
| Modifier | Values | Source |
|---|---|---|
weatherImpact | none | nudge | warning | blocking | Derived from useWeatherForecast + TMI score |
multiSession | false | true | UserState.additionalSessions.length > 0 |
venueFamiliarity | known | first_time | (v2) New query: user session history grouped by courtVenueId |
rsvpStatus | confirmed | pending | waitlisted | declined | useSessionRsvps for the primary session |
rsvpDeadlineSoon | false | < 24h | < 12h | session.rsvpDeadline minus now |
matchupStatus | pending | published | useSessionMatches(sessionId).length > 0 |
pendingScoreVerificationCount | 0 | 1 | 2+ | homeSignals.pendingScoreVerificationCount (also urgency trigger) |
Momentum modifiers (3) — rest + reflection
| Modifier | Values | Source |
|---|---|---|
recentPlayIntensity | none (0) | light (1) | moderate (2–3) | heavy (4+) | homeSignals.matchesLast7dCount |
formVector | hot (4W in last 5) | cold (4L in last 5) | mixed (otherwise) | homeSignals.formVectorLast5 derived |
streakPolarity | win | loss | sign(UserState.currentStreak) (gated on rest_streak) |
Progress / history modifiers (3)
| Modifier | Values | Source |
|---|---|---|
pendingFeedbackCount | 0 | 1+ | homeSignals.pendingFeedbackCount |
gapLength | fresh (≤14d) | mild (14–30d) | deep (30–60d) | dormant (60+) | profile.lastSessionAt |
openPickupsNearby | 0 | 1+ | useOpenPickups filtered by region |
Club / role modifiers (3)
| Modifier | Values | Source |
|---|---|---|
adminSchedulingDebt | false | true | Derived: admin's club has no upcoming sessions in next 14 days |
clubActivityDelta | { sessions: number, newMembers: number } | (v2) New query: sessions + members since profile.lastSessionAt |
newClubMember | false | true | club_members.joinedAt > now - 7 days |
Reserved for v3
| Modifier | Note |
|---|---|
sessionRecurrence | oneoff | weekly | biweekly | monthly — from recurrences table. Data model exists. Deferred until recurrence-aware copy variants land. |
Modifiers dropped during review
newMembersSinceLastVisit→ absorbed intoclubActivityDeltaweatherSeverity→ replaced byweatherImpact(signal vs. impact framing)
Current count: 20 active + 1 reserved.
Relationship taxonomy (7 permitted + 1 forbidden)
Every state declares a relationship. The relationship dictates layout rules: accent inheritance, gap rhythm, motion cascade, CTA placement.
| Name | Roles | Intent | Layout rules | Example |
|---|---|---|---|---|
self | view → ∅ | Main is complete, no supp | No supp region; hero can expand | live, new_user |
view_enrich | view → view | Main shows X; supp adds depth to X | Supp inherits accent; small gap | post_game (result → MVP) |
view_act | view → act | Main shows X; supp offers one action on X | Supp has trailing chevron | upcoming (session → RSVP) |
act_context | act → view | Main prompts action; supp explains context | Supp is quieter (muted text) | rest_pure (create CTA → nearby pickups count) |
emote_act | emote → act | Main anchors emotion; supp offers next step | Supp inherits emotion accent | rest_streak (celebrate → find rematch) |
emote_recall | emote → view | Main greets; supp shows what changed | Supp is information-dense | rest_returning (welcome-back → what you missed) |
emote_forward | emote → view | Main celebrates; supp points toward goal | Supp is a progress bar / next target | rest_milestone (celebrate → next target) |
act_act | act → act | — | — | Forbidden — kills today's rest-state CTA duplication |
Layout rules per relationship
self: [MAIN dominant, no supp] accent: main's own
view_enrich: [MAIN] gap:sm [SUPP tinted] accent: inherited
view_act: [MAIN] gap:sm [SUPP w/ chevron] accent: inherited
act_context: [MAIN] gap:md [SUPP muted] accent: neutral supp
emote_act: [MAIN] gap:sm [SUPP accented] accent: inherited
emote_recall: [MAIN] gap:sm [SUPP info] accent: inherited
emote_forward:[MAIN] gap:sm [SUPP progress] accent: inheritedgap:sm = spacing[3] (12px); gap:md = spacing[4] (16px). Visual thread comes from accent inheritance — supp picks up a 2px top stripe matching main's accent color.
State × relationship × modifier matrix
The single source of truth. Every baseline state has exactly one row.
| State | Relationship | Main shape | Supp shape | Modifier whitelist |
|---|---|---|---|---|
live | self | scoreboard | — | multiSession, pendingScoreVerificationCount |
imminent | view_act | sessionHero(countdown) | matchPreview | rsvpAction | weatherImpact, matchupStatus, rsvpStatus, rsvpDeadlineSoon, venueFamiliarity, multiSession, maturity |
game_day | view_enrich | sessionHero(today) | participants | weatherImpact, rsvpStatus, venueFamiliarity, multiSession, maturity, role |
prep | view_act | sessionHero(tomorrow) | prepChecklist (joined) | participants | weatherImpact, rsvpStatus, venueFamiliarity, maturity |
near_week | view_act | sessionHero(D-N) | rsvpAction | rsvpStatus, rsvpDeadlineSoon, weatherImpact, maturity, newClubMember |
post_game | view_enrich | resultCard | mvpRow | pendingScoreVerificationCount, formVector, maturity |
recap | view_enrich | resultCard(reflection) | feedbackCta | mvpRow | pendingFeedbackCount, formVector, maturity |
rest_returning | emote_recall | welcomeBackHero | clubDelta | gapLength, clubActivityDelta, maturity, role |
rest_streak | emote_act | streakHero | rivalCard | findMatchCta | streakPolarity, recentPlayIntensity, rivalRecentActivity, maturity |
rest_milestone | emote_forward | celebrationHero | nextTargetProgress | maturity, role |
rest_pure | act_context | actionHero | findPickupCta | adminNudge | recentPlayIntensity, weatherImpact, openPickupsNearby, adminSchedulingDebt, maturity, role, timeOfDay |
new_user | self | ftueHero | — | maturity, openPickupsNearby |
Any modifier outside a state's whitelist is ignored when that state's payload resolver runs. Payload resolvers explicitly pattern-match on their whitelisted modifiers; unknown modifiers are a type error.
TypeScript type sketch (target API)
// src/domain/home/home-state.ts
export type HomeState =
| 'live'
| 'imminent'
| 'game_day'
| 'prep'
| 'near_week'
| 'post_game'
| 'recap'
| 'rest_returning'
| 'rest_streak'
| 'rest_milestone'
| 'rest_pure'
| 'new_user';
export type HomeOverride = 'session_cancelled' | 'dues_urgent' | 'score_verification';
export type Relationship =
| 'self'
| 'view_enrich'
| 'view_act'
| 'act_context'
| 'emote_act'
| 'emote_recall'
| 'emote_forward';
export type ElementRole = 'view' | 'act' | 'emote';
export type MainShape =
| 'scoreboard'
| 'sessionHero'
| 'resultCard'
| 'welcomeBackHero'
| 'streakHero'
| 'celebrationHero'
| 'actionHero'
| 'ftueHero'
| 'cancelledHero'
| 'duesHero'
| 'scoreVerifyHero';
export type SuppShape =
| 'matchPreview'
| 'rsvpAction'
| 'participants'
| 'prepChecklist'
| 'mvpRow'
| 'feedbackCta'
| 'clubDelta'
| 'rivalCard'
| 'findMatchCta'
| 'nextTargetProgress'
| 'findPickupCta'
| 'adminNudge';
export interface HomePair {
state: HomeState | HomeOverride;
relationship: Relationship;
main: { shape: MainShape; role: ElementRole; payload: unknown };
supp: { shape: SuppShape; role: ElementRole; payload: unknown } | null;
beltChip: BeltChipTrigger | null; // soft urgency, separate from main+supp
}
export function classifyHomeState(
userState: UserState,
signals: HomeSignals,
now: Date
): HomeState | HomeOverride;
export function buildHomePair(
userState: UserState,
signals: HomeSignals,
modifiers: HomeModifiers, // pre-resolved modifier bag
now: Date
): HomePair;Classification is a two-step pipeline:
classifyHomeState→ picks baseline OR override (single discrete value)buildHomePair→ looks up the state's row in the matrix, resolves payloads using the modifier whitelist, returns the typedHomePair
The matrix itself is a Record<HomeState | HomeOverride, StateSpec> constant. Adding a new state is a matrix entry + a payload resolver function. Nothing else changes.
Worked examples
One per baseline state, showing what the user sees. Examples use realistic seed-scenario data.
live
User's doubles match is in progress, court 1, 6-4.
Main — scoreboard: LIVE pill, court label, team names, big score, progress bar showing 118/120min. Supp — none. Relationship — self.
imminent
Today's session starts in 45 minutes. User has matchupStatus=published, rsvpStatus=confirmed.
Main — sessionHero(countdown): session title, "45분 후 시작", venue image, weather chip if weatherImpact ≥ nudge. Supp — matchPreview: "코트 1 · 복식 / 파트너: 윤하린 / vs 이채원 · 권원호 / H2H 3전 1승" Relationship — view_act (tapping supp opens match board).
game_day
Session at 18:00 today (4h away). User rsvpStatus=confirmed, 4/8 RSVPs so far.
Main — sessionHero(today): D-0, 18:00–20:00, venue image, 복식/4/8명/2면 chips. Supp — participants: avatar row + "서승범님 외 3명 · 4/8명". Relationship — view_enrich.
prep
Session tomorrow at 10am. User is maturity=joined, venueFamiliarity=first_time.
Main — sessionHero(tomorrow): D-1, 10:00–12:00, venue photo, "첫 방문" badge. Supp — prepChecklist: 라켓 / 테니스화 / 물 / 자외선 차단제 (joined maturity activates the checklist variant). Relationship — view_act.
near_week
Session on Saturday (D+3). User rsvpStatus=pending.
Main — sessionHero(D-3): D-3, Saturday 18:00, venue. Supp — rsvpAction: "참가 확정하기" button. Relationship — view_act.
post_game
Session ended 90 minutes ago. 2W 1L, ELO +25, user just finished.
Main — resultCard: "오늘의 결과", 2승 1패 (67%), ELO +25 chip, tier transition if any. Supp — mvpRow: "🏆 MVP: 루하루하 (2승) / 2-0 vs 윤세연". Relationship — view_enrich. Feedback CTA is NOT on post_game — that's recap's job (the user needs time to decompress).
recap
Session ended 8 hours ago. Feedback not submitted.
Main — resultCard(reflection): same summary as post_game but in a calmer tone. Supp — feedbackCta: "오늘 일정 어땠나요? / 피드백 남기기". Relationship — view_enrich (still — feedback here is an enrichment, not a primary action, because the result is the hero).
rest_returning
gapLength=deep (45 days). clubActivityDelta = { sessions: 6, newMembers: 2 }.
Main — welcomeBackHero: "45일 만이에요. 실력은 그대로예요, 1,225 ELO". Supp — clubDelta: "돌아온 사이 · 모임 6회 진행 · 새 멤버 2명 가입". Relationship — emote_recall. Strava pattern — concrete, not motivational.
rest_streak (win variant)
currentStreak = 5, streakPolarity = win, rivalRecentActivity = on_streak.
Main — streakHero: "5연승 중" + streak icon + tier badge + ELO delta this week. Supp — rivalCard: top rival (윤하린) with H2H 4-1. Relationship — emote_act.
rest_streak (loss variant)
currentStreak = -4, streakPolarity = loss, recentPlayIntensity = heavy.
Main — streakHero with inverted tone: "요즘 어려운 경기가 이어지고 있어요" + tier stays visible. Supp — findMatchCta with encouragement copy: "가벼운 번개로 리듬을 찾아보세요". Relationship — emote_act — same relationship, different payload. No new card shape.
rest_milestone
Just hit totalMatches = 10.
Main — celebrationHero: 🏆 "10경기 달성!" + tier icon + ELO since first match. Supp — nextTargetProgress: "다음 목표: 25경기 → 첫 업적" with a progress bar showing 10/25. Relationship — emote_forward.
rest_pure (regular player, evening)
No signals. Good weather.
Main — actionHero: "다음 경기를 만들어보세요" + regional weather chip + CTAs (일정 만들기 / 번개 찾기). Supp — findPickupCta: "내 지역 번개 3개 열려 있어요 →" (driven by openPickupsNearby). Relationship — act_context. Main is the action; supp explains why now (there's demand).
rest_pure (admin with scheduling debt)
role=admin, adminSchedulingDebt=true.
Main — actionHero variant: "이번 주 일정을 등록해주세요". Supp — adminNudge: "지난 모임 8명 참가 · MVP 김영수". Relationship — act_context.
rest_pure (heavy player, rest promotion)
recentPlayIntensity=heavy (4 matches in 7 days).
Main — actionHero with rest promotion: "오늘은 쉬어가세요" + "이번 주 4경기 완주" stat. Supp — findPickupCta muted: "내일 번개도 준비돼 있어요". Relationship — act_context. Whoop pattern — the app tells you to rest when you've been pushing.
new_user
Brand new, no clubs, no matches.
Main — ftueHero: "테니스, 함께 하면 더 재밌어요" + club discovery CTA + pickup browse CTA. Supp — none (FTUE is self-contained). Relationship — self.
Override worked examples
session_cancelled
Today's session was cancelled 2 hours ago. User was RSVPed.
Main — cancelledHero: "오늘 경기가 취소됐어요" + session title + cancellation time. Supp — findPickupCta: "대신 번개 찾아볼까요?" (with nearby pickups count). Relationship — view_act.
If user has another session tomorrow, the hero mentions it: "오늘 경기 취소됨 · 내일 {session.title}는 그대로 진행돼요".
dues_urgent
overdueDuesCount = 2, overdueDuesMaxAgeDays = 25.
Main — duesHero: "회비 2건 미납" + "총 80,000원" + club breakdown. Supp — direct "납부하기" CTA per club. Relationship — view_act. TeamSnap pattern.
score_verification
pendingScoreVerificationCount = 3.
Main — scoreVerifyHero: "경기 점수 확인이 필요해요 · 3건" + explanation that stats are blocked. Supp — list preview (up to 3 matches) with per-match "확인" buttons. Relationship — view_act. Full-page (no home below) — the user must verify before seeing their normal home.
Copy strategy
Canonical strings
- Hero for any "no upcoming session" state:
다음 경기를 만들어보세요— never잡아보세요. The second variant was killed in commitc2c2369for consistency. - Rest-mode day greeting for heavy play:
오늘은 쉬어가세요— promotes rest, not action. - Welcome-back:
{gap}일 만이에요with the gap in days, not weeks or "오랜만".
Recurrence tone (v3-reserved)
When sessionRecurrence ≠ oneoff, the copy variant depends on the state's proximity:
| State | Recurrence copy |
|---|---|
imminent, game_day | 오늘 정기 모임 (immediate) |
prep | 내일 정기 모임 (D-1) |
near_week (recurring weekly) | 이번 주 화요일 모임 or 매주 화요일 정기 모임 |
All three tones are valid. The renderer picks by proximity.
No generic daily-tip in rest states
The "오늘의 테니스 팁" inspiration fallback is gated to maturity=brand_new. Regular/beginner/veteran rest states always have a specific supporting row (pickup CTA, rival, nudge, etc.) — never a generic tip. Feature-blast avoidance.
Data pipeline
Supabase tables
│
▼
┌────────────────────────────┐
│ get_home_signals RPC │ ← single aggregation call per home visit
│ (migration 00103) │ returns flat JSONB with 7 fields
└─────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ HomeSignals (domain) │ ← typed entity, never null
│ EMPTY_HOME_SIGNALS fallback│
└─────────────┬───────────────┘
│
▼ + UserState + now
┌────────────────────────────┐
│ classifyHomeState (domain) │ ← single discrete value: state OR override
└─────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ buildHomePair (domain) │ ← looks up matrix row, resolves payloads
│ + resolveModifiers │ only whitelisted modifiers applied
└─────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ Presentation (React) │ ← renders main, supp, belt chip, override
└────────────────────────────┘Everything upstream of the presentation layer is pure. Everything downstream is dumb rendering. The classifier consumes a snapshot, not a stream — time-based transitions happen on next query refetch.
Stale data handling
React Query's placeholderData: keepPreviousData is enabled on the 7 critical-path hooks (commit aa03f15). During refetch, the classifier sees the previous snapshot, not undefined. This prevents mid-mutation flicker across state transitions (e.g. rest → post_game → rest_streak as queries resolve in order).
Device time zone
Classification uses device local time (via getLocalToday() in src/config/date-utils.ts). A Korean user traveling abroad will see "today" relative to their device, not their club. Known limitation — deferred until international use cases are real.
Known limitations & future-reserved slots
Known bugs / edges
| Issue | Status | Note |
|---|---|---|
| Sessions D+1 invisible before 8pm | ✅ Fixed 06d9972 | prep now fires any hour |
| Sessions exactly D+7 invisible | ✅ Fixed 06d9972 | near_week extended to ≤7 |
post_game cross-midnight | ✅ Fixed 06d9972 | Absolute Date diffs |
game_live limbo if admin doesn't mark complete | Known | Session past endTime + status in_progress → stays live indefinitely. Needs session-service watchdog. |
Ties not counted in formVector | Intentional | team1_score <> team2_score filter in RPC |
| Device-local time zone | Known | International users will see stale "today" |
Future-reserved slots
These are named in the taxonomy so the spec doesn't need rewriting when they land, but are not implemented today:
| Slot | Kind | Trigger when activated |
|---|---|---|
sessionRecurrence | Modifier | Session linked to a recurrences row — activates recurrence copy tones |
tournament_active | Baseline state | User is registered in a live inter-club challenge / bracket |
club_invitation_pending | Banner chip | User has an unresolved club invite |
trust_tier_changed | Override (mild) | Trust tier shifted in last 24h — acknowledges the change |
venueFamiliarity | Modifier | Requires new query for session history by venue |
clubActivityDelta | Modifier | Requires new "since timestamp" aggregation query |
The data RPC (get_home_signals) can add fields to accommodate reserved slots without migration churn — JSONB returns are forward-compatible.
Implementation sequence
The spec authorizes the following PRs, in order. Each is independently shippable.
- ✅ State-engine bug fixes —
06d9972. RewrotecomputeProximityto fix A/B/C. - ✅
keepPreviousDataon 7 hooks —aa03f15. Flicker-free state during refetch. - ✅
get_home_signalsRPC + plumbing —3d5a213. Data pipeline end-to-end, no consumer yet. - ✅ This spec doc —
docs/specifications/home-state-model.md. - Port
build-anchor+build-context→buildHomePair. Domain module that implements the state matrix. Pure function, fully unit-tested. Replaces the current two builders. - Render layer: one
HomePaircomponent that switches onmain.shapeandsupp.shape. ReplacesAnchorZone+ContextZone. - Greeting-belt urgency sub-chip. Extends
home-header.tsxto render one chip based on the soft-tier priority list. - Override layouts:
cancelledHero,duesHero,scoreVerifyHero— three new main shapes. - v2 modifier wave — implement the reserved queries (
venueFamiliarity,clubActivityDelta) and wire them into payload resolvers. - v3 modifier wave —
sessionRecurrence, recurrence copy tones.
Review checklist (for contributors adding a state)
Before merging a new state:
- [ ] State name is one-line describable (if not, it's probably two states)
- [ ] Added to
HomeStateenum AND the state matrix - [ ] Declares one relationship from the permitted 7
- [ ] Main and supp roles satisfy
!(main.role === "act" && supp.role === "act") - [ ] Explicit modifier whitelist (even if empty)
- [ ] Payload resolver is a pure function of
(userState, signals, modifiers) - [ ] Worked example added to this spec
- [ ] Unit test asserts the full pair output for a stubbed scenario
- [ ] If new shape needed, added to
MainShapeorSuppShape+ renderer case - [ ] If new modifier needed, follows the modifier policy (payload-only)
- [ ] If new data source needed, either joins an existing hook or adds a field to
get_home_signals - [ ] CLAUDE.md
Rulessection updated if this introduces a new pattern
Appendix — decision log
| Date | Decision | Rationale |
|---|---|---|
| 2026-04-12 | Drop 잡아보세요 variant, unify on 만들어보세요 | Copy consistency |
| 2026-04-12 | Collapse 3 urgency tiers → 2 (baseline + override) | Only one soft signal qualifies for banner; not worth new primitive |
| 2026-04-13 | Classification stays client-side (domain) | Hex arch invariant |
| 2026-04-13 | pendingScoreVerificationCount ≥ 2 = full override | User decision — score integrity blocks home |
| 2026-04-13 | post_game vs recap stay split | Different tones justify two shapes |
| 2026-04-13 | placeholderData: keepPreviousData on critical hooks | Ecosystem-native flicker fix |
| 2026-04-13 | RPC is pure aggregator, no classification | Keep domain owning the state machine |
| 2026-04-14 | prep removes 8pm gate | Bug A fix — D+1 sessions invisible daytime |
| 2026-04-14 | near_week extended to D+7 inclusive | Bug B fix |
| 2026-04-14 | post_game/recap use absolute Date diffs | Bug C fix — cross-midnight |
| 2026-04-14 | Belt sub-chip is the soft urgency surface | No separate banner component |
| 2026-04-14 | Rest priority: returning > streak > milestone > pure | Stale emotion rule |
| 2026-04-14 | streakPolarity modifier handles loss streaks | Single state, inverted payload |
| 2026-04-14 | All recurrence copy tones valid; renderer picks by proximity | Future-reserved modifier |
| 2026-04-14 | Chip dismissal by action only, not by tap or swipe | Integrity signals keep reminding |