From 55f634e25b5a2b97d7d182e6349ef416da28a186 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Fri, 11 Sep 2026 18:38:47 +0200 Subject: [PATCH] =?UTF-8?q?Grille=20:=20les=20candidats=20sont=20class?= =?UTF-8?q?=C3=A9s=20par=20score=20de=20chance,=20pas=20par=20charge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dans le panneau d'affectation, la liste des candidats d'un créneau n'était triée que par disponibilité puis par nombre d'heures déjà assignées — les vœux n'y jouaient aucun rôle tant qu'on ne cliquait pas sur « Suggestions (solveur) ». Nouveau `candidateChanceScore` (`lib/planning.ts`), portage local et synchrone du scoring du solveur : vœu de poste (bonus), disponibilité déclarée y compris conflit avec un artiste souhaité (malus), charge déjà assignée (malus). C'est ce score qui classe désormais la liste par défaut, dès l'ouverture du détail d'un poste — sans requête réseau ni clic sur le solveur. Si celui-ci a effectivement tourné, ses candidats retenus (vraie optimisation jointe) restent prioritaires en tête. Chaque candidat affiche maintenant, en plus de sa disponibilité, des badges « Vœu de poste » et « Manquerait un artiste souhaité » quand applicable — pour que le classement reste lisible. typecheck / lint / build / smoke au vert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- CLAUDE.md | 10 ++ frontend/src/i18n/locales/fr.json | 4 +- frontend/src/lib/planning.ts | 58 +++++++++++ frontend/src/pages/GrillePage.tsx | 1 + frontend/src/pages/grille/AssignmentPanel.tsx | 99 ++++++++++++++++--- 5 files changed, 156 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68c8a4c..4bf1007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,6 +279,16 @@ affectation n'est jamais persistée par cet endpoint. même calcul — la validation finale à la confirmation reste seule juge de ce cas limite. +**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts` +(`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de +poste, disponibilité y compris conflit d'artiste, charge déjà affectée ; pas +de cumul 8 h / repos, déjà couverts par les badges de validation). C'est ce +qui classe la colonne des candidats dans le panneau d'affectation dès +l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a +effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus +passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation +jointe, pas juste un score local). + --- ## 5. Périmètre diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index beb7de5..76436ee 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -299,5 +299,7 @@ "grille.solver.result": "{count} suggestion(s)", "grille.panel.suggested": "Suggéré", "config.artists.startTime": "Heure de début du passage", - "config.artists.endTime": "Heure de fin du passage" + "config.artists.endTime": "Heure de fin du passage", + "grille.panel.wishesPosition": "Vœu de poste", + "grille.panel.artistConflict": "Manquerait un artiste souhaité" } diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index 43b3f62..3772567 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -1,4 +1,5 @@ import type { + ArtistRead, AssignmentRead, AvailabilityRead, MatrixResponse, @@ -79,6 +80,63 @@ export function availabilityCoverage( 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; +} + +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; + +/** + * 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), 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; + 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_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours; + return score; +} + /** Colonne de la grille : une (date, heure de début) distincte. */ export interface LatticeColumn { key: string; // `YYYY-MM-DD|HH:MM` diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index fe2e77b..8f6627a 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -171,6 +171,7 @@ export function GrillePage() {