P0-3a: dedupe the Explore leaderboard — one row per player+market + per-market cap
Phone audit: leaderboard flooded with 9 consecutive identical 'stolen_bases U0.5 45% B' rows. New shared lib/playerGrouping (dedupeLeaders + groupIntoLadders, name-key aware, 7 unit tests): ONE row per (player, market family) keeping the best-ranked, then a per-market cap (4) so no single prop type floods the board. Applied to ExploreHub. Ledger cards + Consensus grouping follow in P0-3b/c using the same lib. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import SportBadge from '@/components/vyndr/SportBadge';
|
import SportBadge from '@/components/vyndr/SportBadge';
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
|
import { dedupeLeaders } from '@/lib/playerGrouping';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExploreHub (Session 42 leaders; Session 60 night2/C — THE AGGREGATOR).
|
* ExploreHub (Session 42 leaders; Session 60 night2/C — THE AGGREGATOR).
|
||||||
@@ -54,7 +55,11 @@ export default function ExploreHub() {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
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]);
|
}, [leaders, query]);
|
||||||
|
|
||||||
// Wave 2B — never-dark hub. When the selected sport is in its OFF-SEASON, the
|
// Wave 2B — never-dark hub. When the selected sport is in its OFF-SEASON, the
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user