Skip to content

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:

  1. Designers who need a complete state catalog before writing copy or flows
  2. Engineers who need a mechanical translation from state → render
  3. Future screen authors who can clone this structure for club home, profile, etc.

Principles

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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, or emote.
  • Modifier — a signal that mutates a payload within a state.
  • Signal — a raw value read from UserState or homeSignals (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)

TransitionTriggerNotes
imminentlivesession.status flips to in_progressAt startTime if admin auto-starts, else explicit
livepost_gamesession.status flips to completedDepends on admin completing — gap case known
post_gamerecap(now - endTime) > 4hAbsolute diff, handles cross-midnight
recaprest_*(now - endTime) > 24hReflection window closes; rest classifier runs
game_dayimminent(startTime - now) < 2hAt the 2h mark
rest_* internalPriority rule re-evaluatedNo 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.

PriorityOverrideTriggerReplaces
1 (sacred)nonelive baseline runningLive is untouchable
2session_cancelledhomeSignals.recentCancelledSession != nullHero becomes "today's session was cancelled" + alternatives CTA
3dues_urgentoverdueDuesCount ≥ 2 OR overdueDuesMaxAgeDays > 14Hero becomes "회비 N건 미납" + resolve CTA
4score_verificationpendingScoreVerificationCount ≥ 2Full-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:

SignalBelt chip thresholdOverride 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:

PrioritySignalChip copy patternTap target
1pendingScoreVerificationCount = 1"점수 확인 {n}건"Match verification screen
2rsvpDeadlineSoon < 12h AND rsvpStatus = pending"RSVP 마감 임박"Session detail / RSVP sheet
3pendingFeedbackCount ≥ 1"피드백 남기기 {n}건"Feedback screen
4overdueDuesCount = 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

ModifierValuesSource
maturitybrand_new | joined | beginner | regular | veteranUserState.maturity
roleplayer | admin | ownerUserState.role
timeOfDaymorning | afternoon | evening | nightDevice clock

Session-context modifiers (7) — session-anchored states only

ModifierValuesSource
weatherImpactnone | nudge | warning | blockingDerived from useWeatherForecast + TMI score
multiSessionfalse | trueUserState.additionalSessions.length > 0
venueFamiliarityknown | first_time(v2) New query: user session history grouped by courtVenueId
rsvpStatusconfirmed | pending | waitlisted | declineduseSessionRsvps for the primary session
rsvpDeadlineSoonfalse | < 24h | < 12hsession.rsvpDeadline minus now
matchupStatuspending | publisheduseSessionMatches(sessionId).length > 0
pendingScoreVerificationCount0 | 1 | 2+homeSignals.pendingScoreVerificationCount (also urgency trigger)

Momentum modifiers (3) — rest + reflection

ModifierValuesSource
recentPlayIntensitynone (0) | light (1) | moderate (2–3) | heavy (4+)homeSignals.matchesLast7dCount
formVectorhot (4W in last 5) | cold (4L in last 5) | mixed (otherwise)homeSignals.formVectorLast5 derived
streakPolaritywin | losssign(UserState.currentStreak) (gated on rest_streak)

Progress / history modifiers (3)

ModifierValuesSource
pendingFeedbackCount0 | 1+homeSignals.pendingFeedbackCount
gapLengthfresh (≤14d) | mild (14–30d) | deep (30–60d) | dormant (60+)profile.lastSessionAt
openPickupsNearby0 | 1+useOpenPickups filtered by region

Club / role modifiers (3)

ModifierValuesSource
adminSchedulingDebtfalse | trueDerived: admin's club has no upcoming sessions in next 14 days
clubActivityDelta{ sessions: number, newMembers: number }(v2) New query: sessions + members since profile.lastSessionAt
newClubMemberfalse | trueclub_members.joinedAt > now - 7 days

Reserved for v3

ModifierNote
sessionRecurrenceoneoff | weekly | biweekly | monthly — from recurrences table. Data model exists. Deferred until recurrence-aware copy variants land.

