import type { AssignmentRead, AvailabilityRead, MatrixResponse, PositionCategory, ShiftRead, Violation, } from '@/api/schema'; import { t } from '@/i18n'; import { datePart, hoursBetween, naiveMinutes, timePart } from './time'; export function categoryLabel(category: PositionCategory): string { return t(`position.category.${category.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'; } /** Colonne de la grille : une (date, heure de début) distincte. */ export interface LatticeColumn { key: string; // `YYYY-MM-DD|HH:MM` date: string; time: string; } export interface LatticeDay { date: string; columns: LatticeColumn[]; } /** Construit le treillis de colonnes à partir de créneaux (grille alignée). */ export function buildLattice(starts: Array<{ starts_at: string }>): { days: LatticeDay[]; columns: LatticeColumn[]; } { const seen = new Map(); for (const s of starts) { const date = datePart(s.starts_at); const time = timePart(s.starts_at); const key = `${date}|${time}`; if (!seen.has(key)) seen.set(key, { key, date, time }); } const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key)); const days: LatticeDay[] = []; for (const column of columns) { let day = days.find((d) => d.date === column.date); if (!day) { day = { date: column.date, columns: [] }; days.push(day); } day.columns.push(column); } return { days, columns }; } export function latticeFromMatrix(matrix: MatrixResponse) { return buildLattice(matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at })))); } /** 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')}`; }