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:
pyr0ball 2026-04-08 23:10:48 -07:00
parent 4bb93b78d1
commit a523cb094e
3 changed files with 211 additions and 57 deletions

View 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;

View file

@ -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

View file

@ -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 &amp; 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);