Compare commits
8 Commits
v0.5.1
...
8578db99b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 8578db99b3 | |||
| 86fd848a6f | |||
| 6f8e01729d | |||
| 3c7ba89e15 | |||
| cfe6748330 | |||
| 5a1134b955 | |||
| 2a33fe01cf | |||
| f4dd701204 |
@@ -26,14 +26,11 @@ database credentials through your normal secret-management process.
|
|||||||
- Hourly and narrative forecasts
|
- Hourly and narrative forecasts
|
||||||
- Forecast discussions
|
- Forecast discussions
|
||||||
- Weather stories
|
- Weather stories
|
||||||
- Convective outlooks:
|
- Convective outlooks
|
||||||
- `GET /outlooks/convective`
|
|
||||||
- `GET /outlooks/convective/active`
|
|
||||||
- `GET /outlooks/convective/location`
|
|
||||||
|
|
||||||
Common query parameters include `format`, `units`, and route-specific options
|
Common query parameters include `format`, `units`, and route-specific options
|
||||||
such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters
|
such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters
|
||||||
`day`, `outlookType`, and `containsLocation`.
|
`day` and `outlookType`.
|
||||||
|
|
||||||
See [`docs/api.md`](docs/api.md) for the HTTP contract.
|
See [`docs/api.md`](docs/api.md) for the HTTP contract.
|
||||||
|
|
||||||
|
|||||||
87
docs/api.md
87
docs/api.md
@@ -162,8 +162,10 @@ GET /observations?units=us&precision=1
|
|||||||
GET /conditions/current
|
GET /conditions/current
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns current conditions aggregated from recent `observations` rows. The
|
Returns current conditions from recent `observations` rows. Numeric fields are
|
||||||
implemented observation window is 30 minutes.
|
aggregated over the implemented 30-minute observation window. `conditionCode`
|
||||||
|
is selected from the latest observation per source in that window by
|
||||||
|
source-balanced WMO family consensus.
|
||||||
|
|
||||||
Query parameters: `format`, `units`, `precision`.
|
Query parameters: `format`, `units`, `precision`.
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ Common `data` fields:
|
|||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `conditionCode` | integer | WMO weather code selected by source-balanced family consensus |
|
||||||
| `conditionText` | string | optional text derived from WMO code and day/night flag |
|
| `conditionText` | string | optional text derived from WMO code and day/night flag |
|
||||||
| `isDay` | boolean | optional |
|
| `isDay` | boolean | optional |
|
||||||
| `relativeHumidityPercent` | number | optional |
|
| `relativeHumidityPercent` | number | optional |
|
||||||
@@ -194,7 +197,8 @@ GET /conditions/current?format=json&precision=0
|
|||||||
GET /alerts/active
|
GET /alerts/active
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest stored alert run filtered to alerts active at request time.
|
Returns the latest stored alert run filtered to alerts active at request time,
|
||||||
|
omitting older alerts superseded by newer alert references in the same run.
|
||||||
|
|
||||||
Query parameters: `format`, `units`.
|
Query parameters: `format`, `units`.
|
||||||
|
|
||||||
@@ -212,14 +216,20 @@ Run `data` fields:
|
|||||||
| `alerts` | array | active alerts, possibly empty |
|
| `alerts` | array | active alerts, possibly empty |
|
||||||
|
|
||||||
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
|
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
|
||||||
at or before request time, and `expires` is absent or after request time.
|
at or before request time, and the alert end boundary is absent or after request
|
||||||
|
time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as
|
||||||
|
a fallback for older rows or providers that do not supply an alert-period end.
|
||||||
`onset` is presented when available but is not used as the active boundary.
|
`onset` is presented when available but is not used as the active boundary.
|
||||||
|
After active-time filtering, alerts referenced by another alert in the same run
|
||||||
|
are omitted as superseded. References from update and cancel messages are both
|
||||||
|
honored, even when the referencing alert is not itself returned.
|
||||||
|
|
||||||
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
|
||||||
`senderName`, and `references`. Most alert fields are optional except `id` when
|
`areaDescription`, `senderName`, and `references`. Most alert fields are
|
||||||
an alert item is present.
|
optional except `id` when an alert item is present. `ends` is the alert-period
|
||||||
|
end; `expires` is provider expiration metadata.
|
||||||
|
|
||||||
Reference fields are `id`, `identifier`, `sender`, and `sent`.
|
Reference fields are `id`, `identifier`, `sender`, and `sent`.
|
||||||
|
|
||||||
@@ -234,7 +244,6 @@ GET /alerts/active?format=text
|
|||||||
```http
|
```http
|
||||||
GET /outlooks/convective
|
GET /outlooks/convective
|
||||||
GET /outlooks/convective/active
|
GET /outlooks/convective/active
|
||||||
GET /outlooks/convective/location
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest SPC convective outlook run reconstructed from
|
Returns the latest SPC convective outlook run reconstructed from
|
||||||
@@ -246,9 +255,6 @@ Route behavior:
|
|||||||
outlook 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
|
|
||||||
`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` and `discussions` are
|
no outlooks, `data` remains an object and `outlooks` and `discussions` are
|
||||||
@@ -262,14 +268,10 @@ Query parameters:
|
|||||||
| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above |
|
| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above |
|
||||||
| `day` | all outlook routes | `1`, `2`, or `3` |
|
| `day` | all outlook routes | `1`, `2`, or `3` |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
||||||
outlooks are already filtered for the configured location, so
|
outlooks are already filtered for the configured location. `precision`,
|
||||||
`containsLocation` is expected to be true for stored v2 outlooks.
|
`containsLocation`, and unknown parameters are rejected.
|
||||||
`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:
|
||||||
|
|
||||||
@@ -314,7 +316,7 @@ Examples:
|
|||||||
|
|
||||||
```http
|
```http
|
||||||
GET /outlooks/convective?day=1&outlookType=categorical
|
GET /outlooks/convective?day=1&outlookType=categorical
|
||||||
GET /outlooks/convective/location?format=text&tz=CDT
|
GET /outlooks/convective/active?format=text&tz=CDT
|
||||||
```
|
```
|
||||||
|
|
||||||
Example JSON response:
|
Example JSON response:
|
||||||
@@ -367,53 +369,8 @@ Example JSON response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example location-filtered JSON response:
|
Text format uses the shared convective outlook template for both outlook routes
|
||||||
|
and renders a no-data message when `data` is null.
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"locationId": "stl",
|
|
||||||
"asOf": "2026-06-11T18:00:00Z",
|
|
||||||
"outlooks": [
|
|
||||||
{
|
|
||||||
"id": "spc-day1-tor-2pct",
|
|
||||||
"provider": "spc",
|
|
||||||
"product": "convective",
|
|
||||||
"day": 1,
|
|
||||||
"outlookType": "tornado",
|
|
||||||
"label": "2%",
|
|
||||||
"validFrom": "2026-06-11T18:00:00Z",
|
|
||||||
"validTo": "2026-06-12T12:00:00Z",
|
|
||||||
"issuedAt": "2026-06-11T17:00:00Z",
|
|
||||||
"expiresAt": "2026-06-12T12:00:00Z",
|
|
||||||
"containsLocation": true,
|
|
||||||
"geometry": {
|
|
||||||
"type": "Polygon",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Text format uses the shared convective outlook template for all three outlook
|
|
||||||
routes and renders a no-data message when `data` is null.
|
|
||||||
|
|
||||||
### Forecasts
|
### Forecasts
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ Latest parent resources use these ordering rules:
|
|||||||
story_order ASC, story_index ASC`.
|
story_order ASC, story_index ASC`.
|
||||||
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
|
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
|
||||||
|
|
||||||
Current conditions aggregate `observations` rows where `observed_at` is inside
|
Current conditions aggregate numeric values from `observations` rows where
|
||||||
the application-provided observation window.
|
`observed_at` is inside the application-provided observation window. They also
|
||||||
|
use the latest row per `event_source` in that window to select `condition_code`
|
||||||
|
by source-balanced WMO family consensus.
|
||||||
|
|
||||||
## Child Ordering
|
## Child Ordering
|
||||||
|
|
||||||
@@ -79,15 +81,16 @@ The repository reads only these columns.
|
|||||||
|
|
||||||
### `observations`
|
### `observations`
|
||||||
|
|
||||||
`event_id`, `station_id`, `station_name`, `observed_at`, `condition_code`,
|
`event_id`, `event_source`, `station_id`, `station_name`, `observed_at`,
|
||||||
`is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
|
`condition_code`, `is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
|
||||||
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
|
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
|
||||||
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
|
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
|
||||||
`apparent_temperature_c`, and `event_emitted_at`.
|
`apparent_temperature_c`, and `event_emitted_at`.
|
||||||
|
|
||||||
Current conditions additionally aggregate recent `observations` values for
|
Current conditions additionally read recent `observations` values for
|
||||||
temperature, apparent temperature, dewpoint, humidity, wind speed, wind
|
temperature, apparent temperature, dewpoint, humidity, wind speed, wind
|
||||||
direction, condition code, and latest `is_day`.
|
direction, latest `is_day`, and latest condition-code candidates per
|
||||||
|
`event_source`.
|
||||||
|
|
||||||
### `observation_present_weather`
|
### `observation_present_weather`
|
||||||
|
|
||||||
@@ -105,8 +108,8 @@ and `event_emitted_at`.
|
|||||||
|
|
||||||
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
|
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
|
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `area_description`,
|
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
|
||||||
`sender_name`, and `run_event_id`.
|
`area_description`, `sender_name`, and `run_event_id`.
|
||||||
|
|
||||||
### `alert_references`
|
### `alert_references`
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This document describes the internal HTTP adapter under
|
|||||||
|
|
||||||
The HTTP adapter turns feedapi route definitions into calls on the application
|
The HTTP adapter turns feedapi route definitions into calls on the application
|
||||||
service boundary. It owns route registration, query binding, request validation,
|
service boundary. It owns route registration, query binding, request validation,
|
||||||
forecast day-slice filtering, outlook active/location filter construction,
|
forecast day-slice filtering, outlook active filter construction,
|
||||||
alert active-time selection, response envelopes, and template names.
|
alert active-time selection, response envelopes, and template names.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
@@ -36,7 +36,7 @@ The adapter may:
|
|||||||
- choose the presenter function for an endpoint;
|
- choose the presenter function for an endpoint;
|
||||||
- filter forecast copies for `/today` and `/tomorrow`;
|
- filter forecast copies for `/today` and `/tomorrow`;
|
||||||
- pass the current UTC instant to active-alert application filtering;
|
- pass the current UTC instant to active-alert application filtering;
|
||||||
- construct outlook active/location filters.
|
- construct outlook active filters.
|
||||||
|
|
||||||
The adapter must not:
|
The adapter must not:
|
||||||
|
|
||||||
@@ -99,10 +99,8 @@ common query values are lowercased and trimmed before binding where applicable.
|
|||||||
available only through binders used by forecast, discussion, weather story, and
|
available only through binders used by forecast, discussion, weather story, and
|
||||||
outlook routes.
|
outlook routes.
|
||||||
|
|
||||||
Outlook routes accept `day`, `outlookType`, and, except for
|
Outlook routes accept `day` and `outlookType`. `containsLocation` is a response
|
||||||
`/outlooks/convective/location`, `containsLocation`. The location route always
|
field only and is rejected as a request parameter.
|
||||||
adds `containsLocation=true` after binding and rejects an explicit
|
|
||||||
`containsLocation` query value.
|
|
||||||
|
|
||||||
## Timezone Parsing
|
## Timezone Parsing
|
||||||
|
|
||||||
@@ -136,7 +134,12 @@ behavior deterministic.
|
|||||||
|
|
||||||
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
|
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
|
||||||
the application service with `alertNow().UTC()` so active alert filtering uses
|
the application service with `alertNow().UTC()` so active alert filtering uses
|
||||||
the request-time instant while remaining deterministic in endpoint tests.
|
the request-time instant while remaining deterministic in endpoint tests. The
|
||||||
|
application service prefers alert `ends` over `expires` when deciding whether an
|
||||||
|
alert has ended. The application service also suppresses alerts referenced by
|
||||||
|
another alert in the same latest run. This supersession rule uses alert
|
||||||
|
references from update and cancel messages, even when the referencing message is
|
||||||
|
not returned by `/alerts/active`.
|
||||||
|
|
||||||
## Outlook Filters
|
## Outlook Filters
|
||||||
|
|
||||||
@@ -144,17 +147,13 @@ Outlook route filters are built at the HTTP boundary and passed to the
|
|||||||
application service:
|
application service:
|
||||||
|
|
||||||
- `/outlooks/convective` uses only user-supplied filters;
|
- `/outlooks/convective` uses only user-supplied filters;
|
||||||
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`;
|
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`.
|
||||||
- `/outlooks/convective/location` adds the same active timestamp and
|
|
||||||
`ContainsLocation=true`.
|
|
||||||
|
|
||||||
The package variable `outlookNow` exists so endpoint tests can make active and
|
The package variable `outlookNow` exists so endpoint tests can make active
|
||||||
location filtering deterministic.
|
filtering deterministic.
|
||||||
|
|
||||||
The application service returns filtered outlook copies and trims run-level
|
The application service returns filtered outlook copies and trims run-level
|
||||||
discussions to days represented by retained outlooks.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ successful responses with `data: null`.
|
|||||||
|
|
||||||
- `LatestObservation`: latest row from `observations`, then present-weather
|
- `LatestObservation`: latest row from `observations`, then present-weather
|
||||||
rows from `observation_present_weather`.
|
rows from `observation_present_weather`.
|
||||||
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
- `CurrentConditions`: reads an aggregate row from recent `observations` using
|
||||||
application-provided observation window.
|
the application-provided observation window, then reads the latest
|
||||||
|
condition-code candidate per `event_source` in the same window.
|
||||||
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
||||||
`alert_references`. This is the latest stored alert snapshot; active-time
|
`alert_references`. This is the latest stored alert snapshot. The repository
|
||||||
filtering is performed by the application service.
|
maps both `ends` and `expires`; active-time filtering is performed by the
|
||||||
|
application service.
|
||||||
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
||||||
then child `forecast_periods`.
|
then child `forecast_periods`.
|
||||||
- `LatestNarrativeForecast`: latest `forecasts` row where
|
- `LatestNarrativeForecast`: latest `forecasts` row where
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ Outputs are HTTP responses in JSON, XML, or text format. All public endpoint han
|
|||||||
|
|
||||||
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
|
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
|
||||||
|
|
||||||
The API currently serves latest-resource views: latest observation, current conditions, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Active alert, forecast `today` and `tomorrow`, and convective outlook active/location routes derive filtered copies from the latest stored run.
|
The API currently serves latest-resource views: latest observation, current conditions, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Active alert, forecast `today` and `tomorrow`, and convective outlook active routes derive filtered copies from the latest stored run.
|
||||||
|
|
||||||
## Configuration and CLI Boundaries
|
## Configuration and CLI Boundaries
|
||||||
|
|
||||||
|
|||||||
56
docs/roadmap/current.md
Normal file
56
docs/roadmap/current.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Current Conditions Condition-Code Selection
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Improve `/conditions/current` so numeric conditions continue to aggregate from recent observations, but `conditionCode` is selected by source-balanced WMO family consensus instead of numeric maximum. The goal is to prevent a single bad high WMO code, such as an erroneous thunderstorm code, from dominating current conditions while still returning a useful code when providers report semantically similar conditions.
|
||||||
|
|
||||||
|
## Target Behavior
|
||||||
|
|
||||||
|
Current conditions continue to use the application observation window, currently `app.ObservationWindowMinutesDefault`.
|
||||||
|
|
||||||
|
Numeric and directional fields remain aggregate values over recent `observations` rows:
|
||||||
|
|
||||||
|
- temperature, apparent temperature, dewpoint, relative humidity, and wind speed use averages;
|
||||||
|
- wind direction uses circular averaging;
|
||||||
|
- `isDay` comes from the latest row in the window.
|
||||||
|
|
||||||
|
`conditionCode` uses source-balanced consensus:
|
||||||
|
|
||||||
|
1. Select the latest observation per `event_source` within the current window.
|
||||||
|
2. Each source contributes at most one WMO condition-code vote.
|
||||||
|
3. Map each voted WMO code to a semantic family.
|
||||||
|
4. Select the family with the highest source vote count.
|
||||||
|
5. If the family vote is tied, return `model.WMOUnknown`.
|
||||||
|
6. Within the winning family, select the most frequent exact WMO code.
|
||||||
|
7. If exact-code vote is tied within the winning family, select the first code by family-specific representative ranking.
|
||||||
|
8. If there are no recognized condition-code votes, return `model.WMOUnknown`.
|
||||||
|
|
||||||
|
Family mapping and tie ranking:
|
||||||
|
|
||||||
|
| Family | Codes / tie ranking |
|
||||||
|
| --- | --- |
|
||||||
|
| `clear_or_cloud` | `0`, `1`, `2`, `3` |
|
||||||
|
| `fog` | `45`, `48` |
|
||||||
|
| `drizzle` | `51`, `53`, `55`, `56`, `57` |
|
||||||
|
| `rain` | `61`, `63`, `65`, `80`, `81`, `82`, `66`, `67` |
|
||||||
|
| `snow` | `71`, `73`, `75`, `85`, `86`, `77` |
|
||||||
|
| `thunderstorm` | `95`, `96`, `99` |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Source votes | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `0`, `1`, `2` | `0` |
|
||||||
|
| `1`, `2`, `95` | `1` |
|
||||||
|
| `0`, `95` | `model.WMOUnknown` |
|
||||||
|
| `61`, `63`, `80` | `61` |
|
||||||
|
| `61`, `95`, `0` | `model.WMOUnknown` |
|
||||||
|
| only `95` | `95` |
|
||||||
|
|
||||||
|
## Policy Decisions
|
||||||
|
|
||||||
|
- No public response schema change is required.
|
||||||
|
- No weatherfeeder change or database migration is required because `observations.event_source`, `condition_code`, `observed_at`, and `event_emitted_at` already exist.
|
||||||
|
- Provider-specific blacklists, trust weights, and source priorities are intentionally out of scope for this change.
|
||||||
|
- `WMOUnknown` is preferable to falsely choosing between tied precipitation, thunderstorm, and clear/cloud families.
|
||||||
|
- Current conditions remain a latest-window read model, not a durable derived table.
|
||||||
@@ -1,247 +1,110 @@
|
|||||||
# Active Alert Filtering Cleanup
|
# Implement Source-Balanced Current Conditions Condition Codes
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Implementation, current-behavior documentation, and automated verification are
|
|
||||||
complete.
|
|
||||||
|
|
||||||
## Verification Record
|
|
||||||
|
|
||||||
Completed on 2026-06-12:
|
|
||||||
|
|
||||||
- `go test ./internal/app`
|
|
||||||
- `go test ./internal/adapters/inbound/httpapi`
|
|
||||||
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
|
||||||
- `go test ./...`
|
|
||||||
|
|
||||||
Deployment smoke checks were not run from this workspace because the local
|
|
||||||
repository state is not a deployed `weatherapi` instance.
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
Implement the target behavior in `docs/roadmap/current.md`: keep `/conditions/current` response shape unchanged, continue aggregating numeric observations over the current window, and replace SQL `MAX(condition_code)` with Go-based source-balanced WMO family consensus.
|
||||||
|
|
||||||
The current implementation reads the latest `alert_runs` parent row, loads all child `alerts`, and presents the run unchanged. This can expose expired alerts when the latest persisted weatherfeeder snapshot is stale or when a snapshot contains alerts that later expire before the next successful ingestion cycle.
|
This is a behavior change only. Do not add public fields, change query parameters, alter weatherfeeder tables, or add provider-specific blacklists.
|
||||||
|
|
||||||
This cleanup preserves the existing route, response envelope, repository contract, and weatherfeeder table ownership. The change should be implemented as app-layer filtering over the latest stored snapshot, following the same architectural pattern already used by convective outlook active filtering.
|
## Stage 1: Add Condition-Code Consensus Helpers
|
||||||
|
|
||||||
## Current Behavior To Correct
|
Add package-local helpers in `internal/adapters/outbound/postgres` for current-conditions condition-code selection.
|
||||||
|
|
||||||
- `GET /alerts/active` calls `LatestAlertRun(ctx)`.
|
Required data shape:
|
||||||
- `LatestAlertRun(ctx)` returns the latest stored alert snapshot from Postgres.
|
|
||||||
- All child alerts for that run are returned unchanged.
|
|
||||||
- The endpoint does not compare `effective`, `onset`, `expires`, `status`, or `messageType` to the request time.
|
|
||||||
- Expired alerts can therefore appear under an endpoint named `/alerts/active`.
|
|
||||||
|
|
||||||
## Target Behavior
|
- define a small candidate row/type containing `event_source` and `condition_code`;
|
||||||
|
- the selector accepts latest-per-source candidates and returns `model.WMOCode`.
|
||||||
|
|
||||||
- `GET /alerts/active` returns the latest stored alert run with `alerts` filtered to items active at request time.
|
Required selector behavior:
|
||||||
- Missing latest alert run still returns `{ "data": null }`.
|
|
||||||
- A latest run with no currently active alerts returns a non-null run object with `alerts: []`.
|
|
||||||
- The repository remains a latest-snapshot reader and does not own active-time policy.
|
|
||||||
- The app service owns alert active filtering policy.
|
|
||||||
- The HTTP adapter supplies the request-time instant using an injectable clock for tests.
|
|
||||||
- Presentation remains responsible only for response shape, units no-op behavior, and rendering.
|
|
||||||
|
|
||||||
## Active Alert Policy
|
- use the family mapping and tie ranking from `docs/roadmap/current.md` exactly;
|
||||||
|
- ignore unrecognized WMO codes for family voting;
|
||||||
|
- return `model.WMOUnknown` when no recognized candidates exist;
|
||||||
|
- count each source at most once;
|
||||||
|
- return `model.WMOUnknown` on tied family votes;
|
||||||
|
- inside the winning family, choose the most frequent exact code;
|
||||||
|
- when exact codes tie inside the winning family, choose by family ranking.
|
||||||
|
|
||||||
Use a single app-layer predicate for determining whether an alert is active at an instant `activeAt`.
|
Add focused unit tests for the selector before wiring SQL changes.
|
||||||
|
|
||||||
An alert is active when all of the following are true:
|
## Stage 2: Split Current-Conditions Condition-Code Querying
|
||||||
|
|
||||||
- `messageType` is not `Cancel`, case-insensitive after trimming.
|
Update the Postgres current-conditions read path so condition-code selection is no longer computed with `MAX(condition_code)`.
|
||||||
- `effective` is absent or `effective <= activeAt`.
|
|
||||||
- `expires` is absent or `activeAt < expires`.
|
|
||||||
|
|
||||||
Additional policy notes:
|
Required SQL behavior:
|
||||||
|
|
||||||
- Do not use `onset` as a required active boundary. `onset` can describe hazard onset and may be later than alert effective time; using it as a hard lower bound could hide valid watches, warnings, or advisories that are already in effect from an alerting perspective.
|
- keep the existing aggregate query for sample count, numeric averages, circular wind direction, and latest `is_day`;
|
||||||
- Do not filter by `status` in the first cleanup unless existing model/test data proves a specific non-active status must be excluded. NWS active feeds commonly use `Actual`; persisted historical snapshots may include other values, but time and cancellation policy are the high-confidence active criteria.
|
- remove `MAX(condition_code)` from the aggregate query result;
|
||||||
- Treat nil `expires` as active if the other criteria pass. This preserves data when an upstream alert omits an expiration, while still allowing future tightening if real data shows nil expiration should be suppressed.
|
- add a separate query that returns one latest condition-code candidate per `event_source` inside the same observation window;
|
||||||
- Preserve input alert order after filtering.
|
- latest per source is ordered by `observed_at DESC, event_emitted_at DESC`;
|
||||||
- Preserve run metadata such as `asOf`, location fields, latitude, and longitude even when all alerts are filtered out.
|
- candidate columns should include `event_source` and `condition_code`; timestamp columns may remain SQL-only if used only for ordering.
|
||||||
|
|
||||||
## Public API Impact
|
Required repository flow:
|
||||||
|
|
||||||
- Route remains `GET /alerts/active`.
|
1. query aggregate current conditions;
|
||||||
- Supported query parameters remain `format` and `units`.
|
2. return `nil, nil` when the aggregate sample count maps to no data, preserving current behavior;
|
||||||
- `precision`, `tz` / `TZ`, and unknown query parameters remain rejected unless a separate roadmap explicitly changes alert query support.
|
3. query condition-code candidates using the same observation window;
|
||||||
- JSON/XML/text format support remains unchanged.
|
4. run the Go selector;
|
||||||
- Response envelope remains `{ "data": ... }`.
|
5. map numeric aggregate fields plus selected condition code into `app.CurrentConditions`.
|
||||||
- No Postgres schema or weatherfeeder migration is required.
|
|
||||||
|
|
||||||
## Stage 1: App Service Filtering
|
Keep row DTOs and mappers local to the Postgres adapter. Do not move SQL or row types into `internal/app`.
|
||||||
|
|
||||||
### Changes
|
## Stage 3: Preserve API And Presentation Behavior
|
||||||
|
|
||||||
- Keep `app.Repository.LatestAlertRun(ctx)` unchanged.
|
Keep all existing `/conditions/current` API behavior except condition-code selection.
|
||||||
- Add an app-level alert read use case, either:
|
|
||||||
- `LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)`, or
|
|
||||||
- `LatestAlerts(ctx context.Context, filter AlertFilter) (*model.WeatherAlertRun, error)` with `AlertFilter.ActiveAt`.
|
|
||||||
- Prefer the first option unless another alert route is being added at the same time; it is narrower and clearer for the existing API.
|
|
||||||
- Implement the new service method by:
|
|
||||||
- reading `s.repo.LatestAlertRun(ctx)`;
|
|
||||||
- returning `nil, nil` when no run exists;
|
|
||||||
- cloning the run before mutation;
|
|
||||||
- filtering cloned `Alerts` with the active predicate;
|
|
||||||
- returning the cloned run.
|
|
||||||
- Add package-local helpers for:
|
|
||||||
- cloning `WeatherAlertRun`;
|
|
||||||
- cloning `WeatherAlert` values deeply enough to avoid mutating repository-owned slices;
|
|
||||||
- copying `References` slices;
|
|
||||||
- evaluating `isActiveAlert(alert, activeAt)`.
|
|
||||||
|
|
||||||
### Clone Requirements
|
Required invariants:
|
||||||
|
|
||||||
The clone must protect repository-returned data from service mutation:
|
- `conditionCode` remains present in JSON/XML/text responses through the existing presenter path;
|
||||||
|
- `conditionText` continues to derive from selected `conditionCode` and `isDay`;
|
||||||
|
- `format`, `units`, and `precision` behavior is unchanged;
|
||||||
|
- no `tz` support is added;
|
||||||
|
- no public response fields are added or removed.
|
||||||
|
|
||||||
- allocate a new `Alerts` slice;
|
Update existing endpoint or presenter tests only if expected condition codes need to change because of the new selector.
|
||||||
- allocate new `References` slices for each alert;
|
|
||||||
- copy pointer time fields only if the model uses pointer fields for alert timestamps;
|
|
||||||
- preserve string, boolean, enum, and scalar values exactly.
|
|
||||||
|
|
||||||
### Tests
|
## Stage 4: Update Current-Behavior Documentation
|
||||||
|
|
||||||
Add app tests covering:
|
After implementation is complete, update current-behavior docs outside roadmap:
|
||||||
|
|
||||||
- repository delegation and error propagation;
|
- `docs/api.md`: current conditions aggregate numeric fields and choose `conditionCode` by source-balanced WMO family consensus;
|
||||||
- `nil, nil` when the repository has no latest alert run;
|
- `docs/internal/postgres-repository.md`: current conditions use an aggregate row plus a latest-per-source condition-code candidate query;
|
||||||
- expired alerts are removed when `activeAt >= expires`;
|
- `docs/integrations/weatherfeeder-postgres.md`: current conditions use `observations.event_source` for source-balanced condition-code selection.
|
||||||
- alerts expiring exactly at `activeAt` are inactive;
|
|
||||||
- alerts effective exactly at `activeAt` are active;
|
|
||||||
- future-effective alerts are inactive;
|
|
||||||
- missing `effective` does not make an otherwise valid alert inactive;
|
|
||||||
- missing `expires` does not make an otherwise valid alert inactive;
|
|
||||||
- `messageType=Cancel` is excluded case-insensitively;
|
|
||||||
- alert order is preserved;
|
|
||||||
- run metadata is preserved when all alerts are filtered out;
|
|
||||||
- filtering does not mutate the repository-owned run, alerts, references, or timestamp pointers.
|
|
||||||
|
|
||||||
### Verification
|
Do not document any unimplemented options such as provider blacklists, trust weights, or source priorities.
|
||||||
|
|
||||||
```sh
|
## Test Plan
|
||||||
go test ./internal/app
|
|
||||||
```
|
Selector tests:
|
||||||
|
|
||||||
## Stage 2: HTTP Adapter Wiring
|
- `0`, `1`, `2` returns `0` by clear/cloud ranking;
|
||||||
|
- `1`, `2`, `95` returns `1` because clear/cloud family wins;
|
||||||
### Changes
|
- `0`, `95` returns `model.WMOUnknown` because families tie;
|
||||||
|
- `61`, `63`, `80` returns `61` by rain ranking;
|
||||||
- Extend `internal/adapters/inbound/httpapi.Service` with the app service method selected in Stage 1.
|
- `61`, `95`, `0` returns `model.WMOUnknown` because three families tie;
|
||||||
- Add adapter-local clock injection near `alerts_endpoint.go`:
|
- only `95` returns `95`;
|
||||||
|
- unrecognized-only candidates return `model.WMOUnknown`;
|
||||||
```go
|
- duplicate candidates from the same source do not produce multiple votes if the selector receives them.
|
||||||
var alertNow = time.Now
|
|
||||||
```
|
Repository tests:
|
||||||
|
|
||||||
- Update `/alerts/active` handler to call the new active-alert service method with `alertNow().UTC()`.
|
- current conditions no longer chooses the highest numeric condition code;
|
||||||
- Keep `bindQuery` as the binder so public query support remains `format` and `units` only.
|
- latest condition-code candidate per source uses `observed_at DESC, event_emitted_at DESC`;
|
||||||
- Keep the text template name `alerts_active.txt.tmpl`.
|
- aggregate no-sample behavior still returns nil;
|
||||||
- Do not add timezone support in this cleanup. Alert timestamp presentation currently returns canonical model timestamps; timezone support would be a separate public API expansion.
|
- numeric aggregate fields, wind direction, and `isDay` mapping remain unchanged;
|
||||||
|
- SQL/query errors from the candidate query include operation context.
|
||||||
### Tests
|
|
||||||
|
Verification commands:
|
||||||
Update HTTP tests covering:
|
|
||||||
|
|
||||||
- route registration remains unchanged;
|
|
||||||
- handler passes `alertNow().UTC()` to the service;
|
|
||||||
- JSON success response renders filtered service output;
|
|
||||||
- text output renders zero active alerts as `Alerts: 0` when data is non-null;
|
|
||||||
- `data: null` still renders no-data behavior;
|
|
||||||
- `units=us` remains accepted and has no schema effect;
|
|
||||||
- `precision`, `tz`, `TZ`, and unknown query params still return `400`.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/adapters/inbound/httpapi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 3: Presenter And Template Check
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Keep `presenter.AlertsPayload` as a pass-through unless filtering requires defensive copy behavior at presentation time.
|
|
||||||
- Do not move active filtering into the presenter.
|
|
||||||
- Review `templates/alerts_active.txt.tmpl` and ensure it behaves correctly when `Data` is non-null and `Alerts` is empty.
|
|
||||||
- If needed, add an explicit text fixture/assertion rather than changing template wording broadly.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Add or update presenter/template-sensitive tests only if current endpoint tests do not prove:
|
|
||||||
|
|
||||||
- nil data renders no-data text;
|
|
||||||
- non-null data with empty alerts renders an active-alert run with `Alerts: 0`;
|
|
||||||
- expired/canceled alerts do not appear in text output after service filtering.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
go test ./internal/adapters/outbound/postgres ./internal/app
|
||||||
go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 4: Documentation Updates
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
Update current-behavior docs after implementation is complete:
|
|
||||||
|
|
||||||
- `docs/api.md`:
|
|
||||||
- clarify that `/alerts/active` returns the latest alert run filtered to alerts active at request time;
|
|
||||||
- document that no current active alerts returns a run with `alerts: []` when a latest run exists;
|
|
||||||
- keep `data: null` limited to no stored alert run;
|
|
||||||
- keep supported query params as `format` and `units`.
|
|
||||||
- `README.md` if endpoint summaries mention active alerts.
|
|
||||||
- `docs/internal/postgres-repository.md`:
|
|
||||||
- clarify that `LatestAlertRun` reads the latest stored snapshot and active filtering is performed in the app service.
|
|
||||||
- `docs/policy/architecture.md` only if its latest-resource wording needs to distinguish latest snapshots from request-time derived active views.
|
|
||||||
|
|
||||||
Do not document this unimplemented behavior outside `docs/roadmap/` before the code change lands.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
No doc-specific tests are required unless existing docs consistency tests cover endpoint summaries.
|
|
||||||
|
|
||||||
## Stage 5: Final Verification
|
|
||||||
|
|
||||||
Run focused tests:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/app ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the full suite:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual smoke checks after deployment:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=json'
|
|
||||||
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=text'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected behavior when all alerts in the latest stored run are expired:
|
|
||||||
|
|
||||||
- JSON returns a non-null `data` object with `alerts: []`.
|
|
||||||
- Text output shows the run metadata and `Alerts: 0`.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not change weatherfeeder ingestion behavior in this cleanup.
|
|
||||||
- Do not alter Postgres tables or SQL filtering unless a later performance issue justifies it.
|
|
||||||
- Do not make `weatherapi` poll NWS or any upstream provider.
|
|
||||||
- Do not rename `/alerts/active`.
|
|
||||||
- Do not add alert history endpoints in this cleanup.
|
|
||||||
- Do not introduce generic filtering frameworks; use small app-local helpers.
|
|
||||||
- Do not move route query validation into the app layer.
|
|
||||||
- Do not move active filtering into presenters or text templates.
|
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|
||||||
- `weatherfeeder` persists alert snapshots that were active at ingestion time, but `weatherapi` must still enforce request-time active semantics.
|
- `observations.event_source` is non-null in the weatherfeeder-owned schema and is safe to use as the source identity.
|
||||||
- Cancellation messages are operational metadata, not active hazards, and should not be returned by `/alerts/active`.
|
- The latest-per-source query can be implemented with existing Postgres features and does not require a migration.
|
||||||
- Missing `expires` should not suppress an alert unless future real-world data shows this creates stale records.
|
- Current conditions remain repository-owned because they are a Postgres aggregate read model; no new app service method or public interface is needed.
|
||||||
- Time comparisons should use UTC instants; timezone presentation is not part of this cleanup.
|
- The implementation should remain standard-library and SQL based; do not add dependencies.
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ GET {{baseUrl}}/alerts/active?format=text
|
|||||||
GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical
|
GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
### Active convective outlooks for the configured location as text
|
### Active convective outlooks as text
|
||||||
GET {{baseUrl}}/outlooks/convective/location?format=text&tz=CDT
|
GET {{baseUrl}}/outlooks/convective/active?format=text&tz=CDT
|
||||||
|
|
||||||
### Hourly forecast in US units
|
### Hourly forecast in US units
|
||||||
GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago
|
GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -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.12.0
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -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.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1 h1:dZYDpOd0vEIk6QljsrhFyrOY0Lt4WyEoazhqDFIZKNQ=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1/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=
|
||||||
|
|||||||
@@ -324,12 +324,14 @@ func TestObservationUSUnitsChangesFieldNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
||||||
|
ends := time.Date(2026, 6, 11, 14, 0, 0, 0, time.UTC)
|
||||||
h := newHandler(t, &fakeService{
|
h := newHandler(t, &fakeService{
|
||||||
alerts: &model.WeatherAlertRun{
|
alerts: &model.WeatherAlertRun{
|
||||||
AsOf: time.Now().UTC(),
|
AsOf: time.Now().UTC(),
|
||||||
Alerts: []model.WeatherAlert{{
|
Alerts: []model.WeatherAlert{{
|
||||||
ID: "abc",
|
ID: "abc",
|
||||||
Headline: "A headline",
|
Headline: "A headline",
|
||||||
|
Ends: &ends,
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
}, "/alerts/active")
|
}, "/alerts/active")
|
||||||
@@ -359,6 +361,9 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
|||||||
if first["id"] != "abc" {
|
if first["id"] != "abc" {
|
||||||
t.Fatalf("expected alert id abc, got %#v", first["id"])
|
t.Fatalf("expected alert id abc, got %#v", first["id"])
|
||||||
}
|
}
|
||||||
|
if _, ok := first["ends"].(string); !ok {
|
||||||
|
t.Fatalf("expected alert ends string, got %#v", first["ends"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsRouteRegistered(t *testing.T) {
|
func TestAlertsRouteRegistered(t *testing.T) {
|
||||||
@@ -456,8 +461,10 @@ func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
|||||||
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
setAlertNowForTest(t, activeAt)
|
setAlertNowForTest(t, activeAt)
|
||||||
effective := activeAt.Add(-1 * time.Hour)
|
effective := activeAt.Add(-1 * time.Hour)
|
||||||
activeExpires := activeAt.Add(1 * time.Hour)
|
activeEnds := activeAt.Add(1 * time.Hour)
|
||||||
expiredAtBoundary := activeAt
|
expiredEndsAtBoundary := activeAt
|
||||||
|
expiredProviderMetadata := activeAt.Add(-30 * time.Minute)
|
||||||
|
activeProviderMetadata := activeAt.Add(30 * time.Minute)
|
||||||
repo := &alertRepository{
|
repo := &alertRepository{
|
||||||
alerts: &model.WeatherAlertRun{
|
alerts: &model.WeatherAlertRun{
|
||||||
AsOf: activeAt,
|
AsOf: activeAt,
|
||||||
@@ -467,21 +474,23 @@ func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
|||||||
Headline: "Active warning",
|
Headline: "Active warning",
|
||||||
MessageType: "Alert",
|
MessageType: "Alert",
|
||||||
Effective: &effective,
|
Effective: &effective,
|
||||||
Expires: &activeExpires,
|
Ends: &activeEnds,
|
||||||
|
Expires: &expiredProviderMetadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "expired-alert",
|
ID: "expired-alert",
|
||||||
Headline: "Expired warning",
|
Headline: "Expired warning",
|
||||||
MessageType: "Alert",
|
MessageType: "Alert",
|
||||||
Effective: &effective,
|
Effective: &effective,
|
||||||
Expires: &expiredAtBoundary,
|
Ends: &expiredEndsAtBoundary,
|
||||||
|
Expires: &activeProviderMetadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "canceled-alert",
|
ID: "canceled-alert",
|
||||||
Headline: "Canceled warning",
|
Headline: "Canceled warning",
|
||||||
MessageType: " cancel ",
|
MessageType: " cancel ",
|
||||||
Effective: &effective,
|
Effective: &effective,
|
||||||
Expires: &activeExpires,
|
Ends: &activeEnds,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -496,7 +505,7 @@ func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", w.Code)
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
}
|
}
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning"} {
|
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning", "Ends:"} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Fatalf("expected %q in text body, got %q", want, body)
|
t.Fatalf("expected %q in text body, got %q", want, body)
|
||||||
}
|
}
|
||||||
@@ -1352,6 +1361,9 @@ func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
|
|||||||
if payload.Data["conditionText"] != "Rain" {
|
if payload.Data["conditionText"] != "Rain" {
|
||||||
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
|
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
|
||||||
}
|
}
|
||||||
|
if payload.Data["conditionCode"] != float64(63) {
|
||||||
|
t.Fatalf("expected conditionCode 63, got %#v", payload.Data["conditionCode"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCurrentConditionsUSJSON(t *testing.T) {
|
func TestCurrentConditionsUSJSON(t *testing.T) {
|
||||||
@@ -1417,6 +1429,9 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
|
|||||||
if !strings.Contains(w.Body.String(), "temperatureF") {
|
if !strings.Contains(w.Body.String(), "temperatureF") {
|
||||||
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
|
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "<conditionCode>2</conditionCode>") {
|
||||||
|
t.Fatalf("expected conditionCode in XML payload: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
|
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
|
||||||
@@ -1428,7 +1443,7 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
|
|||||||
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
|
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
|
||||||
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"))
|
||||||
}
|
}
|
||||||
if !strings.Contains(w.Body.String(), "Conditions text") {
|
if !strings.Contains(w.Body.String(), "Condition Code: 2") {
|
||||||
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
|
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1484,13 +1499,15 @@ func TestOutlookRoutesRegistered(t *testing.T) {
|
|||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
"/outlooks/convective",
|
"/outlooks/convective",
|
||||||
"/outlooks/convective/active",
|
"/outlooks/convective/active",
|
||||||
"/outlooks/convective/location",
|
|
||||||
} {
|
} {
|
||||||
def := definitionForPath(t, defs, path)
|
def := definitionForPath(t, defs, path)
|
||||||
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
||||||
t.Fatalf("%s: expected GET definition, got %+v", path, def.Methods)
|
t.Fatalf("%s: expected GET definition, got %+v", path, def.Methods)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if endpointExists(defs, "/outlooks/convective/location") {
|
||||||
|
t.Fatal("expected /outlooks/convective/location to be removed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||||
@@ -1499,7 +1516,6 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
|||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
"/outlooks/convective",
|
"/outlooks/convective",
|
||||||
"/outlooks/convective/active",
|
"/outlooks/convective/active",
|
||||||
"/outlooks/convective/location",
|
|
||||||
} {
|
} {
|
||||||
t.Run(path, func(t *testing.T) {
|
t.Run(path, func(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
|
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
|
||||||
@@ -1676,7 +1692,7 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
|||||||
h := newHandler(t, svc, "/outlooks/convective")
|
h := newHandler(t, svc, "/outlooks/convective")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&containsLocation=true&tz=CDT&units=US", nil)
|
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&tz=CDT&units=US", nil)
|
||||||
h.ServeHTTP(w, req)
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
@@ -1692,15 +1708,12 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
|||||||
if filter.OutlookType != "tornado" {
|
if filter.OutlookType != "tornado" {
|
||||||
t.Fatalf("expected outlookType tornado, got %q", filter.OutlookType)
|
t.Fatalf("expected outlookType tornado, got %q", filter.OutlookType)
|
||||||
}
|
}
|
||||||
if filter.ContainsLocation == nil || !*filter.ContainsLocation {
|
|
||||||
t.Fatalf("expected containsLocation true, got %+v", filter.ContainsLocation)
|
|
||||||
}
|
|
||||||
if filter.ActiveAt != nil {
|
if filter.ActiveAt != nil {
|
||||||
t.Fatalf("expected no active filter, got %v", filter.ActiveAt)
|
t.Fatalf("expected no active filter, got %v", filter.ActiveAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
|
func TestOutlookActiveFilterUsesNow(t *testing.T) {
|
||||||
now := time.Date(2026, 6, 11, 15, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
now := time.Date(2026, 6, 11, 15, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
setOutlookNowForTest(t, now)
|
setOutlookNowForTest(t, now)
|
||||||
|
|
||||||
@@ -1716,28 +1729,6 @@ func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
|
|||||||
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
|
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
|
||||||
t.Fatalf("expected activeAt %s, got %v", now.UTC(), activeFilter.ActiveAt)
|
t.Fatalf("expected activeAt %s, got %v", now.UTC(), activeFilter.ActiveAt)
|
||||||
}
|
}
|
||||||
if activeFilter.ContainsLocation != nil {
|
|
||||||
t.Fatalf("expected active route not to force containsLocation, got %+v", activeFilter.ContainsLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
locationSvc := &fakeService{outlookRun: testOutlookRun()}
|
|
||||||
locationHandler := newHandler(t, locationSvc, "/outlooks/convective/location")
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
req = httptest.NewRequest(http.MethodGet, "/outlooks/convective/location?outlookType=hail", nil)
|
|
||||||
locationHandler.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected location 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
locationFilter := locationSvc.outlookFilters[0]
|
|
||||||
if locationFilter.ActiveAt == nil || !locationFilter.ActiveAt.Equal(now.UTC()) {
|
|
||||||
t.Fatalf("expected location activeAt %s, got %v", now.UTC(), locationFilter.ActiveAt)
|
|
||||||
}
|
|
||||||
if locationFilter.ContainsLocation == nil || !*locationFilter.ContainsLocation {
|
|
||||||
t.Fatalf("expected location route to force containsLocation true, got %+v", locationFilter.ContainsLocation)
|
|
||||||
}
|
|
||||||
if locationFilter.OutlookType != "hail" {
|
|
||||||
t.Fatalf("expected outlookType hail, got %q", locationFilter.OutlookType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
|
func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
|
||||||
@@ -1748,10 +1739,10 @@ func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
|
|||||||
"/outlooks/convective?day=4",
|
"/outlooks/convective?day=4",
|
||||||
"/outlooks/convective?day=two",
|
"/outlooks/convective?day=two",
|
||||||
"/outlooks/convective?outlookType=snow",
|
"/outlooks/convective?outlookType=snow",
|
||||||
"/outlooks/convective?containsLocation=maybe",
|
"/outlooks/convective?containsLocation=true",
|
||||||
|
"/outlooks/convective/active?containsLocation=true",
|
||||||
"/outlooks/convective?tz=not-a-timezone",
|
"/outlooks/convective?tz=not-a-timezone",
|
||||||
"/outlooks/convective?tz=CDT&TZ=EST",
|
"/outlooks/convective?tz=CDT&TZ=EST",
|
||||||
"/outlooks/convective/location?containsLocation=true",
|
|
||||||
} {
|
} {
|
||||||
t.Run(rawURL, func(t *testing.T) {
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
|
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
|
||||||
@@ -2440,6 +2431,15 @@ func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) en
|
|||||||
return endpoint.Definition{}
|
return endpoint.Definition{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func endpointExists(defs []endpoint.Definition, path string) bool {
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Path == path {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func testRenderers(t *testing.T) *render.Registry {
|
func testRenderers(t *testing.T) *render.Registry {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -2463,8 +2463,8 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"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}}",
|
"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": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
|
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
|
||||||
"conditions_current.txt.tmpl": "Conditions text",
|
"conditions_current.txt.tmpl": "Condition Code: {{.Data.ConditionCode}}",
|
||||||
} {
|
} {
|
||||||
tmpl, err := template.New(name).Parse(body)
|
tmpl, err := template.New(name).Parse(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type outlookFilterMode int
|
|||||||
const (
|
const (
|
||||||
outlookFilterUser outlookFilterMode = iota
|
outlookFilterUser outlookFilterMode = iota
|
||||||
outlookFilterActive
|
outlookFilterActive
|
||||||
outlookFilterLocation
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var outlookNow = time.Now
|
var outlookNow = time.Now
|
||||||
@@ -27,7 +26,6 @@ func outlookDefinitions(svc Service) []endpoint.Definition {
|
|||||||
return []endpoint.Definition{
|
return []endpoint.Definition{
|
||||||
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
|
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
|
||||||
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
|
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
|
||||||
outlookDefinition("/outlooks/convective/location", outlookFilterLocation, bindOutlookLocationQuery, svc),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +40,10 @@ func outlookDefinition(
|
|||||||
binder,
|
binder,
|
||||||
func(ctx context.Context, req outlookQueryRequest) (any, error) {
|
func(ctx context.Context, req outlookQueryRequest) (any, error) {
|
||||||
filter := req.Filter
|
filter := req.Filter
|
||||||
if mode == outlookFilterActive || mode == outlookFilterLocation {
|
if mode == outlookFilterActive {
|
||||||
activeAt := outlookNow().UTC()
|
activeAt := outlookNow().UTC()
|
||||||
filter.ActiveAt = &activeAt
|
filter.ActiveAt = &activeAt
|
||||||
}
|
}
|
||||||
if mode == outlookFilterLocation {
|
|
||||||
containsLocation := true
|
|
||||||
filter.ContainsLocation = &containsLocation
|
|
||||||
}
|
|
||||||
|
|
||||||
run, err := svc.LatestConvectiveOutlook(ctx, filter)
|
run, err := svc.LatestConvectiveOutlook(ctx, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package presenter
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ type CurrentConditionsResponse struct {
|
|||||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
|
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
|
||||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
|
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
|
||||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
|
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
|
||||||
|
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
|
||||||
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
|
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
|
||||||
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
|
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
|
||||||
IsDayText string `json:"-" xml:"-"`
|
IsDayText string `json:"-" xml:"-"`
|
||||||
@@ -33,6 +35,7 @@ func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, pr
|
|||||||
out := CurrentConditionsResponse{
|
out := CurrentConditionsResponse{
|
||||||
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
|
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
|
||||||
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
|
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
|
||||||
|
ConditionCode: conditions.ConditionCode,
|
||||||
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
|
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
|
||||||
IsDay: copyBoolPtr(conditions.IsDay),
|
IsDay: copyBoolPtr(conditions.IsDay),
|
||||||
IsDayText: boolText(conditions.IsDay),
|
IsDayText: boolText(conditions.IsDay),
|
||||||
|
|||||||
@@ -378,6 +378,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
|||||||
if metric.ConditionText != "Sunny" {
|
if metric.ConditionText != "Sunny" {
|
||||||
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
|
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
|
||||||
}
|
}
|
||||||
|
if metric.ConditionCode != 0 {
|
||||||
|
t.Fatalf("expected condition code 0, got %d", metric.ConditionCode)
|
||||||
|
}
|
||||||
|
|
||||||
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
|
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
|
||||||
us, ok := usPayload.(CurrentConditionsResponse)
|
us, ok := usPayload.(CurrentConditionsResponse)
|
||||||
@@ -389,6 +392,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
|||||||
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
||||||
t.Fatalf("expected metric fields omitted for US payload")
|
t.Fatalf("expected metric fields omitted for US payload")
|
||||||
}
|
}
|
||||||
|
if us.ConditionCode != 0 {
|
||||||
|
t.Fatalf("expected condition code 0, got %d", us.ConditionCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
|
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
|
||||||
|
|||||||
@@ -145,29 +145,16 @@ func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) {
|
func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) {
|
||||||
return bindOutlookQueryInternal(r, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bindOutlookLocationQuery(r *http.Request) (outlookQueryRequest, error) {
|
|
||||||
return bindOutlookQueryInternal(r, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outlookQueryRequest, error) {
|
|
||||||
normalizeCommonQueryValue(r, "units")
|
normalizeCommonQueryValue(r, "units")
|
||||||
normalizeCommonQueryValue(r, "format")
|
normalizeCommonQueryValue(r, "format")
|
||||||
normalizeCommonQueryValue(r, "outlookType")
|
normalizeCommonQueryValue(r, "outlookType")
|
||||||
|
|
||||||
allowedExtra := []string{"tz", "TZ", "day", "outlookType"}
|
|
||||||
if allowContainsLocation {
|
|
||||||
allowedExtra = append(allowedExtra, "containsLocation")
|
|
||||||
}
|
|
||||||
|
|
||||||
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
||||||
AllowUnits: true,
|
AllowUnits: true,
|
||||||
AllowFormat: true,
|
AllowFormat: true,
|
||||||
DefaultUnits: string(presenter.UnitsMetric),
|
DefaultUnits: string(presenter.UnitsMetric),
|
||||||
RejectUnknown: true,
|
RejectUnknown: true,
|
||||||
}, allowedExtra...)
|
}, "tz", "TZ", "day", "outlookType")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return outlookQueryRequest{}, err
|
return outlookQueryRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -182,7 +169,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
|
|||||||
return outlookQueryRequest{}, err
|
return outlookQueryRequest{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
filter, err := bindOutlookFilter(r, allowContainsLocation)
|
filter, err := bindOutlookFilter(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return outlookQueryRequest{}, err
|
return outlookQueryRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -194,7 +181,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.OutlookFilter, error) {
|
func bindOutlookFilter(r *http.Request) (app.OutlookFilter, error) {
|
||||||
var filter app.OutlookFilter
|
var filter app.OutlookFilter
|
||||||
|
|
||||||
if strings.TrimSpace(r.URL.Query().Get("day")) != "" {
|
if strings.TrimSpace(r.URL.Query().Get("day")) != "" {
|
||||||
@@ -218,16 +205,5 @@ func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.Outlook
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(r.URL.Query().Get("containsLocation")) != "" {
|
|
||||||
if !allowContainsLocation {
|
|
||||||
return app.OutlookFilter{}, apierrors.InvalidParameter("containsLocation is not allowed on this endpoint")
|
|
||||||
}
|
|
||||||
containsLocation, err := bind.OptionalBool(r, "containsLocation", false)
|
|
||||||
if err != nil {
|
|
||||||
return app.OutlookFilter{}, err
|
|
||||||
}
|
|
||||||
filter.ContainsLocation = &containsLocation
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter, nil
|
return filter, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func mapAlertRow(row alertRow) indexedAlert {
|
|||||||
Sent: timePtr(row.Sent),
|
Sent: timePtr(row.Sent),
|
||||||
Effective: timePtr(row.Effective),
|
Effective: timePtr(row.Effective),
|
||||||
Onset: timePtr(row.Onset),
|
Onset: timePtr(row.Onset),
|
||||||
|
Ends: timePtr(row.Ends),
|
||||||
Expires: timePtr(row.Expires),
|
Expires: timePtr(row.Expires),
|
||||||
AreaDescription: stringValue(row.AreaDescription),
|
AreaDescription: stringValue(row.AreaDescription),
|
||||||
SenderName: stringValue(row.SenderName),
|
SenderName: stringValue(row.SenderName),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ SELECT
|
|||||||
sent,
|
sent,
|
||||||
effective,
|
effective,
|
||||||
onset,
|
onset,
|
||||||
|
ends,
|
||||||
expires,
|
expires,
|
||||||
area_description,
|
area_description,
|
||||||
sender_name
|
sender_name
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.We
|
|||||||
&row.Sent,
|
&row.Sent,
|
||||||
&row.Effective,
|
&row.Effective,
|
||||||
&row.Onset,
|
&row.Onset,
|
||||||
|
&row.Ends,
|
||||||
&row.Expires,
|
&row.Expires,
|
||||||
&row.AreaDescription,
|
&row.AreaDescription,
|
||||||
&row.SenderName,
|
&row.SenderName,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type alertRow struct {
|
|||||||
Sent sql.NullTime
|
Sent sql.NullTime
|
||||||
Effective sql.NullTime
|
Effective sql.NullTime
|
||||||
Onset sql.NullTime
|
Onset sql.NullTime
|
||||||
|
Ends sql.NullTime
|
||||||
Expires sql.NullTime
|
Expires sql.NullTime
|
||||||
AreaDescription sql.NullString
|
AreaDescription sql.NullString
|
||||||
SenderName sql.NullString
|
SenderName sql.NullString
|
||||||
|
|||||||
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal file
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// conditions_codes.go selects current-conditions WMO codes from source candidates.
|
||||||
|
// Layer: adapters/outbound/postgres conditions feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
|
||||||
|
type currentConditionsConditionCodeCandidate struct {
|
||||||
|
EventSource string
|
||||||
|
ConditionCode model.WMOCode
|
||||||
|
}
|
||||||
|
|
||||||
|
type conditionCodeFamily int
|
||||||
|
|
||||||
|
const (
|
||||||
|
conditionCodeFamilyUnknown conditionCodeFamily = iota
|
||||||
|
conditionCodeFamilyClearOrCloud
|
||||||
|
conditionCodeFamilyFog
|
||||||
|
conditionCodeFamilyDrizzle
|
||||||
|
conditionCodeFamilyRain
|
||||||
|
conditionCodeFamilySnow
|
||||||
|
conditionCodeFamilyThunderstorm
|
||||||
|
)
|
||||||
|
|
||||||
|
var currentConditionsConditionCodeRankings = map[conditionCodeFamily][]model.WMOCode{
|
||||||
|
conditionCodeFamilyClearOrCloud: {0, 1, 2, 3},
|
||||||
|
conditionCodeFamilyFog: {45, 48},
|
||||||
|
conditionCodeFamilyDrizzle: {51, 53, 55, 56, 57},
|
||||||
|
conditionCodeFamilyRain: {61, 63, 65, 80, 81, 82, 66, 67},
|
||||||
|
conditionCodeFamilySnow: {71, 73, 75, 85, 86, 77},
|
||||||
|
conditionCodeFamilyThunderstorm: {95, 96, 99},
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentConditionsConditionCodeFamilies = buildCurrentConditionsConditionCodeFamilies()
|
||||||
|
|
||||||
|
func buildCurrentConditionsConditionCodeFamilies() map[model.WMOCode]conditionCodeFamily {
|
||||||
|
families := make(map[model.WMOCode]conditionCodeFamily)
|
||||||
|
for family, ranking := range currentConditionsConditionCodeRankings {
|
||||||
|
for _, code := range ranking {
|
||||||
|
families[code] = family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return families
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCurrentConditionsConditionCode(candidates []currentConditionsConditionCodeCandidate) model.WMOCode {
|
||||||
|
seenSources := make(map[string]struct{})
|
||||||
|
familyCounts := make(map[conditionCodeFamily]int)
|
||||||
|
codeCounts := make(map[model.WMOCode]int)
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if _, seen := seenSources[candidate.EventSource]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenSources[candidate.EventSource] = struct{}{}
|
||||||
|
|
||||||
|
family, ok := currentConditionsConditionCodeFamilies[candidate.ConditionCode]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
familyCounts[family]++
|
||||||
|
codeCounts[candidate.ConditionCode]++
|
||||||
|
}
|
||||||
|
|
||||||
|
winningFamily, ok := currentConditionsWinningConditionCodeFamily(familyCounts)
|
||||||
|
if !ok {
|
||||||
|
return model.WMOUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentConditionsWinningConditionCode(winningFamily, codeCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsWinningConditionCodeFamily(counts map[conditionCodeFamily]int) (conditionCodeFamily, bool) {
|
||||||
|
winningFamily := conditionCodeFamilyUnknown
|
||||||
|
winningCount := 0
|
||||||
|
tied := false
|
||||||
|
|
||||||
|
for family, count := range counts {
|
||||||
|
if count > winningCount {
|
||||||
|
winningFamily = family
|
||||||
|
winningCount = count
|
||||||
|
tied = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count == winningCount {
|
||||||
|
tied = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if winningCount == 0 || tied {
|
||||||
|
return conditionCodeFamilyUnknown, false
|
||||||
|
}
|
||||||
|
return winningFamily, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsWinningConditionCode(family conditionCodeFamily, counts map[model.WMOCode]int) model.WMOCode {
|
||||||
|
winningCode := model.WMOUnknown
|
||||||
|
winningCount := 0
|
||||||
|
|
||||||
|
for _, code := range currentConditionsConditionCodeRankings[family] {
|
||||||
|
if counts[code] > winningCount {
|
||||||
|
winningCode = code
|
||||||
|
winningCount = counts[code]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return winningCode
|
||||||
|
}
|
||||||
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal file
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// conditions_codes_test.go tests current-conditions WMO code selection.
|
||||||
|
// Layer: adapters/outbound/postgres conditions feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectCurrentConditionsConditionCode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
candidates []currentConditionsConditionCodeCandidate
|
||||||
|
want model.WMOCode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clear cloud ranking breaks exact tie",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear cloud family wins over thunderstorm",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
95,
|
||||||
|
),
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tied families return unknown",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
0,
|
||||||
|
95,
|
||||||
|
),
|
||||||
|
want: model.WMOUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rain ranking breaks exact tie",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
61,
|
||||||
|
63,
|
||||||
|
80,
|
||||||
|
),
|
||||||
|
want: 61,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three tied families return unknown",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
61,
|
||||||
|
95,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
want: model.WMOUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single thunderstorm code wins",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
95,
|
||||||
|
),
|
||||||
|
want: 95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrecognized only returns unknown",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
4,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
want: model.WMOUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate source only votes once",
|
||||||
|
candidates: []currentConditionsConditionCodeCandidate{
|
||||||
|
{EventSource: "source-1", ConditionCode: 95},
|
||||||
|
{EventSource: "source-1", ConditionCode: 95},
|
||||||
|
{EventSource: "source-2", ConditionCode: 0},
|
||||||
|
{EventSource: "source-3", ConditionCode: 0},
|
||||||
|
},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact code frequency wins before ranking",
|
||||||
|
candidates: []currentConditionsConditionCodeCandidate{
|
||||||
|
{EventSource: "source-1", ConditionCode: 61},
|
||||||
|
{EventSource: "source-2", ConditionCode: 63},
|
||||||
|
{EventSource: "source-3", ConditionCode: 63},
|
||||||
|
},
|
||||||
|
want: 63,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := selectCurrentConditionsConditionCode(tt.candidates)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("expected condition code %d, got %d", tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func conditionCodeCandidates(codes ...model.WMOCode) []currentConditionsConditionCodeCandidate {
|
||||||
|
candidates := make([]currentConditionsConditionCodeCandidate, 0, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
candidates = append(candidates, currentConditionsConditionCodeCandidate{
|
||||||
|
EventSource: string(rune('a' + i)),
|
||||||
|
ConditionCode: code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
@@ -7,16 +7,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions {
|
func mapCurrentConditionsRow(row currentConditionsRow, conditionCode model.WMOCode) *app.CurrentConditions {
|
||||||
if row.SampleCount == 0 {
|
if row.SampleCount == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
conditionCode := model.WMOUnknown
|
|
||||||
if row.ConditionCode.Valid {
|
|
||||||
conditionCode = model.WMOCode(row.ConditionCode.Int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &app.CurrentConditions{
|
return &app.CurrentConditions{
|
||||||
TemperatureC: float64Ptr(row.TemperatureC),
|
TemperatureC: float64Ptr(row.TemperatureC),
|
||||||
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
|
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ WITH windowed AS (
|
|||||||
relative_humidity_percent,
|
relative_humidity_percent,
|
||||||
wind_speed_kmh,
|
wind_speed_kmh,
|
||||||
wind_direction_degrees,
|
wind_direction_degrees,
|
||||||
condition_code,
|
|
||||||
is_day,
|
is_day,
|
||||||
observed_at
|
observed_at
|
||||||
FROM observations
|
FROM observations
|
||||||
@@ -39,7 +38,6 @@ SELECT
|
|||||||
AVG(cosd(wind_direction_degrees))
|
AVG(cosd(wind_direction_degrees))
|
||||||
)
|
)
|
||||||
END AS wind_direction_degrees,
|
END AS wind_direction_degrees,
|
||||||
MAX(condition_code) AS condition_code,
|
|
||||||
(
|
(
|
||||||
SELECT is_day
|
SELECT is_day
|
||||||
FROM windowed
|
FROM windowed
|
||||||
@@ -47,4 +45,23 @@ SELECT
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS is_day
|
) AS is_day
|
||||||
FROM windowed`
|
FROM windowed`
|
||||||
|
|
||||||
|
queryCurrentConditionsConditionCodeCandidates = `
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
event_source,
|
||||||
|
condition_code,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY event_source
|
||||||
|
ORDER BY observed_at DESC, event_emitted_at DESC
|
||||||
|
) AS source_rank
|
||||||
|
FROM observations
|
||||||
|
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
event_source,
|
||||||
|
condition_code
|
||||||
|
FROM ranked
|
||||||
|
WHERE source_rank = 1
|
||||||
|
ORDER BY event_source`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
|
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
|
||||||
@@ -25,7 +26,6 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
|
|||||||
&row.RelativeHumidityPercent,
|
&row.RelativeHumidityPercent,
|
||||||
&row.WindSpeedKmh,
|
&row.WindSpeedKmh,
|
||||||
&row.WindDirectionDegrees,
|
&row.WindDirectionDegrees,
|
||||||
&row.ConditionCode,
|
|
||||||
&row.IsDay,
|
&row.IsDay,
|
||||||
)
|
)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -35,5 +35,42 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
|
|||||||
return nil, fmt.Errorf("query current conditions: %w", err)
|
return nil, fmt.Errorf("query current conditions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapCurrentConditionsRow(row), nil
|
if row.SampleCount == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := r.currentConditionsConditionCodeCandidates(ctx, observationWindowMinutes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapCurrentConditionsRow(row, selectCurrentConditionsConditionCode(candidates)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) currentConditionsConditionCodeCandidates(ctx context.Context, observationWindowMinutes int) ([]currentConditionsConditionCodeCandidate, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, queryCurrentConditionsConditionCodeCandidates, observationWindowMinutes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query current conditions condition code candidates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var candidates []currentConditionsConditionCodeCandidate
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
eventSource string
|
||||||
|
conditionCode int64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&eventSource, &conditionCode); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan current conditions condition code candidate: %w", err)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, currentConditionsConditionCodeCandidate{
|
||||||
|
EventSource: eventSource,
|
||||||
|
ConditionCode: model.WMOCode(conditionCode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate current conditions condition code candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
}
|
}
|
||||||
|
|||||||
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal file
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// conditions_read_test.go validates current-conditions repository read flow.
|
||||||
|
// Layer: adapters/outbound/postgres conditions read tests.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentConditionsTestDriverName = "weatherapi_current_conditions_read_test"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sql.Register(currentConditionsTestDriverName, currentConditionsTestDriver{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsUsesConsensusConditionCode(t *testing.T) {
|
||||||
|
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||||
|
currentConditionsAggregateQuery(currentConditionsAggregateRow(3), nil),
|
||||||
|
currentConditionsConditionCodeCandidatesQuery([][]driver.Value{
|
||||||
|
{"source-a", int64(1)},
|
||||||
|
{"source-b", int64(2)},
|
||||||
|
{"source-c", int64(95)},
|
||||||
|
}, nil),
|
||||||
|
)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
conditions, err := repo.CurrentConditions(context.Background(), 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if conditions == nil {
|
||||||
|
t.Fatal("expected current conditions")
|
||||||
|
}
|
||||||
|
if conditions.ConditionCode != 1 {
|
||||||
|
t.Fatalf("expected consensus condition code 1, got %d", conditions.ConditionCode)
|
||||||
|
}
|
||||||
|
if conditions.TemperatureC == nil || *conditions.TemperatureC != 15.5 {
|
||||||
|
t.Fatalf("expected temperature 15.5, got %v", conditions.TemperatureC)
|
||||||
|
}
|
||||||
|
if conditions.WindDirectionDegrees == nil || *conditions.WindDirectionDegrees != 182.5 {
|
||||||
|
t.Fatalf("expected wind direction 182.5, got %v", conditions.WindDirectionDegrees)
|
||||||
|
}
|
||||||
|
if conditions.IsDay == nil || !*conditions.IsDay {
|
||||||
|
t.Fatalf("expected isDay true, got %v", conditions.IsDay)
|
||||||
|
}
|
||||||
|
assertCurrentConditionsTestQueriesConsumed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsNoSamplesSkipsConditionCodeCandidates(t *testing.T) {
|
||||||
|
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||||
|
currentConditionsAggregateQuery([]driver.Value{int64(0), nil, nil, nil, nil, nil, nil, nil}, nil),
|
||||||
|
)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
conditions, err := repo.CurrentConditions(context.Background(), 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if conditions != nil {
|
||||||
|
t.Fatalf("expected nil current conditions, got %+v", conditions)
|
||||||
|
}
|
||||||
|
assertCurrentConditionsTestQueriesConsumed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsConditionCodeCandidateQueryUsesLatestPerSourceOrdering(t *testing.T) {
|
||||||
|
query := compactSQL(queryCurrentConditionsConditionCodeCandidates)
|
||||||
|
want := "PARTITION BY event_source ORDER BY observed_at DESC, event_emitted_at DESC"
|
||||||
|
if !strings.Contains(query, want) {
|
||||||
|
t.Fatalf("expected condition code candidate query to contain %q, got %q", want, query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(query, "SELECT event_source, condition_code") {
|
||||||
|
t.Fatalf("expected condition code candidate query to select event_source and condition_code, got %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsAggregateQueryDoesNotSelectConditionCode(t *testing.T) {
|
||||||
|
query := compactSQL(queryCurrentConditions)
|
||||||
|
if strings.Contains(query, "condition_code") {
|
||||||
|
t.Fatalf("expected aggregate query not to select condition_code, got %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsConditionCodeCandidateQueryWrapsErrors(t *testing.T) {
|
||||||
|
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||||
|
currentConditionsAggregateQuery(currentConditionsAggregateRow(1), nil),
|
||||||
|
scriptedCurrentConditionsQuery{
|
||||||
|
name: "condition code candidates",
|
||||||
|
query: queryCurrentConditionsConditionCodeCandidates,
|
||||||
|
args: []driver.Value{int64(15)},
|
||||||
|
err: errors.New("candidate query unavailable"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
_, err := repo.CurrentConditions(context.Background(), 15)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "query current conditions condition code candidates") {
|
||||||
|
t.Fatalf("expected candidate query context, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openCurrentConditionsTestRepository(t *testing.T, queries ...scriptedCurrentConditionsQuery) (*Repository, func()) {
|
||||||
|
t.Helper()
|
||||||
|
currentConditionsTestScript.set(queries)
|
||||||
|
|
||||||
|
db, err := sql.Open(currentConditionsTestDriverName, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test db: %v", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
return NewRepository(db), func() {
|
||||||
|
_ = db.Close()
|
||||||
|
currentConditionsTestScript.set(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCurrentConditionsTestQueriesConsumed(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if remaining := currentConditionsTestScript.remaining(); remaining != 0 {
|
||||||
|
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsAggregateQuery(row []driver.Value, nextErr error) scriptedCurrentConditionsQuery {
|
||||||
|
return scriptedCurrentConditionsQuery{
|
||||||
|
name: "aggregate",
|
||||||
|
query: queryCurrentConditions,
|
||||||
|
args: []driver.Value{int64(15)},
|
||||||
|
columns: []string{"sample_count", "temperature_c", "apparent_temperature_c", "dewpoint_c", "relative_humidity_percent", "wind_speed_kmh", "wind_direction_degrees", "is_day"},
|
||||||
|
rows: [][]driver.Value{row},
|
||||||
|
nextErr: nextErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsConditionCodeCandidatesQuery(rows [][]driver.Value, nextErr error) scriptedCurrentConditionsQuery {
|
||||||
|
return scriptedCurrentConditionsQuery{
|
||||||
|
name: "condition code candidates",
|
||||||
|
query: queryCurrentConditionsConditionCodeCandidates,
|
||||||
|
args: []driver.Value{int64(15)},
|
||||||
|
columns: []string{"event_source", "condition_code"},
|
||||||
|
rows: rows,
|
||||||
|
nextErr: nextErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsAggregateRow(sampleCount int64) []driver.Value {
|
||||||
|
return []driver.Value{
|
||||||
|
sampleCount,
|
||||||
|
float64(15.5),
|
||||||
|
float64(14.2),
|
||||||
|
float64(10.1),
|
||||||
|
float64(72),
|
||||||
|
float64(24.8),
|
||||||
|
float64(182.5),
|
||||||
|
true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestDriver struct{}
|
||||||
|
|
||||||
|
func (currentConditionsTestDriver) Open(string) (driver.Conn, error) {
|
||||||
|
return currentConditionsTestConn{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestConn struct{}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) Prepare(string) (driver.Stmt, error) {
|
||||||
|
return nil, errors.New("prepare is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) Begin() (driver.Tx, error) {
|
||||||
|
return nil, errors.New("transactions are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
return currentConditionsTestScript.next(query, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptedCurrentConditionsQuery struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
args []driver.Value
|
||||||
|
columns []string
|
||||||
|
rows [][]driver.Value
|
||||||
|
err error
|
||||||
|
nextErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestScriptState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
queries []scriptedCurrentConditionsQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentConditionsTestScript currentConditionsTestScriptState
|
||||||
|
|
||||||
|
func (s *currentConditionsTestScriptState) set(queries []scriptedCurrentConditionsQuery) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.queries = append([]scriptedCurrentConditionsQuery(nil), queries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *currentConditionsTestScriptState) remaining() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *currentConditionsTestScriptState) 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 ¤tConditionsTestRows{
|
||||||
|
columns: append([]string(nil), next.columns...),
|
||||||
|
rows: append([][]driver.Value(nil), next.rows...),
|
||||||
|
nextErr: next.nextErr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestRows struct {
|
||||||
|
columns []string
|
||||||
|
rows [][]driver.Value
|
||||||
|
index int
|
||||||
|
nextErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *currentConditionsTestRows) Columns() []string {
|
||||||
|
return r.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *currentConditionsTestRows) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *currentConditionsTestRows) 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
|
||||||
|
}
|
||||||
@@ -12,6 +12,5 @@ type currentConditionsRow struct {
|
|||||||
RelativeHumidityPercent sql.NullFloat64
|
RelativeHumidityPercent sql.NullFloat64
|
||||||
WindSpeedKmh sql.NullFloat64
|
WindSpeedKmh sql.NullFloat64
|
||||||
WindDirectionDegrees sql.NullFloat64
|
WindDirectionDegrees sql.NullFloat64
|
||||||
ConditionCode sql.NullInt64
|
|
||||||
IsDay sql.NullBool
|
IsDay sql.NullBool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,10 +246,40 @@ func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapAlertRowMapsEndsAndExpires(t *testing.T) {
|
||||||
|
ends := time.Date(2026, 6, 16, 14, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
expires := time.Date(2026, 6, 16, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
|
||||||
|
alert := mapAlertRow(alertRow{
|
||||||
|
AlertIndex: 1,
|
||||||
|
AlertID: "alert-1",
|
||||||
|
Ends: sql.NullTime{Time: ends, Valid: true},
|
||||||
|
Expires: sql.NullTime{Time: expires, Valid: true},
|
||||||
|
}).Alert
|
||||||
|
|
||||||
|
if alert.Ends == nil || alert.Ends.Location().String() != "UTC" || !alert.Ends.Equal(ends.UTC()) {
|
||||||
|
t.Fatalf("expected ends UTC %s, got %v", ends.UTC(), alert.Ends)
|
||||||
|
}
|
||||||
|
if alert.Expires == nil || alert.Expires.Location().String() != "UTC" || !alert.Expires.Equal(expires.UTC()) {
|
||||||
|
t.Fatalf("expected expires UTC %s, got %v", expires.UTC(), alert.Expires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapAlertRowNullableEnds(t *testing.T) {
|
||||||
|
alert := mapAlertRow(alertRow{
|
||||||
|
AlertIndex: 1,
|
||||||
|
AlertID: "alert-1",
|
||||||
|
}).Alert
|
||||||
|
|
||||||
|
if alert.Ends != nil {
|
||||||
|
t.Fatalf("expected nil ends, got %v", alert.Ends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
|
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
|
||||||
got := mapCurrentConditionsRow(currentConditionsRow{
|
got := mapCurrentConditionsRow(currentConditionsRow{
|
||||||
SampleCount: 0,
|
SampleCount: 0,
|
||||||
})
|
}, model.WMOUnknown)
|
||||||
if got != nil {
|
if got != nil {
|
||||||
t.Fatalf("expected nil for empty sample window, got %+v", got)
|
t.Fatalf("expected nil for empty sample window, got %+v", got)
|
||||||
}
|
}
|
||||||
@@ -265,9 +295,8 @@ func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
|
|||||||
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
|
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
|
||||||
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
|
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
|
||||||
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
|
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
|
||||||
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
|
|
||||||
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
||||||
})
|
}, 65)
|
||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatalf("expected mapped current conditions")
|
t.Fatalf("expected mapped current conditions")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const nwsAlertURLPrefix = "https://api.weather.gov/alerts/"
|
||||||
|
|
||||||
// Repository defines outbound data access used by weatherapi use cases.
|
// Repository defines outbound data access used by weatherapi use cases.
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
@@ -27,7 +29,6 @@ type Repository interface {
|
|||||||
type OutlookFilter struct {
|
type OutlookFilter struct {
|
||||||
Day *int
|
Day *int
|
||||||
OutlookType string
|
OutlookType string
|
||||||
ContainsLocation *bool
|
|
||||||
ActiveAt *time.Time
|
ActiveAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +79,10 @@ func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time)
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := cloneAlertRun(run)
|
out := cloneAlertRun(run)
|
||||||
|
supersededIDs := collectSupersededAlertIDs(out.Alerts)
|
||||||
alerts := out.Alerts[:0]
|
alerts := out.Alerts[:0]
|
||||||
for _, alert := range out.Alerts {
|
for _, alert := range out.Alerts {
|
||||||
if isActiveAlert(alert, activeAt) {
|
if isActiveAlert(alert, activeAt) && !isSupersededAlert(alert, supersededIDs) {
|
||||||
alerts = append(alerts, alert)
|
alerts = append(alerts, alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,9 +122,6 @@ func matchesOutlookFilter(outlook model.WeatherOutlook, filter OutlookFilter) bo
|
|||||||
if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) {
|
if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if filter.ContainsLocation != nil && outlook.ContainsLocation != *filter.ContainsLocation {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) {
|
if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -151,6 +150,7 @@ func cloneAlert(alert model.WeatherAlert) model.WeatherAlert {
|
|||||||
out.Sent = copyTime(alert.Sent)
|
out.Sent = copyTime(alert.Sent)
|
||||||
out.Effective = copyTime(alert.Effective)
|
out.Effective = copyTime(alert.Effective)
|
||||||
out.Onset = copyTime(alert.Onset)
|
out.Onset = copyTime(alert.Onset)
|
||||||
|
out.Ends = copyTime(alert.Ends)
|
||||||
out.Expires = copyTime(alert.Expires)
|
out.Expires = copyTime(alert.Expires)
|
||||||
if alert.References != nil {
|
if alert.References != nil {
|
||||||
out.References = make([]model.AlertReference, len(alert.References))
|
out.References = make([]model.AlertReference, len(alert.References))
|
||||||
@@ -174,12 +174,51 @@ func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
|
|||||||
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
|
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if alert.Expires != nil && !activeAt.Before(*alert.Expires) {
|
endBoundary := alert.Ends
|
||||||
|
if endBoundary == nil {
|
||||||
|
endBoundary = alert.Expires
|
||||||
|
}
|
||||||
|
if endBoundary != nil && !activeAt.Before(*endBoundary) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectSupersededAlertIDs(alerts []model.WeatherAlert) map[string]struct{} {
|
||||||
|
supersededIDs := make(map[string]struct{})
|
||||||
|
for _, alert := range alerts {
|
||||||
|
for _, ref := range alert.References {
|
||||||
|
id := normalizeAlertID(referenceAlertID(ref))
|
||||||
|
if id != "" {
|
||||||
|
supersededIDs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return supersededIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupersededAlert(alert model.WeatherAlert, supersededIDs map[string]struct{}) bool {
|
||||||
|
id := normalizeAlertID(alert.ID)
|
||||||
|
if id == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := supersededIDs[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceAlertID(ref model.AlertReference) string {
|
||||||
|
if strings.TrimSpace(ref.Identifier) != "" {
|
||||||
|
return ref.Identifier
|
||||||
|
}
|
||||||
|
return ref.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAlertID(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, nwsAlertURLPrefix)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||||
out := *run
|
out := *run
|
||||||
out.Latitude = copyFloat64(run.Latitude)
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
|
|||||||
if repo.alertRunCalls != 1 {
|
if repo.alertRunCalls != 1 {
|
||||||
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
||||||
}
|
}
|
||||||
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset"})
|
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset", "ends-preferred"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
||||||
@@ -165,9 +165,9 @@ func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
|
|||||||
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
||||||
activeAt := testTime(12)
|
activeAt := testTime(12)
|
||||||
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||||
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
||||||
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14)),
|
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
|
||||||
})}
|
})}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
|
|
||||||
@@ -213,7 +213,8 @@ func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
|||||||
*run.Alerts[0].Sent = testTime(1)
|
*run.Alerts[0].Sent = testTime(1)
|
||||||
*run.Alerts[0].Effective = testTime(2)
|
*run.Alerts[0].Effective = testTime(2)
|
||||||
*run.Alerts[0].Onset = testTime(3)
|
*run.Alerts[0].Onset = testTime(3)
|
||||||
*run.Alerts[0].Expires = testTime(4)
|
*run.Alerts[0].Ends = testTime(4)
|
||||||
|
*run.Alerts[0].Expires = testTime(5)
|
||||||
*run.Alerts[0].References[0].Sent = testTime(5)
|
*run.Alerts[0].References[0].Sent = testTime(5)
|
||||||
run.Alerts[0].ID = "changed"
|
run.Alerts[0].ID = "changed"
|
||||||
run.Alerts[0].References[0].ID = "changed"
|
run.Alerts[0].References[0].ID = "changed"
|
||||||
@@ -237,7 +238,10 @@ func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
|||||||
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
||||||
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
||||||
}
|
}
|
||||||
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) {
|
if original.Alerts[0].Ends == nil || !original.Alerts[0].Ends.Equal(testTime(13)) {
|
||||||
|
t.Fatalf("expected original ends unchanged, got %v", original.Alerts[0].Ends)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(12)) {
|
||||||
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
||||||
}
|
}
|
||||||
if original.Alerts[0].References[0].ID != "ref-current" {
|
if original.Alerts[0].References[0].ID != "ref-current" {
|
||||||
@@ -246,11 +250,82 @@ func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
|||||||
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
||||||
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
||||||
}
|
}
|
||||||
if len(original.Alerts) != 8 {
|
if len(original.Alerts) != 10 {
|
||||||
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
|
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||||
|
testAlert("ends-after-active-expires-before", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(11)),
|
||||||
|
testAlert("expires-fallback", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(13)),
|
||||||
|
testAlert("expires-fallback-expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(12)),
|
||||||
|
})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunSuppressesReferencedOriginal(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update := testAlert("https://api.weather.gov/alerts/urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||||
|
unrelated := testAlert("https://api.weather.gov/alerts/urn:oid:unrelated", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, update, unrelated})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{
|
||||||
|
"https://api.weather.gov/alerts/urn:oid:update",
|
||||||
|
"https://api.weather.gov/alerts/urn:oid:unrelated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunCancelSuppressesReferencedOriginal(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
cancel := testAlert("https://api.weather.gov/alerts/urn:oid:cancel", "Cancel", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
cancel.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, cancel})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunReferenceIdentifierPrecedenceAndIDFallback(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
fromID := testAlert("urn:oid:from-id", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
fromIdentifier := testAlert("urn:oid:from-identifier", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
idFallback := testAlert("urn:oid:id-fallback", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update := testAlert("urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update.References = []model.AlertReference{
|
||||||
|
{ID: "urn:oid:from-id", Identifier: "urn:oid:from-identifier"},
|
||||||
|
{ID: "urn:oid:id-fallback"},
|
||||||
|
}
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{fromID, fromIdentifier, idFallback, update})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{"urn:oid:from-id", "urn:oid:update"})
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
@@ -358,32 +433,6 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
|||||||
assertDiscussionDays(t, run, []int{1})
|
assertDiscussionDays(t, run, []int{1})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
|
||||||
containsLocation := true
|
|
||||||
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, []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) {
|
||||||
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
@@ -399,7 +448,6 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
|||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||||
day := 1
|
day := 1
|
||||||
containsLocation := true
|
|
||||||
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
@@ -407,7 +455,6 @@ func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
|||||||
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
|
||||||
Day: &day,
|
Day: &day,
|
||||||
OutlookType: "categorical",
|
OutlookType: "categorical",
|
||||||
ContainsLocation: &containsLocation,
|
|
||||||
ActiveAt: &activeAt,
|
ActiveAt: &activeAt,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -537,14 +584,16 @@ func TestServicePropagatesErrors(t *testing.T) {
|
|||||||
|
|
||||||
func testAlertRun() *model.WeatherAlertRun {
|
func testAlertRun() *model.WeatherAlertRun {
|
||||||
return testAlertRunWithAlerts([]model.WeatherAlert{
|
return testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(12)),
|
||||||
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||||
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)),
|
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15), testTimePtr(15)),
|
||||||
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
||||||
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)),
|
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14), testTimePtr(14)),
|
||||||
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)),
|
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14), testTimePtr(14)),
|
||||||
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil),
|
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil, nil),
|
||||||
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14)),
|
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
|
||||||
|
testAlert("ends-preferred", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(14), testTimePtr(11)),
|
||||||
|
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(12), testTimePtr(14)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +610,7 @@ func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, expires *time.Time) model.WeatherAlert {
|
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, ends *time.Time, expires *time.Time) model.WeatherAlert {
|
||||||
refSent := testTime(8)
|
refSent := testTime(8)
|
||||||
return model.WeatherAlert{
|
return model.WeatherAlert{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -579,6 +628,7 @@ func testAlert(id string, messageType string, sent *time.Time, effective *time.T
|
|||||||
Sent: sent,
|
Sent: sent,
|
||||||
Effective: effective,
|
Effective: effective,
|
||||||
Onset: onset,
|
Onset: onset,
|
||||||
|
Ends: ends,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
AreaDescription: "St. Louis City",
|
AreaDescription: "St. Louis City",
|
||||||
SenderName: "NWS St. Louis",
|
SenderName: "NWS St. Louis",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ Headline: {{$alert.Headline}}
|
|||||||
{{- if $alert.Severity}}
|
{{- if $alert.Severity}}
|
||||||
Severity: {{$alert.Severity}}
|
Severity: {{$alert.Severity}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if $alert.Ends}}
|
||||||
|
Ends: {{$alert.Ends}}
|
||||||
|
{{- end}}
|
||||||
{{- if $alert.Expires}}
|
{{- if $alert.Expires}}
|
||||||
Expires: {{$alert.Expires}}
|
Expires: {{$alert.Expires}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{{- if .Data -}}
|
{{- if .Data -}}
|
||||||
Current Conditions
|
Current Conditions
|
||||||
|
Condition Code: {{.Data.ConditionCode}}
|
||||||
{{- if .Data.ConditionText}}
|
{{- if .Data.ConditionText}}
|
||||||
Condition: {{.Data.ConditionText}}
|
Condition: {{.Data.ConditionText}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user