Wave 1: kill three trust bugs (billing renewal + namesake collision + Desk copy)

FIX 1 — Honest billing renewal render. VYNDR tiers are monthly, so a
`subscription_end` far in the future (the manually-seeded "RENEWS 6/9/2036"
founder row) is a comped/lifetime/seed value, not a renewal. New
web/src/lib/billingDisplay.js `classifyRenewal()` → date | none | lapsed |
unknown (strict Date.parse guard, MONTHLY_RENEWAL_MAX_DAYS=60). Profile page
renders the classified label for both the "Renews" stat and the
cancel-scheduled "Access ends" line — no raw far-future date. No DB row mutated.

FIX 2 — MLB namesake collision (James Wood → "Chicago Cubs"). searchPlayer now
collects ALL exact-nameKey matches instead of first-`.find`; a ≥2 collision
resolves ONLY via a confident teamHint (the prop's game participants, matched
against the cached /teams list with ESPN↔statsapi abbr reconciliation), else
refuses (null) — never guesses. The hint threads getPlayerStats →
resolvePlayerStats → snapshotService (built from each prop's home/away team).
Join invariant: a single-exact player whose team isn't in the hinted game has
its team DROPPED (null), so streaks/rosterlogs never tag a foreign team. Full
teamHint recovery shipped (not just the refuse fallback).

FIX 3 — DeskShowcase headline "A $1M terminal." → deadpan value-showing copy
"Every grade, every alt line, live." Prices ($44.99 / $34.99) unchanged.

Tests: billingDisplay.test.js (7), mlbNamesakeResolve.test.js (12,
disambiguation + join invariant + pure helpers), ds5PricingStates updated to
assert the new headline and no "$1M". Full suite green (237 suites / 2863
tests); web `next build` exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 03:48:58 -04:00
parent 93a220e0ca
commit b6787af191
11 changed files with 421 additions and 29 deletions
+125 -20
View File
@@ -183,31 +183,132 @@ async function searchPlayers(query, opts = {}) {
return matchPlayers(people, query, opts.limit || 12);
}
async function searchPlayer(name, season = DEFAULT_SEASON) {
// ── Namesake disambiguation (Wave 1 · trust bug) ─────────────────────────────
// Two different players can share an EXACT nameKey ("James Wood" — the Nationals
// star + a Cubs-affiliate namesake). Taking the first `.find` match silently
// tagged the wrong team → wrong opponents → "built vs AL East" fabrication in
// streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a
// confident team hint (the prop's game participants); otherwise refuse (null).
// The odds feed sends ESPN-style abbrs; statsapi uses its own for a few clubs.
// Canonicalize both sides so AZ↔ARI, CHW↔CWS, WSN↔WSH, … compare equal.
const ABBR_ALIAS = Object.freeze({
AZ: 'ARI', ARI: 'ARI',
CHW: 'CWS', CWS: 'CWS',
WSN: 'WSH', WSH: 'WSH',
SDP: 'SD', SD: 'SD',
SFG: 'SF', SF: 'SF',
TBR: 'TB', TB: 'TB',
KCR: 'KC', KC: 'KC',
});
function canonAbbr(a) {
const u = String(a || '').toUpperCase().trim();
return ABBR_ALIAS[u] || u;
}
function teamNorm(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
/**
* Does a resolved team record (`{ id, name }`, from a candidate's currentTeam)
* match ANY identifier in the prop's team hint? A hint entry may be an abbr
* ("WSH") OR a full/partial team name ("Washington Nationals" / "Nationals").
* teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn
* an abbr hint into a team id.
*/
function teamRecordMatchesHint(team, teamHint, teams) {
if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false;
const tId = team.id;
const tName = teamNorm(team.name);
for (const h of teamHint) {
if (!h) continue;
// 1) hint as a name (equal / either-contains — handles "Nationals" vs full)
const hn = teamNorm(h);
if (hn && tName && (hn === tName || tName.includes(hn) || hn.includes(tName))) return true;
// 2) hint as an abbr → resolve to a team id via the teams list, compare ids
const rec = (teams || []).find((t) => canonAbbr(t.abbr) === canonAbbr(h));
if (rec && tId != null && rec.id === tId) return true;
}
return false;
}
/** Among namesake candidates, return the SINGLE one whose currentTeam matches
* the hint, else null (2+ or 0 matches → refuse; never guess). */
function disambiguateByHint(candidates, teamHint, teams) {
if (!Array.isArray(teamHint) || teamHint.length === 0) return null;
const matches = (candidates || []).filter((p) =>
p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams));
return matches.length === 1 ? matches[0] : null;
}
/** S59 fallback for the NO-exact-match case: a unique last-name + first-initial
* hit, else null. A missing profile beats another player's log. */
function lastNameInitialFallback(people, targetKey) {
const parts = targetKey.split(' ');
const first = parts[0] || '';
const last = parts[parts.length - 1] || '';
if (!(first && last && first !== last)) return null;
const cands = (people || []).filter((p) => {
const k = nameKey(p.fullName).split(' ');
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
});
return cands.length === 1 ? cands[0] : null;
}
/**
* Resolve a name → statsapi person. `opts.teamHint` (array of the prop's game
* team identifiers) disambiguates namesakes AND enforces the join invariant:
* when a hint is present but the resolved player's team is NOT a participant of
* the prop's game, the team is DROPPED (returned null) rather than tagging a
* foreign team downstream. `opts.people`/`opts.teams` inject fixtures for tests.
*/
async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
const targetKey = nameKey(name);
if (!targetKey) return null;
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
const people = (data && Array.isArray(data.people)) ? data.people : [];
let hit = people.find((p) => nameKey(p.fullName) === targetKey);
if (!hit) {
const parts = targetKey.split(' ');
const first = parts[0] || '';
const last = parts[parts.length - 1] || '';
if (first && last && first !== last) {
const cands = people.filter((p) => {
const k = nameKey(p.fullName).split(' ');
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
});
if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess
let people = opts.people;
if (!Array.isArray(people)) {
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
people = (data && Array.isArray(data.people)) ? data.people : [];
}
const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null;
let teams = Array.isArray(opts.teams) ? opts.teams : null;
const ensureTeams = async () => {
if (teams) return teams;
try { teams = await getTeams(season); } catch { teams = []; }
return teams;
};
const exact = people.filter((p) => nameKey(p.fullName) === targetKey);
let hit = null;
let teamConfirmed = true; // stays true when there's no hint to check against
if (exact.length === 1) {
hit = exact[0];
if (teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
}
} else if (exact.length >= 2) {
// Namesake collision — resolve ONLY with a confident hint, else refuse.
hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null;
// a hit here is team-confirmed by construction.
} else {
hit = lastNameInitialFallback(people, targetKey);
if (hit && teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
}
}
if (!hit) return null;
return {
id: hit.id,
fullName: hit.fullName ?? name,
team: hit.currentTeam?.name ?? null,
teamId: hit.currentTeam?.id ?? null,
team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null,
teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null,
position: hit.primaryPosition?.abbreviation ?? null,
};
}
@@ -219,9 +320,9 @@ async function searchPlayer(name, season = DEFAULT_SEASON) {
* last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns
* { found: false } on any miss/failure (never throws).
*/
async function getPlayerStats(name, season = DEFAULT_SEASON) {
async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) {
try {
const person = await searchPlayer(name, season);
const person = await searchPlayer(name, season, opts);
if (!person) return { found: false };
const group = person.position === 'P' ? 'pitching' : 'hitting';
const [seasonStat, log] = await Promise.all([
@@ -293,5 +394,9 @@ module.exports = {
getTeams,
resolveTeam,
getTeamRoster,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON },
__internals: {
BASE, TTL, extractSplits, ymd, DEFAULT_SEASON,
// Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
},
};
+4 -1
View File
@@ -116,7 +116,10 @@ async function resolvePlayerStats(name, sport, opts = {}) {
try {
if (sp === 'mlb') {
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
const res = await mlb.getPlayerStats(name);
// Wave 1 — thread the prop's game team hint so a namesake collision
// (two "James Wood") resolves to the RIGHT player, and a team that isn't
// a participant of the prop's game is dropped (never a foreign tag).
const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint });
if (!res || !res.found) return { found: false };
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
// Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G)
+14 -1
View File
@@ -298,6 +298,18 @@ async function runSnapshot(sport, opts = {}) {
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game).
const teamByPlayer = {};
// Wave 1 (trust bug) — the prop's game participants become the resolve's
// teamHint: it disambiguates namesake collisions (two "James Wood") and, when
// the resolved player's real team isn't in the prop's game, the resolver drops
// the team rather than tag a foreign one (the streaks/rosterlogs JOIN
// INVARIANT, mirroring the S59 slate guard). Keyed by the normalized name.
const teamHintByPlayer = {};
for (const p of props || []) {
const k = norm(p.player);
if (!k || teamHintByPlayer[k]) continue;
const hint = [p.home_team, p.away_team].filter(Boolean);
if (hint.length) teamHintByPlayer[k] = hint;
}
// Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks +
// hot lists) starved because its data producers were all external and
// unarmed (tank01-prefetch via n8n, the offline Python grading flow).
@@ -308,7 +320,8 @@ async function runSnapshot(sport, opts = {}) {
const logEntries = [];
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
try {
const stats = await deps.resolveStats(player, sp);
const teamHint = teamHintByPlayer[norm(player)] || null;
const stats = await deps.resolveStats(player, sp, teamHint ? { teamHint } : {});
if (stats && stats.found) {
const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null;