diff --git a/CLAUDE.md b/CLAUDE.md index 86163bc..09a973d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -538,6 +538,15 @@ Sémantique figée : créneau entièrement à découvert. - « 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). + **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). - Repos : `< 8 h 00` strict. - 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 | | 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 | +| 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 de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/position_sheets.py b/backend/app/api/position_sheets.py index b55b031..3e96aad 100644 --- a/backend/app/api/position_sheets.py +++ b/backend/app/api/position_sheets.py @@ -17,7 +17,7 @@ from app.api.deps import get_edition from app.api.schemas import PositionSheetsRequest from app.auth.deps import require_coordinator 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 ( PositionDaySheet, SheetArtist, @@ -25,6 +25,7 @@ from app.sheets.pdf import ( SheetVolunteer, generate_position_sheets_pdf, ) +from app.validation.engine import day_boundary, festival_day router = APIRouter( prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"] @@ -69,6 +70,13 @@ def generate_position_sheets( for vs in volunteers_by_shift.values(): 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, # jamais rempli) n'apparaît pas — évite des pages « personne affecté » # 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 if undefined_need and not volunteers_by_shift.get(shift.id): 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_rows = session.exec( diff --git a/backend/app/solver/loader.py b/backend/app/solver/loader.py index 7503c1e..f19c720 100644 --- a/backend/app/solver/loader.py +++ b/backend/app/solver/loader.py @@ -10,6 +10,7 @@ from app.models import ( Artist, Assignment, Availability, + DayPart, Edition, Shift, Volunteer, @@ -18,6 +19,7 @@ from app.models import ( VolunteerPositionWish, ) from app.solver.models import SolverInput, SolverShift, SolverVolunteer +from app.validation.engine import day_boundary, festival_day 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} + # « 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 # 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 @@ -49,7 +56,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput: predecessor_ids: dict = {} by_day: dict = defaultdict(list) 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(): ids_by_end: dict = defaultdict(list) for s in day_shifts: diff --git a/backend/app/validation/engine.py b/backend/app/validation/engine.py index 51876f9..b82cc9a 100644 --- a/backend/app/validation/engine.py +++ b/backend/app/validation/engine.py @@ -107,26 +107,34 @@ def _check_outside_availability(data: ValidationInput) -> list[Violation]: # --------------------------------------------------------------------------- # # 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: return dt.time(0, 0) 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: return moment.date() return moment.date() - dt.timedelta(days=1) def _check_daily_hours(data: ValidationInput) -> list[Violation]: - boundary = _day_boundary(data.day_parts) + boundary = day_boundary(data.day_parts) 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 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 if total > _EIGHT_HOURS: diff --git a/backend/tests/test_position_sheets_api.py b/backend/tests/test_position_sheets_api.py index 28c6b42..95336f4 100644 --- a/backend/tests/test_position_sheets_api.py +++ b/backend/tests/test_position_sheets_api.py @@ -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)]}) 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 diff --git a/backend/tests/test_solver_loader.py b/backend/tests/test_solver_loader.py new file mode 100644 index 0000000..baa8e08 --- /dev/null +++ b/backend/tests/test_solver_loader.py @@ -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 diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 57c7adc..b69dc3c 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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.", diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index 42abba2..4a09e2d 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -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 diff --git a/frontend/src/pages/config/ConfigGrid.tsx b/frontend/src/pages/config/ConfigGrid.tsx index d8811db..aa029ae 100644 --- a/frontend/src/pages/config/ConfigGrid.tsx +++ b/frontend/src/pages/config/ConfigGrid.tsx @@ -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(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 (
@@ -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; deleteShift: ReturnType; }) { @@ -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')}
+ {dayBoundary !== '00:00' ? ( +
+ {t('config.grid.nightHint', { boundary: dayBoundary })} +
+ ) : null} {createShift.isError ? : null} ); } -function groupByDay(shifts: ShiftRead[]): Map { +/** 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 { const map = new Map(); 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;