feat: recently added assets page (#28272)

* feat(server): add ordering date option to time buckets

* feat(web): add recently added page

* feat(server): recently created assets in explore data

* feat(web): recently added in explore tab

* fix: recently added assets ordering

* fix(server): failing bucket test

* feat(web): improve recently added preview

* chore: update e2e explore/timeline tests

* chore: rename and refactor timeline ordering dates

* fix(web): invalid timeline option

* feat(mobile): recently added page

* fix(server): sync tests

* fix(mobile): resync assets to get uploadedAt column

* chore: rename assetorderby enum

* chore(mobile): formatting

* minor fixes

* stylings

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Ben Beckford
2026-05-11 14:35:10 -07:00
committed by GitHub
parent 38438c8d9a
commit e142e3aca7
66 changed files with 14174 additions and 56 deletions
@@ -1,8 +1,8 @@
import { AssetOrder } from '@immich/sdk';
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
@@ -12,6 +12,7 @@ export class TimelineDay {
readonly index: number;
readonly groupTitle: string;
readonly day: number;
readonly orderBy: AssetOrderBy;
viewerAssets: ViewerAsset[] = $state([]);
height = $state(0);
@@ -24,11 +25,12 @@ export class TimelineDay {
#col = $state(0);
#deferredLayout = false;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string) {
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index;
this.timelineMonth = timelineMonth;
this.day = day;
this.groupTitle = groupTitle;
this.orderBy = orderBy;
}
get top() {
@@ -115,10 +117,10 @@ export class TimelineDay {
continue;
}
const oldTime = { ...asset.localDateTime };
const oldTime = { ...getOrderingDate(asset, this.orderBy) };
const callbackResult = callback(asset);
let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
const newTime = asset.localDateTime;
const newTime = getOrderingDate(asset, this.orderBy);
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;
remove = true;
@@ -1,4 +1,4 @@
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
@@ -20,6 +20,7 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
getOrderingDate,
isAssetResponseDto,
setDifference,
toTimelineAsset,
@@ -252,6 +253,7 @@ export class TimelineManager extends VirtualScrollManager {
timeBucket.count,
false,
this.#options.order,
this.#options.orderBy,
);
});
this.albumAssets.clear();
@@ -393,7 +395,10 @@ export class TimelineManager extends VirtualScrollManager {
return;
}
timelineMonth = await this.#loadTimelineMonthAtTime(timelineAsset.localDateTime, { cancelable: false });
timelineMonth = await this.#loadTimelineMonthAtTime(
getOrderingDate(timelineAsset, this.#options.orderBy || AssetOrderBy.TakenAt),
{ cancelable: false },
);
if (timelineMonth?.findAssetById({ id })) {
return timelineMonth;
}
@@ -462,10 +467,11 @@ export class TimelineManager extends VirtualScrollManager {
}
protected upsertSegmentForAsset(asset: TimelineAsset) {
let month = getTimelineMonthByDate(this, asset.localDateTime);
const dateTime = getOrderingDate(asset, this.#options.orderBy || AssetOrderBy.TakenAt);
let month = getTimelineMonthByDate(this, dateTime);
if (!month) {
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
month = new TimelineMonth(this, dateTime, 1, true, this.#options.order, this.#options.orderBy);
this.months.push(month);
}
return month;
@@ -1,4 +1,4 @@
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { AssetOrder, AssetOrderBy, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store';
@@ -15,10 +15,12 @@ import {
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
fromISODateTimeUTC,
getTimes,
setDifference,
type TimelineDateTime,
type TimelineYearMonth,
getOrderingDate,
} from '$lib/utils/timeline-util';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineDay } from './timeline-day.svelte';
@@ -37,6 +39,7 @@ export class TimelineMonth {
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
#orderBy: AssetOrderBy = AssetOrderBy.TakenAt;
percent: number = $state(0);
assetsCount: number = $derived(
@@ -56,10 +59,12 @@ export class TimelineMonth {
initialCount: number,
loaded: boolean,
order: AssetOrder = AssetOrder.Desc,
orderBy: AssetOrderBy = AssetOrderBy.TakenAt,
) {
this.timelineManager = timelineManager;
this.#initialCount = initialCount;
this.#sortOrder = order;
this.#orderBy = orderBy;
this.yearMonth = { year: yearMonth.year, month: yearMonth.month };
this.title = formatTimelineMonthTitle(fromTimelinePlainYearMonth(yearMonth));
@@ -185,6 +190,7 @@ export class TimelineMonth {
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime,
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
fileCreatedAt,
ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i],
@@ -229,22 +235,22 @@ export class TimelineMonth {
}
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
const { localDateTime } = timelineAsset;
const dateTime = getOrderingDate(timelineAsset, this.#orderBy);
const { year, month } = this.yearMonth;
if (month !== localDateTime.month || year !== localDateTime.year) {
if (month !== dateTime.month || year !== dateTime.year) {
addContext.unprocessedAssets.push(timelineAsset);
return;
}
let timelineDay = addContext.getTimelineDay(localDateTime) || this.findTimelineDayByDay(localDateTime.day);
let timelineDay = addContext.getTimelineDay(dateTime) || this.findTimelineDayByDay(dateTime.day);
if (timelineDay) {
addContext.setTimelineDay(timelineDay, localDateTime);
addContext.setTimelineDay(timelineDay, dateTime);
} else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
timelineDay = new TimelineDay(this, this.timelineDays.length, localDateTime.day, groupTitle);
const groupTitle = formatGroupTitle(fromTimelinePlainDate(dateTime));
timelineDay = new TimelineDay(this, this.timelineDays.length, dateTime.day, groupTitle, this.#orderBy);
this.timelineDays.push(timelineDay);
addContext.setTimelineDay(timelineDay, localDateTime);
addContext.setTimelineDay(timelineDay, dateTime);
addContext.newTimelineDays.add(timelineDay);
}
@@ -372,7 +378,7 @@ export class TimelineMonth {
let closest = undefined;
let smallestDiff = Infinity;
for (const current of this.assetsIterator()) {
const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime);
const currentAssetDate = fromTimelinePlainDateTime(getOrderingDate(current, this.#orderBy));
const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
if (diff < smallestDiff) {
smallestDiff = diff;
@@ -22,6 +22,7 @@ export type TimelineAsset = {
ratio: number;
thumbhash: string | null;
localDateTime: TimelineDateTime;
createdAt: TimelineDateTime;
fileCreatedAt: TimelineDateTime;
visibility: AssetVisibility;
isFavorite: boolean;
+1
View File
@@ -105,6 +105,7 @@ export const Route = {
locked: () => '/locked',
trash: () => '/trash',
viewTrashedAsset: ({ id }: { id: string }) => `/trash/photos/${id}`,
recentlyAdded: () => '/recently-added',
// search
search: (dto?: MetadataSearchDto | SmartSearchDto) => {
+9
View File
@@ -71,6 +71,15 @@ describe('getAltText', () => {
second: testDate.getUTCSeconds(),
millisecond: testDate.getUTCMilliseconds(),
},
createdAt: {
year: testDate.getUTCFullYear(),
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
day: testDate.getUTCDate(),
hour: testDate.getUTCHours(),
minute: testDate.getUTCMinutes(),
second: testDate.getUTCSeconds(),
millisecond: testDate.getUTCMilliseconds(),
},
localDateTime: {
year: testDate.getUTCFullYear(),
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
+6 -1
View File
@@ -1,4 +1,4 @@
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { AssetTypeEnum, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
import { DateTime, type LocaleOptions } from 'luxon';
import { SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store';
@@ -166,6 +166,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
const createdAt = fromISODateTimeUTCToObject(assetResponse.createdAt);
return {
id: assetResponse.id,
@@ -174,6 +175,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
ratio,
thumbhash: assetResponse.thumbhash,
localDateTime,
createdAt,
fileCreatedAt,
isFavorite: assetResponse.isFavorite,
visibility: assetResponse.visibility,
@@ -236,3 +238,6 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
}
return result;
}
export const getOrderingDate = (asset: TimelineAsset, order: AssetOrderBy) =>
order === AssetOrderBy.CreatedAt ? asset.createdAt : asset.localDateTime;