Créneaux à la main + grille des besoins en axe des temps (30 min)

Un poste ne génère plus de créneaux à sa création : la coordination crée
elle-même ceux dont elle a besoin, avec ses propres horaires de début et
de fin (ConfigGrid.tsx). La génération depuis les créneaux types reste
disponible en option (bouton « Générer les créneaux »).

La grille des besoins devient un vrai axe des temps : une colonne = 30 min,
bornée par jour à la plage réellement utilisée, avec les cases fusionnées
pour correspondre aux horaires de chaque créneau (buildTimeGrid /
placeInTimeGrid). La « Vue bénévoles » garde son ancien modèle (une colonne
par créneau distinct), plus adapté à son tableau bénévole × créneau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-22 18:44:20 +02:00
co-authored by Claude Sonnet 5
parent f1d88302a4
commit 15109acfe5
9 changed files with 474 additions and 179 deletions
+119 -8
View File
@@ -263,15 +263,126 @@ export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string)
}
/**
* 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).
* Largeur (px) des tranches de 30 min d'une journée, en fonction de leur
* nombre : une journée courte (peu de tranches) reste lisible en large,
* une journée dense (beaucoup de tranches) se resserre jusqu'à un plancher
* (défilement horizontal) plutôt que d'écraser le reste de la grille.
*/
export function columnWidthPx(columnsInDay: number): number {
if (columnsInDay <= 2) return 160;
if (columnsInDay <= 4) return 120;
if (columnsInDay <= 6) return 92;
return 66;
export function columnWidthPx(slotsInDay: number): number {
if (slotsInDay <= 4) return 110;
if (slotsInDay <= 8) return 80;
if (slotsInDay <= 16) return 56;
return 42;
}
// --------------------------------------------------------------------------- //
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. //
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », //
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
// --------------------------------------------------------------------------- //
const TIME_GRID_SLOT_MINUTES = 30;
export interface TimeGridDay {
date: string; // jour de festival
/** Étiquette d'horloge (`HH:MM`) du début de chaque tranche de 30 min. */
slotLabels: string[];
/** Minutes depuis `dayBoundary` du début de la 1re tranche (multiple de 30). */
startOffset: number;
}
export interface TimeGridPlacement<T> {
item: T;
columnStart: number; // index de tranche dans le jour (0-based)
columnSpan: number; // nombre de tranches fusionnées (>= 1)
}
const clockLabel = (totalMinutes: number): string => {
const wrapped = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60);
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
/** Jour de festival (§ 3 CLAUDE.md) d'un datetime naïf, selon `dayBoundary`. */
function festivalDayOf(iso: string, boundaryMinutes: number): string {
const date = datePart(iso);
const clock = minutesOfDay(timePart(iso));
return clock < boundaryMinutes ? addDays(date, -1) : date;
}
/**
* Construit, pour chaque jour de festival couvert par `shifts`, l'axe des
* tranches de 30 min à afficher — bornée à la plage réellement utilisée ce
* jour-là (du début du créneau le plus matinal à la fin du plus tardif,
* arrondi au multiple de 30 inférieur/supérieur), pas les 24 h complètes.
*/
export function buildTimeGrid(
shifts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
): TimeGridDay[] {
const boundaryMinutes = minutesOfDay(dayBoundary);
const bounds = new Map<string, { min: number; max: number }>();
for (const s of shifts) {
const festivalDay = festivalDayOf(s.starts_at, boundaryMinutes);
const startClock = minutesOfDay(timePart(s.starts_at));
const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60);
const duration = naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at);
const endOffset = startOffset + duration;
const entry = bounds.get(festivalDay);
if (entry) {
entry.min = Math.min(entry.min, startOffset);
entry.max = Math.max(entry.max, endOffset);
} else {
bounds.set(festivalDay, { min: startOffset, max: endOffset });
}
}
return [...bounds.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, { min, max }]) => {
const startOffset = Math.floor(min / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES;
const endOffset = Math.ceil(max / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES;
const slotCount = Math.max(1, (endOffset - startOffset) / TIME_GRID_SLOT_MINUTES);
const slotLabels = Array.from({ length: slotCount }, (_, i) =>
clockLabel(boundaryMinutes + startOffset + i * TIME_GRID_SLOT_MINUTES),
);
return { date, slotLabels, startOffset };
});
}
/**
* Replace des éléments datés (`starts_at`/`ends_at`) dans la grille de
* `days` (voir `buildTimeGrid`) : tranche de départ + nombre de tranches
* fusionnées, groupés et triés par jour. Suppose une seule ligne par
* appelant (ex. un poste) : deux éléments qui se chevaucheraient dans le
* temps ne sont pas gérés (cas non prévu par la grille des besoins).
*/
export function placeInTimeGrid<T extends { starts_at: string; ends_at: string }>(
days: TimeGridDay[],
items: T[],
dayBoundary = '00:00',
): Map<string, TimeGridPlacement<T>[]> {
const boundaryMinutes = minutesOfDay(dayBoundary);
const byDay = new Map(days.map((d) => [d.date, d]));
const placements = new Map<string, TimeGridPlacement<T>[]>();
for (const item of items) {
const festivalDay = festivalDayOf(item.starts_at, boundaryMinutes);
const day = byDay.get(festivalDay);
if (!day) continue;
const startClock = minutesOfDay(timePart(item.starts_at));
const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60);
const duration = naiveMinutes(item.ends_at) - naiveMinutes(item.starts_at);
const columnStart = Math.floor((startOffset - day.startOffset) / TIME_GRID_SLOT_MINUTES);
const columnEnd = Math.ceil(
(startOffset + duration - day.startOffset) / TIME_GRID_SLOT_MINUTES,
);
const list = placements.get(festivalDay) ?? [];
list.push({ item, columnStart, columnSpan: Math.max(1, columnEnd - columnStart) });
placements.set(festivalDay, list);
}
for (const list of placements.values()) list.sort((a, b) => a.columnStart - b.columnStart);
return placements;
}
/** Heures cumulées par bénévole. */