Modifiers dropped during review

  • newMembersSinceLastVisit → absorbed into clubActivityDelta
  • weatherSeverity → replaced by weatherImpact (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.

NameRolesIntentLayout rulesExample
selfview → ∅Main is complete, no suppNo supp region; hero can expandlive, new_user
view_enrichview → viewMain shows X; supp adds depth to XSupp inherits accent; small gappost_game (result → MVP)
view_actview → actMain shows X; supp offers one action on XSupp has trailing chevronupcoming (session → RSVP)
act_contextact → viewMain prompts action; supp explains contextSupp is quieter (muted text)rest_pure (create CTA → nearby pickups count)
emote_actemote → actMain anchors emotion; supp offers next stepSupp inherits emotion accentrest_streak (celebrate → find rematch)
emote_recallemote → viewMain greets; supp shows what changedSupp is information-denserest_returning (welcome-back → what you missed)
emote_forwardemote → viewMain celebrates; supp points toward goalSupp is a progress bar / next targetrest_milestone (celebrate → next target)
act_actact → actForbidden — 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: inherited

gap: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.

StateRelationshipMain shapeSupp shapeModifier whitelist
liveselfscoreboardmultiSession, pendingScoreVerificationCount
imminentview_actsessionHero(countdown)matchPreview | rsvpActionweatherImpact, matchupStatus, rsvpStatus, rsvpDeadlineSoon, venueFamiliarity, multiSession, maturity
game_dayview_enrichsessionHero(today)participantsweatherImpact, rsvpStatus, venueFamiliarity, multiSession, maturity, role
prepview_actsessionHero(tomorrow)prepChecklist (joined) | participantsweatherImpact, rsvpStatus, venueFamiliarity, maturity
near_weekview_actsessionHero(D-N)rsvpActionrsvpStatus, rsvpDeadlineSoon, weatherImpact, maturity, newClubMember
post_gameview_enrichresultCardmvpRowpendingScoreVerificationCount, formVector, maturity
recapview_enrichresultCard(reflection)feedbackCta | mvpRowpendingFeedbackCount, formVector, maturity
rest_returningemote_recallwelcomeBackHeroclubDeltagapLength, clubActivityDelta, maturity, role
rest_streakemote_actstreakHerorivalCard | findMatchCtastreakPolarity, recentPlayIntensity, rivalRecentActivity, maturity
rest_milestoneemote_forwardcelebrationHeronextTargetProgressmaturity, role
rest_pureact_contextactionHerofindPickupCta | adminNudgerecentPlayIntensity, weatherImpact, openPickupsNearby, adminSchedulingDebt, maturity, role, timeOfDay
new_userselfftueHeromaturity, 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)

ts
// 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:

  1. classifyHomeState → picks baseline OR override (single discrete value)
  2. buildHomePair → looks up the state's row in the matrix, resolves payloads using the modifier whitelist, returns the typed HomePair

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.

Mainscoreboard: LIVE pill, court label, team names, big score, progress bar showing 118/120min. Supp — none. Relationshipself.

imminent

Today's session starts in 45 minutes. User has matchupStatus=published, rsvpStatus=confirmed.

MainsessionHero(countdown): session title, "45분 후 시작", venue image, weather chip if weatherImpact ≥ nudge. SuppmatchPreview: "코트 1 · 복식 / 파트너: 윤하린 / vs 이채원 · 권원호 / H2H 3전 1승" Relationshipview_act (tapping supp opens match board).

game_day

Session at 18:00 today (4h away). User rsvpStatus=confirmed, 4/8 RSVPs so far.

MainsessionHero(today): D-0, 18:00–20:00, venue image, 복식/4/8명/2면 chips. Suppparticipants: avatar row + "서승범님 외 3명 · 4/8명". Relationshipview_enrich.

prep

Session tomorrow at 10am. User is maturity=joined, venueFamiliarity=first_time.

