perf(browser): replace LIKE scans with FTS5; cache category counts
- 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
This commit is contained in:
parent
4bb93b78d1
commit
a523cb094e
3 changed files with 211 additions and 57 deletions
43
app/db/migrations/021_recipe_browser_fts.sql
Normal file
43
app/db/migrations/021_recipe_browser_fts.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -66,6 +66,16 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hard Day Mode — surfaced as a top-level toggle button -->
|
||||
<button
|
||||
:class="['btn', 'hard-day-btn', recipesStore.hardDayMode ? 'hard-day-active' : 'btn-secondary']"
|
||||
@click="recipesStore.hardDayMode = !recipesStore.hardDayMode"
|
||||
:aria-pressed="recipesStore.hardDayMode"
|
||||
>
|
||||
🌿 Hard Day Mode
|
||||
<span class="hard-day-sub">{{ recipesStore.hardDayMode ? 'on — quick & simple only' : 'quick, simple recipes only' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Dietary Preferences (collapsible) -->
|
||||
<details class="collapsible form-group">
|
||||
<summary class="collapsible-summary filter-summary">
|
||||
|
|
@ -74,36 +84,45 @@
|
|||
</summary>
|
||||
|
||||
<div class="collapsible-body">
|
||||
<!-- Dietary Constraints Tags -->
|
||||
<!-- Dietary Constraints — preset checkboxes + other -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dietary Constraints</label>
|
||||
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
||||
<span
|
||||
v-for="tag in recipesStore.constraints"
|
||||
:key="tag"
|
||||
class="tag-chip status-badge status-info"
|
||||
>
|
||||
{{ tag }}
|
||||
<button class="chip-remove" @click="removeConstraint(tag)" :aria-label="'Remove constraint: ' + tag">×</button>
|
||||
</span>
|
||||
<label class="form-label">I eat</label>
|
||||
<div class="preset-grid">
|
||||
<button
|
||||
v-for="opt in dietaryPresets"
|
||||
:key="opt.value"
|
||||
:class="['btn', 'btn-sm', 'preset-btn', recipesStore.constraints.includes(opt.value) ? 'preset-active' : '']"
|
||||
@click="toggleConstraint(opt.value)"
|
||||
:aria-pressed="recipesStore.constraints.includes(opt.value)"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<input
|
||||
class="form-input"
|
||||
class="form-input mt-sm"
|
||||
v-model="constraintInput"
|
||||
placeholder="e.g. vegetarian, vegan, gluten-free"
|
||||
placeholder="Other (e.g. low-sodium, raw, whole30)"
|
||||
aria-describedby="constraint-hint"
|
||||
@keydown="onConstraintKey"
|
||||
@blur="commitConstraintInput"
|
||||
/>
|
||||
<span id="constraint-hint" class="form-hint">Press Enter or comma to add each item.</span>
|
||||
<span id="constraint-hint" class="form-hint">Press Enter or comma to add.</span>
|
||||
</div>
|
||||
|
||||
<!-- Allergies Tags -->
|
||||
<!-- Allergies — Big 9 quick-select + other -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Allergies (hard exclusions)</label>
|
||||
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
||||
<label class="form-label">Allergies <span class="text-muted text-xs">(hard exclusions)</span></label>
|
||||
<div class="preset-grid">
|
||||
<button
|
||||
v-for="allergen in allergenPresets"
|
||||
:key="allergen.value"
|
||||
:class="['btn', 'btn-sm', 'allergen-btn', recipesStore.allergies.includes(allergen.value) ? 'allergen-active' : '']"
|
||||
@click="toggleAllergy(allergen.value)"
|
||||
:aria-pressed="recipesStore.allergies.includes(allergen.value)"
|
||||
>{{ allergen.label }}</button>
|
||||
</div>
|
||||
<!-- Active custom allergy tags -->
|
||||
<div v-if="recipesStore.allergies.filter(a => !allergenPresets.map(p=>p.value).includes(a)).length > 0" class="tags-wrap flex flex-wrap gap-xs mt-xs">
|
||||
<span
|
||||
v-for="tag in recipesStore.allergies"
|
||||
v-for="tag in recipesStore.allergies.filter(a => !allergenPresets.map(p=>p.value).includes(a))"
|
||||
:key="tag"
|
||||
class="tag-chip status-badge status-error"
|
||||
>
|
||||
|
|
@ -112,25 +131,14 @@
|
|||
</span>
|
||||
</div>
|
||||
<input
|
||||
class="form-input"
|
||||
class="form-input mt-sm"
|
||||
v-model="allergyInput"
|
||||
placeholder="e.g. peanuts, shellfish, dairy"
|
||||
placeholder="Other allergen…"
|
||||
aria-describedby="allergy-hint"
|
||||
@keydown="onAllergyKey"
|
||||
@blur="commitAllergyInput"
|
||||
/>
|
||||
<span id="allergy-hint" class="form-hint">Press Enter or comma to add. Allergies are hard exclusions — no recipes containing these will appear.</span>
|
||||
</div>
|
||||
|
||||
<!-- Hard Day Mode -->
|
||||
<div class="form-group">
|
||||
<label class="flex-start gap-sm hard-day-toggle">
|
||||
<input type="checkbox" v-model="recipesStore.hardDayMode" />
|
||||
<span class="form-label" style="margin-bottom: 0;">Hard Day Mode</span>
|
||||
</label>
|
||||
<p v-if="recipesStore.hardDayMode" class="text-sm text-secondary mt-xs">
|
||||
Only suggests quick, simple recipes based on your saved equipment.
|
||||
</p>
|
||||
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
|
||||
</div>
|
||||
|
||||
<!-- Shopping Mode (temporary home — moves to Shopping tab in #71) -->
|
||||
|
|
@ -146,7 +154,7 @@
|
|||
|
||||
<!-- Max Missing — hidden in shopping mode -->
|
||||
<div v-if="!recipesStore.shoppingMode" class="form-group">
|
||||
<label class="form-label">Max Missing Ingredients (optional)</label>
|
||||
<label class="form-label">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
|
|
@ -655,10 +663,48 @@ const levels = [
|
|||
{ value: 4, label: 'Surprise Me 🎲', description: 'Fully AI-generated — open-ended and occasionally unexpected. Requires paid tier.' },
|
||||
]
|
||||
|
||||
const dietaryPresets = [
|
||||
{ label: 'Vegetarian', value: 'vegetarian' },
|
||||
{ label: 'Vegan', value: 'vegan' },
|
||||
{ label: 'Gluten-free', value: 'gluten-free' },
|
||||
{ label: 'Dairy-free', value: 'dairy-free' },
|
||||
{ label: 'Keto', value: 'keto' },
|
||||
{ label: 'Low-carb', value: 'low-carb' },
|
||||
{ label: 'Halal', value: 'halal' },
|
||||
{ label: 'Kosher', value: 'kosher' },
|
||||
]
|
||||
|
||||
const allergenPresets = [
|
||||
{ label: 'Peanuts', value: 'peanuts' },
|
||||
{ label: 'Tree nuts', value: 'tree nuts' },
|
||||
{ label: 'Shellfish', value: 'shellfish' },
|
||||
{ label: 'Fish', value: 'fish' },
|
||||
{ label: 'Milk', value: 'milk' },
|
||||
{ label: 'Eggs', value: 'eggs' },
|
||||
{ label: 'Wheat', value: 'wheat' },
|
||||
{ label: 'Soy', value: 'soy' },
|
||||
{ label: 'Sesame', value: 'sesame' },
|
||||
]
|
||||
|
||||
function toggleConstraint(value: string) {
|
||||
if (recipesStore.constraints.includes(value)) {
|
||||
recipesStore.constraints = recipesStore.constraints.filter((c) => 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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue