diff --git a/mobile/openapi/lib/model/database_backup_dto.dart b/mobile/openapi/lib/model/database_backup_dto.dart index 4bf231587b2a7..34912a55e0472 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 38e1fe8e01777..3f740b161fe95 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -17671,11 +17671,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 1ae12cd0911e2..dab6c9aa22ae0 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 dc06cdc6ec69f..c0554f83b7084 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 3c964c950c767..e8eb16de1d7af 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -281,6 +281,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)) @@ -290,7 +291,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 0000000000000..73e9e00546fef --- /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 fd3420d199c00..5aa00d210e407 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 45b475c22ad9d..fbadcef31cebf 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}