From 95c1f0efeb190422d35711ddbe12c0dc20e108c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Mendon=C3=A7a=20Ribeiro?= Date: Mon, 6 Apr 2026 16:27:48 +0100 Subject: [PATCH] fix: timestamp handling for database backup in Web UI (#27359) * Fix #26502: Fix timestamp handling for database backup in Web UI Frontend parsed backup timestamps as UTC, but they were in the server's local timezone, causing wrong relative times. Add `timezone` field to DatabaseBackupDto to expose server timezone. Update frontend to parse timestamps using this timezone. Convert timestamps to user's local timezone before rendering. Fallback to browser timezone if server timezone is missing. Ensures correct relative time display in Web UI. * fix: regenerate open-api types and remove custom backup type - Ran `make open-api` to update types based on backend changes - Removed custom BackupWithTimezone type in MaintenanceBackupsList - Updated timezone props to use the newly generated native type * fix: simplify timezone handling for database backups - Updated DatabaseBackupDto to make timezone a required property - Removed manual DateTime.local().zoneName fallbacks - Cleaned up type casts after regenerating OpenAPI types * fix: Add missing newline at end of spec file --- .../lib/model/database_backup_dto.dart | 14 +++-- open-api/immich-openapi-specs.json | 6 ++- open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/database-backup.dto.ts | 1 + .../src/services/database-backup.service.ts | 3 +- .../MaintenanceBackupEntry.spec.ts | 54 +++++++++++++++++++ .../maintenance/MaintenanceBackupEntry.svelte | 5 +- .../maintenance/MaintenanceBackupsList.svelte | 4 +- 8 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts diff --git a/mobile/openapi/lib/model/database_backup_dto.dart b/mobile/openapi/lib/model/database_backup_dto.dart index 4bf231587b..34912a55e0 100644 --- a/mobile/openapi/lib/model/database_backup_dto.dart +++ b/mobile/openapi/lib/model/database_backup_dto.dart @@ -15,30 +15,36 @@ class DatabaseBackupDto { DatabaseBackupDto({ required this.filename, required this.filesize, + required this.timezone, }); String filename; num filesize; + String timezone; + @override bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto && other.filename == filename && - other.filesize == filesize; + other.filesize == filesize && + other.timezone == timezone; @override int get hashCode => // ignore: unnecessary_parenthesis (filename.hashCode) + - (filesize.hashCode); + (filesize.hashCode) + + (timezone.hashCode); @override - String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]'; + String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize, timezone=$timezone]'; Map toJson() { final json = {}; json[r'filename'] = this.filename; json[r'filesize'] = this.filesize; + json[r'timezone'] = this.timezone; return json; } @@ -53,6 +59,7 @@ class DatabaseBackupDto { return DatabaseBackupDto( filename: mapValueOfType(json, r'filename')!, filesize: num.parse('${json[r'filesize']}'), + timezone: mapValueOfType(json, r'timezone')!, ); } return null; @@ -102,6 +109,7 @@ class DatabaseBackupDto { static const requiredKeys = { 'filename', 'filesize', + 'timezone', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 19427413b0..d975189609 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -17721,11 +17721,15 @@ }, "filesize": { "type": "number" + }, + "timezone": { + "type": "string" } }, "required": [ "filename", - "filesize" + "filesize", + "timezone" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fc465ce529..1541ec828e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -63,6 +63,7 @@ export type DatabaseBackupDeleteDto = { export type DatabaseBackupDto = { filename: string; filesize: number; + timezone: string; }; export type DatabaseBackupListResponseDto = { backups: DatabaseBackupDto[]; diff --git a/server/src/dtos/database-backup.dto.ts b/server/src/dtos/database-backup.dto.ts index dc06cdc6ec..c0554f83b7 100644 --- a/server/src/dtos/database-backup.dto.ts +++ b/server/src/dtos/database-backup.dto.ts @@ -4,6 +4,7 @@ import { IsString } from 'class-validator'; export class DatabaseBackupDto { filename!: string; filesize!: number; + timezone!: string; } export class DatabaseBackupListResponseDto { diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index 666ddcff0a..6afc46c7da 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -283,6 +283,7 @@ export class DatabaseBackupService { async listBackups(): Promise { const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const files = await this.storageRepository.readdir(backupsFolder); + const timezone = DateTime.local().zoneName; const validFiles = files .filter((fn) => isValidDatabaseBackupName(fn)) @@ -292,7 +293,7 @@ export class DatabaseBackupService { const backups = await Promise.all( validFiles.map(async (filename) => { const stats = await this.storageRepository.stat(path.join(backupsFolder, filename)); - return { filename, filesize: stats.size }; + return { filename, filesize: stats.size, timezone }; }), ); diff --git a/web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts b/web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts new file mode 100644 index 0000000000..73e9e00546 --- /dev/null +++ b/web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts @@ -0,0 +1,54 @@ +import { locale } from '$lib/stores/preferences.store'; +import { renderWithTooltips } from '$tests/helpers'; +import { screen } from '@testing-library/svelte'; +import { DateTime } from 'luxon'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte'; + +vi.mock('$lib/services/database-backups.service', () => ({ + getDatabaseBackupActions: () => ({ + Download: { type: 'command', title: 'Download', onAction: vi.fn() }, + Delete: { type: 'command', title: 'Delete', onAction: vi.fn() }, + }), + handleRestoreDatabaseBackup: vi.fn(), +})); + +describe('MaintenanceBackupEntry', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-24T12:00:00Z')); + locale.set('en'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders relative backup time using the user timezone instead of UTC', () => { + const backupTimestamp = '20260324T110000'; + + const expectedRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", { + zone: 'Asia/Tokyo', + }) + .toLocal() + .toRelative({ locale: 'en' }); + + const utcRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", { + zone: 'UTC', + }) + .toLocal() + .toRelative({ locale: 'en' }); + + expect(expectedRelativeTime).toBeTruthy(); + expect(expectedRelativeTime).not.toEqual(utcRelativeTime); + + renderWithTooltips(MaintenanceBackupEntry, { + expectedVersion: '1.2.3', + filename: 'immich-db-backup-20260324T110000-v1.2.3-snapshot.sql.gz', + filesize: 1024, + timezone: 'Asia/Tokyo', + }); + + expect(screen.getByText(expectedRelativeTime!)).toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte b/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte index fd3420d199..5aa00d210e 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte +++ b/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte @@ -13,16 +13,17 @@ filename: string; filesize: number; expectedVersion: string; + timezone?: string; }; - const { filename, filesize, expectedVersion }: Props = $props(); + const { filename, filesize, expectedVersion, timezone }: Props = $props(); const filesizeText = $derived(getBytesWithUnit(filesize, 1)); const backupDateTime = $derived.by(() => { const dateMatch = filename.match(/\d+T\d+/); if (dateMatch) { - return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' }).toLocal(); + return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal(); } return null; }); diff --git a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte index 45b475c22a..fbadcef31c 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte +++ b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte @@ -51,12 +51,13 @@ const unknownDateKey = $t('unknown_date'); for (const backup of backups) { + const timezone = backup.timezone; const dateMatch = backup.filename.match(/\d+T\d+/); let dateKey: string; let dt: DateTime; if (dateMatch) { - dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' }); + dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }); dateKey = dt.toFormat('LLLL d, yyyy'); } else { dt = DateTime.fromMillis(0); @@ -128,6 +129,7 @@ filename={backup.filename} filesize={backup.filesize} expectedVersion={props.expectedVersion} + timezone={backup.timezone} /> {/each}