useGroupMovies is useInfiniteQuery with MOVIES_PER_PAGE=12, getNextPageParam keyed on page index; PosterGrid uses useInfiniteScroll (IntersectionObserver, rootMargin: 200px) on a sentinel div. Task 3.5 is largely complete; remaining work is mostly polish + interaction with realtime/persistence.PosterGrid has a local-state toggle (watchedOpen), aria-expanded, rotate caret. Missing: persistence, reduced-motion, animation polish, default state contract, layout positioning.useRealtimeMovies handles INSERT/UPDATE/DELETE with cache surgery; useRealtimeChannel has ReconnectionManager w/ exponential backoff + jitter (1s→30s). Missing: presence-of-self skip, page-window vs detail-row coherence with infinite scroll, status surface to UI, online/visibility-change reconnect.src/components/shared/empty-state.tsx already has presets (emptyList, noSearchResults, noGenreMatches, newUser). Surfaces use ad-hoc <p> text. home/empty-state.tsx is the polished anchor.OfflineBanner exists but is not mounted anywhere; ErrorBoundary exists but isn't applied to route trees; ErrorMessage has presets but unused. No toast system.Files to modify
src/hooks/use-group-movies.ts — add getNextPageParam guard against undefined, consider maxPages cap when persistence is onsrc/components/movies/poster-grid.tsx — sentinel position, accessible "Load more" button fallbacksrc/hooks/use-infinite-scroll.ts — accept enabled flag (disable while a roll-carousel is in flight to avoid layout shift)src/components/providers.tsx — when persistQueryClient lands, set maxPages on infinite queries (otherwise IndexedDB grows unbounded)Implementation order
enabled to useInfiniteScroll; pass false while rollState !== "idle" (prevents fetching while carousel animation runs).fetchNextPage(). Hide when !hasNextPage.maxPages to ~10 (120 movies) once persistence is enabled, with getPreviousPageParam so older pages can refetch when scrolled back. (Open question — see below.)Trade-offs
maxPages cap vs unbounded: capped is safer for IndexedDB persistence and realtime cache mutation cost (each INSERT iterates pages). Recommend cap=10 unless lists routinely exceed 120 movies.Risks / open questions
refetchOnMount for page 0 + refetchInterval off; rely on realtime for fresh.Files to modify
src/components/movies/poster-grid.tsx — replace useState with persisted hook, add reduced-motion-aware animation, tighten layoutsrc/hooks/use-persisted-state.ts (new) — generic localStorage-backed useState with SSR-safe lazy initsrc/lib/constants.ts — add WATCHED_COLLAPSE_KEY = "moviedice:watched-open" (per-list key shape)Implementation order
usePersistedState<T>(key, initial) — lazy init from localStorage on mount (avoid hydration mismatch by reading inside useEffect and reconciling).groupId: moviedice:watched-open:{groupId}. Default: collapsed (matches "Watched" being de-prioritized; reduces visual noise on big lists).setWatchedOpen in PosterGrid with persisted state.grid-template-rows: 0fr → 1fr) gated by @media (prefers-reduced-motion: reduce) → instant.data-testid="watched-toggle" for tests.Trade-offs
Risks / open questions
(N) — fine.Files to modify
src/hooks/use-realtime-movies.ts — INSERT page-window placement, self-event skipsrc/hooks/use-realtime-channel.ts — reconnect on visibilitychange + online eventssrc/components/movies/movie-list-client.tsx — surface status (banner when error/disconnected >5s)src/lib/realtime/subscription-manager.ts — already completeImplementation order
added_at DESC always belong on page 0. Current code prepends to pages[0] — correct. But if the cache has been maxPages-evicted from the head, the new row may overlap with a future fetch. Mitigation: keep the prepend, and dedupe in fetchGroupMovies via select + id set check, or simpler — leave dedupe in the INSERT handler (already present) and accept occasional duplicate page entries collapsed by id.useAddMovie/useToggleWatched/useDeleteMovie already optimistically update; the realtime echo is idempotent for UPDATE/DELETE but INSERT can briefly double-show. Add payload.new.added_by === currentUser.id && createdWithinLastNms skip OR (preferred) make optimistic INSERT use the real DB id from the mutation response (mutations should already return the row) — then realtime INSERT is a no-op via existing dedupe. Audit useAddMovie to confirm.useRealtimeChannel, add window.addEventListener("online", resubscribe) and document.addEventListener("visibilitychange", () => document.visibilityState === "visible" && resubscribe()). Reset backoff attempt on each. Also call queryClient.invalidateQueries(["group-movies", groupId]) on reconnect to catch missed events during the gap.status from useRealtimeMovies up; show a tiny "Reconnecting..." pill when status === "error" for >3s. Don't surface transient connecting.useRealtimeMovies is called only in MovieListClient (per-list page). Confirmed compliant with CLAUDE.md "one list at a time."Trade-offs
fetchNextPage loads from current from/to which may double-count one row at the boundary. Open question — see below.Risks / open questions
added_at, (b) accept the rare miss, (c) queryClient.invalidateQueries on DELETE. Recommend (c) — full refetch is cheap at 12/page and removes the entire class of bug.Surfaces and treatments
| Surface | File | State today | Plan |
|---|---|---|---|
| Home grid (no lists) | src/components/home/list-grid.tsx |
Uses home/empty-state.tsx |
Done — keep |
| List detail (no movies) | src/components/movies/poster-grid.tsx line 106 |
Plain <p> |
Replace with EmptyState preset emptyList + CTA "Search to add a movie" that focuses the search bar via shared ref/event |
| Search results (no matches) | src/components/movies/search-results.tsx line 43 |
Plain <p> |
Replace with EmptyState preset noSearchResults (compact variant) |
| Genre roll (no matches) | src/components/movies/movie-list-client.tsx line 152 |
Yellow banner | Keep banner — transient roll outcome, not an empty state |
| Watched section (none watched) | poster-grid.tsx line 149 |
Section hidden | Keep hidden |
Roll pool empty (poolEmpty) |
RollBar |
Disabled | Verify RollBar shows tooltip/aria-label "Add movies to roll" |
| All-user-movies (cross-list profile) | use-all-user-movies.ts |
Unknown | Audit; add EmptyState if surfaced |
Files to create/modify
src/components/shared/empty-state.tsx — add compact variant (smaller padding, no large icon)src/components/movies/poster-grid.tsx — swap plain text for <EmptyState>src/components/movies/search-results.tsx — swap plain textsrc/components/home/empty-state.tsx — keep as-is (anchor design); harmonize shared/empty-state.tsx styling on theme tokens (currently gray-900/gray-500 on shared, foreground tokens on home)Implementation order
shared/empty-state.tsx styling to use theme tokens matching home/empty-state.tsx.compact prop and an icon prop convention.Trade-offs
description per surface where needed.Risks / open questions
shared (light-mode-only colors) and home (theme tokens) — confirm dark-mode-first design and consolidate.Files to create/modify
src/components/providers.tsx — set QueryCache global onError, expand defaultOptions (retry policy, mutation onError)src/components/shared/toast.tsx (new) — minimal toast/portal singleton (announce(message, variant))src/hooks/use-toast.ts (new) — thin wrappersrc/app/layout.tsx (or (app)/layout.tsx) — mount <OfflineBanner /> and <ToastViewport />src/app/(app)/layout.tsx — wrap children in <ErrorBoundary> with ErrorMessage fallbacksrc/components/movies/poster-grid.tsx — replace inline "Failed to load movies" with ErrorMessage type="network" + retry button calling refetch()src/components/home/list-grid.tsx — same upgradeuse-delete-movie, use-toggle-watched) — add onError toast (the useAddMovie fix is independent)Implementation order
<OfflineBanner /> in root layout (offline can be detected pre-auth too).info|error|success. ~80 lines, no dependency.QueryCache({ onError }) and MutationCache({ onError }) in Providers to fire toasts globally for unhandled errors. Local handlers can opt out by setting meta: { silent: true }.<ErrorBoundary> around (app) layout content with a fallback that includes a "Reload" button.<p>s to <ErrorMessage> + retry button (reuses TanStack refetch).onError, call toast.error(...). Roll back optimistic updates.Toast vs inline guidance
ErrorMessage + retry): query-level failures where data can't render — ListGrid, PosterGrid, search results.OfflineBanner (already built).Trade-offs
sonner/react-hot-toast: a stripped-down custom toast keeps the dependency footprint clean. Recommend custom, ~80 LOC.QueryCache.onError vs per-call: global catches unhandled, per-call (meta.silent) opts out. Best of both.retry: 1 is set globally. Mutations don't retry by default — keep that (idempotency not guaranteed for INSERT).Risks / open questions
navigator.onLine lies (says online when on a captive portal). Recommend trusting it for the banner only; rely on actual fetch failures for retry logic.(app) layout — fine.Sentry.addBreadcrumb from the toast helper.["group-movies", groupId] on realtime DELETE to avoid pagination boundary drift; INSERT is fine via prepend+dedupe. If a maxPages cap is set (3.5 step 3), realtime INSERT must respect it (drop tail) — implement via slice(0, maxPages) after prepend.persistQueryClient): Cap maxPages to bound IndexedDB size; set gcTime ≥ persistence rehydrate window.error should NOT spam toasts (it backs off automatically). Surface only as a small inline pill after >3s, never as a toast.EmptyState for unwatched ("All caught up — pick something to watch next") and keep Watched expandable below. Worth a dedicated preset.isError and length===0 branches — already correct). Document the distinction in shared/empty-state.tsx JSDoc./home/user/moviedice/src/components/movies/poster-grid.tsx/home/user/moviedice/src/hooks/use-realtime-movies.ts/home/user/moviedice/src/hooks/use-realtime-channel.ts/home/user/moviedice/src/components/providers.tsx/home/user/moviedice/src/components/shared/empty-state.tsx