Wave 3: Record by grade tier — shared TierRecord across dashboard, /u, ledger
Addition 2 (non-negotiable): the model's record must show PER GRADE TIER (A+ went X-Y, A X-Y, …) everywhere the record appears. A blended % hides the proof that higher grades win more — the tier calibration IS the credibility. The backend already computed `by_tier` in getModelAggregate; this is display propagation via ONE shared component (the class fix, not five one-offs). - web/src/lib/tierRecord.js — testable CommonJS row-builder. W-L counts ALWAYS (honest at any n); hit-% only when the upstream n≥20 gate passed (hit_pct != null), else "RECORD BUILDING · N settled". Order A+ A B C D F. A-tier is the only edge (green) tier — no glow below A, red reserved for outcomes. - web/src/components/vyndr/TierRecord.tsx — the ONE shared table. Presentational (byTier) for /u + ledger; self-fetch (/api/ledger/model, sport-scoped) for the dashboard. Fully self-hides until a tier has a settled read. - Ledger swaps its inline TierCalibration for the shared component (single source). /u PublicProfile renders it below the blended hero (by_tier added to the aggregate type; it already flows through the route + proxy untouched). Dashboard gains a compact per-tier surface. - Endpoint/proxy audit: profiles.js + ledger.js return the full aggregate (by_tier included); both Next proxies pass the body through — no threading needed. No change to the gate or math in getModelAggregate. Tests: tests/unit/tierRecord.test.js (row logic + tier order + edge contract + source-assert all three surfaces render the shared component). ledgerService test gains an A+-stands-alone bucketing case. Full suite green (243 suites / 2961 tests); web next build exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { buildTierRows } from '@/lib/tierRecord';
|
||||
|
||||
/**
|
||||
* TierRecord (Wave 3, Addition 2) — the ONE shared record-by-grade-tier
|
||||
* table. Rendered identically on the dashboard, the /u public profile, and
|
||||
* the ledger, so the calibration story is told the same way everywhere.
|
||||
*
|
||||
* The tier gradient IS the credibility: A+ hits more than A, A more than B.
|
||||
* Each tier shows its W-L record ALWAYS (honest even at small n); the hit-%
|
||||
* only appears when the server already cleared the n≥20 gate (`hit_pct !=
|
||||
* null`) — below it, "RECORD BUILDING · N settled". The gate lives in
|
||||
* getModelAggregate; this component never re-derives it.
|
||||
*
|
||||
* COLOR CONTRACT: A-tier (A+/A) earns the green edge color; everything below
|
||||
* A stays neutral. Red is reserved for settled-negative outcomes — never a
|
||||
* tier label.
|
||||
*
|
||||
* TWO MODES:
|
||||
* • presentational — pass `byTier` (ledger + /u already hold the aggregate).
|
||||
* • self-fetch — pass `sport?` (dashboard) → reads /api/ledger/model.
|
||||
* All three surfaces render THIS component (the class fix).
|
||||
*/
|
||||
|
||||
interface TierBucket {
|
||||
settled: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
pushes?: number;
|
||||
hit_pct: number | null;
|
||||
}
|
||||
|
||||
export default function TierRecord({
|
||||
byTier,
|
||||
minSample = 20,
|
||||
sport,
|
||||
title = 'RECORD BY GRADE',
|
||||
dense = false,
|
||||
}: {
|
||||
/** Presentational mode: the getModelAggregate().by_tier map. */
|
||||
byTier?: Record<string, TierBucket> | null;
|
||||
minSample?: number;
|
||||
/** Self-fetch mode (no byTier): scope the /api/ledger/model read. */
|
||||
sport?: string;
|
||||
title?: string;
|
||||
/** Compact layout for tighter surfaces (dashboard). */
|
||||
dense?: boolean;
|
||||
}) {
|
||||
const [fetched, setFetched] = useState<{ by_tier?: Record<string, TierBucket>; min_sample?: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (byTier) return; // presentational — nothing to fetch
|
||||
let active = true;
|
||||
const params = new URLSearchParams({ limit: '1' });
|
||||
if (sport) params.set('sport', sport.toLowerCase());
|
||||
fetch(`/api/ledger/model?${params}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!active || !data?.aggregate) return;
|
||||
setFetched({ by_tier: data.aggregate.by_tier, min_sample: data.min_sample });
|
||||
})
|
||||
.catch(() => { /* self-hide */ });
|
||||
return () => { active = false; };
|
||||
}, [byTier, sport]);
|
||||
|
||||
const tiers = byTier || fetched?.by_tier || {};
|
||||
const ms = byTier ? minSample : Number(fetched?.min_sample) > 0 ? Number(fetched?.min_sample) : minSample;
|
||||
const rows = buildTierRows(tiers, ms);
|
||||
if (rows.length === 0) return null; // no settled reads in any tier yet
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
marginTop: dense ? 0 : 14,
|
||||
paddingTop: dense ? 0 : 12,
|
||||
borderTop: dense ? 'none' : '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.14em',
|
||||
color: 'var(--text-tertiary, #707080)',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{title} · CALIBRATION
|
||||
</div>
|
||||
<div
|
||||
role="table"
|
||||
aria-label="Model hit rate by grade tier"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: dense
|
||||
? 'repeat(auto-fit, minmax(120px, 1fr))'
|
||||
: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: dense ? 8 : 10,
|
||||
}}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.tier}
|
||||
role="row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
padding: dense ? '10px 12px' : '12px 14px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${r.edge ? 'var(--g-a, #00D4A0)' : 'var(--border-hi, #2A2A38)'}`,
|
||||
// GLOW RESERVED FOR A-TIER ONLY — a faint green wash on A-tier,
|
||||
// flat surface below. No red on any tier label.
|
||||
background: r.edge
|
||||
? 'color-mix(in srgb, var(--g-a, #00D4A0) 8%, transparent)'
|
||||
: 'var(--bg-surface, #0C0C12)',
|
||||
}}
|
||||
>
|
||||
{/* Tier label — green only for A-tier (the edge). */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: dense ? 13 : 15,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.04em',
|
||||
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{r.tier}-TIER
|
||||
</span>
|
||||
{/* W-L record — ALWAYS shown, honest at any n. */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: dense ? 15 : 17,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.02em',
|
||||
color: 'var(--text-primary, #F0F0F0)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{r.record}
|
||||
</span>
|
||||
{/* Hit-% ONLY past the n≥20 gate; else building copy. */}
|
||||
{r.ready ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: dense ? 11 : 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{r.hitPct}% HIT
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: dense ? 9.5 : 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--amber, #FFB347)',
|
||||
}}
|
||||
>
|
||||
RECORD BUILDING · {r.settled} settled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } f
|
||||
export { default as ClaimMeter } from './ClaimMeter';
|
||||
export { default as AccuracyBadge } from './AccuracyBadge';
|
||||
export { default as ModelRecord } from './ModelRecord';
|
||||
export { default as TierRecord } from './TierRecord';
|
||||
|
||||
/* Player Intelligence (Session 42) */
|
||||
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||
|
||||
Reference in New Issue
Block a user