From db61876b2f03a17102359f4defb8bf5ae9e6b55c Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 17 Jul 2026 01:07:28 -0400 Subject: [PATCH] P0-3b/c: consensus grouped by player + ledger cards nest alt lines as a ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tests/unit/playerGrouping.test.js | 9 ++++++ web/src/app/ledger/page.tsx | 34 ++++++++++++++++++---- web/src/components/vyndr/MarketBreadth.tsx | 6 +++- web/src/lib/playerGrouping.js | 12 ++++---- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/tests/unit/playerGrouping.test.js b/tests/unit/playerGrouping.test.js index 05b67cb..988dfae 100644 --- a/tests/unit/playerGrouping.test.js +++ b/tests/unit/playerGrouping.test.js @@ -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([]); diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 2c46a37..af923e0 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -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() { ) : (
- {rows.map((row, i) => ( - + {groupIntoLadders(rows).map((g, i) => ( + ))}
)} @@ -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 (
@@ -351,9 +355,27 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
)} -

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

+ {/* 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 ? ( + <> +

+ {row.stat.replace(/_/g, ' ')} · {rungs.length} lines +

+
+ {rungs.map((rung) => ( + + {rung.side}{rung.line} + {rung.grade} + + ))} +
+ + ) : ( +

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

+ )}

{row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {/* model_value is MODEL output — always labeled, never blended with market numbers. */} diff --git a/web/src/components/vyndr/MarketBreadth.tsx b/web/src/components/vyndr/MarketBreadth.tsx index 6c36c87..598b47f 100644 --- a/web/src/components/vyndr/MarketBreadth.tsx +++ b/web/src/components/vyndr/MarketBreadth.tsx @@ -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 ( diff --git a/web/src/lib/playerGrouping.js b/web/src/lib/playerGrouping.js index f8a599b..ca77852 100644 --- a/web/src/lib/playerGrouping.js +++ b/web/src/lib/playerGrouping.js @@ -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);