Skip to content

Improvement plan — front end, back end, docs ​

Status: Active · Date: 2026-09-23 · Owner brief: "gemini is no longer working alongside you … fix the outstanding and explore issues on codes that gemini worked … audit, review, plan, implement and review a plan to improve the front and backends and docs across the app." Standing mandate: I decide product questions with evidence, build, ship to preview OTA, and log each decision for veto (decision log).

This plan turns five audits into ordered waves. Every wave ships with its prevention (a test, a gate or a shared-component contract) in the same commit. Status marks are kept current as waves land.


1. Inputs ​

AuditWhat it coveredFindings
Screen auditEvery screen on the Android emulator (78 routes) plus three code sweepsClasses A–I, 2 P0
Gemini code reviewEvery code diff of f446f97f..3c18d142 (session detail, public profile, ListRow, Badge, HeaderActionsMenu, formatDeadline, OG card, lint ratchet)GM-01…23
Backend auditMetric sources end to end, payment gate vs server, migrations ≥ 00640, edge functions, DM threads, dead RPCsBE-1…16
Docs auditDoc claims contradicted by code, dead canon references, AGENTS.md enforcement claims, index healthDOC-1…18
Benchmark researchEmpty states, locked achievements, notification grouping, dues rows, scoped ratings, attendance definitions, scroll hints, home "today"8 recommendations with sources

Working notes (session scratchpad, not repo): eval/gemini-review.md, eval/backend-audit.md, eval/docs-audit.md, eval/research-benchmarks.md.


2. Decisions ​

Each is logged in the decision log with its evidence. Owner veto reverts it.

IDDecisionEvidence
PAY-NONEA viewer with no payment row is none, never a pending hold. One rule, isSessionPaymentOutstanding(gate, status), backs every 입금 surface, and a cancelled session never asks for money.Server: only a confirmed RSVP on a paid session creates a row; attest_session_payment raises "No pending hold to confirm" otherwise (BE-8). The card had the same bug via viewerPaymentGate.
ELO-PATHThe ELO trend chart plots the rating path (0, then the running total), not per-match deltas.A falling red line for two wins (A2); the chart colours by first vs last point.
SCROLL-PILL-RETIREDThe floating "아래로 스크롤해 더보기" pill is removed from every list.No mainstream precedent for static lists; platforms use the cut-off row plus the scroll indicator. Its measurement bug had put it under single-row lists (D1).
ROW-BLEEDA tappable ListRow keeps its padding (whole press layer) and bleeds by it at a section edge, so its content lands exactly where a static row's does. RowList also delivers edge by context, so wrapper rows (AdminNavRow, SettingsRow…) get the trim.The 16 dp title seam root cause (E1). Wrappers never forwarded the cloned edge.
HOST-TOOLS-ONCEHost tools live only in the body's 호스트 도구 section. The header ⋯ menu keeps share, group chat and 문의하기.Gemini's pass listed five tools twice (GM-02). The body section is the ratified SD-IA home.
CONTACT-HOST문의하기 stays in ⋯. The visible path is the roster's host row → profile → message.No new section (unclutter). Revisit if device QA shows pickups need a visible ask.
LENS-LABELThe 경기 tab's fourth lens is labelled with the directory's own name, 테니스장.Four labels must fit one phone-width strip without ellipsis (G1).
EMPTY-ONCEA section with nothing to show renders nothing. A page's primary list keeps one calm empty line. A page that is mostly empty for a new player shows one line instead of N wells. Ghost/teaser content (— — — rows, faded fake cards, 준비 중 entries) is removed.Strava ("No activities yet") and Instagram ("No Posts Yet") consolidate. NN/G reserves skeletons for loading only. Apple's "don't hide" rule is about tab-bar navigation, not in-page sections.
ACHIEVEMENTS-REALThe gallery lists only achievements that can be earned today, plus any the player holds. Locked real ones stay, de-emphasised. Display-only: the unlock engine stays parked by the owner ("achievements later"); the rest reappear when hasProductionUnlockPath flips.Xbox/Nike show locked milestones. No studied app advertises unreleased features (B6: 54 of 74 rows read 준비 중).
BADGE-EXCEPTIONA per-row badge appears only when the row differs from its list's default. The roster marks 호스트 · 예비 · 나; the leaderboard marks only differing bands.C1, C4.
CLUB-ELO-LABELA club-pool rating is always labelled 클럽 ELO. The global rating stays ELO. Fallbacks use the one DEFAULT_ELO. Owner question (not decided here): the research favours ONE global rating with club context as a rank on it (Chess.com, Lichess, UTR, WTN); retiring the club pool would touch the 조 model, which reads it.club_elo_ratings is a separate per-club pool starting at 1000 (BE-2). Features hardcode ?? 1200 while the server starts at 1000.
ONE-ATTENDANCE출석률 = attended ÷ sessions held since the member joined, pooled for a club. One server function, window stated in the label.Three formulas under one label (BE-3): mean-of-rates vs pooled vs RSVP ÷ all-time sessions. Research: no app defines it well, so define it and say it.
HOME-TODAY일 shows today's sessions from the member's clubs (with their RSVP state) plus their RSVP'd pickups. Discovery stays in 번개 찾기.Spond/TeamSnap "closed" model (research Q8). The member's own club had three open sessions the view hid (H1).

