Grille : colonnes par créneau (pas par heure), largeur adaptative

- `buildLattice` (frontend/src/lib/planning.ts) identifie une colonne par
  (date, heure de début, heure de fin) et non plus par la seule heure de
  début : deux créneaux qui démarrent en même temps mais durent
  différemment ont désormais chacun leur colonne, avec un en-tête « début
  → fin » au lieu du seul début.
- Largeur adaptative par jour (`columnWidthPx`) : peu de créneaux dans une
  journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte
  (défilement horizontal) — fini les colonnes étroites par défaut.
- `VolunteerViewPage` mis à jour pour rester synchronisé avec la nouvelle
  clé de colonne (fonction `buildLattice` partagée).
- CLAUDE.md à jour.

Corrige au passage `bg-hatch-mauve` (case en sureffectif) : la classe était
utilisée depuis le commit précédent mais l'utilitaire Tailwind
correspondant n'avait jamais été ajouté à `backgroundImage` — la case
retombait silencieusement sur un fond uni.

typecheck + lint + build + smoke OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-11 20:28:41 +02:00
co-authored by Claude Sonnet 5
parent 55f634e25b
commit 367dfe42eb
5 changed files with 56 additions and 14 deletions
+8
View File
@@ -289,6 +289,14 @@ effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation
jointe, pas juste un score local). jointe, pas juste un score local).
**Colonnes de la grille des besoins** — une colonne représente un **créneau**
distinct (`starts_at` + `ends_at`), pas seulement une heure de début : deux
créneaux qui démarrent en même temps mais durent différemment ont chacun leur
colonne (`buildLattice` / `LatticeColumn` dans `frontend/src/lib/planning.ts`).
Largeur adaptative par jour (`columnWidthPx`) : peu de créneaux dans une
journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte avec
défilement horizontal — jamais de colonnes étroites par défaut.
--- ---
## 5. Périmètre ## 5. Périmètre
+25 -6
View File
@@ -137,11 +137,15 @@ export function candidateChanceScore(params: {
return score; return score;
} }
/** Colonne de la grille : une (date, heure de début) distincte. */ /** Colonne de la grille : un créneau distinct (date, heure de début, heure de
* fin) — pas seulement une heure de début : deux créneaux qui démarrent à la
* même heure mais durent différemment ont chacun leur colonne. */
export interface LatticeColumn { export interface LatticeColumn {
key: string; // `YYYY-MM-DD|HH:MM` key: string; // `YYYY-MM-DD|HH:MM|HH:MM`
date: string; date: string;
time: string; time: string;
endTime: string;
label: string; // « 17:00 → 22:00 »
} }
export interface LatticeDay { export interface LatticeDay {
@@ -150,7 +154,7 @@ export interface LatticeDay {
} }
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */ /** Construit le treillis de colonnes à partir de créneaux (grille alignée). */
export function buildLattice(starts: Array<{ starts_at: string }>): { export function buildLattice(starts: Array<{ starts_at: string; ends_at: string }>): {
days: LatticeDay[]; days: LatticeDay[];
columns: LatticeColumn[]; columns: LatticeColumn[];
} { } {
@@ -158,8 +162,9 @@ export function buildLattice(starts: Array<{ starts_at: string }>): {
for (const s of starts) { for (const s of starts) {
const date = datePart(s.starts_at); const date = datePart(s.starts_at);
const time = timePart(s.starts_at); const time = timePart(s.starts_at);
const key = `${date}|${time}`; const endTime = timePart(s.ends_at);
if (!seen.has(key)) seen.set(key, { key, date, time }); const key = `${date}|${time}|${endTime}`;
if (!seen.has(key)) seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}` });
} }
const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key)); const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
const days: LatticeDay[] = []; const days: LatticeDay[] = [];
@@ -175,7 +180,21 @@ export function buildLattice(starts: Array<{ starts_at: string }>): {
} }
export function latticeFromMatrix(matrix: MatrixResponse) { export function latticeFromMatrix(matrix: MatrixResponse) {
return buildLattice(matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at })))); return buildLattice(
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
);
}
/**
* Largeur (px) des colonnes d'une journée, en fonction du nombre de créneaux
* distincts qu'elle compte : peu de créneaux ⇒ colonnes larges plutôt que
* resserrées ; beaucoup de créneaux ⇒ largeur compacte (défilement horizontal).
*/
export function columnWidthPx(columnsInDay: number): number {
if (columnsInDay <= 2) return 160;
if (columnsInDay <= 4) return 120;
if (columnsInDay <= 6) return 92;
return 66;
} }
/** Heures cumulées par bénévole. */ /** Heures cumulées par bénévole. */
+16 -7
View File
@@ -5,7 +5,7 @@ import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema
import { Chip } from '@/components/ui'; import { Chip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel'; import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
import { categoryLabel, latticeFromMatrix, type LatticeColumn } from '@/lib/planning'; import { categoryLabel, columnWidthPx, latticeFromMatrix, type LatticeColumn } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time'; import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
@@ -43,7 +43,13 @@ export function GrillePage() {
? matrix.data.positions ? matrix.data.positions
: matrix.data.positions.filter((p) => p.category === categoryFilter); : matrix.data.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = `230px repeat(${shownColumns.length}, 66px)`; const gridTemplateColumns = [
'230px',
...shownDays.flatMap((day) => {
const width = columnWidthPx(day.columns.length);
return day.columns.map(() => `${width}px`);
}),
].join(' ');
const selected = selectedShiftId const selected = selectedShiftId
? matrix.data.positions ? matrix.data.positions
@@ -114,7 +120,7 @@ export function GrillePage() {
<LegendItem swatch="bg-paper" glyph="✓"> <LegendItem swatch="bg-paper" glyph="✓">
{t('grille.legend.complete')} {t('grille.legend.complete')}
</LegendItem> </LegendItem>
<LegendItem swatch="bg-mauve" glyph="▼"> <LegendItem swatch="bg-hatch-mauve" glyph="▼">
{t('grille.legend.overstaffed')} {t('grille.legend.overstaffed')}
</LegendItem> </LegendItem>
<LegendItem swatch="bg-sand-dark" glyph="·"> <LegendItem swatch="bg-sand-dark" glyph="·">
@@ -147,9 +153,9 @@ export function GrillePage() {
{shownColumns.map((column) => ( {shownColumns.map((column) => (
<div <div
key={column.key} key={column.key}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]" className="sticky top-[27px] z-[3] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
> >
{column.time} {column.label}
</div> </div>
))} ))}
@@ -232,7 +238,10 @@ function PositionRow({
}) { }) {
const byColumn = new Map<string, MatrixShift>(); const byColumn = new Map<string, MatrixShift>();
for (const shift of position.shifts) { for (const shift of position.shifts) {
byColumn.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, shift); byColumn.set(
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
shift,
);
} }
return ( return (
@@ -272,7 +281,7 @@ function PositionRow({
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', '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', undefinedNeed && 'bg-sand-dark hover:bg-sand',
isCrit && 'bg-hatch-orange', isCrit && 'bg-hatch-orange',
isOver && 'bg-mauve hover:bg-mauve-hover', isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
isInc && 'bg-yellow-soft hover:bg-yellow', isInc && 'bg-yellow-soft hover:bg-yellow',
isOk && 'bg-paper hover:bg-green/10', isOk && 'bg-paper hover:bg-green/10',
)} )}
+5 -1
View File
@@ -55,7 +55,11 @@ export function VolunteerViewPage() {
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>(); const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>();
for (const a of own) { for (const a of own) {
const shift = shiftsById.get(a.shift_id); const shift = shiftsById.get(a.shift_id);
if (shift) cellByKey.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, { ...a, shift }); if (shift)
cellByKey.set(
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
{ ...a, shift },
);
} }
return { return {
volunteer: v, volunteer: v,
+2
View File
@@ -37,6 +37,8 @@ export default {
// Hachures « sous-effectif critique » // Hachures « sous-effectif critique »
'hatch-orange': 'hatch-orange':
'repeating-linear-gradient(45deg,#FF5C39 0 6px,#E0421F 6px 12px)', 'repeating-linear-gradient(45deg,#FF5C39 0 6px,#E0421F 6px 12px)',
'hatch-mauve':
'repeating-linear-gradient(45deg,#CBA6E3 0 6px,#BE93D9 6px 12px)',
// Hachures « brouillon » // Hachures « brouillon »
'hatch-ink': 'hatch-ink':
'repeating-linear-gradient(45deg,#14110E 0 8px,#2A2520 8px 16px)', 'repeating-linear-gradient(45deg,#14110E 0 8px,#2A2520 8px 16px)',