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
| # | Finding | Severity |
|---|---|---|
| 1 | TierInfoSheet is built entirely with raw RN View + inline style={{}} — a direct Tamagui-only canon violation, opened from every ELO/tier surface in this block | HIGH (canon) |
| 2 | EloTrendCard'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 trend | HIGH |
| 3 | Region leaderboard: with exactly 1 ranked player, the section header renders with zero rows underneath — the sole player (possibly the viewer) vanishes | HIGH (edge case) |
| 4 | Challenge-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/celebrated | MEDIUM-HIGH |
| 5 | localeCompare tiebreak on matchId reintroduces the known ICU-freeze anti-pattern, duplicated in 4 call sites across 2 files | MEDIUM (latent) |
| 6 | All 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 further | MEDIUM |
| 7 | Podium (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 convention | MEDIUM |
| 8 | findTopByRating / club Elo leaderboard ordering has no tie-break column — tied-ELO rank order can flicker across refetches | LOW-MEDIUM |
| 9 | New-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 state | MEDIUM (UX) |
| 10 | TopPartnersCard 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 |
| 11 | Dead 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 consumers | LOW (dead code) |
| 12 | elo_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) |
| 13 | records-screen.tsx hardcodes an ELO fallback of 1200 (profile?.eloRating ?? 1200) that disagrees with the canonical DEFAULT_ELO = 1000 in tier.config.ts | LOW (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
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:395sortsentriesdescending bycompletedAt(newest first) — confirmed the canonical order forMatchHistoryEntry[].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);—changesstays newest-first, never reversed.SparkLine.tsx:22-40plotsdata[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-72auto-colors green whendata[last] > data[0]. Given the un-reversed array,data[0]is the newest match anddata[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.tsxor 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
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/features → one 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);(incompareByRecent, used by every sort order as fallback)records-history-screen.tsx:97: same, insidesortMatches'soldestbranchrecord-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 UUIDs — matchId 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-17 — PodiumEntry interface has rank, name, elo, isCurrentUser — no 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:
.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 contentdocs/specifications/elo-tier-mapping.mddocuments as the rationale for this file), not surfaced anywhere, including not inTierInfoSheet(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/MatchFormattype (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, per00017_pickup_games_guest_invites_dual_elo.sql:105-119'sclub_elo_ratingstable — 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:
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-backeduseMatchHistorysort (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); confirmedPersonalRecordsSection's streak logic correctly assumes descending order. - Diffed client tier bands (
tier.config.tsTIER_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 callingprivate.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.mdmatches 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 (bulkuseClubProfiles/buildNameMapused correctly). Also checkeduseClubEloLeaderboard/useGlobalLeaderboard/regional hooks forstaleTimesanity —STALE_TIME.frequentused consistently (documented@freshness frequentrationale: 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
recordspackage forlocaleCompare,StyleSheet,style={{, and dead-export candidates (getFormatItems, gender-adjusted tier, cross-rating conversions) — cross-checked each dead export against the entire repo (not justrecords) before concluding zero consumers. - Traced
elo_changed/trust_tier_changedsignal types throughgetSignalRouteto 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.tsxviolates outright (raw RNView+ inlinestyle={{}}with variables, no exemption comment). One additional inlinestyle={{ color: medalColor }}inleaderboard-podium.tsx:66is a documented, justified runtime-hex exception (consistent with theno-raw-fontsizeexemption pattern used elsewhere in this same package). - ICU
localeCompareban (project memory:feedback_hermes_localecompare_perf.md) — violated 4x acrossrecords-history-screen.tsxandrecord-detail-screen.tsx. - Graphics-over-text (project memory:
feedback_graphics_over_text.md) — largely honored; the one gap isTopPartnersCard'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 truth —
DEFAULT_ELOduplicated as a literal1200(#13); tier threshold ladder duplicated inline across 2 SQL functions server-side (noted, not filed as top-level).