Files
benevoles/frontend/src/lib/planning.ts
T
AdamThomasandClaude Opus 5.5 f671bb85bf
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 13s
Début de journée indépendant de l'ordre des créneaux types
La « journée de festival » commence désormais au début du plus long creux
laissé par les créneaux types sur 24 h (l'heure où le dernier se termine),
et non plus au début de celui de plus petit `order` : un créneau type
d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles à
21:00, jusqu'à 03:00 le lendemain. Même règle côté backend
(validation.engine.day_boundary, réutilisée par la génération de créneaux)
et frontend (dayBoundaryFromDayParts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:48:14 +02:00

527 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ArtistRead,
AssignmentRead,
AvailabilityRead,
DayPartRead,
MatrixResponse,
PositionCategory,
ShiftRead,
Violation,
VolunteerRead,
} from '@/api/schema';
import { t } from '@/i18n';
import { addDays, datePart, hoursBetween, minutesOfDay, naiveMinutes, timePart } from './time';
export function categoryLabel(category: PositionCategory): string {
return t(`position.category.${category.toLowerCase()}`);
}
/** Normalise pour une recherche insensible aux accents et à la casse. */
export function foldAccents(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
// --------------------------------------------------------------------------- //
// Disponibilités : couverture d'un créneau par les fenêtres déclarées //
// (portage de `backend/app/validation/engine.py`). //
// --------------------------------------------------------------------------- //
const _dayNum = (iso: string): number => {
const [y, m, d] = iso.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000);
};
/** Minutes absolues (depuis une origine arbitraire) d'un datetime naïf ISO. */
function absMinutes(iso: string): number {
const [date, time] = iso.split('T');
const [h, min] = time.split(':').map(Number);
return _dayNum(date) * 1440 + h * 60 + min;
}
function windowInterval(w: AvailabilityRead): [number, number] {
const [sh, sm] = w.start_time.split(':').map(Number);
const [eh, em] = w.end_time.split(':').map(Number);
const start = _dayNum(w.date) * 1440 + sh * 60 + sm;
let end = _dayNum(w.date) * 1440 + eh * 60 + em;
if (end <= start) end += 1440; // fenêtre à cheval sur minuit
return [start, end];
}
/** Minutes du créneau `[startIso, endIso)` hors de l'union des fenêtres. */
export function uncoveredMinutes(
windows: AvailabilityRead[],
startIso: string,
endIso: string,
): number {
const shiftStart = absMinutes(startIso);
const shiftEnd = absMinutes(endIso);
const intervals = windows
.map(windowInterval)
.filter(([a, b]) => b > a)
.sort((a, b) => a[0] - b[0]);
let cursor = shiftStart;
let gap = 0;
for (const [s, e] of intervals) {
if (e <= cursor) continue;
if (s > shiftEnd) break;
if (s > cursor) gap += Math.min(s, shiftEnd) - cursor;
cursor = Math.max(cursor, e);
if (cursor >= shiftEnd) return gap;
}
if (cursor < shiftEnd) gap += shiftEnd - cursor;
return gap;
}
export type Coverage = 'full' | 'partial' | 'none';
/** État de couverture d'un créneau par les fenêtres d'un bénévole. */
export function availabilityCoverage(
windows: AvailabilityRead[],
startIso: string,
endIso: string,
): Coverage {
if (windows.length === 0) return 'none';
const gap = uncoveredMinutes(windows, startIso, endIso);
if (gap === 0) return 'full';
const total = absMinutes(endIso) - absMinutes(startIso);
return gap >= total ? 'none' : 'partial';
}
// --------------------------------------------------------------------------- //
// Score de chance d'un candidat pour un créneau — portage simplifié du //
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
// candidats dans le panneau d'affectation sans lancer de calcul CP-SAT. //
// --------------------------------------------------------------------------- //
export interface TimeWindow {
starts_at: string;
ends_at: string;
}
const _addDaysIso = (dateIso: string, n: number): string => {
const [y, m, d] = dateIso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
};
/** Fenêtre datée du passage d'un artiste (même convention que les créneaux :
* `end_time <= start_time` ⇒ le passage finit le lendemain). */
export function artistPerformanceWindow(artist: ArtistRead): TimeWindow {
const endDate =
artist.end_time > artist.start_time
? artist.performance_date
: _addDaysIso(artist.performance_date, 1);
return {
starts_at: `${artist.performance_date}T${artist.start_time}`,
ends_at: `${endDate}T${artist.end_time}`,
};
}
export function windowsOverlap(a: TimeWindow, b: TimeWindow): boolean {
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
}
/**
* Vœu de compagnon traité comme réciproque : si A souhaite B, B profite
* aussi de la présence de A, même s'il ne l'a pas demandé (le vœu reste à
* sens unique en base — voir `VolunteerCompanionWish` côté back-end — cette
* réciprocité n'est calculée qu'à l'usage, jamais enregistrée). Indexé par
* id de bénévole.
*/
export function buildEffectiveCompanionIds(
volunteers: Pick<VolunteerRead, 'id' | 'wished_companion_ids'>[],
): Map<string, Set<string>> {
const effective = new Map<string, Set<string>>();
for (const v of volunteers) {
effective.set(v.id, new Set(v.wished_companion_ids));
}
for (const v of volunteers) {
for (const companionId of v.wished_companion_ids) {
effective.get(companionId)?.add(v.id);
}
}
return effective;
}
const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
/**
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
* (y compris le conflit avec un artiste qu'il veut voir), compagnons
* souhaités déjà affectés au créneau, charge déjà assignée. Plus haut =
* meilleur candidat. Sert uniquement à trier la liste affichée ; la
* validation (blocage / avertissements) reste seule juge.
*/
export function candidateChanceScore(params: {
wishesPosition: boolean;
uncoveredMinutes: number;
conflictsWishedArtist: boolean;
companionMatches: number;
assignedHours: number;
}): number {
let score = 0;
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
return score;
}
/** 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 {
key: string; // `YYYY-MM-DD|HH:MM|HH:MM`
date: string;
time: string;
endTime: string;
label: string; // « 17:00 → 22:00 »
}
export interface LatticeDay {
date: string;
columns: LatticeColumn[];
}
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
* long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
* le dernier de la journée se termine) — même règle que
* `validation.engine.day_boundary` côté backend. Indépendant de `order`, qui
* ne suit pas les heures quand on les modifie. Sans creux, début du premier
* par `order` ; sans créneau type, minuit. */
export function dayBoundaryFromDayParts(
dayParts: Array<Pick<DayPartRead, 'start_time' | 'end_time' | 'order'>>,
): string {
if (dayParts.length === 0) return '00:00';
const DAY = 24 * 60;
const covered = new Array<boolean>(DAY).fill(false);
for (const part of dayParts) {
const start = minutesOfDay(part.start_time.slice(0, 5));
const end = minutesOfDay(part.end_time.slice(0, 5));
const length = (end - start + DAY) % DAY || DAY;
for (let offset = 0; offset < length; offset++) covered[(start + offset) % DAY] = true;
}
const origin = covered.indexOf(true);
if (covered.every(Boolean)) {
const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min));
return first.start_time.slice(0, 5);
}
// Parcours circulaire depuis une minute couverte : un creux qui enjambe
// minuit est vu d'un seul tenant.
let bestStart = 0;
let bestLength = 0;
let runStart = 0;
let runLength = 0;
for (let offset = 1; offset <= DAY; offset++) {
const minute = (origin + offset) % DAY;
if (covered[minute]) {
runLength = 0;
continue;
}
if (runLength === 0) runStart = minute;
runLength += 1;
if (runLength > bestLength) {
bestStart = runStart;
bestLength = runLength;
}
}
return clockLabel(bestStart);
}
/** Construit le treillis de colonnes à partir de créneaux (grille alignée).
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par
* « journée de festival » plutôt que par date calendaire brute : un créneau
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
* 19:00 — voir CLAUDE.md § 3 (« journée » pour le cumul des 8 h). */
export function buildLattice(
starts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
): {
days: LatticeDay[];
columns: LatticeColumn[];
} {
const boundaryMinutes = minutesOfDay(dayBoundary);
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
for (const s of starts) {
const date = datePart(s.starts_at);
const time = timePart(s.starts_at);
const endTime = timePart(s.ends_at);
const key = `${date}|${time}|${endTime}`;
if (!seen.has(key)) {
const minutes = minutesOfDay(time);
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
}
}
const ordered = [...seen.values()].sort(
(a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes,
);
const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({
key,
date,
time,
endTime,
label,
}));
const days: LatticeDay[] = [];
for (const column of ordered) {
let day = days.find((d) => d.date === column.festivalDay);
if (!day) {
day = { date: column.festivalDay, columns: [] };
days.push(day);
}
day.columns.push({
key: column.key,
date: column.date,
time: column.time,
endTime: column.endTime,
label: column.label,
});
}
return { days, columns };
}
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
return buildLattice(
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
dayBoundary,
);
}
/**
* 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(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;
}
/** Jour de festival d'un datetime naïf (`dayBoundary` = `HH:MM`, voir
* `dayBoundaryFromDayParts`) : avant cette heure, c'est encore la nuit de la
* veille. Même règle que `app/validation/engine.py:festival_day`. */
export function festivalDay(iso: string, dayBoundary = '00:00'): string {
return festivalDayOf(iso, minutesOfDay(dayBoundary));
}
/**
* Datetimes d'un créneau saisi `start`–`end` (`HH:MM`) sur la journée de
* festival `day` : une heure de début avant `dayBoundary` désigne la nuit
* qui suit (00:00 le samedi ⇒ dimanche 00:00, après 23:00 le samedi), pas
* le petit matin du même jour calendaire ; une fin `<=` début passe minuit.
*/
export function shiftDatesForFestivalDay(
day: string,
start: string,
end: string,
dayBoundary = '00:00',
): { starts_at: string; ends_at: string } {
const startDay = minutesOfDay(start) < minutesOfDay(dayBoundary) ? addDays(day, 1) : day;
const endDay = end <= start ? addDays(startDay, 1) : startDay;
return { starts_at: `${startDay}T${start}:00`, ends_at: `${endDay}T${end}:00` };
}
/**
* 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. */
export function hoursByVolunteer(
assignments: AssignmentRead[],
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): Map<string, number> {
const totals = new Map<string, number>();
for (const a of assignments) {
const shift = shiftsById.get(a.shift_id);
if (!shift) continue;
totals.set(
a.volunteer_id,
(totals.get(a.volunteer_id) ?? 0) + hoursBetween(shift.starts_at, shift.ends_at),
);
}
return totals;
}
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
export function isOverloaded(
volunteerId: string,
assignments: AssignmentRead[],
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): boolean {
const shifts = assignments
.filter((a) => a.volunteer_id === volunteerId)
.map((a) => shiftsById.get(a.shift_id))
.filter((s): s is ShiftRead => Boolean(s))
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
const perDay = new Map<string, number>();
for (const s of shifts) {
const day = datePart(s.starts_at);
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
}
for (const hours of perDay.values()) if (hours > 8) return true;
for (let i = 1; i < shifts.length; i += 1) {
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
if (gap > 0 && gap < 8 * 60) return true;
}
return false;
}
/**
* Rend une violation en texte lisible. Les `params` suivent exactement le
* contrat du moteur back-end (`backend/app/validation/engine.py`).
*/
export function violationText(
violation: Violation,
resolvePosition?: (shiftId: string) => string | undefined,
): string {
const p = violation.params;
switch (violation.code) {
case 'SHIFT_OVERLAP': {
const where = resolvePosition?.(String(p.other_shift_id));
return where
? t('validation.SHIFT_OVERLAP.at', { position: where })
: t('validation.SHIFT_OVERLAP');
}
case 'OUTSIDE_AVAILABILITY':
return t('validation.OUTSIDE_AVAILABILITY', {
gap: formatMinutes(Number(p.uncovered_minutes)),
});
case 'DAILY_HOURS_EXCEEDED':
return t('validation.DAILY_HOURS_EXCEEDED', {
hours: formatMinutes(Number(p.total_minutes)),
});
case 'INSUFFICIENT_REST':
return t('validation.INSUFFICIENT_REST', {
rest: formatMinutes(Number(p.gap_minutes)),
});
default:
return violation.code;
}
}
function formatMinutes(total: number): string {
const h = Math.floor(total / 60);
const m = total % 60;
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
}