P0-3b/c: consensus grouped by player + ledger cards nest alt lines as a ladder

Completes P0-3 across all three surfaces:
- CONSENSUS VS MODEL (MarketBreadth): dedupe by player+market so Ben Williamson's
  alt-line variants show as ONE consensus row (no per-market cap — a consensus
  table just shouldn't repeat a player).
- LEDGER cards: group by player+market via groupIntoLadders — Alec Bohm's
  strikeout ladder (U1.6/U1.3/O1.5) is now ONE card with the rungs nested (each
  its own side/line + tier-colored grade), not three separate cards.
- playerGrouping reads player OR player_name (ledger rows use player_name) —
  regression-tested so the ledger doesn't silently empty.

The Alt Line Ladder shape is what /pricing already demos; the record now uses it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-17 01:07:28 -04:00
parent 55f5cdc57d
commit db61876b2f
4 changed files with 49 additions and 12 deletions
+9
View File
@@ -35,6 +35,15 @@ describe('dedupeLeaders', () => {
expect(out).toHaveLength(1);
});
test('reads player_name too (ledger rows) — not just player', () => {
const out = dedupeLeaders([
{ player_name: 'Alec Bohm', stat: 'strikeouts', line: 1.6 },
{ player_name: 'Alec Bohm', stat: 'strikeouts', line: 1.3 },
]);
expect(out).toHaveLength(1);
});
test('empty / malformed → empty, no throw', () => {
expect(dedupeLeaders(null)).toEqual([]);
expect(dedupeLeaders([{}])).toEqual([]);
+25 -3
View File
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { GradePill } from '@/components/GradeCard';
import { groupIntoLadders } from '@/lib/playerGrouping';
import { gradeColor } from '@/lib/vyndrTokens';
import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
@@ -169,8 +171,8 @@ export default function LedgerPage() {
<EmptyLedger tab={tab} />
) : (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{rows.map((row, i) => (
<LedgerCard key={row.id} row={row} index={i} />
{groupIntoLadders(rows).map((g, i) => (
<LedgerCard key={(g.primary as LedgerRow).id} row={g.primary as LedgerRow} ladder={g.ladder as LedgerRow[]} index={i} />
))}
</div>
)}
@@ -326,8 +328,10 @@ function ClvChip({ row }: { row: LedgerRow }) {
);
}
function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
function LedgerCard({ row, index, ladder }: { row: LedgerRow; index: number; ladder?: LedgerRow[] }) {
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
const rungs = Array.isArray(ladder) ? ladder : [row];
const isLadder = rungs.length > 1;
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 }}>
@@ -351,9 +355,27 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
<ArchetypeBadge archetype={row.archetype} size="sm" showDesc />
</div>
)}
{/* P0-3 — ONE card per player+market. Multiple lines nest as a ladder of
rungs (each its own side/line + grade) instead of N separate cards. */}
{isLadder ? (
<>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 6 }}>
{row.stat.replace(/_/g, ' ')} <span style={{ color: 'var(--text-tertiary)' }}>· {rungs.length} lines</span>
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
{rungs.map((rung) => (
<span key={rung.id} className="mono" style={{ fontSize: 10.5, display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 8px', borderRadius: 6, background: 'var(--bg-2)', border: '1px solid var(--border)' }}>
{rung.side}{rung.line}
<span style={{ fontWeight: 800, color: gradeColor(rung.grade) }}>{rung.grade}</span>
</span>
))}
</div>
</>
) : (
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
)}
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
+5 -1
View File
@@ -16,6 +16,7 @@
import SectionHead from './SectionHead';
import { edgeColor } from '@/lib/colorContract';
import { dedupeLeaders } from '@/lib/playerGrouping';
export interface BreadthRow {
player?: string;
@@ -57,7 +58,10 @@ export default function MarketBreadth({
title?: string;
max?: number;
}) {
const rows = (Array.isArray(items) ? items : []).slice(0, Math.max(0, max));
// P0-3 — group by player+market so a player's alt-line ladder shows as ONE
// consensus row (the audit's Ben Williamson ×5). No per-market cap here — a
// consensus table isn't a ranked board, it just shouldn't repeat a player.
const rows = dedupeLeaders(Array.isArray(items) ? items : [], 0).slice(0, Math.max(0, max));
if (rows.length === 0) return null; // self-hide — no honest consensus to show
return (
+7 -5
View File
@@ -37,8 +37,9 @@ function dedupeLeaders(rows, perMarketCap = 4) {
const marketCount = new Map();
const out = [];
for (const r of Array.isArray(rows) ? rows : []) {
if (!r || !r.player) continue;
const key = playerMarketKey(r.player, r.stat);
const who = r.player || r.player_name;
if (!r || !who) continue;
const key = playerMarketKey(who, r.stat);
if (seen.has(key)) continue; // one row per player+market
const fam = marketFamily(r.stat);
const n = marketCount.get(fam) || 0;
@@ -64,10 +65,11 @@ function groupIntoLadders(rows, betterOf) {
const byKey = new Map();
const order = [];
for (const r of Array.isArray(rows) ? rows : []) {
if (!r || !r.player) continue;
const key = playerMarketKey(r.player, r.stat);
const who = r.player || r.player_name;
if (!r || !who) continue;
const key = playerMarketKey(who, r.stat);
if (!byKey.has(key)) {
byKey.set(key, { player: r.player, stat: r.stat, market: marketFamily(r.stat), primary: r, ladder: [r] });
byKey.set(key, { player: who, stat: r.stat, market: marketFamily(r.stat), primary: r, ladder: [r] });
order.push(key);
} else {
const g = byKey.get(key);