From 8ab316b18214f819a217f28ffc0be0f9068bede9 Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Tue, 8 Sep 2026 08:58:08 -0700 Subject: [PATCH 1/4] feat: make schedule time zone deterministic --- .../CaptureScheduleServiceTests.cs | 84 +++++++++++++++++-- IPCamLapse.Tests/TimeZonePageTests.cs | 63 ++++++++++++++ IPCamLapse/Pages/Sessions/Create.cshtml | 1 + IPCamLapse/Pages/Sessions/Create.cshtml.cs | 28 +++++-- IPCamLapse/Pages/System.cshtml | 2 + IPCamLapse/Pages/System.cshtml.cs | 19 ++++- IPCamLapse/Program.cs | 1 + IPCamLapse/Services/CaptureScheduleService.cs | 45 ++++++++-- README.md | 6 ++ 9 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 IPCamLapse.Tests/TimeZonePageTests.cs diff --git a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs index 2539886..2b6ba8d 100644 --- a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs +++ b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs @@ -5,7 +5,8 @@ namespace IPCamLapse.Tests; public sealed class CaptureScheduleServiceTests { - private readonly CaptureScheduleService _service = new(); + private static readonly TimeZoneInfo TestZone = CreateTestZone(); + private readonly CaptureScheduleService _service = new(TestZone); [Fact] public void FutureOneTimeScheduleWaitsForStart() @@ -27,7 +28,7 @@ public void FutureOneTimeScheduleWaitsForStart() public void DailyWindowReportsNextLocalStart() { var localNow = new DateTime(2026, 8, 27, 18, 0, 0, DateTimeKind.Unspecified); - var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local); + var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TestZone); var result = _service.GetAvailability(new CaptureSchedule { @@ -37,7 +38,7 @@ public void DailyWindowReportsNextLocalStart() }, utcNow); Assert.False(result.Active); - var expected = TimeZoneInfo.ConvertTimeToUtc(localNow.Date.AddDays(1).AddHours(7), TimeZoneInfo.Local); + var expected = TimeZoneInfo.ConvertTimeToUtc(localNow.Date.AddDays(1).AddHours(7), TestZone); Assert.Equal(expected, result.NextStartUtc); } @@ -45,7 +46,7 @@ public void DailyWindowReportsNextLocalStart() public void OvernightDailyWindowIncludesEarlyMorning() { var localNow = new DateTime(2026, 8, 27, 2, 0, 0, DateTimeKind.Unspecified); - var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local); + var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TestZone); var result = _service.GetAvailability(new CaptureSchedule { @@ -61,7 +62,7 @@ public void OvernightDailyWindowIncludesEarlyMorning() public void WeeklyWindowReportsTheNextSelectedDay() { var localNow = new DateTime(2026, 8, 27, 12, 0, 0, DateTimeKind.Unspecified); - var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local); + var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TestZone); var result = _service.GetAvailability(new CaptureSchedule { @@ -73,6 +74,77 @@ public void WeeklyWindowReportsTheNextSelectedDay() Assert.False(result.Active); var nextMonday = new DateTime(2026, 8, 31, 8, 0, 0, DateTimeKind.Unspecified); - Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(nextMonday, TimeZoneInfo.Local), result.NextStartUtc); + Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(nextMonday, TestZone), result.NextStartUtc); + } + + [Fact] + public void SpringForwardWallTimeMovesForwardByTheDstGap() + { + var invalid = new DateTime(2026, 3, 8, 2, 30, 0, DateTimeKind.Unspecified); + + var result = _service.ConvertWallTimeToUtc(invalid); + + Assert.Equal(new DateTime(2026, 3, 8, 7, 30, 0, DateTimeKind.Utc), result); + } + + [Fact] + public void AmbiguousOneTimeStartChoosesEarlierUtcOccurrence() + { + var repeated = new DateTime(2026, 11, 1, 1, 30, 0, DateTimeKind.Unspecified); + + var result = _service.ConvertWallTimeToUtc(repeated); + + Assert.Equal(new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc), result); + } + + [Theory] + [InlineData(5)] + [InlineData(6)] + public void BothRepeatedHourInstantsAreInsideRecurringWindow(int utcHour) + { + var result = _service.GetAvailability(new CaptureSchedule + { + Frequency = ScheduleFrequency.Daily, + WindowStartLocal = TimeSpan.FromHours(1), + WindowEndLocal = TimeSpan.FromHours(2) + }, new DateTime(2026, 11, 1, utcHour, 30, 0, DateTimeKind.Utc)); + + Assert.True(result.Active); + } + + [Fact] + public void UtcZoneRemainsDeterministic() + { + var service = new CaptureScheduleService(TimeZoneInfo.Utc); + var wallTime = new DateTime(2026, 6, 1, 12, 15, 0, DateTimeKind.Unspecified); + + Assert.Equal( + new DateTime(2026, 6, 1, 12, 15, 0, DateTimeKind.Utc), + service.ConvertWallTimeToUtc(wallTime)); + } + + private static TimeZoneInfo CreateTestZone() + { + var daylight = TimeZoneInfo.AdjustmentRule.CreateAdjustmentRule( + new DateTime(2020, 1, 1), + new DateTime(2030, 12, 31), + TimeSpan.FromHours(1), + TimeZoneInfo.TransitionTime.CreateFloatingDateRule( + new DateTime(1, 1, 1, 2, 0, 0), + 3, + 2, + DayOfWeek.Sunday), + TimeZoneInfo.TransitionTime.CreateFloatingDateRule( + new DateTime(1, 1, 1, 2, 0, 0), + 11, + 1, + DayOfWeek.Sunday)); + return TimeZoneInfo.CreateCustomTimeZone( + "IPCamLapse-Test-Eastern", + TimeSpan.FromHours(-5), + "Test Eastern", + "Test Standard", + "Test Daylight", + [daylight]); } } diff --git a/IPCamLapse.Tests/TimeZonePageTests.cs b/IPCamLapse.Tests/TimeZonePageTests.cs new file mode 100644 index 0000000..435e906 --- /dev/null +++ b/IPCamLapse.Tests/TimeZonePageTests.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace IPCamLapse.Tests; + +public sealed class TimeZonePageTests +{ + [Theory] + [InlineData("/Sessions/Create")] + [InlineData("/System")] + public async Task PagesShowInjectedStartupTimeZoneAndCurrentOffset(string path) + { + var zone = TimeZoneInfo.CreateCustomTimeZone( + "IPCamLapse-Test-Zone", + TimeSpan.FromHours(3), + "Test Zone", + "Test Zone"); + var now = new DateTimeOffset(2026, 9, 8, 12, 0, 0, TimeSpan.Zero); + await using var factory = new TimeZoneFactory(zone, new FixedTimeProvider(now)); + using var client = factory.CreateClient(); + + var response = await client.GetAsync(path); + response.EnsureSuccessStatusCode(); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Contains("IPCamLapse-Test-Zone", html, StringComparison.Ordinal); + Assert.Contains("UTC+03:00", html, StringComparison.Ordinal); + } + + private sealed class TimeZoneFactory : WebApplicationFactory + { + private readonly TimeZoneInfo _timeZone; + private readonly TimeProvider _timeProvider; + + public TimeZoneFactory(TimeZoneInfo timeZone, TimeProvider timeProvider) + { + _timeZone = timeZone; + _timeProvider = timeProvider; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(_timeZone); + services.AddSingleton(_timeProvider); + }); + } + } + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _now; + + public FixedTimeProvider(DateTimeOffset now) => _now = now; + + public override DateTimeOffset GetUtcNow() => _now; + } +} diff --git a/IPCamLapse/Pages/Sessions/Create.cshtml b/IPCamLapse/Pages/Sessions/Create.cshtml index 00471dc..98a65a3 100644 --- a/IPCamLapse/Pages/Sessions/Create.cshtml +++ b/IPCamLapse/Pages/Sessions/Create.cshtml @@ -126,6 +126,7 @@

