feat(filter): wire up tag filtering with counts and search for recipes and ingredients
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user