Add Tomorrow planning brief generation

This commit is contained in:
2026-05-29 18:00:27 +00:00
parent 53a4abd508
commit 19513e42c1
12 changed files with 518 additions and 35 deletions

View File

@@ -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 {

View File

@@ -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":

View File

@@ -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:]
}

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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":