8 Commits

25 changed files with 885 additions and 28 deletions

View File

@@ -21,6 +21,7 @@ the managed Markdown report after final metadata is saved.
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -37,8 +38,11 @@ weatherreporter inspect sources [--config PATH] RUN_ID
`generate` commands write a JSON module snapshot, YAML data package, preflight
artifact, managed Markdown report, and metadata under the configured workspace.
`--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate storm`
requires explicit event-window bounds with `--start` and `--end`.
notification uses the managed report path, not the extra copy. `generate
near-term` uses the current generation time, covers the next six hours in the
effective report timezone, and does not accept date or event window flags.
`generate storm` requires explicit event-window bounds with `--start` and
`--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
@@ -50,6 +54,9 @@ notification is enabled, batch summaries and status lines include notification
status, accepted distributor run ID, or notification error fields for each
attempted report.
Near-Term Report generation is explicit only; it is not included in `run
morning` or `run evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`.
@@ -76,6 +83,7 @@ are no distributor-specific CLI flags.
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate near-term --out ./near-term.md
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md

View File

@@ -181,9 +181,10 @@ snapshot exists and a threshold is crossed.
`reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
also accepted.
Supported report keys are `daily`, `tomorrow`, `near_term`, `near-term`,
`three_day`, `weekend`, and `storm`. Canonical report IDs such as
`daily_today` and `daily_tomorrow` are also accepted. `near_term` and
`near-term` are aliases for the same Near-Term Report override.
Each report entry supports:

View File

@@ -38,6 +38,14 @@ Outputs:
Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped.
Near-Term Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, or
`tomorrow_planning`.
Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts.
@@ -59,7 +67,8 @@ builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields.
subset of discussion fields. Near-Term Report defaults this module to
`key_messages` and `short_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched

View File

@@ -27,6 +27,12 @@ Outputs:
report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary
Near-Term Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing
from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily
summaries, daypart summaries, or a storm-window summary.
## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a
@@ -62,6 +68,8 @@ and inspection.
- Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries.
- Near-Term Report can derive its default module facts without daily or
daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
- Missing optional SPC convective outlook data produces a nil collected field.

View File

@@ -47,6 +47,24 @@ The registry recognizes these IDs:
Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped.
## Near-Term Composition
The default Near-Term Report module order is:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
Near-Term Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`.
## Options
Most modules use an empty options struct, including

View File

@@ -68,6 +68,12 @@ Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured
timestamps where applicable.
Near-Term Report uses the same package schema and categories. Its default
package includes `precip_timing` under `derived_summaries`, alert and SPC
outlooks under `applicable_risk_products`, AFD/SPC discussion/weather story
under `narrative_products`, and current/hourly data under `raw_data`. It does
not include daily or daypart summary stanzas.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
@@ -106,6 +112,7 @@ None.
## Skip And Resume Behavior
None. Recent Changes is always present as an `items` list and may be empty.
Near-Term Report currently writes an empty `items` list.
## Failure Behavior

View File

@@ -31,6 +31,7 @@ Each report definition declares:
| --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| Near-Term Report | `near_term` | `weather.near_term_report` | `near-term` | `near-term.md` | Near-Term Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
@@ -42,6 +43,9 @@ All report definitions are eligible for generation.
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time.
- Near-Term Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant,
not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight
three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
@@ -64,7 +68,15 @@ IDs, then uses the registry for report policy.
## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions.
output path copying uses batch output names from report definitions. Report
module overrides can use the `near_term` or `near-term` keys for Near-Term
Report.
## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. Evening batches include Daily Tomorrow. Near-Term Report is not part
of a scheduled batch.
## State And App Usage

View File

@@ -11,6 +11,7 @@ Generation commands:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow
weatherreporter generate near-term
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
@@ -25,6 +26,10 @@ Markdown report after `scriptorium run` succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for the current generated report; it
is not used as the distributor upload source.
`generate near-term` is an explicit generation command. It covers the six-hour
rolling period from generation time in the effective report timezone and is not
part of scheduled morning or evening batches.
Batch commands:
```text
@@ -62,6 +67,10 @@ workspace/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
near-term/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
storm/
YYYY-MM-DD/
<run_id>.modules.json
@@ -76,6 +85,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.data_package.yaml
near-term/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.yaml
@@ -89,6 +101,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.render.json
near-term/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
@@ -102,6 +117,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
near-term/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
@@ -112,6 +130,8 @@ workspace/
<run_id>.md
weekend/
<run_id>.md
near-term/
<run_id>.md
storm/
<run_id>.md
```
@@ -218,8 +238,8 @@ Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Storm Report leaves
Recent Changes empty.
compatible Weekend snapshots for the same weekend window. Near-Term Report and
Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty.

