mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
fix: navigate to time action (#20928)
* fix: navigate to time action * change-date -> DateSelectionModal; use luxon; use handle* for callback fn name * refactor change-date dialogs * Review comments * chore: clean up --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
parent
d0eae97037
commit
2919ee4c65
19 changed files with 647 additions and 477 deletions
149
web/src/lib/modals/timezone-utils.ts
Normal file
149
web/src/lib/modals/timezone-utils.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { DateTime, Duration } from 'luxon';
|
||||
|
||||
export type ZoneOption = {
|
||||
/**
|
||||
* Timezone name with offset
|
||||
*
|
||||
* e.g. Asia/Jerusalem (+03:00)
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Timezone name
|
||||
*
|
||||
* e.g. Asia/Jerusalem
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Timezone offset in minutes
|
||||
*
|
||||
* e.g. 300
|
||||
*/
|
||||
offsetMinutes: number;
|
||||
|
||||
/**
|
||||
* True iff the date is valid
|
||||
*
|
||||
* Dates may be invalid for various reasons, for example setting a day that does not exist (30 Feb 2024).
|
||||
* Due to daylight saving time, 2:30am is invalid for Europe/Berlin on Mar 31 2024.The two following local times
|
||||
* are one second apart:
|
||||
*
|
||||
* - Mar 31 2024 01:59:59 (GMT+0100, unix timestamp 1725058799)
|
||||
* - Mar 31 2024 03:00:00 (GMT+0200, unix timestamp 1711846800)
|
||||
*
|
||||
* Mar 31 2024 02:30:00 does not exist in Europe/Berlin, this is an invalid date/time/time zone combination.
|
||||
*/
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
||||
|
||||
export function getTimezones(selectedDate: string) {
|
||||
// Use a fixed modern date to calculate stable timezone offsets for the list
|
||||
// This ensures that the offsets shown in the combobox are always current,
|
||||
// regardless of the historical date selected by the user.
|
||||
return knownTimezones
|
||||
.map((zone) => zoneOptionForDate(zone, selectedDate))
|
||||
.filter((zone) => zone.valid)
|
||||
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
|
||||
}
|
||||
|
||||
export function getModernOffsetForZoneAndDate(
|
||||
zone: string,
|
||||
dateString: string,
|
||||
): { offsetMinutes: number; offsetFormat: string } {
|
||||
const dt = DateTime.fromISO(dateString, { zone });
|
||||
|
||||
// we determine the *modern* offset for this zone based on its current rules.
|
||||
// To do this, we "move" the date to the current year, keeping the local time components.
|
||||
// This allows Luxon to apply current-year DST rules.
|
||||
const modernYearDt = dt.set({ year: DateTime.now().year });
|
||||
|
||||
// Calculate the offset at that modern year's date.
|
||||
const modernOffsetMinutes = modernYearDt.setZone(zone, { keepLocalTime: true }).offset;
|
||||
const modernOffsetFormat = modernYearDt.setZone(zone, { keepLocalTime: true }).toFormat('ZZ');
|
||||
|
||||
return { offsetMinutes: modernOffsetMinutes, offsetFormat: modernOffsetFormat };
|
||||
}
|
||||
|
||||
function zoneOptionForDate(zone: string, date: string) {
|
||||
const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date);
|
||||
// For validity, we still need to check if the exact date/time exists in the *original* timezone (for gaps/overlaps).
|
||||
const dateForValidity = DateTime.fromISO(date, { zone });
|
||||
const valid = dateForValidity.isValid && date === dateForValidity.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
|
||||
return {
|
||||
value: zone,
|
||||
offsetMinutes,
|
||||
label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'),
|
||||
valid,
|
||||
};
|
||||
}
|
||||
|
||||
function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
|
||||
const offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes;
|
||||
if (offsetDifference != 0) {
|
||||
return offsetDifference;
|
||||
}
|
||||
return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
/*
|
||||
* If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00).
|
||||
*
|
||||
* This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)"
|
||||
* instead of just the raw offset or something like "UTC+02:00".
|
||||
*
|
||||
* The provided information (initialDate, from some asset) includes the offset (e.g. +02:00), but no information about
|
||||
* the actual time zone. As several countries/regions may share the same offset, for example Berlin (Germany) and
|
||||
* Blantyre (Malawi) sharing +02:00 in summer, we have to guess and somehow pick a suitable time zone.
|
||||
*
|
||||
* If the time zone configured by the user (in the browser) provides the same offset for the given date (accounting
|
||||
* for daylight saving time and other weirdness), we prefer to show it. This way, for German users, we might be able
|
||||
* to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre".
|
||||
*/
|
||||
export function getPreferredTimeZone(
|
||||
date: DateTime,
|
||||
initialTimeZone: string | undefined,
|
||||
timezones: ZoneOption[],
|
||||
selectedOption?: ZoneOption,
|
||||
) {
|
||||
const offset = date.offset;
|
||||
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
|
||||
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
|
||||
const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
|
||||
const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
|
||||
const utcFallback = {
|
||||
label: 'UTC (+00:00)',
|
||||
offsetMinutes: 0,
|
||||
value: 'UTC',
|
||||
valid: true,
|
||||
};
|
||||
return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
|
||||
}
|
||||
|
||||
export function toDatetime(selectedDate: string, selectedZone: ZoneOption) {
|
||||
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
|
||||
|
||||
// Determine the modern, DST-aware offset for the selected IANA zone
|
||||
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedZone.value, selectedDate);
|
||||
|
||||
// Construct the final ISO string with a fixed-offset zone.
|
||||
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
||||
|
||||
// Create a DateTime object in this fixed-offset zone, preserving the local time.
|
||||
return DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
|
||||
}
|
||||
|
||||
export function toIsoDate(selectedDate: string, selectedZone: ZoneOption) {
|
||||
return toDatetime(selectedDate, selectedZone).toISO({ includeOffset: true })!;
|
||||
}
|
||||
|
||||
export const calcNewDate = (timestamp: DateTime, selectedDuration: number, timezone?: string) => {
|
||||
let newDateTime = timestamp.plus({ minutes: selectedDuration });
|
||||
if (timezone) {
|
||||
newDateTime = newDateTime.setZone(timezone);
|
||||
}
|
||||
return newDateTime.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue