Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/components/filter-overlay/genre-filter-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -42,7 +43,7 @@ export default function GenreFilterSection({
const counts = new Map<string, number>();
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);
});
});
Expand Down
8 changes: 5 additions & 3 deletions src/lib/filters/modules/genres.ts
Original file line number Diff line number Diff line change
@@ -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<FilterId.Genres> = {
id: FilterId.Genres,
Expand Down Expand Up @@ -65,8 +67,8 @@ export const genresFilter: FilterModule<FilterId.Genres> = {
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),
);
Expand Down
8 changes: 5 additions & 3 deletions src/lib/filters/modules/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
8 changes: 5 additions & 3 deletions src/utils/get-genre-movies.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { Movie } from "@/types";
import { getMovieAndIncluded } from "./get-movie-and-included";

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(
Expand All @@ -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) {
Expand Down
78 changes: 78 additions & 0 deletions src/utils/get-movie-and-included.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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",
]);
});
});
38 changes: 38 additions & 0 deletions src/utils/get-movie-and-included.ts
Original file line number Diff line number Diff line change
@@ -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<Movie, IncludedIdKey | "includedMovies" | "isUnmatched">,
key: IncludedIdKey,
): string[] {
const own = movie[key] ?? [];
const included = movie.includedMovies;
if (!included || included.length === 0) return own;

const ids = new Set<string>();
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];
}
Loading