É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>
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
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;
|
||
}
|
||
}
|