From ea54b7931ba2b783382da685d456f15998f4b033 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Tue, 10 Nov 2026 13:12:13 +0100 Subject: [PATCH 1/2] Moved favorites from localstorage to database --- .../backend/src/graphql/resolvers.ts | 17 +++++ T04-Project-2/backend/src/graphql/schema.ts | 2 + T04-Project-2/frontend/src/api/favorites.ts | 42 ++++++++++ .../src/components/FavoritesContext.ts | 3 + .../frontend/src/components/FavoritesPage.tsx | 5 +- .../src/components/FavoritesProvider.tsx | 76 +++++++------------ .../src/components/FeaturedCarousel.tsx | 3 +- .../frontend/src/components/GameCard.tsx | 3 +- .../src/components/GameDetailPage.tsx | 3 +- .../tests/components/FavoritesApi.test.tsx | 28 +++++++ .../tests/components/GameCard.test.tsx | 5 +- .../tests/components/SearchResults.test.tsx | 3 +- .../frontend/tests/integration/api.spec.ts | 21 +++++ .../tests/integration/journey.spec.ts | 58 ++++++++++++++ 14 files changed, 212 insertions(+), 57 deletions(-) create mode 100644 T04-Project-2/frontend/src/api/favorites.ts create mode 100644 T04-Project-2/frontend/tests/components/FavoritesApi.test.tsx diff --git a/T04-Project-2/backend/src/graphql/resolvers.ts b/T04-Project-2/backend/src/graphql/resolvers.ts index dc577e1..44506dc 100644 --- a/T04-Project-2/backend/src/graphql/resolvers.ts +++ b/T04-Project-2/backend/src/graphql/resolvers.ts @@ -68,6 +68,12 @@ export const resolvers = { favorites: async () => { return db.select().from(favorites); }, + myFavorites: async (_parent: unknown, _args: unknown, context: { userId?: number }) => { + if (!context.userId) throw new Error("Authentication required"); + const rows = await db.select({ gameId: favorites.game_id }).from(favorites) + .where(eq(favorites.user_id, context.userId)).orderBy(asc(favorites.game_id)); + return rows.map((row) => row.gameId); + }, reviewCount: async (_parent: unknown, { gameId }: { gameId: string }) => { const parsedGameId = Number(gameId); if (!Number.isSafeInteger(parsedGameId) || parsedGameId <= 0) { @@ -283,6 +289,17 @@ export const resolvers = { return user ?? null; }, + setFavorite: async (_parent: unknown, { gameId, favorite }: { gameId: string; favorite: boolean }, context: { userId?: number }) => { + if (!context.userId) throw new Error("Authentication required"); + const id = Number(gameId); + if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`Invalid game ID: ${gameId}`); + if (favorite) { + await db.insert(favorites).values({ user_id: context.userId, game_id: id }).onConflictDoNothing(); + } else { + await db.delete(favorites).where(and(eq(favorites.user_id, context.userId), eq(favorites.game_id, id))); + } + return true; + }, createReview: async ( _parent: unknown, { gameId, description }: { gameId: string; description: string }, diff --git a/T04-Project-2/backend/src/graphql/schema.ts b/T04-Project-2/backend/src/graphql/schema.ts index 49a35b0..b89e04c 100644 --- a/T04-Project-2/backend/src/graphql/schema.ts +++ b/T04-Project-2/backend/src/graphql/schema.ts @@ -92,6 +92,7 @@ export const typeDefs = /* GraphQL */ ` users: [User!]! reviews: [Review!]! favorites: [Favorite!]! + myFavorites: [ID!]! reviewCount(gameId: ID!): Int! favoriteCount(gameId: ID!): Int! searchGames( @@ -110,6 +111,7 @@ export const typeDefs = /* GraphQL */ ` type Mutation { createUser(username: String!, password: String!): User login(username: String!, password: String!): User + setFavorite(gameId: ID!, favorite: Boolean!): Boolean! createReview(gameId: ID!, description: String!): Review deleteReview(gameId: ID!): Boolean! } diff --git a/T04-Project-2/frontend/src/api/favorites.ts b/T04-Project-2/frontend/src/api/favorites.ts new file mode 100644 index 0000000..8c3a135 --- /dev/null +++ b/T04-Project-2/frontend/src/api/favorites.ts @@ -0,0 +1,42 @@ +async function favoriteRequest(userId: string, query: string, variables: Record = {}): Promise { + const response = await fetch(import.meta.env.VITE_API_URL, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user-id': userId }, + body: JSON.stringify({ query, variables }), + }) + if (!response.ok) throw new Error(`HTTP error: ${response.status}`) + const result = await response.json() + if (result.errors?.length) throw new Error(result.errors[0].message) + if (!result.data) throw new Error('The server returned no favorites result.') + return result.data as T +} + +export async function fetchFavoriteIds(userId: string): Promise { + const data = await favoriteRequest<{ myFavorites: string[] }>(userId, 'query MyFavorites { myFavorites }') + const ids = data.myFavorites.map(String) + // Transfer the old browser-only favorites once, keeping them if any request fails. + const legacyKey = `gaminggoat:favorites:${userId}` + let legacy: string | null + try { + legacy = window.localStorage.getItem(legacyKey) + } catch { + return ids + } + if (legacy === null) return ids + const parsed: unknown = JSON.parse(legacy) + if (!Array.isArray(parsed) || !parsed.every((id) => typeof id === 'string' && /^[1-9]\d*$/.test(id))) { + throw new Error('Saved browser favorites have an invalid format.') + } + const missing = [...new Set(parsed as string[])].filter((id) => !ids.includes(id)) + for (const id of missing) await setFavorite(userId, id, true) + try { + if (window.localStorage.getItem(legacyKey) === legacy) window.localStorage.removeItem(legacyKey) + } catch { + // The database remains the source of truth even if browser storage is unavailable. + } + return [...ids, ...missing] +} + +export async function setFavorite(userId: string, gameId: string, favorite: boolean): Promise { + await favoriteRequest(userId, 'mutation SetFavorite($gameId: ID!, $favorite: Boolean!) { setFavorite(gameId: $gameId, favorite: $favorite) }', { gameId, favorite }) +} diff --git a/T04-Project-2/frontend/src/components/FavoritesContext.ts b/T04-Project-2/frontend/src/components/FavoritesContext.ts index 1859133..9d45718 100644 --- a/T04-Project-2/frontend/src/components/FavoritesContext.ts +++ b/T04-Project-2/frontend/src/components/FavoritesContext.ts @@ -9,6 +9,9 @@ export type FavoritesContextValue = { userId: string | null; favoriteIds: string[]; storageError: string | null; + isLoading: boolean; + isUpdating: boolean; + isUnavailable: boolean; toggleFavorite: (gameId: string) => void; } diff --git a/T04-Project-2/frontend/src/components/FavoritesPage.tsx b/T04-Project-2/frontend/src/components/FavoritesPage.tsx index 30be94c..608b92c 100644 --- a/T04-Project-2/frontend/src/components/FavoritesPage.tsx +++ b/T04-Project-2/frontend/src/components/FavoritesPage.tsx @@ -7,7 +7,7 @@ import { useFavorites } from './useFavorites' import { GameCard } from './GameCard' function FavoritesPage() { - const { userId, favoriteIds, storageError } = useFavorites(); + const { userId, favoriteIds, storageError, isLoading } = useFavorites(); const favoritesQuery = useQuery({ ...gameQueryOptions.favoriteByIds(favoriteIds), enabled: Boolean(userId && favoriteIds.length), @@ -21,7 +21,6 @@ function FavoritesPage() {

{userId ? 'Games you have saved to your collection.' : 'Log in to save and view your favorite games.'}

- {storageError &&

Could not access saved favorites: {storageError}

} {!userId ? (
@@ -29,7 +28,7 @@ function FavoritesPage() {

Your saved games are associated with your account.

Log in
- ) : favoritesQuery.isPending && favoriteIds.length > 0 ? ( + ) : storageError ? null : isLoading || (favoritesQuery.isPending && favoriteIds.length > 0) ? (

Loading your favorites…

) : favoritesQuery.error ? (

Could not load your favorite games: {favoritesQuery.error.message}

diff --git a/T04-Project-2/frontend/src/components/FavoritesProvider.tsx b/T04-Project-2/frontend/src/components/FavoritesProvider.tsx index 5712597..904d2ab 100644 --- a/T04-Project-2/frontend/src/components/FavoritesProvider.tsx +++ b/T04-Project-2/frontend/src/components/FavoritesProvider.tsx @@ -1,55 +1,35 @@ -import { useState, type ReactNode } from 'react' -import { FavoritesContext, type FavoritesState } from './FavoritesContext' - -function readFavorites(userId: string | null): FavoritesState { - if (!userId) return { ids: [], error: null }; - - try { - const stored = window.localStorage.getItem(`gaminggoat:favorites:${userId}`); - if (!stored) return { ids: [], error: null }; - - const ids: unknown = JSON.parse(stored); - if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string' && /^\d+$/.test(id))) { - throw new Error('Saved favorites have an invalid format.'); - } - - return { ids, error: null }; - } catch (error) { - return { - ids: [], - error: error instanceof Error ? error.message : 'Could not read saved favorites.', - }; - } -} +import type { ReactNode } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchFavoriteIds, setFavorite } from '../api/favorites' +import { FavoritesContext } from './FavoritesContext' export function FavoritesProvider({ userId, children }: { userId: string | null; children: ReactNode }) { - const [state, setState] = useState(() => readFavorites(userId)); - + const client = useQueryClient() + const key = ['favorites', 'by-user', userId] as const + const favorites = useQuery({ + queryKey: key, + queryFn: () => fetchFavoriteIds(userId!), + enabled: Boolean(userId), + }) + const mutation = useMutation({ + mutationFn: ({ gameId, favorite }: { gameId: string; favorite: boolean }) => setFavorite(userId!, gameId, favorite), + onSuccess: async (_result, { gameId }) => { + await Promise.all([ + client.invalidateQueries({ queryKey: key }), + client.invalidateQueries({ queryKey: ['favorites', 'count-by-game-id', gameId] }), + ]) + }, + }) + const favoriteIds = userId ? favorites.data ?? [] : [] + const isLoading = Boolean(userId && favorites.isPending) const toggleFavorite = (gameId: string) => { - if (!userId || !/^\d+$/.test(gameId)) return; - - const ids = state.ids.includes(gameId) - ? state.ids.filter((id) => id !== gameId) - : [...state.ids, gameId]; - - try { - window.localStorage.setItem(`gaminggoat:favorites:${userId}`, JSON.stringify(ids)); - setState({ ids, error: null }); - } catch (error) { - setState({ - ...state, - error: error instanceof Error ? error.message : 'Could not save favorites.', - }); - } - }; - + if (!userId || !/^\d+$/.test(gameId) || isLoading || favorites.error || mutation.isPending) return + mutation.mutate({ gameId, favorite: !favoriteIds.includes(gameId) }) + } + const error = favorites.error ?? mutation.error return ( - + + {error &&

Could not update or load favorites: {error.message}

} {children}
) diff --git a/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx b/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx index 700eaae..137e8df 100644 --- a/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx +++ b/T04-Project-2/frontend/src/components/FeaturedCarousel.tsx @@ -35,7 +35,7 @@ function getDescriptionText(description: string | null): string { export const FeaturedCarousel = ({gameIds}: FeaturedCarouselProps) => { const { data: games = [], isPending, error } = useQuery(gameQueryOptions.featuredByIds(gameIds)); - const { userId, favoriteIds, toggleFavorite } = useFavorites(); + const { userId, favoriteIds, toggleFavorite, isLoading, isUpdating, isUnavailable } = useFavorites(); const [carouselIndex, setCarouselIndex] = useState(0); const [previousIndex, setPreviousIndex] = useState(null); const [direction, setDirection] = useState(null); @@ -137,6 +137,7 @@ export const FeaturedCarousel = ({gameIds}: FeaturedCarouselProps) => { type="button" aria-label={`${isFavorite ? 'Remove' : 'Add'} ${currentGameData.name} ${isFavorite ? 'from' : 'to'} favorites`} aria-pressed={isFavorite} + disabled={isLoading || isUpdating || isUnavailable} onClick={() => toggleFavorite(currentGameData.id)} >