diff --git a/tests/unit/ds4Billboards.test.js b/tests/unit/ds4Billboards.test.js new file mode 100644 index 0000000..ddae892 --- /dev/null +++ b/tests/unit/ds4Billboards.test.js @@ -0,0 +1,180 @@ +// DS4 (Design v2) — the BILLBOARDS. Screenshot-first surfaces (the timeline is +// customer #1): the STREAKS row, the grade reveal, the CLV reframe, and the /u +// public profile. The .tsx surfaces are asserted against source (plain-JS Jest, +// no TS transform) — same pattern as publicProfilePage / vyndrParityQA. The +// CLV flat-vs-spread decision is a pure fn and is exercised directly. + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +// ── 1. CLV reframe — the pure flat/spread decision (#16) ────────────────── +describe('clvDisplay — flat CLV says so, spread shows bars', () => { + const { clvMode, isFlatClv, CLV_FLAT_LINE } = require('../../web/src/lib/clvDisplay'); + + test('near-flat (73/74 at the close) is FLAT, not a broken histogram', () => { + const dist = [ + { label: '[-.5,0)', side: 'faded', count: 0 }, + { label: '0', side: 'flat', count: 73 }, + { label: '(0,.5]', side: 'beat', count: 1 }, + ]; + expect(clvMode(dist).mode).toBe('flat'); + expect(isFlatClv(dist)).toBe(true); + }); + + test('real dispersion is SPREAD (the bars are meaningful)', () => { + const dist = [ + { label: '[-1,-.5)', side: 'faded', count: 8 }, + { label: '[-.5,0)', side: 'faded', count: 6 }, + { label: '0', side: 'flat', count: 5 }, + { label: '(0,.5]', side: 'beat', count: 9 }, + { label: '(.5,1]', side: 'beat', count: 12 }, + ]; + expect(clvMode(dist).mode).toBe('spread'); + expect(isFlatClv(dist)).toBe(false); + }); + + test('null / empty / all-zero → none (renders nothing, never errored)', () => { + expect(clvMode(null).mode).toBe('none'); + expect(clvMode([]).mode).toBe('none'); + expect(clvMode([{ side: 'flat', count: 0 }, { side: 'beat', count: 0 }]).mode).toBe('none'); + }); + + test('the flat line is confident + VOICE-compliant (no jargon, no hype)', () => { + expect(CLV_FLAT_LINE).toMatch(/grade the outcome, not the close/); + expect(CLV_FLAT_LINE).not.toMatch(/!/); + expect(CLV_FLAT_LINE.toLowerCase()).not.toMatch(/bayesian|regression|guarantee/); + }); + + test('the ledger MODEL tab renders the flat line, not a histogram, when flat', () => { + const src = read('app/ledger/page.tsx'); + expect(src).toContain("import { clvMode, CLV_FLAT_LINE }"); + expect(src).toMatch(/mode === 'flat'/); + expect(src).toContain('CLV_FLAT_LINE'); + // bars still exist for the spread branch + expect(src).toContain('CLV DISTRIBUTION'); + }); +}); + +// ── 2. STREAKS row — the Bloomberg alert (#2) ───────────────────────────── +describe('StreaksPanel — one hero figure, real identity, one accent', () => { + const src = read('components/StreaksPanel.tsx'); + + test('uses the real PlayerAvatar entity (not a bare )', () => { + expect(src).toContain('import PlayerAvatar'); + expect(src).toContain(' { + // heroNum renders currentStreak and is the biggest fontSize; the player + // name is demoted well below it. + expect(src).toMatch(/heroNum[\s\S]*fontSize:\s*38/); + expect(src).toContain('{s.currentStreak}'); + const heroSize = Number((src.match(/heroNum:[^}]*fontSize:\s*(\d+)/) || [])[1]); + const nameSize = Number((src.match(/playerName:[^}]*fontSize:\s*(\d+)/) || [])[1]); + expect(heroSize).toBeGreaterThan(nameSize); + expect(heroSize).toBeGreaterThanOrEqual(32); + }); + + test('the hero number is mono + tabular (data grammar)', () => { + expect(src).toMatch(/heroNum:[\s\S]*fontVariantNumeric:\s*'tabular-nums'/); + }); + + test('carries the muted "built vs" lens context', () => { + expect(src).toContain('built vs'); + expect(src).toContain('builtVs'); + }); + + test('ONE severity accent — step-up amber / step-down green', () => { + expect(src).toContain("diff === 'step up'"); + expect(src).toContain('var(--amber'); + expect(src).toContain("diff === 'step down'"); + expect(src).toContain('var(--g-a'); + }); + + test('the grade badge is tier-gated (the READ is the paid layer)', () => { + expect(src).toMatch(/tier === 'analyst' \|\| tier === 'desk'/); + expect(src).toContain(' { + const src = read('components/vyndr/GradeResultCard.tsx'); + + test('a negative edge NEVER renders green — negative = var(--miss)', () => { + expect(src).toMatch(/edgeColor\s*=\s*d\.edge != null && d\.edge < 0 \? 'var\(--miss\)' : 'var\(--g-a\)'/); + // both the confidence strip and the projection row use edgeColor, not a + // hardcoded green. + expect(src).toContain('color: edgeColor'); + expect(src).toMatch(/d\.edge != null \? edgeColor : 'var\(--text-2\)'/); + }); + + test('the grade LETTER is still the hero (largest element)', () => { + expect(src).toMatch(/grade-hero[\s\S]*fontSize: compact \? 92 : 116/); + }); + + test('the MODEL/LINE/EDGE row is mono + tabular (demoted data)', () => { + expect(src).toMatch(/fontSize: 19[\s\S]*fontVariantNumeric: 'tabular-nums'/); + }); + + test('reveal choreography is reduced-motion safe', () => { + const proc = read('components/vyndr/ProcessingGrade.tsx'); + expect(proc).toContain("prefers-reduced-motion: reduce"); + expect(proc).toMatch(/setPhase\('card'\)/); + }); +}); + +// ── 4. /u public profile — the shareable record billboard (#4) ──────────── +describe('PublicProfile — the record hero + CLV-verified badge', () => { + const src = read('app/u/[handle]/PublicProfile.tsx'); + + test('the hit rate is the bold hero figure (large mono tabular)', () => { + expect(src).toMatch(/fontSize: 56[\s\S]*fontVariantNumeric: 'tabular-nums'/); + expect(src).toContain('{agg.hit_pct}%'); + expect(src).toContain('HIT RATE'); + }); + + test('beat-close is the secondary bold figure', () => { + expect(src).toContain('{agg.beat_close_pct}%'); + expect(src).toContain('BEAT CLOSE'); + }); + + test('CLV-VERIFIED badge is HONEST — only when closing value is tracked', () => { + expect(src).toContain('✓ CLV-VERIFIED'); + expect(src).toMatch(/clvVerified = Boolean\(ready && agg && agg\.beat_close_pct != null\)/); + }); + + test('still honors the n-gate: RECORD BUILDING under min_sample', () => { + expect(src).toContain('RECORD BUILDING'); + expect(src).toMatch(/settled >= minSample/); + }); + + test('renders real player identity (PlayerAvatar entity)', () => { + expect(src).toContain('import PlayerAvatar'); + expect(src).toContain(' { + const src = read('app/u/[handle]/opengraph-image.tsx'); + + test('social crop size is preserved (1200x630)', () => { + expect(src).toMatch(/size = \{ width: 1200, height: 630 \}/); + }); + + test('renders the real hit% / beat-close% when the record is published', () => { + expect(src).toContain('{rec.hit_pct}%'); + expect(src).toContain('{rec.beat_close_pct}%'); + expect(src).toContain('CLV-VERIFIED RECORD'); + }); + + test('falls back gracefully to the tagline when there is no record', () => { + expect(src).toContain('every settled read · misses included'); + }); +}); diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 1d4ded0..ee94210 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; +import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** * The Ledger (Session 58, Phase 1) — the truth surface, now backed by the @@ -191,15 +192,36 @@ function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: n ); } -/** S6 (A1 board) — compact settled-CLV distribution strip. Bars are DATA - * (mono, tabular): green = beat-the-close side, red = faded side, dim = flat. - * Renders only when the server passed the n≥20 gate (clv_distribution set). */ +/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives + * in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat + * distribution renders as one giant bar + six empty stubs — it reads as + * BROKEN. When CLV is flat we SAY so in one confident VOICE line (the record + * is the best data on the site; it must look the most premium, never errored) + * and show the bars only when there is real spread. */ function ClvDistribution({ agg }: { agg: ModelAggregate }) { const dist = agg.clv_distribution; - if (!Array.isArray(dist) || dist.length === 0) return null; - const total = dist.reduce((n, b) => n + b.count, 0); + const { mode } = clvMode(dist as Parameters[0]); + if (mode === 'none') return null; + + if (mode === 'flat') { + // Flat CLV is a FEATURE, not an error: the model grades outcomes, not the + // close. State it plainly — premium, confident, no broken histogram. + return ( +
+
+ CLOSING-LINE VALUE +
+

