diff --git a/.claude/architecture.md b/.claude/architecture.md index ebb53035..f77355f4 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -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. `` demande son plan dès la connexion. Sans groupe, ou créneau illisible : repli sur `/messages/new/{id}`. ### Suivi unifié des demandes (#292) diff --git a/.claude/frontend.md b/.claude/frontend.md index cf1cec3f..81f9d267 100644 --- a/.claude/frontend.md +++ b/.claude/frontend.md @@ -71,7 +71,7 @@ Améliore le champ texte `#rrggbb` rendu par le serveur (sans JS il fonctionne s ## Cartes : `` et `` (#330) -- **``** (`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 `
`. +- **``** (`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 `
`. - **``** (`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 ``. - Les deux sont `display: block` (comme les `
` qu'elles remplacent) avec `[hidden]` qui reste prioritaire (le paquet masque les cartes passées par l'attribut). diff --git a/public/assets/js/planning/booking/rb-booking-form.js b/public/assets/js/planning/booking/rb-booking-form.js index 3c1bf987..d41d8222 100644 --- a/public/assets/js/planning/booking/rb-booking-form.js +++ b/public/assets/js/planning/booking/rb-booking-form.js @@ -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() { diff --git a/public/assets/js/planning/planning-card.js b/public/assets/js/planning/planning-card.js index 281f72cb..94b1e398 100644 --- a/public/assets/js/planning/planning-card.js +++ b/public/assets/js/planning/planning-card.js @@ -2,14 +2,47 @@ * Logique pure de (#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. */ diff --git a/public/assets/js/planning/planning-card.test.js b/public/assets/js/planning/planning-card.test.js index 02b03c44..d4971ac8 100644 --- a/public/assets/js/planning/planning-card.test.js +++ b/public/assets/js/planning/planning-card.test.js @@ -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'); @@ -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); diff --git a/public/assets/js/planning/rb-planning-card.js b/public/assets/js/planning/rb-planning-card.js index d40413c1..c7e36c49 100644 --- a/public/assets/js/planning/rb-planning-card.js +++ b/public/assets/js/planning/rb-planning-card.js @@ -2,9 +2,10 @@ * (#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. * - * + * * - * `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. @@ -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) { diff --git a/src/Planning/Controller/BookingPageController.php b/src/Planning/Controller/BookingPageController.php index 74a62ba9..b3bf7555 100644 --- a/src/Planning/Controller/BookingPageController.php +++ b/src/Planning/Controller/BookingPageController.php @@ -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; @@ -26,7 +28,7 @@ public function __construct( ) { } - public function index(): Response + public function index(Request $request): Response { $user = $this->authGuard->requireLogin(); @@ -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), ])); } } diff --git a/src/Planning/Presenter/BookingPrefill.php b/src/Planning/Presenter/BookingPrefill.php new file mode 100644 index 00000000..cd3da158 --- /dev/null +++ b/src/Planning/Presenter/BookingPrefill.php @@ -0,0 +1,51 @@ + $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]; + } +} diff --git a/templates/bookings/_time-select.php b/templates/bookings/_time-select.php index 766fd6b4..72013b10 100644 --- a/templates/bookings/_time-select.php +++ b/templates/bookings/_time-select.php @@ -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 ?? ''; ?> - + diff --git a/templates/bookings/index.php b/templates/bookings/index.php index 7f51f18e..9d5bedf6 100644 --- a/templates/bookings/index.php +++ b/templates/bookings/index.php @@ -7,6 +7,7 @@ * @var list>}> $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 */ ?> @@ -29,7 +30,7 @@

Vous devez appartenir à un groupe pour réserver le local.

- + >

Groupe :

@@ -45,16 +46,16 @@
- +
- +
- +
diff --git a/templates/dashboard/index.php b/templates/dashboard/index.php index 781d566c..282109d0 100644 --- a/templates/dashboard/index.php +++ b/templates/dashboard/index.php @@ -40,7 +40,7 @@ $slot = $requestableSlot->slot(); $groupRole = $currentUserGroupRoles[$requestableSlot->groupId()] ?? null; ?> - > + > Aujourd’hui

groupName()) ?>

weekday())) ?>

diff --git a/templates/mail/announcement.html.php b/templates/mail/announcement.html.php index 420ec157..f08377d5 100644 --- a/templates/mail/announcement.html.php +++ b/templates/mail/announcement.html.php @@ -28,6 +28,6 @@

À quoi ça sert

1. Voir le planning. Les créneaux du local apparaissent dans l'appli : vous voyez d'un coup d'œil ce qui est pris et ce qui est libre.

2. Réserver un créneau libre. Un créneau disponible se réserve directement depuis l'appli, et votre réservation y apparaît aussitôt.

-

3. Demander un échange de créneau. Un créneau vous intéresse mais il est pris ? Contactez le groupe concerné depuis l'appli : la demande d'échange lui apparaît dans l'appli.

+

3. Demander un échange de créneau. Un créneau vous intéresse mais il est pris ? Passez par « Réserver le local » : l'appli envoie une demande d'échange au groupe concerné, qui la voit dans l'appli et peut l'accepter ou la refuser.

