Compare commits

..

7 commits

Author SHA1 Message Date
0ff94c61ca Merge pull request 'fix(planning): popovers flottants sûrs sur mobile' (#95) from feat/mobile-popovers-safe into main
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m26s
CI / build (push) Successful in 3m30s
CI / intent-service-test (push) Successful in 5m31s
CI / e2e (push) Successful in 9m45s
CI / test (push) Has been cancelled
2026-09-07 15:46:09 +02:00
33ffa810e3 chore: relance la CI (attente epuisee, pas une collision)
All checks were successful
CI / lint (push) Successful in 2m29s
CI / build (push) Successful in 2m34s
CI / intent-service-test (push) Successful in 4m42s
CI / e2e (push) Successful in 9m43s
CI / test (push) Successful in 29m56s
CI / build-android (push) Has been skipped
Le nouveau step "Wait for any earlier test job" a fonctionne comme
prevu cette fois : il a attendu ~25 min un job anterieur toujours en
cours (task 1259) puis a abandonne proprement (exit 1, "a re-run
should get through") plutot que de risquer une collision - aucun
signe de corruption/collision Postgres dans le log. Simple embouteillage
du a plusieurs PR en test simultanement ; devrait passer maintenant que
la file s'est videe (#93 mergee entre temps).
2026-09-07 15:15:38 +02:00
c520cfca09 merge: rapatrie le correctif de serialisation CI (#96)
Some checks failed
CI / lint (push) Successful in 1m3s
CI / build (push) Successful in 2m37s
CI / intent-service-test (push) Successful in 5m14s
CI / e2e (push) Successful in 7m25s
CI / test (push) Failing after 26m49s
CI / build-android (push) Has been skipped
2026-09-07 14:32:48 +02:00
365e7b38b7 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / lint (push) Successful in 2m43s
CI / build (push) Successful in 3m54s
CI / intent-service-test (push) Successful in 6m10s
CI / e2e (push) Successful in 11m52s
CI / test (push) Failing after 18m30s
CI / build-android (push) Has been skipped
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:45:24 +02:00
cb3dd297a1 chore: relance la CI (contention runner - timeouts Cypress sous charge)
Some checks failed
CI / build (push) Successful in 3m16s
CI / lint (push) Successful in 3m57s
CI / intent-service-test (push) Successful in 6m18s
CI / test (push) Failing after 14m41s
CI / e2e (push) Successful in 14m0s
CI / build-android (push) Has been skipped
e2e a echoue sur des timeouts de 4s (#firstName, #houseName,
.leftovers-basket__toggle introuvables) - reproduit et verifie en local
sur cette meme branche : planning-leftovers.feature passe proprement
en 4s (2/2), aucun probleme reel. Le run CI montrait des blocs
"passing" anormalement lents partout (jusqu'a 22s pour quelques
assertions simples), signe de contention severe sur le runner pendant
que plusieurs PR tournent en parallele, pas une regression de ce
ticket.
2026-09-07 13:22:35 +02:00
8d3af76328 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / lint (push) Successful in 1m46s
CI / build (push) Successful in 3m46s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 14m51s
CI / test (push) Failing after 16m7s
CI / build-android (push) Has been skipped
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
2026-09-07 13:02:26 +02:00
a3701acae4 fix(planning): popovers flottants surs sur mobile
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 4m8s
CI / build (push) Successful in 3m42s
CI / test (push) Failing after 10m11s
CI / e2e (push) Has been cancelled
Ticket #87 du chantier UI mobile & tablette. LeftoversBasket et
WeekNavigator's CalendarPopover etaient tous les deux des popovers a
largeur fixe (18-20rem), ancres right: 0, sans aucune gestion de bord
d'ecran - sur un telephone etroit ils pouvaient deborder hors du
viewport. Les deux sont declenches depuis l'en-tete du planning, un
ecran central de l'usage quotidien - tres probablement la cause
concrete derriere le retour terrain "je n'ai pas acces a tous les
boutons" sur mobile qui a lance ce chantier.

Les deux basculent desormais sur BottomSheet (ticket #91) sous le
breakpoint phone via useIsMobileViewport() - meme contenu, juste une
autre enveloppe (le popover flottant + son ecouteur de clic exterieur
manuel restent inchanges sur desktop). Le clic-exterieur devient un
no-op propre sur mobile plutot qu'un ecouteur qui ne fait jamais rien
(le ref ne s'attache a rien dans la branche BottomSheet).

Nouvelle cle i18n common.calendar.title ("Choisir une semaine") pour
le titre de la feuille - pas de raccourci en decoupant une autre cle
interpolee.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (142/142, 2 nouveaux tests phone dans
leftovers-basket.cy.ts et planning-page.cy.ts) et composants (63/63).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:48:39 +02:00
5 changed files with 120 additions and 26 deletions

View file

@ -105,4 +105,23 @@ describe("Leftovers basket", () => {
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible"); cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
cy.get(".leftovers-basket__count").should("not.exist"); cy.get(".leftovers-basket__count").should("not.exist");
}); });
it("opens inside a bottom sheet on a phone instead of the anchored popover", () => {
// Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) —
// that command doesn't exist on this ticket's own base yet; matches its
// PHONE_VIEWPORT value on purpose, worth switching to the real import
// once both have landed on `main`.
cy.viewport(390, 844);
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers });
cy.visit("/");
cy.get(".leftovers-basket__toggle").click();
cy.get(".leftovers-basket__popover").should("not.exist");
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Restes disponibles").should("be.visible");
cy.contains(".bottom-sheet .leftovers-basket__line", "Lasagnes").should("be.visible");
cy.get(".bottom-sheet__close").click();
cy.get(".bottom-sheet").should("not.exist");
});
}); });

