planmymeal/app/lib/recipes/dish.ts
Cayden Schwechten df5dd3a70e Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
  SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
  color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
  drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
  above the menu with the staples list spelled out, sticky aisle
  header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
  (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:36:33 -06:00

79 lines
3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Appliance, CookingStyle, Cuisine, MealKind, RecipeData, RecipeIngredient, RecipeUnit } from "@/app/types";
// ---------------------------------------------------------------------------
// Compact recipe format, used for the big recipe library in ./extra/.
//
// Instead of a 12-line object per recipe, each dish is one entry with its
// ingredients written like a shopping list:
//
// { title: "Beef Stroganoff", mins: 30, tools: ["stovetop"], styles: ["comfort"],
// about: "Creamy mushroom sauce over egg noodles.",
// items: "ground_beef 1 lb; egg_noodles 12 oz; mushrooms 8 oz; sour_cream 0.5 cup" }
//
// Each ingredient is "<catalog item id> <amount> <unit>". Bad units throw at
// module load, so typos fail loudly in dev instead of producing $0 groceries.
// ---------------------------------------------------------------------------
interface DishInput {
title: string;
/** Defaults to a slug of the title. Set it explicitly to keep saved plans stable if you rename. */
id?: string;
about: string;
mins: number;
tools: Appliance[];
styles?: CookingStyle[];
/** Defaults to the file's cuisine/kind (see `dishes`). */
cuisine?: Cuisine;
kind?: MealKind;
/** People one batch feeds. Default 4. */
serves?: number;
/** "item_id amount unit; item_id amount unit; …" */
items: string;
/** Optional cook's tip shown under the steps. */
tip?: string;
}
const UNITS = new Set<RecipeUnit>(["g", "oz", "lb", "ml", "tsp", "tbsp", "cup", "each"]);
const slugify = (text: string) =>
text
.normalize("NFD") // "Étouffée" → "Etouffee" + combining accents…
.replace(/[\u0300-\u036f]/g, "") // …which this strips
.replace(/ı/g, "i") // Turkish dotless i (doesn't decompose)
.toLowerCase()
.replace(/['’]/g, "") // "Shepherd's" → "shepherds", not "shepherd-s"
.replace(/&/g, "and")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
function parseItems(items: string, title: string): RecipeIngredient[] {
return items
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [itemId, amountText, unit] = part.split(/\s+/);
const amount = Number(amountText);
if (!itemId || !Number.isFinite(amount) || amount <= 0 || !UNITS.has(unit as RecipeUnit)) {
throw new Error(`Recipe "${title}": can't parse ingredient "${part}" (expected "item_id amount unit")`);
}
return { itemId, amount, unit: unit as RecipeUnit };
});
}
/** Build a list of recipes that share a default cuisine and meal kind. */
export function dishes(defaults: { cuisine: Cuisine; kind?: MealKind }, list: DishInput[]): RecipeData[] {
return list.map((d) => ({
id: d.id ?? slugify(d.title),
title: d.title,
description: d.about,
cuisine: d.cuisine ?? defaults.cuisine,
styles: d.styles ?? [],
kind: d.kind ?? defaults.kind ?? "main",
prepTime: d.mins,
requiredAppliances: d.tools,
servings: d.serves ?? 4,
ingredients: parseItems(d.items, d.title),
...(d.tip ? { tip: d.tip } : {}),
}));
}