batchCooking/apps/web/src/pages/planning/PlanningPage.tsx
kyuno053 109dde9c7b
feat(shopping-list): liste de courses agrégée depuis le planning (#73)
GET /shopping-list?date= (shopping-list.service.ts/.routes.ts) somme les
ingrédients de chaque recette planifiée sur la semaine, mis à l'échelle par
les portions de chaque créneau (PlanningItem.portions / Recipe.portions),
regroupés par paire (ingredientId, unitId) — jamais null contrairement à
GET /planning, une semaine vide redescend en items: [].

Côté web, ShoppingListPage rend cette liste groupée par rayon (même
IngredientCategory que IngredientPicker), triée alphabétiquement en
français à l'intérieur d'un rayon (shopping-list.ts, logique pure extraite
du composant). WeekNavigator (flèches + calendrier) est extrait de
PlanningPage vers features/planning/ pour être partagé entre les deux
pages ; ses libellés migrent de planning.* vers common.weekNav.*/
common.calendar.*/common.days.*, plus génériques pour une page qui n'est
plus seulement le planning.

ComingSoonPage retiré (plus aucun appelant, Liste de courses avait le
dernier stub restant).

Tests : Mocha (agrégation, mise à l'échelle par portions, unités non
fusionnées) + Cucumber (shopping-list.feature : liste vide, groupement/tri,
navigation de semaine) + mise à jour de layout.cy.ts/planning-page.cy.ts
pour le nouveau rendu.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:45:06 +02:00

240 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import {
MEALS,
type Meal,
type PlanningItemView,
type PlanningView,
WEEK_DAYS,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
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" });
// 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<PlanningSlot | null>(null);
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]);
}
/** 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]);
}
}
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}
onAddSlot={setOpenSlot}
onRemoveItem={handleRemove}
/>
)}
{openSlot && (
<RecipePickerDialog
slot={openSlot}
onClose={() => setOpenSlot(null)}
onAdded={handleAdded}
/>
)}
</div>
);
}
/** 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 (
<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(`common.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")}
items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
onRemove={onRemoveItem}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. */
function MealCell({
isToday,
items,
onAdd,
onRemove,
}: {
isToday: boolean;
items: PlanningItemView[];
onAdd: () => void;
onRemove: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation();
return (
<td className={isToday ? "meal-cell today" : "meal-cell"}>
<div className="meal-cell__content">
{items.length > 0 && (
<div className="meal-cell__recipes">
{items.map((item) => (
<span key={item.id} className="recipe-chip">
<span className="recipe-chip__name">
{item.recipe.name} · ×{item.portions}
</span>
<button
type="button"
className="recipe-chip__remove"
title={t("planning.grid.removeRecipe")}
onClick={() => onRemove(item)}
>
</button>
</span>
))}
</div>
)}
<button
type="button"
className="add-recipe-btn"
title={t("planning.grid.addRecipe")}
onClick={onAdd}
>
+
</button>
</div>
</td>
);
}