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
16 changes: 8 additions & 8 deletions src/lib/pdf/parseDeviceInstant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
27 changes: 14 additions & 13 deletions src/lib/services/DeviceDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,34 +690,35 @@ 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');

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 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;
}

/**
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 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', () => {
Expand Down
Loading