Merge Wave 1 (wiring/data): trust bugs + DeskShowcase copy
- billing renewal honest render (classifyRenewal — no far-future placeholder) - James Wood nameKey-collision → teamHint disambiguation + streaks join-invariant - DeskShowcase '$1M terminal' → deadpan 'Every grade, every alt line, live. $44.99/mo.' 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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Wave 1 trust bug — honest renewal render. classifyRenewal must never let a
|
||||
// far-future / comped `subscription_end` (the "RENEWS 6/9/2036" lie) render as
|
||||
// a real monthly renewal, and must fail closed to `unknown` on bad input.
|
||||
|
||||
const { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS } = require('../../web/src/lib/billingDisplay');
|
||||
|
||||
const NOW = Date.parse('2026-07-13T00:00:00.000Z');
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe('classifyRenewal — honest billing render', () => {
|
||||
test('MONTHLY_RENEWAL_MAX_DAYS is 60', () => {
|
||||
expect(MONTHLY_RENEWAL_MAX_DAYS).toBe(60);
|
||||
});
|
||||
|
||||
test('the 2036 comped/seed row is NOT a renewal → none', () => {
|
||||
const r = classifyRenewal('2036-06-09T00:00:00.000Z', NOW);
|
||||
expect(r.kind).toBe('none');
|
||||
expect(r.iso).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a plausible monthly next-bill (now + 30d) → date, carries iso', () => {
|
||||
const r = classifyRenewal(new Date(NOW + 30 * DAY).toISOString(), NOW);
|
||||
expect(r.kind).toBe('date');
|
||||
expect(typeof r.iso).toBe('string');
|
||||
expect(Date.parse(r.iso)).toBe(NOW + 30 * DAY);
|
||||
});
|
||||
|
||||
test('absent value (null / undefined / empty) → unknown', () => {
|
||||
expect(classifyRenewal(null, NOW).kind).toBe('unknown');
|
||||
expect(classifyRenewal(undefined, NOW).kind).toBe('unknown');
|
||||
expect(classifyRenewal('', NOW).kind).toBe('unknown');
|
||||
});
|
||||
|
||||
test('a renewal well in the past → lapsed', () => {
|
||||
const r = classifyRenewal(new Date(NOW - 10 * DAY).toISOString(), NOW);
|
||||
expect(r.kind).toBe('lapsed');
|
||||
});
|
||||
|
||||
test('malformed date string → unknown (never coerced to an epoch date)', () => {
|
||||
expect(classifyRenewal('not a date', NOW).kind).toBe('unknown');
|
||||
expect(classifyRenewal('N/A', NOW).kind).toBe('unknown');
|
||||
});
|
||||
|
||||
test('exactly at the 60-day boundary still reads as a date; just beyond → none', () => {
|
||||
expect(classifyRenewal(new Date(NOW + 60 * DAY).toISOString(), NOW).kind).toBe('date');
|
||||
expect(classifyRenewal(new Date(NOW + 61 * DAY).toISOString(), NOW).kind).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -47,10 +47,14 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () =>
|
||||
expect(pricing).not.toContain("originalPrice: '$49.99'");
|
||||
});
|
||||
|
||||
test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => {
|
||||
test('the Desk story leads with deadpan value-showing copy (no "$1M" brag), above the grid, with a real feature ladder', () => {
|
||||
expect(page).toContain('import DeskShowcase');
|
||||
expect(page).toContain('<DeskShowcase');
|
||||
expect(showcase).toContain('$1M terminal');
|
||||
// Wave 1 — the headline SHOWS what Desk does instead of claiming a dollar
|
||||
// figure (VYNDR voice: understated, no hype, no "$1M"/"terminal"-as-brag).
|
||||
expect(showcase).toContain('Every grade, every alt line, live.');
|
||||
expect(showcase).not.toContain('$1M');
|
||||
expect(showcase).not.toContain('1M terminal');
|
||||
expect(showcase).toContain('$44.99'); // the conversion hook figure
|
||||
expect(showcase).toContain('$34.99'); // the founder price
|
||||
// real feature visuals fill the right half (kills the dead half, #8)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Wave 1 trust bug — MLB namesake collision. Two different players can share an
|
||||
// EXACT nameKey ("James Wood": the Nationals star + a Cubs-affiliate namesake).
|
||||
// The old `.find` took the first → wrong currentTeam → wrong opponents → "built
|
||||
// vs AL East" fabrication in streaks/rosterlogs. Doctrine: never guess among
|
||||
// namesakes — resolve only with a confident team hint, else refuse (null).
|
||||
//
|
||||
// Hermetic: `searchPlayer` accepts injected `people`/`teams` fixtures — no
|
||||
// network, no redis.
|
||||
|
||||
const mlb = require('../../src/services/adapters/mlbStatsAdapter');
|
||||
const { teamRecordMatchesHint, disambiguateByHint, canonAbbr } = mlb.__internals;
|
||||
|
||||
// statsapi-shaped fixtures.
|
||||
const JUDGE = {
|
||||
id: 592450, fullName: 'Aaron Judge',
|
||||
currentTeam: { id: 147, name: 'New York Yankees' },
|
||||
primaryPosition: { abbreviation: 'RF' },
|
||||
};
|
||||
const WOOD_NATIONALS = {
|
||||
id: 691026, fullName: 'James Wood',
|
||||
currentTeam: { id: 120, name: 'Washington Nationals' },
|
||||
primaryPosition: { abbreviation: 'LF' },
|
||||
};
|
||||
const WOOD_CUBS = {
|
||||
id: 999999, fullName: 'James Wood',
|
||||
currentTeam: { id: 112, name: 'Chicago Cubs' },
|
||||
primaryPosition: { abbreviation: 'P' },
|
||||
};
|
||||
const SOTO_METS = {
|
||||
id: 665742, fullName: 'Juan Soto',
|
||||
currentTeam: { id: 121, name: 'New York Mets' },
|
||||
primaryPosition: { abbreviation: 'RF' },
|
||||
};
|
||||
|
||||
const TEAMS = [
|
||||
{ id: 147, abbr: 'NYY', name: 'New York Yankees' },
|
||||
{ id: 120, abbr: 'WSH', name: 'Washington Nationals' },
|
||||
{ id: 112, abbr: 'CHC', name: 'Chicago Cubs' },
|
||||
{ id: 146, abbr: 'MIA', name: 'Miami Marlins' },
|
||||
{ id: 121, abbr: 'NYM', name: 'New York Mets' },
|
||||
{ id: 119, abbr: 'LAD', name: 'Los Angeles Dodgers' },
|
||||
{ id: 135, abbr: 'SD', name: 'San Diego Padres' },
|
||||
];
|
||||
|
||||
describe('searchPlayer — namesake disambiguation (never guess)', () => {
|
||||
test('(a) a single unambiguous name resolves (Aaron Judge → Yankees)', async () => {
|
||||
const r = await mlb.searchPlayer('Aaron Judge', 2026, { people: [JUDGE], teams: TEAMS });
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.id).toBe(592450);
|
||||
expect(r.team).toBe('New York Yankees');
|
||||
});
|
||||
|
||||
test('(b) two "James Wood" with NO hint → null (NEVER the Cubs one)', async () => {
|
||||
const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_NATIONALS, WOOD_CUBS], teams: TEAMS });
|
||||
expect(r).toBeNull(); // refuse — honest absent beats wrong
|
||||
});
|
||||
|
||||
test('(c) two "James Wood" + a Nationals-game hint → the Nationals Wood', async () => {
|
||||
const r = await mlb.searchPlayer('James Wood', 2026, {
|
||||
people: [WOOD_CUBS, WOOD_NATIONALS], // Cubs first — the old bug picked this
|
||||
teams: TEAMS,
|
||||
teamHint: ['WSH', 'MIA'], // Nationals @ Marlins
|
||||
});
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.id).toBe(691026);
|
||||
expect(r.team).toBe('Washington Nationals');
|
||||
expect(r.team).not.toBe('Chicago Cubs');
|
||||
});
|
||||
|
||||
test('(c2) a full-team-name hint disambiguates too', async () => {
|
||||
const r = await mlb.searchPlayer('James Wood', 2026, {
|
||||
people: [WOOD_CUBS, WOOD_NATIONALS],
|
||||
teams: TEAMS,
|
||||
teamHint: ['Washington Nationals', 'Miami Marlins'],
|
||||
});
|
||||
expect(r.id).toBe(691026);
|
||||
});
|
||||
|
||||
test('(c3) two namesakes + a hint matching NEITHER → null (still refuse)', async () => {
|
||||
const r = await mlb.searchPlayer('James Wood', 2026, {
|
||||
people: [WOOD_NATIONALS, WOOD_CUBS],
|
||||
teams: TEAMS,
|
||||
teamHint: ['LAD', 'SD'],
|
||||
});
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// JOIN INVARIANT (streaks/rosterlogs) — a resolved player's team must be a
|
||||
// participant of the prop's game; on mismatch the team is DROPPED, never a
|
||||
// foreign tag. This is the mechanism snapshotService relies on before it writes
|
||||
// teamByPlayer + rosterlogs opponents.
|
||||
describe('searchPlayer — team join invariant (drop, never tag foreign)', () => {
|
||||
test('a single player whose team is NOT in the hinted game → team dropped to null', async () => {
|
||||
const r = await mlb.searchPlayer('Juan Soto', 2026, {
|
||||
people: [SOTO_METS],
|
||||
teams: TEAMS,
|
||||
teamHint: ['LAD', 'SD'], // Soto (Mets) is in neither → cannot confirm join
|
||||
});
|
||||
expect(r).not.toBeNull(); // still the right player by name
|
||||
expect(r.id).toBe(665742);
|
||||
expect(r.team).toBeNull(); // but his team is not tagged onto a foreign game
|
||||
expect(r.teamId).toBeNull();
|
||||
});
|
||||
|
||||
test('a single player whose team IS in the hinted game keeps its team', async () => {
|
||||
const r = await mlb.searchPlayer('Juan Soto', 2026, {
|
||||
people: [SOTO_METS],
|
||||
teams: TEAMS,
|
||||
teamHint: ['NYM', 'LAD'], // Mets @ Dodgers → confirmed
|
||||
});
|
||||
expect(r.team).toBe('New York Mets');
|
||||
});
|
||||
|
||||
test('with NO hint, a single player keeps its authoritative team', async () => {
|
||||
const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS });
|
||||
expect(r.team).toBe('New York Mets');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pure helpers — abbr reconciliation + hint matching', () => {
|
||||
test('ESPN↔statsapi abbr aliases canonicalize (AZ↔ARI, CHW↔CWS)', () => {
|
||||
expect(canonAbbr('AZ')).toBe(canonAbbr('ARI'));
|
||||
expect(canonAbbr('CHW')).toBe(canonAbbr('CWS'));
|
||||
expect(canonAbbr('nyy')).toBe('NYY');
|
||||
});
|
||||
|
||||
test('teamRecordMatchesHint matches by abbr via the teams list', () => {
|
||||
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['WSH'], TEAMS)).toBe(true);
|
||||
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['CHC'], TEAMS)).toBe(false);
|
||||
});
|
||||
|
||||
test('teamRecordMatchesHint matches by partial name', () => {
|
||||
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['Nationals'], TEAMS)).toBe(true);
|
||||
});
|
||||
|
||||
test('disambiguateByHint refuses when 2 candidates share the hinted team', () => {
|
||||
const dupe = [WOOD_NATIONALS, { ...WOOD_CUBS, currentTeam: { id: 120, name: 'Washington Nationals' } }];
|
||||
expect(disambiguateByHint(dupe, ['WSH'], TEAMS)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ export default function DeskShowcase() {
|
||||
THE DESK · FLAGSHIP
|
||||
</div>
|
||||
<h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}>
|
||||
A $1M terminal.{' '}
|
||||
Every grade, every alt line, live.{' '}
|
||||
<span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
|
||||
</h2>
|
||||
|
||||
@@ -36,7 +36,7 @@ export const metadata: Metadata = {
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh' }}>
|
||||
{/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the
|
||||
{/* DS5 (#8) — Desk is the hero. The value-showing story leads, above the
|
||||
grid, so the premium tier stops being an afterthought. */}
|
||||
<DeskShowcase />
|
||||
<Pricing />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { currentAccessToken } from '@/lib/authToken';
|
||||
import { classifyRenewal } from '@/lib/billingDisplay';
|
||||
|
||||
interface FullProfile {
|
||||
id: string;
|
||||
@@ -125,7 +126,7 @@ export default function ProfilePage() {
|
||||
{tier !== 'free' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
||||
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
||||
<Stat label="Renews" value={renewalLabel(profile.subscription_end)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -170,7 +171,7 @@ export default function ProfilePage() {
|
||||
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
|
||||
Cancellation scheduled. Access ends{' '}
|
||||
{profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}.
|
||||
{accessEndsLabel(profile.subscription_end)}.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
@@ -199,6 +200,25 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'go
|
||||
);
|
||||
}
|
||||
|
||||
// Renewal render is guarded (Wave 1 trust bug) — VYNDR tiers are monthly, so a
|
||||
// `subscription_end` far in the future is a comped/seed value, not a renewal.
|
||||
// classifyRenewal decides; we never print a raw date the cadence can't justify.
|
||||
function renewalLabel(subscriptionEnd: string | null): string {
|
||||
const r = classifyRenewal(subscriptionEnd);
|
||||
if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString();
|
||||
if (r.kind === 'none') return 'No scheduled renewal';
|
||||
if (r.kind === 'lapsed') return 'Lapsed';
|
||||
return '—';
|
||||
}
|
||||
|
||||
// The cancel-scheduled line only shows a real near date; anything else falls to
|
||||
// the honest "at period end" (never a fabricated 2036 access-end).
|
||||
function accessEndsLabel(subscriptionEnd: string | null): string {
|
||||
const r = classifyRenewal(subscriptionEnd);
|
||||
if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString();
|
||||
return 'at period end';
|
||||
}
|
||||
|
||||
function tierColor(tier: string): string {
|
||||
if (tier === 'desk') return 'var(--grade-a)';
|
||||
if (tier === 'analyst') return 'var(--grade-b)';
|
||||
|
||||
@@ -89,7 +89,7 @@ const TIERS: TierConfig[] = [
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal"
|
||||
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the value-showing
|
||||
// story and the single primary CTA on the grid (color contract #9: never
|
||||
// two competing green CTAs). $44.99 regular, $34.99 for founders.
|
||||
id: 'desk',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* ============================================================
|
||||
VYNDR — BILLING DISPLAY (honest renewal render).
|
||||
Plain CommonJS so .tsx components import it AND the Jest suite
|
||||
requires it directly (same pattern as colorContract.js / checkout.js).
|
||||
|
||||
The lie this kills: the profile page rendered `subscription_end`
|
||||
verbatim, so a manually-seeded / comped founder row reading
|
||||
"6/9/2036" showed as a real renewal. VYNDR tiers are MONTHLY only
|
||||
(no annual), so any date more than ~60 days out is NOT a plausible
|
||||
monthly next-bill — it is a comped / lifetime / seed value and must
|
||||
NOT be rendered as a renewal date.
|
||||
|
||||
Doctrine: absent-but-honest beats wrong-but-full. Never render a
|
||||
renewal date the billing cadence can't justify. Strict parsing
|
||||
(the `Number(null) === 0` class of bug) — an unparseable value is
|
||||
`unknown`, never coerced to an epoch date.
|
||||
============================================================ */
|
||||
|
||||
// A monthly plan renews ~30 days out; allow slack for proration / grace,
|
||||
// but a value beyond this many days out cannot be a monthly renewal.
|
||||
const MONTHLY_RENEWAL_MAX_DAYS = 60;
|
||||
|
||||
// A renewal more than this many days in the PAST is a lapsed subscription
|
||||
// (a small grace window absorbs clock skew / just-past renewals).
|
||||
const LAPSED_GRACE_DAYS = 2;
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* classifyRenewal(subscriptionEnd, nowMs) → { kind, iso? }
|
||||
*
|
||||
* kind ∈
|
||||
* 'date' — a plausible monthly renewal (0..~60d out). Carries `iso`
|
||||
* (the parsed timestamp) for the caller to localize.
|
||||
* 'none' — more than ~60d out → comped / lifetime / seed value; there
|
||||
* is no scheduled monthly renewal to show.
|
||||
* 'lapsed' — more than 2d past → the subscription window has ended.
|
||||
* 'unknown' — absent / empty / unparseable → show an em dash.
|
||||
*
|
||||
* @param {string|number|null|undefined} subscriptionEnd provider-asserted end.
|
||||
* @param {number} [nowMs] current epoch ms (injectable for tests).
|
||||
*/
|
||||
function classifyRenewal(subscriptionEnd, nowMs = Date.now()) {
|
||||
if (subscriptionEnd === null || subscriptionEnd === undefined || subscriptionEnd === '') {
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
const t = Date.parse(subscriptionEnd);
|
||||
if (Number.isNaN(t)) return { kind: 'unknown' };
|
||||
|
||||
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
||||
const diffDays = (t - now) / DAY_MS;
|
||||
|
||||
if (diffDays < -LAPSED_GRACE_DAYS) return { kind: 'lapsed' };
|
||||
if (diffDays > MONTHLY_RENEWAL_MAX_DAYS) return { kind: 'none' };
|
||||
return { kind: 'date', iso: new Date(t).toISOString() };
|
||||
}
|
||||
|
||||
module.exports = { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS };
|
||||
Reference in New Issue
Block a user