refactor(web): rename DayGroup/MonthGroup to TimelineDay/TimelineMonth

- Rename classes: DayGroup → TimelineDay, MonthGroup → TimelineMonth
- Use short variable names: dayGroup → day, monthGroup → month
- Update all method names and properties for consistency
- Convert relative imports to $lib alias convention

No functional changes.
This commit is contained in:
midzelis
2025-11-03 22:09:05 +00:00
parent d6ed52806f
commit 928b69f415
37 changed files with 517 additions and 537 deletions
@@ -6,7 +6,7 @@
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte'; import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { ScrubberMonth, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; import type { ScrubberMonth, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util'; import { getTabbable } from '$lib/utils/focus-util';
@@ -91,7 +91,7 @@
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH; scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
}); });
const toScrollFromMonthGroupPercentage = ( const toScrollFromMonthPercentage = (
scrubberMonth: ViewportTopMonth, scrubberMonth: ViewportTopMonth,
scrubberMonthPercent: number, scrubberMonthPercent: number,
scrubOverallPercent: number, scrubOverallPercent: number,
@@ -124,7 +124,7 @@
} }
}; };
const scrollY = $derived( const scrollY = $derived(
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent), toScrollFromMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
); );
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight); const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
@@ -280,12 +280,12 @@
const boundingClientRect = bestElement.boundingClientRect; const boundingClientRect = bestElement.boundingClientRect;
const sy = boundingClientRect.y; const sy = boundingClientRect.y;
const relativeY = y - sy; const relativeY = y - sy;
const monthGroupPercentY = relativeY / boundingClientRect.height; const monthPercentY = relativeY / boundingClientRect.height;
return { return {
isOnPaddingTop: false, isOnPaddingTop: false,
isOnPaddingBottom: false, isOnPaddingBottom: false,
segment, segment,
monthGroupPercentY, monthPercentY,
}; };
} }
@@ -308,7 +308,7 @@
isOnPaddingTop, isOnPaddingTop,
isOnPaddingBottom, isOnPaddingBottom,
segment: undefined, segment: undefined,
monthGroupPercentY: 0, monthPercentY: 0,
}; };
}; };
@@ -327,7 +327,7 @@
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM); const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper); hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
const x = rect!.left + rect!.width / 2; const x = rect!.left + rect!.width / 2;
const { segment, monthGroupPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY); const { segment, monthPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
activeSegment = segment; activeSegment = segment;
isHoverOnPaddingTop = isOnPaddingTop; isHoverOnPaddingTop = isOnPaddingTop;
isHoverOnPaddingBottom = isOnPaddingBottom; isHoverOnPaddingBottom = isOnPaddingBottom;
@@ -335,7 +335,7 @@
const scrubData = { const scrubData = {
scrubberMonth: segmentDate, scrubberMonth: segmentDate,
overallScrollPercent: toTimelineY(hoverY), overallScrollPercent: toTimelineY(hoverY),
scrubberMonthScrollPercent: monthGroupPercentY, scrubberMonthScrollPercent: monthPercentY,
}; };
if (wasDragging === false && isDragging) { if (wasDragging === false && isDragging) {
void startScrub?.(scrubData); void startScrub?.(scrubData);
+56 -56
View File
@@ -2,17 +2,17 @@
import { afterNavigate, beforeNavigate } from '$app/navigation'; import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -58,11 +58,11 @@
onThumbnailClick?: ( onThumbnailClick?: (
asset: TimelineAsset, asset: TimelineAsset,
timelineManager: TimelineManager, timelineManager: TimelineManager,
dayGroup: DayGroup, day: TimelineDay,
onClick: ( onClick: (
timelineManager: TimelineManager, timelineManager: TimelineManager,
assets: TimelineAsset[], assets: TimelineAsset[],
groupTitle: string, dayTitle: string,
asset: TimelineAsset, asset: TimelineAsset,
) => void, ) => void,
) => void; ) => void;
@@ -130,10 +130,10 @@
timelineManager.scrollableElement = scrollableElement; timelineManager.scrollableElement = scrollableElement;
}); });
const getAssetPosition = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId); const getAssetPosition = (assetId: string, month: TimelineMonth) => month.findAssetAbsolutePosition(assetId);
const scrollToAssetPosition = (assetId: string, monthGroup: MonthGroup) => { const scrollToAssetPosition = (assetId: string, month: TimelineMonth) => {
const position = getAssetPosition(assetId, monthGroup); const position = getAssetPosition(assetId, month);
if (!position) { if (!position) {
return; return;
@@ -176,20 +176,20 @@
}; };
const scrollAndLoadAsset = async (assetId: string) => { const scrollAndLoadAsset = async (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId); const month = await timelineManager.findMonthForAsset(assetId);
if (!monthGroup) { if (!month) {
return false; return false;
} }
scrollToAssetPosition(assetId, monthGroup); scrollToAssetPosition(assetId, month);
return true; return true;
}; };
const scrollToAsset = (asset: TimelineAsset) => { const scrollToAsset = (asset: TimelineAsset) => {
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id); const month = timelineManager.getMonthByAssetId(asset.id);
if (!monthGroup) { if (!month) {
return false; return false;
} }
scrollToAssetPosition(asset.id, monthGroup); scrollToAssetPosition(asset.id, month);
return true; return true;
}; };
@@ -264,10 +264,10 @@
} }
}); });
const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, monthGroupScrollPercent: number) => { const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, monthScrollPercent: number) => {
const topOffset = segmentTop; const topOffset = segmentTop;
const maxScrollPercent = timelineManager.maxScrollPercent; const maxScrollPercent = timelineManager.maxScrollPercent;
const delta = segmentHeight * monthGroupScrollPercent; const delta = segmentHeight * monthScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent; const scrollToTop = (topOffset + delta) * maxScrollPercent;
timelineManager.scrollTo(scrollToTop); timelineManager.scrollTo(scrollToTop);
@@ -296,13 +296,13 @@
scrubberMonthScrollPercent, scrubberMonthScrollPercent,
); );
} else { } else {
const monthGroup = timelineManager.months.find( const month = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
); );
if (!monthGroup) { if (!month) {
return; return;
} }
scrollToSegmentPercentage(monthGroup.top, monthGroup.height, scrubberMonthScrollPercent); scrollToSegmentPercentage(month.top, month.height, scrubberMonthScrollPercent);
} }
}; };
@@ -327,28 +327,28 @@
const monthsLength = timelineManager.months.length; const monthsLength = timelineManager.months.length;
for (let i = -1; i < monthsLength + 1; i++) { for (let i = -1; i < monthsLength + 1; i++) {
let monthGroup: ViewportTopMonth; let month: ViewportTopMonth;
let monthGroupHeight = 0; let monthHeight = 0;
if (i === -1) { if (i === -1) {
// lead-in // lead-in
monthGroup = 'lead-in'; month = 'lead-in';
monthGroupHeight = timelineManager.topSectionHeight; monthHeight = timelineManager.topSectionHeight;
} else if (i === monthsLength) { } else if (i === monthsLength) {
// lead-out // lead-out
monthGroup = 'lead-out'; month = 'lead-out';
monthGroupHeight = timelineManager.bottomSectionHeight; monthHeight = timelineManager.bottomSectionHeight;
} else { } else {
monthGroup = timelineManager.months[i].yearMonth; month = timelineManager.months[i].yearMonth;
monthGroupHeight = timelineManager.months[i].height; monthHeight = timelineManager.months[i].height;
} }
let next = top - monthGroupHeight * maxScrollPercent; let next = top - monthHeight * maxScrollPercent;
// instead of checking for < 0, add a little wiggle room for subpixel resolution // instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && monthGroup) { if (next < -1 && month) {
viewportTopMonth = monthGroup; viewportTopMonth = month;
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage // allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent)); viewportTopMonthScrollPercent = Math.max(0, top / (monthHeight * maxScrollPercent));
// compensate for lost precision/rounding errors advance to the next bucket, if present // compensate for lost precision/rounding errors advance to the next bucket, if present
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) { if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
@@ -442,8 +442,8 @@
assetInteraction.clearAssetSelectionCandidates(); assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) { if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id); let startBucket = timelineManager.getMonthByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id); let endBucket = timelineManager.getMonthByAssetId(asset.id);
if (startBucket === null || endBucket === null) { if (startBucket === null || endBucket === null) {
return; return;
@@ -451,13 +451,13 @@
// Select/deselect assets in range (start,end) // Select/deselect assets in range (start,end)
let started = false; let started = false;
for (const monthGroup of timelineManager.months) { for (const month of timelineManager.months) {
if (monthGroup === endBucket) { if (month === endBucket) {
break; break;
} }
if (started) { if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth); await timelineManager.loadMonth(month.yearMonth);
for (const asset of monthGroup.assetsIterator()) { for (const asset of month.assetsIterator()) {
if (deselect) { if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id); assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else { } else {
@@ -465,29 +465,29 @@
} }
} }
} }
if (monthGroup === startBucket) { if (month === startBucket) {
started = true; started = true;
} }
} }
// Update date group selection in range [start,end] // Update date group selection in range [start,end]
started = false; started = false;
for (const monthGroup of timelineManager.months) { for (const month of timelineManager.months) {
if (monthGroup === startBucket) { if (month === startBucket) {
started = true; started = true;
} }
if (started) { if (started) {
// Split month group into day groups and check each group // Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) { for (const day of month.days) {
const dayGroupTitle = dayGroup.groupTitle; const dayTitle = day.dayTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) { if (day.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle); assetInteraction.addGroupToMultiselectGroup(dayTitle);
} else { } else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle); assetInteraction.removeGroupFromMultiselectGroup(dayTitle);
} }
} }
} }
if (monthGroup === endBucket) { if (month === endBucket) {
break; break;
} }
} }
@@ -531,7 +531,7 @@
$effect(() => { $effect(() => {
if ($showAssetViewer) { if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60); const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month }); void timelineManager.loadMonth({ year: localDateTime.year, month: localDateTime.month });
} }
}); });
</script> </script>
@@ -622,23 +622,23 @@
{/if} {/if}
</section> </section>
{#each timelineManager.months as monthGroup (monthGroup.viewId)} {#each timelineManager.months as month (month.viewId)}
{@const display = monthGroup.intersecting} {@const display = month.intersecting}
{@const absoluteHeight = monthGroup.top} {@const absoluteHeight = month.top}
{#if !monthGroup.isLoaded} {#if !month.isLoaded}
<div <div
style:height={monthGroup.height + 'px'} style:height={month.height + 'px'}
style:position="absolute" style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%" style:width="100%"
> >
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} /> <Skeleton {invisible} height={month.height} title={month.monthTitle} />
</div> </div>
{:else if display} {:else if display}
<div <div
class="month-group" class="month-group"
style:height={monthGroup.height + 'px'} style:height={month.height + 'px'}
style:position="absolute" style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%" style:width="100%"
@@ -650,7 +650,7 @@
{timelineManager} {timelineManager}
{isSelectionMode} {isSelectionMode}
{singleSelect} {singleSelect}
{monthGroup} {month}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)} onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets} onSelectAssets={handleSelectAssets}
@@ -2,7 +2,7 @@
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
@@ -1,19 +1,17 @@
<script lang="ts"> <script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util'; import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
@@ -25,7 +23,7 @@
singleSelect: boolean; singleSelect: boolean;
withStacked: boolean; withStacked: boolean;
showArchiveIcon: boolean; showArchiveIcon: boolean;
monthGroup: MonthGroup; month: TimelineMonth;
timelineManager: TimelineManager; timelineManager: TimelineManager;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>; customLayout?: Snippet<[TimelineAsset]>;
@@ -36,11 +34,11 @@
onThumbnailClick?: ( onThumbnailClick?: (
asset: TimelineAsset, asset: TimelineAsset,
timelineManager: TimelineManager, timelineManager: TimelineManager,
dayGroup: DayGroup, day: TimelineDay,
onClick: ( onClick: (
timelineManager: TimelineManager, timelineManager: TimelineManager,
assets: TimelineAsset[], assets: TimelineAsset[],
groupTitle: string, dayTitle: string,
asset: TimelineAsset, asset: TimelineAsset,
) => void, ) => void,
) => void; ) => void;
@@ -51,7 +49,7 @@
singleSelect, singleSelect,
withStacked, withStacked,
showArchiveIcon, showArchiveIcon,
monthGroup = $bindable(), month = $bindable(),
assetInteraction, assetInteraction,
timelineManager, timelineManager,
customLayout, customLayout,
@@ -63,20 +61,18 @@
}: Props = $props(); }: Props = $props();
let isMouseOverGroup = $state(false); let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state(); let hoveredDay = $state();
const transitionDuration = $derived.by(() => const transitionDuration = $derived.by(() => (month.timelineManager.suspendTransitions && !$isUploading ? 0 : 150));
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const _onClick = ( const _onClick = (
timelineManager: TimelineManager, timelineManager: TimelineManager,
assets: TimelineAsset[], assets: TimelineAsset[],
groupTitle: string, dayTitle: string,
asset: TimelineAsset, asset: TimelineAsset,
) => { ) => {
if (isSelectionMode || assetInteraction.selectionActive) { if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle); assetSelectHandler(timelineManager, asset, assets, dayTitle);
return; return;
} }
void navigate({ targetRoute: 'current', assetId: asset.id }); void navigate({ targetRoute: 'current', assetId: asset.id });
@@ -87,21 +83,19 @@
const assetSelectHandler = ( const assetSelectHandler = (
timelineManager: TimelineManager, timelineManager: TimelineManager,
asset: TimelineAsset, asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[], assetsInDay: TimelineAsset[],
groupTitle: string, dayTitle: string,
) => { ) => {
onSelectAssets(asset); onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon // Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) => let selectedAssetsInDayCount = assetsInDay.filter((asset) => assetInteraction.hasSelectedAsset(asset.id)).length;
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group // if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) { if (selectedAssetsInDayCount == assetsInDay.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle); assetInteraction.addGroupToMultiselectGroup(dayTitle);
} else { } else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle); assetInteraction.removeGroupFromMultiselectGroup(dayTitle);
} }
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) { if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
@@ -111,9 +105,9 @@
} }
}; };
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => { const assetMouseEventHandler = (dayTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group // Show multi select icon on hover on date group
hoveredDayGroup = groupTitle; hoveredDay = dayTitle;
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset); onSelectAssetCandidates(asset);
@@ -124,52 +118,52 @@
return intersectable.filter((int) => int.intersecting); return intersectable.filter((int) => int.intersecting);
} }
const getDayGroupFullDate = (dayGroup: DayGroup): string => { const getDayFullDate = (day: TimelineDay): string => {
const { month, year } = dayGroup.monthGroup.yearMonth; const { month, year } = day.month.yearMonth;
const date = fromTimelinePlainDate({ const date = fromTimelinePlainDate({
year, year,
month, month,
day: dayGroup.day, day: day.day,
}); });
return getDateLocaleString(date); return getDateLocaleString(date);
}; };
</script> </script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} {#each filterIntersecting(month.days) as day, groupIndex (day.day)}
{@const absoluteWidth = dayGroup.left} {@const absoluteWidth = day.left}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<section <section
class={[ class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions }, { 'transition-all': !month.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`, !month.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]} ]}
data-group data-group
style:position="absolute" style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`} style:transform={`translate3d(${absoluteWidth}px,${day.top}px,0)`}
onmouseenter={() => { onmouseenter={() => {
isMouseOverGroup = true; isMouseOverGroup = true;
assetMouseEventHandler(dayGroup.groupTitle, null); assetMouseEventHandler(day.dayTitle, null);
}} }}
onmouseleave={() => { onmouseleave={() => {
isMouseOverGroup = false; isMouseOverGroup = false;
assetMouseEventHandler(dayGroup.groupTitle, null); assetMouseEventHandler(day.dayTitle, null);
}} }}
> >
<!-- Date group title --> <!-- Date group title -->
<div <div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm" class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'} style:width={day.width + 'px'}
> >
{#if !singleSelect} {#if !singleSelect}
<div <div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0" class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || class:w-8={(hoveredDay === day.dayTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)} assetInteraction.selectedGroup.has(day.dayTitle)}
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))} onclick={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))} onkeydown={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
> >
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)} {#if assetInteraction.selectedGroup.has(day.dayTitle)}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" /> <Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else} {:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" /> <Icon icon={mdiCircleOutline} size="24" color="#757575" />
@@ -177,19 +171,14 @@
</div> </div>
{/if} {/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}> <span class="w-full truncate first-letter:capitalize" title={getDayFullDate(day)}>
{dayGroup.groupTitle} {day.dayTitle}
</span> </span>
</div> </div>
<!-- Image grid --> <!-- Image grid -->
<div <div data-image-grid class="relative overflow-clip" style:height={day.height + 'px'} style:width={day.width + 'px'}>
data-image-grid {#each filterIntersecting(day.viewerAssets) as viewerAsset (viewerAsset.id)}
class="relative overflow-clip"
style:height={dayGroup.height + 'px'}
style:width={dayGroup.width + 'px'}
>
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
@@ -212,17 +201,17 @@
{groupIndex} {groupIndex}
onClick={(asset) => { onClick={(asset) => {
if (typeof onThumbnailClick === 'function') { if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick); onThumbnailClick(asset, timelineManager, day, _onClick);
} else { } else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); _onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
} }
}} }}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)} onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))} onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) || selected={assetInteraction.hasSelectedAsset(asset.id) ||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)} day.month.timelineManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)} disabled={day.month.timelineManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width} thumbnailWidth={position.width}
thumbnailHeight={position.height} thumbnailHeight={position.height}
/> />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils'; import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
@@ -7,7 +7,7 @@
setFocusTo as setFocusToInit, setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions'; } from '$lib/components/timeline/actions/focus-actions';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte'; import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
@@ -1,4 +1,4 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { moveFocus } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { InvocationTracker } from '$lib/utils/invocationTracker'; import { InvocationTracker } from '$lib/utils/invocationTracker';
@@ -0,0 +1 @@
export class ScrollSegment {}
@@ -1,18 +1,16 @@
import { AssetOrder } from '@immich/sdk'; import { onCreateDay } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils'; import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { plainDateTimeCompare } from '$lib/utils/timeline-util'; import { plainDateTimeCompare } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { onCreateDayGroup } from '$lib/managers/timeline-manager/internal/TestHooks.svelte'; export class TimelineDay {
import type { MonthGroup } from './month-group.svelte'; readonly month: TimelineMonth;
import type { AssetOperation, Direction, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class DayGroup {
readonly monthGroup: MonthGroup;
readonly index: number; readonly index: number;
readonly groupTitle: string; readonly dayTitle: string;
readonly day: number; readonly day: number;
viewerAssets: ViewerAsset[] = $state([]); viewerAssets: ViewerAsset[] = $state([]);
@@ -26,13 +24,13 @@ export class DayGroup {
#col = $state(0); #col = $state(0);
#deferredLayout = false; #deferredLayout = false;
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) { constructor(month: TimelineMonth, index: number, day: number, dayTitle: string) {
this.index = index; this.index = index;
this.monthGroup = monthGroup; this.month = month;
this.day = day; this.day = day;
this.groupTitle = groupTitle; this.dayTitle = dayTitle;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
onCreateDayGroup(this); onCreateDay(this);
} }
} }
@@ -144,7 +142,7 @@ export class DayGroup {
} }
unprocessedIds.delete(assetId); unprocessedIds.delete(assetId);
processedIds.add(assetId); processedIds.add(assetId);
if (remove || this.monthGroup.timelineManager.isExcluded(asset)) { if (remove || this.month.timelineManager.isExcluded(asset)) {
this.viewerAssets.splice(index, 1); this.viewerAssets.splice(index, 1);
changedGeometry = true; changedGeometry = true;
} }
@@ -153,7 +151,7 @@ export class DayGroup {
} }
layout(options: CommonLayoutOptions, noDefer: boolean) { layout(options: CommonLayoutOptions, noDefer: boolean) {
if (!noDefer && !this.monthGroup.intersecting) { if (!noDefer && !this.month.intersecting) {
this.#deferredLayout = true; this.#deferredLayout = true;
return; return;
} }
@@ -167,7 +165,7 @@ export class DayGroup {
} }
} }
get absoluteDayGroupTop() { get absoluteTop() {
return this.monthGroup.top + this.#top; return this.month.top + this.#top;
} }
} }
@@ -1,16 +1,16 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { getMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { setTestHooks } from '$lib/managers/timeline-manager/internal/TestHooks.svelte'; import { setTestHooks } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AbortError } from '$lib/utils'; import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { MockInstance } from 'vitest'; import type { MockInstance } from 'vitest';
import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types';
async function getAssets(timelineManager: TimelineManager) { async function getAssets(timelineManager: TimelineManager) {
const assets = []; const assets = [];
@@ -98,7 +98,7 @@ describe('TimelineManager', () => {
}); });
}); });
describe('loadMonthGroup', () => { describe('loadMonth', () => {
let timelineManager: TimelineManager; let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) => '2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
@@ -134,47 +134,47 @@ describe('TimelineManager', () => {
}); });
it('loads a month', async () => { it('loads a month', async () => {
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
}); });
it('ignores invalid months', async () => { it('ignores invalid months', async () => {
await timelineManager.loadMonthGroup({ year: 2023, month: 1 }); await timelineManager.loadMonth({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0); expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
}); });
it('cancels month loading', async () => { it('cancels month loading', async () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadMonthGroup({ year: 2024, month: 1 }); void timelineManager.loadMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort'); const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel(); month?.cancel();
expect(abortSpy).toBeCalledTimes(1); expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
}); });
it('prevents loading months multiple times', async () => { it('prevents loading months multiple times', async () => {
await Promise.all([ await Promise.all([
timelineManager.loadMonthGroup({ year: 2024, month: 1 }), timelineManager.loadMonth({ year: 2024, month: 1 }),
timelineManager.loadMonthGroup({ year: 2024, month: 1 }), timelineManager.loadMonth({ year: 2024, month: 1 }),
]); ]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
}); });
it('allows loading a canceled month', async () => { it('allows loading a canceled month', async () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 }); const loadPromise = timelineManager.loadMonth({ year: 2024, month: 1 });
month.cancel(); month.cancel();
await loadPromise; await loadPromise;
expect(month?.getAssets().length).toEqual(0); expect(month?.getAssets().length).toEqual(0);
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(month!.getAssets().length).toEqual(3); expect(month!.getAssets().length).toEqual(3);
}); });
}); });
@@ -244,7 +244,7 @@ describe('TimelineManager', () => {
); );
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]); timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull(); expect(month).not.toBeNull();
expect(month?.getAssets().length).toEqual(3); expect(month?.getAssets().length).toEqual(3);
expect(month?.getAssets()[0].id).toEqual(assetOne.id); expect(month?.getAssets()[0].id).toEqual(assetOne.id);
@@ -319,24 +319,24 @@ describe('TimelineManager', () => {
sortAssetsFn: MockInstance; sortAssetsFn: MockInstance;
}; };
type MonthMocks = { type MonthMocks = {
sortDayGroupsFn: MockInstance; sortDaysFn: MockInstance;
}; };
const dayGroups = new Map<DayGroup, DayMocks>(); const days = new Map<TimelineDay, DayMocks>();
const monthGroups = new Map<MonthGroup, MonthMocks>(); const months = new Map<TimelineMonth, MonthMocks>();
beforeEach(async () => { beforeEach(async () => {
timelineManager = new TimelineManager(); timelineManager = new TimelineManager();
setTestHooks({ setTestHooks({
onCreateDayGroup: (dayGroup: DayGroup) => { onCreateDay: (day: TimelineDay) => {
dayGroups.set(dayGroup, { days.set(day, {
layoutFn: vi.spyOn(dayGroup, 'layout'), layoutFn: vi.spyOn(day, 'layout'),
sortAssetsFn: vi.spyOn(dayGroup, 'sortAssets'), sortAssetsFn: vi.spyOn(day, 'sortAssets'),
}); });
}, },
onCreateMonthGroup: (monthGroup: MonthGroup) => { onCreateMonth: (month: TimelineMonth) => {
monthGroups.set(monthGroup, { months.set(month, {
sortDayGroupsFn: vi.spyOn(monthGroup, 'sortDayGroups'), sortDaysFn: vi.spyOn(month, 'sortDays'),
}); });
}, },
}); });
@@ -394,13 +394,13 @@ describe('TimelineManager', () => {
timelineManager.updateAssetOperation([month1day2asset1.id], (asset) => { timelineManager.updateAssetOperation([month1day2asset1.id], (asset) => {
asset.localDateTime.day = asset.localDateTime.day + 1; asset.localDateTime.day = asset.localDateTime.day + 1;
}); });
for (const [day, mocks] of dayGroups) { for (const [day, mocks] of days) {
if (day.day === 15 && day.monthGroup.yearMonth.month === 1) { if (day.day === 15 && day.month.yearMonth.month === 1) {
// source - should be layout once // source - should be layout once
expect.soft(mocks.layoutFn).toBeCalledTimes(1); expect.soft(mocks.layoutFn).toBeCalledTimes(1);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1); expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
} }
if (day.day === 16 && day.monthGroup.yearMonth.month === 1) { if (day.day === 16 && day.month.yearMonth.month === 1) {
// target - should be layout once // target - should be layout once
expect.soft(mocks.layoutFn).toBeCalledTimes(1); expect.soft(mocks.layoutFn).toBeCalledTimes(1);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1); expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
@@ -409,12 +409,12 @@ describe('TimelineManager', () => {
expect.soft(mocks.layoutFn).toBeCalledTimes(0); expect.soft(mocks.layoutFn).toBeCalledTimes(0);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(0); expect.soft(mocks.sortAssetsFn).toBeCalledTimes(0);
} }
for (const [_, mocks] of monthGroups) { for (const [_, mocks] of months) {
// if the day itself did not change, probably no need to sort it // if the day itself did not change, probably no need to sort it
// in the timeline manager, the day-group identity is immutable - you will never // in the timeline manager, the day-group identity is immutable - you will never
// "move" a whole day to another day - only the assets inside will be moved from // "move" a whole day to another day - only the assets inside will be moved from
// one to the other. // one to the other.
expect.soft(mocks.sortDayGroupsFn).toBeCalledTimes(0); expect.soft(mocks.sortDaysFn).toBeCalledTimes(0);
} }
}); });
}); });
@@ -455,15 +455,15 @@ describe('TimelineManager', () => {
timelineManager.upsertAssets([asset]); timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
timelineManager.upsertAssets([updatedAsset]); timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2); expect(timelineManager.months.length).toEqual(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
}); });
it('asset is removed during upsert when TimelineManager if visibility changes', async () => { it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
@@ -655,8 +655,8 @@ describe('TimelineManager', () => {
}); });
it('returns previous assetId', async () => { it('returns previous assetId', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadMonth({ year: 2024, month: 1 });
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
const a = month!.getAssets()[0]; const a = month!.getAssets()[0];
const b = month!.getAssets()[1]; const b = month!.getAssets()[1];
@@ -665,11 +665,11 @@ describe('TimelineManager', () => {
}); });
it('returns previous assetId spanning multiple months', async () => { it('returns previous assetId spanning multiple months', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 2 }); await timelineManager.loadMonth({ year: 2024, month: 2 });
await timelineManager.loadMonthGroup({ year: 2024, month: 3 }); await timelineManager.loadMonth({ year: 2024, month: 3 });
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getAssets()[0]; const a = month!.getAssets()[0];
const b = previousMonth!.getAssets()[0]; const b = previousMonth!.getAssets()[0];
const previous = await timelineManager.getLaterAsset(a); const previous = await timelineManager.getLaterAsset(a);
@@ -677,23 +677,23 @@ describe('TimelineManager', () => {
}); });
it('loads previous month', async () => { it('loads previous month', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 2 }); await timelineManager.loadMonth({ year: 2024, month: 2 });
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getFirstAsset(); const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset(); const b = previousMonth!.getFirstAsset();
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute'); const loadMonthSpy = vi.spyOn(month!.loader!, 'execute');
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute'); const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a); const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b); expect(previous).toEqual(b);
expect(loadMonthGroupSpy).toBeCalledTimes(0); expect(loadMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0); expect(previousMonthSpy).toBeCalledTimes(0);
}); });
it('skips removed assets', async () => { it('skips removed assets', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); await timelineManager.loadMonth({ year: 2024, month: 1 });
await timelineManager.loadMonthGroup({ year: 2024, month: 2 }); await timelineManager.loadMonth({ year: 2024, month: 2 });
await timelineManager.loadMonthGroup({ year: 2024, month: 3 }); await timelineManager.loadMonth({ year: 2024, month: 3 });
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager); const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
timelineManager.removeAssets([assetTwo.id]); timelineManager.removeAssets([assetTwo.id]);
@@ -701,12 +701,12 @@ describe('TimelineManager', () => {
}); });
it('returns null when no more assets', async () => { it('returns null when no more assets', async () => {
await timelineManager.loadMonthGroup({ year: 2024, month: 3 }); await timelineManager.loadMonth({ year: 2024, month: 3 });
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined(); expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
}); });
}); });
describe('getMonthGroupIndexByAssetId', () => { describe('getMonthIndexByAssetId', () => {
let timelineManager: TimelineManager; let timelineManager: TimelineManager;
beforeEach(async () => { beforeEach(async () => {
@@ -717,8 +717,8 @@ describe('TimelineManager', () => {
}); });
it('returns null for invalid months', () => { it('returns null for invalid months', () => {
expect(getMonthGroupByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined(); expect(getMonthByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined(); expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
}); });
it('returns the month index', () => { it('returns the month index', () => {
@@ -734,10 +734,10 @@ describe('TimelineManager', () => {
); );
timelineManager.upsertAssets([assetOne, assetTwo]); timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024); expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2); expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024); expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1); expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
}); });
it('ignores removed months', () => { it('ignores removed months', () => {
@@ -754,8 +754,8 @@ describe('TimelineManager', () => {
timelineManager.upsertAssets([assetOne, assetTwo]); timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]); timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024); expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1); expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
}); });
}); });
@@ -1,18 +1,30 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateIntersectionMonth } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import { import {
findClosestGroupForDate, findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil, findMonthForAsset as findMonthForAssetUtil,
findMonthGroupForDate, findMonthForDate,
getAssetWithOffset, getAssetWithOffset,
getMonthGroupByDate, getMonthByDate,
retrieveRange as retrieveRangeUtil, retrieveRange as retrieveRangeUtil,
} from '$lib/managers/timeline-manager/internal/search-support.svelte'; } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { isMismatched, updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import type {
AssetDescriptor,
AssetOperation,
Direction,
ScrubberMonth,
TimelineAsset,
TimelineManagerOptions,
Viewport,
} from '$lib/managers/timeline-manager/types';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { import {
setDifferenceInPlace, setDifferenceInPlace,
@@ -23,21 +35,9 @@ import {
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte';
import type {
AssetDescriptor,
AssetOperation,
Direction,
ScrubberMonth,
TimelineAsset,
TimelineManagerOptions,
Viewport,
} from './types';
type ViewportTopMonthIntersection = { type ViewportTopMonthIntersection = {
month: MonthGroup | undefined; month: TimelineMonth | undefined;
// Where viewport top intersects month (0 = month top, 1 = month bottom) // Where viewport top intersects month (0 = month top, 1 = month bottom)
viewportTopRatioInMonth: number; viewportTopRatioInMonth: number;
// Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom) // Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom)
@@ -63,7 +63,7 @@ export class TimelineManager extends VirtualScrollManager {
}); });
isInitialized = $state(false); isInitialized = $state(false);
months: MonthGroup[] = $state([]); months: TimelineMonth[] = $state([]);
albumAssets: Set<string> = new SvelteSet(); albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]); scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0); scrubberTimelineHeight: number = $state(0);
@@ -113,24 +113,24 @@ export class TimelineManager extends VirtualScrollManager {
} }
async *assetsIterator(options?: { async *assetsIterator(options?: {
startMonthGroup?: MonthGroup; startMonth?: TimelineMonth;
startDayGroup?: DayGroup; startDay?: TimelineDay;
startAsset?: TimelineAsset; startAsset?: TimelineAsset;
direction?: Direction; direction?: Direction;
}) { }) {
const direction = options?.direction ?? 'earlier'; const direction = options?.direction ?? 'earlier';
let { startDayGroup, startAsset } = options ?? {}; let { startDay, startAsset } = options ?? {};
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) { for (const month of this.monthIterator({ direction, startMonth: options?.startMonth })) {
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false }); await this.loadMonth(month.yearMonth, { cancelable: false });
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction }); yield* month.assetsIterator({ startDay, startAsset, direction });
startDayGroup = startAsset = undefined; startDay = startAsset = undefined;
} }
} }
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) { *monthIterator(options?: { direction?: Direction; startMonth?: TimelineMonth }) {
const isEarlier = options?.direction === 'earlier'; const isEarlier = options?.direction === 'earlier';
let startIndex = options?.startMonthGroup let startIndex = options?.startMonth
? this.months.indexOf(options.startMonthGroup) ? this.months.indexOf(options.startMonth)
: isEarlier : isEarlier
? 0 ? 0
: this.months.length - 1; : this.months.length - 1;
@@ -157,7 +157,7 @@ export class TimelineManager extends VirtualScrollManager {
this.#websocketSupport = undefined; this.#websocketSupport = undefined;
} }
#calculateMonthBottomViewportRatio(month: MonthGroup | undefined) { #calculateMonthBottomViewportRatio(month: TimelineMonth | undefined) {
if (!month) { if (!month) {
return 0; return 0;
} }
@@ -167,7 +167,7 @@ export class TimelineManager extends VirtualScrollManager {
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1); return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
} }
#calculateVewportTopRatioInMonth(month: MonthGroup | undefined) { #calculateVewportTopRatioInMonth(month: TimelineMonth | undefined) {
if (!month) { if (!month) {
return 0; return 0;
} }
@@ -181,7 +181,7 @@ export class TimelineManager extends VirtualScrollManager {
this.#updatingIntersections = true; this.#updatingIntersections = true;
for (const month of this.months) { for (const month of this.months) {
updateIntersectionMonthGroup(this, month); updateIntersectionMonth(this, month);
} }
const month = this.months.find((month) => month.actuallyIntersecting); const month = this.months.find((month) => month.actuallyIntersecting);
@@ -197,17 +197,17 @@ export class TimelineManager extends VirtualScrollManager {
this.#updatingIntersections = false; this.#updatingIntersections = false;
} }
clearDeferredLayout(month: MonthGroup) { clearDeferredLayout(month: TimelineMonth) {
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout); const hasDeferred = month.days.some((group) => group.deferredLayout);
if (hasDeferred) { if (hasDeferred) {
updateGeometry(this, month, { invalidateHeight: true, noDefer: true }); updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
for (const group of month.dayGroups) { for (const group of month.days) {
group.deferredLayout = false; group.deferredLayout = false;
} }
} }
} }
async #initializeMonthGroups() { async #initializeMonths() {
const timebuckets = await getTimeBuckets({ const timebuckets = await getTimeBuckets({
...authManager.params, ...authManager.params,
...this.#options, ...this.#options,
@@ -215,7 +215,7 @@ export class TimelineManager extends VirtualScrollManager {
this.months = timebuckets.map((timeBucket) => { this.months = timebuckets.map((timeBucket) => {
const date = new SvelteDate(timeBucket.timeBucket); const date = new SvelteDate(timeBucket.timeBucket);
return new MonthGroup( return new TimelineMonth(
this, this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count, timeBucket.count,
@@ -246,7 +246,7 @@ export class TimelineManager extends VirtualScrollManager {
this.albumAssets.clear(); this.albumAssets.clear();
await this.initTask.execute(async () => { await this.initTask.execute(async () => {
this.#options = options; this.#options = options;
await this.#initializeMonthGroups(); await this.#initializeMonths();
}, true); }, true);
} }
@@ -293,31 +293,31 @@ export class TimelineManager extends VirtualScrollManager {
assetCount: month.assetsCount, assetCount: month.assetsCount,
year: month.yearMonth.year, year: month.yearMonth.year,
month: month.yearMonth.month, month: month.yearMonth.month,
title: month.monthGroupTitle, title: month.monthTitle,
height: month.height, height: month.height,
})); }));
this.scrubberTimelineHeight = this.totalViewerHeight; this.scrubberTimelineHeight = this.totalViewerHeight;
} }
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> { async loadMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true; let cancelable = true;
if (options) { if (options) {
cancelable = options.cancelable; cancelable = options.cancelable;
} }
const monthGroup = getMonthGroupByDate(this, yearMonth); const month = getMonthByDate(this, yearMonth);
if (!monthGroup) { if (!month) {
return; return;
} }
if (monthGroup.loader?.executed) { if (month.loader?.executed) {
return; return;
} }
const executionStatus = await monthGroup.loader?.execute(async (signal: AbortSignal) => { const executionStatus = await month.loader?.execute(async (signal: AbortSignal) => {
await loadFromTimeBuckets(this, monthGroup, this.#options, signal); await loadFromTimeBuckets(this, month, this.#options, signal);
}, cancelable); }, cancelable);
if (executionStatus === 'LOADED') { if (executionStatus === 'LOADED') {
updateGeometry(this, monthGroup, { invalidateHeight: false }); updateGeometry(this, month, { invalidateHeight: false });
this.updateIntersections(); this.updateIntersections();
} }
} }
@@ -328,14 +328,14 @@ export class TimelineManager extends VirtualScrollManager {
this.addAssetsToSegments(notExcluded); this.addAssetsToSegments(notExcluded);
} }
async findMonthGroupForAsset(id: string) { async findMonthForAsset(id: string) {
if (!this.isInitialized) { if (!this.isInitialized) {
await this.initTask.waitUntilCompletion(); await this.initTask.waitUntilCompletion();
} }
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {}; let { month } = findMonthForAssetUtil(this, id) ?? {};
if (monthGroup) { if (month) {
return monthGroup; return month;
} }
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null); const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
@@ -348,20 +348,20 @@ export class TimelineManager extends VirtualScrollManager {
return; return;
} }
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false }); month = await this.#loadMonthAtTime(asset.localDateTime, { cancelable: false });
if (monthGroup?.findAssetById({ id })) { if (month?.findAssetById({ id })) {
return monthGroup; return month;
} }
} }
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
await this.loadMonthGroup(yearMonth, options); await this.loadMonth(yearMonth, options);
return getMonthGroupByDate(this, yearMonth); return getMonthByDate(this, yearMonth);
} }
getMonthGroupByAssetId(assetId: string) { getMonthByAssetId(assetId: string) {
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId); const monthInfo = findMonthForAssetUtil(this, assetId);
return monthGroupInfo?.monthGroup; return monthInfo?.month;
} }
// note: the `index` input is expected to be in the range [0, assetCount). This // note: the `index` input is expected to be in the range [0, assetCount). This
@@ -372,7 +372,7 @@ export class TimelineManager extends VirtualScrollManager {
let accumulatedCount = 0; let accumulatedCount = 0;
let randomMonth: MonthGroup | undefined = undefined; let randomMonth: TimelineMonth | undefined = undefined;
for (const month of this.months) { for (const month of this.months) {
if (randomAssetIndex < accumulatedCount + month.assetsCount) { if (randomAssetIndex < accumulatedCount + month.assetsCount) {
randomMonth = month; randomMonth = month;
@@ -384,10 +384,10 @@ export class TimelineManager extends VirtualScrollManager {
if (!randomMonth) { if (!randomMonth) {
return; return;
} }
await this.loadMonthGroup(randomMonth.yearMonth, { cancelable: false }); await this.loadMonth(randomMonth.yearMonth, { cancelable: false });
let randomDay: DayGroup | undefined = undefined; let randomDay: TimelineDay | undefined = undefined;
for (const day of randomMonth.dayGroups) { for (const day of randomMonth.days) {
if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) { if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
randomDay = day; randomDay = day;
break; break;
@@ -443,10 +443,10 @@ export class TimelineManager extends VirtualScrollManager {
} }
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void { protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
let month = getMonthGroupByDate(this, asset.localDateTime); let month = getMonthByDate(this, asset.localDateTime);
if (!month) { if (!month) {
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order); month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
this.months.push(month); this.months.push(month);
} }
@@ -476,7 +476,7 @@ export class TimelineManager extends VirtualScrollManager {
} }
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonthGroups = new Set<MonthGroup>(); const changedMonths = new Set<TimelineMonth>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToProcess = new Set(ids); const idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
@@ -493,15 +493,15 @@ export class TimelineManager extends VirtualScrollManager {
idsProcessed.add(id); idsProcessed.add(id);
} }
if (changedGeometry) { if (changedGeometry) {
changedMonthGroups.add(month); changedMonths.add(month);
} }
} }
} }
if (combinedMoveAssets.length > 0) { if (combinedMoveAssets.length > 0) {
this.addAssetsToSegments(combinedMoveAssets); this.addAssetsToSegments(combinedMoveAssets);
} }
const changedGeometry = changedMonthGroups.size > 0; const changedGeometry = changedMonths.size > 0;
for (const month of changedMonthGroups) { for (const month of changedMonths) {
updateGeometry(this, month, { invalidateHeight: true }); updateGeometry(this, month, { invalidateHeight: true });
} }
if (changedGeometry) { if (changedGeometry) {
@@ -536,20 +536,20 @@ export class TimelineManager extends VirtualScrollManager {
} }
async getClosestAssetToDate(dateTime: TimelineDateTime) { async getClosestAssetToDate(dateTime: TimelineDateTime) {
let monthGroup = findMonthGroupForDate(this, dateTime); let month = findMonthForDate(this, dateTime);
if (!monthGroup) { if (!month) {
// if exact match not found, find closest // if exact match not found, find closest
monthGroup = findClosestGroupForDate(this.months, dateTime); month = findClosestGroupForDate(this.months, dateTime);
if (!monthGroup) { if (!month) {
return; return;
} }
} }
await this.loadMonthGroup(dateTime, { cancelable: false }); await this.loadMonth(dateTime, { cancelable: false });
const asset = monthGroup.findClosest(dateTime); const asset = month.findClosest(dateTime);
if (asset) { if (asset) {
return asset; return asset;
} }
for await (const asset of this.assetsIterator({ startMonthGroup: monthGroup })) { for await (const asset of this.assetsIterator({ startMonth: month })) {
return asset; return asset;
} }
} }
@@ -579,16 +579,16 @@ export class TimelineManager extends VirtualScrollManager {
} }
protected postUpsert(context: GroupInsertionCache): void { protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDayGroups) { for (const group of context.existingDays) {
group.sortAssets(this.#options.order); group.sortAssets(this.#options.order);
} }
for (const monthGroup of context.bucketsWithNewDayGroups) { for (const month of context.monthsWithNewDays) {
monthGroup.sortDayGroups(); month.sortDays();
} }
for (const month of context.updatedBuckets) { for (const month of context.updatedMonths) {
month.sortDayGroups(); month.sortDays();
updateGeometry(this, month, { invalidateHeight: true }); updateGeometry(this, month, { invalidateHeight: true });
} }
} }
@@ -1,10 +1,15 @@
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { onCreateMonth } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import { ScrollSegment } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
formatGroupTitle, formatDayTitle,
formatMonthGroupTitle, formatMonthTitle,
fromTimelinePlainDate, fromTimelinePlainDate,
fromTimelinePlainDateTime, fromTimelinePlainDateTime,
fromTimelinePlainYearMonth, fromTimelinePlainYearMonth,
@@ -13,22 +18,15 @@ import {
type TimelineDateTime, type TimelineDateTime,
type TimelineYearMonth, type TimelineYearMonth,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { onCreateMonthGroup } from '$lib/managers/timeline-manager/internal/TestHooks.svelte'; export class TimelineMonth extends ScrollSegment {
import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup {
#intersecting: boolean = $state(false); #intersecting: boolean = $state(false);
actuallyIntersecting: boolean = $state(false); actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false); isLoaded: boolean = $state(false);
dayGroups: DayGroup[] = $state([]); days: TimelineDay[] = $state([]);
readonly timelineManager: TimelineManager; readonly timelineManager: TimelineManager;
#height: number = $state(0); #height: number = $state(0);
@@ -39,14 +37,12 @@ export class MonthGroup {
percent: number = $state(0); percent: number = $state(0);
assetsCount: number = $derived( assetsCount: number = $derived(
this.isLoaded this.isLoaded ? this.days.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0) : this.#initialCount,
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
: this.#initialCount,
); );
loader: CancellableTask | undefined; loader: CancellableTask | undefined;
isHeightActual: boolean = $state(false); isHeightActual: boolean = $state(false);
readonly monthGroupTitle: string; readonly monthTitle: string;
readonly yearMonth: TimelineYearMonth; readonly yearMonth: TimelineYearMonth;
constructor( constructor(
@@ -56,19 +52,20 @@ export class MonthGroup {
loaded: boolean, loaded: boolean,
order: AssetOrder = AssetOrder.Desc, order: AssetOrder = AssetOrder.Desc,
) { ) {
super();
this.timelineManager = timelineManager; this.timelineManager = timelineManager;
this.#initialCount = initialCount; this.#initialCount = initialCount;
this.#sortOrder = order; this.#sortOrder = order;
this.yearMonth = yearMonth; this.yearMonth = yearMonth;
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth)); this.monthTitle = formatMonthTitle(fromTimelinePlainYearMonth(yearMonth));
this.loader = new CancellableTask( this.loader = new CancellableTask(
() => { () => {
this.isLoaded = true; this.isLoaded = true;
}, },
() => { () => {
this.dayGroups = []; this.days = [];
this.isLoaded = false; this.isLoaded = false;
}, },
this.#handleLoadError, this.#handleLoadError,
@@ -77,7 +74,7 @@ export class MonthGroup {
this.isLoaded = true; this.isLoaded = true;
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
onCreateMonthGroup(this); onCreateMonth(this);
} }
} }
@@ -88,7 +85,7 @@ export class MonthGroup {
} }
this.#intersecting = newValue; this.#intersecting = newValue;
if (newValue) { if (newValue) {
void this.timelineManager.loadMonthGroup(this.yearMonth); void this.timelineManager.loadMonth(this.yearMonth);
} else { } else {
this.cancel(); this.cancel();
} }
@@ -98,25 +95,25 @@ export class MonthGroup {
return this.#intersecting; return this.#intersecting;
} }
get lastDayGroup() { get lastDay() {
return this.dayGroups.at(-1); return this.days.at(-1);
} }
getFirstAsset() { getFirstAsset() {
return this.dayGroups[0]?.getFirstAsset(); return this.days[0]?.getFirstAsset();
} }
getAssets() { getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce // eslint-disable-next-line unicorn/no-array-reduce
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []); return this.days.reduce((accumulator: TimelineAsset[], g: TimelineDay) => accumulator.concat(g.getAssets()), []);
} }
sortDayGroups() { sortDays() {
if (this.#sortOrder === AssetOrder.Asc) { if (this.#sortOrder === AssetOrder.Asc) {
return this.dayGroups.sort((a, b) => a.day - b.day); return this.days.sort((a, b) => a.day - b.day);
} }
return this.dayGroups.sort((a, b) => b.day - a.day); return this.days.sort((a, b) => b.day - a.day);
} }
runAssetOperation(ids: Set<string>, operation: AssetOperation) { runAssetOperation(ids: Set<string>, operation: AssetOperation) {
@@ -129,17 +126,17 @@ export class MonthGroup {
changedGeometry: false, changedGeometry: false,
}; };
} }
const { dayGroups } = this; const { days } = this;
let combinedChangedGeometry = false; let combinedChangedGeometry = false;
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToProcess = new Set(ids); const idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsProcessed = new Set<string>(); const idsProcessed = new Set<string>();
const combinedMoveAssets: TimelineAsset[] = []; const combinedMoveAssets: TimelineAsset[] = [];
let index = dayGroups.length; let index = days.length;
while (index--) { while (index--) {
if (idsToProcess.size > 0) { if (idsToProcess.size > 0) {
const group = dayGroups[index]; const group = days[index];
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation); const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
if (moveAssets.length > 0) { if (moveAssets.length > 0) {
combinedMoveAssets.push(...moveAssets); combinedMoveAssets.push(...moveAssets);
@@ -150,7 +147,7 @@ export class MonthGroup {
} }
combinedChangedGeometry = combinedChangedGeometry || changedGeometry; combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
if (group.viewerAssets.length === 0) { if (group.viewerAssets.length === 0) {
dayGroups.splice(index, 1); days.splice(index, 1);
combinedChangedGeometry = true; combinedChangedGeometry = true;
} }
} }
@@ -205,12 +202,12 @@ export class MonthGroup {
this.addTimelineAsset(timelineAsset, addContext); this.addTimelineAsset(timelineAsset, addContext);
} }
if (!preSorted) { if (!preSorted) {
for (const group of addContext.existingDayGroups) { for (const group of addContext.existingDays) {
group.sortAssets(this.#sortOrder); group.sortAssets(this.#sortOrder);
} }
if (addContext.newDayGroups.size > 0) { if (addContext.newDays.size > 0) {
this.sortDayGroups(); this.sortDays();
} }
addContext.sort(this, this.#sortOrder); addContext.sort(this, this.#sortOrder);
@@ -227,20 +224,20 @@ export class MonthGroup {
return; return;
} }
let dayGroup = addContext.getDayGroup(localDateTime) || this.findDayGroupByDay(localDateTime.day); let day = addContext.getDay(localDateTime) || this.findDayByDay(localDateTime.day);
if (dayGroup) { if (day) {
addContext.setDayGroup(dayGroup, localDateTime); addContext.setDay(day, localDateTime);
} else { } else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime)); const dayTitle = formatDayTitle(fromTimelinePlainDate(localDateTime));
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle); day = new TimelineDay(this, this.days.length, localDateTime.day, dayTitle);
this.dayGroups.push(dayGroup); this.days.push(day);
addContext.setDayGroup(dayGroup, localDateTime); addContext.setDay(day, localDateTime);
addContext.newDayGroups.add(dayGroup); addContext.newDays.add(day);
} }
const viewerAsset = new ViewerAsset(dayGroup, timelineAsset); const viewerAsset = new ViewerAsset(day, timelineAsset);
dayGroup.viewerAssets.push(viewerAsset); day.viewerAssets.push(viewerAsset);
addContext.changedDayGroups.add(dayGroup); addContext.changedDays.add(day);
} }
get viewId() { get viewId() {
@@ -256,9 +253,9 @@ export class MonthGroup {
const index = timelineManager.months.indexOf(this); const index = timelineManager.months.indexOf(this);
const heightDelta = height - this.#height; const heightDelta = height - this.#height;
this.#height = height; this.#height = height;
const prevMonthGroup = timelineManager.months[index - 1]; const prevMonth = timelineManager.months[index - 1];
if (prevMonthGroup) { if (prevMonth) {
const newTop = prevMonthGroup.#top + prevMonthGroup.#height; const newTop = prevMonth.#top + prevMonth.#height;
if (this.#top !== newTop) { if (this.#top !== newTop) {
this.#top = newTop; this.#top = newTop;
} }
@@ -267,10 +264,10 @@ export class MonthGroup {
return; return;
} }
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) { for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
const monthGroup = this.timelineManager.months[cursor]; const month = this.timelineManager.months[cursor];
const newTop = monthGroup.#top + heightDelta; const newTop = month.#top + heightDelta;
if (monthGroup.#top !== newTop) { if (month.#top !== newTop) {
monthGroup.#top = newTop; month.#top = newTop;
} }
} }
if (!timelineManager.viewportTopMonthIntersection) { if (!timelineManager.viewportTopMonthIntersection) {
@@ -302,21 +299,21 @@ export class MonthGroup {
handleError(error, _$t('errors.failed_to_load_assets')); handleError(error, _$t('errors.failed_to_load_assets'));
} }
findDayGroupForAsset(asset: TimelineAsset) { findDayForAsset(asset: TimelineAsset) {
for (const group of this.dayGroups) { for (const group of this.days) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) { if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
return group; return group;
} }
} }
} }
findDayGroupByDay(day: number) { findDayByDay(day: number) {
return this.dayGroups.find((group) => group.day === day); return this.days.find((group) => group.day === day);
} }
findAssetAbsolutePosition(assetId: string) { findAssetAbsolutePosition(assetId: string) {
this.timelineManager.clearDeferredLayout(this); this.timelineManager.clearDeferredLayout(this);
for (const group of this.dayGroups) { for (const group of this.days) {
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId); const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) { if (viewerAsset) {
if (!viewerAsset.position) { if (!viewerAsset.position) {
@@ -331,18 +328,14 @@ export class MonthGroup {
} }
} }
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) { *assetsIterator(options?: { startDay?: TimelineDay; startAsset?: TimelineAsset; direction?: Direction }) {
const direction = options?.direction ?? 'earlier'; const direction = options?.direction ?? 'earlier';
let { startAsset } = options ?? {}; let { startAsset } = options ?? {};
const isEarlier = direction === 'earlier'; const isEarlier = direction === 'earlier';
let groupIndex = options?.startDayGroup let groupIndex = options?.startDay ? this.days.indexOf(options.startDay) : isEarlier ? 0 : this.days.length - 1;
? this.dayGroups.indexOf(options.startDayGroup)
: isEarlier
? 0
: this.dayGroups.length - 1;
while (groupIndex >= 0 && groupIndex < this.dayGroups.length) { while (groupIndex >= 0 && groupIndex < this.days.length) {
const group = this.dayGroups[groupIndex]; const group = this.days[groupIndex];
yield* group.assetsIterator({ startAsset, direction }); yield* group.assetsIterator({ startAsset, direction });
startAsset = undefined; startAsset = undefined;
groupIndex += isEarlier ? 1 : -1; groupIndex += isEarlier ? 1 : -1;
@@ -1,64 +1,64 @@
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util'; import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk'; import { AssetOrder } from '@immich/sdk';
import type { DayGroup } from './day-group.svelte';
import type { MonthGroup } from './month-group.svelte';
import type { TimelineAsset } from './types';
export class GroupInsertionCache { export class GroupInsertionCache {
#lookupCache: { #lookupCache: {
[year: number]: { [month: number]: { [day: number]: DayGroup } }; [year: number]: { [month: number]: { [day: number]: TimelineDay } };
} = {}; } = {};
unprocessedAssets: TimelineAsset[] = []; unprocessedAssets: TimelineAsset[] = [];
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
changedDayGroups = new Set<DayGroup>(); changedDays = new Set<TimelineDay>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
newDayGroups = new Set<DayGroup>(); newDays = new Set<TimelineDay>();
getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined { getDay({ year, month, day }: TimelineDate): TimelineDay | undefined {
return this.#lookupCache[year]?.[month]?.[day]; return this.#lookupCache[year]?.[month]?.[day];
} }
setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelineDate) { setDay(day: TimelineDay, { year, month, day: dayNumber }: TimelineDate) {
if (!this.#lookupCache[year]) { if (!this.#lookupCache[year]) {
this.#lookupCache[year] = {}; this.#lookupCache[year] = {};
} }
if (!this.#lookupCache[year][month]) { if (!this.#lookupCache[year][month]) {
this.#lookupCache[year][month] = {}; this.#lookupCache[year][month] = {};
} }
this.#lookupCache[year][month][day] = dayGroup; this.#lookupCache[year][month][dayNumber] = day;
} }
get existingDayGroups() { get existingDays() {
return setDifference(this.changedDayGroups, this.newDayGroups); return setDifference(this.changedDays, this.newDays);
} }
get updatedBuckets() { get updatedMonths() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>(); const months = new Set<TimelineMonth>();
for (const group of this.changedDayGroups) { for (const day of this.changedDays) {
updated.add(group.monthGroup); months.add(day.month);
} }
return updated; return months;
} }
get bucketsWithNewDayGroups() { get monthsWithNewDays() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>(); const months = new Set<TimelineMonth>();
for (const group of this.newDayGroups) { for (const day of this.newDays) {
updated.add(group.monthGroup); months.add(day.month);
} }
return updated; return months;
} }
sort(monthGroup: MonthGroup, sortOrder: AssetOrder = AssetOrder.Desc) { sort(month: TimelineMonth, sortOrder: AssetOrder = AssetOrder.Desc) {
for (const group of this.changedDayGroups) { for (const day of this.changedDays) {
group.sortAssets(sortOrder); day.sortAssets(sortOrder);
} }
for (const group of this.newDayGroups) { for (const day of this.newDays) {
group.sortAssets(sortOrder); day.sortAssets(sortOrder);
} }
if (this.newDayGroups.size > 0) { if (this.newDays.size > 0) {
monthGroup.sortDayGroups(); month.sortDays();
} }
} }
} }
@@ -1,16 +1,16 @@
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
let testHooks: TestHooks | undefined = undefined; let testHooks: TestHooks | undefined = undefined;
export type TestHooks = { export type TestHooks = {
onCreateMonthGroup(monthGroup: MonthGroup): unknown; onCreateMonth(month: TimelineMonth): unknown;
onCreateDayGroup(dayGroup: DayGroup): unknown; onCreateDay(day: TimelineDay): unknown;
}; };
export const setTestHooks = (hooks: TestHooks) => { export const setTestHooks = (hooks: TestHooks) => {
testHooks = hooks; testHooks = hooks;
}; };
export const onCreateMonthGroup = (monthGroup: MonthGroup) => testHooks?.onCreateMonthGroup(monthGroup); export const onCreateMonth = (month: TimelineMonth) => testHooks?.onCreateMonth(month);
export const onCreateDayGroup = (dayGroup: DayGroup) => testHooks?.onCreateDayGroup(dayGroup); export const onCreateDay = (day: TimelineDay) => testHooks?.onCreateDay(day);
@@ -1,16 +1,16 @@
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import type { MonthGroup } from '../month-group.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
const { const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES; } = TUNABLES;
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) { export function updateIntersectionMonth(timelineManager: TimelineManager, month: TimelineMonth) {
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0); const actuallyIntersecting = calculateMonthIntersecting(timelineManager, month, 0, 0);
let preIntersecting = false; let preIntersecting = false;
if (!actuallyIntersecting) { if (!actuallyIntersecting) {
preIntersecting = calculateMonthGroupIntersecting( preIntersecting = calculateMonthIntersecting(
timelineManager, timelineManager,
month, month,
INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_TOP,
@@ -40,18 +40,18 @@ export function isIntersecting(regionTop: number, regionBottom: number, windowTo
); );
} }
export function calculateMonthGroupIntersecting( export function calculateMonthIntersecting(
timelineManager: TimelineManager, timelineManager: TimelineManager,
monthGroup: MonthGroup, month: TimelineMonth,
expandTop: number, expandTop: number,
expandBottom: number, expandBottom: number,
) { ) {
const monthGroupTop = monthGroup.top; const monthTop = month.top;
const monthGroupBottom = monthGroupTop + monthGroup.height; const monthBottom = monthTop + month.height;
const topWindow = timelineManager.visibleWindow.top - expandTop; const topWindow = timelineManager.visibleWindow.top - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom; const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow); return isIntersecting(monthTop, monthBottom, topWindow, bottomWindow);
} }
/** /**
@@ -1,8 +1,8 @@
import type { MonthGroup } from '../month-group.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineManager } from '../timeline-manager.svelte'; import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { UpdateGeometryOptions } from '../types'; import type { UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) { export function updateGeometry(timelineManager: TimelineManager, month: TimelineMonth, options: UpdateGeometryOptions) {
const { invalidateHeight, noDefer = false } = options; const { invalidateHeight, noDefer = false } = options;
if (invalidateHeight) { if (invalidateHeight) {
month.isHeightActual = false; month.isHeightActual = false;
@@ -17,49 +17,49 @@ export function updateGeometry(timelineManager: TimelineManager, month: MonthGro
} }
return; return;
} }
layoutMonthGroup(timelineManager, month, noDefer); layoutMonth(timelineManager, month, noDefer);
} }
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) { export function layoutMonth(timelineManager: TimelineManager, month: TimelineMonth, noDefer: boolean = false) {
let cumulativeHeight = 0; let cumulativeHeight = 0;
let cumulativeWidth = 0; let cumulativeWidth = 0;
let currentRowHeight = 0; let currentRowHeight = 0;
let dayGroupRow = 0; let dayRow = 0;
let dayGroupCol = 0; let dayCol = 0;
const options = timelineManager.justifiedLayoutOptions; const options = timelineManager.justifiedLayoutOptions;
for (const dayGroup of month.dayGroups) { for (const day of month.days) {
dayGroup.layout(options, noDefer); day.layout(options, noDefer);
// Calculate space needed for this item (including gap if not first in row) // Calculate space needed for this item (including gap if not first in row)
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? timelineManager.gap : 0); const spaceNeeded = day.width + (dayCol > 0 ? timelineManager.gap : 0);
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth; const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
if (fitsInCurrentRow) { if (fitsInCurrentRow) {
dayGroup.row = dayGroupRow; day.row = dayRow;
dayGroup.col = dayGroupCol++; day.col = dayCol++;
dayGroup.left = cumulativeWidth; day.left = cumulativeWidth;
dayGroup.top = cumulativeHeight; day.top = cumulativeHeight;
cumulativeWidth += dayGroup.width + timelineManager.gap; cumulativeWidth += day.width + timelineManager.gap;
} else { } else {
// Move to next row // Move to next row
cumulativeHeight += currentRowHeight; cumulativeHeight += currentRowHeight;
cumulativeWidth = 0; cumulativeWidth = 0;
dayGroupRow++; dayRow++;
dayGroupCol = 0; dayCol = 0;
// Position at start of new row // Position at start of new row
dayGroup.row = dayGroupRow; day.row = dayRow;
dayGroup.col = dayGroupCol; day.col = dayCol;
dayGroup.left = 0; day.left = 0;
dayGroup.top = cumulativeHeight; day.top = cumulativeHeight;
dayGroupCol++; dayCol++;
cumulativeWidth += dayGroup.width + timelineManager.gap; cumulativeWidth += day.width + timelineManager.gap;
} }
currentRowHeight = dayGroup.height + timelineManager.headerHeight; currentRowHeight = day.height + timelineManager.headerHeight;
} }
// Add the height of the final row // Add the height of the final row
@@ -1,21 +1,21 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineManagerOptions } from '$lib/managers/timeline-manager/types';
import { toISOYearMonthUTC } from '$lib/utils/timeline-util'; import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { getTimeBucket } from '@immich/sdk'; import { getTimeBucket } from '@immich/sdk';
import type { MonthGroup } from '../month-group.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { TimelineManagerOptions } from '../types';
export async function loadFromTimeBuckets( export async function loadFromTimeBuckets(
timelineManager: TimelineManager, timelineManager: TimelineManager,
monthGroup: MonthGroup, month: TimelineMonth,
options: TimelineManagerOptions, options: TimelineManagerOptions,
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
if (monthGroup.getFirstAsset()) { if (month.getFirstAsset()) {
return; return;
} }
const timeBucket = toISOYearMonthUTC(monthGroup.yearMonth); const timeBucket = toISOYearMonthUTC(month.yearMonth);
const bucketResponse = await getTimeBucket( const bucketResponse = await getTimeBucket(
{ {
...authManager.params, ...authManager.params,
@@ -43,10 +43,10 @@ export async function loadFromTimeBuckets(
} }
} }
const unprocessedAssets = monthGroup.addAssets(bucketResponse, true); const unprocessedAssets = month.addAssets(bucketResponse, true);
if (unprocessedAssets.length > 0) { if (unprocessedAssets.length > 0) {
console.error( console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify( `Warning: getTimeBucket API returning assets not in requested month: ${month.yearMonth.month}, ${JSON.stringify(
unprocessedAssets.map((unprocessed) => ({ unprocessedAssets.map((unprocessed) => ({
id: unprocessed.id, id: unprocessed.id,
localDateTime: unprocessed.localDateTime, localDateTime: unprocessed.localDateTime,
@@ -1,11 +1,11 @@
import { findClosestGroupForDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { MonthGroup } from '../month-group.svelte';
import { findClosestGroupForDate } from './search-support.svelte';
function createMockMonthGroup(year: number, month: number): MonthGroup { function createMockMonth(year: number, month: number): TimelineMonth {
return { return {
yearMonth: { year, month }, yearMonth: { year, month },
} as MonthGroup; } as TimelineMonth;
} }
describe('findClosestGroupForDate', () => { describe('findClosestGroupForDate', () => {
@@ -15,25 +15,25 @@ describe('findClosestGroupForDate', () => {
}); });
it('should return the only month when there is only one month', () => { it('should return the only month when there is only one month', () => {
const months = [createMockMonthGroup(2024, 6)]; const months = [createMockMonth(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2025, month: 1 }); const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
}); });
it('should return exact match when available', () => { it('should return exact match when available', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; const months = [createMockMonth(2024, 1), createMockMonth(2024, 6), createMockMonth(2024, 12)];
const result = findClosestGroupForDate(months, { year: 2024, month: 6 }); const result = findClosestGroupForDate(months, { year: 2024, month: 6 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
}); });
it('should find closest month when target is between two months', () => { it('should find closest month when target is between two months', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; const months = [createMockMonth(2024, 1), createMockMonth(2024, 6), createMockMonth(2024, 12)];
const result = findClosestGroupForDate(months, { year: 2024, month: 4 }); const result = findClosestGroupForDate(months, { year: 2024, month: 4 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
}); });
it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => { it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => {
const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)]; const months = [createMockMonth(2023, 12), createMockMonth(2024, 2)];
const result = findClosestGroupForDate(months, { year: 2024, month: 1 }); const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
// 2024-01 is 1 month from 2023-12 and 1 month from 2024-02 // 2024-01 is 1 month from 2023-12 and 1 month from 2024-02
// Should return first encountered with min distance (2023-12) // Should return first encountered with min distance (2023-12)
@@ -41,33 +41,33 @@ describe('findClosestGroupForDate', () => {
}); });
it('should correctly calculate distance across years', () => { it('should correctly calculate distance across years', () => {
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)]; const months = [createMockMonth(2022, 6), createMockMonth(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2023, month: 6 }); const result = findClosestGroupForDate(months, { year: 2023, month: 6 });
// Both are exactly 12 months away, should return first encountered // Both are exactly 12 months away, should return first encountered
expect(result?.yearMonth).toEqual({ year: 2022, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2022, month: 6 });
}); });
it('should handle target before all months', () => { it('should handle target before all months', () => {
const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; const months = [createMockMonth(2024, 6), createMockMonth(2024, 12)];
const result = findClosestGroupForDate(months, { year: 2024, month: 1 }); const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
}); });
it('should handle target after all months', () => { it('should handle target after all months', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)]; const months = [createMockMonth(2024, 1), createMockMonth(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2025, month: 1 }); const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
}); });
it('should handle multiple years correctly', () => { it('should handle multiple years correctly', () => {
const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)]; const months = [createMockMonth(2020, 1), createMockMonth(2022, 1), createMockMonth(2024, 1)];
const result = findClosestGroupForDate(months, { year: 2023, month: 1 }); const result = findClosestGroupForDate(months, { year: 2023, month: 1 });
// 2023-01 is 12 months from 2022-01 and 12 months from 2024-01 // 2023-01 is 12 months from 2022-01 and 12 months from 2024-01
expect(result?.yearMonth).toEqual({ year: 2022, month: 1 }); expect(result?.yearMonth).toEqual({ year: 2022, month: 1 });
}); });
it('should prefer closer month when one is clearly closer', () => { it('should prefer closer month when one is clearly closer', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)]; const months = [createMockMonth(2024, 1), createMockMonth(2024, 10)];
const result = findClosestGroupForDate(months, { year: 2024, month: 11 }); const result = findClosestGroupForDate(months, { year: 2024, month: 11 });
// 2024-11 is 1 month from 2024-10 and 10 months from 2024-01 // 2024-11 is 1 month from 2024-10 and 10 months from 2024-01
expect(result?.yearMonth).toEqual({ year: 2024, month: 10 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 10 });
@@ -1,9 +1,9 @@
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk'; import { AssetOrder } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import type { MonthGroup } from '../month-group.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
export async function getAssetWithOffset( export async function getAssetWithOffset(
timelineManager: TimelineManager, timelineManager: TimelineManager,
@@ -11,40 +11,40 @@ export async function getAssetWithOffset(
interval: 'asset' | 'day' | 'month' | 'year' = 'asset', interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
direction: Direction, direction: Direction,
): Promise<TimelineAsset | undefined> { ): Promise<TimelineAsset | undefined> {
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {}; const { asset, month } = findMonthForAsset(timelineManager, assetDescriptor.id) ?? {};
if (!monthGroup || !asset) { if (!month || !asset) {
return; return;
} }
switch (interval) { switch (interval) {
case 'asset': { case 'asset': {
return getAssetByAssetOffset(timelineManager, asset, monthGroup, direction); return getAssetByAssetOffset(timelineManager, asset, month, direction);
} }
case 'day': { case 'day': {
return getAssetByDayOffset(timelineManager, asset, monthGroup, direction); return getAssetByDayOffset(timelineManager, asset, month, direction);
} }
case 'month': { case 'month': {
return getAssetByMonthOffset(timelineManager, monthGroup, direction); return getAssetByMonthOffset(timelineManager, month, direction);
} }
case 'year': { case 'year': {
return getAssetByYearOffset(timelineManager, monthGroup, direction); return getAssetByYearOffset(timelineManager, month, direction);
} }
} }
} }
export function findMonthGroupForAsset(timelineManager: TimelineManager, id: string) { export function findMonthForAsset(timelineManager: TimelineManager, id: string) {
for (const month of timelineManager.months) { for (const month of timelineManager.months) {
const asset = month.findAssetById({ id }); const asset = month.findAssetById({ id });
if (asset) { if (asset) {
return { monthGroup: month, asset }; return { month, asset };
} }
} }
} }
export function getMonthGroupByDate( export function getMonthByDate(
timelineManager: TimelineManager, timelineManager: TimelineManager,
targetYearMonth: TimelineYearMonth, targetYearMonth: TimelineYearMonth,
): MonthGroup | undefined { ): TimelineMonth | undefined {
return timelineManager.months.find( return timelineManager.months.find(
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month, (month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
); );
@@ -53,13 +53,13 @@ export function getMonthGroupByDate(
async function getAssetByAssetOffset( async function getAssetByAssetOffset(
timelineManager: TimelineManager, timelineManager: TimelineManager,
asset: TimelineAsset, asset: TimelineAsset,
monthGroup: MonthGroup, month: TimelineMonth,
direction: Direction, direction: Direction,
) { ) {
const dayGroup = monthGroup.findDayGroupForAsset(asset); const day = month.findDayForAsset(asset);
for await (const targetAsset of timelineManager.assetsIterator({ for await (const targetAsset of timelineManager.assetsIterator({
startMonthGroup: monthGroup, startMonth: month,
startDayGroup: dayGroup, startDay: day,
startAsset: asset, startAsset: asset,
direction, direction,
})) { })) {
@@ -72,13 +72,13 @@ async function getAssetByAssetOffset(
async function getAssetByDayOffset( async function getAssetByDayOffset(
timelineManager: TimelineManager, timelineManager: TimelineManager,
asset: TimelineAsset, asset: TimelineAsset,
monthGroup: MonthGroup, month: TimelineMonth,
direction: Direction, direction: Direction,
) { ) {
const dayGroup = monthGroup.findDayGroupForAsset(asset); const day = month.findDayForAsset(asset);
for await (const targetAsset of timelineManager.assetsIterator({ for await (const targetAsset of timelineManager.assetsIterator({
startMonthGroup: monthGroup, startMonth: month,
startDayGroup: dayGroup, startDay: day,
startAsset: asset, startAsset: asset,
direction, direction,
})) { })) {
@@ -88,44 +88,44 @@ async function getAssetByDayOffset(
} }
} }
async function getAssetByMonthOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) { async function getAssetByMonthOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) {
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) { for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) {
if (targetMonth.yearMonth.month !== month.yearMonth.month) { if (targetMonth.yearMonth.month !== month.yearMonth.month) {
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next(); const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next();
return done ? undefined : value; return done ? undefined : value;
} }
} }
} }
async function getAssetByYearOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) { async function getAssetByYearOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) {
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) { for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) {
if (targetMonth.yearMonth.year !== month.yearMonth.year) { if (targetMonth.yearMonth.year !== month.yearMonth.year) {
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next(); const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next();
return done ? undefined : value; return done ? undefined : value;
} }
} }
} }
export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) { export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) {
let { asset: startAsset, monthGroup: startMonthGroup } = findMonthGroupForAsset(timelineManager, start.id) ?? {}; let { asset: startAsset, month: startMonth } = findMonthForAsset(timelineManager, start.id) ?? {};
if (!startMonthGroup || !startAsset) { if (!startMonth || !startAsset) {
return []; return [];
} }
let { asset: endAsset, monthGroup: endMonthGroup } = findMonthGroupForAsset(timelineManager, end.id) ?? {}; let { asset: endAsset, month: endMonth } = findMonthForAsset(timelineManager, end.id) ?? {};
if (!endMonthGroup || !endAsset) { if (!endMonth || !endAsset) {
return []; return [];
} }
const assetOrder: AssetOrder = timelineManager.getAssetOrder(); const assetOrder: AssetOrder = timelineManager.getAssetOrder();
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) { if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
[startAsset, endAsset] = [endAsset, startAsset]; [startAsset, endAsset] = [endAsset, startAsset];
[startMonthGroup, endMonthGroup] = [endMonthGroup, startMonthGroup]; [startMonth, endMonth] = [endMonth, startMonth];
} }
const range: TimelineAsset[] = []; const range: TimelineAsset[] = [];
const startDayGroup = startMonthGroup.findDayGroupForAsset(startAsset); const startDay = startMonth.findDayForAsset(startAsset);
for await (const targetAsset of timelineManager.assetsIterator({ for await (const targetAsset of timelineManager.assetsIterator({
startMonthGroup, startMonth,
startDayGroup, startDay,
startAsset, startAsset,
})) { })) {
range.push(targetAsset); range.push(targetAsset);
@@ -136,7 +136,7 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
return range; return range;
} }
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) { export function findMonthForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
for (const month of timelineManager.months) { for (const month of timelineManager.months) {
const { year, month: monthNum } = month.yearMonth; const { year, month: monthNum } = month.yearMonth;
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) { if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
@@ -145,10 +145,10 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
} }
} }
export function findClosestGroupForDate(months: MonthGroup[], targetYearMonth: TimelineYearMonth) { export function findClosestGroupForDate(months: TimelineMonth[], targetYearMonth: TimelineYearMonth) {
const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month }); const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month });
let closestMonth: MonthGroup | undefined; let closestMonth: TimelineMonth | undefined;
let minDifference = Number.MAX_SAFE_INTEGER; let minDifference = Number.MAX_SAFE_INTEGER;
for (const month of months) { for (const month of months) {
@@ -1,4 +1,4 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -1,4 +1,4 @@
import type { TimelineAsset } from './types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset); export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset)); export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
@@ -1,19 +1,18 @@
import { calculateViewerAssetIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import type { DayGroup } from './day-group.svelte';
import { calculateViewerAssetIntersecting } from './internal/intersection-support.svelte';
import type { TimelineAsset } from './types';
export class ViewerAsset { export class ViewerAsset {
readonly #group: DayGroup; readonly #day: TimelineDay;
intersecting = $derived.by(() => { intersecting = $derived.by(() => {
if (!this.position) { if (!this.position) {
return false; return false;
} }
const store = this.#group.monthGroup.timelineManager; const store = this.#day.month.timelineManager;
const positionTop = this.#group.absoluteDayGroupTop + this.position.top; const positionTop = this.#day.absoluteTop + this.position.top;
return calculateViewerAssetIntersecting(store, positionTop, this.position.height); return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
}); });
@@ -22,8 +21,8 @@ export class ViewerAsset {
asset: TimelineAsset = <TimelineAsset>$state(); asset: TimelineAsset = <TimelineAsset>$state();
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
constructor(group: DayGroup, asset: TimelineAsset) { constructor(day: TimelineDay, asset: TimelineAsset) {
this.#group = group; this.#day = day;
this.asset = asset; this.asset = asset;
} }
} }
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte'; import DateInput from '$lib/elements/DateInput.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils'; import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui'; import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
+1 -1
View File
@@ -1,5 +1,5 @@
import ToastAction from '$lib/components/ToastAction.svelte'; import ToastAction from '$lib/components/ToastAction.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { StackResponse } from '$lib/utils/asset-utils'; import type { StackResponse } from '$lib/utils/asset-utils';
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk'; import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
+6 -6
View File
@@ -3,7 +3,7 @@ import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -490,17 +490,17 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
isSelectingAllAssets.set(true); isSelectingAllAssets.set(true);
try { try {
for (const monthGroup of timelineManager.months) { for (const month of timelineManager.months) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth); await timelineManager.loadMonth(month.yearMonth);
if (!get(isSelectingAllAssets)) { if (!get(isSelectingAllAssets)) {
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
break; // Cancelled break; // Cancelled
} }
assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()])); assetInteraction.selectAssets(assetsSnapshot([...month.assetsIterator()]));
for (const dateGroup of monthGroup.dayGroups) { for (const dateGroup of month.days) {
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle); assetInteraction.addGroupToMultiselectGroup(dateGroup.dayTitle);
} }
} }
} catch (error) { } catch (error) {
+17 -17
View File
@@ -1,9 +1,9 @@
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { parseUtcDate } from '$lib/utils/date-time'; import { parseUtcDate } from '$lib/utils/date-time';
import { formatGroupTitle, toISOYearMonthUTC } from '$lib/utils/timeline-util'; import { formatDayTitle, toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
describe('formatGroupTitle', () => { describe('formatDayTitle', () => {
beforeAll(() => { beforeAll(() => {
vi.useFakeTimers(); vi.useFakeTimers();
process.env.TZ = 'UTC'; process.env.TZ = 'UTC';
@@ -18,63 +18,63 @@ describe('formatGroupTitle', () => {
it('formats today', () => { it('formats today', () => {
const date = parseUtcDate('2024-07-27T01:00:00Z'); const date = parseUtcDate('2024-07-27T01:00:00Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(date)).toBe('today'); expect(formatDayTitle(date)).toBe('today');
locale.set('es'); locale.set('es');
expect(formatGroupTitle(date)).toBe('hoy'); expect(formatDayTitle(date)).toBe('hoy');
}); });
it('formats yesterday', () => { it('formats yesterday', () => {
const date = parseUtcDate('2024-07-26T23:59:59Z'); const date = parseUtcDate('2024-07-26T23:59:59Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(date)).toBe('yesterday'); expect(formatDayTitle(date)).toBe('yesterday');
locale.set('fr'); locale.set('fr');
expect(formatGroupTitle(date)).toBe('hier'); expect(formatDayTitle(date)).toBe('hier');
}); });
it('formats last week', () => { it('formats last week', () => {
const date = parseUtcDate('2024-07-21T00:00:00Z'); const date = parseUtcDate('2024-07-21T00:00:00Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(date)).toBe('Sunday'); expect(formatDayTitle(date)).toBe('Sunday');
locale.set('ar-SA'); locale.set('ar-SA');
expect(formatGroupTitle(date)).toBe('الأحد'); expect(formatDayTitle(date)).toBe('الأحد');
}); });
it('formats date 7 days ago', () => { it('formats date 7 days ago', () => {
const date = parseUtcDate('2024-07-20T00:00:00Z'); const date = parseUtcDate('2024-07-20T00:00:00Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(date)).toBe('Sat, Jul 20'); expect(formatDayTitle(date)).toBe('Sat, Jul 20');
locale.set('de'); locale.set('de');
expect(formatGroupTitle(date)).toBe('Sa., 20. Juli'); expect(formatDayTitle(date)).toBe('Sa., 20. Juli');
}); });
it('formats date this year', () => { it('formats date this year', () => {
const date = parseUtcDate('2020-01-01T00:00:00Z'); const date = parseUtcDate('2020-01-01T00:00:00Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(date)).toBe('Wed, Jan 1, 2020'); expect(formatDayTitle(date)).toBe('Wed, Jan 1, 2020');
locale.set('ja'); locale.set('ja');
expect(formatGroupTitle(date)).toBe('2020年1月1日(水)'); expect(formatDayTitle(date)).toBe('2020年1月1日(水)');
}); });
it('formats future date', () => { it('formats future date', () => {
const tomorrow = parseUtcDate('2024-07-28T00:00:00Z'); const tomorrow = parseUtcDate('2024-07-28T00:00:00Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(tomorrow)).toBe('Sun, Jul 28'); expect(formatDayTitle(tomorrow)).toBe('Sun, Jul 28');
const nextMonth = parseUtcDate('2024-08-28T00:00:00Z'); const nextMonth = parseUtcDate('2024-08-28T00:00:00Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(nextMonth)).toBe('Wed, Aug 28'); expect(formatDayTitle(nextMonth)).toBe('Wed, Aug 28');
const nextYear = parseUtcDate('2025-01-10T12:00:00Z'); const nextYear = parseUtcDate('2025-01-10T12:00:00Z');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(nextYear)).toBe('Fri, Jan 10, 2025'); expect(formatDayTitle(nextYear)).toBe('Fri, Jan 10, 2025');
}); });
it('returns "Invalid DateTime" when date is invalid', () => { it('returns "Invalid DateTime" when date is invalid', () => {
const date = DateTime.invalid('test'); const date = DateTime.invalid('test');
locale.set('en'); locale.set('en');
expect(formatGroupTitle(date)).toBe('Invalid DateTime'); expect(formatDayTitle(date)).toBe('Invalid DateTime');
locale.set('es'); locale.set('es');
expect(formatGroupTitle(date)).toBe('Invalid DateTime'); expect(formatDayTitle(date)).toBe('Invalid DateTime');
}); });
}); });
+2 -2
View File
@@ -99,7 +99,7 @@ export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string =>
return `${yearFull}-${monthFull}-01T00:00:00.000Z`; return `${yearFull}-${monthFull}-01T00:00:00.000Z`;
}; };
export function formatMonthGroupTitle(_date: DateTime): string { export function formatMonthTitle(_date: DateTime): string {
if (!_date.isValid) { if (!_date.isValid) {
return _date.toString(); return _date.toString();
} }
@@ -113,7 +113,7 @@ export function formatMonthGroupTitle(_date: DateTime): string {
); );
} }
export function formatGroupTitle(_date: DateTime): string { export function formatDayTitle(_date: DateTime): string {
if (!_date.isValid) { if (!_date.isValid) {
return _date.toString(); return _date.toString();
} }
@@ -29,7 +29,7 @@
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AlbumPageViewMode, AppRoute } from '$lib/constants'; import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte'; import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
@@ -141,7 +141,7 @@
const asset = const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle $slideshowNavigation === SlideshowNavigation.Shuffle
? await timelineManager.getRandomAsset() ? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset; : timelineManager.months[0]?.days[0]?.viewerAssets[0]?.asset;
if (asset) { if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow))); handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
} }
@@ -14,7 +14,7 @@
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte'; import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { mdiDotsVertical, mdiPlus } from '@mdi/js';
@@ -17,7 +17,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { mdiDotsVertical, mdiPlus } from '@mdi/js';
@@ -12,7 +12,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility, lockAuthSession } from '@immich/sdk'; import { AssetVisibility, lockAuthSession } from '@immich/sdk';
import { Button } from '@immich/ui'; import { Button } from '@immich/ui';
@@ -26,7 +26,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte'; import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
@@ -22,7 +22,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
@@ -8,7 +8,7 @@
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants'; import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte'; import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import TagCreateModal from '$lib/modals/TagCreateModal.svelte'; import TagCreateModal from '$lib/modals/TagCreateModal.svelte';
import TagEditModal from '$lib/modals/TagEditModal.svelte'; import TagEditModal from '$lib/modals/TagEditModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -9,7 +9,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
@@ -5,8 +5,8 @@
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte'; import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -113,11 +113,11 @@
const handleThumbnailClick = ( const handleThumbnailClick = (
asset: TimelineAsset, asset: TimelineAsset,
timelineManager: TimelineManager, timelineManager: TimelineManager,
dayGroup: DayGroup, day: TimelineDay,
onClick: ( onClick: (
timelineManager: TimelineManager, timelineManager: TimelineManager,
assets: TimelineAsset[], assets: TimelineAsset[],
groupTitle: string, dayTitle: string,
asset: TimelineAsset, asset: TimelineAsset,
) => void, ) => void,
) => { ) => {
@@ -129,7 +129,7 @@
location = { latitude: asset.latitude!, longitude: asset.longitude! }; location = { latitude: asset.latitude!, longitude: asset.longitude! };
void setQueryValue('at', asset.id); void setQueryValue('at', asset.id);
} else { } else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
} }
}; };
</script> </script>