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:
Kev
2026-07-12 19:25:32 -04:00
parent 24af247b29
commit 1c681df5d3
12 changed files with 498 additions and 45 deletions
+78
View File
@@ -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 };