Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -19482,7 +19482,7 @@
]
},
"endDate": {
"description": "End date (latest asset)",
"description": "UTC representation of (local) end date (latest asset)",
"format": "date-time",
"type": "string"
},
Expand Down Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions server/src/dtos/album.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
13 changes: 8 additions & 5 deletions web/src/lib/components/album-page/AlbumSummary.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
</script>

<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<span>{getAlbumDateRange(album)}</span>
<span>•</span>
{#if startDate}
<span>{getAlbumDateRange(startDate, album.endDate ?? startDate)}</span>
<span>•</span>
{/if}
<span>{$t('items_count', { values: { count: album.assetCount } })}</span>
</span>
6 changes: 6 additions & 0 deletions web/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
78 changes: 56 additions & 22 deletions web/src/lib/utils/date-time.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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');
});
});
77 changes: 21 additions & 56 deletions web/src/lib/utils/date-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading