Files
vyndr/web/src/components/SoccerGradeResult.tsx
T
builtbykev 0997334f8b Order B: retire edge_pct display. Promotion gate NOT passed — no flip.
THE PROMOTION WAS NOT PERFORMED. Champion grade path byte-identical (diff empty
across intelligence/, gradeSlateService, snapshotService). Projection, p_win and
the CLV instrument untouched.

REVIEW ZERO IS A GATE AND THREE OF FOUR PREREQUISITES FAIL:
  0.1 scores are ESTIMATED priors from the founding spec, not measured. The
      premise's cited values are not in the code either — the module holds
      nba:points .80 and mlb:total_bases .55; there is no NBA 0.72 and no WNBA
      score at all.
  0.2 VERSION-BOUNDARY TAG DID NOT LAND — config/modelEras.js has zero shading
      references. It was deliberately not applied twice (nothing had been
      promoted) and reported both times. The order's own rule says STOP.
  0.3 NO ROLLBACK FLAG EXISTS — zero occurrences of SHADING_ENABLED /
      EDGE_SHADING / shadingEnabled anywhere in src/.
  0.4 takeable tags DID land (migration 034, 1246/1254 rows). PASS.

AND THE APPROVED DELTA DOES NOT MATCH THE MEASURED ONE. Approved: 43.6% of
grades re-letter, efficient markets tighten and soft hold. Measured on all 1250
live rows: 97.4% change (1217), 79.8% move UP, 17.6% down, resulting in 79.0%
A-family (MLB 93.4%) against the champion's 0.2%. And rows_actually_shaded = 0
of 1250 — 96.5% of markets are unscored (f=1) and the one scored market present
is the anchor (f=1.0 by construction). The entire re-letter comes from switching
to edge-vs-fixed-bar grading, NOT from efficiency shading, which is inert on
this board. That is an unapproved grading-basis change riding along, which the
order's own "no new scaling changes riding along" guardrail forbids.

Flipping would re-letter 97.4% of an append-only public record, move 79.8% of
grades UP and mint A's on 79% of the board, on a letter whose measured
correlation with outcomes is r ~ 0.005 — the exact scenario the permanent
founder ruling forbids.

SHIPPED — ORDER B (independent of the promotion, and a live falsehood):
edge_pct display retired from GradeResultCard (confidence strip, EDGE stat cell
now honest-absent, alt-ladder rung) and SoccerGradeResult. DeskShowcase kept
(already honest). Computation and the board's signed-edge sort fallback SURVIVE
— deleting them would re-break the sort fixed on 2026-07-29; a test asserts all
three survive and the sort still orders agrees -> disagrees -> absent.

Fixed two build-breakers the retirement caused (orphaned edgeColor import,
orphaned edge_pct destructure; edge_pct stays on the props contract). Two
pre-existing tests superseded rather than deleted: they asserted the edge figure
is sign-coloured, and now assert the stronger property that no edge percentage
renders at all.

Floor: 314 suites / 3908 tests green (9 new), web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 00:57:57 -04:00

