Créneaux de nuit rattachés à leur journée de festival
Un créneau saisi « 00:00–01:00 » sur la carte du samedi était enregistré le samedi à 00:00 (nuit de vendredi à samedi), donc affiché la veille dans la grille des besoins. Une heure de début avant le début de journée (premier créneau type) désigne désormais la nuit qui suit : dimanche 00:00, à la suite de la soirée du samedi. Même « journée de festival » partout (validation/engine.py:day_boundary, festival_day ; miroir front lib/planning.ts:festivalDay) : écran Postes & créneaux, feuilles de poste (une seule page pour une soirée qui passe minuit) et chaînage du solveur (00h–01h enchaîne 23h–00h). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c60bc11795
commit
a6e7b6e7fb
@@ -160,6 +160,7 @@
|
||||
"config.grid.shiftStart": "Début du créneau",
|
||||
"config.grid.shiftEnd": "Fin du créneau",
|
||||
"config.grid.addShift": "+ AJOUTER CE CRÉNEAU",
|
||||
"config.grid.nightHint": "Une heure de début avant {boundary} compte pour la nuit qui suit (ex. 00:00 → après minuit, à la suite de la soirée de ce jour).",
|
||||
"config.grid.note": "Un poste ne crée aucun créneau à sa création : ajoutez-les à la main (heure de début et de fin) sur chaque jour, ou utilisez « Générer les créneaux » pour repartir des créneaux types de l'édition. Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».",
|
||||
"config.vol.importTitle": "IMPORTER DES BÉNÉVOLES",
|
||||
"config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.",
|
||||
|
||||
@@ -312,6 +312,30 @@ function festivalDayOf(iso: string, boundaryMinutes: number): string {
|
||||
return clock < boundaryMinutes ? addDays(date, -1) : date;
|
||||
}
|
||||
|
||||
/** Jour de festival d'un datetime naïf (`dayBoundary` = `HH:MM`, voir
|
||||
* `dayBoundaryFromDayParts`) : avant cette heure, c'est encore la nuit de la
|
||||
* veille. Même règle que `app/validation/engine.py:festival_day`. */
|
||||
export function festivalDay(iso: string, dayBoundary = '00:00'): string {
|
||||
return festivalDayOf(iso, minutesOfDay(dayBoundary));
|
||||
}
|
||||
|
||||
/**
|
||||
* Datetimes d'un créneau saisi `start`–`end` (`HH:MM`) sur la journée de
|
||||
* festival `day` : une heure de début avant `dayBoundary` désigne la nuit
|
||||
* qui suit (00:00 le samedi ⇒ dimanche 00:00, après 23:00 le samedi), pas
|
||||
* le petit matin du même jour calendaire ; une fin `<=` début passe minuit.
|
||||
*/
|
||||
export function shiftDatesForFestivalDay(
|
||||
day: string,
|
||||
start: string,
|
||||
end: string,
|
||||
dayBoundary = '00:00',
|
||||
): { starts_at: string; ends_at: string } {
|
||||
const startDay = minutesOfDay(start) < minutesOfDay(dayBoundary) ? addDays(day, 1) : day;
|
||||
const endDay = end <= start ? addDays(startDay, 1) : startDay;
|
||||
return { starts_at: `${startDay}T${start}:00`, ends_at: `${endDay}T${end}:00` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit, pour chaque jour de festival couvert par `shifts`, l'axe des
|
||||
* tranches de 30 min à afficher — bornée à la plage réellement utilisée ce
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
|
||||
import {
|
||||
useCreatePosition,
|
||||
useCreateShift,
|
||||
useDayParts,
|
||||
useDeletePosition,
|
||||
useDeleteShift,
|
||||
useEdition,
|
||||
@@ -15,8 +16,13 @@ import {
|
||||
import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { Btn, HelpTip } from '@/components/ui';
|
||||
import { categoryLabel } from '@/lib/planning';
|
||||
import { addDays, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
||||
import {
|
||||
categoryLabel,
|
||||
dayBoundaryFromDayParts,
|
||||
festivalDay,
|
||||
shiftDatesForFestivalDay,
|
||||
} from '@/lib/planning';
|
||||
import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
@@ -47,6 +53,8 @@ export function ConfigGrid() {
|
||||
const editionQuery = useEdition();
|
||||
const positions = usePositions();
|
||||
const allShifts = useShifts();
|
||||
const dayParts = useDayParts();
|
||||
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
const createPosition = useCreatePosition();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
@@ -88,10 +96,17 @@ export function ConfigGrid() {
|
||||
);
|
||||
}
|
||||
|
||||
const shiftsByDay = groupByDay(positionShifts.data ?? []);
|
||||
const days = editionQuery.data
|
||||
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
|
||||
: [...shiftsByDay.keys()];
|
||||
const shiftsByDay = groupByDay(positionShifts.data ?? [], dayBoundary);
|
||||
// Jours de l'édition, plus tout jour de festival qui porterait un créneau
|
||||
// hors de ces dates (sinon invisible ici, donc impossible à supprimer).
|
||||
const days = [
|
||||
...new Set([
|
||||
...(editionQuery.data
|
||||
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
|
||||
: []),
|
||||
...shiftsByDay.keys(),
|
||||
]),
|
||||
].sort();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
@@ -150,6 +165,7 @@ export function ConfigGrid() {
|
||||
day={day}
|
||||
positionId={position.id}
|
||||
shifts={shiftsByDay.get(day) ?? []}
|
||||
dayBoundary={dayBoundary}
|
||||
updateShift={updateShift}
|
||||
deleteShift={deleteShift}
|
||||
/>
|
||||
@@ -246,12 +262,14 @@ function DayCard({
|
||||
day,
|
||||
positionId,
|
||||
shifts,
|
||||
dayBoundary,
|
||||
updateShift,
|
||||
deleteShift,
|
||||
}: {
|
||||
day: string;
|
||||
positionId: string;
|
||||
shifts: ShiftRead[];
|
||||
dayBoundary: string;
|
||||
updateShift: ReturnType<typeof useUpdateShift>;
|
||||
deleteShift: ReturnType<typeof useDeleteShift>;
|
||||
}) {
|
||||
@@ -261,9 +279,8 @@ function DayCard({
|
||||
|
||||
const addShift = () => {
|
||||
if (!start || !end) return;
|
||||
const endDay = end <= start ? addDays(day, 1) : day;
|
||||
createShift.mutate(
|
||||
{ position_id: positionId, starts_at: `${day}T${start}:00`, ends_at: `${endDay}T${end}:00` },
|
||||
{ position_id: positionId, ...shiftDatesForFestivalDay(day, start, end, dayBoundary) },
|
||||
{ onSuccess: () => { setStart(''); setEnd(''); } },
|
||||
);
|
||||
};
|
||||
@@ -342,15 +359,22 @@ function DayCard({
|
||||
{t('config.grid.addShift')}
|
||||
</button>
|
||||
</div>
|
||||
{dayBoundary !== '00:00' ? (
|
||||
<div className="bg-sand px-[9px] pb-[7px] font-mono text-[9.5px] opacity-70">
|
||||
{t('config.grid.nightHint', { boundary: dayBoundary })}
|
||||
</div>
|
||||
) : null}
|
||||
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByDay(shifts: ShiftRead[]): Map<string, ShiftRead[]> {
|
||||
/** Regroupe par journée de festival (pas par date du calendrier) : 00h–01h
|
||||
* le dimanche reste avec la soirée du samedi, triée après 23h. */
|
||||
function groupByDay(shifts: ShiftRead[], dayBoundary: string): Map<string, ShiftRead[]> {
|
||||
const map = new Map<string, ShiftRead[]>();
|
||||
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
|
||||
const day = s.starts_at.slice(0, 10);
|
||||
const day = festivalDay(s.starts_at, dayBoundary);
|
||||
map.set(day, [...(map.get(day) ?? []), s]);
|
||||
}
|
||||
return map;
|
||||
|
||||
Reference in New Issue
Block a user