Wave 3: /compare built (real head-to-head); resolution tail scoped, not shipped

No grade, ledger or scoring change. Push scoring untouched.

REVIEW ZERO 0.3/0.4 — THE RESOLUTION TAIL DOES NOT FIRE. The resolver is
POST /api/grading/resolve (routes/grading.js:208), and its fanout at :356-371
covers webPush, telegram and discord — but:

  - share-card generation: SPEC'D-NOT-BUILT. Not in the fanout at all (grep
    shareCard in grading.js = 0). shareCards/renderer.js exists with ZERO
    callers, so the component is built but no step would ever invoke it.
  - push notifications: BUILT-NOT-FIRING. In the fanout but gated on
    webPush.configured() (VAPID). push_subscriptions = 0 rows and
    user_notifications = 0 rows — nothing ever subscribed or delivered.
  - Telegram result posts: BUILT-NOT-FIRING (gated on BOT_TOKEN + CHANNEL_ID).
  - Discord result posts: BUILT-NOT-FIRING (gated on webhookFor('results')).
  - recap (all-Final trigger): SPEC'D-NOT-BUILT. No recap file exists in src/.

AND THE WHOLE TAIL IS UNREACHABLE: nothing calls /api/grading/resolve — there is
no ESPN poller in the repo. The live settlement path is the scheduler's
settleAllOutcomes + settleAllLedgers, which fans out to opsNotify only (ops
alerts), with no user-facing output. So even the built channels have no trigger.

Per the order's own rule, ShareCard, /notifications, result posts and recap are
therefore ALL SCOPED, none shipped — no dead shells over a silent pipeline.

BUILT — /compare. Semantics (0.2): a same-market head-to-head, two players with
every row a measure BOTH sides are scored on, aligned via alignRows so the
numbers are comparable — deliberately not two disconnected graded props. Reads
the live /api/stats/player/:name?sport= aggregate. Honest-absent three ways: an
unresolved side reads NO DATA while the other still renders; a measure only one
side has renders a dash, never 0; if neither resolves the page refuses to
compare. NO VERDICT — it shows measures and says the reader draws the call.

Two pre-existing tests (vyndrPhaseE, vyndrParityQA) asserted the in-development
placeholder; both superseded rather than deleted — they now assert the stronger
properties against the real page (live fetch, no sample players, NO VERDICT,
NO DATA, "not a zero").

