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
de cette union déclenchent l'avertissement. Aucune fenêtre déclaré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).
- « Journée » (cumul 8 h) : commence au **début du plus long creux** que
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:
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
@@ -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 |
| 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 |
| 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
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 app.models import DayPart, Edition, Position, Shift
from app.validation.engine import day_boundary
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)]
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)
@@ -40,7 +30,7 @@ def generate_missing_shifts(
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
boundary = _day_boundary(day_parts)
boundary = day_boundary(day_parts)
existing = {
(s.day_part_id, s.starts_at.date())
for s in session.exec(
+39 -3
View File
@@ -107,14 +107,50 @@ def _check_outside_availability(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) #
# --------------------------------------------------------------------------- #
_MINUTES_PER_DAY = 24 * 60
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`."""
"""Début d'une « journée de festival » : début du plus long creux que
laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
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)
if not day_parts:
return dt.time(0, 0)
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:
+34
View File
@@ -275,3 +275,37 @@ def test_only_warnings_is_not_blocking():
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
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 { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
import { routes } from '@/app/router';
import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning';
import { buildTimeGrid, dayBoundaryFromDayParts, placeInTimeGrid } from '@/lib/planning';
import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage';
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 checkPublicForm().catch((err) => fail('checkPublicForm', err));
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
+38 -4
View File
@@ -189,16 +189,50 @@ export interface LatticeDay {
columns: LatticeColumn[];
}
/** 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. */
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
* long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
* 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(
dayParts: Array<Pick<DayPartRead, 'start_time' | 'order'>>,
dayParts: Array<Pick<DayPartRead, 'start_time' | 'end_time' | 'order'>>,
): string {
if (dayParts.length === 0) return '00:00';
const DAY = 24 * 60;
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).
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par