diff --git a/CLAUDE.md b/CLAUDE.md index 3575bfd..2f6b162 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -463,12 +463,22 @@ temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée (du créneau le plus matinal au plus tardif, arrondi à la demi-heure englobante — pas les 24 h complètes). Les créneaux gardent des horaires libres (`ShiftCreate`, § 2) ; ils sont replacés dans cette grille fixe en -fusionnant les cases qu'ils couvrent (`gridColumn: span N`), arrondis à la -tranche englobante s'ils ne tombent pas pile sur un multiple de 30 min -(`buildTimeGrid` / `placeInTimeGrid` dans `frontend/src/lib/planning.ts`). -Largeur adaptative par jour (`columnWidthPx`, en fonction du nombre de -tranches) : peu de tranches ⇒ colonnes larges, beaucoup ⇒ largeur compacte -avec défilement horizontal — jamais de colonnes étroites par défaut. +fusionnant les cases qu'ils couvrent, arrondis à la tranche englobante s'ils +ne tombent pas pile sur un multiple de 30 min (`buildTimeGrid` / +`placeInTimeGrid` dans `frontend/src/lib/planning.ts`). **Chaque cellule +reçoit sa position de grille explicitement** (`gridColumn`/`gridRow` +calculés dans `GrillePage.tsx`, pas seulement `gridColumn: span N`) : un +poste dont les créneaux ne totalisent pas exactement le nombre de tranches +d'un jour (deux créneaux adjacents mais non alignés sur 30 min, désormais +courant depuis la création à la main) ne doit jamais décaler les lignes +suivantes — bug corrigé une fois (postes manquants au-delà du premier), un +positionnement implicite (auto-flow) recréerait la même classe de bug. +Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row` +rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible +dans un simple rendu SSR). Largeur adaptative par jour (`columnWidthPx`, en +fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, beaucoup +⇒ largeur compacte avec défilement horizontal — jamais de colonnes étroites +par défaut. La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle, distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`) plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus @@ -664,6 +674,7 @@ Aucune dépendance locale requise hors Docker. | 25 | Formulaire public : photo recadrée en carré côté navigateur avant l'envoi, aperçu agrandi ; personnalisation de la page (logo, texte d'introduction) depuis l'écran de configuration (`/inscriptions`) | à valider | | 26 | Feuilles de poste : programmation du jour (horaires des artistes, tous postes confondus) sous le référent | à valider | | 27 | Formulaire public : encart de paiement de la cotisation via Yapla (lien externe configurable), affiché sous la case correspondante | à valider | +| 28 | Correctif grille des besoins : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule — un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants (noms de poste manquants au-delà du premier) | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 436514c..8820790 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -228,6 +228,22 @@ async function checkShiftCreation() { }); const generated = await api.positions.generateShifts(e, position.id); + // Deuxième créneau adjacent au premier mais non aligné sur 30 min (fin à + // 22:45, comme le premier) : arrondis à la tranche englobante, les deux + // créneaux se chevauchent légèrement dans la grille (voir GrillePage.tsx, + // `PositionRow`) — ne doit jamais planter le rendu ni décaler les postes + // suivants (régression corrigée : la grille positionnait ses cellules par + // ordre d'émission plutôt qu'explicitement, un poste dont les créneaux ne + // totalisaient pas exactement le nombre de tranches d'un jour décalait + // tous les postes suivants). + await api.shifts.create(e, { + position_id: position.id, + starts_at: `${edition.start_date}T22:45:00`, + ends_at: `${edition.start_date}T23:15:00`, + min_staff: 1, + ideal_staff: 1, + }); + const grid = buildTimeGrid([shift]); const day = grid[0]; const placement = day ? placeInTimeGrid(grid, [shift]).get(day.date)?.[0] : undefined; @@ -260,6 +276,56 @@ async function checkShiftCreation() { } } +/** + * Grille des besoins : chaque poste affiché doit occuper sa propre ligne de + * grille CSS (`gridRow` explicite, voir `GrillePage.tsx:PositionRow`). + * Régression testée : avant le passage à un positionnement explicite, un + * poste dont les créneaux ne totalisaient pas exactement le nombre de + * tranches de 30 min d'un jour (deux créneaux adjacents mais non alignés + * sur 30 min, par ex. — cas courant depuis la création de créneaux à la + * main) décalait, par un pur effet de bord CSS (auto-flow), tous les postes + * suivants — leur nom de poste n'apparaissait alors plus sur sa propre + * ligne. Purement visuel : un rendu SSR ordinaire ne l'aurait pas détecté, + * d'où cette vérification directe des lignes de grille attribuées. + */ +async function checkGridLayout() { + if (!coordinator) throw new Error('grille : pas de session coordination pour ce test'); + const positions = await api.positions.list(e); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await prefetchCore(client); + const html = renderToString( + createElement( + QueryClientProvider, + { client }, + createElement( + AuthContext.Provider, + { value: authValue(coordinator) }, + createElement(MemoryRouter, null, createElement(GrillePage)), + ), + ), + ); + const rowNumbers = [...html.matchAll(/grid-row:\s*(\d+)/g)].map((m) => Number(m[1])); + const uniqueRows = [...new Set(rowNumbers)].sort((a, b) => a - b); + // 2 lignes d'en-tête (jours, puis tranches) + une par poste affiché, + // strictement consécutives (1, 2, 3, … sans trou ni doublon). + const expected = Array.from({ length: positions.length + 2 }, (_, i) => i + 1); + const sequential = uniqueRows.length === expected.length + && uniqueRows.every((row, i) => row === expected[i]); + + console.log( + JSON.stringify( + { positionsCount: positions.length, gridRowsUsed: uniqueRows.length, sequential }, + null, + 2, + ), + ); + if (!sequential) { + throw new Error( + `grille des besoins : lignes de grille ${JSON.stringify(uniqueRows)}, attendu ${JSON.stringify(expected)}`, + ); + } +} + async function prefetchCore(client: QueryClient) { await Promise.all([ client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }), @@ -330,6 +396,7 @@ await checkAuth().catch((err) => fail('checkAuth', err)); await checkPublicForm().catch((err) => fail('checkPublicForm', err)); await checkPositionSheets().catch((err) => fail('checkPositionSheets', err)); await checkShiftCreation().catch((err) => fail('checkShiftCreation', err)); +await checkGridLayout().catch((err) => fail('checkGridLayout', err)); renderPublic('/connexion'); renderPublic('/connexion/coordination'); diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index 9e98841..0a71a4f 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -94,6 +94,21 @@ export function GrillePage() { }), ].join(' '); + // Ligne de grille (1-based) où commence chaque jour, colonne 1 réservée au + // nom du poste — chaque cellule reçoit sa position explicitement + // (`gridColumn`/`gridRow`) plutôt que de compter sur l'ordre d'émission + // des enfants : un poste dont les créneaux ne totalisent pas exactement le + // nombre de tranches d'un jour (créneaux adjacents non alignés sur 30 min, + // par ex.) ne doit jamais décaler les lignes suivantes. + const dayColumnStarts: number[] = []; + { + let col = 2; + for (const day of shownDays) { + dayColumnStarts.push(col); + col += day.slotLabels.length; + } + } + const selected = selectedShiftId ? matrix.data.positions .flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s }))) @@ -206,15 +221,21 @@ export function GrillePage() {
-
+
{t('grille.positionsCount', { count: matrix.data.positions.length })}
- {shownDays.map((day) => ( + {shownDays.map((day, di) => (
{shortDayLabel(`${day.date}T12:00`)} @@ -222,13 +243,17 @@ export function GrillePage() {
))} -
+
{t('grille.assignedRequired')}
- {shownDays.map((day) => + {shownDays.map((day, di) => day.slotLabels.map((label, i) => (
{label} @@ -236,11 +261,13 @@ export function GrillePage() { )), )} - {shownPositions.map((position) => ( + {shownPositions.map((position, posIndex) => ( void; @@ -321,34 +352,62 @@ function PositionRow({ return ( <> -
+
{position.name} {position.location}
- {days.map((day) => { + {days.map((day, di) => { + const base = dayColumnStarts[di]; const dayPlacements = placements.get(day.date) ?? []; const cells: React.ReactNode[] = []; let cursor = 0; for (const { item: shift, columnStart, columnSpan } of dayPlacements) { - for (let i = cursor; i < Math.max(cursor, columnStart); i += 1) { - cells.push(
); + // `columnStart` peut retomber avant `cursor` : deux créneaux du + // même poste adjacents mais non alignés sur 30 min s'arrondissent + // chacun à la tranche englobante et peuvent se toucher/chevaucher + // légèrement à l'affichage — on raccourcit plutôt que de laisser + // deux cellules revendiquer la même colonne (chaque cellule ayant + // sa position de grille explicite, ce repli reste local à ce + // poste : il ne décale jamais les lignes suivantes). + const start = Math.min(Math.max(cursor, columnStart), day.slotLabels.length); + for (let i = cursor; i < start; i += 1) { + cells.push( +
, + ); } - cells.push( - , - ); - cursor = Math.max(cursor, columnStart + columnSpan); + const end = Math.min(Math.max(start + 1, columnStart + columnSpan), day.slotLabels.length); + if (start < day.slotLabels.length) { + cells.push( + , + ); + } + cursor = Math.max(cursor, end); } for (let i = cursor; i < day.slotLabels.length; i += 1) { - cells.push(
); + cells.push( +
, + ); } return cells; })} @@ -358,13 +417,15 @@ function PositionRow({ function ShiftCell({ shift, - span, + gridColumn, + gridRow, selected, onSelect, names, }: { shift: MatrixShift; - span: number; + gridColumn: string; + gridRow: number; selected: boolean; onSelect: (id: string) => void; names: string[]; @@ -380,7 +441,7 @@ function ShiftCell({ return (
onSelect(shift.shift_id)} - style={{ gridColumn: `span ${span}` }} + style={{ gridColumn, gridRow }} className={cx( 'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', undefinedNeed && 'bg-sand-dark hover:bg-sand',