diff --git a/CLAUDE.md b/CLAUDE.md index acf9567..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 @@ -53,6 +54,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 +70,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 +81,14 @@ 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. 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) 1. **Every change starts from an issue.** No issue → create one first (`/new-issue`). diff --git a/README.md b/README.md index 428ee18..f746759 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,62 @@ 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. 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 +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 +183,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/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/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..148b18d --- /dev/null +++ b/frontend/src/pages/FridgePage.test.tsx @@ -0,0 +1,145 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react' +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() { + 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..64164b9 100644 --- a/frontend/src/pages/FridgePage.tsx +++ b/frontend/src/pages/FridgePage.tsx @@ -1,28 +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 [ingredient, setIngredient] = useState('') - 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 = () => { - const trimmedIngredient = ingredient.trim() - - if (trimmedIngredient && !ingredients.includes(trimmedIngredient)) { - setIngredients([...ingredients, trimmedIngredient]) - setIngredient('') - } - } - - 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: '/', @@ -35,35 +23,15 @@ 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.

What do you have?

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

Your ingredients

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/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/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/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..9af3d30 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", @@ -38,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", @@ -529,6 +531,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", @@ -2049,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" @@ -2400,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": { @@ -4648,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 + } + } } } } 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",