From 3ba40c8b8896d64f0663a8dea93a33f6a486fc0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:47:30 +0000 Subject: [PATCH] Match multi-film events on the genres and people of the films they screen A double bill or shorts programme matches no single film, so it carried no genres or credits of its own and never answered a genre, director or cast filter. Read an event as the union of what it screens, in the filters, the genre pages and the overlay's genre counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0144SAfC3w2jNSBYcX6H84Po --- CLAUDE.md | 12 +++ .../filter-overlay/genre-filter-section.tsx | 3 +- src/lib/filters/modules/genres.ts | 8 +- src/lib/filters/modules/people.ts | 8 +- src/utils/get-genre-movies.ts | 8 +- src/utils/get-movie-and-included.test.ts | 78 +++++++++++++++++++ src/utils/get-movie-and-included.ts | 38 +++++++++ 7 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 src/utils/get-movie-and-included.test.ts create mode 100644 src/utils/get-movie-and-included.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4d3407f9..aecea7c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -640,6 +640,18 @@ started emitting it carries none, and TheMovieDB has no score for some people. Absent is not zero — unranked, not unpopular — and the ordering falls through to director-first. +## Multi-film Events + +A double bill, marathon or shorts programme usually matches no single film, so +its own `genres`, `directors` and `actors` are empty (genres hold only the +"Uncategorised" placeholder) while each film in `includedMovies` carries its +own. The genre and people filters, the genre pages and the overlay's counts +read an event through `getMovieAndIncluded` (`@/utils/get-movie-and-included`): +the event plus the films it screens, any one matching, as every multi-select +filter reads "or". An unmatched event whose films supply genres stops being +Uncategorised. A shorts programme whose venue listed no films still has +nothing to read, and stays Uncategorised. + ## Films Filter `FilterId.Movies` (`src/lib/filters/modules/movies.ts`) restricts the grid to a diff --git a/src/components/filter-overlay/genre-filter-section.tsx b/src/components/filter-overlay/genre-filter-section.tsx index fba28de0..df61623a 100644 --- a/src/components/filter-overlay/genre-filter-section.tsx +++ b/src/components/filter-overlay/genre-filter-section.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { CinemaData, Genre } from "@/types"; import Button from "@/components/button"; import Chip from "@/components/chip"; +import { getMovieAndIncluded } from "@/utils/get-movie-and-included"; import styles from "./filter-overlay.module.css"; interface GenreFilterSectionProps { @@ -42,7 +43,7 @@ export default function GenreFilterSection({ const counts = new Map(); availableGenres.forEach((genre) => counts.set(genre.id, 0)); Object.values(movies).forEach((movie) => { - movie.genres?.forEach((genreId) => { + getMovieAndIncluded(movie, "genres").forEach((genreId) => { counts.set(genreId, (counts.get(genreId) || 0) + 1); }); }); diff --git a/src/lib/filters/modules/genres.ts b/src/lib/filters/modules/genres.ts index fbf62227..6ca1c655 100644 --- a/src/lib/filters/modules/genres.ts +++ b/src/lib/filters/modules/genres.ts @@ -1,11 +1,13 @@ import { FilterId, FilterModule, FilterState, MoviesRecord } from "../types"; +import { getMovieAndIncluded } from "@/utils/get-movie-and-included"; /** * Genres filter module. * Filters movies by genre IDs. * - `null` = all genres selected (no filter applied) * - `[]` = no genres selected (no movies match) - * - `[...ids]` = only movies with at least one of these genres + * - `[...ids]` = only movies with at least one of these genres, counting + * those of the films a multi-film event screens */ export const genresFilter: FilterModule = { id: FilterId.Genres, @@ -65,8 +67,8 @@ export const genresFilter: FilterModule = { const result: MoviesRecord = {}; for (const [id, movie] of Object.entries(movies)) { - // Check if movie has any of the selected genres - const movieGenres = movie.genres ?? []; + // Check if movie, or any film it screens, has a selected genre + const movieGenres = getMovieAndIncluded(movie, "genres"); const hasMatchingGenre = movieGenres.some((genreId) => genreSet.has(genreId), ); diff --git a/src/lib/filters/modules/people.ts b/src/lib/filters/modules/people.ts index f7de2742..c072b42c 100644 --- a/src/lib/filters/modules/people.ts +++ b/src/lib/filters/modules/people.ts @@ -6,6 +6,7 @@ import { editBudgetFor, MIN_FUZZY_LENGTH, } from "../word-distance"; +import { getMovieAndIncluded } from "@/utils/get-movie-and-included"; /** The two people filter IDs. Each reads one credit list on the movie. */ export type PeopleFilterId = FilterId.Directors | FilterId.Cast; @@ -119,7 +120,7 @@ function buildPeopleFilter( const result: MoviesRecord = {}; for (const [id, movie] of Object.entries(movies)) { - const credits = movie[group.key] ?? []; + const credits = getMovieAndIncluded(movie, group.key); if (credits.some((personId) => selectedSet.has(personId))) { result[id] = movie; } @@ -163,8 +164,9 @@ export function getPeopleVocabulary( for (const movie of Object.values(movies)) { for (const group of PEOPLE_GROUPS) { const tally = counts[group.filterId]; - // A person credited twice on one film still only screens once. - for (const personId of new Set(movie[group.key] ?? [])) { + // A person credited twice on one film still only screens once. Credits + // on the films an event screens count too, as the filter matches them. + for (const personId of new Set(getMovieAndIncluded(movie, group.key))) { tally.set(personId, (tally.get(personId) ?? 0) + 1); } } diff --git a/src/utils/get-genre-movies.ts b/src/utils/get-genre-movies.ts index f3957cbf..56743abd 100644 --- a/src/utils/get-genre-movies.ts +++ b/src/utils/get-genre-movies.ts @@ -1,4 +1,5 @@ import type { Movie } from "@/types"; +import { getMovieAndIncluded } from "./get-movie-and-included"; export type GenreMovie = { movie: Movie; performanceCount: number }; @@ -6,8 +7,9 @@ export type GenreMovie = { movie: Movie; performanceCount: number }; * Films belonging to a genre that have at least one upcoming performance, * with the count of those upcoming performances. * - * Genre membership is movie-level (`movie.genres`), so a matching movie - * contributes all of its upcoming performances. `nowTs` should be the data's + * Genre membership is movie-level (`movie.genres`, plus those of the films a + * multi-film event screens), so a matching movie contributes all of its + * upcoming performances. `nowTs` should be the data's * generated-at timestamp so results are stable for a given build. */ export function getGenreMovies( @@ -18,7 +20,7 @@ export function getGenreMovies( const result: GenreMovie[] = []; for (const movie of Object.values(movies)) { - if (!movie.genres?.includes(genreId)) continue; + if (!getMovieAndIncluded(movie, "genres").includes(genreId)) continue; let performanceCount = 0; for (const performance of movie.performances) { diff --git a/src/utils/get-movie-and-included.test.ts b/src/utils/get-movie-and-included.test.ts new file mode 100644 index 00000000..91cf466e --- /dev/null +++ b/src/utils/get-movie-and-included.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import type { Movie } from "@/types"; +import { genresFilter } from "@/lib/filters/modules/genres"; +import { directorsFilter } from "@/lib/filters/modules/people"; +import type { FilterState } from "@/lib/filters/types"; +import { getMovieAndIncluded } from "./get-movie-and-included"; + +const UNCATEGORISED = "uncategorised"; + +const film = (overrides: Partial): Movie => ({ + id: "1", + title: "Film", + normalizedTitle: "film", + showings: {}, + performances: [], + ...overrides, +}); + +// An unmatched double bill: no genres of its own beyond the placeholder. +const doubleBill = film({ + id: "double", + title: "Horror Double Bill", + isUnmatched: true, + genres: [UNCATEGORISED], + includedMovies: [ + { id: "a", title: "Halloween", genres: ["27"], directors: ["carpenter"] }, + { id: "b", title: "Scream", genres: ["27", "9648"], directors: ["craven"] }, + ], +}); + +describe("getMovieAndIncluded", () => { + it("returns a film's own list when it screens nothing else", () => { + const movie = film({ genres: ["18"] }); + expect(getMovieAndIncluded(movie, "genres")).toBe(movie.genres); + }); + + it("reads an unmatched event's genres from its films, dropping the placeholder", () => { + expect(getMovieAndIncluded(doubleBill, "genres")).toEqual(["27", "9648"]); + }); + + it("keeps an unmatched event Uncategorised when its films have no genres", () => { + const shorts = film({ + isUnmatched: true, + genres: [UNCATEGORISED], + includedMovies: [{ id: "s", title: "A Short" }], + }); + expect(getMovieAndIncluded(shorts, "genres")).toEqual([UNCATEGORISED]); + }); + + it("adds the included films' credits to a matched film's own", () => { + const movie = film({ + directors: ["lynch"], + includedMovies: [{ id: "a", title: "Short", directors: ["lynch", "x"] }], + }); + expect(getMovieAndIncluded(movie, "directors")).toEqual(["lynch", "x"]); + }); +}); + +describe("filters reading included films", () => { + const movies = { [doubleBill.id]: doubleBill }; + + it("matches a double bill by a genre one of its films has", () => { + const state = { genres: ["9648"] } as FilterState; + expect(Object.keys(genresFilter.apply(movies, state))).toEqual(["double"]); + }); + + it("no longer files a double bill with genred films as Uncategorised", () => { + const state = { genres: [UNCATEGORISED] } as FilterState; + expect(genresFilter.apply(movies, state)).toEqual({}); + }); + + it("matches a double bill by the director of one of its films", () => { + const state = { directors: ["craven"] } as FilterState; + expect(Object.keys(directorsFilter.apply(movies, state))).toEqual([ + "double", + ]); + }); +}); diff --git a/src/utils/get-movie-and-included.ts b/src/utils/get-movie-and-included.ts new file mode 100644 index 00000000..2eb30d8f --- /dev/null +++ b/src/utils/get-movie-and-included.ts @@ -0,0 +1,38 @@ +import type { Movie } from "@/types"; + +/** The id lists a multi-film event can draw from the films it screens. */ +export type IncludedIdKey = "genres" | "directors" | "actors"; + +/** + * A film's genres, directors or cast, plus those of the films it screens. + * + * A double bill, marathon or shorts programme usually matches no single film, + * so its own lists are empty (genres hold only the "Uncategorised" + * placeholder) while each film inside it carries its own. A horror double bill + * should answer a Horror filter, so an event is read as the union of what it + * screens: any one film matching is enough, as every multi-select filter reads + * "or". + * + * An unmatched event's own genres are dropped once its films supply some, + * since they can only be the placeholder; it stays Uncategorised when none of + * its films has a genre either. Returns the movie's own array untouched when + * there is nothing to add, as this runs over every film on every filter pass. + */ +export function getMovieAndIncluded( + movie: Pick, + key: IncludedIdKey, +): string[] { + const own = movie[key] ?? []; + const included = movie.includedMovies; + if (!included || included.length === 0) return own; + + const ids = new Set(); + for (const film of included) { + for (const id of film[key] ?? []) ids.add(id); + } + if (ids.size === 0) return own; + + if (key === "genres" && movie.isUnmatched) return [...ids]; + for (const id of own) ids.add(id); + return [...ids]; +}