planmymeal/app/components/PlannerDashboard.tsx

247 lines
9.3 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
"use client";
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { generateMealPlanAction } from "@/app/actions";
import { Cart, buildGroceryList } from "@/app/lib/grocery";
import { activeRecipeIds } from "@/app/lib/plan";
import { averageGroceryProvider } from "@/app/lib/pricing";
import { RECIPES } from "@/app/lib/recipes";
import { usePlanState } from "@/app/lib/usePlanState";
import type { FillMode } from "@/app/types";
import { BudgetMeter } from "./BudgetMeter";
import { ConfigPanel } from "./ConfigPanel";
import { GroceryListDrawer, GrocerySummaryPanel } from "./GroceryListDrawer";
import { IngredientPanel } from "./IngredientPanel";
import { MealGrid } from "./MealGrid";
import { MealSelectorModal } from "./MealSelectorModal";
import { Navbar } from "./Navbar";
import { RecipeDialog } from "./RecipeDialog";
interface UndoSnapshot {
message: string;
previousSlots: Record<string, string>;
}
export default function PlannerDashboard() {
const { state, dispatch, syncStatus } = usePlanState();
const [isGenerating, startGenerating] = useTransition();
const [notes, setNotes] = useState<string[]>([]);
const [openSlot, setOpenSlot] = useState<string | null>(null);
const [viewSlot, setViewSlot] = useState<string | null>(null);
const [groceriesOpen, setGroceriesOpen] = useState(false);
const [undo, setUndo] = useState<UndoSnapshot | null>(null);
// Auto-dismiss undo toast after 6 seconds
useEffect(() => {
if (!undo) return;
const timer = window.setTimeout(() => setUndo(null), 6000);
return () => window.clearTimeout(timer);
}, [undo]);
const summary = useMemo(
() => buildGroceryList(activeRecipeIds(state), { householdSize: state.householdSize, onHandItems: state.onHandItems }),
[state],
);
const mealCosts = useMemo(() => {
const emptyCart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems });
return new Map(RECIPES.map((r) => [r.id, emptyCart.marginal(r.id).eaten]));
}, [state.householdSize, state.onHandItems]);
function handleGenerate(fillMode: FillMode) {
startGenerating(async () => {
try {
const result = await generateMealPlanAction({ state, fillMode, seed: Date.now() });
dispatch({ type: "replaceSlots", slots: result.slots });
setNotes(result.notes);
setUndo(null);
} catch {
setNotes(["Couldn't reach the planner. Check your connection and try again."]);
}
});
}
const handleRemoveSlot = useCallback(
(key: string, recipeTitle: string) => {
setUndo({ message: `Removed "${recipeTitle}"`, previousSlots: { ...state.slots } });
dispatch({ type: "setSlot", key, recipeId: null });
},
[dispatch, state.slots],
);
const handleClearWeek = useCallback(() => {
const count = activeRecipeIds(state).length;
if (count === 0) return;
setUndo({ message: `Cleared ${count} planned meal${count === 1 ? "" : "s"}`, previousSlots: { ...state.slots } });
dispatch({ type: "clearSlots" });
}, [dispatch, state]);
const handleUndo = useCallback(() => {
if (!undo) return;
dispatch({ type: "replaceSlots", slots: undo.previousSlots });
setUndo(null);
}, [dispatch, undo]);
const closeSelector = useCallback(() => setOpenSlot(null), []);
const closeRecipe = useCallback(() => setViewSlot(null), []);
const closeGroceries = useCallback(() => setGroceriesOpen(false), []);
const toggleOnHand = useCallback(
(itemId: string, onHand: boolean) => dispatch({ type: "setItemPreference", itemIds: [itemId], preference: onHand ? "have" : "use" }),
[dispatch],
);
const mealCount = activeRecipeIds(state).length;
return (
<>
<Navbar checkoutTotal={summary.checkoutTotal} budget={state.targetBudget} syncStatus={syncStatus} isGenerating={isGenerating} />
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left: compact control bar -> immediate MealGrid -> Pantry below */}
<div className="space-y-6 lg:col-span-2">
<ConfigPanel
state={state}
dispatch={dispatch}
onGenerate={handleGenerate}
onClear={handleClearWeek}
isGenerating={isGenerating}
/>
{notes.length > 0 && (
<div
role="status"
className="flex items-start justify-between gap-3 rounded-xl border border-status-warn-line bg-status-warn-bg p-3.5 text-sm text-status-warn"
>
<div className="flex items-start gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<ul className="space-y-1">
{notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
</div>
<button
type="button"
onClick={() => setNotes([])}
aria-label="Dismiss notice"
className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-black/5"
>
<X className="size-4" aria-hidden />
</button>
</div>
)}
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
<MealGrid
state={state}
dispatch={dispatch}
mealCosts={mealCosts}
onOpenSlot={setOpenSlot}
onViewSlot={setViewSlot}
onRemoveSlot={handleRemoveSlot}
/>
</div>
<IngredientPanel state={state} dispatch={dispatch} />
</div>
{/* Right: persistent Market Receipt summary (desktop) */}
<aside className="hidden lg:block">
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-line bg-surface p-5">
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
<GrocerySummaryPanel
summary={summary}
budget={state.targetBudget}
priceSourceLabel={averageGroceryProvider.label}
onToggleOnHand={toggleOnHand}
/>
</div>
</aside>
</div>
</main>
{/* Undo Toast for accidental meal removal or Clear */}
{undo && (
<div
role="status"
aria-live="polite"
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
>
<span className="text-ink">{undo.message}</span>
<button
type="button"
onClick={handleUndo}
className="trim-cap inline-flex min-h-8 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-on-accent hover:bg-brand-500"
>
<RotateCcw className="size-3.5" aria-hidden />
Undo
</button>
<button
type="button"
onClick={() => setUndo(null)}
aria-label="Dismiss undo"
className="grid size-7 place-items-center rounded text-ink-faint hover:text-ink"
>
<X className="size-3.5" aria-hidden />
</button>
</div>
)}
{/* Mobile: sticky bottom bar */}
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-surface/95 px-4 py-3 backdrop-blur-md lg:hidden">
<div className="mx-auto flex max-w-7xl items-center gap-4">
<div className="flex-1">
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
</div>
<button
type="button"
onClick={() => setGroceriesOpen(true)}
className="trim-cap inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs hover:bg-brand-500"
>
<ShoppingBag className="size-4" strokeWidth={2} aria-hidden />
Receipt ({summary.lines.length})
</button>
</div>
<p className="sr-only" aria-live="polite">
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
</p>
</div>
<GroceryListDrawer
open={groceriesOpen}
onClose={closeGroceries}
summary={summary}
budget={state.targetBudget}
priceSourceLabel={averageGroceryProvider.label}
onToggleOnHand={toggleOnHand}
/>
<RecipeDialog
slotKey={viewSlot}
state={state}
onClose={closeRecipe}
onSwap={(key) => {
setViewSlot(null);
setOpenSlot(key);
}}
onRemove={(key, title) => {
handleRemoveSlot(key, title);
setViewSlot(null);
}}
/>
<MealSelectorModal
slotKey={openSlot}
state={state}
onClose={closeSelector}
onPick={(recipeId) => {
if (openSlot) dispatch({ type: "setSlot", key: openSlot, recipeId });
setOpenSlot(null);
}}
/>
</>
);
}