import type { GroceryLine, GrocerySummary, ItemId, StoreItem } from "@/app/types"; import { averageGroceryProvider, type PriceProvider } from "./pricing"; import { RECIPES_BY_ID } from "./recipes"; import { toBaseUnit } from "./units"; // --------------------------------------------------------------------------- // Grocery aggregation + whole-package costing. // // 1. For every planned meal, scale its ingredients to the household size // and convert to the store item's base unit. // 2. Sum per store item across all meals. // 3. Round UP to whole packages. That's the checkout cost. // 4. Also compute the value actually eaten, so the gap (carryover) is visible. // // Why whole packages? You can't buy 0.3 lb of rice. Rounding up is what makes // ingredient overlap matter: a second rice dish is nearly free once the bag is // already in the cart. // // Two entry points: // buildGroceryList the full, sorted list for the drawer and budget meter // Cart "what would adding this recipe cost?", asked hundreds of // times by auto-fill and the meal picker. It only looks at // the recipe's own ingredients instead of rebuilding the list. // --------------------------------------------------------------------------- export interface GroceryOptions { householdSize: number; /** Items already in the kitchen: listed, but cost $0. */ onHandItems: ItemId[]; provider?: PriceProvider; } /** Float-safety: 2.0000000001 packages should still be 2. */ const EPSILON = 1e-6; const packagesFor = (amount: number, item: StoreItem) => Math.max(1, Math.ceil(amount / item.packageSize - EPSILON)); /** What a line costs at checkout and what's actually eaten. On-hand items cost nothing. */ function lineCost(amount: number, item: StoreItem, onHand: boolean) { if (onHand || amount <= 0) return { checkout: 0, eaten: 0 }; return { checkout: packagesFor(amount, item) * item.price, eaten: (amount / item.packageSize) * item.price, }; } /** One recipe's ingredients for this household, in each item's base unit. */ interface RecipeNeeds { title: string; amounts: Map; } function recipeNeeds(recipeId: string, householdSize: number, provider: PriceProvider): RecipeNeeds | null { const recipe = RECIPES_BY_ID.get(recipeId); if (!recipe) return null; const scale = householdSize / recipe.servings; const amounts = new Map(); for (const ingredient of recipe.ingredients) { const item = provider.getItem(ingredient.itemId); if (!item) continue; // unknown to this price source; skip rather than crash the UI const amount = toBaseUnit(ingredient.amount, ingredient.unit, item) * scale; amounts.set(item.id, (amounts.get(item.id) ?? 0) + amount); } return { title: recipe.title, amounts }; } // --- Full list ---------------------------------------------------------------- export function buildGroceryList(recipeIds: string[], options: GroceryOptions): GrocerySummary { const provider = options.provider ?? averageGroceryProvider; const onHandItems = new Set(options.onHandItems); // itemId → running total + which recipes use it const needs = new Map }>(); for (const recipeId of recipeIds) { const recipe = recipeNeeds(recipeId, options.householdSize, provider); if (!recipe) continue; for (const [itemId, amount] of recipe.amounts) { const entry = needs.get(itemId) ?? { amount: 0, usedBy: new Set() }; entry.amount += amount; entry.usedBy.add(recipe.title); needs.set(itemId, entry); } } const lines: GroceryLine[] = []; for (const [itemId, { amount, usedBy }] of needs) { const item = provider.getItem(itemId)!; const onHand = onHandItems.has(itemId); const packages = packagesFor(amount, item); // Still compute packages for on-hand items: the list shows them so you can check your shelf. const cost = lineCost(amount, item, onHand); lines.push({ item, neededAmount: amount, packages, checkoutCost: cost.checkout, usedCost: cost.eaten, leftoverAmount: packages * item.packageSize - amount, onHand, usedBy: [...usedBy], }); } // Group by aisle, on-hand items last, then most expensive first: easy to scan in-store. lines.sort( (a, b) => a.item.aisle.localeCompare(b.item.aisle) || Number(a.onHand) - Number(b.onHand) || b.checkoutCost - a.checkoutCost, ); const checkoutTotal = sum(lines.map((l) => l.checkoutCost)); const usedTotal = sum(lines.map((l) => l.usedCost)); return { lines, checkoutTotal: round2(checkoutTotal), usedTotal: round2(usedTotal), carryoverValue: round2(checkoutTotal - usedTotal), sharedItemCount: lines.filter((l) => l.usedBy.length > 1).length, }; } // --- Incremental cart --------------------------------------------------------- /** * A running cart for "what does this recipe add?" questions. * * const cart = new Cart(options, plannedIds); * cart.marginal("beef-stroganoff") // { checkout: 2.48, eaten: 3.10 } * cart.add("beef-stroganoff") * * Gives the same answer as building the list with and without the recipe and * subtracting, but only touches the recipe's own ingredients: every other line * in the cart cancels out. */ export class Cart { private readonly totals = new Map(); private readonly needsCache = new Map(); private readonly provider: PriceProvider; private readonly onHandItems: Set; constructor( private readonly options: GroceryOptions, recipeIds: string[] = [], ) { this.provider = options.provider ?? averageGroceryProvider; this.onHandItems = new Set(options.onHandItems); for (const id of recipeIds) this.add(id); } add(recipeId: string): void { for (const [itemId, amount] of this.needsOf(recipeId)) { this.totals.set(itemId, (this.totals.get(itemId) ?? 0) + amount); } } /** Extra checkout cost and extra eaten value if this recipe were added. */ marginal(recipeId: string): { checkout: number; eaten: number } { let checkout = 0; let eaten = 0; for (const [itemId, amount] of this.needsOf(recipeId)) { const item = this.provider.getItem(itemId)!; const onHand = this.onHandItems.has(itemId); const before = this.totals.get(itemId) ?? 0; const withIt = lineCost(before + amount, item, onHand); const without = lineCost(before, item, onHand); checkout += withIt.checkout - without.checkout; eaten += withIt.eaten - without.eaten; } return { checkout, eaten }; } private needsOf(recipeId: string): Map { if (!this.needsCache.has(recipeId)) { this.needsCache.set(recipeId, recipeNeeds(recipeId, this.options.householdSize, this.provider)); } return this.needsCache.get(recipeId)?.amounts ?? new Map(); } } const sum = (values: number[]) => values.reduce((total, v) => total + v, 0); const round2 = (value: number) => Math.round(value * 100) / 100;