From 736e02d74d907bc079cc87af61aee65e41d1bdbe Mon Sep 17 00:00:00 2001 From: Stamina9 <1934687883@qq.com> Date: Sun, 25 Oct 2026 11:48:03 +0800 Subject: [PATCH] fix(ical): honor elapsed duration across daylight saving changes --- src/Filee.Engines/Data/ICalendar.cs | 43 ++++++++++++++++++--- tests/Filee.Engines.Tests/ICalendarTests.cs | 20 ++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/Filee.Engines/Data/ICalendar.cs b/src/Filee.Engines/Data/ICalendar.cs index 55b984857..f84998b85 100644 --- a/src/Filee.Engines/Data/ICalendar.cs +++ b/src/Filee.Engines/Data/ICalendar.cs @@ -226,6 +226,21 @@ private static bool ParseIcsDate(Property property, out DateTime value, out bool catch (InvalidTimeZoneException) { return null; } } + // Resolve the offset at an instant after adding the clock part of a DURATION. The local + // wall time may have crossed a daylight-saving transition since DTSTART. + private static TimeSpan? OffsetAtInstant(string id, DateTimeOffset instant, Dictionary zones) + { + if (zones.TryGetValue(id, out var definition) && !definition.HasRule && definition.Transitions.Count > 0) + { + var ordered = definition.Transitions.OrderBy(t => new DateTimeOffset(t.Local, t.From)).ToList(); + var found = ordered.LastOrDefault(t => new DateTimeOffset(t.Local, t.From) <= instant); + return found == default ? ordered[0].From : found.To; + } + try { return TimeZoneInfo.FindSystemTimeZoneById(id).GetUtcOffset(instant); } + catch (TimeZoneNotFoundException) { return null; } + catch (InvalidTimeZoneException) { return null; } + } + private static string DateCell(Property? property, Dictionary zones) { if (property is null) @@ -242,7 +257,9 @@ private static string DateCell(Property? property, Dictionary TimeSpan.FromDays(number * 7d), 'D' when !time => TimeSpan.FromDays(number), @@ -280,12 +299,16 @@ private static bool Duration(string text, out TimeSpan duration) 'S' when time => TimeSpan.FromSeconds(number), _ => TimeSpan.MinValue, }; - if (duration < TimeSpan.Zero) + if (part < TimeSpan.Zero) return false; + if (ch is 'W' or 'D') + days += part; + else + clock += part; digits = ""; any = true; } - duration *= sign; + duration = new CalendarDuration(days * sign, clock * sign); return any && digits.Length == 0; } @@ -300,10 +323,20 @@ public static List> ToRows(List components) if (end is null && start is not null && ParseIcsDate(start, out var startDate, out var date, out var utc) && component.First("DURATION") is { } span && Duration(span.Value, out var duration)) { - var computed = startDate + duration; + // Calendar days keep the local wall time; hours, minutes and seconds are elapsed time (RFC 5545). + var dayEnd = startDate + duration.Days; + var computed = dayEnd + duration.Clock; var synthetic = new Property(start.Name, computed.ToString(date ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture) + (utc && !date ? "Z" : ""), "", start.Parameters); endCell = DateCell(synthetic, component.Zones); + var zone = start.Parameter("TZID"); + if (!date && !utc && duration.Clock != TimeSpan.Zero && zone.Length > 0 + && Offset(zone, dayEnd, component.Zones) is { } dayOffset) + { + var instant = new DateTimeOffset(dayEnd, dayOffset).Add(duration.Clock); + if (OffsetAtInstant(zone, instant, component.Zones) is { } endOffset) + endCell = instant.ToOffset(endOffset).ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); + } } rows.Add( [ diff --git a/tests/Filee.Engines.Tests/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs index dc6a6e188..446e54f1a 100644 --- a/tests/Filee.Engines.Tests/ICalendarTests.cs +++ b/tests/Filee.Engines.Tests/ICalendarTests.cs @@ -78,6 +78,26 @@ public async Task Apple_all_day_exclusive_end_duration_and_unknown_properties() Assert.Equal("2026-11-04", Cell(rows, 3, "End")); } + [Fact] + public async Task Duration_crossing_daylight_saving_time_keeps_calendar_days_and_elapsed_hours() + { + var customZone = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Custom/Spring", "BEGIN:DAYLIGHT", + "DTSTART:20260308T020000", "TZOFFSETFROM:-0500", "TZOFFSETTO:-0400", "END:DAYLIGHT", "END:VTIMEZONE"); + var rows = await ReadCalendar(Calendar(customZone, + Event("spring-clock", "DTSTART;TZID=America/New_York:20260308T013000", "DURATION:PT2H"), + Event("spring-day", "DTSTART;TZID=America/New_York:20260308T013000", "DURATION:P1D"), + Event("spring-24h", "DTSTART;TZID=America/New_York:20260308T013000", "DURATION:PT24H"), + Event("spring-mixed", "DTSTART;TZID=America/New_York:20260307T013000", "DURATION:P1DT2H"), + Event("fall-clock", "DTSTART;TZID=America/New_York:20261101T003000", "DURATION:PT2H"), + Event("custom", "DTSTART;TZID=Custom/Spring:20260308T013000", "DURATION:PT2H"))); + Assert.Equal("2026-03-08T04:30:00-04:00", Cell(rows, 1, "End")); + Assert.Equal("2026-03-09T01:30:00-04:00", Cell(rows, 2, "End")); + Assert.Equal("2026-03-09T02:30:00-04:00", Cell(rows, 3, "End")); + Assert.Equal("2026-03-08T04:30:00-04:00", Cell(rows, 4, "End")); + Assert.Equal("2026-11-01T01:30:00-05:00", Cell(rows, 5, "End")); + Assert.Equal("2026-03-08T04:30:00-04:00", Cell(rows, 6, "End")); + } + [Fact] public async Task Outlook_windows_zone_custom_zone_unknown_rules_and_recurrence_instance() {