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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
▼
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 25 additions & 3 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
<MockedProvider mocks={mocks}>
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
</MockedProvider>,
)

test('renders the home page heading', () => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/RecipeCard.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/RecipeFilter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@ 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('')
})

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()
})
31 changes: 16 additions & 15 deletions frontend/src/components/RecipeFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,22 @@ export default function RecipeFilter() {
onChange={(event) => setParam('category', event.target.value)}
>
<option value="">All categories</option>
<option value="quick & easy">Quick & Easy</option>
{/* Common bases for a dish. Values match dataset category names
case-insensitively (see filterRecipes / buildRecipeQuery). */}
<option value="beef">Beef</option>
<option value="pork">Pork</option>
<option value="lamb">Lamb</option>
<option value="chicken">Chicken</option>
<option value="fish">Fish</option>
<option value="shrimp">Shrimp</option>
<option value="pasta">Pasta</option>
<option value="rice">Rice</option>
<option value="vegetarian">Vegetarian</option>
<option value="soup">Soup</option>
<option value="salad">Salad</option>
<option value="dessert">Dessert</option>
<option value="Quick & Easy">Quick & Easy</option>
{/* 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. */}
<option value="Beef">Beef</option>
<option value="Pork">Pork</option>
<option value="Lamb">Lamb</option>
<option value="Chicken">Chicken</option>
<option value="Fish">Fish</option>
<option value="Shrimp">Shrimp</option>
<option value="Pasta">Pasta</option>
<option value="Rice">Rice</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Soup/Stew">Soup</option>
<option value="Salad">Salad</option>
<option value="Dessert">Dessert</option>
</select>

<label htmlFor="rating" className="visually-hidden">
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/graphql/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'

// Relative URL: Apache proxies it to the backend in production
// (deploy/apache/project2.conf), and Vite's dev server does the same
// (vite.config.ts), so this works unchanged in both. VITE_GRAPHQL_URL
// overrides it, matching IngredientSearch.tsx's raw-fetch requests.
export const client = new ApolloClient({
link: new HttpLink({
uri:
import.meta.env.VITE_GRAPHQL_URL ?? `${import.meta.env.BASE_URL}graphql`,
}),
cache: new InMemoryCache(),
})
94 changes: 94 additions & 0 deletions frontend/src/graphql/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { gql } from '@apollo/client'
import type { Recipe } from '../types/recipe'

// Mirrors backend/src/schema.ts. Field selections only request what each
// view actually renders, per CLAUDE.md's sustainability guidance.

export const RECIPES_QUERY = gql`
query Recipes(
$search: String
$filter: RecipeFilter
$sort: RecipeSort
$limit: Int
$offset: Int
) {
recipes(
search: $search
filter: $filter
sort: $sort
limit: $limit
offset: $offset
) {
totalCount
hasMore
items {
id
title
description
ingredients
categories
rating
calories
image
}
}
}
`

// Only the fields each query above actually selects, not the full Recipe type.
export type RecipeListItem = Pick<
Recipe,
| 'id'
| 'title'
| 'description'
| 'ingredients'
| 'categories'
| 'rating'
| 'calories'
| 'image'
>

export type RecipeSort = 'RELEVANCE' | 'RATING' | 'CALORIES' | 'TITLE'

export type RecipesQueryVariables = {
search?: string
filter?: {
categories?: string[]
maxCalories?: number
minRating?: number
}
sort?: RecipeSort
limit?: number
offset?: number
}

export type RecipesQueryResult = {
recipes: {
totalCount: number
hasMore: boolean
items: RecipeListItem[]
}
}

export const RECIPE_QUERY = gql`
query Recipe($id: Int!) {
recipe(id: $id) {
id
title
description
ingredients
directions
categories
rating
calories
protein
fat
sodium
carbs
image
}
}
`

export type RecipeQueryVariables = { id: number }
export type RecipeQueryResult = { recipe: Recipe | null }
10 changes: 7 additions & 3 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ApolloProvider } from '@apollo/client/react'
import { BrowserRouter } from 'react-router'
import './styles/index.css'
import App from './App.tsx'
import { client } from './graphql/client.ts'

// The router lives here, not in App, so tests can render App inside a
// MemoryRouter without the /project2 basename (jsdom starts at "/").
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/project2">
<App />
</BrowserRouter>
<ApolloProvider client={client}>
<BrowserRouter basename="/project2">
<App />
</BrowserRouter>
</ApolloProvider>
</StrictMode>,
)
Loading