diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 268c91609..3339563e2 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -3127,12 +3127,6 @@ 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/Session/EditAction.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Cannot clone DateTime\\|null\\.$#', - 'identifier' => 'clone.nonObject', - 'count' => 2, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/Session/EditAction.php', -]; $ignoreErrors[] = [ 'message' => '#^Method AppBundle\\\\Controller\\\\Admin\\\\Event\\\\Session\\\\EditAction\\:\\:getForm\\(\\) has parameter \\$roomChoices with no value type specified in iterable type array\\.$#', 'identifier' => 'missingType.iterableValue', diff --git a/sources/AppBundle/Controller/Admin/Event/Session/CalendarAjaxAction.php b/sources/AppBundle/Controller/Admin/Event/Session/CalendarAjaxAction.php index f0ca4191c..89772f6e8 100644 --- a/sources/AppBundle/Controller/Admin/Event/Session/CalendarAjaxAction.php +++ b/sources/AppBundle/Controller/Admin/Event/Session/CalendarAjaxAction.php @@ -4,6 +4,7 @@ namespace AppBundle\Controller\Admin\Event\Session; +use AppBundle\Event\Model\Planning; use AppBundle\Event\Model\Repository\PlanningRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -21,8 +22,12 @@ public function __invoke(int $id, Request $request): Response } $data = $request->toArray(); - $planning->setStart(new \DateTime($data['start'])); - $planning->setEnd(new \DateTime($data['end'])); + // Le calendrier envoie l'heure telle qu'affichée, sans décalage : + // c'est donc l'heure locale de l'événement, pas celle du navigateur. + $timezone = new \DateTimeZone(Planning::TIMEZONE); + + $planning->setStart(new \DateTime($data['start'], $timezone)); + $planning->setEnd(new \DateTime($data['end'], $timezone)); $planning->setRoomId((int) $data['roomId']); $this->planningRepository->save($planning); diff --git a/sources/AppBundle/Controller/Admin/Event/Session/EditAction.php b/sources/AppBundle/Controller/Admin/Event/Session/EditAction.php index 3c210b9d0..f49806c72 100644 --- a/sources/AppBundle/Controller/Admin/Event/Session/EditAction.php +++ b/sources/AppBundle/Controller/Admin/Event/Session/EditAction.php @@ -48,8 +48,8 @@ public function __invoke(Request $request): Response $planning = new Planning(); $planning->setTalkId($talk->getId()); $planning->setEventId($event->getId()); - $planning->setStart(clone $event->getDateStart()); - $planning->setEnd(clone $event->getDateStart()); + $planning->setStart($this->firstDayOfEvent($event)); + $planning->setEnd($this->firstDayOfEvent($event)); } $form = $this->getForm($planning, $roomChoices); @@ -95,9 +95,11 @@ private function getForm(Planning $data, array $roomChoices): FormInterface return $this->createFormBuilder($data) ->add('start', DateTimeType::class, [ 'label' => 'Début', + 'view_timezone' => Planning::TIMEZONE, ]) ->add('end', DateTimeType::class, [ 'label' => 'Fin', + 'view_timezone' => Planning::TIMEZONE, ]) ->add('roomId', ChoiceType::class, [ 'label' => 'Salle', @@ -111,6 +113,17 @@ private function getForm(Planning $data, array $roomChoices): FormInterface } + /** + * Minuit le premier jour de l'événement, dans la timezone où le planning est saisi. + */ + private function firstDayOfEvent(Event $event): \DateTime + { + return new \DateTime( + $event->getDateStart()?->format('Y-m-d') ?? 'today', + new \DateTimeZone(Planning::TIMEZONE), + ); + } + /** * @return array */ diff --git a/sources/AppBundle/Controller/Admin/Event/Session/IndexAction.php b/sources/AppBundle/Controller/Admin/Event/Session/IndexAction.php index 4e61ffd9a..fa4835d9d 100644 --- a/sources/AppBundle/Controller/Admin/Event/Session/IndexAction.php +++ b/sources/AppBundle/Controller/Admin/Event/Session/IndexAction.php @@ -6,13 +6,13 @@ use AppBundle\Event\AdminEventSelection; use AppBundle\Event\Model\Event; +use AppBundle\Event\Model\Planning; use AppBundle\Event\Model\Repository\RoomRepository; use AppBundle\Event\Model\Repository\TalkRepository; use AppBundle\Event\Model\Room; use AppBundle\Event\Model\Session\CalendarEvent; use AppBundle\Event\Model\Session\CalendarResource; use AppBundle\Event\Model\TalkAggregate; -use DateTimeInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -38,6 +38,7 @@ public function __invoke(Request $request, AdminEventSelection $eventSelection): 'events' => $this->calendarEvents($sessions), 'resources' => $this->calendarResources($event), ], + 'timezone' => Planning::TIMEZONE, ]); } @@ -69,6 +70,8 @@ private function calendarResources(Event $event): array */ private function calendarEvents(array $sessions): array { + $timezone = new \DateTimeZone(Planning::TIMEZONE); + $events = []; foreach ($sessions as $session) { if (!$session->planning || !$session->room || !$session->planning->getStart() || !$session->planning->getEnd()) { @@ -77,12 +80,23 @@ private function calendarEvents(array $sessions): array $events[] = new CalendarEvent( $session->planning->getId(), $session->talk->getTitle(), - $session->planning->getStart()->format(DateTimeInterface::ATOM), - $session->planning->getEnd()->format(DateTimeInterface::ATOM), + $this->formatForCalendar($session->planning->getStart(), $timezone), + $this->formatForCalendar($session->planning->getEnd(), $timezone), $session->room->getId(), ); } return $events; } + + /** + * Le calendrier ne gère pas les timezones : il interprète la date reçue telle + * qu'elle est écrite. On lui transmet donc l'heure locale de l'événement, sans décalage. + */ + private function formatForCalendar(\DateTimeInterface $date, \DateTimeZone $timezone): string + { + return \DateTimeImmutable::createFromInterface($date) + ->setTimezone($timezone) + ->format('Y-m-d\TH:i:s'); + } } diff --git a/sources/AppBundle/Event/Model/Planning.php b/sources/AppBundle/Event/Model/Planning.php index d6c90da7a..c46a90b9c 100644 --- a/sources/AppBundle/Event/Model/Planning.php +++ b/sources/AppBundle/Event/Model/Planning.php @@ -11,6 +11,14 @@ class Planning implements NotifyPropertyInterface { use NotifyProperty; + + /** + * Les horaires sont stockés en base sous forme de timestamp, donc absolus. + * Les événements de l'AFUP se déroulant en France, ils sont saisis et affichés + * dans cette timezone, quelle que soit celle du serveur ou du navigateur. + */ + public const string TIMEZONE = 'Europe/Paris'; + private ?int $id = null; #[Assert\NotBlank] diff --git a/templates/event/session/index.html.twig b/templates/event/session/index.html.twig index 2ffd5ee83..52f1b2871 100644 --- a/templates/event/session/index.html.twig +++ b/templates/event/session/index.html.twig @@ -27,14 +27,24 @@ const $el = $('#status'); $el.hide(); + // event-calendar 4.7.1 construit ses Date en UTC (Date.UTC(...)) à partir des + // chiffres affichés sur la grille : il faut donc relire ces chiffres via les + // getters UTC, pas les getters locaux qui dépendent de la timezone du navigateur. + function toLocalDateTime(date) { + const pad = (value) => String(value).padStart(2, '0'); + + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; + } + async function updateEvent(info) { const url = `/admin/event/sessions/${info.event.id}.json`; const response = await fetch(url, { method: "POST", body: JSON.stringify({ - start: info.event.start, - end: info.event.end, + start: toLocalDateTime(info.event.start), + end: toLocalDateTime(info.event.end), roomId: info.event.resourceIds[0], }), }); @@ -106,8 +116,8 @@
{% if session.planning %} - {{ session.planning.start|date("d/m/Y") }} / - {{ session.planning.start|date("H:i") }} - {{ session.planning.end|date("H:i") }} / {{ session.room ? session.room.name }} + {{ session.planning.start|date("d/m/Y", timezone) }} / + {{ session.planning.start|date("H:i", timezone) }} - {{ session.planning.end|date("H:i", timezone) }} / {{ session.room ? session.room.name }} {% else %} non planifié {% endif %}