MainsessionHero(tomorrow): D-1, 10:00–12:00, venue photo, "첫 방문" badge. SuppprepChecklist: 라켓 / 테니스화 / 물 / 자외선 차단제 (joined maturity activates the checklist variant). Relationshipview_act.

near_week

Session on Saturday (D+3). User rsvpStatus=pending.

MainsessionHero(D-3): D-3, Saturday 18:00, venue. SupprsvpAction: "참가 확정하기" button. Relationshipview_act.

post_game

Session ended 90 minutes ago. 2W 1L, ELO +25, user just finished.

MainresultCard: "오늘의 결과", 2승 1패 (67%), ELO +25 chip, tier transition if any. SuppmvpRow: "🏆 MVP: 루하루하 (2승) / 2-0 vs 윤세연". Relationshipview_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.

MainresultCard(reflection): same summary as post_game but in a calmer tone. SuppfeedbackCta: "오늘 일정 어땠나요? / 피드백 남기기". Relationshipview_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 }.

MainwelcomeBackHero: "45일 만이에요. 실력은 그대로예요, 1,225 ELO". SuppclubDelta: "돌아온 사이 · 모임 6회 진행 · 새 멤버 2명 가입". Relationshipemote_recall. Strava pattern — concrete, not motivational.

rest_streak (win variant)

currentStreak = 5, streakPolarity = win, rivalRecentActivity = on_streak.

MainstreakHero: "5연승 중" + streak icon + tier badge + ELO delta this week. SupprivalCard: top rival (윤하린) with H2H 4-1. Relationshipemote_act.

rest_streak (loss variant)

currentStreak = -4, streakPolarity = loss, recentPlayIntensity = heavy.

MainstreakHero with inverted tone: "요즘 어려운 경기가 이어지고 있어요" + tier stays visible. SuppfindMatchCta with encouragement copy: "가벼운 번개로 리듬을 찾아보세요". Relationshipemote_act — same relationship, different payload. No new card shape.

rest_milestone

Just hit totalMatches = 10.

MaincelebrationHero: 🏆 "10경기 달성!" + tier icon + ELO since first match. SuppnextTargetProgress: "다음 목표: 25경기 → 첫 업적" with a progress bar showing 10/25. Relationshipemote_forward.

rest_pure (regular player, evening)

No signals. Good weather.

