diff --git a/README.md b/README.md index ff2c69c..d873e9e 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The application can currently generate Daily Today, Daily Tomorrow, and 3-Day -Outlook Markdown reports through `scriptorium`, with inspectable briefing, -prompt input, preflight, report, and metadata artifacts under the configured -workspace. +The application can currently generate Daily Today, Daily Tomorrow, 3-Day +Outlook, and Weekend Outlook Markdown reports through `scriptorium`, with +inspectable briefing, prompt input, preflight, report, and metadata artifacts +under the configured workspace. ## Quickstart @@ -14,6 +14,7 @@ workspace. weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter generate three-day --out ./three-day.md +weatherreporter generate weekend --out ./weekend.md ``` ## Documentation diff --git a/docs/cli.md b/docs/cli.md index 45f214c..f66bca8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,12 +1,12 @@ # Weatherreporter CLI `weatherreporter generate daily`, `weatherreporter generate tomorrow`, -`weatherreporter generate three-day`, `weatherreporter run evening`, and the -Sunday `weatherreporter run morning` path currently write Markdown reports -through `scriptorium`, after writing managed preparation artifacts and running -`scriptorium render` as a preflight check. Unsupported report generation and -scheduled runs still resolve configuration, report definitions, and valid -periods, then return a not-implemented error. +`weatherreporter generate three-day`, `weatherreporter generate weekend`, +`weatherreporter run morning`, and `weatherreporter run evening` currently +write Markdown reports through `scriptorium`, after writing managed preparation +artifacts and running `scriptorium render` as a preflight check. Unsupported +report generation still resolves configuration, report definitions, and valid +periods, then returns a not-implemented error. ## Shortest Useful Command @@ -32,6 +32,7 @@ For the 3-Day Outlook: ```sh weatherreporter generate three-day --out ./three-day.md +weatherreporter generate weekend --out ./weekend.md ``` ## Command Overview @@ -46,14 +47,13 @@ weatherreporter run morning weatherreporter run evening ``` -`generate daily`, `generate tomorrow`, and `generate three-day` 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. `run morning` can generate Daily Today and the 3-Day -Outlook when the resolved batch contains only implemented report types; when -Weekend Outlook is part of the resolved batch, execution stops at the current -not-implemented boundary. Other `generate` commands resolve one report request -and stop before report generation. +`generate daily`, `generate tomorrow`, `generate three-day`, and +`generate weekend` 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. `run morning` +generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on +Sunday. Other `generate` commands resolve one report request and stop before +report generation. ## Flags @@ -61,7 +61,7 @@ and stop before report generation. - `--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`, `generate tomorrow`, and `generate three-day`; reserved for later generated report output on other `generate` commands. +- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, and `generate weekend`; 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`. diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 56505af..c63a714 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -6,7 +6,7 @@ This document describes the implemented briefing package boundary. `internal/briefing` builds structured report-specific briefing packages from forecast summaries and report metadata. The package currently implements Daily -Today, Daily Tomorrow, and 3-Day Outlook briefing content. +Today, Daily Tomorrow, 3-Day Outlook, and Weekend Outlook briefing content. ## Inputs and Outputs @@ -42,6 +42,9 @@ Output: character, temperature range, precipitation, wind, risk, outdoor-window, and alert inputs, plus broader discussion and weather-story context when available. +- Weekend content uses the same daily outlook summaries and adds planning + inputs for best outdoor windows, worst weather windows, rain/storm timing, + comfort concerns, and confidence or uncertainty context. - Briefing JSON is written atomically by `briefing.Save`. ## Failure Behavior @@ -50,6 +53,8 @@ Output: daily forecast summary. - 3-Day briefing construction requires a 3-Day report definition and at least one derived daily summary in the outlook period. +- Weekend briefing construction requires a Weekend report definition and at + least one derived daily summary in the weekend period. - Save failures include path and operation context. ## Tests @@ -58,6 +63,7 @@ Inspect: - `internal/briefing/daily_test.go` - `internal/briefing/three_day_test.go` +- `internal/briefing/weekend_test.go` - `internal/app/app_test.go` - `internal/cli/root_test.go` diff --git a/docs/internal/changes.md b/docs/internal/changes.md index 2fc919d..333bd86 100644 --- a/docs/internal/changes.md +++ b/docs/internal/changes.md @@ -37,7 +37,7 @@ The app maps these config fields into comparison thresholds: ## Behavior -Daily and 3-Day comparison currently detect: +Daily, 3-Day, and Weekend comparison currently detect: - temperature changes crossing configured thresholds - precipitation probability and timing changes @@ -48,12 +48,15 @@ Daily and 3-Day comparison currently detect: When no prior comparable snapshot exists, the app sends an empty Recent Changes section in the data package. Daily Today and Daily Tomorrow are compatible for same-valid-date comparison through the report registry. 3-Day Outlook compares -with prior 3-Day Outlook snapshots for the same valid local date. +with prior 3-Day Outlook snapshots for the same valid local date. Weekend +Outlook compares with prior Weekend Outlook snapshots for the same weekend +window. ## Failure Behavior Daily comparison requires both inputs to contain Daily briefing content. 3-Day -comparison requires both inputs to contain 3-Day briefing content. +comparison requires both inputs to contain 3-Day briefing content. Weekend +comparison requires both inputs to contain Weekend briefing content. ## Tests @@ -61,6 +64,7 @@ Inspect: - `internal/changes/daily_test.go` - `internal/changes/three_day_test.go` +- `internal/changes/weekend_test.go` - `internal/app/app_test.go` ## Invariants diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index 4dd125b..ae0494e 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -11,7 +11,8 @@ This document describes the implemented prompt input package boundary. Input: -- `briefing.Package` containing Daily-family or 3-Day Outlook content +- `briefing.Package` containing Daily-family, 3-Day Outlook, or Weekend Outlook + content Output: diff --git a/docs/internal/state.md b/docs/internal/state.md index 4d303a5..0934b90 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -52,9 +52,10 @@ and report ID. Metadata is stored beside briefing snapshots by report group and valid local date. Prior snapshot lookup reads metadata for the same valid local date and returns the latest earlier compatible run. Daily Today and Daily Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior -3-Day Outlook snapshots. 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. +3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend +Outlook snapshots for the same weekend window. 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 diff --git a/docs/operations.md b/docs/operations.md index 6c1ee55..85ba716 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,6 +8,8 @@ The implemented generation workflows are: weatherreporter generate daily --date 2026-05-29 weatherreporter generate tomorrow weatherreporter generate three-day +weatherreporter generate weekend +weatherreporter run morning weatherreporter run evening ``` @@ -15,8 +17,8 @@ These commands fetch weather data, build a briefing for the resolved valid period, 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. The -morning run can generate Daily Today and the 3-Day Outlook when the resolved -batch does not include an unsupported report type. +morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook +except on Sunday. ## Filesystem Layout @@ -33,6 +35,10 @@ workspace/ YYYY-MM-DD/ .briefing.json .metadata.json + weekend/ + YYYY-MM-DD/ + .briefing.json + .metadata.json data-packages/ daily/ YYYY-MM-DD/ @@ -40,6 +46,9 @@ workspace/ three-day/ YYYY-MM-DD/ .data_package.json + weekend/ + YYYY-MM-DD/ + .data_package.json preflight/ daily/ YYYY-MM-DD/ @@ -47,16 +56,21 @@ workspace/ three-day/ YYYY-MM-DD/ .render.json + weekend/ + YYYY-MM-DD/ + .render.json reports/ daily/ .md three-day/ .md + weekend/ + .md ``` The Markdown report is written to a RunID-managed report path. When `--out` is -provided to `generate daily`, `generate tomorrow`, or `generate three-day`, the -managed report is also copied to that path. +provided to `generate daily`, `generate tomorrow`, `generate three-day`, or +`generate weekend`, the managed report is also copied to that path. ## Run Identifiers @@ -93,6 +107,10 @@ they cover the same valid local date. Meaningful changes are included under 3-Day Outlook generation compares against a prior compatible 3-Day briefing snapshot for the same valid local date when one exists. +Weekend Outlook generation compares against a prior compatible Weekend briefing +snapshot for the same weekend window when one exists. Friday evening and +Saturday runs may narrow the valid start while keeping the same Monday endpoint. + When no prior comparable snapshot exists, or no configured threshold is crossed, the Recent Changes list is empty. diff --git a/internal/app/app.go b/internal/app/app.go index 3e4271b..bf6aaff 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -155,7 +155,7 @@ func RunBatch(ctx context.Context, req BatchRequest) error { } func isGeneratedReport(id report.ID) bool { - return isDailyReport(id) || id == report.ThreeDay + return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend } func isDailyReport(id report.ID) bool { @@ -443,11 +443,19 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa Units: req.Config.WeatherAPI.Units, Timezone: req.Config.WeatherAPI.Timezone, }, summary) - case report.ThreeDay: + case report.ThreeDay, report.Weekend: summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts) if err != nil { return briefing.Package{}, err } + if req.Resolved.Definition.ID == report.Weekend { + return briefing.BuildWeekend(briefing.BuildContext{ + Resolved: req.Resolved, + Bundle: bundle, + Units: req.Config.WeatherAPI.Units, + Timezone: req.Config.WeatherAPI.Timezone, + }, summaries) + } return briefing.BuildThreeDay(briefing.BuildContext{ Resolved: req.Resolved, Bundle: bundle, @@ -486,6 +494,8 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state. return changes.CompareDaily(previous, current, thresholds) case report.ThreeDay: return changes.CompareThreeDay(previous, current, thresholds) + case report.Weekend: + return changes.CompareWeekend(previous, current, thresholds) default: return nil, nil } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index bb2bf69..a848081 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -598,6 +598,81 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) { } } +func TestGenerateWeekendReportWritesReportAndRecentChanges(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: ReportWeekend, + }, mustParse("2026-05-29T04:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(prior) error = %v", err) + } + priorBriefing := priorWeekendBriefing(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: ReportWeekend, + }, mustParse("2026-05-29T05: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: "# Weekend Outlook\n", + } + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: currentResolved, + Renderer: renderer, + Store: store, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + + if result.Briefing.Weekend == nil { + t.Fatal("Weekend = nil") + } + if len(result.Briefing.Weekend.Days) != 3 { + t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days)) + } + if renderer.renderRequest.PromptID != "weather.weekend_outlook" { + t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID) + } + if result.PriorSnapshot == nil { + t.Fatal("PriorSnapshot = nil, want prior weekend snapshot") + } + if len(result.RecentChanges) == 0 { + t.Fatal("RecentChanges length = 0, want changes from prior weekend snapshot") + } +} + func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" @@ -756,6 +831,35 @@ func priorThreeDayBriefing(resolved report.Resolved) briefing.Package { } } +func priorWeekendBriefing(resolved report.Resolved) briefing.Package { + high := 58.0 + precip := 10.0 + return briefing.Package{ + Metadata: briefing.Metadata{ + SchemaVersion: briefing.SchemaVersion, + RunID: resolved.Metadata().RunID, + ReportID: resolved.Definition.ID, + PromptID: resolved.Definition.PromptID, + GeneratedAt: resolved.GeneratedAt, + Units: "us", + Timezone: resolved.Timezone, + ValidPeriod: resolved.ValidPeriod, + }, + Weekend: &briefing.Weekend{ + Days: []briefing.OutlookDay{ + { + Date: "2026-05-30", + Temperature: forecast.Range{Max: &high}, + MaxPrecipitationProbability: &forecast.TimedValue{ + Value: precip, + Time: resolved.ValidPeriod.Start.Add(12 * time.Hour), + }, + }, + }, + }, + } +} + type recordingRenderer struct { renderCalls int runCalls int diff --git a/internal/briefing/package.go b/internal/briefing/package.go index 4e495b8..e8e502b 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -19,6 +19,7 @@ type Package struct { Metadata Metadata `json:"metadata"` Daily *Daily `json:"daily,omitempty"` ThreeDay *ThreeDay `json:"threeDay,omitempty"` + Weekend *Weekend `json:"weekend,omitempty"` } type Metadata struct { diff --git a/internal/briefing/three_day.go b/internal/briefing/three_day.go index 30a84f2..da054f5 100644 --- a/internal/briefing/three_day.go +++ b/internal/briefing/three_day.go @@ -44,11 +44,18 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package WeatherStory: buildWeatherStory(ctx.Bundle), }, } - alerts := map[string]forecast.AlertOverlap{} for _, summary := range summaries { day := buildOutlookDay(summary) pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day) - for _, alert := range summary.AlertOverlaps { + } + pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days) + return pkg, nil +} + +func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap { + alerts := map[string]forecast.AlertOverlap{} + for _, day := range days { + for _, alert := range day.RelevantAlerts { key := alert.Event if key == "" { key = alert.Headline @@ -63,10 +70,11 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package keys = append(keys, key) } sort.Strings(keys) + out := make([]forecast.AlertOverlap, 0, len(keys)) for _, key := range keys { - pkg.ThreeDay.RelevantAlerts = append(pkg.ThreeDay.RelevantAlerts, alerts[key]) + out = append(out, alerts[key]) } - return pkg, nil + return out } func buildOutlookDay(summary forecast.DailySummary) OutlookDay { diff --git a/internal/briefing/weekend.go b/internal/briefing/weekend.go new file mode 100644 index 0000000..5004548 --- /dev/null +++ b/internal/briefing/weekend.go @@ -0,0 +1,125 @@ +package briefing + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +type Weekend struct { + Days []OutlookDay `json:"days"` + Planning WeekendPlanning `json:"planning"` + RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` + Discussion DiscussionContext `json:"discussion,omitempty"` + WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` +} + +type WeekendPlanning struct { + BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"` + WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"` + RainStormTiming []string `json:"rainStormTiming,omitempty"` + ComfortConcerns []string `json:"comfortConcerns,omitempty"` + UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"` +} + +func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) { + if ctx.Resolved.Definition.ID != report.Weekend { + return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition") + } + if len(summaries) == 0 { + return Package{}, fmt.Errorf("weekend forecast summaries are required") + } + pkg := Package{ + Metadata: BuildMetadata(ctx), + Weekend: &Weekend{ + Discussion: buildDiscussion(summaries[0].Discussion), + WeatherStory: buildWeatherStory(ctx.Bundle), + }, + } + for _, summary := range summaries { + pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary)) + } + pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days) + pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle) + return pkg, nil +} + +func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning { + planning := WeekendPlanning{} + for _, day := range days { + if day.OutdoorWindows.Best != nil { + window := *day.OutdoorWindows.Best + window.Daypart = day.Date + " " + window.Daypart + planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window) + } + if day.OutdoorWindows.Worst != nil { + window := *day.OutdoorWindows.Worst + window.Daypart = day.Date + " " + window.Daypart + planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window) + } + for _, daypart := range day.Dayparts { + planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...) + planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...) + } + } + planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...) + if discussion.ShortTerm != "" { + planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.") + } + if discussion.LongTerm != "" { + planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.") + } + if bundle != nil { + for _, warning := range bundle.Warnings { + if warning.Code != "" { + planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".") + } + } + } + if len(planning.RainStormTiming) == 0 { + planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.") + } + if len(planning.ComfortConcerns) == 0 { + planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.") + } + if len(planning.UncertaintyInputs) == 0 { + planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.") + } + return planning +} + +func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string { + notes := []string{} + label := weekendWindowLabel(date, daypart.Name) + if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 { + notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value)) + } + if daypart.Indicators.Thunder { + notes = append(notes, label+" thunder risk is present.") + } + return notes +} + +func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string { + notes := []string{} + label := weekendWindowLabel(date, daypart.Name) + if daypart.Indicators.Heat { + notes = append(notes, label+" heat may affect outdoor comfort.") + } + if daypart.Indicators.Cold { + notes = append(notes, label+" cold may affect outdoor comfort.") + } + if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 { + notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value)) + } + return notes +} + +func weekendWindowLabel(date string, daypart string) string { + if daypart == "" { + return date + } + return strings.TrimSpace(date + " " + daypart) +} diff --git a/internal/briefing/weekend_test.go b/internal/briefing/weekend_test.go new file mode 100644 index 0000000..7bd1f76 --- /dev/null +++ b/internal/briefing/weekend_test.go @@ -0,0 +1,88 @@ +package briefing + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) { + location := mustLocation(t) + resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{ + Now: mustParse("2026-05-29T08:00:00-05:00"), + Location: location, + }) + if err != nil { + t.Fatalf("resolve weekend: %v", err) + } + precip := 70.0 + gust := 32.0 + summaries := []forecast.DailySummary{ + { + Date: "2026-05-30", + Period: timeutil.Period{ + Start: mustParse("2026-05-30T00:00:00-05:00"), + End: mustParse("2026-05-31T00:00:00-05:00"), + }, + Dayparts: []forecast.DaypartSummary{ + { + Name: "afternoon", + DominantCondition: "Showers and thunderstorms", + Period: timeutil.Period{ + Start: mustParse("2026-05-30T12:00:00-05:00"), + End: mustParse("2026-05-30T18:00:00-05:00"), + }, + MaxPrecipitationProbability: &forecast.TimedValue{ + Value: precip, + Time: mustParse("2026-05-30T15:00:00-05:00"), + }, + PeakWindGust: &forecast.TimedValue{ + Value: gust, + Time: mustParse("2026-05-30T16:00:00-05:00"), + }, + Indicators: forecast.Indicators{Thunder: true, Wind: true}, + HourlyPeriods: []forecast.ForecastPeriod{ + { + StartTime: mustParse("2026-05-30T15:00:00-05:00"), + EndTime: mustParse("2026-05-30T16:00:00-05:00"), + TextDescription: "Showers and thunderstorms", + }, + }, + }, + }, + AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, + Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}}, + }, + } + + pkg, err := BuildWeekend(BuildContext{ + Resolved: resolved, + Units: "us", + Timezone: "America/Chicago", + }, summaries) + if err != nil { + t.Fatalf("BuildWeekend() error = %v", err) + } + + if pkg.Metadata.ReportID != report.Weekend { + t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID) + } + if pkg.Weekend == nil { + t.Fatal("Weekend = nil") + } + if len(pkg.Weekend.Days) != 1 { + t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days)) + } + if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 { + t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows) + } + if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") { + t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming) + } + if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 { + t.Fatal("UncertaintyInputs length = 0, want discussion context") + } +} diff --git a/internal/changes/weekend.go b/internal/changes/weekend.go new file mode 100644 index 0000000..6082219 --- /dev/null +++ b/internal/changes/weekend.go @@ -0,0 +1,26 @@ +package changes + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" +) + +func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { + if previous.Weekend == nil { + return nil, fmt.Errorf("previous weekend briefing is required") + } + if current.Weekend == nil { + return nil, fmt.Errorf("current weekend briefing is required") + } + previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}} + currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}} + changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds) + if err != nil { + return nil, err + } + for i := range changes { + changes[i].Type = "weekend_" + changes[i].Type + } + return changes, nil +} diff --git a/internal/changes/weekend_test.go b/internal/changes/weekend_test.go new file mode 100644 index 0000000..93bda81 --- /dev/null +++ b/internal/changes/weekend_test.go @@ -0,0 +1,43 @@ +package changes + +import ( + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +func TestCompareWeekendDetectsOutlookChanges(t *testing.T) { + previousTemp := 70.0 + currentTemp := 78.0 + previous := briefing.Package{ + Metadata: briefing.Metadata{ReportID: report.Weekend}, + Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{ + Date: "2026-05-30", + Temperature: forecast.Range{Max: &previousTemp}, + }}}, + } + current := briefing.Package{ + Metadata: briefing.Metadata{ReportID: report.Weekend}, + Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{ + Date: "2026-05-30", + Temperature: forecast.Range{Max: ¤tTemp}, + Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}}, + }}}, + } + + changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5}) + if err != nil { + t.Fatalf("CompareWeekend() error = %v", err) + } + if len(changes) == 0 { + t.Fatal("changes length = 0, want weekend changes") + } + for _, change := range changes { + if change.Type == "weekend_outlook_thunder_risk_change" { + return + } + } + t.Fatalf("changes = %#v, want thunder risk change", changes) +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index e0cbb8c..6e0e4f6 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -62,7 +62,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} - err := runner.Run(context.Background(), []string{"generate", "weekend", "--units", "metric"}, &stdout, &stderr) + err := runner.Run(context.Background(), []string{"generate", "storm", "--units", "metric", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want app not implemented error") } @@ -207,6 +207,82 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) { } } +func TestRunGenerateWeekendWritesMarkdownReport(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, "weekend.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{ + "generate", "weekend", + "--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", "weekend", "2026-05-29", "*.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), `"weekend"`) || !strings.Contains(string(data), `"planning"`) { + t.Fatalf("data package output missing weekend content:\n%s", string(data)) + } +} + +func TestRunMorningIncludesWeekendExceptSunday(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", "morning", + "--config", configPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) + if err != nil { + t.Fatalf("glob weekend packages: %v", err) + } + if len(weekendPackages) != 1 { + t.Fatalf("weekend packages = %#v, want one", weekendPackages) + } +} + func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index 36dae17..9eb9c65 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -107,7 +107,7 @@ func Validate(pkg Package) error { if pkg.Briefing.Metadata.ReportID != pkg.Report.ID { return fmt.Errorf("briefing.metadata.reportId must match report.id") } - if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil { + if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil { return fmt.Errorf("briefing report content is required") } return nil diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index f29877c..bfcbe5a 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -75,6 +75,29 @@ func TestBuildThreeDayDataPackage(t *testing.T) { } } +func TestBuildWeekendDataPackage(t *testing.T) { + briefingPackage := validBriefingPackage() + briefingPackage.Metadata.RunID = "20260529T100000Z_weekend" + briefingPackage.Metadata.ReportID = report.Weekend + briefingPackage.Metadata.PromptID = "weather.weekend_outlook" + briefingPackage.Daily = nil + briefingPackage.Weekend = &briefing.Weekend{ + Days: []briefing.OutlookDay{{Date: "2026-05-30"}}, + } + + pkg, err := Build(briefingPackage) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if pkg.Report.ID != report.Weekend { + t.Fatalf("Report.ID = %q, want weekend", pkg.Report.ID) + } + if pkg.Briefing.Weekend == nil { + t.Fatal("Briefing.Weekend = nil") + } +} + func TestMarshalDeterministic(t *testing.T) { pkg, err := Build(validBriefingPackage()) if err != nil { diff --git a/internal/report/period_test.go b/internal/report/period_test.go index f3c84d9..f512765 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -65,6 +65,9 @@ func TestWeekendPeriodCalculation(t *testing.T) { end string }{ {name: "monday", now: "2026-05-25T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, + {name: "tuesday", now: "2026-05-26T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, + {name: "wednesday", now: "2026-05-27T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, + {name: "thursday", now: "2026-05-28T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "friday before evening", now: "2026-05-29T05:00:00-05:00", start: "2026-05-29T18:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "friday after evening", now: "2026-05-29T19:30:00-05:00", start: "2026-05-29T19:30:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "saturday", now: "2026-05-30T08:00:00-05:00", start: "2026-05-30T08:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index 61c80fd..098e86c 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -156,49 +156,50 @@ func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved r } func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) { - if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate { + if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow { return nil, nil } group, err := reportGroup(resolved.Definition.ID) if err != nil { return nil, err } - paths, err := s.Paths(resolved) + dirs, err := s.metadataDirectories(resolved, group) if err != nil { return nil, err } - dir := filepath.Dir(paths.Metadata) - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err) - } var candidates []Metadata - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") { - continue + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err) } - path := filepath.Join(dir, entry.Name()) - var metadata Metadata - if err := readJSON(path, &metadata); err != nil { - return nil, err + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") { + continue + } + path := filepath.Join(dir, entry.Name()) + var metadata Metadata + if err := readJSON(path, &metadata); err != nil { + return nil, err + } + if metadata.RunID == resolved.Metadata().RunID { + continue + } + if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) { + continue + } + if !comparablePeriod(metadata, resolved) { + continue + } + if !metadata.GeneratedAt.Before(resolved.GeneratedAt) { + continue + } + candidates = append(candidates, metadata) } - if metadata.RunID == resolved.Metadata().RunID { - continue - } - if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) { - continue - } - if !sameValidDate(metadata, resolved) { - continue - } - if !metadata.GeneratedAt.Before(resolved.GeneratedAt) { - continue - } - candidates = append(candidates, metadata) } if len(candidates) == 0 { return nil, nil @@ -212,12 +213,39 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R }, nil } +func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group string) ([]string, error) { + paths, err := s.Paths(resolved) + if err != nil { + return nil, err + } + if resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow { + return []string{filepath.Dir(paths.Metadata)}, nil + } + root := s.join(s.snapshotsDir, group) + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read snapshot group directory %q: %w", root, err) + } + var dirs []string + for _, entry := range entries { + if entry.IsDir() { + dirs = append(dirs, filepath.Join(root, entry.Name())) + } + } + return dirs, nil +} + func compatiblePriorReport(group string, prior report.ID, current report.ID) bool { switch group { case "daily": return prior == report.DailyToday || prior == report.DailyTomorrow case "three-day": return prior == report.ThreeDay && current == report.ThreeDay + case "weekend": + return prior == report.Weekend && current == report.Weekend default: return false } @@ -307,6 +335,21 @@ func sameValidDate(metadata Metadata, resolved report.Resolved) bool { return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02") } +func comparablePeriod(metadata Metadata, resolved report.Resolved) bool { + switch resolved.Definition.ComparisonStrategy { + case report.CompareSameValidDate: + return sameValidDate(metadata, resolved) + case report.CompareWeekendWindow: + return sameWeekendWindow(metadata, resolved) + default: + return false + } +} + +func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool { + return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start) +} + func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) { if path == "" { return briefing.Package{}, fmt.Errorf("briefing path is required") diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index f6d1165..0c0da66 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -222,6 +222,78 @@ func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) { } } +func TestFindPriorSnapshotSupportsWeekend(t *testing.T) { + store := newTestStore(t) + first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00") + second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00") + briefingPackage := stateWeekendBriefingPackage(first) + briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage) + if err != nil { + t.Fatalf("SaveBriefing() error = %v", err) + } + paths, err := store.Paths(first) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + _, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{ + Briefing: briefingPath, + Metadata: paths.Metadata, + DataPackage: paths.DataPackage, + Preflight: paths.Preflight, + RenderedReport: paths.RenderedReport, + })) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } + + prior, err := store.FindPriorSnapshot(context.Background(), second) + if err != nil { + t.Fatalf("FindPriorSnapshot() error = %v", err) + } + if prior == nil { + t.Fatal("FindPriorSnapshot() = nil, want prior weekend snapshot") + } + if prior.Metadata.RunID != first.Metadata().RunID { + t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) + } +} + +func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) { + store := newTestStore(t) + first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00") + second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00") + briefingPackage := stateWeekendBriefingPackage(first) + briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage) + if err != nil { + t.Fatalf("SaveBriefing() error = %v", err) + } + paths, err := store.Paths(first) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + _, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{ + Briefing: briefingPath, + Metadata: paths.Metadata, + DataPackage: paths.DataPackage, + Preflight: paths.Preflight, + RenderedReport: paths.RenderedReport, + })) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } + + prior, err := store.FindPriorSnapshot(context.Background(), second) + if err != nil { + t.Fatalf("FindPriorSnapshot() error = %v", err) + } + if prior == nil { + t.Fatal("FindPriorSnapshot() = nil, want prior narrowed weekend snapshot") + } + if prior.Metadata.RunID != first.Metadata().RunID { + t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) + } +} + func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) { cfg := config.Defaults().Workspace cfg.Root = t.TempDir() @@ -287,6 +359,26 @@ func resolveThreeDayAt(t *testing.T, value string) report.Resolved { return resolved } +func resolveWeekendAt(t *testing.T, value string) report.Resolved { + t.Helper() + location, err := timeutil.LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + now, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + resolved, err := report.DefaultRegistry().Resolve(report.Weekend, report.ResolveRequest{ + Now: now, + Location: location, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return resolved +} + func stateBriefingPackage(resolved report.Resolved) briefing.Package { return briefing.Package{ Metadata: briefing.Metadata{ @@ -322,6 +414,24 @@ func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package { } } +func stateWeekendBriefingPackage(resolved report.Resolved) briefing.Package { + return briefing.Package{ + Metadata: briefing.Metadata{ + SchemaVersion: briefing.SchemaVersion, + RunID: resolved.Metadata().RunID, + ReportID: resolved.Definition.ID, + PromptID: resolved.Definition.PromptID, + GeneratedAt: resolved.GeneratedAt, + Units: "us", + Timezone: resolved.Timezone, + ValidPeriod: resolved.ValidPeriod, + }, + Weekend: &briefing.Weekend{ + Days: []briefing.OutlookDay{{Date: "2026-05-30"}}, + }, + } +} + func pathsString(paths ArtifactPaths) string { return strings.Join([]string{ paths.Briefing,