View File

@@ -30,6 +30,7 @@ type ReportKind string
const (
ReportDaily ReportKind = "daily"
ReportTomorrow ReportKind = "tomorrow"
ReportNearTerm ReportKind = "near-term"
ReportThreeDay ReportKind = "three-day"
ReportWeekend ReportKind = "weekend"
ReportStorm ReportKind = "storm"
@@ -386,6 +387,8 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
return report.DailyToday, nil
case ReportTomorrow:
return report.DailyTomorrow, nil
case ReportNearTerm:
return report.NearTerm, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:

View File

@@ -349,6 +349,187 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
}
}
func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
server := nearTermBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportNearTerm,
}, mustParse("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
outputPath := filepath.Join(t.TempDir(), "near-term.md")
renderer := successfulRenderer("# Near-Term Report\n")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" {
t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID)
}
if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" {
t.Fatalf("valid period start = %s, want rolling window start", got)
}
if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" {
t.Fatalf("valid period end = %s, want six-hour rolling window end", got)
}
if renderer.renderRequest.PromptID != "weather.near_term_report" || renderer.runRequest.PromptID != "weather.near_term_report" {
t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID)
}
if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) {
t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath)
}
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) {
t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
}
copiedReport, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if !strings.Contains(string(copiedReport), "# Near-Term Report") {
t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport))
}
wantModules := []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyForecast,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
}
if got := snapshotModuleIDs(result.ModuleSnapshot); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(wantModules), ",") {
t.Fatalf("module snapshot IDs = %#v, want %#v", got, wantModules)
}
hourly, ok, err := module.StanzaValue[briefing.HourlyForecastModule](result.ModuleSnapshot, "hourly_forecast")
if err != nil {
t.Fatalf("decode hourly forecast: %v", err)
}
if !ok || len(hourly.Periods) != 5 {
t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly)
}
hourlyJSON := mustMarshalString(t, hourly)
if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") {
t.Fatalf("hourly forecast missing selected near-term periods:\n%s", hourlyJSON)
}
if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") {
t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON)
}
precip, ok, err := module.StanzaValue[briefing.PrecipTimingModule](result.ModuleSnapshot, "precip_timing")
if err != nil {
t.Fatalf("decode precip timing: %v", err)
}
if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 {
t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip)
}
if precip.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || precip.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" {
t.Fatalf("first precip window = %#v, want first selected hour only", precip.PrecipitationWindows[0])
}
if precip.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 10:00 AM" || precip.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("second precip window = %#v, want late-morning near-term rain", precip.PrecipitationWindows[1])
}
alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest")
if err != nil {
t.Fatalf("decode alert digest: %v", err)
}
if !ok || !alerts.Checked || alerts.ActiveCount != 3 || alerts.RelevantCount != 1 || len(alerts.Relevant) != 1 || alerts.Relevant[0].Event != "Flood Watch" {
t.Fatalf("alert digest = %#v, want only near-term alert overlap relevant", alerts)
}
outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks")
if err != nil {
t.Fatalf("decode SPC outlooks: %v", err)
}
if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" {
t.Fatalf("SPC outlooks = %#v, want one overlapping near-term outlook", outlooks)
}
discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion")
if err != nil {
t.Fatalf("decode SPC discussion: %v", err)
}
if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" {
t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion)
}
afd, ok, err := module.StanzaValue[briefing.AreaForecastDiscussionModule](result.ModuleSnapshot, "area_forecast_discussion")
if err != nil {
t.Fatalf("decode AFD: %v", err)
}
if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." {
t.Fatalf("AFD = %#v, want key messages and short term", afd)
}
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd)
}
if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 {
t.Fatalf("prior=%#v recent=%#v package=%#v, want no rolling-window comparison output", result.PriorSnapshot, result.RecentChanges, result.DataPackage.RecentChanges.Items)
}
data := readDataPackageForTest(t, result)
text := string(data)
for _, want := range []string{
"id: near_term",
"prompt_id: weather.near_term_report",
"valid_period:",
"recent_changes:",
" items: []",
" alert_digest:",
" spc_convective_outlooks:",
" precip_timing:",
" area_forecast_discussion:",
" spc_convective_discussion:",
" weather_story:",
" current_conditions:",
" hourly_forecast:",
} {
if !strings.Contains(text, want) {
t.Fatalf("data package missing %q:\n%s", want, text)
}
}
for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for near-term report."} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text)
}
}
riskIndex := strings.Index(text, " applicable_risk_products:")
derivedIndex := strings.Index(text, " derived_summaries:")
narrativeIndex := strings.Index(text, " narrative_products:")
rawIndex := strings.Index(text, " raw_data:")
alertIndex := strings.Index(text, " alert_digest:")
precipIndex := strings.Index(text, " precip_timing:")
afdIndex := strings.Index(text, " area_forecast_discussion:")
currentIndex := strings.Index(text, " current_conditions:")
hourlyIndex := strings.Index(text, " hourly_forecast:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || precipIndex < 0 || afdIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < alertIndex && alertIndex < derivedIndex && derivedIndex < precipIndex && precipIndex < narrativeIndex && narrativeIndex < afdIndex && afdIndex < rawIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package category order is wrong:\n%s", text)
}
loaded, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
}
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
@@ -1162,23 +1343,59 @@ func TestInspectMissingMetadata(t *testing.T) {
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T18:00:00-05:00")
now := mustParse("2026-05-29T08:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportTomorrow,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
tests := []struct {
name string
kind ReportKind
wantID report.ID
wantPrompt string
wantStart string
wantEnd string
requestDate time.Time
}{
{
name: "tomorrow",
kind: ReportTomorrow,
wantID: report.DailyTomorrow,
wantPrompt: "weather.daily_report",
wantStart: "2026-05-30T00:00:00-05:00",
wantEnd: "2026-05-31T00:00:00-05:00",
requestDate: time.Time{},
},
{
name: "near-term",
kind: ReportNearTerm,
wantID: report.NearTerm,
wantPrompt: "weather.near_term_report",
wantStart: "2026-05-29T08:00:00-05:00",
wantEnd: "2026-05-29T14:00:00-05:00",
},
}
if resolved.Definition.ID != report.DailyTomorrow {
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID)
}
if resolved.Definition.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
}
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" {
t.Fatalf("valid start date = %s, want 2026-05-30", got)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: tt.kind,
Date: tt.requestDate,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != tt.wantID {
t.Fatalf("ID = %q, want %q", resolved.Definition.ID, tt.wantID)
}
if resolved.Definition.PromptID != tt.wantPrompt {
t.Fatalf("PromptID = %q, want %q", resolved.Definition.PromptID, tt.wantPrompt)
}
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != tt.wantStart {
t.Fatalf("valid start = %s, want %s", got, tt.wantStart)
}
if got := resolved.ValidPeriod.End.Format(time.RFC3339); got != tt.wantEnd {
t.Fatalf("valid end = %s, want %s", got, tt.wantEnd)
}
})
}
}
@@ -1221,6 +1438,23 @@ func moduleIDsForTest(ids []module.ID) []string {
return out
}
func snapshotModuleIDs(snapshot module.Snapshot) []module.ID {
ids := make([]module.ID, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
ids = append(ids, output.ID)
}
return ids
}
func mustMarshalString(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal value: %v", err)
}
return string(data)
}
func dailyBundleServer(t *testing.T) *httptest.Server {
t.Helper()
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
@@ -1254,6 +1488,34 @@ func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse st
return server
}
func nearTermBundleServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T13:20:00Z","conditionCode":3}}`))
case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Cloudy","temperatureF":72,"relativeHumidityPercent":70,"windSpeedMph":9}}`))
case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T08:00:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T07:30:00-05:00","endTime":"2026-05-29T08:30:00-05:00","textDescription":"Before-window storms","temperatureF":68,"probabilityOfPrecipitationPercent":90},{"startTime":"2026-05-29T08:00:00-05:00","endTime":"2026-05-29T09:00:00-05:00","textDescription":"Showers entering the area","temperatureF":70,"probabilityOfPrecipitationPercent":50},{"startTime":"2026-05-29T09:00:00-05:00","endTime":"2026-05-29T10:00:00-05:00","textDescription":"Brief dry break","temperatureF":72,"probabilityOfPrecipitationPercent":20},{"startTime":"2026-05-29T10:00:00-05:00","endTime":"2026-05-29T11:00:00-05:00","textDescription":"Thunderstorms increase","temperatureF":73,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-29T11:00:00-05:00","endTime":"2026-05-29T12:00:00-05:00","textDescription":"Heavy rain","temperatureF":74,"probabilityOfPrecipitationPercent":70},{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00","textDescription":"Drying out","temperatureF":76,"probabilityOfPrecipitationPercent":10},{"startTime":"2026-05-29T14:30:00-05:00","endTime":"2026-05-29T15:30:00-05:00","textDescription":"After-window rain","temperatureF":77,"probabilityOfPrecipitationPercent":60}]}}`))
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`))
case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for near-term report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for near-term report."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Near-Term Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/near-term"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-near-term","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"Near-term severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the near-term window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server
}
const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`
const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`

View File

@@ -97,6 +97,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
}
}
func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
t.Fatalf("HourlyForecast = %#v, want near-term hourly period", value)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -320,6 +335,34 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
}
}
func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("near-term default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("AFD = %#v, want key messages and short term", afd)
}
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
}
}
func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday)

View File

@@ -136,6 +136,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
}
}
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.NearTerm)
ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[PrecipTimingModule](t, output)
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
}
}
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)

View File

@@ -249,7 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
}
func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{
{
@@ -286,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
},
@@ -312,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.PrecipTiming,
StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule,

View File

@@ -54,6 +54,27 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
}
}
func TestNearTermDefaultModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.NearTerm)
var found bool
for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion {
continue
}
found = true
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
}
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
}
}
if !found {
t.Fatal("near-term default modules missing area_forecast_discussion")
}
}
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
@@ -91,6 +112,24 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
}
}
func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []module.ID{
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.OutdoorWindows,
module.TomorrowPlanning,
} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.NearTerm, []module.ConfigItem{{ID: id}})
if err == nil || !strings.Contains(err.Error(), `not compatible with report "near_term"`) {
t.Fatalf("ValidateComposition() error = %v, want incompatible near-term module", err)
}
})
}
}
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},

View File

@@ -19,6 +19,7 @@ Usage:
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -386,6 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
return app.ReportDaily, true
case string(app.ReportTomorrow):
return app.ReportTomorrow, true
case string(app.ReportNearTerm):
return app.ReportNearTerm, true
case string(app.ReportThreeDay):
return app.ReportThreeDay, true
case string(app.ReportWeekend):

View File

@@ -28,6 +28,9 @@ func TestRunHelpLongFlag(t *testing.T) {
if !strings.Contains(stdout.String(), "generate daily") {
t.Fatalf("help output missing generate command:\n%s", stdout.String())
}
if !strings.Contains(stdout.String(), "weatherreporter generate near-term") {
t.Fatalf("help output missing near-term generate command:\n%s", stdout.String())
}
removedInspectCommand := "inspect " + "briefing"
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
@@ -809,6 +812,7 @@ func TestResolveGenerateCommands(t *testing.T) {
}{
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "near-term", args: []string{"near-term"}, want: app.ReportNearTerm},
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
@@ -827,6 +831,53 @@ func TestResolveGenerateCommands(t *testing.T) {
}
}
func TestResolveGenerateNearTermAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
req, err := runner.resolveGenerate([]string{"near-term", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./near-term.md"})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if req.Report != app.ReportNearTerm {
t.Fatalf("Report = %q, want near-term", req.Report)
}
if req.Config.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
}
if req.Config.WeatherAPI.Timezone != "America/Chicago" {
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
}
if req.OutputPath != "./near-term.md" {
t.Fatalf("OutputPath = %q, want ./near-term.md", req.OutputPath)
}
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for near-term", req.Date, req.StormStart, req.StormEnd)
}
}
func TestResolveGenerateNearTermRejectsDateAndStormBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
for _, args := range [][]string{
{"near-term", "--date", "2026-05-29"},
{"near-term", "--start", "2026-05-29T18:00"},
{"near-term", "--end", "2026-05-29T20:00"},
} {
_, err := runner.resolveGenerate(args)
if err == nil {
t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args)
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error())
}
}
}
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
runner := Runner{Clock: fixedClock()}

View File

@@ -161,6 +161,45 @@ reports:
}
}
func TestLoadNearTermReportModuleOverrides(t *testing.T) {
tests := []struct {
name string
key string
}{
{name: "UnderscoreAlias", key: "near_term"},
{name: "HyphenAlias", key: "near-term"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeConfig(t, `
reports:
`+tt.key+`:
deterministic_modules:
- metadata
- hourly_forecast
- precip_timing
- alert_digest
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides := cfg.ReportModuleOverrides()
items := overrides[report.NearTerm]
if len(items) != 4 {
t.Fatalf("near-term override length = %d, want 4", len(items))
}
if items[0].ID != module.Metadata ||
items[1].ID != module.HourlyForecast ||
items[2].ID != module.PrecipTiming ||
items[3].ID != module.AlertDigest {
t.Fatalf("near-term override = %#v, want configured module order", items)
}
})
}
}
func TestReportModuleOverrideValidation(t *testing.T) {
tests := []struct {
name string
@@ -244,6 +283,29 @@ reports:
`,
wantErr: "duplicates report override",
},
{
name: "DuplicateNearTermAlias",
yaml: `
reports:
near_term:
deterministic_modules:
- metadata
near-term:
deterministic_modules:
- current_conditions
`,
wantErr: "duplicates report override",
},
{
name: "NearTermIncompatibleDailyModule",
yaml: `
reports:
near_term:
deterministic_modules:
- derived_daily_summary
`,
wantErr: `not compatible with report "near_term"`,
},
{
name: "UnknownReportField",
yaml: `

View File

@@ -113,6 +113,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
return report.DailyToday, nil
case "tomorrow", "daily_tomorrow":
return report.DailyTomorrow, nil
case "near_term":
return report.NearTerm, nil
case "three_day", "three_day_outlook":
return report.ThreeDay, nil
case "weekend", "weekend_outlook":

View File

@@ -114,6 +114,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID {
case report.NearTerm:
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil {

View File

@@ -152,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
}
}
func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:30:00-05:00"), location)
bundle := testBundle(location)
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
}}
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 4 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
}
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
}
if len(derived.ValidPeriodNarrativePeriods) != 1 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
t.Fatalf("near-term summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
}
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
}
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
}
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two near-term windows", derived.PrecipTiming.PrecipitationWindows)
}
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping near-term period", derived.AlertOverlaps)
}
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
t.Fatalf("Alert overlap end = %s, want clipped to near-term end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
}
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
}
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
}
}
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
location := testLocation()
resolved := resolveStormForTest(t, location)
@@ -247,6 +304,22 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
}
}
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:00:00-05:00"), location)
resolved.Definition.ID = report.ID("future_report")
_, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) {
t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err)
}
}
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)

