- Remplace HomePage (table jour unique) par PlanningPage : grille 7 jours × 5 repas, groupés Matin/Midi/Après-midi/Soir (séparateurs pleins, plus épais entre groupes), recettes en pastilles pleine largeur, bouton "+" pleine largeur sans bordure (pas encore branché — pas de catalogue de recettes côté API, tâche future) - WeekNavigator + CalendarPopover (sur date-tools) : flèches semaine précédente/suivante, popover calendrier (mois navigable, clic sur un jour → sa semaine), fermeture au clic extérieur - apiClient.getPlanningForWeek(date) remplace getCurrentPlanning() - i18n: namespace home → planning (+ nouvelles clés jours/repas/ calendrier), common.loadError factorisé (repris par les pages Foyer/Préférences qui réutilisaient l'ancien home.error)
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
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<Meal> = 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<DateTime>(() => getWeekStart(DateTime.utc()));
|
||
const [state, setState] = useState<PlanningState>({ 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 (
|
||
<div className="planning-page">
|
||
<div className="planning-page__header">
|
||
<h1>{t("planning.title")}</h1>
|
||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||
</div>
|
||
|
||
{state.status === "loading" && (
|
||
<p className="planning-page__status">{t("planning.loading")}</p>
|
||
)}
|
||
|
||
{state.status === "error" && (
|
||
<p className="planning-page__status planning-page__status--error">
|
||
{t("common.loadError")}
|
||
</p>
|
||
)}
|
||
|
||
{state.status === "loaded" && (
|
||
<PlanningGrid weekStart={weekStart} planning={state.planning} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** "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 (
|
||
<div className="week-nav">
|
||
<button
|
||
type="button"
|
||
className="week-nav__arrow"
|
||
title={t("planning.weekNav.prevWeek")}
|
||
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
||
>
|
||
‹
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="week-nav__label"
|
||
onClick={() => setIsCalendarOpen((open) => !open)}
|
||
>
|
||
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
|
||
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="week-nav__arrow"
|
||
title={t("planning.weekNav.nextWeek")}
|
||
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
||
>
|
||
›
|
||
</button>
|
||
|
||
{isCalendarOpen && (
|
||
<CalendarPopover
|
||
selectedWeekStart={weekStart}
|
||
onSelectDay={(day) => {
|
||
onChangeWeek(getWeekStart(day));
|
||
setIsCalendarOpen(false);
|
||
}}
|
||
onClose={() => setIsCalendarOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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<HTMLDivElement>(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 (
|
||
<div className="calendar-popover" ref={popoverRef}>
|
||
<div className="calendar-popover__header">
|
||
<button
|
||
type="button"
|
||
title={t("planning.calendar.prevMonth")}
|
||
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
||
>
|
||
‹
|
||
</button>
|
||
<span>
|
||
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
title={t("planning.calendar.nextMonth")}
|
||
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
|
||
<div className="calendar-grid">
|
||
{WEEK_DAYS.map((weekDay) => (
|
||
<span key={weekDay} className="calendar-grid__weekday">
|
||
{t(`planning.days.${weekDay}`).charAt(0)}
|
||
</span>
|
||
))}
|
||
|
||
{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 (
|
||
<button
|
||
key={day.toISO()}
|
||
type="button"
|
||
className={classNames.join(" ")}
|
||
onClick={() => onSelectDay(day)}
|
||
>
|
||
{day.day}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<div className="planning-grid-wrapper">
|
||
<table className="planning-grid">
|
||
<thead>
|
||
<tr>
|
||
<th />
|
||
{days.map(({ weekDay, date }) => (
|
||
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
|
||
<span className="day-name">{t(`planning.days.${weekDay}`)}</span>
|
||
<span className="day-date">{date.day}</span>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{MEALS.map((meal) => (
|
||
<tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
|
||
<th>{t(`planning.meals.${meal}`)}</th>
|
||
{days.map(({ weekDay, date }) => (
|
||
<MealCell
|
||
key={weekDay}
|
||
isToday={date.hasSame(today, "day")}
|
||
recipes={items
|
||
.filter((item) => item.weekDay === weekDay && item.meal === meal)
|
||
.map((item) => ({ id: item.id, name: item.recipe.name }))}
|
||
/>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<td className={isToday ? "meal-cell today" : "meal-cell"}>
|
||
<div className="meal-cell__content">
|
||
{recipes.length > 0 && (
|
||
<div className="meal-cell__recipes">
|
||
{recipes.map((recipe) => (
|
||
<span key={recipe.id} className="recipe-chip">
|
||
<span className="recipe-chip__name">{recipe.name}</span>
|
||
<button
|
||
type="button"
|
||
className="recipe-chip__remove"
|
||
title={t("planning.grid.removeRecipe")}
|
||
>
|
||
✕
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<button type="button" className="add-recipe-btn" title={t("planning.grid.addRecipeSoon")}>
|
||
+
|
||
</button>
|
||
</div>
|
||
</td>
|
||
);
|
||
}
|