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:
@@ -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([]);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
|
||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
{/* 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. */}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user