View File

@@ -14,6 +14,7 @@ type ID string
const (
DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow"
NearTerm ID = "near_term"
ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
@@ -25,6 +26,7 @@ const (
CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
CompareRollingWindow ComparisonStrategy = "rolling_window"
)
type Batch string

View File

@@ -0,0 +1,52 @@
package report
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const nearTermHours = 6
func nearTermDefinition() Definition {
return Definition{
ID: NearTerm,
Name: "Near-Term Report",
PromptID: "weather.near_term_report",
ComparisonStrategy: CompareRollingWindow,
ArtifactGroup: "near-term",
BatchOutputName: "near-term.md",
Generated: true,
CompatiblePriorIDs: []ID{NearTerm},
Modules: nearTermModules(),
resolve: resolveNearTerm,
}
}
func nearTermModules() []module.ConfigItem {
return []module.ConfigItem{
{ID: module.Metadata},
{ID: module.CurrentConditions},
{ID: module.HourlyForecast},
{ID: module.PrecipTiming},
{ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks},
{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: []string{"key_messages", "short_term"},
},
},
{ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory},
}
}
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
return timeutil.Period{
Start: localNow,
End: localNow.Add(nearTermHours * time.Hour),
}, nil
}

View File

@@ -58,6 +58,51 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestNearTermLookupAndPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:15:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Definition.ID != NearTerm {
t.Fatalf("ID = %q, want near_term", resolved.Definition.ID)
}
if resolved.Definition.PromptID != "weather.near_term_report" {
t.Fatalf("PromptID = %q, want weather.near_term_report", resolved.Definition.PromptID)
}
if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
}
func TestNearTermPeriodUsesEffectiveTimezone(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatalf("load location: %v", err)
}
now := mustParse("2026-05-29T10:15:00Z")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
}
func TestNearTermPeriodIsNotCivilDayTruncated(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T22:30:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00")
}
func TestWeekendPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
tests := []struct {
@@ -158,6 +203,25 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
}
}
func TestBatchesDoNotIncludeNearTerm(t *testing.T) {
location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
morning, err := DefaultRegistry().BatchReports(Morning, req)
if err != nil {
t.Fatalf("BatchReports(morning) error = %v", err)
}
evening, err := DefaultRegistry().BatchReports(Evening, req)
if err != nil {
t.Fatalf("BatchReports(evening) error = %v", err)
}
for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == NearTerm {
t.Fatalf("batch included %q, want near-term excluded", resolved.Definition.ID)
}
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil {
@@ -168,6 +232,14 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
}
}
func TestRegistryAllIncludesNearTermInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily_today", "daily_tomorrow", "near_term", "three_day", "weekend", "storm"}
if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want)
}
}
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if definition.PromptID == "" {
@@ -186,6 +258,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName string
generated bool
compatiblePriorIDs []ID
comparisonStrategy ComparisonStrategy
}{
{
id: DailyToday,
@@ -193,6 +266,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "daily.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
comparisonStrategy: CompareSameValidDate,
},
{
id: DailyTomorrow,
@@ -200,6 +274,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "tomorrow.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
comparisonStrategy: CompareSameValidDate,
},
{
id: NearTerm,
artifactGroup: "near-term",
batchOutputName: "near-term.md",
generated: true,
compatiblePriorIDs: []ID{NearTerm},
comparisonStrategy: CompareRollingWindow,
},
{
id: ThreeDay,
@@ -207,6 +290,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "three-day.md",
generated: true,
compatiblePriorIDs: []ID{ThreeDay},
comparisonStrategy: CompareSameValidDate,
},
{
id: Weekend,
@@ -214,6 +298,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "weekend.md",
generated: true,
compatiblePriorIDs: []ID{Weekend},
comparisonStrategy: CompareWeekendWindow,
},
{
id: Storm,
@@ -221,6 +306,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "storm.md",
generated: true,
compatiblePriorIDs: []ID{Storm},
comparisonStrategy: CompareExplicitWindow,
},
}
@@ -240,6 +326,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
}
if definition.ComparisonStrategy != tt.comparisonStrategy {
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
}
@@ -294,6 +383,20 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.HourlyForecast,
},
},
{
id: NearTerm,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyForecast,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},
{
id: ThreeDay,
want: []module.ID{
@@ -412,6 +515,24 @@ func TestResolvedMetadata(t *testing.T) {
}
}
func TestNearTermMetadataRunIDIncludesReportID(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(NearTerm, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := resolved.Metadata()
if metadata.ReportID != NearTerm {
t.Fatalf("ReportID = %q, want near_term", metadata.ReportID)
}
if metadata.PromptID != "weather.near_term_report" {
t.Fatalf("PromptID = %q, want weather.near_term_report", metadata.PromptID)
}
if !strings.Contains(metadata.RunID, "near_term") {
t.Fatalf("RunID = %q, want report id", metadata.RunID)
}
}
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
@@ -433,6 +554,14 @@ func resolvedIDs(resolved []Resolved) []string {
return ids
}
func resolvedDefinitionIDs(definitions []Definition) []string {
ids := make([]string, 0, len(definitions))
for _, definition := range definitions {
ids = append(ids, string(definition.ID))
}
return ids
}
func mustLoadLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("America/Chicago")

View File

@@ -14,6 +14,7 @@ func DefaultRegistry() Registry {
definitions := []Definition{
dailyTodayDefinition(),
dailyTomorrowDefinition(),
nearTermDefinition(),
threeDayDefinition(),
weekendDefinition(),
stormDefinition(),
@@ -73,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
}
func (r Registry) All() []Definition {
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
ids := []ID{DailyToday, DailyTomorrow, NearTerm, ThreeDay, Weekend, Storm}
out := make([]Definition, 0, len(ids))
for _, id := range ids {
if definition, ok := r.definitions[id]; ok {

View File

@@ -305,6 +305,21 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
}
}
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
store := newTestStore(t)
first := resolveNearTermAt(t, "2026-05-29T05:00:00-05:00")
second := resolveNearTermAt(t, "2026-05-29T06:00:00-05:00")
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior != nil {
t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior)
}
}
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
cfg := config.Defaults().Workspace
cfg.Root = t.TempDir()
@@ -390,6 +405,26 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
return resolved
}
func resolveNearTermAt(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.NearTerm, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Metadata{
RunID: resolved.Metadata().RunID,