From 304b3c1d9e76e118bdfc07129eeb1d29dca0ef4e Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Fri, 11 Sep 2026 18:14:07 +0200 Subject: [PATCH] Front : heure de passage des artistes (config + mock du solveur) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- frontend/src/api/mock/dataset.ts | 8 ++- frontend/src/api/mock/mock-client.ts | 36 +++++++++++++- frontend/src/api/schema.ts | 7 +++ frontend/src/i18n/locales/fr.json | 4 +- frontend/src/pages/config/ConfigArtists.tsx | 55 +++++++++++++++++++-- 5 files changed, 102 insertions(+), 8 deletions(-) diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index cba4340..9fe83cf 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -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, })), diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index bb1a8d4..3cfbab1 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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(); + const artistWindowsByVolunteer = new Map(); 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) => { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 764f3e8..12bfae4 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -166,6 +166,9 @@ export interface ArtistRead { name: string; /** Jour de passage, date ISO (YYYY-MM-DD). */ performance_date: string; + /** Heures « HH:MM:SS ». end_time <= start_time ⇒ finit le lendemain. */ + start_time: string; + end_time: string; created_at: string; updated_at: string; } @@ -173,11 +176,15 @@ export interface ArtistRead { export interface ArtistCreate { name: string; performance_date: string; + start_time: string; + end_time: string; } export interface ArtistUpdate { name?: string; performance_date?: string; + start_time?: string; + end_time?: string; } // --------------------------------------------------------------------------- // diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index c0073c6..beb7de5 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -297,5 +297,7 @@ "grille.solver.run": "SUGGESTIONS (SOLVEUR)", "grille.solver.running": "CALCUL EN COURS…", "grille.solver.result": "{count} suggestion(s)", - "grille.panel.suggested": "Suggéré" + "grille.panel.suggested": "Suggéré", + "config.artists.startTime": "Heure de début du passage", + "config.artists.endTime": "Heure de fin du passage" } diff --git a/frontend/src/pages/config/ConfigArtists.tsx b/frontend/src/pages/config/ConfigArtists.tsx index 3818bef..579cdaa 100644 --- a/frontend/src/pages/config/ConfigArtists.tsx +++ b/frontend/src/pages/config/ConfigArtists.tsx @@ -25,8 +25,12 @@ function editionDays(start: string, end: string): string[] { return out; } +/** « HH:MM:SS » -> « HH:MM » (pour un ``). */ +const hm = (time: string): string => time.slice(0, 5); + // Programmation de l'édition : la liste d'artistes que les bénévoles pourront // choisir (voir onglet « Bénévoles & import »). Constituée jour par jour. +// L'heure de passage est prise en compte par le solveur (vœux d'artistes). export function ConfigArtists() { const edition = useEdition(); const artists = useArtists(); @@ -39,7 +43,7 @@ export function ConfigArtists() { map.set(a.performance_date, list); } for (const list of map.values()) { - list.sort((a, b) => a.name.localeCompare(b.name, 'fr')); + list.sort((a, b) => a.start_time.localeCompare(b.start_time) || a.name.localeCompare(b.name, 'fr')); } return map; }, [artists.data]); @@ -71,7 +75,7 @@ export function ConfigArtists() { -
+
{days.map((date) => ( ))} @@ -96,12 +100,19 @@ function DayColumn({ const update = useUpdateArtist(); const remove = useDeleteArtist(); const [draft, setDraft] = useState(''); + const [startDraft, setStartDraft] = useState('20:00'); + const [endDraft, setEndDraft] = useState('21:00'); const add = () => { const name = draft.trim(); if (!name) return; create.mutate( - { name, performance_date: date }, + { + name, + performance_date: date, + start_time: `${startDraft}:00`, + end_time: `${endDraft}:00`, + }, { onSuccess: () => setDraft('') }, ); }; @@ -117,7 +128,7 @@ function DayColumn({
{t('config.artists.empty')}
) : null} {artists.map((a) => ( -
+
{ @@ -126,6 +137,26 @@ function DayColumn({ }} className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold" /> + + e.target.value && + update.mutate({ id: a.id, body: { start_time: `${e.target.value}:00` } }) + } + className="w-[76px] flex-none border-2 border-ink bg-paper px-1 py-1 font-mono text-[10.5px]" + /> + + e.target.value && + update.mutate({ id: a.id, body: { end_time: `${e.target.value}:00` } }) + } + className="w-[76px] flex-none border-2 border-ink bg-paper px-1 py-1 font-mono text-[10.5px]" + />
))} -
+
setDraft(e.target.value)} @@ -150,6 +181,20 @@ function DayColumn({ placeholder={t('config.artists.namePlaceholder')} className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold" /> + setStartDraft(e.target.value)} + className="w-[76px] flex-none border-2 border-ink bg-yellow px-1 py-1 font-mono text-[10.5px]" + /> + setEndDraft(e.target.value)} + className="w-[76px] flex-none border-2 border-ink bg-yellow px-1 py-1 font-mono text-[10.5px]" + />