import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools"; import { ErrorCode, MEALS, type Meal, type PlanningItemView, type PlanningView, WEEK_DAYS, type WeekDay, } from "@batch-cooking/shared"; import { type CollisionDetection, closestCenter, DndContext, type DragEndEvent, KeyboardSensor, MouseSensor, pointerWithin, TouchSensor, useDroppable, useSensor, useSensors, } from "@dnd-kit/core"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ApiError, apiClient } from "../../api/client"; import { useAuth } from "../../features/auth/AuthContext"; import { LeftoverAssignDialog } from "../../features/planning/LeftoverAssignDialog"; import { LeftoversBasket } from "../../features/planning/LeftoversBasket"; import type { LeftoverDragData } from "../../features/planning/leftovers-basket"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { useTour } from "../../features/tour/TourContext"; import { errorMessageService } from "../../services/error-message.service"; import "./planning-page.scss"; /** Drop-target payload each planning cell registers (`useDroppable`'s `data`) — the slot a dragged leftover lands on. */ interface CellDropData { date: string; weekDay: WeekDay; meal: Meal; } /** * Drop onto whichever cell the pointer is actually over; fall back to the * nearest cell centre only when the pointer isn't inside any (e.g. the * keyboard sensor, which has no pointer). Cells are packed edge to edge, so * a rect-overlap strategy would pick a neighbour whenever the small drag * handle's translated box crossed a border. */ const dropOnCellUnderPointer: CollisionDetection = (args) => { const underPointer = pointerWithin(args); return underPointer.length > 0 ? underPointer : closestCenter(args); }; /** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */ type PlanningState = | { status: "loading" } | { status: "loaded"; planning: PlanningView | null } | { status: "error" }; /** Meals that close out a "moment of the day" group (Matin/Midi/Après-midi/Soir) — see `.band-end` in planning-page.scss for the resulting border treatment. */ const BAND_END_MEALS: ReadonlySet = new Set(["collation", "dejeuner", "gouter"]); /** * Landing page for an authenticated visitor — the household's planning for * a selectable week, laid out as a grid (days × meals). Behind * {@link RequireAuth} (via `AppLayout`), so this only renders once a * session is confirmed. * * `null` from the API is a normal, common state (no planning for that week * yet) — unlike the previous single-day table view this replaces, it isn't * rendered as a separate "empty" message: the grid itself, with every cell * showing just its "+" button, already communicates that. The "+" itself * isn't wired to anything yet (no recipe catalog to search — see the * planning page's plan/PR description) — a future task. */ export function PlanningPage() { const { t } = useTranslation(); const navigate = useNavigate(); const [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc())); const [state, setState] = useState({ status: "loading" }); // The slot a `RecipePickerDialog` is currently open for — `null` means // closed. Mounting the dialog only while this is set (rather than an // always-mounted `isOpen` toggle) resets its internal filter/search // state for free on every open, same convention as `WeekNavigator`'s own // `CalendarPopover` (features/planning/WeekNavigator.tsx). const [openSlot, setOpenSlot] = useState(null); // A leftover card has been dropped on a slot and we're asking how many of // its pool to serve there — `null` means no such step is open. const [pendingAssign, setPendingAssign] = useState<(LeftoverDragData & CellDropData) | null>( null, ); // Guided tour #1 (issue #42): a signed-in visitor who's never seen the // general tour gets it on first arrival at "/". Ref-guarded so it fires // once per mount, never on a week-navigation re-render; `startTour` // additionally no-ops if it's already completed or already running. const { user } = useAuth(); const { isReady: isTourReady, isTourCompleted, startTour } = useTour(); const autoTourStarted = useRef(false); useEffect(() => { if (!autoTourStarted.current && isTourReady && user && !isTourCompleted("general")) { autoTourStarted.current = true; startTour("general"); } }, [isTourReady, user, isTourCompleted, startTour]); // Mouse + touch rather than the unified PointerSensor: same coverage, but // driven by plain mouse/touch events, which Cypress can script reliably // (see `planning-leftovers.ts`). A small activation distance so a plain // click on a basket line's "Jeter" button doesn't start a drag; the // keyboard sensor keeps the whole flow reachable without a pointer. const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }), useSensor(KeyboardSensor), ); useEffect(() => { let cancelled = false; setState({ status: "loading" }); apiClient .getPlanningForWeek(formatDateOnly(weekStart)) .then((planning) => { if (!cancelled) setState({ status: "loaded", planning }); }) .catch(() => { if (!cancelled) setState({ status: "error" }); }); return () => { cancelled = true; }; }, [weekStart]); /** * Applies `updater` to the currently loaded planning's items, patching * local state without a full `GET /planning` refetch — same philosophy * as `RecipesPage`'s `handleFavoriteToggled`/`handleDeleted`. Builds a * placeholder parent `Planning` if none existed yet (the week's very * first add, see `addPlanningItem`'s `findOrCreatePlanningForWeek`) — * its `id`/`startDate`/`finishDate` are never read anywhere on this page * (only `planning.items` is), so a placeholder id is harmless; the next * week-navigation away and back re-fetches the real row anyway. */ function patchPlanningItems(updater: (items: PlanningItemView[]) => PlanningItemView[]) { setState((prev) => { if (prev.status !== "loaded") return prev; const planning = prev.planning ?? { id: -1, startDate: weekStart.toISO() ?? "", finishDate: weekStart.plus({ days: 6 }).toISO() ?? "", items: [], }; return { status: "loaded", planning: { ...planning, items: updater(planning.items) } }; }); } function handleAdded(item: PlanningItemView) { patchPlanningItems((items) => [...items, item]); } /** `PATCH .../discard-leftover` returns the updated item — swap it in by id so the basket recomputes. */ function handleLeftoverDiscarded(updated: PlanningItemView) { patchPlanningItems((items) => items.map((i) => (i.id === updated.id ? updated : i))); } /** A leftover card was dropped on a cell — open the "how many portions?" step for that (source, slot) pair. */ function handleDragEnd(event: DragEndEvent) { const drag = event.active.data.current as LeftoverDragData | undefined; const cell = event.over?.data.current as CellDropData | undefined; if (!drag || !cell) return; setPendingAssign({ ...drag, ...cell }); } /** Confirms the assign step — `POST /planning/items/leftover`, then patches the new "leftovers" item into local state. Returns an error label to show inline, or `null` on success. */ async function confirmAssign(portions: number): Promise { if (!pendingAssign) return null; try { const item = await apiClient.assignLeftover({ date: pendingAssign.date, weekDay: pendingAssign.weekDay, meal: pendingAssign.meal, sourceItemId: pendingAssign.sourceItemId, portions, }); patchPlanningItems((items) => [...items, item]); setPendingAssign(null); return null; } catch (err) { return errorMessageService.getLabel( err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR, ); } } /** Optimistic removal (same rollback-on-failure idea as `FavoriteStarButton`) — the API call already happened by the time this needs to roll back, `removePlanningItem` having already rejected. */ async function handleRemove(item: PlanningItemView) { patchPlanningItems((items) => items.filter((i) => i.id !== item.id)); try { await apiClient.removePlanningItem(item.id); } catch { patchPlanningItems((items) => [...items, item]); } } // Enabled only once we know the week has at least one planned recipe — // "cuisiner" an empty week would just land on the page's own empty state. const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0; return (

