diff --git a/src/daily-cache.ts b/src/daily-cache.ts index af76640..c5439c3 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -623,10 +623,14 @@ export function withDailyCacheLock(fn: () => Promise): Promise { export const MS_PER_DAY = 24 * 60 * 60 * 1000 export const BACKFILL_DAYS = 365 -// Keep 2 years of history so the longest bounded UI period (6 months -// via `all`) and the uncapped `lifetime` period both have headroom to -// read from cache while old entries get pruned. -export const DAILY_CACHE_RETENTION_DAYS = 730 +// Ten years. This cache is the ONLY durable record of carried days (their +// session files are long deleted), and the uncapped `lifetime` period reads +// from it via buildDurablePeriod, so pruning at the old 2-year mark would +// have replayed the lost-history bug in slow motion at that horizon. +// Measured envelope keeps this honest: ~2.3 MB / ~11 ms JSON parse per 730 +// days of fully dense data, so even a decade of daily use stays ~11 MB and +// well under 100 ms on the polling path. +export const DAILY_CACHE_RETENTION_DAYS = 3650 export function toDateString(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` diff --git a/src/quota.ts b/src/quota.ts index 48d47ed..17b99d5 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -76,6 +76,10 @@ export function computePace(window: QuotaWindow, now: Date = new Date()): QuotaP const elapsedSeconds = windowSeconds - remainingSeconds const expectedFraction = elapsedSeconds / windowSeconds if (expectedFraction < QUOTA_PACE_MIN_ELAPSED_FRACTION) return undefined + // A NaN usedFraction (e.g. derived from used/limit with limit 0) would sail + // through the clamp below (min/max propagate NaN) and poison every pace + // field. Unknown usage means no pace, not a NaN pace. + if (!Number.isFinite(window.usedFraction)) return undefined const used = Math.min(Math.max(window.usedFraction, 0), 1) if (used >= 1) return undefined diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts index 1d8f7e9..62197fa 100644 --- a/tests/daily-cache-carry-forward.test.ts +++ b/tests/daily-cache-carry-forward.test.ts @@ -388,7 +388,7 @@ describe('never-lose invariant: invalidations with vanished sources', () => { }) it('retention still prunes ancient carried days after a rebuild', async () => { - await seed({ days: [seededDay(), day('2020-01-01', { claude: slice(1, 1) })], complete: false }) + await seed({ days: [seededDay(), day('2010-01-01', { claude: slice(1, 1) })], complete: false }) const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') expect(out.days.map(d => d.date)).toEqual([daysAgoStr(30)]) }) @@ -483,7 +483,7 @@ describe('adoption union across older cache files', () => { const old = { version: 13, days: [ - day('2023-01-01', { claude: slice(1, 1) }), + day('2010-01-01', { claude: slice(1, 1) }), day(daysAgoStr(15), { claude: slice(5, 2) }), day(daysAgoStr(0), { claude: slice(99, 9) }), ], diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 7cff2ea..6ae0707 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -260,7 +260,7 @@ describe('addNewDays', () => { }) it('still prunes when newestDate is valid', () => { - const old = '2020-01-01' + const old = '2010-01-01' const recent = '2026-04-10' const base: DailyCache = { version: DAILY_CACHE_VERSION, @@ -269,7 +269,7 @@ describe('addNewDays', () => { days: [emptyDay(old, 1), emptyDay(recent, 2)], } const updated = addNewDays(base, [], recent) - // 730-day retention from 2026-04-10 → cutoff ~2024-04-11; 2020-01-01 must be gone. + // 3650-day retention from 2026-04-10 puts the cutoff in 2016; 2010-01-01 must be gone. expect(updated.days.find(d => d.date === old)).toBeUndefined() expect(updated.days.find(d => d.date === recent)).toBeDefined() }) diff --git a/tests/providers/vercel-gateway.test.ts b/tests/providers/vercel-gateway.test.ts index 2aa7108..4964003 100644 --- a/tests/providers/vercel-gateway.test.ts +++ b/tests/providers/vercel-gateway.test.ts @@ -101,7 +101,9 @@ describe('vercel-gateway end-to-end (parseAllSessions network path)', () => { ok: true, json: async () => ({ results: [ - { day: '2026-06-01', model: 'openai/gpt-4o', total_cost: 12.34, input_tokens: 1000, output_tokens: 500, request_count: 3 }, + // Relative so the rolling 6-month dashboard window always contains + // it; a fixed date here starts failing once the window moves past it. + { day: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), model: 'openai/gpt-4o', total_cost: 12.34, input_tokens: 1000, output_tokens: 500, request_count: 3 }, ], }), })) as typeof fetch diff --git a/tests/quota.test.ts b/tests/quota.test.ts index bed4638..1be12b1 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -179,3 +179,18 @@ describe('buildPlanQuota', () => { expect(quota.windows[0].pace).toBeUndefined() }) }) + +describe('computePace NaN guard', () => { + it('returns undefined for a non-finite usedFraction instead of a NaN pace', () => { + const now = new Date('2026-07-15T00:00:00Z') + const win = { + kind: 'weekly' as const, + usedFraction: NaN, + resetsAt: new Date('2026-07-18T12:00:00Z'), + windowSeconds: 7 * 24 * 3600, + source: 'live' as const, + } + expect(computePace(win, now)).toBeUndefined() + expect(computePace({ ...win, usedFraction: Infinity }, now)).toBeUndefined() + }) +})