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
7 changes: 6 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { useQuery } from "@tanstack/react-query";
import { getPokemonByID } from "./api/pokemon";
import { PokemonCard } from "./components/PokemonCard/PokemonCard";
import { Favorite } from "./components/Favorite/Favorite";
import { useFavorites } from "./controllers/useFavorites";

export default function App() {
// Start at a default ID
const [pokemonId, setPokemonId] = useState(1);
const { isFavorite, toggleFavorite } = useFavorites();

const query = useQuery({
queryKey: ["pokemon", pokemonId],
Expand Down Expand Up @@ -58,7 +60,10 @@ export default function App() {
{query.isPending && <h1>Loading...</h1>}
{query.isError && <h1>Failed to load Pokemon</h1>}
{query.data && <PokemonCard pokemon={query.data} />}
<Favorite pokemonId={pokemonId} />
<Favorite
isFavorite={isFavorite(pokemonId)}
onToggle={() => toggleFavorite(pokemonId)}
/>
</div>
);
}
39 changes: 39 additions & 0 deletions src/components/Favorite/Favorite.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Favorite } from "./Favorite";

describe("Favorite Component", () => {
test("shows empty heart when not favorite", () => {
render(<Favorite isFavorite={false} onToggle={() => {}} />);

const button = screen.getByRole("button", { name: "Add to favorites" });
expect(button.textContent).toBe("🤍");
expect(button.getAttribute("aria-pressed")).toBe("false");
});

test("shows filled heart when favorite", () => {
render(<Favorite isFavorite={true} onToggle={() => {}} />);

const button = screen.getByRole("button", {
name: "Remove from favorites",
});
expect(button.textContent).toBe("❤️");
expect(button.getAttribute("aria-pressed")).toBe("true");
});

test("calls onToggle when clicked", () => {
const handleToggle = vi.fn();
render(<Favorite isFavorite={false} onToggle={handleToggle} />);

fireEvent.click(screen.getByRole("button"));

expect(handleToggle).toHaveBeenCalledTimes(1);
});

test("matches snapshot", () => {
const { asFragment } = render(
<Favorite isFavorite={true} onToggle={() => {}} />,
);
expect(asFragment()).toMatchSnapshot();
});
});
37 changes: 5 additions & 32 deletions src/components/Favorite/Favorite.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,15 @@
import { useState } from "react";
import "./Favorite.css";

type FavoriteProps = {
pokemonId: number;
isFavorite: boolean;
onToggle: () => void;
};

export function Favorite({ pokemonId }: FavoriteProps) {
// 1. Lazy initialization from localStorage
const [favorites, setFavorites] = useState<number[]>(() => {
try {
const storedFavorites = window.localStorage.getItem("favorites");
return storedFavorites ? JSON.parse(storedFavorites) : [];
} catch (error) {
console.error("Error reading localStorage", error);
return [];
}
});

const isFavorite = favorites.includes(pokemonId);

// 2. The click handler syncing State and Storage
const toggleFavorite = () => {
let updatedFavorites: number[];

if (isFavorite) {
updatedFavorites = favorites.filter((id) => id !== pokemonId);
} else {
updatedFavorites = [...favorites, pokemonId];
}

setFavorites(updatedFavorites);
window.localStorage.setItem("favorites", JSON.stringify(updatedFavorites));
};

// 3. Accessible UI rendering
export function Favorite({ isFavorite, onToggle }: FavoriteProps) {
return (
<button
onClick={toggleFavorite}
onClick={onToggle}
aria-pressed={isFavorite}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
className="favorite-button"
>
Expand Down
13 changes: 13 additions & 0 deletions src/components/Favorite/__snapshots__/Favorite.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Favorite Component > matches snapshot 1`] = `
<DocumentFragment>
<button
aria-label="Remove from favorites"
aria-pressed="true"
class="favorite-button"
>
❤️
</button>
</DocumentFragment>
`;
Empty file removed src/controllers/.gitkeep
Empty file.
44 changes: 44 additions & 0 deletions src/controllers/useFavorites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { saveFavorites } from "../models/favorites";
import { useFavorites } from "./useFavorites";

describe("useFavorites Hook", () => {
beforeEach(() => {
window.localStorage.clear();
});

test("starts empty when nothing is stored", () => {
const { result } = renderHook(() => useFavorites());

expect(result.current.favorites).toEqual([]);
expect(result.current.isFavorite(1)).toBe(false);
});

test("restores favorites from localStorage", () => {
saveFavorites([1, 25]);

const { result } = renderHook(() => useFavorites());

expect(result.current.favorites).toEqual([1, 25]);
expect(result.current.isFavorite(25)).toBe(true);
});

test("toggleFavorite adds and removes and updates storage", () => {
const { result } = renderHook(() => useFavorites());

act(() => {
result.current.toggleFavorite(4);
});

expect(result.current.favorites).toEqual([4]);
expect(window.localStorage.getItem("favorites")).toBe("[4]");

act(() => {
result.current.toggleFavorite(4);
});

expect(result.current.favorites).toEqual([]);
expect(window.localStorage.getItem("favorites")).toBe("[]");
});
});
28 changes: 28 additions & 0 deletions src/controllers/useFavorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from "react";
import {
loadFavorites,
saveFavorites,
toggleFavorite as toggleInList,
} from "../models/favorites";

export function useFavorites() {
// 1. Lazy initialization from localStorage
const [favorites, setFavorites] = useState<number[]>(loadFavorites);

// 2. Keep storage in sync so favorites survive closing the browser
useEffect(() => {
saveFavorites(favorites);
}, [favorites]);

// 3. Handlers passed down to the components
const toggleFavorite = useCallback((id: number) => {
setFavorites((prev) => toggleInList(prev, id));
}, []);

const isFavorite = useCallback(
(id: number) => favorites.includes(id),
[favorites],
);

return { favorites, isFavorite, toggleFavorite };
}
Empty file removed src/models/.gitkeep
Empty file.
68 changes: 68 additions & 0 deletions src/models/favorites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
isFavorite,
loadFavorites,
parseFavorites,
saveFavorites,
toggleFavorite,
} from "./favorites";

describe("Favorites Model", () => {
beforeEach(() => {
window.localStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
});

test("returns empty list when nothing is stored", () => {
expect(parseFavorites(null)).toEqual([]);
expect(loadFavorites()).toEqual([]);
});

test("returns empty list for invalid data", () => {
expect(parseFavorites("{not json")).toEqual([]);
expect(parseFavorites("42")).toEqual([]);
expect(parseFavorites('{"id":1}')).toEqual([]);
});

test("removes invalid ids and duplicates", () => {
const raw = JSON.stringify([1, "4", -2, 25, 1, 1.5, null]);

expect(parseFavorites(raw)).toEqual([1, 25]);
});

test("saves and loads favorites from localStorage", () => {
saveFavorites([1, 4, 25]);

expect(window.localStorage.getItem("favorites")).toBe("[1,4,25]");
expect(loadFavorites()).toEqual([1, 4, 25]);
});

test("does not crash when storage is unavailable", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});

expect(() => saveFavorites([1])).not.toThrow();
expect(loadFavorites()).toEqual([]);
});

test("toggleFavorite adds and removes an id without mutating", () => {
const favorites = [1, 4];

expect(toggleFavorite(favorites, 25)).toEqual([1, 4, 25]);
expect(toggleFavorite(favorites, 4)).toEqual([1]);
expect(favorites).toEqual([1, 4]);
});

test("isFavorite checks if an id is in the list", () => {
expect(isFavorite([1, 4], 4)).toBe(true);
expect(isFavorite([1, 4], 25)).toBe(false);
});
});
51 changes: 51 additions & 0 deletions src/models/favorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const STORAGE_KEY = "favorites";

// Only keeps valid pokemon IDs and removes duplicates
export function parseFavorites(raw: string | null): number[] {
if (!raw) {
return [];
}

let data: unknown;
try {
data = JSON.parse(raw);
} catch {
return [];
}

if (!Array.isArray(data)) {
return [];
}

const ids = data.filter((id): id is number => Number.isInteger(id) && id > 0);

return [...new Set(ids)];
}

export function loadFavorites(): number[] {
try {
return parseFavorites(window.localStorage.getItem(STORAGE_KEY));
} catch (error) {
console.error("Error reading localStorage", error);
return [];
}
}

export function saveFavorites(favorites: number[]) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites));
} catch (error) {
console.error("Error writing localStorage", error);
}
}

export function isFavorite(favorites: number[], id: number) {
return favorites.includes(id);
}

export function toggleFavorite(favorites: number[], id: number): number[] {
if (isFavorite(favorites, id)) {
return favorites.filter((favoriteId) => favoriteId !== id);
}
return [...favorites, id];
}