9 Commits

Author SHA1 Message Date
370d7c0928 Update the weatherfeeder dependency to v0.12.0
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 13:34:33 +00:00
b556052966 Record outlook v2 verification
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-06-12 13:09:38 +00:00
c7760742e6 Document outlook v2 behavior 2026-06-12 13:07:17 +00:00
beeaeeaecb Cover outlook discussions in endpoint responses 2026-06-12 13:02:12 +00:00
1f2459923f Present outlook discussions in responses 2026-06-12 13:00:11 +00:00
fa1b2cb390 Filter outlook discussions with retained outlooks 2026-06-12 12:58:28 +00:00
82dd304f10 Read outlook discussions from Postgres 2026-06-12 12:56:20 +00:00
14c6954296 Update outlook dependency for v2 compile boundary 2026-06-12 12:52:05 +00:00
0135eb1153 Add roadmap and implementation plan to support the new outlook schema
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-06-12 07:47:01 -05:00
23 changed files with 1421 additions and 94 deletions

View File

@@ -230,19 +230,22 @@ GET /outlooks/convective/location
``` ```
Returns the latest SPC convective outlook run reconstructed from Returns the latest SPC convective outlook run reconstructed from
weatherfeeder-owned Postgres tables. weatherfeeder-owned `weather.outlook.v2` Postgres tables.
Route behavior: Route behavior:
- `/outlooks/convective` returns the latest run with all stored outlook - `/outlooks/convective` returns the latest run with stored location-filtered
polygons unless user filters are supplied. outlook polygons unless user filters are supplied.
- `/outlooks/convective/active` adds an active-time filter using the server's - `/outlooks/convective/active` adds an active-time filter using the server's
current UTC time. Outlooks are active when `validFrom <= now < validTo`. current UTC time. Outlooks are active when `validFrom <= now < validTo`.
- `/outlooks/convective/location` adds the same active-time filter and - `/outlooks/convective/location` adds the same active-time filter and
`containsLocation=true`. `containsLocation=true`. It remains as an active local-outlook compatibility
route under the v2 weatherfeeder contract.
When no latest run exists, `data` is null. When a run exists but filters match When no latest run exists, `data` is null. When a run exists but filters match
no outlooks, `data` remains an object and `outlooks` is an empty array. no outlooks, `data` remains an object and `outlooks` and `discussions` are
empty arrays. Outlook endpoints use latest-run semantics and do not accumulate
historical active outlooks across older runs.
Query parameters: Query parameters:
@@ -253,9 +256,12 @@ Query parameters:
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` | | `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean | | `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
`outlookType` values are normalized case-insensitively. `containsLocation` is `outlookType` values are normalized case-insensitively. Weatherfeeder v2
rejected on `/outlooks/convective/location` because that route always applies outlooks are already filtered for the configured location, so
`containsLocation=true`. `precision` and unknown parameters are rejected. `containsLocation` is expected to be true for stored v2 outlooks.
`containsLocation` is rejected on `/outlooks/convective/location` because that
route always applies `containsLocation=true`. `precision` and unknown
parameters are rejected.
Run `data` fields: Run `data` fields:
@@ -266,6 +272,7 @@ Run `data` fields:
| `asOf` | RFC3339 datetime | required when `data` is not null | | `asOf` | RFC3339 datetime | required when `data` is not null |
| `issuedAt` | RFC3339 datetime | optional | | `issuedAt` | RFC3339 datetime | optional |
| `outlooks` | array | ordered outlook polygons, possibly empty | | `outlooks` | array | ordered outlook polygons, possibly empty |
| `discussions` | array | ordered day-level discussions, possibly empty |
Outlook fields: Outlook fields:
@@ -273,17 +280,27 @@ Outlook fields:
| --- | --- | --- | | --- | --- | --- |
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present | | `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
| `day` | integer | SPC outlook day | | `day` | integer | SPC outlook day |
| `labelText`, `forecaster`, `headline`, `summary`, `discussion` | string | optional | | `labelText`, `forecaster` | string | optional |
| `severityRank` | integer | optional | | `severityRank` | integer | optional |
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present | | `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
| `sourceUrl`, `imageUrl` | string | optional | | `sourceUrl`, `imageUrl` | string | optional |
| `containsLocation` | boolean | whether the outlook polygon contains the configured location | | `containsLocation` | boolean | whether the outlook polygon contains the configured location |
| `geometry` | GeoJSON | stored outlook geometry | | `geometry` | GeoJSON | stored outlook geometry |
Discussion fields:
| Field | Type | Notes |
| --- | --- | --- |
| `day` | integer | SPC outlook day |
| `headline`, `summary`, `discussion` | string | optional |
| `updatedAt` | RFC3339 datetime | optional |
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
compares instants and is not changed by the presentation timezone. `updatedAt`. Active filtering compares instants and is not changed by the
presentation timezone. Endpoint filters also filter `discussions` to days
represented by retained outlooks.
Examples: Examples:
@@ -328,6 +345,15 @@ Example JSON response:
] ]
} }
} }
],
"discussions": [
{
"day": 1,
"headline": "Severe storms possible",
"summary": "Scattered severe storms are possible.",
"discussion": "SPC discussion text.",
"updatedAt": "2026-06-11T17:30:00Z"
}
] ]
} }
} }
@@ -354,10 +380,25 @@ Example location-filtered JSON response:
"expiresAt": "2026-06-12T12:00:00Z", "expiresAt": "2026-06-12T12:00:00Z",
"containsLocation": true, "containsLocation": true,
"geometry": { "geometry": {
"type": "Point", "type": "Polygon",
"coordinates": [-90.2, 38.6] "coordinates": [
[
[-90.6, 38.4],
[-90.0, 38.4],
[-90.0, 38.8],
[-90.6, 38.4]
]
]
} }
} }
],
"discussions": [
{
"day": 1,
"headline": "Tornado risk near the configured location",
"discussion": "SPC tornado outlook discussion text.",
"updatedAt": "2026-06-11T17:30:00Z"
}
] ]
} }
} }

View File

@@ -8,10 +8,12 @@
`go.mod` depends on: `go.mod` depends on:
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0` - `gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0`
The repository code also depends on weatherfeeder canonical model types. Table The repository code also depends on weatherfeeder canonical model types. Table
compatibility must match the SQL in `internal/adapters/outbound/postgres`. compatibility must match the SQL in `internal/adapters/outbound/postgres`.
For convective outlooks, `weatherapi` assumes weatherfeeder's
`weather.outlook.v2` table reset has already been applied.
## Boundary ## Boundary
@@ -37,7 +39,7 @@ Postgres owns persistence, backup, restore, and availability.
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` | | Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
| Weather story run | `weather_story_runs`, `weather_stories` | | Weather story run | `weather_story_runs`, `weather_stories` |
| Latest weather story | `weather_stories` | | Latest weather story | `weather_stories` |
| Convective outlook run | `outlook_runs`, `outlooks` | | Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
## Latest Row Selection ## Latest Row Selection
@@ -68,7 +70,8 @@ Child rows are loaded separately and attached in stored order:
- forecast periods: `period_index ASC`; - forecast periods: `period_index ASC`;
- forecast discussion key messages: `message_index ASC`; - forecast discussion key messages: `message_index ASC`;
- weather stories for a run: `story_index ASC`; - weather stories for a run: `story_index ASC`;
- outlooks for a run: `outlook_index ASC`. - outlooks for a run: `outlook_index ASC`;
- outlook discussions for a run: `discussion_index ASC`.
## Columns Read ## Columns Read
@@ -160,12 +163,17 @@ routes.
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`, `outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`, `label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
`expires_at`, `forecaster`, `headline`, `summary`, `discussion`, `source_url`, `expires_at`, `forecaster`, `source_url`, `image_url`, `contains_location`,
`image_url`, `contains_location`, `geometry_json`, and `run_event_id`. `geometry_json`, and `run_event_id`.
`geometry_json` is copied into response GeoJSON without parsing or `geometry_json` is copied into response GeoJSON without parsing or
reserializing. It must contain valid JSON. reserializing. It must contain valid JSON.
### `outlook_discussions`
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
and `run_event_id`.
## Nullability and Time Assumptions ## Nullability and Time Assumptions
The repository scans nullable columns with `sql.Null*` types and maps them to The repository scans nullable columns with `sql.Null*` types and maps them to

View File

@@ -144,6 +144,11 @@ application service:
The package variable `outlookNow` exists so endpoint tests can make active and The package variable `outlookNow` exists so endpoint tests can make active and
location filtering deterministic. location filtering deterministic.
The application service returns filtered outlook copies and trims run-level
discussions to days represented by retained outlooks.
`/outlooks/convective/location` is retained for compatibility and active
local-outlook behavior under the weatherfeeder outlook v2 contract.
## Failure Behavior ## Failure Behavior
Binder failures become feedapi invalid-parameter responses. Handler service Binder failures become feedapi invalid-parameter responses. Handler service

View File

@@ -90,7 +90,7 @@ successful responses with `data: null`.
`weather_stories`. `weather_stories`.
- `LatestWeatherStory`: latest individual row from `weather_stories`. - `LatestWeatherStory`: latest individual row from `weather_stories`.
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child - `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
`outlooks`. `outlooks` and `outlook_discussions`.
Latest parent rows are selected by descending weather timestamp and Latest parent rows are selected by descending weather timestamp and
`event_emitted_at` where that tie-breaker is available in the query. `event_emitted_at` where that tie-breaker is available in the query.
@@ -105,7 +105,8 @@ Child queries preserve stored order:
- forecast periods by `period_index`; - forecast periods by `period_index`;
- discussion key messages by `message_index`; - discussion key messages by `message_index`;
- weather stories by `story_index`; - weather stories by `story_index`;
- outlooks by `outlook_index`. - outlooks by `outlook_index`;
- outlook discussions by `discussion_index`.
Alert references are attached after both alert and reference rows are loaded. Alert references are attached after both alert and reference rows are loaded.
References are grouped by alert index and attached to their corresponding alert. References are grouped by alert index and attached to their corresponding alert.

