diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index 87a9e90..d09612d 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -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>(); +const ingredientRecipeCounts = new Map(); +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 {selectedSearchTypes.map((selectedType)=>)} {selectedTags.map((tag)=>)} + {selectedIngredients.map((ing)=>)} {query&&×} @@ -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} /> diff --git a/src/components/WorkspaceFilterBar.tsx b/src/components/WorkspaceFilterBar.tsx index cf511d8..1f9a2ad 100644 --- a/src/components/WorkspaceFilterBar.tsx +++ b/src/components/WorkspaceFilterBar.tsx @@ -36,6 +36,7 @@ interface Props { purchase: number; }; tagOptions?: FilterOption[]; + ingredientOptions?: FilterOption[]; currentParams: Record; } @@ -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);