Kaynağa Gözat

[Fix] Real-time UPDATE, offline search error state, pre-push hook

Three queued fixes:

1. 3.8 Real-time UPDATE propagation
   - Added migration 00006_movies_realtime_replica_identity.sql
   - Sets REPLICA IDENTITY FULL on public.movies and adds it to the
     supabase_realtime publication. Without REPLICA IDENTITY FULL,
     UPDATE payloads carry only the primary key, so the TanStack Query
     cache merge replaced full rows with sparse ones — losing the
     watched flag on remote clients. INSERT/DELETE worked because they
     don't depend on the replica identity for payload completeness.
   - Migration applied to running dev DB (verified relreplident='f' and
     publication membership).

2. 5.6 Offline search false-empty
   - search-results.tsx and movie-list-client.tsx already wired the
     isError + hasData props to distinguish loading / error / empty.
     This commit captures the working-tree changes.

3. 1.1 Husky pre-push hook
   - Added .husky/pre-push running lint + typecheck + build.
   - Unblocked it by:
     * eslint.config.mjs — ignore .claude/** so worktree .next/build
       artifacts don't get linted (was producing 5763 spurious errors).
     * tsconfig.json — add types: ["vitest/globals", "node"] and bump
       target to ES2018 + add es2018.regexp lib so the regex 's' flag
       in tests typechecks.
     * Inline eslint-disable on two intentional set-state-in-effect
       patterns (RollAnnouncer counter bump, more-info-modal portal
       mount flag).

Verified: npm test (95 passed), npm run build, ./.husky/pre-push all
exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 1 ay önce
ebeveyn
işleme
47b4c01b81

+ 1 - 0
.husky/pre-push

@@ -0,0 +1 @@
+npm run lint && npm run typecheck && npm run build

+ 3 - 0
eslint.config.mjs

@@ -12,6 +12,9 @@ const eslintConfig = defineConfig([
     "out/**",
     "build/**",
     "next-env.d.ts",
+    // Claude Code worktrees contain nested .next/build artifacts from
+    // sibling agent runs — never lint those.
+    ".claude/**",
   ]),
 ]);
 

+ 8 - 7
research/PROJECT_INFO.md

@@ -41,10 +41,11 @@ MovieDice is a mobile-first web app that helps friend groups collaboratively bui
 
 ## Review History
 
-| Date       | Type                                                           | Report                                   |
-| ---------- | -------------------------------------------------------------- | ---------------------------------------- |
-| 2026-04-05 | Pre-implementation architecture review                         | ./research/COMPLIANCE.md                 |
-| 2026-04-05 | Full tech stack audit (Report Mode) — Docker/self-hosted focus | ./research/TECHFILE.md                   |
-| 2026-04-05 | Second review -- technology verification on updated scope      | ./research/TECHFILE.md (Second Review)   |
-| 2026-04-05 | Second security review -- updated architecture analysis        | ./research/SECFILE.md (Second Review)    |
-| 2026-04-05 | Second compliance review -- updated scope (15 new findings)    | ./research/COMPLIANCE.md (Second Review) |
+| Date       | Type                                                           | Report                                                           |
+| ---------- | -------------------------------------------------------------- | ---------------------------------------------------------------- |
+| 2026-04-05 | Pre-implementation architecture review                         | ./research/COMPLIANCE.md                                         |
+| 2026-04-05 | Full tech stack audit (Report Mode) — Docker/self-hosted focus | ./research/TECHFILE.md                                           |
+| 2026-04-05 | Second review -- technology verification on updated scope      | ./research/TECHFILE.md (Second Review)                           |
+| 2026-04-05 | Second security review -- updated architecture analysis        | ./research/SECFILE.md (Second Review)                            |
+| 2026-04-05 | Second compliance review -- updated scope (15 new findings)    | ./research/COMPLIANCE.md (Second Review)                         |
+| 2026-05-07 | programmer                                                     | Realtime UPDATE (3.8), offline search (5.6), pre-push hook (1.1) |

+ 3 - 0
src/components/dice/roll-announcer.tsx

@@ -24,6 +24,9 @@ export function RollAnnouncer({ state, winner }: RollAnnouncerProps) {
 
   useEffect(() => {
     if (state === "complete" && prevStateRef.current !== "complete") {
+      // Intentional: bump a counter to force a fresh aria-live span (see
+      // header comment). The setState-in-effect pattern is the point here.
+      // eslint-disable-next-line react-hooks/set-state-in-effect
       setCompleteCounter((n) => n + 1);
     }
     prevStateRef.current = state;

+ 7 - 4
src/components/landing/more-info-modal.tsx

@@ -21,11 +21,16 @@ export function MoreInfoModal({ movie, onClose }: MoreInfoModalProps) {
   const closeBtnRef = useRef<HTMLButtonElement>(null);
 
   useEffect(() => {
+    // Standard SSR-safe portal mount flag — intentional setState-in-effect.
+    // eslint-disable-next-line react-hooks/set-state-in-effect
     setMounted(true);
   }, []);
 
   const year = movie.release_date ? movie.release_date.slice(0, 4) : "";
-  const genres = movie.genre_ids.map((id) => TMDB_GENRE_MAP[id]).filter(Boolean).slice(0, 4);
+  const genres = movie.genre_ids
+    .map((id) => TMDB_GENRE_MAP[id])
+    .filter(Boolean)
+    .slice(0, 4);
   const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
 
   useEffect(() => {
@@ -121,9 +126,7 @@ export function MoreInfoModal({ movie, onClose }: MoreInfoModalProps) {
         <div className="flex min-w-0 flex-1 flex-col">
           <h2 id="more-info-title" className="pr-12 text-3xl font-semibold leading-tight">
             {movie.title}
-            {year && (
-              <span className="ml-2 text-xl font-normal text-foreground/60">({year})</span>
-            )}
+            {year && <span className="ml-2 text-xl font-normal text-foreground/60">({year})</span>}
           </h2>
 
           {genres.length > 0 && (

+ 7 - 1
src/components/movies/movie-list-client.tsx

@@ -44,7 +44,11 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
   const unwatchedPool = useMemo(() => allMovies.filter((m) => !m.watched), [allMovies]);
   const poolEmpty = unwatchedPool.length === 0;
 
-  const { data: searchData, isLoading: isSearchLoading } = useMovieSearch(searchQuery);
+  const {
+    data: searchData,
+    isLoading: isSearchLoading,
+    isError: isSearchError,
+  } = useMovieSearch(searchQuery);
   const tmdbResults = searchData?.results ?? [];
 
   const addMovie = useAddMovie();
@@ -145,6 +149,8 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
               isAdding={addMovie.isPending}
               addingTmdbId={addingTmdbId}
               onAdd={handleAdd}
+              isError={isSearchError}
+              hasData={!!searchData}
             />
           </div>
         )}

+ 25 - 7
src/components/movies/search-results.tsx

@@ -13,6 +13,8 @@ interface SearchResultsProps {
   isAdding: boolean;
   addingTmdbId: number | null;
   onAdd: (tmdbId: number) => void;
+  isError?: boolean;
+  hasData?: boolean;
 }
 
 export function SearchResults({
@@ -22,6 +24,8 @@ export function SearchResults({
   isAdding,
   addingTmdbId,
   onAdd,
+  isError = false,
+  hasData = true,
 }: SearchResultsProps) {
   if (!query || query.length < 2) return null;
 
@@ -29,9 +33,7 @@ export function SearchResults({
 
   // Filter group movies that match the search query
   const lowerQuery = query.toLowerCase();
-  const matchingGroupMovies = groupMovies.filter((m) =>
-    m.title.toLowerCase().includes(lowerQuery),
-  );
+  const matchingGroupMovies = groupMovies.filter((m) => m.title.toLowerCase().includes(lowerQuery));
 
   // Filter TMDB results: exclude movies already in the group
   const newResults = tmdbResults.filter((m) => !groupTmdbIds.has(m.id));
@@ -39,10 +41,28 @@ export function SearchResults({
   const hasInList = matchingGroupMovies.length > 0;
   const hasNew = newResults.length > 0;
 
-  if (!hasInList && !hasNew) {
+  // Distinguish a network/fetch error (e.g. offline) from a genuine zero-hit
+  // TMDB response. Showing "No results" on a transport failure is misleading
+  // — the user thinks the movie doesn't exist when really the request never
+  // reached TMDB.
+  if (isError && !hasInList) {
+    return (
+      <p role="alert" aria-live="polite" className="py-4 text-center text-sm text-red-500">
+        Couldn&apos;t reach search — check your connection.
+      </p>
+    );
+  }
+
+  if (!hasInList && !hasNew && hasData) {
     return <p className="py-4 text-center text-sm text-gray-500">No results found.</p>;
   }
 
+  if (!hasInList && !hasNew) {
+    // Still loading first response and no local matches — render nothing
+    // rather than a misleading empty-state.
+    return null;
+  }
+
   return (
     <div className="space-y-4" aria-live="polite">
       {hasInList && (
@@ -64,9 +84,7 @@ export function SearchResults({
         </section>
       )}
 
-      {hasInList && hasNew && (
-        <hr className="border-gray-200 dark:border-gray-700" />
-      )}
+      {hasInList && hasNew && <hr className="border-gray-200 dark:border-gray-700" />}
 
       {hasNew && (
         <section>

+ 33 - 0
supabase/migrations/00006_movies_realtime_replica_identity.sql

@@ -0,0 +1,33 @@
+-- Enable Supabase Realtime UPDATE payloads for movies.
+--
+-- Two requirements for cross-window real-time UPDATE propagation:
+--
+-- 1. The table must be a member of the `supabase_realtime` publication.
+--    Without this, no postgres_changes events are emitted at all.
+--
+-- 2. REPLICA IDENTITY FULL is required for UPDATE payloads to include the
+--    full new row. With the default (REPLICA IDENTITY DEFAULT, which logs
+--    only the primary key), `payload.new` arrives with only `id` populated
+--    and our TanStack Query cache merge replaces a fully-populated row with
+--    a sparse one, losing the `watched` flag and other fields.
+--
+-- INSERT and DELETE work without FULL because INSERT carries the full new
+-- row regardless and DELETE only needs the PK to identify the row to remove.
+-- That matches the user-reported symptom (only UPDATE was broken).
+
+ALTER TABLE public.movies REPLICA IDENTITY FULL;
+
+-- Idempotent publication membership: ADD TABLE errors if already present,
+-- so guard with a DO block that checks pg_publication_tables first.
+DO $$
+BEGIN
+  IF NOT EXISTS (
+    SELECT 1 FROM pg_publication_tables
+    WHERE pubname = 'supabase_realtime'
+      AND schemaname = 'public'
+      AND tablename = 'movies'
+  ) THEN
+    EXECUTE 'ALTER PUBLICATION supabase_realtime ADD TABLE public.movies';
+  END IF;
+END
+$$;

+ 3 - 2
tsconfig.json

@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
-    "target": "ES2017",
-    "lib": ["dom", "dom.iterable", "esnext"],
+    "target": "ES2018",
+    "lib": ["dom", "dom.iterable", "esnext", "es2018.regexp"],
     "allowJs": true,
     "skipLibCheck": true,
     "strict": true,
@@ -13,6 +13,7 @@
     "isolatedModules": true,
     "jsx": "react-jsx",
     "incremental": true,
+    "types": ["vitest/globals", "node"],
     "plugins": [
       {
         "name": "next"