Skip to content

PS10 — Records & Analytics UX

Status: Archived Draws from: docs/audits/blocks/U5-records.md (all 13 findings — single-block problem space, no cross-block splitting needed) and docs/architecture/design-specs-2026-07-ui.md's U5 — Records (기록) section (#443-556, findings 1/2/4/5/9 already carry a design spec + Wireframe D, #1012-1039). Cross-cutting fixes are cited from sibling PS docs, not re-derived: PS6 (ps6-clubs-membership.md §2.7/3.7, M7) established the plain-string-compare idiom for the ICU localeCompare class of bug and PS9 (ps9-venues-discovery.md §3.10, slice 12) already promotes it into a shared module and explicitly flags records-history-screen.tsx/ record-detail-screen.tsx as PS10's follow-up — this document closes that follow-up rather than re-inventing the fix. PS6 M6 (ps6-clubs-membership.md §3.4, slice 3) also established the tap-to-profile Pressable-wrap idiom for member/ranking rows that this document extends to leaderboard podium rows — same defect shape, same fix shape, different surface.

Scope note on design-specs-2026-07-ui.md: that document explicitly routes "pure mechanical/backend-only bugs (payload-nesting mismatches, RLS predicates, localeCompare perf fixes, dead-code deletion, doc-drift corrections, route-param threading)" to the implementation plan rather than speccing them, since they carry no design judgment. This solution doc is the landing place those mechanical items were routed to (findings #2 sparkline, #5 localeCompare, #8 tie-break, #11 dead code, #12 signal routing, #13 ELO fallback) — each gets the same rigor as the design-judgment findings, just without a wireframe.


1. Problem statement

#FindingSourceSeverity
R1TierInfoSheet — the tier-explainer opened from every tier badge in this block (HeroCard, every RankRow, every PodiumCard) — is built entirely in raw RN View + inline style={{}} with runtime variables (isActive, primaryVal, tierColor), no exemption comment, a direct Tamagui-only canon violationU5 #1HIGH (canon)
R2EloTrendCard's sparkline plots the 10 most recent ELO deltas newest-first, never reversedSparkLine plots data[0] at the left edge, so the "최근 ELO 추이" chart runs backwards in time (right = past) relative to every other left-to-right convention in the app, and the auto trend-color heuristic (data[last] > data[0]) is semantically inverted as a resultU5 #2HIGH
R3Region leaderboard: top3.length >= 2 gate means a region with exactly 1 ranked player — the plausible-at-launch case — renders a section header with zero rows underneath; club/global tabs have no such gate and render a lone entry correctlyU5 #3HIGH (edge case)
R4useChallengeAchievementBridge's unlock/toast/confetti/streak side effect is wired into only records-screen.tsx, despite its own doc comment ("home challenge section, challenges screen") — a user who never opens 기록 never gets monthly-challenge achievements unlocked or their streak advancedU5 #4MEDIUM-HIGH
R5localeCompare UUID tiebreak reintroduces the ICU-freeze anti-pattern, 4 sites across records-history-screen.tsx/record-detail-screen.tsxU5 #5MEDIUM (latent)
R6All 3 leaderboard tabs cap at top-50 with no "내 순위" affordance — a viewer outside the cap gets zero feedback about where they standU5 #6MEDIUM
R7Podium (top-3) rows aren't tap-to-profile (PodiumEntry has no userId) while rank rows 4+ are — the 3 most prominent rows are the only dead endsU5 #7MEDIUM
R8findTopByRating/club leaderboard ordering has no tie-break column — tied-ELO rank order can flicker across refetchesU5 #8LOW-MEDIUM
R9New-user zero-match state renders 4 separate empty fragments (always-full gray heatmap + 3 compact EmptyStates) instead of 1 unified onboarding moment — the literal first-run state for every early userU5 #9MEDIUM (UX, highest-visibility)
R10TopPartnersCard shows partner rows as text-only — the one people-list in this block skipping the avatar conventionU5 #10LOW
R11Dead code: getGenderAdjustedTier/getGenderThreshold (~55 lines) + 5 cross-rating conversions (~80 lines) in tier.config.ts, plus getFormatItems/MatchFormat in leaderboard-helpers.ts — zero consumers repo-wideU5 #11LOW
R12elo_changed/trust_tier_changed signals route to generic routes.profile — audited as both belonging on 기록U5 #12LOW (not yet live, 00385 unpushed)
R13Hardcoded ELO fallback 1200 in records-screen.tsx disagrees with the canonical DEFAULT_ELO = 1000U5 #13LOW (cosmetic)

Why these are one problem space: every finding here is either (a) a graphics/interaction surface not honoring the app's own established vocabulary — Tamagui tokens (R1), chart directionality (R2), tap-to-profile (R7), avatars (R10), one-task-per-screen empty states (R9) — or (b) a small data-correctness bug feeding those same records/leaderboard surfaces (R2's data half, R3, R6, R8, R12, R13) or their lists (R5, R11). All 13 live in the same 2 packages (packages/features/records, the records-adjacent slice of packages/app) and the same underlying data model (MatchHistoryEntry, tier.config.ts, the 3 leaderboard scopes) — one coherent surface, not a grab-bag.