View file

@ -197,4 +197,25 @@ describe("Planning grid", () => {
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible"); cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".calendar-popover").should("not.exist"); cy.get(".calendar-popover").should("not.exist");
}); });
it("opens the calendar inside a bottom sheet on a phone instead of the anchored popover", () => {
// Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) —
// that command doesn't exist on this ticket's own base yet; matches its
// PHONE_VIEWPORT value on purpose, worth switching to the real import
// once both have landed on `main`.
cy.viewport(390, 844);
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
cy.contains("button", "Semaine du").click();
cy.get(".calendar-popover").should("not.exist");
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Choisir une semaine").should("be.visible");
cy.get(".bottom-sheet .calendar-grid__day").contains(/^25$/).click();
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".bottom-sheet").should("not.exist");
});
}); });

View file

@ -3,6 +3,8 @@ import { useDraggable } from "@dnd-kit/core";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { BottomSheet } from "../../components/ui/BottomSheet";
import { useIsMobileViewport } from "../../lib/useIsMobileViewport";
import { collectLeftoverLines, dragIdForLeftover } from "./leftovers-basket"; import { collectLeftoverLines, dragIdForLeftover } from "./leftovers-basket";
import "./leftovers-basket.scss"; import "./leftovers-basket.scss";
@ -19,6 +21,13 @@ import "./leftovers-basket.scss";
* The list is derived on every render from `planning` (see * The list is derived on every render from `planning` (see
* {@link collectLeftoverLines}) rather than held in its own state, so a * {@link collectLeftoverLines}) rather than held in its own state, so a
* discard or assignment elsewhere on the page is reflected here for free. * discard or assignment elsewhere on the page is reflected here for free.
*
* On a phone this renders inside a {@link BottomSheet} instead of the
* plain anchored `div` below — the fixed-width popover (`right: 0`, no
* edge handling) used to render identically at every width, which could
* push it partly or fully off a narrow screen (see the mobile/tablet UI
* chantier's own audit — very plausibly *the* concrete cause of reported
* "can't reach a button" complaints on mobile).
*/ */
export function LeftoversBasket({ export function LeftoversBasket({
planning, planning,
@ -29,6 +38,7 @@ export function LeftoversBasket({
onLeftoverDiscarded: (item: PlanningItemView) => void; onLeftoverDiscarded: (item: PlanningItemView) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobileViewport();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [discardingId, setDiscardingId] = useState<number | null>(null); const [discardingId, setDiscardingId] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@ -36,7 +46,12 @@ export function LeftoversBasket({
const lines = collectLeftoverLines(planning); const lines = collectLeftoverLines(planning);
useEffect(() => { useEffect(() => {
if (!isOpen) return; // On mobile the BottomSheet branch below never attaches this ref (it's
// a native <dialog> with its own tap-outside handling instead) —
// `popoverRef.current` would just stay `null` and this would silently
// no-op every time, harmless but pointless, so skip attaching the
// listener at all in that case.
if (!isOpen || isMobile) return;
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setIsOpen(false); setIsOpen(false);
@ -44,7 +59,7 @@ export function LeftoversBasket({
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]); }, [isOpen, isMobile]);
async function discard(item: PlanningItemView) { async function discard(item: PlanningItemView) {
setDiscardingId(item.id); setDiscardingId(item.id);
@ -59,6 +74,23 @@ export function LeftoversBasket({
} }
} }
const listContent =
lines.length === 0 ? (
<p className="leftovers-basket__empty">{t("planning.leftovers.empty")}</p>
) : (
<ul className="leftovers-basket__list">
{lines.map(({ item, available }) => (
<LeftoverLineCard
key={item.id}
item={item}
available={available}
isDiscarding={discardingId === item.id}
onDiscard={() => void discard(item)}
/>
))}
</ul>
);
return ( return (
<div className="leftovers-basket"> <div className="leftovers-basket">
<button <button
@ -73,26 +105,17 @@ export function LeftoversBasket({
{lines.length > 0 && <span className="leftovers-basket__count">{lines.length}</span>} {lines.length > 0 && <span className="leftovers-basket__count">{lines.length}</span>}
</button> </button>
{isOpen && ( {isOpen &&
(isMobile ? (
<BottomSheet onClose={() => setIsOpen(false)} title={t("planning.leftovers.title")}>
{listContent}
</BottomSheet>
) : (
<div className="leftovers-basket__popover" ref={popoverRef}> <div className="leftovers-basket__popover" ref={popoverRef}>
<h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2> <h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2>
{lines.length === 0 ? ( {listContent}
<p className="leftovers-basket__empty">{t("planning.leftovers.empty")}</p>
) : (
<ul className="leftovers-basket__list">
{lines.map(({ item, available }) => (
<LeftoverLineCard
key={item.id}
item={item}
available={available}
isDiscarding={discardingId === item.id}
onDiscard={() => void discard(item)}
/>
))}
</ul>
)}
</div> </div>
)} ))}
</div> </div>
); );
} }

View file

@ -8,6 +8,8 @@ import {
import { WEEK_DAYS } from "@batch-cooking/shared"; import { WEEK_DAYS } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BottomSheet } from "../../components/ui/BottomSheet";
import { useIsMobileViewport } from "../../lib/useIsMobileViewport";
import "./week-navigator.scss"; 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). */ /** "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). */
@ -89,7 +91,15 @@ export function WeekNavigator({
); );
} }
/** 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. */ /**
* 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 on desktop; on a phone it renders inside a {@link BottomSheet}
* instead of the plain anchored `div` below, same reasoning as
* `LeftoversBasket`'s identical split — a fixed-width popover with no
* viewport-edge handling can otherwise render partly or fully off a narrow
* screen.
*/
function CalendarPopover({ function CalendarPopover({
selectedWeekStart, selectedWeekStart,
onSelectDay, onSelectDay,
@ -100,6 +110,7 @@ function CalendarPopover({
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobileViewport();
// Its own state: browsing to a different month to pick a week there // 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 // shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open. // first opened (`selectedWeekStart` at that point), not while it's open.
@ -107,6 +118,11 @@ function CalendarPopover({
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
// See LeftoversBasket's identical guard: on mobile this ref never
// attaches to anything (the BottomSheet branch below handles its own
// tap-outside-to-close), so the listener would just be a permanent
// no-op — skip attaching it at all in that case.
if (isMobile) return;
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose(); onClose();
@ -114,14 +130,14 @@ function CalendarPopover({
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]); }, [onClose, isMobile]);
const today = toDateOnly(DateTime.utc()); const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 }); const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth); const weeks = buildCalendarMonth(visibleMonth);
return ( const calendarContent = (
<div className="calendar-popover" ref={popoverRef}> <>
<div className="calendar-popover__header"> <div className="calendar-popover__header">
<button <button
type="button" type="button"
@ -169,6 +185,20 @@ function CalendarPopover({
); );
})} })}
</div> </div>
</>
);
if (isMobile) {
return (
<BottomSheet onClose={onClose} title={t("common.calendar.title")}>
{calendarContent}
</BottomSheet>
);
}
return (
<div className="calendar-popover" ref={popoverRef}>
{calendarContent}
</div> </div>
); );
} }

View file

@ -10,6 +10,7 @@
"label": "Semaine du {{range}}" "label": "Semaine du {{range}}"
}, },
"calendar": { "calendar": {
"title": "Choisir une semaine",
"prevMonth": "Mois précédent", "prevMonth": "Mois précédent",
"nextMonth": "Mois suivant" "nextMonth": "Mois suivant"
}, },