03Schedule

+

Recurring windows use @Model.TimeZoneDisplay. Changing the host time zone requires an application restart.

diff --git a/IPCamLapse/Pages/Sessions/Create.cshtml.cs b/IPCamLapse/Pages/Sessions/Create.cshtml.cs index 8ce5f1a..93fdbfe 100644 --- a/IPCamLapse/Pages/Sessions/Create.cshtml.cs +++ b/IPCamLapse/Pages/Sessions/Create.cshtml.cs @@ -12,19 +12,28 @@ public sealed class CreateModel : PageModel private readonly ICameraUrlPolicy _cameraUrlPolicy; private readonly IStorageService _storage; private readonly IApplicationSettingsService _applicationSettings; + private readonly ICaptureScheduleService _schedule; + private readonly TimeProvider _timeProvider; + private readonly TimeZoneInfo _timeZone; public CreateModel( ICaptureSessionService sessions, ICameraProfileService profiles, ICameraUrlPolicy cameraUrlPolicy, IStorageService storage, - IApplicationSettingsService applicationSettings) + IApplicationSettingsService applicationSettings, + ICaptureScheduleService schedule, + TimeProvider timeProvider, + TimeZoneInfo timeZone) { _sessions = sessions; _profiles = profiles; _cameraUrlPolicy = cameraUrlPolicy; _storage = storage; _applicationSettings = applicationSettings; + _schedule = schedule; + _timeProvider = timeProvider; + _timeZone = timeZone; } [BindProperty] @@ -37,6 +46,7 @@ public CreateModel( public IReadOnlyList Profiles { get; private set; } = Array.Empty(); public StorageStatus Storage { get; private set; } = new(0, 1, 0, 0, false, null); public long EstimatedFrameBytes { get; private set; } = 350 * 1024; + public string TimeZoneDisplay => FormatTimeZone(_timeZone, _timeProvider.GetUtcNow()); public async Task OnGetAsync() { @@ -75,12 +85,10 @@ public async Task OnPostAsync() Session.Configuration.AllowInvalidCertificate = false; } Session.Id = Guid.NewGuid().ToString("N")[..8]; - Session.CreatedAt = DateTime.UtcNow; + Session.CreatedAt = _timeProvider.GetUtcNow().UtcDateTime; Session.Status = SessionStatus.Ready; Session.Configuration.Schedule.StartAtUtc = StartAtLocal.HasValue - ? TimeZoneInfo.ConvertTimeToUtc( - DateTime.SpecifyKind(StartAtLocal.Value, DateTimeKind.Unspecified), - TimeZoneInfo.Local) + ? _schedule.ConvertWallTimeToUtc(StartAtLocal.Value) : null; await _sessions.CreateSessionAsync(Session); return RedirectToPage("/Sessions/Details", new { id = Session.Id }); @@ -116,7 +124,8 @@ private void ValidateSchedule() var schedule = Session.Configuration.Schedule; if (schedule.Frequency == ScheduleFrequency.Once && !StartAtLocal.HasValue) ModelState.AddModelError(nameof(StartAtLocal), "Choose a start time."); - if (StartAtLocal.HasValue && StartAtLocal.Value <= DateTime.Now && + if (StartAtLocal.HasValue && + _schedule.ConvertWallTimeToUtc(StartAtLocal.Value) <= _timeProvider.GetUtcNow().UtcDateTime && schedule.Frequency == ScheduleFrequency.Once) { ModelState.AddModelError(nameof(StartAtLocal), "Start time must be in the future."); @@ -144,4 +153,11 @@ private async Task LoadPageDataAsync() Storage = await _storage.GetStatusAsync(); EstimatedFrameBytes = _applicationSettings.Current.EstimatedFrameBytes; } + + private static string FormatTimeZone(TimeZoneInfo timeZone, DateTimeOffset now) + { + var offset = timeZone.GetUtcOffset(now); + var sign = offset < TimeSpan.Zero ? "−" : "+"; + return $"{timeZone.Id} (UTC{sign}{offset.Duration():hh\\:mm})"; + } } diff --git a/IPCamLapse/Pages/System.cshtml b/IPCamLapse/Pages/System.cshtml index 9c2f532..eeb2e9a 100644 --- a/IPCamLapse/Pages/System.cshtml +++ b/IPCamLapse/Pages/System.cshtml @@ -21,6 +21,8 @@ @Model.Health.Checks.Count(check => check.Healthy) of @Model.Health.Checks.Count checks passed
+

Active scheduling time zone: @Model.TimeZoneDisplay

+
diff --git a/IPCamLapse/Pages/System.cshtml.cs b/IPCamLapse/Pages/System.cshtml.cs index c6f96e1..41d2ac0 100644 --- a/IPCamLapse/Pages/System.cshtml.cs +++ b/IPCamLapse/Pages/System.cshtml.cs @@ -8,15 +8,32 @@ public sealed class SystemModel : PageModel { private readonly ISystemHealthService _health; private readonly IStorageService _storage; + private readonly TimeProvider _timeProvider; + private readonly TimeZoneInfo _timeZone; - public SystemModel(ISystemHealthService health, IStorageService storage) + public SystemModel( + ISystemHealthService health, + IStorageService storage, + TimeProvider timeProvider, + TimeZoneInfo timeZone) { _health = health; _storage = storage; + _timeProvider = timeProvider; + _timeZone = timeZone; } public SystemHealthReport Health { get; private set; } = new(Array.Empty()); public StorageStatus Storage { get; private set; } = new(0, 1, 0, 0, false, null); + public string TimeZoneDisplay + { + get + { + var offset = _timeZone.GetUtcOffset(_timeProvider.GetUtcNow()); + var sign = offset < TimeSpan.Zero ? "−" : "+"; + return $"{_timeZone.Id} (UTC{sign}{offset.Duration():hh\\:mm})"; + } + } public async Task OnGetAsync() { diff --git a/IPCamLapse/Program.cs b/IPCamLapse/Program.cs index 884a123..00f6633 100644 --- a/IPCamLapse/Program.cs +++ b/IPCamLapse/Program.cs @@ -21,6 +21,7 @@ IConfigureOptions, DataProtectionKeyRingOptionsSetup>(); builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(_ => TimeZoneInfo.Local); builder.Services .AddOptions() diff --git a/IPCamLapse/Services/CaptureScheduleService.cs b/IPCamLapse/Services/CaptureScheduleService.cs index a2a3adf..b14b615 100644 --- a/IPCamLapse/Services/CaptureScheduleService.cs +++ b/IPCamLapse/Services/CaptureScheduleService.cs @@ -7,10 +7,18 @@ public sealed record ScheduleAvailability(bool Active, DateTime? NextStartUtc); public interface ICaptureScheduleService { ScheduleAvailability GetAvailability(CaptureSchedule schedule, DateTime utcNow); + DateTime ConvertWallTimeToUtc(DateTime localTime); } public sealed class CaptureScheduleService : ICaptureScheduleService { + private readonly TimeZoneInfo _timeZone; + + public CaptureScheduleService(TimeZoneInfo timeZone) + { + _timeZone = timeZone; + } + public ScheduleAvailability GetAvailability(CaptureSchedule schedule, DateTime utcNow) { utcNow = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc); @@ -27,20 +35,43 @@ public ScheduleAvailability GetAvailability(CaptureSchedule schedule, DateTime u }; } - private static ScheduleAvailability EvaluateDaily(CaptureSchedule schedule, DateTime utcNow) + public DateTime ConvertWallTimeToUtc(DateTime localTime) + { + var wallTime = DateTime.SpecifyKind(localTime, DateTimeKind.Unspecified); + if (_timeZone.IsInvalidTime(wallTime)) + { + var before = wallTime.AddMinutes(-1); + while (_timeZone.IsInvalidTime(before)) + before = before.AddMinutes(-1); + var after = wallTime.AddMinutes(1); + while (_timeZone.IsInvalidTime(after)) + after = after.AddMinutes(1); + wallTime = wallTime.Add(_timeZone.GetUtcOffset(after) - _timeZone.GetUtcOffset(before)); + } + + if (_timeZone.IsAmbiguousTime(wallTime)) + { + var earlierOffset = _timeZone.GetAmbiguousTimeOffsets(wallTime).Max(); + return DateTime.SpecifyKind(wallTime - earlierOffset, DateTimeKind.Utc); + } + + return TimeZoneInfo.ConvertTimeToUtc(wallTime, _timeZone); + } + + private ScheduleAvailability EvaluateDaily(CaptureSchedule schedule, DateTime utcNow) { if (!schedule.HasWindow) return new ScheduleAvailability(true, null); - var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, TimeZoneInfo.Local); + var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _timeZone); if (IsInsideWindow(localNow.TimeOfDay, schedule.WindowStartLocal!.Value, schedule.WindowEndLocal!.Value)) return new ScheduleAvailability(true, null); var nextLocal = NextWindowStart(localNow, schedule.WindowStartLocal.Value); - return new ScheduleAvailability(false, TimeZoneInfo.ConvertTimeToUtc(nextLocal, TimeZoneInfo.Local)); + return new ScheduleAvailability(false, ConvertWallTimeToUtc(nextLocal)); } - private static ScheduleAvailability EvaluateWeekly(CaptureSchedule schedule, DateTime utcNow) + private ScheduleAvailability EvaluateWeekly(CaptureSchedule schedule, DateTime utcNow) { - var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, TimeZoneInfo.Local); + var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _timeZone); var start = schedule.WindowStartLocal ?? TimeSpan.Zero; var end = schedule.WindowEndLocal ?? TimeSpan.FromDays(1); var overnight = start >= end; @@ -57,11 +88,11 @@ private static ScheduleAvailability EvaluateWeekly(CaptureSchedule schedule, Dat continue; var candidate = date + start; if (candidate > localNow) - return new ScheduleAvailability(false, TimeZoneInfo.ConvertTimeToUtc(candidate, TimeZoneInfo.Local)); + return new ScheduleAvailability(false, ConvertWallTimeToUtc(candidate)); } var fallback = localNow.Date.AddDays(7) + start; - return new ScheduleAvailability(false, TimeZoneInfo.ConvertTimeToUtc(fallback, TimeZoneInfo.Local)); + return new ScheduleAvailability(false, ConvertWallTimeToUtc(fallback)); } private static bool IsInsideWindow(TimeSpan current, TimeSpan start, TimeSpan end) diff --git a/README.md b/README.md index 190cb45..395c819 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ Open . Captures, profiles, settings, and credential-prote The command publishes the port on host loopback only. It explicitly allows private bridge traffic inside the container so requests forwarded by Docker can reach the app. Do not change the host binding to `0.0.0.0` unless an authenticated reverse proxy supplies the missing access control. +Recurring daily and weekly windows use the time zone resolved when the process +starts. Container images normally default to UTC; set `TZ`, for example +`--env TZ=Europe/London`, when local wall-clock scheduling is required. Changing +`TZ` takes effect only after the container restarts and affects future recurring +evaluations. Persisted one-time start values remain UTC instants and do not move. + ## Configuration Environment variables use double underscores, such as `Storage__DataPath=/srv/ipcamlapse`. From e251297cab69641011a977e67a17767633c7b406 Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Wed, 9 Sep 2026 06:49:46 -0700 Subject: [PATCH 2/4] fix: evaluate recurring schedules across DST transitions --- .../CaptureScheduleServiceTests.cs | 28 +++++++++ IPCamLapse/Services/CaptureScheduleService.cs | 63 +++++++++++-------- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs index 2b6ba8d..17aa0b5 100644 --- a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs +++ b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs @@ -112,6 +112,34 @@ public void BothRepeatedHourInstantsAreInsideRecurringWindow(int utcHour) Assert.True(result.Active); } + [Fact] + public void SecondFallBackHourSelectsItsOwnFutureRecurringStart() + { + var result = _service.GetAvailability(new CaptureSchedule + { + Frequency = ScheduleFrequency.Daily, + WindowStartLocal = TimeSpan.FromHours(1.5), + WindowEndLocal = TimeSpan.FromHours(1.75) + }, new DateTime(2026, 11, 1, 6, 20, 0, DateTimeKind.Utc)); + + Assert.False(result.Active); + Assert.Equal(new DateTime(2026, 11, 1, 6, 30, 0, DateTimeKind.Utc), result.NextStartUtc); + } + + [Fact] + public void SpringForwardGapDoesNotActivateBeforeAdjustedStart() + { + var result = _service.GetAvailability(new CaptureSchedule + { + Frequency = ScheduleFrequency.Daily, + WindowStartLocal = TimeSpan.FromHours(2.5), + WindowEndLocal = TimeSpan.FromHours(4) + }, new DateTime(2026, 3, 8, 7, 10, 0, DateTimeKind.Utc)); + + Assert.False(result.Active); + Assert.Equal(new DateTime(2026, 3, 8, 7, 30, 0, DateTimeKind.Utc), result.NextStartUtc); + } + [Fact] public void UtcZoneRemainsDeterministic() { diff --git a/IPCamLapse/Services/CaptureScheduleService.cs b/IPCamLapse/Services/CaptureScheduleService.cs index b14b615..704c933 100644 --- a/IPCamLapse/Services/CaptureScheduleService.cs +++ b/IPCamLapse/Services/CaptureScheduleService.cs @@ -62,44 +62,55 @@ private ScheduleAvailability EvaluateDaily(CaptureSchedule schedule, DateTime ut { if (!schedule.HasWindow) return new ScheduleAvailability(true, null); - var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _timeZone); - if (IsInsideWindow(localNow.TimeOfDay, schedule.WindowStartLocal!.Value, schedule.WindowEndLocal!.Value)) - return new ScheduleAvailability(true, null); - var nextLocal = NextWindowStart(localNow, schedule.WindowStartLocal.Value); - return new ScheduleAvailability(false, ConvertWallTimeToUtc(nextLocal)); + return EvaluateRecurring(schedule, utcNow, _ => true); } private ScheduleAvailability EvaluateWeekly(CaptureSchedule schedule, DateTime utcNow) { - var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _timeZone); - var start = schedule.WindowStartLocal ?? TimeSpan.Zero; - var end = schedule.WindowEndLocal ?? TimeSpan.FromDays(1); - var overnight = start >= end; - var effectiveDay = overnight && localNow.TimeOfDay < end - ? localNow.AddDays(-1).DayOfWeek - : localNow.DayOfWeek; - if (effectiveDay == schedule.WeeklyDay && IsInsideWindow(localNow.TimeOfDay, start, end)) - return new ScheduleAvailability(true, null); + return EvaluateRecurring(schedule, utcNow, date => date.DayOfWeek == schedule.WeeklyDay); + } - for (var dayOffset = 0; dayOffset <= 7; dayOffset++) + private ScheduleAvailability EvaluateRecurring(CaptureSchedule schedule, DateTime utcNow, Func includesDate) + { + var localToday = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _timeZone).Date; + var start = schedule.WindowStartLocal!.Value; + var end = schedule.WindowEndLocal!.Value; + DateTime? next = null; + + for (var offset = -1; offset <= 8; offset++) { - var date = localNow.Date.AddDays(dayOffset); - if (date.DayOfWeek != schedule.WeeklyDay) + var date = localToday.AddDays(offset); + if (!includesDate(date)) continue; - var candidate = date + start; - if (candidate > localNow) - return new ScheduleAvailability(false, ConvertWallTimeToUtc(candidate)); + + var startWall = date + start; + var endWall = date + end + (end <= start ? TimeSpan.FromDays(1) : TimeSpan.Zero); + foreach (var startUtc in WallTimeCandidates(startWall)) + { + var endUtc = WallTimeCandidates(endWall).FirstOrDefault(candidate => candidate > startUtc); + if (endUtc == default) + continue; + if (utcNow >= startUtc && utcNow < endUtc) + return new ScheduleAvailability(true, null); + if (startUtc > utcNow && (!next.HasValue || startUtc < next.Value)) + next = startUtc; + } } - var fallback = localNow.Date.AddDays(7) + start; - return new ScheduleAvailability(false, ConvertWallTimeToUtc(fallback)); + return new ScheduleAvailability(false, next); } - private static bool IsInsideWindow(TimeSpan current, TimeSpan start, TimeSpan end) + private IEnumerable WallTimeCandidates(DateTime wallTime) { - if (start < end) - return current >= start && current < end; - return current >= start || current < end; + var normalized = DateTime.SpecifyKind(wallTime, DateTimeKind.Unspecified); + if (_timeZone.IsInvalidTime(normalized)) + return [ConvertWallTimeToUtc(normalized)]; + if (!_timeZone.IsAmbiguousTime(normalized)) + return [TimeZoneInfo.ConvertTimeToUtc(normalized, _timeZone)]; + + return _timeZone.GetAmbiguousTimeOffsets(normalized) + .Select(offset => DateTime.SpecifyKind(normalized - offset, DateTimeKind.Utc)) + .Order(); } private static DateTime NextWindowStart(DateTime localNow, TimeSpan start) From 4612e8b09ec47c3aed9a183276885ffb6d77d977 Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Thu, 10 Sep 2026 06:22:21 -0700 Subject: [PATCH 3/4] fix: span repeated DST hour for recurring windows --- IPCamLapse.Tests/CaptureScheduleServiceTests.cs | 16 ++++++++++++++++ IPCamLapse/Services/CaptureScheduleService.cs | 9 +++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs index 17aa0b5..badea2d 100644 --- a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs +++ b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs @@ -126,6 +126,22 @@ public void SecondFallBackHourSelectsItsOwnFutureRecurringStart() Assert.Equal(new DateTime(2026, 11, 1, 6, 30, 0, DateTimeKind.Utc), result.NextStartUtc); } + [Theory] + [InlineData(ScheduleFrequency.Daily)] + [InlineData(ScheduleFrequency.Weekly)] + public void WindowStartingBeforeRepeatedHourIncludesSecondOccurrence(ScheduleFrequency frequency) + { + var result = _service.GetAvailability(new CaptureSchedule + { + Frequency = frequency, + WeeklyDay = DayOfWeek.Sunday, + WindowStartLocal = TimeSpan.FromMinutes(30), + WindowEndLocal = TimeSpan.FromHours(1.25) + }, new DateTime(2026, 11, 1, 6, 10, 0, DateTimeKind.Utc)); + + Assert.True(result.Active); + } + [Fact] public void SpringForwardGapDoesNotActivateBeforeAdjustedStart() { diff --git a/IPCamLapse/Services/CaptureScheduleService.cs b/IPCamLapse/Services/CaptureScheduleService.cs index 704c933..8efcc7c 100644 --- a/IPCamLapse/Services/CaptureScheduleService.cs +++ b/IPCamLapse/Services/CaptureScheduleService.cs @@ -85,9 +85,14 @@ private ScheduleAvailability EvaluateRecurring(CaptureSchedule schedule, DateTim var startWall = date + start; var endWall = date + end + (end <= start ? TimeSpan.FromDays(1) : TimeSpan.Zero); - foreach (var startUtc in WallTimeCandidates(startWall)) + var startCandidates = WallTimeCandidates(startWall).ToList(); + var endCandidates = WallTimeCandidates(endWall).ToList(); + foreach (var startUtc in startCandidates) { - var endUtc = WallTimeCandidates(endWall).FirstOrDefault(candidate => candidate > startUtc); + var matchingEnds = endCandidates.Where(candidate => candidate > startUtc); + var endUtc = startCandidates.Count == 1 + ? matchingEnds.LastOrDefault() + : matchingEnds.FirstOrDefault(); if (endUtc == default) continue; if (utcNow >= startUtc && utcNow < endUtc) From 3b62b36abd58c780aaac7328c4c32e4c6a6630bd Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Thu, 10 Sep 2026 06:27:59 -0700 Subject: [PATCH 4/4] style: format DST regression changes --- .../CaptureScheduleServiceTests.cs | 30 +++++++++---------- IPCamLapse/Services/CaptureScheduleService.cs | 14 ++++----- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs index badea2d..90d5442 100644 --- a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs +++ b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs @@ -126,21 +126,21 @@ public void SecondFallBackHourSelectsItsOwnFutureRecurringStart() Assert.Equal(new DateTime(2026, 11, 1, 6, 30, 0, DateTimeKind.Utc), result.NextStartUtc); } - [Theory] - [InlineData(ScheduleFrequency.Daily)] - [InlineData(ScheduleFrequency.Weekly)] - public void WindowStartingBeforeRepeatedHourIncludesSecondOccurrence(ScheduleFrequency frequency) - { - var result = _service.GetAvailability(new CaptureSchedule - { - Frequency = frequency, - WeeklyDay = DayOfWeek.Sunday, - WindowStartLocal = TimeSpan.FromMinutes(30), - WindowEndLocal = TimeSpan.FromHours(1.25) - }, new DateTime(2026, 11, 1, 6, 10, 0, DateTimeKind.Utc)); - - Assert.True(result.Active); - } + [Theory] + [InlineData(ScheduleFrequency.Daily)] + [InlineData(ScheduleFrequency.Weekly)] + public void WindowStartingBeforeRepeatedHourIncludesSecondOccurrence(ScheduleFrequency frequency) + { + var result = _service.GetAvailability(new CaptureSchedule + { + Frequency = frequency, + WeeklyDay = DayOfWeek.Sunday, + WindowStartLocal = TimeSpan.FromMinutes(30), + WindowEndLocal = TimeSpan.FromHours(1.25) + }, new DateTime(2026, 11, 1, 6, 10, 0, DateTimeKind.Utc)); + + Assert.True(result.Active); + } [Fact] public void SpringForwardGapDoesNotActivateBeforeAdjustedStart() diff --git a/IPCamLapse/Services/CaptureScheduleService.cs b/IPCamLapse/Services/CaptureScheduleService.cs index 8efcc7c..07ff128 100644 --- a/IPCamLapse/Services/CaptureScheduleService.cs +++ b/IPCamLapse/Services/CaptureScheduleService.cs @@ -85,14 +85,14 @@ private ScheduleAvailability EvaluateRecurring(CaptureSchedule schedule, DateTim var startWall = date + start; var endWall = date + end + (end <= start ? TimeSpan.FromDays(1) : TimeSpan.Zero); - var startCandidates = WallTimeCandidates(startWall).ToList(); - var endCandidates = WallTimeCandidates(endWall).ToList(); - foreach (var startUtc in startCandidates) + var startCandidates = WallTimeCandidates(startWall).ToList(); + var endCandidates = WallTimeCandidates(endWall).ToList(); + foreach (var startUtc in startCandidates) { - var matchingEnds = endCandidates.Where(candidate => candidate > startUtc); - var endUtc = startCandidates.Count == 1 - ? matchingEnds.LastOrDefault() - : matchingEnds.FirstOrDefault(); + var matchingEnds = endCandidates.Where(candidate => candidate > startUtc); + var endUtc = startCandidates.Count == 1 + ? matchingEnds.LastOrDefault() + : matchingEnds.FirstOrDefault(); if (endUtc == default) continue; if (utcNow >= startUtc && utcNow < endUtc)