batchCooking/apps/web/src/pages/planning/PlanningPage.tsx
Nicolas 38fcf03417
All checks were successful
CI / lint (push) Successful in 3m55s
CI / build (push) Successful in 4m53s
CI / intent-service-test (push) Successful in 5m52s
CI / e2e (push) Successful in 10m30s
CI / test (push) Successful in 18m43s
feat(web): moteur de tutoriel guidé driver.js (#42)
Consomme le socle backend de #42/#44 (`GET /reference/tours`,
`GET/POST /profile/tours`). Deux tours : orientation générale (auto sur
`/`) et correction des métadonnées de recette (auto à l'ouverture du
premier popover de correction).

- Dépendance `driver.js` (~5 ko, MIT, zéro dépendance).
- `features/tour/tour-engine.ts` : wrapper vanilla autour de `driver.js` —
  gère ce que la lib ne fait pas seule : la navigation React Router entre
  étapes situées sur des routes différentes, et un `onFinish` unique
  (`"completed"` seulement si on atteint la fin, `"skipped"` pour la croix
  / Échap / clic sur l'overlay).
- `features/tour/TourContext.tsx` : `TourProvider` (charge scénarios +
  progression, keyed sur `user?.id` comme `ThemeContext`) et `useTour()`
  (`startTour(key)` / `isTourCompleted(key)` / `tours` / `isReady`).
  `isReady` est **calculé au render** contre l'id utilisateur chargé — pas
  stocké — pour qu'un changement de `user` le repasse à `false`
  immédiatement, avant qu'un effet enfant (l'auto-déclenchement) ne tourne
  sur des données de complétion périmées.
- `main.tsx` : `<TourProvider>` sous `ThemeProvider`.
- Attributs `data-tour-*` : liens de nav (`AppLayout`), grille / bouton `+`
  du 1er créneau / bouton Cuisiner (`PlanningPage`), page liste de courses
  (`ShoppingListPage`), description d'étape + section technique du popover
  (`StepDescription` / `TechStepCorrectionPopover`).
- Déclenchement auto : tour `general` sur `/` (`PlanningPage`, ref-gardé),
  tour `techStepMetadata` à `activeCorrection` non-null (`StepDescription`).
- Section « Revoir un tutoriel » dans `/parametres/preferences-utilisateur`
  — un `map` sur `tours`, un bouton « Revoir » par scénario.
- `tour.scss` re-skinne le popover `driver.js` sur les tokens du thème.
- i18n `tour.*` (chrome uniquement : boutons du popover, section « Revoir » ;
  le contenu des étapes vient de la base).

Tests Cypress (`tour.feature`, 5 scénarios) : auto-démarrage + élément
surligné pour un nouveau venu ; « Terminé » → complétion enregistrée
`skipped:false` ; croix → `skipped:true` ; un tour déjà vu ne se relance
pas ; « Revoir » le relance depuis les paramètres. Vérifié 5/5 en local.
`pnpm build` + `pnpm lint` verts.

Refs #42
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 06:17:06 +02:00

416 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
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<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 navigate = useNavigate();
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);
// 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<string | null> {
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 (
<DndContext
sensors={sensors}
collisionDetection={dropOnCellUnderPointer}
onDragEnd={handleDragEnd}
>
<div className="planning-page">
<div className="planning-page__header">
<h1>{t("planning.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
{state.status === "loaded" && (
<LeftoversBasket
planning={state.planning}
onLeftoverDiscarded={handleLeftoverDiscarded}
/>
)}
<button
type="button"
className="planning-page__cook-btn"
data-tour="planning-cook-button"
disabled={!hasPlannedRecipes}
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
>
{t("planning.startCooking")}
</button>
</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}
/>
)}
{pendingAssign && (
<LeftoverAssignDialog
recipeName={pendingAssign.recipeName}
available={pendingAssign.available}
onCancel={() => setPendingAssign(null)}
onConfirm={confirmAssign}
/>
)}
</div>
</DndContext>
);
}
/** 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" data-tour="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, mealIndex) => (
<tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
<th>{t(`planning.meals.${meal}`)}</th>
{days.map(({ weekDay, date }, dayIndex) => (
<MealCell
key={weekDay}
isToday={date.hasSame(today, "day")}
date={formatDateOnly(date)}
weekDay={weekDay}
meal={meal}
items={items.filter((item) => 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}
/>
))}
</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. 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 (
<td ref={setNodeRef} className={className}>
<div className="meal-cell__content">
{items.length > 0 && (
<div className="meal-cell__recipes">
{items.map((item) => (
<span
key={item.id}
className={
item.sourceItem !== null ? "recipe-chip recipe-chip--leftover" : "recipe-chip"
}
>
<span className="recipe-chip__name">
{item.sourceItem !== null && (
<span
className="recipe-chip__leftover-badge"
title={t("planning.leftovers.chipBadge")}
>
♻️
</span>
)}
{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}
data-tour={isTourAddTarget ? "planning-add-recipe" : undefined}
>
+
</button>
</div>
</td>
);
}