Draw the calendar on your own clock; calendars follow plMail's timezone (#347)

Closes #343

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 authored and GitHub committed 2026-10-24 18:00:13 +02:00
1 parent b80b264ccf
commit 6e565fdbb7
34 files changed
+613 -176

No files matched your search

@@ -0,0 +1,8 @@
**The calendar follows your timezone.** After you changed your timezone in Settings, the header
clock moved but the calendar grid, its current-time line and "today" stayed on the old zone, so
events sat hours away from the clock. The calendar is now always drawn on your own clock, and every
event shows at the moment it happens for you. A calendar's own time zone in **Settings →
Calendars** now only decides which zone a new event on it is written in, and it starts as **Follow
plMail**; calendars plMail made for you are set to follow, and ones subscribed from Google or a
CalDAV server keep the zone their server reports. Schema change: `calendar.time_zone` becomes
nullable. [#343](https://github.com/karatektus/pl_mail/issues/343), reported by @KieranBeals.
+15 -11
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/calendar.md sha1:d781321a4c7533e5b2770a4250b64e69f3cfee39 -->
<!-- translated-from: features/calendar.md sha1:b293d951b77d4bb00035776790dd9e8bf7517a7e -->
# Kalender
@@ -166,14 +166,13 @@ stimmt.
**Das Raster wird auf deiner Uhr gezeichnet.** Die Stunden an der Seite, die Stelle, an der die
Linie der aktuellen Zeit sitzt, und die Zeit, die für einen neuen Termin vorgeschlagen wird, wenn du
auf leeren Raum doppelklickst, folgen alle der Zeitzone aus **Einstellungen → Allgemein**. Das ist
dieselbe Zone, gegen die jeder andere Zeitstempel in der App gelesen wird. Trägt ein Kalender eine
eigene Zeitzone, gilt sie für seine Termine, wie eh und je; geändert hat sich der Rückfall, der
früher die Vorstellung des Containers von der Zeit war — UTC —, sodass die Linie der aktuellen Zeit
ein bis zwei Stunden neben ihren eigenen Beschriftungen sitzen konnte und ein neuer Termin für einen
bereits vergangenen Zeitpunkt vorgeschlagen wurde. Ein Kalender, den du von Google oder einem
CalDAV-Server abonnierst, ist die Ausnahme: Seine Zone ist die, die das entfernte Konto für ihn
führt, bei einem geteilten Kalender oft UTC; ist so ein Kalender dein Standardkalender, folgt das
Raster trotzdem deiner eigenen Zone.
dieselbe Zone, gegen die jeder andere Zeitstempel in der App gelesen wird, und die der Uhr oben,
also sind sich beide immer einig. Jeder Termin steht zu dem Zeitpunkt da, zu dem er bei dir
tatsächlich stattfindet: Ein Termin um 15:00 in einem Tokioter Kalender liegt, in Berlin gelesen,
auf der Linie von 07:00. Änderst du deine Zeitzone, zieht der Kalender mit. Bis plMail 0.4.0 folgte
das Raster stattdessen der eigenen Zone deines Standardkalenders, und die war eine Kopie deiner Zone
vom Tag, an dem der Kalender angelegt wurde — nach einem Wechsel der Zeitzone waren sich Kalender und
Uhr deshalb uneinig ([#343](https://github.com/karatektus/pl_mail/issues/343)).
Drei Dinge über das Raster sind es wert, gewusst zu werden:
@@ -331,8 +330,13 @@ deshalb das, was der Editor voreingestellt meint: Jede beschreibbare Kopie zieht
Unter **Einstellungen → Kalender** werden Kalender angelegt, umbenannt, umgefärbt, mit einer Zeitzone
versehen, aus den Ansichten ausgeblendet und zu dem ernannt, in dem neue Termine landen. Die Zeitzone
eines Kalenders ist die, in der er gelesen wird, und die, in der ein Termin ohne eigene Zone
angezeigt wird.
eines Kalenders ist die, in der ein neuer Termin darin angelegt wird; wo Termine angezeigt werden,
ändert sie nicht, das ist immer deine eigene Uhr. Sie steht zuerst auf **plMail folgen**, also auf
deiner eigenen Zeitzone, wie sie gerade ist, und die Liste schreibt bei so einem Kalender *Folgt
plMail*. Wähl stattdessen eine Zone für einen Kalender, der woanders hingehört, etwa einen für ein
Büro in einem anderen Land: Ein Zeitfenster, auf das du doppelklickst, öffnet sich dann zum selben
Zeitpunkt, geschrieben in dieser Zone. Ein Kalender, den du von Google oder einem CalDAV-Server
abonnierst, behält die Zone, die das entfernte Konto für ihn führt.
plMail legt zwei Arten von Kalender für dich an: einen Standardkalender je Benutzer und einen je
E-Mail-Konto, in den die Termine einsortiert werden, die in der Mail dieses Kontos gefunden wurden.
+9 -6
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/calendar-model.md sha1:28f6c0c193efafdb492b2b38d8d0882acf3798e1 -->
<!-- translated-from: internals/calendar-model.md sha1:6aa31c45c745b646677a21691573dc9931f7699d -->
# Das Kalendermodell
JSCalendar in jsonb, mit den abfragbaren Teilen in Spalten herausgezogen; die Tabelle der
@@ -262,11 +262,14 @@ sich in UTC aus, was floating gerade bedeutet — überall dieselbe Uhr an der W
`App\Service\Calendar\CalendarTimeResolver` besitzt die andere Hälfte: in welcher Uhrzeit an der
Wand ein Kalender *gelesen* wird, und wie die Ziffern, die ein Browser abschickt, wieder zu
Zeitpunkten werden. Die Zone kommt vom **Kalender**, nicht aus dem Profil der Nutzerin —
`UserTimezoneResolver` beantwortet „auf welche Uhr schaut diese Person?", was für einen
gerenderten Zeitstempel richtig ist, während die eigene Zone eines Kalenders die ist, in der ein
Termin ohne eigene Zone gespeichert und gezeigt wird. Die beiden dürfen ehrlich auseinandergehen:
ein geteilter Arbeitskalender, festgenagelt auf die Zeit im Büro. Jedes Parsen dort ist total —
Zeitpunkten werden. Zwei Uhren, auseinandergehalten (#343). Eine Seite wird auf der Uhr der
Leserin **gezeichnet** — `zoneFor()`, also die Antwort von `UserTimezoneResolver` und dieselbe Uhr,
die oben und in jedem `|date` gilt. Die eigene Zone eines Kalenders (`Calendar::$timeZone`, gelesen
über `calendarZone()`) entscheidet nur, wie eine Zeit ohne Zone **geschrieben** wird:
`newEventZone()` liefert die des Standardkalenders, für einen neuen Termin. Null in dieser Spalte
heißt „plMail folgen", die Zone der Besitzerin im Moment der Frage; so beginnt jeder Kalender, den
plMail anlegt, und ein entfernter Kalender behält die Zone, die sein Gegenüber gemeldet hat. Jedes
Parsen dort ist total —
eine unbrauchbare Zone oder ein nicht parsbares Datum liefert einen Rückfallwert oder null,
statt zu werfen, denn all das kommt aus einem Request.
+13 -8
View File
@@ -148,13 +148,13 @@ all move to the new day — so a calendar left open beside the mail stays true.
**The grid is drawn on your clock.** The hours down the side, where the red current-time line sits,
and the time a new event is proposed at when you double-click empty space all follow the timezone in
**Settings → General**. That is the same zone every other timestamp in the app is read against.
Where a calendar carries a time zone of its own it wins for the events on it, as it always has; what
changed is the fallback, which used to be the container's own idea of the time — UTC — so the
current-time line could sit an hour or two away from the labels beside it and a new event was
proposed at a slot already in the past. A calendar you subscribe to from Google or a CalDAV server
is the exception: its zone is the one the remote account holds for it, often UTC for a shared
calendar, so when such a calendar is your default the grid still follows your own zone.
**Settings → General**. That is the same zone every other timestamp in the app is read against,
and the header clock, so the two always agree. Every event shows at the moment it actually happens
for you: a 15:00 meeting in a Tokyo calendar, read in Berlin, is on the 07:00 line. Change your
timezone and the calendar moves with it. Until plMail 0.4.0 the grid followed your default
calendar's own zone instead, which was a copy of yours from the day that calendar was made, so after
a change of timezone the calendar and the clock disagreed
([#343](https://github.com/karatektus/pl_mail/issues/343)).
Three things about the grid are worth knowing:
@@ -299,7 +299,12 @@ every writable copy moves.
**Settings → Calendars** is where calendars are created, renamed, recoloured, given a time zone,
hidden from the views, and nominated as the one new events land on. A calendar's time zone is the
one it is read in, and the one an event carrying no zone of its own is shown in.
one a new event on it is written in; it does not change where events are shown, which is always on
your own clock. It starts as **Follow plMail**, which means your own timezone whatever it is at the
moment, and the list says *Follows plMail* for such a calendar. Pick a zone instead for a calendar
that belongs somewhere else, such as one for an office in another country: a slot you double-click
then opens at the same moment written in that zone. A calendar you subscribe to from Google or a
CalDAV server keeps the zone the remote account holds for it.
plMail creates two kinds of calendar for you: one default calendar per user, and one per mail
account, which is where events found in that account's mail are filed. Neither can be deleted —
+9 -7
View File
@@ -232,13 +232,15 @@ UTC, which is what floating means — the same wall clock everywhere.
`isAllDay` is true, so the two cannot be set inconsistently by a caller.
`App\Service\Calendar\CalendarTimeResolver` owns the other half: which wall clock a calendar
is *read* in, and how the digits a browser posts turn back into instants. The zone comes from
the **calendar**, not from the user's profile — `UserTimezoneResolver` answers "what clock is
this person reading?", which is right for a rendered timestamp, while a calendar's own zone
is what an event with none of its own is stored and shown in. The two can honestly differ: a
shared work calendar pinned to the office. Every parse there is total — an unusable zone or
an unparseable date returns a fallback or null rather than throwing, because all of it
arrives from a request.
is *read* in, and how the digits a browser posts turn back into instants. Two clocks, kept
apart (#343). A page is **drawn** on the reader's — `zoneFor()`, which is
`UserTimezoneResolver`'s answer and the same clock the header and every `|date` use. A
calendar's own zone (`Calendar::$timeZone`, read through `calendarZone()`) only decides how a
time with no zone attached is **written**: `newEventZone()` gives the default calendar's, for a
new event. Null in that column means "follow plMail", the owner's zone at the moment it is
asked; it is what every calendar plMail makes starts as, and a remote calendar keeps the zone
its remote reported. Every parse there is total — an unusable zone or an unparseable date
returns a fallback or null rather than throwing, because all of it arrives from a request.
## One meeting, two rows
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Let a calendar follow its owner's zone instead of keeping a copy of it (#343).
*
* Every calendar this install made was stamped with the owner's zone on the day
* it was made, and nothing updated it when they changed theirs; the calendar
* page was drawn in that copy, so after a change the grid and the header clock
* disagreed. Null now means "follow plMail".
*
* Every calendar plMail owns goes back to following, including one whose zone
* somebody did pick in the editor. That cannot be told apart from a copy — both
* are just a zone name in the column — and a copy is by far the common case,
* since the picker was the one place to change it and it sat under the name
* and colour. Mirrored calendars keep theirs: it came from the remote, which
* is a real answer about that calendar.
*/
final class Version20261024120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Calendars follow the owner\'s timezone unless one is picked; local calendars go back to following.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE calendar ALTER time_zone DROP DEFAULT');
$this->addSql('ALTER TABLE calendar ALTER time_zone DROP NOT NULL');
// Calendar::isSynced(), in SQL.
$this->addSql("UPDATE calendar SET time_zone = NULL WHERE NOT (role = 'remote' AND remote_id IS NOT NULL)");
}
public function down(Schema $schema): void
{
// The owner's zone is not known here (it may itself be null, meaning the
// install default), so a following calendar goes back to UTC, which is
// what the column defaulted to before.
$this->addSql("UPDATE calendar SET time_zone = 'UTC' WHERE time_zone IS NULL");
$this->addSql('ALTER TABLE calendar ALTER time_zone SET NOT NULL');
$this->addSql("ALTER TABLE calendar ALTER time_zone SET DEFAULT 'UTC'");
}
}
+11 -11
View File
@@ -11,6 +11,7 @@ use App\Repository\Calendar\CalendarEventRepository;
use App\Repository\Calendar\CalendarRepository;
use App\Repository\User\UserRepository;
use App\Service\Calendar\CalendarEventWriter;
use App\Service\User\UserTimezoneResolver;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\ORM\EntityManagerInterface;
@@ -36,12 +37,10 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* has to be lifted out of the hours, and a series whose occurrences raise the
* this-one-or-all question.
*
* **Times are the CALENDAR's wall clock, not UTC**, and the events are seeded
* on the user's default calendar rather than one of this fixture's own. Both
* follow from what the grid reads: it draws against the default calendar's zone
* (CalendarRangeReader::zoneOf), so a fixture written as a UTC instant would sit
* at the hour the spec expects only on an install running in UTC, and one on a
* calendar of its own would draw in that calendar's zone instead.
* **Times are the USER's wall clock, not UTC**, and the events are seeded on
* the user's default calendar. The grid draws against the user's own zone
* (CalendarTimeResolver::zoneFor), so a fixture written as a UTC instant would
* sit at the hour the spec expects only on an install running in UTC.
*
* "Today" rather than "tomorrow", so a spec can reach the events at
* /calendar/day with no date in it and the two cannot disagree about which day
@@ -95,6 +94,7 @@ final class SeedGridEventsCommand extends Command
private readonly CalendarRepository $calendarRepository,
private readonly CalendarEventRepository $events,
private readonly CalendarEventWriter $writer,
private readonly UserTimezoneResolver $timezones,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {
@@ -243,12 +243,12 @@ final class SeedGridEventsCommand extends Command
$this->entityManager->flush();
}
/**
* The owner's zone: the clock every calendar page is drawn on (#343),
* whatever zone the calendar itself carries.
*/
private function zoneOf(Calendar $calendar): DateTimeZone
{
try {
return new DateTimeZone($calendar->timeZone);
} catch (\Exception) {
return new DateTimeZone('UTC');
}
return $this->timezones->resolve($calendar->usr);
}
}
+8 -6
View File
@@ -11,6 +11,7 @@ use App\Repository\Calendar\CalendarEventRepository;
use App\Repository\Calendar\CalendarRepository;
use App\Repository\User\UserRepository;
use App\Service\Calendar\CalendarEventWriter;
use App\Service\User\UserTimezoneResolver;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\ORM\EntityManagerInterface;
@@ -67,6 +68,7 @@ final class SeedShareEventCommand extends Command
private readonly CalendarRepository $calendarRepository,
private readonly CalendarEventRepository $events,
private readonly CalendarEventWriter $writer,
private readonly UserTimezoneResolver $timezones,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {
@@ -120,7 +122,7 @@ final class SeedShareEventCommand extends Command
return Command::SUCCESS;
}
// The CALENDAR's wall clock, not UTC — the share page draws against the
// The owner's wall clock, not UTC — the share page draws against the
// owner's zone, so a UTC instant would land at the hour the spec expects
// only on an install running in UTC.
$zone = $this->zoneOf($calendar);
@@ -162,12 +164,12 @@ final class SeedShareEventCommand extends Command
$this->entityManager->flush();
}
/**
* The owner's zone: the clock every calendar page is drawn on (#343),
* whatever zone the calendar itself carries.
*/
private function zoneOf(Calendar $calendar): DateTimeZone
{
try {
return new DateTimeZone($calendar->timeZone);
} catch (\Exception) {
return new DateTimeZone('UTC');
}
return $this->timezones->resolve($calendar->usr);
}
}
+5 -1
View File
@@ -19,6 +19,7 @@ use App\Service\Calendar\Dav\DavReportRequest;
use App\Service\Calendar\Dav\MultiStatusBuilder;
use App\Domain\Exception\CalendarSyncException;
use App\Service\Calendar\CalendarEventWriter;
use App\Service\Calendar\CalendarTimeResolver;
use App\Service\Calendar\Ics\IcsExporter;
use App\Service\Calendar\Ics\IcsImporter;
use Doctrine\ORM\EntityManagerInterface;
@@ -82,6 +83,7 @@ final class CalDavController extends AbstractController
private readonly IcsImporter $importer,
private readonly CalendarEventWriter $writer,
private readonly EntityManagerInterface $em,
private readonly CalendarTimeResolver $time,
) {
}
@@ -616,7 +618,9 @@ final class CalDavController extends AbstractController
'd:displayname' => $calendar->name,
'ical:calendar-color' => $calendar->color,
'ical:calendar-order' => (string) $calendar->sortOrder,
'c:calendar-timezone-id' => $calendar->timeZone,
// The zone in effect, never null: a client has nothing to do with
// "follow plMail" but read it as the owner's zone.
'c:calendar-timezone-id' => $this->time->calendarZone($calendar)->getName(),
'cs:getctag' => $token,
'd:sync-token' => $token,
'c:supported-calendar-component-set' => function (XMLWriter $w): void {
+10 -4
View File
@@ -210,9 +210,15 @@ final class CalendarController extends AbstractController
public function eventNew(Request $request): Response
{
$user = $this->currentUser();
$zone = $this->time->zoneFor($user);
$zone = $this->time->newEventZone($user);
$startsAt = $this->startFor($request->query->getString('start'), $zone);
// The slot was clicked on a grid drawn on the user's own clock, so it
// is read on that clock and only then moved into the zone the event
// is written in. They are the same zone unless the default calendar
// has one picked; when it does, 14:00 clicked in Berlin opens as 21:00
// in Tokyo, which is the same moment.
$startsAt = $this->startFor($request->query->getString('start'), $this->time->zoneFor($user))
->setTimezone($zone);
$this->ensureCalendars($user);
@@ -346,7 +352,7 @@ final class CalendarController extends AbstractController
$eventId = $request->request->getInt('eventId');
$event = 0 === $eventId ? null : $this->ownedEvent($eventId);
$zoneName = $request->request->getString('timeZone') ?: $this->time->zoneFor($user)->getName();
$zoneName = $request->request->getString('timeZone') ?: $this->time->newEventZone($user)->getName();
$isAllDay = $request->request->getBoolean('isAllDay');
// An all-day event is FLOATING: the same wall-clock day everywhere, no
@@ -1156,7 +1162,7 @@ final class CalendarController extends AbstractController
*/
private function refused(Request $request, ?CalendarEvent $event, User $user, array $errors): Response
{
$zoneName = $request->request->getString('timeZone') ?: $this->time->zoneFor($user)->getName();
$zoneName = $request->request->getString('timeZone') ?: $this->time->newEventZone($user)->getName();
$zone = $this->time->safeZone($zoneName);
// Each falls back to something a datetime-local can render, because
@@ -102,7 +102,8 @@ final class CalendarSettingsController extends AbstractController
$calendar = $this->blank($user);
$form = $this->createForm(CalendarType::class, $calendar, [
'action' => $this->generateUrl('app_settings_calendar_new'),
'action' => $this->generateUrl('app_settings_calendar_new'),
'owner_zone' => $this->timezones->nameFor($user),
]);
$form->handleRequest($request);
@@ -123,7 +124,8 @@ final class CalendarSettingsController extends AbstractController
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $calendar);
$form = $this->createForm(CalendarType::class, $calendar, [
'action' => $this->generateUrl('app_settings_calendar_edit', ['id' => $calendar->id]),
'action' => $this->generateUrl('app_settings_calendar_edit', ['id' => $calendar->id]),
'owner_zone' => $this->timezones->nameFor($calendar->usr),
]);
$form->handleRequest($request);
@@ -478,8 +480,8 @@ final class CalendarSettingsController extends AbstractController
/**
* A new calendar, already carrying the answers a user should not have to
* give: their own zone rather than the server's, and the next colour along
* so two calendars made in a row do not look like one.
* give: following their own zone, and the next colour along so two
* calendars made in a row do not look like one.
*/
private function blank(User $user): Calendar
{
@@ -487,7 +489,7 @@ final class CalendarSettingsController extends AbstractController
$calendar = new Calendar();
$calendar->usr = $user;
$calendar->timeZone = $this->timezones->nameFor($user);
$calendar->timeZone = null;
$calendar->color = Calendar::COLORS[count($siblings) % count(Calendar::COLORS)];
$calendar->sortOrder = count($siblings);
+13 -4
View File
@@ -116,11 +116,20 @@ class Calendar
public string $color = self::DEFAULT_COLOR;
/**
* IANA zone this calendar is displayed in. Events carry their own; this is
* the fallback for ones that do not, and the zone a new event starts in.
* IANA zone an event on this calendar is written in when it carries none
* of its own, and the zone a new one starts in. Null is "follow plMail":
* whatever the owner's display zone is at the moment it is asked, which is
* what every calendar this install makes starts as (#343).
*
* Not the zone the calendar is DRAWN in. Every page is drawn on the
* reader's own clock — see CalendarTimeResolver::zoneFor() — so a meeting
* at 15:00 in Tokyo shows at the hour it happens for the person reading.
*
* Read through CalendarTimeResolver::calendarZone(), never directly: null
* has no meaning without the owner's zone beside it.
*/
#[ORM\Column(length: 64, options: ['default' => 'UTC'])]
public string $timeZone = 'UTC';
#[ORM\Column(length: 64, nullable: true)]
public ?string $timeZone = null;
#[ORM\Column(length: 20, enumType: CalendarRole::class, options: ['default' => 'custom'])]
public CalendarRole $role = CalendarRole::Custom;
+18 -6
View File
@@ -52,12 +52,20 @@ final class CalendarType extends AbstractType
'choice_translation_domain' => false,
])
->add('timeZone', ChoiceType::class, [
'label' => 'calendar.form.time_zone',
'choices' => $this->zoneChoices(),
// The zone an event with none of its own is read in, which is
// not obvious from a field labelled "Time zone" sitting under
// a name.
'help' => 'calendar.form.time_zone_help',
'label' => 'calendar.form.time_zone',
'choices' => $this->zoneChoices(),
// The empty option, and the default: null in the column, the
// owner's zone whenever it is asked (#343). Not required, so
// choosing it submits null rather than failing validation.
'required' => false,
'placeholder' => 'calendar.form.time_zone_follow',
// The zone a new event starts in, which is not obvious from a
// field labelled "Time zone" sitting under a name. It names the
// zone "follow" currently means, since the option cannot.
'help' => 'calendar.form.time_zone_help',
'help_translation_parameters' => ['%zone%' => $options['owner_zone']],
// IANA names are their own labels; see `color` above.
'choice_translation_domain' => false,
]);
}
@@ -66,6 +74,10 @@ final class CalendarType extends AbstractType
$resolver->setDefaults([
'data_class' => Calendar::class,
]);
// The zone "Follow plMail" stands for, named in the help text.
$resolver->setRequired('owner_zone');
$resolver->setAllowedTypes('owner_zone', 'string');
}
/**
+3 -1
View File
@@ -11,6 +11,7 @@ use App\Entity\User\User;
use App\Jmap\Protocol\Exception\MethodException;
use App\Repository\Calendar\CalendarRepository;
use App\Service\Calendar\CalendarEventWriter;
use App\Service\Calendar\CalendarTimeResolver;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
@@ -90,6 +91,7 @@ final class JmapEventWriter
private readonly CalendarRepository $calendars,
private readonly CalendarEventWriter $writer,
private readonly EntityManagerInterface $entityManager,
private readonly CalendarTimeResolver $time,
) {
}
@@ -331,7 +333,7 @@ final class JmapEventWriter
private function zoneName(array $values, Calendar $calendar): ?string
{
if (false === array_key_exists('timeZone', $values)) {
return $calendar->timeZone;
return $this->time->calendarZone($calendar)->getName();
}
if (null === $values['timeZone']) {
+9 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Jmap\Mapper;
use App\Entity\Calendar\Calendar;
use App\Service\Calendar\CalendarTimeResolver;
/**
* Maps a plMail Calendar onto the JMAP Calendar object this server publishes.
@@ -32,6 +33,11 @@ use App\Entity\Calendar\Calendar;
*/
final class CalendarMapper
{
public function __construct(
private readonly CalendarTimeResolver $time,
) {
}
/**
* @param list<string>|null $properties
*
@@ -73,7 +79,9 @@ final class CalendarMapper
'sortOrder' => $calendar->sortOrder,
'isVisible' => $calendar->isVisible,
'isDefault' => $calendar->isDefault,
'timeZone' => $calendar->timeZone,
// The zone in effect. A calendar following plMail stores null, which
// JMAP would read as floating time — see CalendarTimeResolver.
'timeZone' => $this->time->calendarZone($calendar)->getName(),
// plMail extension: what the calendar is FOR, which is what decides
// where an extracted event lands. A client cannot derive it and a
// user cannot change it, but "the calendar this account's mail files
@@ -570,7 +570,7 @@ final readonly class ConfigBackupUserRestorer
$calendar->integration = $integrations[$this->number($row, 'integrationId') ?? 0] ?? null;
$calendar->name = (string) ($this->text($row, 'name') ?? '');
$calendar->color = (string) ($this->text($row, 'color') ?? Calendar::DEFAULT_COLOR);
$calendar->timeZone = (string) ($this->text($row, 'timeZone') ?? 'UTC');
$calendar->timeZone = $this->text($row, 'timeZone');
$calendar->role = CalendarRole::tryFrom((string) $this->text($row, 'role')) ?? CalendarRole::Custom;
$calendar->isVisible = true === ($row['isVisible'] ?? true);
$calendar->isDefault = true === ($row['isDefault'] ?? false);
+6 -8
View File
@@ -9,7 +9,6 @@ use App\Entity\Calendar\Calendar;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Repository\Calendar\CalendarRepository;
use App\Service\User\UserTimezoneResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -36,7 +35,6 @@ final readonly class CalendarProvisioner
public function __construct(
private CalendarRepository $calendars,
private EntityManagerInterface $em,
private UserTimezoneResolver $timezones,
private TranslatorInterface $translator,
) {
}
@@ -70,11 +68,11 @@ final readonly class CalendarProvisioner
$calendar->role = CalendarRole::Default;
$calendar->isDefault = true;
$calendar->color = Calendar::COLORS[0];
// The user's zone, not the process's: PHP's default is pinned to UTC
// in this container, so seeding from it stamped every calendar ever
// provisioned UTC regardless of who owned it — and a calendar's zone is
// what an event with no zone of its own is read in.
$calendar->timeZone = $this->timezones->nameFor($user);
// Following the user's zone rather than a copy of it. A copy is what
// #343 was: the zone the user had on the day the calendar was made,
// kept after they changed theirs. And never the process's, which is
// pinned to UTC in this container.
$calendar->timeZone = null;
$calendar->sortOrder = 0;
$this->em->persist($calendar);
@@ -120,7 +118,7 @@ final readonly class CalendarProvisioner
// first — see Calendar::COLORS.
$calendar->color = Calendar::COLORS[count($siblings) % count(Calendar::COLORS)];
// See ensureDefault() — same reason.
$calendar->timeZone = $this->timezones->nameFor($user);
$calendar->timeZone = null;
$calendar->sortOrder = count($siblings);
$this->em->persist($calendar);
+41 -37
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Calendar;
use App\Entity\Calendar\Calendar;
use App\Entity\Calendar\CalendarEvent;
use App\Entity\User\User;
use App\Repository\Calendar\CalendarRepository;
@@ -20,13 +21,17 @@ use DateTimeZone;
* it, and choosing the wrong one moves the event by the offset without ever
* looking like an error.
*
* The zone comes from the calendar rather than from the user's profile, and
* falls back to the profile when no calendar answers. UserTimezoneResolver
* answers "what clock is this person reading?", which is the right question for
* a rendered timestamp; a calendar's own zone is what an event with none of its
* own is stored and shown in, and the two can honestly differ — a shared work
* calendar pinned to the office. What is never right is falling back past both
* of them to the process's zone; see zoneFor().
* Two clocks, kept apart on purpose (#343). A page is DRAWN on the reader's
* own clock — UserTimezoneResolver's answer, the one the header clock and every
* `|date` use — so an event shows at the hour it actually happens for them,
* whatever zone it or its calendar is in. A calendar's own zone only decides
* how a time with no zone attached is WRITTEN: a new event's, and the fallback
* for an event stored without one. That zone is null, "follow plMail", unless
* somebody picked one, which makes it the user's zone for every calendar
* nobody set.
*
* What is never right is falling back past both to the process's zone, which
* frankenphp/conf.d/10-app.ini pins to UTC.
*
* Every parse is total: an unusable zone or an unparseable date returns a
* fallback or null rather than throwing, because all of it arrives from a
@@ -41,42 +46,41 @@ final readonly class CalendarTimeResolver
}
/**
* The zone a user's calendar pages and new events are read in.
* The clock a user's calendar pages are drawn on: their own.
*
* The default calendar's zone when there is one, and the user's OWN
* configured zone when there is not — never `date_default_timezone_get()`.
* That fallback is the defect this method used to carry, and it is the exact
* one UserTimezoneResolver's header warns about: frankenphp/conf.d/10-app.ini
* pins PHP's default to UTC, which is right for arithmetic and catastrophic
* as a display default.
*
* A user with no default calendar is not exotic — the flag is per-user and
* nothing re-asserts it, so a calendar deleted or provisioned before the
* flag existed leaves the account without one. Every such account had its
* whole calendar drawn in UTC: the current-time line two hours off the
* gutter it is read against, and a new event proposed at the next full UTC
* hour, which for a Berlin user in summer is a slot already an hour past.
* Neither looks like an error; both are just wrong by the offset.
*
* **A mirrored calendar does not get to pick the clock.** Its zone was copied
* from the remote account when it was subscribed, and for a shared Google
* calendar that is routinely UTC — a choice somebody made for a different
* purpose, not this reader's. Letting it win drew the whole page in UTC the
* moment such a calendar became the default: an 11:30 Europe/Paris
* appointment, stored correctly as 09:30 UTC, was printed as 09:30. Only a
* calendar this install owns (isSynced() false) carries a zone somebody chose
* for reading it. The guard does NOT cover a mirrored calendar whose owner
* wants it read in its remote zone — there is no setting for that yet.
* It used to be the default calendar's zone when that calendar was local.
* Every local calendar's zone was a copy of the user's, taken the day the
* calendar was made, and nothing ever updated it — so changing the zone in
* settings moved the header clock and every timestamp in the app but left
* the calendar grid, its now-line and "today" on the old one (#343). A
* calendar's zone is about writing times down, not reading them; see the
* class docblock.
*/
public function zoneFor(User $user): DateTimeZone
{
return $this->timezones->resolve($user);
}
/**
* The zone a calendar writes zone-less times in: its own when one was
* picked, its owner's when it follows plMail.
*/
public function calendarZone(Calendar $calendar): DateTimeZone
{
return null === $calendar->timeZone
? $this->timezones->resolve($calendar->usr)
: $this->safeZone($calendar->timeZone);
}
/**
* The zone a new event starts in when nothing names one: the default
* calendar's, which is where it lands unless the editor is told otherwise.
*/
public function newEventZone(User $user): DateTimeZone
{
$calendar = $this->calendars->findDefaultForUser($user);
if (null === $calendar || true === $calendar->isSynced()) {
return $this->safeZone($this->timezones->nameFor($user));
}
return $this->safeZone($calendar->timeZone);
return null === $calendar ? $this->zoneFor($user) : $this->calendarZone($calendar);
}
/**
+3 -1
View File
@@ -6,6 +6,7 @@ namespace App\Service\Calendar\Ics;
use App\Entity\Calendar\Calendar;
use App\Entity\Calendar\CalendarEvent;
use App\Service\Calendar\CalendarTimeResolver;
use App\Service\Calendar\Sync\CalDav\CalDavEventConverter;
use DateTimeImmutable;
use Sabre\VObject\Component\VCalendar;
@@ -83,6 +84,7 @@ final readonly class IcsExporter
public function __construct(
private CalDavEventConverter $converter,
private VTimeZoneBuilder $timeZones,
private CalendarTimeResolver $time,
) {
}
@@ -172,7 +174,7 @@ final readonly class IcsExporter
$envelope = new VCalendar(['PRODID' => CalDavEventConverter::PRODUCT_ID]);
$envelope->add(IcsDocumentReader::NAME_PROPERTY, $calendar->name);
$envelope->add(IcsDocumentReader::TIME_ZONE_PROPERTY, $calendar->timeZone);
$envelope->add(IcsDocumentReader::TIME_ZONE_PROPERTY, $this->time->calendarZone($calendar)->getName());
return $envelope;
}
@@ -19,7 +19,6 @@ use App\Repository\Calendar\CalendarEventRepository;
use App\Repository\Calendar\CalendarRepository;
use App\Service\Calendar\CalendarProvisioner;
use App\Service\Push\PushTeardown;
use App\Service\User\UserTimezoneResolver;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -96,7 +95,6 @@ final readonly class CalendarSubscriber
private CalendarRepository $calendars,
private CalendarEventRepository $events,
private CalendarProvisioner $provisioner,
private UserTimezoneResolver $timezones,
private EntityManagerInterface $em,
private MessageBusInterface $bus,
private LoggerInterface $logger,
@@ -288,7 +286,9 @@ final readonly class CalendarSubscriber
$calendar->isReadOnly = $remote->isReadOnly;
$calendar->name = $this->nameOf($remote);
$calendar->color = $this->colorOf($remote, count($siblings));
$calendar->timeZone = $remote->timeZone ?? $this->timezones->nameFor($user);
// The remote's zone when it states one; otherwise the calendar
// follows the user's, as a local one does.
$calendar->timeZone = $remote->timeZone;
$calendar->sortOrder = count($siblings);
$this->em->persist($calendar);
@@ -17,6 +17,7 @@ use App\Domain\Interface\CalendarSyncDriverInterface;
use App\Entity\Calendar\Calendar;
use App\Entity\Calendar\CalendarEvent;
use App\Entity\Mail\Account;
use App\Service\User\UserTimezoneResolver;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
@@ -109,6 +110,7 @@ final readonly class GoogleCalendarSyncDriver implements CalendarSyncDriverInter
private GoogleCalendarApiClient $api,
private GoogleEventMapper $mapper,
private LoggerInterface $logger,
private UserTimezoneResolver $timezones,
) {
}
@@ -175,7 +177,9 @@ final readonly class GoogleCalendarSyncDriver implements CalendarSyncDriverInter
$body = $this->api->get($account, $path, $this->pullQuery($syncToken, $pageToken), 'events.list');
foreach ($this->itemsOf($body) as $item) {
$event = $this->mapper->toRemoteEvent($item, $calendar->timeZone);
// CalendarTimeResolver::calendarZone() spelled out, without the
// repository that resolver carries for its other questions.
$event = $this->mapper->toRemoteEvent($item, $calendar->timeZone ?? $this->timezones->nameFor($calendar->usr));
if (null === $event) {
continue;
+4 -1
View File
@@ -127,7 +127,10 @@ Variables:
</span>
{% endif %}
<span class="text-[10px] text-ink-faint">{{ calendar.timeZone }}</span>
{# Null is "follow plMail" (#343): said in words rather
than resolved to a zone name, because the name would
read as one this calendar is pinned to. #}
<span class="text-[10px] text-ink-faint">{{ calendar.timeZone ?? 'settings.calendars.zone_follows'|trans }}</span>
</span>
{# Only for a calendar that mirrors something. A local list
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Controller\Calendar;
use App\Domain\Enum\Calendar\CalendarRole;
use App\Domain\Enum\User\ClockFormat;
use App\Entity\Calendar\Calendar;
use App\Entity\Calendar\CalendarEvent;
use App\Entity\Calendar\CalendarEventOccurrence;
@@ -145,7 +146,10 @@ final class CalendarTimeGridTest extends WebTestCase
{
$client = $this->signIn();
$this->calendar->timeZone = 'Europe/Berlin';
// Pinned somewhere else on purpose: the grid is the reader's clock,
// not the calendar's (#343).
$this->calendar->timeZone = 'America/New_York';
$this->user->timezone = 'Europe/Berlin';
$this->em->flush();
$event = $this->oneOff();
@@ -162,26 +166,24 @@ final class CalendarTimeGridTest extends WebTestCase
/**
* And so does the time PRINTED in it, which is a different code path and
* was on a different clock.
* was once on a different clock: a block sat on the 15:00 line with
* "17:30" written inside it.
*
* The block is positioned against the calendar's zone; the chip inside it
* printed through Twig's `|date`, which converts to whatever
* TwigTimezoneSubscriber last set — the reader's own. Where the two differ,
* and they do for every calendar provisioned before its zone was seeded from
* its owner, a block sat on the 15:00 line with "17:30" written inside it.
* The grid was right; the label was on another clock.
*
* The fixture makes them differ deliberately: the calendar reads Berlin and
* the user reads UTC, which is the shape of the report — a legacy UTC
* calendar under a Berlin reader — with the two swapped so the assertion
* can name one and not the other.
* Both are the reader's clock now, whatever zone the calendar has (#343).
* It used to be the default calendar's zone, a copy of the owner's taken
* the day the calendar was made, so a user who later changed theirs had a
* header clock and a grid that disagreed. The fixture pins the calendar to
* Berlin and the reader to Tokyo, so the assertion can name one and not
* the other.
*/
public function testTheTimeDrawnInABlockIsOnTheSameClockAsItsPosition(): void
public function testTheTimeDrawnInABlockIsOnTheReadersClock(): void
{
$client = $this->signIn();
$this->calendar->timeZone = 'Europe/Berlin';
$this->user->timezone = 'UTC';
$this->user->timezone = 'Asia/Tokyo';
// A 24-hour chip, so "18:00" cannot be read as "6:00" by either side.
$this->user->setSetting(User::SETTING_CLOCK, ClockFormat::TwentyFour->value);
$this->em->flush();
$event = $this->oneOff();
@@ -191,17 +193,18 @@ final class CalendarTimeGridTest extends WebTestCase
self::assertCount(1, $block, 'the event is on the grid');
$berlin = $this->start()->setTimezone(new DateTimeZone('Europe/Berlin'));
$tokyo = $this->start()->setTimezone(new DateTimeZone('Asia/Tokyo'));
self::assertSame($tokyo->format('Y-m-d\TH:i:s'), $block->attr('data-starts-at'), 'the block sits on the reader\'s clock');
self::assertStringContainsString(
$berlin->format('H:i'),
$tokyo->format('H:i'),
$block->text(),
'the chip prints the hour the block is drawn against',
);
self::assertStringNotContainsString(
$this->start()->format('H:i'),
$this->start()->setTimezone(new DateTimeZone('Europe/Berlin'))->format('H:i'),
$block->text(),
'and not the reader\'s, which is where the two hours came from',
'and not the calendar\'s',
);
}
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Calendar;
use App\Domain\Enum\Calendar\CalendarRole;
use App\Entity\Calendar\Calendar;
use App\Entity\User\User;
use App\Repository\Calendar\CalendarRepository;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* A calendar's zone is "follow plMail" unless somebody picks one (#343).
*
* The report: change your timezone in settings and the header clock moves but
* the calendar does not. Every calendar had been stamped with a copy of its
* owner's zone on the day it was made, and the page was drawn in that copy.
* The page is the reader's clock now — CalendarTimeGridTest pins that — and
* these pin the other half: what a calendar's own zone still decides, and how
* it is picked.
*
* As requests, because the form, the list and the editor each read the column
* and each has to understand null.
*/
final class CalendarZoneFollowTest extends WebTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
private Calendar $calendar;
protected function tearDown(): void
{
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
/** "Follow plMail" is the empty option, and saving it stores null. */
public function testANewCalendarFollowsPlMailByDefault(): void
{
$client = $this->signIn();
$crawler = $client->request('GET', '/settings/calendars/new');
self::assertResponseIsSuccessful();
$form = $crawler->filter('form')->form();
self::assertSame('', $form['calendar[timeZone]']->getValue(), 'the empty option is the one chosen');
self::assertStringContainsString('Asia/Tokyo', $crawler->filter('form')->text(), 'the help names what following means today');
$form['calendar[name]'] = 'Following';
$client->submit($form);
self::assertNull($this->calendarNamed('Following')->timeZone);
}
/** Picking a zone stores it, and picking "Follow plMail" again clears it. */
public function testAPickedZoneIsStoredAndCanBeCleared(): void
{
$client = $this->signIn();
$this->edit($client, 'America/New_York');
self::assertSame('America/New_York', $this->reload()->timeZone);
$this->edit($client, '');
self::assertNull($this->reload()->timeZone);
}
/** The list says "follows" rather than printing a zone it is not pinned to. */
public function testTheListSaysAFollowingCalendarFollows(): void
{
$client = $this->signIn();
$crawler = $client->request('GET', '/settings?section=calendars');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Follows plMail', $crawler->filter('body')->text());
}
/**
* A new event on a following calendar starts in the owner's zone, read
* from wherever their zone is NOW — the copy taken at creation is what the
* report was.
*/
public function testANewEventOnAFollowingCalendarOpensInTheOwnersCurrentZone(): void
{
$client = $this->signIn();
$this->user->timezone = 'America/Los_Angeles';
$this->em->flush();
$crawler = $client->request('GET', '/calendar/event/new?start=2026-11-02 14:00');
self::assertResponseIsSuccessful();
self::assertSame('America/Los_Angeles', $crawler->filter('input[name="timeZone"]')->attr('value'));
self::assertSame('2026-11-02T14:00', $crawler->filter('input[name="startsAt"]')->attr('value'));
}
/**
* A calendar with a zone picked writes new events in it. The slot was
* clicked on the reader's clock, so it opens at the same MOMENT in the
* calendar's zone: 14:00 in Tokyo is 06:00 in Berlin in November.
*/
public function testANewEventOnAPinnedCalendarOpensAtTheClickedMomentInItsZone(): void
{
$client = $this->signIn();
$this->calendar->timeZone = 'Europe/Berlin';
$this->em->flush();
$crawler = $client->request('GET', '/calendar/event/new?start=2026-11-02 14:00');
$clicked = new DateTimeImmutable('2026-11-02 14:00', new DateTimeZone('Asia/Tokyo'));
self::assertResponseIsSuccessful();
self::assertSame('Europe/Berlin', $crawler->filter('input[name="timeZone"]')->attr('value'));
self::assertSame(
$clicked->setTimezone(new DateTimeZone('Europe/Berlin'))->format('Y-m-d\TH:i'),
$crawler->filter('input[name="startsAt"]')->attr('value'),
);
}
private function edit(KernelBrowser $client, string $zone): void
{
$crawler = $client->request('GET', '/settings/calendars/' . $this->calendar->id . '/edit');
self::assertResponseIsSuccessful();
$form = $crawler->filter('form')->form();
$form['calendar[timeZone]'] = $zone;
$client->submit($form);
self::assertResponseIsSuccessful();
}
private function reload(): Calendar
{
$this->em->clear();
$calendar = $this->em->find(Calendar::class, $this->calendar->id);
self::assertInstanceOf(Calendar::class, $calendar);
return $calendar;
}
private function calendarNamed(string $name): Calendar
{
$this->em->clear();
$calendar = static::getContainer()->get(CalendarRepository::class)->findOneBy(['name' => $name]);
self::assertInstanceOf(Calendar::class, $calendar);
return $calendar;
}
private function signIn(): KernelBrowser
{
$client = static::createClient();
$client->disableReboot();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
// Never committed, so the suite leaves nothing behind.
$this->connection->beginTransaction();
$user = new User();
$user->email = 'zonefollow-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Zone';
$user->nameLast = 'Follow';
$user->roles = ['ROLE_USER'];
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
// Far from the install default, so following it cannot be mistaken
// for following the server.
$user->timezone = 'Asia/Tokyo';
$this->em->persist($user);
$calendar = new Calendar();
$calendar->usr = $user;
$calendar->name = 'Zone fixture';
$calendar->role = CalendarRole::Default;
$calendar->isDefault = true;
$this->em->persist($calendar);
$this->em->flush();
$this->user = $user;
$this->calendar = $calendar;
$client->loginUser($user);
return $client;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Tests\Jmap\Mapper;
use App\Entity\Calendar\Calendar;
use App\Entity\User\User;
use App\Jmap\Mapper\CalendarMapper;
use App\Repository\Calendar\CalendarRepository;
use App\Service\Calendar\CalendarTimeResolver;
use App\Service\User\UserTimezoneResolver;
use PHPUnit\Framework\TestCase;
/**
* A calendar following plMail stores null (#343), and JMAP reads a null
* timeZone as floating time, so the published zone is the one in effect.
*/
final class CalendarMapperZoneTest extends TestCase
{
public function testAFollowingCalendarPublishesItsOwnersZone(): void
{
$user = new User();
$user->timezone = 'Asia/Tokyo';
$calendar = new Calendar();
$calendar->usr = $user;
self::assertSame('Asia/Tokyo', $this->mapper()->toJmap($calendar, ['timeZone'])['timeZone']);
}
public function testAPinnedCalendarPublishesItsOwnZone(): void
{
$calendar = new Calendar();
$calendar->usr = new User();
$calendar->timeZone = 'America/New_York';
self::assertSame('America/New_York', $this->mapper()->toJmap($calendar, ['timeZone'])['timeZone']);
}
private function mapper(): CalendarMapper
{
return new CalendarMapper(new CalendarTimeResolver(
$this->createStub(CalendarRepository::class),
new UserTimezoneResolver('Europe/Berlin'),
));
}
}
+4 -4
View File
@@ -265,17 +265,17 @@ final class AllDayEventTest extends KernelTestCase
$user->nameLast = 'Day';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
// The page is drawn on the user's own clock — see
// CalendarTimeResolver::zoneFor() — so it is the one that has to be far
// from UTC for this fixture to mean anything.
$user->timezone = 'Pacific/Auckland';
$this->em->persist($user);
// The display zone comes off the DEFAULT calendar — see
// CalendarRangeReader::zoneOf() — so it is the one that has to be far
// from UTC for this fixture to mean anything.
$calendar = new Calendar();
$calendar->usr = $user;
$calendar->name = 'Auckland';
$calendar->role = CalendarRole::Default;
$calendar->isDefault = true;
$calendar->timeZone = 'Pacific/Auckland';
$this->em->persist($calendar);
$this->em->flush();
@@ -18,15 +18,51 @@ use PHPUnit\Framework\TestCase;
/**
* Getting a calendar's zone wrong does not look like an error — it moves every
* event by the local UTC offset and leaves a page that reads as if it worked.
* These pin the three places that decision is made.
* These pin the places that decision is made.
*/
final class CalendarTimeResolverTest extends TestCase
{
public function testTheCalendarsOwnZoneIsWhatItIsReadIn(): void
/**
* A calendar with a zone picked writes new events in it. Differs from the
* install default and the user's, so nothing but the calendar can have
* produced it.
*/
public function testANewEventStartsInTheDefaultCalendarsPickedZone(): void
{
$resolver = $this->resolver('Europe/Berlin');
$user = new User();
$user->timezone = 'Europe/Paris';
self::assertSame('Europe/Berlin', $resolver->zoneFor(new User())->getName());
self::assertSame('America/New_York', $this->resolver('America/New_York')->newEventZone($user)->getName());
}
/** And one that follows plMail writes them in its owner's. */
public function testANewEventOnAFollowingCalendarStartsInTheOwnersZone(): void
{
$user = new User();
$user->timezone = 'Europe/Paris';
$calendar = new Calendar();
$calendar->usr = $user;
self::assertSame('Europe/Paris', $this->resolverFor($calendar)->newEventZone($user)->getName());
}
/**
* "Follow plMail" is resolved when it is asked, not when the calendar was
* made — the copy taken at creation is what #343 was.
*/
public function testAFollowingCalendarTracksAChangeToTheOwnersZone(): void
{
$user = new User();
$user->timezone = 'Europe/Paris';
$calendar = new Calendar();
$calendar->usr = $user;
$resolver = $this->resolverFor($calendar);
self::assertSame('Europe/Paris', $resolver->calendarZone($calendar)->getName());
$user->timezone = 'Asia/Tokyo';
self::assertSame('Asia/Tokyo', $resolver->calendarZone($calendar)->getName());
}
/**
@@ -91,12 +127,14 @@ final class CalendarTimeResolverTest extends TestCase
}
/**
* Presence companion to the two above: a calendar this install owns is still
* read in its own zone, because there the zone is somebody's decision — a
* calendar pinned to an office clock. Differs from both the install default
* and the user's, so nothing but the calendar can have produced it.
* **Nor does a local calendar's zone move the page (#343).** It used to:
* every local calendar carried a copy of its owner's zone from the day it
* was made, the page was drawn in that copy, and a user who later changed
* theirs had a header clock and a calendar grid that disagreed. A picked
* zone decides how new events are written — see the tests above — and the
* page stays on the reader's clock.
*/
public function testALocalDefaultCalendarStillWinsOverTheUsersZone(): void
public function testALocalDefaultCalendarsZoneDoesNotMoveThePage(): void
{
$local = new Calendar();
$local->timeZone = 'America/New_York';
@@ -105,7 +143,7 @@ final class CalendarTimeResolverTest extends TestCase
$user->timezone = 'Europe/Paris';
self::assertSame(
'America/New_York',
'Europe/Paris',
$this->resolverFor($local, 'Europe/Berlin')->zoneFor($user)->getName(),
);
}
@@ -126,7 +164,10 @@ final class CalendarTimeResolverTest extends TestCase
*/
public function testAnUnknownZoneNameFallsBackToUtcRatherThanThrowing(): void
{
self::assertSame('UTC', $this->resolver('Mars/Olympus_Mons')->zoneFor(new User())->getName());
$calendar = new Calendar();
$calendar->timeZone = 'Mars/Olympus_Mons';
self::assertSame('UTC', $this->resolverFor($calendar)->calendarZone($calendar)->getName());
self::assertSame('UTC', $this->resolver(null)->safeZone('')->getName());
}
@@ -145,7 +186,7 @@ final class CalendarTimeResolverTest extends TestCase
}
/** Rows written before the column existed, and imports that omitted it. */
public function testAnEventWithNoZoneFallsBackToTheCalendars(): void
public function testAnEventWithNoZoneFallsBackToTheUsers(): void
{
$resolver = $this->resolver('Europe/Berlin');
@@ -242,9 +283,9 @@ final class CalendarTimeResolverTest extends TestCase
}
/**
* @param string|null $calendarZone null leaves the property unset, which is
* how a calendar seeded before the column
* existed reaches the resolver
* @param string|null $calendarZone null leaves the calendar following
* plMail, with no owner set — so it
* answers the install default
*/
private function resolver(?string $calendarZone): CalendarTimeResolver
{
@@ -19,6 +19,7 @@ use App\Service\Calendar\Sync\Google\GoogleCalendarSyncDriver;
use App\Service\Calendar\Sync\Google\GoogleEventMapper;
use App\Service\Calendar\Sync\Google\GoogleRecurrenceMapper;
use App\Service\OAuth\OAuthTokenManager;
use App\Service\User\UserTimezoneResolver;
use App\Tests\Service\Calendar\RecordingLogger;
use DateTimeImmutable;
use DateTimeZone;
@@ -80,6 +81,7 @@ final class GoogleDriverFixture
new GoogleCalendarApiClient($this->http, self::tokens()),
new GoogleEventMapper($recurrence, new AlertReader(new NullLogger())),
$this->logger,
new UserTimezoneResolver('Europe/Berlin'),
);
}
@@ -23,7 +23,7 @@ use ReflectionProperty;
* that day is local to is the same decision the calendar grid makes — so the
* dot has to ask the same resolver, not read the default calendar's column
* itself. It did, and a mirrored default calendar stamped UTC put the dot's
* "today" two hours off the grid it sits above.
* "today" two hours off the grid it sits above. Both are the user's own zone.
*
* Observed through the upper bound handed to the occurrence query, because that
* bound is the whole of what the zone decides here.
@@ -45,13 +45,12 @@ final class UpcomingEventIndicatorZoneTest extends TestCase
}
/**
* Presence companion: a calendar this install owns still decides. UTC here
* is a deliberate choice, so the same instant ends "today" at 00:00 UTC —
* which a resolver that ignored the calendar entirely could not produce.
* Nor does a local one, even with a zone picked (#343): the dot's "today"
* is the reader's, the same as the grid's and the header clock's.
*/
public function testALocalDefaultCalendarStillDecidesTodaysEnd(): void
public function testALocalDefaultCalendarDoesNotMoveTodaysEnd(): void
{
self::assertSame('2026-10-15 00:00:00', $this->windowEnd($this->calendar(1, 'UTC'), 'Europe/Paris'));
self::assertSame('2026-10-15 22:00:00', $this->windowEnd($this->calendar(1, 'UTC'), 'Europe/Paris'));
}
/** The upper bound the occurrence query was asked for, as a UTC wall string. */
+5 -2
View File
@@ -1475,9 +1475,11 @@ calendar:
edit: Kalender bearbeiten
name: Name
time_zone: Zeitzone
time_zone_follow: plMail folgen
time_zone_help: >-
Die Zone, in der dieser Kalender gelesen wird — und in der ein
Termin ohne eigene Zone angezeigt wird.
Die Zone, in der ein neuer Termin in diesem Kalender angelegt wird.
„plMail folgen“ nimmt deine eigene, gerade %zone%. Termine stehen
immer zu der Uhrzeit da, zu der sie bei dir stattfinden.
grid:
all_day: Ganztägig
continues_after: Geht in den nächsten Tag hinein
@@ -4306,6 +4308,7 @@ settings:
deinen Standardkalender um.
sync_now: '"%name%" jetzt abgleichen'
sync_now_title: Jetzt abgleichen
zone_follows: Folgt plMail
labels:
count: '%count% Labels'
delete: Label "%name%" löschen
+5 -2
View File
@@ -1454,9 +1454,11 @@ calendar:
edit: Edit calendar
name: Name
time_zone: Time zone
time_zone_follow: Follow plMail
time_zone_help: >-
The zone this calendar is read in, and the one an event that carries
no zone of its own is shown in.
The zone a new event on this calendar is written in. Follow plMail uses
your own, currently %zone%. Events always show at the time they
happen on your clock.
grid:
all_day: All day
continues_after: Carries on into the next day
@@ -4285,6 +4287,7 @@ settings:
to your default calendar first.
sync_now: Sync "%name%" now
sync_now_title: Sync now
zone_follows: Follows plMail
labels:
count: '%count% labels'
delete: Delete label "%name%"
+5 -2
View File
@@ -1372,9 +1372,11 @@ calendar:
edit: Amend the almanac
name: What it be called
time_zone: Which bell
time_zone_follow: 'Follow plMail''s bell'
time_zone_help: >-
The bell this almanac be read by — and the one a reckonin' with no
bell of its own be shown in.
The bell a new reckonin' on this almanac be written by. Followin'
plMail takes yer own, now %zone%. Reckonin's always show at the hour
they happen by yer own bell.
grid:
all_day: All the day long
continues_after: Sails on into the next day
@@ -4104,6 +4106,7 @@ settings:
yer own almanac first.
sync_now: Haul "%name%" now
sync_now_title: Haul now
zone_follows: 'Follows plMail''s bell'
labels:
count: '%count% flags'
delete: Strike the flag "%name%"
+5 -2
View File
@@ -1463,9 +1463,11 @@ calendar:
edit: Modifier l’agenda
name: Nom
time_zone: Fuseau horaire
time_zone_follow: Suivre plMail
time_zone_help: >-
Le fuseau dans lequel cet agenda est lu, et celui dans lequel s’affiche
un événement qui n’a pas de fuseau propre.
Le fuseau dans lequel un nouvel événement de cet agenda est créé.
« Suivre plMail » prend le vôtre, actuellement %zone%. Les événements
s’affichent toujours à l’heure où ils ont lieu pour vous.
grid:
all_day: Toute la journée
continues_after: Se poursuit le jour suivant
@@ -4342,6 +4344,7 @@ settings:
agenda par défaut.
sync_now: Synchroniser « %name% » maintenant
sync_now_title: Synchroniser maintenant
zone_follows: Suit plMail
labels:
count: '%count% libellés'
delete: Supprimer le libellé « %name% »
+3 -1
View File
@@ -1284,7 +1284,8 @@ calendar:
edit: 编辑日历
name: 名称
time_zone: 时区
time_zone_help: 显示此日历时使用的时区,也用于显示未指定时区的日程。
time_zone_follow: 跟随 plMail
time_zone_help: 此日历中新日程使用的时区。“跟随 plMail”即使用你自己的时区(当前为 %zone%)。日程始终按你所在时区的实际时间显示。
grid:
all_day: 全天
continues_after: 延续到次日
@@ -3645,6 +3646,7 @@ settings:
unsubscribe_note: '取消勾选会删除此处保存的日历副本。其中来自邮件而非日历本身的日程,会先移到默认日历。'
sync_now: '立即同步“%name%”'
sync_now_title: '立即同步'
zone_follows: 跟随 plMail
labels:
count: '%count% 个标签'
delete: '删除标签“%name%”'