278 lines
17 KiB
Plaintext
278 lines
17 KiB
Plaintext
---
|
||
export const prerender = false;
|
||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||
import EntityDirectory from "../../../components/EntityDirectory";
|
||
import WorkspaceFilterBar from "../../../components/WorkspaceFilterBar";
|
||
import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../../../lib/icons";
|
||
import { openDatabase } from "../../../lib/database";
|
||
import { readOnlyMode } from "../../../lib/runtime";
|
||
import { titleCase } from "../../../lib/format";
|
||
import type {
|
||
DirectoryBookRow,
|
||
DirectoryIngredientRow,
|
||
DirectoryPurchaseRow,
|
||
DirectoryRecipeRow,
|
||
} from "../../../lib/repository";
|
||
|
||
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,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,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
|
||
FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as unknown as DirectoryIngredientRow[];
|
||
const books = database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as unknown as DirectoryBookRow[];
|
||
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();
|
||
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);
|
||
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 ?? "")
|
||
? requested
|
||
: (query ? undefined : "recipe");
|
||
|
||
// Recipe attention filters (Meez spec)
|
||
const emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1";
|
||
const missingYield=Astro.url.searchParams.get("missing_yield")==="1";
|
||
const placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1";
|
||
const attentionRecipe=Astro.url.searchParams.get("attention")==="1";
|
||
const recipeFilterActive=emptyRecipe||missingYield||placeholderSteps||attentionRecipe;
|
||
|
||
// Ingredient attention filters (Meez spec)
|
||
const unused=Astro.url.searchParams.get("unused")==="1";
|
||
const missingCost=Astro.url.searchParams.get("missing_cost")==="1";
|
||
const noPurchase=Astro.url.searchParams.get("no_purchase")==="1" || Astro.url.searchParams.get("no_usda")==="1";
|
||
const attentionIngredient=Astro.url.searchParams.get("attention")==="1";
|
||
const ingredientFilterActive=unused||missingCost||noPurchase||attentionIngredient;
|
||
|
||
const recipeAttentionCounts = {
|
||
empty: recipes.filter(r => r.item_count === 0).length,
|
||
missingYield: recipes.filter(r => !r.yield_quantity || Number(r.yield_quantity) <= 0 || !r.yield_unit_id).length,
|
||
placeholder: recipes.filter(r => r.placeholder_count > 0).length
|
||
};
|
||
|
||
const ingredientAttentionCounts = {
|
||
unused: ingredients.filter(i => i.recipe_count === 0).length,
|
||
missingCost: ingredients.filter(i => i.price_count === 0).length,
|
||
noPurchase: ingredients.filter(i => i.price_count === 0 || i.nutrition_count === 0).length
|
||
};
|
||
|
||
const searchItemTypeCounts = {
|
||
recipe: recipes.filter(item => `${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length,
|
||
ingredient: ingredients.filter(item => `${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length,
|
||
book: books.filter(item => `${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length,
|
||
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 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,
|
||
empty_recipe: Astro.url.searchParams.get("empty_recipe") ?? "",
|
||
missing_yield: Astro.url.searchParams.get("missing_yield") ?? "",
|
||
placeholder_steps: Astro.url.searchParams.get("placeholder_steps") ?? "",
|
||
unused: Astro.url.searchParams.get("unused") ?? "",
|
||
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,
|
||
tag: selectedTags,
|
||
ingredient: selectedIngredients
|
||
};
|
||
|
||
const filteredIngredients=ingredients.filter((ingredient)=>{
|
||
if (ingredientFilterActive) {
|
||
const conditions = [
|
||
unused && ingredient.recipe_count === 0,
|
||
missingCost && ingredient.price_count === 0,
|
||
noPurchase && (ingredient.price_count === 0 || ingredient.nutrition_count === 0)
|
||
];
|
||
const matchesAttention = (unused || missingCost || noPurchase)
|
||
? conditions.some(Boolean)
|
||
: (ingredient.recipe_count === 0 || ingredient.price_count === 0 || ingredient.nutrition_count === 0);
|
||
if (!matchesAttention) return false;
|
||
}
|
||
|
||
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) {
|
||
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
|
||
];
|
||
const matchesAttention = (emptyRecipe || missingYield || placeholderSteps)
|
||
? conditions.some(Boolean)
|
||
: (recipe.item_count === 0 || isMissingYield || recipe.placeholder_count > 0);
|
||
if (!matchesAttention) return false;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
});
|
||
|
||
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[
|
||
{type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"},
|
||
{type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"},
|
||
{type:"book",label:"Recipe books",count:books.length,kind:"book"},
|
||
{type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"},
|
||
{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))) && (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`})),
|
||
].sort((a,b)=>a.name.localeCompare(b.name)):[];
|
||
const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults;
|
||
const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`}));
|
||
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const}));
|
||
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const}));
|
||
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const}));
|
||
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const}));
|
||
---
|
||
<BaseLayout title="Recipe management"><div class="workspace-search-tools"><div class="workspace-search-tools-inner">
|
||
<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}/>)}
|
||
{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>}
|
||
</form>
|
||
{!readOnlyMode&&<details class="workspace-new-menu">
|
||
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span class="new-trigger-label">New</span></summary>
|
||
<nav aria-label="Create new item">
|
||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.recipe} style={TYPE_ICON_TRANSFORMS.recipe?{transform:TYPE_ICON_TRANSFORMS.recipe}:undefined}/></svg></span><strong>Recipe</strong></a>
|
||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.book} style={TYPE_ICON_TRANSFORMS.book?{transform:TYPE_ICON_TRANSFORMS.book}:undefined}/></svg></span><strong>Recipe book</strong></a>
|
||
</nav>
|
||
</details>}
|
||
</div>
|
||
</div>
|
||
<section class="shell directory-workspace">
|
||
<div class="workspace-nav-block">
|
||
{!readOnlyMode&&<nav class="workspace-utility-nav" aria-label="Secondary tools">
|
||
<a class="workspace-utility-link" href="/app/archive/">Archive</a>
|
||
<a class="workspace-utility-link" href="/app/settings/">Data Management</a>
|
||
</nav>}
|
||
<nav class="workspace-pills" aria-label="Workspaces">
|
||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={tab.href ?? `/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||
<WorkspaceFilterBar
|
||
client:load
|
||
type={type}
|
||
query={query}
|
||
recipeCounts={recipeAttentionCounts}
|
||
ingredientCounts={ingredientAttentionCounts}
|
||
itemTypeCounts={searchItemTypeCounts}
|
||
tagOptions={tagOptions}
|
||
ingredientOptions={ingredientOptions}
|
||
currentParams={currentParams}
|
||
/>
|
||
</nav>
|
||
</div>
|
||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchRows.length?<EntityDirectory client:load rows={searchRows} emptyMessage="No items of the selected types match this search." readOnly={readOnlyMode}/>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters." readOnly={readOnlyMode}/>}
|
||
{type==="recipe"&&<EntityDirectory client:load rows={recipeRows} entityType="recipe" emptyMessage="No recipes yet." readOnly={readOnlyMode}/>}
|
||
{type==="book"&&<EntityDirectory client:load rows={bookRows} entityType="book" emptyMessage="No recipe books yet." readOnly={readOnlyMode}/>}
|
||
{type==="purchase"&&<EntityDirectory client:load rows={purchaseRows} entityType="purchase" emptyMessage="No purchase items yet." readOnly={readOnlyMode}/>}
|
||
{!type&&<p class="empty-state">Select a workspace or search to browse recipes, ingredients, and purchase items.</p>}
|
||
</>}
|
||
</section>
|
||
</BaseLayout>
|