diff --git a/README.md b/README.md index e5fc3c5..6f05562 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,15 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The application can currently generate a Daily Markdown report through -`scriptorium`, with inspectable briefing, prompt input, preflight, report, and -metadata artifacts under the configured workspace. +The application can currently generate Daily Today and Daily Tomorrow Markdown +reports through `scriptorium`, with inspectable briefing, prompt input, +preflight, report, and metadata artifacts under the configured workspace. ## Quickstart ```sh weatherreporter generate daily --date 2026-05-29 --out ./daily.md +weatherreporter generate tomorrow --out ./tomorrow.md ``` ## Documentation diff --git a/docs/cli.md b/docs/cli.md index d2ce393..3b0c31c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,7 @@ # Weatherreporter CLI -`weatherreporter generate daily` currently writes a Daily Markdown report +`weatherreporter generate daily`, `weatherreporter generate tomorrow`, and +`weatherreporter run evening` currently write Daily-family Markdown reports through `scriptorium`, after writing managed preparation artifacts and running `scriptorium render` as a preflight check. Other report generation and scheduled runs still resolve configuration, report definitions, and valid periods, then @@ -19,6 +20,13 @@ invokes `scriptorium run --input data_package= --out .md ``` -The Markdown report is written to the managed report path. When `--out` is -provided, the managed report is also copied to that path. +The Markdown report is written to a RunID-managed report path. When `--out` is +provided to `generate daily` or `generate tomorrow`, the managed report is also +copied to that path. ## Run Identifiers @@ -67,7 +71,9 @@ Each Daily generation writes metadata that links: When a prior comparable Daily briefing snapshot exists for the same valid local date, the app compares structured briefing data before writing the prompt input -data package. Meaningful changes are included under `recentChanges.items`. +data package. Daily Today and Daily Tomorrow can compare with each other when +they cover the same valid local date. Meaningful changes are included under +`recentChanges.items`. When no prior comparable snapshot exists, or no configured threshold is crossed, the Recent Changes list is empty. diff --git a/internal/app/app.go b/internal/app/app.go index 7872e2f..bf642fe 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -41,6 +41,7 @@ type GenerateRequest struct { Config config.Config Report ReportKind OutputPath string + Now time.Time Date time.Time StormStart time.Time StormEnd time.Time @@ -49,6 +50,7 @@ type GenerateRequest struct { type BatchRequest struct { Config config.Config Batch BatchKind + Now time.Time } type FetchBundleRequest struct { @@ -97,11 +99,15 @@ type Renderer interface { } func Generate(ctx context.Context, req GenerateRequest) error { - resolved, err := ResolveGenerate(req, time.Now()) + now := req.Now + if now.IsZero() { + now = time.Now() + } + resolved, err := ResolveGenerate(req, now) if err != nil { return err } - if resolved.Definition.ID == report.DailyToday { + if isDailyReport(resolved.Definition.ID) { _, err := GenerateDailyReport(ctx, DailyReportRequest{ Config: req.Config, Resolved: resolved, @@ -113,13 +119,35 @@ func Generate(ctx context.Context, req GenerateRequest) error { } func RunBatch(ctx context.Context, req BatchRequest) error { - _ = ctx - if _, err := ResolveBatch(req, time.Now()); err != nil { + now := req.Now + if now.IsZero() { + now = time.Now() + } + resolvedReports, err := ResolveBatch(req, now) + if err != nil { return err } + if req.Batch == BatchEvening { + for _, resolved := range resolvedReports { + if !isDailyReport(resolved.Definition.ID) { + return fmt.Errorf("run is not implemented") + } + if _, err := GenerateDailyReport(ctx, DailyReportRequest{ + Config: req.Config, + Resolved: resolved, + }); err != nil { + return err + } + } + return nil + } return fmt.Errorf("run is not implemented") } +func isDailyReport(id report.ID) bool { + return id == report.DailyToday || id == report.DailyTomorrow +} + func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) if err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 87e27e5..d973b90 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -414,6 +414,115 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) } } +func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportTomorrow, + }, mustParse("2026-05-29T18:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 0}, + runBody: "# Tomorrow Planning Brief\n", + } + + result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ + Config: cfg, + Resolved: resolved, + Renderer: renderer, + }) + if err != nil { + t.Fatalf("GenerateDailyReport() error = %v", err) + } + + if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" { + t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant) + } + if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" { + t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate) + } + if result.Briefing.Daily.Planning == nil { + t.Fatal("Planning = nil, want tomorrow planning inputs") + } + if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") { + t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath) + } +} + +func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + store, err := state.NewFilesystemStore(cfg.Workspace) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + priorResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-30T12:00:00-05:00"), + }, mustParse("2026-05-29T17:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(prior) error = %v", err) + } + priorBriefing := priorDailyBriefing(priorResolved) + priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) + if err != nil { + t.Fatalf("SaveBriefing() error = %v", err) + } + priorPaths, err := store.Paths(priorResolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ + Briefing: priorBriefingPath, + DataPackage: priorPaths.DataPackage, + Preflight: priorPaths.Preflight, + RenderedReport: priorPaths.RenderedReport, + })) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } + + currentResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportTomorrow, + }, mustParse("2026-05-29T18:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(current) error = %v", err) + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 0}, + runBody: "# Tomorrow Planning Brief\n", + } + + result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ + Config: cfg, + Resolved: currentResolved, + Renderer: renderer, + Store: store, + }) + if err != nil { + t.Fatalf("GenerateDailyReport() error = %v", err) + } + if result.PriorSnapshot == nil { + t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot") + } + if len(result.RecentChanges) == 0 { + t.Fatal("RecentChanges length = 0, want changes from compatible prior daily snapshot") + } +} + func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" @@ -446,9 +555,9 @@ func dailyBundleServer(t *testing.T) *httptest.Server { case "/conditions/current": _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) case "/forecast/hourly": - _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) + _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) case "/forecast/narrative": - _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."}]}}`)) + _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`)) case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`)) case "/discussion": diff --git a/internal/briefing/daily.go b/internal/briefing/daily.go index 9f6adfb..fcdf21e 100644 --- a/internal/briefing/daily.go +++ b/internal/briefing/daily.go @@ -15,6 +15,7 @@ type Daily struct { Dayparts []forecast.DaypartSummary `json:"dayparts"` RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` OutdoorWindows OutdoorWindows `json:"outdoorWindows"` + Planning *TomorrowPlanning `json:"planning,omitempty"` NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` Discussion DiscussionContext `json:"discussion,omitempty"` WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` @@ -42,6 +43,12 @@ type OutdoorWindow struct { Score float64 `json:"score"` } +type TomorrowPlanning struct { + MorningReadiness []string `json:"morningReadiness,omitempty"` + CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"` + OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"` +} + type DiscussionContext struct { Product string `json:"product,omitempty"` KeyMessages []string `json:"keyMessages,omitempty"` @@ -74,6 +81,9 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro ForecastSummaryDate: summary.Date, }, } + if ctx.Resolved.Definition.ID == report.DailyTomorrow { + pkg.Daily.Planning = buildTomorrowPlanning(summary) + } return pkg, nil } @@ -122,6 +132,124 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { return OutdoorWindows{Best: best, Worst: worst} } +func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { + planning := &TomorrowPlanning{} + morning := daypartNamed(summary.Dayparts, "morning") + if morning != nil { + planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) + } + if len(planning.MorningReadiness) == 0 { + planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.") + } + + for _, daypart := range summary.Dayparts { + if daypart.Name == "overnight" || daypart.Name == "evening" { + continue + } + planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...) + } + for _, alert := range summary.AlertOverlaps { + if alert.Event != "" { + planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".") + } + } + if len(planning.CommuteSchoolWorkdayConcerns) == 0 { + planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.") + } + + overnight := daypartNamed(summary.Dayparts, "overnight") + if overnight != nil { + planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, overnightWatchNotes(*overnight)...) + } + if len(planning.OvernightChangeWatch) == 0 { + planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") + } + + return planning +} + +func readinessNotes(daypart forecast.DaypartSummary) []string { + notes := []string{} + if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 { + notes = append(notes, fmt.Sprintf("Morning precipitation chance peaks near %.0f%%.", daypart.MaxPrecipitationProbability.Value)) + } + if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { + notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value)) + } + if daypart.Indicators.Thunder { + notes = append(notes, "Morning thunder could affect departure timing.") + } + if daypart.Indicators.Snow || daypart.Indicators.Ice { + notes = append(notes, "Morning wintry weather could affect surfaces and travel.") + } + if daypart.Indicators.Fog { + notes = append(notes, "Morning fog could reduce visibility.") + } + if daypart.Temperature.Min != nil && *daypart.Temperature.Min <= 32 { + notes = append(notes, "Morning temperatures may be at or below freezing.") + } + return appendUnique(nil, notes...) +} + +func concernNotes(daypart forecast.DaypartSummary) []string { + notes := []string{} + prefix := titleWord(daypart.Name) + if prefix == "" { + prefix = "Daytime" + } + if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 40 { + notes = append(notes, fmt.Sprintf("%s precipitation chance reaches %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value)) + } + if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { + notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value)) + } + if daypart.Indicators.Thunder { + notes = append(notes, prefix+" thunder may disrupt outdoor plans.") + } + if daypart.Indicators.Snow || daypart.Indicators.Ice { + notes = append(notes, prefix+" wintry weather may affect travel.") + } + if daypart.Indicators.Heat { + notes = append(notes, prefix+" heat may require extra hydration and breaks.") + } + if daypart.Indicators.Cold { + notes = append(notes, prefix+" cold may require extra layers.") + } + if len(daypart.AlertOverlaps) > 0 { + notes = append(notes, prefix+" alert overlap needs attention.") + } + return appendUnique(nil, notes...) +} + +func overnightWatchNotes(daypart forecast.DaypartSummary) []string { + notes := []string{} + if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 { + notes = append(notes, fmt.Sprintf("Overnight precipitation timing may shift; current peak is near %.0f%%.", daypart.MaxPrecipitationProbability.Value)) + } + if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { + notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value)) + } + if daypart.Indicators.Thunder { + notes = append(notes, "Overnight storms could change morning impacts.") + } + if daypart.Indicators.Snow || daypart.Indicators.Ice { + notes = append(notes, "Overnight wintry weather could leave morning travel impacts.") + } + if len(daypart.AlertOverlaps) > 0 { + notes = append(notes, "Overnight alert timing could affect the morning setup.") + } + return appendUnique(nil, notes...) +} + +func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary { + for i := range dayparts { + if strings.EqualFold(dayparts[i].Name, name) { + return &dayparts[i] + } + } + return nil +} + func buildDiscussion(discussion *forecast.Discussion) DiscussionContext { if discussion == nil { return DiscussionContext{} @@ -276,3 +404,28 @@ func dedupe(values []string) []string { } return out } + +func appendUnique(values []string, candidates ...string) []string { + seen := map[string]struct{}{} + for _, value := range values { + seen[value] = struct{}{} + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + values = append(values, candidate) + } + return values +} + +func titleWord(value string) string { + if value == "" { + return "" + } + return strings.ToUpper(value[:1]) + value[1:] +} diff --git a/internal/briefing/daily_test.go b/internal/briefing/daily_test.go index 3ee2b8e..406e81b 100644 --- a/internal/briefing/daily_test.go +++ b/internal/briefing/daily_test.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { @@ -122,6 +123,77 @@ func TestDailyBriefingAlertExclusion(t *testing.T) { } } +func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) { + location := mustLocation(t) + resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{ + Now: mustParse("2026-05-29T18:00:00-05:00"), + Location: location, + }) + if err != nil { + t.Fatalf("resolve tomorrow: %v", err) + } + precip := 70.0 + wind := 34.0 + summary := &forecast.DailySummary{ + Date: "2026-05-30", + Period: resolved.ValidPeriod, + Dayparts: []forecast.DaypartSummary{ + { + Name: "overnight", + Period: timeutil.Period{ + Start: mustParse("2026-05-30T00:00:00-05:00"), + End: mustParse("2026-05-30T06:00:00-05:00"), + }, + MaxPrecipitationProbability: &forecast.TimedValue{ + Value: 40, + Time: mustParse("2026-05-30T03:00:00-05:00"), + }, + }, + { + Name: "morning", + Period: timeutil.Period{ + Start: mustParse("2026-05-30T06:00:00-05:00"), + End: mustParse("2026-05-30T12:00:00-05:00"), + }, + MaxPrecipitationProbability: &forecast.TimedValue{ + Value: precip, + Time: mustParse("2026-05-30T08:00:00-05:00"), + }, + PeakWindGust: &forecast.TimedValue{ + Value: wind, + Time: mustParse("2026-05-30T09:00:00-05:00"), + }, + Indicators: forecast.Indicators{Thunder: true}, + }, + }, + } + + pkg, err := BuildDaily(BuildContext{ + Resolved: resolved, + Units: "us", + Timezone: "America/Chicago", + }, summary) + if err != nil { + t.Fatalf("BuildDaily() error = %v", err) + } + + if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" { + t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant) + } + if pkg.Daily.ForecastSummaryDate != "2026-05-30" { + t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate) + } + if pkg.Daily.Planning == nil { + t.Fatal("Planning = nil, want tomorrow planning inputs") + } + if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 { + t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning) + } + if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") { + t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness) + } +} + func TestSaveBriefingPackage(t *testing.T) { pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}} path := filepath.Join(t.TempDir(), "nested", "briefing.json") diff --git a/internal/cli/root.go b/internal/cli/root.go index 4bc28fd..e291f21 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ Options: --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --units VALUE Override weather API units. --tz NAME Override weather API timezone. - --out PATH Write an extra Markdown report copy for generate daily. + --out PATH Write an extra Markdown report copy for generate daily or tomorrow. ` type Runner struct { @@ -57,7 +57,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr } return app.Generate(ctx, req) case "run": - req, err := resolveRun(args[1:]) + req, err := r.resolveRun(args[1:]) if err != nil { return err } @@ -82,6 +82,9 @@ type generateOptions struct { } func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { + if r.Clock == nil { + r.Clock = timeutil.SystemClock{} + } if len(args) == 0 { return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") } @@ -112,6 +115,7 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { Config: cfg, Report: report, OutputPath: opts.Output, + Now: r.Clock.Now(), } switch report { @@ -147,7 +151,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { return req, nil } -func resolveRun(args []string) (app.BatchRequest, error) { +func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { + if r.Clock == nil { + r.Clock = timeutil.SystemClock{} + } if len(args) == 0 { return app.BatchRequest{}, fmt.Errorf("run requires a batch name") } @@ -167,7 +174,11 @@ func resolveRun(args []string) (app.BatchRequest, error) { if err != nil { return app.BatchRequest{}, err } - return app.BatchRequest{Config: cfg, Batch: batch}, nil + return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now()}, nil +} + +func resolveRun(args []string) (app.BatchRequest, error) { + return Runner{Clock: timeutil.SystemClock{}}.resolveRun(args) } func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index fbc3215..36b82d2 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -62,7 +62,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} - err := runner.Run(context.Background(), []string{"generate", "tomorrow", "--units", "metric"}, &stdout, &stderr) + err := runner.Run(context.Background(), []string{"generate", "three-day", "--units", "metric"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want app not implemented error") } @@ -71,6 +71,96 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { } } +func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + configPath := filepath.Join(tempDir, "config.yml") + workspaceRoot := filepath.Join(tempDir, "workspace") + configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + outPath := filepath.Join(tempDir, "tomorrow.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{ + "generate", "tomorrow", + "--config", configPath, + "--out", outPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + report, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + if !strings.Contains(string(report), "# Daily Report") { + t.Fatalf("report output missing markdown:\n%s", string(report)) + } + dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")) + if err != nil { + t.Fatalf("glob data package: %v", err) + } + if len(dataPackageMatches) != 1 { + t.Fatalf("data package files = %#v, want one", dataPackageMatches) + } + data, err := os.ReadFile(dataPackageMatches[0]) + if err != nil { + t.Fatalf("read managed data package: %v", err) + } + if !strings.Contains(string(data), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) { + t.Fatalf("data package output missing tomorrow content:\n%s", string(data)) + } + reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) + if err != nil { + t.Fatalf("glob managed report: %v", err) + } + if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") { + t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches) + } +} + +func TestRunEveningGeneratesTomorrowReport(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + configPath := filepath.Join(tempDir, "config.yml") + workspaceRoot := filepath.Join(tempDir, "workspace") + configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{ + "run", "evening", + "--config", configPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")) + if err != nil { + t.Fatalf("glob data package: %v", err) + } + if len(dataPackageMatches) != 1 { + t.Fatalf("data package files = %#v, want one", dataPackageMatches) + } + reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) + if err != nil { + t.Fatalf("glob managed report: %v", err) + } + if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") { + t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches) + } +} + func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() @@ -272,9 +362,9 @@ func dailyServer(t *testing.T) *httptest.Server { case "/conditions/current": _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) case "/forecast/hourly": - _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) + _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) case "/forecast/narrative": - _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."}]}}`)) + _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`)) case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) case "/discussion":