Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/lib/pdf/parseDeviceInstant.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 5 additions & 1 deletion src/lib/services/DeviceDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
35 changes: 35 additions & 0 deletions src/lib/tests/PDFReportTimezone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
19 changes: 19 additions & 0 deletions src/lib/tests/ParseDeviceInstant.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
22 changes: 1 addition & 21 deletions src/routes/api/devices/[devEui]/pdf/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.)
Expand Down
Loading