From 38bfeac4fadbba3f037249b250a924fa2376c084 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 19 Nov 2025 12:23:47 +0900 Subject: [PATCH] =?UTF-8?q?The=20timestamps=20coming=20from=20Supabase=20w?= =?UTF-8?q?ere=20being=20emitted=20without=20a=20timezone=20suffix,=20so?= =?UTF-8?q?=20Vercel=20(UTC)=20interpreted=20them=20differently=20than=20y?= =?UTF-8?q?our=20JST=20laptop.=20I=20updated=20the=20normalization=20logic?= =?UTF-8?q?=20so=20every=20=E2=80=9Cfloating=E2=80=9D=20timestamp=20is=20n?= =?UTF-8?q?ow=20treated=20as=20UTC=20before=20being=20converted=20into=20t?= =?UTF-8?q?he=20user=E2=80=99s=20timezone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/pdf/parseDeviceInstant.ts | 16 +++++++------- src/lib/services/DeviceDataService.ts | 27 ++++++++++++------------ src/lib/tests/ParseDeviceInstant.test.ts | 4 ++-- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/lib/pdf/parseDeviceInstant.ts b/src/lib/pdf/parseDeviceInstant.ts index beb9a007..30bd8198 100644 --- a/src/lib/pdf/parseDeviceInstant.ts +++ b/src/lib/pdf/parseDeviceInstant.ts @@ -6,11 +6,11 @@ export const timestampHasExplicitOffset = (value: string): boolean => tzOffsetPattern.test(value.trim()); /** - * Normalize device timestamps into the requested timezone. When a timestamp - * string lacks an explicit offset we treat it as already representing the - * user's chosen timezone and simply attach that zone so the rendering is - * independent of the host machine's locale (critical on Vercel where the - * runtime is UTC). + * Normalize device timestamps into the requested timezone. Strings without an + * explicit offset are assumed to be stored in UTC (Postgres default) and are + * therefore converted from UTC into the caller's timezone so that report data + * always represents the user's day (JST, etc.) regardless of where the server + * runs. */ export function parseDeviceInstant(input: string | Date, tz: string): DateTime { if (input instanceof Date) { @@ -33,8 +33,8 @@ export function parseDeviceInstant(input: string | Date, tz: string): DateTime { return dt.setZone(tz); } - let dt = DateTime.fromISO(value, { zone: tz }); - if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: tz }); - if (!dt.isValid) dt = DateTime.fromRFC2822(value, { zone: tz }); + let dt = DateTime.fromISO(value, { zone: 'utc' }); + if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: 'utc' }); + if (!dt.isValid) dt = DateTime.fromRFC2822(value, { zone: 'utc' }); return dt.setZone(tz); } diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts index 45aa836a..009a3ba1 100644 --- a/src/lib/services/DeviceDataService.ts +++ b/src/lib/services/DeviceDataService.ts @@ -690,11 +690,16 @@ export class DeviceDataService implements IDeviceDataService { const asIso = DateTime.fromJSDate(utcTimestamp, { zone: 'utc' }).setZone(timezone).toISO(); return asIso ?? utcTimestamp.toISOString(); } + + const normalized = + typeof utcTimestamp === 'string' ? utcTimestamp.trim() : String(utcTimestamp); + if (!normalized) { + return normalized; + } if (timezone === 'UTC') { - return typeof utcTimestamp === 'string' ? utcTimestamp : String(utcTimestamp); + return normalized; } - const normalized = utcTimestamp.trim(); const hasOffset = this.timezoneOffsetPattern.test(normalized); let dt = DateTime.invalid('unparsed'); @@ -702,22 +707,18 @@ export class DeviceDataService implements IDeviceDataService { dt = DateTime.fromISO(normalized, { setZone: true }); if (!dt.isValid) dt = DateTime.fromSQL(normalized, { setZone: true }); if (!dt.isValid) dt = DateTime.fromRFC2822(normalized, { setZone: true }); - if (!dt.isValid) { - console.warn(`Failed to parse timestamp with offset: ${utcTimestamp}`); - return normalized; - } - const converted = dt.setZone(timezone).toISO(); - return converted ?? normalized; + } else { + dt = DateTime.fromISO(normalized, { zone: 'utc' }); + if (!dt.isValid) dt = DateTime.fromSQL(normalized, { zone: 'utc' }); + if (!dt.isValid) dt = DateTime.fromRFC2822(normalized, { zone: 'utc' }); } - dt = DateTime.fromISO(normalized, { zone: timezone }); - if (!dt.isValid) dt = DateTime.fromSQL(normalized, { zone: timezone }); - if (!dt.isValid) dt = DateTime.fromRFC2822(normalized, { zone: timezone }); if (!dt.isValid) { - console.warn(`Failed to interpret timestamp without offset for ${timezone}: ${utcTimestamp}`); + console.warn(`Failed to parse timestamp: ${utcTimestamp}`); return normalized; } - return dt.toISO() ?? normalized; + + return dt.setZone(timezone).toISO() ?? normalized; } /** diff --git a/src/lib/tests/ParseDeviceInstant.test.ts b/src/lib/tests/ParseDeviceInstant.test.ts index c11784f4..4a2f790d 100644 --- a/src/lib/tests/ParseDeviceInstant.test.ts +++ b/src/lib/tests/ParseDeviceInstant.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'; import { parseDeviceInstant } from '$lib/pdf/parseDeviceInstant'; describe('parseDeviceInstant', () => { - it('treats offset-less ISO timestamps as local values in the requested zone', () => { + it('treats offset-less ISO timestamps as UTC then converts to the requested zone', () => { const dt = parseDeviceInstant('2025-11-08T15:00:00', 'Asia/Tokyo'); - expect(dt.toISO()).toBe('2025-11-08T15:00:00.000+09:00'); + expect(dt.toISO()).toBe('2025-11-09T00:00:00.000+09:00'); }); it('respects explicit offsets on the source timestamp', () => {