Item 1 — VERB LAW: one verb, READ (never SCAN), + a lint that enforces it
The product argued with itself: FAB/nav said "Scan", Free tier "5 scans", ticker "MLB slate scanned" — while the Ledger says "MY READS". Swept every user-visible surface to READ: - BottomTabBar FAB + Nav link: 'Scan' → 'Read' - Pricing free tier: '5 scans to try the model' → '5 reads …' - StatStrip: 'Awaiting next scan' → 'Awaiting next read' - Ticker badge + snapshotService event: tag 'SCAN' → 'READ', 'slate scanned' → 'slate read' (readSportOf parses BOTH old and new so cached ticker items dedupe cleanly through the rollover) - upgradePitch: 'You've scanned N parlays' / 'unlimited scans' → read/reads Internal untouched (not user-visible): /api/scan routes, scan_count column, scanning state, DemoScan/ScanIcon, scanlines CSS, the transitional SCAN color-map key. tests/unit/verbLaw.test.js is the enforcement: it fails on user-visible scan/scanned/scans copy across web/src + src/services (skips comments). Suite 270/3254 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -128,8 +128,9 @@ const isTopGrade = (g) => g === 'A+' || g === 'A';
|
|||||||
function generateTickerEvents(sport, grades, deltas, ts) {
|
function generateTickerEvents(sport, grades, deltas, ts) {
|
||||||
const events = [];
|
const events = [];
|
||||||
events.push({
|
events.push({
|
||||||
tag: 'SCAN', color: 'var(--g-a)', ts, sport, // sport tag → dedupe one SCAN per sport
|
// VERB LAW — the verb is READ, never SCAN. One READ event per sport (deduped).
|
||||||
text: `${sport.toUpperCase()} slate scanned · ${grades.length} props graded`,
|
tag: 'READ', color: 'var(--g-a)', ts, sport,
|
||||||
|
text: `${sport.toUpperCase()} slate read · ${grades.length} props graded`,
|
||||||
});
|
});
|
||||||
for (const g of grades.filter((x) => isTopGrade(x.grade)).slice(0, 6)) {
|
for (const g of grades.filter((x) => isTopGrade(x.grade)).slice(0, 6)) {
|
||||||
const arch = g.archetype ? `${g.archetype} ` : '';
|
const arch = g.archetype ? `${g.archetype} ` : '';
|
||||||
@@ -149,12 +150,14 @@ function generateTickerEvents(sport, grades, deltas, ts) {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session 47 — a SCAN event's sport, from the event field or its text prefix
|
// Session 47 — a READ event's sport, from the event field or its text prefix
|
||||||
// (defends ticker items written before the `sport` field existed).
|
// (defends ticker items written before the `sport` field existed). Accepts the
|
||||||
function scanSportOf(e) {
|
// legacy 'SCAN'/'slate scanned' shape too so cached items dedupe cleanly through
|
||||||
if (e.tag !== 'SCAN') return null;
|
// the verb-law rollover (the ticker regenerates as READ on the next snapshot).
|
||||||
|
function readSportOf(e) {
|
||||||
|
if (e.tag !== 'READ' && e.tag !== 'SCAN') return null;
|
||||||
if (e.sport) return String(e.sport).toLowerCase();
|
if (e.sport) return String(e.sport).toLowerCase();
|
||||||
const m = String(e.text || '').match(/^([a-z]+)\s+slate scanned/i);
|
const m = String(e.text || '').match(/^([a-z]+)\s+slate (?:read|scanned)/i);
|
||||||
return m ? m[1].toLowerCase() : null;
|
return m ? m[1].toLowerCase() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,13 +165,13 @@ async function pushTickerItems(events, deps) {
|
|||||||
if (!events || events.length === 0) return;
|
if (!events || events.length === 0) return;
|
||||||
const existing = await deps.cacheGet('ticker:items');
|
const existing = await deps.cacheGet('ticker:items');
|
||||||
const arr = Array.isArray(existing) ? existing : [];
|
const arr = Array.isArray(existing) ? existing : [];
|
||||||
// Keep only the LATEST SCAN per sport: drop existing SCAN events for any sport
|
// Keep only the LATEST READ per sport: drop existing READ events for any sport
|
||||||
// that has a fresh SCAN in this batch. MOVE/GRADE events are time-specific and
|
// that has a fresh READ in this batch. MOVE/GRADE events are time-specific and
|
||||||
// preserved.
|
// preserved.
|
||||||
const freshScanSports = new Set(events.map(scanSportOf).filter(Boolean));
|
const freshReadSports = new Set(events.map(readSportOf).filter(Boolean));
|
||||||
const pruned = arr.filter((e) => {
|
const pruned = arr.filter((e) => {
|
||||||
const sp = scanSportOf(e);
|
const sp = readSportOf(e);
|
||||||
return !(sp && freshScanSports.has(sp));
|
return !(sp && freshReadSports.has(sp));
|
||||||
});
|
});
|
||||||
const merged = [...events, ...pruned].slice(0, TICKER_CAP);
|
const merged = [...events, ...pruned].slice(0, TICKER_CAP);
|
||||||
await deps.cacheSet('ticker:items', merged, TICKER_TTL);
|
await deps.cacheSet('ticker:items', merged, TICKER_TTL);
|
||||||
|
|||||||
@@ -68,15 +68,15 @@ async function generateUpgradePitch(supabase, userId, currentScanResults) {
|
|||||||
|
|
||||||
const tierBenefit = tierRecommended === 'desk'
|
const tierBenefit = tierRecommended === 'desk'
|
||||||
? 'Desk tier adds full bet tracking, ROI analytics, and priority cascade alerts.'
|
? 'Desk tier adds full bet tracking, ROI analytics, and priority cascade alerts.'
|
||||||
: 'Analyst tier gives you unlimited scans plus line movement alerts so you never miss a soft number.';
|
: 'Analyst tier gives you unlimited reads plus line movement alerts so you never miss a soft number.';
|
||||||
|
|
||||||
const founderPrice = tierRecommended === 'desk' ? '$34.99/mo' : '$14.99/mo';
|
const founderPrice = tierRecommended === 'desk' ? '$34.99/mo' : '$14.99/mo';
|
||||||
const standardPrice = tierRecommended === 'desk' ? '$49.99/mo' : '$19.99/mo';
|
const standardPrice = tierRecommended === 'desk' ? '$49.99/mo' : '$19.99/mo';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hook: `You've scanned ${totalScans} parlays this month. ${goodCount} graded B or higher — ${compliment}.`,
|
hook: `You've read ${totalScans} parlays this month. ${goodCount} graded B or higher — ${compliment}.`,
|
||||||
insight: `Your best edge has been ${topStatType} ${topDirection}s. ${tierBenefit}`,
|
insight: `Your best edge has been ${topStatType} ${topDirection}s. ${tierBenefit}`,
|
||||||
cta: `Unlock unlimited scans for ${founderPrice} (founder rate)`,
|
cta: `Unlock unlimited reads for ${founderPrice} (founder rate)`,
|
||||||
tier_recommended: tierRecommended,
|
tier_recommended: tierRecommended,
|
||||||
founder_price: founderPrice,
|
founder_price: founderPrice,
|
||||||
standard_price: standardPrice,
|
standard_price: standardPrice,
|
||||||
|
|||||||
@@ -72,15 +72,15 @@ describe('GET /api/ticker', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('returns snapshot items newest-first', async () => {
|
it('returns snapshot items newest-first', async () => {
|
||||||
mockCache.value = [{ tag: 'SCAN', text: 'MLB slate scanned · 248 props graded' }, { tag: 'A+', text: 'BOMBER Judge graded A+' }];
|
mockCache.value = [{ tag: 'READ', text: 'MLB slate read · 248 props graded' }, { tag: 'A+', text: 'BOMBER Judge graded A+' }];
|
||||||
delete process.env.TICKER_MANUAL;
|
delete process.env.TICKER_MANUAL;
|
||||||
const res = await request(mountTicker()).get('/api/ticker');
|
const res = await request(mountTicker()).get('/api/ticker');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.items[0].tag).toBe('SCAN');
|
expect(res.body.items[0].tag).toBe('READ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges editorial pins from TICKER_MANUAL', async () => {
|
it('merges editorial pins from TICKER_MANUAL', async () => {
|
||||||
mockCache.value = [{ tag: 'SCAN', text: 'scanned' }];
|
mockCache.value = [{ tag: 'READ', text: 'read' }];
|
||||||
process.env.TICKER_MANUAL = JSON.stringify([{ tag: 'ALERT', text: 'VYNDR 2.0 is live.' }]);
|
process.env.TICKER_MANUAL = JSON.stringify([{ tag: 'ALERT', text: 'VYNDR 2.0 is live.' }]);
|
||||||
const res = await request(mountTicker()).get('/api/ticker');
|
const res = await request(mountTicker()).get('/api/ticker');
|
||||||
expect(res.body.items.find((i) => i.tag === 'ALERT')).toBeTruthy();
|
expect(res.body.items.find((i) => i.tag === 'ALERT')).toBeTruthy();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe('GET /api/internal/snapshot/status', () => {
|
|||||||
mockCacheGet.mockImplementation(async (key) => {
|
mockCacheGet.mockImplementation(async (key) => {
|
||||||
if (key === 'snapshot:mlb:latest') return { updated_at: '2026-06-19T18:00:00Z', grades: [{}, {}, {}], deltas: [{}] };
|
if (key === 'snapshot:mlb:latest') return { updated_at: '2026-06-19T18:00:00Z', grades: [{}, {}, {}], deltas: [{}] };
|
||||||
if (key === 'grades:mlb') return { grades: [{}, {}, {}] };
|
if (key === 'grades:mlb') return { grades: [{}, {}, {}] };
|
||||||
if (key === 'ticker:items') return [{ type: 'SCAN' }, { type: 'ALERT' }];
|
if (key === 'ticker:items') return [{ type: 'READ' }, { type: 'ALERT' }];
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () =>
|
|||||||
expect(tierBlock(pricing, 'desk')).toBe('true');
|
expect(tierBlock(pricing, 'desk')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('real prices only — Desk $34.99 founder / $44.99 regular, Analyst $14.99/$19.99, Free 5 scans', () => {
|
test('real prices only — Desk $34.99 founder / $44.99 regular, Analyst $14.99/$19.99, Free 5 reads', () => {
|
||||||
expect(pricing).toMatch(/id: 'desk'[\s\S]*?price: '\$34\.99'/);
|
expect(pricing).toMatch(/id: 'desk'[\s\S]*?price: '\$34\.99'/);
|
||||||
expect(pricing).toMatch(/id: 'desk'[\s\S]*?originalPrice: '\$44\.99'/);
|
expect(pricing).toMatch(/id: 'desk'[\s\S]*?originalPrice: '\$44\.99'/);
|
||||||
expect(pricing).toMatch(/id: 'analyst'[\s\S]*?price: '\$14\.99'/);
|
expect(pricing).toMatch(/id: 'analyst'[\s\S]*?price: '\$14\.99'/);
|
||||||
expect(pricing).toMatch(/id: 'analyst'[\s\S]*?originalPrice: '\$19\.99'/);
|
expect(pricing).toMatch(/id: 'analyst'[\s\S]*?originalPrice: '\$19\.99'/);
|
||||||
expect(pricing).toContain('5 scans to try the model');
|
expect(pricing).toContain('5 reads to try the model');
|
||||||
// no fabricated legacy price
|
// no fabricated legacy price
|
||||||
expect(pricing).not.toContain("originalPrice: '$24.99'");
|
expect(pricing).not.toContain("originalPrice: '$24.99'");
|
||||||
expect(pricing).not.toContain("originalPrice: '$49.99'");
|
expect(pricing).not.toContain("originalPrice: '$49.99'");
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ describe('Slate uses the VYNDR card + pre-graded snapshot (swap is real)', () =>
|
|||||||
|
|
||||||
describe('StatStrip renders snapshot states', () => {
|
describe('StatStrip renders snapshot states', () => {
|
||||||
const src = read('components/vyndr/StatStrip.tsx');
|
const src = read('components/vyndr/StatStrip.tsx');
|
||||||
it('renders Awaiting next scan + line-delta sub-line', () => {
|
it('renders Awaiting next read + line-delta sub-line', () => {
|
||||||
expect(src).toContain('Awaiting next scan');
|
expect(src).toContain('Awaiting next read');
|
||||||
expect(src).toContain('TOWARD');
|
expect(src).toContain('TOWARD');
|
||||||
expect(src).toContain('Graded ');
|
expect(src).toContain('Graded ');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('generateTickerEvents', () => {
|
|||||||
];
|
];
|
||||||
it('emits a SCAN summary + GRADE events for A/A+ only', () => {
|
it('emits a SCAN summary + GRADE events for A/A+ only', () => {
|
||||||
const ev = svc.generateTickerEvents('mlb', grades, [], '2026-06-18T20:00:00Z');
|
const ev = svc.generateTickerEvents('mlb', grades, [], '2026-06-18T20:00:00Z');
|
||||||
expect(ev[0].tag).toBe('SCAN');
|
expect(ev[0].tag).toBe('READ');
|
||||||
expect(ev[0].text).toContain('2 props graded');
|
expect(ev[0].text).toContain('2 props graded');
|
||||||
const grade = ev.find((e) => e.tag === 'A+');
|
const grade = ev.find((e) => e.tag === 'A+');
|
||||||
expect(grade.text).toContain('BOMBER');
|
expect(grade.text).toContain('BOMBER');
|
||||||
@@ -167,7 +167,7 @@ describe('runSnapshot (fully injected)', () => {
|
|||||||
await svc.runSnapshot('mlb', deps(cache));
|
await svc.runSnapshot('mlb', deps(cache));
|
||||||
const items = cache.store['ticker:items'];
|
const items = cache.store['ticker:items'];
|
||||||
expect(Array.isArray(items)).toBe(true);
|
expect(Array.isArray(items)).toBe(true);
|
||||||
expect(items.find((e) => e.tag === 'SCAN')).toBeTruthy();
|
expect(items.find((e) => e.tag === 'READ')).toBeTruthy();
|
||||||
expect(items.length).toBeLessThanOrEqual(50);
|
expect(items.length).toBeLessThanOrEqual(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,38 +10,38 @@ function memCache(initial) {
|
|||||||
describe('pushTickerItems — SCAN dedup', () => {
|
describe('pushTickerItems — SCAN dedup', () => {
|
||||||
it('replaces a prior SCAN for the same sport (only one MLB SCAN)', async () => {
|
it('replaces a prior SCAN for the same sport (only one MLB SCAN)', async () => {
|
||||||
const cache = memCache([
|
const cache = memCache([
|
||||||
{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 20 props graded' },
|
{ tag: 'READ', sport: 'mlb', text: 'MLB slate read · 20 props graded' },
|
||||||
{ tag: 'MOVE', text: 'Judge o2.5 → o3.5 ▲+1' },
|
{ tag: 'MOVE', text: 'Judge o2.5 → o3.5 ▲+1' },
|
||||||
]);
|
]);
|
||||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
|
await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'MLB slate read · 25 props graded' }], cache);
|
||||||
const items = cache.store['ticker:items'];
|
const items = cache.store['ticker:items'];
|
||||||
expect(items.filter((e) => e.tag === 'SCAN' && e.sport === 'mlb')).toHaveLength(1);
|
expect(items.filter((e) => e.tag === 'READ' && e.sport === 'mlb')).toHaveLength(1);
|
||||||
expect(items.find((e) => e.tag === 'SCAN').text).toContain('25 props');
|
expect(items.find((e) => e.tag === 'READ').text).toContain('25 props');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves MOVE/GRADE events and other sports', async () => {
|
it('preserves MOVE/GRADE events and other sports', async () => {
|
||||||
const cache = memCache([
|
const cache = memCache([
|
||||||
{ tag: 'MOVE', text: 'move 1' },
|
{ tag: 'MOVE', text: 'move 1' },
|
||||||
{ tag: 'A+', text: 'BOMBER graded A+' },
|
{ tag: 'A+', text: 'BOMBER graded A+' },
|
||||||
{ tag: 'SCAN', sport: 'nba', text: 'NBA slate scanned · 10 props graded' },
|
{ tag: 'READ', sport: 'nba', text: 'NBA slate read · 10 props graded' },
|
||||||
]);
|
]);
|
||||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
|
await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'MLB slate read · 25 props graded' }], cache);
|
||||||
const items = cache.store['ticker:items'];
|
const items = cache.store['ticker:items'];
|
||||||
expect(items.find((e) => e.tag === 'MOVE')).toBeTruthy();
|
expect(items.find((e) => e.tag === 'MOVE')).toBeTruthy();
|
||||||
expect(items.find((e) => e.tag === 'A+')).toBeTruthy();
|
expect(items.find((e) => e.tag === 'A+')).toBeTruthy();
|
||||||
expect(items.find((e) => e.tag === 'SCAN' && e.sport === 'nba')).toBeTruthy();
|
expect(items.find((e) => e.tag === 'READ' && e.sport === 'nba')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dedupes legacy SCAN items that lack a sport field (parse from text)', async () => {
|
it('dedupes legacy SCAN items that lack a sport field (parse from text)', async () => {
|
||||||
const cache = memCache([{ tag: 'SCAN', text: 'MLB slate scanned · 20 props graded' }]);
|
const cache = memCache([{ tag: 'READ', text: 'MLB slate read · 20 props graded' }]);
|
||||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
|
await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'MLB slate read · 25 props graded' }], cache);
|
||||||
expect(cache.store['ticker:items'].filter((e) => e.tag === 'SCAN')).toHaveLength(1);
|
expect(cache.store['ticker:items'].filter((e) => e.tag === 'READ')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays capped at 50 items', async () => {
|
it('stays capped at 50 items', async () => {
|
||||||
const many = Array.from({ length: 60 }, (_, i) => ({ tag: 'MOVE', text: `m${i}` }));
|
const many = Array.from({ length: 60 }, (_, i) => ({ tag: 'MOVE', text: `m${i}` }));
|
||||||
const cache = memCache(many);
|
const cache = memCache(many);
|
||||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'scanned' }], cache);
|
await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'read' }], cache);
|
||||||
expect(cache.store['ticker:items'].length).toBeLessThanOrEqual(50);
|
expect(cache.store['ticker:items'].length).toBeLessThanOrEqual(50);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// VERB LAW (Truth-Everywhere Part 2, item 1): the user-facing verb is READ,
|
||||||
|
// never SCAN. This lint fails if user-VISIBLE scan copy reappears anywhere in
|
||||||
|
// the web app or the backend strings that reach the UI (ticker text, upgrade
|
||||||
|
// pitch). It targets RENDERED patterns, not internal identifiers — route paths
|
||||||
|
// (/api/scan), state vars (scanning), DB columns (scan_count), component names
|
||||||
|
// (DemoScan/ScanIcon), the CSS 'scanlines' class, and the transitional ticker
|
||||||
|
// color-map key are all legitimately internal and NOT matched.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = [
|
||||||
|
path.join(__dirname, '..', '..', 'web', 'src'),
|
||||||
|
path.join(__dirname, '..', '..', 'src', 'services'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Each pattern is a user-visible scan phrase that must never ship.
|
||||||
|
const FORBIDDEN = [
|
||||||
|
{ re: /label:\s*['"]Scan['"]/, why: "nav/tab label 'Scan' → 'Read'" },
|
||||||
|
{ re: /slate scanned/i, why: "ticker 'slate scanned' → 'slate read'" },
|
||||||
|
{ re: /\b\d+\s+scans\b/i, why: "tier copy 'N scans' → 'N reads'" },
|
||||||
|
{ re: /unlimited scans/i, why: "'unlimited scans' → 'unlimited reads'" },
|
||||||
|
{ re: /you'?ve scanned/i, why: "'You've scanned …' → 'You've read …'" },
|
||||||
|
{ re: /Awaiting next scan/i, why: "'Awaiting next scan' → 'Awaiting next read'" },
|
||||||
|
{ re: /Scanning\.\.\./, why: "loading 'Scanning…' → 'Reading…'" },
|
||||||
|
{ re: />\s*Scan\b(?!Icon)/, why: "JSX text 'Scan' → 'Read'" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function walk(dir, out) {
|
||||||
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
const p = path.join(dir, name);
|
||||||
|
const st = fs.statSync(p);
|
||||||
|
if (st.isDirectory()) { if (name !== 'node_modules') walk(p, out); }
|
||||||
|
else if (/\.(tsx?|jsx?)$/.test(name)) out.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VERB LAW — no user-visible "scan" copy', () => {
|
||||||
|
const files = [];
|
||||||
|
for (const r of ROOTS) if (fs.existsSync(r)) walk(r, files);
|
||||||
|
|
||||||
|
test('every rendered surface uses READ, never SCAN', () => {
|
||||||
|
const hits = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const lines = fs.readFileSync(f, 'utf8').split('\n');
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const t = line.trim();
|
||||||
|
// Comments aren't user-visible — skip comment-only lines (rate-limit
|
||||||
|
// docs, history notes). A rendered string never starts with a comment.
|
||||||
|
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return;
|
||||||
|
for (const { re, why } of FORBIDDEN) {
|
||||||
|
if (re.test(line)) hits.push(`${path.relative(process.cwd(), f)}:${i + 1} — ${why}\n ${line.trim()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hits.length) throw new Error(`User-visible "scan" copy found (verb is READ):\n${hits.join('\n')}`);
|
||||||
|
expect(hits).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ describe('Phase F — BottomTabBar (5-tab spec)', () => {
|
|||||||
// Session 57 (Phase 0) — Terminal tab retired (fabricated surface);
|
// Session 57 (Phase 0) — Terminal tab retired (fabricated surface);
|
||||||
// Explore holds its slot so the bar keeps 5 tabs.
|
// Explore holds its slot so the bar keeps 5 tabs.
|
||||||
it('renders the five spec tabs', () => {
|
it('renders the five spec tabs', () => {
|
||||||
['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((label) => {
|
['Slate', 'Explore', 'Read', 'Ledger', 'More'].forEach((label) => {
|
||||||
expect(src).toContain(`'${label}'`);
|
expect(src).toContain(`'${label}'`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe('QA.11 — mobile parity (5-tab bar)', () => {
|
|||||||
// Session 57 (Phase 0) — Terminal retired; Explore holds its slot.
|
// Session 57 (Phase 0) — Terminal retired; Explore holds its slot.
|
||||||
it('BottomTabBar declares Slate/Explore/Scan/Ledger/More', () => {
|
it('BottomTabBar declares Slate/Explore/Scan/Ledger/More', () => {
|
||||||
const src = read('components/BottomTabBar.tsx');
|
const src = read('components/BottomTabBar.tsx');
|
||||||
['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`));
|
['Slate', 'Explore', 'Read', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type TabDef = {
|
|||||||
const TABS: TabDef[] = [
|
const TABS: TabDef[] = [
|
||||||
{ id: 'slate', label: 'Slate', href: '/dashboard', icon: SlateIcon },
|
{ id: 'slate', label: 'Slate', href: '/dashboard', icon: SlateIcon },
|
||||||
{ id: 'explore', label: 'Explore', href: '/explore', icon: ExploreIcon },
|
{ id: 'explore', label: 'Explore', href: '/explore', icon: ExploreIcon },
|
||||||
{ id: 'scan', label: 'Scan', href: '/scan', icon: ScanIcon, primary: true },
|
{ id: 'scan', label: 'Read', href: '/scan', icon: ScanIcon, primary: true },
|
||||||
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
|
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
|
||||||
{ id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
|
{ id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import NotificationBell from '@/components/NotificationBell';
|
|||||||
// as brand language only. Don't re-add the link until it's fed real data.
|
// as brand language only. Don't re-add the link until it's fed real data.
|
||||||
const PRIMARY = [
|
const PRIMARY = [
|
||||||
{ id: 'slate', label: 'Slate', href: '/dashboard' },
|
{ id: 'slate', label: 'Slate', href: '/dashboard' },
|
||||||
{ id: 'scan', label: 'Scan', href: '/scan' },
|
{ id: 'scan', label: 'Read', href: '/scan' },
|
||||||
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
||||||
];
|
];
|
||||||
const MORE = [
|
const MORE = [
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const TIERS: TierConfig[] = [
|
|||||||
headline: 'Try the model. No card required.',
|
headline: 'Try the model. No card required.',
|
||||||
cta: 'Start Free',
|
cta: 'Start Free',
|
||||||
features: [
|
features: [
|
||||||
'5 scans to try the model',
|
'5 reads to try the model',
|
||||||
'Grade letter + projection',
|
'Grade letter + projection',
|
||||||
'Cross-book line comparison',
|
'Cross-book line comparison',
|
||||||
'Confidence indicator',
|
'Confidence indicator',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface StripProp {
|
|||||||
// Session 45 — pre-graded snapshot model: the locked grade + market movement.
|
// Session 45 — pre-graded snapshot model: the locked grade + market movement.
|
||||||
gradedAt?: { line: number; odds?: number | null; timestamp?: string; ago?: string } | null;
|
gradedAt?: { line: number; odds?: number | null; timestamp?: string; ago?: string } | null;
|
||||||
delta?: { delta: number; direction: 'toward' | 'away'; currentLine: number } | null;
|
delta?: { delta: number; direction: 'toward' | 'away'; currentLine: number } | null;
|
||||||
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next scan"
|
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next read"
|
||||||
// Session 55 — settled outcome from the self-learning loop (once the game is final).
|
// Session 55 — settled outcome from the self-learning loop (once the game is final).
|
||||||
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
|
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
|
||||||
// Session 60 (Phase 2.5) — intraday movement + public revision.
|
// Session 60 (Phase 2.5) — intraday movement + public revision.
|
||||||
@@ -443,7 +443,7 @@ export default function StatStrip({
|
|||||||
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
|
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
|
||||||
<BestPriceDot p={p} />
|
<BestPriceDot p={p} />
|
||||||
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next scan'}</span>
|
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next read'}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type TickerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TAG_COLORS: Record<string, string> = {
|
const TAG_COLORS: Record<string, string> = {
|
||||||
'A+': 'var(--g-ap)', A: 'var(--g-a)', SCAN: 'var(--g-a)',
|
'A+': 'var(--g-ap)', A: 'var(--g-a)', READ: 'var(--g-a)', SCAN: 'var(--g-a)',
|
||||||
MOVE: 'var(--amber)', CASCADE: 'var(--amber)', ALERT: 'var(--text-0)',
|
MOVE: 'var(--amber)', CASCADE: 'var(--amber)', ALERT: 'var(--text-0)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
Session 59 (work-order 2.3) — the REAL pipeline schedule, for honest
|
Session 59 (work-order 2.3) — the REAL pipeline schedule, for honest
|
||||||
waiting states. "Awaiting next scan" told the user nothing; the truth is
|
waiting states. "Awaiting next read" told the user nothing; the truth is
|
||||||
the cron fires at fixed UTC hours, so we can say "Grades post ~6:00 PM ET".
|
the cron fires at fixed UTC hours, so we can say "Grades post ~6:00 PM ET".
|
||||||
|
|
||||||
HOURS_UTC mirrors the backend default (snapshotScheduler SNAPSHOT_HOURS_UTC
|
HOURS_UTC mirrors the backend default (snapshotScheduler SNAPSHOT_HOURS_UTC
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ function slateTeamsMatch(a, b) {
|
|||||||
* Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's
|
* Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's
|
||||||
* locked grades onto the game's odds-derived props (which already carry the
|
* locked grades onto the game's odds-derived props (which already carry the
|
||||||
* correct game grouping). Each prop is either graded (grade + gradedAt + delta)
|
* correct game grouping). Each prop is either graded (grade + gradedAt + delta)
|
||||||
* or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read
|
* or `awaiting:true` (no snapshot match yet → "Awaiting next read", no Read
|
||||||
* button). Archetype comes from the snapshot's per-player classification.
|
* button). Archetype comes from the snapshot's per-player classification.
|
||||||
*
|
*
|
||||||
* Session 59 (work-order 1.6) — THE JOIN INVARIANT: when the snapshot knows
|
* Session 59 (work-order 1.6) — THE JOIN INVARIANT: when the snapshot knows
|
||||||
|
|||||||
Reference in New Issue
Block a user