diff --git a/apps/web/cypress/e2e/layout.cy.ts b/apps/web/cypress/e2e/layout.cy.ts index c00089f..8bd0547 100644 --- a/apps/web/cypress/e2e/layout.cy.ts +++ b/apps/web/cypress/e2e/layout.cy.ts @@ -276,7 +276,13 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () => }); }); - it("keeps the 7-day planning grid on its own horizontal scroll rather than overflowing the page", () => { + // Until issue #88, a phone viewport just let the 7-column grid scroll + // horizontally inside `.planning-grid-wrapper` (a shrunk desktop table, + // not a real mobile layout). #88 replaces it outright with + // `PlanningDayView` below the phone breakpoint — this test now covers + // *that* switch; the day view's own day-by-day content is + // PlanningPage.tsx's own spec (planning-page.cy.ts). + it("shows the one-day mobile view instead of the horizontally-scrolling grid, on a phone", () => { cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: { @@ -301,22 +307,22 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () => cy.setViewport("phone"); cy.visit("/"); - // The grid wrapper is the scroll container, and its content is genuinely - // wider than the phone viewport (7 columns never crushed). - cy.get(".planning-grid-wrapper").should(($el) => { - expect(getComputedStyle($el[0]).overflowX).to.be.oneOf(["auto", "scroll"]); - expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth); - }); + // The desktop grid is gone entirely, not just visually — the day view + // takes over the whole page. + cy.get(".planning-grid-wrapper").should("not.exist"); + cy.get(".planning-day-view").should("be.visible"); + cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible"); - // ...and that overflow stays *inside* the wrapper — the document itself - // never scrolls sideways (global rule against a horizontally-scrolling - // body). + // The document itself never scrolls sideways (global rule against a + // horizontally-scrolling body) — still true for the day view's own + // stacked-card layout, same as every other page. cy.document() .its("documentElement.scrollWidth") .should("be.lte", PHONE_VIEWPORT.width + 1); // The per-recipe remove "✕" is hover-gated on desktop; on touch it must - // be permanently visible or a planned recipe can't be removed at all. + // be permanently visible or a planned recipe can't be removed at all — + // same shared `.recipe-chip__remove` class either layout renders. cy.get(".recipe-chip__remove") .first() .should(($btn) => { diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index 76981b7..b7e6922 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -219,3 +219,84 @@ describe("Planning grid", () => { cy.get(".bottom-sheet").should("not.exist"); }); }); + +// Ticket #88 — the 7-column grid gives way entirely to `PlanningDayView` on +// a phone (not the same table, scrolled): a switcher plus one day's meals +// stacked as cards. `.recipe-chip`/`.add-recipe-btn` are the same elements +// either layout renders (see `mealCellBody`, shared by `MealCell` and +// `MealCellRow` in PlanningPage.tsx), so this only covers what's actually +// different here — day selection, its clamped edges, and that the day +// view's own "+" still opens the real recipe picker. +describe("Planning day view (phone)", () => { + beforeEach(() => { + cy.setViewport("phone"); + freezeToday(); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + }); + + it("defaults to today, shows only that day's recipes, and clamps at Monday", () => { + cy.intercept("GET", /\/planning\?/, { + statusCode: 200, + body: { + id: 1, + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + items: [ + { + id: 1, + weekDay: "lundi", + meal: "diner", + portions: 4, + recipe: { id: 1, name: "Ratatouille" }, + }, + { + id: 2, + weekDay: "mardi", + meal: "dejeuner", + portions: 2, + recipe: { id: 2, name: "Curry de lentilles" }, + }, + ], + }, + }).as("getPlanning"); + + cy.visit("/"); + cy.wait("@getPlanning"); + + // The table is gone entirely on phone, not just visually hidden. + cy.get("table.planning-grid").should("not.exist"); + cy.get(".planning-day-view").should("be.visible"); + + // Today (17 août, Lundi) is the default day — only its own recipe shows. + cy.contains(".planning-day-view__label", "Lundi").should("be.visible"); + cy.contains(".planning-day-view__label", "17").should("be.visible"); + cy.contains(".planning-day-view__label .today-badge", "Aujourd'hui").should("be.visible"); + cy.contains(".recipe-chip", "Ratatouille").should("be.visible"); + cy.contains(".recipe-chip", "Curry de lentilles").should("not.exist"); + + // Clamped, not wrapping: Monday is the week's first day. + cy.get(".planning-day-view__arrow").first().should("be.disabled"); + + cy.get(".planning-day-view__arrow").last().click(); + cy.contains(".planning-day-view__label", "Mardi").should("be.visible"); + cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible"); + cy.contains(".recipe-chip", "Ratatouille").should("not.exist"); + cy.get(".planning-day-view__arrow").first().should("not.be.disabled"); + }); + + it("clamps at Sunday going forward, and its own '+' opens the recipe picker", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning"); + cy.visit("/"); + cy.wait("@getPlanning"); + + for (let i = 0; i < 6; i++) { + cy.get(".planning-day-view__arrow").last().click(); + } + cy.contains(".planning-day-view__label", "Dimanche").should("be.visible"); + cy.get(".planning-day-view__arrow").last().should("be.disabled"); + + cy.get(".add-recipe-btn").first().click(); + cy.get(".recipe-picker-dialog").should("be.visible"); + cy.contains(".recipe-picker-dialog", "Choisir une recette").should("be.visible"); + }); +}); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 2828029..73a6956 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -9,6 +9,11 @@ "nextWeek": "Semaine suivante", "label": "Semaine du {{range}}" }, + "dayNav": { + "prevDay": "Jour précédent", + "nextDay": "Jour suivant", + "today": "Aujourd'hui" + }, "calendar": { "title": "Choisir une semaine", "prevMonth": "Mois précédent", diff --git a/apps/web/src/pages/planning/PlanningPage.tsx b/apps/web/src/pages/planning/PlanningPage.tsx index 583dbf2..96bc04f 100644 --- a/apps/web/src/pages/planning/PlanningPage.tsx +++ b/apps/web/src/pages/planning/PlanningPage.tsx @@ -21,7 +21,7 @@ import { useSensor, useSensors, } from "@dnd-kit/core"; -import { useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ApiError, apiClient } from "../../api/client"; @@ -32,6 +32,7 @@ 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 { useIsMobileViewport } from "../../lib/useIsMobileViewport"; import { errorMessageService } from "../../services/error-message.service"; import "./planning-page.scss"; @@ -63,6 +64,13 @@ type PlanningState = /** 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"]); +/** Index (0=Monday..6=Sunday) of `weekStart`'s week that contains today, or `0` if today falls outside it — the sensible default for "which day should the mobile day view open on". */ +function defaultDayIndexForWeek(weekStart: DateTime): number { + const today = toDateOnly(DateTime.utc()); + const diffDays = Math.round(today.diff(weekStart, "days").days); + return diffDays >= 0 && diffDays <= 6 ? diffDays : 0; +} + /** * Landing page for an authenticated visitor — the household's planning for * a selectable week, laid out as a grid (days × meals). Behind @@ -75,11 +83,27 @@ const BAND_END_MEALS: ReadonlySet = new Set(["collation", "dejeuner", "gou * 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. + * + * On a phone, the 7-column grid gives way to {@link PlanningDayView} — one + * day at a time with a switcher, not the same grid just scrolled + * horizontally (see the mobile/tablet UI chantier's own audit: a 62rem-wide + * table forced into a phone viewport was the actual reason some planning + * controls were unreachable). Both views share the *same* `weekStart`/ + * `planning` state and the same {@link MealCell} drop-target logic — only + * the layout differs, decided once via {@link useIsMobileViewport} rather + * than duplicating any of the data/DnD wiring below. */ export function PlanningPage() { const { t } = useTranslation(); const navigate = useNavigate(); + const isMobile = useIsMobileViewport(); const [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc())); + // Which day the mobile view is showing — irrelevant on desktop (the grid + // always shows the whole week), but kept in this shared parent rather + // than local to `PlanningDayView` so switching to mobile mid-session + // (e.g. rotating a tablet past the phone breakpoint) doesn't need to + // re-derive it from scratch on first mobile render only. + const [selectedDayIndex, setSelectedDayIndex] = useState(() => defaultDayIndexForWeek(weekStart)); const [state, setState] = useState({ status: "loading" }); // The slot a `RecipePickerDialog` is currently open for — `null` means // closed. Mounting the dialog only while this is set (rather than an @@ -136,6 +160,14 @@ export function PlanningPage() { }; }, [weekStart]); + // A new week means "which day" needs re-deriving too — otherwise jumping + // from this week to next week while showing "Mercredi" would silently + // keep showing index 2 (now next Wednesday) instead of resetting to + // today-if-in-week-else-Monday, same default the initial render uses. + useEffect(() => { + setSelectedDayIndex(defaultDayIndexForWeek(weekStart)); + }, [weekStart]); + /** * Applies `updater` to the currently loaded planning's items, patching * local state without a full `GET /planning` refetch — same philosophy @@ -248,14 +280,24 @@ export function PlanningPage() {

)} - {state.status === "loaded" && ( - - )} + {state.status === "loaded" && + (isMobile ? ( + + ) : ( + + ))} {openSlot && ( void; + 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 selectedDay = days[selectedDayIndex]; + // `selectedDayIndex` only ever moves via the switcher below (clamped to + // [0, 6] by its own disabled state) or `defaultDayIndexForWeek` (same + // range), and `days` always has exactly WEEK_DAYS.length === 7 entries — + // so this is unreachable in practice. `noUncheckedIndexedAccess` can't + // see that though, and a real guard is preferred over a `days[i]!` + // assertion (see specs/dev-conventions.md's `noNonNullAssertion` rule). + if (!selectedDay) return null; + const { weekDay, date } = selectedDay; + const isToday = date.hasSame(today, "day"); + const items = planning?.items ?? []; + + return ( +
+
+ + + {t(`common.days.${weekDay}`)} {date.day} + {isToday && {t("common.dayNav.today")}} + + +
+ +
    + {MEALS.map((meal, mealIndex) => ( +
  • + item.weekDay === weekDay && item.meal === meal)} + onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })} + onRemove={onRemoveItem} + isTourAddTarget={mealIndex === 0} + /> +
  • + ))} +
