Merge pull request '[UI mobile] Grille planning : vue mobile dédiée' (#97) from feat/mobile-planning-view into main
Some checks are pending
Some checks are pending
This commit is contained in:
commit
e21c3f8255
5 changed files with 485 additions and 75 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Meal> = 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<Meal> = 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<DateTime>(() => 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<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
|
||||
|
|
@ -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() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && (
|
||||
{state.status === "loaded" &&
|
||||
(isMobile ? (
|
||||
<PlanningDayView
|
||||
weekStart={weekStart}
|
||||
selectedDayIndex={selectedDayIndex}
|
||||
onSelectDayIndex={setSelectedDayIndex}
|
||||
planning={state.planning}
|
||||
onAddSlot={setOpenSlot}
|
||||
onRemoveItem={handleRemove}
|
||||
/>
|
||||
) : (
|
||||
<PlanningGrid
|
||||
weekStart={weekStart}
|
||||
planning={state.planning}
|
||||
onAddSlot={setOpenSlot}
|
||||
onRemoveItem={handleRemove}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
|
||||
{openSlot && (
|
||||
<RecipePickerDialog
|
||||
|
|
@ -278,7 +320,7 @@ export function PlanningPage() {
|
|||
);
|
||||
}
|
||||
|
||||
/** The week grid itself — 7 day columns × 5 meal rows. */
|
||||
/** The week grid itself — 7 day columns × 5 meal rows. Desktop/tablet only, see {@link PlanningDayView} for the phone equivalent. */
|
||||
function PlanningGrid({
|
||||
weekStart,
|
||||
planning,
|
||||
|
|
@ -336,7 +378,111 @@ function PlanningGrid({
|
|||
);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/**
|
||||
* Phone equivalent of {@link PlanningGrid} — one day at a time (a switcher
|
||||
* up top, the day's 5 meals stacked below) instead of a 7-column table
|
||||
* that would need `min-width: 62rem` and a permanent horizontal scroll to
|
||||
* stay legible. Same `planning`/`onAddSlot`/`onRemoveItem` contract as the
|
||||
* grid; `selectedDayIndex` is lifted to `PlanningPage` (see its own doc
|
||||
* comment on why) rather than owned here.
|
||||
*
|
||||
* The switcher clamps at the week's own edges (Monday/Sunday) rather than
|
||||
* wrapping or rolling into the next/previous week — crossing a week
|
||||
* boundary here would need to also change `weekStart` (a real refetch),
|
||||
* which `WeekNavigator`'s own arrows already do; conflating the two would
|
||||
* make it unclear which control moves by a day and which by a week.
|
||||
*/
|
||||
function PlanningDayView({
|
||||
weekStart,
|
||||
selectedDayIndex,
|
||||
onSelectDayIndex,
|
||||
planning,
|
||||
onAddSlot,
|
||||
onRemoveItem,
|
||||
}: {
|
||||
weekStart: DateTime;
|
||||
selectedDayIndex: number;
|
||||
onSelectDayIndex: (index: number) => 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 (
|
||||
<div className="planning-day-view">
|
||||
<div className="planning-day-view__switcher">
|
||||
<button
|
||||
type="button"
|
||||
className="planning-day-view__arrow"
|
||||
title={t("common.dayNav.prevDay")}
|
||||
disabled={selectedDayIndex === 0}
|
||||
onClick={() => onSelectDayIndex(selectedDayIndex - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className={isToday ? "planning-day-view__label today" : "planning-day-view__label"}>
|
||||
{t(`common.days.${weekDay}`)} {date.day}
|
||||
{isToday && <span className="today-badge">{t("common.dayNav.today")}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="planning-day-view__arrow"
|
||||
title={t("common.dayNav.nextDay")}
|
||||
disabled={selectedDayIndex === 6}
|
||||
onClick={() => onSelectDayIndex(selectedDayIndex + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="planning-day-view__meals" data-tour="planning-grid">
|
||||
{MEALS.map((meal, mealIndex) => (
|
||||
<li key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
|
||||
<MealCellRow
|
||||
mealLabel={t(`planning.meals.${meal}`)}
|
||||
isToday={isToday}
|
||||
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}
|
||||
isTourAddTarget={mealIndex === 0}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Props shared by both meal-cell layouts — everything except the wrapping element itself, which differs (a `<td>` for the grid vs. a plain `<div>` 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,6 +505,69 @@ function MealCell({
|
|||
|
||||
return (
|
||||
<td ref={setNodeRef} className={className}>
|
||||
{mealCellBody({ items, onAdd, onRemove, isTourAddTarget, t })}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div ref={setNodeRef} className={className}>
|
||||
<h3 className="meal-day-row__label">{mealLabel}</h3>
|
||||
{mealCellBody({ items, onAdd, onRemove, isTourAddTarget, t })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual recipe-chips-plus-"+"-button content shared by both
|
||||
* {@link MealCell} (a `<td>`) and {@link MealCellRow} (a `<div>`) — 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<MealCellSharedProps, "items" | "onAdd" | "onRemove" | "isTourAddTarget"> & {
|
||||
t: (key: string) => string;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="meal-cell__content">
|
||||
{items.length > 0 && (
|
||||
<div className="meal-cell__recipes">
|
||||
|
|
@ -411,6 +611,5 @@ function MealCell({
|
|||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue