Files
benevoles/frontend/src/lib/planning.ts
T
AdamThomasandClaude Opus 5.5 0f904f5e3d
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 24s
ci-cd / deploy (push) Successful in 27s
Deux postes en même temps possibles, malus dans le solveur
Le chevauchement de créneaux (SHIFT_OVERLAP) n'est plus bloquant : il
remonte en avertissement et l'affectation est enregistrée. Le solveur ne
l'exclut plus mais applique un fort malus au bénévole déjà affecté
ailleurs au même moment (proposé seulement sous l'effectif minimal, faute
de mieux) ; il n'est jamais reproposé sur un créneau où il est déjà.
Même malus dans le classement par défaut du panneau et dans le mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:38:52 +01:00

418 lines
16 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,
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();
}
// --------------------------------------------------------------------------- //
// 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_ARTIST_CONFLICT_PENALTY = 80;
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
// Déjà affecté à un autre poste au même moment : possible, mais en fin de liste.
const CHANCE_ALREADY_BUSY_PENALTY = 300;
/**
* Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
* un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
* déjà affecté ailleurs au même moment, 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;
conflictsWishedArtist: boolean;
companionMatches: number;
busyElsewhere: boolean;
assignedHours: number;
}): number {
let score = 0;
if (params.busyElsewhere) score -= CHANCE_ALREADY_BUSY_PENALTY;
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
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. */
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;
}
/**
* 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');
}
default:
return violation.code;
}
}