Début de journée indépendant de l'ordre des créneaux types
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 13s

La « journée de festival » commence désormais au début du plus long creux
laissé par les créneaux types sur 24 h (l'heure où le dernier se termine),
et non plus au début de celui de plus petit `order` : un créneau type
d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles à
21:00, jusqu'à 03:00 le lendemain. Même règle côté backend
(validation.engine.day_boundary, réutilisée par la génération de créneaux)
et frontend (dayBoundaryFromDayParts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-05 20:48:14 +02:00
co-authored by Claude Opus 5.5
parent ee1f6ed72e
commit f671bb85bf
6 changed files with 170 additions and 25 deletions
+11 -2
View File
@@ -555,8 +555,16 @@ Sémantique figée :
bénévole (converties en instants, minuit géré) ; les minutes du créneau hors bénévole (converties en instants, minuit géré) ; les minutes du créneau hors
de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒ de cette union déclenchent l'avertissement. Aucune fenêtre déclaré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) : commence au **début du plus long creux** que
premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça). laissent les `DayPart` sur le cadran de 24 h, c.-à-d. à l'heure où le
dernier créneau type de la journée se termine (Matin 08–13, Après-midi
13–19, Soirée 19–02 ⇒ journée de 02:00 à 02:00). **Indépendant de
`DayPart.order`**, qui n'est qu'un ordre d'affichage figé à la création
et ne suit pas les heures modifiées ensuite. Avant, c'était le début du
`DayPart` de plus petit `order`, ce qui pouvait donner une journée de
21:00 à 03:00 le lendemain dans les grilles (bug corrigé une fois). Sans
creux (24 h couvertes) : début du premier par `order` ; sans `DayPart` :
minuit.
**Même « journée de festival » partout** (`app/validation/engine.py: **Même « journée de festival » partout** (`app/validation/engine.py:
day_boundary`/`festival_day`, miroir front `lib/planning.ts:festivalDay`) : 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 une soirée qui passe minuit (22h–23h, 23h–00h, 00h–01h…) reste d'un seul
@@ -883,6 +891,7 @@ Aucune dépendance locale requise hors Docker.
| 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 | | 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 |
| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider | | 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider |
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider | | 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à 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 +
+2 -12
View File
@@ -5,6 +5,7 @@ import datetime as dt
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models import DayPart, Edition, Position, Shift from app.models import DayPart, Edition, Position, Shift
from app.validation.engine import day_boundary
def _dates(edition: Edition) -> list[dt.date]: def _dates(edition: Edition) -> list[dt.date]:
@@ -12,17 +13,6 @@ 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 _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]: 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 actual_day = day + dt.timedelta(days=1) if part.start_time < boundary else day
start = dt.datetime.combine(actual_day, part.start_time) start = dt.datetime.combine(actual_day, part.start_time)
@@ -40,7 +30,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) 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(
+40 -4
View File
@@ -107,14 +107,50 @@ def _check_outside_availability(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) # # DAILY_HOURS_EXCEEDED (avertissement) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
_MINUTES_PER_DAY = 24 * 60
def day_boundary(day_parts) -> dt.time: def day_boundary(day_parts) -> dt.time:
"""Début d'une « journée de festival » : début du premier créneau type """Début d'une « journée de festival » : début du plus long creux que
(`DayPart`, par `order`), minuit à défaut — voir CLAUDE.md § 3. Accepte laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.""" laquelle le dernier de la journée se termine —, minuit à défaut — voir
CLAUDE.md § 3. Avec Matin 08 h–13 h, Après-midi 13 h–19 h, Soirée
19 h–02 h, le creux 02 h–08 h est le plus long : la journée
commence à 02 h, la soirée qui passe minuit reste sur le jour où elle a
commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
créneaux types couvrent 24 h), début du premier par `order`. Accepte
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
day_parts = list(day_parts) 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 covered = [False] * _MINUTES_PER_DAY
for part in day_parts:
start = part.start_time.hour * 60 + part.start_time.minute
end = part.end_time.hour * 60 + part.end_time.minute
length = (end - start) % _MINUTES_PER_DAY or _MINUTES_PER_DAY
for offset in range(length):
covered[(start + offset) % _MINUTES_PER_DAY] = True
if all(covered):
return min(day_parts, key=lambda part: part.order).start_time
# Parcours circulaire à partir d'une minute couverte : chaque creux est
# vu d'un seul tenant, même s'il enjambe minuit.
origin = covered.index(True)
best_start, best_length = 0, 0
run_start, run_length = 0, 0
for offset in range(1, _MINUTES_PER_DAY + 1):
minute = (origin + offset) % _MINUTES_PER_DAY
if covered[minute]:
run_length = 0
continue
if run_length == 0:
run_start = minute
run_length += 1
if run_length > best_length:
best_start, best_length = run_start, run_length
return dt.time(best_start // 60, best_start % 60)
def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date: def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
+34
View File
@@ -275,3 +275,37 @@ def test_only_warnings_is_not_blocking():
violations = validate(data) violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY] assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert has_blocking(violations) is False assert has_blocking(violations) is False
# --- Début de « journée de festival » (day_boundary) ------------------------
def test_day_boundary_starts_after_last_day_part_ends():
from app.validation.engine import day_boundary
parts = (
DayPartWindow("MATIN", dt.time(8, 0), dt.time(13, 0), 0),
DayPartWindow("APRES_MIDI", dt.time(13, 0), dt.time(19, 0), 1),
DayPartWindow("SOIREE", dt.time(19, 0), dt.time(2, 0), 2),
)
assert day_boundary(parts) == dt.time(2, 0)
def test_day_boundary_ignores_order_out_of_step_with_hours():
"""Un créneau type d'ordre 0 déplacé à 21 h ne fait plus commencer la
journée à 21 h : seul compte le creux entre la fin du dernier et le
début du premier."""
from app.validation.engine import day_boundary
parts = (
DayPartWindow("SOIREE", dt.time(21, 0), dt.time(3, 0), 0),
DayPartWindow("JOUR", dt.time(10, 0), dt.time(21, 0), 1),
)
assert day_boundary(parts) == dt.time(3, 0)
def test_day_boundary_full_coverage_falls_back_to_first_by_order():
from app.validation.engine import day_boundary
assert day_boundary(DAY_PARTS) == dt.time(6, 0)
assert day_boundary(()) == dt.time(0, 0)
+43 -1
View File
@@ -18,7 +18,7 @@ import type { AccountRead } from '@/api/schema';
import { queryKeys } from '@/api/hooks'; import { queryKeys } from '@/api/hooks';
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context'; import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
import { routes } from '@/app/router'; import { routes } from '@/app/router';
import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning'; import { buildTimeGrid, dayBoundaryFromDayParts, placeInTimeGrid } from '@/lib/planning';
import { BadgesPage } from '@/pages/BadgesPage'; import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { FormBuilderPage } from '@/pages/FormBuilderPage'; import { FormBuilderPage } from '@/pages/FormBuilderPage';
@@ -424,7 +424,49 @@ function renderPublic(path: string) {
} }
} }
// Début de journée indépendant de `order` (régression : un créneau type
// d'ordre 0 déplacé à 21:00 faisait commencer chaque journée à 21:00 dans les
// grilles, jusqu'à 03:00 le lendemain) — miroir de
// backend/tests/test_validation_engine.py (day_boundary).
function checkDayBoundary() {
const cases: Array<[Parameters<typeof dayBoundaryFromDayParts>[0], string]> = [
[[], '00:00'],
[
[
{ start_time: '08:00:00', end_time: '13:00:00', order: 0 },
{ start_time: '13:00:00', end_time: '19:00:00', order: 1 },
{ start_time: '19:00:00', end_time: '02:00:00', order: 2 },
],
'02:00',
],
[
[
{ start_time: '21:00:00', end_time: '03:00:00', order: 0 },
{ start_time: '10:00:00', end_time: '21:00:00', order: 1 },
],
'03:00',
],
[
[
{ start_time: '06:00:00', end_time: '19:00:00', order: 1 },
{ start_time: '19:00:00', end_time: '06:00:00', order: 0 },
],
'19:00',
],
];
for (const [parts, expected] of cases) {
const got = dayBoundaryFromDayParts(parts);
if (got !== expected) throw new Error(`attendu ${expected}, obtenu ${got}`);
}
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
try {
checkDayBoundary();
} catch (err) {
fail('checkDayBoundary', err);
}
await checkAuth().catch((err) => fail('checkAuth', err)); await checkAuth().catch((err) => fail('checkAuth', err));
await checkPublicForm().catch((err) => fail('checkPublicForm', err)); await checkPublicForm().catch((err) => fail('checkPublicForm', err));
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err)); await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
+40 -6
View File
@@ -189,15 +189,49 @@ export interface LatticeDay {
columns: LatticeColumn[]; columns: LatticeColumn[];
} }
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du /** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
* créneau type de plus petit `order` — même règle que * long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
* `validation.engine._day_boundary` côté backend. Sans créneau type, minuit. */ * le dernier de la journée se termine) — même règle que
* `validation.engine.day_boundary` côté backend. Indépendant de `order`, qui
* ne suit pas les heures quand on les modifie. Sans creux, début du premier
* par `order` ; sans créneau type, minuit. */
export function dayBoundaryFromDayParts( export function dayBoundaryFromDayParts(
dayParts: Array<Pick<DayPartRead, 'start_time' | 'order'>>, dayParts: Array<Pick<DayPartRead, 'start_time' | 'end_time' | 'order'>>,
): string { ): string {
if (dayParts.length === 0) return '00:00'; if (dayParts.length === 0) return '00:00';
const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min)); const DAY = 24 * 60;
return first.start_time.slice(0, 5); const covered = new Array<boolean>(DAY).fill(false);
for (const part of dayParts) {
const start = minutesOfDay(part.start_time.slice(0, 5));
const end = minutesOfDay(part.end_time.slice(0, 5));
const length = (end - start + DAY) % DAY || DAY;
for (let offset = 0; offset < length; offset++) covered[(start + offset) % DAY] = true;
}
const origin = covered.indexOf(true);
if (covered.every(Boolean)) {
const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min));
return first.start_time.slice(0, 5);
}
// Parcours circulaire depuis une minute couverte : un creux qui enjambe
// minuit est vu d'un seul tenant.
let bestStart = 0;
let bestLength = 0;
let runStart = 0;
let runLength = 0;
for (let offset = 1; offset <= DAY; offset++) {
const minute = (origin + offset) % DAY;
if (covered[minute]) {
runLength = 0;
continue;
}
if (runLength === 0) runStart = minute;
runLength += 1;
if (runLength > bestLength) {
bestStart = runStart;
bestLength = runLength;
}
}
return clockLabel(bestStart);
} }
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). /** Construit le treillis de colonnes à partir de créneaux (grille alignée).