From ca7766fcd60f69c6637fc732d8405e9e817ae993 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 18 Oct 2026 19:00:06 +0300 Subject: [PATCH] Show on-this-day memories from the last 20 years with automatic playback --- server/src/queries/asset.repository.sql | 21 +- server/src/repositories/asset.repository.ts | 22 +- .../repositories/asset.repository.spec.ts | 63 ++++- .../components/memories/MemoriesLane.spec.ts | 178 ++++++++++++++ .../components/memories/MemoriesLane.svelte | 226 ++++++++++++++++++ .../__test__/MemoriesLaneHarness.svelte | 11 + .../(user)/photos/[[assetId=id]]/+page.svelte | 53 ++-- 7 files changed, 526 insertions(+), 48 deletions(-) create mode 100644 web/src/lib/components/memories/MemoriesLane.spec.ts create mode 100644 web/src/lib/components/memories/MemoriesLane.svelte create mode 100644 web/src/lib/components/memories/__test__/MemoriesLaneHarness.svelte diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ee09684dd..4e252143e 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -109,18 +109,7 @@ with select make_date(year::int, $1::int, $2::int) as "date" from - generate_series( - ( - select - date_part( - 'year', - min(("localDateTime" at time zone 'UTC')::date) - )::int - from - asset - ), - $3 - ) as "year" + generate_series($3::int, $4::int) as "year" ) select "a".* @@ -135,21 +124,21 @@ with inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId" where (asset."localDateTime" at time zone 'UTC')::date = today.date - and "asset"."ownerId" = any ($4::uuid[]) - and "asset"."visibility" = $5 + and "asset"."ownerId" = any ($5::uuid[]) + and "asset"."visibility" = $6 and exists ( select from "asset_file" where "assetId" = "asset"."id" - and "asset_file"."type" = $6 + and "asset_file"."type" = $7 ) and "asset"."deletedAt" is null order by (asset."localDateTime" at time zone 'UTC')::date desc limit - $7 + $8 ) as "a" on true ) select diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 313830d0e..13fc820d0 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -464,15 +464,19 @@ export class AssetRepository { qb .with('today', (qb) => qb - .selectFrom((eb) => - eb - .fn('generate_series', [ - sql`(select date_part('year', min(("localDateTime" at time zone 'UTC')::date))::int from asset)`, - sql`${year - 1}`, - ]) - .as('year'), - ) - .select((eb) => eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]).as('date')), + .selectFrom((eb) => eb.fn('generate_series', [sql`${year - 20}::int`, sql`${year - 1}::int`]).as('year')) + .select((eb) => { + const date = eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]); + return ( + month === 2 && day === 29 + ? eb + .case() + .when(sql`year::int % 4 = 0 and (year::int % 100 != 0 or year::int % 400 = 0)`) + .then(date) + .end() + : date + ).as('date'); + }), ) .selectFrom('today') .innerJoinLateral( diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 7a434d78d..a39b55e0c 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js'; +import { AssetFileType, AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { DB } from 'src/schema/index.js'; @@ -64,6 +64,67 @@ const keyframeRow = (assetId: string, n: number) => ({ }); describe(AssetRepository.name, () => { + describe('getByDayOfYear', () => { + it('should include every year from one through twenty years ago on the same local calendar day', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const target = { year: 2026, month: 10, day: 18 }; + const expectedAssetIds = []; + + for (let yearsAgo = 0; yearsAgo <= 21; yearsAgo++) { + const { asset } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: `${target.year - yearsAgo}-10-18T12:00:00.000Z`, + }); + await ctx.newJobStatus({ assetId: asset.id }); + await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' }); + if (yearsAgo >= 1 && yearsAgo <= 20) { + expectedAssetIds.push(asset.id); + } + } + + for (const dto of [ + { ownerId: user.id, localDateTime: '2025-10-17T23:59:59.999Z' }, + { ownerId: user.id, localDateTime: '2025-10-19T00:00:00.000Z' }, + { ownerId: otherUser.id, localDateTime: '2025-10-18T12:00:00.000Z' }, + ]) { + const { asset } = await ctx.newAsset(dto); + await ctx.newJobStatus({ assetId: asset.id }); + await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' }); + } + + const memories = await sut.getByDayOfYear([user.id], target); + + expect(memories.map(({ year }) => year)).toEqual(Array.from({ length: 20 }, (_, index) => 2025 - index)); + expect(memories.flatMap(({ assets }) => assets.map(({ id }) => id))).toEqual(expectedAssetIds); + }); + + it.each([2028, 2104])('should skip non-leap years on February 29th in %i', async (year) => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const expectedYears = []; + + for (let yearsAgo = 1; yearsAgo <= 20; yearsAgo++) { + const assetYear = year - yearsAgo; + const isLeapYear = assetYear % 4 === 0 && (assetYear % 100 !== 0 || assetYear % 400 === 0); + const { asset } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: `${assetYear}-${isLeapYear ? '02-29' : '03-01'}T12:00:00.000Z`, + }); + await ctx.newJobStatus({ assetId: asset.id }); + await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' }); + if (isLeapYear) { + expectedYears.push(assetYear); + } + } + + const memories = await sut.getByDayOfYear([user.id], { year, month: 2, day: 29 }); + + expect(memories.map(({ year }) => year)).toEqual(expectedYears); + }); + }); + describe('getTimeBucket', () => { it('should order assets by local day first and fileCreatedAt within each day', async () => { const { ctx, sut } = setup(); diff --git a/web/src/lib/components/memories/MemoriesLane.spec.ts b/web/src/lib/components/memories/MemoriesLane.spec.ts new file mode 100644 index 000000000..50892c765 --- /dev/null +++ b/web/src/lib/components/memories/MemoriesLane.spec.ts @@ -0,0 +1,178 @@ +import { MemoryType, type MemoryResponseDto } from '@immich/sdk'; +import { cleanup, fireEvent, render } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import MemoriesLane from './__test__/MemoriesLaneHarness.svelte'; + +const date = '2026-10-18'; +const makeMemory = (year: number, assetCount = 2): MemoryResponseDto => ({ + id: `memory-${year}`, + type: MemoryType.OnThisDay, + data: { year }, + assets: Array.from({ length: assetCount }, (_, index) => assetFactory.build({ id: `asset-${year}-${index}` })), + createdAt: date, + updatedAt: date, + memoryAt: `${year}-10-18T00:00:00.000Z`, + showAt: `${date}T00:00:00.000Z`, + ownerId: 'owner', + isSaved: false, +}); + +describe('MemoriesLane', () => { + let intersectionCallback: IntersectionObserverCallback; + const disconnect = vi.fn(); + + const intersect = async (isIntersecting: boolean) => { + intersectionCallback([{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver); + await tick(); + }; + + const advance = async () => { + await vi.advanceTimersByTimeAsync(5000); + await tick(); + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${date}T12:00:00Z`)); + vi.stubGlobal( + 'IntersectionObserver', + vi.fn(function (callback: IntersectionObserverCallback) { + intersectionCallback = callback; + return { observe: vi.fn(), disconnect, unobserve: vi.fn(), takeRecords: vi.fn() }; + }), + ); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + disconnect.mockClear(); + }); + + it('shows today from 1 through 20 years ago, recent years first, and keeps birthdays', () => { + const yesterday = { ...makeMemory(2024), memoryAt: '2024-10-17T00:00:00.000Z' }; + const stale = { ...makeMemory(2023), showAt: '2025-10-18T00:00:00.000Z' }; + const birthday = { ...makeMemory(2022), type: MemoryType.Birthday, data: { year: 2022, personName: 'Alex' } }; + const { getAllByRole } = render(MemoriesLane, { + date, + memories: [makeMemory(2005), makeMemory(2006), makeMemory(2026), yesterday, stale, makeMemory(2025), birthday], + }); + + expect(getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([ + '/memories/memory-2022?assetId=asset-2022-0', + '/memories/memory-2025?assetId=asset-2025-0', + '/memories/memory-2006?assetId=asset-2006-0', + ]); + }); + + it('rotates each card every five seconds, opens its displayed asset, and wraps its photos', async () => { + const { getByRole, getAllByRole } = render(MemoriesLane, { + date, + memories: [makeMemory(2025), makeMemory(2024, 3)], + }); + await intersect(true); + await advance(); + + const link = getAllByRole('link')[0]; + expect(link.querySelector('img')?.src).toContain('asset-2025-1'); + let clickedHref: string | null = null; + link.addEventListener('click', (event) => { + event.preventDefault(); + clickedHref = (event.currentTarget as HTMLAnchorElement).getAttribute('href'); + }); + await fireEvent.click(link); + expect(clickedHref).toBe('/memories/memory-2025?assetId=asset-2025-1'); + + await advance(); + expect(link.querySelector('img')?.src).toContain('asset-2025-0'); + expect(getAllByRole('link')[1].querySelector('img')?.src).toContain('asset-2024-2'); + expect(getByRole('button', { name: 'pause_memories' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('pauses for hover, focus, hidden page, offscreen lane, and a disabled viewer state', async () => { + const component = render(MemoriesLane, { date, memories: [makeMemory(2025)] }); + const region = component.getByRole('region'); + const image = component.getByRole('img') as HTMLImageElement; + await intersect(true); + await fireEvent.mouseEnter(region); + await advance(); + expect(image.src).toContain('asset-2025-0'); + await fireEvent.mouseLeave(region); + await fireEvent.focusIn(component.getByRole('link')); + await advance(); + expect(image.src).toContain('asset-2025-0'); + await fireEvent.focusOut(component.getByRole('link'), { relatedTarget: null }); + + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + await fireEvent(document, new Event('visibilitychange')); + await advance(); + expect(image.src).toContain('asset-2025-0'); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + await fireEvent(document, new Event('visibilitychange')); + await intersect(false); + await advance(); + expect(image.src).toContain('asset-2025-0'); + await intersect(true); + await component.rerender({ enabled: false }); + await advance(); + expect(image.src).toContain('asset-2025-0'); + await component.rerender({ enabled: true }); + await advance(); + expect(image.src).toContain('asset-2025-1'); + }); + + it('supports an explicit pause and resume and starts paused for reduced motion', async () => { + vi.mocked(globalThis.matchMedia).mockReturnValueOnce({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList); + const component = render(MemoriesLane, { date, memories: [makeMemory(2025)] }); + await intersect(true); + await advance(); + expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-0'); + const region = component.getByRole('region'); + await fireEvent.mouseEnter(region); + const resumeButton = component.getByRole('button', { name: 'resume' }); + resumeButton.focus(); + await tick(); + await fireEvent.click(resumeButton); + await fireEvent.mouseLeave(region); + await advance(); + expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-1'); + await fireEvent.click(component.getByRole('button', { name: 'pause_memories' })); + await advance(); + expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-1'); + }); + + it('advances the horizontal lane and loops at the end, then cleans up its timer and observer', async () => { + const component = render(MemoriesLane, { date, memories: [makeMemory(2025), makeMemory(2024), makeMemory(2023)] }); + const scroller = component.getByRole('region').firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + clientWidth: { configurable: true, value: 200 }, + scrollWidth: { configurable: true, value: 500 }, + }); + vi.spyOn(component.getAllByRole('link')[0], 'getBoundingClientRect').mockReturnValue({ width: 150 } as DOMRect); + const scrollTo = vi.fn((options: ScrollToOptions | number | undefined) => { + scroller.scrollLeft = typeof options === 'object' ? (options.left ?? 0) : (options ?? 0); + }); + scroller.scrollTo = scrollTo; + await intersect(true); + + await advance(); + expect(scrollTo).toHaveBeenLastCalledWith({ left: 150, behavior: 'smooth' }); + await advance(); + expect(scrollTo).toHaveBeenLastCalledWith({ left: 300, behavior: 'smooth' }); + await advance(); + expect(scrollTo).toHaveBeenLastCalledWith({ left: 0, behavior: 'smooth' }); + component.unmount(); + expect(disconnect).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + await advance(); + expect(scrollTo).toHaveBeenCalledTimes(3); + }); +}); diff --git a/web/src/lib/components/memories/MemoriesLane.svelte b/web/src/lib/components/memories/MemoriesLane.svelte new file mode 100644 index 000000000..41562eb29 --- /dev/null +++ b/web/src/lib/components/memories/MemoriesLane.svelte @@ -0,0 +1,226 @@ + + +{#if items.length > 0} +
(hovered = true)} + onmouseleave={() => (hovered = false)} + onfocusin={(event) => (focused = hasContentFocus(event.target))} + onfocusout={(event) => (focused = hasContentFocus(event.relatedTarget))} + > +
(scrollPosition = Math.abs(scroller?.scrollLeft ?? 0))} + > + {#if canScrollPrevious || canScrollNext} +
+ {#if canScrollPrevious} +
+ scroll(false)} + /> +
+ {/if} + {#if canScrollNext} +
+ scroll(true)} + /> +
+ {/if} +
+ {/if} +
+ {#each items as item (item.id)} + + {/each} +
+
+
+ (paused = !paused)} + /> +
+
+{/if} diff --git a/web/src/lib/components/memories/__test__/MemoriesLaneHarness.svelte b/web/src/lib/components/memories/__test__/MemoriesLaneHarness.svelte new file mode 100644 index 000000000..d777bdf4a --- /dev/null +++ b/web/src/lib/components/memories/__test__/MemoriesLaneHarness.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index ec895aaf7..cd366a26e 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -1,6 +1,7 @@ @@ -100,11 +109,11 @@ withStacked > {#if authManager.preferences.memories.enabled} - - {#snippet child(item)} - - {/snippet} - + {/if} {#snippet empty()} openFileUploadDialog()} class="mx-auto mt-10" />