diff --git a/CLAUDE.md b/CLAUDE.md index c630133..66b12d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,10 +36,14 @@ Chosen: - Client state: Zustand with persist middleware for the fridge (`localStorage`) - Dataset / domain: recipes, built from two Kaggle datasets (Epicurious ratings + a recipe/image dataset for photos), see README.md "Data" +- GraphQL client: Apollo Client. Its normalized cache also satisfies the assignment's + state-management requirement for server data; Zustand (above) covers local UI state Still open (record the decision here and in README.md when made): -- GraphQL client, E2E tool, UI/component lib: TBD +- E2E tool, UI/component lib: TBD +- Server-side filtering for "My Fridge" ingredients (tracked in a follow-up issue); for now + it only matches within the page already loaded from the server, not the whole dataset ## Repo layout diff --git a/README.md b/README.md index 95db018..990e4de 100644 --- a/README.md +++ b/README.md @@ -13,23 +13,25 @@ Hosted on the group VM: http://it2810-13.idi.ntnu.no/project2/ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Home** (`/`) | A search field (debounced by 300 ms, or press Enter to search right away), filters for category and minimum rating, and the results as a grid of recipe cards. The result count is read out to screen readers when the filters change. | | **Recipe detail** (`/recipes/:id`) | Photo, rating, categories, nutrition per serving, ingredients and directions. Each recipe gets its own browser tab title. | -| **My Fridge** (`/fridge`) | Add the ingredients you have, then "Find recipes" shows the recipes that use all of them. | +| **My Fridge** (`/fridge`) | Add the ingredients you have (typo-tolerant autocomplete), then "Find recipes" shows recipes that use any of them; extra ingredients in the fridge don't exclude a match. | | **404** | Shown for any unknown route. | Search terms and filters are kept in the URL (`?q=&category=&rating=&ingredients=`), so the Back button and shared links bring you back to the same results. **Current status:** the backend (GraphQL API over PostgreSQL with full-text -search, filtering, sorting and paging) is working. The frontend still uses -mock data (`frontend/src/mocks/`) and filters it in the browser -(`frontend/src/utils/recipeFilters.ts`). Next step is to connect it to the -API, so that search, filtering and sorting run on the server over the whole -dataset. User-generated data (such as reviews or ratings) is not built yet. +search, filtering, sorting and paging) is running on the VM, and the +frontend is connected to it with Apollo Client (`frontend/src/graphql/`) - +search, category/rating filtering, sorting and "Load more" paging all run +on the server over the whole dataset. The exception is "My Fridge": the +backend has no ingredient filter yet, so it only matches within the page +already loaded (tracked as a follow-up issue). User-generated data (such as +reviews or ratings) is not built yet. ## Architecture ``` -Browser ── React + TypeScript (Vite), React Router +Browser ── React + TypeScript (Vite), React Router, Apollo Client │ served by Apache at /project2 on the VM │ GraphQL ▼ @@ -41,9 +43,10 @@ PostgreSQL 17: recipes table, tsvector full-text search, GIN/B-tree indexes - **Frontend** (`frontend/src/`): `pages/` holds one component per route, `components/` holds shared parts (Navbar, Footer, RecipeCard, - RecipeFilter), `styles/` has one CSS file per component, and `utils/` has - plain logic such as reading filters from the URL. Tests (`*.test.tsx`) sit - next to the code they test. + RecipeFilter), `styles/` has one CSS file per component, `graphql/` has + the Apollo Client instance and queries, and `utils/` has plain logic such + as reading filters from the URL. Tests (`*.test.tsx`) sit next to the + code they test. - **Backend** (`backend/src/`): `schema.ts` holds the GraphQL types and resolvers and builds parameterised SQL in `buildRecipeQuery`. `server.test.ts` tests the API against a stub database. diff --git a/frontend/package.json b/frontend/package.json index 2977531..447ec29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "typecheck": "tsc -b" }, "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router": "^8.4.0", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 01e6a8f..c41626b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,13 +1,35 @@ import { render, screen } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing/react' import { MemoryRouter } from 'react-router' import { expect, test } from 'vitest' import App from './App' +import { RECIPES_QUERY } from './graphql/queries' + +const mocks = [ + { + request: { + query: RECIPES_QUERY, + variables: { + search: undefined, + filter: { categories: undefined, minRating: undefined }, + sort: undefined, + limit: 20, + offset: 0, + }, + }, + result: { + data: { recipes: { totalCount: 0, hasMore: false, items: [] } }, + }, + }, +] const renderAt = (path: string) => render( - - - , + + + + + , ) test('renders the home page heading', () => { diff --git a/frontend/src/components/RecipeCard.tsx b/frontend/src/components/RecipeCard.tsx index 3669afb..92eb6e5 100644 --- a/frontend/src/components/RecipeCard.tsx +++ b/frontend/src/components/RecipeCard.tsx @@ -1,9 +1,9 @@ import { Link } from 'react-router' -import type { Recipe } from '../types/recipe' +import type { RecipeListItem } from '../graphql/queries' import '../styles/RecipeCard.css' type RecipeCardProps = { - recipe: Recipe + recipe: RecipeListItem } export default function RecipeCard({ recipe }: RecipeCardProps) { diff --git a/frontend/src/components/RecipeFilter.test.tsx b/frontend/src/components/RecipeFilter.test.tsx index 50c9b06..6ecb41a 100644 --- a/frontend/src/components/RecipeFilter.test.tsx +++ b/frontend/src/components/RecipeFilter.test.tsx @@ -40,8 +40,8 @@ test('Enter searches immediately', () => { test('selects update the URL and can be cleared', () => { const category = screen.getByLabelText('Category') - fireEvent.change(category, { target: { value: 'vegetarian' } }) - expect(url()).toBe('?category=vegetarian') + fireEvent.change(category, { target: { value: 'Vegetarian' } }) + expect(url()).toBe('?category=Vegetarian') fireEvent.change(category, { target: { value: '' } }) expect(url()).toBe('') @@ -49,7 +49,7 @@ test('selects update the URL and can be cleared', () => { test('offers beef and other common bases as category filters', () => { const category = screen.getByLabelText('Category') - fireEvent.change(category, { target: { value: 'beef' } }) - expect(url()).toBe('?category=beef') + fireEvent.change(category, { target: { value: 'Beef' } }) + expect(url()).toBe('?category=Beef') expect(screen.getByRole('option', { name: 'Lamb' })).toBeInTheDocument() }) diff --git a/frontend/src/components/RecipeFilter.tsx b/frontend/src/components/RecipeFilter.tsx index 7e0e894..5c58742 100644 --- a/frontend/src/components/RecipeFilter.tsx +++ b/frontend/src/components/RecipeFilter.tsx @@ -69,21 +69,22 @@ export default function RecipeFilter() { onChange={(event) => setParam('category', event.target.value)} > - - {/* Common bases for a dish. Values match dataset category names - case-insensitively (see filterRecipes / buildRecipeQuery). */} - - - - - - - - - - - - + + {/* Common bases for a dish. Values must match the dataset's + category strings exactly: the server does an array-contains + match (buildRecipeQuery), not a case-insensitive search. */} + + + + + + + + + + + +