Retrait des disponibilités et des limites de temps dans la journée
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-05 21:29:58 +02:00
co-authored by Claude Opus 5.5
parent f671bb85bf
commit d722a81a87
40 changed files with 213 additions and 1845 deletions
+36 -53
View File
@@ -20,7 +20,6 @@ colonne, route et variable utilise la colonne « Code / base ».
| Poste | `Position` |
| Référent d'un poste | `Position.lead_account_id` → `Account` |
| Créneau | `Shift` |
| Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) |
| Affectation | `Assignment` |
| Artiste programmé | `Artist` (`name`, `performance_date`, `start_time`, `end_time`) |
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
@@ -39,14 +38,14 @@ colonne, route et variable utilise la colonne « Code / base ».
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
| Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` |
### Distinction critique (ne jamais confondre, ni en base ni dans l'UI)
### Pas de disponibilités déclarées
- **`Availability`** = une *déclaration* du bénévole : « le samedi, je peux
venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour.
- **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar
samedi 18 h – 22 h ».
Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts.
Les fenêtres de disponibilité (`Availability`) ont été retirées à l'étape 42
(table supprimée, migration `0022`) : l'appli ne connaît que les
**`Assignment`**, *décisions* de l'organisation (« tu tiens le bar samedi
18 h – 22 h »). Seule subsiste la case informative « je suis disponible » du
formulaire public (`Volunteer.self_declared_available`), sans aucun effet sur
les affectations.
---
@@ -63,7 +62,7 @@ les tables sauf les tables d'association.
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
`code`) ; `code` figé après création. Sert à générer les créneaux 0/0 des
postes (pas les disponibilités, qui sont des fenêtres libres).
postes.
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
l'écriture).
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
@@ -81,7 +80,7 @@ les tables sauf les tables d'association.
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
main depuis la fiche une fois les deux bénévoles inscrits),
`self_declared_available` (case à cocher **purement informative**, sans
lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
paiement SumUp plus bas).
@@ -131,14 +130,6 @@ les tables sauf les tables d'association.
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les
créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
modifié depuis).
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique
(`volunteer_id`, `date`, `start_time`, `end_time`). Éditée en bloc :
`GET`/`PUT /editions/{id}/volunteers/{vid}/availability` (lecture : COORD /
LEAD ou le bénévole lui-même ; `PUT` : COORDINATION, remplace tout).
`GET /editions/{id}/availability` liste celles de toute l'édition (COORD /
LEAD) — pour la grille des besoins.
- **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
@@ -203,8 +194,7 @@ les tables sauf les tables d'association.
paiement — même règle que `lib/fee.ts:feeMode` —, montants par type,
mécénat, `PendingFormSubmission` jamais payées), besoins face aux
bénévoles : par **journée de festival** (§ 3), heures × personnes
demandées (min/idéal) vs heures de disponibilité déclarées vs heures
affectées ; par poste, places min/idéales, places **pourvues plafonnées
demandées (min/idéal) vs heures affectées ; par poste, places min/idéales, places **pourvues plafonnées
créneau par créneau à l'effectif idéal** (`filled` — un sureffectif ne
compense jamais un trou ailleurs ; `assigned` garde le total brut) et
nombre de vœux. Graphiques en SVG maison (`components/charts.tsx`,
@@ -386,7 +376,7 @@ Conventions techniques :
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
`artist`, `account`. La suppression complète d'un bénévole efface
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
donc ses `Assignment`, vœux (artistes, postes, compagnons —
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
`Account`.
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
@@ -411,7 +401,6 @@ Matrice appliquée par les endpoints :
| `assignments` écriture | ✔ | ses postes | — |
| `assignments` lecture | ✔ | ✔ | les siennes |
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — |
| `position-sheets` (génération) | ✔ | — | — |
@@ -536,26 +525,28 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation**,
un cas passant + un cas déclenchant par règle.
Retourne une liste de violations typées `BLOCKING` ou `WARNING`. Chaque
Retourne une liste de violations typées `BLOCKING` ou `WARNING` (aucune règle
`WARNING` aujourd'hui : le type reste prévu par le contrat de l'API). Chaque
violation est un **code** + des paramètres structurés, jamais une phrase (la
traduction se fait via `locales/fr.json`).
| Code | Sévérité | Règle |
|---------------------------|------------|-------------------------------------------------------------|
| `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. |
| `OUTSIDE_AVAILABILITY` | `WARNING` | Le créneau n'est pas entièrement couvert par les fenêtres de disponibilité déclarées ce jour-là (params : `date`, `uncovered_minutes`). |
| `DAILY_HOURS_EXCEEDED` | `WARNING` | > 8 h 00 d'affectations cumulées sur une même journée. |
| `INSUFFICIENT_REST` | `WARNING` | < 8 h 00 entre la fin d'un créneau et le début du suivant. |
**Aucune limite de temps dans la journée** (étape 42) : plus de cumul
journalier maximal (`DAILY_HOURS_EXCEEDED`, ex-8 h), plus de repos minimal
entre deux créneaux (`INSUFFICIENT_REST`, ex-8 h), plus de contrôle de
disponibilité (`OUTSIDE_AVAILABILITY`). Ni la validation ni le solveur n'en
tiennent compte.
Sémantique figée :
- Toutes les affectations du bénévole comptent pour chevauchement / cumul /
repos (une affectation = une décision, toujours active).
- `OUTSIDE_AVAILABILITY` : on prend l'**union** des fenêtres `Availability` du
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) : commence au **début du plus long creux** que
- Toutes les affectations du bénévole comptent pour le chevauchement (une
affectation = une décision, toujours active).
- « Journée de festival » (plus utilisée par la validation elle-même, mais par
les grilles, les feuilles de poste, les statistiques et le chaînage du
solveur) : 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
@@ -574,8 +565,6 @@ Sémantique figée :
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
matin du même jour calendaire.
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
- Repos : `< 8 h 00` strict.
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
- Une violation `BLOCKING` empêche l'enregistrement, sans dérogation.
- Une violation `WARNING` est retournée à l'appelant mais n'empêche rien
@@ -607,13 +596,9 @@ affectation n'est jamais persistée par cet endpoint.
moment où la coordination confirme une affectation :
- un créneau encore sous son effectif **minimal** prime largement sur tout
le reste (le solveur doit presque toujours proposer quelqu'un pour un
trou urgent, quitte à ce que ce soit un pis-aller signalé ensuite par un
avertissement) ;
trou urgent, quitte à ce que ce soit un pis-aller) ;
- bonus si le poste fait partie des vœux du bénévole
(`VolunteerPositionWish`) ;
- malus proportionnel aux minutes hors des fenêtres de disponibilité
déclarées (réutilise `validation.engine.uncovered_minutes`), et si le
repos avec une affectation déjà décidée serait `< 8 h 00` ;
- malus si le bénévole a déjà (affectation décidée) le créneau
immédiatement précédent, le même jour — **n'importe quel poste**
(`SolverShift.predecessor_shift_ids`) : limite les enchaînements de
@@ -626,11 +611,9 @@ affectation n'est jamais persistée par cet endpoint.
au créneau — plusieurs compagnons présents cumulent le bonus ;
- léger malus pour les bénévoles déjà bien chargés (répartition de la
charge).
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la
validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un
bénévole qui dépasserait 8 h sur la journée — seule la validation le
signale, à la confirmation.
- Simplification assumée : repos, chaînage et compagnons ne sont vérifiés
- **Aucune limite de temps** : ni cumul journalier, ni repos minimal, ni
disponibilité (§ 3).
- Simplification assumée : chaînage et compagnons ne sont vérifiés
que contre les affectations **déjà décidées**, pas entre deux suggestions
du même calcul (deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement) — la validation finale à la confirmation
@@ -638,9 +621,8 @@ affectation n'est jamais persistée par cet endpoint.
**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts`
(`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de
poste, disponibilité y compris conflit d'artiste, compagnons souhaités déjà
présents sur le créneau, charge déjà affectée ; pas de cumul 8 h / repos /
chaînage, déjà couverts par les badges de validation). C'est ce
poste, conflit d'artiste, compagnons souhaités déjà présents sur le créneau,
charge déjà affectée ; pas de chaînage). C'est ce
qui classe la colonne des candidats dans le panneau d'affectation dès
l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a
effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
@@ -693,7 +675,7 @@ adapté à son tableau bénévole × créneau.
1. Modèle de données complet + migrations Alembic.
2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone,
commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux
commentaire), en deux
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
validé. Rapport ligne par ligne. Détection des doublons : upsert sur
(`email`, `last_name` + `first_name`) — l'email seul ne suffit pas,
@@ -706,7 +688,7 @@ adapté à son tableau bénévole × créneau.
5. Endpoint matrice d'une édition :
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
quelques trous d'effectif, 2–3 avertissements actifs, programmation
quelques trous d'effectif, programmation
(artistes les jours de festival) et vœux des bénévoles (postes, artistes)
pour que le solveur ait matière à arbitrer.
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
@@ -746,7 +728,7 @@ l'étape 21.)*
│ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # availability, positions, shifts,
│ │ │ # positions, shifts,
│ │ │ # assignments, solver, matrix, notifications,
│ │ │ # badges, position_sheets, forms, stats
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
@@ -806,8 +788,8 @@ Aucune dépendance locale requise hors Docker.
- `Edition.retention_days` : durée de rétention paramétrable.
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
- **Dates et heures :** stockées naïves, interprétées en heure locale
Europe/Paris (heure murale). Les règles « 8 h », « journée », « demi-journée »
sont toutes en heure locale.
Europe/Paris (heure murale). La « journée de festival » est en heure
locale.
- **Validation TDD :** tests avant implémentation, un cas passant + un cas
déclenchant par règle.
- **Git :** un commit atomique par étape validée, message en français.
@@ -892,6 +874,7 @@ Aucune dépendance locale requise hors Docker.
| 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 |
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
-132
View File
@@ -1,132 +0,0 @@
"""Disponibilités déclarées par les bénévoles.
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
Ces fenêtres sont prises en compte par le moteur de validation (règle
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import AvailabilityRead, AvailabilitySet
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Account, Availability, Edition, Role, Volunteer
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
return list(
session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer_id)
.order_by(Availability.date, Availability.start_time)
)
)
@router.get(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def list_availability(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Availability]:
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _windows(session, volunteer_id)
@router.put(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def replace_availability(
volunteer_id: uuid.UUID,
payload: AvailabilitySet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Availability]:
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
for w in payload.windows:
if not edition.start_date <= w.date <= edition.end_date:
raise HTTPException(
status_code=422,
detail={
"code": "availability.date_outside_edition",
"params": {"date": w.date.isoformat()},
},
)
if w.start_time == w.end_time:
raise HTTPException(
status_code=422,
detail={"code": "availability.empty_window"},
)
session.execute(
delete(Availability).where(Availability.volunteer_id == volunteer_id)
)
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
seen: set[tuple] = set()
for w in payload.windows:
key = (w.date, w.start_time, w.end_time)
if key in seen:
continue
seen.add(key)
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer_id,
date=w.date,
start_time=w.start_time,
end_time=w.end_time,
)
)
session.commit()
return _windows(session, volunteer_id)
@router.get("/availability", response_model=list[AvailabilityRead])
def list_edition_availability(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[Availability]:
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
return list(
session.exec(
select(Availability)
.where(Availability.edition_id == edition.id)
.order_by(
Availability.volunteer_id,
Availability.date,
Availability.start_time,
)
)
)
+1 -32
View File
@@ -254,33 +254,6 @@ class DayPartUpdate(ApiModel):
order: int | None = None
# --------------------------------------------------------------------------- #
# Availability (disponibilités déclarées par le bénévole) #
# --------------------------------------------------------------------------- #
class AvailabilityWindowIn(ApiModel):
date: dt.date
start_time: dt.time
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
end_time: dt.time
class AvailabilitySet(ApiModel):
"""Remplace l'intégralité des disponibilités d'un bénévole."""
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
class AvailabilityRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
volunteer_id: uuid.UUID
date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
# --------------------------------------------------------------------------- #
# Assignment #
# --------------------------------------------------------------------------- #
@@ -714,15 +687,12 @@ class StatsArtistWish(ApiModel):
class StatsNeedDay(ApiModel):
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
× personnes demandées (effectif minimal / idéal), heures de disponibilité
déclarées, heures déjà affectées."""
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
date: dt.date
shifts: int
min_hours: float
ideal_hours: float
available_hours: float
available_volunteers: int
assigned_hours: float
@@ -745,7 +715,6 @@ class StatsNeedPosition(ApiModel):
class StatsNeeds(ApiModel):
by_day: list[StatsNeedDay]
by_position: list[StatsNeedPosition]
volunteers_with_availability: int
volunteers_with_assignment: int
+1 -15
View File
@@ -30,7 +30,6 @@ from app.models import (
Account,
Artist,
Assignment,
Availability,
DayPart,
Edition,
FeePaymentStatus,
@@ -126,9 +125,6 @@ def get_stats(
assignments = list(
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
)
availabilities = list(
session.exec(select(Availability).where(Availability.edition_id == edition.id))
)
artists = list(
session.exec(
select(Artist)
@@ -203,8 +199,7 @@ def get_stats(
shifts_by_id = {s.id: s for s in shifts}
day_rows: dict[dt.date, dict] = defaultdict(
lambda: {
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "available_hours": 0.0,
"available_volunteers": set(), "assigned_hours": 0.0,
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "assigned_hours": 0.0,
}
)
for s in shifts:
@@ -219,12 +214,6 @@ def get_stats(
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
s.starts_at, s.ends_at
)
for w in availabilities:
starts_at = dt.datetime.combine(w.date, w.start_time)
end_date = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
row = day_rows[festival_day(starts_at, boundary)]
row["available_hours"] += _hours(starts_at, dt.datetime.combine(end_date, w.end_time))
row["available_volunteers"].add(w.volunteer_id)
wishers = Counter(w.position_id for w in position_wishes)
assigned_by_shift = Counter(a.shift_id for a in assignments)
@@ -239,8 +228,6 @@ def get_stats(
shifts=row["shifts"],
min_hours=_round(row["min_hours"]),
ideal_hours=_round(row["ideal_hours"]),
available_hours=_round(row["available_hours"]),
available_volunteers=len(row["available_volunteers"]),
assigned_hours=_round(row["assigned_hours"]),
)
for day, row in sorted(day_rows.items())
@@ -260,7 +247,6 @@ def get_stats(
)
for p in positions
],
volunteers_with_availability=len({w.volunteer_id for w in availabilities}),
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
)
-5
View File
@@ -43,9 +43,6 @@
"import.row.updated_existing": "Bénévole existant mis à jour.",
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
"notification.no_recipients": "Aucun bénévole ciblé.",
"email.magic_link.subject": "Ton lien de connexion",
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
@@ -58,8 +55,6 @@
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.image_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.",
-2
View File
@@ -7,7 +7,6 @@ from app.api.accounts import router as account_router
from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.availability import router as availability_router
from app.api.badges import router as badge_router
from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_router
@@ -67,7 +66,6 @@ app.include_router(account_router)
app.include_router(import_router)
app.include_router(artist_router)
app.include_router(volunteer_router)
app.include_router(availability_router)
app.include_router(position_router)
app.include_router(shift_router)
app.include_router(assignment_router)
@@ -0,0 +1,55 @@
"""Suppression des disponibilités déclarées
Revision ID: 0022_drop_availability
Revises: 0021_payment_option_free_amount
Create Date: 2026-10-05
Les fenêtres de disponibilité (`availability`) disparaissent, avec la règle
`OUTSIDE_AVAILABILITY` qui les exploitait. Les données existantes sont
perdues : le `downgrade` recrée la table vide. La case « je suis
disponible » du formulaire public (`volunteer.self_declared_available`)
n'est pas concernée.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0022_drop_availability"
down_revision: str | None = "0021_payment_option_free_amount"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
op.drop_table("availability")
def downgrade() -> None:
op.create_table(
"availability",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("start_time", sa.Time(), nullable=False),
sa.Column("end_time", sa.Time(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
op.create_index(
op.f("ix_availability_edition_id"), "availability", ["edition_id"], unique=False
)
op.create_index(
op.f("ix_availability_volunteer_id"), "availability", ["volunteer_id"], unique=False
)
-2
View File
@@ -9,7 +9,6 @@ from app.models.domain import (
Account,
Artist,
Assignment,
Availability,
BadgeField,
BadgeTemplate,
DayPart,
@@ -41,7 +40,6 @@ __all__ = [
"Artist",
"Assignment",
"AssignmentSource",
"Availability",
"BadgeAlign",
"BadgeField",
"BadgeFieldType",
+2 -38
View File
@@ -184,8 +184,7 @@ class Volunteer(TimestampMixin, table=True):
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
companion_wish_note: str | None = None
# Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de lien avec `Availability`, pas de
# workflow de paiement).
# logique n'y est attachée (pas de workflow de paiement).
self_declared_available: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
@@ -237,9 +236,6 @@ class Volunteer(TimestampMixin, table=True):
link_model=VolunteerPositionWish,
sa_relationship_kwargs=_PASSIVE,
)
availabilities: list["Availability"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
assignments: list["Assignment"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
@@ -352,43 +348,11 @@ class Shift(TimestampMixin, table=True):
)
class Availability(TimestampMixin, table=True):
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
fenêtres par jour possibles.
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
(décision de l'organisation).
"""
__table_args__ = (
UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
)
date: dt.date
start_time: dt.time
end_time: dt.time
volunteer: Volunteer = Relationship(back_populates="availabilities")
class Assignment(TimestampMixin, table=True):
"""Décision de l'organisation : « tu tiens tel créneau ».
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
affectation, c'est supprimer la ligne. À ne pas confondre avec Availability
(déclaration du bénévole).
affectation, c'est supprimer la ligne.
"""
__table_args__ = (
+3 -163
View File
@@ -9,9 +9,8 @@ Déterministe (graine fixe). Aucune donnée nominative réelle.
import datetime as dt
import random
from collections import Counter
from dataclasses import dataclass, field
from dataclasses import dataclass
from sqlalchemy import delete
from sqlmodel import Session, select
from app.auth.passwords import hash_password
@@ -22,7 +21,6 @@ from app.models import (
Artist,
Assignment,
AssignmentSource,
Availability,
DayPart,
Edition,
FeePaymentStatus,
@@ -35,14 +33,7 @@ from app.models import (
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.validation import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation import Severity, validate
from app.validation.loader import build_validation_input
SEED = 20260709
@@ -227,7 +218,6 @@ class Summary:
companion_wishes: int = 0
fee_paid_classique: int = 0
fee_paid_mecenat: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0
def __str__(self) -> str:
@@ -245,10 +235,7 @@ class Summary:
f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Cotisation payée (classique) : {self.fee_paid_classique}",
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
f" Avertissements actifs : {sum(self.warnings.values())}",
]
for code, count in sorted(self.warnings.items()):
lines.append(f" {code} : {count}")
lines.append(f" Violations bloquantes : {self.blocking}")
lines.append("")
lines.append(
@@ -309,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
]
session.add_all(day_parts)
session.flush()
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
coordination = Account(
edition_id=edition.id,
@@ -428,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
)
)
n_companion_wishes += 1
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
availabilities: dict[object, list[AvailabilityWindow]] = {}
for volunteer in volunteers:
windows: list[AvailabilityWindow] = []
for day in dates:
if rng.random() < 0.8:
start, end = _FULL_DAY
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
)
windows.append(AvailabilityWindow(day, start, end))
availabilities[volunteer.id] = windows
shifts: list[Shift] = []
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
for day in dates:
@@ -470,28 +433,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
# l'affectation générale pour rester libres.
reserved = volunteers[:3]
reserved_ids = {v.id for v in reserved}
assignable = [v for v in volunteers if v.id not in reserved_ids]
def _clean(volunteer: Volunteer, shift: Shift) -> bool:
data = ValidationInput(
candidate_shift=ShiftInfo(
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in assigned[volunteer.id]
),
availabilities=tuple(availabilities[volunteer.id]),
day_parts=dp_windows,
)
return not validate(data)
n_assignments = 0
order = shifts[:]
rng.shuffle(order)
@@ -500,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
target = max(0, shift.min_staff - 1)
else:
target = rng.randint(shift.min_staff, shift.ideal_staff)
pool = assignable[:]
pool = volunteers[:]
rng.shuffle(pool)
placed = 0
for volunteer in pool:
@@ -508,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
break
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
continue
if not _clean(volunteer, shift):
continue
session.add(
Assignment(
edition_id=edition.id,
@@ -522,9 +461,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
placed += 1
n_assignments += 1
n_assignments += _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
)
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
session.flush()
@@ -558,74 +494,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
return summary
def _clear_day(session, volunteer, day, availabilities) -> None:
session.execute(
delete(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == day)
)
availabilities[volunteer.id] = [
w for w in availabilities[volunteer.id] if w.date != day
]
def _declare_window(
session, edition, volunteer, day, start, end, availabilities
) -> None:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
)
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
def _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
) -> int:
"""Quelques affectations qui déclenchent des avertissements (et rien
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
bénévoles tenus à l'écart de l'affectation générale.
"""
a, b, tired = reserved
added = 0
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
# déclaré aucune disponibilité.
plain = sorted(shifts, key=lambda s: s.starts_at)
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
_add_active(session, edition, volunteer, shift, assigned)
added += 1
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
# disponibilité.
pair = _find_rest_pair(shifts)
if pair is not None:
first, second = pair
for shift in (first, second):
_clear_day(session, tired, shift.starts_at.date(), availabilities)
_declare_window(
session,
edition,
tired,
shift.starts_at.date(),
dt.time(6, 0),
dt.time(4, 0),
availabilities,
)
_add_active(session, edition, tired, first, assigned)
_add_active(session, edition, tired, second, assigned)
added += 2
return added
def _add_demo_payments(
session: Session, volunteers: list[Volunteer], rng: random.Random
) -> tuple[int, int]:
@@ -664,32 +532,6 @@ def _add_demo_payments(
return len(classique), len(mecenat)
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain):
for second in plain[i + 1 :]:
gap = second.starts_at - first.ends_at
if (
first.ends_at <= second.starts_at
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
and first.starts_at.date() != second.starts_at.date()
):
return first, second
return None
def _add_active(session, edition, volunteer, shift, assigned) -> None:
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
source=AssignmentSource.MANUAL,
)
)
assigned[volunteer.id].append(shift)
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
session.flush()
assignments = session.exec(
@@ -709,8 +551,6 @@ def _verify(session: Session, edition: Edition, summary: Summary) -> None:
):
if violation.severity is Severity.BLOCKING:
summary.blocking += 1
else:
summary.warnings[violation.code] += 1
def main() -> None:
+11 -43
View File
@@ -6,17 +6,13 @@ coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
contente de faire remonter ces candidats en tête de liste).
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu
d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte —
comme le moteur de validation, qui reste seul juge au moment où la
coordination confirme une affectation.
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
reste vérifiée par la validation, à la confirmation.
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (chaînage entre
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
par journée ni de temps de repos : ni le solveur ni la validation n'en
tiennent compte.
"""
import datetime as dt
from ortools.sat.python import cp_model
from app.solver.models import (
@@ -26,22 +22,16 @@ from app.solver.models import (
SolverSolution,
SolverVolunteer,
)
from app.validation.engine import uncovered_minutes
from app.validation.models import ShiftInfo
_EIGHT_HOURS_MIN = 8 * 60
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
# L'écart à un effectif minimal non atteint prime sur tout le reste : le
# solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage —
# la validation reste seule juge à la confirmation). Au-delà du minimum, un
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
# à ce que le candidat soit mal assorti. Au-delà du minimum, un candidat mal
# assorti peut légitimement ne pas être suggéré du tout.
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
_WISH_BONUS = 30
_AVAIL_PENALTY_PER_HALF_HOUR = 5
_REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
_ARTIST_CONFLICT_PENALTY = 80
@@ -58,22 +48,6 @@ def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
candidate = shift.shift_info
for other in volunteer.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
else:
continue
if gap < dt.timedelta(minutes=_EIGHT_HOURS_MIN):
return True
return False
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
"""Le bénévole a déjà (affectation décidée) l'un des créneaux qui
précèdent immédiatement celui-ci, le même jour — n'importe quel poste :
@@ -88,20 +62,14 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
Purement additif (aucune interaction entre deux affectations proposées),
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
compagnons ne sont vérifiés que contre les affectations déjà décidées,
pas entre deux suggestions du même calcul : la validation finale (à la
confirmation) reste seule juge en cas de cas limite entre deux
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement, faute d'interaction entre variables).
ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
sont vérifiés que contre les affectations déjà décidées, pas entre deux
suggestions du même calcul (deux compagnons suggérés ensemble ne se
renforcent pas mutuellement, faute d'interaction entre variables).
"""
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
if shift.position_id in volunteer.wished_position_ids:
score += _WISH_BONUS
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY
if _chained_from_predecessor(volunteer, shift):
score -= _CHAIN_PENALTY
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
+1 -11
View File
@@ -9,7 +9,6 @@ from sqlmodel import Session, select
from app.models import (
Artist,
Assignment,
Availability,
DayPart,
Edition,
Shift,
@@ -20,7 +19,7 @@ from app.models import (
)
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.engine import day_boundary, festival_day
from app.validation.models import AvailabilityWindow, ShiftInfo
from app.validation.models import ShiftInfo
def _artist_window(artist: Artist) -> ShiftInfo:
@@ -93,14 +92,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
if assigned_count.get(s.id, 0) < s.ideal_staff
)
availabilities: dict = defaultdict(list)
for a in session.exec(
select(Availability).where(Availability.edition_id == edition.id)
):
availabilities[a.volunteer_id].append(
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
)
wishes: dict = defaultdict(set)
for w in session.exec(
select(VolunteerPositionWish).join(
@@ -141,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
solver_volunteers = tuple(
SolverVolunteer(
id=str(v.id),
availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
+3 -4
View File
@@ -2,13 +2,13 @@
Le solveur ne connaît que ces structures simples (jamais les modèles
SQLModel), comme le moteur de validation dont il réutilise les briques
(`app.validation.models`, `app.validation.engine.uncovered_minutes`).
(`app.validation.models`).
"""
import datetime as dt
from dataclasses import dataclass, field
from app.validation.models import AvailabilityWindow, ShiftInfo
from app.validation.models import ShiftInfo
@dataclass(frozen=True)
@@ -39,7 +39,6 @@ class SolverShift:
@dataclass(frozen=True)
class SolverVolunteer:
id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset()
# Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à
# sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le
@@ -50,7 +49,7 @@ class SolverVolunteer:
# lui ferait manquer le passage.
wished_artist_windows: tuple[ShiftInfo, ...] = ()
# Créneaux déjà occupés (affectations existantes, décisions figées) :
# comptent pour chevauchement, repos, chaînage.
# comptent pour chevauchement et chaînage.
existing_shifts: tuple[ShiftInfo, ...] = ()
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
existing_minutes: int = 0
-2
View File
@@ -5,7 +5,6 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
from app.validation.engine import has_blocking, validate
from app.validation.models import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -15,7 +14,6 @@ from app.validation.models import (
)
__all__ = [
"AvailabilityWindow",
"DayPartWindow",
"Severity",
"ShiftInfo",
+2 -107
View File
@@ -7,7 +7,6 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
import datetime as dt
from app.validation.models import (
AvailabilityWindow,
Severity,
ShiftInfo,
ValidationInput,
@@ -15,15 +14,10 @@ from app.validation.models import (
ViolationCode,
)
_EIGHT_HOURS = dt.timedelta(hours=8)
def validate(data: ValidationInput) -> list[Violation]:
violations: list[Violation] = []
violations.extend(_check_overlap(data))
violations.extend(_check_outside_availability(data))
violations.extend(_check_daily_hours(data))
violations.extend(_check_insufficient_rest(data))
return violations
@@ -55,57 +49,8 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY (avertissement) #
# --------------------------------------------------------------------------- #
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
start = dt.datetime.combine(w.date, w.start_time)
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
return start, dt.datetime.combine(end_day, w.end_time)
def uncovered_minutes(
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
) -> int:
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
"""
cursor = shift.starts_at
intervals = sorted(
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
key=lambda iv: iv[0],
)
gap = dt.timedelta()
for start, end in intervals:
if end <= cursor:
continue
if start > shift.ends_at:
break
if start > cursor:
gap += min(start, shift.ends_at) - cursor
cursor = max(cursor, end)
if cursor >= shift.ends_at:
return int(gap.total_seconds() // 60)
if cursor < shift.ends_at:
gap += shift.ends_at - cursor
return int(gap.total_seconds() // 60)
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
if missing == 0:
return []
return [
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=data.candidate_shift.starts_at.date().isoformat(),
uncovered_minutes=missing,
)
]
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) #
# « Journée de festival » — plus utilisée par la validation elle-même, mais #
# partagée par le solveur, les feuilles de poste et les statistiques. #
# --------------------------------------------------------------------------- #
_MINUTES_PER_DAY = 24 * 60
@@ -161,53 +106,3 @@ def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
if moment.time() >= boundary:
return moment.date()
return moment.date() - dt.timedelta(days=1)
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
boundary = day_boundary(data.day_parts)
candidate = data.candidate_shift
key = festival_day(candidate.starts_at, boundary)
total = candidate.ends_at - candidate.starts_at
for other in data.existing_shifts:
if festival_day(other.starts_at, boundary) == key:
total += other.ends_at - other.starts_at
if total > _EIGHT_HOURS:
return [
Violation.of(
ViolationCode.DAILY_HOURS_EXCEEDED,
date=key.isoformat(),
total_minutes=int(total.total_seconds() // 60),
)
]
return []
# --------------------------------------------------------------------------- #
# INSUFFICIENT_REST (avertissement) #
# --------------------------------------------------------------------------- #
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
candidate = data.candidate_shift
out: list[Violation] = []
for other in data.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
direction = "BEFORE"
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
direction = "AFTER"
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
continue
if gap < _EIGHT_HOURS:
out.append(
Violation.of(
ViolationCode.INSUFFICIENT_REST,
other_shift_id=other.id,
direction=direction,
gap_minutes=int(gap.total_seconds() // 60),
)
)
return out
-24
View File
@@ -8,15 +8,11 @@ from sqlmodel import Session, select
from app.models import (
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
)
from app.validation.models import (
AvailabilityWindow,
DayPartWindow,
ShiftInfo,
ValidationInput,
)
@@ -30,10 +26,6 @@ def build_validation_input(
shift: Shift,
exclude_assignment_id: uuid.UUID | None = None,
) -> ValidationInput:
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Toute affectation existante du bénévole compte (une affectation = une
# décision, toujours active).
stmt = (
@@ -49,17 +41,6 @@ def build_validation_input(
for s in session.exec(stmt)
)
declarations = tuple(
AvailabilityWindow(
date=a.date, start_time=a.start_time, end_time=a.end_time
)
for a in session.exec(
select(Availability).where(
Availability.volunteer_id == volunteer.id
)
)
)
return ValidationInput(
candidate_shift=ShiftInfo(
id=str(shift.id),
@@ -67,9 +48,4 @@ def build_validation_input(
ends_at=shift.ends_at,
),
existing_shifts=existing_shifts,
availabilities=declarations,
day_parts=tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
for dp in day_parts
),
)
+2 -22
View File
@@ -18,16 +18,10 @@ class Severity(StrEnum):
class ViolationCode(StrEnum):
SHIFT_OVERLAP = "SHIFT_OVERLAP"
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
INSUFFICIENT_REST = "INSUFFICIENT_REST"
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
}
@@ -53,8 +47,8 @@ class ShiftInfo:
@dataclass(frozen=True)
class DayPartWindow:
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
cumul des 8 h (début du premier créneau type)."""
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée de
festival » (`engine.day_boundary`)."""
code: str
start_time: dt.time
@@ -63,18 +57,6 @@ class DayPartWindow:
order: int
@dataclass(frozen=True)
class AvailabilityWindow:
"""« Le <date>, je peux de <start_time> à <end_time>. »
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
"""
date: dt.date
start_time: dt.time
end_time: dt.time
@dataclass(frozen=True)
class ValidationInput:
"""Tout ce qu'il faut pour valider une affectation candidate.
@@ -85,5 +67,3 @@ class ValidationInput:
candidate_shift: ShiftInfo
existing_shifts: tuple[ShiftInfo, ...] = ()
availabilities: tuple[AvailabilityWindow, ...] = ()
day_parts: tuple[DayPartWindow, ...] = ()
-30
View File
@@ -4,7 +4,6 @@ import datetime as dt
from app.models import (
Artist,
Availability,
Position,
PositionCategory,
Shift,
@@ -74,34 +73,5 @@ def make_volunteer(
return volunteer
def make_availability(
session, edition, volunteer, *, date, start_time, end_time
):
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
row = Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=date,
start_time=start_time,
end_time=end_time,
)
session.add(row)
session.commit()
return row
def declare_available_all_day(session, edition, volunteer, date):
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
make_availability(
session,
edition,
volunteer,
date=date,
start_time=dt.time(0, 0),
end_time=dt.time(23, 59),
)
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
return dt.datetime(2026, 7, day, hour, minute)
+20 -24
View File
@@ -1,7 +1,6 @@
import datetime as dt
from tests.factories import (
declare_available_all_day,
dt_at,
make_position,
make_shift,
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
return f"/editions/{edition.id}/assignments{suffix}"
def _setup(session, edition, *, available=True):
def _setup(session, edition):
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
if available:
declare_available_all_day(
session, edition, volunteer, dt.date(2026, 7, 10)
)
return volunteer, shift
@@ -39,16 +34,21 @@ def test_create_assignment_clean(client, edition, session, day_parts):
assert body["assignment"]["locked"] is False
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
# Bénévole non déclaré disponible sur le créneau.
volunteer, shift = _setup(session, edition, available=False)
resp = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
# Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
# 18h–04h (14 h d'affilée) passe sans avertissement.
volunteer, shift = _setup(session, edition)
position = make_position(session, edition, name="Bar")
following = make_shift(
session, edition, position, start=dt_at(10, 18), end=dt_at(11, 4)
)
assert resp.status_code == 201
codes = {w["code"] for w in resp.json()["warnings"]}
assert "OUTSIDE_AVAILABILITY" in codes
for s in (shift, following):
resp = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(s.id)},
)
assert resp.status_code == 201
assert resp.json()["warnings"] == []
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
@@ -110,7 +110,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, available=False)
volunteer, shift = _setup(session, edition)
resp = client.post(
_url(edition, "/validate"),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -118,14 +118,12 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
assert resp.status_code == 200
body = resp.json()
assert body["blocking"] is False
assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"])
assert body["violations"] == []
assert client.get(_url(edition)).json() == []
def test_patch_locked_keeps_assignment_and_returns_warnings(
client, edition, session, day_parts
):
volunteer, shift = _setup(session, edition, available=False)
def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition)
aid = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -134,9 +132,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
assert resp.status_code == 200
assert resp.json()["assignment"]["locked"] is True
assert any(
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
)
assert resp.json()["warnings"] == []
def test_delete_assignment(client, edition, session, day_parts):
-114
View File
@@ -1,114 +0,0 @@
from app.models import Role
from tests.factories import make_volunteer
def _url(edition, volunteer, suffix=""):
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
def test_put_then_get_returns_windows_sorted(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
]
},
)
assert resp.status_code == 200
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
assert got == [
("2026-07-10", "08:00:00", "13:00:00"),
("2026-07-10", "19:00:00", "02:00:00"),
("2026-07-11", "14:00:00", "19:00:00"),
]
assert client.get(_url(edition, v)).json() == resp.json()
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
]
},
)
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
]
},
)
body = resp.json()
assert len(body) == 1
assert body[0]["date"] == "2026-07-11"
def test_put_rejects_date_outside_edition(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
def test_put_rejects_empty_window(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "availability.empty_window"
def test_edition_wide_listing(client, edition, session):
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
for v in (v1, v2):
client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
]
},
)
body = client.get(f"/editions/{edition.id}/availability").json()
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
v = make_volunteer(session, edition, email="v@e.fr")
lead = make_account(Role.LEAD, email="lead@e.fr")
api = as_account(lead)
assert api.get(_url(edition, v)).status_code == 200
resp = api.put(_url(edition, v), json={"windows": []})
assert resp.status_code == 403
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
api = as_account(account)
assert api.get(_url(edition, me)).status_code == 200
assert api.get(_url(edition, other)).status_code == 403
-8
View File
@@ -14,7 +14,6 @@ from app.models import (
from tests.factories import (
dt_at,
make_artist,
make_availability,
make_position,
make_shift,
make_volunteer,
@@ -141,10 +140,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
)
alex = make_volunteer(session, edition, first_name="Alex")
sam = make_volunteer(session, edition, first_name="Sam")
make_availability(
session, edition, alex,
date=dt.date(2026, 7, 9), start_time=dt.time(20, 0), end_time=dt.time(2, 0),
)
session.add_all([
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
@@ -159,8 +154,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
"shifts": 2,
"min_hours": 3.0,
"ideal_hours": 5.0,
"available_hours": 6.0,
"available_volunteers": 1,
"assigned_hours": 2.0,
}
]
@@ -176,7 +169,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
"wishes": 2,
}
]
assert needs["volunteers_with_availability"] == 1
assert needs["volunteers_with_assignment"] == 1
+1 -11
View File
@@ -6,11 +6,10 @@ from openpyxl import load_workbook
from pypdf import PdfReader
from sqlmodel import select
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
from tests.factories import (
dt_at,
make_artist,
make_availability,
make_position,
make_shift,
make_volunteer,
@@ -208,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
make_availability(
session,
edition,
volunteer,
date=dt.date(2026, 7, 10),
start_time=dt.time(8, 0),
end_time=dt.time(13, 0),
)
session.add(
Assignment(
edition_id=edition.id,
@@ -230,7 +221,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
assert session.exec(select(Volunteer)).all() == []
assert session.exec(select(Assignment)).all() == []
assert session.exec(select(Availability)).all() == []
# Le créneau subsiste.
from app.models import Shift
-27
View File
@@ -5,7 +5,6 @@ from sqlalchemy import text
from app.models import (
Assignment,
AssignmentSource,
Availability,
Edition,
Position,
PositionCategory,
@@ -25,7 +24,6 @@ EXPECTED_TABLES = {
"magic_link_token",
"position",
"shift",
"availability",
"assignment",
"badge_template",
"badge_field",
@@ -101,31 +99,6 @@ def test_create_core_graph(session):
assert volunteer.assignments[0].id == assignment.id
def test_availability_window_round_trip(session):
edition = _edition(session)
volunteer = Volunteer(
edition_id=edition.id, first_name="Sam", last_name="Nuit"
)
session.add(volunteer)
session.commit()
session.refresh(volunteer)
avail = Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=dt.date(2026, 7, 9),
start_time=dt.time(19, 0),
end_time=dt.time(2, 0), # passe minuit
)
session.add(avail)
session.commit()
session.expire_all()
reloaded = session.get(Availability, avail.id)
assert reloaded.start_time == dt.time(19, 0)
assert reloaded.end_time == dt.time(2, 0)
def test_volunteer_email_can_be_shared(session):
# Un couple, une famille : même boîte mail, deux bénévoles distincts —
# ce n'est plus une contrainte d'unicité en base (voir migration 0009).
-4
View File
@@ -13,9 +13,6 @@ def test_build_creates_a_realistic_dataset(session):
assert summary.assignments > 100
assert summary.understaffed_shifts >= 3 # « quelques trous d'effectif »
assert summary.blocking == 0 # aucune violation bloquante
# Au moins deux règles d'avertissement représentées, quelques alertes.
assert len(summary.warnings) >= 2
assert sum(summary.warnings.values()) >= 3
# Programmation et vœux des bénévoles : le mode démo doit les illustrer.
assert summary.artists > 0
assert summary.position_wishes > 0
@@ -58,7 +55,6 @@ def test_build_is_deterministic_and_idempotent(session):
)
assert second.assignments == first.assignments
assert second.understaffed_shifts == first.understaffed_shifts
assert dict(second.warnings) == dict(first.warnings)
assert second.artists == first.artists
assert second.position_wishes == first.position_wishes
assert second.artist_wishes == first.artist_wishes
+32 -68
View File
@@ -4,7 +4,7 @@ import datetime as dt
from app.solver.engine import solve
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, ShiftInfo
from app.validation.models import ShiftInfo
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
@@ -39,7 +39,6 @@ def _shift(
def _volunteer(
vid: str,
*,
availability=(),
wished_position_ids=frozenset(),
wished_companion_ids=frozenset(),
wished_artist_windows=(),
@@ -48,7 +47,6 @@ def _volunteer(
) -> SolverVolunteer:
return SolverVolunteer(
id=vid,
availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids),
wished_companion_ids=frozenset(wished_companion_ids),
wished_artist_windows=tuple(wished_artist_windows),
@@ -57,10 +55,6 @@ def _volunteer(
)
def _full_day(day: int) -> AvailabilityWindow:
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
for s in solution.suggestions:
if s.shift_id == shift_id:
@@ -75,7 +69,7 @@ def test_no_variables_returns_empty_solution():
def test_fully_staffed_shift_gets_no_suggestion():
shift = _shift(ideal_staff=1, already_assigned=1)
volunteer = _volunteer("v1", availability=[_full_day(10)])
volunteer = _volunteer("v1")
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert solution.suggestions == ()
@@ -84,105 +78,77 @@ def test_picks_the_only_free_volunteer_over_a_busy_one():
shift = _shift()
busy = _volunteer(
"busy",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
free = _volunteer("free", availability=[_full_day(10)])
free = _volunteer("free")
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
assert _by_shift(solution, "s1") == ("free",)
def test_does_not_exceed_ideal_staff_cap():
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
a = _volunteer("a", availability=[_full_day(10)])
b = _volunteer("b", availability=[_full_day(10)])
a = _volunteer("a")
b = _volunteer("b")
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
assert len(_by_shift(solution, "s1")) == 1
def test_prefers_volunteer_with_matching_position_wish():
shift = _shift(position_id="regie")
plain = _volunteer("plain", availability=[_full_day(10)])
plain = _volunteer("plain")
wisher = _volunteer(
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
"wisher", wished_position_ids={"regie"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
assert _by_shift(solution, "s1") == ("wisher",)
def test_prefers_fully_available_volunteer_over_partially_available():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full",)
def test_ranks_both_candidates_when_two_slots_are_open():
shift = _shift(ideal_staff=2)
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full", "partial")
def test_never_double_books_a_volunteer_across_two_new_suggestions():
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
# deux : le solveur ne le propose qu'une fois.
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
only = _volunteer("only", availability=[_full_day(10)])
only = _volunteer("only")
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
picked_a = _by_shift(solution, "a")
picked_b = _by_shift(solution, "b")
assert len(picked_a) + len(picked_b) == 1
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
def test_short_rest_is_not_penalized():
# Plus de repos minimal (8 h) : 4 h après un créneau déjà décidé, le
# bénévole reste à égalité avec un autre.
shift = _shift(ideal_staff=2, start=_dt(10, 14), end=_dt(10, 18))
tired = _volunteer(
"tired",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
assert set(_by_shift(solution, "s1")) == {"tired", "fresh"}
def test_daily_cumulative_hours_no_longer_penalized():
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
# validation, à la confirmation, le signale désormais
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
# Aucun plafond de cumul journalier : 7 h + 2 h = 9 h sur la journée ne
# pénalise pas le bénévole.
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
long_day = _volunteer(
"long_day",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
def test_prefers_volunteer_without_the_immediately_preceding_shift():
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage
# doit différencier les deux bénévoles.
# Seul le chaînage doit différencier les deux bénévoles.
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer(
"chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
@@ -196,10 +162,9 @@ def test_chain_penalty_applies_across_different_positions():
)
chained = _volunteer(
"chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
@@ -210,7 +175,6 @@ def test_still_suggests_the_only_candidate_despite_chaining():
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer(
"chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
@@ -227,9 +191,9 @@ def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
)
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
almost_chained = _volunteer(
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]
"almost_chained", existing_shifts=[unrelated]
)
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
truly_fresh = _volunteer("truly_fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
@@ -241,9 +205,9 @@ def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
"fan", wished_artist_windows=[passage]
)
indiff = _volunteer("indiff", availability=[_full_day(10)])
indiff = _volunteer("indiff")
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
assert _by_shift(solution, "s1") == ("indiff",)
@@ -254,7 +218,7 @@ def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
"fan", wished_artist_windows=[passage]
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",)
@@ -266,7 +230,7 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21))
fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
"fan", wished_artist_windows=[passage]
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",)
@@ -277,9 +241,9 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
# préféré à « stranger », qui n'a pas ce lien.
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
buddy = _volunteer(
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"}
"buddy", wished_companion_ids={"copain"}
)
stranger = _volunteer("stranger", availability=[_full_day(10)])
stranger = _volunteer("stranger")
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
assert _by_shift(solution, "s1") == ("buddy",)
@@ -290,10 +254,10 @@ def test_companion_bonus_scales_with_number_of_matches():
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
)
two_friends = _volunteer(
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"}
"two_friends", wished_companion_ids={"a", "b"}
)
one_friend = _volunteer(
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"}
"one_friend", wished_companion_ids={"a"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
@@ -302,7 +266,7 @@ def test_companion_bonus_scales_with_number_of_matches():
def test_wished_companion_absent_from_the_shift_has_no_effect():
shift = _shift()
volunteer = _volunteer(
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"}
"v", wished_companion_ids={"not-here"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert _by_shift(solution, "s1") == ("v",)
+15 -215
View File
@@ -4,7 +4,6 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
import datetime as dt
from app.validation import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -33,14 +32,6 @@ def _codes(violations) -> list[ViolationCode]:
return [v.code for v in violations]
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
`end_h <= start_h` ⇒ se termine le lendemain."""
return AvailabilityWindow(
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
)
# --------------------------------------------------------------------------- #
# SHIFT_OVERLAP #
# --------------------------------------------------------------------------- #
@@ -48,233 +39,42 @@ def test_overlap_pass_when_shifts_are_adjacent():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
availabilities=(_win(10, 8, 23),),
)
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
assert validate(data) == []
def test_overlap_triggers_and_is_blocking():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
availabilities=(_win(10, 8, 23),),
)
violations = validate(data)
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP]
assert len(overlap) == 1
assert overlap[0].severity is Severity.BLOCKING
assert overlap[0].params["other_shift_id"] == "e"
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
assert violations[0].severity is Severity.BLOCKING
assert violations[0].params["other_shift_id"] == "e"
assert has_blocking(violations) is True
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY #
# Plus de limite de temps dans la journée #
# --------------------------------------------------------------------------- #
def test_availability_pass_when_shift_within_declared_window():
def test_long_day_and_short_rest_raise_nothing():
"""Ni cumul journalier (> 8 h) ni repos minimal (< 8 h) : seuls les
chevauchements sont signalés."""
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
availabilities=(_win(10, 13, 19),),
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 22)),
existing_shifts=(
_shift("matin", _dt(10, 6), _dt(10, 14)),
_shift("nuit", _dt(10, 23), _dt(11, 3)),
),
)
assert validate(data) == []
def test_availability_pass_when_window_exactly_matches_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
availabilities=(_win(10, 14, 18),),
)
assert validate(data) == []
def test_availability_triggers_when_nothing_declared():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].severity is Severity.WARNING
assert violations[0].params["date"] == "2026-07-10"
assert violations[0].params["uncovered_minutes"] == 240
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
availabilities=(_win(10, 13, 19),),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["uncovered_minutes"] == 60
def test_availability_union_of_several_windows_covers_the_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
)
assert validate(data) == []
def test_availability_midnight_crossing_window_covers_night_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
)
assert validate(data) == []
def test_availability_gap_between_two_windows_is_flagged():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["uncovered_minutes"] == 120
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED #
# --------------------------------------------------------------------------- #
def test_daily_hours_pass_at_exactly_eight():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
day_parts=DAY_PARTS,
availabilities=(_win(10, 8, 18),),
)
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
def test_daily_hours_triggers_above_eight():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 20, 30)),), # +30 min
day_parts=DAY_PARTS,
)
violations = [
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
]
assert len(violations) == 1
assert violations[0].params["total_minutes"] == 510
assert violations[0].params["date"] == "2026-07-10"
def test_daily_hours_day_starts_at_first_day_part_boundary():
# Frontière de journée = 06:00 (début de MORNING). Un créneau de nuit qui
# commence à 03:00 le 11 appartient à la journée du 10.
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 15)), # 7 h, journée 10
existing_shifts=(_shift("e", _dt(11, 3), _dt(11, 5)),), # 2 h, journée 10
day_parts=DAY_PARTS,
)
violations = [
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
]
assert len(violations) == 1
assert violations[0].params["date"] == "2026-07-10"
def test_daily_hours_ignores_other_days():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h journée 10
existing_shifts=(_shift("e", _dt(9, 9), _dt(9, 17)),), # 8 h journée 9
day_parts=DAY_PARTS,
)
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
# --------------------------------------------------------------------------- #
# INSUFFICIENT_REST #
# --------------------------------------------------------------------------- #
def test_rest_pass_with_more_than_eight_hours_gap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
availabilities=(_win(10, 8, 12),),
)
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
def test_rest_pass_at_exactly_eight_hours():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 4), _dt(10, 12)),
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 22)),), # écart 8 h pile
)
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
def test_rest_triggers_for_shift_before():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
existing_shifts=(_shift("e", _dt(10, 2), _dt(10, 6)),), # écart 4 h
)
violations = [
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
]
assert len(violations) == 1
assert violations[0].params["direction"] == "BEFORE"
assert violations[0].params["gap_minutes"] == 240
def test_rest_triggers_for_shift_after():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 20)),), # écart 4 h
)
violations = [
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
]
assert len(violations) == 1
assert violations[0].params["direction"] == "AFTER"
def test_back_to_back_shifts_are_rest_violation_not_overlap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
availabilities=(_win(10, 8, 23),),
)
codes = _codes(validate(data))
assert ViolationCode.SHIFT_OVERLAP not in codes
assert ViolationCode.INSUFFICIENT_REST in codes
# --------------------------------------------------------------------------- #
# Combinaisons #
# --------------------------------------------------------------------------- #
def test_clean_assignment_has_no_violation():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
day_parts=DAY_PARTS,
availabilities=(_win(10, 13, 19),),
)
data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)))
assert validate(data) == []
def test_multiple_rules_fire_together():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
)
codes = set(_codes(validate(data)))
assert {
ViolationCode.SHIFT_OVERLAP,
ViolationCode.OUTSIDE_AVAILABILITY,
} <= codes
assert has_blocking(validate(data)) is True
def test_only_warnings_is_not_blocking():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert has_blocking(violations) is False
assert has_blocking([]) is False
# --- Début de « journée de festival » (day_boundary) ------------------------
-13
View File
@@ -1,11 +1,7 @@
import datetime as dt
from app.models import Assignment
from app.validation import AvailabilityWindow
from app.validation.loader import build_validation_input
from tests.factories import (
dt_at,
make_availability,
make_position,
make_shift,
make_volunteer,
@@ -21,11 +17,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
make_availability(
session, edition, volunteer,
date=dt.date(2026, 7, 10),
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
)
session.add(
Assignment(
edition_id=edition.id, volunteer_id=volunteer.id,
@@ -41,10 +32,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
assert data.candidate_shift.id == str(candidate.id)
# Toute affectation existante compte, sauf le créneau candidat lui-même.
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
assert len(data.day_parts) == 3
assert data.availabilities == (
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
)
def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
-33
View File
@@ -16,7 +16,6 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateResult,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
@@ -135,23 +134,6 @@ export function useShifts(positionId?: string) {
});
}
/** Fenêtres de disponibilité d'un bénévole. */
export function useVolunteerAvailability(volunteerId: string | null | undefined) {
return useQuery({
queryKey: ['availability', edition, volunteerId ?? null],
queryFn: () => api.availability.list(edition, volunteerId as string),
enabled: Boolean(volunteerId),
});
}
/** Toutes les disponibilités de l'édition (grille des besoins). */
export function useEditionAvailability() {
return useQuery({
queryKey: ['availability', edition, 'all'],
queryFn: () => api.availability.listEdition(edition),
});
}
export function useAssignments(
params?: { volunteerId?: string; shiftId?: string },
options?: { enabled?: boolean },
@@ -247,7 +229,6 @@ function useInvalidate() {
const client = useQueryClient();
return {
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }),
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
@@ -408,20 +389,6 @@ export function useExportMecenatReceipts() {
});
}
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
export function useReplaceAvailability() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ volunteerId, body }: { volunteerId: string; body: AvailabilitySet }) =>
api.availability.replace(edition, volunteerId, body),
onSuccess: () => {
invalidate.availability();
// Les avertissements « hors disponibilité » de la grille en dépendent.
invalidate.planning();
},
});
}
/** Import de bénévoles — phase 1 : analyse du fichier. */
export function useAnalyzeImport() {
return useMutation({
-11
View File
@@ -6,7 +6,6 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
@@ -172,16 +171,6 @@ export function createHttpApi(baseUrl: string): PlanningApi {
remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
},
availability: {
list: (editionId, volunteerId) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
replace: (editionId, volunteerId, body: AvailabilitySet) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listEdition: (editionId) => request(`${base(editionId)}/availability`),
},
imports: {
analyze: (editionId, file: File) => {
const form = new FormData();
+1 -38
View File
@@ -4,7 +4,6 @@ import type {
AccountRead,
ArtistRead,
AssignmentRead,
AvailabilityRead,
BadgeFieldRead,
DayPartRead,
EditionRead,
@@ -67,7 +66,6 @@ export interface MockState {
artists: ArtistRead[];
volunteers: VolunteerRead[];
/** volunteerId -> fenêtres de disponibilité déclarées. */
availability: Map<string, AvailabilityRead[]>;
positions: PositionRead[];
shifts: ShiftRead[];
assignments: AssignmentRead[];
@@ -266,37 +264,10 @@ export function buildMockState(): MockState {
);
const volunteers: VolunteerRead[] = [];
const availability = new Map<string, AvailabilityRead[]>();
let availSeq = 0;
const window = (
volunteerId: string,
date: string,
start_time: string,
end_time: string,
): AvailabilityRead => ({
id: `av${availSeq++}`,
edition_id,
volunteer_id: volunteerId,
date,
start_time,
end_time,
created_at: NOW,
updated_at: NOW,
});
for (let i = 0; i < 64; i += 1) {
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const id = `v${i}`;
// Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain.
const windows: AvailabilityRead[] = [];
for (const dateStr of DAY_DATES) {
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
if (rnd() < (edge ? 0.55 : 0.8)) {
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
}
}
availability.set(id, windows);
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
const wished_position_ids =
@@ -354,10 +325,6 @@ export function buildMockState(): MockState {
const me = volunteers[0];
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
availability.set(
me.id,
DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')),
);
NEVER_ASSIGNED.forEach((i) => {
volunteers[i].comment = 'Inscrit, pas encore affecté';
});
@@ -390,10 +357,7 @@ export function buildMockState(): MockState {
.sort((a, b) => score(b) - score(a));
function score(v: VolunteerRead): number {
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr)
? 100
: 0;
return available - (totalHours.get(v.id) ?? 0) * 3;
return -(totalHours.get(v.id) ?? 0) * 3;
}
for (const v of pool.slice(0, target)) {
@@ -510,7 +474,6 @@ export function buildMockState(): MockState {
assignments,
accounts,
passwords,
availability,
badgeTemplate: {
frontImage: null,
frontImageType: null,
+3 -156
View File
@@ -1,4 +1,4 @@
import { dayBoundaryFromDayParts, festivalDay, uncoveredMinutes } from '@/lib/planning';
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time';
import type {
@@ -8,8 +8,6 @@ import type {
ArtistUpdate,
AssignmentRead,
AssignmentValidateResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
@@ -54,7 +52,6 @@ import { buildMockState, type MockPendingSubmission, type MockState } from './da
// comportement (chargements, invalidations) proche du back-end réel.
const LATENCY_MS = 120;
const EIGHT_HOURS_MIN = 8 * 60;
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
let state: MockState = buildMockState();
@@ -199,14 +196,6 @@ const slugCode = (label: string): string =>
/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */
const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time);
const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] =>
[...ws].sort(
(a, b) =>
a.volunteer_id.localeCompare(b.volunteer_id) ||
a.date.localeCompare(b.date) ||
a.start_time.localeCompare(b.start_time),
);
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
function resolveWishes<T extends { id: string }>(
pool: T[],
@@ -260,7 +249,6 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
};
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
pending.promotedVolunteerId = volunteer.id;
pending.status = 'PAID';
@@ -471,63 +459,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
}
}
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
// fenêtres de disponibilité déclarées.
const gapMin = uncoveredMinutes(
state.availability.get(volunteerId) ?? [],
candidate.starts_at,
candidate.ends_at,
);
if (gapMin > 0) {
violations.push({
code: 'OUTSIDE_AVAILABILITY',
severity: 'WARNING',
params: {
date: candidate.starts_at.slice(0, 10),
uncovered_minutes: gapMin,
},
});
}
// DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau.
const dayKey = candidate.starts_at.slice(0, 10);
let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at));
for (const other of existing) {
if (other.starts_at.slice(0, 10) === dayKey) {
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
}
}
if (totalMin > EIGHT_HOURS_MIN) {
violations.push({
code: 'DAILY_HOURS_EXCEEDED',
severity: 'WARNING',
params: { date: dayKey, total_minutes: totalMin },
});
}
// INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict).
for (const other of existing) {
if (overlaps(candidate, other)) continue;
let gap: number;
let direction: 'BEFORE' | 'AFTER';
if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) {
gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at);
direction = 'BEFORE';
} else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at);
direction = 'AFTER';
} else {
continue;
}
if (gap < EIGHT_HOURS_MIN) {
violations.push({
code: 'INSUFFICIENT_REST',
severity: 'WARNING',
params: { other_shift_id: other.id, direction, gap_minutes: gap },
});
}
}
return violations;
}
@@ -567,10 +498,10 @@ function buildStats(): EditionStats {
const sum = (list: VolunteerRead[]) => round(list.reduce((t, v) => t + (v.fee_amount ?? 0), 0));
const boundary = dayBoundaryFromDayParts(state.dayParts);
type Row = { shifts: number; min: number; ideal: number; avail: number; who: Set<string>; assigned: number };
type Row = { shifts: number; min: number; ideal: number; assigned: number };
const rows = new Map<string, Row>();
const row = (d: string) => {
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, avail: 0, who: new Set(), assigned: 0 });
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, assigned: 0 });
return rows.get(d) as Row;
};
const shiftsById = new Map(state.shifts.map((s) => [s.id, s]));
@@ -585,17 +516,6 @@ function buildStats(): EditionStats {
const s = shiftsById.get(a.shift_id);
if (s) row(festivalDay(s.starts_at, boundary)).assigned += hoursBetween(s.starts_at, s.ends_at);
}
const withAvailability = new Set<string>();
for (const [vid, windows] of state.availability) {
for (const w of windows) {
const start = `${w.date}T${w.start_time.slice(0, 5)}:00`;
const endDate = w.end_time > w.start_time ? w.date : addDay(w.date);
const r = row(festivalDay(start, boundary));
r.avail += hoursBetween(start, `${endDate}T${w.end_time.slice(0, 5)}:00`);
r.who.add(vid);
withAvailability.add(vid);
}
}
const assignedByShift = new Map<string, number>();
for (const a of state.assignments) assignedByShift.set(a.shift_id, (assignedByShift.get(a.shift_id) ?? 0) + 1);
@@ -650,8 +570,6 @@ function buildStats(): EditionStats {
shifts: r.shifts,
min_hours: round(r.min),
ideal_hours: round(r.ideal),
available_hours: round(r.avail),
available_volunteers: r.who.size,
assigned_hours: round(r.assigned),
})),
by_position: [...state.positions]
@@ -669,7 +587,6 @@ function buildStats(): EditionStats {
wishes: vols.filter((v) => v.wished_position_ids.includes(p.id)).length,
};
}),
volunteers_with_availability: withAvailability.size,
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
},
};
@@ -714,8 +631,6 @@ function buildMatrix(): MatrixResponse {
const SOLVER_URGENT_BASE = 1000;
const SOLVER_NORMAL_BASE = 20;
const SOLVER_WISH_BONUS = 30;
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const SOLVER_REST_PENALTY = 60;
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
// Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
@@ -829,24 +744,6 @@ function solverScore(
}
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
const windows = state.availability.get(volunteer.id) ?? [];
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
for (const other of existing) {
if (overlaps(shift, other)) continue;
let gap: number | null = null;
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
}
if (gap !== null && gap < EIGHT_HOURS_MIN) {
score -= SOLVER_REST_PENALTY;
break;
}
}
const existingMinutes = existing.reduce(
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
0,
@@ -1112,7 +1009,6 @@ export function createMockApi(): PlanningApi {
updated_at: now,
};
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay(volunteer);
},
update: (_edition, id, body) => {
@@ -1154,7 +1050,6 @@ export function createMockApi(): PlanningApi {
remove: (_edition, id) => {
state.volunteers = state.volunteers.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
state.availability.delete(id);
state.photos.delete(id);
// RGPD : effacement complet, y compris le compte de connexion éventuel
// et les vœux de compagnon dont il était la cible.
@@ -1256,50 +1151,6 @@ export function createMockApi(): PlanningApi {
},
},
availability: {
list: (_edition, volunteerId) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
return delay(sortWindows(state.availability.get(volunteerId) ?? []));
},
listEdition: () =>
delay(
sortWindows(
[...state.availability.values()].flat().filter((w) =>
state.volunteers.some((v) => v.id === w.volunteer_id),
),
),
),
replace: (_edition, volunteerId, body: AvailabilitySet) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
const seen = new Set<string>();
const windows: AvailabilityRead[] = [];
for (const w of body.windows) {
if (w.date < state.edition.start_date || w.date > state.edition.end_date) {
throw new ApiError(422, 'availability.date_outside_edition');
}
const start = hms(w.start_time);
const end = hms(w.end_time);
if (start === end) throw new ApiError(422, 'availability.empty_window');
const key = `${w.date}|${start}|${end}`;
if (seen.has(key)) continue;
seen.add(key);
const now = touch();
windows.push({
id: `av${state.seq++}`,
edition_id: state.edition.id,
volunteer_id: volunteerId,
date: w.date,
start_time: start,
end_time: end,
created_at: now,
updated_at: now,
});
}
state.availability.set(volunteerId, windows);
return delay(sortWindows(windows));
},
},
imports: {
analyze: async (_edition, file: File) => {
const { headers, rows } = await readImportTable(file);
@@ -1480,9 +1331,6 @@ export function createMockApi(): PlanningApi {
if (!mapping.dry_run) {
state.volunteers = working;
for (const v of working) {
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
}
}
return delay(report);
},
@@ -1935,7 +1783,6 @@ export function createMockApi(): PlanningApi {
};
if (photo) state.photos.set(volunteer.id, photo);
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({
volunteer_id: volunteer.id,
pending_token: null,
+1 -34
View File
@@ -18,11 +18,7 @@ export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
export type AuthMethod = 'password' | 'magic_link';
export type ViolationCode =
| 'SHIFT_OVERLAP'
| 'OUTSIDE_AVAILABILITY'
| 'DAILY_HOURS_EXCEEDED'
| 'INSUFFICIENT_REST';
export type ViolationCode = 'SHIFT_OVERLAP';
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
@@ -177,32 +173,6 @@ export interface VolunteerPlanningEntry {
referent_phone: string | null;
}
// --------------------------------------------------------------------------- //
// Availability (disponibilités déclarées par le bénévole) //
// --------------------------------------------------------------------------- //
export interface AvailabilityWindowInput {
/** Date ISO (YYYY-MM-DD). */
date: string;
/** Heure « HH:MM » ou « HH:MM:SS ». end <= start ⇒ se termine le lendemain. */
start_time: string;
end_time: string;
}
export interface AvailabilitySet {
windows: AvailabilityWindowInput[];
}
export interface AvailabilityRead {
id: string;
edition_id: string;
volunteer_id: string;
date: string;
start_time: string;
end_time: string;
created_at: string;
updated_at: string;
}
// --------------------------------------------------------------------------- //
// Artist (programmation) //
// --------------------------------------------------------------------------- //
@@ -766,8 +736,6 @@ export interface StatsNeedDay {
shifts: number;
min_hours: number;
ideal_hours: number;
available_hours: number;
available_volunteers: number;
assigned_hours: number;
}
@@ -801,7 +769,6 @@ export interface EditionStats {
needs: {
by_day: StatsNeedDay[];
by_position: StatsNeedPosition[];
volunteers_with_availability: number;
volunteers_with_assignment: number;
};
}
-14
View File
@@ -11,8 +11,6 @@ import type {
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
@@ -106,18 +104,6 @@ export interface PlanningApi {
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>;
};
availability: {
/** Fenêtres déclarées par un bénévole. */
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
/** Remplace l'intégralité des fenêtres d'un bénévole (COORDINATION). */
replace(
editionId: string,
volunteerId: string,
body: AvailabilitySet,
): Promise<AvailabilityRead[]>;
/** Toutes les fenêtres de l'édition (grille des besoins). */
listEdition(editionId: string): Promise<AvailabilityRead[]>;
};
imports: {
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
analyze(editionId: string, file: File): Promise<ImportAnalyzeResponse>;
+6 -29
View File
@@ -70,12 +70,6 @@
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
"validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment",
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({gap} non couvertes).",
"validation.OUTSIDE_AVAILABILITY.rule": "Le créneau n'est pas entièrement couvert par les disponibilités déclarées.",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})",
"validation.DAILY_HOURS_EXCEEDED.rule": "Plus de 8 h 00 d'affectations cumulées sur une même journée.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux ({rest})",
"validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.",
"grille.filter.day": "JOUR",
"grille.filter.position": "POSTE",
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
@@ -107,18 +101,16 @@
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
"volunteers.filter.show": "AFFICHER",
"volunteers.filter.all": "TOUS",
"volunteers.filter.overloaded": "SURCHARGÉS",
"volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.assigned": "AFFECTATION",
"volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H",
"volunteers.name": "NOM",
"volunteers.cumulative": "CUMUL",
"volunteers.badge.overloaded": "▲ SURCHARGE",
"volunteers.badge.unassigned": "○ OUBLIÉ",
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.",
"volunteers.footnote": "Oublié = aucune affectation.",
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
"mobileVol.greeting": "SALUT {name}",
"mobileVol.slots": "{count} CRÉNEAUX",
@@ -174,7 +166,7 @@
"config.vol.phone": "TÉLÉPHONE",
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
"config.vol.delete": "SUPPRIMER",
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (affectations, vœux) ?",
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade.",
"config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.",
"config.mail.templateTitle": "MODÈLE DE MESSAGE",
@@ -209,9 +201,7 @@
"config.rules.slotAdd": "AJOUTER",
"config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.",
"config.rules.thresholdsTitle": "MOTEUR DE VALIDATION",
"config.rules.maxDaily": "Maximum d'heures par journée",
"config.rules.minRest": "Repos minimum entre deux créneaux",
"config.rules.thresholdsNote": "Seuils figés côté back-end (CLAUDE.md §3) : non paramétrables. Le double emploi au même créneau est toujours bloquant.",
"config.rules.thresholdsNote": "Seule règle appliquée : le double emploi au même moment est toujours bloquant. Aucune limite d'heures par journée ni de temps de repos.",
"config.rules.rulesTitle": "RÈGLES APPLIQUÉES",
"config.rules.rolesTitle2": "RÔLES ET ACCÈS",
"config.rules.roleCoordinator": "Coordination",
@@ -325,17 +315,6 @@
"config.vol.fee.short.failed": "échouée",
"config.vol.fee.short.expired": "expirée",
"config.vol.wishesCount": "{count} vœu(x)",
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
"config.vol.avail.title": "Disponibilités",
"config.vol.avail.hint": "Plages horaires où le bénévole peut venir. Une heure de fin ≤ début signifie que la plage se termine le lendemain.",
"config.vol.avail.none": "Aucune disponibilité déclarée",
"config.vol.avail.add": "Ajouter une plage",
"config.vol.avail.save": "Enregistrer les disponibilités",
"grille.panel.coverage.full": "Disponible",
"grille.panel.coverage.partial": "Partiellement dispo",
"grille.panel.coverage.none": "Dispo non déclarée",
"availability.date_outside_edition": "Cette date est hors des dates de l'édition.",
"availability.empty_window": "Une plage doit avoir un début et une fin différents.",
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
"grille.solver.running": "CALCUL EN COURS…",
"grille.solver.result": "{count} suggestion(s)",
@@ -392,7 +371,7 @@
"stats.kpi.registered": "Bénévoles inscrits · +{count} en 7 jours",
"stats.kpi.collected": "Cotisations encaissées en ligne",
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
"stats.kpi.withAvailability": "Ont déclaré des disponibilités · sur {total}",
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
"stats.showTable": "Voir le tableau",
"stats.unit.hours": "h",
@@ -424,13 +403,12 @@
"stats.payments.mecenat": "Dont mécénat : {count} don(s), {amount} (reçus fiscaux).",
"stats.payments.unfinished": "{count} formulaire(s) rempli(s) sans paiement abouti : ces personnes ne sont pas comptées parmi les bénévoles (paiement refusé, abandonné ou expiré).",
"stats.needs.title": "Besoins et bénévoles",
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures de disponibilité déclarées et aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
"stats.needs.empty": "Aucun créneau défini pour l'instant.",
"stats.needs.byDay": "Par jour (heures × personnes)",
"stats.needs.byPosition": "Par poste (places = personnes × créneaux)",
"stats.needs.min": "Besoin minimal",
"stats.needs.ideal": "Besoin idéal",
"stats.needs.available": "Disponibilités déclarées",
"stats.needs.assigned": "Déjà affecté",
"stats.needs.filledWithExtra": "{filled} / {ideal} (+{extra} en sureffectif)",
"stats.needs.wishesHint": "Pourvues : places occupées, plafonnées à l'effectif idéal de chaque créneau (« +2 » = bénévoles en sureffectif, non comptés). Vœux / place : nombre de bénévoles qui ont demandé le poste, rapporté aux places idéales. En dessous de 1, le poste aura besoin de bénévoles qui ne l'ont pas choisi.",
@@ -443,7 +421,6 @@
"stats.col.paymentType": "Type de paiement",
"stats.col.amount": "Montant",
"stats.col.shifts": "Créneaux",
"stats.col.availableVolunteers": "Bénévoles dispo.",
"stats.col.position": "Poste",
"stats.col.minSlots": "Places min.",
"stats.col.idealSlots": "Places idéales",
@@ -459,7 +436,7 @@
"form.field.companionWish": "Collègue apprécié",
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
"form.field.available": "Case « je suis disponible »",
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
"form.field.available.help": "Purement informatif — simple case à cocher, sans effet sur les affectations.",
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
"config.vol.feeStatus.pending": "Cotisation : paiement en attente",
"config.vol.feeStatus.paid": "Cotisation payée ✓",
+4 -117
View File
@@ -1,7 +1,6 @@
import type {
ArtistRead,
AssignmentRead,
AvailabilityRead,
DayPartRead,
MatrixResponse,
PositionCategory,
@@ -25,71 +24,6 @@ export function foldAccents(value: string): string {
.toLowerCase();
}
// --------------------------------------------------------------------------- //
// Disponibilités : couverture d'un créneau par les fenêtres déclarées //
// (portage de `backend/app/validation/engine.py`). //
// --------------------------------------------------------------------------- //
const _dayNum = (iso: string): number => {
const [y, m, d] = iso.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000);
};
/** Minutes absolues (depuis une origine arbitraire) d'un datetime naïf ISO. */
function absMinutes(iso: string): number {
const [date, time] = iso.split('T');
const [h, min] = time.split(':').map(Number);
return _dayNum(date) * 1440 + h * 60 + min;
}
function windowInterval(w: AvailabilityRead): [number, number] {
const [sh, sm] = w.start_time.split(':').map(Number);
const [eh, em] = w.end_time.split(':').map(Number);
const start = _dayNum(w.date) * 1440 + sh * 60 + sm;
let end = _dayNum(w.date) * 1440 + eh * 60 + em;
if (end <= start) end += 1440; // fenêtre à cheval sur minuit
return [start, end];
}
/** Minutes du créneau `[startIso, endIso)` hors de l'union des fenêtres. */
export function uncoveredMinutes(
windows: AvailabilityRead[],
startIso: string,
endIso: string,
): number {
const shiftStart = absMinutes(startIso);
const shiftEnd = absMinutes(endIso);
const intervals = windows
.map(windowInterval)
.filter(([a, b]) => b > a)
.sort((a, b) => a[0] - b[0]);
let cursor = shiftStart;
let gap = 0;
for (const [s, e] of intervals) {
if (e <= cursor) continue;
if (s > shiftEnd) break;
if (s > cursor) gap += Math.min(s, shiftEnd) - cursor;
cursor = Math.max(cursor, e);
if (cursor >= shiftEnd) return gap;
}
if (cursor < shiftEnd) gap += shiftEnd - cursor;
return gap;
}
export type Coverage = 'full' | 'partial' | 'none';
/** État de couverture d'un créneau par les fenêtres d'un bénévole. */
export function availabilityCoverage(
windows: AvailabilityRead[],
startIso: string,
endIso: string,
): Coverage {
if (windows.length === 0) return 'none';
const gap = uncoveredMinutes(windows, startIso, endIso);
if (gap === 0) return 'full';
const total = absMinutes(endIso) - absMinutes(startIso);
return gap >= total ? 'none' : 'partial';
}
// --------------------------------------------------------------------------- //
// Score de chance d'un candidat pour un créneau — portage simplifié du //
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
@@ -145,28 +79,25 @@ export function buildEffectiveCompanionIds(
}
const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
/**
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
* (y compris le conflit avec un artiste qu'il veut voir), compagnons
* souhaités déjà affectés au créneau, charge déjà assignée. Plus haut =
* Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
* un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
* charge déjà assignée. Plus haut =
* meilleur candidat. Sert uniquement à trier la liste affichée ; la
* validation (blocage / avertissements) reste seule juge.
*/
export function candidateChanceScore(params: {
wishesPosition: boolean;
uncoveredMinutes: number;
conflictsWishedArtist: boolean;
companionMatches: number;
assignedHours: number;
}): number {
let score = 0;
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
@@ -239,7 +170,7 @@ export function dayBoundaryFromDayParts(
* « 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). */
* 19:00 — voir CLAUDE.md § 3. */
export function buildLattice(
starts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
@@ -460,32 +391,6 @@ export function hoursByVolunteer(
return totals;
}
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
export function isOverloaded(
volunteerId: string,
assignments: AssignmentRead[],
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): boolean {
const shifts = assignments
.filter((a) => a.volunteer_id === volunteerId)
.map((a) => shiftsById.get(a.shift_id))
.filter((s): s is ShiftRead => Boolean(s))
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
const perDay = new Map<string, number>();
for (const s of shifts) {
const day = datePart(s.starts_at);
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
}
for (const hours of perDay.values()) if (hours > 8) return true;
for (let i = 1; i < shifts.length; i += 1) {
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
if (gap > 0 && gap < 8 * 60) return true;
}
return false;
}
/**
* Rend une violation en texte lisible. Les `params` suivent exactement le
* contrat du moteur back-end (`backend/app/validation/engine.py`).
@@ -502,25 +407,7 @@ export function violationText(
? t('validation.SHIFT_OVERLAP.at', { position: where })
: t('validation.SHIFT_OVERLAP');
}
case 'OUTSIDE_AVAILABILITY':
return t('validation.OUTSIDE_AVAILABILITY', {
gap: formatMinutes(Number(p.uncovered_minutes)),
});
case 'DAILY_HOURS_EXCEEDED':
return t('validation.DAILY_HOURS_EXCEEDED', {
hours: formatMinutes(Number(p.total_minutes)),
});
case 'INSUFFICIENT_REST':
return t('validation.INSUFFICIENT_REST', {
rest: formatMinutes(Number(p.gap_minutes)),
});
default:
return violation.code;
}
}
function formatMinutes(total: number): string {
const h = Math.floor(total / 60);
const m = total % 60;
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
}
+3 -8
View File
@@ -57,8 +57,8 @@ export function StatsPage() {
label={t('stats.kpi.coverage', { assigned: fr(filled), ideal: fr(idealSlots) })}
/>
<StatCard
value={fr(s.needs.volunteers_with_availability)}
label={t('stats.kpi.withAvailability', { total: fr(s.registrations.total) })}
value={fr(s.needs.volunteers_with_assignment)}
label={t('stats.kpi.withAssignment', { total: fr(s.registrations.total) })}
/>
</div>
@@ -230,7 +230,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
}
const series = [
{ label: t('stats.needs.ideal'), color: SERIES.blue },
{ label: t('stats.needs.available'), color: SERIES.orange },
{ label: t('stats.needs.assigned'), color: SERIES.aqua },
];
return (
@@ -243,7 +242,7 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
groups={n.by_day.map((d) => ({
key: d.date,
label: shortDayLabel(d.date),
values: [d.ideal_hours, d.available_hours, d.assigned_hours],
values: [d.ideal_hours, d.assigned_hours],
}))}
/>
<TableToggle label={t('stats.showTable')}>
@@ -253,8 +252,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
t('stats.col.shifts'),
t('stats.needs.min'),
t('stats.needs.ideal'),
t('stats.needs.available'),
t('stats.col.availableVolunteers'),
t('stats.needs.assigned'),
]}
rows={n.by_day.map((d) => [
@@ -262,8 +259,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
fr(d.shifts),
fr(d.min_hours, 1),
fr(d.ideal_hours, 1),
fr(d.available_hours, 1),
fr(d.available_volunteers),
fr(d.assigned_hours, 1),
])}
/>
+3 -20
View File
@@ -4,12 +4,12 @@ import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } f
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer, isOverloaded } from '@/lib/planning';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
type Filter = 'all' | 'overloaded' | 'unassigned';
type Filter = 'all' | 'unassigned';
export function VolunteerViewPage() {
const volunteers = useVolunteers();
@@ -48,7 +48,6 @@ export function VolunteerViewPage() {
return (volunteers.data ?? [])
.filter((v) => {
const own = byVolunteer.get(v.id) ?? [];
if (filter === 'overloaded') return isOverloaded(v.id, all, shiftsById);
if (filter === 'unassigned') return own.length === 0;
return true;
})
@@ -67,7 +66,6 @@ export function VolunteerViewPage() {
volunteer: v,
cellByKey,
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
over: isOverloaded(v.id, all, shiftsById),
empty: own.length === 0,
};
});
@@ -102,9 +100,6 @@ export function VolunteerViewPage() {
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
{t('volunteers.filter.all')}
</Chip>
<Chip active={filter === 'overloaded'} onClick={() => setFilter('overloaded')}>
{t('volunteers.filter.overloaded')}
</Chip>
<Chip active={filter === 'unassigned'} onClick={() => setFilter('unassigned')}>
{t('volunteers.filter.unassigned')}
</Chip>
@@ -182,7 +177,6 @@ type Row = {
volunteer: VolunteerRead;
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
total: number;
over: boolean;
empty: boolean;
};
@@ -201,11 +195,6 @@ function VolunteerRow({
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.first_name} {row.volunteer.last_name}
</span>
{row.over ? (
<span className="flex-none border-[1.5px] border-ink bg-orange px-1 py-[2px] font-display text-[8px] font-extrabold">
{t('volunteers.badge.overloaded')}
</span>
) : null}
{row.empty ? (
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
{t('volunteers.badge.unassigned')}
@@ -224,14 +213,8 @@ function VolunteerRow({
</div>
);
})}
<div
className={cx(
'sticky right-0 z-[2] flex items-center justify-center gap-[3px] border-b border-l-[3px] border-ink font-mono text-[13px] font-bold',
row.over ? 'bg-orange' : 'bg-paper',
)}
>
<div className="sticky right-0 z-[2] flex items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold">
{row.total}
{row.over ? <span className="font-display text-[9px] font-bold">▲</span> : null}
</div>
</>
);
+3 -10
View File
@@ -18,14 +18,9 @@ import { t } from '@/i18n';
const hm = (time: string) => time.slice(0, 5); // "HH:MM:SS" -> "HH:MM"
const RULE_CODES = [
'SHIFT_OVERLAP',
'OUTSIDE_AVAILABILITY',
'DAILY_HOURS_EXCEEDED',
'INSUFFICIENT_REST',
] as const;
const RULE_CODES = ['SHIFT_OVERLAP'] as const;
// Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est
// Le moteur de validation n'a qu'une règle, figée (CLAUDE.md §3). L'édition (nom, dates) est
// éditable — c'est là qu'on ajoute ou retire des jours.
export function ConfigRules() {
const volunteers = useVolunteers();
@@ -62,9 +57,7 @@ export function ConfigRules() {
<HelpTip text={t('config.rules.thresholdsNote')} side="bottom" />
</PanelHeader>
<div className="flex flex-col gap-2 p-[13px]">
<InfoRow label={t('config.rules.maxDaily')} value="8 h" />
<InfoRow label={t('config.rules.minRest')} value="8 h" />
<div className="mt-2 border-t-2 border-ink pt-2">
<div>
<div className="mb-1 font-display text-[9px] font-extrabold uppercase tracking-widest">
{t('config.rules.rulesTitle')}
</div>
+1 -152
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '@/api';
import { roleLabelKey } from '@/auth/roles';
@@ -10,16 +10,13 @@ import {
useCreateVolunteer,
useDeleteAccount,
useDeleteVolunteer,
useEdition,
useExecuteImport,
useExportMecenatReceipts,
useExportVolunteers,
usePositions,
useReplaceAvailability,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer,
useVolunteerAvailability,
useVolunteerPhotoUrl,
useVolunteers,
} from '@/api/hooks';
@@ -870,8 +867,6 @@ export function ConfigVolunteers() {
<PublicFormAnswers volunteer={selected} patch={patch} />
<AvailabilityEditor volunteerId={selected.id} />
<button
type="button"
onClick={() => {
@@ -941,7 +936,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
<div className="flex items-center justify-between bg-ink px-[14px] py-[11px] text-cream">
<span className="flex items-center gap-[7px] font-display text-[14px] font-black">
{t('config.vol.addTitle')}
<HelpTip text={t('config.vol.addAvailabilityHint')} side="bottom" />
</span>
<button
type="button"
@@ -1361,148 +1355,3 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
</div>
);
}
// --------------------------------------------------------------------------- //
// Éditeur de disponibilités (fiche bénévole) //
// --------------------------------------------------------------------------- //
type WindowRow = { date: string; start: string; end: string };
function editionDays(start: string, end: string): string[] {
const out: string[] = [];
for (
let d = Date.parse(`${start}T00:00:00Z`);
d <= Date.parse(`${end}T00:00:00Z`);
d += 86_400_000
) {
out.push(new Date(d).toISOString().slice(0, 10));
}
return out;
}
function AvailabilityEditor({ volunteerId }: { volunteerId: string }) {
const edition = useEdition();
const windows = useVolunteerAvailability(volunteerId);
const replace = useReplaceAvailability();
const [rows, setRows] = useState<WindowRow[]>([]);
const [dirty, setDirty] = useState(false);
const serverRows = useMemo<WindowRow[]>(
() =>
(windows.data ?? []).map((w) => ({
date: w.date,
start: w.start_time.slice(0, 5),
end: w.end_time.slice(0, 5),
})),
[windows.data],
);
// Recharge depuis le serveur tant que l'utilisateur n'a rien modifié.
useEffect(() => {
if (!dirty) setRows(serverRows);
}, [serverRows, dirty]);
// Repart de zéro quand on change de bénévole.
useEffect(() => {
setDirty(false);
}, [volunteerId]);
const days = edition.data
? editionDays(edition.data.start_date, edition.data.end_date)
: [];
const patch = (i: number, next: Partial<WindowRow>) => {
setDirty(true);
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...next } : r)));
};
const save = () => {
replace.mutate(
{
volunteerId,
body: {
windows: rows.map((r) => ({
date: r.date,
start_time: `${r.start}:00`,
end_time: `${r.end}:00`,
})),
},
},
{ onSuccess: () => setDirty(false) },
);
};
return (
<div>
<FieldLabel>
{t('config.vol.avail.title')} <HelpTip text={t('config.vol.avail.hint')} />
</FieldLabel>
<div className="flex flex-col gap-[6px]">
{rows.map((row, i) => (
<div key={i} className="flex flex-wrap items-center gap-1">
<select
value={row.date}
onChange={(e) => patch(i, { date: e.target.value })}
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[6px] font-mono text-[11px]"
>
{days.map((d) => (
<option key={d} value={d}>
{shortDayLabel(`${d}T12:00`)}
</option>
))}
</select>
<input
type="time"
value={row.start}
onChange={(e) => patch(i, { start: e.target.value })}
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
/>
<input
type="time"
value={row.end}
onChange={(e) => patch(i, { end: e.target.value })}
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
/>
<button
type="button"
onClick={() => {
setDirty(true);
setRows((rs) => rs.filter((_, j) => j !== i));
}}
aria-label={t('action.remove')}
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
>
✕
</button>
</div>
))}
{rows.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{t('config.vol.avail.none')}</span>
) : null}
</div>
<div className="mt-2 flex gap-2">
<button
type="button"
disabled={days.length === 0}
onClick={() => {
setDirty(true);
setRows((rs) => [...rs, { date: days[0], start: '08:00', end: '18:00' }]);
}}
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
>
+ {t('config.vol.avail.add')}
</button>
<button
type="button"
disabled={!dirty || replace.isPending}
onClick={save}
className="cursor-pointer border-2 border-ink bg-yellow px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-green hover:text-white disabled:opacity-50"
>
{t('config.vol.avail.save')}
</button>
</div>
{replace.isError ? <ErrorNote error={replace.error} /> : null}
</div>
);
}
+3 -54
View File
@@ -6,33 +6,24 @@ import {
useAssignments,
useCreateAssignment,
useDeleteAssignment,
useEditionAvailability,
usePositions,
useShifts,
useSolverSuggestions,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
import type {
AvailabilityRead,
MatrixShift,
PositionCategory,
VolunteerRead,
} from '@/api/schema';
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
import { Btn } from '@/components/ui';
import { Loading } from '@/components/state';
import {
artistPerformanceWindow,
availabilityCoverage,
buildEffectiveCompanionIds,
candidateChanceScore,
categoryLabel,
foldAccents,
hoursByVolunteer,
uncoveredMinutes,
violationText,
windowsOverlap,
type Coverage,
} from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
@@ -63,7 +54,6 @@ export function AssignmentPanel({
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
const shifts = useShifts();
const positions = usePositions();
const availability = useEditionAvailability();
const artists = useArtists();
const solverSuggestions = useSolverSuggestions();
const deleteAssignment = useDeleteAssignment();
@@ -75,27 +65,6 @@ export function AssignmentPanel({
return entry?.volunteer_ids ?? [];
}, [solverSuggestions.data, shift.shift_id]);
const windowsByVolunteer = useMemo(() => {
const byVol = new Map<string, AvailabilityRead[]>();
for (const w of availability.data ?? []) {
const list = byVol.get(w.volunteer_id) ?? [];
list.push(w);
byVol.set(w.volunteer_id, list);
}
return byVol;
}, [availability.data]);
const coverageByVolunteer = useMemo(() => {
const map = new Map<string, Coverage>();
for (const v of volunteers.data ?? []) {
map.set(
v.id,
availabilityCoverage(windowsByVolunteer.get(v.id) ?? [], shift.starts_at, shift.ends_at),
);
}
return map;
}, [windowsByVolunteer, volunteers.data, shift.starts_at, shift.ends_at]);
const artistsById = useMemo(
() => new Map((artists.data ?? []).map((a) => [a.id, a])),
[artists.data],
@@ -166,8 +135,7 @@ export function AssignmentPanel({
}, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
// disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons
// souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
// conflit avec un artiste qu'il veut voir, compagnons souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
// liste, pas juste « le moins chargé ».
const scoreByVolunteer = useMemo(() => {
const map = new Map<string, number>();
@@ -177,11 +145,6 @@ export function AssignmentPanel({
v.id,
candidateChanceScore({
wishesPosition: details?.wishesPosition ?? false,
uncoveredMinutes: uncoveredMinutes(
windowsByVolunteer.get(v.id) ?? [],
shift.starts_at,
shift.ends_at,
),
conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
companionMatches: details?.companionMatches ?? 0,
assignedHours: hours.get(v.id) ?? 0,
@@ -189,7 +152,7 @@ export function AssignmentPanel({
);
}
return map;
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
}, [volunteers.data, detailsByVolunteer, hours]);
const needle = foldAccents(search.trim());
@@ -319,7 +282,6 @@ export function AssignmentPanel({
shiftId={shift.shift_id}
hours={round(hours.get(volunteer.id) ?? 0)}
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
wishesPosition={detailsByVolunteer.get(volunteer.id)?.wishesPosition ?? false}
conflictsWishedArtist={
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
@@ -334,18 +296,11 @@ export function AssignmentPanel({
);
}
const COVERAGE_STYLE: Record<Coverage, { cls: string; key: string }> = {
full: { cls: 'bg-green text-white', key: 'grille.panel.coverage.full' },
partial: { cls: 'bg-yellow-soft', key: 'grille.panel.coverage.partial' },
none: { cls: 'bg-sand-dark', key: 'grille.panel.coverage.none' },
};
function CandidateRow({
volunteer,
shiftId,
hours,
slots,
coverage,
wishesPosition,
conflictsWishedArtist,
companionMatches,
@@ -356,7 +311,6 @@ function CandidateRow({
shiftId: string;
hours: number;
slots: number;
coverage: Coverage;
wishesPosition: boolean;
conflictsWishedArtist: boolean;
companionMatches: number;
@@ -411,11 +365,6 @@ function CandidateRow({
</div>
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
<span
className={`inline-block border-[1.5px] border-ink px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide ${COVERAGE_STYLE[coverage].cls}`}
>
{t(COVERAGE_STYLE[coverage].key)}
</span>
{wishesPosition ? (
<span className="inline-block border-[1.5px] border-ink bg-yellow px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('grille.panel.wishesPosition')}