View File

@@ -118,9 +118,11 @@ response envelope so renderers can produce `data: null`.
- Discussions: full or focused payload shapes, section copy, key-message copy, - Discussions: full or focused payload shapes, section copy, key-message copy,
timezone conversion. timezone conversion.
- Weather stories: run/story copy and timezone conversion. - Weather stories: run/story copy and timezone conversion.
- Convective outlooks: canonical model copy, pointer and geometry copy, and - Convective outlooks: canonical model copy, pointer and geometry copy,
timezone conversion. `units` is accepted by routes but ignored by the run-level discussion copy, and timezone conversion for run, outlook, and
presenter because outlook fields are not unit-bearing. discussion timestamps. Outlook polygon prose is not handled by the presenter;
prose is carried by run-level discussions. `units` is accepted by routes but
ignored by the presenter because outlook fields are not unit-bearing.
## Templates ## Templates

View File

@@ -91,8 +91,13 @@ build.
## Database Dependency ## Database Dependency
`weatherapi` expects weatherfeeder-compatible tables for observations, current `weatherapi` expects weatherfeeder-compatible tables for observations, current
conditions aggregation, active alerts, forecasts, forecast discussions, and conditions aggregation, active alerts, forecasts, forecast discussions, weather
weather stories. It only reads those tables. stories, and convective outlooks. It only reads those tables.
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
operators reset or recreate outlook tables during a weatherfeeder upgrade,
complete that weatherfeeder-side migration before starting `weatherapi`.
Operational ownership is split: Operational ownership is split:
@@ -113,7 +118,7 @@ template named by each endpoint, with templates stored under `templates.base_dir
The repository includes templates for all implemented endpoint families: The repository includes templates for all implemented endpoint families:
observations, current conditions, active alerts, hourly forecasts, narrative observations, current conditions, active alerts, hourly forecasts, narrative
forecasts, forecast discussions, and weather stories. forecasts, forecast discussions, weather stories, and convective outlooks.
If text rendering fails or returns an unsupported-format error, verify: If text rendering fails or returns an unsupported-format error, verify:

View File

@@ -1,14 +1,524 @@
# SPC Convective Outlook API Roadmap # Implement Weatherfeeder Outlook V2 Support
There are no active roadmap items for the SPC convective outlook API. ## Status
Current behavior is documented in: Implementation, current-behavior documentation, and automated verification are
complete. This file now serves as the checklist and verification record for the
outlook v2 compatibility update. Authoritative implemented behavior is
documented in `docs/api.md`, `docs/integrations/weatherfeeder-postgres.md`, and
`docs/internal/`.
- [`docs/api.md`](../api.md) ## Verification Record
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
- [`docs/internal/presenters.md`](../internal/presenters.md)
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
Future changes to convective outlook behavior should be proposed in a new Completed on 2026-06-12:
roadmap entry before implementation.
- `go test ./internal/app`
- `go test ./internal/adapters/outbound/postgres`
- `go test ./internal/adapters/inbound/httpapi/presenter`
- `go test ./internal/adapters/inbound/httpapi`
- `go test ./...`
- `go build ./cmd/weatherapi`
The database-backed endpoint checklist requires a reachable PostgreSQL database
populated by weatherfeeder outlook v2 tables. It was not run in this workspace
because no such populated database was available.
## Summary
Implement `weatherapi` support for the `weatherfeeder` SPC outlook v2 contract described in `docs/roadmap/outlook.md`.
This is a compatibility update for the existing convective outlook route family. Do not add or remove public routes. Update dependency, repository reads, app filtering, presenters, templates, tests, and current-behavior docs so `weatherapi` reads `weather.outlook.v2` data from the new weatherfeeder Postgres table shape.
## Required End State
- `go.mod` depends on the released `weatherfeeder` version containing `weather.outlook.v2` support.
- Existing public routes remain available:
- `GET /outlooks/convective`
- `GET /outlooks/convective/active`
- `GET /outlooks/convective/location`
- `weatherapi` remains read-only and does not create, migrate, or repair weatherfeeder tables.
- The Postgres repository reads `outlook_runs`, `outlooks`, and `outlook_discussions`.
- The repository no longer expects polygon-level `headline`, `summary`, or `discussion` columns on `outlooks`.
- Returned `WeatherOutlookRun` values include run-level `Discussions` loaded from `outlook_discussions`.
- App-level filtering trims both `Outlooks` and `Discussions` so discussions are present only for days with retained outlooks.
- Presenters copy and timezone-convert run-level discussion `updatedAt` values.
- JSON/XML/text responses expose run-level `discussions` and do not expose polygon-level outlook prose fields.
- `data: null` semantics for no latest run are preserved.
- Filtered no-match responses return a non-null run with `outlooks: []` and `discussions: []`.
- Public query behavior remains compatible: `format`, `units`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation` behavior are preserved; `precision` and unknown params remain rejected.
## Guardrails
- Do not implement weatherfeeder table creation or migration in weatherapi.
- Do not call SPC or any upstream weather provider from weatherapi.
- Do not introduce historical browsing or cross-run accumulation in this change.
- Do not remove `/outlooks/convective/location`; keep it as an API-compatible active local-outlook route.
- Do not keep stale v1 SQL reads for `outlooks.headline`, `outlooks.summary`, or `outlooks.discussion`.
- Do not commit a `replace` directive for `weatherfeeder`.
- Keep SQL in `*_queries.go`, row DTOs in `*_rows.go`, row mapping in `*_mapper.go`, and request/presentation policy in HTTP/presenter packages.
## Stage 1: Dependency And Compile Boundary
### Changes
1. Update `go.mod` to the released `gitea.maximumdirect.net/ejr/weatherfeeder` version that includes:
- `standards.SchemaWeatherOutlookV2`;
- `model.WeatherOutlookRun.Discussions`;
- `model.WeatherOutlookDiscussion`;
- `model.WeatherOutlook` without `Headline`, `Summary`, or `Discussion`.
2. Run `go mod tidy`.
3. Fix compile errors from removed polygon-level outlook prose fields.
4. Search for outlook v1 and polygon prose references:
```sh
rg "SchemaWeatherOutlookV1|weather\.outlook\.v1|\.Headline|\.Summary|\.Discussion|headline|summary|discussion" internal docs templates README.md
```
5. Treat matches carefully:
- Keep unrelated alert and forecast-discussion uses.
- Remove or update outlook polygon-level prose references.
- Replace outlook-specific schema references with v2 where applicable.
### Expected Files
- `go.mod`
- `go.sum`
- `internal/adapters/outbound/postgres/outlooks_rows.go`
- `internal/adapters/outbound/postgres/outlooks_mapper.go`
- `internal/adapters/outbound/postgres/outlooks_queries.go`
- `internal/adapters/outbound/postgres/outlooks_read.go`
- `internal/adapters/inbound/httpapi/presenter/outlook.go`
- `internal/app/service.go`
- outlook-related tests under `internal/app`, `internal/adapters/outbound/postgres`, `internal/adapters/inbound/httpapi`, and presenter tests
### Verification
At this stage, run a compile-oriented focused set. It may fail until later stages are complete, but failures should identify remaining v1/prose references.
```sh
go test ./internal/app ./internal/adapters/outbound/postgres ./internal/adapters/inbound/httpapi/presenter
```
## Stage 2: Postgres Query, Row, And Mapper Updates
### Query Changes
Update `internal/adapters/outbound/postgres/outlooks_queries.go`.
Parent query:
- Keep latest run ordering:
```sql
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1
```
- Continue selecting parent fields needed by `WeatherOutlookRun`.
- Optionally select `discussion_count` for test/sanity visibility, but do not expose it in the API response model.
Outlook child query:
- Remove these v1 columns from the `SELECT` list:
- `headline`
- `summary`
- `discussion`
- Continue selecting:
- `outlook_index`
- `outlook_id`
- `provider`
- `product`
- `day`
- `outlook_type`
- `label`
- `label_text`
- `severity_rank`
- `valid_from`
- `valid_to`
- `issued_at`
- `expires_at`
- `forecaster`
- `source_url`
- `image_url`
- `contains_location`
- `geometry_json`
- Keep `WHERE run_event_id = $1`.
- Keep `ORDER BY outlook_index ASC`.
Discussion child query:
- Add `queryOutlookDiscussionsForRun`:
```sql
SELECT
discussion_index,
day,
headline,
summary,
discussion,
updated_at
FROM outlook_discussions
WHERE run_event_id = $1
ORDER BY discussion_index ASC
```
### Row DTO Changes
Update `internal/adapters/outbound/postgres/outlooks_rows.go`:
- Remove `Headline`, `Summary`, and `Discussion` from `outlookRow`.
- Add `DiscussionCount` to `outlookRunParentRow` only if selected by parent query.
- Add `outlookDiscussionRow`:
- `DiscussionIndex int`
- `Day int`
- `Headline sql.NullString`
- `Summary sql.NullString`
- `Discussion sql.NullString`
- `UpdatedAt sql.NullTime`
### Mapper Changes
Update `internal/adapters/outbound/postgres/outlooks_mapper.go`:
- `mapOutlookRow` maps only v2 polygon fields.
- Keep geometry validation with `json.Valid` and return contextual mapper errors for invalid JSON.
- Continue copying geometry bytes with `append([]byte(nil), geometry...)`.
- Continue normalizing outlook timestamps to UTC.
- Add `mapOutlookDiscussionRow` returning `model.WeatherOutlookDiscussion`:
- `Day` from row day;
- string fields via existing `stringValue` helper;
- `UpdatedAt` via existing `timePtr` helper, ensuring UTC normalization.
### Read Flow Changes
Update `internal/adapters/outbound/postgres/outlooks_read.go`:
- `LatestConvectiveOutlookRun` loads parent row as today.
- After parent row:
- call `loadOutlooks(ctx, row.EventID)`;
- call `loadOutlookDiscussions(ctx, row.EventID)`;
- attach both to the run.
- Add `loadOutlookDiscussions` mirroring `loadOutlooks` style:
- query with context;
- scan rows;
- map rows;
- return iteration errors;
- wrap query, scan, map, and iteration errors with operation context.
- Preserve `nil, nil` on missing parent row.
### Tests
Update or add tests in `internal/adapters/outbound/postgres`:
- `mapOutlookRow` maps v2 polygon fields and no longer expects polygon-level prose.
- `mapOutlookRow` rejects invalid `geometry_json`.
- nullable label/forecaster/source/image/severity fields map correctly.
- `mapOutlookDiscussionRow` maps day/headline/summary/discussion/updatedAt and normalizes time to UTC.
- nullable discussion fields map to empty/omitted canonical values.
- latest run read loads outlooks in `outlook_index ASC` order.
- latest run read loads discussions in `discussion_index ASC` order.
- missing latest parent returns `nil, nil`.
- query/scan/iteration failures remain context-wrapped.
### Verification
```sh
go test ./internal/adapters/outbound/postgres
```
## Stage 3: Application Filtering And Copy Semantics
### Changes
Update `internal/app/service.go`.
Clone behavior:
- `cloneOutlookRun` must deep-copy `Discussions` in addition to `Outlooks`.
- Ensure outlook severity pointers, geometry bytes, latitude/longitude pointers, and issuedAt pointers remain copied.
- Add a helper such as `cloneOutlookDiscussion` if useful.
Filtering behavior:
- Filter `Outlooks` as today using `OutlookFilter`.
- After filtering outlooks, filter `Discussions` to only days represented by retained outlooks.
- Preserve discussion order from the repository result.
- If no outlooks remain, set discussions to an empty slice, not stale unfiltered discussions.
- Preserve nil/missing run behavior: repository `nil, nil` still returns `nil, nil`.
Recommended helper shape:
```go
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion
```
Rules:
- Build a set of retained outlook days.
- If the set is empty, return `[]model.WeatherOutlookDiscussion{}` when discussions was non-nil or when consistent empty-array output is desired.
- Include each discussion only if `discussion.Day` is in the set.
- Do not synthesize discussions.
### Query Compatibility
Do not change query binder behavior in this stage unless tests reveal compile fallout from the model update. Public query behavior remains:
- `day` accepted on all outlook routes.
- `outlookType` accepted on all outlook routes.
- `containsLocation` accepted on `/outlooks/convective` and `/outlooks/convective/active`.
- `containsLocation` rejected on `/outlooks/convective/location`.
- `format`, `units`, and `tz` / `TZ` accepted.
- `precision` and unknown params rejected.
### Tests
Update `internal/app/service_test.go`:
- Delegation test still asserts one repository call.
- Day filter retains matching outlooks and only matching day discussions.
- Outlook type filter retains discussions only for days with retained outlooks.
- Active filter retains discussions only for days with active retained outlooks.
- `containsLocation=false` returns an empty outlook/discussion run for v2-style test data.
- Filtered no-match result has empty `Outlooks` and empty `Discussions`.
- Mutating returned outlooks/discussions does not mutate repository-owned data.
- Geometry byte copy and pointer copy behavior remains covered.
### Verification
```sh
go test ./internal/app
```
## Stage 4: Presenter And Text Template Updates
### Presenter Changes
Update `internal/adapters/inbound/httpapi/presenter/outlook.go`:
- Include `Discussions` when constructing the presented `model.WeatherOutlookRun`.
- Convert `WeatherOutlookDiscussion.UpdatedAt` using `inLocationTimePtr`.
- Deep-copy discussions so presenter output mutation cannot affect repository/app input.
- Remove `Headline`, `Summary`, and `Discussion` assignments from `copyOutlook`.
- Continue converting:
- run `AsOf`;
- run `IssuedAt`;
- outlook `ValidFrom`;
- outlook `ValidTo`;
- outlook `IssuedAt`;
- outlook `ExpiresAt`.
- Continue preserving geometry copy behavior.
- Continue returning `nil` for nil input.
Recommended helper:
```go
func copyOutlookDiscussion(d model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion
```
### Template Changes
Update `templates/outlooks_convective.txt.tmpl`:
- Render run-level discussions from `.Data.Discussions`.
- Do not reference outlook-level `.Headline`, `.Summary`, or `.Discussion`.
- Keep no-data output when `.Data` is nil.
- Keep useful output when `.Data` exists and `.Data.Outlooks` is empty.
- Include discussion day/headline/summary/discussion fields in a readable, conditional way.
- Avoid printing zero timestamps or `<nil>` values for absent `updatedAt`.
### Presenter Tests
Update `internal/adapters/inbound/httpapi/presenter/payload_test.go`:
- `OutlookRunPayload(nil, ...)` returns nil.
- Output includes copied `Discussions`.
- Discussion `UpdatedAt` is converted to requested timezone.
- Mutating output discussions does not mutate input discussions.
- Mutating output geometry does not mutate input geometry.
- Outlook timestamps still convert to requested timezone.
- Polygon-level prose fields are not expected in output structs/tests.
### Verification
```sh
go test ./internal/adapters/inbound/httpapi/presenter
```
## Stage 5: HTTP Endpoint Behavior And Tests
### Handler Behavior
Keep route registration and handler structure unchanged unless compile updates require mechanical edits.
Ensure endpoint responses reflect the updated presenter/app behavior:
- `/outlooks/convective` returns latest run with v2 shape.
- `/outlooks/convective/active` applies active filter and trims discussions.
- `/outlooks/convective/location` applies active local filter and trims discussions.
- `data: null` remains unchanged when no run exists.
- Filtered no-match response returns data object with empty outlook and discussion arrays.
### Endpoint Tests
Update `internal/adapters/inbound/httpapi/endpoints_test.go`:
- test fakes build `model.WeatherOutlookRun` with `Discussions`.
- JSON success asserts run-level `discussions` are present.
- JSON success asserts outlook polygons do not include polygon-level prose.
- XML success renders run-level discussions without errors.
- Text success renders run-level discussion content.
- Timezone test checks conversion of:
- run `asOf`;
- run `issuedAt`;
- outlook times;
- discussion `updatedAt`.
- Null data test remains unchanged.
- Filtered no-match test expects both `outlooks` and `discussions` empty.
- Query acceptance still covers `units`, `format`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation`.
- Query rejection still covers `precision`, unknown params, invalid day, invalid outlookType, invalid containsLocation, invalid timezone, conflicting `tz` / `TZ`, and `containsLocation` on `/location`.
- Active/location route tests continue using injectable `outlookNow`.
### Verification
```sh
go test ./internal/adapters/inbound/httpapi
```
## Stage 6: Documentation Updates
After code behavior is updated, update current-behavior documentation. Do not leave v2 behavior only in roadmap files.
### Public Docs
Update `docs/api.md`:
- State outlook endpoints serve weatherfeeder `weather.outlook.v2` data.
- Run fields include `discussions`.
- Add discussion field table:
- `day`;
- `headline`;
- `summary`;
- `discussion`;
- `updatedAt`.
- Remove polygon-level `headline`, `summary`, and `discussion` from outlook fields.
- State v2 outlooks are already location-filtered by weatherfeeder.
- State `containsLocation` is expected to be true for v2 outlooks.
- Explain `/outlooks/convective/location` remains an active local-outlook compatibility route under v2.
- State endpoint filters also filter `discussions` to days represented by retained outlooks.
- State current endpoints use latest-run semantics and do not accumulate historical active outlook rows.
- Update examples to show run-level `discussions`.
- Ensure examples use valid GeoJSON polygon or multipolygon, not a point, if demonstrating SPC geometry.
Update `README.md` only if its overview implies all-polygons or old response shape.
### Integration/Internal Docs
Update `docs/integrations/weatherfeeder-postgres.md`:
- Update dependency version.
- Table family for convective outlook includes `outlook_runs`, `outlooks`, and `outlook_discussions`.
- Latest run selection remains `as_of DESC, event_emitted_at DESC`.
- Child ordering includes:
- outlooks by `outlook_index ASC`;
- outlook discussions by `discussion_index ASC`.
- `outlook_runs` includes `discussion_count` if listed.
- `outlooks` no longer includes `headline`, `summary`, or `discussion`.
- Add `outlook_discussions` columns.
- State weatherapi assumes weatherfeeder's outlook v2 table reset has already been applied.
Update `docs/internal/postgres-repository.md`:
- `LatestConvectiveOutlookRun` loads parent, outlook children, and discussion children.
- Add outlook discussion child ordering.
Update `docs/internal/presenters.md`:
- Outlook presenter copies and timezone-converts run-level discussions.
- Outlook presenter no longer handles polygon-level prose.
Update `docs/internal/http-adapter.md`:
- Outlook filters trim discussions to retained outlook days.
- `/location` is retained for compatibility and active local-outlook behavior.
Update `docs/operations.md` and `docs/troubleshooting.md` only if they mention the old weatherfeeder table shape or should warn operators that weatherapi requires weatherfeeder v2 outlook tables.
### Roadmap Docs
After implementation is complete, replace or update `docs/roadmap/outlook.md` and `docs/roadmap/implementation.md` according to repository convention:
- Either mark the roadmap complete and point to current docs.
- Or move any remaining deferred ideas to a future roadmap file.
Do not leave roadmap files claiming unimplemented work after the implementation has shipped.
### Documentation Checks
Run searches for stale outlook v1/current behavior:
```sh
rg "weather\.outlook\.v1|outlooks\.headline|outlooks\.summary|outlooks\.discussion|polygon-level|all stored outlook|all polygons|headline, summary, discussion" docs README.md internal templates
```
Allowed matches:
- explicit historical/legacy notes, if any;
- unrelated alert or forecast discussion prose fields;
- roadmap files that are intentionally historical.
## Stage 7: Full Verification
Run focused tests:
```sh
go test ./internal/app
go test ./internal/adapters/outbound/postgres
go test ./internal/adapters/inbound/httpapi/presenter
go test ./internal/adapters/inbound/httpapi
```
Run the full suite:
```sh
go test ./...
```
Optional build check:
```sh
go build ./cmd/weatherapi
```
Manual verification against a database populated by weatherfeeder outlook v2 tables:
```http
GET /outlooks/convective
GET /outlooks/convective/active
GET /outlooks/convective/location
GET /outlooks/convective?day=1
GET /outlooks/convective?outlookType=tornado
GET /outlooks/convective?containsLocation=false
GET /outlooks/convective?format=text&tz=America/Chicago
```
Expected manual results:
- Latest run includes run-level `discussions`.
- Active route filters outlooks and discussions together.
- Location route returns active local outlooks and remains valid.
- `containsLocation=false` returns an empty run for v2 data.
- Text format renders discussions without template errors.
- XML format renders without errors.
- No endpoint queries historical active rows across older runs.
## Assumptions
- A weatherfeeder release containing outlook v2 exists before final implementation is committed.
- Operators have reset/recreated weatherfeeder outlook tables according to weatherfeeder transition docs.
- Existing weatherapi route names remain stable for external consumers.
- Keeping `containsLocation` query support on non-location routes is useful backward compatibility even though v2 data should always be local.
- Latest-run semantics are correct for current convective outlook endpoints.
## Open Questions
None. This plan fixes the route, dependency, storage, filtering, presentation, and documentation decisions required to support weatherfeeder outlook v2 while preserving weatherapi's existing public route family.

View File

@@ -1,14 +1,36 @@
# SPC Convective Outlook API Roadmap # Weatherfeeder Outlook V2 Support
There are no active roadmap items for the SPC convective outlook API. ## Status
Current behavior is documented in: Implemented in current behavior docs and code. The authoritative implemented
contracts now live in:
- [`docs/api.md`](../api.md) - [`docs/api.md`](../api.md) for public outlook routes, query parameters, and
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md) response fields;
- [`docs/internal/presenters.md`](../internal/presenters.md)
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md) - [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
for weatherfeeder-owned table assumptions;
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md),
[`docs/internal/http-adapter.md`](../internal/http-adapter.md), and
[`docs/internal/presenters.md`](../internal/presenters.md) for internal
behavior.
Future changes to convective outlook behavior should be proposed in a new `docs/roadmap/implementation.md` remains as the implementation checklist and
roadmap entry before implementation. verification record for this compatibility update.
## Delivered Behavior
- Existing public routes remain available:
- `GET /outlooks/convective`
- `GET /outlooks/convective/active`
- `GET /outlooks/convective/location`
- The Postgres repository reads weatherfeeder outlook v2 tables:
`outlook_runs`, `outlooks`, and `outlook_discussions`.
- Outlook polygons no longer include prose fields.
- Run-level `discussions` are loaded, copied, presented, and timezone-converted.
- Application filtering trims `discussions` to days represented by retained
outlooks.
- Missing latest data still returns `data: null`.
- Filtered no-match responses return a run object with `outlooks: []` and
`discussions: []`.
- `weatherapi` remains read-only and does not create, migrate, or repair
weatherfeeder tables.

View File

@@ -109,7 +109,7 @@ Likely causes:
- unknown query parameter; - unknown query parameter;
- `precision` outside `0` through `2`, or not an integer; - `precision` outside `0` through `2`, or not an integer;
- `precision` used on alerts, discussions, or weather stories; - `precision` used on alerts, discussions, weather stories, or outlooks;
- `tz` used on observations, current conditions, or alerts; - `tz` used on observations, current conditions, or alerts;
- invalid timezone value; - invalid timezone value;
- both `tz` and `TZ` are present with different values. - both `tz` and `TZ` are present with different values.
@@ -187,6 +187,10 @@ Safe fix: verify weatherfeeder is running and writing to the same database that
`weatherapi` uses as its first configured database. For current conditions, `weatherapi` uses as its first configured database. For current conditions,
wait for recent observations or inspect weatherfeeder ingestion. wait for recent observations or inspect weatherfeeder ingestion.
For convective outlooks, also verify that weatherfeeder has applied its
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
`outlook_discussions`.
## Forecast Day Routes Return Empty `periods` ## Forecast Day Routes Return Empty `periods`
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`, Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.5
require ( require (
gitea.maximumdirect.net/ejr/feedapi v0.1.0 gitea.maximumdirect.net/ejr/feedapi v0.1.0
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0 gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
) )

