diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2da7aaf931a8f6..cbf7a429f8df11 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -19482,7 +19482,7 @@ ] }, "endDate": { - "description": "End date (latest asset)", + "description": "UTC representation of (local) end date (latest asset)", "format": "date-time", "type": "string" }, @@ -19513,7 +19513,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 fa48163be6e4e1..81987e16937f49 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -868,7 +868,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; @@ -881,7 +881,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 1e3b3b6193e44c..f6a1bdb0de7588 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -173,9 +173,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/components/album-page/AlbumSummary.svelte b/web/src/lib/components/album-page/AlbumSummary.svelte index 3e6e160c9ca0ae..dd84bbec98c327 100644 --- a/web/src/lib/components/album-page/AlbumSummary.svelte +++ b/web/src/lib/components/album-page/AlbumSummary.svelte @@ -3,15 +3,18 @@ import type { AlbumResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - interface Props { + type Props = { album: AlbumResponseDto; - } + }; - let { album }: Props = $props(); + const { album }: Props = $props(); + const startDate = album.startDate; - {getAlbumDateRange(album)} - • + {#if startDate} + {getAlbumDateRange(startDate, album.endDate ?? startDate)} + • + {/if} {$t('items_count', { values: { count: album.assetCount } })} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 6f598b09283d8c..b6664bfa330259 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -34,6 +34,12 @@ export const dateFormats = { month: 'short', day: 'numeric', year: 'numeric', + timeZone: 'UTC', + } satisfies Intl.DateTimeFormatOptions, + albumShort: { + month: 'short', + year: 'numeric', + timeZone: 'UTC', } satisfies Intl.DateTimeFormatOptions, settings: { month: 'short', diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index 555c9b82bebf37..1fe6c629f15154 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { locale } from '$lib/stores/preferences.store'; import { getAlbumDateRange, getShortDateRange } from './date-time'; vitest.mock('$lib/stores/preferences.store', () => ({ @@ -8,59 +9,92 @@ vitest.mock('$lib/stores/preferences.store', () => ({ describe('getShortDateRange', () => { beforeEach(() => { vi.stubEnv('TZ', 'UTC'); + locale.set('en'); }); afterAll(() => { vi.unstubAllEnvs(); + locale.set('en'); }); - it('should correctly return month if start and end date are within the same month', () => { - expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022'); + it('should correctly return long month if start and end date are within the same month', () => { + 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', () => { - expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022'); + expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan – Feb 2022'); }); it('should correctly return range if start and end date are in separate months and years', () => { - expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022'); + expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 – Jan 2022'); }); - it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => { + 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('Jan 2022'); + 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'); + expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan – Feb 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(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022'); + expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 – Jan 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(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 – Jan 2022'); + }); + + it('should use the correct locale to return month range', () => { + locale.set('fr'); + expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('janv.–févr. 2022'); + }); + + it('should use the correct locale to return month-year range', () => { + locale.set('fr'); + expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('déc. 2021 – janv. 2022'); }); }); -describe('getAlbumDate', () => { - beforeAll(() => { - process.env.TZ = 'UTC'; +describe('getAlbumDateRange', () => { + beforeEach(() => { + vi.stubEnv('TZ', 'UTC'); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it('should work', () => { + expect(getAlbumDateRange('2021-01-01T00:00:00Z', '2021-01-05T00:00:00Z')).toEqual('Jan 1 – 5, 2021'); }); - it('should work with only a start date', () => { - expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00Z' })).toEqual('Jan 1, 2021'); + it('should work with a single day range', () => { + expect(getAlbumDateRange('2021-01-01T09:00:00Z', '2021-01-01T10:00:00Z')).toEqual('Jan 1, 2021'); }); - it('should work with a start and end date', () => { - expect( - getAlbumDateRange({ - startDate: '2021-01-01T00:00:00Z', - endDate: '2021-01-05T00:00:00Z', - }), - ).toEqual('Jan 1, 2021 - Jan 5, 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 work with the new date format', () => { - expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); + 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 dd37776739bf02..7f772a375e512f 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -7,69 +7,34 @@ export function parseUtcDate(date: string) { return DateTime.fromISO(date, { zone: 'UTC' }).toUTC(); } -export const getShortDateRange = (startTimestamp: string, endTimestamp: 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); - let startDate = DateTime.fromISO(startTimestamp).setZone('UTC'); - let endDate = DateTime.fromISO(endTimestamp).setZone('UTC'); + const startDate = DateTime.fromISO(startTimestamp).setZone('UTC'); + const endDate = DateTime.fromISO(endTimestamp).setZone('UTC'); - if (userLocale) { - startDate = startDate.setLocale(userLocale); - endDate = endDate.setLocale(userLocale); + if (startDate.year === endDate.year && startDate.month === endDate.month && format === 'short') { + return endDate.setLocale(userLocale).toLocaleString({ month: 'long', year: 'numeric' }); } - const endDateLocalized = endDate.toLocaleString({ - month: 'short', - year: 'numeric', - }); - - if (startDate.year === endDate.year) { - if (startDate.month === endDate.month) { - // Same year and month. - // e.g.: aug. 2024 - return endDateLocalized; - } - - // Same year but different month. - // e.g.: jul. - sept. 2024 - const startMonthLocalized = startDate.toLocaleString({ - month: 'short', - }); - return `${startMonthLocalized} - ${endDateLocalized}`; - } - - // Different year. - // e.g.: feb. 2021 - sept. 2024 - const startDateLocalized = startDate.toLocaleString({ - month: 'short', - year: 'numeric', - }); - return `${startDateLocalized} - ${endDateLocalized}`; + const formatter = new Intl.DateTimeFormat( + userLocale, + format === 'short' ? dateFormats.albumShort : dateFormats.album, + ); + return formatter.formatRange(startDate.toJSDate(), endDate.toJSDate()); }; -const formatDate = (date?: string) => { - if (!date) { - return; - } - - // without timezone - const localDate = date.replace(/Z$/, '').replace(/\+.+$/, ''); - return localDate ? new Date(localDate).toLocaleDateString(get(locale), dateFormats.album) : undefined; -}; - -export const getAlbumDateRange = (album: { startDate?: string; endDate?: string }) => { - const start = formatDate(album.startDate); - const end = formatDate(album.endDate); - - if (start && end && start !== end) { - return `${start} - ${end}`; - } - - if (start) { - return start; - } +/** + * 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'); - return ''; -}; +/** + * 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'); /** * Use this to convert from "5pm EST" to "5pm UTC"