Grille : ordonner les créneaux de nuit après minuit selon la vraie journée
Un créneau type commençant avant la frontière du jour (le plus petit `order`, ex. « Nuit » 00 h–03 h après « Soirée » 19 h–00 h) était généré sur la même date calendaire que la soirée elle-même au lieu du lendemain, et s'affichait donc avant 19 h/22 h/23 h dans la grille des besoins au lieu d'après 23 h. Corrigé dans la génération des créneaux (_shiftgen.py, même frontière que validation.engine._day_boundary) et dans le regroupement/tri de la grille (planning.ts : `dayBoundary` calculé depuis les créneaux types). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f565feb3bc
commit
4bcdafca96
@@ -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)]
|
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]:
|
def _day_boundary(day_parts: list[DayPart]) -> dt.time:
|
||||||
start = dt.datetime.combine(day, part.start_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 = (
|
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)
|
return start, dt.datetime.combine(end_day, part.end_time)
|
||||||
|
|
||||||
@@ -28,6 +40,7 @@ def generate_missing_shifts(
|
|||||||
day_parts = list(
|
day_parts = list(
|
||||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||||
)
|
)
|
||||||
|
boundary = _day_boundary(day_parts)
|
||||||
existing = {
|
existing = {
|
||||||
(s.day_part_id, s.starts_at.date())
|
(s.day_part_id, s.starts_at.date())
|
||||||
for s in session.exec(
|
for s in session.exec(
|
||||||
@@ -37,9 +50,9 @@ def generate_missing_shifts(
|
|||||||
created = 0
|
created = 0
|
||||||
for day in _dates(edition):
|
for day in _dates(edition):
|
||||||
for part in day_parts:
|
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
|
continue
|
||||||
start, end = _window(day, part)
|
|
||||||
session.add(
|
session.add(
|
||||||
Shift(
|
Shift(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Génération des créneaux d'un poste à partir des créneaux types."""
|
"""Génération des créneaux d'un poste à partir des créneaux types."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
|
||||||
def _positions_url(edition):
|
def _positions_url(edition):
|
||||||
return f"/editions/{edition.id}/positions"
|
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
|
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):
|
def test_generation_does_not_touch_staffing_already_set(client, edition):
|
||||||
position = _create_position(client, edition)
|
position = _create_position(client, edition)
|
||||||
shift = client.get(
|
shift = client.get(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ArtistRead,
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
|
DayPartRead,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
@@ -10,7 +11,7 @@ import type {
|
|||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { t } from '@/i18n';
|
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 {
|
export function categoryLabel(category: PositionCategory): string {
|
||||||
return t(`position.category.${category.toLowerCase()}`);
|
return t(`position.category.${category.toLowerCase()}`);
|
||||||
@@ -188,35 +189,76 @@ export interface LatticeDay {
|
|||||||
columns: LatticeColumn[];
|
columns: LatticeColumn[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */
|
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du
|
||||||
export function buildLattice(starts: Array<{ starts_at: string; ends_at: string }>): {
|
* 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<Pick<DayPartRead, 'start_time' | 'order'>>,
|
||||||
|
): 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[];
|
days: LatticeDay[];
|
||||||
columns: LatticeColumn[];
|
columns: LatticeColumn[];
|
||||||
} {
|
} {
|
||||||
const seen = new Map<string, LatticeColumn>();
|
const boundaryMinutes = minutesOfDay(dayBoundary);
|
||||||
|
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
|
||||||
for (const s of starts) {
|
for (const s of starts) {
|
||||||
const date = datePart(s.starts_at);
|
const date = datePart(s.starts_at);
|
||||||
const time = timePart(s.starts_at);
|
const time = timePart(s.starts_at);
|
||||||
const endTime = timePart(s.ends_at);
|
const endTime = timePart(s.ends_at);
|
||||||
const key = `${date}|${time}|${endTime}`;
|
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[] = [];
|
const days: LatticeDay[] = [];
|
||||||
for (const column of columns) {
|
for (const column of ordered) {
|
||||||
let day = days.find((d) => d.date === column.date);
|
let day = days.find((d) => d.date === column.festivalDay);
|
||||||
if (!day) {
|
if (!day) {
|
||||||
day = { date: column.date, columns: [] };
|
day = { date: column.festivalDay, columns: [] };
|
||||||
days.push(day);
|
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 };
|
return { days, columns };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function latticeFromMatrix(matrix: MatrixResponse) {
|
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
|
||||||
return buildLattice(
|
return buildLattice(
|
||||||
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
|
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
|
||||||
|
dayBoundary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export function timePart(iso: string): string {
|
|||||||
return iso.slice(11, 16);
|
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 = ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'];
|
||||||
const WEEKDAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
const WEEKDAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { useMemo, useState } from 'react';
|
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 type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
||||||
import { Chip, HelpTip } from '@/components/ui';
|
import { Chip, HelpTip } from '@/components/ui';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
|
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 { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
export function GrillePage() {
|
export function GrillePage() {
|
||||||
const matrix = useMatrix();
|
const matrix = useMatrix();
|
||||||
|
const dayParts = useDayParts();
|
||||||
const runSolver = useRunSolver();
|
const runSolver = useRunSolver();
|
||||||
const assignments = useAssignments();
|
const assignments = useAssignments();
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
@@ -19,9 +26,10 @@ export function GrillePage() {
|
|||||||
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
||||||
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||||
const lattice = useMemo(
|
const lattice = useMemo(
|
||||||
() => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }),
|
() => (matrix.data ? latticeFromMatrix(matrix.data, dayBoundary) : { days: [], columns: [] }),
|
||||||
[matrix.data],
|
[matrix.data, dayBoundary],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une
|
// Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react';
|
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 type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
||||||
import { Chip, HelpTip } from '@/components/ui';
|
import { Chip, HelpTip } from '@/components/ui';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
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 { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
@@ -16,6 +16,7 @@ export function VolunteerViewPage() {
|
|||||||
const assignments = useAssignments();
|
const assignments = useAssignments();
|
||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
|
const dayParts = useDayParts();
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
|
|
||||||
const shiftsById = useMemo(
|
const shiftsById = useMemo(
|
||||||
@@ -26,9 +27,10 @@ export function VolunteerViewPage() {
|
|||||||
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
||||||
[positions.data],
|
[positions.data],
|
||||||
);
|
);
|
||||||
|
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||||
const lattice = useMemo(
|
const lattice = useMemo(
|
||||||
() => buildLattice(shifts.data ?? []),
|
() => buildLattice(shifts.data ?? [], dayBoundary),
|
||||||
[shifts.data],
|
[shifts.data, dayBoundary],
|
||||||
);
|
);
|
||||||
const hours = useMemo(
|
const hours = useMemo(
|
||||||
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
||||||
|
|||||||
Reference in New Issue
Block a user