- Delete .cursor/rules/boardui.mdc (rules live in AGENTS.md) and package-lock.json; README now uses bun install/dev/test/build - Remove migrateV1Slots, the v1 localStorage key, the legacy "asian" cuisine expansion, the ownsPantryStaples fallback, and the pre-BoardUI theme-key carryover in the layout init script; drop their tests Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
119 lines
4.7 KiB
TypeScript
119 lines
4.7 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, 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STORAGE_KEY = "budget-meal-planner:v2";
|
|
|
|
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));
|
|
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 };
|
|
}
|