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>
174 lines
5.7 KiB
TypeScript
174 lines
5.7 KiB
TypeScript
import {
|
||
addWeeks,
|
||
buildCalendarMonth,
|
||
DateTime,
|
||
getWeekStart,
|
||
toDateOnly,
|
||
} from "@batch-cooking/date-tools";
|
||
import { WEEK_DAYS } from "@batch-cooking/shared";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import "./week-navigator.scss";
|
||
|
||
/** "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} — week-selection
|
||
* UI shared by any page organized around "one week at a time" (originally
|
||
* `PlanningPage`'s own grid, now also `ShoppingListPage` — both just need a
|
||
* `weekStart` in/out, neither cares how the other renders its own content
|
||
* for that week). Copy comes from `common.weekNav.*`/`common.calendar.*`/
|
||
* `common.days.*` rather than `planning.*` — generic enough ("Semaine
|
||
* précédente", day names) to not read as planning-specific from a page that
|
||
* isn't the planning grid.
|
||
*/
|
||
export 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("common.weekNav.prevWeek")}
|
||
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
||
>
|
||
‹
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="week-nav__label"
|
||
onClick={() => setIsCalendarOpen((open) => !open)}
|
||
>
|
||
📅 {t("common.weekNav.label", { range: formatWeekRange(weekStart) })}
|
||
{isThisWeek && <span className="today-badge">{t("common.weekNav.thisWeek")}</span>}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="week-nav__arrow"
|
||
title={t("common.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("common.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("common.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(`common.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>
|
||
);
|
||
}
|