Skip to content

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)

#DriftCanonical fix
1.1Weekday 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.228 (dues day max) in 3 zod bounds, the seed script and both i18n strings; no constantDUES_DAY_MAX exported beside the schema; i18n duesDayMax becomes a template taking it; seed imports it
1.3VENUE_COURT_COUNT_MAX exists but venue-contribution-payload.entity.ts re-types 40 twiceimport the constant
1.4Club-group name (20) and target size (2–40) bounds invented in group-editor.tsx; entity schema has no upper boundbounds 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.5Display-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 storespersist-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.7TIER_LABEL_KO mirrored in the push edge function by comment discipline onlyextend push-copy-sync.test.ts (already diffs CATEGORY_LABEL_KO) to diff the tier labels
1.8Admin divisions in four formats (generator's own literal → migration 00279 → divisions.kr.tsregions.ts), no gate; the generator's lineage comment is backwardsgenerator reads divisions.kr.ts; check:admin-divisions-parity regenerates and diffs against 00279
1.9Achievement type strings re-typed in process_seed_achievements() (SQL), no gatecheck:achievement-seed-types asserts every literal in the migration is in ACHIEVEMENT_TYPES

Wave 2 — one implementation per utility (lane A)

#DriftCanonical fix
2.1Rating-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.2formatDurationMinutes in domain/utils/session-calculator.ts hardcodes Korean and has no product callerdelete (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 oneformatDateDot and formatTimestamp reuse formatDateDotFromDate; local helper renamed
2.4Relative-time bucket math written twice (formatRelativeTime, DM formatThreadTime)one relativeTimeBucket(date, now){unit, value}; each caller maps through its own copy keys
2.5Venue operating-hours formatTimeRange hardcodes " - " and shadows the canonical namerenamed formatOperatingHoursRange, separator from common.timeRange
2.6dues-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 sortByEloDescendinginline / delete / reuse
2.7Saved-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.8use-start-next-round.ts hand-rolls useMutationcreateMutationHook

Wave 3 — structural clones (jscpd)

#CloneCanonical fix
3.1Platform 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.2Adapter 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-calluse-agree-to-correction; two blocks in use-session-payments.ts, club-media.supabase.ts, venue-search.naver.ts, club/membership.tsone refetchMatch / refetchSession helper per adapter; shared row mappers; one createAgreeHook factory
3.3Sibling UI clones: members-paneclub-members-screen; home-month-viewhome-week-viewhome-today-view recap block; nominate-guest-modalinvite-guest-modal; dm-add-membersdm-create-group; session-save-buttonvenue-save-button; club-groups-sectionregular-meets-section; completed-rowdraws-row; score-call-sheetscore-correction-sheet; leaderboard-club-tableaderboard-global-tab; dropdown-chiprange-chip; remote-imageskeleton block; bracket.strategyking-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.jsonextract the shared component / helper / schema fragment; never a second copy
3.4Four theme files repeat the status / medal / naver / banner blocks; the two wizard stores repeat the step-navigation slicethemes/shared-semantics.ts (byte-identical values — the palette itself is not touched); createWizardStepSlice()
3.554 ratchet waiversburn 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

#DriftCanonical fix
4.150 test files hand-roll a @twomore/ui mock and 34 a @twomore/app mock (~6,200 lines) beside 54 / 56 files already on createUiMock / createAppMockevery feature test uses the kit factories with only genuine overrides

Prevention (ships with the waves)

  • check:clonesjscpd in yarn check with 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 opens jest.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 pattern CLUB_NAME_MAX / VENUE_COURT_COUNT_MAX already set; UI never invents a bound.
  • persist-keys.ts is 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-utilsuser-state.rules, factoriesuse-club-role, club-default-photosbanner-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).

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