Owner questions (not decided here) ​

IDQuestionEvidenceRecommendation
RSVP-DEADLINE-MEANINGDoes 참석 마감 close RSVPs?Found on device 2026-09-23 (OTA 7469af8a): a session whose deadline passed shows "참석 마감 9/8 … 마감됨" and the card's 모집 마감, while its CTA still offers 참석 확정하기 and the server accepts it. The owner-ratified guest rule (00481 "Axis 3") uses rsvp_deadline as the END OF MEMBER PRIORITY — guests are waitlisted before it and get the normal confirm flow after it — while the wizard ("… 마감"), the fact row and deriveRecruitmentStatus (deadline ⇒ closed) all treat it as a close. No server path closes member RSVPs at the deadline.Split the one column into two facts: an optional hard 참석 마감 (server rejects new RSVPs after it; CTA and card say 마감) and the guest opening time (the 00481 window). Until decided, nothing changes: the CTA follows the server.
CLUB-ELO-POOLKeep the separate per-club rating pool?Decided 2026-09-24: retired (owner approved); one rating, club ranks are a filter of it. See club-rating-unification-plan-2026-09-24.md. CLUB-ELO-LABEL is superseded.Label now; decide the pool with the 조 model.

3. Waves ​

Status: ✅ shipped · 🔨 in progress · ⏳ planned. Files are the main edit sites; tests go with them.

W0 — correctness and root causes ✅ ​

ItemFixPrevention
A1 payment banner for non-payers (P0)PAY-NONE: payment-gate.ts none state + isSessionPaymentOutstanding; detail model and ClubSessionCard both call itDomain tests; the screen test's mocked model now uses the real rule
A2 ELO chart falls for a winner (P0)ELO-PATH: records/cards/elo-trend-series.ts; formatEloDelta replaces three hand-rolled signsSeries tests + a card test on the charted data
D1 scroll pillSCROLL-PILL-RETIRED: removed from FeedList/GroupedFeedList, props, i18n key and bridgeNothing left to drift; the removal exposed four hand copies of the list slots, now ONE GroupedFeedList for both platforms + resolveListSlots + useScrollJankHandlers (clone baseline 1018 → 891)
E1/E4 tappable-row seamROW-BLEED: resolveListRowBox (one box for both branches) + RowEdgeContext; flush dropped from the two RowList hosts Gemini had flushedInvariant test: static ≡ tappable content inset for every edge, dense and regular
G1 "테니스장 ..."LENS-LABEL—
H1 wrong CTA일 empty-state CTA → routes.activityPickups; device QA then showed every ?tab= opener was broken — alias redirects dropped the query string (TAB-PARAMS): TabAliasRedirect for all five tab aliasescheck:screen-blueprints rejects a bare tab-root alias redirect (route-tree test)
C9 "복식 / 복식"Timeline row states the session title once (it carries the format)Record detail tests assert no format line
F2 club rows without timeClubScheduleRows rows carry a start timeRow test

