mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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:
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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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% »
|
||||
|
||||
@@ -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%”'
|
||||
|
||||
Reference in new issue
Block a user