Consolidation plan — configuration and code, 2026-09-13
Status: Accepted · Executed: 2026-09-13 (rebuild-log entry "Configuration and code consolidation"; the gates it introduced are the living part) · Owner ask: "clean up and consolidate the configurations and codes across the app … do a deep audit across the app, plan for the cleanup and execute."
Evidence
Three read-only lanes (utilities/formatters, configuration drift, hooks/components/adapters) plus a copy-paste scan (jscpd, min 60 tokens / 8 lines, tests and i18n excluded): 195 clones, 3,383 duplicated lines (1.5 %). Prior audits (docs/audits/hardcoding-audit-2026-09-09-*.md, duplication audit 2026-09-08) were re-verified; fixed items are not re-listed. yarn metrics at start: 0 lint errors, 54 ratchet waivers (28 no-middle-dot-join, 12 no-raw-expo-image, 11 no-korean-string-literal, 3 no-stylesheet-create), 243k source lines.
Findings → canonical fix (ranked by blast radius inside each wave)
Wave 1 — configuration owns its values (domain/config, small surgical edits)
| # | Drift | Canonical fix |
|---|---|---|
| 1.1 | Weekday vocabulary hand-typed 4× (club/schemas.ts:41 zod, discovery.ts:63 type, member-availability.entity.ts:65 zod, market.config.ts:36 array) | One DAYS_OF_WEEK const in club/schemas.ts; the schema, the type and WEEK_DAYS_MON_FIRST derive from it |
| 1.2 | 28 (dues day max) in 3 zod bounds, the seed script and both i18n strings; no constant | DUES_DAY_MAX exported beside the schema; i18n duesDayMax becomes a template taking it; seed imports it |
| 1.3 | VENUE_COURT_COUNT_MAX exists but venue-contribution-payload.entity.ts re-types 40 twice | import the constant |
| 1.4 | Club-group name (20) and target size (2–40) bounds invented in group-editor.tsx; entity schema has no upper bound | bounds live on club-group.entity.ts (CLUB_GROUP_NAME_MAX, CLUB_GROUP_TARGET_SIZE_MIN/MAX) and the schema enforces them; the editor imports |
| 1.5 | Display-name max (20) invented in onboarding step 2; profile.entity.ts has only .min(1) | DISPLAY_NAME_MAX on the entity + schema .max; the step imports (the create-club name bound already follows this pattern: CLUB_NAME_MAX) |
| 1.6 | 'twomore-query-cache' typed 5× across mmkv.ts, mmkv.web.ts, query-persister.ts; every persisted store names itself and storage-migration.ts hand-maintains a list that is missing five live stores | persist-keys.ts is the one registry of every persist name (stores import theirs); the migration's wipe list and the MMKV wipe set are Object.values of it |
| 1.7 | TIER_LABEL_KO mirrored in the push edge function by comment discipline only | extend push-copy-sync.test.ts (already diffs CATEGORY_LABEL_KO) to diff the tier labels |
| 1.8 | Admin divisions in four formats (generator's own literal → migration 00279 → divisions.kr.ts → regions.ts), no gate; the generator's lineage comment is backwards | generator reads divisions.kr.ts; check:admin-divisions-parity regenerates and diffs against 00279 |
| 1.9 | Achievement type strings re-typed in process_seed_achievements() (SQL), no gate | check:achievement-seed-types asserts every literal in the migration is in ACHIEVEMENT_TYPES |
Wave 2 — one implementation per utility (lane A)
| # | Drift | Canonical fix |
|---|---|---|
| 2.1 | Rating-band formatting three ways; formatTierNtrp prints NTRP 3.0~3.5 while every sibling prints – (tier-info sheet and range selector differ from cards/filters) | formatTierNtrp/formatUtrBand delegate to formatDecimalBand (the documented S7 target); one separator |
| 2.2 | formatDurationMinutes in domain/utils/session-calculator.ts hardcodes Korean and has no product caller | delete (with its test); formatDuration is the formatter |
| 2.3 | "YYYY.MM.DD" built three ways (formatDateDot, formatDateDotFromDate, inline in formatTimestamp); a feature-local formatDateShort collides with the exported one | formatDateDot and formatTimestamp reuse formatDateDotFromDate; local helper renamed |
| 2.4 | Relative-time bucket math written twice (formatRelativeTime, DM formatThreadTime) | one relativeTimeBucket(date, now) → {unit, value}; each caller maps through its own copy keys |
| 2.5 | Venue operating-hours formatTimeRange hardcodes " - " and shadows the canonical name | renamed formatOperatingHoursRange, separator from common.timeRange |
| 2.6 | dues-helpers.formatAmount wraps t().common.won for four callers while every other feature calls it directly; stale "migrate later" comment in create-club-helpers.ts; inline ELO sort in adapters/supabase/profile/leaderboard.ts beside sortByEloDescending | inline / delete / reuse |
| 2.7 | Saved-session and saved-venue duplicated at port, adapter and hook layer (only table/column differ) | createSavedEntityRepository(table, idColumn) + createSavedIdsHooks(...); the two names stay as thin exports |
| 2.8 | use-start-next-round.ts hand-rolls useMutation | createMutationHook |
Wave 3 — structural clones (jscpd)
| # | Clone | Canonical fix |
|---|---|---|
| 3.1 | Platform twins duplicate their pure core: 5 charts (*.tsx / *.web.tsx: props, types, layout math), image-resize (types, presets), sentry (shared block), feed-list.web.tsx ignores feed-list/{hooks,parts,tuning} | <name>.shared.ts holds everything platform-neutral; the two platform files are thin renderers; web feed list imports the shared modules |
| 3.2 | Adapter self-clones: "RPC then refetch the row" ×3 in match.supabase.ts, ×3 in session.supabase.ts; shared select/mapper blocks between match-score-call / session-detail-bundle / session-card-bundle; use-agree-to-call ≈ use-agree-to-correction; two blocks in use-session-payments.ts, club-media.supabase.ts, venue-search.naver.ts, club/membership.ts | one refetchMatch / refetchSession helper per adapter; shared row mappers; one createAgreeHook factory |
| 3.3 | Sibling UI clones: members-pane ≈ club-members-screen; home-month-view ≈ home-week-view ≈ home-today-view recap block; nominate-guest-modal ≈ invite-guest-modal; dm-add-members ≈ dm-create-group; session-save-button ≈ venue-save-button; club-groups-section ≈ regular-meets-section; completed-row ≈ draws-row; score-call-sheet ≈ score-correction-sheet; leaderboard-club-tab ≈ leaderboard-global-tab; dropdown-chip ≈ range-chip; remote-image ≈ skeleton block; bracket.strategy ≈ king-of-court.strategy block; internal repeats in draws-tab, club-sessions-screen, club-challenges-screen, activity-screen, member-stats-modal, create-session-step-players, club/schemas.ts, club/inputs.ts, venue.entity.ts, scenario-chains.json | extract the shared component / helper / schema fragment; never a second copy |
| 3.4 | Four theme files repeat the status / medal / naver / banner blocks; the two wizard stores repeat the step-navigation slice | themes/shared-semantics.ts (byte-identical values — the palette itself is not touched); createWizardStepSlice() |
| 3.5 | 54 ratchet waivers | burn down: META_TEXT joiner for middle dots, RemoteImage for raw expo-image, i18n for Korean literals, Tamagui for StyleSheet.create |
Wave 4 — tests stand on the kit
| # | Drift | Canonical fix |
|---|---|---|
| 4.1 | 50 test files hand-roll a @twomore/ui mock and 34 a @twomore/app mock (~6,200 lines) beside 54 / 56 files already on createUiMock / createAppMock | every feature test uses the kit factories with only genuine overrides |
Prevention (ships with the waves)
check:clones—jscpdinyarn checkwith a committed baseline of duplicated lines per file pair; a pair may only shrink, a new pair fails.check:test-kit-mocks— a feature test that opensjest.mock('@twomore/ui'|'@twomore/app', () => {with an inline factory fails; baseline ratchets to zero during wave 4.check:admin-divisions-parity,check:achievement-seed-types— the two missing config parity gates.- Entity bounds are exported constants (
*_MAX/*_MIN) beside their zod schema — the patternCLUB_NAME_MAX/VENUE_COURT_COUNT_MAXalready set; UI never invents a bound. persist-keys.tsis the only place a persist name is typed.
Residue after execution
90 clones / 1,192 duplicated lines remain (from 195 / 3,383), held by check:clones: the chart twins' remaining JSX (two SVG libraries render the same shared geometry), feed-list.tsx's own two list shells, and sub-25-line fragments (date-utils ↔ user-state.rules, factories ↔ use-club-role, club-default-photos ↔ banner-presets, use-friend-mutations, primary-action, the two prefill hooks). The middle-dot inline exceptions (one-fact composites) are the wireframe-first follow-up: stack facts as lines/chips once the owner rules on the layout.
Out of scope (tracked elsewhere)
venue-detail-screen / directory-venue-detail-screen merge (blocked on court_venues.venue_id, "Option B"); the no-numeric-spacing-in-features scope gap (no live violation; a follow-up lint scope widen); tier.config.ts raw hex (config table, not a style prop).