353 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useMemo } from 'react';
/**
* Soccer result card — renders an analyze/prop response with
* soccer-specific visual treatment. We can't surface raw feature
* values (the backend response carries only `reasoning.summary` +
* `kill_conditions_triggered` per the engine1 → legacy adapter), so
* we parse the summary for known soccer-signal phrases and surface
* each as a colored chip above the prose.
*
* Free-tier responses already arrive gated (the Session 7h
* `applyTierGating` redacts `reasoning` and `kill_conditions`); we
* just need to detect the `tier_gated` / `locked` markers and show
* an upgrade CTA over the blurred content.
*/
interface KillCondition {
code: string;
reason: string;
locked?: boolean;
}
interface Reasoning {
summary?: string;
steps?: unknown;
locked?: boolean;
}
export interface SoccerGradeResultProps {
player: string;
stat_type: string;
line: number;
direction: 'over' | 'under';
league: string;
grade: string;
confidence?: number;
edge_pct?: number;
reasoning?: Reasoning;
kill_conditions_triggered?: KillCondition[];
tier_gated?: boolean;
upgrade_hint?: string;
onUpgradeClick?: () => void;
onClose?: () => void;
}
type SignalTone = 'positive' | 'caution' | 'warning' | 'neutral';
interface ParsedSignal {
icon: string;
label: string;
detail: string;
tone: SignalTone;
}
const SIGNAL_TONE_STYLE: Record<SignalTone, { color: string; bg: string; border: string }> = {
positive: { color: 'var(--grade-a)', bg: 'rgba(0,200,150,0.08)', border: 'rgba(0,200,150,0.40)' },
caution: { color: 'var(--grade-c, #B8BCC8)', bg: 'rgba(255,179,71,0.08)', border: 'rgba(255,179,71,0.40)' },
warning: { color: 'var(--grade-d, #ff5a5a)', bg: 'rgba(255,90,90,0.08)', border: 'rgba(255,90,90,0.40)' },
neutral: { color: 'var(--text-secondary)', bg: 'transparent', border: 'var(--border)' },
};
// Pattern-match the concrete sentences `buildSoccerReasoningLines`
// emits in src/services/intelligence/analyzeViaEngine1.js. Order
// matters — earlier patterns win when multiple match the same line.
const SIGNAL_PATTERNS: Array<(line: string) => ParsedSignal | null> = [
(line) => {
const m = line.match(/scores ([\d.]+) goals per 90 minutes/i);
if (m) return { icon: '⚽', label: 'Goals / 90', detail: `${m[1]}`, tone: 'positive' };
return null;
},
(line) => {
const m = line.match(/Expected goals \(xG\): ([\d.]+) per 90 — (.+)/i);
if (m) {
const trend = m[2].toLowerCase();
const tone: SignalTone = trend.includes('regression') ? 'caution'
: trend.includes('breakout') ? 'positive' : 'neutral';
return { icon: '📊', label: 'xG / 90', detail: `${m[1]}${m[2]}`, tone };
}
return null;
},
(line) => {
if (/Designated penalty taker/i.test(line)) {
return { icon: '🎯', label: 'Penalty Taker', detail: '+0.15 goals/90 boost', tone: 'positive' };
}
return null;
},
(line) => {
if (/Direct free-kick specialist/i.test(line)) {
return { icon: '🏹', label: 'Free-Kick Taker', detail: 'shot/goal probability boost', tone: 'positive' };
}
return null;
},
(line) => {
if (/corner taker/i.test(line)) {
return { icon: '⛳', label: 'Corner Taker', detail: 'assist probability boost', tone: 'positive' };
}
return null;
},
(line) => {
const m = line.match(/Match at ([\d,]+)ft altitude\.\s*(.+)/i);
if (m) {
const isAcclimated = /acclimated host/i.test(m[2]);
return {
icon: '🏔️',
label: 'Altitude',
detail: `${m[1]}ft — ${isAcclimated ? 'host acclimated' : 'visitor risk'}`,
tone: isAcclimated ? 'neutral' : 'warning',
};
}
return null;
},
(line) => {
const m = line.match(/(.+?) averages ([\d.]+) cards per match/i);
if (m) {
const cardsPerGame = parseFloat(m[2]);
const tone: SignalTone = cardsPerGame >= 5 ? 'caution' : 'neutral';
return { icon: '🟨', label: `Referee: ${m[1].trim()}`, detail: `${m[2]} cards/match`, tone };
}
return null;
},
(line) => {
const m = line.match(/Averaging only ([\d.]+) minutes per match/i);
if (m) return { icon: '⏱️', label: 'Minutes', detail: `${m[1]}/90 — under-line discount`, tone: 'caution' };
return null;
},
(line) => {
const m = line.match(/(.+?) concedes ([\d.]+) goals per game/i);
if (m) {
const conceded = parseFloat(m[2]);
const tone: SignalTone = conceded <= 0.8 ? 'warning' : conceded >= 1.6 ? 'positive' : 'neutral';
return { icon: '🛡️', label: `Defense: ${m[1].trim()}`, detail: `${m[2]} GA/match`, tone };
}
return null;
},
(line) => {
const m = line.match(/Tournament pedigree: (\d+) career World Cup goals/i);
if (m) return { icon: '🏆', label: 'WC Pedigree', detail: `${m[1]} career goals`, tone: 'positive' };
return null;
},
];
function parseSignals(summary: string | undefined): ParsedSignal[] {
if (!summary) return [];
const out: ParsedSignal[] = [];
// The buildSoccerReasoningLines output is a single `lines.join(' ')`,
// so split on period+space and trim. Some sentences contain periods
// (e.g. "0.67 goals per 90"), so re-match conservatively.
const fragments = summary.split(/(?<=\.)\s+(?=[A-Z⚽📊🎯🏹⛳🏔️🟨⏱️🛡️🏆])/);
for (const frag of fragments) {
for (const fn of SIGNAL_PATTERNS) {
const sig = fn(frag);
if (sig) {
out.push(sig);
break;
}
}
}
return out;
}
function gradeColor(grade: string): string {
const g = (grade || '').trim().toUpperCase().charAt(0);
if (g === 'A') return 'var(--grade-a)';
if (g === 'B') return 'var(--grade-b, #F0F0F0)';
if (g === 'C') return 'var(--grade-c, #B8BCC8)';
return 'var(--grade-d, #ff5a5a)';
}
export default function SoccerGradeResult(props: SoccerGradeResultProps) {
const {
// ORDER B: `edge_pct` stays on the props CONTRACT (callers still pass it and the
// backend still computes it) but is no longer destructured for display.
player, stat_type, line, direction, league, grade, confidence,
reasoning, kill_conditions_triggered, tier_gated, upgrade_hint,
onUpgradeClick, onClose,
} = props;
const signals = useMemo(() => parseSignals(reasoning?.summary), [reasoning?.summary]);
const color = gradeColor(grade);
const locked = !!tier_gated || !!reasoning?.locked;
const kills = Array.isArray(kill_conditions_triggered) ? kill_conditions_triggered : [];
return (
<article
className="surface diagonal-cut"
style={{
padding: 24,
border: `1px solid ${color}`,
background: 'var(--bg-elevated)',
borderRadius: 8,
marginTop: 16,
position: 'relative',
}}
data-testid="soccer-grade-result"
>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
position: 'absolute', top: 8, right: 12,
background: 'transparent', border: 0, cursor: 'pointer',
fontSize: 18, color: 'var(--text-tertiary)',
}}
>
×
</button>
)}
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{player}</div>
<div
className="mono"
style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4, textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{direction.toUpperCase()} {line.toFixed(1)} {stat_type.replace(/_/g, ' ')} · {league.toUpperCase()}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 44, fontWeight: 800, color, lineHeight: 1, letterSpacing: '-0.04em' }}>
{grade}
</div>
{typeof confidence === 'number' && (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>
{confidence.toFixed(0)}% conf
{/* ORDER B (2026-07-31) — "% edge" retired. edge_pct is price-free and
line-scaled; calling it an edge is false at any scale. The prop is
still passed and still computed — only the claim is withdrawn. */}
</div>
)}
</div>
</header>
{signals.length > 0 && !locked && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
{signals.map((sig, idx) => {
const style = SIGNAL_TONE_STYLE[sig.tone];
return (
<div
key={`${sig.label}-${idx}`}
style={{
padding: '8px 12px',
border: `1px solid ${style.border}`,
background: style.bg,
borderRadius: 6,
fontSize: 12,
display: 'flex',
alignItems: 'baseline',
gap: 6,
maxWidth: '100%',
}}
>
<span aria-hidden style={{ fontSize: 14 }}>{sig.icon}</span>
<span
className="mono"
style={{
color: style.color,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
fontSize: 10,
}}
>
{sig.label}
</span>
<span style={{ color: 'var(--text-primary)' }}>{sig.detail}</span>
</div>
);
})}
</div>
)}
{!locked && reasoning?.summary && (
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: 16 }}>
{reasoning.summary}
</p>
)}
{locked && (
<div
style={{
padding: 20,
border: '1px dashed var(--border)',
borderRadius: 6,
background: 'rgba(0,0,0,0.20)',
textAlign: 'center',
marginBottom: 16,
}}
>
<div
className="mono"
style={{
filter: 'blur(4px)',
userSelect: 'none',
color: 'var(--text-tertiary)',
fontSize: 13,
marginBottom: 16,
}}
aria-hidden
>
Goals/90: 0.67 · 📊 xG: 0.52 overperforming · 🏔 altitude 7,349ft · 🟨 ref 4.7 cards/match · 🎯 penalty taker
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
{upgrade_hint || 'Unlock full intelligence — xG regression, altitude, referee, set-piece role.'}
</p>
<button
type="button"
onClick={onUpgradeClick}
className="btn-primary"
style={{ padding: '8px 18px', fontSize: 13 }}
>
Unlock full analysis
</button>
</div>
)}
{kills.length > 0 && (
<section style={{ marginTop: 8 }}>
<h3
className="mono"
style={{ fontSize: 10, color: 'var(--grade-d, #ff5a5a)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}
>
Kill conditions ({kills.length})
</h3>
<ul style={{ display: 'grid', gap: 6 }}>
{kills.map((k, idx) => (
<li
key={`${k.code}-${idx}`}
style={{
padding: '6px 10px',
border: '1px solid rgba(255,90,90,0.30)',
background: 'rgba(255,90,90,0.04)',
borderRadius: 4,
fontSize: 12,
}}
>
<span className="mono" style={{ color: 'var(--grade-d, #ff5a5a)', fontWeight: 700, marginRight: 6 }}>
{k.code}
</span>
<span style={{ color: 'var(--text-secondary)' }}>{k.reason}</span>
</li>
))}
</ul>
</section>
)}
</article>
);
}