Bladeren bron

[Phase 5 U8] Swap home Genre Roll to structured filter

Replace the legacy string-tokenizer filterByGenresAndEmotions with
filterByGenresAndEmotionsStructured so genre IDs and mood keys from
GenreRollModal match against both numeric TMDB IDs and the name strings
stored in movies.genres. Genre-only selections no longer fall through
to an unfiltered pool.

Document the genres-as-names invariant in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 2 maanden geleden
bovenliggende
commit
99e7c5d628
2 gewijzigde bestanden met toevoegingen van 10 en 11 verwijderingen
  1. 1 0
      CLAUDE.md
  2. 9 11
      src/components/home/roll-section.tsx

+ 1 - 0
CLAUDE.md

@@ -31,6 +31,7 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 - `users.id` = Supabase Auth UID from `signInAnonymously()`. Recovery codes hashed with Argon2id (memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes).
 - `users.last_active_at` — updated on writes, throttled to 1x/24h. 12-month inactivity = auto-delete.
 - `movies.added_by` — FK with `ON DELETE SET NULL`. `trailer_url` validated against allowlist (youtube.com, themoviedb.org, imdb.com).
+- `movies.genres` stores TMDB genre **names** (e.g. `"Action"`), not numeric IDs. Genre filtering must accept both — use `filterByGenresAndEmotionsStructured` (matches IDs + names), not the legacy string tokenizer.
 - `movies.metadata_refreshed_at` — for monthly TMDB metadata refresh (post-MVP).
 - Invite codes: WORD-WORD format (2,000+ words, 3-8 chars, offensive terms filtered, case-insensitive).
 - `admin_sessions` — iron-session v8 encrypted cookies, no DB table.

+ 9 - 11
src/components/home/roll-section.tsx

@@ -8,7 +8,7 @@ import { RollAnimation } from "@/components/dice/roll-animation";
 import { RollAnnouncer } from "@/components/dice/roll-announcer";
 import { GenreRollModal, type GenreRollPayload } from "@/components/dice/genre-roll-modal";
 import { HomeRollTeaserCard } from "@/components/home/home-roll-teaser-card";
-import { filterByGenresAndEmotions } from "@/lib/dice/genre-filter";
+import { filterByGenresAndEmotionsStructured } from "@/lib/dice/genre-filter";
 import type { Movie } from "@/types/movie";
 
 /**
@@ -17,11 +17,10 @@ import type { Movie } from "@/types/movie";
  * The home-page roll renders IN PLACE — no `router.push()` on the roll path
  * (PROJECT_SCOPE.md:222-223). User stays on `/home`.
  *
- * Genre roll filter: uses the legacy string-tokenizer `filterByGenresAndEmotions`
- * (Option A from the PHASE4 plan). Mood keys resolve via EMOTION_TO_GENRE_MAP;
- * bare numeric genre IDs pass through the tokenizer without matching, so a
- * genre-only selection currently degrades to a full-pool roll. U8 owns the
- * structured replacement in a parallel branch we must not touch.
+ * Genre roll filter: uses `filterByGenresAndEmotionsStructured` so genre IDs
+ * and mood keys from `<GenreRollModal>` are matched against both numeric TMDB
+ * IDs and the name strings stored in `movies.genres`. A genre-only selection
+ * now filters correctly instead of falling through to an unfiltered pool.
  */
 
 export function RollSection() {
@@ -62,12 +61,11 @@ export function RollSection() {
     setGenreModalOpen(false);
     if (!hasPool) return;
 
-    const tokens = [...payload.genreIds.map(String), ...payload.moodKeys].join(" ");
-    const { movies: filtered, noMatches } = filterByGenresAndEmotions(tokens, fullPool);
+    const { movies: filtered, noMatches } = filterByGenresAndEmotionsStructured(
+      { genreIds: payload.genreIds, moodKeys: payload.moodKeys },
+      fullPool,
+    );
 
-    // On no-match, filterByGenresAndEmotions already returns the full pool,
-    // but we roll explicitly on `fullPool` so future contract changes can't
-    // silently break the fallback.
     const rollPool = noMatches ? fullPool : filtered;
     setNoMatchesBanner(noMatches);
     setActivePool(rollPool);