From 439b7fe3be8a89999df94e6bbdb538adeefaab02 Mon Sep 17 00:00:00 2001 From: Mees Frensel Date: Wed, 10 Jun 2026 14:20:35 +0200 Subject: [PATCH] move setLocale and add comment --- .../openapi/lib/model/album_response_dto.dart | 4 +-- open-api/immich-openapi-specs.json | 4 +-- packages/sdk/src/fetch-client.ts | 4 +-- server/src/dtos/album.dto.ts | 12 +++++++-- web/src/lib/utils/date-time.spec.ts | 25 ++++++++++++------- web/src/lib/utils/date-time.ts | 12 ++++++--- 6 files changed, 41 insertions(+), 20 deletions(-) diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index a7e350fd53..8424d05289 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -54,7 +54,7 @@ class AlbumResponseDto { /// Album description String description; - /// End date (latest asset) + /// UTC representation of (local) end date (latest asset) /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -92,7 +92,7 @@ class AlbumResponseDto { /// Is shared album bool shared; - /// Start date (earliest asset) + /// UTC representation of (local) start date (earliest asset) /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e51914d5b4..4ad0dad4d9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16235,7 +16235,7 @@ "type": "string" }, "endDate": { - "description": "End date (latest asset)", + "description": "UTC representation of (local) end date (latest asset)", "format": "date-time", "type": "string" }, @@ -16264,7 +16264,7 @@ "type": "boolean" }, "startDate": { - "description": "Start date (earliest asset)", + "description": "UTC representation of (local) start date (earliest asset)", "format": "date-time", "type": "string" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 720d741ebd..2f25039d31 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -484,7 +484,7 @@ export type AlbumResponseDto = { createdAt: string; /** Album description */ description: string; - /** End date (latest asset) */ + /** UTC representation of (local) end date (latest asset) */ endDate?: string; /** Has shared link */ hasSharedLink: boolean; @@ -497,7 +497,7 @@ export type AlbumResponseDto = { order?: AssetOrder; /** Is shared album */ shared: boolean; - /** Start date (earliest asset) */ + /** UTC representation of (local) start date (earliest asset) */ startDate?: string; /** Last update date */ updatedAt: string; diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index ee6f2c07eb..afc8bb3e06 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -131,9 +131,17 @@ export const AlbumResponseSchema = z .optional() .describe('Last modified asset timestamp'), // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - startDate: z.string().meta({ format: 'date-time' }).optional().describe('Start date (earliest asset)'), + startDate: z + .string() + .meta({ format: 'date-time' }) + .optional() + .describe('UTC representation of (local) start date (earliest asset)'), // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - endDate: z.string().meta({ format: 'date-time' }).optional().describe('End date (latest asset)'), + endDate: z + .string() + .meta({ format: 'date-time' }) + .optional() + .describe('UTC representation of (local) end date (latest asset)'), isActivityEnabled: z.boolean().describe('Activity feed enabled'), order: AssetOrderSchema.optional(), contributorCounts: z.array(ContributorCountResponseSchema).optional(), diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index 6cdf63fcd2..1fe6c629f1 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -34,6 +34,11 @@ describe('getShortDateRange', () => { expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022'); }); + it('should correctly return long month if start and end date are within the same month, ignoring local time zone', () => { + vi.stubEnv('TZ', 'UTC-6'); + expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022'); + }); + it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => { vi.stubEnv('TZ', 'UTC+6'); expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan – Feb 2022'); @@ -61,7 +66,7 @@ describe('getShortDateRange', () => { }); describe('getAlbumDateRange', () => { - beforeAll(() => { + beforeEach(() => { vi.stubEnv('TZ', 'UTC'); }); @@ -77,17 +82,19 @@ describe('getAlbumDateRange', () => { expect(getAlbumDateRange('2021-01-01T09:00:00Z', '2021-01-01T10:00:00Z')).toEqual('Jan 1, 2021'); }); - it('should work with positive time zone present', () => { - expect(getAlbumDateRange('2021-01-01T00:00:00+05:00', '2021-01-01T00:00:00+05:00')).toEqual('Jan 1, 2021'); - }); - - it('should work with negative time zone present', () => { - expect(getAlbumDateRange('2021-01-01T00:00:00-05:00', '2021-01-01T00:00:00-05:00')).toEqual('Jan 1, 2021'); - }); - it('should use the proper locale', () => { locale.set('fr'); expect(getAlbumDateRange('2020-03-26T12:00:00Z', '2021-12-01T00:00:00Z')).toEqual('26 mars 2020 – 1 déc. 2021'); locale.set('en'); }); + + it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => { + vi.stubEnv('TZ', 'UTC+6'); + expect(getAlbumDateRange('2021-12-01T00:00:00Z', '2022-01-01T00:00:00Z')).toEqual('Dec 1, 2021 – Jan 1, 2022'); + }); + + it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => { + vi.stubEnv('TZ', 'UTC-6'); + expect(getAlbumDateRange('2021-12-01T00:00:00Z', '2022-01-01T00:00:00Z')).toEqual('Dec 1, 2021 – Jan 1, 2022'); + }); }); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index 314045e607..6701df5ebf 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -8,12 +8,14 @@ export function parseUtcDate(date: string) { } const getDateRange = (startTimestamp: string, endTimestamp: string, format: 'short' | 'long') => { + // We don't need to check if the locale is set/nonempty. MDN's Intl docs: + // "If the application doesn't provide a locales argument, or the runtime doesn't have a locale that matches the request, then the runtime's default locale is used." const userLocale = get(locale); const startDate = DateTime.fromISO(startTimestamp).setZone('UTC'); - const endDate = DateTime.fromISO(endTimestamp).setZone('UTC').setLocale(userLocale); + const endDate = DateTime.fromISO(endTimestamp).setZone('UTC'); if (startDate.year === endDate.year && startDate.month === endDate.month && format === 'short') { - return endDate.toLocaleString({ month: 'long', year: 'numeric' }); + return endDate.setLocale(userLocale).toLocaleString({ month: 'long', year: 'numeric' }); } const formatter = new Intl.DateTimeFormat( @@ -24,10 +26,14 @@ const getDateRange = (startTimestamp: string, endTimestamp: string, format: 'sho }; /** - * Get localized date range in short format like 'Oct – Nov 2026', with full month if start and end are the same: 'October 2026' + * Get localized date range in short format like 'Oct – Nov 2026', with full month if start and end are the same: 'October 2026'. + * Timestamps are expected to be date-only in UTC. */ export const getShortDateRange = (start: string, end: string) => getDateRange(start, end, 'short'); +/** + * Get localized date range in long format. Timestamps are expected to be date-only in UTC. + */ export const getAlbumDateRange = (start: string, end: string) => getDateRange(start, end, 'long'); /**