2. Research

2.1 Component inventory — what this block already has to build with

Read every file in packages/ui/src/charts/ (SparkLine, TriRingProgress, SegmentedDonutChart, MiniBarChart, StackedBarChart, CapacityRing, CourtLinesTile, FormatGlyph, DiagonalGradientFill, CelebrationOverlay) plus the flat packages/ui/src/* primitives. Confirms the audit's "what works well" note with file-level precision — this block already uses, with no gap:

  • Charts: SparkLine (EloTrendCard), TriRingProgress (HeroCard activity rings), a hand-built 12×7 grid (ActivityHeatmapCard — not a packages/ui primitive, but Tamagui-token-driven, no violation) — no ad-hoc SVG anywhere.
  • Canonical components: EmptyState (full/compact, used 7× across this block), ProgressBar (FormatStatsCard/TopPartnersCard/HeroCard tier progress), Badge (medal ranks — neutral/premium/accent literals, COMP-2-compliant), AvatarBubble (PodiumCard, PlayerIdentityChip), Pressable (every tappable row), ModalPanel (TierInfoSheet — confirmed already correctly wrapped, §2.2), FeedList (all 3 leaderboard tabs).
  • themeHex(theme, key) (packages/ui/src/theme-hex.ts:47-50) — the one sanctioned escape hatch for a raw-RN-boundary color read (ActivityIndicator color, SparkLine's auto-color, leaderboard-podium.tsx:66's justified medalColor exception). This is the exact helper TierInfoSheet's fix (R1) needs to route its tierColor/primaryVal reads through, instead of the raw theme.primary?.val ?? 'transparent' pattern it uses today.

No new primitive is needed anywhere in this problem space — every fix below is a correctness/composition fix using components that already exist.

2.2 TierInfoSheet — confirmed already ModalPanel-wrapped; only the row markup needs the port

Read packages/app/src/presentation/components/tier-info-sheet.tsx in full. Confirms the audit's framing precisely: the outer shell (ModalPanel / ModalPanel.Overlay / ModalPanel.Frame, lines 32-34) is already correctly Tamagui — the canon violation is scoped to the row markup inside the .map() (lines 53-91), which is raw RN View + a computed style={{...}} object reading isActive/tierColor/primaryVal/ primarySubtleVal as plain JS variables, not Tamagui props. This means the fix is a pure primitive swap inside an already-correct shell — no ModalPanel migration needed, contrary to what a naive reading of "raw View" might suggest.

tierColor (def.color, from TIER_DEFINITIONS — a genuinely runtime-computed hex per tier, not a theme token) is the one value that legitimately can't become a $token — it needs themeHex-style handling (§2.1), matching leaderboard-podium.tsx:66's own justified exception (style={{ color: medalColor }} // medalColor is a runtime hex — not a Tamagui token). primaryVal/primarySubtleVal, by contrast, ARE theme tokens read the hard way (theme.primary?.val) — these convert directly to $primary/$primarySubtle Tamagui props, no themeHex needed.

2.3 Sparkline direction — tracing the exact index math, not just the symptom

packages/ui/src/charts/SparkLine.tsx:32-39 (buildPoints): data[index] maps to x = index * step, i.e. index 0 renders at the left edge (x=0), last index at the right edge. SparkLine.tsx:63-72's auto-color: last = data[data.length - 1], first = data[0]; last > first → green. This is an unambiguous left = past, right = present contract — matches every other left-to-right surface in this block (ActivityHeatmapCard's own doc comment: "Column 0 = oldest week, column 11 = current week").

elo-trend-card.tsx:20-22 feeds SparkLine a changes array built from recentMatches.slice(0, 10) with no reversaluse-match-history.ts:395's entries is sorted newest-first (descending completedAt), so changes[0] is the most recent match's ELO delta and changes[9] is the oldest of the 10. Fed straight into SparkLine, this inverts BOTH axes of the component's contract at once: the newest match plots leftmost (should be rightmost) and the auto-color reads "is the oldest delta bigger than the newest delta" (should read "is the trend rising"). A single .slice().reverse() on the memoized window fixes both — SparkLine itself needs no change; the bug is entirely in elo-trend-card.tsx's data prep, confirming the audit's "no test file exists for this data-shaping path" note.

2.4 ELO fallback + tie-break — exact constants and columns

  • R13: records-screen.tsx:151: profile?.eloRating ?? 1200 vs. tier.config.ts:295: export const DEFAULT_ELO = 1000; (Silver III floor — the actual server-side new-profile seed, confirmed against INITIAL_ELO_SEEDS.lowerIntermediate = 1000 two lines above it in the same file). The fallback is rarely hit (HeroCard only paints after player.queries — including the profile query — settle, per Protocol G), but it's a magic-number duplicate of a named domain constant one edit away from drifting further.
  • R8: profile.supabase.ts:243-250 (findTopByRating, backing the global leaderboard, limit=100 then client-sliced to 50): .order('elo_rating', { ascending: false }).limit(limit) — no secondary .order(). Postgres doesn't guarantee stable ordering among equal elo_rating rows without one. leaderboard-club-tab.tsx:55 (useClubEloLeaderboard's consumer) re-sorts client-side with the same single-key comparator — same gap, client half. id (the profile UUID, already selected in PUBLIC_PROFILE_COLUMNS) is the obvious deterministic tie-break column — unique, already fetched, no new column/migration.

2.5 Zero-match unified empty state + solo-region leaderboard — Wireframe D is the source of truth

design-specs-2026-07-ui.md already specced both of these (findings #5/#2 in that doc's U5 section, #516-535 and #467-481) with Wireframe D (#1012-1039) as the approved layout — this document does not re-derive the UX decision, only grounds it in the exact code paths and confirms it's still accurate against the current records-screen.tsx.

Re-verified against the live file (records-screen.tsx:247-379): the render is scope === 'friends' && !hasFriends ? <EmptyStateA/> : scope === 'friends' && hasFriends && scopedMatches.length === 0 ? <EmptyStateB/> : <>4 cards, unconditional</> — the friends-scope zero-match case is already handled with a dedicated empty state (2 branches, #316-333); the gap is the non-friends scopes (club/regional/national), which fall straight into the <>...4 cards...</> branch regardless of scopedMatches.length. The fix is a 3rd ternary arm inserted between the friends branches and the final else — scopedMatches.length === 0 → the unified EmptyState, else → the existing 4-card <>. PersonalRecordsSection (records-screen.tsx:313) sits above this ternary (right after HeroCard) and is explicitly confirmed by the audit as already correct at 0 matches ("shows teaser ghost rows below the 5-match threshold instead of hiding outright") — it is not touched by this fix and Wireframe D's ASCII mock omits it only for diagram brevity, not because it should be removed.

i18n reuse, not new copy invented: rankingScreen.noRecentMatches = '아직 경기 기록이 없어요' (ko/ranking.ts:306) already matches Wireframe D's title exactly — reuse it rather than adding a duplicate key. home.findPickup = '번개 찾기' (ko/home.ts:79) already matches the actionLabel. Only the body copy ("첫 경기를 시작하고 나만의 기록을 만들어보세요") needs one new key.

Region leaderboard solo case (leaderboard-region-tab.tsx:168): top3.length >= 2 is the only gate in any of the 3 tabs — leaderboard-club-tab.tsx:155 and leaderboard-global-tab.tsx:106 call <PodiumRow top3={top3} .../> unconditionally, and PodiumRow's own render (leaderboard-podium.tsx:100-116) already handles top3.length === 1 correctly (the .filter() on [top3[1], top3[0], top3[2]] just drops the two undefined slots). The region tab's >= 2 is a regression relative to its two siblings, not a structural limitation of PodiumRow — deleting the extra condition is sufficient.

2.6 Tap-to-profile — the exact shared pattern, cited not re-derived

packages/app/src/presentation/components/player-identity-chip.tsx (PlayerIdentityChip) is the canonical tap-to-profile unit: onPress omitted → defaults to appRouter.push(routes.profileDetail(userId)); onPress={null} → renders non-interactive. leaderboard-rank-row.tsx:57-63 already uses it correctly (RankRow, rank 4+), including the onPressIn-prefetch pattern (prefetchProfile(queryClient, userId), Protocol H). PS6 M6 (ps6-clubs-membership.md §3.4, slice 3) applies the identical fix shape to club member/ranking rows — this document extends the same idiom to PodiumCard, the one remaining tap-dead-end row family in the app's identity-row vocabulary (confirmed via the PlayerIdentityChip docstring: "Tap defaults to /profile/[userId]... Lives in @twomore/app because the tier badge is domain-aware").

PodiumEntry (leaderboard-podium.tsx:12-17) has rank/name/elo/ isCurrentUser — no userId. All 3 call sites already have userId on the underlying leaderboard row (RegionEntry/ClubEntry/GlobalEntry all carry userId, confirmed in leaderboard-region-tab.tsx:47-53, leaderboard-club-tab.tsx:30-36, leaderboard-global-tab.tsx:22-28) — the top3 mapper in each tab simply omits it when building PodiumEntry. Adding userId to the interface and threading it through is a 4-line, 4-file change (interface + 3 mapper call sites), no new data fetch.

Nesting check (≤3-affordance rule): PodiumCard already has one Pressable (the tier badge, opens TierInfoSheet, leaderboard-podium.tsx:73-79). Wrapping the avatar+name block in a second Pressable (profile) gives 2 nested targets — within the row-level ActionButton-pair precedent (docs/architecture/design-specs-2026-07-ui.md's vocabulary section, "≤3 nested affordances").

2.7 Challenge-achievement bridge — verifying the doc comment against what actually renders

use-challenge-achievement-bridge.ts:157-164's own doc comment says "home challenge section, challenges screen." Grepped both claims against the live tree:

  • "challenges screen": packages/features/clubs/src/club-challenges-screen.tsx is the only screen with "challenges" in its name — but it renders useInterClubChallenges (InterClubChallenge — club-vs-club competitions, a completely different domain object) and has zero references to getCurrentChallenges/MonthlyChallenge (confirmed: grep -rln "MonthlyChallenge\|getCurrentChallenges" packages/features → exactly one hit, records-screen.tsx). The audit's own probe already caught this (0 matches for getCurrentChallenges in that file) — this research additionally confirms why: it's a same-word, different-feature naming collision, not a screen that dropped the bridge call.
  • "home challenge section": no such section exists anywhere in packages/features/home/src/** today (confirmed via full directory listing — no challenge-card/monthly-challenge component). But packages/app/src/config/monthly-challenges.ts resolves challenge copy via t().home.challenge — an i18n namespace literally named for a Home surface that was evidently planned but never shipped. MonthlyChallenge has no rendered UI anywhere in the apprecords-screen.tsx computes it purely to feed the bridge's side effects (unlock/toast/streak), never renders a challenge list/progress card itself.

This means the audit's literal fix ("call the hook from wherever challenges are displayed") has no second call site to add it to today — the two places its own doc comment names either don't exist (Home) or display a different domain object entirely (club-challenges-screen.tsx). Building a real Home challenge-progress card is a genuine new feature (new card, placement decision, wireframe-worthy) — out of this document's scope, same discipline PS9 uses for direction-proposed-not-specified items (§2.7 there).

What IS in scope and cheap: decouple the side effect from "records-screen happens to be mounted" without requiring a new visible card. apps/mobile/app/_layout.tsx already mounts app-root, always-on hooks unconditionally for every session (ForegroundRefreshGate wrapping useAppForegroundRefresh(), _layout.tsx:150-154) — this is the established pattern for "must run regardless of which tab is open." The bridge's required inputs (ChallengeProgressInput) are currently computed inline in records-screen.tsx:163-181 from usePlayerStatsDetail + useMatchHistory (both staleTime: STALE_TIME.stable, use-match-history.ts:217,403 — hours-scale, confirmed) plus 3 fields hardcoded to 0 (practiceCount/publicMeetJoinCount/inviteCount — a pre-existing, separate incompleteness in the current computation, not newly introduced by this fix and not re-scoped here since it's not an audited U5 finding). Because TanStack Query dedupes by query key regardless of which component mounts the hook, mounting the identical usePlayerStatsDetail/ useMatchHistory calls at app-root does not double the network cost — if records-screen is later opened in the same STALE_TIME.stable window it's a cache hit; if it's never opened, the root mount is the only fetch, firing at most once per stable-staleTime window (not once per foreground, since STALE_TIME.stable gates re-fetching within TanStack's own cache layer).

2.8 Signal routing — cross-validating the audit's own claim, not just implementing it

signal-routes.ts:43-56: category === 'progression' || category === 'rating' || type === 'achievement_unlocked'routes.profile; separately category === 'trust' || category === 'system'routes.profile. signal.entity.ts:118,122: elo_changed is category: 'rating', trust_tier_changed is category: 'trust' (confirmed both are grouped under distinct category values, not the same one as the audit's phrasing "category: 'rating'/'trust'" could be misread to imply).

Cross-validation catch (multi-dimensional research mandate): the audit claims both elo_changed and trust_tier_changed should route to 기록 because "the actual ELO/tier UI... lives on 기록, not 프로필." Traced where trustTier is actually rendered: grep -rln trustTier packages/featuresprofile-hero-card.tsx, public-profile-screen.tsx, settings-privacy-screen.tsx — all in the profile feature package. TrustTierBadge (used by player-identity-card.tsx:92) never appears anywhere in packages/features/records. Trust tier and ELO tier are two unrelated "tier" concepts — ELO tier (브론즈/실버/골드/마스터/그랜드마스터, HeroCard/ TierInfoSheet/EloTierBadge) lives on 기록; trust tier (sprout/active/ trusted/veteran, the reputation system also used for venue-correction authority per PS9 §2.2) lives on 프로필. The audit's claim is correct for elo_changed, incorrect for trust_tier_changedtrust_tier_changedroutes.profile is already the right destination and should NOT be changed.

signal.entity.ts:118-120 groups elo_changed/tier_promoted/tier_demoted under one // rating comment block, all category: 'rating' — the same getSignalRoute branch currently sends all 3 to routes.profile. Since tier_promoted/tier_demoted's async/notification-tap path shares the exact same defect shape as elo_changed (same category, same branch, same mismatch between "where the content lives" and "where the tap lands") and the fix is the same one-line branch split, routing the whole 'rating' category is the internally-consistent fix rather than special-casing one type out of three sharing a branch. achievement_unlocked/'progression' stay at routes.profile — confirmed via achievements-gallery-screen.tsx/ achievements-preview-section.tsx, both in packages/features/profile, zero presence in records.

2.9 localeCompare — reusing PS9's shared module, not re-deriving the idiom

PS9 (ps9-venues-discovery.md §3.10/slice 12) already builds packages/app/src/presentation/utils/plain-string-compare.ts (exporting plainStringCompare(a, b) => a < b ? -1 : a > b ? 1 : 0) and explicitly scopes its own sweep to 5 club-discovery call sites, flagging the remaining 6 repo-wide hits — including this block's 4 — as follow-ups belonging to sibling problem spaces, PS10 among them. PS6 (ps6-clubs-membership.md §3.7) independently arrived at the identical comparator but inlined it per-site rather than sharing a module. This document follows PS9's shared module, not PS6's inline copy, because both compared sites here (matchId, a UUID) are the textbook case for one canonical implementation rather than a 5th hand-rolled inline copy of the same 1-line function — single-source-of-truth over precedent-matching a sibling doc's stylistic choice.

Re-verified all 4 sites directly (matching the audit's line numbers exactly): records-history-screen.tsx:84 (compareByRecent's fallback, used by every sort order), records-history-screen.tsx:97 (sortMatches's oldest branch), record-detail-screen.tsx:179,192 (identical duplicated shape). All 4 compare matchId — a UUID string with no locale-sensitive ordering need — so plainStringCompare is a correctness-neutral, pure performance fix, matching PS9/PS6's own conclusion for their respective UUID/ISO-string sites.

Sequencing dependency, stated explicitly: if PS9's slice 12 lands first, this document's slice imports the already-shipped module. If this document ships first, it creates the module at the same path (packages/app/src/presentation/utils/plain-string-compare.ts) with the identical export shape, and PS9's slice 12 becomes a no-op create (import already exists) when it lands. Either order is safe; the module's location and signature are fixed here so both docs converge on the same file regardless of ship order.


3. Solution design

3.1 R1 — TierInfoSheet Tamagui port

tsx
// tier-info-sheet.tsx — row markup only; ModalPanel shell unchanged (§2.2)
import { YStack, XStack, Text, useTheme, themeHex, ModalPanel } from '@twomore/ui';
// (drop `import { View } from 'react-native'`)

{
  [...TIER_DEFINITIONS].reverse().map((def) => {
    const isActive = def.tier === currentTier;
    const tierColorHex = def.color; // genuinely runtime — themeHex is not for
    // this (it's not a theme key), stays a
    // literal hex prop on the dot, same as
    // leaderboard-podium.tsx:66's precedent
    return (
      <XStack
        key={def.tier}
        alignItems="center"
        gap="$3"
        paddingVertical="$2"
        paddingHorizontal="$3"
        borderRadius="$3"
        backgroundColor={isActive ? '$primarySubtle' : 'transparent'}
        borderLeftWidth={isActive ? 3 : 0}
        borderLeftColor={isActive ? '$primary' : 'transparent'}
      >
        <YStack
          width={12}
          height={12}
          borderRadius={6}
          backgroundColor={tierColorHex} // one literal-hex prop, same class
          // of exception as PodiumCard's medal
          flexShrink={0}
        />
        <Text
          role="cardBody"
          flex={1}
          fontWeight={isActive ? '700' : '400'}
          color={isActive ? '$primary' : '$text'}
        >
          {tierLabel(def.tier)}
        </Text>
        <Text role="cardMeta">{rangeLabel}</Text>
      </XStack>
    );
  });
}

useTheme() and the manual theme.primary?.val/theme.primarySubtle?.val reads are deleted entirely — $primary/$primarySubtle as direct Tamagui color props resolve through the theme automatically, which is the actual canon fix (not just swapping ViewYStack while keeping the manual reads).

3.2 R2 — sparkline direction (one line, elo-trend-card.tsx)

ts
const {
  window: matchWindow,
  changes,
  totalChange,
} = useMemo(() => {
  const w = recentMatches.slice(0, 10).reverse(); // oldest→newest, left→right
  const c = w.map((m) => m.eloChange ?? 0);
  return { window: w, changes: c, totalChange: c.reduce((sum, x) => sum + x, 0) };
}, [recentMatches]);

.reverse() after .slice() (not before — recentMatches itself must stay newest-first for matchWindow.length/other consumers unaffected). No change to SparkLine.tsx — its left=past/right=present + last>first→green contract was already correct; the bug was entirely upstream data prep.

3.3 R13 — ELO fallback (one line, records-screen.tsx)

ts
import { DEFAULT_ELO } from '@twomore/app'; // already exported per index.ts audit trace
const elo = profile?.eloRating ?? DEFAULT_ELO; // was: ?? 1200

3.4 R8 — tie-break column (profile.supabase.ts + 2 client sort sites)

ts
// findTopByRating — profile.supabase.ts:249
.order('elo_rating', { ascending: false })
.order('id', { ascending: true }) // NEW — deterministic tiebreak, no migration
.limit(limit);
ts
// leaderboard-club-tab.tsx:55 — client re-sort, matching tiebreak
const entries = (data ?? [])
  .sort((a, b) => b.eloRating - a.eloRating || (a.userId < b.userId ? -1 : 1))
  .slice(0, 50);
// ...

getRegionalLeaderboard's backing query (not cited by the audit's #8, but sharing the identical shape) gets the same .order('id', ...) addition for consistency — grounded by the same reasoning, not a new finding.

3.5 R9 + R3 — Wireframe D + region-tab gate (records-screen.tsx, leaderboard-region-tab.tsx)

tsx
// records-screen.tsx — new 3rd ternary arm, between the 2 friends branches
// and the existing 4-card else (§2.5)
) : scopedMatches.length === 0 ? (
  <EmptyState
    icon={Trophy} // already imported (footer LinkCard) — reused, no new icon
    title={t().rankingScreen.noRecentMatches} // reused key, §2.5
    subtitle={t().recordsScreen.zeroMatchSubtitle} // new key
    variant="full"
    actionLabel={t().home.findPickup} // reused key, '번개 찾기'
    onAction={() => appRouter.push(routes.discoverPickups as never)}
  />
) : (
  <>
    {/* existing 4-card block, unchanged */}
  </>
)

