Fix off-by-one dates: new Date("2026-08-05") parses UTC, toLocaleString is local
The problem
new Date('2026-08-05').getDate() returned 4 on any machine behind UTC: date-only strings parse as UTC midnight, then local-time accessors shift them a day. Group-by-day buckets silently merged events, and one report was off by one for every US user.
What didn't work
toISOString().slice(0, 10)for display — always UTC, so an 11pm ET event shows as the NEXT day.new Date(y, m-1, d)in one file andnew Date(dateString)in another — two different instants representing "the same day", diverging by timezone.- Adding +1 day "where it's wrong" — fixes users behind UTC and breaks users ahead of it (Tokyo now shows +2).
The fix
// date-only values: never pass them through the TZ machinery
function parseDateOnly(s: string): { y: number; m: number; d: number } {
const [y, m, d] = s.split('-').map(Number);
return { y, m, d }; // compare/store these fields directly; no Date object needed
}
// instants: format IN the chosen zone instead of shifting the Date
function dateKey(iso: string, tz: string = 'UTC'): string {
return new Intl.DateTimeFormat('en-CA', { // 'en-CA' yields YYYY-MM-DD
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date(iso));
}
dateKey('2026-08-05T23:30:00-04:00'); // '2026-08-05' (UTC bucket)
dateKey('2026-08-05T23:30:00-04:00', 'Asia/Tokyo'); // '2026-08-06' — intended, not a bug
Why it works
The bug is mixing a UTC-parsed string with local-time accessors; keeping date-only values as plain field triplets and formatting instants through Intl.DateTimeFormat with an explicit timeZone removes every implicit UTC↔local conversion from the code.