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); +}