feat(filter): wire up tag filtering with counts and search for recipes and ingredients

This commit is contained in:
2026-08-18 13:26:12 -05:00
parent 15afcc5c60
commit 1aaab51dc4
3 changed files with 109 additions and 25 deletions
+69 -11
View File
@@ -16,11 +16,11 @@ import type {
const database = openDatabase();
if (!database) return new Response("Database unavailable", { status: 503 });
const recipes = database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,
const recipes = database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,r.tags_json,r.categories_json,r.station,
(SELECT count(*) FROM recipe_items ri WHERE ri.recipe_id=r.id) item_count,
(SELECT count(*) FROM recipe_steps rs WHERE rs.recipe_id=r.id AND rs.instruction LIKE 'TODO:%') placeholder_count
FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as unknown as DirectoryRecipeRow[];
const ingredients = database.prepare(`SELECT i.id,i.name,i.status,
const ingredients = database.prepare(`SELECT i.id,i.name,i.status,i.tags_json,i.categories_json,
(SELECT count(*) FROM recipe_items r WHERE r.ingredient_id=i.id) recipe_count,
(SELECT count(*) FROM price_observations po JOIN purchase_items p ON p.id=po.purchase_item_id WHERE p.ingredient_id=i.id) price_count,
(SELECT count(*) FROM source_mappings m WHERE m.subject_type='ingredient' AND m.subject_id=i.id AND m.mapping_type='nutrition' AND m.status='reviewed') nutrition_count
@@ -40,6 +40,7 @@ const normalizedQuery=query.toLocaleLowerCase();
const validSearchTypes=["recipe","ingredient","book","purchase"];
const selectedSearchTypes=Astro.url.searchParams.getAll("item_type").filter((value)=>validSearchTypes.includes(value));
const filteringSearchTypes=selectedSearchTypes.length>0;
const selectedTags=Astro.url.searchParams.getAll("tag").map(t=>t.trim().toLowerCase()).filter(Boolean);
// Recipe attention filters (Meez spec)
const emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1";
@@ -74,6 +75,42 @@ const searchItemTypeCounts = {
purchase: purchases.filter(item => `${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length
};
function parseItemTags(tagsJson?: string, categoriesJson?: string): string[] {
const set = new Set<string>();
if (tagsJson) {
try {
const arr = JSON.parse(tagsJson);
if (Array.isArray(arr)) arr.forEach((t: string) => t && set.add(String(t).trim().toLowerCase()));
} catch {}
}
if (categoriesJson) {
try {
const arr = JSON.parse(categoriesJson);
if (Array.isArray(arr)) arr.forEach((c: string) => c && set.add(String(c).trim().toLowerCase()));
} catch {}
}
return Array.from(set);
}
// Compute tag counts
const activeDataset = type === "ingredient" ? ingredients : recipes;
const tagCountsMap = new Map<string, number>();
activeDataset.forEach((item) => {
const tags = parseItemTags(item.tags_json, item.categories_json);
tags.forEach((t) => {
tagCountsMap.set(t, (tagCountsMap.get(t) || 0) + 1);
});
});
const tagOptions = Array.from(tagCountsMap.entries())
.map(([tag, count]) => ({
id: tag,
name: titleCase(tag.replace(/_/g, " ")),
count,
checked: selectedTags.includes(tag)
}))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
const currentParams = {
type: type ?? "",
q: query,
@@ -84,34 +121,53 @@ const currentParams = {
missing_cost: Astro.url.searchParams.get("missing_cost") ?? "",
no_purchase: Astro.url.searchParams.get("no_purchase") ?? "",
no_usda: Astro.url.searchParams.get("no_usda") ?? "",
item_type: selectedSearchTypes
item_type: selectedSearchTypes,
tag: selectedTags
};
const filteredIngredients=ingredients.filter((ingredient)=>{
if (!ingredientFilterActive) return true;
if (ingredientFilterActive) {
const conditions = [
unused && ingredient.recipe_count === 0,
missingCost && ingredient.price_count === 0,
noPurchase && (ingredient.price_count === 0 || ingredient.nutrition_count === 0)
];
if (unused || missingCost || noPurchase) {
return conditions.some(Boolean);
const matchesAttention = (unused || missingCost || noPurchase)
? conditions.some(Boolean)
: (ingredient.recipe_count === 0 || ingredient.price_count === 0 || ingredient.nutrition_count === 0);
if (!matchesAttention) return false;
}
return ingredient.recipe_count === 0 || ingredient.price_count === 0 || ingredient.nutrition_count === 0;
if (selectedTags.length > 0) {
const tags = parseItemTags(ingredient.tags_json, ingredient.categories_json);
const matchesTags = selectedTags.some(t => tags.includes(t));
if (!matchesTags) return false;
}
return true;
});
const filteredRecipes=recipes.filter((recipe)=>{
if (!recipeFilterActive) return true;
if (recipeFilterActive) {
const isMissingYield = !recipe.yield_quantity || Number(recipe.yield_quantity) <= 0 || !recipe.yield_unit_id;
const conditions = [
emptyRecipe && recipe.item_count === 0,
missingYield && isMissingYield,
placeholderSteps && recipe.placeholder_count > 0
];
if (emptyRecipe || missingYield || placeholderSteps) {
return conditions.some(Boolean);
const matchesAttention = (emptyRecipe || missingYield || placeholderSteps)
? conditions.some(Boolean)
: (recipe.item_count === 0 || isMissingYield || recipe.placeholder_count > 0);
if (!matchesAttention) return false;
}
return recipe.item_count === 0 || isMissingYield || recipe.placeholder_count > 0;
if (selectedTags.length > 0) {
const tags = parseItemTags(recipe.tags_json, recipe.categories_json);
const matchesTags = selectedTags.some(t => tags.includes(t));
if (!matchesTags) return false;
}
return true;
});
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[
@@ -138,6 +194,7 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
<form class="workspace-global-search" method="get" action="/app/" role="search">
<input type="hidden" name="type" value={type??""}/>
{selectedSearchTypes.map((selectedType)=><input type="hidden" name="item_type" value={selectedType}/>)}
{selectedTags.map((tag)=><input type="hidden" name="tag" value={tag}/>)}
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="search" name="q" value={query} placeholder="Search " aria-label="Search all items" autofocus={Boolean(query)}/>
{query&&<a href={type?`/app/?type=${type}`:"/app/"} aria-label="Clear search">×</a>}
@@ -166,6 +223,7 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
recipeCounts={recipeAttentionCounts}
ingredientCounts={ingredientAttentionCounts}
itemTypeCounts={searchItemTypeCounts}
tagOptions={tagOptions}
currentParams={currentParams}
/>
</nav>
+24 -3
View File
@@ -35,6 +35,7 @@ interface Props {
book: number;
purchase: number;
};
tagOptions?: FilterOption[];
currentParams: Record<string, string | string[]>;
}
@@ -62,6 +63,7 @@ export default function WorkspaceFilterBar({
recipeCounts,
ingredientCounts,
itemTypeCounts,
tagOptions = [],
currentParams
}: Props) {
const [menuOpen, setMenuOpen] = useState(false);
@@ -107,6 +109,12 @@ export default function WorkspaceFilterBar({
? [currentParams["item_type"] as string]
: [];
const selectedTags = Array.isArray(currentParams["tag"])
? currentParams["tag"]
: currentParams["tag"]
? [currentParams["tag"] as string]
: [];
// Determine configured categories
const categories: FilterCategoryConfig[] = [];
@@ -121,7 +129,7 @@ export default function WorkspaceFilterBar({
{ id: "placeholder_steps", name: "Contain undefined ingredients / steps", count: recipeCounts.placeholder, checked: placeholderStepsActive }
]
});
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: [], emptyMessage: "No additional filter options available." });
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: tagOptions, emptyMessage: "No additional filter options available." });
categories.push({ id: "ingredients", label: "Ingredients", icon: ICONS.ingredient, options: [], emptyMessage: "No additional filter options available." });
categories.push({ id: "created_by", label: "Created By", icon: ICONS.person, options: [], emptyMessage: "No additional filter options available." });
categories.push({ id: "prep_stations", label: "Prep Stations", icon: ICONS.station, options: [], emptyMessage: "No additional filter options available." });
@@ -136,7 +144,7 @@ export default function WorkspaceFilterBar({
{ id: "no_purchase", name: "No vendor purchase associated", count: ingredientCounts.noPurchase, checked: noPurchaseActive }
]
});
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: [], emptyMessage: "No additional filter options available." });
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: tagOptions, emptyMessage: "No additional filter options available." });
categories.push({ id: "created_by", label: "Created By", icon: ICONS.person, options: [], emptyMessage: "No additional filter options available." });
} else {
categories.push({
@@ -150,7 +158,7 @@ export default function WorkspaceFilterBar({
{ id: "purchase", name: "Purchase items", count: itemTypeCounts?.purchase, checked: selectedItemTypes.includes("purchase") }
]
});
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: [], emptyMessage: "No additional filter options available." });
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: tagOptions, emptyMessage: "No additional filter options available." });
}
// Active filter categories in state
@@ -165,6 +173,9 @@ export default function WorkspaceFilterBar({
if (isGlobalSearch && selectedItemTypes.length > 0) {
ids.push("item_type");
}
if (selectedTags.length > 0) {
ids.push("tags");
}
return ids;
});
@@ -191,6 +202,8 @@ export default function WorkspaceFilterBar({
if (cat) {
if (catId === "item_type") {
params.delete("item_type");
} else if (catId === "tags") {
params.delete("tag");
} else {
cat.options.forEach(opt => params.delete(opt.id));
params.delete("attention");
@@ -219,6 +232,14 @@ export default function WorkspaceFilterBar({
} else {
[...current, optionId].forEach(v => params.append("item_type", v));
}
} else if (catId === "tags") {
const current = params.getAll("tag");
params.delete("tag");
if (currentChecked) {
current.filter(v => v !== optionId).forEach(v => params.append("tag", v));
} else {
[...current, optionId].forEach(v => params.append("tag", v));
}
} else {
if (currentChecked) {
params.delete(optionId);
+5
View File
@@ -155,6 +155,9 @@ export type DirectoryRecipeRow = {
yield_unit_id: string;
item_count: number;
placeholder_count: number;
tags_json?: string;
categories_json?: string;
station?: string | null;
};
export type DirectoryIngredientRow = {
@@ -164,6 +167,8 @@ export type DirectoryIngredientRow = {
recipe_count: number;
price_count: number;
nutrition_count: number;
tags_json?: string;
categories_json?: string;
};
export type DirectoryBookRow = {