From 201f8e4f8e4b4a29e8b8caed172a98b016919355 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:29:26 +0000 Subject: [PATCH 1/2] fix(frontend): seed Purchase History dates from the local calendar day The Purchase History From/To inputs were seeded (and snapped to a plan's purchases) with toISOString().split('T')[0], i.e. the UTC calendar day, while shows the user's local day. Evening users west of UTC saw tomorrow in To; morning users east of UTC saw yesterday. Move toLocalDateInputValue and parseLocalDateInputValue out of apikeys.ts into a shared lib/local-date.ts and use them for all four history.ts sites. Because /api/history filters on whole UTC days, loadHistory now widens the local From/To pair to the UTC days that cover it (localDayRangeToUtcDays), so a local range never silently drops a purchase made near either edge (e.g. a UTC+12 user filtering on today previously lost this morning's purchases). Refs #51 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/history.test.ts | 69 ++++++++++++++----- frontend/src/__tests__/local-date.test.ts | 47 +++++++++++++ frontend/src/apikeys.ts | 24 +------ frontend/src/history.ts | 24 +++++-- frontend/src/lib/local-date.ts | 55 +++++++++++++++ .../tests-e2e/history-local-dates.spec.ts | 69 +++++++++++++++++++ 6 files changed, 240 insertions(+), 48 deletions(-) create mode 100644 frontend/src/__tests__/local-date.test.ts create mode 100644 frontend/src/lib/local-date.ts create mode 100644 frontend/tests-e2e/history-local-dates.spec.ts diff --git a/frontend/src/__tests__/history.test.ts b/frontend/src/__tests__/history.test.ts index f2b8ebf97..e2f638c5f 100644 --- a/frontend/src/__tests__/history.test.ts +++ b/frontend/src/__tests__/history.test.ts @@ -106,28 +106,44 @@ describe('History Module', () => { }); describe('initHistoryDateRange', () => { - test('sets default date range to 7 days', () => { - initHistoryDateRange(); + // Issue #51: the inputs are seeded from the LOCAL calendar day. Jest + // runs in America/New_York (globalSetup), so pin "now" to an evening + // instant where the local day trails the UTC day: the old toISOString() + // seeding showed tomorrow in To and dropped a day from From. + test('sets default 7-day range from the local calendar day', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-07-28T03:30:00Z')); // Jul 27, 23:30 EDT + try { + initHistoryDateRange(); + } finally { + jest.useRealTimers(); + } const startInput = document.getElementById('history-start') as HTMLInputElement; const endInput = document.getElementById('history-end') as HTMLInputElement; + // Purchase events are a log view (7 days); Savings History has its + // own 90-day default via #savings-period in index.html. + expect(endInput.value).toBe('2026-07-27'); + expect(startInput.value).toBe('2026-07-20'); + }); - expect(startInput.value).toBeTruthy(); - expect(endInput.value).toBeTruthy(); - - // End date should be today in UTC (code uses toISOString which is UTC) - const today = new Date(); - const todayUTC = today.toISOString().split('T')[0] || ''; - expect(endInput.value).toBe(todayUTC); + test('the seeded range requests a purchase made earlier the same local day', async () => { + // A purchase at 21:00 EDT on Jul 27 is 01:00 UTC on Jul 28. The + // server filters on whole UTC days, so the request must reach Jul 28 + // even though the To input (correctly) shows Jul 27. + const purchaseAt = '2026-07-28T01:00:00Z'; + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [] }); + jest.useFakeTimers().setSystemTime(new Date('2026-07-28T03:30:00Z')); + try { + initHistoryDateRange(); + } finally { + jest.useRealTimers(); + } + await loadHistory(); - // Start date should be 7 days ago (UTC). Purchase events are a log - // view — recent activity is what matters. Savings History (the trend - // view) defaults to 90 days separately via #savings-period in - // index.html. - const expectedStart = new Date(); - expectedStart.setDate(expectedStart.getDate() - 7); - const expectedStartUTC = expectedStart.toISOString().split('T')[0] || ''; - expect(startInput.value).toBe(expectedStartUTC); + const filters = (api.getHistory as jest.Mock).mock.calls[0][0] as { start: string; end: string }; + const purchaseUtcDay = purchaseAt.slice(0, 10); + expect(filters.start <= purchaseUtcDay && purchaseUtcDay <= filters.end).toBe(true); + expect(filters).toEqual(expect.objectContaining({ start: '2026-07-20', end: '2026-07-28' })); }); test('does not overwrite existing values', () => { @@ -238,6 +254,20 @@ describe('History Module', () => { expect(endInput.value).toBe('2026-02-04'); }); + test('snaps to the LOCAL day of a purchase made after UTC midnight', async () => { + // 02:00 UTC on Feb 5 is 21:00 EST on Feb 4: the table shows Feb 4, + // so the To input must too (issue #51). + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [{ timestamp: '2026-02-01T12:00:00Z' }, { timestamp: '2026-02-05T02:00:00Z' }], + }); + + await viewPlanHistory('plan-123'); + + expect((document.getElementById('history-start') as HTMLInputElement).value).toBe('2026-02-01'); + expect((document.getElementById('history-end') as HTMLInputElement).value).toBe('2026-02-04'); + }); + test('leaves From/To inputs untouched when the plan has no purchases', async () => { // No data → no honest range to display. Clobbering the inputs with // `today` would lie to the user; preserve whatever was there. @@ -275,9 +305,12 @@ describe('History Module', () => { await loadHistory(); + // The inputs are local (America/New_York) days; the request carries + // the UTC days covering them, so the To day's evening (already Mar 2 + // in UTC) is included (issue #51). expect(api.getHistory).toHaveBeenCalledWith({ start: '2024-01-01', - end: '2024-03-01', + end: '2024-03-02', provider: 'aws' }); }); diff --git a/frontend/src/__tests__/local-date.test.ts b/frontend/src/__tests__/local-date.test.ts new file mode 100644 index 000000000..febb59522 --- /dev/null +++ b/frontend/src/__tests__/local-date.test.ts @@ -0,0 +1,47 @@ +/** + * Local-calendar date input helpers (issue #51). Jest runs in + * America/New_York (globalSetup), so the UTC and local days differ for the + * evening instants used here. The east-of-UTC case (From widens back a + * day) runs in Playwright under Pacific/Auckland: tests-e2e/history-local-dates.spec.ts. + */ +import { localDayRangeToUtcDays, parseLocalDateInputValue, toLocalDateInputValue } from '../lib/local-date'; + +describe('toLocalDateInputValue', () => { + test('uses the local calendar day, not the UTC day', () => { + const eveningEdt = new Date('2026-07-28T03:30:00Z'); // Jul 27, 23:30 EDT + expect(eveningEdt.toISOString().slice(0, 10)).toBe('2026-07-28'); + expect(toLocalDateInputValue(eveningEdt)).toBe('2026-07-27'); + }); + + test('zero-pads month and day', () => { + expect(toLocalDateInputValue(new Date(2026, 0, 5))).toBe('2026-01-05'); + }); +}); + +describe('parseLocalDateInputValue', () => { + test('parses a bare date as local midnight', () => { + const d = parseLocalDateInputValue('2026-03-15'); + expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()]).toEqual([2026, 2, 15, 0]); + }); + + test('round-trips with toLocalDateInputValue', () => { + expect(toLocalDateInputValue(parseLocalDateInputValue('2026-11-01'))).toBe('2026-11-01'); + }); +}); + +describe('localDayRangeToUtcDays', () => { + test('extends the To day to the UTC day holding its local evening (west of UTC)', () => { + // Local Jul 20 00:00 EDT = Jul 20 04:00Z; local Jul 27 23:59 EDT = Jul 28 03:59Z. + expect(localDayRangeToUtcDays('2026-07-20', '2026-07-27')).toEqual({ start: '2026-07-20', end: '2026-07-28' }); + }); + + test('handles the day DST ends (25-hour local day)', () => { + // Nov 1 2026 ends at 23:59:59.999 EST = Nov 2 04:59Z. + expect(localDayRangeToUtcDays('2026-11-01', '2026-11-01')).toEqual({ start: '2026-11-01', end: '2026-11-02' }); + }); + + test('passes empty, missing and malformed values through unchanged', () => { + expect(localDayRangeToUtcDays('', undefined)).toEqual({ start: '', end: undefined }); + expect(localDayRangeToUtcDays('yesterday', '2026-7-1')).toEqual({ start: 'yesterday', end: '2026-7-1' }); + }); +}); diff --git a/frontend/src/apikeys.ts b/frontend/src/apikeys.ts index 31cd71265..f9cf062be 100644 --- a/frontend/src/apikeys.ts +++ b/frontend/src/apikeys.ts @@ -10,6 +10,7 @@ import { confirmDialog } from './confirmDialog'; import { showToast } from './toast'; import { openModal, closeModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { parseLocalDateInputValue, toLocalDateInputValue } from './lib/local-date'; // State for modal management let currentApiKeys: APIKeyInfo[] = []; @@ -291,29 +292,6 @@ export async function showCreateKeyModal(): Promise { updateCreateKeySubmitState(); } -/** - * Formats a Date as the YYYY-MM-DD value an expects, - * using local calendar components. toISOString() converts to UTC first, - * which can shift the date by a day near local midnight. - */ -function toLocalDateInputValue(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -/** - * Parses an value (YYYY-MM-DD) as local midnight. - * new Date(dateString) parses it as UTC midnight instead, which can read as - * "yesterday evening" in timezones behind UTC and reject a same-day or - * next-day expiration that should be valid (CodeRabbit finding on #392). - */ -function parseLocalDateInputValue(value: string): Date { - const [year, month, day] = value.split('-').map(Number); - return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1); -} - /** * Close create API key modal */ diff --git a/frontend/src/history.ts b/frontend/src/history.ts index b4c183d44..6c191654e 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -14,6 +14,7 @@ import { showToast } from './toast'; import { getCurrentUser } from './state'; import { canAccess } from './permissions'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { localDayRangeToUtcDays, toLocalDateInputValue } from './lib/local-date'; import { getAccountName } from './recommendations'; import { applyColumnFilters } from './lib/column-filters'; import { @@ -232,10 +233,10 @@ export function initHistoryDateRange(): void { const endInput = document.getElementById('history-end') as HTMLInputElement | null; if (startInput && !startInput.value) { - startInput.value = start.toISOString().split('T')[0] || ''; + startInput.value = toLocalDateInputValue(start); } if (endInput && !endInput.value) { - endInput.value = end.toISOString().split('T')[0] || ''; + endInput.value = toLocalDateInputValue(end); } } @@ -300,7 +301,8 @@ export async function viewPlanHistory(planId: string): Promise { * Set the From/To inputs to bracket the purchases that just rendered. * No-op when the list is empty — keeping the previous values is more * honest than seeding a fake "today" range. Timestamps are normalised - * to UTC YYYY-MM-DD so they slot directly into ``. + * to the LOCAL calendar day (the day a date input shows and the table's + * formatted timestamps use), not the UTC day (issue #51). */ function snapDateInputsToPurchases(purchases: HistoryPurchase[]): void { if (purchases.length === 0) return; @@ -310,8 +312,8 @@ function snapDateInputsToPurchases(purchases: HistoryPurchase[]): void { if (epochs.length === 0) return; const startInput = document.getElementById('history-start') as HTMLInputElement | null; const endInput = document.getElementById('history-end') as HTMLInputElement | null; - const minDate = new Date(Math.min(...epochs)).toISOString().split('T')[0] || ''; - const maxDate = new Date(Math.max(...epochs)).toISOString().split('T')[0] || ''; + const minDate = toLocalDateInputValue(new Date(Math.min(...epochs))); + const maxDate = toLocalDateInputValue(new Date(Math.max(...epochs))); if (startInput) startInput.value = minDate; if (endInput) endInput.value = maxDate; } @@ -354,9 +356,17 @@ export async function loadHistory(): Promise { const stateAccountIDs = state.getCurrentAccountIDs(); const accountIDs: string[] | undefined = stateAccountIDs.length > 0 ? stateAccountIDs : undefined; + // The From/To inputs hold LOCAL calendar days, but /history filters on + // whole UTC days. Widen to the UTC days covering the local range so an + // off-UTC user never loses rows from the edges of the days they picked + // (issue #51). + const range = localDayRangeToUtcDays( + (document.getElementById('history-start') as HTMLInputElement | null)?.value, + (document.getElementById('history-end') as HTMLInputElement | null)?.value, + ); const filters: api.HistoryFilters = { - start: (document.getElementById('history-start') as HTMLInputElement | null)?.value, - end: (document.getElementById('history-end') as HTMLInputElement | null)?.value, + start: range.start, + end: range.end, provider, account_ids: accountIDs }; diff --git a/frontend/src/lib/local-date.ts b/frontend/src/lib/local-date.ts new file mode 100644 index 000000000..a28651f21 --- /dev/null +++ b/frontend/src/lib/local-date.ts @@ -0,0 +1,55 @@ +/** + * Local-calendar helpers for `` values (issue #51). + * + * A date input shows and edits a YYYY-MM-DD day in the user's LOCAL + * calendar. `toISOString()` converts to UTC first, so seeding an input with + * `date.toISOString().split('T')[0]` shows "tomorrow" to an evening user + * west of UTC and "yesterday" to a morning user east of it. Build and parse + * those values from local date components instead. + */ + +/** Format a Date as the local-calendar YYYY-MM-DD value a date input expects. */ +export function toLocalDateInputValue(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * Parse a date input value (YYYY-MM-DD) as LOCAL midnight. `new Date(value)` + * parses a bare date as UTC midnight, which reads as "yesterday evening" + * west of UTC and rejects a same-day or next-day date that should be valid. + */ +export function parseLocalDateInputValue(value: string): Date { + const [year, month, day] = value.split('-').map(Number); + return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1); +} + +const DATE_VALUE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Translate a local-calendar From/To pair into the UTC calendar days an API + * that filters on whole UTC days needs to cover it. The From day starts at + * local midnight and the To day ends at the following local midnight; each + * instant is mapped to the UTC day containing it. The result is the + * smallest UTC-day range that includes every row the user's local range + * covers, so no row on the chosen local days is silently filtered out (it + * may include a few hours past either edge). Empty or malformed values pass + * through unchanged so the server keeps reporting its own validation error. + */ +export function localDayRangeToUtcDays( + start: string | undefined, + end: string | undefined, +): { start: string | undefined; end: string | undefined } { + const utcDay = (instant: number): string => new Date(instant).toISOString().slice(0, 10); + const startDay = start && DATE_VALUE.test(start) ? parseLocalDateInputValue(start) : null; + const endDay = end && DATE_VALUE.test(end) ? parseLocalDateInputValue(end) : null; + const nextMidnight = endDay + ? new Date(endDay.getFullYear(), endDay.getMonth(), endDay.getDate() + 1).getTime() + : NaN; + return { + start: startDay && !Number.isNaN(startDay.getTime()) ? utcDay(startDay.getTime()) : start, + end: Number.isFinite(nextMidnight) ? utcDay(nextMidnight - 1) : end, + }; +} diff --git a/frontend/tests-e2e/history-local-dates.spec.ts b/frontend/tests-e2e/history-local-dates.spec.ts new file mode 100644 index 000000000..922b0cb7a --- /dev/null +++ b/frontend/tests-e2e/history-local-dates.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +// Issue #51: the Purchase History From/To inputs show LOCAL calendar days, +// while /api/history filters on whole UTC days (handler_history.go). The +// mock below applies the same UTC-day filter so a wrong bound drops rows +// exactly as the real backend would. + +const ROW = { + purchase_id: '51515151-5151-5151-5151-515151515151', provider: 'aws', service: 'ec2', + resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 1, upfront_cost: 1200, + monthly_cost: 50, estimated_savings: 10, status: 'completed', account_id: 'acct-001', +}; + +async function historyFixture(page: Page, now: string, purchasedAt: string) { + const requests: URL[] = []; + await page.clock.setFixedTime(new Date(now)); + await seedAuth(page); + await mockApi(page); + await page.route('**/api/history**', route => { + const url = new URL(route.request().url()); + requests.push(url); + const start = url.searchParams.get('start'); + const end = url.searchParams.get('end'); + const at = Date.parse(purchasedAt); + const inRange = (!start || at >= Date.parse(`${start}T00:00:00Z`)) + && (!end || at <= Date.parse(`${end}T23:59:59Z`)); + return route.fulfill({ json: { summary: {}, purchases: inRange ? [{ ...ROW, timestamp: purchasedAt }] : [] } }); + }); + return requests; +} + +test.describe('east of UTC (Pacific/Auckland)', () => { + test.use({ timezoneId: 'Pacific/Auckland' }); + + test('seeds today and keeps a purchase from earlier today when filtering on today only', async ({ page }) => { + // 10:00 NZST on Jul 28 is 22:00 UTC on Jul 27; the purchase ran at 09:00 NZST. + const requests = await historyFixture(page, '2026-07-27T22:00:00Z', '2026-07-27T21:00:00Z'); + await page.goto('/purchases/history'); + await expect(page.locator('#history-end')).toHaveValue('2026-07-28'); + await expect(page.locator('#history-start')).toHaveValue('2026-07-21'); + + await page.locator('#history-start').fill('2026-07-28'); + const response = page.waitForResponse(r => r.url().includes('/api/history?')); + await page.locator('#load-history-btn').click(); + await response; + const last = requests[requests.length - 1]!; + expect(last.searchParams.get('start')).toBe('2026-07-27'); + expect(last.searchParams.get('end')).toBe('2026-07-28'); + await expect(page.locator('#history-list tbody tr')).not.toHaveCount(0); + }); +}); + +test.describe('west of UTC (America/Los_Angeles)', () => { + test.use({ timezoneId: 'America/Los_Angeles' }); + + test('seeds today, not tomorrow, and still requests this evening\'s purchases', async ({ page }) => { + // 20:00 PDT on Jul 27 is 03:00 UTC on Jul 28; the purchase ran at 17:30 PDT. + const requests = await historyFixture(page, '2026-07-28T03:00:00Z', '2026-07-28T00:30:00Z'); + await page.goto('/purchases/history'); + await expect(page.locator('#history-end')).toHaveValue('2026-07-27'); + await expect(page.locator('#history-start')).toHaveValue('2026-07-20'); + await expect.poll(() => requests.length).toBeGreaterThan(0); + const last = requests[requests.length - 1]!; + expect(last.searchParams.get('start')).toBe('2026-07-20'); + expect(last.searchParams.get('end')).toBe('2026-07-28'); + await expect(page.locator('#history-list tbody tr')).not.toHaveCount(0); + }); +}); From a7b65a643047998f5b4fd709449b7d4acbf3deed Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 13:36:45 +0000 Subject: [PATCH 2/2] fix(frontend): keep widened history ranges within the server's 366-day cap Widening the local From/To days to the UTC days that cover them adds a day, so any off-UTC user picking a non-leap one-year range (e.g. 2025-10-24..2026-10-24 in Los Angeles) sent a 367-day span and got "date range too large" for a range the server accepts as typed. When the widened span would exceed the cap, the From side now gives the extra day back: the request keeps the user's whole To day (usually today, the newest rows) and loses only the hours of the oldest day that fall outside the server's window. A range already over the cap, or with From after To, passes through unchanged so the server reports the same error as before. The jest sandbox cannot switch zones, so the new range tests run the module in a child node under America/Los_Angeles, Pacific/Auckland and UTC, checking one-year, leap-year and cap-boundary ranges plus a sweep of To days around DST changes and Feb 29 against the server's rule. Refs #51 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/local-date.test.ts | 128 +++++++++++++++++++++- frontend/src/history.ts | 3 +- frontend/src/lib/local-date.ts | 46 +++++++- 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/frontend/src/__tests__/local-date.test.ts b/frontend/src/__tests__/local-date.test.ts index febb59522..286ce4033 100644 --- a/frontend/src/__tests__/local-date.test.ts +++ b/frontend/src/__tests__/local-date.test.ts @@ -1,10 +1,31 @@ /** * Local-calendar date input helpers (issue #51). Jest runs in * America/New_York (globalSetup), so the UTC and local days differ for the - * evening instants used here. The east-of-UTC case (From widens back a - * day) runs in Playwright under Pacific/Auckland: tests-e2e/history-local-dates.spec.ts. + * evening instants used here. The jest sandbox cannot change its own zone, + * so the multi-zone range checks below run the module in a child `node` + * with TZ set; the browser flow runs in Playwright under Pacific/Auckland + * and America/Los_Angeles: tests-e2e/history-local-dates.spec.ts. */ -import { localDayRangeToUtcDays, parseLocalDateInputValue, toLocalDateInputValue } from '../lib/local-date'; +import { execFileSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as ts from 'typescript'; +import { + MAX_HISTORY_RANGE_DAYS, + localDayRangeToUtcDays, + parseLocalDateInputValue, + toLocalDateInputValue, +} from '../lib/local-date'; + +type Range = { start: string | undefined; end: string | undefined }; + +/** parseHistoryDateRange in internal/api/handler_history.go, both sides set. */ +function serverAccepts({ start, end }: Range): boolean { + const day = 86_400_000; + const from = Date.parse(`${start}T00:00:00Z`); + const to = Date.parse(`${end}T00:00:00Z`) + day - 1000; // end of the To day + return from <= to && to - from <= MAX_HISTORY_RANGE_DAYS * day; +} describe('toLocalDateInputValue', () => { test('uses the local calendar day, not the UTC day', () => { @@ -44,4 +65,105 @@ describe('localDayRangeToUtcDays', () => { expect(localDayRangeToUtcDays('', undefined)).toEqual({ start: '', end: undefined }); expect(localDayRangeToUtcDays('yesterday', '2026-7-1')).toEqual({ start: 'yesterday', end: '2026-7-1' }); }); + + test('keeps a one-year range within the server cap (west of UTC)', () => { + // 2025-10-24..2026-10-24 is the server's 366-day maximum as typed. + // Widening the To side to Oct 25 UTC would make it 367 days and get + // "date range too large", so From gives the extra day back instead. + const range = localDayRangeToUtcDays('2025-10-24', '2026-10-24'); + expect(range).toEqual({ start: '2025-10-25', end: '2026-10-25' }); + expect(serverAccepts(range)).toBe(true); + }); + + test('passes an over-cap or inverted range through for the server to reject', () => { + expect(localDayRangeToUtcDays('2025-10-23', '2026-10-24')).toEqual({ start: '2025-10-23', end: '2026-10-24' }); + expect(localDayRangeToUtcDays('2026-07-29', '2026-07-28')).toEqual({ start: '2026-07-29', end: '2026-07-28' }); + }); +}); + +describe('localDayRangeToUtcDays in other zones', () => { + type Result = Range & { startCovered: boolean; endCovered: boolean }; + + // Transpile the module and run it in a child node with TZ set. For each + // case it reports the request and whether that request's UTC days cover + // the first and last instant of the user's local From/To days. + function runInZone(zone: string, cases: Array<[string, string]>): { offset: number; results: Result[] } { + const source = ts.transpileModule(readFileSync(join(__dirname, '../lib/local-date.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const script = ` + const m = { exports: {} }; + (function (module, exports) { ${source} })(m, m.exports); + const { localDayRangeToUtcDays, parseLocalDateInputValue } = m.exports; + const results = JSON.parse(process.argv[1]).map(([start, end]) => { + const r = localDayRangeToUtcDays(start, end); + const e = parseLocalDateInputValue(end); + const lastInstant = new Date(e.getFullYear(), e.getMonth(), e.getDate() + 1).getTime() - 1; + return { + ...r, + startCovered: Date.parse(r.start + 'T00:00:00Z') <= parseLocalDateInputValue(start).getTime(), + endCovered: Date.parse(r.end + 'T23:59:59.999Z') >= lastInstant, + }; + }); + process.stdout.write(JSON.stringify({ offset: new Date(2026, 0, 15).getTimezoneOffset(), results }));`; + const out = execFileSync(process.execPath, ['-e', script, JSON.stringify(cases)], { + env: { ...process.env, TZ: zone }, + encoding: 'utf8', + }); + return JSON.parse(out) as { offset: number; results: Result[] }; + } + + const ONE_YEAR: [string, string] = ['2025-10-24', '2026-10-24']; // non-leap, 366 days counting both ends + const LEAP_YEAR: [string, string] = ['2028-01-01', '2028-12-31']; // 366 days, includes Feb 29 + const OVER_CAP: [string, string] = ['2027-10-24', '2028-10-24']; // includes Feb 29: 367 days + const UNDER_CAP: [string, string] = ['2026-01-01', '2026-12-31']; // 365 days: room to widen + + // [zone, January UTC offset in minutes, expected ONE_YEAR, LEAP_YEAR, UNDER_CAP requests] + const zones: Array<[string, number, Range, Range, Range]> = [ + ['America/Los_Angeles', 480, + { start: '2025-10-25', end: '2026-10-25' }, + { start: '2028-01-02', end: '2029-01-01' }, + { start: '2026-01-01', end: '2027-01-01' }], + ['Pacific/Auckland', -780, + { start: '2025-10-24', end: '2026-10-24' }, + { start: '2028-01-01', end: '2028-12-31' }, + { start: '2025-12-31', end: '2026-12-31' }], + ['UTC', 0, + { start: '2025-10-24', end: '2026-10-24' }, + { start: '2028-01-01', end: '2028-12-31' }, + { start: '2026-01-01', end: '2026-12-31' }], + ]; + + test.each(zones)('%s: one-year, leap-year and cap-boundary ranges', (zone, offset, oneYear, leapYear, underCap) => { + const run = runInZone(zone, [ONE_YEAR, LEAP_YEAR, OVER_CAP, UNDER_CAP]); + expect(run.offset).toBe(offset); // the child really ran in this zone + const [one, leap, over, under] = run.results as [Result, Result, Result, Result]; + + for (const [got, want] of [[one, oneYear], [leap, leapYear], [under, underCap]] as Array<[Result, Range]>) { + expect({ start: got.start, end: got.end }).toEqual(want); + expect(serverAccepts(got)).toBe(true); + expect(got.endCovered).toBe(true); // the user's whole To day is requested + } + // Below the cap the full widening fits, so the whole From day is requested too. + expect(under.startCovered).toBe(true); + // Over the cap: sent exactly as typed, rejected exactly as before #51. + expect({ start: over.start, end: over.end }).toEqual({ start: OVER_CAP[0], end: OVER_CAP[1] }); + expect(serverAccepts(over)).toBe(false); + }); + + test.each(zones.map(([zone]) => zone))('%s: every range up to the cap is accepted and keeps its To day', zone => { + // To days around DST changes in both hemispheres, Feb 29, and today. + const ends = ['2026-03-08', '2026-04-05', '2026-09-27', '2026-11-01', '2028-02-29', '2028-03-01', '2026-10-24']; + const gaps = [0, 7, 364, MAX_HISTORY_RANGE_DAYS - 1]; + const cases = ends.flatMap(end => gaps.map((gap): [string, string] => { + const e = Date.parse(`${end}T00:00:00Z`); + return [new Date(e - gap * 86_400_000).toISOString().slice(0, 10), end]; + })); + const { results } = runInZone(zone, cases); + results.forEach((got, i) => { + const accepted = serverAccepts(got); + const covered = got.endCovered; + expect({ range: cases[i], accepted, covered }).toEqual({ range: cases[i], accepted: true, covered: true }); + }); + }); }); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 6c191654e..648e0037d 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -359,7 +359,8 @@ export async function loadHistory(): Promise { // The From/To inputs hold LOCAL calendar days, but /history filters on // whole UTC days. Widen to the UTC days covering the local range so an // off-UTC user never loses rows from the edges of the days they picked - // (issue #51). + // (issue #51), without pushing a range the server accepts as typed + // over its 366-day cap. const range = localDayRangeToUtcDays( (document.getElementById('history-start') as HTMLInputElement | null)?.value, (document.getElementById('history-end') as HTMLInputElement | null)?.value, diff --git a/frontend/src/lib/local-date.ts b/frontend/src/lib/local-date.ts index a28651f21..8ad70d17c 100644 --- a/frontend/src/lib/local-date.ts +++ b/frontend/src/lib/local-date.ts @@ -27,6 +27,21 @@ export function parseLocalDateInputValue(value: string): Date { } const DATE_VALUE = /^\d{4}-\d{2}-\d{2}$/; +const DAY_MS = 86_400_000; + +/** + * The longest range /api/history accepts (MaxHistoryDateRangeDays in + * internal/api/handler_history.go). The server reads `end` as 23:59:59 UTC + * of that day and rejects `end - start > 366 * 24h`, so the two YYYY-MM-DD + * values may be at most 365 days apart: 366 days counting both ends. + */ +export const MAX_HISTORY_RANGE_DAYS = 366; + +/** Whole days since the epoch for a YYYY-MM-DD value, ignoring time zones. */ +function dayNumber(value: string): number { + const [year, month, day] = value.split('-').map(Number); + return Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1) / DAY_MS; +} /** * Translate a local-calendar From/To pair into the UTC calendar days an API @@ -35,8 +50,17 @@ const DATE_VALUE = /^\d{4}-\d{2}-\d{2}$/; * instant is mapped to the UTC day containing it. The result is the * smallest UTC-day range that includes every row the user's local range * covers, so no row on the chosen local days is silently filtered out (it - * may include a few hours past either edge). Empty or malformed values pass - * through unchanged so the server keeps reporting its own validation error. + * may include a few hours past either edge). + * + * Widening adds a day, so a range the server accepts as typed (up to + * MAX_HISTORY_RANGE_DAYS days) could go one day over its cap. When it + * would, the From side gives the extra day back: the request then keeps + * the whole To day (usually today, the newest rows) and loses only the + * hours of the oldest day that fall outside the server's window. + * + * Empty or malformed values, a From after To, and a range already over the + * cap pass through unchanged so the server keeps reporting its own + * validation error. */ export function localDayRangeToUtcDays( start: string | undefined, @@ -48,8 +72,18 @@ export function localDayRangeToUtcDays( const nextMidnight = endDay ? new Date(endDay.getFullYear(), endDay.getMonth(), endDay.getDate() + 1).getTime() : NaN; - return { - start: startDay && !Number.isNaN(startDay.getTime()) ? utcDay(startDay.getTime()) : start, - end: Number.isFinite(nextMidnight) ? utcDay(nextMidnight - 1) : end, - }; + const hasStart = startDay !== null && !Number.isNaN(startDay.getTime()); + const hasEnd = Number.isFinite(nextMidnight); + let utcStart = hasStart ? utcDay(startDay.getTime()) : start; + const utcEnd = hasEnd ? utcDay(nextMidnight - 1) : end; + if (hasStart && hasEnd) { + const maxGap = MAX_HISTORY_RANGE_DAYS - 1; + const localGap = dayNumber(end as string) - dayNumber(start as string); + if (localGap < 0 || localGap > maxGap) return { start, end }; + const lastDay = dayNumber(utcEnd as string); + if (lastDay - dayNumber(utcStart as string) > maxGap) { + utcStart = utcDay((lastDay - maxGap) * DAY_MS); + } + } + return { start: utcStart, end: utcEnd }; }