diff --git a/apps/web/package.json b/apps/web/package.json index 890c1fc..cf78bf5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "e2e": "start-server-and-test dev http://localhost:5173 cy:run" }, "dependencies": { + "@batch-cooking/date-tools": "workspace:*", "@batch-cooking/shared": "workspace:*", "i18next": "^26.3.6", "react": "^18.3.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ec6d75f..4abe841 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,8 +2,8 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RequireAuth } from "./features/auth/RequireAuth"; import { AppLayout } from "./layouts/AppLayout"; -import { HomePage } from "./pages/HomePage"; import { LoginPage } from "./pages/LoginPage"; +import { PlanningPage } from "./pages/PlanningPage"; import { RecipesPage } from "./pages/RecipesPage"; import { ShoppingListPage } from "./pages/ShoppingListPage"; import { SignupPage } from "./pages/SignupPage"; @@ -45,7 +45,7 @@ export function App() { } > - } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 58489ca..99c979c 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -103,9 +103,13 @@ export class ApiClient { return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); } - /** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */ - public getCurrentPlanning(): Promise { - return this.request("/planning/current"); + /** + * Fetches the current user's household's planning covering `date` + * (`YYYY-MM-DD`, e.g. from `date-tools`'s `formatDateOnly`), or `null` if + * there isn't one for that week yet. + */ + public getPlanningForWeek(date: string): Promise { + return this.request(`/planning?date=${date}`); } /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 15545fe..35a1e30 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1,7 +1,8 @@ { "common": { "saving": "Enregistrement…", - "saved": "Enregistré ✓" + "saved": "Enregistré ✓", + "loadError": "Impossible de charger le planning, réessayez plus tard" }, "errors": { "VALIDATION_ERROR": "Erreur de validation", @@ -77,15 +78,38 @@ "greeting": "Bonjour {{firstName}} 👋", "logout": "Se déconnecter" }, - "home": { + "planning": { "title": "Planning de la semaine", "loading": "Chargement du planning…", - "error": "Impossible de charger le planning, réessayez plus tard", - "empty": "Aucun planning pour cette semaine.", - "table": { - "day": "Jour", - "meal": "Repas", - "recipe": "Recette" + "weekNav": { + "thisWeek": "Cette semaine", + "prevWeek": "Semaine précédente", + "nextWeek": "Semaine suivante", + "label": "Semaine du {{range}}" + }, + "calendar": { + "prevMonth": "Mois précédent", + "nextMonth": "Mois suivant" + }, + "days": { + "lundi": "Lundi", + "mardi": "Mardi", + "mercredi": "Mercredi", + "jeudi": "Jeudi", + "vendredi": "Vendredi", + "samedi": "Samedi", + "dimanche": "Dimanche" + }, + "meals": { + "petit-dejeuner": "Petit-déjeuner", + "collation": "Collation", + "dejeuner": "Déjeuner", + "gouter": "Goûter", + "diner": "Dîner" + }, + "grid": { + "addRecipeSoon": "Recherche de recettes à venir", + "removeRecipe": "Retirer cette recette" } }, "recipes": { diff --git a/apps/web/src/pages/HomePage.scss b/apps/web/src/pages/HomePage.scss deleted file mode 100644 index 656e51c..0000000 --- a/apps/web/src/pages/HomePage.scss +++ /dev/null @@ -1,56 +0,0 @@ -// ============================================================================= -// Styles specific to HomePage — colocated next to HomePage.tsx since nothing -// else uses these classes. -// ============================================================================= - -// No `@use` of the theme partial needed here: every design token below is a -// CSS custom property (--color-*, --space-*...) declared once on :root in -// styles/global.scss and available globally at runtime — not a Sass-level -// variable/mixin that would require an explicit compile-time import. - -// No outer centering wrapper here (unlike the old version of this file): -// AppLayout's `.app-content` already owns the page background/padding — -// this is just the page's own content. -.home-page { - &__status { - color: var(--color-text-muted); - font-size: var(--font-size-md); - } - - &__status--error { - color: var(--color-error); - } -} - -// The current planning, one row per meal slot. Raised on its own surface, -// same card treatment used elsewhere in the app, so it reads as a distinct -// piece of content rather than bare text on the page background. -.planning-table { - width: 100%; - max-width: 40rem; - margin-top: var(--space-md); - border-collapse: collapse; - background: var(--color-surface); - border-radius: var(--radius-md); - overflow: hidden; - box-shadow: var(--shadow-sm); - - th, - td { - padding: var(--space-sm) var(--space-md); - text-align: left; - border-bottom: 1px solid var(--color-border); - } - - th { - background: var(--color-surface-alt); - color: var(--color-text-muted); - font-size: var(--font-size-xs); - text-transform: uppercase; - letter-spacing: 0.04em; - } - - tr:last-child td { - border-bottom: none; - } -} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx deleted file mode 100644 index 4181fa6..0000000 --- a/apps/web/src/pages/HomePage.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { PlanningView } from "@batch-cooking/shared"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { apiClient } from "../api/client"; -import "./HomePage.scss"; - -/** Load state for the `GET /planning/current` 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" }; - -/** - * Landing page for an authenticated visitor — the household's current - * planning. Behind {@link RequireAuth} (via `AppLayout`), so this only - * renders once a session is confirmed; the planning itself still has to be - * fetched separately, hence the loading/error/empty/loaded states below. - * `null` from the API is a normal, common state (no planning created yet), - * not an error — see `apps/api`'s `planning.service.ts`. - */ -export function HomePage() { - const { t } = useTranslation(); - const [state, setState] = useState({ status: "loading" }); - - useEffect(() => { - // Guards against setting state after unmount (e.g. the user navigates - // away before the request resolves) — no cleanup-worthy resource here, - // just avoids a "set state on unmounted component" warning. - let cancelled = false; - - apiClient - .getCurrentPlanning() - .then((planning) => { - if (!cancelled) setState({ status: "loaded", planning }); - }) - .catch(() => { - if (!cancelled) setState({ status: "error" }); - }); - - return () => { - cancelled = true; - }; - }, []); - - return ( -
-

{t("home.title")}

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

{t("home.loading")}

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

{t("home.error")}

- )} - - {state.status === "loaded" && state.planning === null && ( -

{t("home.empty")}

- )} - - {state.status === "loaded" && state.planning !== null && ( - - - - - - - - - - {state.planning.items.map((item) => ( - - - - - - ))} - -
{t("home.table.day")}{t("home.table.meal")}{t("home.table.recipe")}
{item.weekDay}{item.meal}{item.recipe.name}
- )} -
- ); -} diff --git a/apps/web/src/pages/PlanningPage.tsx b/apps/web/src/pages/PlanningPage.tsx new file mode 100644 index 0000000..749d862 --- /dev/null +++ b/apps/web/src/pages/PlanningPage.tsx @@ -0,0 +1,318 @@ +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} + + + ))} +
+ )} + +
+ + ); +} diff --git a/apps/web/src/pages/planning-page.scss b/apps/web/src/pages/planning-page.scss new file mode 100644 index 0000000..f6c1431 --- /dev/null +++ b/apps/web/src/pages/planning-page.scss @@ -0,0 +1,356 @@ +// ============================================================================= +// Styles specific to PlanningPage — colocated next to PlanningPage.tsx since +// nothing else uses these classes. Ported from the reviewed HTML mockup +// (see the plan file / PR description) onto the app's real design tokens — +// no light/dark duplication needed here, unlike the standalone mockup: +// every `var(--color-*)` below already resolves per-theme globally (see +// styles/_theme.scss). +// ============================================================================= + +// `.app-content` (AppLayout.scss) already stretches to the full viewport +// height (flex item of `.app-layout`, itself `min-height: 100vh` — the +// same stretch the sidebar relies on to pin its footer at the bottom). +// `.planning-page` just needs to fill that box and lay out as a column so +// `.planning-grid-wrapper` can grow to fill whatever's left under the +// header, instead of the grid being only as tall as its content. +.planning-page { + height: 100%; + display: flex; + flex-direction: column; + + &__header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-md); + margin-bottom: var(--space-lg); + } + + &__status { + color: var(--color-text-muted); + font-size: var(--font-size-md); + } + + &__status--error { + color: var(--color-error); + } +} + +// --- Week navigator (arrows + clickable label opening the calendar) ------- +.week-nav { + position: relative; + display: flex; + align-items: center; + gap: var(--space-xs); + + &__arrow { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--font-size-md); + cursor: pointer; + + &:hover { + background: var(--color-surface-alt); + } + } + + &__label { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: 0.45rem var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + font-weight: 600; + font-size: var(--font-size-sm); + cursor: pointer; + + &:hover { + background: var(--color-surface-alt); + } + } +} + +.today-badge { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + padding: 0.1rem 0.4rem; + border-radius: var(--radius-pill); +} + +// --- Calendar popover ------------------------------------------------------- +.calendar-popover { + position: absolute; + top: calc(100% + var(--space-xs)); + right: 0; + z-index: 10; + width: 18rem; + padding: var(--space-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-sm); + font-weight: 700; + font-size: var(--font-size-sm); + text-transform: capitalize; + + button { + width: 1.6rem; + height: 1.6rem; + border: none; + background: none; + cursor: pointer; + font-size: var(--font-size-base); + color: var(--color-text-muted); + border-radius: var(--radius-base); + + &:hover { + background: var(--color-surface-alt); + } + } + } +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + + &__weekday { + text-align: center; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + font-weight: 600; + padding-bottom: var(--space-xs); + } + + &__day { + aspect-ratio: 1; + display: grid; + place-items: center; + font-size: var(--font-size-sm); + border-radius: var(--radius-base); + cursor: pointer; + color: var(--color-text); + border: none; + background: none; + font: inherit; + + &:hover { + background: var(--color-surface-alt); + } + + &--muted { + color: var(--color-border); + } + + &--in-selected-week { + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + border-radius: 0; + } + + &--today { + box-shadow: inset 0 0 0 2px var(--color-primary); + font-weight: 700; + } + } +} + +// --- The grid itself -------------------------------------------------------- +.planning-grid-wrapper { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); +} + +table.planning-grid { + width: 100%; + height: 100%; + min-width: 62rem; + border-collapse: collapse; + table-layout: fixed; + + th, + td { + border: 1px solid var(--color-border); + vertical-align: top; + } + + thead th { + padding: var(--space-sm) var(--space-md); + background: var(--color-surface-alt); + text-align: left; + + &:first-child { + width: 9rem; + } + + .day-name { + display: block; + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + font-weight: 600; + } + + .day-date { + display: block; + font-size: var(--font-size-md); + font-weight: 700; + margin-top: 2px; + color: var(--color-text); + } + + &.today { + background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface-alt)); + + .day-date { + color: var(--color-primary); + } + } + } + + tbody th { + padding: var(--space-sm) var(--space-md); + background: var(--color-surface-alt); + text-align: center; + vertical-align: middle; + font-size: var(--font-size-sm); + font-weight: 600; + } + + td.today { + background: color-mix(in srgb, var(--color-primary) 4%, var(--color-surface)); + } + + // Repas groupés par moment de la journée (Matin / Midi / Après-midi / + // Soir) — piloté uniquement via `border-bottom` (jamais `border-top`) : + // avec `border-collapse: collapse`, deux bordures différentes qui se + // rencontrent sur la même arête peuvent fusionner de façon ambiguë selon + // le navigateur — en désactivant `border-top` sur tbody, chaque arête + // horizontale n'est plus définie que d'un seul côté, sans ambiguïté + // possible. Toutes les séparations entre repas sont pleines (même couleur + // que les séparations de jour) ; seule la frontière entre deux groupes + // ("band-end", la dernière ligne d'un groupe) se distingue par une + // épaisseur plus marquée. + tbody th, + tbody td { + border-top: none; + border-bottom: 1px solid var(--color-border); + } + + tbody tr.band-end th, + tbody tr.band-end td { + border-bottom: 2px solid var(--color-border); + } +} + +// --- Case : pastilles de recette + bouton "+" ------------------------------- +.meal-cell { + padding: var(--space-sm); +} + +.meal-cell__content { + display: flex; + flex-direction: column; + gap: var(--space-xs); + height: 100%; +} + +.meal-cell__recipes { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.recipe-chip { + display: flex; + align-items: center; + gap: var(--space-xs); + width: 100%; + box-sizing: border-box; + padding: 0.3rem var(--space-sm); + border-radius: var(--radius-pill); + background: var(--color-tag); + color: var(--color-tag-ink); + font-size: var(--font-size-xs); + font-weight: 600; + + &__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__remove { + flex-shrink: 0; + width: 1rem; + height: 1rem; + display: grid; + place-items: center; + border: none; + background: none; + cursor: pointer; + color: inherit; + opacity: 0; + font-size: 0.65rem; + border-radius: 50%; + } + + &:hover &__remove { + opacity: 0.7; + } + + &__remove:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.15); + } +} + +// Pleine largeur, sans bordure (juste un fond au survol — plus épuré qu'un +// contour pointillé), et toujours collé en haut de la case (juste sous la +// dernière recette s'il y en a), jamais centré au milieu d'une case vide. +.add-recipe-btn { + width: 100%; + box-sizing: border-box; + padding: 0.35rem; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--radius-base); + background: none; + color: var(--color-text-muted); + font-size: var(--font-size-base); + line-height: 1; + cursor: pointer; + + &:hover { + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + } +} diff --git a/apps/web/src/pages/settings/HouseholdSettingsPage.tsx b/apps/web/src/pages/settings/HouseholdSettingsPage.tsx index 313a10d..1e37399 100644 --- a/apps/web/src/pages/settings/HouseholdSettingsPage.tsx +++ b/apps/web/src/pages/settings/HouseholdSettingsPage.tsx @@ -66,7 +66,9 @@ export function HouseholdSettingsPage() { return (

{t("household.title")}

-

{t("home.error")}

+

+ {t("common.loadError")} +

); } diff --git a/apps/web/src/pages/settings/PreferencesPage.tsx b/apps/web/src/pages/settings/PreferencesPage.tsx index 75f0249..6b2ef3f 100644 --- a/apps/web/src/pages/settings/PreferencesPage.tsx +++ b/apps/web/src/pages/settings/PreferencesPage.tsx @@ -129,7 +129,9 @@ export function PreferencesPage() { return (

{t("preferences.title")}

-

{t("home.error")}

+

+ {t("common.loadError")} +

); }