From 5b397e22655fa68321392f3df35e0adf5ab9e8aa Mon Sep 17 00:00:00 2001
From: code002lover
Date: Fri, 19 Dec 2025 14:06:31 +0100
Subject: [PATCH] feat: modularize game filtering logic and UI into a custom
hook and dedicated components.feat: modularize game filtering logic and UI
into a custom hook and dedicated components.
---
frontend/index.html | 1 -
frontend/src/GameFilter.tsx | 224 ++----------------
frontend/src/components/FilteredGamesList.tsx | 94 ++++++++
frontend/src/components/PersonSelector.tsx | 55 +++++
frontend/src/hooks/useGameFilter.ts | 98 ++++++++
5 files changed, 267 insertions(+), 205 deletions(-)
create mode 100644 frontend/src/components/FilteredGamesList.tsx
create mode 100644 frontend/src/components/PersonSelector.tsx
create mode 100644 frontend/src/hooks/useGameFilter.ts
diff --git a/frontend/index.html b/frontend/index.html
index fd9ec27..513c137 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,6 @@
-
Game List
diff --git a/frontend/src/GameFilter.tsx b/frontend/src/GameFilter.tsx
index 7313e87..d80fd6d 100644
--- a/frontend/src/GameFilter.tsx
+++ b/frontend/src/GameFilter.tsx
@@ -1,25 +1,19 @@
import { useState, useEffect } from "react";
-import {
- Person,
- PersonList as PersonListProto,
- Game as GameProto,
- GetGameInfoRequest,
- GameInfoResponse,
-} from "../items";
+import { Person, PersonList as PersonListProto } from "../items";
import { apiFetch } from "./api";
-import { Link } from "react-router-dom";
-import { GameImage } from "./GameImage";
-import "./GameFilter.css"
+import "./GameFilter.css";
+import { useGameFilter } from "./hooks/useGameFilter";
+import { PersonSelector } from "./components/PersonSelector";
+import { FilteredGamesList } from "./components/FilteredGamesList";
export function GameFilter() {
const [people, setPeople] = useState([]);
const [selectedPeople, setSelectedPeople] = useState>(new Set());
- const [filteredGames, setFilteredGames] = useState([]);
- const [gameToPositive, setGameToPositive] = useState<
- Map>
- >(new Map());
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [metaData, _setMetaData] = useState<{ [key: string]: GameProto }>({});
+
+ const { filteredGames, gameToPositive } = useGameFilter(
+ people,
+ selectedPeople
+ );
useEffect(() => {
apiFetch("/api")
@@ -31,83 +25,6 @@ export function GameFilter() {
.catch((err) => console.error("Failed to fetch people:", err));
}, []);
- useEffect(() => {
- if (selectedPeople.size === 0) {
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setFilteredGames([]);
- return;
- }
-
- // Get all games where ALL selected people have "Would Play"
- const selectedPersons = people.filter((p) => selectedPeople.has(p.name));
-
- if (selectedPersons.length === 0) {
- setFilteredGames([]);
- return;
- }
-
- // Create a map of game -> set of people who would not play it
- const gameToNegative = new Map>();
- const gameToPositiveOpinion = new Map>();
-
- selectedPersons.forEach((person) => {
- person.opinion.forEach((op) => {
- if (!gameToNegative.has(op.title)) {
- gameToNegative.set(op.title, new Set());
- }
- if (!gameToPositiveOpinion.has(op.title)) {
- gameToPositiveOpinion.set(op.title, new Set());
- }
- if (!op.wouldPlay) {
- gameToNegative.get(op.title)!.add(person.name);
- }
- if (op.wouldPlay) {
- gameToPositiveOpinion.get(op.title)!.add(person.name);
- }
- });
- });
-
- setGameToPositive(gameToPositiveOpinion);
-
- // Filter games where ALL selected people would play
- const game_titles = Array.from(gameToNegative.entries())
- .filter(([, players]) => players.size === 0)
- .map(([game]) => game);
-
- let games = game_titles.filter((title) => metaData[title]).map((title) => metaData[title]);
- const gamesToFetch = GetGameInfoRequest.encode(
- GetGameInfoRequest.create({
- games: game_titles.filter((title) => !metaData[title]),
- })
- ).finish();
-
- apiFetch("/api/games/batch", {
- method: "POST",
- headers: {
- "Content-Type": "application/octet-stream",
- },
- body: gamesToFetch,
- })
- .then((res) => res.arrayBuffer())
- .then((buffer) => {
- const list = GameInfoResponse.decode(new Uint8Array(buffer));
- games = games.concat(list.games);
-
- games.forEach((game) => {
- metaData[game.title] = game;
- });
-
- const filteredGames = games.filter((g) => {
- const game = g as GameProto;
- return (
- game.maxPlayers >= selectedPeople.size &&
- game.minPlayers <= selectedPeople.size
- );
- });
- setFilteredGames(filteredGames.map((g) => (g as GameProto).title));
- });
- }, [selectedPeople, people, metaData]);
-
const togglePerson = (name: string) => {
const newSelected = new Set(selectedPeople);
if (newSelected.has(name)) {
@@ -125,118 +42,17 @@ export function GameFilter() {
Select multiple people to find games that everyone would play
-
-
Select People
-
- {people.map((person) => (
-
togglePerson(person.name)}
- >
-
- {person.name}
-
-
- {person.opinion.length} opinion(s)
-
-
- ))}
-
-
+
- {selectedPeople.size > 0 && (
-
-
Games Everyone Would Play ({filteredGames.length})
- {filteredGames.length > 0 ? (
-
- {filteredGames.map((game) => (
-
-
-
{game}
-
- ✓ {gameToPositive.get(game)!.size} selected
- would play
-
- {selectedPeople.size - gameToPositive.get(game)!.size >
- 0 && (
-
- ?{" "}
- {selectedPeople.size - gameToPositive.get(game)!.size}{" "}
- {selectedPeople.size - gameToPositive.get(game)!.size >
- 1
- ? "are"
- : "is"}{" "}
- neutral
-
- )}
-
-
-
- ))}
-
- ) : (
-
-
🔍
-
No games found where all selected people would play.
-
- Try selecting fewer people or adding more opinions!
-
-
- )}
-
- )}
+
);
}
diff --git a/frontend/src/components/FilteredGamesList.tsx b/frontend/src/components/FilteredGamesList.tsx
new file mode 100644
index 0000000..f6845d2
--- /dev/null
+++ b/frontend/src/components/FilteredGamesList.tsx
@@ -0,0 +1,94 @@
+import { Link } from "react-router-dom";
+import { GameImage } from "../GameImage";
+
+interface FilteredGamesListProps {
+ filteredGames: string[];
+ gameToPositive: Map>;
+ selectedPeopleCount: number;
+}
+
+export function FilteredGamesList({
+ filteredGames,
+ gameToPositive,
+ selectedPeopleCount,
+}: FilteredGamesListProps) {
+ if (selectedPeopleCount === 0) return null;
+
+ return (
+
+
Games Everyone Would Play ({filteredGames.length})
+ {filteredGames.length > 0 ? (
+
+ {filteredGames.map((game) => {
+ const positiveCount = gameToPositive.get(game)?.size || 0;
+ const neutralCount = selectedPeopleCount - positiveCount;
+
+ return (
+
+
+
{game}
+
+ ✓ {positiveCount} selected would play
+
+ {neutralCount > 0 && (
+
+ ? {neutralCount}{" "}
+ {neutralCount > 1 ? "are" : "is"} neutral
+
+ )}
+
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function EmptyState() {
+ return (
+
+
🔍
+
No games found where all selected people would play.
+
+ Try selecting fewer people or adding more opinions!
+
+
+ );
+}
diff --git a/frontend/src/components/PersonSelector.tsx b/frontend/src/components/PersonSelector.tsx
new file mode 100644
index 0000000..6e6d18f
--- /dev/null
+++ b/frontend/src/components/PersonSelector.tsx
@@ -0,0 +1,55 @@
+import { Person } from "../../items";
+
+interface PersonSelectorProps {
+ people: Person[];
+ selectedPeople: Set;
+ onTogglePerson: (name: string) => void;
+}
+
+export function PersonSelector({
+ people,
+ selectedPeople,
+ onTogglePerson,
+}: PersonSelectorProps) {
+ return (
+
+
Select People
+
+ {people.map((person) => (
+
onTogglePerson(person.name)}
+ >
+
+ {person.name}
+
+
+ {person.opinion.length} opinion(s)
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/hooks/useGameFilter.ts b/frontend/src/hooks/useGameFilter.ts
new file mode 100644
index 0000000..5f1b617
--- /dev/null
+++ b/frontend/src/hooks/useGameFilter.ts
@@ -0,0 +1,98 @@
+import { useState, useEffect, useRef, useMemo } from "react";
+import {
+ Person,
+ Game as GameProto,
+ GetGameInfoRequest,
+ GameInfoResponse,
+} from "../../items";
+import { apiFetch } from "../api";
+
+export function useGameFilter(people: Person[], selectedPeople: Set) {
+ const [fetchedTitles, setFetchedTitles] = useState([]);
+ const metaDataRef = useRef<{ [key: string]: GameProto }>({});
+
+ const { gameToNegative, gameToPositiveOpinion } = useMemo(() => {
+ const gameToNegative = new Map>();
+ const gameToPositiveOpinion = new Map>();
+
+ if (selectedPeople.size === 0)
+ return { gameToNegative, gameToPositiveOpinion };
+
+ const selectedPersons = people.filter((p) => selectedPeople.has(p.name));
+ selectedPersons.forEach((person) => {
+ person.opinion.forEach((op) => {
+ if (!gameToNegative.has(op.title))
+ gameToNegative.set(op.title, new Set());
+ if (!gameToPositiveOpinion.has(op.title))
+ gameToPositiveOpinion.set(op.title, new Set());
+
+ if (!op.wouldPlay) {
+ gameToNegative.get(op.title)!.add(person.name);
+ } else {
+ gameToPositiveOpinion.get(op.title)!.add(person.name);
+ }
+ });
+ });
+
+ return { gameToNegative, gameToPositiveOpinion };
+ }, [people, selectedPeople]);
+
+ const titlesEveryoneWouldPlay = useMemo(() => {
+ return Array.from(gameToNegative.entries())
+ .filter(([, players]) => players.size === 0)
+ .map(([game]) => game);
+ }, [gameToNegative]);
+
+ useEffect(() => {
+ const titlesToFetch = titlesEveryoneWouldPlay.filter(
+ (title) => !metaDataRef.current[title]
+ );
+ if (titlesToFetch.length === 0) return;
+
+ const gamesToFetch = GetGameInfoRequest.encode(
+ GetGameInfoRequest.create({
+ games: titlesToFetch,
+ })
+ ).finish();
+
+ apiFetch("/api/games/batch", {
+ method: "POST",
+ headers: { "Content-Type": "application/octet-stream" },
+ body: gamesToFetch,
+ })
+ .then((res) => res.arrayBuffer())
+ .then((buffer) => {
+ const list = GameInfoResponse.decode(new Uint8Array(buffer));
+ list.games.forEach((game) => {
+ metaDataRef.current[game.title] = game;
+ });
+ // Trigger a re-render to update filteredGames
+ setFetchedTitles([...titlesToFetch]);
+ })
+ .catch((err) => console.error("Failed to fetch game metadata:", err));
+ }, [titlesEveryoneWouldPlay]);
+
+ const filteredGames = useMemo(() => {
+ if (selectedPeople.size === 0) return [];
+
+ const games = titlesEveryoneWouldPlay
+ .filter((title) => metaDataRef.current[title])
+ .map((title) => metaDataRef.current[title]);
+
+ return filterByPlayerCount(games, selectedPeople.size);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [titlesEveryoneWouldPlay, selectedPeople.size, fetchedTitles]);
+
+ return { filteredGames, gameToPositive: gameToPositiveOpinion };
+}
+
+function filterByPlayerCount(
+ games: GameProto[],
+ playerCount: number
+): string[] {
+ return games
+ .filter(
+ (game) => game.maxPlayers >= playerCount && game.minPlayers <= playerCount
+ )
+ .map((game) => game.title);
+}