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
| Audit | What it covered | Findings |
|---|---|---|
| Screen audit | Every screen on the Android emulator (78 routes) plus three code sweeps | Classes A–I, 2 P0 |
| Gemini code review | Every code diff of f446f97f..3c18d142 (session detail, public profile, ListRow, Badge, HeaderActionsMenu, formatDeadline, OG card, lint ratchet) | GM-01…23 |
| Backend audit | Metric sources end to end, payment gate vs server, migrations ≥ 00640, edge functions, DM threads, dead RPCs | BE-1…16 |
| Docs audit | Doc claims contradicted by code, dead canon references, AGENTS.md enforcement claims, index health | DOC-1…18 |
| Benchmark research | Empty 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.
| ID | Decision | Evidence |
|---|---|---|
| PAY-NONE | A 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-PATH | The 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-RETIRED | The 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-BLEED | A 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-ONCE | Host 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-LABEL | The 경기 tab's fourth lens is labelled with the directory's own name, 테니스장. | Four labels must fit one phone-width strip without ellipsis (G1). |
| EMPTY-ONCE | A 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-REAL | The 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-EXCEPTION | A 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-LABEL | A 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)
| ID | Question | Evidence | Recommendation |
|---|---|---|---|
| RSVP-DEADLINE-MEANING | Does 참석 마감 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-POOL | Keep 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 ✅
| Item | Fix | Prevention |
|---|---|---|
| A1 payment banner for non-payers (P0) | PAY-NONE: payment-gate.ts none state + isSessionPaymentOutstanding; detail model and ClubSessionCard both call it | Domain 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 signs | Series tests + a card test on the charted data |
| D1 scroll pill | SCROLL-PILL-RETIRED: removed from FeedList/GroupedFeedList, props, i18n key and bridge | Nothing 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 seam | ROW-BLEED: resolveListRowBox (one box for both branches) + RowEdgeContext; flush dropped from the two RowList hosts Gemini had flushed | Invariant 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 aliases | check: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 time | ClubScheduleRows rows carry a start time | Row test |
W0b — Gemini follow-through ✅ (profile items move to W2/W4)
| Item | Fix | Status |
|---|---|---|
| GM-01 | = E1 above | ✅ |
| GM-02 host tools twice | HOST-TOOLS-ONCE | ✅ |
| GM-03 inline alert block, ad hoc gates | SessionAlertsSection; showAlerts/showTerminalBanner/showPendingApplicantsBanner from the model | ✅ |
GM-04 DM logic in the screen, isSelfHost dup | inquireHost built in the pure share builder from a hoisted initiateDmThread | ✅ + tests |
| GM-06/07 title by string concat; a11y override | surfaceParticipationTitle(confirmed, max) template; default label restored | ✅ |
| GM-21 3-branch 내 상태 | One item from a standing value | ✅ |
GM-08 HeaderActionsMenu stale doc, raw numbers, backdrop label | Doc, CONTENT_INSET, FLOATING_SURFACE_SHADOW preset, uiStrings().close, menu roles | ✅ |
| GM-19 OG card literals + ratchet exemption | OG card reads t()/sessionTitle/formatSkillRange/formatDate/formatTimeRange via pure deep imports; ratchet back to []; status words now the app's (완료/취소됨/모집 마감) | ✅ |
| GM-09…17 public profile | Doc 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 unmasked | Accepted: it is the viewer's own data | — |
GM-20 Badge sm default | Kept (logged owner-era decision); the badge count drops under BADGE-EXCEPTION instead | — |
| GM-22 | formatDeadline/formatTime docs corrected | ✅ |
| GM-23 | Not 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).
| Item | Approach | Wireframe first? |
|---|---|---|
| B1/B2 new-player profiles | EMPTY-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 start | Hide until a draw exists | No |
| B4 "— — —" personal records | Hide until one record exists | No |
| B5 ghost recap card | One line for an empty period | No |
| B6 준비 중 achievements | ACHIEVEMENTS-REAL — isAchievementShown; gallery and preview totals count one set; the badge's 준비 중 state retired | ✅ |
| B7 empty roster groups | Render a group only when it has rows | No |
| B8 host dashboard with no applicants | One empty line; 번개 취소 stays only in 위험 구역 | No |
| B9 join review empties | One "no history" line | No |
| B10 zero attention cells | attentionSnapshotCells; one "지금 확인할 일이 없어요" line when none | ✅ |
| C1 roster badges | BADGE-EXCEPTION — only 호스트 and 예비 carry a badge | ✅ |
| C4/C6 repeated badges | BADGE-EXCEPTION on the leaderboard and scorecard | No |
| C2 dues rows | Amount once in the header; one action on unpaid rows only; 면제/메모 in the row detail (KakaoBank 모임통장 pattern) | Yes (dues board) |
| C3 notifications | Group same-kind items (iOS/Android model: grouping key, summary line, expand) | Yes (notifications board) |
| C5 알림함 ×13 | State in-app delivery once in the header | No |
| C7 attendance check buttons | Exceptions only: rows default to 참석; one control to mark 불참 | Yes (with C2) |
| C8 home title restates pills | Title = 홈 (common.tabHome), like every other tab | ✅ |
| D2 persistent explainers | First-visit only (U-07b pattern) | No |
| D3 default court band on club cards | Owner design queue (club-card canon) — proposal only | Owner |
| D4 클럽 만들기 band | Band 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.
| Item | Approach |
|---|---|
| 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 rows | LinkCard → ListRow + chevron |
| F6 destructive rows | Venue rows use RemoveIconButton (item-remove law) |
| F7 dates, numbers, two clocks | Lint 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 rows | Onto ListRow/RowList (challenge blocks, tournament round, participants, ApplicationRow, FriendRow, DM participants) |
| I3 venue receipts | Onto the flat grammar (text headers, glued "label: value" badges, loose buttons) |
| CL-STR hardcoded Korean outside the registry | Mutation 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.
| Item | Approach |
|---|---|
| A3/BE-1 "최종 레이팅" mixes pools | Scope the day's final rating to one discipline (doubles, like every ELO surface) or label the discipline |
| A3/BE-2 club vs global ELO | CLUB-ELO-LABEL via one label helper; DEFAULT_ELO replaces ?? 1200 (4 sites) and the mapper's ?? 1000 |
| A4/BE-3 attendance ×3 | ONE-ATTENDANCE: one RPC; club header = pooled rollup; member-stats uses the same definition |
| A4/BE-4 completeness 8 vs 7 checks | One rule module (or RPC) both screens read; same media-visibility condition |
| A5/BE-6 총 경기 fallback | Honest 0; sessions attended gets its own labelled fact if kept |
| A6/BE-5 연승 | Profile label → 최고 연승 |
| A7/BE-7 analytics | Conversion under the right cell; shown only when the ratio is cohort-bounded; enum → label map with an exhaustiveness test |
| A8 notification chip units | Every chip counts rows |
| A9 stale states and times | State 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).
| Item | Approach |
|---|---|
| H1 home 일 content | HOME-TODAY (needs HomeTodayView testable first — W5) |
| H2 buried actions | CONTACT-HOST decided; public-profile 공유 moves out of the 신고/차단 menu |
| H3 dead ends | Admin 신고 → 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 friction | Date picker; region select; 회원 탈퇴 placeholder; owner-claim search on VenueFinder |
| GM-15 profile 신고 | Real ReportModal (target = user) |
| A10/BE-13 stale DM preview | Migration: 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).
| Item | Approach |
|---|---|
| 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 infra | react-native-spotlight-tour into the test-kit transform allowlist (or a kit mock), so HomeTodayView can be render-tested |
BE-11 static-map auth posture | verify_jwt = false in supabase/config.toml, not only a CLI flag |
| BE-12 DM get-or-create race | ON CONFLICT in the RPC |
| BE-14/15 dead RPCs | Drop is_authenticated, decay_confidence_for_long_inactive after a caller grep |
| BE-10 migration numbering gaps | Record 00367 and 00395–00401 as intentional in the migrations README |
| KR-1 phone rule | Retire 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
- W0 + W0b land together (one commit per concern), then preview OTA.
- 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.
- W3 needs server work: one migration per metric with its pgTAP test,
db push(standing permission), then the client reads the one source. - W1 items marked "wireframe first" get boards before code. Pure subtractions go straight to code.
- At most three sonnet agents at a time, disjoint files, "Do not spawn sub-agents. Never run git stash." in every prompt.
yarn checkgreen 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.