From 1e57caff93394c14fd2a22ca05a58425c8a9d83d Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 1 Nov 2025 20:15:33 -0500 Subject: [PATCH] fix block start time calculation --- CHANGELOG.md | 1 + .../BlockScheduling/EffectiveBlockTests.cs | 79 ++++++++++++++++++- .../PlayoutBuilderTestBase.cs | 17 ++-- .../BlockScheduling/EffectiveBlock.cs | 36 ++++++--- 4 files changed, 109 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bd937d5..1a1b66995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix playback failure when seeking content with certain DTS audio (e.g. DTS-HD MA) - Properly set explicit audio decoder on combined audio and video input file - Fix building sequential schedules across a UTC offset change +- Fix block start time calculation across a UTC offset change ## [25.8.0] - 2025-10-26 ### Added diff --git a/ErsatzTV.Core.Tests/Scheduling/BlockScheduling/EffectiveBlockTests.cs b/ErsatzTV.Core.Tests/Scheduling/BlockScheduling/EffectiveBlockTests.cs index fa5ec8c44..59e872d8a 100644 --- a/ErsatzTV.Core.Tests/Scheduling/BlockScheduling/EffectiveBlockTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/BlockScheduling/EffectiveBlockTests.cs @@ -101,9 +101,82 @@ public static class EffectiveBlockTests result[2].Start.Date.ShouldBe(GetLocalDate(2024, 1, 19).Date); } - // TODO: test when clocks spring forward - // TODO: test when clocks fall back + [Test] + public void Should_Handle_Spring_Forward() + { + DateTimeOffset now = DateTimeOffset.Now; + + List templates = + [ + new() + { + Index = 1, + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), + Template = SingleBlockTemplate(now), // 9am block + DateUpdated = now.UtcDateTime + } + ]; + + // In 2024, DST starts on March 10 for America/Chicago + // For Windows, this would be "Central Standard Time" + var tz = TimeZoneInfo.FindSystemTimeZoneById("America/Chicago"); + var start = new DateTime(2024, 3, 9, 0, 0, 0, DateTimeKind.Unspecified); + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + List result = EffectiveBlock.GetEffectiveBlocks(templates, dto, 5); + + result.Count.ShouldBe(5); + + // Saturday March 9, 9am is CST (-6) + var blockOnSat = result.Single(r => r.Start.Day == 9); + blockOnSat.Start.Hour.ShouldBe(9); + blockOnSat.Start.Offset.ShouldBe(TimeSpan.FromHours(-6)); + + // Sunday March 10, 9am is CDT (-5) + var blockOnSun = result.Single(r => r.Start.Day == 10); + blockOnSun.Start.Hour.ShouldBe(9); + blockOnSun.Start.Offset.ShouldBe(TimeSpan.FromHours(-5)); + } - // TODO: offset may be incorrect on days with time change, since start offset is re-used + [Test] + public void Should_Handle_Fall_Back() + { + DateTimeOffset now = DateTimeOffset.Now; + + List templates = + [ + new() + { + Index = 1, + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), + Template = SingleBlockTemplate(now), // 9am block + DateUpdated = now.UtcDateTime + } + ]; + + // In 2024, DST ends on Nov 3 for America/Chicago + // For Windows, this would be "Central Standard Time" + var tz = TimeZoneInfo.FindSystemTimeZoneById("America/Chicago"); + var start = new DateTime(2024, 11, 2, 0, 0, 0, DateTimeKind.Unspecified); + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + List result = EffectiveBlock.GetEffectiveBlocks(templates, dto, 5); + + result.Count.ShouldBe(5); + + // Saturday Nov 2, 9am is CDT (-5) + var blockOnSat = result.Single(r => r.Start.Day == 2); + blockOnSat.Start.Hour.ShouldBe(9); + blockOnSat.Start.Offset.ShouldBe(TimeSpan.FromHours(-5)); + + // Sunday Nov 3, 9am is CST (-6) + var blockOnSun = result.Single(r => r.Start.Day == 3); + blockOnSun.Start.Hour.ShouldBe(9); + blockOnSun.Start.Offset.ShouldBe(TimeSpan.FromHours(-6)); + } } } diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs index 2f685a982..c087ab8e9 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs @@ -37,14 +37,15 @@ public abstract class PlayoutBuilderTestBase protected static DateTimeOffset HoursAfterMidnight(int hours) { - DateTimeOffset now = DateTimeOffset.Now; - return now - now.TimeOfDay + TimeSpan.FromHours(hours); - - // // pick a timezone that has DST and a known offset on a specific date - // TimeZoneInfo eastern = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); - // DateTime date = new DateTime(2025, 11, 2, 0, 0, 0, DateTimeKind.Unspecified); - // DateTimeOffset now = new DateTimeOffset(date, eastern.GetUtcOffset(date)); - // return now.Date + TimeSpan.FromHours(hours); + // DateTimeOffset now = DateTimeOffset.Now; + // return now - now.TimeOfDay + TimeSpan.FromHours(hours); + + // pick a timezone that has DST and a known offset on a specific date + TimeZoneInfo eastern = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + //DateTime date = new DateTime(2025, 11, 2, 0, 0, 0, DateTimeKind.Unspecified); + DateTime date = new DateTime(2025, 10, 4, 0, 0, 0, DateTimeKind.Unspecified); + DateTimeOffset now = new DateTimeOffset(date, eastern.GetUtcOffset(date)); + return now.Date + TimeSpan.FromHours(hours); } protected TestData TestDataFloodForItems( diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs index 7d432190f..9a4d3b29f 100644 --- a/ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs @@ -9,6 +9,8 @@ internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset St DateTimeOffset start, int daysToBuild) { + var timeZone = TimeZoneInfo.Local; + DateTimeOffset finish = start.AddDays(daysToBuild); var effectiveBlocks = new List(); @@ -26,14 +28,15 @@ internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset St DateTimeOffset today = current; var newBlocks = playoutTemplate.Template.Items - .Map(i => ToEffectiveBlock(playoutTemplate, i, today, start)) + .Map(i => ToEffectiveBlock(playoutTemplate, i, today)) .Map(NormalizeGuideMode) .ToList(); effectiveBlocks.AddRange(newBlocks); } - current = current.AddDays(1); + var localDate = TimeZoneInfo.ConvertTime(current, timeZone).Date; + current = new DateTimeOffset(localDate.AddDays(1), timeZone.GetUtcOffset(localDate.AddDays(1))); } effectiveBlocks.RemoveAll(b => b.Start.AddMinutes(b.Block.Minutes) < start || b.Start > finish); @@ -45,20 +48,27 @@ internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset St private static EffectiveBlock ToEffectiveBlock( PlayoutTemplate playoutTemplate, TemplateItem templateItem, - DateTimeOffset current, - DateTimeOffset start) => - new( + DateTimeOffset current) + { + var blockStartTime = new DateTime( + current.Year, + current.Month, + current.Day, + templateItem.StartTime.Hours, + templateItem.StartTime.Minutes, + 0, + DateTimeKind.Unspecified); + + // use the system's local time zone + var timeZone = TimeZoneInfo.Local; + var blockStart = new DateTimeOffset(blockStartTime, timeZone.GetUtcOffset(blockStartTime)); + + return new EffectiveBlock( templateItem.Block, new BlockKey(templateItem.Block, templateItem.Template, playoutTemplate), - new DateTimeOffset( - current.Year, - current.Month, - current.Day, - templateItem.StartTime.Hours, - templateItem.StartTime.Minutes, - 0, - start.Offset), + blockStart, templateItem.Id); + } private static EffectiveBlock NormalizeGuideMode(EffectiveBlock effectiveBlock) {