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[], ): Map> { const effective = new Map>(); 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>, ): string { if (dayParts.length === 0) return '00:00'; const DAY = 24 * 60; const covered = new Array(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(); 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 { 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(); 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( days: TimeGridDay[], items: T[], dayBoundary = '00:00', ): Map[]> { const boundaryMinutes = minutesOfDay(dayBoundary); const byDay = new Map(days.map((d) => [d.date, d])); const placements = new Map[]>(); 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>, ): Map { const totals = new Map(); 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>, ): 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(); 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')}`; }