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:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user