112 lines
4.7 KiB
TypeScript
112 lines
4.7 KiB
TypeScript
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(", ")}`);
|
|
}
|