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
11 changes: 7 additions & 4 deletions PTG/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,24 @@ import { usePokemonTeam } from "./usePokemonTeam.ts";
export default function App(){
const [selectedGame, setSelectedGame] = useState(() => loadStoredFilters().selectedGame);
const [selectedType, setSelectedType] = useState(() => loadStoredFilters().selectedType);
const [selectedName, setSelectedName] = useState(() => loadStoredFilters().selectedName);

useEffect(() => {
saveStoredFilters({ selectedGame, selectedType });
}, [selectedGame, selectedType]);
saveStoredFilters({ selectedGame, selectedType, selectedName });
}, [selectedGame, selectedType, selectedName]);

const { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite } = usePokemonTeam(selectedGame, selectedType);
const { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite } = usePokemonTeam(selectedGame, selectedType, selectedName);

return(
<div>
<Header />
<ChoosePokemon
selectedGame={selectedGame}
selectedType={selectedType}
selectedName={selectedName}
onGameChange={setSelectedGame}
onTypeChange={setSelectedType}
onNameChange={setSelectedName}
/>
<Team
team={team}
Expand All @@ -34,7 +37,7 @@ export default function App(){
generateTeam={generateTeam}
toggleFavourite={toggleFavourite}
/>
<PokemonGrid selectedGame={selectedGame} selectedType={selectedType} />
<PokemonGrid selectedGame={selectedGame} selectedType={selectedType} selectedName={selectedName} />
<Footer/>
</div>

Expand Down
6 changes: 6 additions & 0 deletions PTG/src/ChoosePokemon.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@
width: fit-content;
max-width: 100%;
}

.chooseName {
field-sizing: content;
width: fit-content;
max-width: 100%;
}
13 changes: 12 additions & 1 deletion PTG/src/ChoosePokemon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ const ANY_TYPE: TypeChoice = { label: 'Any Type', value: 'any' };
type ChoosePokemonProps = {
selectedGame: string;
selectedType: string;
selectedName: string;
onGameChange: (value: string) => void;
onTypeChange: (value: string) => void;
onNameChange: (value: string) => void;
}

export default function ChoosePokemon({ selectedGame, selectedType, onGameChange, onTypeChange }: ChoosePokemonProps){
export default function ChoosePokemon({ selectedGame, selectedType, selectedName, onGameChange, onTypeChange, onNameChange }: ChoosePokemonProps){
const { data: games = [], error: gamesError } = useQuery({
queryKey: ["games"],
queryFn: fetchGames,
Expand All @@ -27,6 +29,8 @@ export default function ChoosePokemon({ selectedGame, selectedType, onGameChange
queryFn: fetchTypes,
});

function handleNameChange(e: React.ChangeEvent<HTMLInputElement>) { onNameChange(e.target.value); }

function handleGameChange(e: React.ChangeEvent<HTMLSelectElement>) { onGameChange(e.target.value); }

function handleTypeChange(e: React.ChangeEvent<HTMLSelectElement>) { onTypeChange(e.target.value); }
Expand All @@ -38,6 +42,13 @@ export default function ChoosePokemon({ selectedGame, selectedType, onGameChange

return(
<>
<input
type="text"
className="chooseName"
placeholder="Name..."
value={selectedName}
onChange={handleNameChange}
/>
<select
className="chooseType"
value={selectedGame}
Expand Down
3 changes: 2 additions & 1 deletion PTG/src/PokemonGrid.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6));
}

.pokemonGridError {
.pokemonGridError,
.pokemonGridEmpty {
color: red;
text-align: center;
}
9 changes: 7 additions & 2 deletions PTG/src/PokemonGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@ function capitalize(name: string) {
type PokemonGridProps = {
selectedGame: string;
selectedType: string;
selectedName: string;
};

export default function PokemonGrid({ selectedGame, selectedType }: PokemonGridProps) {
const { pokemon, typeMap, pokemonError, typeError, gameError } = useFilteredPokemon(selectedGame, selectedType);
export default function PokemonGrid({ selectedGame, selectedType, selectedName }: PokemonGridProps) {
const { pokemon, typeMap, isLoading, pokemonError, typeError, gameError } = useFilteredPokemon(selectedGame, selectedType, selectedName);

if (pokemonError) return <p className="pokemonGridError">Failed to load Pokémon: {pokemonError.message}</p>;
if (typeError) return <p className="pokemonGridError">Failed to load type: {typeError.message}</p>;
if (gameError) return <p className="pokemonGridError">Failed to load game: {gameError.message}</p>;

if (!isLoading && pokemon.length === 0) {
return <p className="pokemonGridEmpty">No pokèmon found for current filter</p>;
}

return (
<div className="pokemonGrid">
{pokemon.map(({ id, name, image }) => {
Expand Down
4 changes: 3 additions & 1 deletion PTG/src/pokemonFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ const STORAGE_KEY = "pokemonFilters";
export type PokemonFilters = {
selectedGame: string;
selectedType: string;
selectedName: string;
};

const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedType: "any" };
const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedType: "any", selectedName: "" };

export function loadStoredFilters(): PokemonFilters {
try {
Expand All @@ -15,6 +16,7 @@ export function loadStoredFilters(): PokemonFilters {
return {
selectedGame: typeof parsed.selectedGame === "string" ? parsed.selectedGame : DEFAULT_FILTERS.selectedGame,
selectedType: typeof parsed.selectedType === "string" ? parsed.selectedType : DEFAULT_FILTERS.selectedType,
selectedName: typeof parsed.selectedName === "string" ? parsed.selectedName : DEFAULT_FILTERS.selectedName,
};
} catch {
return DEFAULT_FILTERS;
Expand Down
11 changes: 8 additions & 3 deletions PTG/src/useFilteredPokemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,15 @@ export function usePokemonList() {
export type FilteredPokemon = {
pokemon: Pokemon[];
typeMap: Map<string, PokemonType[]> | undefined;
isLoading: boolean;
pokemonError: Error | null;
typeError: Error | null;
gameError: Error | null;
};

// Resolves the current game/type filters against the full Pokémon list.
export function useFilteredPokemon(selectedGame: string, selectedType: string): FilteredPokemon {
const { data: pokemon = [], error: pokemonError } = usePokemonList();
// Resolves the current game/type/name filters against the full Pokémon list.
export function useFilteredPokemon(selectedGame: string, selectedType: string, selectedName: string = ""): FilteredPokemon {
const { data: pokemon = [], error: pokemonError, isLoading } = usePokemonList();

// Decorative type icons. A failure here shouldn't block the rest of the grid.
const { data: typeMap } = useQuery({
Expand All @@ -72,16 +73,20 @@ export function useFilteredPokemon(selectedGame: string, selectedType: string):
enabled: !!selectedGame,
});

const nameQuery = selectedName.trim().toLowerCase();

// Each filter is resolved to a matching-id/name set independently above. A Pokemon must satisfy all of them.
const filtered = pokemon.filter(({ id, name }) => {
if (gameIds && !gameIds.has(id)) return false;
if (typeNames && !typeNames.has(name)) return false;
if (nameQuery && !name.toLowerCase().startsWith(nameQuery)) return false;
return true;
});

return {
pokemon: filtered,
typeMap,
isLoading,
pokemonError,
typeError,
gameError,
Expand Down
46 changes: 33 additions & 13 deletions PTG/src/usePokemonTeam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { loadStoredTeam, saveStoredTeam, type TeamSlot } from "./pokemonTeam";
import { useFilteredPokemon, usePokemonList, type Pokemon } from "./useFilteredPokemon";
import type { PokemonType } from "./pokemonTypes";

function shuffle<T>(items: T[]): T[] {
const result = [...items];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}

export type PokemonTeam = {
team: TeamSlot[];
pokemonById: Map<number, Pokemon>;
Expand All @@ -12,13 +21,13 @@ export type PokemonTeam = {
toggleFavourite: (index: number) => void;
};

export function usePokemonTeam(selectedGame: string, selectedType: string): PokemonTeam {
export function usePokemonTeam(selectedGame: string, selectedType: string, selectedName: string): PokemonTeam {
const [team, setTeam] = useState<TeamSlot[]>(() => loadStoredTeam());

// The team can hold Pokémon that fall outside the current filter (picked before the filter changed),
// so members are looked up from the full list, while new picks are drawn from the filtered one.
const { data: allPokemon = [] } = usePokemonList();
const { pokemon: filteredPokemon, typeMap } = useFilteredPokemon(selectedGame, selectedType);
const { pokemon: filteredPokemon, typeMap } = useFilteredPokemon(selectedGame, selectedType, selectedName);

useEffect(() => {
saveStoredTeam(team);
Expand All @@ -28,18 +37,29 @@ export function usePokemonTeam(selectedGame: string, selectedType: string): Poke
const allFavourited = team.every((slot) => slot.favourite);

function generateTeam() {
const favouriteIds = new Set(team.filter((slot) => slot.favourite).map((slot) => slot.id));
const available = filteredPokemon.filter(({ id }) => !favouriteIds.has(id));
// Built fresh inside the updater (rather than closed over) so nothing is mutated
// across React StrictMode's double-invocation of setState updaters.
setTeam((current) => {
// Exclude every Pokémon already on the team (not just favourites) so a slot can't end up duplicated.
const teamIds = new Set(current.filter((slot) => slot.id !== null).map((slot) => slot.id));
const pool = filteredPokemon.filter(({ id }) => !teamIds.has(id));

setTeam((current) =>
current.map((slot) => {
if (slot.favourite) return slot;
if (available.length === 0) return { ...slot, id: null };
const index = Math.floor(Math.random() * available.length);
const [picked] = available.splice(index, 1);
return { ...slot, id: picked.id };
})
);
const nonFavouriteIndices = current
.map((slot, index) => (slot.favourite ? -1 : index))
.filter((index) => index !== -1);

// When the filter has fewer non-teammate matches than there are non-favourite slots,
// only that many slots get replaced - chosen at random - and the rest are left as they were.
const replaceCount = Math.min(pool.length, nonFavouriteIndices.length);
const indicesToReplace = new Set(shuffle(nonFavouriteIndices).slice(0, replaceCount));
const picks = shuffle(pool).slice(0, replaceCount);

let pickIndex = 0;
return current.map((slot, index) => {
if (!indicesToReplace.has(index)) return slot;
return { ...slot, id: picks[pickIndex++].id };
});
});
}

function toggleFavourite(index: number) {
Expand Down