init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
106
app/lib/sanitize.ts
Normal file
106
app/lib/sanitize.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
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<string, Cuisine[]> = {
|
||||
asian: ["chinese", "japanese", "korean", "thai"],
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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<ItemId>(raw.avoidedItems, isItem);
|
||||
const onHandItems = Array.isArray(raw.onHandItems)
|
||||
? onlyValid<ItemId>(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<Appliance>(raw.selectedAppliances, (v) => VALID_APPLIANCES.has(v))
|
||||
: DEFAULT_STATE.selectedAppliances,
|
||||
preferredCuisines: onlyValid<Cuisine>(cuisinesIn, (v) => VALID_CUISINES.has(v)),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
const mealsPerDay = Math.round(clamp(raw.mealsPerDay, 1, 3, DEFAULT_STATE.mealsPerDay));
|
||||
const offset = 3 - mealsPerDay;
|
||||
|
||||
const slots: Record<string, unknown> = {};
|
||||
for (const [key, recipeId] of Object.entries((raw.slots as Record<string, unknown>) ?? {})) {
|
||||
const [day, meal] = key.split("-").map(Number);
|
||||
const fixedMeal = meal + offset;
|
||||
if (fixedMeal >= 1 && fixedMeal <= 3) slots[`${day}-${fixedMeal}`] = recipeId;
|
||||
}
|
||||
return { ...raw, slots };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue