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 = (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 = { 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 { return mealNumber === 1 ? "breakfast" : "main"; } /** Every slot currently visible in the grid, in day-then-meal order. */ export function activeSlots(state: Pick): 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): Record { const slots = { ...state.slots }; for (const slot of activeSlots(state)) delete slots[slotKey(slot.dayIndex, slot.mealNumber)]; return slots; } export function activeRecipeIds(state: Pick): 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; 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; }