Honesty pass: remove every live fabrication (REMOVE/HIDE only, no feature cut)
Six live untruths corrected — no grade/snapshot/scorer/pipeline touched: 1. /compare — hardcoded Jokic A+/Wembanyama A + fake VERDICT replaced with an honest in-development state; removed from Nav + BottomTabBar (route still resolves, never the sample). Real two-player build is later. 2. Pricing — founder Desk $34.99→$44.99 (matches lib/checkout.js), Analyst $14.99; removed the struck $19.99/$44.99 "regular" numbers and DeskShowcase's stale $34.99. First-100 counter is real (ClaimMeter → Stripe countFounderSeats); no fake "first 50" desk claim added (no such counter exists). 3. FAQ "NexaPay" → Stripe (verified: live checkout is Next→Express→checkout.stripe.com). 4. FAQ + Features "Brier/CLV published from day one" removed (not surfaced yet) — returns when real. Backend Brier compute untouched. 5. MobileEdgeBoard removed from the Slate — its edge% feed was a miscalibrated placeholder (masked >40% as "—"); phones now show the real game cards. 6. Price triplet — never-computed model/EV now derives NO_MODEL (honest absent, MODEL "—" / "NOT PRICED", no verdict) instead of QUARANTINE's false "we suppressed our price / a leg is poisoned" copy. Fixes grade card + LiveHeroProp. Full suite 3833 green, web build exit 0. Tests updated to the new honest contracts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
This commit is contained in:
@@ -1,65 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import TerminalInput from '@/components/vyndr/TerminalInput';
|
||||
|
||||
// Head-to-head player comparison (§6). Sample data for now — real player
|
||||
// resolution wires to /api/players/search + game logs in a later pass.
|
||||
const SAMPLE = {
|
||||
a: { name: 'Nikola Jokić', team: 'DEN', rows: { 'L10 PTS': '28.4', 'L10 REB': '12.1', 'L10 AST': '9.8', 'Usage%': '29.1', 'Grade': 'A+' } },
|
||||
b: { name: 'Victor Wembanyama', team: 'SA', rows: { 'L10 PTS': '26.9', 'L10 REB': '10.4', 'L10 AST': '4.2', 'Usage%': '31.0', 'Grade': 'A' } },
|
||||
};
|
||||
const ROW_KEYS = ['L10 PTS', 'L10 REB', 'L10 AST', 'Usage%', 'Grade'];
|
||||
|
||||
const num = (v: string) => parseFloat(v.replace(/[^\d.]/g, ''));
|
||||
|
||||
// Head-to-head player comparison (§6). IN DEVELOPMENT — real two-player
|
||||
// resolution wires to /api/players/search + live game logs in a later pass.
|
||||
// Until then this surface shows an honest in-development state: NO sample
|
||||
// matchups, NO hardcoded grades, no fabricated verdict.
|
||||
export default function ComparePage() {
|
||||
const [a, setA] = useState('Nikola Jokić');
|
||||
const [b, setB] = useState('Victor Wembanyama');
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 96px' }}>
|
||||
<SectionHead accent="var(--g-a)">▚ HEAD TO HEAD</SectionHead>
|
||||
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 18px' }}>COMPARE</h1>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
||||
<TerminalInput prompt="›" value={a} onChange={setA} placeholder="Player A" />
|
||||
<TerminalInput prompt="›" value={b} onChange={setB} placeholder="Player B" amber />
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr', padding: '14px 16px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, textAlign: 'right' }}>{SAMPLE.a.name} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{SAMPLE.a.team}</span></div>
|
||||
<div className="label" style={{ textAlign: 'center' }}>VS</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800 }}>{SAMPLE.b.name} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{SAMPLE.b.team}</span></div>
|
||||
</div>
|
||||
{ROW_KEYS.map((k) => {
|
||||
const av = SAMPLE.a.rows[k as keyof typeof SAMPLE.a.rows];
|
||||
const bv = SAMPLE.b.rows[k as keyof typeof SAMPLE.b.rows];
|
||||
const isGrade = k === 'Grade';
|
||||
const aWins = !isGrade && num(av) >= num(bv);
|
||||
const bWins = !isGrade && num(bv) >= num(av);
|
||||
return (
|
||||
<div key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr', padding: '11px 16px', borderBottom: '1px solid var(--border)', alignItems: 'center' }}>
|
||||
<div className="mono" style={{ textAlign: 'right', fontSize: 15, fontWeight: 700, color: aWins ? 'var(--g-a)' : 'var(--text-0)' }}>
|
||||
{isGrade ? <GradeBadge grade={av} size="sm" glow /> : av}
|
||||
</div>
|
||||
<div className="label" style={{ textAlign: 'center' }}>{k}</div>
|
||||
<div className="mono" style={{ fontSize: 15, fontWeight: 700, color: bWins ? 'var(--g-a)' : 'var(--text-0)' }}>
|
||||
{isGrade ? <GradeBadge grade={bv} size="sm" glow /> : bv}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="intel-surface scanlines" style={{ marginTop: 16, borderRadius: 10, padding: '16px 18px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div className="intel-surface scanlines" style={{ borderRadius: 10, padding: '22px 20px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
<div className="label" style={{ color: 'rgba(232,255,244,.6)', marginBottom: 6 }}>VYNDR VERDICT</div>
|
||||
<div className="label" style={{ color: 'rgba(232,255,244,.6)', marginBottom: 8 }}>IN DEVELOPMENT</div>
|
||||
<div className="mono" style={{ fontSize: 14, color: '#e8fff4', lineHeight: 1.6 }}>
|
||||
{SAMPLE.a.name} carries the higher floor on scoring, boards, and playmaking — on current form, the edge tilts his way.
|
||||
Head-to-head comparison is being wired to live player game logs. We are not
|
||||
shipping sample matchups here — when the real two-player read is ready it grades
|
||||
from the same pipeline as every other card. Check back soon.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function DeskShowcase() {
|
||||
Claim a Founder Desk →
|
||||
</a>
|
||||
<span className="mono" style={{ fontSize: 12.5, color: 'var(--text-tertiary)' }}>
|
||||
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>$34.99</span> locked for the first 100 · then $44.99
|
||||
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>$44.99</span> locked for the first 100 founders · the rate holds for life
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,8 @@ const TABS: TabDef[] = [
|
||||
];
|
||||
|
||||
const MORE_ITEMS = [
|
||||
{ label: 'Compare', href: '/compare' },
|
||||
// Compare removed from nav — in development, no real feed yet (route still
|
||||
// resolves to an honest in-dev state, never the old sample matchup).
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'The Report', href: '/blog' },
|
||||
{ label: 'Invite Friends', href: '/invite' },
|
||||
|
||||
@@ -9,7 +9,7 @@ const FAQS = [
|
||||
},
|
||||
{
|
||||
q: 'How accurate is the model?',
|
||||
a: 'Check the ledger. Every grade, every result, updated nightly. We don\'t hide misses. Brier score and CLV are tracked from day one and published.',
|
||||
a: 'Check the ledger. Every grade, every result, updated nightly. We don\'t hide misses. Hit rate is published by grade tier once the sample is large enough to mean something.',
|
||||
},
|
||||
{
|
||||
q: 'What sports do you cover?',
|
||||
@@ -21,11 +21,11 @@ const FAQS = [
|
||||
},
|
||||
{
|
||||
q: 'What is the Founder Access price?',
|
||||
a: 'First 100 users lock $14.99/mo for life. After that the price moves to $24.99/mo and never comes back to $14.99. Locked-in pricing carries if you maintain continuous subscription.',
|
||||
a: 'First 100 users lock $14.99/mo for life. When the 100 founder seats are gone, standard pricing applies. Locked-in pricing carries if you maintain continuous subscription.',
|
||||
},
|
||||
{
|
||||
q: 'How is payment processed?',
|
||||
a: 'We use NexaPay. You pay with Visa, Mastercard, Apple Pay, or Google Pay. We never see your card data. We are PCI-out-of-scope.',
|
||||
a: 'We use Stripe. You pay with Visa, Mastercard, Apple Pay, or Google Pay. We never see your card data. We are PCI-out-of-scope.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const FEATURES = [
|
||||
{
|
||||
icon: '⌦',
|
||||
title: 'The honest ledger',
|
||||
body: 'Every grade. Every result. No hiding. No deletion. Brier score and CLV from day one. Public accuracy by tier.',
|
||||
body: 'Every grade. Every result. No hiding. No deletion. Public accuracy by grade tier, once the sample is large enough to mean something.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ const MORE = [
|
||||
{ label: 'Explore', href: '/explore' },
|
||||
// Session 60 (4.4) — discoverable Parlay Lab entry (opens the drawer).
|
||||
{ label: 'Parlay Lab', href: '#parlay', action: 'parlay' as const },
|
||||
{ label: 'Compare', href: '/compare' },
|
||||
// Compare is in development (no real two-player feed yet) — no nav entry until
|
||||
// it renders real data. The route still resolves to an honest in-dev state.
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'The Report', href: '/blog' },
|
||||
{ label: 'Invite', href: '/invite' },
|
||||
|
||||
@@ -68,8 +68,10 @@ const TIERS: TierConfig[] = [
|
||||
{
|
||||
id: 'analyst',
|
||||
name: 'Analyst',
|
||||
// Founder price. First 100 seats lock $14.99 for life (real Stripe-synced
|
||||
// counter via ClaimMeter). No struck "regular" number: the post-founder
|
||||
// price is not wired, and we do not promise a figure that isn't.
|
||||
price: '$14.99',
|
||||
originalPrice: '$19.99',
|
||||
cadence: '/mo',
|
||||
badge: 'Founder Access',
|
||||
headline: 'The full intelligence layer.',
|
||||
@@ -91,11 +93,11 @@ const TIERS: TierConfig[] = [
|
||||
{
|
||||
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the value-showing
|
||||
// story and the single primary CTA on the grid (color contract #9: never
|
||||
// two competing green CTAs). $44.99 regular, $34.99 for founders.
|
||||
// two competing green CTAs). Founder Desk = $44.99 (matches lib/checkout.js).
|
||||
// No struck "regular" number: the post-founder desk price is not wired.
|
||||
id: 'desk',
|
||||
name: 'Desk',
|
||||
price: '$34.99',
|
||||
originalPrice: '$44.99',
|
||||
price: '$44.99',
|
||||
cadence: '/mo',
|
||||
badge: 'Founder Desk',
|
||||
headline: 'The professional terminal. Everything the model knows.',
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useRouter } from 'next/navigation';
|
||||
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey, flattenToEdgeBoard, edgeBoardBreadth } from '@/lib/slateAdapter';
|
||||
import MobileEdgeBoard from '@/components/vyndr/MobileEdgeBoard';
|
||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter';
|
||||
// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no
|
||||
// live games (and it's not a fetch failure) the grid shows REAL data —
|
||||
// yesterday's proven receipts + tomorrow's date-pinned schedule.
|
||||
@@ -1205,34 +1204,22 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
|
||||
|
||||
{(() => {
|
||||
// Build each game's card once, then present two ways (Rev 3, screen 01):
|
||||
// MOBILE = Design's FLAT edge-ranked board (all props, one list, sorted
|
||||
// by edge); DESKTOP = the game-grouped cards. Same data, different IA —
|
||||
// toggled by width in globals.css (.edge-board-mobile / -desktop).
|
||||
// The game-grouped cards, shown identically on mobile and desktop.
|
||||
// The FLAT mobile "edge board" was removed (honesty pass): its edge%
|
||||
// feed was a miscalibrated placeholder, so it renders nowhere until a
|
||||
// real edge feed exists. Phones now show the same real game cards.
|
||||
const cards = orderedGames.map((g) => slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex));
|
||||
const edgeRows = flattenToEdgeBoard(cards);
|
||||
const breadth = edgeBoardBreadth(edgeRows, cards);
|
||||
const hasBoard = edgeRows.length > 0;
|
||||
return (
|
||||
<>
|
||||
{hasBoard && (
|
||||
<div className="edge-board-mobile" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<MobileEdgeBoard rows={edgeRows} breadth={breadth} />
|
||||
</div>
|
||||
)}
|
||||
{/* Cards: on mobile they hide ONLY when the flat board is present
|
||||
(an ungraded slate still shows the game cards on phones). */}
|
||||
<div className={hasBoard ? 'edge-board-desktop' : ''} style={{ display: 'grid', gap: 16 }}>
|
||||
{cards.map((card, i) => (
|
||||
<VyndrGameCard
|
||||
key={`${card.sport}-${card.away.abbr}-${card.home.abbr}-${i}`}
|
||||
game={card}
|
||||
preferredBooks={preferredBooks}
|
||||
onOpen={() => router.push('/scan')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{cards.map((card, i) => (
|
||||
<VyndrGameCard
|
||||
key={`${card.sport}-${card.away.abbr}-${card.home.abbr}-${i}`}
|
||||
game={card}
|
||||
preferredBooks={preferredBooks}
|
||||
onOpen={() => router.push('/scan')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
@@ -229,6 +229,9 @@ export default function PriceTriplet({
|
||||
|
||||
const modelWithheld = state === STATES.QUARANTINE;
|
||||
const modelLocked = state === STATES.NO_VERDICT_LOCKED;
|
||||
// Honesty pass: model never computed → absent leg, honest note, NO verdict
|
||||
// (verdictFor returns null for this state), never QUARANTINE's "suppressed" copy.
|
||||
const modelAbsent = state === STATES.NO_MODEL;
|
||||
// GREEN = TAKEABLE EDGE ONLY. The model leg is green on exactly one state.
|
||||
const modelColor = state === STATES.VALUE ? 'var(--g-a)' : 'var(--text-0)';
|
||||
const verdict = verdictFor(state, vsFair, Number.isFinite(ev as number) ? (ev as number) : null);
|
||||
@@ -251,7 +254,7 @@ export default function PriceTriplet({
|
||||
<Leg
|
||||
label="MODEL"
|
||||
value={modelWithheld ? null : model}
|
||||
note={modelWithheld ? 'WITHHELD' : modelLocked ? 'ANALYST' : 'OUR PRICE'}
|
||||
note={modelWithheld ? 'WITHHELD' : modelLocked ? 'ANALYST' : modelAbsent ? 'NOT PRICED' : 'OUR PRICE'}
|
||||
color={modelColor}
|
||||
locked={modelLocked}
|
||||
compact={compact}
|
||||
|
||||
@@ -52,6 +52,13 @@ const STATES = Object.freeze({
|
||||
// Not a verdict — a display state. The model leg exists and is honest, the
|
||||
// viewer just isn't entitled to it (free tier). Book + fair render normally.
|
||||
NO_VERDICT_LOCKED: 'NO_VERDICT_LOCKED',
|
||||
// Honesty pass: the model price was never COMPUTED (the EV layer isn't
|
||||
// producing p_win/model_odds yet) — NOT quarantined, NOT locked. The model
|
||||
// leg renders absent ('—'), book + fair stand, and NO verdict is claimed.
|
||||
// This exists so a never-priced row never wears QUARANTINE's "we suppressed
|
||||
// our own price / a leg is poisoned" copy, which asserts a deliberate act
|
||||
// that did not happen.
|
||||
NO_MODEL: 'NO_MODEL',
|
||||
});
|
||||
|
||||
/** Strict numeric parse — `Number(null) === 0` is the fabrication bug this
|
||||
@@ -107,9 +114,11 @@ function deriveValueState(row = {}) {
|
||||
|
||||
const model = num(row.model_odds);
|
||||
const ev = num(row.ev_pct);
|
||||
// Without a model price or an EV there is no verdict to render — treat as a
|
||||
// withheld model leg rather than asserting "no edge" we didn't compute.
|
||||
if (model == null || ev == null) return STATES.QUARANTINE;
|
||||
// Without a model price or an EV there is no verdict to render. This is an
|
||||
// absent leg, NOT a quarantine: we never computed it, so we must not claim we
|
||||
// "suppressed" or "poisoned" it. Book + fair still stand; the model leg goes
|
||||
// honestly absent and no verdict is asserted.
|
||||
if (model == null || ev == null) return STATES.NO_MODEL;
|
||||
|
||||
// 3. The three real verdicts.
|
||||
if (isValue(book, ev)) return STATES.VALUE;
|
||||
@@ -130,6 +139,7 @@ function valueStateColor(state) {
|
||||
case STATES.NO_EDGE:
|
||||
case STATES.REFUSAL:
|
||||
case STATES.NO_VERDICT_LOCKED:
|
||||
case STATES.NO_MODEL:
|
||||
default:
|
||||
return 'var(--text-2)';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user