DS1 (design): speed + trust bugs
Fixes the three DESIGN-SPEC Part 4 + #17 audit findings. 1. React #418 hydration mismatch (landing → dashboard entry). The `maybeSignedIn` value was computed in a useState INITIALIZER that reads localStorage during render: server (no window) → false → emits the marketing tree; a signed-in visitor's first CLIENT render → true → emits the loading placeholder. Whole-subtree server/client mismatch → React discarded and re-rendered the page. Deferred behind a mounted flag so the first client render matches the server; the stored-session check flips post-mount. SSR HTML is no longer discarded. 2. Loading walls → skeletons. New tokenized Skeleton primitive (.vyndr-skeleton, reduced-motion-safe via the global rule). Swapped into every text-wall loader: dashboard slate load ("Loading the slate…"), /desk ("Assembling the pack…"), /ledger ("Loading…"), scan ("Loading the model…"), and the landing redirect placeholder. No bare text loader remains. 3. scan→ledger persistence. Root cause: the scan page read its bearer token from localStorage['sb-token'] — a key written ONLY by the OAuth callback — so email/password users posted /api/scan anonymously and the ledger write (gated on an authed user) was silently skipped. Now uses the authoritative session.access_token (matching the ledger read path). Extracted the row builder to web/src/lib/ledgerRow.js (shared, testable). Tests: +17 (scanLedgerPersistence write→mine round-trip + scope + idempotency; ds1SpeedTrust hydration/skeleton/persistence source invariants). Full suite 233 suites / 2793 green; web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* DS1 (DESIGN-SPEC §4 + §17) — source invariants for the Speed + Trust bugs.
|
||||||
|
* - React #418 hydration: no client-only value rendered in the SSR path
|
||||||
|
* without a mounted guard.
|
||||||
|
* - Loading walls: every named loader is a skeleton, not a text wall.
|
||||||
|
* - Persistence: the scan write uses the authoritative session token.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||||
|
|
||||||
|
describe('DS1 — React #418 hydration safety (§4, audit #5)', () => {
|
||||||
|
const landing = read('app/page.tsx');
|
||||||
|
|
||||||
|
test('landing no longer reads localStorage inside a useState initializer', () => {
|
||||||
|
// The exact antipattern that caused #418: the client-only stored-session
|
||||||
|
// check ran during render, so server (false) and client (true) diverged.
|
||||||
|
expect(landing).not.toMatch(/useState<boolean>\(\s*\(\)\s*=>\s*hasStoredSession\(\)\s*\)/);
|
||||||
|
expect(landing).not.toMatch(/useState\(\s*\(\)\s*=>\s*hasStoredSession/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the client-only value is deferred behind a mounted flag', () => {
|
||||||
|
// Server + first client render both compute maybeSignedIn from mounted=false.
|
||||||
|
expect(landing).toMatch(/const\s+\[mounted,\s*setMounted\]\s*=\s*useState\(false\)/);
|
||||||
|
expect(landing).toMatch(/setMounted\(true\)/);
|
||||||
|
expect(landing).toMatch(/maybeSignedIn\s*=\s*mounted\s*&&\s*hasStoredSession\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasStoredSession still guards against server-side window access', () => {
|
||||||
|
expect(landing).toMatch(/typeof window === 'undefined'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DS1 — loading walls are skeletons, never text (§4, audit #5)', () => {
|
||||||
|
const files = {
|
||||||
|
'dashboard slate load': 'app/dashboard/page.tsx',
|
||||||
|
'/desk assembling': 'app/desk/page.tsx',
|
||||||
|
'/ledger loading': 'app/ledger/page.tsx',
|
||||||
|
'scan loading': 'app/scan/page.tsx',
|
||||||
|
'landing redirect': 'app/page.tsx',
|
||||||
|
};
|
||||||
|
const BANNED = [
|
||||||
|
'Loading the slate',
|
||||||
|
'Assembling the pack',
|
||||||
|
'Loading the model',
|
||||||
|
/<p[^>]*>\s*Loading…\s*<\/p>/, // the bare ledger text loader
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, rel] of Object.entries(files)) {
|
||||||
|
test(`${name} contains no text-wall loader`, () => {
|
||||||
|
const src = read(rel);
|
||||||
|
for (const b of BANNED) {
|
||||||
|
if (typeof b === 'string') expect(src).not.toContain(b);
|
||||||
|
else expect(src).not.toMatch(b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('each loader path renders the shared Skeleton', () => {
|
||||||
|
for (const rel of ['app/dashboard/page.tsx', 'app/desk/page.tsx', 'app/ledger/page.tsx', 'app/scan/page.tsx', 'app/page.tsx']) {
|
||||||
|
const src = read(rel);
|
||||||
|
expect(src).toMatch(/Skeleton/);
|
||||||
|
expect(src).toMatch(/from '@\/components\/vyndr'/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Skeleton primitive is tokenized (no raw hex) and reduced-motion-safe', () => {
|
||||||
|
const skel = read('components/vyndr/Skeleton.tsx');
|
||||||
|
// Uses the CSS class, no inline colors.
|
||||||
|
expect(skel).toMatch(/vyndr-skeleton/);
|
||||||
|
expect(skel).not.toMatch(/#[0-9a-fA-F]{3,8}/);
|
||||||
|
const css = fs.readFileSync(path.join(WEB, 'app', 'globals.css'), 'utf8');
|
||||||
|
expect(css).toMatch(/\.vyndr-skeleton\s*\{/);
|
||||||
|
expect(css).toMatch(/var\(--bg-surface\)/);
|
||||||
|
// The global prefers-reduced-motion rule freezes every animation.
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DS1 — scan→ledger persistence uses the authoritative token (§17)', () => {
|
||||||
|
const scan = read('app/scan/page.tsx');
|
||||||
|
|
||||||
|
test('scan pulls the bearer token from the live Supabase session', () => {
|
||||||
|
expect(scan).toMatch(/session,/); // destructured from useAuth
|
||||||
|
expect(scan).toMatch(/session\?\.access_token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan no longer relies solely on the OAuth-only legacy localStorage key', () => {
|
||||||
|
// The stale key may remain only as a FALLBACK after the session token.
|
||||||
|
const m = scan.match(/session\?\.access_token\s*\|\|[\s\S]{0,120}sb-token/);
|
||||||
|
expect(m).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* DS1 (DESIGN-SPEC §17) — scan→ledger persistence.
|
||||||
|
*
|
||||||
|
* Proves a completed scan's ledger row (built by the SAME builder the
|
||||||
|
* /api/scan route uses) is readable by the /api/ledger/mine query: scoped by
|
||||||
|
* user_id, keyed by player_key = nameKey, deduped idempotently. This is the
|
||||||
|
* "every grade, no hiding" guarantee the audit found broken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { buildManualLedgerRow, LEDGER_CONFLICT_COLS, gameDateET } = require('../../web/src/lib/ledgerRow');
|
||||||
|
const { nameKey, normalizeName } = require('../../web/src/lib/playerName');
|
||||||
|
|
||||||
|
// A minimal in-memory stand-in for the service-role Supabase client, modelling
|
||||||
|
// exactly the two operations that matter: the route's upsert(row, {onConflict,
|
||||||
|
// ignoreDuplicates}) and the /api/ledger/mine read
|
||||||
|
// (.select().eq('user_id',id).order('graded_at',desc).limit(n)).
|
||||||
|
function makeFakeSupabase() {
|
||||||
|
const rows = [];
|
||||||
|
const conflictCols = LEDGER_CONFLICT_COLS.split(',');
|
||||||
|
const keyOf = (r) => conflictCols.map((c) => String(r[c])).join('|');
|
||||||
|
|
||||||
|
return {
|
||||||
|
_rows: rows,
|
||||||
|
from() {
|
||||||
|
return {
|
||||||
|
async upsert(row, opts = {}) {
|
||||||
|
const k = keyOf(row);
|
||||||
|
const existing = rows.find((r) => keyOf(r) === k);
|
||||||
|
if (existing) {
|
||||||
|
if (!opts.ignoreDuplicates) Object.assign(existing, row);
|
||||||
|
return { data: null, error: null };
|
||||||
|
}
|
||||||
|
rows.push({ ...row, id: `row-${rows.length + 1}` });
|
||||||
|
return { data: null, error: null };
|
||||||
|
},
|
||||||
|
// Chainable read that mirrors the mine route.
|
||||||
|
select() {
|
||||||
|
const filters = {};
|
||||||
|
let orderCol = null; let asc = true; let lim = Infinity;
|
||||||
|
const q = {
|
||||||
|
eq(col, val) { filters[col] = val; return q; },
|
||||||
|
order(col, o = {}) { orderCol = col; asc = o.ascending !== false; return q; },
|
||||||
|
limit(n) { lim = n; return q; },
|
||||||
|
then(resolve) {
|
||||||
|
let out = rows.filter((r) => Object.entries(filters).every(([c, v]) => r[c] === v));
|
||||||
|
if (orderCol) {
|
||||||
|
out = [...out].sort((a, b) => (a[orderCol] < b[orderCol] ? -1 : a[orderCol] > b[orderCol] ? 1 : 0));
|
||||||
|
if (!asc) out.reverse();
|
||||||
|
}
|
||||||
|
out = out.slice(0, lim);
|
||||||
|
resolve({ data: out, error: null });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return q;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mine query, byte-for-byte in spirit with routes/ledger.js GET /mine.
|
||||||
|
async function mineQuery(sb, userId, limit = 100) {
|
||||||
|
const { data } = await sb.from('ledger_entries')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('graded_at', { ascending: false })
|
||||||
|
.limit(limit);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scan→ledger persistence (DS1 §17)', () => {
|
||||||
|
const USER_A = 'user-aaaa';
|
||||||
|
const USER_B = 'user-bbbb';
|
||||||
|
|
||||||
|
test('a completed scan write is immediately readable by the mine query', async () => {
|
||||||
|
const sb = makeFakeSupabase();
|
||||||
|
const row = buildManualLedgerRow({
|
||||||
|
userId: USER_A,
|
||||||
|
sport: 'MLB',
|
||||||
|
player: 'Aaron Judge',
|
||||||
|
stat: 'home_runs',
|
||||||
|
line: 1.5,
|
||||||
|
side: 'over',
|
||||||
|
book: 'draftkings',
|
||||||
|
grade: 'A',
|
||||||
|
confidence: 71,
|
||||||
|
projection: 1.9,
|
||||||
|
edge: 12.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true });
|
||||||
|
|
||||||
|
const mine = await mineQuery(sb, USER_A);
|
||||||
|
expect(mine).toHaveLength(1);
|
||||||
|
const got = mine[0];
|
||||||
|
expect(got.user_id).toBe(USER_A);
|
||||||
|
// The join key the ledger/model reads on is player_key = nameKey.
|
||||||
|
expect(got.player_key).toBe(nameKey('Aaron Judge'));
|
||||||
|
expect(got.player_name).toBe(normalizeName('Aaron Judge').display);
|
||||||
|
expect(got.sport).toBe('mlb');
|
||||||
|
expect(got.stat).toBe('home_runs');
|
||||||
|
expect(got.side).toBe('over');
|
||||||
|
expect(got.line).toBe(1.5);
|
||||||
|
expect(got.grade).toBe('A');
|
||||||
|
// game_id embeds the ET game_date so the settle pass can find it.
|
||||||
|
expect(got.game_id).toBe(`manual:mlb:${gameDateET()}:${nameKey('Aaron Judge')}`);
|
||||||
|
expect(got.game_date).toBe(gameDateET());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the mine query is scoped: another user cannot read the row', async () => {
|
||||||
|
const sb = makeFakeSupabase();
|
||||||
|
const row = buildManualLedgerRow({
|
||||||
|
userId: USER_A, sport: 'MLB', player: 'Shohei Ohtani', stat: 'total_bases',
|
||||||
|
line: 1.5, side: 'over', book: 'fanduel', grade: 'B',
|
||||||
|
});
|
||||||
|
await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true });
|
||||||
|
|
||||||
|
expect(await mineQuery(sb, USER_A)).toHaveLength(1);
|
||||||
|
expect(await mineQuery(sb, USER_B)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-scanning the same prop is idempotent (never duplicates the row)', async () => {
|
||||||
|
const sb = makeFakeSupabase();
|
||||||
|
const mk = () => buildManualLedgerRow({
|
||||||
|
userId: USER_A, sport: 'MLB', player: 'Mookie Betts', stat: 'hits',
|
||||||
|
line: 0.5, side: 'over', book: 'draftkings', grade: 'A',
|
||||||
|
});
|
||||||
|
await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true });
|
||||||
|
await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true });
|
||||||
|
|
||||||
|
expect(await mineQuery(sb, USER_A)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DATA SEMANTICS: absent beats wrong — no snapshot ⇒ locked_odds/team null, model fields carried', () => {
|
||||||
|
const row = buildManualLedgerRow({
|
||||||
|
userId: USER_A, sport: 'WNBA', player: "A'ja Wilson", stat: 'points',
|
||||||
|
line: 22.5, side: 'over', grade: 'B', confidence: 60, projection: 24,
|
||||||
|
// no team / lockedOdds supplied (no snapshot match)
|
||||||
|
});
|
||||||
|
expect(row.locked_odds).toBeNull();
|
||||||
|
expect(row.team).toBeNull();
|
||||||
|
expect(row.opponent).toBeNull();
|
||||||
|
expect(row.model_value).toBe(24);
|
||||||
|
expect(row.confidence).toBe(60);
|
||||||
|
// Non-numeric model fields degrade to null, never 0 (Number(null)===0 bug).
|
||||||
|
expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).confidence).toBeNull();
|
||||||
|
expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).model_value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the dedupe key matches the migration-019 constraint the route upserts on', () => {
|
||||||
|
expect(LEDGER_CONFLICT_COLS).toBe('user_id,player_key,stat,line,side,game_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -193,12 +193,10 @@ async function writeLedgerEntry(
|
|||||||
data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number },
|
data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { nameKey, normalizeName } = await import('@/lib/playerName');
|
const { nameKey } = await import('@/lib/playerName');
|
||||||
|
const { buildManualLedgerRow, LEDGER_CONFLICT_COLS } = await import('@/lib/ledgerRow');
|
||||||
const sport = body.sport.toLowerCase();
|
const sport = body.sport.toLowerCase();
|
||||||
const playerKey = nameKey(body.player);
|
const playerKey = nameKey(body.player);
|
||||||
const gameDate = new Intl.DateTimeFormat('en-CA', {
|
|
||||||
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
||||||
}).format(new Date());
|
|
||||||
|
|
||||||
// Cache-only snapshot read (never triggers an odds fetch → no quota).
|
// Cache-only snapshot read (never triggers an odds fetch → no quota).
|
||||||
let lockedOdds: string | null = null;
|
let lockedOdds: string | null = null;
|
||||||
@@ -220,31 +218,28 @@ async function writeLedgerEntry(
|
|||||||
if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
|
if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
|
||||||
} catch { /* absent beats wrong */ }
|
} catch { /* absent beats wrong */ }
|
||||||
|
|
||||||
await sb.from('ledger_entries').upsert(
|
// Session 59 — team from the snapshot's stats resolve (real feed); opponent
|
||||||
{
|
// stays null on manual scans (no game context — never guessed).
|
||||||
user_id: userId,
|
const row = buildManualLedgerRow({
|
||||||
player_key: playerKey,
|
userId,
|
||||||
player_name: normalizeName(body.player).display || body.player,
|
sport: body.sport,
|
||||||
sport,
|
player: body.player,
|
||||||
stat: body.stat.toLowerCase(),
|
stat: body.stat,
|
||||||
line: body.line,
|
line: body.line,
|
||||||
side: body.direction,
|
side: body.direction,
|
||||||
locked_odds: lockedOdds,
|
book: body.book ?? 'draftkings',
|
||||||
book: body.book ?? 'draftkings',
|
team,
|
||||||
// Session 59 — team from the snapshot's stats resolve (real feed);
|
lockedOdds,
|
||||||
// opponent stays null on manual scans (no game context — never guessed).
|
grade: data.grade,
|
||||||
team,
|
edge: data.edge_pct,
|
||||||
opponent: null,
|
confidence: data.confidence,
|
||||||
grade: data.grade,
|
projection: data.projection,
|
||||||
edge: typeof data.edge_pct === 'number' ? data.edge_pct : null,
|
});
|
||||||
confidence: typeof data.confidence === 'number' ? data.confidence : null,
|
|
||||||
model_value: typeof data.projection === 'number' ? data.projection : null,
|
await sb.from('ledger_entries').upsert(row, {
|
||||||
graded_at: new Date().toISOString(),
|
onConflict: LEDGER_CONFLICT_COLS,
|
||||||
game_id: `manual:${sport}:${gameDate}:${playerKey}`,
|
ignoreDuplicates: true,
|
||||||
game_date: gameDate,
|
});
|
||||||
},
|
|
||||||
{ onConflict: 'user_id,player_key,stat,line,side,game_id', ignoreDuplicates: true },
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[scan] ledger write failed', err);
|
console.warn('[scan] ledger write failed', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { GradePill } from '@/components/GradeCard';
|
|||||||
// below as intelligence layers on top of the raw odds.
|
// below as intelligence layers on top of the raw odds.
|
||||||
import Slate from '@/components/Slate';
|
import Slate from '@/components/Slate';
|
||||||
// Session 55 — the self-learning loop's track record, live in the header.
|
// Session 55 — the self-learning loop's track record, live in the header.
|
||||||
import { AccuracyBadge } from '@/components/vyndr';
|
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
|
||||||
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
||||||
import { emptyStateCopy } from '@/lib/emptyState';
|
import { emptyStateCopy } from '@/lib/emptyState';
|
||||||
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
||||||
@@ -181,9 +181,15 @@ export default function DashboardPage() {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
if (authLoading || !user) {
|
if (authLoading || !user) {
|
||||||
|
// DS1 (§4) — layout-matched skeleton, never a text wall. Mirrors the real
|
||||||
|
// dashboard: header strip + a stack of slate cards.
|
||||||
return (
|
return (
|
||||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px 120px' }} aria-busy="true">
|
||||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the slate…</p>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, gap: 16 }}>
|
||||||
|
<Skeleton height={30} width={220} radius={10} />
|
||||||
|
<Skeleton height={30} width={120} radius={999} />
|
||||||
|
</div>
|
||||||
|
<SkeletonList count={5} height={92} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Skeleton, SkeletonList } from '@/components/vyndr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal.
|
* /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal.
|
||||||
@@ -103,10 +104,21 @@ export default function DeskPage() {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (status === 'loading') {
|
||||||
|
// DS1 (§4) — the pack is assembling; layout-matched skeleton, not a text
|
||||||
|
// wall. Mirrors the header + stack of FormatCards below.
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }} aria-busy="true">
|
||||||
|
<Skeleton height={14} width={90} radius={4} style={{ marginBottom: 12 }} />
|
||||||
|
<Skeleton height={30} width={260} radius={8} style={{ marginBottom: 24 }} />
|
||||||
|
<SkeletonList count={4} height={120} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (status !== 'ready' || !pack) {
|
if (status !== 'ready' || !pack) {
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
|
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
|
||||||
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}</p>
|
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>Pack unavailable. Check back after the first pipeline run.</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,22 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
animation: shimmer 1.5s linear infinite;
|
animation: shimmer 1.5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DS1 (§4 — kill loading walls) — the ONE skeleton primitive. Layout-matched
|
||||||
|
dark shimmer blocks replace every text-wall loader. Tokenized (no one-off
|
||||||
|
CSS); the global prefers-reduced-motion rule freezes the sweep to a static,
|
||||||
|
still-visible surface — arrives-then-rests, honored globally. */
|
||||||
|
.vyndr-skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--bg-surface) 0%,
|
||||||
|
var(--bg-surface-hover) 50%,
|
||||||
|
var(--bg-surface) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s linear infinite;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* S6 (A1 board, LCP) — floored at a VISIBLE state (.6), matching the S33
|
/* S6 (A1 board, LCP) — floored at a VISIBLE state (.6), matching the S33
|
||||||
entrance-keyframe rule (a paused frame is never invisible). Starting at
|
entrance-keyframe rule (a paused frame is never invisible). Starting at
|
||||||
opacity 0 also delayed the hero heading's first contentful paint — an
|
opacity 0 also delayed the hero heading's first contentful paint — an
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { GradePill } from '@/components/GradeCard';
|
import { GradePill } from '@/components/GradeCard';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Skeleton } from '@/components/vyndr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Ledger (Session 58, Phase 1) — the truth surface, now backed by the
|
* The Ledger (Session 58, Phase 1) — the truth surface, now backed by the
|
||||||
@@ -153,7 +154,12 @@ export default function LedgerPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rows === null ? (
|
{rows === null ? (
|
||||||
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading…</p>
|
// DS1 (§4) — grid of card skeletons matching the ledger's real layout.
|
||||||
|
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }} aria-busy="true">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height={132} radius={14} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<EmptyLedger tab={tab} />
|
<EmptyLedger tab={tab} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+18
-6
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import Hero from '@/components/Hero';
|
import Hero from '@/components/Hero';
|
||||||
import { ClaimMeter, ModelRecord } from '@/components/vyndr';
|
import { ClaimMeter, ModelRecord, Skeleton, SkeletonList } from '@/components/vyndr';
|
||||||
// Session 55 — live top A-rated grades pulled from tonight's real snapshot,
|
// Session 55 — live top A-rated grades pulled from tonight's real snapshot,
|
||||||
// with the self-learning loop's accuracy line. The product shown, not described.
|
// with the self-learning loop's accuracy line. The product shown, not described.
|
||||||
import TopSignals from '@/components/TopSignals';
|
import TopSignals from '@/components/TopSignals';
|
||||||
@@ -47,7 +47,18 @@ function hasStoredSession(): boolean {
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [maybeSignedIn] = useState<boolean>(() => hasStoredSession());
|
// DS1 (DESIGN-SPEC §4, audit #5) — React #418 hydration fix. Reading
|
||||||
|
// localStorage inside a useState INITIALIZER runs during render: the server
|
||||||
|
// (no `window`) computes `false` and emits the marketing tree, but the first
|
||||||
|
// CLIENT render of a signed-in visitor reads the stored session, computes
|
||||||
|
// `true`, and emits the "Loading" placeholder instead — a server/client HTML
|
||||||
|
// mismatch that made React discard and re-render the whole page (#418). The
|
||||||
|
// client-only value is now deferred behind a mounted flag: the first client
|
||||||
|
// render matches the server (both treat it as `false`), then the stored-
|
||||||
|
// session check flips it post-mount. SSR HTML is never discarded.
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
const maybeSignedIn = mounted && hasStoredSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) router.replace('/dashboard');
|
if (!loading && user) router.replace('/dashboard');
|
||||||
@@ -56,11 +67,12 @@ export default function Home() {
|
|||||||
// Suppress the marketing render ONLY for visitors with a stored session
|
// Suppress the marketing render ONLY for visitors with a stored session
|
||||||
// (they're about to redirect) — anonymous first paint is instant.
|
// (they're about to redirect) — anonymous first paint is instant.
|
||||||
if (user || (loading && maybeSignedIn)) {
|
if (user || (loading && maybeSignedIn)) {
|
||||||
|
// Signed-in visitor mid-redirect to /dashboard — a layout-matched skeleton,
|
||||||
|
// never a text wall (§4).
|
||||||
return (
|
return (
|
||||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px' }} aria-busy="true">
|
||||||
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
<Skeleton height={40} width={280} radius={10} style={{ marginBottom: 24 }} />
|
||||||
Loading the slate
|
<SkeletonList count={4} height={96} />
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
|
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
|
||||||
import PriorReads from '@/components/vyndr/PriorReads';
|
import PriorReads from '@/components/vyndr/PriorReads';
|
||||||
import { AccuracyBadge } from '@/components/vyndr';
|
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
|
||||||
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
|
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
|
||||||
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
|
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
|
||||||
import { normalizeName, nameKey } from '@/lib/playerName';
|
import { normalizeName, nameKey } from '@/lib/playerName';
|
||||||
@@ -110,7 +110,7 @@ const SPORT_ACCENT: Record<Sport, string> = {
|
|||||||
|
|
||||||
export default function ScanPage() {
|
export default function ScanPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
|
const { user, session, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
|
||||||
const { addLeg, legCount, open } = useParlay();
|
const { addLeg, legCount, open } = useParlay();
|
||||||
|
|
||||||
const [sport, setSport] = useState<Sport>('NBA');
|
const [sport, setSport] = useState<Sport>('NBA');
|
||||||
@@ -241,7 +241,16 @@ export default function ScanPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
// DS1 (§17 — scan→ledger persistence). The authoritative bearer token is
|
||||||
|
// the live Supabase session's access_token (set for EVERY sign-in method).
|
||||||
|
// The legacy `localStorage['sb-token']` key is written ONLY by the OAuth
|
||||||
|
// callback — so email/password users sent NO Authorization header, the
|
||||||
|
// /api/scan route saw an anonymous request, and the completed read was
|
||||||
|
// silently dropped from the ledger (the write is gated on an authed user).
|
||||||
|
// Prefer the session token; keep the legacy key as a fallback.
|
||||||
|
const token =
|
||||||
|
session?.access_token ||
|
||||||
|
(typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null);
|
||||||
const res = await fetch('/api/scan', {
|
const res = await fetch('/api/scan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -301,9 +310,13 @@ export default function ScanPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (authLoading || !user) {
|
if (authLoading || !user) {
|
||||||
|
// DS1 (§4) — layout-matched skeleton of the scan form, not a text wall.
|
||||||
return (
|
return (
|
||||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<section style={{ maxWidth: 720, margin: '0 auto', padding: '32px 16px 96px' }} aria-busy="true">
|
||||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the model…</p>
|
<Skeleton height={30} width={200} radius={8} style={{ marginBottom: 8 }} />
|
||||||
|
<Skeleton height={16} width={320} radius={6} style={{ marginBottom: 28 }} />
|
||||||
|
<SkeletonList count={3} height={64} />
|
||||||
|
<Skeleton height={52} radius={12} style={{ marginTop: 20 }} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DS1 (DESIGN-SPEC §4 — "kill loading walls") — the single skeleton primitive.
|
||||||
|
*
|
||||||
|
* Governing law: premium = SPEED + RESTRAINT. A text-wall loader ("Loading…")
|
||||||
|
* reads cheap; a layout-matched skeleton reads instant. This is a tokenized
|
||||||
|
* dark shimmer block (`.vyndr-skeleton` in globals.css) — the sweep is chrome,
|
||||||
|
* never data, and the global prefers-reduced-motion rule freezes it to a
|
||||||
|
* still, visible surface.
|
||||||
|
*/
|
||||||
|
export function Skeleton({
|
||||||
|
height = 16,
|
||||||
|
width = '100%',
|
||||||
|
radius = 8,
|
||||||
|
style,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
height?: number | string;
|
||||||
|
width?: number | string;
|
||||||
|
radius?: number | string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`vyndr-skeleton ${className}`.trim()}
|
||||||
|
style={{ height, width, borderRadius: radius, ...style }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of card-shaped skeletons — matches horizontal-scroll grade/slate rows.
|
||||||
|
*/
|
||||||
|
export function SkeletonCards({ count = 4, height = 110, minWidth = 200 }: { count?: number; height?: number; minWidth?: number }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 12 }} aria-hidden="true">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height={height} width={minWidth} radius={16} style={{ minWidth, flex: '0 0 auto' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stack of full-width row skeletons — matches list/grid loading states
|
||||||
|
* (the slate, the ledger, the desk pack).
|
||||||
|
*/
|
||||||
|
export function SkeletonList({ count = 4, height = 80, gap = 12 }: { count?: number; height?: number; gap?: number }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap }} aria-hidden="true">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height={height} radius={16} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Skeleton;
|
||||||
@@ -34,6 +34,9 @@ export { default as BookWordmark } from './BookWordmark';
|
|||||||
export { default as SearchModal } from './SearchModal';
|
export { default as SearchModal } from './SearchModal';
|
||||||
export { DotStrip, LineSparkline } from './StatStrip';
|
export { DotStrip, LineSparkline } from './StatStrip';
|
||||||
|
|
||||||
|
// DS1 (§4) — skeleton loaders replace text-wall "Loading…" states everywhere.
|
||||||
|
export { default as Skeleton, SkeletonCards, SkeletonList } from './Skeleton';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
GRADE_COLORS,
|
GRADE_COLORS,
|
||||||
GRADE_HEX,
|
GRADE_HEX,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/* DS1 (DESIGN-SPEC §17 — scan→ledger persistence).
|
||||||
|
*
|
||||||
|
* The single row-builder for a MANUAL scan's ledger_entries row. Shared
|
||||||
|
* (CommonJS) so the Next scan route builds the row through it AND the Jest
|
||||||
|
* suite can prove a written row is readable by the /api/ledger/mine query
|
||||||
|
* (same user_id scope, same player_key = nameKey, same dedupe key).
|
||||||
|
*
|
||||||
|
* DATA SEMANTICS: line/book are the REAL book values the user scanned;
|
||||||
|
* locked_odds/team attach only when the cache-only snapshot confirms them
|
||||||
|
* (absent beats wrong). Only grade/edge/confidence/model_value are MODEL. */
|
||||||
|
|
||||||
|
const { nameKey, normalizeName } = require('./playerName');
|
||||||
|
|
||||||
|
/** The columns the dedupe UNIQUE constraint (migration 019) is keyed on, and
|
||||||
|
* the onConflict target for the upsert. Kept here as the single source. */
|
||||||
|
const LEDGER_CONFLICT_COLS = 'user_id,player_key,stat,line,side,game_id';
|
||||||
|
|
||||||
|
/** Today's date in ET (YYYY-MM-DD) — the game_date the settle pass keys on. */
|
||||||
|
function gameDateET(now = new Date()) {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
}).format(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ledger_entries row for a completed manual scan.
|
||||||
|
* @param {{
|
||||||
|
* userId: string,
|
||||||
|
* sport: string,
|
||||||
|
* player: string,
|
||||||
|
* stat: string,
|
||||||
|
* line: number,
|
||||||
|
* side: string,
|
||||||
|
* book?: string | null,
|
||||||
|
* team?: string | null,
|
||||||
|
* lockedOdds?: string | number | null,
|
||||||
|
* grade?: string | null,
|
||||||
|
* edge?: number | null,
|
||||||
|
* confidence?: number | null,
|
||||||
|
* projection?: number | null,
|
||||||
|
* now?: Date,
|
||||||
|
* }} opts
|
||||||
|
* @returns {Record<string, unknown>} a row ready for `sb.from('ledger_entries').upsert(row, ...)`.
|
||||||
|
*/
|
||||||
|
function buildManualLedgerRow({
|
||||||
|
userId, sport, player, stat, line, side, book,
|
||||||
|
team = null, lockedOdds = null,
|
||||||
|
grade = null, edge = null, confidence = null, projection = null,
|
||||||
|
now = new Date(),
|
||||||
|
}) {
|
||||||
|
const s = String(sport || '').toLowerCase();
|
||||||
|
const playerKey = nameKey(player);
|
||||||
|
const gameDate = gameDateET(now);
|
||||||
|
return {
|
||||||
|
user_id: userId,
|
||||||
|
player_key: playerKey,
|
||||||
|
player_name: normalizeName(player).display || player,
|
||||||
|
sport: s,
|
||||||
|
stat: String(stat || '').toLowerCase(),
|
||||||
|
line,
|
||||||
|
side,
|
||||||
|
locked_odds: lockedOdds != null ? String(lockedOdds) : null,
|
||||||
|
book: book || 'draftkings',
|
||||||
|
team,
|
||||||
|
opponent: null,
|
||||||
|
grade: grade ?? null,
|
||||||
|
edge: numOrNull(edge),
|
||||||
|
confidence: numOrNull(confidence),
|
||||||
|
model_value: numOrNull(projection),
|
||||||
|
graded_at: now.toISOString(),
|
||||||
|
game_id: `manual:${s}:${gameDate}:${playerKey}`,
|
||||||
|
game_date: gameDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildManualLedgerRow, gameDateET, LEDGER_CONFLICT_COLS };
|
||||||
Reference in New Issue
Block a user