Skip to content

Session event type, tier cue, quick-chip filters — implementation plan ​

Status: Active · Date: 2026-09-18 · Owner decisions: the folded card ships (CARD-FOLDED); 친선/경쟁 leaves the card once a real type taxonomy exists; list-level facts move to the frame; the tier band gets a glyph and an eligibility cue; build order (i) tier cue → (ii) quick chips + card omission → (iii) taxonomy.

Research inputs: backend taxonomy report (sessions columns, tournaments 1:1 attach, no league/interclub session FK, enum-vs-registry doctrine, the three existing kinds), frontend wiring report (DEFAULT_ELO loaded gun, hero facts closed set, PickupFilterBar anatomy, omit threading chain, test-kit twin), external benchmark (Airbnb/당근 quick chips, Playtomic level bands + request-a-spot, Toss consumer-UX guide on non-punitive ineligibility, Baymard 6–8 chip threshold and zero-result recovery).

Decisions ​

#DecisionWhy
D1Column sessions.event_type TEXT NOT NULL DEFAULT 'regular' CHECK IN (regular,pickup,tournament,league,interclub); TS SessionEventType, session.eventType. Not kind — SessionInfoModel.kind, StrategyRegistry.kind, seasons.kind already exist.Name collision ×3; TEXT+CHECK follows 00500 (composition/tier) and the "enums are irreversible" doctrine; a parity gate mirrors TS ↔ SQL.
D2Backfill by property: pickup ← clubs.type='pickup'; tournament ← EXISTS tournaments; else regular. Two triggers keep it honest: BEFORE INSERT on sessions (a pickup club's session is never regular), AFTER INSERT on tournaments (attaching a bracket makes the session a tournament).Every writer today goes through use-submit-session/host-pickup-game/seed; triggers cover the attach-after-create path and seed rows.
D3league / interclub are host-declared labels (wizard 경기 유형 picker) with no backing structure yet — R7 stays held. They filter and label; the card gets no separate branch until an info model diverges.Benchmark: Playtomic gives tournaments a tab only because the flow diverges; Strava keeps type as a filter field. Don't fork navigation preemptively.
D4is_friendly stays as the ELO axis (generation policy, standings) and leaves the card: sessionTitle() = format (+ event type when not regular): "복식", "복식 번개", "복식 대회". 친선/경쟁 lives on the detail info row (경기 방식) and in the filter panel (competition).Owner: type will matter more than 친선/경쟁 on the card.
D5Terminology: 경기 유형 = event type (정기 · 번개 · 대회 · 리그 · 대항전); 경기 방식 = 친선/경쟁 (ELO-affecting style); 종목 and 대진 방식 unchanged. The guide's stale SessionStyle row (토너먼트) is corrected; 대회 (the event) ≠ 토너먼트 (a draw type under 대진 방식).Resolves the documented 성격/경기 방식 overload the backend report found.
D6Tier cue: the hero gains a 5th fact 🎯 NTRP a–b (실력 무관 when no band); the card's L3 tier segment gets the same 🎯 glyph. When the viewer's real ELO is outside the band the glyph is 🔒 and the numbers go $textTertiary; the card is never greyed or hidden. The detail CTA keeps its gate and states the reason.Toss guide: state the reason, never negative-only; Playtomic never hard-blocks. One glyph grammar (📅 🎾 🎯) across card and hero.
D7Real viewer ELO at list scope: useSessionCardModels calls useProfile(userId) once (15-min stable cache, same hook the detail uses) and threads singlesElo/doublesElo; until the profile loads the builder receives null and viewerEligibleForTier is null (no cue), never a DEFAULT_ELO guess. The LOADED GUN comment is retired.Protocol A (one query per screen); no false lock flashes.
D8REVERSED the same day (owner veto #1 — QUICK-CHIPS-REVERSED in the decision log): the chips duplicated the panel's own rows; the panel is the one filter surface. Original: Quick-chip bar: the collapsed PickupFilterBar shows three chips — 날짜 · 종목 · 경기 유형 — plus the 필터 toggle with its count badge; the chips are the panel's own DropdownChips (single source), a set chip is filled and shows its value ("복식", not "종목"); tap re-filters immediately; the panel keeps region/tier/competition/court/fee. Zero results after filtering render EmptyState with a 필터 해제 secondary CTA.Airbnb/당근 grammar; Apple's ambiguous-filter warning; Baymard's zero-result rule; under the 6–8 threshold.
D9Card omission law (narrowed by D10 — date left the card for good): SessionCardV2 omit? (a read-only set of format / type) — a fact the frame states is not restated on the card. Activity feeds derive omit from the active chips (date mode day → date; format → format; event type → type); home's 오늘 pane omits date (week/month panes keep it — they state only the period). Threaded through useClubSessionCardRow(viewerId, omit?) and its test-kit twin in lockstep.The card shortens exactly when the user narrowed the list; never loses a fact that still varies.

Slices ​

  1. Tier cue (app + features/sessions): D6 + D7. Files: use-session-card-models.ts, session-info-model/{builder,types}.ts (nullable viewer ELO → null eligibility), session-card-v2.tsx (🎯/🔒 on L3, SESSION_FACT_GLYPH exported for the hero), tier-range-badge.tsx (muted), session-hero-section.tsx (🎯 fact), rsvp-cancel-actions.tsx (reason copy audit), tests.
  2. Taxonomy backend + domain (migration 00657 + pgTAP + check:session-event-type-parity + entity/mapper/zod/generated types + seed writers), in parallel with slice 1 — no file overlap.
  3. Quick chips + omission (features/activity, features/home, app card/hook, test-kit): D8 + D9, with the 경기 유형 chip wired to slice 2's field. Wireframe board session-filters (activity feed: Now vs quick-chip bar, empty-state recovery) drawn first.
  4. Taxonomy surfaces: wizard 경기 유형 picker (club flow; pickup flow forces pickup), sessionTitle() D4, detail info row, i18n, terminology guide, canon sessions.md, blueprints, decision log, device list.

Verification per slice: yarn check, web-render rung on /activity, /sessions/[sessionId], /create-session; pgTAP for the migration; preview OTA after slices 1–2 and again after 3–4.

Round 2 — owner feedback on the shipped slices (2026-09-18 afternoon) ​

Owner: (1) the 🎯 cue's bottom was clipped on the phone; (2) confirm the taxonomy fits real tennis; (3) the quick chips duplicated the filter panel — remove them; make the DATE the frame of every session list instead of a per-filter omission; would filters fit on home's 주/월 date selectors?; (4) 🎾 type + 🎯 tier on one row is crammed — research compact categorization.

Research (three lanes, URLs in the agents' reports folded here): real-world taxonomy (USTA leagues/flex/ladders/mixers, LTA ClubSpark box leagues, KTA/KATA 대회 divisions, Playtomic open/competitive/tournament/clinic, Korean 정모·번개·대회·리그·대항전·교류전·게스트데이·레슨) reduces to exactly our five values — box leagues/ladders are draw or season structures (대진 방식 / R7), 게스트 데이 is a session field we have, lessons are a separate product in every app and absent from our schema. List surfacing: Google Calendar schedule view, Apple Calendar list, Todoist Today/Upcoming and Luma all make the DAY the frame and leave only the time on the row; Strava and ClassPass keep the date navigator and the filter entry as separate controls; M3 list guidance and Airbnb's card grammar keep supporting text to one quiet line, never co-equal facts stacked.

#DecisionWhy
D10DATE-SECTIONS law. Every list of session cards is grouped by day: a SessionDayHeader (SectionHeader variant="list", title formatDate(date) "9월 19일 (토)", right-pole meta 오늘/내일 when applicable) precedes each day's cards; days keep the list's own order (a stable partition — groupSessionsByDay), no empty-day rows. A single-day surface (home 오늘 pane, the live stack) IS the frame and renders no header. The card never renders the date again (L2 = ⏰ time); omit narrows to format/type; OMIT_DATE is deleted. Screen-reader label keeps date+time.The benchmark is unanimous; the owner asked for it; home-pane-row.ts already builds section-title rows in a row-union FeedList, so it costs one helper + one header, no new list primitive (GroupedFeedList is un-virtualized SectionList — not used).
D11Card 2+2. L1 venue + lead chip + bookmark · L2 ⏰ time · 🎾 format(+type word, shrinkable) · L3 🎯 tier band | fee · meter. Two facts per row: when/what, then who/cost.The owner's cram is the three-fact L3; the date leaving L2 frees the room. Measured: at 360dp the worst case (단식+복식 대회) ellipsizes the format segment, nothing else moves.
D12No filters on home's 주/월 (owner confirmed 2026-09-18). Home lists the viewer's own sessions (2–12 per period on the QA bed); Strava/ClassPass put filters where lists are long (discovery). If ever wanted: the 필터 toggle at PeriodNavPanel's right pole opening the same panel — never a chip row.Baymard: a filter that removes nothing is chrome. Decision line on the board for veto.
D13Taxonomy stays five. No lesson/guest/ladder values; 리그/대항전 remain host-declared labels until R7.Every candidate is either another axis (draw type, 경기 방식, a guest field) or a different product (lessons).

Mechanics (single sources): groupSessionsByDay<T extends { date: string }>(items): DayGroup<T>[] and SessionDayHeader live in @twomore/app; useDaySectionedSessionList(sessions, models, renderRow) (beside useSessionListRenderer) returns { data: SessionListRow[], keyExtractor, renderItem } for every FeedList of cards (activity ×3, club sessions ×3, guest section via the same grouping helper); home's HomePaneRow gains a day-title row built by the SAME helper and rendered by the SAME header. Seam: a day header owns paddingTop SEAM.rail except the list's first row (the day boundary is a bigger seam than card→card, exactly as the home units join today).

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