New i18n key: recordsScreen.zeroMatchSubtitle: '첫 경기를 시작하고 나만의 기록을 만들어보세요' (ko/ranking.ts, sibling to heatmapSummary).

ts
// leaderboard-region-tab.tsx:168 — delete the extra gate
{top3.length >= 1 && ( // was: >= 2
  <PodiumRow top3={top3} currentUserId={currentUserId} onTierPress={onTierPress} />
)}

Solo-region encouragement copy (design-specs' suggestion, optional polish, same slice): a conditional Text role="cardMeta" under the podium when top3.length === 1, new key leaderboardScreen.soloRegionEncouragement: '첫 번째로 랭크에 등록됐어요'.

3.6 R7 — podium tap-to-profile

ts
// leaderboard-podium.tsx
export interface PodiumEntry {
  rank: number;
  userId: string; // NEW
  name: string;
  elo: number;
  isCurrentUser: boolean;
}
tsx
// PodiumCard — wrap the identity block, tier-badge Pressable stays nested
<Pressable
  variant="row"
  onPress={() => appRouter.push(routes.profileDetail(entry.userId))}
  accessibilityLabel={entry.name}
>
  <AvatarBubble name={entry.name} size={52} tone="primary" />
</Pressable>;
{
  /* medal Text, podium block unchanged; tier-badge Pressable stays as-is */
}

3 mapper call sites add userId: e.userId to the top3/entries builders (leaderboard-region-tab.tsx:109-114, leaderboard-club-tab.tsx:139 via entries.slice(0,3)entries already carries userId, leaderboard-global-tab.tsx:90 same shape) — RegionEntry/ClubEntry/GlobalEntry already have userId (§2.6), so this is additive-only.

Extend the existing getRegionalRanking COUNT-based idiom (profile.supabase.ts:430-471) to club and global scope — same 2-query shape (total count + lte('elo_rating', userElo) count), no migration:

ts
// profile.supabase.ts — mirrors getRegionalRanking exactly, scoped differently
async getClubRanking(userId: string, clubId: string): Promise<{ totalPlayers: number; rankPosition: number } | null> { /* WHERE club membership instead of region */ }
async getGlobalRanking(userId: string): Promise<{ totalPlayers: number; rankPosition: number } | null> { /* same WHERE as findTopByRating's base filter (deleted_at null, has played) */ }
ts
// useRegionalRanking (+ new useClubRanking/useGlobalRanking, same shape) —
// expose `rank`, not just `percentile` — one-line addition to the existing
// useMemo (use-regional-ranking.ts:35-50):
const rank = raw.totalPlayers - raw.rankPosition + 1;
return { percentile, rank, totalPlayers, region: regionStr };
tsx
// each tab's FeedList `footer` prop (new — none of the 3 tabs currently
// pass one), rendered only when rank > 50:
{
  myRank != null && myRank.rank > 50 ? (
    <YStack paddingHorizontal="$4" paddingTop="$3">
      <Card tone="flat">
        <XStack alignItems="center" gap="$3">
          <PlayerIdentityChip
            profile={{
              id: currentUserId,
              displayName: t().leaderboardScreen.selfBadge,
              eloRating: myElo,
            }}
            onPress={null}
            showTier={false}
          />
          <Text role="cardTitle">
            {t().leaderboardScreen.myRankLabel(myRank.rank)} {/* "내 순위 · N위" */}
          </Text>
        </XStack>
      </Card>
    </YStack>
  ) : null;
}

New query keys mirroring regionalRankingKeys: clubRankingKeys.byUserClub(userId, clubId), globalRankingKeys.byUser(userId), both staleTime: STALE_TIME.frequent (matching useRegionalRanking's own freshness rationale — ELO shifts after every match).

3.8 R4 — challenge-bridge app-root mount

tsx
// apps/mobile/app/_layout.tsx — alongside ForegroundRefreshGate (§2.7)
function ChallengeBridgeGate({ children }: { children: React.ReactNode }): React.JSX.Element {
  const { userId } = useSession();
  useMonthlyChallengeBridge(userId); // new @twomore/app export, wraps the
  // existing records-screen.tsx:163-181
  // input-wiring + useChallengeAchievementBridge
  return <>{children}</>;
}

New useMonthlyChallengeBridge(userId) hook (packages/app/src/presentation/hooks/) extracts the exact usePlayerStatsDetail + useMatchHistory + getCurrentChallenges + useChallengeAchievementBridge wiring currently inline in records-screen.tsx:163-181 into one reusable hook — records-screen switches to calling this hook instead of duplicating the logic (single source of truth for the computation, mounted from 2 places: app-root unconditionally, records-screen for its existing render-time needs). Per §2.7, this adds zero net queries when records-screen is also opened in the same session (TanStack cache dedup on identical query keys); when it's never opened, this is the ONLY path that fires the check, closing the actual defect. Doc comment on useChallengeAchievementBridge updated to drop the stale "home challenge section, challenges screen" claim (§2.7 — neither exists) and state the real mount points (app-root + records-screen).

3.9 R12 — signal-routes.ts

ts
// signal-routes.ts — split the 'rating' branch out of 'progression'
if (category === 'progression' || type === 'achievement_unlocked') {
  return routes.profile; // achievements gallery — packages/features/profile
}

if (category === 'rating') {
  return routes.records; // elo_changed/tier_promoted/tier_demoted — HeroCard/
  // EloTrendCard/TierInfoSheet all live on 기록 (§2.8)
}

// ... unchanged below:
if (category === 'trust' || category === 'system') {
  return routes.profile; // trust_tier_changed correctly stays — TrustTierBadge
  // only renders in packages/features/profile (§2.8)
}

3.10 R10 — TopPartnersCard avatars

ts
// records-screen.tsx buildTopPartners() — key by partnerId instead of
// partnerName (also fixes a latent same-name-different-person merge bug),
// carry avatarUrl through
const map = new Map<
  string,
  { partnerId: string | null; matches: number; wins: number; avatarUrl?: string | null }
>();
for (const match of matches) {
  if (!match.partnerName || match.format === 'singles') continue;
  const key = match.partnerId ?? match.partnerName; // fallback for legacy rows with no partnerId
  const existing = map.get(key) ?? {
    partnerId: match.partnerId,
    matches: 0,
    wins: 0,
    avatarUrl: match.partnerAvatarUrl,
  };
  // ... unchanged accumulation
}

MatchHistoryEntry gains partnerAvatarUrl: string | null in use-match-history.ts (mirroring MatchHistoryOpponent.avatarUrl's existing resolution against profileMap, use-match-history.ts:323-329's pattern, applied to the partner branch at :341-348).

tsx
// top-partners-card.tsx — AvatarBubble added, medal Badge stays alongside
<XStack gap="$3" alignItems="center" flex={1}>
  <AvatarBubble
    name={partner.partnerName}
    imageUrl={partner.avatarUrl}
    size={AVATAR_SIZE.SM}
    tone="primary"
  />
  <Badge
    label={MEDAL_LABELS[idx] ?? String(idx + 1)}
    variant={MEDAL_COLORS[idx] ?? 'neutral'}
    size="sm"
  />
  <Text role="cardBody" fontWeight="600" numberOfLines={1} flex={1} minWidth={0}>
    {partner.partnerName}
  </Text>
</XStack>

3.11 R11 — dead code removal

Delete tier.config.ts:195-279 (eloToNtrp/eloToUtr/eloToKoreanDiv/ eloToKoreanWomenDiv/eloToKoreanPoints) and tier.config.ts:339-403 (getGenderAdjustedTier/getGenderThreshold, plus their now-orphaned GenderPoolStats/MIN_POOL_SIZE_FOR_ADJUSTMENT support types if those have no other consumer — verify at implementation time), and leaderboard-helpers.ts's MatchFormat/getFormatItems (lines 8, 18-25). Re-grep each export repo-wide immediately before deletion (not just within records/tier.config.ts's own directory) per the audit's own verification method, since tier.config.ts is a shared domain file other features could theoretically import from.

3.12 R5 — localeCompareplainStringCompare

ts
// records-history-screen.tsx:84,97 and record-detail-screen.tsx:179,192
import { plainStringCompare } from '@/presentation/utils/plain-string-compare';
// replaces: a.matchId.localeCompare(b.matchId)
return plainStringCompare(a.matchId, b.matchId);

If PS9's slice 12 has not yet shipped when this slice starts, create the module first (§2.9 — identical path/signature either doc would produce).


4. Slices

#SliceFindingTypeBlast radiusVerificationDepends on
1TierInfoSheet Tamagui portR1client OTAtier-info-sheet.tsx onlyyarn check; on-device: open every tier-badge entry point (HeroCard, a RankRow, a PodiumCard) in both light/dark theme — active-tier highlight + medal-dot colors render identically to before, no raw View/style={{}} remains (grep -c "style={{" tier-info-sheet.tsx → 0)none
2Sparkline direction + ELO fallbackR2, R13client OTAelo-trend-card.tsx, records-screen.tsx (1-line each)yarn check; seed a scenario with a monotonic 5-match losing-then-winning streak, confirm the sparkline's left-to-right order matches chronological order and the auto-color matches the true recent trend (not the inverted one)none
3Tie-break column (server order + 2 client sorts)R8client OTAprofile.supabase.ts (.order addition, no migration — id already selected), leaderboard-club-tab.tsx, region-leaderboard adapteryarn check; seed 3 profiles with identical elo_rating, refetch the global/club leaderboard 5× — assert identical row order every time (was previously nondeterministic)none
4Wireframe D unified zero-match state + region solo-podium gateR9, R3client OTArecords-screen.tsx (new ternary arm, 1 new i18n key), leaderboard-region-tab.tsx (delete 1 condition, optional encouragement copy)yarn check; on-device: fresh seeded account with 0 matches on club/regional/national scope shows exactly ONE EmptyState (Trophy icon, 번개 찾기 CTA) — not the old 4-fragment stack; seed a region with exactly 1 ranked player — confirm the podium now renders (was blank)none
5Podium tap-to-profileR7client OTAleaderboard-podium.tsx (PodiumEntry.userId + Pressable wrap), 3 tab mapper call sitesyarn check; on-device: tap a top-3 avatar on each of the 3 leaderboard tabs — lands on /profile/[userId]; tap the tier badge on the same card — still opens TierInfoSheet, not profile (2 independent nested targets)none
6"내 순위" footer row (all 3 tabs)R6client OTAprofile.supabase.ts (2 new adapter methods, mirrors getRegionalRanking), use-regional-ranking.ts (+rank field) + 2 new hooks, 3 tab files (footer prop), 1 new i18n key + 2 new query-key modulesyarn check; seed an account ranked >50 in a club/region/globally — confirm the "내 순위 · N위" card renders at the list's bottom with the correct rank; seed an account ranked ≤50 — confirm it does NOT render (already visible in the capped list)none
7Challenge-bridge app-root mountR4client OTAnew useMonthlyChallengeBridge hook (packages/app), apps/mobile/app/_layout.tsx (+1 gate component), records-screen.tsx (switch to the extracted hook), doc-comment fix on useChallengeAchievementBridgeyarn check; seed an account that completes a monthly challenge purely via match-play (no 기록 tab visit this session) — open ANY tab (e.g. 홈) — confirm the achievement toast/confetti fires and markMonthCompleted advances, without ever navigating to 기록none
8elo_changed/tier_promoted/tier_demotedroutes.recordsR12client OTAsignal-routes.ts only (branch split, trust_tier_changed path unchanged)yarn check; unit-test getSignalRoute({category:'rating', type:'elo_changed', ...})routes.records; getSignalRoute({category:'trust', type:'trust_tier_changed', ...})routes.profile (unchanged — regression guard for the audit's over-broad original claim, §2.8)Soft: sequence after/alongside migration 00385 if that lands first (not a hard blocker — this is a pure client route-map change, testable independent of whether the signal type is live yet, per the audit's own "not yet live" framing)
9TopPartnersCard avatarsR10client OTAuse-match-history.ts (partnerAvatarUrl field), records-screen.tsx (buildTopPartners re-keyed by partnerId), top-partners-card.tsxyarn check; on-device: a doubles-heavy account's top-partners card shows real avatars, not initials-only fallback, for partners with an avatarUrl; two different partners who happen to share a display name now aggregate separately (regression check for the partnerId-keying fix)none
10Dead code removalR11client OTAtier.config.ts (~135 lines), leaderboard-helpers.ts (~20 lines)yarn check; grep -rn "eloToNtrp|eloToUtr|eloToKoreanDiv|eloToKoreanWomenDiv|eloToKoreanPoints|getGenderAdjustedTier|getGenderThreshold|getFormatItems|MatchFormat" packages/ (excluding this doc) → 0 hits post-deletionnone
11localeCompareplainStringCompare sweepR5client OTArecords-history-screen.tsx, record-detail-screen.tsx (4 sites), shared module (create-if-absent, §2.9)yarn check (existing sort-order tests, if any, must still pass — byte-order matches chronological order for UUID ties, which have no defined "correct" order anyway); manual: match history + record-detail sort toggles render unchanged for a representative fixture setSoft: converges with PS9 slice 12 on the same module path — whichever ships first creates it, the other imports

Total: 11 slices, all client-OTA — zero migrations, zero edge-fn changes. Every slice in this problem space is a JS-only change (packages/app, packages/features/records, apps/mobile/app/_layout.tsx), consistent with the "cheapest sufficient process" ladder: yarn check + the seeded/on-device probes listed above are sufficient verification for all 11; no hosted Maestro run is warranted (no native surface touched, no release-gate evidence requirement named for this problem space). Slices 1-3, 9-11 are fully independent and can ship in any order/in parallel. Slice 4 and slice 5 both touch leaderboard-podium.tsx's siblings but not the same lines — safe to parallelize. Slice 6 depends conceptually on slice 3 (both touch profile.supabase.ts's ranking queries) — sequence together to avoid a rebase, no functional dependency. Slice 7 is the largest (new hook + _layout.tsx change) — recommend shipping it alone given the app-root blast radius (_layout.tsx is the one file every screen in the app depends on transitively).

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