4. Écrire aux autres membres. La messagerie permet d'échanger entre membres directement dans l'appli.

diff --git a/templates/mail/announcement.txt.php b/templates/mail/announcement.txt.php index 6ba42d53..b9749633 100644 --- a/templates/mail/announcement.txt.php +++ b/templates/mail/announcement.txt.php @@ -21,5 +21,5 @@ À QUOI ÇA SERT 1. Voir le planning. Les créneaux du local apparaissent dans l'appli : vous voyez d'un coup d'œil ce qui est pris et ce qui est libre. 2. Réserver un créneau libre. Un créneau disponible se réserve directement depuis l'appli, et votre réservation y apparaît aussitôt. -3. Demander un échange de créneau. Un créneau vous intéresse mais il est pris ? Contactez le groupe concerné depuis l'appli : la demande d'échange lui apparaît dans l'appli. +3. Demander un échange de créneau. Un créneau vous intéresse mais il est pris ? Passez par « Réserver le local » : l'appli envoie une demande d'échange au groupe concerné, qui la voit dans l'appli et peut l'accepter ou la refuser. 4. Écrire aux autres membres. La messagerie permet d'échanger entre membres directement dans l'appli. diff --git a/tests/Mail/MailRendererTest.php b/tests/Mail/MailRendererTest.php index 4fcdb7cf..e7f0cf69 100644 --- a/tests/Mail/MailRendererTest.php +++ b/tests/Mail/MailRendererTest.php @@ -105,7 +105,9 @@ public function testAnnouncementExplainsTheAimTheTwoWaysToLogInAndTheFourMainFea self::assertStringContainsString('Mot de passe oublié', $body, 'activation du compte'); self::assertStringContainsString('déjà', $body, 'compte déjà configuré : rien à refaire'); self::assertStringContainsString('créneau libre', $body, 'réservation'); - self::assertStringContainsString('échange', $body, 'demande par contact du groupe'); + self::assertStringContainsString('demande d\'échange', $body); + self::assertStringContainsString('Réserver le local', $body, 'la demande passe par la réservation, pas par un message'); + self::assertStringNotContainsString('Contactez le groupe', $body); self::assertStringContainsString('messagerie', $body); self::assertStringContainsString('https://b27.example/forgot-password', $body); self::assertStringContainsString('https://b27.example/login', $body); diff --git a/tests/Planning/Controller/BookingPageControllerTest.php b/tests/Planning/Controller/BookingPageControllerTest.php index 0b80a850..0c1fc90e 100644 --- a/tests/Planning/Controller/BookingPageControllerTest.php +++ b/tests/Planning/Controller/BookingPageControllerTest.php @@ -76,7 +76,7 @@ public function testItRequiresALogin(): void { $this->expectException(UnauthenticatedException::class); - $this->controller->index(); + $this->controller->index($this->request()); } #[Test] @@ -84,7 +84,7 @@ public function testAMemberSeesTheFormForTheirGroupWithLabelledFieldsAndTheQuart { $this->login('alice'); - $html = $this->controller->index()->body(); + $html = $this->controller->index($this->request())->body(); self::assertStringContainsString('name="csrf-token"', $html); self::assertStringContainsString('groups->addMember($beta->id(), $this->aliceId); $this->login('alice'); - $html = $this->controller->index()->body(); + $html = $this->controller->index($this->request())->body(); self::assertStringContainsString('Beta', $html); @@ -130,7 +130,7 @@ public function testTheGroupsBookingsAreListedWithTheirStateTheNoteAndACancelBut $this->bookings->decide($refused->id(), FreeSlotBookingStatus::Refusee, $adminId, 'Local fermé', new \DateTimeImmutable('2026-10-04 12:00:00')); $this->login('alice'); - $html = $this->controller->index()->body(); + $html = $this->controller->index($this->request())->body(); self::assertSame(2, substr_count($html, 'login('seul'); - $html = $this->controller->index()->body(); + $html = $this->controller->index($this->request())->body(); self::assertStringContainsString('appartenir à un groupe', $html); self::assertStringNotContainsString('login('alice'); - $html = $this->controller->index()->body(); + $html = $this->controller->index($this->request())->body(); preg_match_all('/]*>.*?<\/rb-time-picker>/s', $html, $pickers); self::assertCount(2, $pickers[0]); @@ -185,4 +185,38 @@ private static function values(string $select): array return $found[1]; } + + /** @param array $query */ + private function request(array $query = []): \App\Http\Request + { + return new \App\Http\Request('GET', '/bookings', $query, [], []); + } + + #[Test] + public function testALinkFromThePlanningOpensTheFormFilledWithTheSlot(): void + { + $this->login('alice'); + $date = (new \DateTimeImmutable('today'))->modify('+2 days')->format('Y-m-d'); + + $html = $this->controller->index($this->request(['date' => $date, 'start' => '18:30', 'end' => '22:45']))->body(); + + self::assertStringContainsString('value="' . $date . '"', $html); + self::assertStringContainsString('name="start" value="18:30"', $html); + self::assertStringContainsString('name="end" value="22:45"', $html); + self::assertStringContainsString('