diff --git a/backend/app/api/_shiftgen.py b/backend/app/api/_shiftgen.py index 077d9fc..230b824 100644 --- a/backend/app/api/_shiftgen.py +++ b/backend/app/api/_shiftgen.py @@ -12,10 +12,22 @@ def _dates(edition: Edition) -> list[dt.date]: return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)] -def _window(day: dt.date, part: DayPart) -> tuple[dt.datetime, dt.datetime]: - start = dt.datetime.combine(day, part.start_time) +def _day_boundary(day_parts: list[DayPart]) -> dt.time: + """Début du premier créneau type du jour (le plus petit `order`) — même + frontière que `validation.engine._day_boundary`. Un créneau type dont + l'heure de début tombe avant cette frontière est la suite de la nuit du + jour précédent (ex. `Nuit3` 00 h–01 h après `Soirée` 19 h–00 h) : il doit + être généré sur le jour *suivant*, pas sur `day` lui-même.""" + if not day_parts: + return dt.time(0, 0) + return min(day_parts, key=lambda part: part.order).start_time + + +def _window(day: dt.date, part: DayPart, boundary: dt.time) -> tuple[dt.datetime, dt.datetime]: + actual_day = day + dt.timedelta(days=1) if part.start_time < boundary else day + start = dt.datetime.combine(actual_day, part.start_time) end_day = ( - day if part.end_time > part.start_time else day + dt.timedelta(days=1) + actual_day if part.end_time > part.start_time else actual_day + dt.timedelta(days=1) ) return start, dt.datetime.combine(end_day, part.end_time) @@ -28,6 +40,7 @@ def generate_missing_shifts( day_parts = list( session.exec(select(DayPart).where(DayPart.edition_id == edition.id)) ) + boundary = _day_boundary(day_parts) existing = { (s.day_part_id, s.starts_at.date()) for s in session.exec( @@ -37,9 +50,9 @@ def generate_missing_shifts( created = 0 for day in _dates(edition): for part in day_parts: - if (part.id, day) in existing: + start, end = _window(day, part, boundary) + if (part.id, start.date()) in existing: continue - start, end = _window(day, part) session.add( Shift( edition_id=edition.id, diff --git a/backend/tests/test_shift_generation.py b/backend/tests/test_shift_generation.py index a6baf8e..3c123ed 100644 --- a/backend/tests/test_shift_generation.py +++ b/backend/tests/test_shift_generation.py @@ -1,5 +1,7 @@ """Génération des créneaux d'un poste à partir des créneaux types.""" +import datetime as dt + def _positions_url(edition): return f"/editions/{edition.id}/positions" @@ -58,6 +60,31 @@ def test_adding_a_day_part_then_generating_adds_it_everywhere(client, edition): assert resp.json()["created"] == 4 # 1 par jour +def test_day_part_before_the_day_boundary_rolls_to_the_next_calendar_day(client, edition): + """La frontière du jour est le début du créneau type de plus petit `order` + (ici MORNING, 06 h — voir CLAUDE.md § 3 et `validation.engine._day_boundary`). + Un créneau type qui commence avant cette heure (ex. `Nuit` 02 h–05 h, + suite de `Soirée` 19 h–00 h) est donc la fin de la nuit du jour + *précédent* : il doit être généré sur le jour suivant, pas sur `day`.""" + position = _create_position(client, edition) + client.post( + f"/editions/{edition.id}/day-parts", + json={"label": "Nuit", "start_time": "02:00", "end_time": "05:00", "order": 3}, + ) + client.post(f"{_positions_url(edition)}/{position['id']}/generate-shifts") + + shifts = client.get( + f"/editions/{edition.id}/shifts", params={"position_id": position["id"]} + ).json() + nuit_shifts = [s for s in shifts if dt.time.fromisoformat(s["starts_at"][11:19]) == dt.time(2, 0)] + assert len(nuit_shifts) == 4 + + nuit_dates = sorted(dt.date.fromisoformat(s["starts_at"][:10]) for s in nuit_shifts) + # Édition du 9 au 12/07 : la nuit du 9 (Soirée 19 h) se termine le 10 à + # 05 h, ..., la nuit du 12 se termine le 13 — jamais le 9 lui-même. + assert nuit_dates == [dt.date(2026, 7, d) for d in (10, 11, 12, 13)] + + def test_generation_does_not_touch_staffing_already_set(client, edition): position = _create_position(client, edition) shift = client.get( diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index 5499f49..0480e9d 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -2,6 +2,7 @@ import type { ArtistRead, AssignmentRead, AvailabilityRead, + DayPartRead, MatrixResponse, PositionCategory, ShiftRead, @@ -10,7 +11,7 @@ import type { } from '@/api/schema'; import { t } from '@/i18n'; -import { datePart, hoursBetween, naiveMinutes, timePart } from './time'; +import { addDays, datePart, hoursBetween, minutesOfDay, naiveMinutes, timePart } from './time'; export function categoryLabel(category: PositionCategory): string { return t(`position.category.${category.toLowerCase()}`); @@ -188,35 +189,76 @@ export interface LatticeDay { columns: LatticeColumn[]; } -/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */ -export function buildLattice(starts: Array<{ starts_at: string; ends_at: string }>): { +/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du + * créneau type de plus petit `order` — même règle que + * `validation.engine._day_boundary` côté backend. Sans créneau type, minuit. */ +export function dayBoundaryFromDayParts( + dayParts: Array>, +): string { + if (dayParts.length === 0) return '00:00'; + const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min)); + return first.start_time.slice(0, 5); +} + +/** Construit le treillis de colonnes à partir de créneaux (grille alignée). + * `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par + * « journée de festival » plutôt que par date calendaire brute : un créneau + * commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé + * minuit) est rattaché au jour précédent et trié après 23:00, pas avant + * 19:00 — voir CLAUDE.md § 3 (« journée » pour le cumul des 8 h). */ +export function buildLattice( + starts: Array<{ starts_at: string; ends_at: string }>, + dayBoundary = '00:00', +): { days: LatticeDay[]; columns: LatticeColumn[]; } { - const seen = new Map(); + const boundaryMinutes = minutesOfDay(dayBoundary); + const seen = new Map(); for (const s of starts) { const date = datePart(s.starts_at); const time = timePart(s.starts_at); const endTime = timePart(s.ends_at); const key = `${date}|${time}|${endTime}`; - if (!seen.has(key)) seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}` }); + if (!seen.has(key)) { + const minutes = minutesOfDay(time); + const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date; + const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60); + seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes }); + } } - const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key)); + const ordered = [...seen.values()].sort( + (a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes, + ); + const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({ + key, + date, + time, + endTime, + label, + })); const days: LatticeDay[] = []; - for (const column of columns) { - let day = days.find((d) => d.date === column.date); + for (const column of ordered) { + let day = days.find((d) => d.date === column.festivalDay); if (!day) { - day = { date: column.date, columns: [] }; + day = { date: column.festivalDay, columns: [] }; days.push(day); } - day.columns.push(column); + day.columns.push({ + key: column.key, + date: column.date, + time: column.time, + endTime: column.endTime, + label: column.label, + }); } return { days, columns }; } -export function latticeFromMatrix(matrix: MatrixResponse) { +export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) { return buildLattice( matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))), + dayBoundary, ); } diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index 52a6915..e7139db 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -27,6 +27,18 @@ export function timePart(iso: string): string { return iso.slice(11, 16); } +/** Minutes depuis minuit d'une heure `HH:MM` (ou `HH:MM:SS`). */ +export function minutesOfDay(hhmm: string): number { + const [h, m] = hhmm.split(':').map(Number); + return h * 60 + m; +} + +/** Décale une date `YYYY-MM-DD` de `delta` jours (peut être négatif). */ +export function addDays(datePartStr: string, delta: number): string { + const [y, m, d] = datePartStr.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d + delta)).toISOString().slice(0, 10); +} + const WEEKDAYS = ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.']; const WEEKDAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index 57a23ff..4207be1 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -1,17 +1,24 @@ import { useMemo, useState } from 'react'; -import { useAssignments, useMatrix, useRunSolver, useVolunteers } from '@/api/hooks'; +import { useAssignments, useDayParts, useMatrix, useRunSolver, useVolunteers } from '@/api/hooks'; import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema'; import { Chip, HelpTip } from '@/components/ui'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { AssignmentPanel } from '@/pages/grille/AssignmentPanel'; -import { categoryLabel, columnWidthPx, latticeFromMatrix, type LatticeColumn } from '@/lib/planning'; +import { + categoryLabel, + columnWidthPx, + dayBoundaryFromDayParts, + latticeFromMatrix, + type LatticeColumn, +} from '@/lib/planning'; import { datePart, shortDayLabel, timePart } from '@/lib/time'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; export function GrillePage() { const matrix = useMatrix(); + const dayParts = useDayParts(); const runSolver = useRunSolver(); const assignments = useAssignments(); const volunteers = useVolunteers(); @@ -19,9 +26,10 @@ export function GrillePage() { const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all'); const [selectedShiftId, setSelectedShiftId] = useState(null); + const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]); const lattice = useMemo( - () => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }), - [matrix.data], + () => (matrix.data ? latticeFromMatrix(matrix.data, dayBoundary) : { days: [], columns: [] }), + [matrix.data, dayBoundary], ); // Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une diff --git a/frontend/src/pages/VolunteerViewPage.tsx b/frontend/src/pages/VolunteerViewPage.tsx index 72e068e..7619731 100644 --- a/frontend/src/pages/VolunteerViewPage.tsx +++ b/frontend/src/pages/VolunteerViewPage.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; -import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks'; +import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema'; import { Chip, HelpTip } from '@/components/ui'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; -import { buildLattice, hoursByVolunteer, isOverloaded } from '@/lib/planning'; +import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer, isOverloaded } from '@/lib/planning'; import { datePart, shortDayLabel, timePart } from '@/lib/time'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; @@ -16,6 +16,7 @@ export function VolunteerViewPage() { const assignments = useAssignments(); const shifts = useShifts(); const positions = usePositions(); + const dayParts = useDayParts(); const [filter, setFilter] = useState('all'); const shiftsById = useMemo( @@ -26,9 +27,10 @@ export function VolunteerViewPage() { () => new Map((positions.data ?? []).map((p) => [p.id, p])), [positions.data], ); + const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]); const lattice = useMemo( - () => buildLattice(shifts.data ?? []), - [shifts.data], + () => buildLattice(shifts.data ?? [], dayBoundary), + [shifts.data, dayBoundary], ); const hours = useMemo( () => hoursByVolunteer(assignments.data ?? [], shiftsById),