init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
150
app/lib/plan.ts
Normal file
150
app/lib/plan.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue