This commit is contained in:
Cayden Schwechten 2026-10-31 20:44:29 -05:00
commit c19b910c36
80 changed files with 16134 additions and 0 deletions

183
app/lib/grocery.ts Normal file
View file

@ -0,0 +1,183 @@
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<ItemId, number>;
}
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<ItemId, number>();
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<ItemId, { amount: number; usedBy: Set<string> }>();
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<string>() };
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<ItemId, number>();
private readonly needsCache = new Map<string, RecipeNeeds | null>();
private readonly provider: PriceProvider;
private readonly onHandItems: Set<ItemId>;
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<ItemId, number> {
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;