From 65e68fcb0a03eabc7240276390be26ff70aae6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Fri, 16 Oct 2026 18:34:59 +0200 Subject: [PATCH] Calendar: an event that ends when it starts stays on its day (#198) An end equal to the start (or a zero DURATION) is a stated instant and now gets one minute instead of the nominal hour, so a 23:59 reminder no longer runs past midnight and shows again on the next day. The nominal hour for an event with no end at all now stops at the midnight of the day it starts on, in DTSTART's own zone. Closes #170 Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ .../Sync/CalDav/CalDavEventConverter.php | 72 ++++++++++++++++++- .../Sync/CalDav/CalDavEventConverterTest.php | 54 ++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cdc888..edd21d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ corner of the dialog, just below the dialog's X. Now there is only the dialog's: puts the screenshot back, pressed again it closes What's new, the same as Escape. [#187](https://github.com/karatektus/pl_mail/issues/187). +**An event that ends when it starts no longer runs into the next day.** A calendar feed or CalDAV +event whose end is its start, such as a reminder at 23:59, was given an hour, as if no end had been +stated. Other clients show it as 23:59–23:59, and here it ran past midnight and showed again on the +next day. It now gets one minute, which keeps it on its own day. An event with no end at all still +gets an hour, but the hour stops at midnight. Events already synced are corrected when the publisher +next changes them, or right away by removing the calendar and adding it again. +[#170](https://github.com/karatektus/pl_mail/issues/170). + ## v0.3.6 — 2026-10-16 **Several Gmail accounts import without failing on a duplicate key.** With more than one Gmail diff --git a/src/Service/Calendar/Sync/CalDav/CalDavEventConverter.php b/src/Service/Calendar/Sync/CalDav/CalDavEventConverter.php index 4977b5a6..5cf7ba9c 100644 --- a/src/Service/Calendar/Sync/CalDav/CalDavEventConverter.php +++ b/src/Service/Calendar/Sync/CalDav/CalDavEventConverter.php @@ -109,6 +109,9 @@ final readonly class CalDavEventConverter */ private const int DEFAULT_DURATION_MINUTES = 60; + /** An event whose end is stated to be its start. See endOf(). */ + private const int INSTANT_MINUTES = 1; + public function __construct( private RecurrenceRuleConverter $recurrence, private AlertReader $alerts, @@ -635,26 +638,89 @@ final readonly class CalDavEventConverter } } + /** + * Where an event ends, never at or before where it starts. + * + * Three kinds of answer. A real end, from DTEND or DURATION, is taken as + * written. An end the publisher STATES to be the start — DTEND equal to + * DTSTART, or a zero DURATION — is an instant, a reminder at 23:59 say, + * and gets the one minute that keeps it in the views (#170). It used to get + * the nominal hour, which other clients do not draw, and an instant late in + * the evening ran into the next day and showed there too. An end nobody + * stated gets the nominal hour, see nominalEndOf(). + * + * One minute rather than none, because none is invisible: an occurrence's + * span is `[start, end)`, and an empty range overlaps no window a view asks + * for. One minute and not more, because a minute starting on the last + * minute of a day ends on its midnight, which the day grid and the day + * walk both read as not continuing — in every zone, since no zone's offset + * is a fraction of a minute. + */ private function endOf(VEvent $vevent, DateTimeImmutable $start): DateTimeImmutable { - $end = $this->instantOf($vevent, 'DTEND'); + $instant = $start->modify(sprintf('+%d minutes', self::INSTANT_MINUTES)); + $end = $this->instantOf($vevent, 'DTEND'); if (null !== $end && $end > $start) { return $end; } + // Equal, and not merely missing or before: a DTEND before DTSTART is a + // broken event, not a stated instant, and gets the nominal hour. + if (null !== $end && $end->getTimestamp() === $start->getTimestamp()) { + return $instant; + } + $durations = $vevent->select('DURATION'); if ([] !== $durations) { try { - return $start->add(DateTimeParser::parseDuration((string) reset($durations))); + $end = $start->add(DateTimeParser::parseDuration((string) reset($durations))); + + if ($end > $start) { + return $end; + } + + if ($end->getTimestamp() === $start->getTimestamp()) { + return $instant; + } } catch (\Throwable) { // Falls through to the nominal hour: an unparseable DURATION is // no more informative than an absent one. } } - return $start->modify(sprintf('+%d minutes', self::DEFAULT_DURATION_MINUTES)); + return $this->nominalEndOf($vevent, $start); + } + + /** + * The nominal hour for an event that says nothing about its length, cut + * at the midnight that ends the day it starts on. + * + * Cut so an event at 23:30 stays on its day rather than leaving half an + * hour on the next, where it was drawn as a second entry (#170). The day is + * the one DTSTART's own zone has, read before $start was moved to UTC; a + * start that is already UTC, or floating, is cut at midnight there, which + * is no worse than before. + */ + private function nominalEndOf(VEvent $vevent, DateTimeImmutable $start): DateTimeImmutable + { + $nominal = $start->modify(sprintf('+%d minutes', self::DEFAULT_DURATION_MINUTES)); + + $dtstart = $vevent->select('DTSTART')[0] ?? null; + + if (false === $dtstart instanceof ICalDateTime) { + return $nominal; + } + + try { + $local = DateTimeImmutable::createFromInterface($dtstart->getDateTime()); + $midnight = $local->setTime(0, 0)->modify('+1 day')->setTimezone(new DateTimeZone('UTC')); + } catch (\Throwable) { + return $nominal; + } + + return $midnight > $start && $midnight < $nominal ? $midnight : $nominal; } /** A DATE value rather than a DATE-TIME is what makes an event all-day. */ diff --git a/tests/Service/Calendar/Sync/CalDav/CalDavEventConverterTest.php b/tests/Service/Calendar/Sync/CalDav/CalDavEventConverterTest.php index a3e795ac..fa8b3a72 100644 --- a/tests/Service/Calendar/Sync/CalDav/CalDavEventConverterTest.php +++ b/tests/Service/Calendar/Sync/CalDav/CalDavEventConverterTest.php @@ -146,6 +146,60 @@ final class CalDavEventConverterTest extends TestCase self::assertSame('2026-08-04 09:00:00', $event?->endsAt?->format('Y-m-d H:i:s')); } + /** + * An end stated to be the start is an instant, not an unstated length: + * other clients draw 23:59–23:59, and the nominal hour ran it into the next + * day, where it showed as a second entry (#170). One minute keeps it on its + * own day, in its own zone. + */ + public function testAnEndEqualToTheStartIsAMinuteRatherThanAnHour(): void + { + $ics = $this->calendar('', 'DTSTART;TZID=Europe/Berlin:20261015T235900', 'DTEND;TZID=Europe/Berlin:20261015T235900'); + + $event = $this->converter()->toRemoteEvent($ics, '/c/1.ics', null); + + self::assertSame('PT1M', $event?->jscalendar['duration'] ?? null); + self::assertSame( + '2026-10-16 00:00', + $event?->endsAt?->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d H:i'), + 'it ends on the midnight that closes its day, not an hour into the next', + ); + } + + public function testAZeroDurationIsAnInstantToo(): void + { + $ics = $this->calendar('DURATION:PT0S', 'DTSTART:20260804T080000Z', ''); + + $event = $this->converter()->toRemoteEvent($ics, '/c/1.ics', null); + + self::assertSame('PT1M', $event?->jscalendar['duration'] ?? null); + self::assertSame('2026-08-04 08:01:00', $event?->endsAt?->format('Y-m-d H:i:s')); + } + + /** The nominal hour stops at the end of the day the event starts on, in its own zone. */ + public function testTheNominalHourDoesNotRunIntoTheNextDay(): void + { + $ics = $this->calendar('', 'DTSTART;TZID=Europe/Berlin:20261015T233000', ''); + + $event = $this->converter()->toRemoteEvent($ics, '/c/1.ics', null); + + self::assertSame('PT30M', $event?->jscalendar['duration'] ?? null); + self::assertSame( + '2026-10-16 00:00', + $event?->endsAt?->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d H:i'), + ); + } + + /** A DTEND before DTSTART is broken, not a stated instant: it keeps the nominal hour. */ + public function testAnEndBeforeTheStartStillGetsTheNominalHour(): void + { + $ics = $this->calendar('', 'DTSTART:20260804T080000Z', 'DTEND:20260804T070000Z'); + + $event = $this->converter()->toRemoteEvent($ics, '/c/1.ics', null); + + self::assertSame('PT1H', $event?->jscalendar['duration'] ?? null); + } + public function testTheSeriesMasterWinsOverAnEditedInstance(): void { // A CalDAV resource holds every component sharing one UID, so a moved