Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 |
Expand All @@ -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 <pkg> -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`).
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions backend/db/ingredients.sql
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions backend/scripts/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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) {
Expand Down
87 changes: 87 additions & 0 deletions backend/scripts/ingredient-catalog.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
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
}
18 changes: 18 additions & 0 deletions backend/scripts/refresh-ingredients.ts
Original file line number Diff line number Diff line change
@@ -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()
}
102 changes: 102 additions & 0 deletions backend/src/ingredients.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Loading