From 8fafacf92108e311ddfec867d1192e8da61c6bf2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 13 Aug 2026 00:39:46 +0000 Subject: [PATCH] Preserve civil daypart clocks across DST --- docs/internal/report-registry.md | 7 +++- internal/report/period_test.go | 31 ++++++++++++++ internal/timeutil/periods.go | 13 ++++-- internal/timeutil/periods_test.go | 69 +++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index 0a88742..861db4c 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -13,7 +13,12 @@ Each `Definition` declares a stable ID and display name, prompt ID and version, | `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` | | `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` | -Daily derives its filename from the resolved valid-period start in the effective timezone, so multiple Daily items have distinct destinations. Exact template fields and schema assets belong to [report templates](../templates.md) and [generated-text internals](generatedtext.md). Prompt assets own default profile selection; the registry stores no provider setting. +Daily derives its filename and run identity from the resolved valid-period start +in the effective timezone, so multiple Daily items have distinct destinations +and identifiers. Exact template fields and schema assets belong to [report +templates](../templates.md) and [generated-text internals](generatedtext.md). +Prompt assets own default profile selection; the registry stores no provider +setting. ## Collaborators And Boundaries diff --git a/internal/report/period_test.go b/internal/report/period_test.go index ac861bd..40028a4 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -43,6 +43,37 @@ func TestResolveDailyRequiresDate(t *testing.T) { } } +func TestDailyRunIDsDistinguishResolvedDates(t *testing.T) { + location := loadTestLocation(t) + now := parseTestTime("2026-05-29T17:45:00-05:00") + tests := []struct { + date time.Time + wantDate string + }{ + {date: time.Date(2026, time.May, 30, 12, 0, 0, 0, location), wantDate: "2026-05-30"}, + {date: time.Date(2026, time.May, 31, 12, 0, 0, 0, location), wantDate: "2026-05-31"}, + } + + runIDs := make(map[string]struct{}, len(tests)) + for _, tt := range tests { + resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: tt.date}) + if err != nil { + t.Fatalf("Resolve(Daily) error = %v", err) + } + runID := resolved.Metadata().RunID + if runID == "" { + t.Fatal("Daily RunID is empty") + } + if !strings.HasSuffix(runID, "_"+tt.wantDate) { + t.Fatalf("Daily RunID = %q, want resolved date %q", runID, tt.wantDate) + } + if _, exists := runIDs[runID]; exists { + t.Fatalf("duplicate Daily RunID %q", runID) + } + runIDs[runID] = struct{}{} + } +} + func TestRegistryContainsOnlyPromptBackedReports(t *testing.T) { registry := DefaultRegistry() definitions := registry.All() diff --git a/internal/timeutil/periods.go b/internal/timeutil/periods.go index 449be0d..769dd2e 100644 --- a/internal/timeutil/periods.go +++ b/internal/timeutil/periods.go @@ -72,10 +72,17 @@ func CivilDay(date time.Time, location *time.Location) Period { func ClockWindow(date time.Time, location *time.Location, startClock time.Duration, endClock time.Duration) Period { day := CivilDay(date, location) - start := day.Start.Add(startClock) - end := day.Start.Add(endClock) + start := civilClock(day.Start, location, startClock) + end := civilClock(day.Start, location, endClock) if !end.After(start) { - end = end.AddDate(0, 0, 1) + end = civilClock(day.Start.AddDate(0, 0, 1), location, endClock) } return Period{Start: start, End: end} } + +func civilClock(date time.Time, location *time.Location, clock time.Duration) time.Time { + local := date.In(location) + hour := int(clock / time.Hour) + minute := int(clock%time.Hour) / int(time.Minute) + return time.Date(local.Year(), local.Month(), local.Day(), hour, minute, 0, 0, location) +} diff --git a/internal/timeutil/periods_test.go b/internal/timeutil/periods_test.go index a36fe17..360fd15 100644 --- a/internal/timeutil/periods_test.go +++ b/internal/timeutil/periods_test.go @@ -33,3 +33,72 @@ func TestClockWindowHandlesOvernight(t *testing.T) { t.Fatalf("End = %s, want 2026-05-30T06:00", got) } } + +func TestClockWindowUsesCivilTimeAcrossDSTTransitions(t *testing.T) { + location, err := LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + tests := []struct { + name string + date time.Time + start string + end string + wantStart string + wantEnd string + }{ + { + name: "spring-forward daytime", + date: time.Date(2026, time.March, 8, 12, 0, 0, 0, location), + start: "00:00", + end: "06:00", + wantStart: "2026-03-08T00:00:00-06:00", + wantEnd: "2026-03-08T06:00:00-05:00", + }, + { + name: "fall-back daytime", + date: time.Date(2026, time.November, 1, 12, 0, 0, 0, location), + start: "00:00", + end: "06:00", + wantStart: "2026-11-01T00:00:00-05:00", + wantEnd: "2026-11-01T06:00:00-06:00", + }, + { + name: "spring-forward overnight", + date: time.Date(2026, time.March, 8, 12, 0, 0, 0, location), + start: "22:00", + end: "06:00", + wantStart: "2026-03-08T22:00:00-05:00", + wantEnd: "2026-03-09T06:00:00-05:00", + }, + { + name: "spring-forward end of day", + date: time.Date(2026, time.March, 8, 12, 0, 0, 0, location), + start: "18:00", + end: "24:00", + wantStart: "2026-03-08T18:00:00-05:00", + wantEnd: "2026-03-09T00:00:00-05:00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, err := ParseClock(tt.start) + if err != nil { + t.Fatalf("ParseClock(%q) error = %v", tt.start, err) + } + end, err := ParseClock(tt.end) + if err != nil { + t.Fatalf("ParseClock(%q) error = %v", tt.end, err) + } + + window := ClockWindow(tt.date, location, start, end) + if got := window.Start.Format(time.RFC3339); got != tt.wantStart { + t.Fatalf("Start = %s, want %s", got, tt.wantStart) + } + if got := window.End.Format(time.RFC3339); got != tt.wantEnd { + t.Fatalf("End = %s, want %s", got, tt.wantEnd) + } + }) + } +}