import { DateTime, addWeeks, buildCalendarMonth, formatDateOnly, getWeekStart, toDateOnly, } from "@batch-cooking/date-tools"; import { MEALS, type Meal, type PlanningView, WEEK_DAYS } from "@batch-cooking/shared"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { apiClient } from "../api/client"; import "./planning-page.scss"; /** 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 [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc())); const [state, setState] = useState({ status: "loading" }); 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]); return (

{t("planning.title")}

{state.status === "loading" && (

{t("planning.loading")}

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

{t("common.loadError")}

)} {state.status === "loaded" && ( )}
); } /** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */ function formatWeekRange(weekStart: DateTime): string { const weekEnd = weekStart.plus({ days: 6 }); const sameMonth = weekStart.hasSame(weekEnd, "month"); const startLabel = weekStart.toLocaleString( sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" }, { locale: "fr" }, ); const endLabel = weekEnd.toLocaleString( { day: "numeric", month: "long", year: "numeric" }, { locale: "fr" }, ); return `${startLabel} au ${endLabel}`; } /** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */ function WeekNavigator({ weekStart, onChangeWeek, }: { weekStart: DateTime; onChangeWeek: (weekStart: DateTime) => void; }) { const { t } = useTranslation(); const [isCalendarOpen, setIsCalendarOpen] = useState(false); const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day"); return (
{isCalendarOpen && ( { onChangeWeek(getWeekStart(day)); setIsCalendarOpen(false); }} onClose={() => setIsCalendarOpen(false)} /> )}
); } /** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */ function CalendarPopover({ selectedWeekStart, onSelectDay, onClose, }: { selectedWeekStart: DateTime; onSelectDay: (day: DateTime) => void; onClose: () => void; }) { const { t } = useTranslation(); // Its own state: browsing to a different month to pick a week there // shouldn't jump back every render — only re-anchors when the popover is // first opened (`selectedWeekStart` at that point), not while it's open. const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month")); const popoverRef = useRef(null); useEffect(() => { function handleClickOutside(e: MouseEvent) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [onClose]); const today = toDateOnly(DateTime.utc()); const selectedWeekEnd = selectedWeekStart.plus({ days: 6 }); const weeks = buildCalendarMonth(visibleMonth); return (
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
{WEEK_DAYS.map((weekDay) => ( {t(`planning.days.${weekDay}`).charAt(0)} ))} {weeks.flat().map((day) => { const classNames = ["calendar-grid__day"]; if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted"); if (day >= selectedWeekStart && day <= selectedWeekEnd) { classNames.push("calendar-grid__day--in-selected-week"); } if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today"); return ( ); })}
); } /** The week grid itself — 7 day columns × 5 meal rows. */ function PlanningGrid({ weekStart, planning, }: { weekStart: DateTime; planning: PlanningView | null }) { 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) => ( {days.map(({ weekDay, date }) => ( item.weekDay === weekDay && item.meal === meal) .map((item) => ({ id: item.id, name: item.recipe.name }))} /> ))} ))}
{days.map(({ weekDay, date }) => ( {t(`planning.days.${weekDay}`)} {date.day}
{t(`planning.meals.${meal}`)}
); } /** One (day, meal) cell: the recipes already planned for it (as pills) plus the "+" to add another. */ function MealCell({ isToday, recipes, }: { isToday: boolean; recipes: { id: number; name: string }[]; }) { const { t } = useTranslation(); return (
{recipes.length > 0 && (
{recipes.map((recipe) => ( {recipe.name} ))}
)}
); }