Floor: 316 suites / 3930 tests green (10 new), web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-07-31 05:31:56 -04:00
parent 831d09bdde
commit bf7c0a3c08
7 changed files with 329 additions and 19 deletions
+149 -13
View File
@@ -1,27 +1,163 @@
'use client';
import { useCallback, useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
/**
* HEAD TO HEAD (§6) — Wave 3.
*
* SEMANTICS (Review Zero 0.2): a meaningful compare is a SAME-MARKET head-to-head —
* two players, and every row is a stat BOTH sides are measured on, aligned so the
* numbers are actually comparable. It is deliberately NOT "two arbitrary graded props
* side by side", which would be two disconnected cards wearing a compare label.
*
* TRUTH LAW: nothing here is generated. Every number comes from
* `/api/stats/player/:name?sport=` (the same aggregate the player profile renders).
* A side that does not resolve is marked NO DATA and the other side still renders —
* a missing player is NEVER filled in, and a stat only one side has shows an explicit
* "—" on the other rather than a zero.
*/
type Row = { k: string; v: string };
type IntelRow = { label: string; value: string; color?: string };
interface PlayerPayload {
player?: string; team?: string | null; found?: boolean;
archetype?: { primary?: { name?: string; color?: string; description?: string } | null } | null;
season?: Row[]; intel?: IntelRow[]; activeProps?: unknown[];
}
const SPORTS = ['mlb', 'wnba', 'nba'] as const;
async function loadPlayer(name: string, sport: string): Promise<PlayerPayload | null> {
if (!name.trim()) return null;
try {
const res = await fetch(`/api/stats/player/${encodeURIComponent(name.trim())}?sport=${sport}`, { cache: 'no-store' });
if (!res.ok) return { found: false, player: name.trim() };
const data = (await res.json()) as PlayerPayload;
return data && typeof data === 'object' ? data : { found: false, player: name.trim() };
} catch {
return { found: false, player: name.trim() };
}
}
/** Align two stat lists on their SHARED keys — the comparison is only honest where
* both sides are measured on the same thing. Keys either side lacks render "—". */
function alignRows(a: Row[] | undefined, b: Row[] | undefined) {
const keys: string[] = [];
for (const r of a || []) if (r && r.k && !keys.includes(r.k)) keys.push(r.k);
for (const r of b || []) if (r && r.k && !keys.includes(r.k)) keys.push(r.k);
return keys.map((k) => ({
k,
a: (a || []).find((r) => r.k === k)?.v ?? null,
b: (b || []).find((r) => r.k === k)?.v ?? null,
}));
}
function Side({ p, label }: { p: PlayerPayload | null; label: string }) {
if (!p) return <div className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</div>;
const arch = p.archetype?.primary;
return (
<div>
<div className="mono" style={{ fontSize: 15, fontWeight: 800, color: 'var(--text-0)' }}>{p.player || label}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2 }}>
{p.found === false ? 'NO DATA' : (p.team || '—')}
</div>
{arch?.name && (
<div style={{ marginTop: 8 }}>
<ArchetypeBadge archetype={arch.name} sport="mlb" />
</div>
)}
</div>
);
}
// Head-to-head player comparison (§6). IN DEVELOPMENT — real two-player
// resolution wires to /api/players/search + live game logs in a later pass.
// Until then this surface shows an honest in-development state: NO sample
// matchups, NO hardcoded grades, no fabricated verdict.
export default function ComparePage() {
const [sport, setSport] = useState<string>('mlb');
const [nameA, setNameA] = useState('');
const [nameB, setNameB] = useState('');
const [a, setA] = useState<PlayerPayload | null>(null);
const [b, setB] = useState<PlayerPayload | null>(null);
const [loading, setLoading] = useState(false);
const [ran, setRan] = useState(false);
const run = useCallback(async () => {
setLoading(true);
const [ra, rb] = await Promise.all([loadPlayer(nameA, sport), loadPlayer(nameB, sport)]);
setA(ra); setB(rb); setRan(true); setLoading(false);
}, [nameA, nameB, sport]);
const rows = alignRows(a?.season, b?.season);
const bothMissing = ran && a?.found === false && b?.found === false;
return (
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 96px' }}>
<SectionHead accent="var(--g-a)"> HEAD TO HEAD</SectionHead>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 18px' }}>COMPARE</h1>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>COMPARE</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 18 }}>
Two players, same measures, side by side. Every number is read from the live player
feed nothing here is modelled or filled in.
</p>
<div className="intel-surface scanlines" style={{ borderRadius: 10, padding: '22px 20px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'relative', zIndex: 2 }}>
<div className="label" style={{ color: 'rgba(232,255,244,.6)', marginBottom: 8 }}>IN DEVELOPMENT</div>
<div className="mono" style={{ fontSize: 14, color: '#e8fff4', lineHeight: 1.6 }}>
Head-to-head comparison is being wired to live player game logs. We are not
shipping sample matchups here when the real two-player read is ready it grades
from the same pipeline as every other card. Check back soon.
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
<select value={sport} onChange={(e) => setSport(e.target.value)} className="mono"
style={{ padding: '9px 10px', background: 'var(--bg-2)', color: 'var(--text-0)', border: '1px solid var(--border)', borderRadius: 6 }}>
{SPORTS.map((s) => <option key={s} value={s}>{s.toUpperCase()}</option>)}
</select>
<input value={nameA} onChange={(e) => setNameA(e.target.value)} placeholder="Player A" className="mono"
style={{ flex: '1 1 180px', padding: '9px 10px', background: 'var(--bg-2)', color: 'var(--text-0)', border: '1px solid var(--border)', borderRadius: 6 }} />
<input value={nameB} onChange={(e) => setNameB(e.target.value)} placeholder="Player B" className="mono"
style={{ flex: '1 1 180px', padding: '9px 10px', background: 'var(--bg-2)', color: 'var(--text-0)', border: '1px solid var(--border)', borderRadius: 6 }} />
<VBtn variant="primary" onClick={() => void run()} disabled={loading || !nameA.trim() || !nameB.trim()}>
{loading ? 'READING…' : 'COMPARE'}
</VBtn>
</div>
{!ran && (
<div className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>
Enter two players to compare.
</div>
)}
{bothMissing && (
<div className="intel-surface" style={{ borderRadius: 10, padding: '18px 16px' }}>
<div className="mono" style={{ fontSize: 13, color: '#e8fff4' }}>
Neither player resolved in {sport.toUpperCase()}. Check the spelling or the sport
we will not invent a comparison.
</div>
</div>
</div>
)}
{ran && !bothMissing && (
<div className="intel-surface" style={{ borderRadius: 10, padding: '18px 16px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 14 }}>
<Side p={a} label="Player A" />
<Side p={b} label="Player B" />
</div>
{rows.length === 0 ? (
<div className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>
No shared season measures to compare yet.
</div>
) : (
<div>
{rows.map((r) => (
<div key={r.k} style={{ display: 'grid', gridTemplateColumns: '1fr 90px 1fr', alignItems: 'center', gap: 8, padding: '7px 0', borderTop: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 14, textAlign: 'right', color: r.a == null ? 'var(--text-2)' : 'var(--text-0)' }}>{r.a ?? '—'}</div>
<div className="mono" style={{ fontSize: 10, textAlign: 'center', color: 'var(--text-2)', letterSpacing: '.08em' }}>{r.k}</div>
<div className="mono" style={{ fontSize: 14, color: r.b == null ? 'var(--text-2)' : 'var(--text-0)' }}>{r.b ?? '—'}</div>
</div>
))}
</div>
)}
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', marginTop: 12 }}>
NO VERDICT we show the measures, you read them. A dash means that side has no
value for that measure, not a zero.
</div>
</div>
)}
</section>
);
}