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

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

View File

@@ -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=<managed_path> --out <managed_repo
When `--out` is supplied, it also writes a copy of the Markdown report to that
path.
For tomorrow planning:
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter run evening
```
## Command Overview
```text
@@ -31,10 +39,11 @@ weatherreporter run morning
weatherreporter run evening
```
`generate daily` writes a briefing snapshot, prompt input data package, render
preflight output, Markdown report, and metadata file under the configured
workspace. Other `generate` commands resolve one report request and stop before
report generation. `run` commands resolve a scheduled batch request and stop
`generate daily` and `generate tomorrow` write a briefing snapshot, prompt input
data package, render preflight output, Markdown report, and metadata file under
the configured workspace. `run evening` generates the Tomorrow Planning Brief.
Other `generate` commands resolve one report request and stop before report
generation. Other `run` commands resolve a scheduled batch request and stop
before execution.
## Flags
@@ -43,7 +52,7 @@ before execution.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone.
- `--out PATH`: optional Markdown report copy for `generate daily`; reserved for later generated report output on other `generate` commands.
- `--out PATH`: optional Markdown report copy for `generate daily` and `generate tomorrow`; reserved for later generated report output on other `generate` commands.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`.

View File

@@ -5,8 +5,8 @@ This document describes the implemented briefing package boundary.
## Purpose
`internal/briefing` builds structured report-specific briefing packages from
forecast summaries and report metadata. The package currently implements the
Daily briefing only.
forecast summaries and report metadata. The package currently implements Daily
Today and Daily Tomorrow briefing content.
## Inputs and Outputs
@@ -35,6 +35,8 @@ Output:
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
outdoor window inputs, narrative periods, discussion context, and weather
story context when available.
- Daily Tomorrow also includes planning inputs for morning readiness,
commute/school/workday concerns, and what may change overnight.
- Briefing JSON is written atomically by `briefing.Save`.
## Failure Behavior

View File

@@ -46,7 +46,8 @@ Daily comparison currently detects:
- snow, ice, and thunder risk changes
When no prior comparable snapshot exists, the app sends an empty Recent Changes
section in the data package.
section in the data package. Daily Today and Daily Tomorrow are compatible for
same-valid-date comparison through the report registry.
## Failure Behavior

View File

@@ -50,9 +50,10 @@ Workspace subdirectories must be relative paths that stay under
Managed artifact names use RunID, which is generated from report generation time
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
local date. Prior Daily snapshot lookup reads metadata for the same valid local
date and returns the latest earlier run. The store can load a briefing snapshot
by path for structured comparison. The store prepares the managed Markdown
report path before `scriptorium run` writes it.
date and returns the latest earlier compatible Daily Today or Daily Tomorrow
run. The store can load a briefing snapshot by path for structured comparison.
The store prepares the managed Markdown report path before `scriptorium run`
writes it.
## Failure Behavior

View File

@@ -2,15 +2,18 @@
## Normal Workflow
The implemented Daily generation workflow is:
The implemented Daily-family generation workflows are:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow
weatherreporter run evening
```
The command fetches weather data, builds the Daily briefing, builds the prompt
input data package, runs `scriptorium render`, runs `scriptorium run`, and
writes inspectable artifacts under the configured workspace.
These commands fetch weather data, build a Daily briefing for the resolved
valid date, build the prompt input data package, run `scriptorium render`, run
`scriptorium run`, and write inspectable artifacts under the configured
workspace. The evening run resolves only the Tomorrow Planning Brief.
## Filesystem Layout
@@ -36,8 +39,9 @@ workspace/
<run_id>.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.

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