Files
formulation/src/application/pages/app/index.astro
T
nicholasandnicholas 2a1e16ed30
Build & Deploy Formulation / Build & Push Image (push) Failing after 15s
Build & Deploy Formulation / deploy (push) Skipped
add core features (#14)
Reviewed-on: #14
Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
2026-08-18 18:22:34 -05:00

278 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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
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
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 || Array.from(recipeIngredientsMap.get(item.id) ?? []).some((id: string) => 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 ?? undefined}
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>