Front : heure de passage des artistes (config + mock du solveur)

- ArtistRead/Create/Update gagnent start_time/end_time.
- Onglet Programmation : chaque artiste a désormais deux champs heure
  (début/fin de passage), éditables comme le nom.
- Mock du solveur : porte le même malus de conflit d'artiste que le
  back-end (un créneau qui chevauche un passage souhaité est pénalisé),
  pour un comportement de démo cohérent.
- Jeu de démo : 2 artistes par jour avec des horaires distincts
  (18h-19h / 20h30-22h) au lieu d'un horaire par défaut implicite.

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:14:07 +02:00
co-authored by Claude Sonnet 5
parent fea9d37019
commit 304b3c1d9e
5 changed files with 102 additions and 8 deletions
+7 -1
View File
@@ -179,13 +179,19 @@ export function buildMockState(): MockState {
}
}
// Programmation : 2 artistes par jour de l'édition.
// Programmation : 2 artistes par jour de l'édition, à des horaires distincts.
const ARTIST_SLOTS: Array<[string, string]> = [
['18:00:00', '19:00:00'],
['20:30:00', '22:00:00'],
];
const artists: ArtistRead[] = DAY_DATES.flatMap((dateStr, d) =>
[0, 1].map((k) => ({
id: `art${d * 2 + k}`,
edition_id,
name: ARTIST_POOL[(d * 2 + k) % ARTIST_POOL.length],
performance_date: dateStr,
start_time: ARTIST_SLOTS[k][0],
end_time: ARTIST_SLOTS[k][1],
created_at: NOW,
updated_at: NOW,
})),
+35 -1
View File
@@ -391,22 +391,45 @@ const SOLVER_WISH_BONUS = 30;
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2;
const SOLVER_REST_PENALTY = 60;
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
type TimeWindow = { starts_at: string; ends_at: string };
function timeRangesOverlap(a: TimeWindow, b: TimeWindow): boolean {
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
}
function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
return state.assignments
.filter((a) => a.volunteer_id === volunteerId)
.map((a) => shiftById(a.shift_id));
}
/** Fenêtres datées des passages que le bénévole veut voir (un créneau qui
* chevauche l'une d'elles lui ferait manquer le passage). */
function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] {
return volunteer.artist_ids
.map((id) => state.artists.find((a) => a.id === id))
.filter((a): a is ArtistRead => Boolean(a))
.map((a) => {
const endDate = a.end_time > a.start_time ? a.performance_date : addDays(a.performance_date, 1);
return { starts_at: `${a.performance_date}T${a.start_time}`, ends_at: `${endDate}T${a.end_time}` };
});
}
function solverScore(
volunteer: VolunteerRead,
shift: ShiftRead,
assignedCount: number,
existing: ShiftRead[],
wishedArtists: TimeWindow[],
): number {
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS;
if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) {
score -= SOLVER_ARTIST_CONFLICT_PENALTY;
}
const windows = state.availability.get(volunteer.id) ?? [];
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
@@ -454,8 +477,10 @@ function runSolver(): SolverRunResult {
);
const existingByVolunteer = new Map<string, ShiftRead[]>();
const artistWindowsByVolunteer = new Map<string, TimeWindow[]>();
for (const v of state.volunteers) {
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
}
// Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux
// fois un même bénévole sur des créneaux qui se chevauchent.
@@ -485,6 +510,7 @@ function runSolver(): SolverRunResult {
shift,
assignedCount.get(shift.id) ?? 0,
existingByVolunteer.get(v.id) ?? [],
artistWindowsByVolunteer.get(v.id) ?? [],
),
}))
.filter((c) => c.score > 0)
@@ -599,6 +625,8 @@ export function createMockApi(): PlanningApi {
edition_id: state.edition.id,
name,
performance_date: body.performance_date,
start_time: hms(body.start_time),
end_time: hms(body.end_time),
created_at: now,
updated_at: now,
};
@@ -620,7 +648,13 @@ export function createMockApi(): PlanningApi {
) {
throw new ApiError(409, 'artist.duplicate');
}
Object.assign(artist, { name, performance_date: date, updated_at: touch() });
Object.assign(artist, {
name,
performance_date: date,
...(body.start_time ? { start_time: hms(body.start_time) } : {}),
...(body.end_time ? { end_time: hms(body.end_time) } : {}),
updated_at: touch(),
});
return delay(artist);
},
remove: (_edition, id) => {