planmymeal/app/lib/usePlanState.ts
Cayden Schwechten c19b910c36 init
2026-10-31 20:44:29 -05:00

128 lines
5.1 KiB
TypeScript

"use client";
import { useEffect, useReducer, useState } from "react";
import type { Appliance, CookingStyle, Cuisine, ItemId, ItemPreference, MealPlanState } from "@/app/types";
import { toggle, withoutVisibleSlots } from "./plan";
import { DEFAULT_STATE, migrateV1Slots, sanitizeState } from "./sanitize";
// ---------------------------------------------------------------------------
// All plan state lives in one reducer, persisted to localStorage.
//
// Why a reducer: a dozen controls each change one field, and the slot grid
// needs swap/replace operations. One switch statement is easier to follow
// than a dozen useStates.
// ---------------------------------------------------------------------------
// v2: meal numbers became fixed (1 = Breakfast, 2 = Lunch, 3 = Dinner).
// v1 numbered meals from the first VISIBLE one, so it's converted once on load.
const STORAGE_KEY = "budget-meal-planner:v2";
const LEGACY_STORAGE_KEY = "budget-meal-planner:v1";
export type PlanAction =
| { type: "setBudget"; value: number }
| { type: "setDays"; value: number }
| { type: "setMealsPerDay"; value: number }
| { type: "setHouseholdSize"; value: number }
| { type: "toggleAppliance"; value: Appliance }
| { type: "toggleCuisine"; value: Cuisine }
| { type: "toggleStyle"; value: CookingStyle }
| { type: "setItemPreference"; itemIds: ItemId[]; preference: ItemPreference }
| { type: "setSlot"; key: string; recipeId: string | null }
| { type: "swapSlots"; from: string; to: string }
| { type: "replaceSlots"; slots: Record<string, string> }
| { type: "clearSlots" };
/** Exported for tests. */
export function planReducer(state: MealPlanState, action: PlanAction): MealPlanState {
switch (action.type) {
case "setBudget":
return { ...state, targetBudget: action.value };
case "setDays":
return { ...state, daysCount: action.value };
case "setMealsPerDay":
// Slots don't move: meal numbers are fixed (see visibleMeals in plan.ts).
return { ...state, mealsPerDay: action.value };
case "setHouseholdSize":
return { ...state, householdSize: action.value };
case "toggleAppliance":
return { ...state, selectedAppliances: toggle(state.selectedAppliances, action.value) };
case "toggleCuisine":
return { ...state, preferredCuisines: toggle(state.preferredCuisines, action.value) };
case "toggleStyle":
return { ...state, preferredStyles: toggle(state.preferredStyles, action.value) };
case "setItemPreference": {
// An item lives in at most one list: avoided, on hand, or neither ("use").
const ids = new Set(action.itemIds);
const avoidedItems = state.avoidedItems.filter((id) => !ids.has(id));
const onHandItems = state.onHandItems.filter((id) => !ids.has(id));
if (action.preference === "avoid") avoidedItems.push(...ids);
if (action.preference === "have") onHandItems.push(...ids);
return { ...state, avoidedItems, onHandItems };
}
case "setSlot": {
const slots = { ...state.slots };
if (action.recipeId) slots[action.key] = action.recipeId;
else delete slots[action.key];
return { ...state, slots };
}
case "swapSlots": {
const slots = { ...state.slots };
const from = slots[action.from];
const to = slots[action.to];
if (to) slots[action.from] = to;
else delete slots[action.from];
if (from) slots[action.to] = from;
else delete slots[action.to];
return { ...state, slots };
}
case "replaceSlots":
return { ...state, slots: action.slots };
case "clearSlots":
// Clears what you can see; meals on hidden days/meals survive.
return { ...state, slots: withoutVisibleSlots(state) };
}
}
export type SyncStatus = "saved" | "error";
/** Read the saved plan. Only call in the browser (the planner is client-only, see page.tsx). */
function loadSavedState(): MealPlanState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) return sanitizeState(JSON.parse(raw));
const legacy = window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
window.localStorage.removeItem(LEGACY_STORAGE_KEY); // the v2 copy is written on first render
return sanitizeState(migrateV1Slots(JSON.parse(legacy)));
}
return DEFAULT_STATE;
} catch {
return DEFAULT_STATE; // corrupt JSON or blocked storage
}
}
function storageWorks(): boolean {
try {
window.localStorage.setItem(`${STORAGE_KEY}:probe`, "1");
window.localStorage.removeItem(`${STORAGE_KEY}:probe`);
return true;
} catch {
return false; // e.g. some private-browsing modes
}
}
export function usePlanState() {
// Lazy initializers run once, on first render, so the saved plan shows immediately.
const [state, dispatch] = useReducer(planReducer, undefined, loadSavedState);
const [canSave] = useState(storageWorks);
// Persist every change. (Writing to an external system is exactly what effects are for.)
useEffect(() => {
if (!canSave) return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state, canSave]);
const syncStatus: SyncStatus = canSave ? "saved" : "error";
return { state, dispatch, syncStatus };
}