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>
527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
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')}`;
|
||
}
|