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 `weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data. reports from normalized forecast data.
The application can currently generate a Daily Markdown report through The application can currently generate Daily Today and Daily Tomorrow Markdown
`scriptorium`, with inspectable briefing, prompt input, preflight, report, and reports through `scriptorium`, with inspectable briefing, prompt input,
metadata artifacts under the configured workspace. preflight, report, and metadata artifacts under the configured workspace.
## Quickstart ## Quickstart
```sh ```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md
``` ```
## Documentation ## Documentation

View File

@@ -1,6 +1,7 @@
# Weatherreporter CLI # 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 through `scriptorium`, after writing managed preparation artifacts and running
`scriptorium render` as a preflight check. Other report generation and scheduled `scriptorium render` as a preflight check. Other report generation and scheduled
runs still resolve configuration, report definitions, and valid periods, then 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 When `--out` is supplied, it also writes a copy of the Markdown report to that
path. path.
For tomorrow planning:
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter run evening
```
## Command Overview ## Command Overview
```text ```text
@@ -31,10 +39,11 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`generate daily` writes a briefing snapshot, prompt input data package, render `generate daily` and `generate tomorrow` write a briefing snapshot, prompt input
preflight output, Markdown report, and metadata file under the configured data package, render preflight output, Markdown report, and metadata file under
workspace. Other `generate` commands resolve one report request and stop before the configured workspace. `run evening` generates the Tomorrow Planning Brief.
report generation. `run` commands resolve a scheduled batch request and stop Other `generate` commands resolve one report request and stop before report
generation. Other `run` commands resolve a scheduled batch request and stop
before execution. before execution.
## Flags ## Flags
@@ -43,7 +52,7 @@ before execution.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units. - `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone. - `--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. - `--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`. - `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end 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 ## Purpose
`internal/briefing` builds structured report-specific briefing packages from `internal/briefing` builds structured report-specific briefing packages from
forecast summaries and report metadata. The package currently implements the forecast summaries and report metadata. The package currently implements Daily
Daily briefing only. Today and Daily Tomorrow briefing content.
## Inputs and Outputs ## Inputs and Outputs
@@ -35,6 +35,8 @@ Output:
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts, - Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
outdoor window inputs, narrative periods, discussion context, and weather outdoor window inputs, narrative periods, discussion context, and weather
story context when available. 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`. - Briefing JSON is written atomically by `briefing.Save`.
## Failure Behavior ## Failure Behavior

View File

@@ -46,7 +46,8 @@ Daily comparison currently detects:
- snow, ice, and thunder risk changes - snow, ice, and thunder risk changes
When no prior comparable snapshot exists, the app sends an empty Recent 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 ## 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 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 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 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 date and returns the latest earlier compatible Daily Today or Daily Tomorrow
by path for structured comparison. The store prepares the managed Markdown run. The store can load a briefing snapshot by path for structured comparison.
report path before `scriptorium run` writes it. The store prepares the managed Markdown report path before `scriptorium run`
writes it.
## Failure Behavior ## Failure Behavior

View File

@@ -2,15 +2,18 @@
## Normal Workflow ## Normal Workflow
The implemented Daily generation workflow is: The implemented Daily-family generation workflows are:
```text ```text
weatherreporter generate daily --date 2026-05-29 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 These commands fetch weather data, build a Daily briefing for the resolved
input data package, runs `scriptorium render`, runs `scriptorium run`, and valid date, build the prompt input data package, run `scriptorium render`, run
writes inspectable artifacts under the configured workspace. `scriptorium run`, and write inspectable artifacts under the configured
workspace. The evening run resolves only the Tomorrow Planning Brief.
## Filesystem Layout ## Filesystem Layout
@@ -36,8 +39,9 @@ workspace/
<run_id>.md <run_id>.md
``` ```
The Markdown report is written to the managed report path. When `--out` is The Markdown report is written to a RunID-managed report path. When `--out` is
provided, the managed report is also copied to that path. provided to `generate daily` or `generate tomorrow`, the managed report is also
copied to that path.
## Run Identifiers ## 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 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 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, When no prior comparable snapshot exists, or no configured threshold is crossed,
the Recent Changes list is empty. the Recent Changes list is empty.

View File

