feat(filter): remove created by and prep stations, implement ingredient filter with recipe counts and search
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user