From 5621f9bc01637b1db24801c37ecef106d6db1cb2 Mon Sep 17 00:00:00 2001 From: Jonas Hella Date: Thu, 15 Oct 2026 11:52:29 +0200 Subject: [PATCH 1/2] feat(fridge): add typo-tolerant ingredient autocomplete Add indexed PostgreSQL search with fuzzy matching and ingredient aliases. Support debounced suggestions, caching, keyboard navigation and error feedback. Test matching, index usage and catalog refresh with 50,000 synthetic entries. Closes #55 --- CLAUDE.md | 11 + README.md | 46 ++++ backend/db/ingredients.sql | 21 ++ backend/package.json | 2 + backend/scripts/import.ts | 4 + backend/scripts/ingredient-catalog.ts | 87 ++++++++ backend/scripts/refresh-ingredients.ts | 18 ++ backend/src/ingredients.integration.test.ts | 102 +++++++++ backend/src/ingredients.test.ts | 111 ++++++++++ backend/src/ingredients.ts | 36 +++ backend/src/schema.ts | 17 ++ deploy/apache/project2.conf | 3 + .../src/components/IngredientSearch.test.tsx | 155 +++++++++++++ frontend/src/components/IngredientSearch.tsx | 208 ++++++++++++++++++ frontend/src/pages/FridgePage.test.tsx | 60 +++++ frontend/src/pages/FridgePage.tsx | 37 +--- frontend/src/styles/FridgePage.css | 52 +++++ frontend/vite.config.ts | 8 + package-lock.json | 8 + package.json | 1 + 20 files changed, 958 insertions(+), 29 deletions(-) create mode 100644 backend/db/ingredients.sql create mode 100644 backend/scripts/ingredient-catalog.ts create mode 100644 backend/scripts/refresh-ingredients.ts create mode 100644 backend/src/ingredients.integration.test.ts create mode 100644 backend/src/ingredients.test.ts create mode 100644 backend/src/ingredients.ts create mode 100644 frontend/src/components/IngredientSearch.test.tsx create mode 100644 frontend/src/components/IngredientSearch.tsx create mode 100644 frontend/src/pages/FridgePage.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index acf9567..2eefabd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,10 @@ backend/ Apollo GraphQL server src/index.ts starts the server on PORT (default 3001) scripts/import.ts downloads/extracts datasets, loads Postgres, converts images scripts/transform.ts pure functions that build the catalog from raw dataset rows + scripts/ingredient-catalog.ts derives ingredient names/aliases from recipe lines + scripts/refresh-ingredients.ts rebuilds the ingredient catalog without reimporting recipes + src/ingredients.ts bounded prefix/fuzzy autocomplete query (pg_trgm indexes) + db/ingredients.sql ingredient catalog and search indexes scripts/debug-ui.ts local debug page (port 3002): raw SQL, timings, query plans. Not part of the app db/schema.sql recipes table + indexes, applied by import.ts docs/ assignment text @@ -65,6 +69,7 @@ Commands (run from the root): | -------------------------------------- | ----------------------------------------------------------- | | `npm run dev:frontend` / `dev:backend` | Dev servers (Vite / node --watch) | | `npm run import` | Load the recipe dataset into Postgres, see README.md "Data" | +| `npm run ingredients:refresh` | Build ingredient autocomplete from existing recipes | | `npm run debug` | Debug page on http://localhost:3002 (needs imported data) | | `npm test` | Vitest in both workspaces | | `npm run lint` | oxlint | @@ -75,6 +80,12 @@ Commands (run from the root): No CI (git.ntnu.no has no Actions runners): run `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm test` before opening a PR. Add dependencies to a workspace: `npm i -w frontend` (or `-w backend`). +My Fridge autocomplete talks to GraphQL at `/project2/graphql` (Vite/Apache +proxy to port 3001; optional `VITE_GRAPHQL_URL` override). It uses 300 ms +debouncing, cancellation and a bounded query cache. Catalog names and aliases +are rebuilt during import or via `ingredients:refresh`; `pg_trgm` is required. +Recipe browsing still uses mock data and fridge selections are not persisted. + ## How we work (GitHub flow, `main` is protected) 1. **Every change starts from an issue.** No issue → create one first (`/new-issue`). diff --git a/README.md b/README.md index 428ee18..314e264 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,52 @@ queries in Apollo Sandbox, e.g.: The full API (`recipes` with search, filter, sort and paging, `recipe(id)`, `categories`) is in `backend/src/schema.ts`. +### Ingredient autocomplete (My Fridge) + +My Fridge queries `ingredients(search: String!, limit: Int = 8)` over GraphQL. +It waits 300 ms after typing stops, starts at two characters, cancels obsolete +requests, and caches up to 30 searches. Suggestions support mouse selection, +arrow keys, Enter and Escape. Manual entry remains available if the API is down +or an ingredient is missing; the fridge list is still local component state. + +The ingredient catalog is derived from the recipe ingredient lines during +`npm run import`. For an **existing recipe database**, run this once instead +of importing the datasets again: + +```bash +npm run ingredients:refresh +``` + +This rebuilds only the ingredient catalog in a transaction and leaves recipes +untouched. It requires PostgreSQL's `pg_trgm` extension (on Ubuntu, install +`postgresql-contrib` if unavailable). The database role needs permission to +create this trusted extension, or an administrator must enable it first with +`CREATE EXTENSION IF NOT EXISTS pg_trgm;` in the app database. + +Names are lowercased and deduplicated after removing common quantities, units +and preparation notes. This is a heuristic for English dataset lines, not a +complete ingredient parser. A small alias map in +`backend/scripts/ingredient-catalog.ts` covers alternate names such as +aubergine/eggplant and scallion/green onion; extend it and refresh the catalog +when adding synonyms. Aliases return the canonical name to the fridge. + +Two-character queries use prefix search; from three characters, fuzzy matches +are included (e.g. `tomatoe` → `tomato`). Exact matches rank before prefixes, +then trigram similarity. Each branch retrieves at most 40 candidates and the +API returns at most 20 suggestions (8 in the UI). B-tree prefix and GiST fuzzy +indexes keep matching on the server; the full catalog is never sent to the +browser. See [PostgreSQL's pg_trgm documentation](https://www.postgresql.org/docs/17/pgtrgm.html). + +Vite proxies `/project2/graphql` to the local backend on port 3001. Production +uses the same path through Apache (see below). To target another endpoint, +set `VITE_GRAPHQL_URL` in `frontend/.env.local` before starting/building Vite. + Checks: `npm test`, `npm run lint`, `npm run format:check`, `npm run typecheck`. +Ingredient API/SQL tests include an in-process PostgreSQL engine (PGlite, +development dependency only) with a 50,000-name fixture, typo/alias matching, +index-plan checks and repeatable catalog refresh. No running DB is needed for +these tests. Frontend tests cover debounce/cancellation, API failures, keyboard +selection and adding/removing ingredients through the fridge page. ## Team workflow @@ -128,6 +173,7 @@ The frontend is served by Apache on the VM from `/var/www/html/project2` ```bash sudo cp deploy/apache/project2.conf /etc/apache2/conf-available/ + sudo a2enmod proxy proxy_http sudo a2enconf project2 sudo apache2ctl configtest sudo systemctl reload apache2 diff --git a/backend/db/ingredients.sql b/backend/db/ingredients.sql new file mode 100644 index 0000000..60190e3 --- /dev/null +++ b/backend/db/ingredients.sql @@ -0,0 +1,21 @@ +-- pg_trgm supplies typo matching and GiST nearest-neighbour search. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE IF NOT EXISTS ingredients ( + name text PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS ingredient_terms ( + term text PRIMARY KEY, + ingredient_name text NOT NULL REFERENCES ingredients(name) ON DELETE CASCADE +); + +-- Short prefixes use B-tree; fuzzy searches use GiST and a bounded candidate set. +CREATE INDEX IF NOT EXISTS ingredient_terms_prefix_idx + ON ingredient_terms (term COLLATE "C"); +CREATE INDEX IF NOT EXISTS ingredient_terms_fuzzy_idx + ON ingredient_terms USING gist (term gist_trgm_ops); + +-- Avoid a full alias-table scan per ingredient during catalog replacement. +CREATE INDEX IF NOT EXISTS ingredient_terms_ingredient_idx + ON ingredient_terms (ingredient_name); diff --git a/backend/package.json b/backend/package.json index 00f50d5..ac09cf8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "dev": "node --watch --env-file-if-exists=.env src/index.ts", "start": "node --env-file-if-exists=.env src/index.ts", "import": "node --env-file-if-exists=.env scripts/import.ts", + "ingredients:refresh": "node --env-file-if-exists=.env scripts/refresh-ingredients.ts", "debug": "node --env-file-if-exists=.env scripts/debug-ui.ts", "typecheck": "tsc", "test": "vitest run", @@ -20,6 +21,7 @@ "sharp": "^0.35.4" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.10", "@types/node": "^24.13.6", "@types/pg": "^8.23.1", "typescript": "^6.0.3", diff --git a/backend/scripts/import.ts b/backend/scripts/import.ts index 0b23e1b..242671c 100644 --- a/backend/scripts/import.ts +++ b/backend/scripts/import.ts @@ -17,6 +17,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { buildCatalog } from './transform.ts' import type { EpiRaw, ImageRaw } from './transform.ts' +import { refreshIngredients } from './ingredient-catalog.ts' const startedAt = Date.now() @@ -223,6 +224,9 @@ try { console.log('analyzing table') await client.query('ANALYZE recipes') + console.log('building ingredient search catalog') + await refreshIngredients(client) + await client.query('COMMIT') console.log('committed') } catch (err) { diff --git a/backend/scripts/ingredient-catalog.ts b/backend/scripts/ingredient-catalog.ts new file mode 100644 index 0000000..c222e2c --- /dev/null +++ b/backend/scripts/ingredient-catalog.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs/promises' +import type { Client } from 'pg' + +// These aliases supplement the imported vocabulary; they are not the catalog. +const synonyms: Record = { + tomato: ['tomatoes'], + potato: ['potatoes'], + onion: ['onions'], + 'green onion': [ + 'green onions', + 'scallion', + 'scallions', + 'spring onion', + 'spring onions', + ], + eggplant: ['eggplants', 'aubergine', 'aubergines'], + zucchini: ['courgette', 'courgettes'], + cilantro: ['fresh coriander', 'coriander leaves'], + 'bell pepper': ['bell peppers', 'sweet pepper', 'capsicum'], + arugula: ['rocket'], +} + +const canonical = new Map( + Object.entries(synonyms).flatMap(([name, aliases]) => + [name, ...aliases].map((alias) => [alias, name] as const), + ), +) + +// Dataset lines contain amounts and preparation notes. This conservative +// heuristic keeps compound names (e.g. soy sauce); it is not a food ontology. +export function ingredientName(line: string): string { + const name = line + .normalize('NFKC') + .toLowerCase() + .replace(/\([^)]*\)/g, ' ') + .replace(/^[\s\d\u00bc-\u00be\u2044/.,–—-]+/, '') + .replace( + /^(?:(?:cups?|tablespoons?|teaspoons?|tbsps?|tsps?|ounces?|oz|pounds?|lbs?|grams?|kg|g|ml|liters?|litres?|cloves?|cans?|packages?|bunch(?:es)?|pinch|large|medium|small|chopped|diced|sliced|of)\.?\s+)+/, + '', + ) + .split(/[,;]|\s+(?:plus|for serving|for garnish|to taste)\b/)[0] + .replace(/\s+/g, ' ') + .trim() + // Resolve "fresh coriander" before stripping fresh: coriander seeds are a + // different ingredient and must not be treated as cilantro. + const withoutFresh = name.replace(/^fresh\s+/, '') + return canonical.get(name) ?? canonical.get(withoutFresh) ?? withoutFresh +} + +export function buildIngredientCatalog(lines: string[]) { + const names = [...new Set(lines.map(ingredientName))] + .filter( + (name) => name.length >= 2 && name.length <= 100 && /[a-z]/.test(name), + ) + .sort() + return names.flatMap((name) => + [name, ...(synonyms[name] ?? [])].map((term) => ({ name, term })), + ) +} + +// Caller owns the transaction, so readers see either the old or new catalog. +export async function refreshIngredients(client: Client) { + await client.query( + await fs.readFile( + new URL('../db/ingredients.sql', import.meta.url), + 'utf8', + ), + ) + const result = await client.query<{ line: string }>( + 'SELECT DISTINCT unnest(ingredients) AS line FROM recipes', + ) + const terms = buildIngredientCatalog(result.rows.map(({ line }) => line)) + await client.query('DELETE FROM ingredient_terms') + await client.query('DELETE FROM ingredients') + const names = [...new Set(terms.map(({ name }) => name))] + await client.query( + 'INSERT INTO ingredients(name) SELECT unnest($1::text[])', + [names], + ) + await client.query( + `INSERT INTO ingredient_terms(term, ingredient_name) + SELECT * FROM unnest($1::text[], $2::text[])`, + [terms.map(({ term }) => term), terms.map(({ name }) => name)], + ) + await client.query('ANALYZE ingredient_terms') + return names.length +} diff --git a/backend/scripts/refresh-ingredients.ts b/backend/scripts/refresh-ingredients.ts new file mode 100644 index 0000000..33b2bdc --- /dev/null +++ b/backend/scripts/refresh-ingredients.ts @@ -0,0 +1,18 @@ +import { Client } from 'pg' +import { refreshIngredients } from './ingredient-catalog.ts' + +const client = new Client({ + connectionString: process.env.DATABASE_URL ?? 'postgres://localhost/t13', +}) +await client.connect() +try { + await client.query('BEGIN') + const count = await refreshIngredients(client) + await client.query('COMMIT') + console.log(`Ingredient catalog ready: ${count} ingredients`) +} catch (error) { + await client.query('ROLLBACK') + throw error +} finally { + await client.end() +} diff --git a/backend/src/ingredients.integration.test.ts b/backend/src/ingredients.integration.test.ts new file mode 100644 index 0000000..89cb2a4 --- /dev/null +++ b/backend/src/ingredients.integration.test.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises' +import { PGlite } from '@electric-sql/pglite' +import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm' +import { afterAll, beforeAll, expect, test } from 'vitest' +import { buildIngredientQuery } from './ingredients.ts' +import { + buildIngredientCatalog, + refreshIngredients, +} from '../scripts/ingredient-catalog.ts' +import type { Client } from 'pg' + +const db = new PGlite({ extensions: { pg_trgm } }) + +beforeAll(async () => { + await db.exec( + await fs.readFile( + new URL('../db/ingredients.sql', import.meta.url), + 'utf8', + ), + ) + const terms = buildIngredientCatalog([ + '2 tomatoes', + 'tomato paste', + 'garlic', + '1 aubergine', + '2 scallions', + 'soy sauce', + '1 cup fresh coriander', + 'coriander seeds', + ]) + await db.query('INSERT INTO ingredients SELECT DISTINCT unnest($1::text[])', [ + terms.map(({ name }) => name), + ]) + await db.query( + 'INSERT INTO ingredient_terms SELECT * FROM unnest($1::text[], $2::text[])', + [terms.map(({ term }) => term), terms.map(({ name }) => name)], + ) + // A varied vocabulary exercises both indexes beyond a tiny fixture table. + await db.exec(` + INSERT INTO ingredients SELECT 'ingredient ' || md5(i::text) FROM generate_series(1, 50000) i; + INSERT INTO ingredient_terms SELECT name, name FROM ingredients WHERE name LIKE 'ingredient %'; + ANALYZE ingredient_terms; + `) +}, 60000) + +afterAll(async () => { + await db.close() +}) + +async function search(term: string, limit = 8) { + const query = buildIngredientQuery(term, limit)! + return (await db.query<{ name: string }>(query.text, query.values)).rows.map( + ({ name }) => name, + ) +} + +test('real SQL handles prefixes, case, typos, incomplete words and synonyms', async () => { + expect((await search('TO'))[0]).toBe('tomato') + expect((await search('tomatoe'))[0]).toBe('tomato') + expect((await search('garilc'))[0]).toBe('garlic') + expect((await search('auberg'))[0]).toBe('eggplant') + expect((await search('scallion'))[0]).toBe('green onion') + expect((await search('fresh coriander'))[0]).toBe('cilantro') + expect((await search('soy sau'))[0]).toBe('soy sauce') + expect(await search('tomato', 1)).toEqual(['tomato']) + expect(await search('qxzvjk')).toEqual([]) + expect(await search('%_')).toEqual([]) +}) + +test('large-catalog query plans use prefix and nearest-neighbour indexes', async () => { + for (const term of ['to', 'tomatoe']) { + const query = buildIngredientQuery(term)! + const plan = await db.query( + `EXPLAIN (ANALYZE, FORMAT JSON) ${query.text}`, + query.values, + ) + const json = JSON.stringify(plan.rows) + // On C-locale databases the primary key can also serve the prefix range. + expect(json).toContain('Index Scan') + if (term === 'to') expect(json).toContain('Index Cond') + else expect(json).toContain('ingredient_terms_fuzzy_idx') + expect(json).not.toContain('Seq Scan') + expect((await search(term)).length).toBeLessThanOrEqual(8) + } +}) + +test('refresh works on existing recipes, is repeatable and leaves recipes intact', async () => { + await db.exec( + "CREATE TABLE recipes (ingredients text[]); INSERT INTO recipes VALUES (ARRAY['2 tomatoes', '1 aubergine']); BEGIN;", + ) + // node-postgres accepts schema batches in query(); PGlite uses exec(). + const client = { + query: (sql: string, params?: unknown[]) => + !params && sql.includes(';') ? db.exec(sql) : db.query(sql, params), + } as unknown as Client + await refreshIngredients(client) + await refreshIngredients(client) + expect(await search('tomatoe')).toEqual(['tomato']) + expect(await search('aubergine')).toEqual(['eggplant']) + expect((await db.query('SELECT * FROM recipes')).rows).toHaveLength(1) + await db.exec('ROLLBACK') +}, 30000) diff --git a/backend/src/ingredients.test.ts b/backend/src/ingredients.test.ts new file mode 100644 index 0000000..3ecddea --- /dev/null +++ b/backend/src/ingredients.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test, vi } from 'vitest' +import { createServer } from './server.ts' +import { buildIngredientQuery } from './ingredients.ts' +import { + buildIngredientCatalog, + ingredientName, +} from '../scripts/ingredient-catalog.ts' + +describe('ingredient catalog', () => { + test('removes amounts and preparation while keeping compound names', () => { + expect(ingredientName('2 tablespoons soy sauce, divided')).toBe('soy sauce') + expect(ingredientName('1 (14-ounce) can tomatoes')).toBe('tomato') + expect(ingredientName('½ cup fresh coriander, chopped')).toBe('cilantro') + expect(ingredientName('2 large aubergines')).toBe('eggplant') + }) + + test('deduplicates names and maps aliases to one canonical ingredient', () => { + const terms = buildIngredientCatalog([ + 'Tomatoes', + 'tomato', + '2 tomatoes', + '1 aubergine', + '', + '123', + ]) + expect( + terms.filter(({ name, term }) => name === 'tomato' && term === 'tomato'), + ).toHaveLength(1) + expect(terms).toContainEqual({ name: 'eggplant', term: 'aubergine' }) + expect(new Set(terms.map(({ name }) => name))).toEqual( + new Set(['tomato', 'eggplant']), + ) + }) +}) + +describe('ingredient API', () => { + test('normalizes input, bounds results and escapes literal prefixes', () => { + expect(buildIngredientQuery(' TOMATOE ', 999)?.values).toEqual([ + 'tomatoe', + 'tomatoe%', + 20, + true, + ]) + expect(buildIngredientQuery('SOY SAUCE', -1)?.values).toEqual([ + 'soy sauce', + 'soy sauce%', + 1, + true, + ]) + expect(buildIngredientQuery('a_%')?.values[1]).toBe('a\\_\\%%') + expect(buildIngredientQuery('to')?.values[3]).toBe(false) + expect(buildIngredientQuery(' ')).toBeNull() + expect(buildIngredientQuery('t')).toBeNull() + expect(() => buildIngredientQuery('a'.repeat(101))).toThrow( + '100 characters', + ) + }) + + test('returns bounded suggestions through GraphQL and never interpolates user input', async () => { + const query = vi.fn().mockResolvedValue({ rows: [{ name: 'tomato' }] }) + const server = createServer() + const result = await server.executeOperation( + { + query: + 'query($q: String!) { ingredients(search: $q, limit: 8) { name } }', + variables: { q: "tomatoe'; DROP TABLE ingredients; --" }, + }, + { contextValue: { db: { query } } }, + ) + expect(result.body.kind).toBe('single') + if (result.body.kind === 'single') { + expect(result.body.singleResult.errors).toBeUndefined() + expect(result.body.singleResult.data).toEqual({ + ingredients: [{ name: 'tomato' }], + }) + } + expect(query.mock.calls[0][0]).not.toContain('DROP TABLE') + expect(query.mock.calls[0][1][0]).toContain('DROP TABLE'.toLowerCase()) + await server.stop() + }) + + test('empty/short queries skip SQL; overlong queries and DB failures return GraphQL errors', async () => { + const query = vi.fn().mockRejectedValue(new Error('offline')) + const server = createServer() + for (const search of ['', ' ', 't']) { + const result = await server.executeOperation( + { + query: 'query($s: String!) { ingredients(search: $s) { name } }', + variables: { s: search }, + }, + { contextValue: { db: { query } } }, + ) + if (result.body.kind === 'single') + expect(result.body.singleResult.data).toEqual({ ingredients: [] }) + } + expect(query).not.toHaveBeenCalled() + for (const search of ['a'.repeat(101), 'tomato']) { + const result = await server.executeOperation( + { + query: 'query($s: String!) { ingredients(search: $s) { name } }', + variables: { s: search }, + }, + { contextValue: { db: { query } } }, + ) + if (result.body.kind === 'single') + expect(result.body.singleResult.errors).toHaveLength(1) + } + expect(query).toHaveBeenCalledTimes(1) + await server.stop() + }) +}) diff --git a/backend/src/ingredients.ts b/backend/src/ingredients.ts new file mode 100644 index 0000000..eda5ae3 --- /dev/null +++ b/backend/src/ingredients.ts @@ -0,0 +1,36 @@ +import { GraphQLError } from 'graphql' + +export function buildIngredientQuery(search: string, requestedLimit = 8) { + const term = search.trim().toLowerCase().replace(/\s+/g, ' ') + if (term.length > 100) { + throw new GraphQLError( + 'Ingredient searches must be at most 100 characters.', + { + extensions: { code: 'BAD_USER_INPUT' }, + }, + ) + } + if (term.length < 2) return null + const limit = Math.min(Math.max(requestedLimit, 1), 20) + // Escape LIKE metacharacters: user input is always a literal prefix. + const prefix = term.replace(/[\\%_]/g, '\\$&') + '%' + // GiST nearest-neighbour ordering bounds fuzzy candidates before applying + // a permissive threshold, which also catches transpositions like garilc. + return { + text: `WITH candidates AS ( + (SELECT ingredient_name, term FROM ingredient_terms + WHERE term COLLATE "C" LIKE $2 ORDER BY term COLLATE "C" LIMIT 40) + UNION + (SELECT ingredient_name, term FROM ingredient_terms + WHERE $4 ORDER BY term <-> $1 LIMIT 40) + ) + SELECT ingredient_name AS name FROM candidates + WHERE term LIKE $2 OR similarity(term, $1) >= 0.25 + GROUP BY ingredient_name + ORDER BY max((term = $1)::int) DESC, + max((term LIKE $2)::int) DESC, + max(similarity(term, $1)) DESC, ingredient_name + LIMIT $3`, + values: [term, prefix, limit, term.length >= 3], + } +} diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 6703987..20a027b 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -1,4 +1,5 @@ import type { Pool } from 'pg' +import { buildIngredientQuery } from './ingredients.ts' export const typeDefs = `#graphql type Recipe { @@ -48,7 +49,13 @@ export const typeDefs = `#graphql count: Int! } + type Ingredient { + name: String! + } + type Query { + "Ingredient autocomplete: case-insensitive prefixes, aliases and typo matching" + ingredients(search: String!, limit: Int = 8): [Ingredient!]! recipes( search: String filter: RecipeFilter @@ -142,6 +149,16 @@ export function buildRecipeQuery(args: RecipesArgs) { export const resolvers = { Query: { + ingredients: async ( + _: unknown, + { search, limit }: { search: string; limit?: number | null }, + { db }: Context, + ) => { + const query = buildIngredientQuery(search, limit ?? 8) + if (!query) return [] + const result = await db.query(query.text, query.values) + return result.rows + }, recipes: async (_: unknown, args: RecipesArgs, { db }: Context) => { const q = buildRecipeQuery(args) const [count, page] = await Promise.all([ diff --git a/deploy/apache/project2.conf b/deploy/apache/project2.conf index df56af2..27f6b71 100644 --- a/deploy/apache/project2.conf +++ b/deploy/apache/project2.conf @@ -6,6 +6,9 @@ # serves index.html for any path that is not a real file, and React Router # renders the right page. Lives here and not in a .htaccess because the VM has # AllowOverride None for /var/www. +ProxyPass /project2/graphql http://127.0.0.1:3001/ +ProxyPassReverse /project2/graphql http://127.0.0.1:3001/ + FallbackResource /project2/index.html diff --git a/frontend/src/components/IngredientSearch.test.tsx b/frontend/src/components/IngredientSearch.test.tsx new file mode 100644 index 0000000..ee0d6c1 --- /dev/null +++ b/frontend/src/components/IngredientSearch.test.tsx @@ -0,0 +1,155 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import IngredientSearch from './IngredientSearch' + +const fetchMock = vi.fn() +const onAdd = vi.fn() +const response = (names: string[]) => ({ + ok: true, + json: async () => ({ + data: { ingredients: names.map((name) => ({ name })) }, + }), +}) +const input = () => screen.getByRole('combobox') +const type = (value: string) => fireEvent.change(input(), { target: { value } }) +const debounce = () => + act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset().mockResolvedValue(response(['tomato', 'tomato paste'])) + onAdd.mockReset() +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +test('debounces, normalizes requests and skips empty or short input', async () => { + render() + await debounce() + type('t') + await debounce() + expect(fetchMock).not.toHaveBeenCalled() + type('tom') + type('TOMATOE') + expect(fetchMock).not.toHaveBeenCalled() + await debounce() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(JSON.parse(fetchMock.mock.calls[0][1].body).variables).toEqual({ + search: 'tomatoe', + }) + expect(screen.getAllByRole('option')).toHaveLength(2) + type('') + expect(screen.queryByRole('option')).not.toBeInTheDocument() + await debounce() + expect(fetchMock).toHaveBeenCalledTimes(1) +}) + +test('adds a canonical suggestion with mouse or keyboard and supports Escape', async () => { + render() + type('tomatoe') + await debounce() + fireEvent.keyDown(input(), { key: 'ArrowDown' }) + expect(input()).toHaveAttribute( + 'aria-activedescendant', + 'ingredient-option-0', + ) + fireEvent.keyDown(input(), { key: 'Escape' }) + expect(input()).toHaveAttribute('aria-expanded', 'false') + fireEvent.keyDown(input(), { key: 'ArrowDown' }) + fireEvent.submit(input().closest('form')!) + expect(onAdd).toHaveBeenLastCalledWith('tomato') + expect(input()).toHaveValue('') + type('tom') + await debounce() + fireEvent.click(screen.getByRole('option', { name: 'tomato paste' })) + expect(onAdd).toHaveBeenLastCalledWith('tomato paste') +}) + +test('filters selected ingredients and reuses cached queries', async () => { + render() + type('tom') + await debounce() + expect( + screen.queryByRole('option', { name: 'tomato' }), + ).not.toBeInTheDocument() + type('') + type('TOM') + await debounce() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect( + screen.getByRole('option', { name: 'tomato paste' }), + ).toBeInTheDocument() +}) + +test.each(['http', 'graphql', 'network', 'invalid'])( + 'reports %s errors and recovers after editing the query', + async (kind) => { + if (kind === 'http') fetchMock.mockResolvedValueOnce({ ok: false }) + if (kind === 'graphql') + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ errors: [{ message: 'offline' }] }), + }) + if (kind === 'network') + fetchMock.mockRejectedValueOnce(new Error('offline')) + if (kind === 'invalid') + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { ingredients: [null] } }), + }) + render() + type('tom') + await debounce() + expect(screen.getByRole('status')).toHaveTextContent( + 'Could not load suggestions', + ) + type('toma') + await debounce() + expect(screen.getAllByRole('option')).toHaveLength(2) + }, +) + +test('reports no matches and allows manual additions', async () => { + fetchMock.mockResolvedValue(response([])) + render() + type('my ingredient') + await debounce() + expect(screen.getByRole('status')).toHaveTextContent( + 'No matching ingredients', + ) + fireEvent.click(screen.getByRole('button', { name: 'Add ingredient' })) + expect(onAdd).toHaveBeenCalledWith('my ingredient') +}) + +test('aborts obsolete requests and ignores responses that arrive out of order', async () => { + let resolveOld!: (value: ReturnType) => void + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve + }), + ) + const view = render() + type('tom') + await debounce() + const oldSignal = fetchMock.mock.calls[0][1].signal as AbortSignal + type('gar') + expect(oldSignal.aborted).toBe(true) + fetchMock.mockResolvedValueOnce(response(['garlic'])) + await debounce() + await act(async () => { + resolveOld(response(['tomato'])) + }) + expect(screen.getByRole('option', { name: 'garlic' })).toBeInTheDocument() + expect( + screen.queryByRole('option', { name: 'tomato' }), + ).not.toBeInTheDocument() + const signal = fetchMock.mock.calls[1][1].signal as AbortSignal + view.unmount() + expect(signal.aborted).toBe(true) +}) diff --git a/frontend/src/components/IngredientSearch.tsx b/frontend/src/components/IngredientSearch.tsx new file mode 100644 index 0000000..5393793 --- /dev/null +++ b/frontend/src/components/IngredientSearch.tsx @@ -0,0 +1,208 @@ +import { useEffect, useRef, useState } from 'react' + +type SearchResult = { + query: string + names: string[] + status: 'ready' | 'loading' | 'error' +} + +type Props = { + selected: string[] + onAdd: (name: string) => void +} + +export default function IngredientSearch({ selected, onAdd }: Props) { + const [input, setInput] = useState('') + const [open, setOpen] = useState(false) + const [active, setActive] = useState(-1) + const [result, setResult] = useState({ + query: '', + names: [], + status: 'ready', + }) + const cache = useRef(new Map()) + const activeOption = useRef(null) + const query = input.trim().toLowerCase().replace(/\s+/g, ' ') + + useEffect(() => { + if (open && active >= 0) { + activeOption.current?.scrollIntoView?.({ block: 'nearest' }) + } + }, [active, open]) + + useEffect(() => { + if (query.length < 2) return + const controller = new AbortController() + let current = true + const timer = setTimeout(async () => { + const cached = cache.current.get(query) + if (cached) { + setResult({ query, names: cached, status: 'ready' }) + return + } + setResult({ query, names: [], status: 'loading' }) + try { + const response = await fetch( + import.meta.env.VITE_GRAPHQL_URL ?? + `${import.meta.env.BASE_URL}graphql`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: `query IngredientSuggestions($search: String!) { + ingredients(search: $search, limit: 8) { name } + }`, + variables: { search: query }, + }), + signal: controller.signal, + }, + ) + if (!response.ok) throw new Error('Ingredient request failed') + const body = await response.json() + const items: unknown = body.data?.ingredients + if ( + body.errors?.length || + !Array.isArray(items) || + !items.every((item) => typeof item?.name === 'string') + ) { + throw new Error('Invalid ingredient response') + } + if (!current) return + const names = items.map((item) => item.name as string) + // Bound the cache when users try many different searches. + if (cache.current.size >= 30) { + cache.current.delete(cache.current.keys().next().value!) + } + cache.current.set(query, names) + setResult({ query, names, status: 'ready' }) + } catch { + if (current) setResult({ query, names: [], status: 'error' }) + } + }, 300) + return () => { + current = false + clearTimeout(timer) + controller.abort() + } + }, [query]) + + const loading = + query.length >= 2 && (result.query !== query || result.status === 'loading') + const failed = !loading && result.query === query && result.status === 'error' + const names = + query.length >= 2 && result.query === query && result.status === 'ready' + ? result.names.filter( + (name) => + !selected.some((item) => item.toLowerCase() === name.toLowerCase()), + ) + : [] + const expanded = open && names.length > 0 + + const add = (name: string) => { + if (!name.trim()) return + onAdd(name.trim()) + setInput('') + setActive(-1) + setOpen(false) + } + + const feedback = + query.length < 2 + ? 'Type at least 2 characters for ingredient suggestions.' + : loading + ? 'Searching ingredients…' + : failed + ? 'Could not load suggestions. Edit your search to try again, or add your ingredient manually.' + : names.length === 0 + ? 'No matching ingredients available. Try another name or add your ingredient manually.' + : `${names.length} suggestions available. Use the arrow keys to choose.` + + return ( +
{ + event.preventDefault() + add(expanded && active >= 0 ? names[active] : input) + }} + > +
+ + = 0 ? `ingredient-option-${active}` : undefined + } + aria-describedby="ingredient-search-status" + autoComplete="off" + maxLength={100} + placeholder="e.g. chicken, tomato, garlic..." + value={input} + onChange={(event) => { + setInput(event.target.value) + setActive(-1) + setOpen(true) + }} + onFocus={() => setOpen(true)} + onBlur={() => { + setOpen(false) + setActive(-1) + }} + onKeyDown={(event) => { + if ( + (event.key === 'ArrowDown' || event.key === 'ArrowUp') && + names.length + ) { + event.preventDefault() + setOpen(true) + setActive((index) => + event.key === 'ArrowDown' + ? (index + 1) % names.length + : (index <= 0 ? names.length : index) - 1, + ) + } else if (event.key === 'Escape') { + event.preventDefault() + setOpen(false) + setActive(-1) + } + }} + /> + +
+ +

