diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 9105a554..0eb56e05 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1423,6 +1423,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: ) } +// ── Photo date grouping ─────────────────────────────────────────────────── + +function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] { + const map = new Map() + for (const asset of photos) { + const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__' + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(asset) + } + return [...map.entries()].map(([date, assets]) => ({ + date, + label: date === '__unknown__' + ? 'Unknown date' + : new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }), + assets, + })) +} + // ── Provider Picker ─────────────────────────────────────────────────────── function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: { @@ -1732,51 +1750,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on

) : ( -
- {photos.map((asset: any) => { - const isSelected = selected.has(asset.id) - const alreadyAdded = existingAssetIds.has(asset.id) - return ( -
!alreadyAdded && toggleAsset(asset.id)} - className={`relative aspect-square rounded-lg overflow-hidden ${ - alreadyAdded - ? 'opacity-40 cursor-not-allowed' - : isSelected - ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer' - : 'cursor-pointer' - }`} - > - { - const img = e.currentTarget - const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original` - if (!img.src.includes('/original')) img.src = original - }} - /> - {alreadyAdded && ( -
- -
- )} - {isSelected && !alreadyAdded && ( -
- -
- )} - {asset.city && ( -
-

{asset.city}

-
- )} +
+ {groupPhotosByDate(photos).map(group => ( +
+

+ {group.label} +

+
+ {group.assets.map((asset: any) => { + const isSelected = selected.has(asset.id) + const alreadyAdded = existingAssetIds.has(asset.id) + return ( +
!alreadyAdded && toggleAsset(asset.id)} + className={`relative aspect-square rounded-lg overflow-hidden ${ + alreadyAdded + ? 'opacity-40 cursor-not-allowed' + : isSelected + ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer' + : 'cursor-pointer' + }`} + > + { + const img = e.currentTarget + const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original` + if (!img.src.includes('/original')) img.src = original + }} + /> + {alreadyAdded && ( +
+ +
+ )} + {isSelected && !alreadyAdded && ( +
+ +
+ )} + {asset.city && ( +
+

{asset.city}

+
+ )} +
+ ) + })}
- ) - })} +
+ ))} {/* Infinite scroll trigger */} {hasMore && !selectedAlbum && }
diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts index 87f3fa0f..9486234a 100644 --- a/server/src/routes/memories/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -60,16 +60,12 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => { router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { from, to, size } = req.body; + const { from, to, size, page } = req.body; + const pageNum = Math.max(1, Number(page) || 1); const pageSize = Math.min(Number(size) || 50, 200); - const allAssets: any[] = []; - for (let page = 1; page <= 20; page++) { - const result = await searchPhotos(authReq.user.id, from, to, page, pageSize); - if (result.error) return res.status(result.status!).json({ error: result.error }); - if (result.assets) allAssets.push(...result.assets); - if (!result.hasMore) break; - } - res.json({ assets: allAssets }); + const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ assets: result.assets || [], hasMore: !!result.hasMore }); }); // ── Asset Details ────────────────────────────────────────────────────────── diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index eb19cd48..2b3cdefb 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -273,18 +273,19 @@ describe('Immich browse and search', () => { expect(res.body.buckets.length).toBeGreaterThan(0); }); - it('IMMICH-042 — POST /search returns mapped assets', async () => { + it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) - .send({}); + .send({ page: 1, size: 50 }); expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' }); + expect(typeof res.body.hasMore).toBe('boolean'); }); it('IMMICH-043 — POST /search when upstream throws returns 502', async () => { @@ -611,43 +612,77 @@ describe('Immich syncAlbumAssets', () => { // ── searchPhotos pagination safety ──────────────────────────────────────────── -describe('Immich searchPhotos pagination safety', () => { - it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => { +describe('Immich searchPhotos pagination pass-through', () => { + it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); - // Return a full page of 1000 items on every call, so the loop would - // run indefinitely without the page > 20 safety check. + // Return a full page so hasMore=true (items.length >= size) const fullPageResponse = { ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { - items: Array.from({ length: 1000 }, (_, i) => ({ - id: `asset-${i}`, + items: Array.from({ length: 50 }, (_, i) => ({ + id: `asset-p2-${i}`, fileCreatedAt: '2024-06-01T10:00:00.000Z', - exifInfo: { city: 'Paris', country: 'France' }, + exifInfo: { city: 'Berlin', country: 'Germany' }, })), }, }), body: null, } as any; - // Clear previous call history so the count only reflects this test vi.mocked(safeFetch).mockClear(); vi.mocked(safeFetch).mockResolvedValue(fullPageResponse); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) - .send({}); + .send({ page: 2, size: 50 }); expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); - // 20 pages × 1000 items = 20000 assets total (safety limit) - expect(res.body.assets.length).toBe(20000); - // safeFetch should have been called exactly 20 times (the safety limit) - expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20); + // Single page returned — not 20× aggregation + expect(res.body.assets.length).toBe(50); + expect(res.body.hasMore).toBe(true); + // Immich was called exactly once + expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1); + // page=2 was forwarded to Immich + const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string); + expect(callBody.page).toBe(2); + }); + + it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => { + const { user } = createUser(testDb); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + + // Partial page → hasMore=false + const partialPageResponse = { + ok: true, status: 200, + headers: { get: () => null }, + json: () => Promise.resolve({ + assets: { + items: Array.from({ length: 3 }, (_, i) => ({ + id: `asset-last-${i}`, + fileCreatedAt: '2024-06-01T10:00:00.000Z', + exifInfo: { city: 'Rome', country: 'Italy' }, + })), + }, + }), + body: null, + } as any; + + vi.mocked(safeFetch).mockResolvedValue(partialPageResponse); + + const res = await request(app) + .post(`${IMMICH}/search`) + .set('Cookie', authCookie(user.id)) + .send({ page: 5, size: 50 }); + + expect(res.status).toBe(200); + expect(res.body.assets.length).toBe(3); + expect(res.body.hasMore).toBe(false); }); });