import type { Appliance, CookingStyle, Cuisine, ItemId, MealPlanState } from "@/app/types"; import { CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "./catalog"; import { APPLIANCES, CUISINES, STYLES } from "./plan"; import { RECIPES_BY_ID } from "./recipes"; // --------------------------------------------------------------------------- // One place that turns "anything" into a valid MealPlanState. // // Used by: // - the Server Action (input from the network is untrusted) // - the localStorage loader (old versions of the app saved older shapes) // // Old-shape migrations live here too, so components never see legacy data. // --------------------------------------------------------------------------- export const DEFAULT_STATE: MealPlanState = { targetBudget: 75, daysCount: 7, mealsPerDay: 2, householdSize: 2, selectedAppliances: ["stovetop", "oven", "microwave"], preferredCuisines: [], preferredStyles: [], avoidedItems: [], onHandItems: PANTRY_STAPLE_IDS, slots: {}, }; /** v1 had a single "asian" cuisine; it was split into four. */ const LEGACY_CUISINES: Record = { asian: ["chinese", "japanese", "korean", "thai"], }; const VALID_APPLIANCES = new Set(APPLIANCES.map((a) => a.id)); const VALID_CUISINES = new Set(CUISINES.map((c) => c.id)); const VALID_STYLES = new Set(STYLES.map((s) => s.id)); const clamp = (value: unknown, min: number, max: number, fallback: number) => { const n = typeof value === "number" && Number.isFinite(value) ? value : fallback; return Math.min(max, Math.max(min, n)); }; /** Keep only known string values, de-duplicated. */ const onlyValid = (values: unknown, isValid: (v: string) => boolean): T[] => Array.isArray(values) ? ([...new Set(values.filter((v): v is string => typeof v === "string" && isValid(v)))] as T[]) : []; export function sanitizeState(input: unknown): MealPlanState { const raw = (typeof input === "object" && input !== null ? input : {}) as Record; // Slots: "day-meal" keys pointing at real recipes only. const slots: Record = {}; for (const [key, recipeId] of Object.entries((raw.slots as Record) ?? {})) { if (/^[0-6]-[1-3]$/.test(key) && typeof recipeId === "string" && RECIPES_BY_ID.has(recipeId)) slots[key] = recipeId; } // Cuisines, with the legacy "asian" expanded. const cuisinesIn = Array.isArray(raw.preferredCuisines) ? raw.preferredCuisines.flatMap((c) => LEGACY_CUISINES[c as string] ?? [c]) : []; // Items. v1 had `ownsPantryStaples: boolean` instead of an on-hand list. const isItem = (id: string) => CATALOG_BY_ID.has(id); const avoidedItems = onlyValid(raw.avoidedItems, isItem); const onHandItems = Array.isArray(raw.onHandItems) ? onlyValid(raw.onHandItems, isItem) : raw.ownsPantryStaples === false ? [] : PANTRY_STAPLE_IDS; return { targetBudget: clamp(raw.targetBudget, 10, 1000, DEFAULT_STATE.targetBudget), daysCount: Math.round(clamp(raw.daysCount, 1, 7, DEFAULT_STATE.daysCount)), mealsPerDay: Math.round(clamp(raw.mealsPerDay, 1, 3, DEFAULT_STATE.mealsPerDay)), householdSize: Math.round(clamp(raw.householdSize, 1, 6, DEFAULT_STATE.householdSize)), selectedAppliances: Array.isArray(raw.selectedAppliances) ? onlyValid(raw.selectedAppliances, (v) => VALID_APPLIANCES.has(v)) : DEFAULT_STATE.selectedAppliances, preferredCuisines: onlyValid(cuisinesIn, (v) => VALID_CUISINES.has(v)), preferredStyles: onlyValid(raw.preferredStyles, (v) => VALID_STYLES.has(v)), avoidedItems, // An item can't be both avoided and on hand; "avoid" wins. onHandItems: onHandItems.filter((id) => !avoidedItems.includes(id)), slots, }; } /** * Plans saved before meal numbers were fixed counted from the first VISIBLE * meal: with 2 meals a day, "1" meant Lunch. Today "1" is always Breakfast, * so shift each meal up by the number of meals that were hidden in front of it. * 2 meals/day: 1 (Lunch) → 2, 2 (Dinner) → 3 * 1 meal/day: 1 (Dinner) → 3 * Anything that lands past Dinner was already hidden and is dropped. */ export function migrateV1Slots(input: unknown): unknown { if (typeof input !== "object" || input === null) return input; const raw = input as Record; const mealsPerDay = Math.round(clamp(raw.mealsPerDay, 1, 3, DEFAULT_STATE.mealsPerDay)); const offset = 3 - mealsPerDay; const slots: Record = {}; for (const [key, recipeId] of Object.entries((raw.slots as Record) ?? {})) { const [day, meal] = key.split("-").map(Number); const fixedMeal = meal + offset; if (fixedMeal >= 1 && fixedMeal <= 3) slots[`${day}-${fixedMeal}`] = recipeId; } return { ...raw, slots }; }