From e704b5be807ca34e49c5370822f4c8d3c02bfba4 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 19 Nov 2025 10:54:16 +0900 Subject: [PATCH] more timezone fixes to report generator --- src/lib/pdf/parseDeviceInstant.ts | 31 ++++++++++++++++ src/lib/services/DeviceDataService.ts | 6 +++- src/lib/tests/PDFReportTimezone.test.ts | 35 +++++++++++++++++++ src/lib/tests/ParseDeviceInstant.test.ts | 19 ++++++++++ .../api/devices/[devEui]/pdf/+server.ts | 22 +----------- 5 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 src/lib/pdf/parseDeviceInstant.ts create mode 100644 src/lib/tests/ParseDeviceInstant.test.ts diff --git a/src/lib/pdf/parseDeviceInstant.ts b/src/lib/pdf/parseDeviceInstant.ts new file mode 100644 index 00000000..0f3b6db4 --- /dev/null +++ b/src/lib/pdf/parseDeviceInstant.ts @@ -0,0 +1,31 @@ +import { DateTime } from 'luxon'; + +const tzOffsetPattern = /([zZ]|[+\-]\d{2}:?\d{2})$/; + +/** + * 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. + */ +export function parseDeviceInstant(input: string | Date, tz: string): DateTime { + if (input instanceof Date) { + return DateTime.fromJSDate(input, { zone: 'utc' }).setZone(tz); + } + + if (typeof input !== 'string') { + 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 }); + 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' }); + return dt.setZone(tz); +} diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts index 6f32da3d..9355a24a 100644 --- a/src/lib/services/DeviceDataService.ts +++ b/src/lib/services/DeviceDataService.ts @@ -427,7 +427,11 @@ export class DeviceDataService implements IDeviceDataService { } ]; } - return data as DeviceDataRecord[]; + + const records = data as DeviceDataRecord[]; + const hasTrafficHourField = records.some((record) => 'traffic_hour' in record); + const tableNameForConversion = hasTrafficHourField ? 'cw_traffic2' : 'report_data'; + return this.convertRecordTimestampsToUserTimezone(records, timezone, tableNameForConversion); } catch (error) { this.errorHandler.logError(error as Error); if (error instanceof Error && error.message.includes('AbortError')) { diff --git a/src/lib/tests/PDFReportTimezone.test.ts b/src/lib/tests/PDFReportTimezone.test.ts index bdc04790..365ef166 100644 --- a/src/lib/tests/PDFReportTimezone.test.ts +++ b/src/lib/tests/PDFReportTimezone.test.ts @@ -226,6 +226,41 @@ describe('PDF Report Temperature Device Timezone Tests', () => { }); } }); + + it('should convert report data timestamps to the requested timezone', async () => { + const rpcRows: DeviceDataRecord[] = [ + { + dev_eui: tempDeviceEui, + created_at: '2025-11-08T15:00:00Z', + temperature_c: 24.2 + }, + { + dev_eui: tempDeviceEui, + created_at: '2025-11-08T14:30:00Z', + temperature_c: 22.9 + } + ]; + + (mockSupabase.rpc as any).mockResolvedValueOnce({ + data: rpcRows, + error: null + }); + + const startDate = new Date('2025-11-08T00:00:00.000Z'); + const endDate = new Date('2025-11-09T00:00:00.000Z'); + const result = await deviceDataService.getDeviceDataForReport({ + devEui: tempDeviceEui, + startDate, + endDate, + timezone, + intervalMinutes: 30 + }); + + expect(result).toHaveLength(2); + expect(result[0].created_at).toContain('+09:00'); + expect(DateTime.fromISO(result[0].created_at).toISO()).toBe('2025-11-09T00:00:00.000+09:00'); + expect(DateTime.fromISO(result[1].created_at).toISO()).toBe('2025-11-08T23:30:00.000+09:00'); + }); }); describe('PDF Report Data Sorting and Formatting', () => { diff --git a/src/lib/tests/ParseDeviceInstant.test.ts b/src/lib/tests/ParseDeviceInstant.test.ts new file mode 100644 index 00000000..822f0d00 --- /dev/null +++ b/src/lib/tests/ParseDeviceInstant.test.ts @@ -0,0 +1,19 @@ +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', () => { + const dt = parseDeviceInstant('2025-11-08T15:00:00', 'Asia/Tokyo'); + expect(dt.toISO()).toBe('2025-11-09T00:00:00.000+09:00'); + }); + + it('respects explicit offsets on the source timestamp', () => { + const dt = parseDeviceInstant('2025-11-08T15:00:00-05:00', 'Asia/Tokyo'); + expect(dt.toISO()).toBe('2025-11-09T05:00:00.000+09:00'); + }); + + it('handles Date objects by treating them as UTC instants', () => { + const dt = parseDeviceInstant(new Date('2025-11-08T15:00:00Z'), 'Asia/Tokyo'); + expect(dt.toISO()).toBe('2025-11-09T00:00:00.000+09:00'); + }); +}); diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts index afe19ee4..8a79b22a 100644 --- a/src/routes/api/devices/[devEui]/pdf/+server.ts +++ b/src/routes/api/devices/[devEui]/pdf/+server.ts @@ -11,6 +11,7 @@ import { import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; import { createPDFLineChartImage } from '$lib/pdf/pdfLineChartImage'; import { checkMatch, getValue } from '$lib/pdf/utils'; +import { parseDeviceInstant } from '$lib/pdf/parseDeviceInstant'; import { DeviceRepository } from '$lib/repositories/DeviceRepository'; import { LocationRepository } from '$lib/repositories/LocationRepository'; import { DeviceDataService } from '$lib/services/DeviceDataService'; @@ -28,27 +29,6 @@ import type { RequestHandler } from './$types'; import { drawSummaryPanel } from './drawSummaryPanel'; import { drawRightAlertPanel } from './drawRightAlertPanel'; -const tzOffsetPattern = /([zZ]|[+\-]\d{2}:\d{2}|[+\-]\d{4}|[+\-]\d{2})$/; -/** - * Parse a device timestamp into a Luxon DateTime in the target zone. - * - If input has an offset (or 'Z'), respect it then convert to tz. - * - If no offset, treat it as zoned in tz (not UTC) for local semantics. - */ -function parseDeviceInstant(input: string | Date, tz: string): DateTime { - if (input instanceof Date) { - return DateTime.fromJSDate(input, { zone: 'utc' }).setZone(tz); - } - let dt: DateTime; - if (tzOffsetPattern.test(input)) { - dt = DateTime.fromISO(input, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromSQL(input, { setZone: true }); - return dt.setZone(tz); - } - dt = DateTime.fromISO(input, { zone: tz }); - if (!dt.isValid) dt = DateTime.fromSQL(input, { zone: tz }); - return dt; -} - /** * JWT-authenticated PDF generation endpoint for device data reports * Designed for server-to-server calls (Node-RED, automation tools, etc.)