planmymeal/app/lib/sanitize.ts

70 lines
3.2 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
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 (saved data is untrusted too)
2026-10-31 20:44:29 -05:00
// ---------------------------------------------------------------------------
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: {},
};
const VALID_APPLIANCES = new Set<string>(APPLIANCES.map((a) => a.id));
const VALID_CUISINES = new Set<string>(CUISINES.map((c) => c.id));
const VALID_STYLES = new Set<string>(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 = <T extends string>(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<string, unknown>;
// Slots: "day-meal" keys pointing at real recipes only.
const slots: Record<string, string> = {};
for (const [key, recipeId] of Object.entries((raw.slots as Record<string, unknown>) ?? {})) {
if (/^[0-6]-[1-3]$/.test(key) && typeof recipeId === "string" && RECIPES_BY_ID.has(recipeId)) slots[key] = recipeId;
}
// Items.
2026-10-31 20:44:29 -05:00
const isItem = (id: string) => CATALOG_BY_ID.has(id);
const avoidedItems = onlyValid<ItemId>(raw.avoidedItems, isItem);
const onHandItems = Array.isArray(raw.onHandItems) ? onlyValid<ItemId>(raw.onHandItems, isItem) : PANTRY_STAPLE_IDS;
2026-10-31 20:44:29 -05:00
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<Appliance>(raw.selectedAppliances, (v) => VALID_APPLIANCES.has(v))
: DEFAULT_STATE.selectedAppliances,
preferredCuisines: onlyValid<Cuisine>(raw.preferredCuisines, (v) => VALID_CUISINES.has(v)),
2026-10-31 20:44:29 -05:00
preferredStyles: onlyValid<CookingStyle>(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,
};
}