184 lines
7 KiB
TypeScript
184 lines
7 KiB
TypeScript
|
|
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;
|