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
+1
View File
@@ -171,6 +171,7 @@ export function GrillePage() {
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
{selected ? (
<AssignmentPanel
positionId={selected.position.id}
positionName={selected.position.name}
positionLocation={selected.position.location}
positionCategory={selected.position.category}
+84 -15
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { ApiError } from '@/api';
import {
useArtists,
useAssignments,
useCreateAssignment,
useDeleteAssignment,
@@ -21,18 +22,21 @@ import type {
import { Btn } from '@/components/ui';
import { Loading } from '@/components/state';
import {
artistPerformanceWindow,
availabilityCoverage,
candidateChanceScore,
categoryLabel,
hoursByVolunteer,
uncoveredMinutes,
violationText,
windowsOverlap,
type Coverage,
} from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
const COVERAGE_ORDER: Record<Coverage, number> = { full: 0, partial: 1, none: 2 };
interface Props {
positionId: string;
positionName: string;
positionLocation: string | null;
positionCategory: PositionCategory;
@@ -43,6 +47,7 @@ interface Props {
const MAX_CANDIDATES = 10;
export function AssignmentPanel({
positionId,
positionName,
positionLocation,
positionCategory,
@@ -55,6 +60,7 @@ export function AssignmentPanel({
const shifts = useShifts();
const positions = usePositions();
const availability = useEditionAvailability();
const artists = useArtists();
const solverSuggestions = useSolverSuggestions();
const deleteAssignment = useDeleteAssignment();
@@ -65,22 +71,31 @@ export function AssignmentPanel({
return entry?.volunteer_ids ?? [];
}, [solverSuggestions.data, shift.shift_id]);
const coverageByVolunteer = useMemo(() => {
const windowsByVolunteer = useMemo(() => {
const byVol = new Map<string, AvailabilityRead[]>();
for (const w of availability.data ?? []) {
const list = byVol.get(w.volunteer_id) ?? [];
list.push(w);
byVol.set(w.volunteer_id, list);
}
return byVol;
}, [availability.data]);
const coverageByVolunteer = useMemo(() => {
const map = new Map<string, Coverage>();
for (const v of volunteers.data ?? []) {
map.set(
v.id,
availabilityCoverage(byVol.get(v.id) ?? [], shift.starts_at, shift.ends_at),
availabilityCoverage(windowsByVolunteer.get(v.id) ?? [], shift.starts_at, shift.ends_at),
);
}
return map;
}, [availability.data, volunteers.data, shift.starts_at, shift.ends_at]);
}, [windowsByVolunteer, volunteers.data, shift.starts_at, shift.ends_at]);
const artistsById = useMemo(
() => new Map((artists.data ?? []).map((a) => [a.id, a])),
[artists.data],
);
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
@@ -111,25 +126,61 @@ export function AssignmentPanel({
const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
// Raisons du score, par bénévole : vœu de poste, conflit avec un artiste
// qu'il veut voir sur ce créneau (indépendant de sa disponibilité déclarée).
const detailsByVolunteer = useMemo(() => {
const map = new Map<string, { wishesPosition: boolean; conflictsWishedArtist: boolean }>();
for (const v of volunteers.data ?? []) {
const conflictsWishedArtist = v.artist_ids.some((id) => {
const artist = artistsById.get(id);
return artist ? windowsOverlap(shift, artistPerformanceWindow(artist)) : false;
});
map.set(v.id, {
wishesPosition: v.wished_position_ids.includes(positionId),
conflictsWishedArtist,
});
}
return map;
}, [volunteers.data, artistsById, shift, positionId]);
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
// disponibilité (dont conflit avec un artiste qu'il veut voir), charge déjà
// assignée — c'est ce qui classe la liste, pas juste « le moins chargé ».
const scoreByVolunteer = useMemo(() => {
const map = new Map<string, number>();
for (const v of volunteers.data ?? []) {
const details = detailsByVolunteer.get(v.id);
map.set(
v.id,
candidateChanceScore({
wishesPosition: details?.wishesPosition ?? false,
uncoveredMinutes: uncoveredMinutes(
windowsByVolunteer.get(v.id) ?? [],
shift.starts_at,
shift.ends_at,
),
conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
assignedHours: hours.get(v.id) ?? 0,
}),
);
}
return map;
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
const candidates = useMemo(() => {
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i]));
const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none'];
return (volunteers.data ?? [])
.filter((v) => !assignedIds.has(v.id))
// Le solveur d'abord (dans son ordre), puis les disponibles, puis les
// moins chargés.
// Le solveur d'abord (dans son ordre, s'il a tourné), puis par score de
// chance décroissant (vœu de poste, disponibilité, charge).
.sort((a, b) => {
const ra = suggestedRank.get(a.id) ?? Infinity;
const rb = suggestedRank.get(b.id) ?? Infinity;
return (
ra - rb ||
cov(a.id) - cov(b.id) ||
(hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0)
);
return ra - rb || (scoreByVolunteer.get(b.id) ?? 0) - (scoreByVolunteer.get(a.id) ?? 0);
})
.slice(0, MAX_CANDIDATES);
}, [volunteers.data, assigned, hours, coverageByVolunteer, suggestedIds]);
}, [volunteers.data, assigned, scoreByVolunteer, suggestedIds]);
if (volunteers.isLoading || shiftAssignments.isLoading) {
return <Loading />;
@@ -225,6 +276,10 @@ export function AssignmentPanel({
hours={round(hours.get(volunteer.id) ?? 0)}
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
wishesPosition={detailsByVolunteer.get(volunteer.id)?.wishesPosition ?? false}
conflictsWishedArtist={
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
}
suggested={suggestedIds.includes(volunteer.id)}
resolvePosition={resolvePosition}
/>
@@ -246,6 +301,8 @@ function CandidateRow({
hours,
slots,
coverage,
wishesPosition,
conflictsWishedArtist,
suggested,
resolvePosition,
}: {
@@ -254,6 +311,8 @@ function CandidateRow({
hours: number;
slots: number;
coverage: Coverage;
wishesPosition: boolean;
conflictsWishedArtist: boolean;
suggested: boolean;
resolvePosition: (shiftId: string) => string | undefined;
}) {
@@ -304,12 +363,22 @@ function CandidateRow({
)}
</div>
<div className="px-[7px] pb-[7px]">
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
<span
className={`inline-block border-[1.5px] border-ink px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide ${COVERAGE_STYLE[coverage].cls}`}
>
{t(COVERAGE_STYLE[coverage].key)}
</span>
{wishesPosition ? (
<span className="inline-block border-[1.5px] border-ink bg-yellow px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('grille.panel.wishesPosition')}
</span>
) : null}
{conflictsWishedArtist ? (
<span className="inline-block border-[1.5px] border-ink bg-orange px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('grille.panel.artistConflict')}
</span>
) : null}
</div>
{blocking ? (