Début de journée indépendant de l'ordre des créneaux types
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:
co-authored by
Claude Opus 5.5
parent
ee1f6ed72e
commit
f671bb85bf
@@ -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 +
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user