diff --git a/IPCamLapse.Tests/CaptureScheduleServiceTests.cs b/IPCamLapse.Tests/CaptureScheduleServiceTests.cs index 2539886..90d5442 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,121 @@ 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 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); + } + + [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() + { + 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() + { + 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 52a442f..a22c0ff 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..07ff128 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,48 +35,87 @@ 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); - 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 EvaluateRecurring(schedule, utcNow, _ => true); } - private static ScheduleAvailability EvaluateWeekly(CaptureSchedule schedule, DateTime utcNow) + private ScheduleAvailability EvaluateWeekly(CaptureSchedule schedule, DateTime utcNow) { - var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, TimeZoneInfo.Local); - 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); + } + + 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 dayOffset = 0; dayOffset <= 7; dayOffset++) + 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, TimeZoneInfo.ConvertTimeToUtc(candidate, TimeZoneInfo.Local)); + + 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 matchingEnds = endCandidates.Where(candidate => candidate > startUtc); + var endUtc = startCandidates.Count == 1 + ? matchingEnds.LastOrDefault() + : matchingEnds.FirstOrDefault(); + 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, TimeZoneInfo.ConvertTimeToUtc(fallback, TimeZoneInfo.Local)); + 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) diff --git a/README.md b/README.md index dc17e62..d1fb502 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`.