feat(filter): remove created by and prep stations, implement ingredient filter with recipe counts and search

This commit is contained in:
2026-08-18 13:40:48 -05:00
parent 974d99141e
commit 8c3926e6c7
2 changed files with 56 additions and 6 deletions
+36 -2
View File
@@ -29,9 +29,23 @@ const books = database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*
const purchases = database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id,
(SELECT amount FROM price_observations x WHERE x.purchase_item_id=p.id ORDER BY effective_at DESC LIMIT 1) latest_price
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as unknown as DirectoryPurchaseRow[];
const recipeItemRows = database.prepare("SELECT recipe_id, ingredient_id FROM recipe_items WHERE ingredient_id IS NOT NULL").all() as { recipe_id: string; ingredient_id: string }[];
const inventoryCounts = database.prepare("SELECT count(*) as c FROM inventory_counts WHERE deleted_at IS NULL").get() as { c: number } | undefined;
database.close();
const recipeIngredientsMap = new Map<string, Set<string>>();
const ingredientRecipeCounts = new Map<string, number>();
recipeItemRows.forEach(({ recipe_id, ingredient_id }) => {
if (!recipeIngredientsMap.has(recipe_id)) {
recipeIngredientsMap.set(recipe_id, new Set());
}
const set = recipeIngredientsMap.get(recipe_id)!;
if (!set.has(ingredient_id)) {
set.add(ingredient_id);
ingredientRecipeCounts.set(ingredient_id, (ingredientRecipeCounts.get(ingredient_id) || 0) + 1);
}
});
const requested=Astro.url.searchParams.get("type");
if (requested === "inventory") return Astro.redirect("/app/inventory/", 303);
const query=(Astro.url.searchParams.get("q")??"").trim();
@@ -40,6 +54,7 @@ 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);
const selectedIngredients=Astro.url.searchParams.getAll("ingredient").map(i=>i.trim().toLowerCase()).filter(Boolean);
// Default to "recipe" when not in a global search
const type = ["recipe","ingredient","book","purchase"].includes(requested ?? "")
@@ -115,6 +130,16 @@ const tagOptions = Array.from(tagCountsMap.entries())
}))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
const ingredientOptions = ingredients
.filter(i => (ingredientRecipeCounts.get(i.id) || 0) > 0)
.map(i => ({
id: i.id,
name: titleCase(i.name),
count: ingredientRecipeCounts.get(i.id) || 0,
checked: selectedIngredients.includes(i.id)
}))
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0) || a.name.localeCompare(b.name));
const currentParams = {
type: type ?? "",
q: query,
@@ -126,7 +151,8 @@ const currentParams = {
no_purchase: Astro.url.searchParams.get("no_purchase") ?? "",
no_usda: Astro.url.searchParams.get("no_usda") ?? "",
item_type: selectedSearchTypes,
tag: selectedTags
tag: selectedTags,
ingredient: selectedIngredients
};
const filteredIngredients=ingredients.filter((ingredient)=>{
@@ -171,6 +197,12 @@ const filteredRecipes=recipes.filter((recipe)=>{
if (!matchesTags) return false;
}
if (selectedIngredients.length > 0) {
const ingIds = recipeIngredientsMap.get(recipe.id) ?? new Set();
const matchesIngredients = selectedIngredients.some(id => ingIds.has(id));
if (!matchesIngredients) return false;
}
return true;
});
@@ -182,7 +214,7 @@ const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredien
{type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"},
];
const allSearchResults=normalizedQuery ? [
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t)))).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t))) && (selectedIngredients.length === 0 || (recipeIngredientsMap.get(item.id) ?? new Set()).some(id => selectedIngredients.includes(id)))).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t)))).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})),
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})),
@@ -199,6 +231,7 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
<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}/>)}
{selectedIngredients.map((ing)=><input type="hidden" name="ingredient" value={ing}/>)}
<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>}
@@ -228,6 +261,7 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
ingredientCounts={ingredientAttentionCounts}
itemTypeCounts={searchItemTypeCounts}
tagOptions={tagOptions}
ingredientOptions={ingredientOptions}
currentParams={currentParams}
/>
</nav>
+20 -4
View File
@@ -36,6 +36,7 @@ interface Props {
purchase: number;
};
tagOptions?: FilterOption[];
ingredientOptions?: FilterOption[];
currentParams: Record<string, string | string[]>;
}
@@ -64,6 +65,7 @@ export default function WorkspaceFilterBar({
ingredientCounts,
itemTypeCounts,
tagOptions = [],
ingredientOptions = [],
currentParams
}: Props) {
const [menuOpen, setMenuOpen] = useState(false);
@@ -115,6 +117,12 @@ export default function WorkspaceFilterBar({
? [currentParams["tag"] as string]
: [];
const selectedIngredients = Array.isArray(currentParams["ingredient"])
? currentParams["ingredient"]
: currentParams["ingredient"]
? [currentParams["ingredient"] as string]
: [];
// Determine configured categories
const categories: FilterCategoryConfig[] = [];
@@ -130,9 +138,7 @@ export default function WorkspaceFilterBar({
]
});
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." });
categories.push({ id: "ingredients", label: "Ingredients", icon: ICONS.ingredient, options: ingredientOptions, emptyMessage: "No additional filter options available." });
} else if (isIngredient) {
categories.push({
id: "needs_attention",
@@ -145,7 +151,6 @@ export default function WorkspaceFilterBar({
]
});
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({
id: "item_type",
@@ -159,6 +164,7 @@ export default function WorkspaceFilterBar({
]
});
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: ingredientOptions, emptyMessage: "No additional filter options available." });
}
// Helper to check if a category has active selected options
@@ -197,6 +203,8 @@ export default function WorkspaceFilterBar({
params.delete("item_type");
} else if (catId === "tags") {
params.delete("tag");
} else if (catId === "ingredients") {
params.delete("ingredient");
} else {
cat.options.forEach(opt => params.delete(opt.id));
params.delete("attention");
@@ -232,6 +240,14 @@ export default function WorkspaceFilterBar({
} else {
[...current, optionId].forEach(v => params.append("tag", v));
}
} else if (catId === "ingredients") {
const current = params.getAll("ingredient");
params.delete("ingredient");
if (currentChecked) {
current.filter(v => v !== optionId).forEach(v => params.append("ingredient", v));
} else {
[...current, optionId].forEach(v => params.append("ingredient", v));
}
} else {
if (currentChecked) {
params.delete(optionId);