Grille : les candidats sont classés par score de chance, pas par charge

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-11 18:38:47 +02:00
co-authored by Claude Sonnet 5
parent 282be85498
commit 55f634e25b
5 changed files with 156 additions and 16 deletions
+58
View File
@@ -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`