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
+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 };