import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { locale } from '$lib/stores/preferences.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { DateTime, type LocaleOptions } from 'luxon'; import { SvelteSet } from 'svelte/reactivity'; import { get } from 'svelte/store'; // Move type definitions to the top export type TimelineYearMonth = { year: number; month: number; }; export type TimelineDate = TimelineYearMonth & { day: number; }; export type TimelineDateTime = TimelineDate & { hour: number; minute: number; second: number; millisecond: number; }; export type ScrubberListener = (scrubberData: { scrubberMonth: { year: number; month: number }; overallScrollPercent: number; scrubberMonthScrollPercent: number; scrollToFunction?: (top: number) => void; }) => void | Promise; // used for AssetResponseDto.dateTimeOriginal, amongst others export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime => DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime; export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelineDateTime => (fromISODateTime(isoDateTime, timeZone) as DateTime).toObject(); // used for AssetResponseDto.localDateTime, amongst others export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC'); export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelineDateTime => (fromISODateTimeUTC(isoDateTimeUtc) as DateTime).toObject(); // used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information export const fromISODateTimeTruncateTZToObject = ( isoDateTimeUtc: string, timeZone: string | undefined, ): TimelineDateTime => ( fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime ).toObject(); // Used to derive a local date time from an ISO string and a UTC offset in hours export const fromISODateTimeWithOffsetToObject = (isoDateTimeUtc: string, utcOffsetHours: number): TimelineDateTime => { const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc); // Apply the offset to get the local time // Note: offset is in hours (may be fractional), positive for east of UTC, negative for west const localDateTime = utcDateTime.plus({ hours: utcOffsetHours }); // Return as plain object (keeping the local time but in UTC zone context) return (localDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime).toObject(); }; export const getTimes = (isoDateTimeUtc: string, localUtcOffsetHours: number) => { const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc); const fileCreatedAt = (utcDateTime as DateTime).toObject(); // Apply the offset to get the local time // Note: offset is in hours (may be fractional), positive for east of UTC, negative for west const luxonLocalDateTime = utcDateTime.plus({ hours: localUtcOffsetHours }); // Return as plain object (keeping the local time but in UTC zone context) const localDateTime = (luxonLocalDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime).toObject(); return { fileCreatedAt, localDateTime, }; }; export const fromTimelinePlainDateTime = (timelineDateTime: TimelineDateTime): DateTime => DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime; export const fromTimelinePlainDate = (timelineYearMonth: TimelineDate): DateTime => DateTime.fromObject( { year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day }, { zone: 'local', locale: get(locale) }, ) as DateTime; export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelineYearMonth): DateTime => DateTime.fromObject( { year: timelineYearMonth.year, month: timelineYearMonth.month }, { zone: 'local', locale: get(locale) }, ) as DateTime; export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string => { const yearFull = `${year}`.padStart(4, '0'); const monthFull = `${month}`.padStart(2, '0'); return `${yearFull}-${monthFull}-01T00:00:00.000Z`; }; export function formatMonthGroupTitle(_date: DateTime): string { if (!_date.isValid) { return _date.toString(); } const date = _date as DateTime; return date.toLocaleString( { month: 'short', year: 'numeric', }, { locale: get(locale) }, ); } export function formatGroupTitle(_date: DateTime): string { if (!_date.isValid) { return _date.toString(); } const date = _date as DateTime; const today = DateTime.now().startOf('day'); // Today if (today.hasSame(date, 'day')) { return date.toRelativeCalendar({ locale: get(locale) }); } // Yesterday if (today.minus({ days: 1 }).hasSame(date, 'day')) { return date.toRelativeCalendar({ locale: get(locale) }); } // Last week if (date >= today.minus({ days: 6 }) && date < today) { return date.toLocaleString({ weekday: 'long' }, { locale: get(locale) }); } // This year if (today.hasSame(date, 'year')) { return date.toLocaleString( { weekday: 'short', month: 'short', day: 'numeric', }, { locale: get(locale) }, ); } return getDateLocaleString(date, { locale: get(locale) }); } export const formatGroupTitleFull = (_date: DateTime): string => { if (!_date.isValid) { return _date.toString(); } const date = _date as DateTime; return getDateLocaleString(date); }; export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); export const getDateTimeOffsetLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString( { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'longOffset' }, opts, ); export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => { if (isTimelineAsset(unknownAsset)) { return unknownAsset; } const assetResponse = unknownAsset; const { width, height } = getAssetRatio(assetResponse); const ratio = width / height; const city = assetResponse.exifInfo?.city; const country = assetResponse.exifInfo?.country; const people = assetResponse.people?.map((person) => person.name) || []; const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime); const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC'); return { id: assetResponse.id, ownerId: assetResponse.ownerId, ratio, thumbhash: assetResponse.thumbhash, localDateTime, fileCreatedAt, isFavorite: assetResponse.isFavorite, visibility: assetResponse.visibility, isTrashed: assetResponse.isTrashed, isVideo: assetResponse.type == AssetTypeEnum.Video, isImage: assetResponse.type == AssetTypeEnum.Image, stack: assetResponse.stack || null, duration: assetResponse.duration || null, projectionType: assetResponse.exifInfo?.projectionType || null, livePhotoVideoId: assetResponse.livePhotoVideoId || null, city: city || null, country: country || null, people, latitude: assetResponse.exifInfo?.latitude || null, longitude: assetResponse.exifInfo?.longitude || null, }; }; export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset => (unknownAsset as TimelineAsset).ratio !== undefined; export const plainDateTimeCompare = (ascending: boolean, a: TimelineDateTime, b: TimelineDateTime) => { const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a]; if (aDateTime.year !== bDateTime.year) { return aDateTime.year - bDateTime.year; } if (aDateTime.month !== bDateTime.month) { return aDateTime.month - bDateTime.month; } if (aDateTime.day !== bDateTime.day) { return aDateTime.day - bDateTime.day; } if (aDateTime.hour !== bDateTime.hour) { return aDateTime.hour - bDateTime.hour; } if (aDateTime.minute !== bDateTime.minute) { return aDateTime.minute - bDateTime.minute; } if (aDateTime.second !== bDateTime.second) { return aDateTime.second - bDateTime.second; } return aDateTime.millisecond - bDateTime.millisecond; }; export function setDifference(setA: Set, setB: Set): SvelteSet { const result = new SvelteSet(); for (const value of setA) { if (!setB.has(value)) { result.add(value); } } return result; } export interface MonthGroupForSearch { yearMonth: TimelineYearMonth; top: number; height: number; } export interface BinarySearchResult { month: TimelineYearMonth; monthScrollPercent: number; } export function findMonthAtScrollPosition( months: MonthGroupForSearch[], scrollPosition: number, maxScrollPercent: number, ): BinarySearchResult | null { const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month if (months.length === 0) { return null; } // Check if we're before the first month const firstMonthTop = months[0].top * maxScrollPercent; if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) { return null; } // Check if we're after the last month const lastMonth = months.at(-1)!; const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent; if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) { return null; } // Binary search to find the month containing the scroll position let left = 0; let right = months.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); const month = months[mid]; const monthTop = month.top * maxScrollPercent; const monthBottom = monthTop + month.height * maxScrollPercent; if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) { // Found the month containing the scroll position const distanceIntoMonth = scrollPosition - monthTop; const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent)); // Handle month boundary edge case if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) { return { month: months[mid + 1].yearMonth, monthScrollPercent: 0, }; } return { month: month.yearMonth, monthScrollPercent, }; } if (scrollPosition < monthTop) { right = mid - 1; } else { left = mid + 1; } } // Shouldn't reach here, but return null if we do return null; }