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 (
+
+ {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 (
+