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:
@@ -0,0 +1,72 @@
|
|||||||
|
# SPEC — WAVE 3: /compare BUILT · resolution tail SCOPED (2026-07-31)
|
||||||
|
No grade, ledger, or scoring change (diff empty across `intelligence/`, `ledgerService`,
|
||||||
|
`outcomeService`). Push scoring untouched.
|
||||||
|
|
||||||
|
## REVIEW ZERO 0.3/0.4 — THE RESOLUTION TAIL: **IT DOES NOT FIRE**
|
||||||
|
|
||||||
|
The resolver IS `POST /api/grading/resolve` (`src/routes/grading.js:208`, documented "called by
|
||||||
|
the ESPN poller at FINAL", internal-key + loopback gated). Its fanout at `:356-371`:
|
||||||
|
|
||||||
|
| tail output | state | evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| **share-card generation** | **SPEC'D-NOT-BUILT** (as a pipeline step) | **`grep shareCard src/routes/grading.js` = 0** — it is not in the fanout at all. `services/shareCards/renderer.js` exists with **zero callers** → the component is BUILT-NOT-FIRING, but no step would ever invoke it. |
|
||||||
|
| **push notifications** | **BUILT-NOT-FIRING** | `webPush.sendPushToSport(...)` is in the fanout but gated on `webPush.configured()` (VAPID keys). **`push_subscriptions` = 0 rows, `user_notifications` = 0 rows** — nothing has ever been subscribed or delivered. |
|
||||||
|
| **Telegram result posts** | **BUILT-NOT-FIRING** | `telegram.postToTelegram(...)` in the fanout, gated on `configured()` = `BOT_TOKEN && CHANNEL_ID`. |
|
||||||
|
| **Discord result posts** | **BUILT-NOT-FIRING** | `discord.postToDiscord('results', ...)`, gated on `webhookFor('results')`. |
|
||||||
|
| **recap (all-Final trigger)** | **SPEC'D-NOT-BUILT** | **No recap file exists anywhere in `src/`**, and `grep recap src/routes/grading.js` = 0. |
|
||||||
|
|
||||||
|
**🔴 AND THE WHOLE TAIL IS UNREACHABLE: nothing calls `/api/grading/resolve`.** There is **no ESPN
|
||||||
|
poller in the repo** (`grep grading/resolve` outside the route itself = 0 hits). The live settlement
|
||||||
|
path is the scheduler's `settleAllOutcomes` + `settleAllLedgers`, which fans out to **`opsNotify`
|
||||||
|
only** (ops alerts to ntfy) — **no user-facing output of any kind**. So even the BUILT-NOT-FIRING
|
||||||
|
channels have no trigger.
|
||||||
|
|
||||||
|
**Therefore ShareCard, /notifications, result posts and recap are ALL scoped, none shipped** — per
|
||||||
|
the order's own rule: nothing ships as UI over a pipeline step that does not fire.
|
||||||
|
|
||||||
|
## PHASE 1 — /compare BUILT
|
||||||
|
|
||||||
|
**0.1** confirmed: the page was the honest in-development state (fabrication retired earlier).
|
||||||
|
**0.2 semantics — a meaningful compare is a SAME-MARKET head-to-head:** two players, every row a
|
||||||
|
measure **both sides are scored on**, aligned so the numbers are actually comparable. Deliberately
|
||||||
|
NOT "two arbitrary graded props side by side", which is two disconnected cards wearing a compare label.
|
||||||
|
|
||||||
|
Built on the live feed `/api/stats/player/:name?sport=` (the same aggregate the player profile
|
||||||
|
renders — real: Aaron Judge → `found:true`, team, BOMBER archetype, season rows `[{k,v}]`).
|
||||||
|
`alignRows` unions both sides' stat keys and renders each side's value or `—`.
|
||||||
|
|
||||||
|
**Honest-absent, three ways:** a side that does not resolve renders **NO DATA** and the other side
|
||||||
|
still shows · a measure only one side has renders **`—`, never 0** · if **neither** resolves the page
|
||||||
|
**refuses to compare** ("we will not invent a comparison"). There is **NO VERDICT** — it shows the
|
||||||
|
measures and explicitly says the reader draws the call.
|
||||||
|
|
||||||
|
## PHASES 2/3 — SCOPED, NOT SHIPPED
|
||||||
|
|
||||||
|
**ShareCard** — needs a generation step inside the resolve fanout (render → store → serve URL), and
|
||||||
|
that fanout needs a trigger. Not shipped: a share card with no generator is a dead shell, and per the
|
||||||
|
order a public marketing artifact must carry Truth-Law-honest numbers, which requires a real resolved
|
||||||
|
result to render from.
|
||||||
|
|
||||||
|
**/notifications** — the store EXISTS (`push_subscriptions`, `user_notifications`, both empty) and
|
||||||
|
`webPush` exists, but **there is no trigger and no consent/subscribe surface**. Shipping a settings
|
||||||
|
screen now would silently drop every notification — explicitly forbidden. Scope: (1) a caller for
|
||||||
|
`/api/grading/resolve` (or move the fanout into the scheduler's settle pass), (2) VAPID config, (3) a
|
||||||
|
consented subscribe UI, (4) then the settings screen. Types stay **result/status-driven**
|
||||||
|
("your graded pick resolved"), never promotional — the anti-capper line holds.
|
||||||
|
|
||||||
|
**Result posts / recap** — same gate. Recap has no code at all; it is a user-facing + SEO artifact, so
|
||||||
|
it ships only when it can render real slate results with honest-absent thin data.
|
||||||
|
|
||||||
|
## VERIFY
|
||||||
|
**316 suites / 3,930 tests green** (10 new), **web build exit 0**. Two pre-existing tests
|
||||||
|
(`vyndrPhaseE`, `vyndrParityQA`) asserted the in-dev placeholder; both **superseded, not deleted** —
|
||||||
|
they now assert the stronger properties against the real page (live fetch, no sample players, NO
|
||||||
|
VERDICT, NO DATA, "not a zero").
|
||||||
|
|
||||||
|
**Chrome manifest additions:** `/compare` logged-out — enter two real players, confirm aligned rows
|
||||||
|
and that a misspelled name reads NO DATA rather than a fabricated card.
|
||||||
|
|
||||||
|
## TAGS
|
||||||
|
VERIFIED: all five tail states; /compare built on the live feed. CANNOT DETERMINE: whether VAPID /
|
||||||
|
Telegram / Discord env is configured in prod (not visible externally) — moot while no trigger exists.
|
||||||
|
**BLOCKED: ShareCard, /notifications, result posts, recap — all on a resolution tail that never fires.**
|
||||||
@@ -284,11 +284,17 @@ describe('P1-7 — edge board guards the broken edge placeholder', () => {
|
|||||||
// ── P1-8 lock — Compare ships NO fabrication (honest in-development state) ────
|
// ── P1-8 lock — Compare ships NO fabrication (honest in-development state) ────
|
||||||
describe('P1-8 — Compare carries no hardcoded grades or phantom verdict', () => {
|
describe('P1-8 — Compare carries no hardcoded grades or phantom verdict', () => {
|
||||||
const compare = read('app/compare/page.tsx');
|
const compare = read('app/compare/page.tsx');
|
||||||
it('no sample players, no fake verdict, no "tonight" claim — honest in-dev', () => {
|
// SUPERSEDED 2026-07-31 by WAVE 3 — the in-dev placeholder became the real
|
||||||
|
// head-to-head. Every honesty invariant is kept and tightened: no sample players,
|
||||||
|
// no verdict, no "tonight" claim, AND a missing side must read as absent, never 0.
|
||||||
|
it('no sample players, no fake verdict, no "tonight" claim — now on the REAL page', () => {
|
||||||
expect(compare).not.toMatch(/edge tonight/i);
|
expect(compare).not.toMatch(/edge tonight/i);
|
||||||
expect(compare).not.toMatch(/Joki|Wembanyama/);
|
expect(compare).not.toMatch(/Joki|Wembanyama/);
|
||||||
expect(compare).not.toContain('VYNDR VERDICT');
|
expect(compare).not.toContain('VYNDR VERDICT');
|
||||||
expect(compare).toMatch(/IN DEVELOPMENT/i);
|
expect(compare).not.toMatch(/IN DEVELOPMENT/i);
|
||||||
|
expect(compare).toMatch(/NO VERDICT/);
|
||||||
|
expect(compare).toMatch(/NO DATA/); // honest-absent side
|
||||||
|
expect(compare).toMatch(/not a zero/); // dash is absence, not 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,17 @@ describe('Phase E.3 — stubs are now real pages', () => {
|
|||||||
it.each(pages)('/%s is no longer a RouteStub', (p) => {
|
it.each(pages)('/%s is no longer a RouteStub', (p) => {
|
||||||
expect(read(`app/${p}/page.tsx`)).not.toContain('RouteStub');
|
expect(read(`app/${p}/page.tsx`)).not.toContain('RouteStub');
|
||||||
});
|
});
|
||||||
it('compare shows an honest in-development state — no sample grades, no fake verdict', () => {
|
// SUPERSEDED 2026-07-31 by WAVE 3: /compare is now the REAL head-to-head, so the
|
||||||
|
// in-development placeholder is correctly gone. The honesty properties it guarded
|
||||||
|
// (no sample players, no fake verdict) are asserted here in their stronger form —
|
||||||
|
// against a page that fetches live data rather than one that renders nothing.
|
||||||
|
it('compare is a real head-to-head with no sample grades and no fake verdict', () => {
|
||||||
const src = read('app/compare/page.tsx');
|
const src = read('app/compare/page.tsx');
|
||||||
expect(src).toMatch(/IN DEVELOPMENT/i);
|
expect(src).not.toMatch(/IN DEVELOPMENT/i);
|
||||||
expect(src).not.toContain('VYNDR VERDICT');
|
expect(src).not.toContain('VYNDR VERDICT');
|
||||||
expect(src).not.toMatch(/Joki|Wembanyama/); // no hardcoded sample players
|
expect(src).not.toMatch(/Joki|Wembanyama/); // no hardcoded sample players
|
||||||
|
expect(src).toMatch(/\/api\/stats\/player\//); // reads the live feed
|
||||||
|
expect(src).toMatch(/NO VERDICT/); // shows measures, renders no call
|
||||||
});
|
});
|
||||||
it('invite renders the 3-friends referral messaging', () => {
|
it('invite renders the 3-friends referral messaging', () => {
|
||||||
expect(read('app/invite/page.tsx')).toContain('3 friends');
|
expect(read('app/invite/page.tsx')).toContain('3 friends');
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* WAVE 3 — /compare head-to-head + the resolution-tail state
|
||||||
|
* (specs/wave3-compare-and-resolution-tail.md).
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const read = (p) => fs.readFileSync(path.join(__dirname, '../../', p), 'utf8');
|
||||||
|
|
||||||
|
describe('/compare is a real same-market head-to-head, not two cards', () => {
|
||||||
|
const src = read('web/src/app/compare/page.tsx');
|
||||||
|
|
||||||
|
test('the in-development placeholder is GONE', () => {
|
||||||
|
expect(src).not.toMatch(/IN DEVELOPMENT/);
|
||||||
|
expect(src).not.toMatch(/Check back soon/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it fetches BOTH sides from the live player feed', () => {
|
||||||
|
expect(src).toMatch(/\/api\/stats\/player\//);
|
||||||
|
expect(src).toMatch(/Promise\.all\(\[loadPlayer\(nameA/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows are ALIGNED on shared measures (what makes it a comparison)', () => {
|
||||||
|
expect(src).toMatch(/function alignRows/);
|
||||||
|
expect(src).toMatch(/shared keys/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carries NO fabricated player and NO verdict', () => {
|
||||||
|
expect(src).not.toMatch(/Jokic|Jokić|Wembanyama/);
|
||||||
|
expect(src).toMatch(/NO VERDICT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing side is honest-absent — dash, never zero, never invented', () => {
|
||||||
|
expect(src).toMatch(/NO DATA/);
|
||||||
|
expect(src).toMatch(/r\.a \?\? '—'/);
|
||||||
|
expect(src).toMatch(/not a zero/);
|
||||||
|
// and a totally unresolved pair refuses to compare
|
||||||
|
expect(src).toMatch(/we will not invent a comparison/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('alignRows — the comparison contract', () => {
|
||||||
|
// mirrors the page's pure helper
|
||||||
|
const alignRows = (a, b) => {
|
||||||
|
const keys = [];
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
test('keys present on only one side yield null on the other, not 0', () => {
|
||||||
|
const out = alignRows([{ k: 'HR', v: '17' }], [{ k: 'AVG', v: '.301' }]);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ k: 'HR', a: '17', b: null },
|
||||||
|
{ k: 'AVG', a: null, b: '.301' },
|
||||||
|
]);
|
||||||
|
for (const r of out) {
|
||||||
|
expect(r.a === 0 || r.b === 0).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared keys line up on one row', () => {
|
||||||
|
const out = alignRows([{ k: 'HR', v: '17' }], [{ k: 'HR', v: '9' }]);
|
||||||
|
expect(out).toEqual([{ k: 'HR', a: '17', b: '9' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input compares nothing rather than inventing rows', () => {
|
||||||
|
expect(alignRows(undefined, undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolution tail — nothing shipped as a dead shell', () => {
|
||||||
|
test('ShareCard is still NOT wired to any generator (its pipeline step does not exist)', () => {
|
||||||
|
const grading = read('src/routes/grading.js');
|
||||||
|
expect(grading).not.toMatch(/shareCard/i); // no generation step in the resolve fanout
|
||||||
|
const importers = fs.readdirSync(path.join(__dirname, '../../web/src/components'))
|
||||||
|
.filter((f) => f !== 'ShareCard.tsx')
|
||||||
|
.filter((f) => f.endsWith('.tsx'))
|
||||||
|
.filter((f) => read(`web/src/components/${f}`).includes('ShareCard'));
|
||||||
|
expect(importers).toEqual([]); // still unrouted, deliberately
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/notifications remains a stub — no settings screen over a silent pipeline', () => {
|
||||||
|
const src = read('web/src/app/notifications/page.tsx');
|
||||||
|
expect(src).toMatch(/RouteStub/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+149
-13
@@ -1,27 +1,163 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import SectionHead from '@/components/vyndr/SectionHead';
|
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() {
|
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 (
|
return (
|
||||||
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 96px' }}>
|
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 96px' }}>
|
||||||
<SectionHead accent="var(--g-a)">▚ HEAD TO HEAD</SectionHead>
|
<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={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
<select value={sport} onChange={(e) => setSport(e.target.value)} className="mono"
|
||||||
<div className="label" style={{ color: 'rgba(232,255,244,.6)', marginBottom: 8 }}>IN DEVELOPMENT</div>
|
style={{ padding: '9px 10px', background: 'var(--bg-2)', color: 'var(--text-0)', border: '1px solid var(--border)', borderRadius: 6 }}>
|
||||||
<div className="mono" style={{ fontSize: 14, color: '#e8fff4', lineHeight: 1.6 }}>
|
{SPORTS.map((s) => <option key={s} value={s}>{s.toUpperCase()}</option>)}
|
||||||
Head-to-head comparison is being wired to live player game logs. We are not
|
</select>
|
||||||
shipping sample matchups here — when the real two-player read is ready it grades
|
<input value={nameA} onChange={(e) => setNameA(e.target.value)} placeholder="Player A" className="mono"
|
||||||
from the same pipeline as every other card. Check back soon.
|
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>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user