{t("planning.title")}

{state.status === "loaded" && ( )}
{state.status === "loading" && (

{t("planning.loading")}

)} {state.status === "error" && (

{t("common.loadError")}

)} {state.status === "loaded" && ( )} {openSlot && ( setOpenSlot(null)} onAdded={handleAdded} /> )} {pendingAssign && ( setPendingAssign(null)} onConfirm={confirmAssign} /> )}
); } /** The week grid itself — 7 day columns × 5 meal rows. */ function PlanningGrid({ weekStart, planning, onAddSlot, onRemoveItem, }: { weekStart: DateTime; planning: PlanningView | null; onAddSlot: (slot: PlanningSlot) => void; onRemoveItem: (item: PlanningItemView) => void; }) { const { t } = useTranslation(); const today = toDateOnly(DateTime.utc()); const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) })); const items = planning?.items ?? []; return (
))} {MEALS.map((meal, mealIndex) => ( {days.map(({ weekDay, date }, dayIndex) => ( item.weekDay === weekDay && item.meal === meal)} onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })} onRemove={onRemoveItem} // Only the very first cell's "+" carries the tour anchor — // the guided tour just needs one to point at. isTourAddTarget={mealIndex === 0 && dayIndex === 0} /> ))} ))}
{days.map(({ weekDay, date }) => ( {t(`common.days.${weekDay}`)} {date.day}
{t(`planning.meals.${meal}`)}
); } /** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. Also a `@dnd-kit` drop target for a leftover card. */ function MealCell({ isToday, date, weekDay, meal, items, onAdd, onRemove, isTourAddTarget = false, }: { isToday: boolean; date: string; weekDay: WeekDay; meal: Meal; items: PlanningItemView[]; onAdd: () => void; onRemove: (item: PlanningItemView) => void; isTourAddTarget?: boolean; }) { const { t } = useTranslation(); const { setNodeRef, isOver } = useDroppable({ id: `cell-${weekDay}-${meal}`, data: { date, weekDay, meal } satisfies CellDropData, }); const className = ["meal-cell", isToday ? "today" : "", isOver ? "meal-cell--drop-over" : ""] .filter(Boolean) .join(" "); return (
{items.length > 0 && (
{items.map((item) => ( {item.sourceItem !== null && ( ♻️ )} {item.recipe.name} · ×{item.portions} ))}
)}
); }