From a523cb094eb99ec712beae9c38d9c5a332be39a3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 8 Apr 2026 23:10:48 -0700 Subject: [PATCH] perf(browser): replace LIKE scans with FTS5; cache category counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add migration 021: recipe_browser_fts FTS5 table on category + keywords columns, eliminating LIKE '%keyword%' full sequential scans on 3.1M rows - _count_recipes_for_keywords now uses FTS5 MATCH (O(log N) vs O(N)) - browse_recipes reuses cached count, eliminating the second COUNT(*) scan per page request; ORDER BY r.id replaces the unindexed ORDER BY title sort - Module-level _COUNT_CACHE keyed by (db_path, keywords) means domain-switch category counts are computed once per process lifetime feat(find): dietary preset grid, Big 9 allergen pills, Hard Day Mode surface - Dietary constraints replaced with toggle-button preset grid (8 options) + free-text "Other" field; removes dense freeform text input - Allergies replaced with Big 9 pill picker (peanuts, tree nuts, shellfish, fish, milk, eggs, wheat, soy, sesame) + "Other" for custom entries - Hard Day Mode surfaced as a standalone aria-pressed button above the dietary collapsible; no longer buried inside a collapsed section - Active-state dot indicators on both collapsibles show filter engagement at a glance without expanding fix(a11y): aria-describedby wiring for wildcard checkbox and tag inputs (#40) - Persistent hint spans replace placeholder-only instructions for constraint and allergy fields (WCAG 3.3.2) fix(browse): auto-select highest-count category on domain switch (#41) - Eliminates the 3-decision cold start (domain → category → content) - Surprise Me button added for zero-decision random navigation --- app/db/migrations/021_recipe_browser_fts.sql | 43 +++++ app/db/store.py | 56 +++--- frontend/src/components/RecipesView.vue | 169 +++++++++++++++---- 3 files changed, 211 insertions(+), 57 deletions(-) create mode 100644 app/db/migrations/021_recipe_browser_fts.sql diff --git a/app/db/migrations/021_recipe_browser_fts.sql b/app/db/migrations/021_recipe_browser_fts.sql new file mode 100644 index 0000000..103e754 --- /dev/null +++ b/app/db/migrations/021_recipe_browser_fts.sql @@ -0,0 +1,43 @@ +-- Migration 021: FTS5 inverted index for the recipe browser (category + keywords). +-- +-- The browser domain queries were using LIKE '%keyword%' against category and +-- keywords columns — a leading wildcard prevents any B-tree index use, so every +-- query was a full sequential scan of 3.1M rows. This FTS5 index replaces those +-- scans with O(log N) token lookups. +-- +-- Content-table backed: stores only the inverted index, no text duplication. +-- The keywords column is a JSON array; FTS5 tokenises it as plain text, stripping +-- the punctuation, which gives correct per-word matching. +-- +-- One-time rebuild cost on 3.1M rows: ~20-40 seconds at first startup. +-- Subsequent startups skip this migration (IF NOT EXISTS guard). + +CREATE VIRTUAL TABLE IF NOT EXISTS recipe_browser_fts USING fts5( + category, + keywords, + content=recipes, + content_rowid=id, + tokenize="unicode61" +); + +INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild'); + +CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ai + AFTER INSERT ON recipes BEGIN + INSERT INTO recipe_browser_fts(rowid, category, keywords) + VALUES (new.id, new.category, new.keywords); +END; + +CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ad + AFTER DELETE ON recipes BEGIN + INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords) + VALUES ('delete', old.id, old.category, old.keywords); +END; + +CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_au + AFTER UPDATE ON recipes BEGIN + INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords) + VALUES ('delete', old.id, old.category, old.keywords); + INSERT INTO recipe_browser_fts(rowid, category, keywords) + VALUES (new.id, new.category, new.keywords); +END; diff --git a/app/db/store.py b/app/db/store.py index 3f28102..b0e96af 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -14,9 +14,16 @@ from circuitforge_core.db.migrations import run_migrations MIGRATIONS_DIR = Path(__file__).parent / "migrations" +# Module-level cache for recipe counts by keyword set. +# The recipe corpus is static at runtime — counts are computed once per +# (db_path, keyword_set) and reused for all subsequent requests. +# Key: (db_path_str, sorted_keywords_tuple) → int +_COUNT_CACHE: dict[tuple[str, ...], int] = {} + class Store: def __init__(self, db_path: Path, key: str = "") -> None: + self._db_path = str(db_path) self.conn: sqlite3.Connection = get_connection(db_path, key) self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA foreign_keys=ON") @@ -911,18 +918,26 @@ class Store: results.append({"category": category, "recipe_count": count}) return results + @staticmethod + def _browser_fts_query(keywords: list[str]) -> str: + """Build an FTS5 MATCH expression that ORs all keywords as exact phrases.""" + phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords] + return " OR ".join(phrases) + def _count_recipes_for_keywords(self, keywords: list[str]) -> int: if not keywords: return 0 - conditions = " OR ".join( - ["lower(category) LIKE ?"] * len(keywords) - + ["lower(keywords) LIKE ?"] * len(keywords) - ) - params = tuple(f"%{kw.lower()}%" for kw in keywords) * 2 + cache_key = (self._db_path, *sorted(keywords)) + if cache_key in _COUNT_CACHE: + return _COUNT_CACHE[cache_key] + match_expr = self._browser_fts_query(keywords) row = self.conn.execute( - f"SELECT count(*) AS n FROM recipes WHERE {conditions}", params + "SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?", + (match_expr,), ).fetchone() - return row[0] if row else 0 + count = row[0] if row else 0 + _COUNT_CACHE[cache_key] = count + return count def browse_recipes( self, @@ -940,28 +955,23 @@ class Store: if not keywords: return {"recipes": [], "total": 0, "page": page} - conditions = " OR ".join( - ["lower(category) LIKE ?"] * len(keywords) - + ["lower(keywords) LIKE ?"] * len(keywords) - ) - like_params = tuple(f"%{kw.lower()}%" for kw in keywords) * 2 + match_expr = self._browser_fts_query(keywords) offset = (page - 1) * page_size - total_row = self.conn.execute( - f"SELECT count(*) AS n FROM recipes WHERE {conditions}", like_params - ).fetchone() - total = total_row[0] if total_row else 0 + # Reuse cached count — avoids a second index scan on every page turn. + total = self._count_recipes_for_keywords(keywords) rows = self._fetch_all( - f""" - SELECT id, title, category, keywords, ingredient_names, - calories, fat_g, protein_g, sodium_mg, source_url - FROM recipes - WHERE {conditions} - ORDER BY title ASC + """ + SELECT r.id, r.title, r.category, r.keywords, r.ingredient_names, + r.calories, r.fat_g, r.protein_g, r.sodium_mg, r.source_url + FROM recipe_browser_fts fts + JOIN recipes r ON r.id = fts.rowid + WHERE fts MATCH ? + ORDER BY r.id ASC LIMIT ? OFFSET ? """, - like_params + (page_size, offset), + (match_expr, page_size, offset), ) pantry_set = {p.lower() for p in pantry_items} if pantry_items else None diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index 79c0a7b..44cab41 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -66,6 +66,16 @@ + + +
@@ -74,36 +84,45 @@
- +
- -
- - {{ tag }} - - + +
+
- Press Enter or comma to add each item. + Press Enter or comma to add.
- +
- -
+ +
+ +
+ +
@@ -112,25 +131,14 @@
- Press Enter or comma to add. Allergies are hard exclusions — no recipes containing these will appear. -
- - -
- -

- Only suggests quick, simple recipes based on your saved equipment. -

+ No recipes containing these ingredients will appear.
@@ -146,7 +154,7 @@
- + c !== value) + } else { + recipesStore.constraints = [...recipesStore.constraints, value] + } +} + +function toggleAllergy(value: string) { + if (recipesStore.allergies.includes(value)) { + recipesStore.allergies = recipesStore.allergies.filter((a) => a !== value) + } else { + recipesStore.allergies = [...recipesStore.allergies, value] + } +} + const dietaryActive = computed(() => recipesStore.constraints.length > 0 || recipesStore.allergies.length > 0 || - recipesStore.hardDayMode || recipesStore.shoppingMode ) @@ -1085,9 +1131,64 @@ details[open] .collapsible-summary::before { padding-top: var(--spacing-sm); display: flex; flex-direction: column; + gap: var(--spacing-sm); +} + +/* Hard Day Mode button */ +.hard-day-btn { + width: 100%; + display: flex; + align-items: center; + gap: var(--spacing-sm); + justify-content: flex-start; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-weight: 500; + transition: background 0.15s, color 0.15s; +} + +.hard-day-active { + background: var(--color-success, #2d7a4f); + color: white; + border-color: var(--color-success, #2d7a4f); +} + +.hard-day-sub { + font-size: var(--font-size-xs, 0.75rem); + font-weight: 400; + opacity: 0.85; + margin-left: auto; +} + +/* Preset grid — auto-fill 2+ columns */ +.preset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--spacing-xs); } +.preset-btn { + justify-content: center; + text-align: center; +} + +.preset-active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +.allergen-btn { + justify-content: center; + text-align: center; +} + +.allergen-active { + background: var(--color-error, #c0392b); + color: white; + border-color: var(--color-error, #c0392b); +} + .swap-row { padding: var(--spacing-xs) 0; border-bottom: 1px solid var(--color-border);