From caf21dfedd08168aac46d5ea548a3ee7f84d3dc8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 17:17:50 +0000 Subject: [PATCH] Add report registry and valid periods --- docs/cli.md | 9 +- docs/internal/report-registry.md | 63 +++++++++ internal/app/app.go | 71 +++++++++- internal/app/app_test.go | 76 ++++++++++ internal/report/definition.go | 87 ++++++++++++ internal/report/period.go | 144 +++++++++++++++++++ internal/report/period_test.go | 231 +++++++++++++++++++++++++++++++ internal/report/registry.go | 88 ++++++++++++ 8 files changed, 763 insertions(+), 6 deletions(-) create mode 100644 docs/internal/report-registry.md create mode 100644 internal/report/definition.go create mode 100644 internal/report/period.go create mode 100644 internal/report/period_test.go create mode 100644 internal/report/registry.go diff --git a/docs/cli.md b/docs/cli.md index 3688755..d2f4a98 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,7 +1,8 @@ # Weatherreporter CLI -`weatherreporter` currently resolves configuration and command requests, then -returns a not-implemented error for report generation and scheduled runs. +`weatherreporter` currently resolves configuration, command requests, report +definitions, and valid periods, then returns a not-implemented error for report +generation and scheduled runs. ## Shortest Useful Command @@ -9,8 +10,8 @@ returns a not-implemented error for report generation and scheduled runs. weatherreporter generate daily --date 2026-05-29 --out ./daily.md ``` -The command parses flags, loads configuration, resolves the request, and then -stops before weather data fetching or report rendering. +The command parses flags, loads configuration, resolves report identity and the +valid period, and then stops before weather data fetching or report rendering. ## Command Overview diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md new file mode 100644 index 0000000..eebfa32 --- /dev/null +++ b/docs/internal/report-registry.md @@ -0,0 +1,63 @@ +# Report Registry Internals + +This document describes the implemented report identity and valid-period +boundary. + +## Purpose + +`internal/report` centralizes report IDs, prompt IDs, comparison strategies, +valid-period resolution, report metadata, and scheduled batch membership. + +## Inputs and Outputs + +Inputs: + +- report ID or batch name +- generation time +- configured timezone +- optional Daily date override +- optional manual storm start and end times + +Outputs: + +- `report.Resolved` values with definition metadata and half-open valid periods +- `report.Metadata` values suitable for later persisted run metadata + +## Boundaries + +- This package defines report identity and time coverage only. +- It does not fetch weather data, build briefings, compare snapshots, write + state, or call `scriptorium`. + +## Behavior + +- Daily Today covers one configured local civil day. +- Daily Tomorrow covers the next configured local civil day. +- 3-Day Outlook covers generation time through local midnight after the second + following local civil day. +- Weekend Outlook covers Saturday 00:00 to Monday 00:00 Monday through + Thursday; Friday and Saturday cover the remaining weekend from Friday 18:00 + or generation time, whichever is later. +- Manual Storm Report uses explicit start and end times. +- Morning batch resolves Daily Today and 3-Day Outlook, plus Weekend Outlook + except on Sunday. +- Evening batch resolves Daily Tomorrow. + +## Failure Behavior + +- Unknown report and batch names return actionable errors. +- Sunday Weekend Outlook resolution returns an error. +- Storm windows require start and end, with end after start. + +## Tests + +Inspect: + +- `internal/report/period_test.go` +- `internal/app/app_test.go` + +## Invariants + +- Report selection goes through the registry. +- Valid periods are independent of rendered report text. +- Prompt IDs and comparison strategies are declared with report definitions. diff --git a/internal/app/app.go b/internal/app/app.go index 39201ee..29e36f5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index afbc27a..158010a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 +} diff --git a/internal/report/definition.go b/internal/report/definition.go new file mode 100644 index 0000000..55690b2 --- /dev/null +++ b/internal/report/definition.go @@ -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, + } +} diff --git a/internal/report/period.go b/internal/report/period.go new file mode 100644 index 0000000..e890e72 --- /dev/null +++ b/internal/report/period.go @@ -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 +} diff --git a/internal/report/period_test.go b/internal/report/period_test.go new file mode 100644 index 0000000..f3c84d9 --- /dev/null +++ b/internal/report/period_test.go @@ -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 +} diff --git a/internal/report/registry.go b/internal/report/registry.go new file mode 100644 index 0000000..cec0cd4 --- /dev/null +++ b/internal/report/registry.go @@ -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 +}