+ {CLV_FLAT_LINE} +

+
+ ); + } + + const distArr = dist as ClvBucket[]; + const total = distArr.reduce((n, b) => n + b.count, 0); if (total === 0) return null; - const max = Math.max(...dist.map((b) => b.count)); + const max = Math.max(...distArr.map((b) => b.count)); const color = (side: ClvBucket['side']) => side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)'; return ( @@ -208,7 +230,7 @@ function ClvDistribution({ agg }: { agg: ModelAggregate }) { CLV DISTRIBUTION · {total} SETTLED
- {dist.map((b) => ( + {distArr.map((b) => (
0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}> {b.count} diff --git a/web/src/app/u/[handle]/PublicProfile.tsx b/web/src/app/u/[handle]/PublicProfile.tsx index a0c38b5..03811dd 100644 --- a/web/src/app/u/[handle]/PublicProfile.tsx +++ b/web/src/app/u/[handle]/PublicProfile.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; /** * PublicProfile (A1 Session 10) — the public ledger record for one handle. @@ -144,30 +145,60 @@ export default function PublicProfile({ handle }: { handle: string }) { ); } +/** + * The record HERO (DS4 · billboard — DESIGN-SPEC Part 3 + Part 6 #4). This is + * the shareable surface Kev pitches partners with. One bold figure among muted + * context: the HIT RATE as a large mono tabular number, beat-close as the + * secondary bold figure, the raw record demoted. CLV-VERIFIED badge only when + * closing-line value is actually tracked (honest — never a decorative badge). + */ function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) { const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null); + const clvVerified = Boolean(ready && agg && agg.beat_close_pct != null); return (
{ready && agg ? ( -
- - RECORD · LAST 30D - - - {agg.hits}-{agg.misses} · {agg.hit_pct}% HIT - - {agg.beat_close_pct != null && ( - - {agg.beat_close_pct}% BEAT CLOSE + <> +
+ + RECORD · LAST 30D - )} - - {agg.pending} pending - -
+ {clvVerified && ( + + ✓ CLV-VERIFIED + + )} +
+ {/* HERO figures — hit rate is the largest thing on the page. */} +
+
+
+ {agg.hit_pct}% +
+
+ HIT RATE · {agg.hits}-{agg.misses} +
+
+ {agg.beat_close_pct != null && ( +
+
+ {agg.beat_close_pct}% +
+
+ BEAT CLOSE +
+
+ )} +
+
+ {agg.pending} pending +
+
+
+ ) : (

@@ -230,10 +261,16 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {

-

{row.player_name}

-

- {row.side} {row.line} {row.stat.replace(/_/g, ' ')} -

+ {/* Real entity identity (DS0) — headshot / team-colored monogram. */} +
+ +
+

{row.player_name}

+

+ {row.side} {row.line} {row.stat.replace(/_/g, ' ')} +

+
+

{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {row.model_value != null && · MODEL {row.model_value}} diff --git a/web/src/app/u/[handle]/opengraph-image.tsx b/web/src/app/u/[handle]/opengraph-image.tsx index 1473020..8b63f46 100644 --- a/web/src/app/u/[handle]/opengraph-image.tsx +++ b/web/src/app/u/[handle]/opengraph-image.tsx @@ -1,15 +1,36 @@ import { ImageResponse } from 'next/og'; -// A1 Session 10 — every shared /u/:handle link unfurls as a record card. -// Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule — -// next/og breaks under edge off Vercel). +// A1 Session 10 · elevated DS4 — every shared /u/:handle link unfurls as a +// record card at social crop (1200×630). The billboard now carries the REAL +// record (hit% + beat-close%) when the handle is published + past the n-gate, +// so the timeline (customer #1) sees the proof, not just a tagline. Self-hosted +// standalone build → Node runtime (NOT edge; Session-53 rule). export const alt = 'VYNDR Public Ledger'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> { + try { + const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!r.ok) return null; + const d = await r.json(); + const agg = d && d.aggregate; + if (!agg || agg.hit_pct == null) return null; + return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses }; + } catch { + return null; + } +} + export default async function Image({ params }: { params: Promise<{ handle: string }> }) { const { handle } = await params; const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle'; + const rec = await fetchRecord(h); return new ImageResponse( (

- PUBLIC LEDGER + {rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'}
-
+
@{h}
-
- CLV-verified record · every settled read · misses included -
+ {rec ? ( +
+
+
{rec.hit_pct}%
+
HIT RATE · {rec.hits}-{rec.misses}
+
+ {rec.beat_close_pct != null && ( +
+
{rec.beat_close_pct}%
+
BEAT CLOSE
+
+ )} +
+ ) : ( +
+ CLV-verified record · every settled read · misses included +
+ )}
diff --git a/web/src/components/StreaksPanel.tsx b/web/src/components/StreaksPanel.tsx index d00a2e2..46a41ad 100644 --- a/web/src/components/StreaksPanel.tsx +++ b/web/src/components/StreaksPanel.tsx @@ -2,15 +2,18 @@ import { useEffect, useState } from 'react'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import GradeBadge from '@/components/vyndr/GradeBadge'; import { type Tier } from '@/lib/tierGate'; /** - * StreaksPanel (Session 23). + * StreaksPanel (Session 23 · rebuilt DS4 — the STREAKS BILLBOARD). * - * Surfaces computed player streaks for a sport, narrowed by the active - * stat filter. Everything through VYNDR's lens — "4-game 28+ scoring - * streak", not "31.2 PPG". Free users see the top 3 with an upgrade - * nudge; paid users see the full list. + * DESIGN-SPEC Part 7 (#2): the STREAKS row is a P0 billboard — the timeline + * is customer #1. Not a log line: a Bloomberg alert. ONE bold figure (the + * streak LENGTH, mono + tabular, the largest thing in the row) among muted + * context (real player identity + "built vs" lens), with a SINGLE severity + * accent — tonight's matchup difficulty (step-up amber / step-down green). + * The grade badge is tier-gated (the READ is the paid layer). * * Self-hides when there are no streaks so the landing page never shows an * empty box — the other layers (schedule, game lines, props) carry the @@ -61,39 +64,60 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit // Session 60 (product def) — the PICTURE is free: every streak shows for // every tier. Only an explicit `limit` (landing teaser) caps the list. - // The paid layer is the GRADE, not the data. tierGate still governs the - // hot LISTS (top-3 free) — not streaks. - void tier; const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks; const hidden = streaks.length - visible.length; + // DS4 — the GRADE (the READ) is the paid layer; only analyst/desk see it. + const canSeeGrade = tier === 'analyst' || tier === 'desk'; return (

🔥 STREAKS

-
- {visible.map((s) => ( -
- {/* DS0 — real headshot / team-colored monogram (kills the gray - silhouette). The STREAKS row is a P0 billboard. */} - -
-
- {s.player}{s.team ? ` · ${s.team}` : ''} - {/* Grade letter when the snapshot graded this player (the paid - READ layer; the letter itself is public on the slate). */} - {s.grade && {s.grade}} +
+ {visible.map((s) => { + const diff = s.lens?.difficulty; + // ONE severity accent — tonight's matchup difficulty. + const sev = diff === 'step up' + ? { color: 'var(--amber, #FFB347)', label: '▲ STEP-UP SPOT TONIGHT' } + : diff === 'step down' + ? { color: 'var(--g-a, #00D4A0)', label: '▼ SOFTER SPOT TONIGHT' } + : null; + // The LENS — muted "built vs" context; never the raw streak alone. + const builtVs = Array.isArray(s.lens?.builtVs) && s.lens!.builtVs!.length > 0 + ? `built vs ${s.lens!.builtVs!.slice(0, 3).join(', ')}` + : (s.lens?.read || s.description); + return ( +
+ {/* Real identity — headshot / team-colored monogram (DS0). */} + + + {/* THE HERO — streak length, mono + tabular, the largest figure + in the row. One bold number; everything else demoted. */} +
+ {s.currentStreak} + GAME
STREAK
- {/* THE LENS — the interpreted read, never the raw streak alone. */} -
{s.lens?.read || s.description}
- {s.lens?.difficulty && ( -
- {s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''} + + {/* Identity + the LENS — demoted, muted context. */} +
+
+ {s.player}{s.team ? · {s.team} : null}
- )} +
{s.description}
+
{builtVs}
+
+ + {/* The single severity accent + tier-gated grade badge. */} +
+ {sev && {sev.label}} + {s.grade && ( + canSeeGrade + ? + : GRADE ◆ + )} +
- {s.currentStreak} G -
- ))} + ); + })}
{hidden > 0 && ( @@ -109,20 +133,51 @@ const panelHeading: React.CSSProperties = { color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px', }; const rowStyle: React.CSSProperties = { - display: 'flex', alignItems: 'center', gap: 10, - padding: '8px 10px', borderRadius: 10, + display: 'flex', alignItems: 'center', gap: 14, + padding: '12px 14px', borderRadius: 12, background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)', }; +// THE HERO NUMBER — deliberately the largest font in the row (mono, tabular). +const heroBlock: React.CSSProperties = { + flex: '0 0 auto', display: 'flex', alignItems: 'baseline', gap: 6, + minWidth: 62, +}; +const heroNum: React.CSSProperties = { + fontSize: 38, fontWeight: 800, lineHeight: 1, + color: 'var(--text-primary, #F0F0F4)', fontVariantNumeric: 'tabular-nums', + letterSpacing: '-0.03em', +}; +const heroUnit: React.CSSProperties = { + fontSize: 8.5, fontWeight: 700, lineHeight: 1.05, letterSpacing: '0.1em', + color: 'var(--text-tertiary, #6A6A78)', +}; const playerName: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }; -const streakDesc: React.CSSProperties = { - fontSize: 12, color: 'var(--text-secondary, #9A9AA8)', +const teamTag: React.CSSProperties = { + fontSize: 12, fontWeight: 500, color: 'var(--text-tertiary, #6A6A78)', }; -const badgeStyle: React.CSSProperties = { - flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px', - borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)', +const categoryLine: React.CSSProperties = { + fontSize: 11, color: 'var(--text-secondary, #9A9AA8)', marginTop: 1, +}; +const lensLine: React.CSSProperties = { + fontSize: 11, color: 'var(--text-tertiary, #6A6A78)', marginTop: 2, + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', +}; +const rightRail: React.CSSProperties = { + flex: '0 0 auto', display: 'flex', flexDirection: 'column', + alignItems: 'flex-end', gap: 6, +}; +const sevChip: React.CSSProperties = { + fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em', + padding: '2px 7px', borderRadius: 5, border: '1px solid transparent', + background: 'transparent', whiteSpace: 'nowrap', +}; +const gradeLock: React.CSSProperties = { + fontSize: 10.5, fontWeight: 800, letterSpacing: '0.06em', + color: 'var(--text-tertiary, #6A6A78)', textDecoration: 'none', + border: '1px solid var(--border, #2A2A36)', borderRadius: 4, padding: '2px 6px', }; const upsellStyle: React.CSSProperties = { display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600, diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 9ce58c3..7a7576e 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -69,6 +69,9 @@ export default function GradeResultCard({ }, [replayKey]); const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)'; + // COLOR CONTRACT (Part 1 #3): edge/CLV colored by SIGN. A negative edge must + // NEVER render green — negative = var(--miss), positive/zero = signal-green. + const edgeColor = d.edge != null && d.edge < 0 ? 'var(--miss)' : 'var(--g-a)'; const hasKill = !!d.killConditions && d.killConditions.length > 0; const hasBooks = Array.isArray(d.books) && d.books.length > 0; const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0; @@ -150,7 +153,7 @@ export default function GradeResultCard({ {d.edge != null && ( <> · - {d.edge >= 0 ? '+' : ''}{d.edge}% edge + {d.edge >= 0 ? '+' : ''}{d.edge}% edge )} · @@ -173,11 +176,11 @@ export default function GradeResultCard({ {[ { l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' }, { l: 'LINE', v: d.line, col: 'var(--text-0)' }, - { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' }, + { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? edgeColor : 'var(--text-2)' }, ].map((x, i) => (
{x.l}
-
{x.v}
+
{x.v}
))}
diff --git a/web/src/lib/clvDisplay.js b/web/src/lib/clvDisplay.js new file mode 100644 index 0000000..51bc32d --- /dev/null +++ b/web/src/lib/clvDisplay.js @@ -0,0 +1,45 @@ +/** + * clvDisplay (DS4 · Billboards — CLV reframe, DESIGN-SPEC Part 3 #16). + * + * "Charts that are flat should SAY so." When settled CLV is near-flat (e.g. + * 73 of 74 reads landed at the close), a 7-bar histogram is one giant bar and + * six empty stubs — it reads as BROKEN. The record (55-19, 74% hit) is the + * best data on the site; it must look the MOST premium, never errored. + * + * This is the single pure decision: given the server's `clv_distribution` + * (already n≥20 gated in ledgerService), decide FLAT vs SPREAD. Flat → a + * confident VOICE line. Spread → the real bars. Pure + CommonJS so the .tsx + * consumers (ledger, profile) and Jest share one tested rule. + */ + +// The flat bucket must hold this share of settled CLV for the histogram to be +// declared "flat." 73/74 → 0.986; a genuinely dispersed book → well under. +const FLAT_SHARE_THRESHOLD = 0.6; + +// VOICE-compliant one-liner. No jargon; states the model's actual claim. +const CLV_FLAT_LINE = 'CLV flat — we grade the outcome, not the close.'; + +/** + * @param {Array<{label:string,count:number,side:'beat'|'faded'|'flat'}>|null} dist + * @returns {{ mode:'flat'|'spread'|'none', total:number, flat:number, flatShare:number }} + */ +function clvMode(dist) { + if (!Array.isArray(dist) || dist.length === 0) { + return { mode: 'none', total: 0, flat: 0, flatShare: 0 }; + } + const total = dist.reduce((n, b) => n + (Number(b && b.count) || 0), 0); + if (total <= 0) return { mode: 'none', total: 0, flat: 0, flatShare: 0 }; + const flat = dist + .filter((b) => b && b.side === 'flat') + .reduce((n, b) => n + (Number(b.count) || 0), 0); + const flatShare = flat / total; + const mode = flatShare >= FLAT_SHARE_THRESHOLD ? 'flat' : 'spread'; + return { mode, total, flat, flatShare }; +} + +/** Convenience predicate for the flat branch. */ +function isFlatClv(dist) { + return clvMode(dist).mode === 'flat'; +} + +module.exports = { clvMode, isFlatClv, CLV_FLAT_LINE, FLAT_SHARE_THRESHOLD };