planmymeal/app/lib/plan.ts

151 lines
6.3 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
import type { Appliance, CookingStyle, Cuisine, MealKind, MealPlanState, MealSlot, Recipe } from "@/app/types";
import { CATALOG_BY_ID } from "./catalog";
import { RECIPES_BY_ID } from "./recipes";
// ---------------------------------------------------------------------------
// Small, pure helpers about the plan shape. Used by UI and server alike.
// ---------------------------------------------------------------------------
export const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
export const APPLIANCES: { id: Appliance; label: string; icon: string }[] = [
{ id: "stovetop", label: "Stovetop", icon: "🔥" },
{ id: "oven", label: "Oven", icon: "♨️" },
{ id: "air_fryer", label: "Air Fryer", icon: "🌀" },
{ id: "slow_cooker", label: "Slow Cooker", icon: "🍲" },
{ id: "microwave", label: "Microwave", icon: "⚡" },
{ id: "blender", label: "Blender", icon: "🥤" },
{ id: "rice_cooker", label: "Rice Cooker", icon: "🍚" },
];
export const CUISINES: { id: Cuisine; label: string }[] = [
{ id: "american", label: "American" },
{ id: "southern", label: "Southern & Cajun" },
{ id: "mexican", label: "Mexican" },
{ id: "caribbean", label: "Caribbean" },
{ id: "italian", label: "Italian" },
{ id: "mediterranean", label: "Mediterranean" },
{ id: "middle_eastern", label: "Middle Eastern" },
{ id: "indian", label: "Indian" },
{ id: "chinese", label: "Chinese" },
{ id: "japanese", label: "Japanese" },
{ id: "korean", label: "Korean" },
{ id: "thai", label: "Thai" },
];
export const STYLES: { id: CookingStyle; label: string }[] = [
{ id: "comfort", label: "Comfort classics" },
{ id: "quick", label: "Quick (≤25 min)" },
{ id: "healthy", label: "Lighter" },
{ id: "meal_prep", label: "Meal-prep friendly" },
{ id: "vegetarian", label: "Vegetarian" },
{ id: "seafood", label: "Seafood" },
{ id: "caydens_corner", label: "Cayden's Corner" },
];
/** Add the value if it's missing, remove it if it's there. Returns a new array. */
export const toggle = <T,>(list: readonly T[], value: T): T[] =>
list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
export const slotKey = (dayIndex: number, mealNumber: number) => `${dayIndex}-${mealNumber}`;
/**
* Meal numbers are FIXED: 1 = Breakfast, 2 = Lunch, 3 = Dinner.
* "Meals per day" only decides which of them are visible:
* 1 → Dinner 2 → Lunch, Dinner 3 → Breakfast, Lunch, Dinner
* 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).
*/
const MEAL_NAMES = ["Breakfast", "Lunch", "Dinner"] as const;
2026-10-31 20:44:29 -05:00
/** Visible meal numbers for a count, e.g. 2 → [2, 3]. */
export function visibleMeals(mealsPerDay: number): number[] {
const first = MEAL_NAMES.length - mealsPerDay + 1;
return Array.from({ length: mealsPerDay }, (_, i) => first + i);
}
export function mealLabel(mealNumber: number): string {
return MEAL_NAMES[mealNumber - 1] ?? `Meal ${mealNumber}`;
}
export const MEAL_KIND_LABELS: Record<MealKind, string> = {
breakfast: "Breakfast",
main: "Lunch & dinner",
side: "Appetizer & side",
dessert: "Dessert",
};
/** Planner slots are only ever breakfast or a main; sides and desserts are added by hand. */
export function mealKindFor(mealNumber: number): Extract<MealKind, "breakfast" | "main"> {
return mealNumber === 1 ? "breakfast" : "main";
}
/** Every slot currently visible in the grid, in day-then-meal order. */
export function activeSlots(state: Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots">): MealSlot[] {
const result: MealSlot[] = [];
for (let day = 0; day < state.daysCount; day++) {
for (const meal of visibleMeals(state.mealsPerDay)) {
result.push({ dayIndex: day, mealNumber: meal, recipeId: state.slots[slotKey(day, meal)] ?? null });
}
}
return result;
}
/** Recipe IDs in the visible part of the plan (duplicates kept: each is one cooked meal). */
/** The plan with every visible slot emptied (hidden days/meals are kept). */
export function withoutVisibleSlots(state: Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots">): Record<string, string> {
const slots = { ...state.slots };
for (const slot of activeSlots(state)) delete slots[slotKey(slot.dayIndex, slot.mealNumber)];
return slots;
}
export function activeRecipeIds(state: Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots">): string[] {
return activeSlots(state)
.map((s) => s.recipeId)
.filter((id): id is string => id !== null && RECIPES_BY_ID.has(id));
}
// --- Can this recipe be planned? ---------------------------------------------
interface RecipeBlockers {
2026-10-31 20:44:29 -05:00
/** 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"]. */
avoidedIngredients: string[];
}
type BlockerInputs = Pick<MealPlanState, "selectedAppliances" | "avoidedItems">;
export function recipeBlockers(recipe: Recipe, state: BlockerInputs): RecipeBlockers {
return {
missingAppliances: recipe.requiredAppliances
.filter((a) => !state.selectedAppliances.includes(a))
.map((a) => APPLIANCES.find((x) => x.id === a)?.label ?? a),
avoidedIngredients: recipe.ingredients
.filter((i) => state.avoidedItems.includes(i.itemId))
.map((i) => CATALOG_BY_ID.get(i.itemId)?.name ?? i.itemId),
};
}
export function isCookable(recipe: Recipe, state: BlockerInputs): boolean {
const { missingAppliances, avoidedIngredients } = recipeBlockers(recipe, state);
return missingAppliances.length === 0 && avoidedIngredients.length === 0;
}
/** One short line for the UI, e.g. "Needs Oven · Contains Ground Pork". */
export function describeBlockers({ missingAppliances, avoidedIngredients }: RecipeBlockers): string {
return [
missingAppliances.length > 0 ? `Needs ${missingAppliances.join(" + ")}` : "",
avoidedIngredients.length > 0 ? `Contains ${avoidedIngredients.join(", ")}` : "",
]
.filter(Boolean)
.join(" · ");
}
/** Empty preference lists mean "anything goes". */
export function matchesPreferences(recipe: Recipe, cuisines: Cuisine[], styles: CookingStyle[]): boolean {
const cuisineOk = cuisines.length === 0 || cuisines.includes(recipe.cuisine);
const styleOk = styles.length === 0 || recipe.styles.some((s) => styles.includes(s));
return cuisineOk && styleOk;
}