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