diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx index 4e88406..e479ad8 100644 --- a/frontend/src/components/stats/SessionList.tsx +++ b/frontend/src/components/stats/SessionList.tsx @@ -4,7 +4,7 @@ import { useSettings } from '../../hooks/useSettings'; import { Pagination } from './Pagination'; import { Edit2, Check, X, Trash2 } from 'lucide-react'; import type { SleepSessionRead } from '../../types'; -import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes, toUTCFromLocalInput } from '../../utils/time'; +import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes, toUTCFromLocalInput, parseBackendDateTime } from '../../utils/time'; export function SessionList() { const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions(); @@ -57,8 +57,8 @@ export function SessionList() { const startEditing = (s: SleepSessionRead) => { setEditingId(s.id); - setEditStart(toLocalDatetimeInputValue(new Date(s.start_time))); - setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : ''); + setEditStart(toLocalDatetimeInputValue(parseBackendDateTime(s.start_time))); + setEditEnd(s.end_time ? toLocalDatetimeInputValue(parseBackendDateTime(s.end_time)) : ''); }; const handleCancel = () => { @@ -161,6 +161,6 @@ export function SessionList() { onPageChange={setCurrentPage} /> )} - + ); } diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts index e4530e1..724fadb 100644 --- a/frontend/src/utils/time.ts +++ b/frontend/src/utils/time.ts @@ -198,3 +198,15 @@ export function formatMinutes(totalMinutes: number): string { } return `${mins}m`; } + +/** + * Parses a backend timestamp string into a Date object, + * ensuring it is treated as UTC if no timezone info is present. + */ +export function parseBackendDateTime(time: string): Date { + if (typeof time === 'string' && !time.endsWith('Z') && !time.includes('+')) { + return new Date(time + 'Z'); + } + // If it already has Z or offset, new Date() will handle it correctly. + return new Date(time); +} diff --git a/frontend/tests/unit/time.test.ts b/frontend/tests/unit/time.test.ts index 5362c55..2959466 100644 --- a/frontend/tests/unit/time.test.ts +++ b/frontend/tests/unit/time.test.ts @@ -13,6 +13,7 @@ import { getLocalOffsetFromReference, formatDateTime, toUTCFromLocalInput, + parseBackendDateTime, } from '../../src/utils/time'; describe('formatMinutesAfter', () => { @@ -167,4 +168,29 @@ describe('toUTCFromLocalInput', () => { expect(toUTCFromLocalInput('2026-06-0')).toBe(''); }); }); +describe('parseBackendDateTime', () => { + it('should parse a UTC string without Z as UTC', () => { + const time = '2026-06-04 13:58:00'; + const date = parseBackendDateTime(time); + expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z'); + }); + + it('should parse a UTC string with Z as UTC', () => { + const time = '2026-06-04T13:58:00Z'; + const date = parseBackendDateTime(time); + expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z'); + }); + + it('should parse a string with an offset correctly', () => { + const time = '2026-06-04T13:58:00+02:00'; + const date = parseBackendDateTime(time); + expect(date.toISOString()).toBe('2026-06-04T11:58:00.000Z'); + }); + + it('should handle ISO format with T', () => { + const time = '2026-06-04T13:58:00'; + const date = parseBackendDateTime(time); + expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z'); + }); +}); diff --git a/frontend/tests/unit/timezone_fix.test.ts b/frontend/tests/unit/timezone_fix.test.ts new file mode 100644 index 0000000..918651f --- /dev/null +++ b/frontend/tests/unit/timezone_fix.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { toLocalDatetimeInputValue } from '../../src/utils/time'; + +describe('toLocalDatetimeInputValue - Timezone Correctness', () => { + it('converts a UTC date to the correct local datetime-local input value (Stockholm)', () => { + // 11:58 UTC on June 4th, 2026 should be 13:58 in Stockholm (UTC+2) + const date = new Date('2026-06-04T11:58:00Z'); + + // We calculate the expected value using a deterministic formatter with explicit timezone + const expected = new Intl.DateTimeFormat('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'Europe/Stockholm' + }).format(date).replace(' ', 'T'); + + const result = toLocalDatetimeInputValue(date); + + // This assertion will fail if the function returns UTC instead of Stockholm local time + expect(result).toBe(expected); + }); + + it('converts a UTC date to the correct local datetime-local input value (other timezone)', () => { + // Testing with another timezone to ensure it's not hardcoded (though it shouldn't be) + const date = new Date('2026-06-04T11:58:00Z'); + + // Tokyo is UTC+9. 11:58 UTC -> 20:58 Tokyo + const expectedTokyo = new Intl.DateTimeFormat('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'Asia/Tokyo' + }).format(date).replace(' ', 'T'); + + const result = toLocalDatetimeInputValue(date); + + // NOTE: This second test might fail if the environment is NOT Tokyo/Stockholm, + // but it's useful to show that the current implementation depends on runtime TZ. + // However, for our specific bug fix, we only need to prove it's NOT necessarily local if the runner is UTC. + // Actually, let's just stick to one robust test that proves it doesn't match the intended local time if the runner is UTC. + }); +});