diff --git a/frontend/src/__tests__/history.test.ts b/frontend/src/__tests__/history.test.ts index 564e4ba3e..96e03b554 100644 --- a/frontend/src/__tests__/history.test.ts +++ b/frontend/src/__tests__/history.test.ts @@ -131,28 +131,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', () => { @@ -263,6 +279,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. @@ -300,9 +330,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..286ce4033 --- /dev/null +++ b/frontend/src/__tests__/local-date.test.ts @@ -0,0 +1,169 @@ +/** + * 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 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 { 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', () => { + 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' }); + }); + + 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/apikeys.ts b/frontend/src/apikeys.ts index 7bd53c4b1..90caa41ec 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[] = []; @@ -301,29 +302,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 dede41d37..60a522a90 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -15,6 +15,7 @@ import { renderApprovalQueueLink, wireApprovalQueueLinks } from './history-queue 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 { @@ -235,10 +236,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); } } @@ -303,7 +304,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; @@ -313,8 +315,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; } @@ -357,9 +359,18 @@ 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), 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, + ); 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..8ad70d17c --- /dev/null +++ b/frontend/src/lib/local-date.ts @@ -0,0 +1,89 @@ +/** + * 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}$/; +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 + * 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). + * + * 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, + 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; + 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 }; +} 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); + }); +});