Skip to content

U5 — 기록/Records: Adversarial UI/UX Audit

Status: Archived Date: 2026-07-11 Scope: packages/features/records/src/** (RecordsScreen dashboard, leaderboards club/region/global, ELO/tier display, RecordDetailScreen, RecordsHistoryScreen, HeadToHeadScreen), plus the underlying hooks/domain in packages/app/src/{presentation/hooks/queries,domain/rules,presentation/components} that feed these screens (use-match-history.ts, tier.config.ts, tier-info-sheet.tsx, signal-routes.ts). Method: direct code read (every file in the records package) + targeted grep sweeps for localeCompare/style={{/dead exports + cross-reference against docs/canon/*, migration 00235_tier_at_match_time.sql (server tier bands), and docs/specifications/elo-tier-mapping.md. No code modified. Note on scope anchor: "TMI" listed in the U5 scope anchor is actually weather/playability-index content reachable from the Home header sheet (playability-info-sheet.tsx, docs/architecture/screen-blueprint.md:1381) — it has no presence in the 기록 tab at all. Not audited further here; belongs to U1.


Executive-severity ranking

#FindingSeverity
1TierInfoSheet is built entirely with raw RN View + inline style={{}} — a direct Tamagui-only canon violation, opened from every ELO/tier surface in this blockHIGH (canon)
2EloTrendCard's sparkline plots per-match ELO deltas in reverse chronological order with no re-reversal, and the auto trend-color heuristic (first vs last) is inverted as a result — the "최근 ELO 추이" chart can visually contradict the player's real trendHIGH
3Region leaderboard: with exactly 1 ranked player, the section header renders with zero rows underneath — the sole player (possibly the viewer) vanishesHIGH (edge case)
4Challenge-based achievement unlock detection (useChallengeAchievementBridge) is wired into only records-screen.tsx, contradicting its own doc comment ("home challenge section, challenges screen") — a user who never opens 기록 never gets challenge achievements unlocked/celebratedMEDIUM-HIGH
5localeCompare tiebreak on matchId reintroduces the known ICU-freeze anti-pattern, duplicated in 4 call sites across 2 filesMEDIUM (latent)
6All three leaderboard tabs (club/region/global) hard-cap at top-50 with no "my rank" indicator when the viewer isn't in the visible slice, and no way to page furtherMEDIUM
7Podium (top-3) rows are not tap-to-profile, while rank rows 4+ are (PodiumEntry has no userId field at all) — inconsistent with the established tap-to-profile conventionMEDIUM
8findTopByRating / club Elo leaderboard ordering has no tie-break column — tied-ELO rank order can flicker across refetchesLOW-MEDIUM
9New-user zero-match state on RecordsScreen renders 4-5 separately-empty cards (heatmap of all-gray cells + 3 "no data yet" cards) instead of one unified onboarding empty stateMEDIUM (UX)
10TopPartnersCard shows partner rows as text-only (no avatar) — the one people-list row in this block that skips the graphics-over-text avatar convention used everywhere else (RankRow, PodiumCard)LOW
11Dead code: getGenderAdjustedTier/getGenderThreshold (~55 lines) and eloToNtrp/eloToUtr/eloToKoreanDiv/eloToKoreanWomenDiv/eloToKoreanPoints (~80 lines) in tier.config.ts, plus getFormatItems/MatchFormat in leaderboard-helpers.ts — all exported, all zero consumersLOW (dead code)
12elo_changed / trust_tier_changed signals (00385) route to generic routes.profile, not to 기록 where the ELO hero/tier-progress/trend actually live (Protocol J says analytics content belongs on 기록)LOW (not yet live — 00385 unpushed)
13records-screen.tsx hardcodes an ELO fallback of 1200 (profile?.eloRating ?? 1200) that disagrees with the canonical DEFAULT_ELO = 1000 in tier.config.tsLOW (cosmetic drift, rarely hit)

What works well (evidence): graphics-over-text is broadly honored — TriRingProgress activity rings + SparkLine + 12-week activity heatmap + ProgressBar tier progress on HeroCard/ActivityHeatmapCard/FormatStatsCard/EloTrendCard (all from packages/ui/src/charts or packages/ui primitives, no ad-hoc SVG). Client tier bands in tier.config.ts (900/1400/2000/2500) match the server private.tier_index_from_elo bands in 00235_tier_at_match_time.sql:30-33 exactly — no client/server tier drift found. All 5 live tier labels (브론즈/실버/골드/마스터/그랜드마스터) are present in ko/ranking.ts:70-76 with legacy platinum/diamond aliasing in tier-label.ts — no raw-enum leakage risk. formatElo() centralizes ko-KR thousands-separator formatting (10+ former call sites consolidated). Leaderboards use bulk useClubProfiles/buildNameMap name resolution (Protocol A honored, no per-row profile fetch/N+1 anywhere in this block). PersonalRecordsSection correctly shows teaser ghost rows below the 5-match threshold instead of hiding outright.


1. HIGH — TierInfoSheet bypasses Tamagui entirely

Evidence: packages/app/src/presentation/components/tier-info-sheet.tsx:11-91

ts
import { View } from 'react-native';
...
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, ...,
  backgroundColor: isActive ? (primarySubtleVal ?? 'transparent') : 'transparent',
  borderLeftWidth: isActive ? 3 : 0,
  borderLeftColor: isActive ? primaryVal : 'transparent' }}>
  <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: tierColor }} />

Every field of every inline style object is a runtime variable (isActive, primaryVal, tierColor). No eslint-disable comment, no justifying note (unlike the one accepted exception in leaderboard-podium.tsx:66, which explains itself inline: // medalColor is a runtime hex — not a Tamagui token). CLAUDE.md's hard rule: "Tamagui-only (no StyleSheet.create, className, NativeWind, or inline style={{}} with variables)".

Impact: TierInfoSheet is the single tier-explainer surface opened from HeroCard (tap tier badge), every RankRow (tap tier badge), and every PodiumCard (tap tier badge) in this entire block — i.e. it's on the direct path of the single most common tier-related interaction. It sits entirely outside the Tamagui theming system (manual dark/light theme.primary?.val reads instead of $primary tokens), making it the one surface in U5 that would silently desync from a future token change.

2. HIGH — EloTrendCard sparkline direction/color is backwards

Evidence:

  • use-match-history.ts:395 sorts entries descending by completedAt (newest first) — confirmed the canonical order for MatchHistoryEntry[].
  • records-screen.tsx:235: trendMatches = scopedMatches.slice(0, 10) — takes the 10 most recent, still newest-first.
  • elo-trend-card.tsx:20-22: const w = recentMatches.slice(0, 10); const c = w.map(m => m.eloChange ?? 0);changes stays newest-first, never reversed.
  • SparkLine.tsx:22-40 plots data[index] left→right by array index (index 0 at x=0). With no reversal, index 0 (the most recent match) plots at the left edge and the oldest-of-10 plots at the right edge — the chart reads backwards in time (right = past, left = present), the opposite of every other left-to-right time convention in the app (heatmap, match history lists).
  • SparkLine.tsx:63-72 auto-colors green when data[last] > data[0]. Given the un-reversed array, data[0] is the newest match and data[last] is the oldest of the window — so the color heuristic effectively asks "was the oldest-of-10 match's delta bigger than the newest match's delta," which has no relationship to "is the player's recent trend improving."
  • No test file exists for elo-trend-card.tsx or this data-shaping path.

Impact: the "최근 ELO 추이" (recent ELO trend) card — one of the four always-visible analytics cards on the primary 기록 screen — can show a green/rising-looking line for a player who just lost ELO over their last 10 matches, or vice versa, and the line's left-to-right direction never matches its own axis convention. This is not a cosmetic nit; it's the chart's core semantic (trend direction/color) being inverted.

3. HIGH — Region leaderboard: single-player region shows nothing

Evidence: leaderboard-region-tab.tsx:109-122,159-173

ts
const top3 = (leaderboard ?? []).slice(0, 3).map(...)
const rest = (leaderboard ?? []).slice(3).map(...)
...
{top3.length >= 2 && (
  <PodiumRow top3={top3} currentUserId={currentUserId} onTierPress={onTierPress} />
)}

When leaderboard.length === 1: top3 = [entry] (length 1, fails the >= 2 gate → no podium), rest = [] (nothing beyond index 3 → empty list body). The SectionHeader for "지역 전체 순위" still renders (gated only on leaderboard.length > 0), so the screen shows a section title with no rows under it at all — the one ranked player (who may well be the viewer, since this is the exact state a first mover in a new region would see) is present in the fetched data but invisible in the UI. leaderboard-club-tab.tsx and leaderboard-global-tab.tsx have no equivalent >= 2 gate — they always call <PodiumRow top3={top3} .../> and a lone entry renders correctly as a single podium block — so this is a region-tab-only regression, not shared logic.

Reproduction: any region with exactly 1 rated player (highly plausible at launch, per the audit brief's "most common state at launch" concern — regional leaderboards will be sparse well before club/global ones are).

4. MEDIUM-HIGH — Challenge achievement unlocks only fire from 기록

Evidence: packages/app/src/presentation/hooks/use-challenge-achievement-bridge.ts:157-164:

"Call this hook wherever challenges are displayed (home challenge section, challenges screen). It is idempotent..."

grep -rln useChallengeAchievementBridge packages/featuresone hit: packages/features/records/src/records-screen.tsx:182. Challenges are actually displayed in packages/features/clubs/src/club-challenges-screen.tsx (confirmed by grep -n "getCurrentChallenges" club-challenges-screen.tsx → 0 matches; challenges UI there is built from its own data path, not the bridge). The bridge's unlock/toast/confetti/streak-tracking side effect (use-challenge-achievement-bridge.ts:186-241) never runs unless the 기록 screen has mounted with the current month's challenge state computed.

Impact: a user who plays regularly and completes monthly challenges but navigates only via 클럽 → 챌린지 (never opening 기록) will never see CHALLENGE_FIRST_CLEAR/sweep/streak achievements unlock, toast, or celebrate — and the monthly streak counter (markMonthCompleted) silently never advances for them either, since that also lives inside this same records-screen-gated effect (use-challenge-achievement-bridge.ts:186-193).

5. MEDIUM (latent) — localeCompare tiebreak reintroduces the ICU-freeze pattern

Evidence:

  • records-history-screen.tsx:84: return a.matchId.localeCompare(b.matchId); (in compareByRecent, used by every sort order as fallback)
  • records-history-screen.tsx:97: same, inside sortMatches's oldest branch
  • record-detail-screen.tsx:179,192: identical duplicated pattern

This is the exact anti-pattern called out by project memory ("Never localeCompare-sort large lists on Hermes — ICU freezes the JS thread for seconds") and already found twice more in the U2 audit (docs/audits/blocks/U2-clubs.md finding #4/#5) and once fixed at the app level (commit 030420b3, "remove ICU localeCompare sort — the real 3-4s directory freeze"). Here it's used as a tiebreak on UUIDsmatchId is a UUID string with no locale-sensitive ordering need; a plain </> comparison is both correct and free. Blast radius is bounded (match history defaults to 50 entries, ranged recap up to 500) vs. the thousands-of-rows case that caused the original 3-4s freeze, but the tie condition (completedAt byte-identical) is plausible whenever multiple matches in one round/session complete via a single batch score-submission with the same server timestamp — exactly the kind of burst the seed-scenario tooling and multi-court sessions produce.

6. MEDIUM — Leaderboards cap at top-50 with no "my rank" fallback

Evidence: leaderboard-club-tab.tsx:54-56 (.slice(0, 50)), leaderboard-global-tab.tsx:76 (.slice(0, 50), source capped at 100 via findTopByRating(100) in use-ranking.ts:33), leaderboard-region-tab.tsx (no explicit cap found in the region query, but same rendering shape). All three build isCurrentUser purely by scanning the rendered (already-capped) slice — leaderboard-club-tab.tsx:62, leaderboard-global-tab.tsx:81, leaderboard-region-tab.tsx:113,121. If the viewer's rank is outside the top-50, isCurrentUser is never true anywhere on screen, and there is no separate "당신의 순위: N위" indicator, no pagination, and no "jump to my rank" affordance anywhere in records-leaderboard-screen.tsx or its three tabs. A viewer below rank 50 gets zero feedback about where they actually stand — this hits the "huge leaderboards" edge case named in the audit brief directly: as the player base grows past 50 in a club/region/nationally, an increasing fraction of users see a leaderboard that structurally excludes them.

7. MEDIUM — Podium (top-3) rows aren't tap-to-profile; rows 4+ are

Evidence: leaderboard-podium.tsx:12-17PodiumEntry interface has rank, name, elo, isCurrentUserno userId field, and PodiumCard's only interactive element is the tier-badge Pressable (leaderboard-podium.tsx:73-79, opens TierInfoSheet, not profile). Compare leaderboard-rank-row.tsx:57-63: RankRow wraps identity in PlayerIdentityChip with onPressIn={... prefetchProfile ...} and tap-to-profile navigation. The three most prominent, highest-status rows on any leaderboard (1st/2nd/3rd place, rendered bigger with medal emoji and avatar) are the ones a curious viewer is most likely to want to tap through to — and they're the only rows in the whole leaderboard screen that can't be.

8. LOW-MEDIUM — No tie-break column on ELO leaderboard ordering

Evidence: profile.supabase.ts:243-250:

ts
.order('elo_rating', { ascending: false })
.limit(limit);

No secondary .order() on a unique column (e.g. id). Postgres does not guarantee stable ordering among rows with equal elo_rating absent a tie-break column, and the client-side re-sorts (leaderboard-club-tab.tsx:55, records-leaderboard-screen.tsx:66) only sort by eloRating too — ties keep whatever order the DB happened to return, which can vary run-to-run/refetch-to-refetch. This is exactly the "tie ELO" edge case named in the audit brief: two same-ELO players can see their relative rank order flicker on pull-to-refresh with no underlying rating change.

9. MEDIUM (UX) — Zero-match new-user state is a wall of empty fragments

Evidence: records-screen.tsx:334-369 — for a brand-new user with 0 matches in the active scope, the branch that's NOT the friends-empty-state renders unconditionally: ActivityHeatmapCard (no zero-guard at all — activity-heatmap-card.tsx always renders the full 12×7 grid, all cells $surfaceSecondary when matches=[], plus a "0일 활동" summary line), FormatStatsCard (own EmptyState variant="compact"), TopPartnersCard (own EmptyState variant="compact"), EloTrendCard (own EmptyState variant="compact"), plus 3 footer LinkCards. That's 1 inert gray grid + 3 separate small "no data yet" empty-state blurbs + 3 nav links, all stacked under a HeroCard showing ELO 1000/Bronze — no single, unified "아직 경기 기록이 없어요, 첫 경기를 시작해보세요" welcome message with one clear CTA (e.g. into 번개 찾기 or session creation). Given the audit brief's explicit callout ("zero matches — the most common state at launch"), this is the literal first-run experience for essentially every early user of the 기록 tab, and it currently reads as 4 quiet non-answers rather than one confident onboarding moment — a Toss-filter violation (one task per screen; this is 4 "no data" mini-tasks).

10. LOW — TopPartnersCard is the one people-list without avatars

Evidence: top-partners-card.tsx:59-67 renders partner.partnerName as plain Text, no AvatarBubble/PlayerIdentityChip. Every other person-representing row in this block uses an avatar: RankRow via PlayerIdentityChip (leaderboard-rank-row.tsx:57), PodiumCard via AvatarBubble (leaderboard-podium.tsx:42). Root cause: MatchHistoryEntry.partnerName is a plain string with no partnerId-keyed avatar URL threaded through buildTopPartners() (records-screen.tsx:95-115) — MatchHistoryOpponent already carries avatarUrl (use-match-history.ts:88) but the equivalent partner-side data isn't captured with an avatar. A medal Badge substitutes for rank instead of a face, which reads as a rank list, not a people list.

11. LOW — Dead code in the tier/leaderboard domain layer

Evidence (zero consumers confirmed via repo-wide grep, excluding the declaring file and domain/index.ts re-export):

  • getGenderAdjustedTier / getGenderThreshold (tier.config.ts:339-392, ~55 lines) — gender-adjusted tier display machinery, fully built, never called by any hook or screen.
  • eloToNtrp / eloToUtr / eloToKoreanDiv / eloToKoreanWomenDiv / eloToKoreanPoints (tier.config.ts:195-279, ~80 lines) — cross-rating-system conversions (the exact kind of content docs/specifications/elo-tier-mapping.md documents as the rationale for this file), not surfaced anywhere, including not in TierInfoSheet (which only shows raw ELO ranges per tier, no NTRP/UTR/Korean-division cross-reference that would help players calibrate against systems they already know).
  • getFormatItems / MatchFormat type (leaderboard-helpers.ts:8,18-25) — a singles/doubles/mixed leaderboard filter scaffold, never imported by any of the 3 leaderboard tabs (confirmed: only "dual ELO" in this codebase means global+per-club, per 00017_pickup_games_guest_invites_dual_elo.sql:105-119's club_elo_ratings table — there is no separate singles/doubles rating to filter by, so this scaffold has no data to back it even if wired up).

12. LOW (not yet live) — elo_changed/trust_tier_changed signals route to 프로필, not 기록

Evidence: signal-routes.ts:43-45:

ts
if (category === 'progression' || category === 'rating' || type === 'achievement_unlocked') {
  return routes.profile;
}

elo_changed and trust_tier_changed are both category: 'rating'/'trust' type signals per signal.entity.ts:118,122,327,330. Both fall through to the generic routes.profile destination. But the actual ELO/tier UI — hero card with tier-progress ProgressBar, EloTrendCard, TierInfoSheet — lives on 기록, not 프로필 (per records-screen.tsx's own header comment: "Protocol J — challenge analytics belongs on 기록, not 프로필"). Real-time in-context tier-up celebration is already handled separately and correctly by useTierUpDetection/tier-promotion-sheet.tsx (fired from match-board-screen.tsx right after a match completes) — this gap is specifically about the async/notification-center path: tapping an "ELO changed" entry in the notification list later sends the user to their identity screen, not to the analytics screen that actually explains the change. Per the audit-plan doc, migration 00385_alert_model_wiring.sql is still blocked on an owner DB push, so this destination mismatch is not yet customer-visible — flagging so it's fixed before/alongside that push lands.

13. LOW — Hardcoded ELO fallback (1200) disagrees with DEFAULT_ELO (1000)

Evidence: records-screen.tsx:151: const elo = profile?.eloRating ?? 1200; vs. tier.config.ts:295: export const DEFAULT_ELO = 1000; (Silver III floor, used as the actual new-profile seed). In practice this fallback is rarely exercised — HeroCard renders inside the QueryBoundary content branch, which only paints after player.queries (including the profile query) settle, so profile should be non-null by the time elo is read. Still, a magic-number duplicate of a named domain constant is a single-source-of-truth violation waiting to bite the next person who edits either value independently.


Adversarial probes run

  • Traced MatchHistoryEntry[] ordering end-to-end from the Supabase-backed useMatchHistory sort (use-match-history.ts:395) through every consumer slice (records-screen.tsx:235, elo-trend-card.tsx:20, personal-records-section.tsx:178-182) to catch order-dependent bugs — found the sparkline reversal bug (#2); confirmed PersonalRecordsSection's streak logic correctly assumes descending order.
  • Diffed client tier bands (tier.config.ts TIER_DEFINITIONS) against server bands in 3 separate SQL functions (00235_tier_at_match_time.sql, 00230_rsvp_tier_eligibility_enforcement.sql) — numerically identical (900/1400/2000/2500), but noted the SQL side duplicates the threshold ladder inline in two places instead of always calling private.tier_index_from_elo (DB-side single-source-of-truth risk, not filed as a top-level finding since no drift currently exists). docs/specifications/elo-tier-mapping.md matches at 47-column parity; not filed as a separate finding.
  • Grepped for useQuery/per-row profile fetches inside every leaderboard row component to rule out an N+1/Protocol A violation — none found (bulk useClubProfiles/buildNameMap used correctly). Also checked useClubEloLeaderboard/useGlobalLeaderboard/regional hooks for staleTime sanity — STALE_TIME.frequent used consistently (documented @freshness frequent rationale: ratings shift after every match). Traced empty-leaderboard / 1-entry / 2-entry / 50+-entry boundary cases through all 3 tabs by hand — found the 1-entry region-tab bug (#3) and the top-50-cap/no-my-rank gap (#6).
  • Grepped the whole records package for localeCompare, StyleSheet, style={{, and dead-export candidates (getFormatItems, gender-adjusted tier, cross-rating conversions) — cross-checked each dead export against the entire repo (not just records) before concluding zero consumers.
  • Traced elo_changed/trust_tier_changed signal types through getSignalRoute to see where a tap on a rating-change notification lands, cross-referenced against where the actual ELO/tier UI lives (기록 vs 프로필) per the codebase's own stated Protocol J.

Canon violations (summary)

  • Tamagui-only styling (AGENTS.md STYLE / CLAUDE.md "Tamagui & styling conventions")tier-info-sheet.tsx violates outright (raw RN View + inline style={{}} with variables, no exemption comment). One additional inline style={{ color: medalColor }} in leaderboard-podium.tsx:66 is a documented, justified runtime-hex exception (consistent with the no-raw-fontsize exemption pattern used elsewhere in this same package).
  • ICU localeCompare ban (project memory: feedback_hermes_localecompare_perf.md) — violated 4x across records-history-screen.tsx and record-detail-screen.tsx.
  • Graphics-over-text (project memory: feedback_graphics_over_text.md) — largely honored; the one gap is TopPartnersCard's avatar-less partner rows (#10).
  • Toss filter — one task per screen — the zero-match new-user state (#9) reads as 4 separate "no data" mini-answers rather than one clear onboarding task.
  • Single source of truthDEFAULT_ELO duplicated as a literal 1200 (#13); tier threshold ladder duplicated inline across 2 SQL functions server-side (noted, not filed as top-level).

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