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:
co-authored by
Claude Sonnet 5
parent
f1d88302a4
commit
15109acfe5
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user