Files
benevoles/frontend/src/lib/planning.ts
T
AdamThomasandClaude Opus 5.5 c6ea7378c7
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s
Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
Étape 62 : les fiches de poste intégraient la photo d'origine de chaque
bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites
à la vignette une fois par PDF ; badges : photo recadrée une fois par
génération et décodée à taille réduite.

Étape 61 : sans photo, le cadre de la photo reste vide sur le badge.

Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées
sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:01:48 +01:00

338 lines
13 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,
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;
}
/** 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);
}
/**
* 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. Même axe //
// pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
// --------------------------------------------------------------------------- //
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;
}
}