Compare commits

...

3 Commits

Author SHA1 Message Date
Mees Frensel 439b7fe3be move setLocale and add comment 2026-06-10 16:23:55 +02:00
Mees Frensel b430f5188b Merge branch 'main' into fix/date-range-formatting 2026-06-10 13:33:15 +02:00
Mees Frensel 54c1fbebde fix(web): album date range formatting 2026-05-22 16:53:28 +02:00
8 changed files with 129 additions and 113 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ class AlbumResponseDto {
/// Album description /// Album description
String 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 /// 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 /// does not include a default value (using the "default:" property), however, the generated
@@ -92,7 +92,7 @@ class AlbumResponseDto {
/// Is shared album /// Is shared album
bool shared; 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 /// 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 /// does not include a default value (using the "default:" property), however, the generated
+2 -2
View File
@@ -16235,7 +16235,7 @@
"type": "string" "type": "string"
}, },
"endDate": { "endDate": {
"description": "End date (latest asset)", "description": "UTC representation of (local) end date (latest asset)",
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
@@ -16264,7 +16264,7 @@
"type": "boolean" "type": "boolean"
}, },
"startDate": { "startDate": {
"description": "Start date (earliest asset)", "description": "UTC representation of (local) start date (earliest asset)",
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
+2 -2
View File
@@ -484,7 +484,7 @@ export type AlbumResponseDto = {
createdAt: string; createdAt: string;
/** Album description */ /** Album description */
description: string; description: string;
/** End date (latest asset) */ /** UTC representation of (local) end date (latest asset) */
endDate?: string; endDate?: string;
/** Has shared link */ /** Has shared link */
hasSharedLink: boolean; hasSharedLink: boolean;
@@ -497,7 +497,7 @@ export type AlbumResponseDto = {
order?: AssetOrder; order?: AssetOrder;
/** Is shared album */ /** Is shared album */
shared: boolean; shared: boolean;
/** Start date (earliest asset) */ /** UTC representation of (local) start date (earliest asset) */
startDate?: string; startDate?: string;
/** Last update date */ /** Last update date */
updatedAt: string; updatedAt: string;
+10 -2
View File
@@ -131,9 +131,17 @@ export const AlbumResponseSchema = z
.optional() .optional()
.describe('Last modified asset timestamp'), .describe('Last modified asset timestamp'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. // 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. // 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'), isActivityEnabled: z.boolean().describe('Activity feed enabled'),
order: AssetOrderSchema.optional(), order: AssetOrderSchema.optional(),
contributorCounts: z.array(ContributorCountResponseSchema).optional(), contributorCounts: z.array(ContributorCountResponseSchema).optional(),
@@ -3,15 +3,18 @@
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { type Props = {
album: AlbumResponseDto; album: AlbumResponseDto;
} };
let { album }: Props = $props(); const { album }: Props = $props();
const startDate = album.startDate;
</script> </script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> <span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<span>{getAlbumDateRange(album)}</span> {#if startDate}
<span></span> <span>{getAlbumDateRange(startDate, album.endDate ?? startDate)}</span>
<span></span>
{/if}
<span>{$t('items_count', { values: { count: album.assetCount } })}</span> <span>{$t('items_count', { values: { count: album.assetCount } })}</span>
</span> </span>
+6
View File
@@ -34,6 +34,12 @@ export const dateFormats = {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
timeZone: 'UTC',
} satisfies Intl.DateTimeFormatOptions,
albumShort: {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
} satisfies Intl.DateTimeFormatOptions, } satisfies Intl.DateTimeFormatOptions,
settings: { settings: {
month: 'short', month: 'short',
+78 -44
View File
@@ -1,7 +1,71 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { locale } from '$lib/stores/preferences.store';
import { getAlbumDateRange, getShortDateRange } from './date-time'; import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => { describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
locale.set('en');
});
afterAll(() => {
vi.unstubAllEnvs();
locale.set('en');
});
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');
});
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');
});
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 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');
});
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 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('getAlbumDateRange', () => {
beforeEach(() => { beforeEach(() => {
vi.stubEnv('TZ', 'UTC'); vi.stubEnv('TZ', 'UTC');
}); });
@@ -10,57 +74,27 @@ describe('getShortDateRange', () => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it('should correctly return month if start and end date are within the same month', () => { it('should work', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022'); expect(getAlbumDateRange('2021-01-01T00:00:00Z', '2021-01-05T00:00:00Z')).toEqual('Jan 1  5, 2021');
}); });
it('should correctly return month range if start and end date are in separate months within the same year', () => { it('should work with a single day range', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022'); expect(getAlbumDateRange('2021-01-01T09:00:00Z', '2021-01-01T10:00:00Z')).toEqual('Jan 1, 2021');
}); });
it('should correctly return range if start and end date are in separate months and years', () => { it('should use the proper locale', () => {
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022'); 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 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');
});
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');
}); });
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => { 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'); 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(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', () => {
describe('getAlbumDate', () => { vi.stubEnv('TZ', 'UTC-6');
beforeAll(() => { expect(getAlbumDateRange('2021-12-01T00:00:00Z', '2022-01-01T00:00:00Z')).toEqual('Dec 1, 2021  Jan 1, 2022');
process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
});
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 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 work with the new date format', () => {
expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021');
}); });
}); });
+21 -56
View File
@@ -7,69 +7,34 @@ export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC(); 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); const userLocale = get(locale);
let startDate = DateTime.fromISO(startTimestamp).setZone('UTC'); const startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
let endDate = DateTime.fromISO(endTimestamp).setZone('UTC'); const endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
if (userLocale) { if (startDate.year === endDate.year && startDate.month === endDate.month && format === 'short') {
startDate = startDate.setLocale(userLocale); return endDate.setLocale(userLocale).toLocaleString({ month: 'long', year: 'numeric' });
endDate = endDate.setLocale(userLocale);
} }
const endDateLocalized = endDate.toLocaleString({ const formatter = new Intl.DateTimeFormat(
month: 'short', userLocale,
year: 'numeric', format === 'short' ? dateFormats.albumShort : dateFormats.album,
}); );
return formatter.formatRange(startDate.toJSDate(), endDate.toJSDate());
if (startDate.year === endDate.year) {
if (startDate.month === endDate.month) {
// Same year and month.
// e.g.: aug. 2024
return endDateLocalized;
} else {
// Same year but different month.
// e.g.: jul. - sept. 2024
const startMonthLocalized = startDate.toLocaleString({
month: 'short',
});
return `${startMonthLocalized} - ${endDateLocalized}`;
}
} else {
// Different year.
// e.g.: feb. 2021 - sept. 2024
const startDateLocalized = startDate.toLocaleString({
month: 'short',
year: 'numeric',
});
return `${startDateLocalized} - ${endDateLocalized}`;
}
}; };
const formatDate = (date?: string) => { /**
if (!date) { * Get localized date range in short format like 'Oct Nov 2026', with full month if start and end are the same: 'October 2026'.
return; * Timestamps are expected to be date-only in UTC.
} */
export const getShortDateRange = (start: string, end: string) => getDateRange(start, end, 'short');
// without timezone /**
const localDate = date.replace(/Z$/, '').replace(/\+.+$/, ''); * Get localized date range in long format. Timestamps are expected to be date-only in UTC.
return localDate ? new Date(localDate).toLocaleDateString(get(locale), dateFormats.album) : undefined; */
}; export const getAlbumDateRange = (start: string, end: string) => getDateRange(start, end, 'long');
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;
}
return '';
};
/** /**
* Use this to convert from "5pm EST" to "5pm UTC" * Use this to convert from "5pm EST" to "5pm UTC"