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:
parent
b02c7a024b
commit
df5dd3a70e
56 changed files with 4749 additions and 788 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
50
app/lib/exportPlan.test.ts
Normal file
50
app/lib/exportPlan.test.ts
Normal 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
170
app/lib/exportPlan.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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"]. */
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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."],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue