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:
co-authored by
Claude Sonnet 5
parent
fea9d37019
commit
304b3c1d9e
@@ -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) =>
|
const artists: ArtistRead[] = DAY_DATES.flatMap((dateStr, d) =>
|
||||||
[0, 1].map((k) => ({
|
[0, 1].map((k) => ({
|
||||||
id: `art${d * 2 + k}`,
|
id: `art${d * 2 + k}`,
|
||||||
edition_id,
|
edition_id,
|
||||||
name: ARTIST_POOL[(d * 2 + k) % ARTIST_POOL.length],
|
name: ARTIST_POOL[(d * 2 + k) % ARTIST_POOL.length],
|
||||||
performance_date: dateStr,
|
performance_date: dateStr,
|
||||||
|
start_time: ARTIST_SLOTS[k][0],
|
||||||
|
end_time: ARTIST_SLOTS[k][1],
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -391,22 +391,45 @@ const SOLVER_WISH_BONUS = 30;
|
|||||||
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||||
const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2;
|
const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2;
|
||||||
const SOLVER_REST_PENALTY = 60;
|
const SOLVER_REST_PENALTY = 60;
|
||||||
|
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
|
||||||
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
|
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[] {
|
function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
|
||||||
return state.assignments
|
return state.assignments
|
||||||
.filter((a) => a.volunteer_id === volunteerId)
|
.filter((a) => a.volunteer_id === volunteerId)
|
||||||
.map((a) => shiftById(a.shift_id));
|
.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(
|
function solverScore(
|
||||||
volunteer: VolunteerRead,
|
volunteer: VolunteerRead,
|
||||||
shift: ShiftRead,
|
shift: ShiftRead,
|
||||||
assignedCount: number,
|
assignedCount: number,
|
||||||
existing: ShiftRead[],
|
existing: ShiftRead[],
|
||||||
|
wishedArtists: TimeWindow[],
|
||||||
): number {
|
): number {
|
||||||
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
|
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 (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 windows = state.availability.get(volunteer.id) ?? [];
|
||||||
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
|
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
|
||||||
@@ -454,8 +477,10 @@ function runSolver(): SolverRunResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const existingByVolunteer = new Map<string, ShiftRead[]>();
|
const existingByVolunteer = new Map<string, ShiftRead[]>();
|
||||||
|
const artistWindowsByVolunteer = new Map<string, TimeWindow[]>();
|
||||||
for (const v of state.volunteers) {
|
for (const v of state.volunteers) {
|
||||||
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
|
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
|
// 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.
|
// fois un même bénévole sur des créneaux qui se chevauchent.
|
||||||
@@ -485,6 +510,7 @@ function runSolver(): SolverRunResult {
|
|||||||
shift,
|
shift,
|
||||||
assignedCount.get(shift.id) ?? 0,
|
assignedCount.get(shift.id) ?? 0,
|
||||||
existingByVolunteer.get(v.id) ?? [],
|
existingByVolunteer.get(v.id) ?? [],
|
||||||
|
artistWindowsByVolunteer.get(v.id) ?? [],
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
.filter((c) => c.score > 0)
|
.filter((c) => c.score > 0)
|
||||||
@@ -599,6 +625,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
edition_id: state.edition.id,
|
edition_id: state.edition.id,
|
||||||
name,
|
name,
|
||||||
performance_date: body.performance_date,
|
performance_date: body.performance_date,
|
||||||
|
start_time: hms(body.start_time),
|
||||||
|
end_time: hms(body.end_time),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -620,7 +648,13 @@ export function createMockApi(): PlanningApi {
|
|||||||
) {
|
) {
|
||||||
throw new ApiError(409, 'artist.duplicate');
|
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);
|
return delay(artist);
|
||||||
},
|
},
|
||||||
remove: (_edition, id) => {
|
remove: (_edition, id) => {
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ export interface ArtistRead {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Jour de passage, date ISO (YYYY-MM-DD). */
|
/** Jour de passage, date ISO (YYYY-MM-DD). */
|
||||||
performance_date: string;
|
performance_date: string;
|
||||||
|
/** Heures « HH:MM:SS ». end_time <= start_time ⇒ finit le lendemain. */
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -173,11 +176,15 @@ export interface ArtistRead {
|
|||||||
export interface ArtistCreate {
|
export interface ArtistCreate {
|
||||||
name: string;
|
name: string;
|
||||||
performance_date: string;
|
performance_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtistUpdate {
|
export interface ArtistUpdate {
|
||||||
name?: string;
|
name?: string;
|
||||||
performance_date?: string;
|
performance_date?: string;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -297,5 +297,7 @@
|
|||||||
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
|
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
|
||||||
"grille.solver.running": "CALCUL EN COURS…",
|
"grille.solver.running": "CALCUL EN COURS…",
|
||||||
"grille.solver.result": "{count} suggestion(s)",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ function editionDays(start: string, end: string): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** « HH:MM:SS » -> « HH:MM » (pour un `<input type="time">`). */
|
||||||
|
const hm = (time: string): string => time.slice(0, 5);
|
||||||
|
|
||||||
// Programmation de l'édition : la liste d'artistes que les bénévoles pourront
|
// 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.
|
// 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() {
|
export function ConfigArtists() {
|
||||||
const edition = useEdition();
|
const edition = useEdition();
|
||||||
const artists = useArtists();
|
const artists = useArtists();
|
||||||
@@ -39,7 +43,7 @@ export function ConfigArtists() {
|
|||||||
map.set(a.performance_date, list);
|
map.set(a.performance_date, list);
|
||||||
}
|
}
|
||||||
for (const list of map.values()) {
|
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;
|
return map;
|
||||||
}, [artists.data]);
|
}, [artists.data]);
|
||||||
@@ -71,7 +75,7 @@ export function ConfigArtists() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
|
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(320px,1fr))]">
|
||||||
{days.map((date) => (
|
{days.map((date) => (
|
||||||
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
|
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
|
||||||
))}
|
))}
|
||||||
@@ -96,12 +100,19 @@ function DayColumn({
|
|||||||
const update = useUpdateArtist();
|
const update = useUpdateArtist();
|
||||||
const remove = useDeleteArtist();
|
const remove = useDeleteArtist();
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
|
const [startDraft, setStartDraft] = useState('20:00');
|
||||||
|
const [endDraft, setEndDraft] = useState('21:00');
|
||||||
|
|
||||||
const add = () => {
|
const add = () => {
|
||||||
const name = draft.trim();
|
const name = draft.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ name, performance_date: date },
|
{
|
||||||
|
name,
|
||||||
|
performance_date: date,
|
||||||
|
start_time: `${startDraft}:00`,
|
||||||
|
end_time: `${endDraft}:00`,
|
||||||
|
},
|
||||||
{ onSuccess: () => setDraft('') },
|
{ onSuccess: () => setDraft('') },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -117,7 +128,7 @@ function DayColumn({
|
|||||||
<div className="font-mono text-[10px] opacity-55">{t('config.artists.empty')}</div>
|
<div className="font-mono text-[10px] opacity-55">{t('config.artists.empty')}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{artists.map((a) => (
|
{artists.map((a) => (
|
||||||
<div key={a.id} className="flex items-center gap-1">
|
<div key={a.id} className="flex flex-wrap items-center gap-1">
|
||||||
<input
|
<input
|
||||||
defaultValue={a.name}
|
defaultValue={a.name}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
@@ -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"
|
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label={t('config.artists.startTime')}
|
||||||
|
defaultValue={hm(a.start_time)}
|
||||||
|
onBlur={(e) =>
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label={t('config.artists.endTime')}
|
||||||
|
defaultValue={hm(a.end_time)}
|
||||||
|
onBlur={(e) =>
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove.mutate(a.id)}
|
onClick={() => remove.mutate(a.id)}
|
||||||
@@ -137,7 +168,7 @@ function DayColumn({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
|
<div className="mt-1 flex flex-wrap items-center gap-1 border-t-2 border-ink pt-2">
|
||||||
<input
|
<input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -150,6 +181,20 @@ function DayColumn({
|
|||||||
placeholder={t('config.artists.namePlaceholder')}
|
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"
|
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label={t('config.artists.startTime')}
|
||||||
|
value={startDraft}
|
||||||
|
onChange={(e) => setStartDraft(e.target.value)}
|
||||||
|
className="w-[76px] flex-none border-2 border-ink bg-yellow px-1 py-1 font-mono text-[10.5px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label={t('config.artists.endTime')}
|
||||||
|
value={endDraft}
|
||||||
|
onChange={(e) => setEndDraft(e.target.value)}
|
||||||
|
className="w-[76px] flex-none border-2 border-ink bg-yellow px-1 py-1 font-mono text-[10.5px]"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!draft.trim() || create.isPending}
|
disabled={!draft.trim() || create.isPending}
|
||||||
|
|||||||
Reference in New Issue
Block a user