fix(ics): keep a task's span, drop after-start timed alarms (#225)

DUE was read only when a VTODO had no DTSTART, and never as the end. A task
carrying both imported as a zero-length event instead of the span between them.
It is now resolved after the property sweep, so a DUE ahead of the DTSTART isn't
mistaken for the start either.

A timed VALARM firing after the start is a follow-up alarm and models no lead
time — clamping it to zero invented a reminder at the start that the file never
asked for. Dropped again, as the insert path did before. The clamp stays for
all-day, where it's how the Fossify family writes "on the day at 09:00".
This commit is contained in:
2026-08-31 19:11:25 +02:00
parent 89262a0be6
commit 0b7f960511
3 changed files with 88 additions and 9 deletions

View File

@@ -395,10 +395,11 @@ property is a tag list and must not outrank something that names the calendar
outright — bare `EXDATE` day codes on a timed series are resolved against the
series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
to `DTSTART`'s zone, not the device's) while an all-day `EXDATE` keeps the day it
spells out rather than the day its resolved instant lands on in UTC, and a
`VALARM` trigger pointing *after* the start — which is how that family encodes
"on the day at 09:00" — is read as zero days before rather than clamped to a lead
time of zero.
spells out rather than the day its resolved instant lands on in UTC, and an
all-day `VALARM` trigger pointing *after* the start — which is how that family
encodes "on the day at 09:00" — is read as zero days before. On a *timed* event
the same shape means a follow-up alarm and is dropped: clamping it to zero would
invent a reminder at the start that the file never asked for.
All-day reminder offsets are **whole days, rounded up** in both directions. A
file's raw offset is `days × 1440 timeOfDay`, so rounding to the nearest day
@@ -410,6 +411,16 @@ after the event — so `toIcsEvent` decodes it back through
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
its own setting.
**A backup carries the series' deleted occurrences.** `EXDATE` lives on the
master row, so the export query's `ORIGINAL_ID IS NULL` filter — which does drop
`RECURRENCE-ID` overrides — never hid it; it simply wasn't read. It is now part
of `EventExportProjection`, and `IcsWriter` marks an all-day series' exclusions
`VALUE=DATE`, because RFC 5545 ties `EXDATE`'s value type to `DTSTART`'s and a
bare day code without it reads as a malformed DATE-TIME. Sync adapters disagree
on whether an all-day exclusion is `yyyyMMdd` or a padded midnight stamp, so
`exportExDates` drops the time part on the way out. Without this, an in-app
backup→restore resurrected every occurrence the user had deleted.
That re-encode is sampled where the reminder will actually *fire*, not at
`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an
anchor and is routinely ancient — Fossify writes a year-less contact birthday at