diff --git a/tests/unit/playerGrouping.test.js b/tests/unit/playerGrouping.test.js new file mode 100644 index 0000000..05b67cb --- /dev/null +++ b/tests/unit/playerGrouping.test.js @@ -0,0 +1,70 @@ +'use strict'; + +// P0-3 — player/market grouping. Same player must present as ONE row per market +// family (alt lines nested), and no single market may flood a ranked list. + +const { dedupeLeaders, groupIntoLadders, marketFamily } = require('../../web/src/lib/playerGrouping'); + +describe('dedupeLeaders', () => { + test("collapses a player's alt-line ladder to ONE leaderboard row (Bohm strikeouts)", () => { + const rows = [ + { player: 'Alec Bohm', stat: 'strikeouts', line: 1.6, side: 'U', grade: 'B' }, + { player: 'Alec Bohm', stat: 'strikeouts', line: 1.3, side: 'U', grade: 'B' }, + { player: 'Alec Bohm', stat: 'strikeouts', line: 1.5, side: 'O', grade: 'B' }, + { player: 'Mike Trout', stat: 'hits', line: 0.5, side: 'O', grade: 'A' }, + ]; + const out = dedupeLeaders(rows); + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ player: 'Alec Bohm', line: 1.6 }); // first (best-ranked) kept + expect(out[1].player).toBe('Mike Trout'); + }); + + test('caps a flooding market so 9 identical stolen_bases rows do not dominate', () => { + const rows = Array.from({ length: 9 }, (_, i) => ({ player: `Runner ${i}`, stat: 'stolen_bases', line: 0.5, side: 'U', grade: 'B' })); + rows.push({ player: 'Slugger', stat: 'home_runs', line: 0.5, side: 'O', grade: 'A' }); + const out = dedupeLeaders(rows, 4); + expect(out.filter((r) => r.stat === 'stolen_bases')).toHaveLength(4); // capped + expect(out.some((r) => r.stat === 'home_runs')).toBe(true); // other markets survive + }); + + test('name variants merge (accents/nicknames) — no double entry', () => { + const out = dedupeLeaders([ + { player: 'José Ramírez', stat: 'total_bases', line: 1.5 }, + { player: 'Jose Ramirez', stat: 'total_bases', line: 2.5 }, + ]); + expect(out).toHaveLength(1); + }); + + test('empty / malformed → empty, no throw', () => { + expect(dedupeLeaders(null)).toEqual([]); + expect(dedupeLeaders([{}])).toEqual([]); + }); +}); + +describe('groupIntoLadders', () => { + test('nests alt lines under one player+market entry, rungs sorted by line', () => { + const groups = groupIntoLadders([ + { player: 'Alec Bohm', stat: 'strikeouts', line: 1.6, grade: 'B' }, + { player: 'Alec Bohm', stat: 'strikeouts', line: 1.3, grade: 'A' }, + { player: 'Alec Bohm', stat: 'strikeouts', line: 1.5, grade: 'B' }, + ], (a, b) => (b.grade < a.grade ? b : a)); // pick the best grade as primary + expect(groups).toHaveLength(1); + expect(groups[0].ladder.map((r) => r.line)).toEqual([1.3, 1.5, 1.6]); // rungs ascending + expect(groups[0].primary.grade).toBe('A'); // betterOf picked the A rung + }); + + test('different markets for the same player stay separate ladders', () => { + const groups = groupIntoLadders([ + { player: 'X', stat: 'hits', line: 0.5 }, + { player: 'X', stat: 'total_bases', line: 1.5 }, + ]); + expect(groups).toHaveLength(2); + }); +}); + +describe('marketFamily', () => { + test('strips case/space; side+line are not part of the family', () => { + expect(marketFamily('Total Bases')).toBe('total_bases'); + expect(marketFamily('stolen_bases')).toBe('stolen_bases'); + }); +}); diff --git a/web/src/components/ExploreHub.tsx b/web/src/components/ExploreHub.tsx index 0baf0ad..e338431 100644 --- a/web/src/components/ExploreHub.tsx +++ b/web/src/components/ExploreHub.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import SportBadge from '@/components/vyndr/SportBadge'; import GradeBadge from '@/components/vyndr/GradeBadge'; import { playerHref } from '@/lib/playerHref'; +import { dedupeLeaders } from '@/lib/playerGrouping'; /** * ExploreHub (Session 42 leaders; Session 60 night2/C — THE AGGREGATOR). @@ -54,7 +55,11 @@ export default function ExploreHub() { const rows = useMemo(() => { const q = query.trim().toLowerCase(); - return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders; + const filtered = q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders; + // P0-3 — ONE row per player+market family + a per-market cap, so a player's + // alt-line ladder (Bohm ×3) collapses to one entry and no single prop type + // (9× stolen_bases) can flood the ranked board. + return dedupeLeaders(filtered, 4) as Leader[]; }, [leaders, query]); // Wave 2B — never-dark hub. When the selected sport is in its OFF-SEASON, the diff --git a/web/src/lib/playerGrouping.js b/web/src/lib/playerGrouping.js new file mode 100644 index 0000000..f8a599b --- /dev/null +++ b/web/src/lib/playerGrouping.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * Player/market grouping (P0-3) — the phone audit found the same player listed + * as separate flat entries everywhere (Bohm's strikeout ladder = 3 ledger cards, + * a leaderboard flooded with 9 identical stolen_bases rows). Props must present + * as ONE row per player per market family, with alt lines nested as a ladder, + * and no single market may flood a ranked list. + * + * CommonJS so it's unit-testable by the plain-JS Jest suite AND importable by + * the .tsx surfaces (allowJs) — same doctrine as vyndrTokens / playerName. + */ + +const { nameKey } = require('./playerName'); + +/** A stat's MARKET FAMILY — alt lines of the same market collapse together. + * (Side/line are stripped; only the stat type identifies the family.) */ +function marketFamily(stat) { + return String(stat || '').trim().toLowerCase().replace(/\s+/g, '_'); +} + +function playerMarketKey(player, stat) { + return `${nameKey(player)}|${marketFamily(stat)}`; +} + +/** + * Dedupe ranked leaderboard rows: ONE row per (player, market family) — keeping + * the FIRST (best-ranked) occurrence — then a per-market cap so one prop type + * can't flood the board. Input order is preserved (callers pass pre-ranked). + * + * @param {Array<{player?:string, stat?:string}>} rows + * @param {number} [perMarketCap=4] max rows of one market family on the board + * @returns {Array} deduped, capped, order-preserving + */ +function dedupeLeaders(rows, perMarketCap = 4) { + const seen = new Set(); + 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); + if (seen.has(key)) continue; // one row per player+market + const fam = marketFamily(r.stat); + const n = marketCount.get(fam) || 0; + if (perMarketCap > 0 && n >= perMarketCap) continue; // don't let one market flood + seen.add(key); + marketCount.set(fam, n + 1); + out.push(r); + } + return out; +} + +/** + * Group flat prop rows into ONE entry per (player, market family) with the alt + * lines nested as a ladder — the Alt Line Ladder shape (the /pricing Desk demo). + * `betterOf(a, b)` picks the primary/base line (default: keep the first seen). + * Order of first appearance is preserved. + * + * @param {Array} rows each { player, stat, line, ... } + * @param {(a,b)=>any} [betterOf] choose the representative row of a ladder + * @returns {Array<{ player, stat, market, primary, ladder }>} + */ +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); + if (!byKey.has(key)) { + byKey.set(key, { player: r.player, stat: r.stat, market: marketFamily(r.stat), primary: r, ladder: [r] }); + order.push(key); + } else { + const g = byKey.get(key); + g.ladder.push(r); + if (typeof betterOf === 'function') g.primary = betterOf(g.primary, r); + } + } + // Sort each ladder by line ascending for a stable rungs display. + for (const key of order) { + const g = byKey.get(key); + g.ladder.sort((a, b) => (Number(a.line) || 0) - (Number(b.line) || 0)); + } + return order.map((k) => byKey.get(k)); +} + +module.exports = { marketFamily, playerMarketKey, dedupeLeaders, groupIntoLadders };