MainactionHero: "다음 경기를 만들어보세요" + regional weather chip + CTAs (일정 만들기 / 번개 찾기). SuppfindPickupCta: "내 지역 번개 3개 열려 있어요 →" (driven by openPickupsNearby). Relationshipact_context. Main is the action; supp explains why now (there's demand).

rest_pure (admin with scheduling debt)

role=admin, adminSchedulingDebt=true.

MainactionHero variant: "이번 주 일정을 등록해주세요". SuppadminNudge: "지난 모임 8명 참가 · MVP 김영수". Relationshipact_context.

rest_pure (heavy player, rest promotion)

recentPlayIntensity=heavy (4 matches in 7 days).

MainactionHero with rest promotion: "오늘은 쉬어가세요" + "이번 주 4경기 완주" stat. SuppfindPickupCta muted: "내일 번개도 준비돼 있어요". Relationshipact_context. Whoop pattern — the app tells you to rest when you've been pushing.

new_user

Brand new, no clubs, no matches.

MainftueHero: "테니스, 함께 하면 더 재밌어요" + club discovery CTA + pickup browse CTA. Supp — none (FTUE is self-contained). Relationshipself.


Override worked examples

session_cancelled

Today's session was cancelled 2 hours ago. User was RSVPed.

MaincancelledHero: "오늘 경기가 취소됐어요" + session title + cancellation time. SuppfindPickupCta: "대신 번개 찾아볼까요?" (with nearby pickups count). Relationshipview_act.

If user has another session tomorrow, the hero mentions it: "오늘 경기 취소됨 · 내일 {session.title}는 그대로 진행돼요".

dues_urgent

overdueDuesCount = 2, overdueDuesMaxAgeDays = 25.

MainduesHero: "회비 2건 미납" + "총 80,000원" + club breakdown. Supp — direct "납부하기" CTA per club. Relationshipview_act. TeamSnap pattern.

score_verification

pendingScoreVerificationCount = 3.

MainscoreVerifyHero: "경기 점수 확인이 필요해요 · 3건" + explanation that stats are blocked. Supp — list preview (up to 3 matches) with per-match "확인" buttons. Relationshipview_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 commit c2c2369 for 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:

StateRecurrence 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

IssueStatusNote
Sessions D+1 invisible before 8pm✅ Fixed 06d9972prep now fires any hour
Sessions exactly D+7 invisible✅ Fixed 06d9972near_week extended to ≤7
post_game cross-midnight✅ Fixed 06d9972Absolute Date diffs
game_live limbo if admin doesn't mark completeKnownSession past endTime + status in_progress → stays live indefinitely. Needs session-service watchdog.
Ties not counted in formVectorIntentionalteam1_score <> team2_score filter in RPC
Device-local time zoneKnownInternational 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:

SlotKindTrigger when activated
sessionRecurrenceModifierSession linked to a recurrences row — activates recurrence copy tones
tournament_activeBaseline stateUser is registered in a live inter-club challenge / bracket
club_invitation_pendingBanner chipUser has an unresolved club invite
trust_tier_changedOverride (mild)Trust tier shifted in last 24h — acknowledges the change
venueFamiliarityModifierRequires new query for session history by venue
clubActivityDeltaModifierRequires 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.

  1. ✅ State-engine bug fixes06d9972. Rewrote computeProximity to fix A/B/C.
  2. keepPreviousData on 7 hooksaa03f15. Flicker-free state during refetch.
  3. get_home_signals RPC + plumbing3d5a213. Data pipeline end-to-end, no consumer yet.
  4. ✅ This spec docdocs/specifications/home-state-model.md.
  5. Port build-anchor + build-contextbuildHomePair. Domain module that implements the state matrix. Pure function, fully unit-tested. Replaces the current two builders.
  6. Render layer: one HomePair component that switches on main.shape and supp.shape. Replaces AnchorZone + ContextZone.
  7. Greeting-belt urgency sub-chip. Extends home-header.tsx to render one chip based on the soft-tier priority list.
  8. Override layouts: cancelledHero, duesHero, scoreVerifyHero — three new main shapes.
  9. v2 modifier wave — implement the reserved queries (venueFamiliarity, clubActivityDelta) and wire them into payload resolvers.
  10. v3 modifier wavesessionRecurrence, 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 HomeState enum 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 MainShape or SuppShape + 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 Rules section updated if this introduces a new pattern

Appendix — decision log

DateDecisionRationale
2026-04-12Drop 잡아보세요 variant, unify on 만들어보세요Copy consistency
2026-04-12Collapse 3 urgency tiers → 2 (baseline + override)Only one soft signal qualifies for banner; not worth new primitive
2026-04-13Classification stays client-side (domain)Hex arch invariant
2026-04-13pendingScoreVerificationCount ≥ 2 = full overrideUser decision — score integrity blocks home
2026-04-13post_game vs recap stay splitDifferent tones justify two shapes
2026-04-13placeholderData: keepPreviousData on critical hooksEcosystem-native flicker fix
2026-04-13RPC is pure aggregator, no classificationKeep domain owning the state machine
2026-04-14prep removes 8pm gateBug A fix — D+1 sessions invisible daytime
2026-04-14near_week extended to D+7 inclusiveBug B fix
2026-04-14post_game/recap use absolute Date diffsBug C fix — cross-midnight
2026-04-14Belt sub-chip is the soft urgency surfaceNo separate banner component
2026-04-14Rest priority: returning > streak > milestone > pureStale emotion rule
2026-04-14streakPolarity modifier handles loss streaksSingle state, inverted payload
2026-04-14All recurrence copy tones valid; renderer picks by proximityFuture-reserved modifier
2026-04-14Chip dismissal by action only, not by tap or swipeIntegrity signals keep reminding

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