4
go.sum
View File

@@ -1,7 +1,7 @@
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY= gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw= gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0 h1:4hRiktLMFS8Arl98j21YYskXT8RHHQGFDPJxOF3CpwU= gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw= gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -1314,10 +1314,9 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
var payload struct { var payload struct {
Data struct { Data struct {
LocationID string `json:"locationId"` LocationID string `json:"locationId"`
Outlooks []struct { Outlooks []map[string]any `json:"outlooks"`
ID string `json:"id"` Discussions []outlookDiscussionCheck `json:"discussions"`
} `json:"outlooks"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
@@ -1326,9 +1325,20 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
if payload.Data.LocationID != "stl" { if payload.Data.LocationID != "stl" {
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID) t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
} }
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" { if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0]["id"] != "cat-1" {
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks) t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
} }
for _, field := range []string{"headline", "summary", "discussion"} {
if _, ok := payload.Data.Outlooks[0][field]; ok {
t.Fatalf("expected outlook polygon to omit %s, got %+v", field, payload.Data.Outlooks[0])
}
}
if len(payload.Data.Discussions) != 1 {
t.Fatalf("expected one discussion, got %+v", payload.Data.Discussions)
}
if payload.Data.Discussions[0].Day != 1 || payload.Data.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("unexpected discussions payload: %+v", payload.Data.Discussions)
}
}) })
} }
} }
@@ -1369,7 +1379,8 @@ func TestOutlookTextResponseUsesTemplate(t *testing.T) {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
} }
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") { if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") ||
!strings.Contains(body, "Discussions: 1") || !strings.Contains(body, "Day 1 discussion") {
t.Fatalf("expected outlook text template body, got %q", body) t.Fatalf("expected outlook text template body, got %q", body)
} }
} }
@@ -1390,11 +1401,15 @@ func TestOutlookXMLResponseRenders(t *testing.T) {
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") { if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
t.Fatalf("expected outlook XML payload, got %q", w.Body.String()) t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
} }
if !strings.Contains(w.Body.String(), "<Discussions>") || !strings.Contains(w.Body.String(), "<Headline>Day 1 headline</Headline>") {
t.Fatalf("expected outlook XML discussions, got %q", w.Body.String())
}
} }
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) { func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
run := testOutlookRun() run := testOutlookRun()
run.Outlooks = []model.WeatherOutlook{} run.Outlooks = []model.WeatherOutlook{}
run.Discussions = []model.WeatherOutlookDiscussion{}
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective") h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1407,8 +1422,9 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
var payload struct { var payload struct {
Data struct { Data struct {
LocationID string `json:"locationId"` LocationID string `json:"locationId"`
Outlooks []model.WeatherOutlook `json:"outlooks"` Outlooks []model.WeatherOutlook `json:"outlooks"`
Discussions []model.WeatherOutlookDiscussion `json:"discussions"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
@@ -1420,6 +1436,39 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 { if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks) t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
} }
if payload.Data.Discussions == nil || len(payload.Data.Discussions) != 0 {
t.Fatalf("expected empty discussions slice, got %+v", payload.Data.Discussions)
}
}
func TestOutlookTimezoneQuery(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?tz=CDT", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload outlookTimePayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode outlook payload: %v", err)
}
assertOffsetSeconds(t, payload.Data.AsOf, -5*60*60)
assertOffsetSeconds(t, *payload.Data.IssuedAt, -5*60*60)
if len(payload.Data.Outlooks) != 1 {
t.Fatalf("expected one outlook, got %+v", payload.Data.Outlooks)
}
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidFrom, -5*60*60)
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidTo, -5*60*60)
assertOffsetSeconds(t, payload.Data.Outlooks[0].IssuedAt, -5*60*60)
assertOffsetSeconds(t, payload.Data.Outlooks[0].ExpiresAt, -5*60*60)
if len(payload.Data.Discussions) != 1 || payload.Data.Discussions[0].UpdatedAt == nil {
t.Fatalf("expected one discussion with updatedAt, got %+v", payload.Data.Discussions)
}
assertOffsetSeconds(t, *payload.Data.Discussions[0].UpdatedAt, -5*60*60)
} }
func TestOutlookQueryParamsConstructFilter(t *testing.T) { func TestOutlookQueryParamsConstructFilter(t *testing.T) {
@@ -2211,7 +2260,7 @@ func testRenderers(t *testing.T) *render.Registry {
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term", "discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
"forecast_hourly.txt.tmpl": "Forecast text", "forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast", "forecast_narrative.txt.tmpl": "Narrative Forecast",
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}", "outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
"weatherstories.txt.tmpl": "Weather Stories", "weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story", "weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "Alerts text", "alerts_active.txt.tmpl": "Alerts text",
@@ -2250,6 +2299,7 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
func testOutlookRun() *model.WeatherOutlookRun { func testOutlookRun() *model.WeatherOutlookRun {
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
return &model.WeatherOutlookRun{ return &model.WeatherOutlookRun{
LocationID: "stl", LocationID: "stl",
LocationName: "St. Louis", LocationName: "St. Louis",
@@ -2270,6 +2320,13 @@ func testOutlookRun() *model.WeatherOutlookRun {
ContainsLocation: true, ContainsLocation: true,
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`), Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
}}, }},
Discussions: []model.WeatherOutlookDiscussion{{
Day: 1,
Headline: "Day 1 headline",
Summary: "Day 1 summary",
Discussion: "Day 1 discussion",
UpdatedAt: &discussionUpdatedAt,
}},
} }
} }
@@ -2343,6 +2400,27 @@ type discussionFocusedTimePayload struct {
} `json:"data"` } `json:"data"`
} }
type outlookDiscussionCheck struct {
Day int `json:"day"`
Headline string `json:"headline"`
}
type outlookTimePayload struct {
Data struct {
AsOf time.Time `json:"asOf"`
IssuedAt *time.Time `json:"issuedAt"`
Outlooks []struct {
ValidFrom time.Time `json:"validFrom"`
ValidTo time.Time `json:"validTo"`
IssuedAt time.Time `json:"issuedAt"`
ExpiresAt time.Time `json:"expiresAt"`
} `json:"outlooks"`
Discussions []struct {
UpdatedAt *time.Time `json:"updatedAt"`
} `json:"discussions"`
} `json:"data"`
}
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload { func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
t.Helper() t.Helper()

View File

@@ -22,10 +22,14 @@ func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location)
AsOf: inLocationTime(run.AsOf, tz), AsOf: inLocationTime(run.AsOf, tz),
IssuedAt: inLocationTimePtr(run.IssuedAt, tz), IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)), Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
Discussions: make([]model.WeatherOutlookDiscussion, 0, len(run.Discussions)),
} }
for _, outlook := range run.Outlooks { for _, outlook := range run.Outlooks {
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz)) out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
} }
for _, discussion := range run.Discussions {
out.Discussions = append(out.Discussions, copyOutlookDiscussion(discussion, tz))
}
return &out return &out
} }
@@ -44,9 +48,6 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
IssuedAt: inLocationTime(outlook.IssuedAt, tz), IssuedAt: inLocationTime(outlook.IssuedAt, tz),
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz), ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
Forecaster: outlook.Forecaster, Forecaster: outlook.Forecaster,
Headline: outlook.Headline,
Summary: outlook.Summary,
Discussion: outlook.Discussion,
SourceURL: outlook.SourceURL, SourceURL: outlook.SourceURL,
ImageURL: outlook.ImageURL, ImageURL: outlook.ImageURL,
ContainsLocation: outlook.ContainsLocation, ContainsLocation: outlook.ContainsLocation,
@@ -57,6 +58,16 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
return out return out
} }
func copyOutlookDiscussion(discussion model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion {
return model.WeatherOutlookDiscussion{
Day: discussion.Day,
Headline: discussion.Headline,
Summary: discussion.Summary,
Discussion: discussion.Discussion,
UpdatedAt: inLocationTimePtr(discussion.UpdatedAt, tz),
}
}
func copyIntPtr(v *int) *int { func copyIntPtr(v *int) *int {
if v == nil { if v == nil {
return nil return nil

View File

@@ -440,6 +440,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60) loc := time.FixedZone("UTC-05:00", -5*60*60)
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC) asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
issuedAt := asOf.Add(-1 * time.Hour) issuedAt := asOf.Add(-1 * time.Hour)
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
severityRank := 5 severityRank := 5
latitude := 38.627123 latitude := 38.627123
longitude := -90.199456 longitude := -90.199456
@@ -465,14 +466,18 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
IssuedAt: issuedAt, IssuedAt: issuedAt,
ExpiresAt: asOf.Add(6 * time.Hour), ExpiresAt: asOf.Add(6 * time.Hour),
Forecaster: "DIAL", Forecaster: "DIAL",
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
SourceURL: "https://example.test/source", SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png", ImageURL: "https://example.test/image.png",
ContainsLocation: true, ContainsLocation: true,
Geometry: geometry, Geometry: geometry,
}}, }},
Discussions: []model.WeatherOutlookDiscussion{{
Day: 1,
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
UpdatedAt: &discussionUpdatedAt,
}},
} }
payload := OutlookRunPayload(run, UnitsUS, loc) payload := OutlookRunPayload(run, UnitsUS, loc)
@@ -489,9 +494,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
if len(out.Outlooks) != 1 { if len(out.Outlooks) != 1 {
t.Fatalf("expected one outlook, got %d", len(out.Outlooks)) t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
} }
if len(out.Discussions) != 1 {
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
}
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank { if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
t.Fatalf("expected severity rank pointer to be copied") t.Fatalf("expected severity rank pointer to be copied")
} }
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
t.Fatalf("expected discussion updatedAt pointer to be copied")
}
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] { if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
t.Fatalf("expected geometry bytes to be copied") t.Fatalf("expected geometry bytes to be copied")
} }
@@ -502,6 +513,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60) assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60) assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60) assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -5*60*60)
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) { if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
t.Fatalf("expected timezone conversion to preserve instants") t.Fatalf("expected timezone conversion to preserve instants")
} }
@@ -515,12 +527,19 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
if string(out.Outlooks[0].Geometry) != string(geometry) { if string(out.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry) t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
} }
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
out.Discussions[0].Discussion != "Discussion text." {
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
}
*out.Latitude = 99 *out.Latitude = 99
*out.Longitude = -99 *out.Longitude = -99
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) *out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
*out.Outlooks[0].SeverityRank = 99 *out.Outlooks[0].SeverityRank = 99
*out.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
out.Outlooks[0].Geometry[0] = '[' out.Outlooks[0].Geometry[0] = '['
out.Discussions[0].Headline = "changed"
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) { if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
t.Fatalf("expected source run pointers not to mutate") t.Fatalf("expected source run pointers not to mutate")
} }
@@ -530,8 +549,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
if string(run.Outlooks[0].Geometry) != string(geometry) { if string(run.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry) t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
} }
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
}
if run.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
}
assertOffsetSeconds(t, run.AsOf, 0) assertOffsetSeconds(t, run.AsOf, 0)
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0) assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
} }
func float64Ptr(v float64) *float64 { func float64Ptr(v float64) *float64 {

View File

@@ -41,9 +41,6 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
IssuedAt: row.IssuedAt.UTC(), IssuedAt: row.IssuedAt.UTC(),
ExpiresAt: row.ExpiresAt.UTC(), ExpiresAt: row.ExpiresAt.UTC(),
Forecaster: stringValue(row.Forecaster), Forecaster: stringValue(row.Forecaster),
Headline: stringValue(row.Headline),
Summary: stringValue(row.Summary),
Discussion: stringValue(row.Discussion),
SourceURL: stringValue(row.SourceURL), SourceURL: stringValue(row.SourceURL),
ImageURL: stringValue(row.ImageURL), ImageURL: stringValue(row.ImageURL),
ContainsLocation: row.ContainsLocation, ContainsLocation: row.ContainsLocation,
@@ -51,6 +48,16 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
}, nil }, nil
} }
func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion {
return model.WeatherOutlookDiscussion{
Day: row.Day,
Headline: stringValue(row.Headline),
Summary: stringValue(row.Summary),
Discussion: stringValue(row.Discussion),
UpdatedAt: timePtr(row.UpdatedAt),
}
}
func intPtr(v sql.NullInt64) *int { func intPtr(v sql.NullInt64) *int {
if !v.Valid { if !v.Valid {
return nil return nil

View File

@@ -79,9 +79,6 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
IssuedAt: issuedAt, IssuedAt: issuedAt,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
Forecaster: sql.NullString{String: "DIAL", Valid: true}, Forecaster: sql.NullString{String: "DIAL", Valid: true},
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true}, SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true}, ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
ContainsLocation: true, ContainsLocation: true,
@@ -103,8 +100,8 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) { if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank) t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
} }
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" { if outlook.Forecaster != "DIAL" {
t.Fatalf("unexpected text fields: %+v", outlook) t.Fatalf("unexpected forecaster: %+v", outlook)
} }
if outlook.SourceURL == "" || outlook.ImageURL == "" { if outlook.SourceURL == "" || outlook.ImageURL == "" {
t.Fatalf("expected source and image URLs: %+v", outlook) t.Fatalf("expected source and image URLs: %+v", outlook)
@@ -142,8 +139,7 @@ func TestMapOutlookRowMissingOptionals(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" || if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook) t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
} }
if outlook.SeverityRank != nil { if outlook.SeverityRank != nil {
@@ -172,3 +168,49 @@ func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
t.Fatal("expected invalid geometry error") t.Fatal("expected invalid geometry error")
} }
} }
func TestMapOutlookDiscussionRowMapsFields(t *testing.T) {
updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600))
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
DiscussionIndex: 2,
Day: 2,
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
})
if discussion.Day != 2 {
t.Fatalf("expected day 2, got %d", discussion.Day)
}
if discussion.Headline != "Severe storms possible" {
t.Fatalf("unexpected headline: %q", discussion.Headline)
}
if discussion.Summary != "Scattered severe storms are possible." {
t.Fatalf("unexpected summary: %q", discussion.Summary)
}
if discussion.Discussion != "Discussion text." {
t.Fatalf("unexpected discussion: %q", discussion.Discussion)
}
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt)
}
}
func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) {
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
DiscussionIndex: 1,
Day: 1,
})
if discussion.Day != 1 {
t.Fatalf("expected day 1, got %d", discussion.Day)
}
if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" {
t.Fatalf("expected empty optional strings, got %+v", discussion)
}
if discussion.UpdatedAt != nil {
t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt)
}
}

View File

@@ -32,9 +32,6 @@ SELECT
issued_at, issued_at,
expires_at, expires_at,
forecaster, forecaster,
headline,
summary,
discussion,
source_url, source_url,
image_url, image_url,
contains_location, contains_location,
@@ -42,4 +39,16 @@ SELECT
FROM outlooks FROM outlooks
WHERE run_event_id = $1 WHERE run_event_id = $1
ORDER BY outlook_index ASC` ORDER BY outlook_index ASC`
queryOutlookDiscussionsForRun = `
SELECT
discussion_index,
day,
headline,
summary,
discussion,
updated_at
FROM outlook_discussions
WHERE run_event_id = $1
ORDER BY discussion_index ASC`
) )

View File

@@ -40,6 +40,12 @@ func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.Wea
} }
run.Outlooks = outlooks run.Outlooks = outlooks
discussions, err := r.loadOutlookDiscussions(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Discussions = discussions
return &run, nil return &run, nil
} }
@@ -68,9 +74,6 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
&row.IssuedAt, &row.IssuedAt,
&row.ExpiresAt, &row.ExpiresAt,
&row.Forecaster, &row.Forecaster,
&row.Headline,
&row.Summary,
&row.Discussion,
&row.SourceURL, &row.SourceURL,
&row.ImageURL, &row.ImageURL,
&row.ContainsLocation, &row.ContainsLocation,
@@ -90,3 +93,32 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
} }
return out, nil return out, nil
} }
func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) {
rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID)
if err != nil {
return nil, fmt.Errorf("query outlook discussions: %w", err)
}
defer rows.Close()
out := make([]model.WeatherOutlookDiscussion, 0)
for rows.Next() {
var row outlookDiscussionRow
if err := rows.Scan(
&row.DiscussionIndex,
&row.Day,
&row.Headline,
&row.Summary,
&row.Discussion,
&row.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan outlook discussion row: %w", err)
}
out = append(out, mapOutlookDiscussionRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate outlook discussion rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,405 @@
// outlooks_read_test.go validates outlook repository read flow.
// Layer: adapters/outbound/postgres outlook read tests.
package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
)
const outlookTestDriverName = "weatherapi_outlook_read_test"
func init() {
sql.Register(outlookTestDriverName, outlookTestDriver{})
}
func TestLatestConvectiveOutlookRunLoadsOutlooksAndDiscussions(t *testing.T) {
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
issuedAt := asOf.Add(-1 * time.Hour)
discussionUpdated := asOf.Add(-30 * time.Minute)
repo, closeDB := openOutlookTestRepository(t,
outlookParentQuery([][]driver.Value{{
"evt-outlook-run",
"stl",
"St. Louis",
float64(38.62),
float64(-90.2),
asOf,
issuedAt,
}}),
outlookRowsQuery([][]driver.Value{
outlookReadRow(1, "day-1", 1, "categorical"),
outlookReadRow(2, "day-2", 2, "wind"),
}, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), int64(1), "Day 1 headline", "Day 1 summary", "Day 1 discussion", discussionUpdated},
{int64(2), int64(2), "Day 2 headline", nil, "Day 2 discussion", nil},
}, nil),
)
defer closeDB()
run, err := repo.LatestConvectiveOutlookRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil {
t.Fatal("expected outlook run")
}
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
t.Fatalf("unexpected run metadata: %+v", run)
}
if len(run.Outlooks) != 2 {
t.Fatalf("expected 2 outlooks, got %d", len(run.Outlooks))
}
if run.Outlooks[0].ID != "day-1" || run.Outlooks[1].ID != "day-2" {
t.Fatalf("expected outlook order from rows, got %+v", run.Outlooks)
}
if len(run.Discussions) != 2 {
t.Fatalf("expected 2 discussions, got %d", len(run.Discussions))
}
if run.Discussions[0].Day != 1 || run.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("unexpected first discussion: %+v", run.Discussions[0])
}
if run.Discussions[1].Day != 2 || run.Discussions[1].Summary != "" {
t.Fatalf("unexpected second discussion: %+v", run.Discussions[1])
}
if run.Discussions[0].UpdatedAt == nil || run.Discussions[0].UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected discussion updatedAt UTC pointer, got %v", run.Discussions[0].UpdatedAt)
}
if run.Discussions[1].UpdatedAt != nil {
t.Fatalf("expected nil discussion updatedAt, got %v", run.Discussions[1].UpdatedAt)
}
assertOutlookTestQueriesConsumed(t)
}
func TestLatestConvectiveOutlookRunMissingParentReturnsNil(t *testing.T) {
repo, closeDB := openOutlookTestRepository(t, outlookParentQuery(nil))
defer closeDB()
run, err := repo.LatestConvectiveOutlookRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run != nil {
t.Fatalf("expected nil run, got %+v", run)
}
assertOutlookTestQueriesConsumed(t)
}
func TestLatestConvectiveOutlookRunWrapsReadErrors(t *testing.T) {
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
tests := []struct {
name string
queries []scriptedOutlookQuery
want string
}{
{
name: "parent query",
queries: []scriptedOutlookQuery{
{name: "parent", query: queryLatestConvectiveOutlookRun, err: errors.New("parent unavailable")},
},
want: "query latest convective outlook run",
},
{
name: "outlooks query",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
{name: "outlooks", query: queryOutlooksForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("outlooks unavailable")},
},
want: "query outlooks",
},
{
name: "outlook scan",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
{int64(1), "day-1"},
}, nil),
},
want: "scan outlook row",
},
{
name: "outlook map",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
outlookReadRowWithGeometry(1, "day-1", 1, "categorical", `{"type":"Point"`),
}, nil),
},
want: "map outlook row",
},
{
name: "outlook iteration",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
outlookReadRow(1, "day-1", 1, "categorical"),
}, errors.New("outlook iteration failed")),
},
want: "iterate outlook rows",
},
{
name: "discussions query",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
{name: "discussions", query: queryOutlookDiscussionsForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("discussions unavailable")},
},
want: "query outlook discussions",
},
{
name: "discussion scan",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), "bad day", nil, nil, nil, nil},
}, nil),
},
want: "scan outlook discussion row",
},
{
name: "discussion iteration",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), int64(1), nil, nil, nil, nil},
}, errors.New("discussion iteration failed")),
},
want: "iterate outlook discussion rows",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo, closeDB := openOutlookTestRepository(t, tt.queries...)
defer closeDB()
_, err := repo.LatestConvectiveOutlookRun(context.Background())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected error containing %q, got %v", tt.want, err)
}
})
}
}
func openOutlookTestRepository(t *testing.T, queries ...scriptedOutlookQuery) (*Repository, func()) {
t.Helper()
outlookTestScript.set(queries)
db, err := sql.Open(outlookTestDriverName, "")
if err != nil {
t.Fatalf("open test db: %v", err)
}
db.SetMaxOpenConns(1)
return NewRepository(db), func() {
_ = db.Close()
outlookTestScript.set(nil)
}
}
func assertOutlookTestQueriesConsumed(t *testing.T) {
t.Helper()
if remaining := outlookTestScript.remaining(); remaining != 0 {
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
}
}
func outlookParentQuery(rows [][]driver.Value) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "parent",
query: queryLatestConvectiveOutlookRun,
columns: []string{"event_id", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at"},
rows: rows,
}
}
func outlookRowsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "outlooks",
query: queryOutlooksForRun,
args: []driver.Value{"evt-outlook-run"},
columns: []string{"outlook_index", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "source_url", "image_url", "contains_location", "geometry_json"},
rows: rows,
nextErr: nextErr,
}
}
func outlookDiscussionsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "discussions",
query: queryOutlookDiscussionsForRun,
args: []driver.Value{"evt-outlook-run"},
columns: []string{"discussion_index", "day", "headline", "summary", "discussion", "updated_at"},
rows: rows,
nextErr: nextErr,
}
}
func outlookReadRow(index int64, id string, day int64, outlookType string) []driver.Value {
return outlookReadRowWithGeometry(index, id, day, outlookType, `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`)
}
func outlookReadRowWithGeometry(index int64, id string, day int64, outlookType string, geometry string) []driver.Value {
validFrom := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
validTo := validFrom.Add(6 * time.Hour)
return []driver.Value{
index,
id,
"spc",
"convective",
day,
outlookType,
"SLGT",
"Slight Risk",
int64(5),
validFrom,
validTo,
validFrom.Add(-1 * time.Hour),
validTo,
"DIAL",
"https://example.test/source",
"https://example.test/image.png",
true,
geometry,
}
}
type outlookTestDriver struct{}
func (outlookTestDriver) Open(string) (driver.Conn, error) {
return outlookTestConn{}, nil
}
type outlookTestConn struct{}
func (outlookTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (outlookTestConn) Close() error {
return nil
}
func (outlookTestConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported")
}
func (outlookTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
return outlookTestScript.next(query, args)
}
type scriptedOutlookQuery struct {
name string
query string
args []driver.Value
columns []string
rows [][]driver.Value
err error
nextErr error
}
type outlookTestScriptState struct {
mu sync.Mutex
queries []scriptedOutlookQuery
}
var outlookTestScript outlookTestScriptState
func (s *outlookTestScriptState) set(queries []scriptedOutlookQuery) {
s.mu.Lock()
defer s.mu.Unlock()
s.queries = append([]scriptedOutlookQuery(nil), queries...)
}
func (s *outlookTestScriptState) remaining() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.queries)
}
func (s *outlookTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.queries) == 0 {
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
}
next := s.queries[0]
s.queries = s.queries[1:]
if compactSQL(query) != compactSQL(next.query) {
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
}
if len(args) != len(next.args) {
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
}
for i, arg := range args {
if arg.Value != next.args[i] {
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
}
}
if next.err != nil {
return nil, next.err
}
return &outlookTestRows{
columns: append([]string(nil), next.columns...),
rows: append([][]driver.Value(nil), next.rows...),
nextErr: next.nextErr,
}, nil
}
type outlookTestRows struct {
columns []string
rows [][]driver.Value
index int
nextErr error
}
func (r *outlookTestRows) Columns() []string {
return r.columns
}
func (r *outlookTestRows) Close() error {
return nil
}
func (r *outlookTestRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
if r.nextErr != nil {
err := r.nextErr
r.nextErr = nil
return err
}
return io.EOF
}
copy(dest, r.rows[r.index])
r.index++
return nil
}
func compactSQL(query string) string {
return strings.Join(strings.Fields(query), " ")
}
func namedValues(args []driver.NamedValue) []driver.Value {
out := make([]driver.Value, len(args))
for i := range args {
out[i] = args[i].Value
}
return out
}

View File

@@ -32,11 +32,17 @@ type outlookRow struct {
IssuedAt time.Time IssuedAt time.Time
ExpiresAt time.Time ExpiresAt time.Time
Forecaster sql.NullString Forecaster sql.NullString
Headline sql.NullString
Summary sql.NullString
Discussion sql.NullString
SourceURL sql.NullString SourceURL sql.NullString
ImageURL sql.NullString ImageURL sql.NullString
ContainsLocation bool ContainsLocation bool
GeometryJSON string GeometryJSON string
} }
type outlookDiscussionRow struct {
DiscussionIndex int
Day int
Headline sql.NullString
Summary sql.NullString
Discussion sql.NullString
UpdatedAt sql.NullTime
}

View File

@@ -85,6 +85,7 @@ func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFil
} }
} }
out.Outlooks = outlooks out.Outlooks = outlooks
out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks)
return out, nil return out, nil
} }
@@ -123,6 +124,12 @@ func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
out.Outlooks[i] = cloneOutlook(run.Outlooks[i]) out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
} }
} }
if run.Discussions != nil {
out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions))
for i := range run.Discussions {
out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i])
}
}
return &out return &out
} }
@@ -135,6 +142,31 @@ func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook {
return out return out
} }
func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion {
out := discussion
out.UpdatedAt = copyTime(discussion.UpdatedAt)
return out
}
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion {
if len(outlooks) == 0 {
return []model.WeatherOutlookDiscussion{}
}
retainedDays := make(map[int]struct{}, len(outlooks))
for _, outlook := range outlooks {
retainedDays[outlook.Day] = struct{}{}
}
out := discussions[:0]
for _, discussion := range discussions {
if _, ok := retainedDays[discussion.Day]; ok {
out = append(out, discussion)
}
}
return out
}
func copyFloat64(value *float64) *float64 { func copyFloat64(value *float64) *float64 {
if value == nil { if value == nil {
return nil return nil

View File

@@ -5,6 +5,7 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"strconv"
"testing" "testing"
"time" "time"
@@ -131,6 +132,7 @@ func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
if run == nil || run.LocationID != "stl" { if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected outlook run: %+v", run) t.Fatalf("unexpected outlook run: %+v", run)
} }
assertDiscussionDays(t, run, []int{1, 2})
} }
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) { func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
@@ -208,6 +210,7 @@ func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
assertOutlookIDs(t, run, []string{"day-2"}) assertOutlookIDs(t, run, []string{"day-2"})
assertDiscussionDays(t, run, []int{2})
} }
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) { func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
@@ -219,6 +222,7 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
assertOutlookIDs(t, run, []string{"tor-1"}) assertOutlookIDs(t, run, []string{"tor-1"})
assertDiscussionDays(t, run, []int{1})
} }
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) { func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
@@ -230,7 +234,21 @@ func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"}) assertOutlookIDs(t, run, []string{"cat-1", "tor-1", "day-2"})
assertDiscussionDays(t, run, []int{1, 2})
}
func TestServiceLatestConvectiveOutlookContainsLocationFalseReturnsEmptyRun(t *testing.T) {
containsLocation := false
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ContainsLocation: &containsLocation})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, nil)
assertDiscussionDays(t, run, nil)
} }
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) { func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
@@ -243,6 +261,7 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"}) assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
assertDiscussionDays(t, run, []int{1})
} }
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) { func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
@@ -262,6 +281,7 @@ func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
assertOutlookIDs(t, run, []string{"cat-1"}) assertOutlookIDs(t, run, []string{"cat-1"})
assertDiscussionDays(t, run, []int{1})
} }
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) { func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
@@ -277,12 +297,14 @@ func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
t.Fatalf("unexpected validFrom error: %v", err) t.Fatalf("unexpected validFrom error: %v", err)
} }
assertOutlookIDs(t, fromRun, []string{"cat-1"}) assertOutlookIDs(t, fromRun, []string{"cat-1"})
assertDiscussionDays(t, fromRun, []int{1})
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo}) toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
if err != nil { if err != nil {
t.Fatalf("unexpected validTo error: %v", err) t.Fatalf("unexpected validTo error: %v", err)
} }
assertOutlookIDs(t, toRun, nil) assertOutlookIDs(t, toRun, nil)
assertDiscussionDays(t, toRun, nil)
} }
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) { func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
@@ -306,6 +328,12 @@ func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *test
if len(run.Outlooks) != 0 { if len(run.Outlooks) != 0 {
t.Fatalf("expected no outlooks, got %+v", run.Outlooks) t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
} }
if run.Discussions == nil {
t.Fatal("expected empty discussions slice, got nil")
}
if len(run.Discussions) != 0 {
t.Fatalf("expected no discussions, got %+v", run.Discussions)
}
} }
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) { func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
@@ -325,9 +353,12 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
*run.Longitude = -99 *run.Longitude = -99
*run.IssuedAt = testTime(99) *run.IssuedAt = testTime(99)
*run.Outlooks[0].SeverityRank = 99 *run.Outlooks[0].SeverityRank = 99
*run.Discussions[0].UpdatedAt = testTime(98)
run.Outlooks[0].Geometry[0] = '{' run.Outlooks[0].Geometry[0] = '{'
run.Outlooks[0].ID = "changed" run.Outlooks[0].ID = "changed"
run.Discussions[0].Headline = "changed"
run.Outlooks = run.Outlooks[:1] run.Outlooks = run.Outlooks[:1]
run.Discussions = run.Discussions[:1]
if *original.Latitude != 38.62 { if *original.Latitude != 38.62 {
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude) t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
@@ -350,6 +381,15 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
if len(original.Outlooks) != 3 { if len(original.Outlooks) != 3 {
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks)) t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
} }
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
}
if original.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
}
if len(original.Discussions) != 3 {
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
}
} }
func TestServicePropagatesErrors(t *testing.T) { func TestServicePropagatesErrors(t *testing.T) {
@@ -376,8 +416,25 @@ func testOutlookRun() *model.WeatherOutlookRun {
Outlooks: []model.WeatherOutlook{ Outlooks: []model.WeatherOutlook{
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`), testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`), testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
testOutlook("day-2", 2, "wind", false, testTime(18), testTime(24), 2, `["wind"]`), testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
}, },
Discussions: []model.WeatherOutlookDiscussion{
testOutlookDiscussion(1),
testOutlookDiscussion(2),
testOutlookDiscussion(3),
},
}
}
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
updatedAt := testTime(9 + day)
dayText := strconv.Itoa(day)
return model.WeatherOutlookDiscussion{
Day: day,
Headline: "Day " + dayText + " headline",
Summary: "Day " + dayText + " summary",
Discussion: "Day " + dayText + " discussion",
UpdatedAt: &updatedAt,
} }
} }
@@ -396,9 +453,6 @@ func testOutlook(id string, day int, outlookType string, containsLocation bool,
IssuedAt: validFrom.Add(-time.Hour), IssuedAt: validFrom.Add(-time.Hour),
ExpiresAt: validTo, ExpiresAt: validTo,
Forecaster: "DIAL", Forecaster: "DIAL",
Headline: id + " headline",
Summary: id + " summary",
Discussion: id + " discussion",
SourceURL: "https://example.test/" + id, SourceURL: "https://example.test/" + id,
ImageURL: "https://example.test/" + id + ".png", ImageURL: "https://example.test/" + id + ".png",
ContainsLocation: containsLocation, ContainsLocation: containsLocation,
@@ -424,3 +478,18 @@ func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string)
} }
} }
} }
func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int) {
t.Helper()
if run == nil {
t.Fatal("expected outlook run")
}
if len(run.Discussions) != len(want) {
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
}
for i := range want {
if run.Discussions[i].Day != want[i] {
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
}
}
}

View File

@@ -15,16 +15,28 @@ Contains Location: {{$outlook.ContainsLocation}}
{{- if $outlook.LabelText}} {{- if $outlook.LabelText}}
Label Text: {{$outlook.LabelText}} Label Text: {{$outlook.LabelText}}
{{- end}} {{- end}}
{{- if $outlook.Headline}}
Headline: {{$outlook.Headline}}
{{- end}}
{{- if $outlook.Summary}}
Summary: {{$outlook.Summary}}
{{- end}}
{{- if $outlook.SourceURL}} {{- if $outlook.SourceURL}}
Source URL: {{$outlook.SourceURL}} Source URL: {{$outlook.SourceURL}}
{{- end}} {{- end}}
{{- end}} {{- end}}
Discussions: {{len .Data.Discussions}}
{{- range $i, $discussion := .Data.Discussions}}
[{{$i}}] Day {{$discussion.Day}} Discussion
{{- if $discussion.UpdatedAt}}
Updated At: {{$discussion.UpdatedAt}}
{{- end}}
{{- if $discussion.Headline}}
Headline: {{$discussion.Headline}}
{{- end}}
{{- if $discussion.Summary}}
Summary: {{$discussion.Summary}}
{{- end}}
{{- if $discussion.Discussion}}
Discussion: {{$discussion.Discussion}}
{{- end}}
{{- end}}
{{- else -}} {{- else -}}
No convective outlook data available. No convective outlook data available.
{{- end}} {{- end}}