Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 51 additions & 18 deletions frontend/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'
});
});
Expand Down
169 changes: 169 additions & 0 deletions frontend/src/__tests__/local-date.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
});
24 changes: 1 addition & 23 deletions frontend/src/apikeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -301,29 +302,6 @@ export async function showCreateKeyModal(): Promise<void> {
updateCreateKeySubmitState();
}

/**
* Formats a Date as the YYYY-MM-DD value an <input type="date"> 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 <input type="date"> 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
*/
Expand Down
25 changes: 18 additions & 7 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -303,7 +304,8 @@ export async function viewPlanHistory(planId: string): Promise<void> {
* 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 `<input type="date">`.
* 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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -357,9 +359,18 @@ export async function loadHistory(): Promise<void> {
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
};
Expand Down
Loading
Loading