Rename rolling report to hourly

This commit is contained in:
2026-06-14 04:26:05 +00:00
parent b3f7c9c1f2
commit 8d737395dc
28 changed files with 220 additions and 600 deletions

View File

@@ -21,7 +21,7 @@ the managed Markdown report after final metadata is saved.
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] 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 tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--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 weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -39,7 +39,7 @@ weatherreporter inspect sources [--config PATH] RUN_ID
artifact, managed Markdown report, and metadata under the configured workspace. artifact, managed Markdown report, and metadata under the configured workspace.
`--out` writes an extra Markdown copy for the operator; distributor `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate 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 hourly` uses the current generation time, covers the next six hours in the
effective report timezone, and does not accept date or event window flags. effective report timezone, and does not accept date or event window flags.
`generate storm` requires explicit event-window bounds with `--start` and `generate storm` requires explicit event-window bounds with `--start` and
`--end`. `--end`.
@@ -54,7 +54,7 @@ notification is enabled, batch summaries and status lines include notification
status, accepted distributor run ID, or notification error fields for each status, accepted distributor run ID, or notification error fields for each
attempted report. attempted report.
Near-Term Report generation is explicit only; it is not included in `run Hourly Report generation is explicit only; it is not included in `run
morning` or `run evening`. morning` or `run evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout. `inspect` commands read existing workspace artifacts and emit JSON to stdout.
@@ -83,7 +83,7 @@ are no distributor-specific CLI flags.
```sh ```sh
weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate near-term --out ./near-term.md weatherreporter generate hourly --out ./hourly.md
weatherreporter generate three-day --out ./three-day.md weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md

View File

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

View File

@@ -38,7 +38,7 @@ Outputs:
Every registered composition entry has a builder. Unknown or unimplemented Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped. module IDs fail validation instead of being skipped.
Near-Term Report supports source and valid-period modules that operate over its Hourly Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`, rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`, `precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`. `area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
@@ -67,7 +67,7 @@ builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance. `sourceLocationId` and `sourceLocation` remain source provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a `area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. Near-Term Report defaults this module to subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`. `key_messages` and `short_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived `spc_convective_outlooks` uses collected SPC run metadata and derived

View File

@@ -27,7 +27,7 @@ Outputs:
report-period SPC convective outlooks and discussions, daily summaries, report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary daypart summaries, and Storm Report window summary
Near-Term Report uses the generic valid-period hourly and narrative selection Hourly Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing 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 from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily SPC outlooks/discussions overlapping that period. It does not build daily
@@ -68,7 +68,7 @@ and inspection.
- Invalid timezone names return an error. - Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast - Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries. derivation error for reports that require daily summaries.
- Near-Term Report can derive its default module facts without daily or - Hourly Report can derive its default module facts without daily or
daypart summaries. daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data - Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields. produces empty or nil derived fields.

View File

@@ -47,9 +47,9 @@ The registry recognizes these IDs:
Every registered module has a builder. Report composition entries that refer to Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped. unknown or unimplemented module IDs fail validation instead of being skipped.
## Near-Term Composition ## Hourly Composition
The default Near-Term Report module order is: The default Hourly Report module order is:
1. `metadata` 1. `metadata`
2. `current_conditions` 2. `current_conditions`
@@ -61,7 +61,7 @@ The default Near-Term Report module order is:
8. `spc_convective_discussion` 8. `spc_convective_discussion`
9. `weather_story` 9. `weather_story`
Near-Term Report does not include daily or daypart summary modules by default. Hourly Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`. `key_messages` and `short_term`.

View File

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

View File

@@ -31,7 +31,7 @@ Each report definition declares:
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow | | 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 | | 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 | | Hourly Report | `hourly` | `weather.hourly_report` | `hourly` | `hourly.md` | Hourly Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook | | 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 | | Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report | | Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
@@ -43,7 +43,7 @@ All report definitions are eligible for generation.
- Daily Today covers the selected local civil day, or the current local civil - Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied. day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time. - 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 - Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant, the effective report timezone. The duration is an internal report constant,
not a configuration field. not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight - 3-Day Outlook covers the interval from generation time through local midnight
@@ -69,13 +69,12 @@ IDs, then uses the registry for report policy.
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. Report 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 module overrides can use the `hourly` key for Hourly Report.
Report.
## Batch Membership ## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except 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 on Sunday. Evening batches include Daily Tomorrow. Hourly Report is not part
of a scheduled batch. of a scheduled batch.
## State And App Usage ## State And App Usage

View File

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

View File

