Files
vyndr/web/src/app/u/[handle]/PublicProfile.tsx
T
builtbykev 45bafbc01a DS4 (design): billboards — STREAKS row, grade reveal, CLV reframe, /u profile
P0 billboards (the timeline is customer #1). Pixel-level craft: one bold hero
among muted context, real entities from DS0.

- STREAKS row: rebuilt from a log line into a Bloomberg alert. Streak LENGTH is
  now the mono/tabular HERO (38px, the largest figure in the row); real
  PlayerAvatar identity; muted "built vs [opponents]" lens; ONE severity accent
  (step-up amber / step-down green); grade badge tier-gated (the READ is paid).
- Grade reveal: edge is now sign-colored — negative edge uses var(--miss),
  never green (color contract #3). Fixed in BOTH the confidence strip and the
  MODEL/LINE/EDGE row; that row is now mono + tabular. Letter stays the hero.
- CLV reframe: new pure lib/clvDisplay.js (clvMode flat/spread/none). A near-
  flat distribution (73/74) now renders a confident VOICE line — "CLV flat — we
  grade the outcome, not the close" — instead of a broken-looking histogram;
  bars show only on real spread.
- /u profile: hit% is the bold record hero (56px mono tabular) + beat-close
  secondary; honest CLV-VERIFIED badge (only when closing value is tracked);
  real PlayerAvatar identity on cards; OG image elevated to carry the real
  record at 1200x630 social crop (graceful tagline fallback).

Tests: +23 (tests/unit/ds4Billboards.test.js). Full suite green (2732).
web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:25:57 -04:00

285 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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.
*
* DATA SEMANTICS: every row here is a REAL settled ledger entry — outcome,
* actual value, closing-line value. Nothing curated: publishing shows the
* ENTIRE settled record, misses included. The header NEVER renders a
* percentage under min_sample (20) settles — it shows RECORD BUILDING.
*
* Unknown and unpublished handles arrive as the same 404 — the page renders
* one indistinguishable not-found state for both.
*/
interface ProfileRow {
id: string;
player_name: string;
sport: string;
stat: string;
line: number;
side: 'over' | 'under';
locked_odds?: string | null;
book?: string | null;
grade: string;
model_value?: number | null;
game_date: string;
closing_line?: number | null;
clv?: number | null;
clv_result?: 'beat' | 'faded' | 'flat' | null;
outcome?: 'hit' | 'miss' | 'push' | null;
actual_value?: number | null;
revised_from_grade?: string | null;
}
interface ProfileAggregate {
settled: number;
hits: number;
misses: number;
pushes: number;
hit_pct: number | null;
clv_sample: number;
clv_beat: number;
beat_close_pct: number | null;
pending: number;
}
interface ProfilePayload {
handle: string;
aggregate: ProfileAggregate | null;
entries: ProfileRow[];
min_sample?: number;
}
const SPORT_COLOR: Record<string, string> = {
nba: '#E94B3C',
mlb: '#1E90FF',
wnba: '#FFB347',
soccer: '#7BC96F',
};
export default function PublicProfile({ handle }: { handle: string }) {
const [data, setData] = useState<ProfilePayload | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'notfound'>('loading');
useEffect(() => {
let active = true;
setState('loading');
fetch(`/api/profiles/${encodeURIComponent(handle)}`)
.then(async (r) => {
if (!active) return;
if (!r.ok) { setState('notfound'); return; }
const d = await r.json();
if (!active) return;
setData(d);
setState('ready');
})
.catch(() => { if (active) setState('notfound'); });
return () => { active = false; };
}, [handle]);
if (state === 'loading') {
return (
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading record</p>
</section>
);
}
if (state === 'notfound' || !data) {
// Same state for unknown AND unpublished — the API never tells us which.
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '64px 16px 120px', textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber, #FFB347)', marginBottom: 10 }}>
NO RECORD HERE
</p>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>This profile does not exist or is not published.</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
VYNDR ledgers are private by default. A record only appears here when its owner publishes it.
</p>
</section>
);
}
const agg = data.aggregate;
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
<header style={{ marginBottom: 20 }}>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
PUBLIC LEDGER
</p>
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
@{data.handle}
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
Every settled read. Wins and misses. Nothing curated.
</p>
</header>
{/* Record header — never a percentage under min_sample settles. */}
<RecordHeader agg={agg} minSample={minSample} />
{data.entries.length === 0 ? (
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
NO SETTLED READS YET
</p>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Reads land here as they settle against real results.
</p>
</div>
) : (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{data.entries.map((row, i) => (
<ProfileCard key={row.id} row={row} index={i} />
))}
</div>
)}
</section>
);
}
/**
* 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 (
<div
className="surface diagonal-cut"
style={{ padding: 24, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
>
{ready && agg ? (
<>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
RECORD · LAST 30D
</span>
{clvVerified && (
<span className="mono" style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', border: '1px solid var(--g-a, #00D4A0)', borderRadius: 999, padding: '3px 10px', background: 'color-mix(in srgb, var(--g-a, #00D4A0) 12%, transparent)' }}>
CLV-VERIFIED
</span>
)}
</div>
{/* HERO figures — hit rate is the largest thing on the page. */}
<div style={{ display: 'flex', gap: 40, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<div className="mono" style={{ fontSize: 56, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.03em', color: 'var(--g-a, #00D4A0)', fontVariantNumeric: 'tabular-nums' }}>
{agg.hit_pct}%
</div>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
HIT RATE · {agg.hits}-{agg.misses}
</div>
</div>
{agg.beat_close_pct != null && (
<div>
<div className="mono" style={{ fontSize: 40, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.02em', color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{agg.beat_close_pct}%
</div>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
BEAT CLOSE
</div>
</div>
)}
<div style={{ marginLeft: 'auto' }}>
<div className="mono" style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-tertiary)', fontVariantNumeric: 'tabular-nums' }}>
{agg.pending} pending
</div>
</div>
</div>
</>
) : (
<div>
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
RECORD BUILDING
</p>
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
Percentages render at {minSample} settled reads.
{agg && (
<span className="mono" style={{ color: 'var(--text-primary)' }}>
{' '}{agg.settled} settled · {agg.pending} pending
</span>
)}
</p>
</div>
)}
</div>
);
}
function OutcomeChip({ row }: { row: ProfileRow }) {
if (!row.outcome) {
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
}
const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
: row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)';
const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : ' PUSH';
return (
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
</span>
);
}
function ClvChip({ row }: { row: ProfileRow }) {
if (!row.clv_result || row.clv == null) return null;
const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)'
: row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
return (
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
</span>
);
}
function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
return (
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
{row.sport.toUpperCase()}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{row.revised_from_grade && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
{row.revised_from_grade}
</span>
)}
<GradePill grade={row.grade} />
</span>
</div>
{/* Real entity identity (DS0) — headshot / team-colored monogram. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<PlayerAvatar name={row.player_name} sport={row.sport} size={34} />
<div style={{ minWidth: 0 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', margin: 0 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
</div>
</div>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
<OutcomeChip row={row} />
<ClvChip row={row} />
</div>
</article>
);
}