Skip to content
1 change: 1 addition & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ Navigation entre pages : rendu serveur classique (`PageController`, GET, templat
- **Un geste = des étapes indépendantes** (`primaryAction`, `stepRequest`, `summarize` dans `planning/booking/booking-plan.js`, logique pure testée sans DOM) : d'abord chaque **partie libre** (`POST /api/bookings`), puis chaque **demande d'échange** (`POST /api/availability`, sur la plage chevauchée) ; une étape qui échoue n'annule pas les autres et le bilan dit exactement ce qui est parti et ce qui a échoué. Si la demande est refusée, la partie libre reste réservée et s'annule. Le bilan survit au rechargement (`sessionStorage`, facultatif) ; la page est rechargée pour afficher la liste à jour.
- **Piège relevé en test de bout en bout** : le menu fixe du bas peut recouvrir un champ ou le bouton ; les éléments de la page ont `scroll-margin-bottom` pour que le focus clavier et le défilement ne les laissent jamais dessous.
- Les identifiants (groupe, réservation) sont vérifiés strictement numériques avant de toucher une URL.
- **Page remplie depuis le planning (#457)** : la carte d'un AUTRE groupe (pour une personne qui a un groupe) ouvre `/bookings?date=&start=&end=` avec sa prochaine occurrence (`nextOccurrence`, `destinationFor` dans `planning-card.js`, logique pure testée) au lieu de la messagerie : l'échange devient une demande officielle, visible sur le tableau de bord du groupe titulaire (et notifiée par e-mail, #455), et le planning reste à jour. `BookingPrefill::from` ne garde que les paramètres fiables (jour réel de la fenêtre de 30 jours, quarts d'heure, fin après le début et avant 23:30) ; sinon la page s'ouvre vide. `<rb-booking-form prefilled>` demande son plan dès la connexion. Sans groupe, ou créneau illisible : repli sur `/messages/new/{id}`.


### Suivi unifié des demandes (#292)
Expand Down
2 changes: 1 addition & 1 deletion .claude/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Améliore le champ texte `#rrggbb` rendu par le serveur (sans JS il fonctionne s

## Cartes : `<rb-planning-card>` et `<rb-request-card>` (#330)

- **`<rb-planning-card>`** (`planning/rb-planning-card.js`, logique pure `planning/planning-card.js`) : une carte du planning fixe, `role="button"` `tabindex="0"` et attributs `group-id`, `group-name`, `group-slug`, `weekday`, `member` (posé par le serveur quand l'utilisateur appartient au groupe). Clic ou Entrée / Espace : elle émet `planning-card:open` { url } (annulable) puis navigue vers l'espace du groupe (membre) ou la page « nouvelle conversation ». Plus d'écouteur global sur le document (`contact.js` supprimé) : une carte insérée plus tard s'active seule. Les cartes des créneaux occasionnels ne sont pas cliquables : de simples `<article>`.
- **`<rb-planning-card>`** (`planning/rb-planning-card.js`, logique pure `planning/planning-card.js`) : une carte du planning fixe, `role="button"` `tabindex="0"` et attributs `group-id`, `group-name`, `group-slug`, `weekday`, `start-time`, `end-time`, `member` (posé par le serveur quand l'utilisateur appartient au groupe), `can-book` (il a un groupe pour réserver, #457). Clic ou Entrée / Espace : elle émet `planning-card:open` { url } (annulable) puis navigue vers l'espace du groupe (membre), « Réserver le local » rempli avec ce créneau (autre groupe, `can-book`, #457) ou la page « nouvelle conversation » (repli). Plus d'écouteur global sur le document (`contact.js` supprimé) : une carte insérée plus tard s'active seule. Les cartes des créneaux occasionnels ne sont pas cliquables : de simples `<article>`.
- **`<rb-request-card>`** (`planning/rb-request-card.js`) : une demande d'échange de créneau (`exception-id`, `occurrence-date` portés par la carte, boutons `data-action="respond|cancel"`, formulaire `form[data-update]`). Elle **traduit les gestes en évènements** (`request:respond`, `request:cancel`, `request:update`) et **ne parle jamais à l'API** : c'est `availability.js` (même contrat que la messagerie). Pendant l'appel la carte est **occupée** (`busy` : boutons désactivés, `aria-busy`) : un double clic sur « Accepter » n'envoie plus qu'une requête. La pile (glisser, profondeur) reste dans `exception-deck.js`. Les cartes de réservation libre gardent leur propre `<rb-booking-item>`.
- Les deux sont `display: block` (comme les `<article>` qu'elles remplacent) avec `[hidden]` qui reste prioritaire (le paquet masque les cartes passées par l'attribut).

Expand Down
3 changes: 3 additions & 0 deletions public/assets/js/planning/booking/rb-booking-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export class RbBookingForm extends HTMLElement {
this.addEventListener('input', this.#onInput);
this.addEventListener('change', this.#onInput);
this.addEventListener('click', this.#onClick);
if (this.hasAttribute('prefilled')) {
this.#onInput(); // lien depuis le planning (#457) : le formulaire arrive rempli, le plan s'affiche sans rien toucher
}
}

disconnectedCallback() {
Expand Down
43 changes: 38 additions & 5 deletions public/assets/js/planning/planning-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,47 @@
* Logique pure de <rb-planning-card> (#330), testable sans DOM : où mène une carte du planning, et quelles touches l'activent.
*/

/** Date locale « AAAA-MM-JJ » (jamais toISOString : il convertit en UTC et décale d'un jour le soir). */
function isoDay(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

/**
* Un groupe dont on est membre ouvre son espace ; un autre groupe ouvre la page « nouvelle conversation » (/messages/new/{id}), où
* l'on écrit le premier message. Le serveur revérifie tout à l'envoi, l'adresse e-mail de contact n'est jamais exposée au client.
* Prochaine date d'un jour de semaine, aujourd'hui compris. `weekday` suit l'enum du serveur : lundi = 0 … dimanche = 6.
*
* @param {{groupId: string, slug: string, isMember: boolean}} card
* @param {number} weekday
* @param {Date} [from]
*/
export function destinationFor({ groupId, slug, isMember }) {
return isMember ? `/groups/${slug}/space` : `/messages/new/${groupId}`;
export function nextOccurrence(weekday, from = new Date()) {
const today = (from.getDay() + 6) % 7; // JavaScript : dimanche = 0
const next = new Date(from.getFullYear(), from.getMonth(), from.getDate() + ((weekday - today + 7) % 7));

return isoDay(next);
}

/**
* Où mène une carte du planning :
* - un groupe dont on est membre : son espace ;
* - un autre groupe, quand on a un groupe pour réserver : « Réserver le local » déjà rempli avec ce créneau (sa prochaine date),
* pour que la demande d'échange soit officielle et que le planning reste à jour ;
* - sinon (aucun groupe, ou créneau illisible) : la page « nouvelle conversation » (/messages/new/{id}).
* Le serveur revérifie tout ; l'adresse e-mail de contact n'est jamais exposée au client.
*
* @param {{groupId: string, slug: string, isMember: boolean, canBook?: boolean, weekday?: string, startTime?: string, endTime?: string}} card
* @param {Date} [from]
*/
export function destinationFor({ groupId, slug, isMember, canBook = false, weekday = '', startTime = '', endTime = '' }, from = new Date()) {
if (isMember) {
return `/groups/${slug}/space`;
}
const day = Number(weekday);
const readable = weekday !== '' && Number.isInteger(day) && day >= 0 && day <= 6 && /^\d{2}:\d{2}$/.test(startTime) && /^\d{2}:\d{2}$/.test(endTime);
if (canBook && readable) {
return '/bookings?' + new URLSearchParams({ date: nextOccurrence(day, from), start: startTime, end: endTime });
}

return `/messages/new/${groupId}`;
}

/** Entrée et Espace activent la carte, comme un bouton. */
Expand Down
29 changes: 28 additions & 1 deletion public/assets/js/planning/planning-card.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { destinationFor, isActivationKey } from './planning-card.js';
import { destinationFor, isActivationKey, nextOccurrence } from './planning-card.js';

test('a card of a group the user belongs to opens the group space', () => {
assert.equal(destinationFor({ groupId: '5', slug: 'mon-groupe', isMember: true }), '/groups/mon-groupe/space');
Expand All @@ -10,6 +10,33 @@ test('a card of another group opens a new conversation page (no modal)', () => {
assert.equal(destinationFor({ groupId: '5', slug: 'groupe-tiers', isMember: false }), '/messages/new/5');
});

// 2026-10-12 est un lundi. Lundi = 0 … dimanche = 6 (comme l'enum Weekday côté serveur).
const MONDAY_NOON = new Date(2026, 9, 12, 12, 0);

test('the next occurrence of a weekday is today when it falls today, else the next one', () => {
assert.equal(nextOccurrence(0, MONDAY_NOON), '2026-10-12');
assert.equal(nextOccurrence(2, MONDAY_NOON), '2026-10-14');
assert.equal(nextOccurrence(6, MONDAY_NOON), '2026-10-18');
});

test('the next occurrence crosses a month end', () => {
assert.equal(nextOccurrence(1, new Date(2026, 9, 31, 9, 0)), '2026-11-03');
});

test('a card of another group opens the booking page prefilled with the slot when the user has a group to book from', () => {
const card = { groupId: '5', slug: 'groupe-tiers', isMember: false, canBook: true, weekday: '2', startTime: '18:30', endTime: '22:45' };
assert.equal(destinationFor(card, MONDAY_NOON), '/bookings?date=2026-10-14&start=18%3A30&end=22%3A45');
});

test('a person without any group still opens the conversation page', () => {
assert.equal(destinationFor({ groupId: '5', slug: 'g', isMember: false, canBook: false, weekday: '2', startTime: '18:30', endTime: '22:45' }, MONDAY_NOON), '/messages/new/5');
});

test('a card with an unreadable weekday or time falls back to the conversation page rather than a broken prefill', () => {
assert.equal(destinationFor({ groupId: '5', slug: 'g', isMember: false, canBook: true, weekday: 'x', startTime: '18:30', endTime: '22:45' }, MONDAY_NOON), '/messages/new/5');
assert.equal(destinationFor({ groupId: '5', slug: 'g', isMember: false, canBook: true, weekday: '2', startTime: '', endTime: '22:45' }, MONDAY_NOON), '/messages/new/5');
});

test('Enter and Space activate a card, any other key does not', () => {
assert.equal(isActivationKey('Enter'), true);
assert.equal(isActivationKey(' '), true);
Expand Down
9 changes: 7 additions & 2 deletions public/assets/js/planning/rb-planning-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
* <rb-planning-card> (#330) — une carte du planning fixe (Light DOM) qui mène au groupe qu'elle montre : clic, ou Entrée / Espace
* au clavier. Le serveur rend le contenu et les attributs ; le composant ne construit rien.
*
* <rb-planning-card class="rb-planning-card" role="button" tabindex="0" group-id="5" group-name="…" group-slug="…" weekday="2" [member]>
* <rb-planning-card class="rb-planning-card" role="button" tabindex="0" group-id="5" group-name="…" group-slug="…" weekday="2" start-time="18:30" end-time="22:45" [member] [can-book]>
*
* `member` est posé par le serveur quand l'utilisateur appartient au groupe (la carte ouvre alors son espace). L'activation émet
* `member` est posé par le serveur quand l'utilisateur appartient au groupe (la carte ouvre alors son espace) ; `can-book` quand il a
* un groupe pour réserver (la carte d'un autre groupe ouvre alors « Réserver le local » rempli avec ce créneau). L'activation émet
* `planning-card:open` { url } (annulable, remonte) puis navigue, sauf si l'évènement est annulé : c'est le point d'extension et
* ce qui rend le comportement testable sans quitter la page. Plus d'écouteur global sur le document : une carte insérée plus tard
* (fragment du serveur) s'active toute seule.
Expand All @@ -31,6 +32,10 @@ if (typeof HTMLElement !== 'undefined') {
groupId: this.getAttribute('group-id') ?? '',
slug: this.getAttribute('group-slug') ?? '',
isMember: this.hasAttribute('member'),
canBook: this.hasAttribute('can-book'),
weekday: this.getAttribute('weekday') ?? '',
startTime: this.getAttribute('start-time') ?? '',
endTime: this.getAttribute('end-time') ?? '',
});
const proceed = this.dispatchEvent(new CustomEvent('planning-card:open', { detail: { url }, bubbles: true, cancelable: true }));
if (proceed) {
Expand Down
5 changes: 4 additions & 1 deletion src/Planning/Controller/BookingPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use App\Group\Repository\GroupRepositoryInterface;
use App\Security\AuthGuard;
use App\Security\CsrfTokenManager;
use App\Http\Request;
use App\Planning\Presenter\BookingPrefill;
use App\Planning\Service\FreeSlotBookingPolicy;
use App\Planning\Service\FreeSlotBookingService;
use App\View\TemplateRendererInterface;
Expand All @@ -26,7 +28,7 @@ public function __construct(
) {
}

public function index(): Response
public function index(Request $request): Response
{
$user = $this->authGuard->requireLogin();

Expand All @@ -44,6 +46,7 @@ public function index(): Response
'minDate' => $today->format('Y-m-d'),
'maxDate' => $today->modify('+' . FreeSlotBookingPolicy::MAX_DAYS_AHEAD . ' days')->format('Y-m-d'),
'currentUserRole' => $user->role(),
'prefill' => BookingPrefill::from(['date' => $request->query('date'), 'start' => $request->query('start'), 'end' => $request->query('end')], $today),
]));
}
}
51 changes: 51 additions & 0 deletions src/Planning/Presenter/BookingPrefill.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Planning\Presenter;

use App\Planning\Service\FreeSlotBookingPolicy;
use App\Support\QuarterHour;

/**
* Remplissage de « Réserver le local » (#457) : un lien depuis le planning peut porter `date`, `start` et `end`. Rien n'est cru sur
* parole : tout ce qui n'est pas un jour réel de la fenêtre de réservation et une plage au quart d'heure, dans les bornes de la
* politique, est ignoré (la page s'ouvre alors vide, comme avant). Le serveur revérifie de toute façon à la réservation.
*/
final class BookingPrefill
{
/**
* @param array<string, mixed> $query
*
* @return array{date: string, start: string, end: string}|null
*/
public static function from(array $query, \DateTimeImmutable $today): ?array
{
$date = $query['date'] ?? null;
$start = $query['start'] ?? null;
$end = $query['end'] ?? null;
if (!is_string($date) || !is_string($start) || !is_string($end)) {
return null;
}

$day = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($day === false || $day->format('Y-m-d') !== $date) { // « 2026-02-31 » déborde : refusé
return null;
}
$first = $today->setTime(0, 0);
if ($day < $first || $day > $first->modify('+' . FreeSlotBookingPolicy::MAX_DAYS_AHEAD . ' days')) {
return null;
}

foreach ([$start, $end] as $time) {
if (preg_match('/^\d{2}:\d{2}$/', $time) !== 1 || !QuarterHour::isAligned($time)) {
return null;
}
}
if ($end <= $start || $end . ':00' > FreeSlotBookingPolicy::MAX_END_TIME) {
return null;
}

return ['date' => $date, 'start' => $start, 'end' => $end];
}
}
8 changes: 5 additions & 3 deletions templates/bookings/_time-select.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,23 @@
* @var string $label début ou fin, pour les noms accessibles des listes
* @var string $min horaire minimal « HH:MM »
* @var string $max horaire maximal « HH:MM »
* @var string $value horaire de départ « HH:MM » (liens depuis le planning, #457) ; vide : rien de choisi
*/
$value = $value ?? '';
?>
<rb-time-picker class="rb-time-picker" data-min="<?= e($min) ?>" data-max="<?= e($max) ?>">
<input type="hidden" name="<?= e($name) ?>" value="">
<input type="hidden" name="<?= e($name) ?>" value="<?= e($value) ?>">
<select id="<?= e($id) ?>" class="rb-input rb-select" data-time-hours aria-label="<?= e($label) ?> : heures" required>
<option value="">--</option>
<?php for ($hour = 0; $hour < 24; ++$hour): ?>
<option value="<?= e(sprintf('%02d', $hour)) ?>"><?= e(sprintf('%02d', $hour)) ?></option>
<option value="<?= e(sprintf('%02d', $hour)) ?>"<?= $value !== '' && substr($value, 0, 2) === sprintf('%02d', $hour) ? ' selected' : '' ?>><?= e(sprintf('%02d', $hour)) ?></option>
<?php endfor; ?>
</select>
<span class="rb-time-picker-colon" aria-hidden="true">:</span>
<select class="rb-input rb-select" data-time-minutes aria-label="<?= e($label) ?> : minutes" required>
<option value="">--</option>
<?php foreach (\App\Support\QuarterHour::range('00:00', '00:45') as $minute): ?>
<option value="<?= e(substr($minute, 3)) ?>"><?= e(substr($minute, 3)) ?></option>
<option value="<?= e(substr($minute, 3)) ?>"<?= $value !== '' && substr($value, 3, 2) === substr($minute, 3) ? ' selected' : '' ?>><?= e(substr($minute, 3)) ?></option>
<?php endforeach; ?>
</select>
</rb-time-picker>
9 changes: 5 additions & 4 deletions templates/bookings/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @var list<array{id: int, name: string, bookings: list<array<string, mixed>>}> $groups
* @var string $minDate
* @var string $maxDate
* @var array{date: string, start: string, end: string}|null $prefill créneau d'un lien depuis le planning (#457), déjà validé
* @var \App\Account\Entity\UserRole $currentUserRole
*/
?>
Expand All @@ -29,7 +30,7 @@
<?php if ($groups === []): ?>
<p class="rb-bookings-empty">Vous devez appartenir à un groupe pour réserver le local.</p>
<?php else: ?>
<rb-booking-form class="rb-booking-form rb-card">
<rb-booking-form class="rb-booking-form rb-card"<?= $prefill !== null ? ' prefilled' : '' ?>>
<?php if (count($groups) === 1): ?>
<input type="hidden" name="groupId" value="<?= e((string) $groups[0]['id']) ?>">
<p class="rb-booking-form-group">Groupe : <strong><?= e($groups[0]['name']) ?></strong></p>
Expand All @@ -45,16 +46,16 @@
<?php endif; ?>
<div class="rb-field">
<label for="booking-date">Jour</label>
<input type="date" id="booking-date" name="date" class="rb-input" min="<?= e($minDate) ?>" max="<?= e($maxDate) ?>" required>
<input type="date" id="booking-date" name="date" class="rb-input" min="<?= e($minDate) ?>" max="<?= e($maxDate) ?>" value="<?= e($prefill['date'] ?? '') ?>" required>
</div>
<div class="rb-booking-form-times">
<div class="rb-field">
<label for="booking-start">De</label>
<?php $id = 'booking-start'; $name = 'start'; $label = 'Début'; $min = '00:00'; $max = '23:45'; require __DIR__ . '/_time-select.php'; ?>
<?php $id = 'booking-start'; $name = 'start'; $label = 'Début'; $min = '00:00'; $max = '23:45'; $value = $prefill['start'] ?? ''; require __DIR__ . '/_time-select.php'; ?>
</div>
<div class="rb-field">
<label for="booking-end">À</label>
<?php $id = 'booking-end'; $name = 'end'; $label = 'Fin'; $min = '00:15'; $max = substr(\App\Planning\Service\FreeSlotBookingPolicy::MAX_END_TIME, 0, 5); require __DIR__ . '/_time-select.php'; ?>
<?php $id = 'booking-end'; $name = 'end'; $label = 'Fin'; $min = '00:15'; $max = substr(\App\Planning\Service\FreeSlotBookingPolicy::MAX_END_TIME, 0, 5); $value = $prefill['end'] ?? ''; require __DIR__ . '/_time-select.php'; ?>
</div>
</div>
<div class="rb-field">
Expand Down
Loading
Loading