Retrait des disponibilités et des limites de temps dans la journée
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-05 21:29:58 +02:00
co-authored by Claude Opus 5.5
parent f671bb85bf
commit d722a81a87
40 changed files with 213 additions and 1845 deletions
+4 -117
View File
@@ -1,7 +1,6 @@
import type {
ArtistRead,
AssignmentRead,
AvailabilityRead,
DayPartRead,
MatrixResponse,
PositionCategory,
@@ -25,71 +24,6 @@ export function foldAccents(value: string): string {
.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 //
@@ -145,28 +79,25 @@ export function buildEffectiveCompanionIds(
}
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 =
* 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,
* 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;
@@ -239,7 +170,7 @@ export function dayBoundaryFromDayParts(
* « 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). */
* 19:00 — voir CLAUDE.md § 3. */
export function buildLattice(
starts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
@@ -460,32 +391,6 @@ export function hoursByVolunteer(
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`).
@@ -502,25 +407,7 @@ export function violationText(
? 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')}`;
}