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:
AdamThomas
2026-10-03 18:47:06 +02:00
co-authored by Claude Opus 5.5
parent c60bc11795
commit a6e7b6e7fb
9 changed files with 153 additions and 18 deletions
+10
View File
@@ -538,6 +538,15 @@ Sémantique figée :
créneau entièrement à découvert. créneau entièrement à découvert.
- « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du - « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du
premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça). premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça).
**Même « journée de festival » partout** (`app/validation/engine.py:
day_boundary`/`festival_day`, miroir front `lib/planning.ts:festivalDay`) :
une soirée qui passe minuit (22h–23h, 23h–00h, 00h–01h…) reste d'un seul
tenant sur le jour où elle a commencé — grille des besoins, écran « Postes
& créneaux », feuilles de poste, chaînage du solveur. À la saisie d'un
créneau sur la carte d'un jour (`ConfigGrid.tsx`,
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
matin du même jour calendaire.
- Cumul : `> 8 h 00` strict (8 h 00 pile passe). - Cumul : `> 8 h 00` strict (8 h 00 pile passe).
- Repos : `< 8 h 00` strict. - Repos : `< 8 h 00` strict.
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement. - Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
@@ -852,6 +861,7 @@ Aucune dépendance locale requise hors Docker.
| 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider | | 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider |
| 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider | | 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider |
| 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider | | 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider |
| 38 | Créneaux de nuit : une heure de début avant le début de journée (premier créneau type) saisie sur la carte d'un jour désigne la nuit qui suit (00:00 le samedi ⇒ dimanche 00:00) ; écran « Postes & créneaux », feuilles de poste et chaînage du solveur regroupent par journée de festival, comme la grille et la validation | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+11 -2
View File
@@ -17,7 +17,7 @@ from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.db import get_session from app.db import get_session
from app.models import Account, Artist, Assignment, Edition, Position, Shift, Volunteer from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.sheets.pdf import ( from app.sheets.pdf import (
PositionDaySheet, PositionDaySheet,
SheetArtist, SheetArtist,
@@ -25,6 +25,7 @@ from app.sheets.pdf import (
SheetVolunteer, SheetVolunteer,
generate_position_sheets_pdf, generate_position_sheets_pdf,
) )
from app.validation.engine import day_boundary, festival_day
router = APIRouter( router = APIRouter(
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"] prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
@@ -69,6 +70,13 @@ def generate_position_sheets(
for vs in volunteers_by_shift.values(): for vs in volunteers_by_shift.values():
vs.sort(key=lambda v: (v.last_name, v.first_name)) vs.sort(key=lambda v: (v.last_name, v.first_name))
# « Jour » = journée de festival (CLAUDE.md § 3), pas date du calendrier :
# une soirée qui passe minuit (23h–00h, 00h–01h…) reste sur une seule
# feuille, celle du jour où elle a commencé.
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement, # Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement,
# jamais rempli) n'apparaît pas — évite des pages « personne affecté » # jamais rempli) n'apparaît pas — évite des pages « personne affecté »
# pour des créneaux qui ne représentent aucun besoin. # pour des créneaux qui ne représentent aucun besoin.
@@ -77,7 +85,8 @@ def generate_position_sheets(
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0 undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
if undefined_need and not volunteers_by_shift.get(shift.id): if undefined_need and not volunteers_by_shift.get(shift.id):
continue continue
shifts_by_position_day[(shift.position_id, shift.starts_at.date())].append(shift) day = festival_day(shift.starts_at, boundary)
shifts_by_position_day[(shift.position_id, day)].append(shift)
Referent = aliased(Volunteer) Referent = aliased(Volunteer)
referent_rows = session.exec( referent_rows = session.exec(
+8 -1
View File
@@ -10,6 +10,7 @@ from app.models import (
Artist, Artist,
Assignment, Assignment,
Availability, Availability,
DayPart,
Edition, Edition,
Shift, Shift,
Volunteer, Volunteer,
@@ -18,6 +19,7 @@ from app.models import (
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.solver.models import SolverInput, SolverShift, SolverVolunteer from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.engine import day_boundary, festival_day
from app.validation.models import AvailabilityWindow, ShiftInfo from app.validation.models import AvailabilityWindow, ShiftInfo
@@ -41,6 +43,11 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
) )
shifts_by_id = {s.id: s for s in shifts} shifts_by_id = {s.id: s for s in shifts}
# « Même jour » = même journée de festival (CLAUDE.md § 3) : 00h–01h
# enchaîne bien 23h–00h de la veille au calendrier.
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la # Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la
# fin coïncide avec le début de celui-ci — sert au malus de chaînage # fin coïncide avec le début de celui-ci — sert au malus de chaînage
# (voir `engine._score`), pour limiter les enchaînements de shifts plutôt # (voir `engine._score`), pour limiter les enchaînements de shifts plutôt
@@ -49,7 +56,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
predecessor_ids: dict = {} predecessor_ids: dict = {}
by_day: dict = defaultdict(list) by_day: dict = defaultdict(list)
for s in shifts: for s in shifts:
by_day[s.starts_at.date()].append(s) by_day[festival_day(s.starts_at, boundary)].append(s)
for day_shifts in by_day.values(): for day_shifts in by_day.values():
ids_by_end: dict = defaultdict(list) ids_by_end: dict = defaultdict(list)
for s in day_shifts: for s in day_shifts:
+13 -5
View File
@@ -107,26 +107,34 @@ def _check_outside_availability(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) # # DAILY_HOURS_EXCEEDED (avertissement) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _day_boundary(day_parts: tuple) -> dt.time: def day_boundary(day_parts) -> dt.time:
"""Début d'une « journée de festival » : début du premier créneau type
(`DayPart`, par `order`), minuit à défaut — voir CLAUDE.md § 3. Accepte
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`."""
day_parts = list(day_parts)
if not day_parts: if not day_parts:
return dt.time(0, 0) return dt.time(0, 0)
return min(day_parts, key=lambda part: part.order).start_time return min(day_parts, key=lambda part: part.order).start_time
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date: def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
"""Journée de festival d'un instant : avant `boundary`, c'est encore la
nuit de la veille (ex. 01:00 le dimanche ⇒ samedi). Partagé avec le
solveur et les feuilles de poste, pour qu'une soirée qui passe minuit
reste d'un seul tenant partout."""
if moment.time() >= boundary: if moment.time() >= boundary:
return moment.date() return moment.date()
return moment.date() - dt.timedelta(days=1) return moment.date() - dt.timedelta(days=1)
def _check_daily_hours(data: ValidationInput) -> list[Violation]: def _check_daily_hours(data: ValidationInput) -> list[Violation]:
boundary = _day_boundary(data.day_parts) boundary = day_boundary(data.day_parts)
candidate = data.candidate_shift candidate = data.candidate_shift
key = _day_key(candidate.starts_at, boundary) key = festival_day(candidate.starts_at, boundary)
total = candidate.ends_at - candidate.starts_at total = candidate.ends_at - candidate.starts_at
for other in data.existing_shifts: for other in data.existing_shifts:
if _day_key(other.starts_at, boundary) == key: if festival_day(other.starts_at, boundary) == key:
total += other.ends_at - other.starts_at total += other.ends_at - other.starts_at
if total > _EIGHT_HOURS: if total > _EIGHT_HOURS:
+25
View File
@@ -149,3 +149,28 @@ def test_position_ids_filter_scopes_the_sheets(client, edition, session):
resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]}) resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]})
assert resp.status_code == 200 assert resp.status_code == 200
def test_a_night_that_passes_midnight_stays_on_its_festival_day(client, edition, session):
# Début de journée = 06:00 (premier créneau type de la fixture) : 00h–02h
# le 10 appartient encore à la soirée du 9 — une seule page, pas deux.
from io import BytesIO
from pypdf import PdfReader
position = make_position(session, edition, name="Bar de nuit")
for start, end in [
(dt_at(9, 22), dt_at(9, 23)),
(dt_at(9, 23), dt_at(10, 0)),
(dt_at(10, 0), dt_at(10, 1)),
(dt_at(10, 1), dt_at(10, 2)),
]:
make_shift(session, edition, position, start=start, end=end)
resp = client.post(_url(edition), json={})
assert resp.status_code == 200
reader = PdfReader(BytesIO(resp.content))
assert len(reader.pages) == 1
text = reader.pages[0].extract_text()
assert "jeudi 9 juillet" in text.lower()
assert "01:00" in text
+27
View File
@@ -0,0 +1,27 @@
"""Construction du `SolverInput` depuis la base (`app/solver/loader.py`)."""
from app.solver.loader import build_solver_input
from tests.factories import dt_at, make_position, make_shift
def test_predecessor_is_found_across_midnight_within_the_festival_day(edition, session):
# Début de journée = 06:00 (fixture) : 23h–00h le 9 puis 00h–01h le 10
# appartiennent à la même soirée — le second enchaîne le premier.
bar = make_position(session, edition, name="Bar")
before = make_shift(session, edition, bar, start=dt_at(9, 23), end=dt_at(10, 0))
after = make_shift(session, edition, bar, start=dt_at(10, 0), end=dt_at(10, 1))
data = build_solver_input(session=session, edition=edition)
by_id = {s.id: s for s in data.shifts}
assert by_id[str(after.id)].predecessor_shift_ids == frozenset({str(before.id)})
def test_no_predecessor_across_the_festival_day_boundary(edition, session):
# 05h–06h le 10 clôt la journée du 9 ; 06h–07h le 10 ouvre la suivante.
bar = make_position(session, edition, name="Bar")
make_shift(session, edition, bar, start=dt_at(10, 5), end=dt_at(10, 6))
morning = make_shift(session, edition, bar, start=dt_at(10, 6), end=dt_at(10, 7))
data = build_solver_input(session=session, edition=edition)
by_id = {s.id: s for s in data.shifts}
assert not by_id[str(morning.id)].predecessor_shift_ids
+1
View File
@@ -160,6 +160,7 @@
"config.grid.shiftStart": "Début du créneau", "config.grid.shiftStart": "Début du créneau",
"config.grid.shiftEnd": "Fin du créneau", "config.grid.shiftEnd": "Fin du créneau",
"config.grid.addShift": "+ AJOUTER CE 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.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.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.", "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.",
+24
View File
@@ -312,6 +312,30 @@ function festivalDayOf(iso: string, boundaryMinutes: number): string {
return clock < boundaryMinutes ? addDays(date, -1) : date; 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 * 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 * tranches de 30 min à afficher — bornée à la plage réellement utilisée ce
+34 -10
View File
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { import {
useCreatePosition, useCreatePosition,
useCreateShift, useCreateShift,
useDayParts,
useDeletePosition, useDeletePosition,
useDeleteShift, useDeleteShift,
useEdition, useEdition,
@@ -15,8 +16,13 @@ import {
import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema'; import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { Btn, HelpTip } from '@/components/ui'; import { Btn, HelpTip } from '@/components/ui';
import { categoryLabel } from '@/lib/planning'; import {
import { addDays, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time'; categoryLabel,
dayBoundaryFromDayParts,
festivalDay,
shiftDatesForFestivalDay,
} from '@/lib/planning';
import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
@@ -47,6 +53,8 @@ export function ConfigGrid() {
const editionQuery = useEdition(); const editionQuery = useEdition();
const positions = usePositions(); const positions = usePositions();
const allShifts = useShifts(); const allShifts = useShifts();
const dayParts = useDayParts();
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const createPosition = useCreatePosition(); const createPosition = useCreatePosition();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -88,10 +96,17 @@ export function ConfigGrid() {
); );
} }
const shiftsByDay = groupByDay(positionShifts.data ?? []); const shiftsByDay = groupByDay(positionShifts.data ?? [], dayBoundary);
const days = editionQuery.data // Jours de l'édition, plus tout jour de festival qui porterait un créneau
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date) // hors de ces dates (sinon invisible ici, donc impossible à supprimer).
: [...shiftsByDay.keys()]; const days = [
...new Set([
...(editionQuery.data
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
: []),
...shiftsByDay.keys(),
]),
].sort();
return ( return (
<div className="flex min-h-0 flex-1 items-stretch"> <div className="flex min-h-0 flex-1 items-stretch">
@@ -150,6 +165,7 @@ export function ConfigGrid() {
day={day} day={day}
positionId={position.id} positionId={position.id}
shifts={shiftsByDay.get(day) ?? []} shifts={shiftsByDay.get(day) ?? []}
dayBoundary={dayBoundary}
updateShift={updateShift} updateShift={updateShift}
deleteShift={deleteShift} deleteShift={deleteShift}
/> />
@@ -246,12 +262,14 @@ function DayCard({
day, day,
positionId, positionId,
shifts, shifts,
dayBoundary,
updateShift, updateShift,
deleteShift, deleteShift,
}: { }: {
day: string; day: string;
positionId: string; positionId: string;
shifts: ShiftRead[]; shifts: ShiftRead[];
dayBoundary: string;
updateShift: ReturnType<typeof useUpdateShift>; updateShift: ReturnType<typeof useUpdateShift>;
deleteShift: ReturnType<typeof useDeleteShift>; deleteShift: ReturnType<typeof useDeleteShift>;
}) { }) {
@@ -261,9 +279,8 @@ function DayCard({
const addShift = () => { const addShift = () => {
if (!start || !end) return; if (!start || !end) return;
const endDay = end <= start ? addDays(day, 1) : day;
createShift.mutate( 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(''); } }, { onSuccess: () => { setStart(''); setEnd(''); } },
); );
}; };
@@ -342,15 +359,22 @@ function DayCard({
{t('config.grid.addShift')} {t('config.grid.addShift')}
</button> </button>
</div> </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} {createShift.isError ? <ErrorNote error={createShift.error} /> : null}
</div> </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[]>(); const map = new Map<string, ShiftRead[]>();
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) { 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]); map.set(day, [...(map.get(day) ?? []), s]);
} }
return map; return map;