151 lines
6.3 KiB
TypeScript
151 lines
6.3 KiB
TypeScript
|
|
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).
|
||
|
|
*/
|
||
|
|
export 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[] {
|
||
|
|
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? ---------------------------------------------
|
||
|
|
|
||
|
|
export 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"]. */
|
||
|
|
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;
|
||
|
|
}
|