From 71dd1eccb8f33f56bdf301dcacbef6ebc4e76d11 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 19 Nov 2025 11:59:32 +0900 Subject: [PATCH] attempting to adjust for vercel timezone --- src/lib/pdf/parseDeviceInstant.ts | 29 ++++++++++----- src/lib/services/DeviceDataService.ts | 47 +++++++++++++++--------- src/lib/tests/ParseDeviceInstant.test.ts | 4 +- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/lib/pdf/parseDeviceInstant.ts b/src/lib/pdf/parseDeviceInstant.ts index 0f3b6db4..beb9a007 100644 --- a/src/lib/pdf/parseDeviceInstant.ts +++ b/src/lib/pdf/parseDeviceInstant.ts @@ -2,11 +2,15 @@ import { DateTime } from 'luxon'; const tzOffsetPattern = /([zZ]|[+\-]\d{2}:?\d{2})$/; +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 UTC (the format returned by - * Supabase/Postgres) and then convert it into the provided timezone so the PDF - * table always displays local times. + * 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). */ export function parseDeviceInstant(input: string | Date, tz: string): DateTime { if (input instanceof Date) { @@ -17,15 +21,20 @@ export function parseDeviceInstant(input: string | Date, tz: string): DateTime { return DateTime.invalid('Unsupported timestamp type'); } - if (tzOffsetPattern.test(input)) { - let dt = DateTime.fromISO(input, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromSQL(input, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromRFC2822(input, { setZone: true }); + const value = input.trim(); + if (!value) { + return DateTime.invalid('Empty timestamp'); + } + + if (timestampHasExplicitOffset(value)) { + let dt = DateTime.fromISO(value, { setZone: true }); + if (!dt.isValid) dt = DateTime.fromSQL(value, { setZone: true }); + if (!dt.isValid) dt = DateTime.fromRFC2822(value, { setZone: true }); return dt.setZone(tz); } - let dt = DateTime.fromISO(input, { zone: 'utc' }); - if (!dt.isValid) dt = DateTime.fromSQL(input, { zone: 'utc' }); - if (!dt.isValid) dt = DateTime.fromRFC2822(input, { zone: 'utc' }); + 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 }); return dt.setZone(tz); } diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts index 9355a24a..45aa836a 100644 --- a/src/lib/services/DeviceDataService.ts +++ b/src/lib/services/DeviceDataService.ts @@ -680,33 +680,44 @@ export class DeviceDataService implements IDeviceDataService { * @param timezone User's timezone string * @returns Formatted timestamp in user's timezone */ - private convertUTCToUserTimezone(utcTimestamp: string, timezone: string): string { + private readonly timezoneOffsetPattern = /([zZ]|[+\-]\d{2}:?\d{2})$/; + + private convertUTCToUserTimezone(utcTimestamp: string | Date, timezone: string): string { + if (!utcTimestamp) { + return ''; + } + if (utcTimestamp instanceof Date) { + const asIso = DateTime.fromJSDate(utcTimestamp, { zone: 'utc' }).setZone(timezone).toISO(); + return asIso ?? utcTimestamp.toISOString(); + } if (timezone === 'UTC') { - return utcTimestamp; // No conversion needed + return typeof utcTimestamp === 'string' ? utcTimestamp : String(utcTimestamp); } - // Parse UTC timestamp and convert to user timezone - let dt = DateTime.fromISO(utcTimestamp, { zone: 'UTC' }); + const normalized = utcTimestamp.trim(); + const hasOffset = this.timezoneOffsetPattern.test(normalized); + let dt = DateTime.invalid('unparsed'); - if (!dt.isValid) { - // Try parsing as SQL format if ISO fails - dt = DateTime.fromSQL(utcTimestamp, { zone: 'UTC' }); + if (hasOffset) { + 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: ${utcTimestamp}`); - return utcTimestamp; // Return original if parsing fails + console.warn(`Failed to parse timestamp with offset: ${utcTimestamp}`); + return normalized; } + const converted = dt.setZone(timezone).toISO(); + return converted ?? normalized; } - // Convert to user timezone and return as ISO string - const converted = dt.setZone(timezone); - const result = converted.toISO(); - - if (!result) { - console.warn(`Failed to convert timestamp to timezone ${timezone}: ${utcTimestamp}`); - return utcTimestamp; + 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}`); + return normalized; } - - return result; + return dt.toISO() ?? normalized; } /** diff --git a/src/lib/tests/ParseDeviceInstant.test.ts b/src/lib/tests/ParseDeviceInstant.test.ts index 822f0d00..c11784f4 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 UTC before converting to target zone', () => { + it('treats offset-less ISO timestamps as local values in the requested zone', () => { const dt = parseDeviceInstant('2025-11-08T15:00:00', 'Asia/Tokyo'); - expect(dt.toISO()).toBe('2025-11-09T00:00:00.000+09:00'); + expect(dt.toISO()).toBe('2025-11-08T15:00:00.000+09:00'); }); it('respects explicit offsets on the source timestamp', () => {