W0b — Gemini follow-through ✅ (profile items move to W2/W4) ​

ItemFixStatus
GM-01= E1 above✅
GM-02 host tools twiceHOST-TOOLS-ONCE✅
GM-03 inline alert block, ad hoc gatesSessionAlertsSection; showAlerts/showTerminalBanner/showPendingApplicantsBanner from the model✅
GM-04 DM logic in the screen, isSelfHost dupinquireHost built in the pure share builder from a hoisted initiateDmThread✅ + tests
GM-06/07 title by string concat; a11y overridesurfaceParticipationTitle(confirmed, max) template; default label restored✅
GM-21 3-branch 내 상태One item from a standing value✅
GM-08 HeaderActionsMenu stale doc, raw numbers, backdrop labelDoc, CONTENT_INSET, FLOATING_SURFACE_SHADOW preset, uiStrings().close, menu roles✅
GM-19 OG card literals + ratchet exemptionOG card reads t()/sessionTitle/formatSkillRange/formatDate/formatTimeRange via pure deep imports; ratchet back to []; status words now the app's (완료/취소됨/모집 마감)✅
GM-09…17 public profileDoc rewrite; A5 honest 총 경기; DEFAULT_ELO; formatPercent; drop the club link that duplicates its row; drop the "이미 친구예요" well; 신고 opens the real ReportModal (moved to @twomore/app); recent matches on the one match row (W2 F3)⏳ W2/W4
GM-18 own public profile unmaskedAccepted: it is the viewer's own data—
GM-20 Badge sm defaultKept (logged owner-era decision); the badge count drops under BADGE-EXCEPTION instead—
GM-22formatDeadline/formatTime docs corrected✅
GM-23Not a bug: the model documents a legacy cost_total split as informational (real money split outside the app's rails)—

W1 — subtraction by rule ✅ ​

Done: C1, C8, B6, B10, D4 (batch 1, OTA 7469af8a); B4, C4 (round 2); B3, B7, B8, C6, A9, B5 (round 3); B1/B2 EMPTY-ONCE, C2/C7 EXCEPTION-LEDGER, C3 KIND-GROUPS (round 4, boards decided first).

ItemApproachWireframe first?
B1/B2 new-player profilesEMPTY-ONCE: hide empty sections; one line when all are empty; own profile keeps a one-line prompt only where a next action exists (친구 → 친구 찾기)Yes (profile board)
B3 empty 경기 section before startHide until a draw existsNo
B4 "— — —" personal recordsHide until one record existsNo
B5 ghost recap cardOne line for an empty periodNo
B6 준비 중 achievementsACHIEVEMENTS-REAL — isAchievementShown; gallery and preview totals count one set; the badge's 준비 중 state retired✅
B7 empty roster groupsRender a group only when it has rowsNo
B8 host dashboard with no applicantsOne empty line; 번개 취소 stays only in 위험 구역No
B9 join review emptiesOne "no history" lineNo
B10 zero attention cellsattentionSnapshotCells; one "지금 확인할 일이 없어요" line when none✅
C1 roster badgesBADGE-EXCEPTION — only 호스트 and 예비 carry a badge✅
C4/C6 repeated badgesBADGE-EXCEPTION on the leaderboard and scorecardNo
C2 dues rowsAmount once in the header; one action on unpaid rows only; 면제/메모 in the row detail (KakaoBank 모임통장 pattern)Yes (dues board)
C3 notificationsGroup same-kind items (iOS/Android model: grouping key, summary line, expand)Yes (notifications board)
C5 알림함 ×13State in-app delivery once in the headerNo
C7 attendance check buttonsExceptions only: rows default to 참석; one control to mark 불참Yes (with C2)
C8 home title restates pillsTitle = 홈 (common.tabHome), like every other tab✅
D2 persistent explainersFirst-visit only (U-07b pattern)No
D3 default court band on club cardsOwner design queue (club-card canon) — proposal onlyOwner
D4 클럽 만들기 bandBand only for a newcomer (secondary, U-10); a member with clubs gets a "+" header icon✅

W2 — one grammar per entity ✅ ​

Done: F3 + GM-16 one MatchHistoryRow; F7 part (format breakdown on SessionFormat); CL-STR strings into i18n — round 2. LOCALIZED-TEXT — round 3. F1 PERSON-ROW (+ the lint ratchet back to empty), F5, F6, F7 ONE-CLOCK, E2/E3, I3 — round 4.

ItemApproach
F1 person rows (≥7 forms)One PersonRow on ListRow (photo, then initials; name; one sub; exception badge; optional action)
F3 + GM-16 match rows (3 forms)MatchHistoryRow + formatMatchupDisplay move to @twomore/app; tappable to its session everywhere; score via one formatter
F5 nav rowsLinkCard → ListRow + chevron
F6 destructive rowsVenue rows use RemoveIconButton (item-remove law)
F7 dates, numbers, two clocksLint for hand-formatted dates/numbers in features; choose ONE time format (24 h formatTime vs 12 h formatTimeOfDayKo12 both ship today); computeFormatBreakdown on SessionFormat
E2/E3 hand-rolled rowsOnto ListRow/RowList (challenge blocks, tournament round, participants, ApplicationRow, FriendRow, DM participants)
I3 venue receiptsOnto the flat grammar (text headers, glued "label: value" badges, loose buttons)
CL-STR hardcoded Korean outside the registryMutation toasts (22), adapter errors (auth.supabase.ts ×23, storage.supabase.ts), notification.expo.ts, domain labels (practice log, rotation presets, achievement catalog, scale config, entities) → i18n; widen no-korean-string-literal to those contexts; data tables (divisions.kr.ts, korean-grids.ts, korean-josa.ts) stay

W3 — one number per concept ✅ (front + back) ​

Done (round 3, migrations 00659–00662 + 00666): ONE-ATTENDANCE, BE-4 completeness parity, BE-7 conversions, CLUB-ELO-LABEL + DEFAULT_ELO, BE-5 최장 연승, A8 chip units.

ItemApproach
A3/BE-1 "최종 레이팅" mixes poolsScope the day's final rating to one discipline (doubles, like every ELO surface) or label the discipline
A3/BE-2 club vs global ELOCLUB-ELO-LABEL via one label helper; DEFAULT_ELO replaces ?? 1200 (4 sites) and the mapper's ?? 1000
A4/BE-3 attendance ×3ONE-ATTENDANCE: one RPC; club header = pooled rollup; member-stats uses the same definition
A4/BE-4 completeness 8 vs 7 checksOne rule module (or RPC) both screens read; same media-visibility condition
A5/BE-6 총 경기 fallbackHonest 0; sessions attended gets its own labelled fact if kept
A6/BE-5 연승Profile label → 최고 연승
A7/BE-7 analyticsConversion under the right cell; shown only when the ratio is cohort-bounded; enum → label map with an exhaustiveness test
A8 notification chip unitsEvery chip counts rows
A9 stale states and timesState words derive from dates; date-only values never render a time; completed roster word

W4 — flow and IA ✅ ​

Done: H1 HOME-TODAY + PICKUP-TODAY (round 3); H2, H3 (신고 review screen, tournament next step), GM-15, H4, H5 (round 4).

ItemApproach
H1 home 일 contentHOME-TODAY (needs HomeTodayView testable first — W5)
H2 buried actionsCONTACT-HOST decided; public-profile 공유 moves out of the 신고/차단 menu
H3 dead endsAdmin 신고 → a review list over member_reports; tournament with no rounds → next step; period match rows tappable (F3)
H4 misplaced entries설정 grouped into sections; 없는 장소 추가/제안 내역 move to the 테니스장 directory; new-member mission hidden from the club owner
H5 form frictionDate picker; region select; 회원 탈퇴 placeholder; owner-claim search on VenueFinder
GM-15 profile 신고Real ReportModal (target = user)
A10/BE-13 stale DM previewMigration: AFTER DELETE trigger recomputes the thread's cached preview

W5 — process, platform and back-end hygiene ✅ ​

Done: test infra (spotlight-tour kit mock), BE-10…15 (00663–00665, config.toml, migrations README), pgTAP hygiene, EDGE-AUTH-DECLARED (round 3); I5 (GATE-ONE, AOR-10); ARCH-BLIND — the ArchUnitTS suite had been checking nothing (no graph edges, empty file contents); archunit 2.5.4 + canaries, and the 19 violations it then found are fixed (decision log).

ItemApproach
I5 two agents, one tree ✅AGENTS.md AOR-10 single writer; yarn check = docs + check:static + typecheck + test + lint:strict + agents-md + stale-claims, and pre-push and CI run that same set
Test infrareact-native-spotlight-tour into the test-kit transform allowlist (or a kit mock), so HomeTodayView can be render-tested
BE-11 static-map auth postureverify_jwt = false in supabase/config.toml, not only a CLI flag
BE-12 DM get-or-create raceON CONFLICT in the RPC
BE-14/15 dead RPCsDrop is_authenticated, decay_confidence_for_long_inactive after a caller grep
BE-10 migration numbering gapsRecord 00367 and 00395–00401 as intentional in the migrations README
KR-1 phone ruleRetire no-direct-libphonenumber if nothing can import it (DOC-15)

Docs 🔨 ​

DOC-1…18 (agent, in progress); the session-detail IA sync in canon/sessions.md (DOC-2/DOC-17) is done — verified 2026-09-24: "Detail-screen overview IA" documents the shipped order (live anchor → page-native hero → alerts → notice/live-participant-bar → facts → participation → matches → info → host tools → danger zone) with the Option-A-alerts update (2026-09-22, 85a12160) folded in and an explicit note that the old two-surface/HostCalloutSection framing "supersedes every prior... none of that exists in the shipped screen." Still open: the decision-log follow-up for 85a12160 (DOC-6) and blueprint re-dating for every screen touched above.


4. Order and lanes ​

  1. W0 + W0b land together (one commit per concern), then preview OTA.
  2. W2 before W1 where they share files (profile, roster, dues): a subtraction lands on the one grammar, not on a form that is about to be deleted.
  3. W3 needs server work: one migration per metric with its pgTAP test, db push (standing permission), then the client reads the one source.
  4. W1 items marked "wireframe first" get boards before code. Pure subtractions go straight to code.
  5. At most three sonnet agents at a time, disjoint files, "Do not spawn sub-agents. Never run git stash." in every prompt. yarn check green before every push.

5. Review ​

Each wave closes with an adversarial review of its own diff (a second agent reads the change against this plan and the canon), yarn check, a preview OTA, and the device checklist rows it changes.

Round 4's review (2026-09-24, before it shipped) found one P0 and two P1s, all fixed in the same OTA: the club 신고 queue had no filing path and was open to direct writes (migration 00667, REPORT-ONE-PIPE: reports from club and club-session chats reach club admins; trust counts distinct reporters); dismissing one admin-work kind hid the club's other kinds; the 신고 route had no web page. Also closed: the guest-row warning, the person-row rule's blind spots (and the two rows they hid), a UTC/local date mismatch in the blueprint gate, and an unchecked README migration count. Decision log: ROUND-4 REVIEW.

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