Add report registry and valid periods

This commit is contained in:
2026-05-29 17:17:50 +00:00
parent d494550b20
commit caf21dfedd
8 changed files with 763 additions and 6 deletions

View File

@@ -9,6 +9,8 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ReportKind string
@@ -49,16 +51,81 @@ type FetchBundleRequest struct {
func Generate(ctx context.Context, req GenerateRequest) error {
_ = ctx
_ = req
if _, err := ResolveGenerate(req, time.Now()); err != nil {
return err
}
return fmt.Errorf("generate is not implemented")
}
func RunBatch(ctx context.Context, req BatchRequest) error {
_ = ctx
_ = req
if _, err := ResolveBatch(req, time.Now()); err != nil {
return err
}
return fmt.Errorf("run is not implemented")
}
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return report.Resolved{}, err
}
id, err := reportIDForCommand(req.Report)
if err != nil {
return report.Resolved{}, err
}
return report.DefaultRegistry().Resolve(id, report.ResolveRequest{
Now: now,
Location: location,
Date: req.Date,
StormStart: req.StormStart,
StormEnd: req.StormEnd,
})
}
func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := reportBatchForCommand(req.Batch)
if err != nil {
return nil, err
}
return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{
Now: now,
Location: location,
})
}
func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind {
case ReportDaily:
return report.DailyToday, nil
case ReportTomorrow:
return report.DailyTomorrow, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:
return report.Weekend, nil
case ReportStorm:
return report.Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", kind)
}
}
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
switch kind {
case BatchMorning:
return report.Morning, nil
case BatchEvening:
return report.Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", kind)
}
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
client, err := weatherapi.New(req.Config)
if err != nil {

View File

@@ -8,8 +8,10 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestFetchAndSaveBundle(t *testing.T) {
@@ -62,3 +64,77 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
t.Fatalf("error = %q, want output path context", err.Error())
}
}
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T18:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportTomorrow,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != report.DailyTomorrow {
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID)
}
if resolved.Definition.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
}
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" {
t.Fatalf("valid start date = %s, want 2026-05-30", got)
}
}
func TestResolveGenerateStorm(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T12:00:00-05:00")
start := mustParse("2026-05-29T18:00:00-05:00")
end := mustParse("2026-05-30T06:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportStorm,
StormStart: start,
StormEnd: end,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != report.Storm {
t.Fatalf("ID = %q, want storm", resolved.Definition.ID)
}
if !resolved.ValidPeriod.Start.Equal(start) || !resolved.ValidPeriod.End.Equal(end) {
t.Fatalf("period = %#v, want storm window", resolved.ValidPeriod)
}
}
func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-31T06:00:00-05:00")
resolved, err := ResolveBatch(BatchRequest{Config: cfg, Batch: BatchMorning}, now)
if err != nil {
t.Fatalf("ResolveBatch() error = %v", err)
}
if len(resolved) != 2 {
t.Fatalf("resolved length = %d, want 2", len(resolved))
}
for _, item := range resolved {
if item.Definition.ID == report.Weekend {
t.Fatal("morning batch included weekend on Sunday")
}
}
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,87 @@
// Package report defines report identities, registry metadata, and valid periods.
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ID string
const (
DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow"
ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
)
type ComparisonStrategy string
const (
CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
)
type Batch string
const (
Morning Batch = "morning"
Evening Batch = "evening"
)
type Definition struct {
ID ID
Name string
PromptID string
ComparisonStrategy ComparisonStrategy
DefaultOutputName string
Morning bool
Evening bool
resolve func(ResolveRequest) (timeutil.Period, error)
}
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
if d.resolve == nil {
return timeutil.Period{}, fmt.Errorf("report %q has no valid-period resolver", d.ID)
}
return d.resolve(req)
}
type ResolveRequest struct {
Now time.Time
Location *time.Location
Date time.Time
StormStart time.Time
StormEnd time.Time
}
type Resolved struct {
Definition Definition
GeneratedAt time.Time
Timezone string
ValidPeriod timeutil.Period
}
type Metadata struct {
RunID string `json:"runId"`
ReportID ID `json:"reportId"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
}
func (r Resolved) Metadata() Metadata {
return Metadata{
RunID: r.GeneratedAt.UTC().Format("20060102T150405Z") + "_" + string(r.Definition.ID),
ReportID: r.Definition.ID,
PromptID: r.Definition.PromptID,
GeneratedAt: r.GeneratedAt,
Timezone: r.Timezone,
ValidPeriod: r.ValidPeriod,
}
}

144
internal/report/period.go Normal file
View File

@@ -0,0 +1,144 @@
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
return DefaultRegistry().Resolve(id, req)
}
func (r Registry) Resolve(id ID, req ResolveRequest) (Resolved, error) {
definition, err := r.Lookup(id)
if err != nil {
return Resolved{}, err
}
return r.resolveDefinition(definition, req)
}
func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, error) {
if req.Location == nil {
req.Location = time.UTC
}
if req.Now.IsZero() {
req.Now = time.Now()
}
switch batch {
case Morning:
ids := []ID{DailyToday, ThreeDay}
if req.Now.In(req.Location).Weekday() != time.Sunday {
ids = append(ids, Weekend)
}
return r.resolveIDs(ids, req)
case Evening:
return r.resolveIDs([]ID{DailyTomorrow}, req)
default:
return nil, fmt.Errorf("unknown batch %q", batch)
}
}
func (r Registry) resolveIDs(ids []ID, req ResolveRequest) ([]Resolved, error) {
resolved := make([]Resolved, 0, len(ids))
for _, id := range ids {
item, err := r.Resolve(id, req)
if err != nil {
return nil, err
}
resolved = append(resolved, item)
}
return resolved, nil
}
func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) {
if req.Location == nil {
req.Location = time.UTC
}
if req.Now.IsZero() {
req.Now = time.Now()
}
period, err := definition.ResolvePeriod(req)
if err != nil {
return Resolved{}, err
}
return Resolved{
Definition: definition,
GeneratedAt: req.Now,
Timezone: req.Location.String(),
ValidPeriod: period,
}, nil
}
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil
}
return timeutil.CivilDay(req.Now, req.Location), nil
}
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
if location == nil {
location = time.UTC
}
startTime, err := timeutil.ParseStormTime(start, location)
if err != nil {
return timeutil.Period{}, err
}
endTime, err := timeutil.ParseStormTime(end, location)
if err != nil {
return timeutil.Period{}, err
}
period := timeutil.Period{Start: startTime, End: endTime}
if !period.IsValid() {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return period, nil
}
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
}
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
endDate := localNow.AddDate(0, 0, 3)
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
return timeutil.Period{Start: localNow, End: end}, nil
}
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
weekday := localNow.Weekday()
if weekday == time.Sunday {
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
}
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
if weekday == time.Friday || weekday == time.Saturday {
friday := start.AddDate(0, 0, -1)
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
start = fridayEvening
if localNow.After(start) {
start = localNow
}
}
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
return timeutil.Period{Start: start, End: end}, nil
}
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
if req.StormStart.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
}
if req.StormEnd.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
}
if !req.StormEnd.After(req.StormStart) {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
}

View File

@@ -0,0 +1,231 @@
package report
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestDailyValidPeriod(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
}
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
date := mustParse("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T20:00:00-05:00")
resolved, err := Resolve(DailyTomorrow, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
}
}
func TestThreeDayPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:00:00-05:00")
resolved, err := Resolve(ThreeDay, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestWeekendPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
tests := []struct {
name string
now string
start string
end string
}{
{name: "monday", now: "2026-05-25T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "friday before evening", now: "2026-05-29T05:00:00-05:00", start: "2026-05-29T18:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "friday after evening", now: "2026-05-29T19:30:00-05:00", start: "2026-05-29T19:30:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "saturday", now: "2026-05-30T08:00:00-05:00", start: "2026-05-30T08:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolved, err := Resolve(Weekend, ResolveRequest{Now: mustParse(tt.now), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, tt.start, tt.end)
})
}
}
func TestWeekendSundayErrors(t *testing.T) {
location := mustLoadLocation(t)
_, err := Resolve(Weekend, ResolveRequest{Now: mustParse("2026-05-31T08:00:00-05:00"), Location: location})
if err == nil {
t.Fatal("Resolve() error = nil, want Sunday weekend error")
}
if !strings.Contains(err.Error(), "Sunday") {
t.Fatalf("error = %q, want Sunday context", err.Error())
}
}
func TestStormManualPeriodParsingAndValidation(t *testing.T) {
location := mustLoadLocation(t)
period, err := ParseStormPeriod("2026-05-29T18:00", "2026-05-30T06:00:00-05:00", location)
if err != nil {
t.Fatalf("ParseStormPeriod() error = %v", err)
}
assertPeriod(t, period, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
_, err = ParseStormPeriod("2026-05-30T06:00", "2026-05-29T18:00", location)
if err == nil {
t.Fatal("ParseStormPeriod() error = nil, want invalid period error")
}
}
func TestStormResolve(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(Storm, ResolveRequest{
Now: mustParse("2026-05-29T12:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T18:00:00-05:00"),
StormEnd: mustParse("2026-05-30T06:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
if resolved.Definition.ComparisonStrategy != CompareExplicitWindow {
t.Fatalf("ComparisonStrategy = %q, want explicit event window", resolved.Definition.ComparisonStrategy)
}
}
func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-31T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_today,three_day" {
t.Fatalf("ids = %v, want daily_today and three_day", ids)
}
}
func TestEveningBatchIncludesTomorrow(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Evening, ResolveRequest{
Now: mustParse("2026-05-29T18:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_tomorrow" {
t.Fatalf("ids = %v, want daily_tomorrow", ids)
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil {
t.Fatal("Lookup() error = nil, want unknown report error")
}
if !strings.Contains(err.Error(), `unknown report "unknown"`) {
t.Fatalf("error = %q, want unknown report context", err.Error())
}
}
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if definition.PromptID == "" {
t.Fatalf("%s PromptID is empty", definition.ID)
}
if definition.ComparisonStrategy == "" {
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
}
}
}
func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := resolved.Metadata()
if metadata.ReportID != DailyToday {
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID)
}
if metadata.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID)
}
if !strings.Contains(metadata.RunID, "daily_today") {
t.Fatalf("RunID = %q, want report id", metadata.RunID)
}
}
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
t.Fatalf("period = %#v, want valid", period)
}
if period.Start.Format(time.RFC3339) != wantStart {
t.Fatalf("Start = %s, want %s", period.Start.Format(time.RFC3339), wantStart)
}
if period.End.Format(time.RFC3339) != wantEnd {
t.Fatalf("End = %s, want %s", period.End.Format(time.RFC3339), wantEnd)
}
}
func resolvedIDs(resolved []Resolved) []string {
ids := make([]string, 0, len(resolved))
for _, item := range resolved {
ids = append(ids, string(item.Definition.ID))
}
return ids
}
func mustLoadLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("load location: %v", err)
}
return location
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,88 @@
package report
import "fmt"
type Registry struct {
definitions map[ID]Definition
}
func DefaultRegistry() Registry {
definitions := []Definition{
{
ID: DailyToday,
Name: "Daily Report",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "daily.md",
Morning: true,
resolve: resolveDailyToday,
},
{
ID: DailyTomorrow,
Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "tomorrow.md",
Evening: true,
resolve: resolveDailyTomorrow,
},
{
ID: ThreeDay,
Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "three_day.md",
Morning: true,
resolve: resolveThreeDay,
},
{
ID: Weekend,
Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow,
DefaultOutputName: "weekend.md",
Morning: true,
resolve: resolveWeekend,
},
{
ID: Storm,
Name: "Storm Report",
PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow,
DefaultOutputName: "storm.md",
resolve: resolveStorm,
},
}
registry := Registry{definitions: map[ID]Definition{}}
for _, definition := range definitions {
registry.definitions[definition.ID] = definition
}
return registry
}
func (r Registry) Lookup(id ID) (Definition, error) {
definition, ok := r.definitions[id]
if !ok {
return Definition{}, fmt.Errorf("unknown report %q", id)
}
return definition, nil
}
func (r Registry) MustLookup(id ID) Definition {
definition, err := r.Lookup(id)
if err != nil {
panic(err)
}
return definition
}
func (r Registry) All() []Definition {
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
out := make([]Definition, 0, len(ids))
for _, id := range ids {
if definition, ok := r.definitions[id]; ok {
out = append(out, definition)
}
}
return out
}