+ {feedback} +

+
+ ) +} diff --git a/frontend/src/pages/FridgePage.test.tsx b/frontend/src/pages/FridgePage.test.tsx new file mode 100644 index 0000000..5d00844 --- /dev/null +++ b/frontend/src/pages/FridgePage.test.tsx @@ -0,0 +1,60 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { afterEach, expect, test, vi } from 'vitest' +import FridgePage from './FridgePage' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function Destination() { + const location = useLocation() + return ( + + {location.search} + {location.hash} + + ) +} + +test('selects a corrected ingredient, avoids duplicates, removes ingredients and searches recipes', async () => { + vi.useFakeTimers() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { ingredients: [{ name: 'tomato' }] } }), + }), + ) + render( + + + } /> + } /> + + , + ) + expect(screen.getByRole('button', { name: 'Find recipes' })).toBeDisabled() + const input = screen.getByRole('combobox') + fireEvent.change(input, { target: { value: 'tomatoe' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + fireEvent.click(screen.getByRole('option', { name: 'tomato' })) + const list = screen.getByRole('heading', { + name: 'Your ingredients', + }).parentElement! + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + fireEvent.change(input, { target: { value: 'TOMATO' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add ingredient' })) + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Remove tomato' })) + expect(screen.getByRole('button', { name: 'Find recipes' })).toBeDisabled() + fireEvent.change(input, { target: { value: 'garlic' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add ingredient' })) + fireEvent.click(screen.getByRole('button', { name: 'Find recipes' })) + expect(screen.getByRole('status')).toHaveTextContent( + '?ingredients=garlic#recipes', + ) +}) diff --git a/frontend/src/pages/FridgePage.tsx b/frontend/src/pages/FridgePage.tsx index 56c07c4..59845e8 100644 --- a/frontend/src/pages/FridgePage.tsx +++ b/frontend/src/pages/FridgePage.tsx @@ -1,19 +1,18 @@ import { useState } from 'react' import { useNavigate } from 'react-router' +import IngredientSearch from '../components/IngredientSearch' import '../styles/FridgePage.css' export default function FridgePage() { - const [ingredient, setIngredient] = useState('') const [ingredients, setIngredients] = useState([]) const navigate = useNavigate() - const addIngredient = () => { - const trimmedIngredient = ingredient.trim() - - if (trimmedIngredient && !ingredients.includes(trimmedIngredient)) { - setIngredients([...ingredients, trimmedIngredient]) - setIngredient('') - } + const addIngredient = (name: string) => { + setIngredients((current) => + current.some((item) => item.toLowerCase() === name.toLowerCase()) + ? current + : [...current, name], + ) } const removeIngredient = (ingredientToRemove: string) => { @@ -43,27 +42,7 @@ export default function FridgePage() {

What do you have?

-
{ - event.preventDefault() - addIngredient() - }} - > - - - setIngredient(event.target.value)} - /> - - -
+

Your ingredients

diff --git a/frontend/src/styles/FridgePage.css b/frontend/src/styles/FridgePage.css index fac5e88..7fb26da 100644 --- a/frontend/src/styles/FridgePage.css +++ b/frontend/src/styles/FridgePage.css @@ -39,6 +39,58 @@ display: grid; grid-template-columns: 1fr auto; gap: 1rem; + align-items: start; +} + +.ingredient-search { + position: relative; + min-width: 0; +} + +.ingredient-search input { + width: 100%; + box-sizing: border-box; +} + +.ingredient-search [role='listbox'] { + position: absolute; + z-index: 2; + top: 100%; + left: 0; + right: 0; + max-height: 18rem; + overflow-y: auto; + padding: 0.25rem; + margin: 0.25rem 0 0; + list-style: none; + border: 1px solid var(--color-border); + border-radius: 0.75rem; + background: var(--color-card); + box-shadow: 0 0.5rem 1rem var(--color-shadow); +} + +.ingredient-search [role='option'] { + padding: 0.75rem; + border-radius: 0.5rem; + overflow-wrap: anywhere; + cursor: pointer; +} + +.ingredient-search [role='option']:hover, +.ingredient-search [aria-selected='true'] { + background: var(--color-button-bg); + color: var(--color-button-text); +} + +.ingredient-search-status { + grid-column: 1 / -1; + margin: 0; + color: var(--color-text-muted); +} + +.ingredient-form button:disabled { + opacity: 0.5; + cursor: not-allowed; } .ingredient-form input { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fce960b..77f11b2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,14 @@ export default defineConfig({ plugins: [react()], // Served by Apache on the VM under /project2/ base: '/project2/', + server: { + proxy: { + '/project2/graphql': { + target: 'http://localhost:3001', + rewrite: () => '/', + }, + }, + }, test: { environment: 'jsdom', setupFiles: './src/test-setup.ts', diff --git a/package-lock.json b/package-lock.json index fedd3fe..13378ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "sharp": "^0.35.4" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.10", "@types/node": "^24.13.6", "@types/pg": "^8.23.1", "typescript": "^6.0.3", @@ -529,6 +530,13 @@ "node": ">=20.19.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.10.tgz", + "integrity": "sha512-tXc/25U3ulOkiRQuTFdwIv+JhBqtMdAF7Gvo6CfZZWtSB4+hgC4Wr2vwK5ngvvN5PJfAAEGIsfyCVy1JhCOBYw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", diff --git a/package.json b/package.json index ee60bd4..f89a0d2 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev:frontend": "npm run dev -w frontend", "dev:backend": "npm run dev -w backend", "import": "npm run import -w backend", + "ingredients:refresh": "npm run ingredients:refresh -w backend", "debug": "npm run debug -w backend", "build": "npm run build -w frontend", "lint": "oxlint", From 30631907a344be5145066c709827adaba3213551 Mon Sep 17 00:00:00 2001 From: Jonas Hella Date: Thu, 15 Oct 2026 12:12:55 +0200 Subject: [PATCH 2/2] fix(fridge): preserve selections and match available ingredients Persist fridge ingredients across navigation and reloads using Zustand and localStorage, and sync changes between browser tabs. Match recipes using any available fridge ingredient and provide a Back to Fridge link when no recipes match. Closes #57 Closes #59 --- CLAUDE.md | 7 +- README.md | 12 ++- frontend/package.json | 3 +- frontend/src/pages/FridgePage.test.tsx | 89 ++++++++++++++++++- frontend/src/pages/FridgePage.tsx | 27 ++---- frontend/src/pages/HomePage.test.tsx | 70 +++++++++++++++ frontend/src/pages/HomePage.tsx | 13 ++- frontend/src/stores/fridgeStore.test.ts | 85 ++++++++++++++++++ frontend/src/stores/fridgeStore.ts | 106 +++++++++++++++++++++++ frontend/src/styles/HomePage.css | 10 ++- frontend/src/utils/recipeFilters.test.ts | 16 +++- frontend/src/utils/recipeFilters.ts | 8 +- package-lock.json | 36 +++++++- 13 files changed, 444 insertions(+), 38 deletions(-) create mode 100644 frontend/src/pages/HomePage.test.tsx create mode 100644 frontend/src/stores/fridgeStore.test.ts create mode 100644 frontend/src/stores/fridgeStore.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2eefabd..c630133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,12 +33,13 @@ Chosen: - Backend runs TypeScript directly with Node's type stripping (Node >= 22.18): no build step, so only erasable TS syntax (no `enum`, no `namespace`), and imports use the `.ts` extension - Database: PostgreSQL 17 (full-text search via `tsvector`) +- 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" Still open (record the decision here and in README.md when made): -- GraphQL client / state lib, E2E tool, UI/component lib: TBD +- GraphQL client, E2E tool, UI/component lib: TBD ## Repo layout @@ -84,7 +85,9 @@ My Fridge autocomplete talks to GraphQL at `/project2/graphql` (Vite/Apache proxy to port 3001; optional `VITE_GRAPHQL_URL` override). It uses 300 ms debouncing, cancellation and a bounded query cache. Catalog names and aliases are rebuilt during import or via `ingredients:refresh`; `pg_trgm` is required. -Recipe browsing still uses mock data and fridge selections are not persisted. +Recipe browsing still uses mock data. Fridge selections persist in localStorage +through a Zustand store (`frontend/src/stores/fridgeStore.ts`) and sync across +browser tabs on the same origin; they are not yet saved to the database. ## How we work (GitHub flow, `main` is protected) diff --git a/README.md b/README.md index 314e264..f746759 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,17 @@ My Fridge queries `ingredients(search: String!, limit: Int = 8)` over GraphQL. It waits 300 ms after typing stops, starts at two characters, cancels obsolete requests, and caches up to 30 searches. Suggestions support mouse selection, arrow keys, Enter and Escape. Manual entry remains available if the API is down -or an ingredient is missing; the fridge list is still local component state. +or an ingredient is missing. Fridge selections use a shared +[Zustand store](https://zustand.docs.pmnd.rs/reference/integrations/persisting-store-data) +persisted to `localStorage` (`recipeapp-fridge-v1`), so navigating, going back +and refreshing retain the list. Changes sync between browser tabs on the same +origin. Storage is local to the browser; this is not database persistence or +cross-device sync. If browser storage is blocked or full, the list remains +usable in memory but cannot be guaranteed to survive a reload. + +Find recipes matches any of the selected fridge ingredients. Extra ingredients +do not exclude a recipe, while search/category/rating filters still apply. +When no recipes match, Back to Fridge returns to the saved ingredient list. The ingredient catalog is derived from the recipe ingredient lines during `npm run import`. For an **existing recipe database**, run this once instead diff --git a/frontend/package.json b/frontend/package.json index 88a6206..2977531 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,8 @@ "dependencies": { "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "zustand": "^5.0.15" }, "devDependencies": { "@testing-library/jest-dom": "^7.0.1", diff --git a/frontend/src/pages/FridgePage.test.tsx b/frontend/src/pages/FridgePage.test.tsx index 5d00844..148b18d 100644 --- a/frontend/src/pages/FridgePage.test.tsx +++ b/frontend/src/pages/FridgePage.test.tsx @@ -1,11 +1,96 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' -import { afterEach, expect, test, vi } from 'vitest' +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigate, +} from 'react-router' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' import FridgePage from './FridgePage' +import App from '../App' +import { FRIDGE_STORAGE_KEY, useFridgeStore } from '../stores/fridgeStore' + +beforeEach(() => { + localStorage.clear() + useFridgeStore.setState({ ingredients: [] }) +}) afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() + localStorage.clear() +}) + +function BackButton() { + const navigate = useNavigate() + return +} + +test('keeps fridge ingredients through navigation and the back button', () => { + render( + + + + , + ) + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'garlic' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Add ingredient' })) + fireEvent.click(screen.getByRole('link', { name: 'Home' })) + expect( + screen.queryByRole('button', { name: 'Remove garlic' }), + ).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Go back' })) + expect( + screen.getByRole('button', { name: 'Remove garlic' }), + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('link', { name: 'Home' })) + fireEvent.click(screen.getByRole('link', { name: 'My Fridge' })) + expect( + screen.getByRole('button', { name: 'Remove garlic' }), + ).toBeInTheDocument() +}) + +test('updates the visible fridge when another browser tab changes or clears storage', () => { + render( + + + , + ) + act(() => { + localStorage.setItem( + FRIDGE_STORAGE_KEY, + JSON.stringify({ state: { ingredients: ['tomato'] }, version: 0 }), + ) + window.dispatchEvent( + new StorageEvent('storage', { key: FRIDGE_STORAGE_KEY }), + ) + }) + expect( + screen.getByRole('button', { name: 'Remove tomato' }), + ).toBeInTheDocument() + act(() => { + localStorage.clear() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + }) + expect(screen.getByRole('button', { name: 'Find recipes' })).toBeDisabled() +}) + +test('loads changes made in another tab while the fridge page was closed', () => { + localStorage.setItem( + FRIDGE_STORAGE_KEY, + JSON.stringify({ state: { ingredients: ['onion'] }, version: 0 }), + ) + render( + + + , + ) + expect( + screen.getByRole('button', { name: 'Remove onion' }), + ).toBeInTheDocument() }) function Destination() { diff --git a/frontend/src/pages/FridgePage.tsx b/frontend/src/pages/FridgePage.tsx index 59845e8..64164b9 100644 --- a/frontend/src/pages/FridgePage.tsx +++ b/frontend/src/pages/FridgePage.tsx @@ -1,27 +1,16 @@ -import { useState } from 'react' import { useNavigate } from 'react-router' import IngredientSearch from '../components/IngredientSearch' +import { useFridgeStorageSync, useFridgeStore } from '../stores/fridgeStore' import '../styles/FridgePage.css' export default function FridgePage() { - const [ingredients, setIngredients] = useState([]) + const ingredients = useFridgeStore((state) => state.ingredients) + const addIngredient = useFridgeStore((state) => state.addIngredient) + const removeIngredient = useFridgeStore((state) => state.removeIngredient) + useFridgeStorageSync() const navigate = useNavigate() - const addIngredient = (name: string) => { - setIngredients((current) => - current.some((item) => item.toLowerCase() === name.toLowerCase()) - ? current - : [...current, name], - ) - } - - const removeIngredient = (ingredientToRemove: string) => { - setIngredients( - ingredients.filter((ingredient) => ingredient !== ingredientToRemove), - ) - } - - // Shows the recipe list filtered to recipes that use every fridge ingredient. + // Find recipes using any fridge ingredient; unused ingredients do not exclude matches. const findRecipes = () => navigate({ pathname: '/', @@ -34,8 +23,8 @@ export default function FridgePage() {

My Fridge

- Add the ingredients you already have and discover recipes you can - make. + Add the ingredients you already have and discover recipes that use + them.

diff --git a/frontend/src/pages/HomePage.test.tsx b/frontend/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..3305134 --- /dev/null +++ b/frontend/src/pages/HomePage.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, expect, test } from 'vitest' +import App from '../App' +import { useFridgeStore } from '../stores/fridgeStore' + +beforeEach(() => { + localStorage.clear() + useFridgeStore.setState({ ingredients: [] }) +}) +afterEach(() => localStorage.clear()) + +function renderAt(path: string) { + return render( + + + , + ) +} + +test('shows a recipe matching part of the fridge despite extra ingredients', () => { + renderAt('/?ingredients=tomato,pepper') + expect( + screen.getByRole('link', { name: 'Fresh Tomato Pasta' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'Creamy Garlic Chicken Pasta' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'Back to Fridge' }), + ).not.toBeInTheDocument() + expect( + screen.getByText(/matching any of: tomato, pepper/), + ).toBeInTheDocument() +}) + +test('offers a return to the saved fridge when no recipes match', () => { + useFridgeStore.getState().addIngredient('pepper') + useFridgeStore.getState().addIngredient('onion') + renderAt('/?ingredients=pepper,onion') + expect(screen.getByText(/No recipes match your search/)).toBeInTheDocument() + expect(screen.queryByRole('article')).not.toBeInTheDocument() + const back = screen.getByRole('link', { name: 'Back to Fridge' }) + expect(back).toHaveAttribute('href', '/fridge') + fireEvent.click(back) + expect( + screen.getByRole('heading', { level: 1, name: 'My Fridge' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Remove pepper' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Remove onion' }), + ).toBeInTheDocument() +}) + +test('offers Back to Fridge for empty results from other filters too', () => { + renderAt('/?q=no-such-recipe') + expect( + screen.getByRole('link', { name: 'Back to Fridge' }), + ).toBeInTheDocument() +}) + +test('an empty fridge filter leaves recipes visible', () => { + renderAt('/?ingredients=') + expect(screen.getAllByRole('article')).toHaveLength(3) + expect( + screen.queryByRole('link', { name: 'Back to Fridge' }), + ).not.toBeInTheDocument() +}) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 02bc203..00de60a 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -53,13 +53,18 @@ export default function HomePage() {

{results.length === 1 ? '1 recipe' : `${results.length} recipes`} {filters.ingredients.length > 0 && - ` with ${filters.ingredients.join(', ')}`} + ` matching any of: ${filters.ingredients.join(', ')}`}

{results.length === 0 ? ( -

- No recipes match your search. Try another word or fewer filters. -

+
+

+ No recipes match your search. Try another word or fewer filters. +

+ + Back to Fridge + +
) : (
    {results.map((recipe) => ( diff --git a/frontend/src/stores/fridgeStore.test.ts b/frontend/src/stores/fridgeStore.test.ts new file mode 100644 index 0000000..e1ddf42 --- /dev/null +++ b/frontend/src/stores/fridgeStore.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { createFridgeStore, FRIDGE_STORAGE_KEY } from './fridgeStore' + +beforeEach(() => localStorage.clear()) +afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() +}) + +test('restores ingredients in a new store after reload, including removals and an empty fridge', () => { + const store = createFridgeStore() + store.getState().addIngredient(' tomato ') + store.getState().addIngredient('TOMATO') + store.getState().addIngredient('garlic') + expect(createFridgeStore().getState().ingredients).toEqual([ + 'tomato', + 'garlic', + ]) + store.getState().removeIngredient('tomato') + expect(createFridgeStore().getState().ingredients).toEqual(['garlic']) + store.getState().removeIngredient('garlic') + expect(createFridgeStore().getState().ingredients).toEqual([]) +}) + +test.each([ + 'broken json', + JSON.stringify({ state: { ingredients: 'tomato' } }), + JSON.stringify({ state: null }), +])('handles invalid saved data: %s', (saved) => { + localStorage.setItem(FRIDGE_STORAGE_KEY, saved) + const store = createFridgeStore() + expect(store.getState().ingredients).toEqual([]) + expect(() => store.getState().addIngredient('garlic')).not.toThrow() + expect(createFridgeStore().getState().ingredients).toEqual(['garlic']) +}) + +test('validates saved entries and preserves store actions', () => { + localStorage.setItem( + FRIDGE_STORAGE_KEY, + JSON.stringify({ + state: { + ingredients: [ + ' tomato ', + 'TOMATO', + 2, + null, + '', + 'garlic', + 'x'.repeat(101), + ], + addIngredient: 'invalid action', + }, + }), + ) + const store = createFridgeStore() + expect(store.getState().ingredients).toEqual(['tomato', 'garlic']) + store.getState().addIngredient('onion') + expect(store.getState().ingredients).toEqual(['tomato', 'garlic', 'onion']) +}) + +test('keeps working in memory when storage access is blocked', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked') + }) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked') + }) + const store = createFridgeStore() + store.getState().addIngredient('tomato') + store.persist.rehydrate() + expect(store.getState().ingredients).toEqual(['tomato']) + store.getState().removeIngredient('tomato') + expect(store.getState().ingredients).toEqual([]) +}) + +test('does not restore stale storage over current state when writes fail', () => { + const store = createFridgeStore() + store.getState().addIngredient('onion') + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded') + }) + store.getState().addIngredient('tomato') + store.persist.rehydrate() + expect(store.getState().ingredients).toEqual(['onion', 'tomato']) +}) diff --git a/frontend/src/stores/fridgeStore.ts b/frontend/src/stores/fridgeStore.ts new file mode 100644 index 0000000..a415c0b --- /dev/null +++ b/frontend/src/stores/fridgeStore.ts @@ -0,0 +1,106 @@ +import { useEffect } from 'react' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' + +export const FRIDGE_STORAGE_KEY = 'recipeapp-fridge-v1' + +type FridgeState = { + ingredients: string[] + addIngredient: (name: string) => void + removeIngredient: (name: string) => void +} + +function validIngredients(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const names = new Map() + for (const item of value) { + if (typeof item !== 'string') continue + const name = item.trim() + if (name && name.length <= 100 && !names.has(name.toLowerCase())) { + names.set(name.toLowerCase(), name) + } + } + return [...names.values()] +} + +export function createFridgeStore() { + // Retain state during navigation even if browser storage is blocked or full. + let memory: string | null = null + let storageFailed = false + const storage = createJSONStorage>(() => ({ + getItem: (key) => { + if (storageFailed) return memory + try { + memory = localStorage.getItem(key) + return memory + } catch { + storageFailed = true + return memory + } + }, + setItem: (key, value) => { + memory = value + try { + localStorage.setItem(key, value) + } catch { + storageFailed = true + } + }, + removeItem: (key) => { + memory = null + try { + localStorage.removeItem(key) + } catch { + storageFailed = true + } + }, + })) + + return create()( + persist( + (set) => ({ + ingredients: [], + addIngredient: (name) => + set(({ ingredients }) => ({ + ingredients: validIngredients([...ingredients, name]), + })), + removeIngredient: (name) => + set(({ ingredients }) => ({ + ingredients: ingredients.filter( + (item) => item.toLowerCase() !== name.trim().toLowerCase(), + ), + })), + }), + { + name: FRIDGE_STORAGE_KEY, + storage, + partialize: ({ ingredients }) => ({ ingredients }), + // Stored JSON is untrusted; keep actions intact and validate list entries. + merge: (saved, current) => ({ + ...current, + ingredients: validIngredients( + saved && typeof saved === 'object' && 'ingredients' in saved + ? saved.ingredients + : undefined, + ), + }), + }, + ), + ) +} + +export const useFridgeStore = createFridgeStore() + +export function useFridgeStorageSync() { + useEffect(() => { + const sync = (event: StorageEvent) => { + if (event.key === FRIDGE_STORAGE_KEY || event.key === null) { + void useFridgeStore.persist.rehydrate() + } + } + // Another tab may have changed the list while this route was unmounted. + void useFridgeStore.persist.rehydrate() + window.addEventListener('storage', sync) + return () => window.removeEventListener('storage', sync) + }, []) +} diff --git a/frontend/src/styles/HomePage.css b/frontend/src/styles/HomePage.css index beb3dc7..8372c30 100644 --- a/frontend/src/styles/HomePage.css +++ b/frontend/src/styles/HomePage.css @@ -36,7 +36,8 @@ } .hero-button, -.hero-fridge-button { +.hero-fridge-button, +.fridge-back-button { display: inline-flex; justify-content: center; align-items: center; @@ -92,6 +93,10 @@ margin-top: 2rem; } +.fridge-back-button { + margin-top: 0.5rem; +} + .recipe-grid { width: min(92%, 75rem); margin: 2rem auto 0; @@ -155,7 +160,8 @@ } .hero-actions .hero-button:hover, -.hero-actions .hero-fridge-button:hover { +.hero-actions .hero-fridge-button:hover, +.fridge-back-button:hover { background-color: var(--color-button-hover-bg); border-color: var(--color-button-hover-bg); color: var(--color-button-hover-text); diff --git a/frontend/src/utils/recipeFilters.test.ts b/frontend/src/utils/recipeFilters.test.ts index f8d7911..5dc3896 100644 --- a/frontend/src/utils/recipeFilters.test.ts +++ b/frontend/src/utils/recipeFilters.test.ts @@ -39,7 +39,21 @@ describe('filterRecipes', () => { expect(run('category=vegetarian')).toEqual([salad]) expect(run('rating=4')).toEqual([pasta]) expect(run('ingredients=garlic, pasta')).toEqual([pasta]) - expect(run('ingredients=garlic,basil')).toEqual([]) + expect(run('ingredients=garlic,basil')).toEqual([pasta, salad]) + expect(run('ingredients=garlic,basil&category=vegetarian')).toEqual([salad]) + expect(run('ingredients=garlic,basil&rating=4')).toEqual([pasta]) + expect(run('ingredients=garlic,basil&q=chicken')).toEqual([pasta]) + }) + + test('recipes can use only part of the fridge without matching extra ingredients', () => { + expect(run('ingredients=tomato,pepper')).toEqual([salad]) + expect(run('ingredients=TOMATO,PEPPER,ONION')).toEqual([salad]) + expect(run('ingredients=tomato,garlic,pepper')).toEqual([pasta, salad]) + }) + + test('recipes with no fridge overlap are excluded', () => { + expect(run('ingredients=pepper,onion')).toEqual([]) + expect(run('ingredients=, ,')).toEqual([pasta, salad]) }) test('invalid rating is ignored', () => { diff --git a/frontend/src/utils/recipeFilters.ts b/frontend/src/utils/recipeFilters.ts index b404802..5c09870 100644 --- a/frontend/src/utils/recipeFilters.ts +++ b/frontend/src/utils/recipeFilters.ts @@ -48,8 +48,10 @@ export function filterRecipes( recipe.ingredients.some((i) => includes(i, q))) && (!category || recipe.categories.some((c) => includes(c, category))) && (minRating === null || (recipe.rating ?? 0) >= minRating) && - ingredients.every((wanted) => - recipe.ingredients.some((i) => includes(i, wanted)), - ), + // Extra fridge ingredients must not exclude a recipe that uses others. + (ingredients.length === 0 || + ingredients.some((wanted) => + recipe.ingredients.some((i) => includes(i, wanted)), + )), ) } diff --git a/package-lock.json b/package-lock.json index 13378ef..9af3d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "dependencies": { "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "zustand": "^5.0.15" }, "devDependencies": { "@testing-library/jest-dom": "^7.0.1", @@ -2057,7 +2058,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2408,7 +2409,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/csv-parse": { @@ -4656,6 +4657,35 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } }