Add Tomorrow planning brief generation
This commit is contained in:
@@ -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
|
||||
|
||||
21
docs/cli.md
21
docs/cli.md
@@ -1,6 +1,7 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter generate daily` currently writes a Daily Markdown report
|
||||
`weatherreporter generate daily`, `weatherreporter generate tomorrow`, and
|
||||
`weatherreporter run evening` currently write Daily-family Markdown reports
|
||||
through `scriptorium`, after writing managed preparation artifacts and running
|
||||
`scriptorium render` as a preflight check. Other report generation and scheduled
|
||||
runs still resolve configuration, report definitions, and valid periods, then
|
||||
@@ -19,6 +20,13 @@ invokes `scriptorium run --input data_package=<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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user