init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
183
app/lib/grocery.ts
Normal file
183
app/lib/grocery.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue