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>
This commit is contained in:
Cayden Schwechten 2026-11-06 16:36:33 -06:00
parent b02c7a024b
commit df5dd3a70e
56 changed files with 4749 additions and 788 deletions

View file

@ -28,8 +28,8 @@ export const CATALOG: StoreItem[] = [
// --- Meat & Eggs ---------------------------------------------------------
{ id: "chicken_breast", name: "Boneless Skinless Chicken Breast", aisle: "Meat & Eggs", packageSize: 2.5 * LB, baseUnit: "g", packageLabel: "2.5 lb tray", price: 10.5 },
{ id: "chicken_thighs", name: "Boneless Skinless Chicken Thighs", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb tray", price: 9.89 },
{ id: "ground_beef", name: "80/20 Ground Beef", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 6.49 },
{ id: "ground_turkey", name: "93/7 Ground Turkey", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.99 },
{ id: "ground_beef", name: "80/20 Ground Beef", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 6.49 },
{ id: "ground_turkey", name: "93/7 Ground Turkey", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.99 },
{ id: "pork_shoulder", name: "Bone-In Pork Shoulder Roast", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "~3 lb roast", price: 8.39 },
{ id: "smoked_sausage", name: "Smoked Sausage Rope", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz", price: 3.79 },
{ id: "bacon", name: "Hickory Smoked Bacon", aisle: "Meat & Eggs", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz pack", price: 5.09 },
@ -38,7 +38,7 @@ export const CATALOG: StoreItem[] = [
// --- Dairy -----------------------------------------------------------------
{ id: "milk", name: "2% Reduced Fat Milk", aisle: "Dairy", packageSize: 128 * FL_OZ, baseUnit: "ml", packageLabel: "1 gallon", price: 3.59 },
{ id: "butter", name: "Salted Butter Sticks", aisle: "Dairy", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb (4 sticks)", price: 3.96, gramsPerMl: 0.96 },
{ id: "butter", name: "Salted Butter Sticks", aisle: "Dairy", packageSize: LB, baseUnit: "g", packageLabel: "1 lb (4 sticks)", price: 3.96, gramsPerMl: 0.96 },
{ id: "cheddar", name: "Shredded Mild Cheddar", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
{ id: "mozzarella", name: "Shredded Low-Moisture Mozzarella", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
{ id: "parmesan", name: "Grated Parmesan", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz shaker", price: 3.99, gramsPerMl: 0.42 },
@ -66,11 +66,11 @@ export const CATALOG: StoreItem[] = [
// --- Pantry: grains, cans, baking -------------------------------------------
{ id: "white_rice", name: "Long Grain White Rice", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.24, gramsPerMl: 0.78 },
{ id: "spaghetti", name: "Spaghetti", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "penne", name: "Penne Rigate", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "elbow_macaroni", name: "Elbow Macaroni", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "spaghetti", name: "Spaghetti", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "penne", name: "Penne Rigate", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "elbow_macaroni", name: "Elbow Macaroni", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "oats", name: "Quick Oats", aisle: "Pantry", packageSize: 42 * OZ, baseUnit: "g", packageLabel: "42 oz canister", price: 4.19, gramsPerMl: 0.34 },
{ id: "red_lentils", name: "Dry Lentils", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz bag", price: 1.79, gramsPerMl: 0.8 },
{ id: "red_lentils", name: "Dry Lentils", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz bag", price: 1.79, gramsPerMl: 0.8 },
{ id: "flour_tortillas", name: "Flour Tortillas (Taco Size)", aisle: "Bakery", packageSize: 10, baseUnit: "each", packageLabel: "10 ct", price: 2.49 },
{ id: "sandwich_bread", name: "White Sandwich Bread", aisle: "Bakery", packageSize: 20, baseUnit: "each", packageLabel: "20 oz loaf (~20 slices)", price: 2.29 },
{ id: "breadcrumbs", name: "Italian Breadcrumbs", aisle: "Pantry", packageSize: 15 * OZ, baseUnit: "g", packageLabel: "15 oz canister", price: 1.99, gramsPerMl: 0.45 },
@ -122,12 +122,13 @@ export const CATALOG: StoreItem[] = [
// --- Cayden's Corner (family recipes) --------------------------------------------
// Meat & seafood
{ id: "italian_sausage", name: "Mild Italian Sausage (bulk)", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.69 },
{ id: "italian_sausage", name: "Mild Italian Sausage (bulk)", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.69 },
{ id: "turkey_sausage", name: "Smoked Turkey Sausage", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz rope", price: 3.79 },
{ id: "pork_tenderloin", name: "Pork Tenderloin", aisle: "Meat & Eggs", packageSize: 1.25 * LB, baseUnit: "g", packageLabel: "~1.25 lb", price: 6.49 },
{ id: "bottom_round_roast", name: "Beef Bottom Round Roast", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "per lb", price: 7.49 },
{ id: "rib_roast", name: "Beef Standing Rib Roast", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "per lb", price: 15.99 },
{ id: "bottom_round_roast", name: "Beef Bottom Round Roast", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "per lb", price: 7.49 },
{ id: "rib_roast", name: "Beef Standing Rib Roast", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "per lb", price: 15.99 },
{ id: "spiral_ham", name: "Spiral-Sliced Bone-In Half Ham", aisle: "Meat & Eggs", packageSize: 8 * LB, baseUnit: "g", packageLabel: "~8 lb", price: 21.99 },
{ id: "ham_steak", name: "Bone-In Ham Steak", aisle: "Meat & Eggs", packageSize: 1, baseUnit: "each", packageLabel: "~1 lb steak", price: 5.49 },
{ id: "deli_ham", name: "Sliced Deli Ham", aisle: "Meat & Eggs", packageSize: 9 * OZ, baseUnit: "g", packageLabel: "9 oz tub", price: 4.29 },
{ id: "bacon_bits", name: "Real Bacon Bits", aisle: "Pantry", packageSize: 3 * OZ, baseUnit: "g", packageLabel: "3 oz pouch", price: 3.29, gramsPerMl: 0.3 },
{ id: "canned_crab", name: "Lump Crabmeat, canned", aisle: "Seafood", packageSize: 6 * OZ, baseUnit: "g", packageLabel: "6 oz can", price: 6.99 },
@ -162,9 +163,9 @@ export const CATALOG: StoreItem[] = [
{ id: "crackers", name: "Butter Crackers", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "13.7 oz box", price: 3.49 },
{ id: "saltines", name: "Saltine Crackers", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz box", price: 2.99 },
// Baking
{ id: "baking_soda", name: "Baking Soda", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb box", price: 1.19, gramsPerMl: 1.0, pantryStaple: true },
{ id: "baking_soda", name: "Baking Soda", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "1 lb box", price: 1.19, gramsPerMl: 1.0, pantryStaple: true },
{ id: "vanilla_extract", name: "Vanilla Extract", aisle: "Pantry", packageSize: 2 * FL_OZ, baseUnit: "ml", packageLabel: "2 oz bottle", price: 5.49 },
{ id: "mint_extract", name: "Pure Peppermint Extract", aisle: "Pantry", packageSize: 1 * FL_OZ, baseUnit: "ml", packageLabel: "1 oz bottle", price: 3.99 },
{ id: "mint_extract", name: "Pure Peppermint Extract", aisle: "Pantry", packageSize: FL_OZ, baseUnit: "ml", packageLabel: "1 oz bottle", price: 3.99 },
{ id: "powdered_sugar", name: "Powdered Sugar", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.59, gramsPerMl: 0.5 },
{ id: "shortening", name: "Vegetable Shortening", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz can", price: 3.79, gramsPerMl: 0.8 },
{ id: "molasses", name: "Molasses", aisle: "Pantry", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 4.29 },
@ -191,9 +192,9 @@ export const CATALOG: StoreItem[] = [
// Meat & deli
{ id: "rotisserie_chicken", name: "Rotisserie Chicken (meat only)", aisle: "Meat & Eggs", packageSize: 1.5 * LB, baseUnit: "g", packageLabel: "1 whole bird (~1.5 lb meat)", price: 6.99, gramsPerMl: 0.59 },
{ id: "chicken_drumsticks", name: "Chicken Drumsticks", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb family pack", price: 5.22 },
{ id: "ground_chicken", name: "Ground Chicken", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.99 },
{ id: "ground_chicken", name: "Ground Chicken", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.99 },
{ id: "pork_chops", name: "Boneless Pork Chops", aisle: "Meat & Eggs", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb pack", price: 9.1 },
{ id: "beef_stew_meat", name: "Beef Stew Meat", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb tray", price: 7.99 },
{ id: "beef_stew_meat", name: "Beef Stew Meat", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb tray", price: 7.99 },
{ id: "diced_ham", name: "Diced Ham", aisle: "Meat & Eggs", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz pack", price: 3.29, gramsPerMl: 0.6 },
{ id: "hot_dogs", name: "Beef Franks", aisle: "Meat & Eggs", packageSize: 8, baseUnit: "each", packageLabel: "8 ct", price: 4.49 },
{ id: "frozen_meatballs", name: "Frozen Homestyle Meatballs", aisle: "Frozen", packageSize: 26 * OZ, baseUnit: "g", packageLabel: "26 oz bag", price: 6.49 },
@ -236,7 +237,7 @@ export const CATALOG: StoreItem[] = [
{ id: "naan", name: "Garlic Naan", aisle: "Bakery", packageSize: 4, baseUnit: "each", packageLabel: "4 ct", price: 4.29 },
// Pantry: grains & pasta
{ id: "egg_noodles", name: "Wide Egg Noodles", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 2.19 },
{ id: "lasagna_noodles", name: "Lasagna Noodles", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.89 },
{ id: "lasagna_noodles", name: "Lasagna Noodles", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.89 },
{ id: "rice_noodles", name: "Thin Rice Noodles", aisle: "International", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz pack", price: 2.99 },
{ id: "quinoa", name: "Quinoa", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 3.99, gramsPerMl: 0.72 },
{ id: "couscous", name: "Couscous", aisle: "Pantry", packageSize: 10 * OZ, baseUnit: "g", packageLabel: "10 oz box", price: 2.79, gramsPerMl: 0.73 },
@ -275,9 +276,9 @@ export const CATALOG: StoreItem[] = [
{ id: "red_pepper_flakes", name: "Crushed Red Pepper", aisle: "Spices & Sauces", packageSize: 1.5 * OZ, baseUnit: "g", packageLabel: "1.5 oz", price: 1.89, gramsPerMl: 0.4 },
// --- Added for the expanded cuisines ------------------------------------------
{ id: "ground_pork", name: "Ground Pork", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.19 },
{ id: "breakfast_sausage", name: "Original Pork Sausage Roll", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 3.99 },
{ id: "shrimp", name: "Frozen Raw Shrimp, peeled & deveined", aisle: "Seafood", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb bag", price: 7.99 },
{ id: "ground_pork", name: "Ground Pork", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.19 },
{ id: "breakfast_sausage", name: "Original Pork Sausage Roll", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 3.99 },
{ id: "shrimp", name: "Frozen Raw Shrimp, peeled & deveined", aisle: "Seafood", packageSize: LB, baseUnit: "g", packageLabel: "1 lb bag", price: 7.99 },
{ id: "feta", name: "Crumbled Feta", aisle: "Dairy", packageSize: 4 * OZ, baseUnit: "g", packageLabel: "4 oz tub", price: 3.29, gramsPerMl: 0.53 },
{ id: "cucumber", name: "Cucumber", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.89 },
{ id: "roma_tomatoes", name: "Roma Tomato", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.48 },

View file

@ -20,7 +20,7 @@ const FRACTIONS: [number, string][] = [
];
/** 0.75 → "¾", 1.5 → "1½", 2.33 → "2⅓", 13.2 → "13". */
export function formatQuantity(value: number): string {
function formatQuantity(value: number): string {
if (value >= 10) return String(Math.round(value));
const whole = Math.floor(value);
const fraction = value - whole;
@ -30,7 +30,7 @@ export function formatQuantity(value: number): string {
return `${total > 0 ? total : ""}${glyph}` || "0";
}
export interface IngredientLine {
interface IngredientLine {
quantity: string;
/** e.g. "cup", "lb", or "×" for counted items. */
unit: string;
@ -39,7 +39,7 @@ export interface IngredientLine {
detail?: string;
}
export function ingredientLine(ingredient: RecipeIngredient, scale: number): IngredientLine {
function ingredientLine(ingredient: RecipeIngredient, scale: number): IngredientLine {
const item = CATALOG_BY_ID.get(ingredient.itemId);
const name = item?.name ?? ingredient.itemId;
const quantity = formatQuantity(ingredient.amount * scale);

View file

@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import { jsPDF } from "jspdf";
import { buildPlanModel, pdfSafe, renderPlanPdf } from "./exportPlan";
import { RECIPES } from "./recipes";
const breakfast = RECIPES.find((r) => r.kind === "breakfast")!;
const dinner = RECIPES.find((r) => r.kind === "main")!;
const state = {
daysCount: 2,
mealsPerDay: 3,
householdSize: 4,
// Monday breakfast + dinner, Tuesday dinner; "6-3" is outside daysCount and must not export.
slots: { "0-1": breakfast.id, "0-3": dinner.id, "1-3": dinner.id, "6-3": dinner.id },
};
describe("plan export", () => {
const model = buildPlanModel(state);
test("organizes meals under each planned day, in meal order", () => {
expect(model.days.map((d) => d.name)).toEqual(["Monday", "Tuesday"]);
const [monday, tuesday] = model.days;
expect(monday.meals.map((m) => ("empty" in m ? `${m.label}: -` : `${m.label}: ${m.title}`))).toEqual([
`Breakfast: ${breakfast.title}`,
"Lunch: -",
`Dinner: ${dinner.title}`,
]);
expect(tuesday.meals.filter((m) => !("empty" in m))).toHaveLength(1);
});
test("each meal carries its full recipe, scaled to the household", () => {
const meal = model.days[0].meals[0];
if ("empty" in meal) throw new Error("expected a planned meal");
expect(meal.steps).toEqual(breakfast.steps);
expect(meal.ingredients).toHaveLength(breakfast.ingredients.length);
expect(meal.meta).toContain("serves 4");
});
test("text is made printable in the base PDF font", () => {
expect(pdfSafe("1⅓ cup flour")).toBe("1 1/3 cup flour");
expect(pdfSafe("⅛ tsp salt")).toBe("1/8 tsp salt");
expect(pdfSafe("½ cup · 350°F — Rakı")).toBe("½ cup · 350°F — Raki");
});
test("renders a real multi-page PDF (one day per page)", () => {
const bytes = new Uint8Array(renderPlanPdf(model, jsPDF).output("arraybuffer"));
expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe("%PDF-");
expect(renderPlanPdf(model, jsPDF).getNumberOfPages()).toBeGreaterThanOrEqual(2);
});
});

170
app/lib/exportPlan.ts Normal file
View file

@ -0,0 +1,170 @@
import type { jsPDF as JsPDF } from "jspdf";
import type { MealPlanState } from "@/app/types";
import { ingredientLines } from "./display";
import { activeSlots, mealLabel } from "./plan";
import { RECIPES_BY_ID } from "./recipes";
// ---------------------------------------------------------------------------
// Downloadable weekly plan as a PDF, organized by day, with the full recipe
// (scaled ingredients + steps) under every planned meal.
//
// Two layers: `buildPlanModel` (pure, tested) decides the content;
// `renderPlanPdf` lays it out with jsPDF. The planner loads jsPDF on click.
// ---------------------------------------------------------------------------
const FULL_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
interface PlannedMeal {
label: string;
title: string;
meta: string;
description: string;
ingredients: string[];
steps: string[];
tip?: string;
}
interface PlanModel {
intro: string;
days: { name: string; meals: (PlannedMeal | { label: string; empty: true })[] }[];
}
type PlanInput = Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots" | "householdSize">;
export function buildPlanModel(state: PlanInput): PlanModel {
const servings = state.householdSize;
const slots = activeSlots(state);
return {
intro: `${state.daysCount} day${state.daysCount === 1 ? "" : "s"} · ingredients scaled for ${servings} ${
servings === 1 ? "person" : "people"
}. Step wording follows each recipe's original batch size.`,
days: FULL_DAY_NAMES.slice(0, state.daysCount).map((name, dayIndex) => ({
name,
meals: slots
.filter((s) => s.dayIndex === dayIndex)
.map((s) => {
const label = mealLabel(s.mealNumber);
const recipe = s.recipeId ? RECIPES_BY_ID.get(s.recipeId) : undefined;
if (!recipe) return { label, empty: true as const };
return {
label,
title: recipe.title,
meta: `${recipe.prepTime} min · serves ${servings}`,
description: recipe.description,
ingredients: ingredientLines(recipe, servings).map(
(l) => `${l.quantity} ${l.unit} ${l.name}${l.detail ? ` (${l.detail})` : ""}`,
),
steps: recipe.steps,
tip: recipe.tip,
};
}),
})),
};
}
// jsPDF's built-in Helvetica only covers WinAnsi (Latin-1 + a few symbols).
const SPELLED: Record<string, string> = { "⅓": " 1/3", "⅔": " 2/3", "⅛": " 1/8", "ı": "i" };
const WINANSI_EXTRA = new Set("€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ");
/** Make text printable in the base PDF font: spell out ⅓-style fractions, decompose or drop anything else. */
export function pdfSafe(text: string): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0)!;
if (code <= 0x7e || (code >= 0xa0 && code <= 0xff) || WINANSI_EXTRA.has(ch)) out += ch;
else if (SPELLED[ch]) out += SPELLED[ch];
else out += ch.normalize("NFKD").replace(/[^\x20-\x7e]/g, "");
}
// "⅓ cup" → " 1/3 cup": drop the space the spelling adds when nothing precedes it.
return out.replace(/(^|\s) (\d\/\d)/g, "$1$2");
}
const ACCENT: [number, number, number] = [206, 87, 68]; // matches the app's salmon accent-500
const INK: [number, number, number] = [30, 37, 48];
const MUTED: [number, number, number] = [74, 85, 101];
/** Lay the plan out on US Letter pages; one day per page, recipes never split mid-line. */
export function renderPlanPdf(model: PlanModel, JsPdf: typeof JsPDF): JsPDF {
const doc = new JsPdf({ unit: "pt", format: "letter" });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 54;
const width = pageW - margin * 2;
let y = margin;
const ensure = (height: number) => {
if (y + height > pageH - margin) {
doc.addPage();
y = margin;
}
};
const write = (text: string, opts: { size: number; bold?: boolean; color?: [number, number, number]; indent?: number; gap?: number }) => {
const indent = opts.indent ?? 0;
const lineH = opts.size * 1.4;
doc.setFont("helvetica", opts.bold ? "bold" : "normal").setFontSize(opts.size).setTextColor(...(opts.color ?? INK));
for (const line of doc.splitTextToSize(pdfSafe(text), width - indent) as string[]) {
ensure(lineH);
doc.text(line, margin + indent, y + opts.size);
y += lineH;
}
y += opts.gap ?? 0;
};
/** Numbered/bulleted item with a hanging indent so wrapped lines align under the text. */
const item = (marker: string, text: string) => {
const size = 10.5;
const lineH = size * 1.4;
doc.setFont("helvetica", "normal").setFontSize(size).setTextColor(...INK);
const lines = doc.splitTextToSize(pdfSafe(text), width - 20) as string[];
lines.forEach((line, i) => {
ensure(lineH);
if (i === 0) doc.text(marker, margin + 4, y + size);
doc.text(line, margin + 20, y + size);
y += lineH;
});
y += 2;
};
write("My meal plan", { size: 24, bold: true, gap: 4 });
write(model.intro, { size: 10.5, color: MUTED, gap: 16 });
model.days.forEach((day, dayIndex) => {
if (dayIndex > 0) {
doc.addPage();
y = margin;
}
write(day.name, { size: 18, bold: true, gap: 2 });
doc.setDrawColor(...ACCENT).setLineWidth(1.5).line(margin, y, margin + width, y);
y += 14;
for (const meal of day.meals) {
ensure(80); // keep a meal's heading with the start of its recipe
write(meal.label.toUpperCase(), { size: 9, bold: true, color: ACCENT, gap: 2 });
if ("empty" in meal) {
write("Nothing planned", { size: 10.5, color: MUTED, gap: 16 });
continue;
}
write(meal.title, { size: 14, bold: true });
write(meal.meta, { size: 9.5, color: MUTED, gap: 4 });
write(meal.description, { size: 10.5, gap: 8 });
write("Ingredients", { size: 11, bold: true, gap: 2 });
meal.ingredients.forEach((line) => item("•", line));
y += 6;
write("Steps", { size: 11, bold: true, gap: 2 });
meal.steps.forEach((step, i) => item(`${i + 1}.`, step));
if (meal.tip) {
y += 4;
write(`Tip: ${meal.tip}`, { size: 10, color: MUTED });
}
y += 18;
}
});
const pages = doc.getNumberOfPages();
for (let page = 1; page <= pages; page++) {
doc.setPage(page).setFont("helvetica", "normal").setFontSize(8.5).setTextColor(...MUTED);
doc.text(`Plan my Meals · page ${page} of ${pages}`, pageW / 2, pageH - 28, { align: "center" });
}
return doc;
}

View file

@ -23,7 +23,7 @@ import { toBaseUnit } from "./units";
// the recipe's own ingredients instead of rebuilding the list.
// ---------------------------------------------------------------------------
export interface GroceryOptions {
interface GroceryOptions {
householdSize: number;
/** Items already in the kitchen: listed, but cost $0. */
onHandItems: ItemId[];

View file

@ -56,7 +56,7 @@ export const slotKey = (dayIndex: number, mealNumber: number) => `${dayIndex}-${
* So changing the count never moves a meal to a different slot, and meals
* hidden by a lower count come back when you raise it again (like days do).
*/
export const MEAL_NAMES = ["Breakfast", "Lunch", "Dinner"] as const;
const MEAL_NAMES = ["Breakfast", "Lunch", "Dinner"] as const;
/** Visible meal numbers for a count, e.g. 2 → [2, 3]. */
export function visibleMeals(mealsPerDay: number): number[] {
@ -107,7 +107,7 @@ export function activeRecipeIds(state: Pick<MealPlanState, "daysCount" | "mealsP
// --- Can this recipe be planned? ---------------------------------------------
export interface RecipeBlockers {
interface RecipeBlockers {
/** Appliance labels the user doesn't have, e.g. ["Oven"]. */
missingAppliances: string[];
/** Store-item names the user chose to avoid, e.g. ["80/20 Ground Beef"]. */

View file

@ -32,7 +32,7 @@ export const CAYDENS_CORNER_MAINS = dishes({ cuisine: "american" }, [
{ id: "family-crispy-pork-cutlets", title: "Crispy Pork Cutlets with Lemon Butter Sauce", mins: 45, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Panko-crusted pork tenderloin with a silky lemon butter sauce.", items: "pork_tenderloin 2.5 lb; flour 2 tbsp; eggs 2 each; panko 3 cup; white_wine 0.5 cup; lemon 1 each; heavy_cream 2 tbsp; shallots 1 each; minced_garlic 0.5 tsp; butter 0.5 cup; vegetable_oil 0.5 cup" },
{ id: "family-curried-chicken-broccoli-rice-casserole", title: "Curried Chicken Broccoli Rice Casserole", mins: 60, serves: 8, tools: ["stovetop", "oven"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Curry-mayo chicken and broccoli under cheddar and French-fried onions, over rice.", tip: "This reheats like a dream and the flavors are even better after they've had time to sit, so consider making a large batch so you'll have leftovers.", items: "chicken_breast 1.5 lb; frozen_broccoli 16 oz; mayo 1.5 cup; curry_powder 2 tbsp; cayenne 1.5 tsp; garlic_powder 1 tsp; cheddar 2.5 cup; cream_of_chicken 2 each; cream_of_mushroom 1 each; french_fried_onions 6 oz; white_rice 0.75 cup" },
{ id: "family-green-chicken-chili-soup", title: "Green Chicken Chili Soup", cuisine: "mexican", mins: 20, serves: 6, tools: ["stovetop", "slow_cooker"], styles: ["caydens_corner", "healthy", "meal_prep"], about: "Salsa verde, white beans and green chiles, slow-cooked with chicken.", items: "olive_oil 2 tbsp; onion 1 each; bell_pepper 1 each; jalapeno 1 each; minced_garlic 2 tsp; chicken_broth 3 cup; white_beans 1 each; salsa_verde 2 cup; green_chiles 1.5 each; chicken_breast 1.5 lb; cumin 1 tsp; chili_powder 1 tsp; salt 1 tsp; lime 1 each; avocado 1 each; cilantro 1 each" },
{ id: "family-ham-cabbage-potato-soup", title: "Ham, Cabbage, and Potato Soup", mins: 35, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Creamy potato and ham soup thickened with a butter-flour roux.", items: "potatoes 1.25 lb; diced_ham 0.75 cup; celery 1 each; onion 0.5 each; chicken_bouillon 2 tbsp; black_pepper 1 tsp; salt 0.5 tsp; butter 5 tbsp; flour 5 tbsp; milk 2 cup" },
{ id: "family-ham-cabbage-potato-soup", title: "Cade's Cabbage Soup", mins: 60, serves: 8, tools: ["stovetop"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "A whole cabbage, chunky skin-on potatoes and bone-in ham, slow-boiled in chicken broth.", tip: "You'll need one large pot with a lid. Buy three 32 oz cartons of broth; you'll probably use about two and a half.", items: "cabbage 1 each; potatoes 3.5 lb; ham_steak 1 each; chicken_broth 10 cup; salt 1 tsp; black_pepper 1 tsp" },
{ id: "family-indian-chicken-curry", title: "Indian Chicken Curry", cuisine: "indian", mins: 40, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Warm-spiced curry with yogurt and coconut milk.", items: "olive_oil 3 tbsp; onion 1 each; minced_garlic 1 tsp; curry_powder 3 tbsp; cinnamon 1 tsp; paprika 1 tsp; ginger_paste 0.5 tsp; sugar 0.5 tsp; chicken_breast 1 lb; tomato_paste 0.1 each; greek_yogurt 1 cup; coconut_milk 0.75 cup; lemon 0.5 each; cayenne 0.5 tsp" },
{ id: "family-japanese-glazed-chicken", title: "Japanese Glazed Chicken", cuisine: "japanese", mins: 15, serves: 4, tools: ["slow_cooker"], styles: ["caydens_corner", "meal_prep"], about: "Drumsticks slow-cooked in a sweet balsamic-soy glaze, over rice.", items: "chicken_drumsticks 2.5 lb; balsamic_vinegar 1 cup; soy_sauce 0.66 cup; sugar 5 tbsp; minced_garlic 1 tsp; jalapeno 2 each; white_rice 1.5 cup" },
{ id: "family-loaded-baked-potato-soup", title: "Loaded Baked Potato Soup", mins: 15, serves: 8, tools: ["slow_cooker"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Dump-and-go crockpot soup with cream cheese, bacon and cheddar.", tip: "Make a large batch of this soup – it reheats extremely well!", items: "frozen_hash_browns 64 oz; cream_cheese 8 oz; cream_of_chicken 2 each; bacon_bits 1 cup; cheddar 1.5 cup; green_onions 1 each; garlic_powder 1 tbsp; chicken_broth 4 cup" },

View file

@ -14,7 +14,7 @@ import type { Appliance, CookingStyle, Cuisine, MealKind, RecipeData, RecipeIngr
// module load, so typos fail loudly in dev instead of producing $0 groceries.
// ---------------------------------------------------------------------------
export interface DishInput {
interface DishInput {
title: string;
/** Defaults to a slug of the title. Set it explicitly to keep saved plans stable if you rename. */
id?: string;
@ -35,7 +35,7 @@ export interface DishInput {
const UNITS = new Set<RecipeUnit>(["g", "oz", "lb", "ml", "tsp", "tbsp", "cup", "each"]);
export const slugify = (text: string) =>
const slugify = (text: string) =>
text
.normalize("NFD") // "Étouffée" → "Etouffee" + combining accents…
.replace(/[\u0300-\u036f]/g, "") // …which this strips

View file

@ -67,7 +67,6 @@ export const EXTRA_SOUTHERN = dishes({ cuisine: "southern" }, [
{ title: "Cajun Shrimp Pasta", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort", "quick"], about: "Spicy cream sauce, plump shrimp.", items: "spaghetti 1 lb; shrimp 1 lb; heavy_cream 1 cup; cajun_seasoning 1 tbsp; parmesan 0.5 cup; minced_garlic 1 tbsp; butter 2 tbsp" },
{ title: "Fish Stick Po' Boys", mins: 20, tools: ["air_fryer"], styles: ["seafood", "quick"], about: "Crispy fish, slaw and spicy mayo on hoagies.", items: "fish_sticks 16 oz; hoagie_rolls 4 each; coleslaw_mix 6 oz; mayo 0.25 cup; hot_sauce 1 tbsp; lemon 1 each" },
{ title: "Smothered Pork Chops", mins: 40, tools: ["stovetop"], styles: ["comfort"], about: "Seared chops buried in onion gravy, over rice.", items: "pork_chops 1.5 lb; onion 2 each; flour 3 tbsp; chicken_broth 2 cup; garlic_powder 1 tsp; white_rice 1.5 cup" },
{ title: "Ham, Bean & Cabbage Soup", mins: 35, tools: ["stovetop"], styles: ["healthy", "comfort"], about: "Smoky beans, ham and cabbage greens.", items: "white_beans 2 each; diced_ham 8 oz; cabbage 0.5 each; onion 1 each; chicken_broth 4 cup; apple_cider_vinegar 1 tbsp" },
{ title: "Shrimp Étouffée", mins: 40, tools: ["stovetop"], styles: ["seafood", "comfort"], about: "Buttery blond roux, shrimp, rice.", items: "shrimp 1 lb; butter 4 tbsp; flour 0.25 cup; onion 1 each; bell_pepper 1 each; celery 1 each; chicken_broth 2 cup; cajun_seasoning 2 tsp; white_rice 1.5 cup" },
{ title: "Low Country Sausage & Cabbage", mins: 30, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Fried cabbage with smoked sausage.", items: "smoked_sausage 13 oz; cabbage 1 each; onion 1 each; butter 2 tbsp; cajun_seasoning 1 tsp" },
{ title: "Slow Cooker Beans, Greens & Cornbread", mins: 15, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Set-and-forget beans and greens.", items: "pinto_beans 3 each; diced_ham 8 oz; cabbage 1 each; onion 1 each; chicken_broth 4 cup; hot_sauce 1 tbsp; cornbread_mix 1 each; eggs 1 each; milk 0.33 cup" },

View file

@ -119,11 +119,15 @@ export const CAYDENS_CORNER_STEPS: Record<string, string[]> = {
"Stir in lime juice. Serve topped with avocado and cilantro.",
],
"family-ham-cabbage-potato-soup": [
"Combine potatoes, 3¼ cups water, ham, celery, and onion in a stockpot. Bring to a boil.",
"Simmer over medium heat until potatoes are tender, 10 to 15 minutes. Stir in chicken bouillon, pepper, and salt.",
"Melt butter in a separate saucepan over medium-low heat. Add flour; cook and stir until thick, about 1 minute.",
"Whisk in milk; cook and stir until thick, 4 to 5 minutes.",
"Pour the milk mixture into the stockpot; cook and stir until warmed through.",
"Wash the cabbage and remove any dirt, and peel back any dead / dying leaves.",
"Chop the cabbage into large leaf chunks and put them in a large pot.",
"Wash 5 whole potatoes and chop them into large cubes. They do not need to be peeled.",
"Put the potatoes in the pot on top of the cabbage.",
"Put in the chicken broth until it is filled to a high level.",
"Boil down the cabbage and potatoes for 20-30 minutes, or until the cabbage is a soft texture.",
"Cut up your ham steak into medium-sized cubes and put in the pot. Add the ham bone as well for extra flavor.",
"Continue the slow boil for an additional 15 minutes before taking off heat.",
"Put in a generous amount of pepper and salt to taste.",
],
"family-indian-chicken-curry": [
"Heat olive oil in a skillet over medium heat. Sauté onion until lightly browned.",

View file

@ -61,7 +61,6 @@ export const EXTRA_AMERICAS_STEPS: Record<string, string[]> = {
"cajun-shrimp-pasta": ["Boil spaghetti.", "Toss shrimp with Cajun seasoning; cook in butter 2 min per side; remove.", "Add garlic and cream; simmer 3 min; stir in parmesan.", "Toss pasta and shrimp in the sauce."],
"fish-stick-po-boys": ["Air fry fish sticks at 400°F 10–12 min.", "Mix mayo with hot sauce and lemon juice.", "Fill rolls with slaw, fish sticks and spicy mayo."],
"smothered-pork-chops": ["Cook rice.", "Season chops; dredge in flour; brown in oil and remove.", "Cook sliced onions in the pan 8 min; sprinkle leftover flour and garlic powder, stir 1 min.", "Whisk in broth; return chops, cover and simmer 15 min. Serve over rice."],
"ham-bean-and-cabbage-soup": ["Brown ham with diced onion.", "Add chopped cabbage, beans and broth; simmer 20 min.", "Stir in vinegar; season."],
"shrimp-etouffee": ["Cook rice.", "Melt butter; whisk in flour; stir 8–10 min until peanut-butter colored.", "Add diced onion, pepper and celery; cook 5 min; whisk in broth and Cajun seasoning; simmer 10 min.", "Add shrimp; cook 3–4 min. Serve over rice."],
"low-country-sausage-and-cabbage": ["Brown sliced sausage in butter; remove.", "Add sliced onion and chopped cabbage; cook 10–12 min until tender and browned in spots.", "Return sausage; season with Cajun seasoning."],
"slow-cooker-beans-greens-and-cornbread": ["Add beans (with liquid), ham, chopped cabbage, onion, broth and hot sauce to the slow cooker.", "Cook on low 6–8 hours.", "Bake cornbread: mix with egg and milk, bake at 400°F 15–20 min.", "Serve beans and greens with cornbread."],