diff --git a/CLAUDE.md b/CLAUDE.md index 0dcdb08..b355cfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + diff --git a/backend/app/api/_shiftgen.py b/backend/app/api/_shiftgen.py index 230b824..084a4dc 100644 --- a/backend/app/api/_shiftgen.py +++ b/backend/app/api/_shiftgen.py @@ -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( diff --git a/backend/app/validation/engine.py b/backend/app/validation/engine.py index b82cc9a..e8357f0 100644 --- a/backend/app/validation/engine.py +++ b/backend/app/validation/engine.py @@ -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) - 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: diff --git a/backend/tests/test_validation_engine.py b/backend/tests/test_validation_engine.py index 644c9e8..afbea8d 100644 --- a/backend/tests/test_validation_engine.py +++ b/backend/tests/test_validation_engine.py @@ -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) diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index c0ce3ff..0ce72d3 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -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[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)); diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index 4a09e2d..0c38206 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -189,15 +189,49 @@ 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>, + 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); + const DAY = 24 * 60; + const covered = new Array(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).