diff --git a/CLAUDE.md b/CLAUDE.md index 72e5914..8123819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,9 +247,10 @@ les tables sauf les tables d'association. au ratio du cadre sans déformation, orientation EXIF appliquée et réduite à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou - photo illisible, les **initiales** du bénévole sont imprimées à la - place (fond `#E7E0CE`, même forme) plutôt que de faire échouer la - planche ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur + photo illisible, **rien n'est imprimé** (cadre vide, choix de la + coordination — avant l'étape 61, les initiales sur fond beige), sans + faire échouer la planche ; l'éditeur n'y montre qu'un repère pointillé + « sans photo », non imprimé ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au milieu d'un mot trop long seul ; les `\n` restent des retours à la ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme @@ -280,7 +281,16 @@ les tables sauf les tables d'association. avec les bénévoles affectés et leur téléphone, pour affichage sur place. `POST /editions/{id}/position-sheets/generate` (COORDINATION, `{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune - affectation n'apparaît pas (rien à imprimer). + affectation n'apparaît pas (rien à imprimer). Photo de chaque bénévole en + vignette de 14 mm : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`, + décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une + photo illisible laissant la case vide. Avant l'étape 62, la photo + d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle + pour chaque bénévole de chaque créneau : génération de plusieurs minutes, + serveur à 300 % de CPU, PDF énorme et « erreur inattendue » (bug corrigé + une fois). Même principe pour les badges (`_fit_photo` + `PhotoCache` : + une photo recadrée une fois par génération, malgré recto, verso et + exemplaires multiples). - **Statistiques** (`GET /editions/{id}/stats`, COORDINATION, `app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total, @@ -786,10 +796,16 @@ bug. Largeur adaptative par jour (`columnWidthPx`, en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes étroites par défaut. -La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle, -distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`) -plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus -adapté à son tableau bénévole × créneau. +La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des +temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par +jour) : chaque affectation est une barre étirée sur toute la durée de son +créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent +sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en +jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans +l'en-tête des tranches. Avant l'étape 60, une colonne par créneau distinct +(`buildLattice`, supprimé) : une affectation n'apparaissait que dans la +colonne de son propre créneau, jamais sur les autres colonnes qu'elle +couvrait, et deux postes simultanés se masquaient. --- @@ -1035,6 +1051,9 @@ Aucune dépendance locale requise hors Docker. | 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider | | 58 | Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (`Assignment.attendance` + heure, migration `0030`, `PUT .../assignments/{id}/attendance`, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue | à valider | | 59 | Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau | à valider | +| 60 | Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau | à valider | +| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider | +| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/badges.py b/backend/app/api/badges.py index 328c99f..cce01e6 100644 --- a/backend/app/api/badges.py +++ b/backend/app/api/badges.py @@ -37,7 +37,6 @@ from app.badges.pdf import ( BadgeTableSection, generate_badge_sheets_pdf, image_size_mm, - initials, ) from app.db import get_session from app.i18n import t @@ -320,8 +319,8 @@ def _field_text( # Prénom puis nom, chacun sur sa ligne. return f"{volunteer.first_name}\n{volunteer.last_name}" if field.field_type == BadgeFieldType.PHOTO: - # Imprimées à la place d'une photo absente ou illisible. - return initials(volunteer.first_name, volunteer.last_name) + # Une image, pas un texte (rien d'imprimé sans photo). + return "" if field.field_type == BadgeFieldType.FREE_TEXT: # Texte commun saisi dans l'éditeur, remplacé pour un bénévole par # le « Texte du badge » de sa fiche s'il en a un. diff --git a/backend/app/badges/pdf.py b/backend/app/badges/pdf.py index 65bf6f3..e22c405 100644 --- a/backend/app/badges/pdf.py +++ b/backend/app/badges/pdf.py @@ -42,8 +42,6 @@ _AUTO_FIT_STEP_PT = 0.5 _AUTO_FIT_MIN_PT = 4.0 # Photo absente ou illisible : initiales sur ce fond, à cette taille (part de # la largeur du cadre). -_INITIALS_BG = "#E7E0CE" -_INITIALS_SIZE_RATIO = 0.28 # Résolution supposée d'une image de fond importée, pour convertir ses pixels # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution @@ -86,9 +84,8 @@ class BadgeFieldSpec: `is_photo` : le champ est la photo du bénévole (`image`, ses octets bruts tels que stockés) plutôt qu'un texte, découpée selon - `photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), les - initiales du bénévole (`text`) sont imprimées à la place, sur un fond - neutre de la même forme. + `photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien + n'est imprimé : le cadre reste vide. `table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`. @@ -193,6 +190,9 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None: ) try: with Image.open(io.BytesIO(image)) as img: + # JPEG : décodage directement à taille réduite (bien plus rapide + # qu'une photo de téléphone décodée en pleine résolution). + img.draft("RGB", (target[0] * 2, target[1] * 2)) fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target) except (UnidentifiedImageError, OSError): return None @@ -224,38 +224,37 @@ def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: f return path -def initials(first_name: str, last_name: str) -> str: - """Initiales imprimées à la place d'une photo absente : première lettre - du prénom et du nom, en majuscules.""" - return "".join(part.strip()[:1] for part in (first_name, last_name)).upper() - - -def _draw_initials(c: canvas.Canvas, f: BadgeFieldSpec, x: float, y: float, w: float, h: float): - c.setFillColor(HexColor(_INITIALS_BG)) - c.drawPath(_shape_path(c, f.photo_shape, x, y, w, h), stroke=0, fill=1) - if not f.text: - return - size = max(_AUTO_FIT_MIN_PT, w * _INITIALS_SIZE_RATIO) - c.setFont("Helvetica-Bold", size) - c.setFillColor(HexColor(f.color)) - # Ligne de base décalée d'environ un tiers de la taille : centre - # visuel des majuscules (hauteur de capitale ≈ 0,7 × la taille). - c.drawCentredString(x + w / 2, y + h / 2 - size * 0.35, f.text) +# Photos déjà recadrées pendant une génération, par (photo, largeur, +# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso +# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une +# fois. +PhotoCache = dict[tuple[bytes, float, float], bytes | None] def _draw_photo( - c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float + c: canvas.Canvas, + f: BadgeFieldSpec, + x0_mm: float, + y0_mm: float, + badge_height_mm: float, + cache: PhotoCache, ) -> None: x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm) - photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None - c.saveState() + photo = None + if f.image: + key = (f.image, f.width_mm, f.height_mm) + if key not in cache: + cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm) + photo = cache[key] if photo is None: - _draw_initials(c, f, x, y, w, h) - else: - if f.photo_shape == "OVAL": - # Découpe en ellipse inscrite dans le cadre. - c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0) - c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h) + # Pas de photo (ou illisible) : cadre laissé vide, choix de la + # coordination — avant, les initiales étaient imprimées à la place. + return + c.saveState() + if f.photo_shape == "OVAL": + # Découpe en ellipse inscrite dans le cadre. + c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0) + c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h) c.restoreState() @@ -495,10 +494,11 @@ def _draw_fields( x0_mm: float, y0_mm: float, badge_height_mm: float, + photos: PhotoCache, ) -> None: for f in fields: if f.is_photo: - _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm) + _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos) continue _draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm) if f.table is not None: @@ -522,17 +522,18 @@ def generate_badge_sheets_pdf( buffer = io.BytesIO() c = canvas.Canvas(buffer, pagesize=A4) + photos: PhotoCache = {} for page_badges in pages: for i, badge in enumerate(page_badges): x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) _draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm) - _draw_fields(c, badge.fields, x0, y0, badge_height_mm) + _draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos) c.showPage() for i, badge in enumerate(page_badges): x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm) - _draw_fields(c, badge.back_fields, x0, y0, badge_height_mm) + _draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos) c.showPage() c.save() return buffer.getvalue() diff --git a/backend/app/sheets/pdf.py b/backend/app/sheets/pdf.py index eb1f828..ec084d2 100644 --- a/backend/app/sheets/pdf.py +++ b/backend/app/sheets/pdf.py @@ -13,11 +13,12 @@ import datetime as dt import io from dataclasses import dataclass +from PIL import Image as PILImage +from PIL import ImageOps, UnidentifiedImageError from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import mm -from reportlab.lib.utils import ImageReader from reportlab.platypus import ( Image, PageBreak, @@ -29,6 +30,8 @@ from reportlab.platypus import ( ) _PHOTO_BOX_MM = 14 * mm +# Côté de la vignette en pixels : 14 mm à 300 dpi. +_PHOTO_BOX_PX = round(14 / 25.4 * 300) _WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] _MONTHS_FR = [ @@ -115,16 +118,45 @@ def _styles() -> dict[str, ParagraphStyle]: } -def _photo_thumbnail(photo: bytes) -> Image: - """Vignette carrée (14 mm de côté max), proportions préservées.""" - width_px, height_px = ImageReader(io.BytesIO(photo)).getSize() +def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None: + """Photo réduite à la taille de la vignette (orientation EXIF appliquée), + en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo + d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle + quelle pour chaque bénévole de chaque créneau faisait tourner la + génération plusieurs minutes et produisait un PDF énorme — jusqu'à + l'erreur (bug corrigé une fois).""" + try: + with PILImage.open(io.BytesIO(photo)) as img: + # JPEG : décodage directement à taille réduite (bien plus rapide). + img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX)) + thumb = ImageOps.exif_transpose(img).convert("RGB") + thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX)) + except (UnidentifiedImageError, OSError, ValueError): + return None + out = io.BytesIO() + thumb.save(out, format="JPEG", quality=85) + return out.getvalue(), thumb.width, thumb.height + + +def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]): + """Vignette carrée (14 mm de côté max), proportions préservées. Chaque + photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole + figure sur plusieurs créneaux.""" + if not photo: + return "" + if photo not in cache: + cache[photo] = _thumbnail(photo) + thumb = cache[photo] + if thumb is None: + return "" + data, width_px, height_px = thumb scale = _PHOTO_BOX_MM / max(width_px, height_px) - return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale) + return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale) -def _shift_table(shift: SheetShift) -> Table: +def _shift_table(shift: SheetShift, cache: dict[bytes, tuple[bytes, int, int] | None]) -> Table: rows = [["", "Bénévole", "Téléphone"]] + [ - [_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"] + [_photo_cell(v.photo, cache), v.name, v.phone or "—"] for v in shift.volunteers ] table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm]) @@ -153,6 +185,7 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes: ) story = [] + thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {} for i, sheet in enumerate(sheets): if i > 0: story.append(PageBreak()) @@ -190,7 +223,7 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes: style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"] story.append(Paragraph(header, style)) if shift.volunteers: - story.append(_shift_table(shift)) + story.append(_shift_table(shift, thumbnails)) else: story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"])) story.append(Spacer(1, 0)) diff --git a/backend/tests/test_badges_api.py b/backend/tests/test_badges_api.py index 2cdc5df..5cbfe51 100644 --- a/backend/tests/test_badges_api.py +++ b/backend/tests/test_badges_api.py @@ -360,7 +360,7 @@ def test_generating_with_back_fields(client, edition, session, monkeypatch): assert back[False].bg_color == "#FFD23F" assert back[False].auto_fit is True assert back[True].photo_shape == "OVAL" - assert back[True].text == "AM" # initiales, faute de photo + assert back[True].text == "" # pas de photo : rien d'imprimé def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch): @@ -427,7 +427,11 @@ def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatc assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200 (badge,) = captured["badges"] - assert [f.text for f in badge.fields] == ["Alex\nMartin", "AM", "Staff"] + assert [(f.is_photo, f.text) for f in badge.fields] == [ + (False, "Alex\nMartin"), + (True, ""), + (False, "Staff"), + ] def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition): diff --git a/backend/tests/test_badges_fields.py b/backend/tests/test_badges_fields.py index 50d5e68..4698ec6 100644 --- a/backend/tests/test_badges_fields.py +++ b/backend/tests/test_badges_fields.py @@ -115,10 +115,11 @@ def test_field_text_full_name_is_first_name_then_last_name_on_two_lines(): assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin" -def test_field_text_photo_is_the_initials_used_without_a_photo(): +def test_field_text_photo_is_empty(): volunteer = _volunteer(first_name="alex", last_name="martin") field = BadgeField( edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO, x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9, ) - assert _field_text(field, volunteer, {}, set()) == "AM" + # Une image, pas un texte : rien n'est imprimé sans photo. + assert _field_text(field, volunteer, {}, set()) == "" diff --git a/backend/tests/test_badges_pdf.py b/backend/tests/test_badges_pdf.py index 016e3ae..8b9b4eb 100644 --- a/backend/tests/test_badges_pdf.py +++ b/backend/tests/test_badges_pdf.py @@ -242,7 +242,7 @@ def test_table_rows_beyond_the_frame_are_not_drawn(): # Éditeur v2 : verso, fond de zone, réduction automatique, photo # # --------------------------------------------------------------------------- # from app.badges import pdf as pdf_module # noqa: E402 -from app.badges.pdf import fit_text, initials # noqa: E402 +from app.badges.pdf import fit_text # noqa: E402 def _text_field(**kw) -> BadgeFieldSpec: @@ -269,9 +269,9 @@ def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeyp calls = [] original = pdf_module._draw_fields - def spy(c, fields, x0, y0, badge_height_mm): + def spy(c, fields, x0, y0, badge_height_mm, photos): calls.append((fields, x0, y0)) - original(c, fields, x0, y0, badge_height_mm) + original(c, fields, x0, y0, badge_height_mm, photos) monkeypatch.setattr(pdf_module, "_draw_fields", spy) back = (_text_field(text="Verso Alex"),) @@ -376,30 +376,23 @@ def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch): assert middle < top -def test_initials_are_the_uppercase_first_letters(): - assert initials("alex", "martin") == "AM" - assert initials("Élodie", "de la Tour") == "ÉD" - - -def test_missing_photo_prints_the_initials(monkeypatch): - drawn = [] - monkeypatch.setattr( - pdf_module.canvas.Canvas, "drawCentredString", - lambda self, x, y, text, *a, **k: drawn.append(text), - ) - for image in (None, b"pas une image"): - badge = BadgeSpec( - fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),) +def test_missing_photo_prints_nothing(monkeypatch): + calls = [] + for name in ("drawImage", "drawPath", "drawCentredString", "drawString"): + monkeypatch.setattr( + pdf_module.canvas.Canvas, name, + lambda self, *a, _name=name, **k: calls.append(_name), ) - generate_badge_sheets_pdf([badge], None, None) - assert drawn == ["AM", "AM"] + for image in (None, b"pas une image"): + generate_badge_sheets_pdf([_photo_badge(image)], None, None) + assert calls == [] -def test_oval_photo_and_oval_initials_generate_a_valid_pdf(): +def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf(): for image in (_jpeg(400, 500), None): field = _photo_badge(image).fields[0] badge = BadgeSpec( - fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL", "text": "AM"}),) + fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),) ) pdf = generate_badge_sheets_pdf([badge], None, None) assert pdf.startswith(b"%PDF") diff --git a/backend/tests/test_position_sheets_pdf.py b/backend/tests/test_position_sheets_pdf.py index 4de824a..1d42f61 100644 --- a/backend/tests/test_position_sheets_pdf.py +++ b/backend/tests/test_position_sheets_pdf.py @@ -2,6 +2,7 @@ import base64 import datetime as dt +import io from app.sheets.pdf import ( PositionDaySheet, @@ -142,3 +143,34 @@ def test_generates_a_valid_pdf_without_a_lineup(): assert sheet.artists == () pdf = generate_position_sheets_pdf([sheet]) assert pdf.startswith(b"%PDF") + + +def _phone_photo(seed: int) -> bytes: + """Photo de téléphone : 3000 × 4000 px, plusieurs Mo.""" + from PIL import Image as PILImage + + img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60)) + img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200)) + out = io.BytesIO() + img.save(out, format="JPEG", quality=92) + return out.getvalue() + + +def test_large_photos_are_shrunk_to_the_thumbnail(): + # Régression : la photo d'origine était intégrée telle quelle pour + # chaque bénévole de chaque créneau — génération interminable, PDF + # énorme, puis erreur. + photos = [_phone_photo(i) for i in range(5)] + volunteers = tuple( + SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos) + ) + pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)]) + assert sum(len(p) for p in photos) > 2_000_000 + assert len(pdf) < 200_000 + + +def test_unreadable_photo_does_not_break_the_sheet(): + pdf = generate_position_sheets_pdf( + [_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))] + ) + assert pdf.startswith(b"%PDF") diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 1dac4cb..9e1202e 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -106,6 +106,10 @@ "volunteers.filter.all": "TOUS", "volunteers.filter.unassigned": "SANS AFFECTATION", "volunteers.legend.assigned": "AFFECTATION", + "volunteers.legend.overlap": "DEUX POSTES EN MÊME TEMPS", + "volunteers.legend.now": "MAINTENANT", + "volunteers.bar.title": "{position} · {time}", + "volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste", "volunteers.count": "{count} BÉNÉVOLES", "volunteers.totalHours": "TOTAL H", "volunteers.sort.label": "TRIER PAR", @@ -494,6 +498,7 @@ "badges.preview.zoomIn": "Agrandir l'aperçu", "badges.preview.zoomOut": "Réduire l'aperçu", "badges.preview.zoom": "{percent} %", + "badges.preview.noPhoto": "sans photo", "badges.preview.truncated": "▲ TEXTE COUPÉ", "badges.preview.dims": "{width} × {height} mm", "badges.preview.reducedTo": " · réduit à {size} pt", @@ -526,7 +531,7 @@ "badges.inspector.photoShape": "Forme de la photo", "badges.inspector.photoRect": "▢ Rectangle", "badges.inspector.photoOval": "◯ Ovale", - "badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo : initiales sur fond beige.", + "badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo, le cadre reste vide.", "badges.inspector.geometry": "Position et taille (mm)", "badges.inspector.x": "X", "badges.inspector.y": "Y", @@ -560,8 +565,8 @@ "badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.", "badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.", "badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.", - "badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : ses initiales seront imprimées à la place.", - "badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : leurs initiales seront imprimées à la place.", + "badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : le cadre restera vide sur son badge.", + "badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : le cadre restera vide sur leur badge.", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", "badge.empty_image": "L'image est vide.", "badge.image_too_large": "L'image est trop volumineuse.", diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index aefb3a4..13ce327 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -2,7 +2,6 @@ import type { ArtistRead, AssignmentRead, DayPartRead, - MatrixResponse, PositionCategory, ShiftRead, Violation, @@ -108,22 +107,6 @@ export function candidateChanceScore(params: { return score; } -/** Colonne de la grille : un créneau distinct (date, heure de début, heure de - * fin) — pas seulement une heure de début : deux créneaux qui démarrent à la - * même heure mais durent différemment ont chacun leur colonne. */ -export interface LatticeColumn { - key: string; // `YYYY-MM-DD|HH:MM|HH:MM` - date: string; - time: string; - endTime: string; - label: string; // « 17:00 → 22:00 » -} - -export interface LatticeDay { - date: string; - columns: LatticeColumn[]; -} - /** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus * long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où * le dernier de la journée se termine) — même règle que @@ -169,68 +152,6 @@ export function dayBoundaryFromDayParts( return clockLabel(bestStart); } -/** Construit le treillis de colonnes à partir de créneaux (grille alignée). - * `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par - * « 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. */ -export function buildLattice( - starts: Array<{ starts_at: string; ends_at: string }>, - dayBoundary = '00:00', -): { - days: LatticeDay[]; - columns: LatticeColumn[]; -} { - const boundaryMinutes = minutesOfDay(dayBoundary); - const seen = new Map(); - for (const s of starts) { - const date = datePart(s.starts_at); - const time = timePart(s.starts_at); - const endTime = timePart(s.ends_at); - const key = `${date}|${time}|${endTime}`; - if (!seen.has(key)) { - const minutes = minutesOfDay(time); - const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date; - const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60); - seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes }); - } - } - const ordered = [...seen.values()].sort( - (a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes, - ); - const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({ - key, - date, - time, - endTime, - label, - })); - const days: LatticeDay[] = []; - for (const column of ordered) { - let day = days.find((d) => d.date === column.festivalDay); - if (!day) { - day = { date: column.festivalDay, columns: [] }; - days.push(day); - } - day.columns.push({ - key: column.key, - date: column.date, - time: column.time, - endTime: column.endTime, - label: column.label, - }); - } - return { days, columns }; -} - -export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) { - return buildLattice( - matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))), - dayBoundary, - ); -} - /** * Largeur (px) des tranches de 30 min d'une journée, en fonction de leur * nombre : une journée courte (peu de tranches) reste lisible en large, @@ -247,9 +168,8 @@ export function columnWidthPx(slotsInDay: number): number { // --------------------------------------------------------------------------- // // Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, // // eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés // -// dans cette grille fixe en fusionnant les cases qu'ils couvrent. // -// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », // -// une colonne par créneau distinct plutôt qu'un axe temporel continu.) // +// dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe // +// pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). // // --------------------------------------------------------------------------- // const TIME_GRID_SLOT_MINUTES = 30; diff --git a/frontend/src/pages/VolunteerViewPage.tsx b/frontend/src/pages/VolunteerViewPage.tsx index 13bcd5b..747163d 100644 --- a/frontend/src/pages/VolunteerViewPage.tsx +++ b/frontend/src/pages/VolunteerViewPage.tsx @@ -1,11 +1,21 @@ import { useMemo, useState } from 'react'; import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks'; -import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema'; +import type { VolunteerRead } from '@/api/schema'; import { Chip, HelpTip } from '@/components/ui'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; -import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning'; -import { datePart, shortDayLabel, timePart } from '@/lib/time'; +import { + buildTimeGrid, + columnWidthPx, + dayBoundaryFromDayParts, + festivalDay, + hoursByVolunteer, + placeInTimeGrid, + type TimeGridDay, +} from '@/lib/planning'; +import { clockLabel } from '@/lib/slotTimeline'; +import { minutesOfDay, naiveMinutes, shortDayLabel, timePart } from '@/lib/time'; +import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; @@ -14,15 +24,47 @@ type Filter = 'all' | 'unassigned'; * total d'heures affectées, croissant ou décroissant. */ type Sort = 'name' | 'hoursAsc' | 'hoursDesc'; +const NAME_W = 230; +const TOTAL_W = 84; +const SLOT_MINUTES = 30; +/** Hauteur d'une ligne de poste dans la rangée d'un bénévole : deux postes + * en même temps ⇒ deux lignes, la rangée s'agrandit. */ +const LANE_H = 26; +const ROW_PAD = 3; + +interface Bar { + assignmentId: string; + positionName: string; + start: number; + end: number; + /** Position horizontale (px) dans la piste des créneaux. */ + left: number; + width: number; + lane: number; + /** Chevauche un autre créneau du même bénévole. */ + overlap: boolean; + label: string; +} + +/** + * Vue par bénévole (COORDINATION) : un vrai axe des temps, comme la grille + * des besoins (une colonne = 30 min, bornée par jour de festival à la plage + * utilisée — `buildTimeGrid`). Chaque affectation s'étire sur toute la + * durée de son créneau, avec le nom du poste ; deux postes tenus en même + * temps s'empilent sur deux lignes (la rangée s'agrandit) et sont signalés. + * Ligne rouge verticale à l'heure actuelle quand elle tombe dans la grille. + */ export function VolunteerViewPage() { const volunteers = useVolunteers(); const assignments = useAssignments(); const shifts = useShifts(); const positions = usePositions(); const dayParts = useDayParts(); + const now = useNowMinutes(); const [filter, setFilter] = useState('all'); const [sort, setSort] = useState('name'); + const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]); const shiftsById = useMemo( () => new Map((shifts.data ?? []).map((s) => [s.id, s])), [shifts.data], @@ -31,60 +73,85 @@ export function VolunteerViewPage() { () => new Map((positions.data ?? []).map((p) => [p.id, p])), [positions.data], ); - const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]); - const lattice = useMemo( - () => buildLattice(shifts.data ?? [], dayBoundary), - [shifts.data, dayBoundary], - ); const hours = useMemo( () => hoursByVolunteer(assignments.data ?? [], shiftsById), [assignments.data, shiftsById], ); + // Axe des temps : jours de festival, largeur de tranche par jour et + // décalage (px) de chaque jour dans la piste. + const axis = useMemo(() => { + const days = buildTimeGrid(shifts.data ?? [], dayBoundary); + let offset = 0; + const layout = days.map((day) => { + const slotW = columnWidthPx(day.slotLabels.length); + const entry = { day, slotW, offset, width: slotW * day.slotLabels.length }; + offset += entry.width; + return entry; + }); + return { days, layout, width: offset }; + }, [shifts.data, dayBoundary]); + const rows = useMemo(() => { - const all = assignments.data ?? []; - const byVolunteer = new Map(); - for (const a of all) { - const list = byVolunteer.get(a.volunteer_id) ?? []; - list.push(a); - byVolunteer.set(a.volunteer_id, list); + const byVolunteer = new Map>(); + for (const a of assignments.data ?? []) { + byVolunteer.set(a.volunteer_id, [...(byVolunteer.get(a.volunteer_id) ?? []), { id: a.id, shiftId: a.shift_id }]); } + const layoutByDay = new Map(axis.layout.map((l) => [l.day.date, l])); const built = (volunteers.data ?? []) - .filter((v) => { - const own = byVolunteer.get(v.id) ?? []; - if (filter === 'unassigned') return own.length === 0; - return true; - }) + .filter((v) => filter === 'all' || !(byVolunteer.get(v.id)?.length)) .map((v) => { - const own = byVolunteer.get(v.id) ?? []; - const cellByKey = new Map(); - for (const a of own) { - const shift = shiftsById.get(a.shift_id); - if (shift) - cellByKey.set( - `${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`, - { ...a, shift }, - ); + const items = (byVolunteer.get(v.id) ?? []).flatMap(({ id, shiftId }) => { + const shift = shiftsById.get(shiftId); + return shift ? [{ id, shift, starts_at: shift.starts_at, ends_at: shift.ends_at }] : []; + }); + const bars: Bar[] = []; + for (const [day, placements] of placeInTimeGrid(axis.days, items, dayBoundary)) { + const l = layoutByDay.get(day); + if (!l) continue; + for (const p of placements) { + const { shift } = p.item; + bars.push({ + assignmentId: p.item.id, + positionName: positionsById.get(shift.position_id)?.name ?? '?', + start: naiveMinutes(shift.starts_at), + end: naiveMinutes(shift.ends_at), + left: l.offset + p.columnStart * l.slotW, + width: p.columnSpan * l.slotW, + lane: 0, + overlap: false, + label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`, + }); + } + } + // Lignes : un créneau prend la première ligne libre à son début. + bars.sort((a, b) => a.start - b.start || a.end - b.end); + const laneEnds: number[] = []; + for (const bar of bars) { + let lane = laneEnds.findIndex((end) => end <= bar.start); + if (lane === -1) lane = laneEnds.length; + laneEnds[lane] = bar.end; + bar.lane = lane; + bar.overlap = bars.some((o) => o !== bar && o.start < bar.end && bar.start < o.end); } return { volunteer: v, - cellByKey, + bars, + lanes: Math.max(1, laneEnds.length), total: Math.round((hours.get(v.id) ?? 0) * 10) / 10, - empty: own.length === 0, + empty: items.length === 0, }; }); if (sort === 'name') return built; const direction = sort === 'hoursAsc' ? 1 : -1; // `sort` est stable : à total égal, l'ordre par nom est conservé. return built.sort((a, b) => direction * (a.total - b.total)); - }, [volunteers.data, assignments.data, shiftsById, hours, filter, sort]); + }, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort]); - if ( - volunteers.isLoading || - shifts.isLoading || - assignments.isLoading || - positions.isLoading - ) + // Position (px) de l'heure actuelle dans la piste, `null` hors grille. + const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]); + + if (volunteers.isLoading || shifts.isLoading || assignments.isLoading || positions.isLoading) return ; const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error; if (error) return ; @@ -97,9 +164,6 @@ export function VolunteerViewPage() { ); } - const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`; - const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase(); - return (
@@ -124,9 +188,11 @@ export function VolunteerViewPage() { {t('volunteers.sort.hoursDesc')}
-
+
{t('legend.title')} {t('volunteers.legend.assigned')} + {t('volunteers.legend.overlap')} + {t('volunteers.legend.now')}
@@ -134,62 +200,99 @@ export function VolunteerViewPage() { {rows.length === 0 ? ( {t('volunteers.empty')} ) : ( -
-
- {t('volunteers.count', { count: rows.length })} - -
- {lattice.days.map((day) => ( -
- - {shortDayLabel(`${day.date}T12:00`)} - +
+ {/* En-tête : jours, puis tranches de 30 min. */} +
+
+ {t('volunteers.count', { count: rows.length })} + +
+ {axis.layout.map(({ day, width }) => ( +
+ {shortDayLabel(`${day.date}T12:00`)} +
+ ))} +
- ))} - - -
- {t('volunteers.name')} -
- {lattice.columns.map((column) => ( -
- {column.time} +
+
+ {t('volunteers.name')} +
+
+ {axis.layout.map(({ day, slotW }) => + day.slotLabels.map((label, i) => ( +
+ {label} +
+ )), + )} + {nowX !== null ? ( + + {clockLabel(now)} + + ) : null} +
+
+ {t('volunteers.cumulative')} +
- ))} -
- {t('volunteers.cumulative')} -
- {rows.map((row) => ( - - ))} -
+ {rows.map((row) => ( + + ))} +
)}
); } +function nowPosition( + layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>, + now: number, + dayBoundary: string, +): number | null { + const iso = new Date(now * 60_000).toISOString().slice(0, 16); + const entry = layout.find((l) => l.day.date === festivalDay(iso, dayBoundary)); + if (!entry) return null; + const boundary = minutesOfDay(dayBoundary); + const offset = ((minutesOfDay(timePart(iso)) - boundary + 1440) % 1440) - entry.day.startOffset; + if (offset < 0 || offset > entry.day.slotLabels.length * SLOT_MINUTES) return null; + return entry.offset + (offset / SLOT_MINUTES) * entry.slotW; +} + function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) { return ( @@ -199,25 +302,24 @@ function LegendItem({ swatch, children }: { swatch: string; children: React.Reac ); } -type Row = { - volunteer: VolunteerRead; - cellByKey: Map; - total: number; - empty: boolean; -}; - function VolunteerRow({ row, - columns, - abbr, + layout, + width, + nowX, }: { - row: Row; - columns: { key: string }[]; - abbr: (positionId: string) => string; + row: { volunteer: VolunteerRead; bars: Bar[]; lanes: number; total: number; empty: boolean }; + layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>; + width: number; + nowX: number | null; }) { + const height = row.lanes * LANE_H + ROW_PAD * 2; return ( - <> -
+
+
{row.volunteer.first_name} {row.volunteer.last_name} @@ -227,21 +329,54 @@ function VolunteerRow({ ) : null}
- {columns.map((column) => { - const cell = row.cellByKey.get(column.key); - if (!cell) return
; - return ( + +
+ {/* Tranches de 30 min, filet épais entre deux jours. */} + {layout.map((l) => ( +
); } diff --git a/frontend/src/pages/badges/BadgeCanvas.tsx b/frontend/src/pages/badges/BadgeCanvas.tsx index 3da0e5f..da4d7ac 100644 --- a/frontend/src/pages/badges/BadgeCanvas.tsx +++ b/frontend/src/pages/badges/BadgeCanvas.tsx @@ -16,7 +16,6 @@ import { import { FACE_LABEL_KEY, fmt, - initials, isVerticallyCentered, renderField, SAFE_MARGIN_MM, @@ -25,8 +24,6 @@ import { type FieldState, } from './model'; -/** Fond d'une photo absente (initiales à la place), comme le PDF. */ -export const PHOTO_PLACEHOLDER_BG = '#E7E0CE'; /** Pas d'accroche du glisser-déposer (mm). */ const DRAG_SNAP_MM = 0.5; /** Plus petit cadre possible au redimensionnement (mm). */ @@ -301,7 +298,7 @@ function FieldBox({ }} > {rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? ( - + ) : rendered?.kind === 'table' ? ( ) : rendered?.kind === 'text' ? ( @@ -450,12 +447,10 @@ function PhotoContent({ field, volunteer, photoUrl, - scale, }: { field: FieldState; volunteer: VolunteerRead | null; photoUrl: string | null; - scale: number; }) { const radius = field.photo_shape === 'OVAL' ? '50%' : 0; if (volunteer?.has_photo && photoUrl) { @@ -469,19 +464,14 @@ function PhotoContent({ /> ); } + // Sans photo, rien n'est imprimé : juste un repère pointillé dans + // l'éditeur (pas sur le badge) pour savoir où est le cadre. return (
- {volunteer ? initials(volunteer) : null} + {t('badges.preview.noPhoto')}
); } diff --git a/frontend/src/pages/badges/model.ts b/frontend/src/pages/badges/model.ts index be9b9eb..a59a191 100644 --- a/frontend/src/pages/badges/model.ts +++ b/frontend/src/pages/badges/model.ts @@ -258,10 +258,6 @@ export interface BadgeData { dayBoundary: string; } -export function initials(volunteer: VolunteerRead): string { - return `${volunteer.first_name.trim().charAt(0)}${volunteer.last_name.trim().charAt(0)}`.toUpperCase(); -} - export function fullNameText(volunteer: VolunteerRead): string { return `${volunteer.first_name}\n${volunteer.last_name}`; }