+
+ ); +} + +/** Props shared by both meal-cell layouts — everything except the wrapping element itself, which differs (a `` for the grid vs. a plain `
` card for the day view). */ +interface MealCellSharedProps { + isToday: boolean; + date: string; + weekDay: WeekDay; + meal: Meal; + items: PlanningItemView[]; + onAdd: () => void; + onRemove: (item: PlanningItemView) => void; + isTourAddTarget?: boolean; +} + +/** 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. Desktop/tablet grid layout — see {@link MealCellRow} for the phone day-view equivalent, which shares this same content via {@link mealCellBody}. */ function MealCell({ isToday, date, @@ -346,16 +492,7 @@ function MealCell({ onAdd, onRemove, isTourAddTarget = false, -}: { - isToday: boolean; - date: string; - weekDay: WeekDay; - meal: Meal; - items: PlanningItemView[]; - onAdd: () => void; - onRemove: (item: PlanningItemView) => void; - isTourAddTarget?: boolean; -}) { +}: MealCellSharedProps) { const { t } = useTranslation(); const { setNodeRef, isOver } = useDroppable({ id: `cell-${weekDay}-${meal}`, @@ -368,49 +505,111 @@ function MealCell({ return ( -
- {items.length > 0 && ( -
- {items.map((item) => ( - - - {item.sourceItem !== null && ( - - ♻️ - - )} - {item.recipe.name} · ×{item.portions} - - - - ))} -
- )} - -
+ {mealCellBody({ items, onAdd, onRemove, isTourAddTarget, t })} ); } + +/** + * Same drop target and content as {@link MealCell}, laid out as a labeled + * row/card for {@link PlanningDayView} instead of a table cell — a phone + * viewport shows one day's meals stacked, each needing its own "Déjeuner"/ + * "Dîner"/… heading that the grid gets for free from its own header row. + */ +function MealCellRow({ + mealLabel, + isToday, + date, + weekDay, + meal, + items, + onAdd, + onRemove, + isTourAddTarget = false, +}: MealCellSharedProps & { mealLabel: string }) { + const { t } = useTranslation(); + const { setNodeRef, isOver } = useDroppable({ + id: `cell-${weekDay}-${meal}`, + data: { date, weekDay, meal } satisfies CellDropData, + }); + + const className = [ + "meal-cell", + "meal-day-row", + isToday ? "today" : "", + isOver ? "meal-cell--drop-over" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+

{mealLabel}

+ {mealCellBody({ items, onAdd, onRemove, isTourAddTarget, t })} +
+ ); +} + +/** + * The actual recipe-chips-plus-"+"-button content shared by both + * {@link MealCell} (a ``) and {@link MealCellRow} (a `
`) — a plain + * function, not a component: it renders no hooks of its own, so calling it + * directly from each wrapper's render (rather than mounting it as a nested + * component) avoids a pointless extra element in the tree for something + * that's really just shared markup. + */ +function mealCellBody({ + items, + onAdd, + onRemove, + isTourAddTarget, + t, +}: Pick & { + t: (key: string) => string; +}): ReactNode { + return ( +
+ {items.length > 0 && ( +
+ {items.map((item) => ( + + + {item.sourceItem !== null && ( + + ♻️ + + )} + {item.recipe.name} · ×{item.portions} + + + + ))} +
+ )} + +
+ ); +} diff --git a/apps/web/src/pages/planning/planning-page.scss b/apps/web/src/pages/planning/planning-page.scss index af40575..0534110 100644 --- a/apps/web/src/pages/planning/planning-page.scss +++ b/apps/web/src/pages/planning/planning-page.scss @@ -267,6 +267,125 @@ table.planning-grid { } } +// --- Vue mobile "un jour à la fois" (issue #88) ----------------------------- +// Remplace entièrement `.planning-grid-wrapper`/`table.planning-grid` sous le +// breakpoint phone (voir `useIsMobileViewport()` dans PlanningPage.tsx) — pas +// une variante CSS de la même table, un DOM différent (switcher + liste de +// cartes), le tableau 7 colonnes n'ayant pas de mise en page lisible en +// dessous de son `min-width: 62rem`. `.meal-cell`/`.meal-cell__content`/ +// `.recipe-chip`/`.add-recipe-btn` ci-dessus restent utilisés tels quels +// (voir `mealCellBody` partagé côté TSX) — seul l'habillage `.meal-day-row` +// autour change. +.planning-day-view { + display: flex; + flex-direction: column; + gap: var(--space-md); + flex: 1; + min-height: 0; +} + +.planning-day-view__switcher { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); +} + +.planning-day-view__arrow { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + display: grid; + place-items: center; + border: none; + border-radius: 50%; + background: none; + color: var(--color-text); + font-size: var(--font-size-lg); + line-height: 1; + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--color-surface-alt); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +.planning-day-view__label { + flex: 1; + text-align: center; + font-size: var(--font-size-md); + font-weight: 700; + color: var(--color-text); + + &.today { + color: var(--color-primary); + } + + .today-badge { + display: inline-block; + margin-left: var(--space-xs); + padding: 0.1rem 0.5rem; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + color: var(--color-primary); + font-size: var(--font-size-xs); + font-weight: 600; + vertical-align: middle; + } +} + +.planning-day-view__meals { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--space-sm); + list-style: none; + margin: 0; + padding: 0 0 var(--space-md); + + li.band-end { + margin-bottom: var(--space-sm); + } +} + +.meal-day-row { + padding: var(--space-sm) var(--space-md); + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + + &.today { + box-shadow: 0 0 0 1px var(--color-primary), var(--shadow-sm); + } + + &.meal-cell--drop-over { + background: var(--color-tag); + outline: 2px dashed var(--color-primary); + outline-offset: -2px; + } + + &__label { + margin: 0 0 var(--space-xs); + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + font-weight: 600; + } +} + // --- Phone (issue #39, responsive pass) ----------------------------------- // The grid itself already degrades gracefully on a narrow screen — the // wrapper's `overflow: auto` + the table's `min-width: 62rem` mean it just