@@ -4,8 +4,8 @@
This roadmap defines planned work to move selected reports from full Markdown This roadmap defines planned work to move selected reports from full Markdown
LLM generation to deterministic Markdown rendering with structured LLM-filled LLM generation to deterministic Markdown rendering with structured LLM-filled
text slots. The first target report is the rolling next-hours report, now named text slots. The first target report is the rolling next-hours report, named
`hourly` rather than `near_term`. `hourly`.
This feature is not implemented yet, so this document lives under This feature is not implemented yet, so this document lives under
`docs/roadmap/`. `docs/roadmap/`.
@@ -67,15 +67,14 @@ not become a second application logic layer.
Schema dependency in the first implementation. Schema dependency in the first implementation.
- Do not generate schemas or templates from inline Go. - Do not generate schemas or templates from inline Go.
- Start with the rolling next-hours report only. - Start with the rolling next-hours report only.
- Rename the planned `near_term` report to `hourly`. - Use `hourly` as the only public report ID for the rolling next-hours
- Do not keep both `near_term` and `hourly` public report IDs. report.
- Do not keep compatibility aliases from retired roadmap terminology.
- Do not add a generic workflow engine or plugin system. - Do not add a generic workflow engine or plugin system.
## Hourly Report Target ## Hourly Report Target
The planned `near_term` report should become the `hourly` report. The rolling next-hours report target identity is:
Target report identity:
- report ID: `hourly` - report ID: `hourly`
- CLI command: `weatherreporter generate hourly` - CLI command: `weatherreporter generate hourly`
@@ -143,7 +142,7 @@ Keep the first schema small. Example shape:
```json ```json
{ {
"summary": "Brief near-term overview.", "summary": "Brief hourly overview.",
"timing": "Plain-language timing of notable changes.", "timing": "Plain-language timing of notable changes.",
"impacts": "Practical impacts for the next few hours.", "impacts": "Practical impacts for the next few hours.",
"confidence": "Optional confidence or uncertainty note." "confidence": "Optional confidence or uncertainty note."

View File

@@ -7,8 +7,8 @@ This roadmap defines the concrete implementation sequence for
will implement each stage in order. will implement each stage in order.
This is a future-work roadmap. Until a stage is implemented, non-roadmap docs This is a future-work roadmap. Until a stage is implemented, non-roadmap docs
must not describe `GeneratedText`, embedded report templates, or must not describe `GeneratedText`, embedded report templates, or other
`weatherreporter generate hourly` as available behavior. unimplemented GeneratedText behavior as available.
## Source Feature Roadmap ## Source Feature Roadmap
@@ -26,8 +26,8 @@ for reaching that shape.
- Implement the first GeneratedText path for the rolling next-hours report, - Implement the first GeneratedText path for the rolling next-hours report,
named `hourly`. named `hourly`.
- Replace planned/public `near_term` naming with `hourly`; do not keep a - Use `hourly` as the only planned public name for the rolling next-hours
public `near-term` alias. report; do not keep compatibility aliases from retired roadmap terminology.
- Use report ID `hourly`, artifact group `hourly`, batch output name - Use report ID `hourly`, artifact group `hourly`, batch output name
`hourly.md`, and CLI command `weatherreporter generate hourly`. `hourly.md`, and CLI command `weatherreporter generate hourly`.
- Keep the rolling report explicit-generation only; do not add scheduled batch - Keep the rolling report explicit-generation only; do not add scheduled batch
@@ -61,7 +61,7 @@ planned public name for the rolling next-hours report.
Files to inspect: Files to inspect:
- `docs/roadmap/generated-text.md` - `docs/roadmap/generated-text.md`
- `docs/roadmap/near-term.md` - the superseded roadmap file for the retired rolling-report name
- `docs/roadmap/future.md` - `docs/roadmap/future.md`
- `docs/roadmap/implementation.md` - `docs/roadmap/implementation.md`
- `internal/app/app.go` - `internal/app/app.go`
@@ -70,30 +70,32 @@ Files to inspect:
Implementation: Implementation:
- Replace `docs/roadmap/near-term.md` with a short superseded note pointing to - Replace the superseded roadmap file for the retired rolling-report name with
`docs/roadmap/generated-text.md`, or delete it if the repository policy at a short note pointing to `docs/roadmap/generated-text.md`, or delete it if
implementation time prefers removing obsolete roadmap files. the repository policy at implementation time prefers removing obsolete
- Ensure no roadmap instructs a future agent to implement public roadmap files.
`near_term` / `near-term` behavior. - Ensure no roadmap instructs a future agent to implement retired public
- If partial `near_term` code already exists, rename it to `hourly` rather identifiers for the rolling report.
than adding aliases: - If partial code using retired identifiers already exists, rename it to
- `ReportNearTerm` -> `ReportHourly`; `hourly` rather than adding aliases:
- `report.NearTerm` -> `report.Hourly`; - app report kind -> `ReportHourly`;
- CLI command `near-term` -> `hourly`; - report ID constant -> `report.Hourly`;
- artifact group `near-term` -> `hourly`; - CLI command -> `hourly`;
- artifact group -> `hourly`;
- prompt IDs and tests updated to hourly names. - prompt IDs and tests updated to hourly names.
- Do not add compatibility aliases for `near-term`. - Do not add compatibility aliases for retired command or config names.
Acceptance criteria: Acceptance criteria:
- Active roadmap docs use `hourly` for the rolling report. - Active roadmap docs use `hourly` for the rolling report.
- No active roadmap describes `near_term` as the target public report ID. - No active roadmap describes a retired identifier as the target public report
ID.
- Existing code compiles after any rename work. - Existing code compiles after any rename work.
Suggested validation: Suggested validation:
```bash ```bash
rg -n "near_term|near-term|NearTerm" docs internal rg -n 'near[_-]term|Near''Term' docs internal
go test ./internal/app ./internal/report ./internal/cli go test ./internal/app ./internal/report ./internal/cli
``` ```
@@ -208,7 +210,7 @@ Implementation:
- no daily summaries, daypart summaries, or storm summary. - no daily summaries, daypart summaries, or storm summary.
- Add config report aliases: - Add config report aliases:
- `hourly` - `hourly`
- reject or do not recognize `near_term` / `near-term`. - reject or do not recognize retired rolling-report config keys.
- Add app report kind `ReportHourly = "hourly"` and map it to - Add app report kind `ReportHourly = "hourly"` and map it to
`report.Hourly`. `report.Hourly`.
- Add CLI parsing and help for: - Add CLI parsing and help for:
@@ -651,17 +653,17 @@ Files to inspect:
Implementation: Implementation:
- Add CLI command tests for `generate hourly`. - Add CLI command tests for `generate hourly`.
- Reject `generate near-term`. - Reject the retired rolling-report command name.
- Reject hourly `--date`, `--start`, `--end`, and duration-like flags. - Reject hourly `--date`, `--start`, `--end`, and duration-like flags.
- Add config override tests for `reports.hourly.deterministic_modules`. - Add config override tests for `reports.hourly.deterministic_modules`.
- Ensure `near_term` and `near-term` report config keys are rejected. - Ensure retired rolling-report config keys are rejected.
- Add app/CLI workflow tests using fake Scriptorium structured output. - Add app/CLI workflow tests using fake Scriptorium structured output.
- Update examples only if they enumerate report module overrides. - Update examples only if they enumerate report module overrides.
Acceptance criteria: Acceptance criteria:
- `weatherreporter --help` lists `generate hourly`; - `weatherreporter --help` lists `generate hourly`;
- `generate near-term` is not accepted; - the retired rolling-report command name is not accepted;
- hourly config overrides validate compatible modules; - hourly config overrides validate compatible modules;
- incompatible daily/daypart modules fail clearly for hourly; - incompatible daily/daypart modules fail clearly for hourly;
- examples load successfully. - examples load successfully.
@@ -693,13 +695,13 @@ Files to inspect and update:
- new `docs/internal/reporttemplate.md` - new `docs/internal/reporttemplate.md`
- `docs/integrations/scriptorium.md` - `docs/integrations/scriptorium.md`
- `docs/roadmap/future.md` - `docs/roadmap/future.md`
- `docs/roadmap/near-term.md` - the superseded roadmap file for the retired rolling-report name
- `examples/config.yml`, only if examples changed - `examples/config.yml`, only if examples changed
Documentation requirements: Documentation requirements:
- Non-roadmap docs describe only implemented hourly and GeneratedText behavior. - Non-roadmap docs describe only implemented hourly and GeneratedText behavior.
- `docs/cli.md` documents `generate hourly`, not `generate near-term`. - `docs/cli.md` documents `generate hourly`, not the retired command name.
- `docs/config.md` documents `reports.hourly` only if override support is - `docs/config.md` documents `reports.hourly` only if override support is
implemented. implemented.
- Internal docs distinguish: - Internal docs distinguish:
@@ -711,20 +713,22 @@ Documentation requirements:
- Scriptorium integration docs include the structured-output command actually - Scriptorium integration docs include the structured-output command actually
used by the adapter. used by the adapter.
- State docs list generated-text artifacts and metadata links. - State docs list generated-text artifacts and metadata links.
- `docs/roadmap/near-term.md` is deleted or marked superseded. - the superseded roadmap file for the retired rolling-report name is deleted
or marked superseded.
- Deferred migrations for other report types stay under roadmap docs only. - Deferred migrations for other report types stay under roadmap docs only.
Acceptance criteria: Acceptance criteria:
- No non-roadmap docs describe unimplemented generated-text migrations. - No non-roadmap docs describe unimplemented generated-text migrations.
- No active docs describe `near_term` as the target public report. - No active docs describe retired rolling-report identifiers as the target
public report.
- Maintained examples remain valid. - Maintained examples remain valid.
Suggested validation: Suggested validation:
```bash ```bash
go test ./internal/config go test ./internal/config
rg -n "near_term|near-term|NearTerm" docs internal examples rg -n 'near[_-]term|Near''Term' docs internal examples
git diff --check git diff --check
``` ```
@@ -745,7 +749,7 @@ git diff --check
Manual checks: Manual checks:
- `weatherreporter --help` lists `generate hourly`. - `weatherreporter --help` lists `generate hourly`.
- `weatherreporter --help` does not list `generate near-term`. - `weatherreporter --help` does not list the retired rolling-report command.
- generated hourly Markdown has deterministic headings and section order. - generated hourly Markdown has deterministic headings and section order.
- GeneratedText JSON is persisted separately from deterministic facts. - GeneratedText JSON is persisted separately from deterministic facts.
- render context JSON is persisted and readable. - render context JSON is persisted and readable.

View File

@@ -1,363 +1,6 @@
# Near-Term Report Roadmap # Superseded Roadmap
## Purpose This roadmap has been superseded by `docs/roadmap/generated-text.md`.
This roadmap defines planned work to add a rolling `near_term` report focused The rolling next-hours report is named `hourly`. Use the GeneratedText roadmap
on the next several hours. The feature is not implemented yet, so this document for current future-work guidance.
lives under `docs/roadmap/`.
The goal is a frequently generated report that helps readers understand what
matters in the immediate future: current conditions, hourly evolution,
precipitation timing, applicable hazards, applicable SPC risk products, and the
most relevant short-term narrative context.
## Target Behavior
Add a generated report with:
- report ID: `near_term`
- display name: `Near-Term Report`
- prompt ID: `weather.near_term_report`
- artifact group: `near-term`
- batch output name: `near-term.md`
- default valid-period length: 6 hours
- valid period: `[generation_time, generation_time + nearTermHours)`
The report should use a package-owned constant for the valid-period length, for
example:
```go
const nearTermHours = 6
```
Do not make the duration configurable in the first implementation. The constant
exists so the value can be changed later to 4 or 8 hours without changing
valid-period logic in multiple places.
## Locked Decisions
- The report ID is `near_term`.
- The report is rolling and generation-time anchored, not civil-day anchored.
- The first implementation covers the next 6 hours through a constant.
- The report should be generated explicitly by CLI command before deciding
whether it belongs in scheduled batches.
- The first implementation should declare `rolling_window` comparison policy
but should not emit Recent Changes output for `near_term`.
- Alert and SPC products should be included only when their valid periods
overlap the resolved near-term report period.
- SPC discussion text should keep the existing categorical-risk threshold rule
and should still require overlap with the near-term report period.
- The AFD stanza should include key messages and short term text by default.
- Daily-only modules should not be forced into this report.
## Report Definition
Add a report definition under `internal/report`, preferably in a focused
`near_term_report.go` file.
Definition fields:
- `ID`: `NearTerm`
- `Name`: `Near-Term Report`
- `PromptID`: `weather.near_term_report`
- `ComparisonStrategy`: `rolling_window`
- `ArtifactGroup`: `near-term`
- `BatchOutputName`: `near-term.md`
- `Generated`: `true`
- `CompatiblePriorIDs`: `[]ID{NearTerm}`
- `Modules`: near-term module list below
- `resolve`: rolling near-term resolver
Valid-period resolver:
```go
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
}
```
The implementation should use idiomatic package-local constants and avoid
duplicating duration literals in tests or app code.
## Default Module Composition
Default module order should be:
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`
`area_forecast_discussion` should use options equivalent to:
```yaml
sections:
- key_messages
- short_term
```
Do not include these daily/daypart-oriented modules initially:
- `derived_daily_summary`
- `derived_daypart_summaries`
- `outdoor_windows`
- `tomorrow_planning`
If the report needs deterministic summary facts later, add a purpose-built
module such as `derived_near_term_summary` rather than stretching daily modules
into a rolling sub-daily context.
## Prompt Package Shape
The existing prompt-input category layout should remain unchanged:
- `applicable_risk_products`
- `derived_summaries`
- `narrative_products`
- `raw_data`
Near-term output is expected to emphasize:
- `current_conditions` and `hourly_forecast` under `raw_data`
- `precip_timing` under `derived_summaries`
- `alert_digest` and `spc_convective_outlooks` under
`applicable_risk_products`
- `area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story` under `narrative_products`
Module interval fields should use the existing prompt-facing
`period_begins` / `period_ends` convention.
## Overlap And Filtering Rules
Hourly forecast:
- use only hourly periods overlapping the near-term valid period;
- preserve hourly period order;
- do not include the full daily forecast.
Narrative products:
- AFD key messages and short term text may be included because the AFD is an
official short-term forecast discussion product;
- narrative forecast periods are not included by default unless a later prompt
test shows they improve near-term output.
Alerts:
- include active alert overlaps only when the alert overlaps the near-term
valid period;
- if alerts were checked successfully and no alerts overlap, emit checked empty
alert context through existing module behavior.
SPC outlooks:
- include only retained outlooks whose valid periods overlap the near-term
valid period;
- do not include non-overlapping outlooks even if they are severe;
- keep current prompt-facing field exclusions for geometry, severity rank,
expiration time, and source URL.
SPC discussion:
- include discussion only for SPC days where a retained overlapping categorical
outlook has severity rank at least `3`;
- do not include discussion for low-risk, non-categorical-only, or
non-overlapping outlooks.
Weather story:
- include when available under current optional-source behavior;
- do not require the story valid period to exactly match the near-term period
unless later testing shows stale stories are a problem.
## Implementation Stages
### Stage 1: Report Registry
Goal: add the `near_term` report definition and valid-period resolver.
Files to inspect or update:
- `internal/report/definition.go`
- `internal/report/registry.go`
- `internal/report/period.go`
- new `internal/report/near_term_report.go`
- `internal/report/period_test.go`
- `docs/internal/report-registry.md` after implementation
Acceptance criteria:
- `report.DefaultRegistry().Lookup(report.NearTerm)` succeeds.
- `report.Registry.All()` includes the report in a stable order.
- resolving the report at a fixed generation time produces a half-open
six-hour period.
- report metadata and RunID include `near_term`.
### Stage 2: Module Compatibility And Defaults
Goal: allow existing relevant modules to build for `near_term` and define the
default near-term composition.
Files to inspect or update:
- `internal/briefing/modules.go`
- `internal/report/near_term_report.go`
- `internal/briefing/*_module_test.go`
- `internal/module/module_test.go`
Acceptance criteria:
- all default near-term modules validate and build from appropriate test facts;
- daily-only modules remain incompatible unless intentionally expanded;
- `area_forecast_discussion` defaults to key messages and short term only for
this report.
### Stage 3: Derived Facts
Goal: make `internal/facts` derive report-period facts for `near_term`.
Files to inspect or update:
- `internal/facts/facts.go`
- `internal/facts/facts_test.go`
- `internal/forecast` selection helpers, if needed
Acceptance criteria:
- valid-period hourly periods are sliced to the six-hour window;
- precipitation timing is built from that six-hour hourly slice;
- alert overlaps use the near-term valid period;
- SPC outlook and discussion derivation use the near-term valid period;
- no daily summaries or daypart summaries are required for the default
near-term module set.
### Stage 4: CLI And App Wiring
Goal: add explicit generation support without changing existing commands.
Files to inspect or update:
- `internal/app`
- `internal/cli`
- `cmd/weatherreporter/main.go`
- `docs/cli.md` after implementation
Expected command:
```bash
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
```
Acceptance criteria:
- command maps to `report.NearTerm`;
- no `--date`, `--start`, or `--end` flags are added for this report;
- existing `generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, and `generate storm` behavior is unchanged;
- CLI help lists the new command.
### Stage 5: Prompt Input And State Coverage
Goal: prove the managed module snapshot and data package contain the intended
near-term stanzas.
Files to inspect or update:
- `internal/app/app_test.go`
- `internal/promptinput`
- `internal/state`
Acceptance criteria:
- generated module snapshot includes the near-term default module list in
order;
- data package uses existing categories;
- alert/SPC stanzas are omitted or checked-empty according to current module
behavior and valid-period overlap;
- report metadata preserves canonical `valid_period`;
- module interval labels use `period_begins` and `period_ends`.
### Stage 6: Documentation And Examples
Goal: document implemented behavior only after the code exists.
Files to inspect or update after implementation:
- `docs/cli.md`
- `docs/internal/report-registry.md`
- `docs/internal/module.md`
- `docs/internal/facts.md`
- `docs/internal/briefing.md`
- `docs/config.md` only if report module overrides need an example update
- `examples/config.yml` only if it lists report module overrides
Acceptance criteria:
- non-roadmap docs describe `near_term` only after implementation;
- future or deferred near-term summary modules remain under roadmap docs;
- examples, if updated, load through existing config tests.
## Test Plan
Focused tests:
```bash
go test ./internal/report ./internal/briefing ./internal/facts
go test ./internal/app ./internal/cli ./internal/promptinput
```
Full validation:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Important coverage:
- six-hour valid-period resolution from a fixed generation time;
- timezone-aware period start and end;
- no civil-day truncation;
- hourly periods selected by overlap with the rolling window;
- alert overlap filtering;
- SPC outlook filtering by overlap;
- SPC discussion filtering by overlap plus categorical severity threshold;
- AFD key messages and short term included;
- daily-only modules rejected or absent from default near-term composition;
- distributor bundle path templates render sensibly for a sub-daily report,
especially valid start time variables.
## Deferred Work
Do not include these in the first implementation:
- user-configurable near-term duration;
- adding `near_term` to scheduled morning or evening batches;
- a dedicated `derived_near_term_summary` module;
- narrative forecast periods by default;
- separate AFD section modules;
- near-term-specific Recent Changes comparison output;
- CLI flags for custom near-term duration.
## Open Questions
None block the roadmap.
Recommendation: implement the first version as explicit `generate near-term`
only, with no scheduled batch membership. That keeps the user-visible behavior
small while the prompt and generated report are tested.
Viable alternative: add `near_term` to a frequent scheduled batch immediately.
That may be useful operationally, but it should wait until the report cadence
and downstream distributor behavior are clear.

View File

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

View File

@@ -349,19 +349,19 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
} }
} }
func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) { func TestGenerateHourlyReportWritesExpectedArtifacts(t *testing.T) {
server := nearTermBundleServer(t) server := hourlyBundleServer(t)
cfg := dailyTestConfig(t, server) cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportNearTerm, Report: ReportHourly,
}, mustParse("2026-05-29T08:30:00-05:00")) }, mustParse("2026-05-29T08:30:00-05:00"))
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
outputPath := filepath.Join(t.TempDir(), "near-term.md") outputPath := filepath.Join(t.TempDir(), "hourly.md")
renderer := successfulRenderer("# Near-Term Report\n") renderer := successfulRenderer("# Hourly Report\n")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
@@ -373,8 +373,8 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" { if result.Metadata.ReportID != report.Hourly || result.Metadata.PromptID != "weather.hourly_report" {
t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID) t.Fatalf("metadata report/prompt = %q/%q, want hourly", result.Metadata.ReportID, result.Metadata.PromptID)
} }
if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" { 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) t.Fatalf("valid period start = %s, want rolling window start", got)
@@ -382,14 +382,14 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" { 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) 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" { if renderer.renderRequest.PromptID != "weather.hourly_report" || renderer.runRequest.PromptID != "weather.hourly_report" {
t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID) t.Fatalf("renderer prompt IDs = %q/%q, want hourly prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID)
} }
if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) { if !strings.Contains(result.ReportPath, filepath.Join("reports", "hourly")) {
t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath) t.Fatalf("ReportPath = %q, want hourly artifact group", result.ReportPath)
} }
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) { if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "hourly", "2026-05-29")) {
t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath) t.Fatalf("DataPackagePath = %q, want hourly artifact group", result.DataPackagePath)
} }
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath) assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if result.OutputPath != outputPath { if result.OutputPath != outputPath {
@@ -399,7 +399,7 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read output copy: %v", err) t.Fatalf("read output copy: %v", err)
} }
if !strings.Contains(string(copiedReport), "# Near-Term Report") { if !strings.Contains(string(copiedReport), "# Hourly Report") {
t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport)) t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport))
} }
@@ -423,11 +423,11 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
t.Fatalf("decode hourly forecast: %v", err) t.Fatalf("decode hourly forecast: %v", err)
} }
if !ok || len(hourly.Periods) != 5 { if !ok || len(hourly.Periods) != 5 {
t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly) t.Fatalf("hourly forecast = %#v, want five overlapping hourly periods", hourly)
} }
hourlyJSON := mustMarshalString(t, hourly) hourlyJSON := mustMarshalString(t, hourly)
if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") { 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) t.Fatalf("hourly forecast missing selected hourly periods:\n%s", hourlyJSON)
} }
if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") { 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) t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON)
@@ -438,13 +438,13 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
t.Fatalf("decode precip timing: %v", err) t.Fatalf("decode precip timing: %v", err)
} }
if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 { if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 {
t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip) t.Fatalf("precip timing = %#v, want hourly 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" { 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]) 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" { 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]) t.Fatalf("second precip window = %#v, want late-morning hourly rain", precip.PrecipitationWindows[1])
} }
alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest") alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest")
@@ -452,7 +452,7 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
t.Fatalf("decode alert digest: %v", err) 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" { 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) t.Fatalf("alert digest = %#v, want only hourly alert overlap relevant", alerts)
} }
outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks") outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks")
@@ -460,13 +460,13 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
t.Fatalf("decode SPC outlooks: %v", err) t.Fatalf("decode SPC outlooks: %v", err)
} }
if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" { 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) t.Fatalf("SPC outlooks = %#v, want one overlapping hourly outlook", outlooks)
} }
discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion") discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion")
if err != nil { if err != nil {
t.Fatalf("decode SPC discussion: %v", err) t.Fatalf("decode SPC discussion: %v", err)
} }
if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" { if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "hourly severe storms" {
t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion) t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion)
} }
@@ -474,11 +474,11 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("decode AFD: %v", err) t.Fatalf("decode AFD: %v", err)
} }
if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." { if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for hourly report." {
t.Fatalf("AFD = %#v, want key messages and short term", afd) t.Fatalf("AFD = %#v, want key messages and short term", afd)
} }
if afd.Product != "" || afd.LongTerm != "" { if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd) t.Fatalf("AFD = %#v, want hourly defaults to omit product and long term", afd)
} }
if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 { if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 {
@@ -487,8 +487,8 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
data := readDataPackageForTest(t, result) data := readDataPackageForTest(t, result)
text := string(data) text := string(data)
for _, want := range []string{ for _, want := range []string{
"id: near_term", "id: hourly",
"prompt_id: weather.near_term_report", "prompt_id: weather.hourly_report",
"valid_period:", "valid_period:",
"recent_changes:", "recent_changes:",
" items: []", " items: []",
@@ -505,9 +505,9 @@ func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
t.Fatalf("data package missing %q:\n%s", want, text) 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."} { for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for hourly report."} {
if strings.Contains(text, omitted) { if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text) t.Fatalf("data package contains %q, want hourly filtered/canonical output:\n%s", omitted, text)
} }
} }
riskIndex := strings.Index(text, " applicable_risk_products:") riskIndex := strings.Index(text, " applicable_risk_products:")
@@ -1364,10 +1364,10 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
requestDate: time.Time{}, requestDate: time.Time{},
}, },
{ {
name: "near-term", name: "hourly",
kind: ReportNearTerm, kind: ReportHourly,
wantID: report.NearTerm, wantID: report.Hourly,
wantPrompt: "weather.near_term_report", wantPrompt: "weather.hourly_report",
wantStart: "2026-05-29T08:00:00-05:00", wantStart: "2026-05-29T08:00:00-05:00",
wantEnd: "2026-05-29T14:00:00-05:00", wantEnd: "2026-05-29T14:00:00-05:00",
}, },
@@ -1488,7 +1488,7 @@ func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse st
return server return server
} }
func nearTermBundleServer(t *testing.T) *httptest.Server { func hourlyBundleServer(t *testing.T) *httptest.Server {
t.Helper() t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
@@ -1503,11 +1503,11 @@ func nearTermBundleServer(t *testing.T) *httptest.Server {
case "/alerts/active": 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"}]}}`)) _, _ = 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": 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."}}}`)) _, _ = 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 hourly report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for hourly report."}}}`))
case "/weatherstories/latest": 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"}}`)) _, _ = 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":"Hourly 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/hourly"}}`))
case "/outlooks/convective": 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"}]}}`)) _, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-hourly","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":"hourly severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the hourly 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: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View File

@@ -97,10 +97,10 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
} }
} }
func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) { func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm) ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil { if err != nil {
@@ -108,7 +108,7 @@ func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) {
} }
value := moduleValue[HourlyForecastModule](t, output) value := moduleValue[HourlyForecastModule](t, output)
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." { if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
t.Fatalf("HourlyForecast = %#v, want near-term hourly period", value) t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
} }
} }
@@ -335,10 +335,10 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
} }
} }
func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) { func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm) ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
var item module.ConfigItem var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules { for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion { if candidate.ID == module.AreaForecastDiscussion {
@@ -347,7 +347,7 @@ func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) {
} }
} }
if item.ID == "" { if item.ID == "" {
t.Fatal("near-term default modules missing area_forecast_discussion") t.Fatal("hourly default modules missing area_forecast_discussion")
} }
output, err := registry.BuildModule(ctx, item) output, err := registry.BuildModule(ctx, item)

View File

@@ -138,7 +138,7 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) { func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.NearTerm) ctx := derivedModuleContext(report.Hourly)
ctx.Derived.DailySummaries = nil ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil ctx.Derived.DaypartSummaries = nil

View File

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

View File

@@ -54,8 +54,8 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
} }
} }
func TestNearTermDefaultModuleOptions(t *testing.T) { func TestHourlyDefaultModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.NearTerm) definition := report.DefaultRegistry().MustLookup(report.Hourly)
var found bool var found bool
for _, item := range definition.Modules { for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion { if item.ID != module.AreaForecastDiscussion {
@@ -71,7 +71,7 @@ func TestNearTermDefaultModuleOptions(t *testing.T) {
} }
} }
if !found { if !found {
t.Fatal("near-term default modules missing area_forecast_discussion") t.Fatal("hourly default modules missing area_forecast_discussion")
} }
} }
@@ -112,7 +112,7 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
} }
} }
func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) { func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
for _, id := range []module.ID{ for _, id := range []module.ID{
module.NarrativeForecast, module.NarrativeForecast,
@@ -122,9 +122,9 @@ func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) {
module.TomorrowPlanning, module.TomorrowPlanning,
} { } {
t.Run(string(id), func(t *testing.T) { t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.NearTerm, []module.ConfigItem{{ID: id}}) err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
if err == nil || !strings.Contains(err.Error(), `not compatible with report "near_term"`) { if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
t.Fatalf("ValidateComposition() error = %v, want incompatible near-term module", err) t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
} }
}) })
} }

View File

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

View File

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

View File

@@ -161,19 +161,10 @@ reports:
} }
} }
func TestLoadNearTermReportModuleOverrides(t *testing.T) { func TestLoadHourlyReportModuleOverrides(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, ` path := writeConfig(t, `
reports: reports:
`+tt.key+`: hourly:
deterministic_modules: deterministic_modules:
- metadata - metadata
- hourly_forecast - hourly_forecast
@@ -186,17 +177,15 @@ reports:
t.Fatalf("LoadFile() error = %v", err) t.Fatalf("LoadFile() error = %v", err)
} }
overrides := cfg.ReportModuleOverrides() overrides := cfg.ReportModuleOverrides()
items := overrides[report.NearTerm] items := overrides[report.Hourly]
if len(items) != 4 { if len(items) != 4 {
t.Fatalf("near-term override length = %d, want 4", len(items)) t.Fatalf("hourly override length = %d, want 4", len(items))
} }
if items[0].ID != module.Metadata || if items[0].ID != module.Metadata ||
items[1].ID != module.HourlyForecast || items[1].ID != module.HourlyForecast ||
items[2].ID != module.PrecipTiming || items[2].ID != module.PrecipTiming ||
items[3].ID != module.AlertDigest { items[3].ID != module.AlertDigest {
t.Fatalf("near-term override = %#v, want configured module order", items) t.Fatalf("hourly override = %#v, want configured module order", items)
}
})
} }
} }
@@ -284,27 +273,14 @@ reports:
wantErr: "duplicates report override", wantErr: "duplicates report override",
}, },
{ {
name: "DuplicateNearTermAlias", name: "HourlyIncompatibleDailyModule",
yaml: ` yaml: `
reports: reports:
near_term: hourly:
deterministic_modules:
- metadata
near-term:
deterministic_modules:
- current_conditions
`,
wantErr: "duplicates report override",
},
{
name: "NearTermIncompatibleDailyModule",
yaml: `
reports:
near_term:
deterministic_modules: deterministic_modules:
- derived_daily_summary - derived_daily_summary
`, `,
wantErr: `not compatible with report "near_term"`, wantErr: `not compatible with report "hourly"`,
}, },
{ {
name: "UnknownReportField", name: "UnknownReportField",

View File

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

View File

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

View File

@@ -152,9 +152,9 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
} }
} }
func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) { func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:30:00-05:00"), location) resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:30:00-05:00"), location)
bundle := testBundle(location) bundle := testBundle(location)
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{ 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":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
@@ -184,7 +184,7 @@ func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods)) t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
} }
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil { 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) t.Fatalf("hourly 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" { 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) t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
@@ -193,13 +193,13 @@ func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation) t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
} }
if len(derived.PrecipTiming.PrecipitationWindows) != 2 { if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two near-term windows", derived.PrecipTiming.PrecipitationWindows) t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two hourly windows", derived.PrecipTiming.PrecipitationWindows)
} }
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" { if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping near-term period", derived.AlertOverlaps) t.Fatalf("AlertOverlaps = %#v, want only alert overlapping hourly period", derived.AlertOverlaps)
} }
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" { 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)) t.Fatalf("Alert overlap end = %s, want clipped to hourly 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, ",") { 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) t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
@@ -306,7 +306,7 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) { func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
resolved.Definition.ID = report.ID("future_report") resolved.Definition.ID = report.ID("future_report")
_, err := BuildDerived(BuildDerivedRequest{ _, err := BuildDerived(BuildDerivedRequest{

View File

@@ -14,7 +14,7 @@ type ID string
const ( const (
DailyToday ID = "daily_today" DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow" DailyTomorrow ID = "daily_tomorrow"
NearTerm ID = "near_term" Hourly ID = "hourly"
ThreeDay ID = "three_day" ThreeDay ID = "three_day"
Weekend ID = "weekend" Weekend ID = "weekend"
Storm ID = "storm" Storm ID = "storm"

View File

@@ -7,24 +7,24 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
const nearTermHours = 6 const hourlyHours = 6
func nearTermDefinition() Definition { func hourlyDefinition() Definition {
return Definition{ return Definition{
ID: NearTerm, ID: Hourly,
Name: "Near-Term Report", Name: "Hourly Report",
PromptID: "weather.near_term_report", PromptID: "weather.hourly_report",
ComparisonStrategy: CompareRollingWindow, ComparisonStrategy: CompareRollingWindow,
ArtifactGroup: "near-term", ArtifactGroup: "hourly",
BatchOutputName: "near-term.md", BatchOutputName: "hourly.md",
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{NearTerm}, CompatiblePriorIDs: []ID{Hourly},
Modules: nearTermModules(), Modules: hourlyModules(),
resolve: resolveNearTerm, resolve: resolveHourly,
} }
} }
func nearTermModules() []module.ConfigItem { func hourlyModules() []module.ConfigItem {
return []module.ConfigItem{ return []module.ConfigItem{
{ID: module.Metadata}, {ID: module.Metadata},
{ID: module.CurrentConditions}, {ID: module.CurrentConditions},
@@ -43,10 +43,10 @@ func nearTermModules() []module.ConfigItem {
} }
} }
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) { func resolveHourly(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location) localNow := req.Now.In(req.Location)
return timeutil.Period{ return timeutil.Period{
Start: localNow, Start: localNow,
End: localNow.Add(nearTermHours * time.Hour), End: localNow.Add(hourlyHours * time.Hour),
}, nil }, nil
} }

View File

@@ -58,19 +58,19 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
} }
func TestNearTermLookupAndPeriodCalculation(t *testing.T) { func TestHourlyLookupAndPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:15:00-05:00") now := mustParse("2026-05-29T05:15:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
if resolved.Definition.ID != NearTerm { if resolved.Definition.ID != Hourly {
t.Fatalf("ID = %q, want near_term", resolved.Definition.ID) t.Fatalf("ID = %q, want hourly", resolved.Definition.ID)
} }
if resolved.Definition.PromptID != "weather.near_term_report" { if resolved.Definition.PromptID != "weather.hourly_report" {
t.Fatalf("PromptID = %q, want weather.near_term_report", resolved.Definition.PromptID) t.Fatalf("PromptID = %q, want weather.hourly_report", resolved.Definition.PromptID)
} }
if resolved.Definition.ComparisonStrategy != CompareRollingWindow { if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy) t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
@@ -78,25 +78,25 @@ func TestNearTermLookupAndPeriodCalculation(t *testing.T) {
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
} }
func TestNearTermPeriodUsesEffectiveTimezone(t *testing.T) { func TestHourlyPeriodUsesEffectiveTimezone(t *testing.T) {
location, err := time.LoadLocation("America/New_York") location, err := time.LoadLocation("America/New_York")
if err != nil { if err != nil {
t.Fatalf("load location: %v", err) t.Fatalf("load location: %v", err)
} }
now := mustParse("2026-05-29T10:15:00Z") now := mustParse("2026-05-29T10:15:00Z")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
} }
func TestNearTermPeriodIsNotCivilDayTruncated(t *testing.T) { func TestHourlyPeriodIsNotCivilDayTruncated(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T22:30:00-05:00") now := mustParse("2026-05-29T22:30:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
@@ -203,7 +203,7 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
} }
} }
func TestBatchesDoNotIncludeNearTerm(t *testing.T) { func TestBatchesDoNotIncludeHourly(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location} req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
@@ -216,8 +216,8 @@ func TestBatchesDoNotIncludeNearTerm(t *testing.T) {
t.Fatalf("BatchReports(evening) error = %v", err) t.Fatalf("BatchReports(evening) error = %v", err)
} }
for _, resolved := range append(morning, evening...) { for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == NearTerm { if resolved.Definition.ID == Hourly {
t.Fatalf("batch included %q, want near-term excluded", resolved.Definition.ID) t.Fatalf("batch included %q, want hourly excluded", resolved.Definition.ID)
} }
} }
} }
@@ -232,9 +232,9 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
} }
} }
func TestRegistryAllIncludesNearTermInStableOrder(t *testing.T) { func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All()) ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily_today", "daily_tomorrow", "near_term", "three_day", "weekend", "storm"} want := []string{"daily_today", "daily_tomorrow", "hourly", "three_day", "weekend", "storm"}
if strings.Join(ids, ",") != strings.Join(want, ",") { if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want) t.Fatalf("All() ids = %#v, want %#v", ids, want)
} }
@@ -277,11 +277,11 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
comparisonStrategy: CompareSameValidDate, comparisonStrategy: CompareSameValidDate,
}, },
{ {
id: NearTerm, id: Hourly,
artifactGroup: "near-term", artifactGroup: "hourly",
batchOutputName: "near-term.md", batchOutputName: "hourly.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{NearTerm}, compatiblePriorIDs: []ID{Hourly},
comparisonStrategy: CompareRollingWindow, comparisonStrategy: CompareRollingWindow,
}, },
{ {
@@ -384,7 +384,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
}, },
}, },
{ {
id: NearTerm, id: Hourly,
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -515,20 +515,20 @@ func TestResolvedMetadata(t *testing.T) {
} }
} }
func TestNearTermMetadataRunIDIncludesReportID(t *testing.T) { func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(NearTerm, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location}) resolved, err := Resolve(Hourly, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
metadata := resolved.Metadata() metadata := resolved.Metadata()
if metadata.ReportID != NearTerm { if metadata.ReportID != Hourly {
t.Fatalf("ReportID = %q, want near_term", metadata.ReportID) t.Fatalf("ReportID = %q, want hourly", metadata.ReportID)
} }
if metadata.PromptID != "weather.near_term_report" { if metadata.PromptID != "weather.hourly_report" {
t.Fatalf("PromptID = %q, want weather.near_term_report", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.hourly_report", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "near_term") { if !strings.Contains(metadata.RunID, "hourly") {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want report id", metadata.RunID)
} }
} }

View File

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

View File

@@ -307,8 +307,8 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) { func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveNearTermAt(t, "2026-05-29T05:00:00-05:00") first := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveNearTermAt(t, "2026-05-29T06:00:00-05:00") second := resolveHourlyAt(t, "2026-05-29T06:00:00-05:00")
savePriorMetadata(t, store, first, stateBriefingMetadata(first)) savePriorMetadata(t, store, first, stateBriefingMetadata(first))
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
@@ -405,7 +405,7 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
return resolved return resolved
} }
func resolveNearTermAt(t *testing.T, value string) report.Resolved { func resolveHourlyAt(t *testing.T, value string) report.Resolved {
t.Helper() t.Helper()
location, err := timeutil.LoadLocation("America/Chicago") location, err := timeutil.LoadLocation("America/Chicago")
if err != nil { if err != nil {
@@ -415,7 +415,7 @@ func resolveNearTermAt(t *testing.T, value string) report.Resolved {
if err != nil { if err != nil {
t.Fatalf("parse time: %v", err) t.Fatalf("parse time: %v", err)
} }
resolved, err := report.DefaultRegistry().Resolve(report.NearTerm, report.ResolveRequest{ resolved, err := report.DefaultRegistry().Resolve(report.Hourly, report.ResolveRequest{
Now: now, Now: now,
Location: location, Location: location,
}) })