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:
Kev
2026-07-13 03:49:54 -04:00
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;
+48
View File
@@ -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');
});
});
+6 -2
View File
@@ -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)
+141
View File
@@ -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();
});
});
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 />
+22 -2
View File
@@ -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)';
+1 -1
View File
@@ -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',
+58
View File
@@ -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 };