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', () => {