planmymeal/app/lib/recipes/index.ts

113 lines
4.7 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
import type { Recipe, RecipeData } from "@/app/types";
import { CATALOG_BY_ID } from "../catalog";
import { toBaseUnit } from "../units";
import { BREAKFASTS } from "./breakfasts";
import { CAYDENS_CORNER_DESSERTS, CAYDENS_CORNER_MAINS, CAYDENS_CORNER_SIDES } from "./caydens-corner";
import { EXTRA_AMERICAN, EXTRA_SOUTHERN } from "./extra/americas-north";
import { EXTRA_CARIBBEAN, EXTRA_MEXICAN } from "./extra/americas-south";
import { EXTRA_CHINESE, EXTRA_INDIAN, EXTRA_JAPANESE, EXTRA_KOREAN, EXTRA_THAI } from "./extra/asia";
import { EXTRA_BREAKFASTS } from "./extra/breakfasts";
import { EXTRA_ITALIAN, EXTRA_MEDITERRANEAN, EXTRA_MIDDLE_EASTERN } from "./extra/mediterranean";
import { EXTRA_SEAFOOD } from "./extra/seafood";
import { MAINS_AMERICAS } from "./mains-americas";
import { MAINS_ASIA } from "./mains-asia";
import { MAINS_MEDITERRANEAN } from "./mains-mediterranean";
import { SEAFOOD } from "./seafood";
import { CAYDENS_CORNER_STEPS } from "./steps/caydens-corner";
import { CORE_STEPS } from "./steps/core";
import { EXTRA_AMERICAS_STEPS } from "./steps/extra-americas";
import { EXTRA_ASIA_STEPS } from "./steps/extra-asia";
import { EXTRA_BREAKFAST_STEPS } from "./steps/extra-breakfasts";
import { EXTRA_MEDITERRANEAN_STEPS } from "./steps/extra-mediterranean";
import { EXTRA_SEAFOOD_STEPS } from "./steps/extra-seafood";
// ---------------------------------------------------------------------------
// Recipe database.
//
// ./*.ts the original recipes, written as full objects
// ./extra/*.ts the big library, written in the compact `dishes()` format
// ./caydens-corner.ts family recipes
// ./steps/*.ts cooking instructions for all of the above, keyed by recipe id
//
// Ingredients deliberately overlap (rice, chicken, onion, garlic, soy sauce,
// canned tomatoes, tortillas…) so the planner has real overlap to exploit.
// Amounts are for `servings` people and get scaled to household size.
// ---------------------------------------------------------------------------
const CORE: RecipeData[] = [...BREAKFASTS, ...MAINS_AMERICAS, ...MAINS_MEDITERRANEAN, ...MAINS_ASIA, ...SEAFOOD];
const EXTRA: RecipeData[] = [
...EXTRA_BREAKFASTS,
...EXTRA_AMERICAN,
...EXTRA_SOUTHERN,
...EXTRA_MEXICAN,
...EXTRA_CARIBBEAN,
...EXTRA_ITALIAN,
...EXTRA_MEDITERRANEAN,
...EXTRA_MIDDLE_EASTERN,
...EXTRA_INDIAN,
...EXTRA_CHINESE,
...EXTRA_JAPANESE,
...EXTRA_KOREAN,
...EXTRA_THAI,
...EXTRA_SEAFOOD,
];
const STEPS: Record<string, string[]> = {
...CORE_STEPS,
...EXTRA_BREAKFAST_STEPS,
...EXTRA_AMERICAS_STEPS,
...EXTRA_MEDITERRANEAN_STEPS,
...EXTRA_ASIA_STEPS,
...EXTRA_SEAFOOD_STEPS,
...CAYDENS_CORNER_STEPS,
};
/** Cayden's Corner: family recipes. */
const FAMILY: RecipeData[] = [...CAYDENS_CORNER_SIDES, ...CAYDENS_CORNER_MAINS, ...CAYDENS_CORNER_DESSERTS];
const ALL: RecipeData[] = [...CORE, ...EXTRA, ...FAMILY];
// --- Load-time checks ---------------------------------------------------------
// A typo in recipe data should crash the app on startup (and fail `next build`),
// not quietly drop an ingredient from someone's grocery bill.
// 1. Every recipe must have instructions, and every instruction set must belong to a recipe.
const missingSteps = ALL.filter((r) => !STEPS[r.id]?.length).map((r) => r.id);
const recipeIds = new Set(ALL.map((r) => r.id));
const orphanSteps = Object.keys(STEPS).filter((id) => !recipeIds.has(id));
if (missingSteps.length || orphanSteps.length) {
throw new Error(`Recipe steps out of sync. Missing: [${missingSteps.join(", ")}] Orphaned: [${orphanSteps.join(", ")}]`);
}
// 2. Every ingredient must be a real store item, in a unit that converts to how it's sold.
const badIngredients: string[] = [];
for (const recipe of ALL) {
for (const { itemId, amount, unit } of recipe.ingredients) {
const item = CATALOG_BY_ID.get(itemId);
if (!item) {
badIngredients.push(`${recipe.id}: unknown item "${itemId}"`);
continue;
}
try {
toBaseUnit(amount, unit, item);
} catch (error) {
badIngredients.push(`${recipe.id}: ${(error as Error).message}`);
}
}
}
if (badIngredients.length) {
throw new Error(`Bad recipe ingredients:\n ${badIngredients.join("\n ")}`);
}
export const RECIPES: Recipe[] = ALL.map((r) => ({ ...r, steps: STEPS[r.id] }));
export const RECIPES_BY_ID: ReadonlyMap<string, Recipe> = new Map(RECIPES.map((r) => [r.id, r]));
// 3. Duplicate ids would silently shadow each other in RECIPES_BY_ID; fail loudly instead.
if (RECIPES_BY_ID.size !== RECIPES.length) {
const seen = new Set<string>();
const dupes = RECIPES.filter((r) => (seen.has(r.id) ? true : (seen.add(r.id), false))).map((r) => r.id);
throw new Error(`Duplicate recipe ids: ${dupes.join(", ")}`);
}