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
29 changes: 19 additions & 10 deletions src/lib/pdf/parseDeviceInstant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
47 changes: 29 additions & 18 deletions src/lib/services/DeviceDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/lib/tests/ParseDeviceInstant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading