From c04e3c55995e00f35bc7f9013d121162796cfa76 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 17 Jun 2026 15:53:12 +0000 Subject: [PATCH] Add daily coverage planning helper --- internal/app/batch_plan.go | 66 ++++++++++ internal/app/batch_plan_test.go | 217 ++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 internal/app/batch_plan.go create mode 100644 internal/app/batch_plan_test.go diff --git a/internal/app/batch_plan.go b/internal/app/batch_plan.go new file mode 100644 index 0000000..87e0e7a --- /dev/null +++ b/internal/app/batch_plan.go @@ -0,0 +1,66 @@ +package app + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time { + if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 { + return nil + } + + hourlyStarts := make(map[time.Time]struct{}, len(hourly.Periods)) + var maxLocalDate time.Time + for _, period := range hourly.Periods { + if !isHourlyPeriod(period) { + continue + } + start := period.StartTime + hourlyStarts[instantKey(start)] = struct{}{} + localDate := localDateStart(start, location) + if maxLocalDate.IsZero() || localDate.After(maxLocalDate) { + maxLocalDate = localDate + } + } + if len(hourlyStarts) == 0 || maxLocalDate.IsZero() { + return nil + } + + startDate := localDateStart(now.In(location).AddDate(0, 0, 2), location) + var dates []time.Time + for candidate := startDate; !candidate.After(maxLocalDate); candidate = candidate.AddDate(0, 0, 1) { + if hasFullHourlyCoverage(candidate, location, hourlyStarts) { + dates = append(dates, candidate) + } + } + return dates +} + +func isHourlyPeriod(period weatherdata.ForecastPeriod) bool { + if period.StartTime.IsZero() || period.EndTime.IsZero() { + return false + } + return period.EndTime.Equal(period.StartTime.Add(time.Hour)) +} + +func hasFullHourlyCoverage(date time.Time, location *time.Location, hourlyStarts map[time.Time]struct{}) bool { + day := timeutil.CivilDay(date, location) + for required := day.Start; required.Before(day.End); required = required.Add(time.Hour) { + if _, ok := hourlyStarts[instantKey(required)]; !ok { + return false + } + } + return true +} + +func instantKey(value time.Time) time.Time { + return value.UTC() +} + +func localDateStart(value time.Time, location *time.Location) time.Time { + local := value.In(location) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) +} diff --git a/internal/app/batch_plan_test.go b/internal/app/batch_plan_test.go new file mode 100644 index 0000000..3816393 --- /dev/null +++ b/internal/app/batch_plan_test.go @@ -0,0 +1,217 @@ +package app + +import ( + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...) + + got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location, "2026-05-31") +} + +func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...) + + got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location, "2026-05-31") +} + +func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + periods := fullDayPeriods(t, "2026-05-31", location) + periods = append(periods[:12], periods[13:]...) + hourly := hourlyRun(periods...) + + got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location) +} + +func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + periods := fullDayPeriods(t, "2026-05-31", location) + periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...) + hourly := hourlyRun(periods...) + + got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location, "2026-05-31") +} + +func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + periods := fullDayPeriods(t, "2026-05-29", location) + periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...) + periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...) + hourly := hourlyRun(periods...) + + got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location, "2026-05-31") +} + +func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + periods := fullDayPeriods(t, "2026-05-31", location) + periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...) + hourly := hourlyRun(periods...) + + got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location, "2026-05-31", "2026-06-01") +} + +func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location) + periods := []weatherdata.ForecastPeriod{ + {StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)}, + {StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)}, + {StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)}, + } + periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...) + hourly := hourlyRun(periods...) + + got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location, "2026-06-01") +} + +func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) { + location := mustLoadTestLocation(t, "America/New_York") + tests := []struct { + name string + now string + date string + }{ + { + name: "spring forward", + now: "2026-03-06T08:00:00-05:00", + date: "2026-03-08", + }, + { + name: "fall back", + now: "2026-10-30T08:00:00-04:00", + date: "2026-11-01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...) + + got := eligibleDailyDates(hourly, mustParse(tt.now), location) + assertLocalDates(t, got, location, tt.date) + }) + } +} + +func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + fullDay := fullDayPeriods(t, "2026-05-31", location) + + tests := []struct { + name string + hourly *weatherdata.ForecastRun + }{ + {name: "nil run"}, + {name: "empty periods", hourly: hourlyRun()}, + {name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location) + assertLocalDates(t, got, location) + }) + } +} + +func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun { + return forecastRun("hourly", periods...) +} + +func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun { + return &weatherdata.ForecastRun{ + Product: product, + Periods: periods, + } +} + +func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod { + t.Helper() + day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location) + var periods []weatherdata.ForecastPeriod + for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) { + periods = append(periods, weatherdata.ForecastPeriod{ + StartTime: start, + EndTime: start.Add(time.Hour), + }) + } + return periods +} + +func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod { + t.Helper() + periods := fullDayPeriods(t, date, location) + if count > len(periods) { + count = len(periods) + } + return periods[:count] +} + +func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod { + t.Helper() + out := make([]weatherdata.ForecastPeriod, 0, len(periods)) + for _, period := range periods { + start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339)) + if err != nil { + t.Fatalf("parse fixed-offset start: %v", err) + } + end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339)) + if err != nil { + t.Fatalf("parse fixed-offset end: %v", err) + } + out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end}) + } + return out +} + +func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) { + t.Helper() + gotDates := make([]string, 0, len(got)) + for _, date := range got { + gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout)) + } + if strings.Join(gotDates, ",") != strings.Join(want, ",") { + t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ",")) + } + for _, date := range got { + day := timeutil.CivilDay(date, location) + if !date.Equal(day.Start) { + t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start) + } + } +} + +func mustLoadTestLocation(t *testing.T, name string) *time.Location { + t.Helper() + location, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("LoadLocation(%q) error = %v", name, err) + } + return location +} + +func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time { + t.Helper() + parsed, err := timeutil.ParseLocalDate(value, location) + if err != nil { + t.Fatalf("ParseLocalDate(%q) error = %v", value, err) + } + return parsed +}