@@ -41,6 +41,7 @@ type GenerateRequest struct {
Config config.Config Config config.Config
Report ReportKind Report ReportKind
OutputPath string OutputPath string
Now time.Time
Date time.Time Date time.Time
StormStart time.Time StormStart time.Time
StormEnd time.Time StormEnd time.Time
@@ -49,6 +50,7 @@ type GenerateRequest struct {
type BatchRequest struct { type BatchRequest struct {
Config config.Config Config config.Config
Batch BatchKind Batch BatchKind
Now time.Time
} }
type FetchBundleRequest struct { type FetchBundleRequest struct {
@@ -97,11 +99,15 @@ type Renderer interface {
} }
func Generate(ctx context.Context, req GenerateRequest) error { 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 { if err != nil {
return err return err
} }
if resolved.Definition.ID == report.DailyToday { if isDailyReport(resolved.Definition.ID) {
_, err := GenerateDailyReport(ctx, DailyReportRequest{ _, err := GenerateDailyReport(ctx, DailyReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
@@ -113,12 +119,34 @@ func Generate(ctx context.Context, req GenerateRequest) error {
} }
func RunBatch(ctx context.Context, req BatchRequest) error { func RunBatch(ctx context.Context, req BatchRequest) error {
_ = ctx now := req.Now
if _, err := ResolveBatch(req, time.Now()); err != nil { if now.IsZero() {
now = time.Now()
}
resolvedReports, err := ResolveBatch(req, now)
if err != nil {
return err return err
} }
if req.Batch == BatchEvening {
for _, resolved := range resolvedReports {
if !isDailyReport(resolved.Definition.ID) {
return fmt.Errorf("run is not implemented") 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) { func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)

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) { func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago" cfg.WeatherAPI.Timezone = "America/Chicago"
@@ -446,9 +555,9 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
case "/conditions/current": case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
case "/forecast/hourly": 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": 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": 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"}]}}`)) _, _ = 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": case "/discussion":

View File

@@ -15,6 +15,7 @@ type Daily struct {
Dayparts []forecast.DaypartSummary `json:"dayparts"` Dayparts []forecast.DaypartSummary `json:"dayparts"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"` OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
Planning *TomorrowPlanning `json:"planning,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"` Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
@@ -42,6 +43,12 @@ type OutdoorWindow struct {
Score float64 `json:"score"` 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 { type DiscussionContext struct {
Product string `json:"product,omitempty"` Product string `json:"product,omitempty"`
KeyMessages []string `json:"keyMessages,omitempty"` KeyMessages []string `json:"keyMessages,omitempty"`
@@ -74,6 +81,9 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
ForecastSummaryDate: summary.Date, ForecastSummaryDate: summary.Date,
}, },
} }
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
pkg.Daily.Planning = buildTomorrowPlanning(summary)
}
return pkg, nil return pkg, nil
} }
@@ -122,6 +132,124 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
return OutdoorWindows{Best: best, Worst: worst} 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 { func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
if discussion == nil { if discussion == nil {
return DiscussionContext{} return DiscussionContext{}
@@ -276,3 +404,28 @@ func dedupe(values []string) []string {
} }
return out 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/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { 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) { func TestSaveBriefingPackage(t *testing.T) {
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}} pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
path := filepath.Join(t.TempDir(), "nested", "briefing.json") 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. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units. --units VALUE Override weather API units.
--tz NAME Override weather API timezone. --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 { 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) return app.Generate(ctx, req)
case "run": case "run":
req, err := resolveRun(args[1:]) req, err := r.resolveRun(args[1:])
if err != nil { if err != nil {
return err return err
} }
@@ -82,6 +82,9 @@ type generateOptions struct {
} }
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 { if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") 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, Config: cfg,
Report: report, Report: report,
OutputPath: opts.Output, OutputPath: opts.Output,
Now: r.Clock.Now(),
} }
switch report { switch report {
@@ -147,7 +151,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
return req, nil 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 { if len(args) == 0 {
return app.BatchRequest{}, fmt.Errorf("run requires a batch name") return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
} }
@@ -167,7 +174,11 @@ func resolveRun(args []string) (app.BatchRequest, error) {
if err != nil { if err != nil {
return app.BatchRequest{}, err 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) { func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) {

View File

@@ -62,7 +62,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} 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 { if err == nil {
t.Fatal("Run() error = nil, want app not implemented error") 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) { func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -272,9 +362,9 @@ func dailyServer(t *testing.T) *httptest.Server {
case "/conditions/current": case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
case "/forecast/hourly": 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": 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": case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion": case "/discussion":