import { useMemo } from 'react' import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useFilterStore, filtersToParams } from '../store/filterStore' import api from '../services/api' import type { Photo } from '../types/photo' /** * Optimistically strip the supplied photo ids from every cached * timeline list. Used by discard / delete flows so the photos vanish * from the grid the instant the user acts, without waiting for the * mutation round-trip + invalidation refetch. The subsequent invalidate * still runs and reconciles the cache with server truth. */ export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) { if (ids.length === 0) return const removed = new Set(ids) queryClient.setQueriesData({ queryKey: ['photos'] }, (prev) => { if (!prev) return prev return prev.filter((p) => !removed.has(p.id)) }) } interface CursorPage { photos: Photo[] next_cursor: string | null } async function fetchCursorPage( params: Record, signal?: AbortSignal, ): Promise { const resp = await api.get('/photos', { params, signal }) return resp.data } /** * Single source of truth for the timeline photos query. Both Timeline and * PreviewView call this so they share one cache entry — previously * PreviewView looked the cache up by key directly, which broke the moment * Timeline's key gained the filter params. */ export function usePhotosQuery() { const q = useFilterStore((s) => s.q) const dateFrom = useFilterStore((s) => s.dateFrom) const dateTo = useFilterStore((s) => s.dateTo) const mediaTypes = useFilterStore((s) => s.mediaTypes) const ratingMin = useFilterStore((s) => s.ratingMin) const ratingMax = useFilterStore((s) => s.ratingMax) const colorLabel = useFilterStore((s) => s.colorLabel) const flag = useFilterStore((s) => s.flag) const heapId = useFilterStore((s) => s.heapId) const folderId = useFilterStore((s) => s.folderId) const tagIds = useFilterStore((s) => s.tagIds) const duplicates = useFilterStore((s) => s.duplicates) const needsReview = useFilterStore((s) => s.needsReview) const groupBy = useFilterStore((s) => s.groupBy) const sortBy = useFilterStore((s) => s.sortBy) const sortOrder = useFilterStore((s) => s.sortOrder) const filterParams = useMemo( () => filtersToParams({ q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder, }), [q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder] ) const queryClient = useQueryClient() return useQuery({ queryKey: ['photos', filterParams], queryFn: async ({ signal }) => { // Two-phase fetch using cursor-based (keyset) pagination. // Phase 1 returns the first page (resolves the useQuery promise // so consumers exit loading state). Phase 2 chains cursors in // the background — each response includes a `next_cursor` that // seeks directly to the next slice via an indexed range scan, // O(1) regardless of depth (no OFFSET skipping). const PER_PAGE = 500 const MAX_PAGES = 200 const first = await fetchCursorPage( { per_page: PER_PAGE, ...filterParams }, signal, ) const firstBatch = first.photos || [] let nextCursor: string | null = first.next_cursor if (nextCursor) { // Fire-and-forget background loop using cursor chaining. void (async () => { for (let i = 0; i < MAX_PAGES && nextCursor; i++) { if (signal?.aborted) return try { const page = await fetchCursorPage( { per_page: PER_PAGE, cursor: nextCursor, ...filterParams }, signal, ) if (signal?.aborted) return const more = page.photos || [] nextCursor = page.next_cursor queryClient.setQueryData( ['photos', filterParams], (prev) => (prev ? [...prev, ...more] : more) ) if (!nextCursor || more.length < PER_PAGE) return } catch { return } } })() } return firstBatch }, staleTime: 30_000, }) }