Session F (night2): Phase 5 — records + dossier

5.1 Archetype defined on-page: one line from the archetype library under
    ARCHETYPE DNA (BOMBER — elite raw power…); expander keeps the long form.
5.2 VYNDR-on-team live: getModelAggregate team scope (migration-020 column)
    + /api/ledger/model?team= + ModelRecord mounted on the Team Hub header.
5.3 WNBA/NBA profile parity: minutes-based usage (+MIN season cell) when
    the feed carries minutes — absent beats invented.
5.4 Settings read meter: real rolling-24h usage from the SAME store the
    limiter enforces (GET /api/user/scan-meter + proxy). Metered tiers see
    'X of N reads today' + bar; unlimited tiers see nothing. Corrects the
    stale '5 scans / month' copy.
5.5 Per-tier calibration on the MODEL tab: A+/A/B/C chips with hit% at
    n>=20 PER TIER, 'building (n/20)' below — the separation between tiers
    is the proof the grades mean something.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 01:05:29 -04:00
parent 5d19660f8e
commit f110bd63f1
13 changed files with 206 additions and 4 deletions
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Scan-meter proxy (Session 60, night2/F) — forwards the caller's auth to
* Express, which reads the live limiter's rolling-24h usage. */
export async function GET(req: NextRequest) {
const auth = req.headers.get('authorization');
if (!auth) return NextResponse.json({ unlimited: false, used: null, limit: null }, { status: 401 });
try {
const upstream = await fetch(`${BACKEND_URL}/api/user/scan-meter`, {
headers: { Accept: 'application/json', Authorization: auth },
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ unlimited: false, used: null, limit: null }, { status: 200 });
}
}
+28
View File
@@ -44,6 +44,7 @@ interface LedgerRow {
revised_from_grade?: string | null;
}
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
interface ModelAggregate {
settled: number;
hits: number;
@@ -55,6 +56,8 @@ interface ModelAggregate {
beat_close_pct: number | null;
pending: number;
min_sample?: number;
// Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier).
by_tier?: Record<string, TierRecord>;
}
const SPORT_COLOR: Record<string, string> = {
@@ -160,6 +163,30 @@ export default function LedgerPage() {
);
}
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
/** Session 60 (5.5) — per-tier calibration: the separation between tiers is
* the proof the grades mean something. A tier under n≥20 shows "building",
* never a small-sample percentage. Self-hides until ANY tier is ready. */
function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const tiers = agg.by_tier || {};
const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null);
if (!anyReady) return null;
return (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
const b = tiers[t];
const ready = b.hit_pct != null;
return (
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
</span>
);
})}
</div>
);
}
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const ready = agg.settled >= minSample && agg.hit_pct != null;
return (
@@ -200,6 +227,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
</p>
</div>
)}
<TierCalibration agg={agg} minSample={minSample} />
</div>
);
}
+10
View File
@@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import ModelRecord from '@/components/vyndr/ModelRecord';
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
import { archetypeInfo } from '@/lib/archetypes';
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
@@ -105,6 +106,15 @@ export default function PlayerProfilePage() {
<div style={{ marginTop: 15 }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
{/* Session 60 (5.1, spec amendment) — define the term on-page:
one line from the archetype library under the dual bar.
The "What does this mean?" expander keeps the long version. */}
{p.archetype.primary && archetypeInfo(p.archetype.primary)?.d && (
<div className="mono" style={{ marginTop: 8, fontSize: 11.5, color: 'var(--text-1)', lineHeight: 1.5 }}>
<span style={{ fontWeight: 800, color: 'var(--text-0)' }}>{String(p.archetype.primary).toUpperCase()}</span>
<span style={{ color: 'var(--text-2)' }}> </span>{archetypeInfo(p.archetype.primary).d}
</div>
)}
</div>
)}
</div>
+34 -1
View File
@@ -68,6 +68,18 @@ export default function SettingsPage() {
const [deleteText, setDeleteText] = useState('');
const [deleting, setDeleting] = useState(false);
const [delError, setDelError] = useState('');
// Session 60 (5.4) — the daily read meter (metered tiers only).
const [meter, setMeter] = useState<{ unlimited: boolean; used: number | null; limit: number | null; remaining?: number | null } | null>(null);
useEffect(() => {
const token = session?.access_token;
if (!token) return;
let active = true;
fetch('/api/user/scan-meter', { headers: { Authorization: `Bearer ${token}` } })
.then((r) => (r.ok ? r.json() : null))
.then((data) => { if (active && data && typeof data.unlimited === 'boolean') setMeter(data); })
.catch(() => { /* meter hides itself */ });
return () => { active = false; };
}, [session]);
// Session 49 — onboarding preferences (load + edit + save).
const [prefSports, setPrefSports] = useState<string[]>([]);
@@ -162,10 +174,31 @@ export default function SettingsPage() {
{/* SUBSCRIPTION */}
<Section label="SUBSCRIPTION">
{/* Session 60 (5.4) — the daily read meter for METERED tiers, from
the live limiter's rolling-24h window (never disagrees with the
gate). Unlimited tiers hide it. */}
{meter && !meter.unlimited && meter.limit != null && (
<>
<Row>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Reads today</div>
<div style={{ marginTop: 8, height: 6, background: 'var(--bg-3)', borderRadius: 3, overflow: 'hidden', maxWidth: 260 }}>
<div style={{ width: `${Math.min(100, Math.round(((meter.used ?? 0) / meter.limit) * 100))}%`, height: '100%', background: (meter.remaining ?? 1) <= 0 ? 'var(--miss)' : 'var(--g-a)', borderRadius: 3 }} />
</div>
</div>
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: (meter.remaining ?? 1) <= 0 ? 'var(--miss)' : 'var(--text-0)' }}>
{meter.used ?? 0} of {meter.limit}
</span>
</Row>
<Divider />
</>
)}
<Row>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>{plan.label === 'FREE' ? '5 scans / month' : 'Active subscription'}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
{meter && !meter.unlimited && meter.limit != null ? `${meter.limit} reads / day` : plan.label === 'FREE' ? '3 reads / day' : 'Active subscription'}
</div>
</div>
<div style={{ display: 'flex', gap: 9 }}>
{plan.label !== 'FREE' && (
+7
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import SportBadge from '@/components/vyndr/SportBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import ModelRecord from '@/components/vyndr/ModelRecord';
import { playerHref } from '@/lib/playerHref';
import { useParlay, legKey } from '@/contexts/ParlayContext';
@@ -112,6 +113,12 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
{data.record && <span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>{data.record.wins}-{data.record.losses}</span>}
</div>
{/* Session 60 (5.2, migration 020) — VYNDR-on-team: the model's settled
record on THIS team's players. Deferred-render until rows exist. */}
<div style={{ margin: '0 0 16px' }}>
<ModelRecord team={data.team.name} sport={data.team.sport} align="left" />
</div>
{data.note && <p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 16 }}>{data.note}</p>}
{/* Controls */}