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,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