16 Commits

Author SHA1 Message Date
0bccfc50d9 Implemented the weather forecast discussion product from weatherfeeder v0.8.3
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-28 17:36:35 -05:00
291a9178c8 Added new endpoints under /forecast/narrative
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-27 22:39:17 -05:00
78dc7817e9 Simplified the forecast schema and removed fields deprecated upstream in weatherfeeder
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-26 21:35:39 -05:00
dbefa8ed28 Added support for /forecast/hourly/today and /forecast/hourly/tomorrow endpoints
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-26 20:44:55 -05:00
b3ac19a65d Add timezone support to /forecast/hourly endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-26 20:06:17 -05:00
6806de3c0a Added support for rounding of values by default in API responses
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-20 11:30:11 -05:00
bb18bcfb15 Refactored the application structure to better separate concerns between files and packages
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-20 09:44:53 -05:00
8deb4fd12e Add a /conditions/current endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-19 23:16:26 -05:00
26a52f8c44 Add US unit support for weather observations and forecasts
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-19 22:36:15 -05:00
6e8adcc9cc Updated Dockerfile
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-19 21:38:29 -05:00
312e738b25 Patch the Dockerfile so that go build reuses the module cache
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 21:20:25 -05:00
d55b4be7ec Updated Dockerfile and woodpecker to use private Gitea login credentials
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 21:09:10 -05:00
56f96c4a7a Bugfix to accommodate private upstream repository for feedapi
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 20:58:01 -05:00
e7a9893824 Update go.mod
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 19:57:48 -05:00
a74591382a Add Dockerfile
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 19:55:41 -05:00
67beeb4d5e Add build image configuration for Woodpecker CI
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 19:53:54 -05:00
57 changed files with 4431 additions and 798 deletions

View File

@@ -0,0 +1,25 @@
when:
# Allow both normal runs (push) and UI-triggered runs (manual)
- event: [push, manual]
steps:
- name: build-and-push-image
image: harbor.maximumdirect.net/proxy-dockerhub/woodpeckerci/plugin-kaniko
environment:
GITEA_USER:
from_secret: GITEA_USER
GITEA_TOKEN:
from_secret: GITEA_TOKEN
settings:
registry: harbor.maximumdirect.net
repo: build/weatherapi
auto_tag: true
build_args_from_env:
- GITEA_USER
- GITEA_TOKEN
username:
from_secret: HARBOR_ROBOT_USER
password:
from_secret: HARBOR_ROBOT_TOKEN
cache: true
cache_repo: build-cache/weatherapi

87
Dockerfile Normal file
View File

@@ -0,0 +1,87 @@
# syntax=docker/dockerfile:1.6
ARG GO_VERSION=1.25
ARG GITEA_USER
ARG GITEA_TOKEN
############################
# Build stage
############################
FROM harbor.maximumdirect.net/proxy-dockerhub/golang:${GO_VERSION}-bookworm AS build
WORKDIR /src
ARG GITEA_USER
ARG GITEA_TOKEN
ENV GOPRIVATE=gitea.maximumdirect.net/ejr/* \
GONOSUMDB=gitea.maximumdirect.net/ejr/*
# Install baseline packages
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata git build-essential \
&& rm -rf /var/lib/apt/lists/*
# Cache dependencies first
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
git config --global url."https://${GITEA_USER}:${GITEA_TOKEN}@gitea.maximumdirect.net/".insteadOf "https://gitea.maximumdirect.net/" && \
go mod download && go mod verify && \
rm -f /root/.gitconfig
# Copy the rest of the source
COPY . .
# Default to a static build (no CGO)
# If errors, can build with: --build-arg CGO_ENABLED=1
ARG CGO_ENABLED=0
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ENV CGO_ENABLED=${CGO_ENABLED} \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
# Run tests before building the final binary
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
git config --global url."https://${GITEA_USER}:${GITEA_TOKEN}@gitea.maximumdirect.net/".insteadOf "https://gitea.maximumdirect.net/" && \
go test ./...
# Build the cmd entrypoint
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
git config --global url."https://${GITEA_USER}:${GITEA_TOKEN}@gitea.maximumdirect.net/".insteadOf "https://gitea.maximumdirect.net/" && \
go build \
-trimpath \
-ldflags="-s -w" \
-o /out/weatherapi \
./cmd/weatherapi
############################
# Runtime stage
############################
FROM harbor.maximumdirect.net/proxy-dockerhub/debian:bookworm-slim AS runtime
# Install runtime necessities
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata curl \
&& rm -rf /var/lib/apt/lists/*
# Define /weatherapi as the working directory
WORKDIR /weatherapi
# Create an unprivileged user
RUN useradd \
--uid 10001 \
--no-create-home \
--shell /usr/sbin/nologin \
weatherapi
# Copy the binary
COPY --chown=weatherapi:weatherapi --from=build /out/weatherapi /weatherapi/weatherapi
COPY --chown=weatherapi:weatherapi config.yml /weatherapi/config.yml
COPY --chown=weatherapi:weatherapi templates /weatherapi/templates
USER weatherapi
# The application expects config.yml in the same directory as the binary
ENTRYPOINT ["/weatherapi/weatherapi"]

View File

@@ -1,3 +1,31 @@
# weatherapi # weatherapi
A small HTTP API that serves a variety of weather-related endpoints. A small HTTP API that serves a variety of weather-related endpoints.
## Endpoints
- `GET /observations`
- `GET /conditions/current`
- `GET /alerts/active`
- `GET /discussion`
- `GET /forecast/hourly`
- `GET /forecast/hourly/today`
- `GET /forecast/hourly/tomorrow`
- `GET /forecast/narrative`
- `GET /forecast/narrative/today`
- `GET /forecast/narrative/tomorrow`
## Query Parameters
Shared weather query parameters:
- `format` (`json`, `xml`, `text`)
- `units` (`metric`, `us`)
Forecast endpoint query parameters:
- `precision` (`0`-`2`)
Forecast and discussion endpoint query parameters:
- `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset)

View File

@@ -1,3 +1,5 @@
// main.go wires configuration, dependencies, and HTTP runtime startup.
// Layer: cmd/weatherapi executable composition root.
package main package main
import ( import (
@@ -15,7 +17,7 @@ import (
"gitea.maximumdirect.net/ejr/feedapi/db" "gitea.maximumdirect.net/ejr/feedapi/db"
httpapi "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi" httpapi "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi"
wfpq "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/outbound/postgres" wfpq "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/outbound/postgres"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core" "gitea.maximumdirect.net/ejr/weatherapi/internal/app"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
@@ -60,7 +62,7 @@ func run(ctx context.Context, cfgPath string) error {
} }
repo := wfpq.NewRepository(primary) repo := wfpq.NewRepository(primary)
svc := core.NewService(repo) svc := app.NewService(repo)
defs := httpapi.Definitions(svc) defs := httpapi.Definitions(svc)
a, err := feedapp.New(cfg, a, err := feedapp.New(cfg,

8
go.mod
View File

@@ -3,13 +3,9 @@ module gitea.maximumdirect.net/ejr/weatherapi
go 1.25.5 go 1.25.5
require ( require (
gitea.maximumdirect.net/ejr/feedapi v0.0.0 gitea.maximumdirect.net/ejr/feedapi v0.1.0
gitea.maximumdirect.net/ejr/weatherfeeder v0.0.0 gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
) )
require gopkg.in/yaml.v3 v3.0.1 // indirect require gopkg.in/yaml.v3 v3.0.1 // indirect
replace gitea.maximumdirect.net/ejr/feedapi => ../feedapi
replace gitea.maximumdirect.net/ejr/weatherfeeder => ../weatherfeeder

4
go.sum
View File

@@ -1,3 +1,7 @@
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/weatherfeeder v0.8.3 h1:vH5p8zKiJ6D7JnbpA43iWKNEdgQg/VwKKaWlwF3AXXs=
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3/go.mod h1:YeHGpmJihwutT+2t8La8e2pPCusdxqiUNyZZOPqE33I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -0,0 +1,28 @@
// alerts_endpoint.go defines the /alerts/active endpoint behavior.
// Layer: adapters/inbound/httpapi alerts route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func alertsDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/alerts/active",
bindQuery,
func(ctx context.Context, req queryRequest) (any, error) {
run, err := svc.LatestAlertRun(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.AlertsPayload(run, req.Units)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("alerts_active.txt.tmpl"),
)
}

View File

@@ -0,0 +1,28 @@
// conditions_endpoint.go defines the /conditions/current endpoint behavior.
// Layer: adapters/inbound/httpapi current-conditions route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func conditionsDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/conditions/current",
bindPrecisionQuery,
func(ctx context.Context, req precisionQueryRequest) (any, error) {
conditions, err := svc.CurrentConditions(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.CurrentConditionsPayload(conditions, req.Units, req.Precision)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("conditions_current.txt.tmpl"),
)
}

View File

@@ -0,0 +1,28 @@
// discussion_endpoint.go defines the /discussion endpoint behavior.
// Layer: adapters/inbound/httpapi discussion route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func discussionDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/discussion",
bindTimezoneQuery,
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
run, err := svc.LatestForecastDiscussion(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.DiscussionPayload(run, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("discussion.txt.tmpl"),
)
}

View File

@@ -1,76 +1,16 @@
// endpoints.go registers all HTTP endpoint definitions for weatherapi.
// Layer: adapters/inbound/httpapi endpoint registry only.
package httpapi package httpapi
import ( import "gitea.maximumdirect.net/ejr/feedapi/endpoint"
"context"
"net/http"
"gitea.maximumdirect.net/ejr/feedapi/bind"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Service describes the weather use-cases needed by the HTTP adapter.
type Service interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
}
type emptyRequest struct{}
func Definitions(svc Service) []endpoint.Definition { func Definitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{ defs := []endpoint.Definition{
endpoint.GET( observationDefinition(svc),
"/observations", alertsDefinition(svc),
bindFormatOnly, discussionDefinition(svc),
func(ctx context.Context, _ emptyRequest) (any, error) { conditionsDefinition(svc),
obs, err := svc.LatestObservation(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: obs}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("observations.txt.tmpl"),
),
endpoint.GET(
"/forecast/hourly",
bindFormatOnly,
func(ctx context.Context, _ emptyRequest) (any, error) {
run, err := svc.LatestHourlyForecast(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: run}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("forecast_hourly.txt.tmpl"),
),
endpoint.GET(
"/alerts/active",
bindFormatOnly,
func(ctx context.Context, _ emptyRequest) (any, error) {
run, err := svc.LatestActiveAlerts(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: run}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("alerts_active.txt.tmpl"),
),
} }
} defs = append(defs, forecastDefinitions(svc)...)
return defs
func bindFormatOnly(r *http.Request) (emptyRequest, error) {
_, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowFormat: true,
RejectUnknown: true,
})
if err != nil {
return emptyRequest{}, err
}
return emptyRequest{}, nil
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
// forecast_endpoint.go defines forecast endpoint behavior.
// Layer: adapters/inbound/httpapi forecast route.
package httpapi
import (
"context"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type forecastDaySlice int
const (
forecastDaySliceAll forecastDaySlice = iota
forecastDaySliceToday
forecastDaySliceTomorrow
)
var forecastNow = time.Now
func forecastDefinitions(svc Service) []endpoint.Definition {
out := make([]endpoint.Definition, 0, 6)
out = append(out, forecastDefinitionSet(
"/forecast/hourly",
"forecast_hourly.txt.tmpl",
svc.LatestHourlyForecast,
)...)
out = append(out, forecastDefinitionSet(
"/forecast/narrative",
"forecast_narrative.txt.tmpl",
svc.LatestNarrativeForecast,
)...)
return out
}
func forecastDefinitionSet(
basePath string,
templateName string,
fetch func(context.Context) (*model.WeatherForecastRun, error),
) []endpoint.Definition {
return []endpoint.Definition{
forecastDefinition(basePath, forecastDaySliceAll, templateName, fetch),
forecastDefinition(basePath+"/today", forecastDaySliceToday, templateName, fetch),
forecastDefinition(basePath+"/tomorrow", forecastDaySliceTomorrow, templateName, fetch),
}
}
func forecastDefinition(
path string,
daySlice forecastDaySlice,
templateName string,
fetch func(context.Context) (*model.WeatherForecastRun, error),
) endpoint.Definition {
return endpoint.GET(
path,
bindForecastPrecisionQuery,
func(ctx context.Context, req precisionQueryRequest) (any, error) {
run, err := fetch(ctx)
if err != nil {
return nil, err
}
if daySlice != forecastDaySliceAll {
run = filterForecastRunByDaySlice(run, req.Timezone, daySlice)
}
return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate(templateName),
)
}
func filterForecastRunByDaySlice(run *model.WeatherForecastRun, tz *time.Location, daySlice forecastDaySlice) *model.WeatherForecastRun {
if run == nil {
return nil
}
loc := tz
if loc == nil {
loc = time.UTC
}
now := forecastNow().In(loc)
target := now
if daySlice == forecastDaySliceTomorrow {
target = target.AddDate(0, 0, 1)
}
year, month, day := target.Date()
periods := make([]model.WeatherForecastPeriod, 0, len(run.Periods))
for _, period := range run.Periods {
start := period.StartTime.In(loc)
y, m, d := start.Date()
if y == year && m == month && d == day {
periods = append(periods, period)
}
}
cloned := *run
cloned.Periods = periods
return &cloned
}

View File

@@ -0,0 +1,28 @@
// observations_endpoint.go defines the /observations endpoint behavior.
// Layer: adapters/inbound/httpapi observation route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func observationDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/observations",
bindPrecisionQuery,
func(ctx context.Context, req precisionQueryRequest) (any, error) {
obs, err := svc.LatestObservation(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.ObservationPayload(obs, req.Units, req.Precision)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("observations.txt.tmpl"),
)
}

View File

@@ -0,0 +1,12 @@
// alerts.go presents alert-run payloads.
// Layer: adapters/inbound/httpapi/presenter alerts payload mapping.
package presenter
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
func AlertsPayload(run *model.WeatherAlertRun, _ Units) any {
if run == nil {
return nil
}
return run
}

View File

@@ -0,0 +1,54 @@
// conditions.go presents current-conditions payloads in metric and US shapes.
// Layer: adapters/inbound/httpapi/presenter current-conditions payload mapping.
package presenter
import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
)
// CurrentConditionsResponse is the response shape for /conditions/current.
// Unit-bearing fields are populated according to the requested unit mode.
type CurrentConditionsResponse struct {
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
IsDayText string `json:"-" xml:"-"`
}
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
if conditions == nil {
return nil
}
out := CurrentConditionsResponse{
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
IsDay: copyBoolPtr(conditions.IsDay),
IsDayText: boolText(conditions.IsDay),
}
if units == UnitsUS {
out.TemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.TemperatureC), precision)
out.ApparentTemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.ApparentTemperatureC), precision)
out.DewpointF = roundedPtr(celsiusToFahrenheitPtr(conditions.DewpointC), precision)
out.WindSpeedMph = roundedPtr(scalePtr(conditions.WindSpeedKmh, kmhToMphFactor), precision)
return out
}
out.TemperatureC = roundedPtr(copyFloat64Ptr(conditions.TemperatureC), precision)
out.ApparentTemperatureC = roundedPtr(copyFloat64Ptr(conditions.ApparentTemperatureC), precision)
out.DewpointC = roundedPtr(copyFloat64Ptr(conditions.DewpointC), precision)
out.WindSpeedKmh = roundedPtr(copyFloat64Ptr(conditions.WindSpeedKmh), precision)
return out
}

View File

@@ -0,0 +1,13 @@
// constants.go defines unit conversion constants for payload presentation.
// Layer: adapters/inbound/httpapi/presenter conversion constants.
package presenter
const (
celsiusToFahrenheitScale = 9.0 / 5.0
celsiusToFahrenheitOffset = 32.0
kmhToMphFactor = 0.621371192237334
metersToMilesFactor = 0.000621371192237334
metersToFeetFactor = 3.280839895013123
paToInHgFactor = 0.000295299830714045
mmToInchesFactor = 0.03937007874015748
)

View File

@@ -0,0 +1,38 @@
// discussion.go presents forecast discussion payloads.
// Layer: adapters/inbound/httpapi/presenter discussion payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
out := model.WeatherForecastDiscussion{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
KeyMessages: append([]string(nil), run.KeyMessages...),
ShortTerm: copyDiscussionSection(run.ShortTerm, tz),
LongTerm: copyDiscussionSection(run.LongTerm, tz),
}
return &out
}
func copyDiscussionSection(in *model.WeatherForecastDiscussionSection, tz *time.Location) *model.WeatherForecastDiscussionSection {
if in == nil {
return nil
}
return &model.WeatherForecastDiscussionSection{
Qualifier: in.Qualifier,
IssuedAt: inLocationTimePtr(in.IssuedAt, tz),
Text: in.Text,
}
}

View File

@@ -0,0 +1,134 @@
// forecast.go presents forecast payloads in metric and US shapes.
// Layer: adapters/inbound/httpapi/presenter forecast payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// WeatherForecastRunUS is the US-customary response shape for forecasts.
type WeatherForecastRunUS struct {
LocationID string `json:"locationId,omitempty" xml:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty" xml:"locationName,omitempty"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
Product model.ForecastProduct `json:"product" xml:"product"`
Latitude *float64 `json:"latitude,omitempty" xml:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty" xml:"longitude,omitempty"`
ElevationFeet *float64 `json:"elevationFeet,omitempty" xml:"elevationFeet,omitempty"`
Periods []WeatherForecastPeriodUS `json:"periods" xml:"periods"`
}
// WeatherForecastPeriodUS is the US-customary response shape for forecast periods.
type WeatherForecastPeriodUS struct {
StartTime time.Time `json:"startTime" xml:"startTime"`
EndTime time.Time `json:"endTime" xml:"endTime"`
Name string `json:"name,omitempty" xml:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty" xml:"temperatureFMin,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty" xml:"temperatureFMax,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty" xml:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty" xml:"probabilityOfPrecipitationPercent,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty" xml:"precipitationAmountIn,omitempty"`
SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty" xml:"snowfallDepthIn,omitempty"`
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
}
func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, tz *time.Location) any {
if run == nil {
return nil
}
if units == UnitsUS {
out := WeatherForecastRunUS{
LocationID: run.LocationID,
LocationName: run.LocationName,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
Product: run.Product,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
ElevationFeet: roundedPtr(scalePtr(run.ElevationMeters, metersToFeetFactor), precision),
Periods: make([]WeatherForecastPeriodUS, 0, len(run.Periods)),
}
for _, p := range run.Periods {
out.Periods = append(out.Periods, WeatherForecastPeriodUS{
StartTime: inLocationTime(p.StartTime, tz),
EndTime: inLocationTime(p.EndTime, tz),
Name: p.Name,
IsDay: copyBoolPtr(p.IsDay),
ConditionCode: p.ConditionCode,
TextDescription: p.TextDescription,
TemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureC), precision),
TemperatureFMin: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMin), precision),
TemperatureFMax: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMax), precision),
DewpointF: roundedPtr(celsiusToFahrenheitPtr(p.DewpointC), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(p.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(p.WindDirectionDegrees), precision),
WindSpeedMph: roundedPtr(scalePtr(p.WindSpeedKmh, kmhToMphFactor), precision),
WindGustMph: roundedPtr(scalePtr(p.WindGustKmh, kmhToMphFactor), precision),
BarometricPressureInHg: roundedPtr(scalePtr(p.BarometricPressurePa, paToInHgFactor), precision),
VisibilityMiles: roundedPtr(scalePtr(p.VisibilityMeters, metersToMilesFactor), precision),
ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.ApparentTemperatureC), precision),
CloudCoverPercent: roundedPtr(copyFloat64Ptr(p.CloudCoverPercent), precision),
ProbabilityOfPrecipitationPercent: roundedPtr(copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), precision),
PrecipitationAmountIn: roundedPtr(scalePtr(p.PrecipitationAmountMm, mmToInchesFactor), precision),
SnowfallDepthIn: roundedPtr(scalePtr(p.SnowfallDepthMM, mmToInchesFactor), precision),
UVIndex: roundedPtr(copyFloat64Ptr(p.UVIndex), precision),
})
}
return out
}
out := model.WeatherForecastRun{
LocationID: run.LocationID,
LocationName: run.LocationName,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
Product: run.Product,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
ElevationMeters: roundedPtr(copyFloat64Ptr(run.ElevationMeters), precision),
Periods: make([]model.WeatherForecastPeriod, 0, len(run.Periods)),
}
for _, p := range run.Periods {
out.Periods = append(out.Periods, model.WeatherForecastPeriod{
StartTime: inLocationTime(p.StartTime, tz),
EndTime: inLocationTime(p.EndTime, tz),
Name: p.Name,
IsDay: copyBoolPtr(p.IsDay),
ConditionCode: p.ConditionCode,
TextDescription: p.TextDescription,
TemperatureC: roundedPtr(copyFloat64Ptr(p.TemperatureC), precision),
TemperatureCMin: roundedPtr(copyFloat64Ptr(p.TemperatureCMin), precision),
TemperatureCMax: roundedPtr(copyFloat64Ptr(p.TemperatureCMax), precision),
DewpointC: roundedPtr(copyFloat64Ptr(p.DewpointC), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(p.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(p.WindDirectionDegrees), precision),
WindSpeedKmh: roundedPtr(copyFloat64Ptr(p.WindSpeedKmh), precision),
WindGustKmh: roundedPtr(copyFloat64Ptr(p.WindGustKmh), precision),
BarometricPressurePa: roundedPtr(copyFloat64Ptr(p.BarometricPressurePa), precision),
VisibilityMeters: roundedPtr(copyFloat64Ptr(p.VisibilityMeters), precision),
ApparentTemperatureC: roundedPtr(copyFloat64Ptr(p.ApparentTemperatureC), precision),
CloudCoverPercent: roundedPtr(copyFloat64Ptr(p.CloudCoverPercent), precision),
ProbabilityOfPrecipitationPercent: roundedPtr(copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), precision),
PrecipitationAmountMm: roundedPtr(copyFloat64Ptr(p.PrecipitationAmountMm), precision),
SnowfallDepthMM: roundedPtr(copyFloat64Ptr(p.SnowfallDepthMM), precision),
UVIndex: roundedPtr(copyFloat64Ptr(p.UVIndex), precision),
})
}
return &out
}

View File

@@ -0,0 +1,89 @@
// helpers.go contains shared pointer and scalar conversion helpers.
// Layer: adapters/inbound/httpapi/presenter helper functions.
package presenter
import (
"math"
"time"
)
func celsiusToFahrenheitPtr(v *float64) *float64 {
if v == nil {
return nil
}
out := (*v * celsiusToFahrenheitScale) + celsiusToFahrenheitOffset
return &out
}
func scalePtr(v *float64, factor float64) *float64 {
if v == nil {
return nil
}
out := *v * factor
return &out
}
func copyFloat64Ptr(v *float64) *float64 {
if v == nil {
return nil
}
out := *v
return &out
}
func copyBoolPtr(v *bool) *bool {
if v == nil {
return nil
}
out := *v
return &out
}
func copyTimePtr(v *time.Time) *time.Time {
if v == nil {
return nil
}
out := *v
return &out
}
func inLocationTime(v time.Time, loc *time.Location) time.Time {
if loc == nil {
return v
}
return v.In(loc)
}
func inLocationTimePtr(v *time.Time, loc *time.Location) *time.Time {
if v == nil {
return nil
}
out := inLocationTime(*v, loc)
return &out
}
func boolText(v *bool) string {
if v == nil {
return ""
}
if *v {
return "true"
}
return "false"
}
func roundedPtr(v *float64, precision int) *float64 {
if v == nil {
return nil
}
out := roundFloat(*v, precision)
return &out
}
func roundFloat(v float64, precision int) float64 {
if precision <= 0 {
return math.Round(v)
}
factor := math.Pow10(precision)
return math.Round(v*factor) / factor
}

View File

@@ -0,0 +1,76 @@
// observation.go presents observation payloads in metric and US shapes.
// Layer: adapters/inbound/httpapi/presenter observation payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// WeatherObservationUS is the US-customary response shape for observations.
type WeatherObservationUS struct {
StationID string `json:"stationId,omitempty" xml:"stationId,omitempty"`
StationName string `json:"stationName,omitempty" xml:"stationName,omitempty"`
Timestamp time.Time `json:"timestamp" xml:"timestamp"`
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"`
}
func ObservationPayload(obs *model.WeatherObservation, units Units, precision int) any {
if obs == nil {
return nil
}
if units == UnitsUS {
converted := WeatherObservationUS{
StationID: obs.StationID,
StationName: obs.StationName,
Timestamp: obs.Timestamp,
ConditionCode: obs.ConditionCode,
IsDay: copyBoolPtr(obs.IsDay),
TextDescription: obs.TextDescription,
TemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.TemperatureC), precision),
DewpointF: roundedPtr(celsiusToFahrenheitPtr(obs.DewpointC), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(obs.WindDirectionDegrees), precision),
WindSpeedMph: roundedPtr(scalePtr(obs.WindSpeedKmh, kmhToMphFactor), precision),
WindGustMph: roundedPtr(scalePtr(obs.WindGustKmh, kmhToMphFactor), precision),
BarometricPressureInHg: roundedPtr(scalePtr(obs.BarometricPressurePa, paToInHgFactor), precision),
VisibilityMiles: roundedPtr(scalePtr(obs.VisibilityMeters, metersToMilesFactor), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(obs.RelativeHumidityPercent), precision),
ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.ApparentTemperatureC), precision),
PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...),
}
return converted
}
rounded := model.WeatherObservation{
StationID: obs.StationID,
StationName: obs.StationName,
Timestamp: obs.Timestamp,
ConditionCode: obs.ConditionCode,
IsDay: copyBoolPtr(obs.IsDay),
TextDescription: obs.TextDescription,
TemperatureC: roundedPtr(copyFloat64Ptr(obs.TemperatureC), precision),
DewpointC: roundedPtr(copyFloat64Ptr(obs.DewpointC), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(obs.WindDirectionDegrees), precision),
WindSpeedKmh: roundedPtr(copyFloat64Ptr(obs.WindSpeedKmh), precision),
WindGustKmh: roundedPtr(copyFloat64Ptr(obs.WindGustKmh), precision),
BarometricPressurePa: roundedPtr(copyFloat64Ptr(obs.BarometricPressurePa), precision),
VisibilityMeters: roundedPtr(copyFloat64Ptr(obs.VisibilityMeters), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(obs.RelativeHumidityPercent), precision),
ApparentTemperatureC: roundedPtr(copyFloat64Ptr(obs.ApparentTemperatureC), precision),
PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...),
}
return &rounded
}

View File

@@ -0,0 +1,395 @@
// payload_test.go validates presenter conversion and payload shaping behavior.
// Layer: adapters/inbound/httpapi/presenter unit and schema tests.
package presenter
import (
"encoding/json"
"math"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestObservationPayloadUS(t *testing.T) {
obs := &model.WeatherObservation{
StationID: "KSTL",
Timestamp: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
ConditionCode: 2,
TemperatureC: float64Ptr(20),
DewpointC: float64Ptr(10),
WindSpeedKmh: float64Ptr(100),
WindGustKmh: float64Ptr(80),
BarometricPressurePa: float64Ptr(101325),
VisibilityMeters: float64Ptr(1609.344),
ApparentTemperatureC: float64Ptr(25),
RelativeHumidityPercent: float64Ptr(50),
}
payload := ObservationPayload(obs, UnitsUS, 2)
converted, ok := payload.(WeatherObservationUS)
if !ok {
t.Fatalf("expected WeatherObservationUS payload, got %T", payload)
}
assertApprox(t, converted.TemperatureF, 68.0, 0.0001)
assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001)
assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001)
assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001)
if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil {
t.Fatalf("expected converted Fahrenheit fields to be populated")
}
}
func TestForecastPayloadUS(t *testing.T) {
issuedAt := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(1 * time.Hour)
run := &model.WeatherForecastRun{
LocationID: "stl",
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Product: model.ForecastProductHourly,
ElevationMeters: float64Ptr(1000),
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(1 * time.Hour),
ConditionCode: 63,
TemperatureC: float64Ptr(0),
TemperatureCMin: float64Ptr(-5),
TemperatureCMax: float64Ptr(5),
WindSpeedKmh: float64Ptr(64.37376),
PrecipitationAmountMm: float64Ptr(25.4),
SnowfallDepthMM: float64Ptr(50.8),
}},
}
payload := ForecastPayload(run, UnitsUS, 2, nil)
converted, ok := payload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
}
assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001)
if len(converted.Periods) != 1 {
t.Fatalf("expected 1 period, got %d", len(converted.Periods))
}
period := converted.Periods[0]
assertApprox(t, period.TemperatureF, 32.0, 0.0001)
assertApprox(t, period.TemperatureFMin, 23.0, 0.0001)
assertApprox(t, period.TemperatureFMax, 41.0, 0.0001)
assertApprox(t, period.WindSpeedMph, 40.0, 0.0001)
assertApprox(t, period.PrecipitationAmountIn, 1.0, 0.0001)
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
}
func TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) {
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
Periods: []model.WeatherForecastPeriod{{
StartTime: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 20, 13, 0, 0, 0, time.UTC),
ConditionCode: model.WMOUnknown,
TextDescription: "Cloudy",
}},
}
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsMetric, 0, nil))
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsUS, 0, nil))
}
func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt.Add(1 * time.Hour),
EndTime: issuedAt.Add(2 * time.Hour),
ConditionCode: model.WMOUnknown,
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
if !metric.IssuedAt.UTC().Equal(issuedAt) {
t.Fatalf("expected metric issuedAt to preserve instant")
}
usPayload := ForecastPayload(run, UnitsUS, 0, loc)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)
// Source model remains untouched.
assertOffsetSeconds(t, run.IssuedAt, 0)
assertOffsetSeconds(t, *run.UpdatedAt, 0)
}
func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(time.Hour)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(time.Hour),
ConditionCode: model.WMOUnknown,
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
if metric == run {
t.Fatalf("expected metric payload to be copied")
}
if metric.UpdatedAt == run.UpdatedAt {
t.Fatalf("expected updatedAt pointer to be copied")
}
if !metric.IssuedAt.Equal(run.IssuedAt) {
t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
}
assertOffsetSeconds(t, metric.IssuedAt, 0)
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
}
func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
KeyMessages: []string{"msg one"},
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
}
payload := DiscussionPayload(run, UnitsMetric, loc)
discussion, ok := payload.(*model.WeatherForecastDiscussion)
if !ok {
t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload)
}
if discussion == run {
t.Fatalf("expected discussion payload to be copied")
}
if discussion.ShortTerm == run.ShortTerm {
t.Fatalf("expected shortTerm pointer to be copied")
}
assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60)
if discussion.KeyMessages[0] != "msg one" {
t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages)
}
assertOffsetSeconds(t, run.IssuedAt, 0)
}
func TestMetricCopyAndNilHandling(t *testing.T) {
obs := &model.WeatherObservation{
TemperatureC: float64Ptr(20.6),
}
metric := ObservationPayload(obs, UnitsMetric, 0)
metricObs, ok := metric.(*model.WeatherObservation)
if !ok {
t.Fatalf("expected metric payload to remain model type, got %T", metric)
}
if metricObs == obs {
t.Fatalf("expected metric payload to be copied")
}
assertApprox(t, metricObs.TemperatureC, 21, 0.0001)
assertApprox(t, obs.TemperatureC, 20.6, 0.0001)
if metricObs.TemperatureC == obs.TemperatureC {
t.Fatalf("expected temperature pointer copy, got same pointer")
}
if ObservationPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil observation input to return nil payload")
}
if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
t.Fatalf("expected nil forecast input to return nil payload")
}
if AlertsPayload(nil, UnitsUS) != nil {
t.Fatalf("expected nil alerts input to return nil payload")
}
if DiscussionPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil discussion input to return nil payload")
}
if CurrentConditionsPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil current conditions input to return nil payload")
}
}
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
conditions := &app.CurrentConditions{
TemperatureC: float64Ptr(20),
ApparentTemperatureC: float64Ptr(18),
DewpointC: float64Ptr(10),
RelativeHumidityPercent: float64Ptr(55),
WindSpeedKmh: float64Ptr(100),
WindDirectionDegrees: float64Ptr(225),
ConditionCode: 0,
IsDay: boolPtr(true),
}
metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2)
metric, ok := metricPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
}
assertApprox(t, metric.TemperatureC, 20, 0.0001)
assertApprox(t, metric.WindSpeedKmh, 100, 0.0001)
if metric.TemperatureF != nil || metric.WindSpeedMph != nil {
t.Fatalf("expected US fields omitted for metric payload")
}
if metric.ConditionText != "Sunny" {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
}
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
}
assertApprox(t, us.TemperatureF, 68, 0.0001)
assertApprox(t, us.WindSpeedMph, 62.14, 0.0001)
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload")
}
}
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
night := false
payload := CurrentConditionsPayload(&app.CurrentConditions{
ConditionCode: 0,
IsDay: &night,
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
if metric.ConditionText != "Clear" {
t.Fatalf("expected condition text Clear, got %q", metric.ConditionText)
}
}
func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) {
payload := CurrentConditionsPayload(&app.CurrentConditions{
TemperatureC: float64Ptr(-1.5),
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
assertApprox(t, metric.TemperatureC, -2, 0.0001)
}
func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
run := &model.WeatherForecastRun{
Latitude: float64Ptr(38.627123),
Longitude: float64Ptr(-90.199456),
ElevationMeters: float64Ptr(10.499),
Product: model.ForecastProductHourly,
IssuedAt: time.Now().UTC(),
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertApprox(t, metric.Latitude, 38.627123, 0.000001)
assertApprox(t, metric.Longitude, -90.199456, 0.000001)
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertApprox(t, us.Latitude, 38.627123, 0.000001)
assertApprox(t, us.Longitude, -90.199456, 0.000001)
}
func float64Ptr(v float64) *float64 {
return &v
}
func boolPtr(v bool) *bool {
return &v
}
func assertApprox(t *testing.T, got *float64, want, eps float64) {
t.Helper()
if got == nil {
t.Fatalf("expected value near %f, got nil", want)
}
if math.Abs(*got-want) > eps {
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
}
}
func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
t.Helper()
_, got := ts.Zone()
if got != want {
t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
}
}
func assertForecastPayloadHasNoLegacyDescriptionFields(t *testing.T, payload any) {
t.Helper()
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal(payload) error = %v", err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("json.Unmarshal(payload) error = %v", err)
}
periodsRaw, ok := root["periods"].([]any)
if !ok || len(periodsRaw) == 0 {
t.Fatalf("expected non-empty periods in payload: %#v", root["periods"])
}
period, ok := periodsRaw[0].(map[string]any)
if !ok {
t.Fatalf("expected first period map, got %#v", periodsRaw[0])
}
for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} {
if _, exists := period[key]; exists {
t.Fatalf("unexpected legacy field %q in payload period: %#v", key, period)
}
}
if period["textDescription"] != "Cloudy" {
t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"])
}
}

View File

@@ -0,0 +1,11 @@
// units.go defines response unit modes for HTTP presentation.
// Layer: adapters/inbound/httpapi/presenter unit selection.
package presenter
// Units controls response-unit output formatting.
type Units string
const (
UnitsMetric Units = "metric"
UnitsUS Units = "us"
)

View File

@@ -0,0 +1,137 @@
// query_bind.go binds endpoint query parameters into typed request config.
// Layer: adapters/inbound/httpapi request binding.
package httpapi
import (
"net/http"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedapi/bind"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
type queryRequest struct {
Units presenter.Units
}
type precisionQueryRequest struct {
Units presenter.Units
Precision int
Timezone *time.Location
}
type timezoneQueryRequest struct {
Units presenter.Units
Timezone *time.Location
}
func bindQuery(r *http.Request) (queryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
})
if err != nil {
return queryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
return queryRequest{Units: units}, nil
}
func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, false)
}
func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, true)
}
func bindTimezoneQuery(r *http.Request) (timezoneQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, "tz", "TZ")
if err != nil {
return timezoneQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
tz, err := parseTimezoneQuery(r)
if err != nil {
return timezoneQueryRequest{}, err
}
return timezoneQueryRequest{
Units: units,
Timezone: tz,
}, nil
}
func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
normalizeCommonQueryValue(r, "precision")
allowedExtra := []string{"precision"}
if allowTimezone {
allowedExtra = append(allowedExtra, "tz", "TZ")
}
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, allowedExtra...)
if err != nil {
return precisionQueryRequest{}, err
}
precision, err := bind.OptionalInt(r, "precision", 0)
if err != nil {
return precisionQueryRequest{}, err
}
if err := bind.MinInt(precision, 0, "precision"); err != nil {
return precisionQueryRequest{}, err
}
if err := bind.MaxInt(precision, 2, "precision"); err != nil {
return precisionQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
var tz *time.Location
if allowTimezone {
tz, err = parseTimezoneQuery(r)
if err != nil {
return precisionQueryRequest{}, err
}
}
return precisionQueryRequest{
Units: units,
Precision: precision,
Timezone: tz,
}, nil
}

View File

@@ -0,0 +1,23 @@
// query_normalize.go normalizes common query values before binding.
// Layer: adapters/inbound/httpapi request pre-processing.
package httpapi
import (
"net/http"
"strings"
)
func normalizeCommonQueryValue(r *http.Request, key string) {
q := r.URL.Query()
values, ok := q[key]
if !ok || len(values) == 0 {
return
}
normalized := strings.ToLower(strings.TrimSpace(values[0]))
if normalized == values[0] {
return
}
q.Set(key, normalized)
r.URL.RawQuery = q.Encode()
}

View File

@@ -0,0 +1,140 @@
// query_timezone.go parses and validates timezone query parameters.
// Layer: adapters/inbound/httpapi request binding helpers.
package httpapi
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
)
const maxUTCOffsetSeconds = 14 * 60 * 60
var usTimezoneAbbreviations = map[string]int{
"CDT": -5 * 60 * 60,
"CST": -6 * 60 * 60,
"EDT": -4 * 60 * 60,
"EST": -5 * 60 * 60,
"MDT": -6 * 60 * 60,
"MST": -7 * 60 * 60,
"PDT": -7 * 60 * 60,
"PST": -8 * 60 * 60,
}
var timezoneAliases = map[string]string{
"chicago": "America/Chicago",
"stl": "America/Chicago",
}
func parseTimezoneQuery(r *http.Request) (*time.Location, error) {
lower := strings.TrimSpace(r.URL.Query().Get("tz"))
upper := strings.TrimSpace(r.URL.Query().Get("TZ"))
if lower != "" && upper != "" && !strings.EqualFold(lower, upper) {
return nil, apierrors.InvalidParameter("tz and TZ must match when both are provided")
}
raw := lower
if raw == "" {
raw = upper
}
if raw == "" {
return nil, nil
}
loc, err := parseTimezoneValue(raw)
if err != nil {
return nil, apierrors.InvalidParameter(err.Error())
}
return loc, nil
}
func parseTimezoneValue(raw string) (*time.Location, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if offsetSeconds, ok, err := parseUTCOffsetSeconds(raw); ok {
if err != nil {
return nil, err
}
return time.FixedZone(formatUTCOffsetName(offsetSeconds), offsetSeconds), nil
}
if offsetSeconds, ok := usTimezoneAbbreviations[strings.ToUpper(raw)]; ok {
return time.FixedZone(strings.ToUpper(raw), offsetSeconds), nil
}
if alias, ok := timezoneAliases[strings.ToLower(raw)]; ok {
raw = alias
}
loc, err := time.LoadLocation(raw)
if err != nil {
return nil, fmt.Errorf("tz must be a valid timezone")
}
return loc, nil
}
func parseUTCOffsetSeconds(raw string) (int, bool, error) {
if len(raw) < 2 {
return 0, false, nil
}
sign := raw[0]
if sign != '+' && sign != '-' {
return 0, false, nil
}
remainder := raw[1:]
parts := strings.Split(remainder, ":")
if len(parts) > 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
hours, err := strconv.Atoi(parts[0])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
if hours < 0 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes := 0
if len(parts) == 2 {
if len(parts[1]) != 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes, err = strconv.Atoi(parts[1])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
}
if minutes < 0 || minutes > 59 {
return 0, true, fmt.Errorf("tz offset minutes must be between 00 and 59")
}
offset := (hours * 60 * 60) + (minutes * 60)
if sign == '-' {
offset = -offset
}
if offset < -maxUTCOffsetSeconds || offset > maxUTCOffsetSeconds {
return 0, true, fmt.Errorf("tz offset must be between -14 and +14 hours")
}
return offset, true, nil
}
func formatUTCOffsetName(offsetSeconds int) string {
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
hours := offsetSeconds / 3600
minutes := (offsetSeconds % 3600) / 60
return fmt.Sprintf("UTC%s%02d:%02d", sign, hours, minutes)
}

View File

@@ -0,0 +1,20 @@
// service.go defines the inbound service contract consumed by HTTP handlers.
// Layer: adapters/inbound/httpapi boundary to application service.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Service describes weather resource queries needed by the HTTP adapter.
type Service interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
}

View File

@@ -0,0 +1,70 @@
// alerts_mapper.go maps alert rows into weather model payloads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
func mapAlertRunParentRow(row alertRunParentRow) model.WeatherAlertRun {
return model.WeatherAlertRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
AsOf: row.AsOf.UTC(),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
}
}
func mapAlertRow(row alertRow) indexedAlert {
return indexedAlert{
Index: row.AlertIndex,
Alert: model.WeatherAlert{
ID: row.AlertID,
Event: stringValue(row.Event),
Headline: stringValue(row.Headline),
Severity: stringValue(row.Severity),
Urgency: stringValue(row.Urgency),
Certainty: stringValue(row.Certainty),
Status: stringValue(row.Status),
MessageType: stringValue(row.MessageType),
Category: stringValue(row.Category),
Response: stringValue(row.Response),
Description: stringValue(row.Description),
Instruction: stringValue(row.Instruction),
Sent: timePtr(row.Sent),
Effective: timePtr(row.Effective),
Onset: timePtr(row.Onset),
Expires: timePtr(row.Expires),
AreaDescription: stringValue(row.AreaDescription),
SenderName: stringValue(row.SenderName),
},
}
}
func mapAlertReferenceRow(row alertReferenceRow) indexedAlertReference {
return indexedAlertReference{
AlertIndex: row.AlertIndex,
Reference: model.AlertReference{
ID: stringValue(row.ID),
Identifier: stringValue(row.Identifier),
Sender: stringValue(row.Sender),
Sent: timePtr(row.Sent),
},
}
}
func attachAlertReferences(alerts []indexedAlert, references []indexedAlertReference) []model.WeatherAlert {
refsByAlertIndex := make(map[int][]model.AlertReference, len(alerts))
for _, ref := range references {
refsByAlertIndex[ref.AlertIndex] = append(refsByAlertIndex[ref.AlertIndex], ref.Reference)
}
out := make([]model.WeatherAlert, 0, len(alerts))
for _, alert := range alerts {
mapped := alert.Alert
if refs := refsByAlertIndex[alert.Index]; len(refs) > 0 {
mapped.References = refs
}
out = append(out, mapped)
}
return out
}

View File

@@ -0,0 +1,53 @@
// alerts_queries.go contains SQL text for alert-run reads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
const (
queryLatestAlertRun = `
SELECT
event_id,
location_id,
location_name,
as_of,
latitude,
longitude
FROM alert_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryAlerts = `
SELECT
alert_index,
alert_id,
event,
headline,
severity,
urgency,
certainty,
status,
message_type,
category,
response,
description,
instruction,
sent,
effective,
onset,
expires,
area_description,
sender_name
FROM alerts
WHERE run_event_id = $1
ORDER BY alert_index ASC`
queryAlertReferences = `
SELECT
alert_index,
id,
identifier,
sender,
sent
FROM alert_references
WHERE run_event_id = $1
ORDER BY alert_index ASC, reference_index ASC`
)

View File

@@ -0,0 +1,110 @@
// alerts_read.go executes alert-run, alerts, and reference queries.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row alertRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestAlertRun).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.AsOf,
&row.Latitude,
&row.Longitude,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest alert run: %w", err)
}
run := mapAlertRunParentRow(row)
alerts, err := r.loadAlerts(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Alerts = alerts
return &run, nil
}
func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.WeatherAlert, error) {
alertsRows, err := r.db.QueryContext(ctx, queryAlerts, eventID)
if err != nil {
return nil, fmt.Errorf("query alerts: %w", err)
}
defer alertsRows.Close()
indexedAlerts := make([]indexedAlert, 0)
for alertsRows.Next() {
var row alertRow
if err := alertsRows.Scan(
&row.AlertIndex,
&row.AlertID,
&row.Event,
&row.Headline,
&row.Severity,
&row.Urgency,
&row.Certainty,
&row.Status,
&row.MessageType,
&row.Category,
&row.Response,
&row.Description,
&row.Instruction,
&row.Sent,
&row.Effective,
&row.Onset,
&row.Expires,
&row.AreaDescription,
&row.SenderName,
); err != nil {
return nil, fmt.Errorf("scan alerts row: %w", err)
}
indexedAlerts = append(indexedAlerts, mapAlertRow(row))
}
if err := alertsRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alerts rows: %w", err)
}
referenceRows, err := r.db.QueryContext(ctx, queryAlertReferences, eventID)
if err != nil {
return nil, fmt.Errorf("query alert references: %w", err)
}
defer referenceRows.Close()
indexedReferences := make([]indexedAlertReference, 0)
for referenceRows.Next() {
var row alertReferenceRow
if err := referenceRows.Scan(
&row.AlertIndex,
&row.ID,
&row.Identifier,
&row.Sender,
&row.Sent,
); err != nil {
return nil, fmt.Errorf("scan alert reference row: %w", err)
}
indexedReferences = append(indexedReferences, mapAlertReferenceRow(row))
}
if err := referenceRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alert reference rows: %w", err)
}
return attachAlertReferences(indexedAlerts, indexedReferences), nil
}

View File

@@ -0,0 +1,59 @@
// alerts_rows.go defines row DTOs for alert-run, alert, and reference reads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import (
"database/sql"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type alertRunParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
AsOf time.Time
Latitude sql.NullFloat64
Longitude sql.NullFloat64
}
type alertRow struct {
AlertIndex int
AlertID string
Event sql.NullString
Headline sql.NullString
Severity sql.NullString
Urgency sql.NullString
Certainty sql.NullString
Status sql.NullString
MessageType sql.NullString
Category sql.NullString
Response sql.NullString
Description sql.NullString
Instruction sql.NullString
Sent sql.NullTime
Effective sql.NullTime
Onset sql.NullTime
Expires sql.NullTime
AreaDescription sql.NullString
SenderName sql.NullString
}
type indexedAlert struct {
Index int
Alert model.WeatherAlert
}
type alertReferenceRow struct {
AlertIndex int
ID sql.NullString
Identifier sql.NullString
Sender sql.NullString
Sent sql.NullTime
}
type indexedAlertReference struct {
AlertIndex int
Reference model.AlertReference
}

View File

@@ -0,0 +1,30 @@
// conditions_mapper.go maps current-conditions DB rows into app models.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions {
if row.SampleCount == 0 {
return nil
}
conditionCode := model.WMOUnknown
if row.ConditionCode.Valid {
conditionCode = model.WMOCode(row.ConditionCode.Int64)
}
return &app.CurrentConditions{
TemperatureC: float64Ptr(row.TemperatureC),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
DewpointC: float64Ptr(row.DewpointC),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
ConditionCode: conditionCode,
IsDay: boolPtr(row.IsDay),
}
}

View File

@@ -0,0 +1,50 @@
// conditions_queries.go contains SQL text for current-conditions reads.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
const (
queryCurrentConditions = `
WITH windowed AS (
SELECT
temperature_c,
apparent_temperature_c,
dewpoint_c,
relative_humidity_percent,
wind_speed_kmh,
wind_direction_degrees,
condition_code,
is_day,
observed_at
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
)
SELECT
COUNT(*) AS sample_count,
AVG(temperature_c) AS temperature_c,
AVG(apparent_temperature_c) AS apparent_temperature_c,
AVG(dewpoint_c) AS dewpoint_c,
AVG(relative_humidity_percent) AS relative_humidity_percent,
AVG(wind_speed_kmh) AS wind_speed_kmh,
CASE
WHEN atan2d(
AVG(sind(wind_direction_degrees)),
AVG(cosd(wind_direction_degrees))
) < 0
THEN atan2d(
AVG(sind(wind_direction_degrees)),
AVG(cosd(wind_direction_degrees))
) + 360.0
ELSE atan2d(
AVG(sind(wind_direction_degrees)),
AVG(cosd(wind_direction_degrees))
)
END AS wind_direction_degrees,
MAX(condition_code) AS condition_code,
(
SELECT is_day
FROM windowed
ORDER BY observed_at DESC
LIMIT 1
) AS is_day
FROM windowed`
)

View File

@@ -0,0 +1,39 @@
// conditions_read.go executes current-conditions queries.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
)
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row currentConditionsRow
err := r.db.QueryRowContext(ctx, queryCurrentConditions, observationWindowMinutes).Scan(
&row.SampleCount,
&row.TemperatureC,
&row.ApparentTemperatureC,
&row.DewpointC,
&row.RelativeHumidityPercent,
&row.WindSpeedKmh,
&row.WindDirectionDegrees,
&row.ConditionCode,
&row.IsDay,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query current conditions: %w", err)
}
return mapCurrentConditionsRow(row), nil
}

View File

@@ -0,0 +1,17 @@
// conditions_rows.go defines row DTOs for current-conditions reads.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import "database/sql"
type currentConditionsRow struct {
SampleCount int64
TemperatureC sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
DewpointC sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
ConditionCode sql.NullInt64
IsDay sql.NullBool
}

View File

@@ -0,0 +1,45 @@
// discussion_mapper.go maps forecast discussion rows into weather model payloads.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
import (
"database/sql"
"strings"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapDiscussionParentRow(row discussionParentRow) model.WeatherForecastDiscussion {
return model.WeatherForecastDiscussion{
OfficeID: stringValue(row.OfficeID),
OfficeName: stringValue(row.OfficeName),
Product: model.ForecastDiscussionProduct(strings.TrimSpace(row.Product)),
IssuedAt: row.IssuedAt.UTC(),
UpdatedAt: timePtr(row.UpdatedAt),
ShortTerm: discussionSectionPtr(row.ShortTermQualifier, row.ShortTermIssuedAt, row.ShortTermText),
LongTerm: discussionSectionPtr(row.LongTermQualifier, row.LongTermIssuedAt, row.LongTermText),
KeyMessages: nil,
}
}
func mapDiscussionKeyMessageRow(row discussionKeyMessageRow) string {
return stringValue(row.MessageText)
}
func discussionSectionPtr(
qualifier sql.NullString,
issuedAt sql.NullTime,
text sql.NullString,
) *model.WeatherForecastDiscussionSection {
q := stringValue(qualifier)
t := stringValue(text)
i := timePtr(issuedAt)
if q == "" && t == "" && i == nil {
return nil
}
return &model.WeatherForecastDiscussionSection{
Qualifier: q,
IssuedAt: i,
Text: t,
}
}

View File

@@ -0,0 +1,31 @@
// discussion_queries.go contains SQL text for forecast discussion reads.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
const (
queryLatestForecastDiscussion = `
SELECT
event_id,
office_id,
office_name,
issued_at,
updated_at,
product,
short_term_qualifier,
short_term_issued_at,
short_term_text,
long_term_qualifier,
long_term_issued_at,
long_term_text
FROM forecast_discussions
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryForecastDiscussionKeyMessages = `
SELECT
message_index,
message_text
FROM forecast_discussion_key_messages
WHERE run_event_id = $1
ORDER BY message_index ASC`
)

View File

@@ -0,0 +1,74 @@
// discussion_read.go executes forecast discussion queries.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row discussionParentRow
err := r.db.QueryRowContext(ctx, queryLatestForecastDiscussion).Scan(
&row.EventID,
&row.OfficeID,
&row.OfficeName,
&row.IssuedAt,
&row.UpdatedAt,
&row.Product,
&row.ShortTermQualifier,
&row.ShortTermIssuedAt,
&row.ShortTermText,
&row.LongTermQualifier,
&row.LongTermIssuedAt,
&row.LongTermText,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest forecast discussion: %w", err)
}
run := mapDiscussionParentRow(row)
keyMessages, err := r.loadForecastDiscussionKeyMessages(ctx, row.EventID)
if err != nil {
return nil, err
}
run.KeyMessages = keyMessages
return &run, nil
}
func (r *Repository) loadForecastDiscussionKeyMessages(ctx context.Context, eventID string) ([]string, error) {
rows, err := r.db.QueryContext(ctx, queryForecastDiscussionKeyMessages, eventID)
if err != nil {
return nil, fmt.Errorf("query forecast discussion key messages: %w", err)
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var row discussionKeyMessageRow
if err := rows.Scan(
&row.MessageIndex,
&row.MessageText,
); err != nil {
return nil, fmt.Errorf("scan forecast discussion key message row: %w", err)
}
out = append(out, mapDiscussionKeyMessageRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forecast discussion key message rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,28 @@
// discussion_rows.go defines row DTOs for forecast discussion reads.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
import (
"database/sql"
"time"
)
type discussionParentRow struct {
EventID string
OfficeID sql.NullString
OfficeName sql.NullString
IssuedAt time.Time
UpdatedAt sql.NullTime
Product string
ShortTermQualifier sql.NullString
ShortTermIssuedAt sql.NullTime
ShortTermText sql.NullString
LongTermQualifier sql.NullString
LongTermIssuedAt sql.NullTime
LongTermText sql.NullString
}
type discussionKeyMessageRow struct {
MessageIndex int
MessageText sql.NullString
}

View File

@@ -0,0 +1,45 @@
// forecast_mapper.go maps forecast rows into weather model payloads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun {
return model.WeatherForecastRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
IssuedAt: row.IssuedAt.UTC(),
UpdatedAt: timePtr(row.UpdatedAt),
Product: model.ForecastProduct(row.Product),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
ElevationMeters: float64Ptr(row.ElevationMeters),
}
}
func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod {
return model.WeatherForecastPeriod{
StartTime: row.StartTime.UTC(),
EndTime: row.EndTime.UTC(),
Name: stringValue(row.Name),
IsDay: boolPtr(row.IsDay),
ConditionCode: model.WMOCode(row.ConditionCode),
TextDescription: stringValue(row.TextDescription),
TemperatureC: float64Ptr(row.TemperatureC),
TemperatureCMin: float64Ptr(row.TemperatureCMin),
TemperatureCMax: float64Ptr(row.TemperatureCMax),
DewpointC: float64Ptr(row.DewpointC),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
CloudCoverPercent: float64Ptr(row.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: float64Ptr(row.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: float64Ptr(row.PrecipitationAmountMM),
SnowfallDepthMM: float64Ptr(row.SnowfallDepthMM),
UVIndex: float64Ptr(row.UVIndex),
}
}

View File

@@ -0,0 +1,66 @@
// forecast_queries.go contains SQL text for forecast reads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
const (
queryLatestHourlyForecast = `
SELECT
event_id,
location_id,
location_name,
issued_at,
updated_at,
product,
latitude,
longitude,
elevation_meters
FROM forecasts
WHERE product = 'hourly'
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryLatestNarrativeForecast = `
SELECT
event_id,
location_id,
location_name,
issued_at,
updated_at,
product,
latitude,
longitude,
elevation_meters
FROM forecasts
WHERE product = 'narrative'
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryForecastPeriods = `
SELECT
period_index,
start_time,
end_time,
name,
is_day,
condition_code,
text_description,
temperature_c,
temperature_c_min,
temperature_c_max,
dewpoint_c,
relative_humidity_percent,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
apparent_temperature_c,
cloud_cover_percent,
probability_of_precipitation_percent,
precipitation_amount_mm,
snowfall_depth_mm,
uv_index
FROM forecast_periods
WHERE run_event_id = $1
ORDER BY period_index ASC`
)

View File

@@ -0,0 +1,104 @@
// forecast_read.go executes forecast and period queries.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return r.loadLatestForecastRun(ctx, queryLatestHourlyForecast, "hourly")
}
func (r *Repository) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return r.loadLatestForecastRun(ctx, queryLatestNarrativeForecast, "narrative")
}
func (r *Repository) loadLatestForecastRun(
ctx context.Context,
parentQuery string,
productLabel string,
) (*model.WeatherForecastRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row forecastParentRow
err := r.db.QueryRowContext(ctx, parentQuery).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.IssuedAt,
&row.UpdatedAt,
&row.Product,
&row.Latitude,
&row.Longitude,
&row.ElevationMeters,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest %s forecast: %w", productLabel, err)
}
run := mapForecastParentRow(row)
periods, err := r.loadForecastPeriods(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Periods = periods
return &run, nil
}
func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([]model.WeatherForecastPeriod, error) {
rows, err := r.db.QueryContext(ctx, queryForecastPeriods, eventID)
if err != nil {
return nil, fmt.Errorf("query forecast periods: %w", err)
}
defer rows.Close()
out := make([]model.WeatherForecastPeriod, 0)
for rows.Next() {
var row forecastPeriodRow
if err := rows.Scan(
&row.PeriodIndex,
&row.StartTime,
&row.EndTime,
&row.Name,
&row.IsDay,
&row.ConditionCode,
&row.TextDescription,
&row.TemperatureC,
&row.TemperatureCMin,
&row.TemperatureCMax,
&row.DewpointC,
&row.RelativeHumidityPercent,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.ApparentTemperatureC,
&row.CloudCoverPercent,
&row.ProbabilityOfPrecipitationPercent,
&row.PrecipitationAmountMM,
&row.SnowfallDepthMM,
&row.UVIndex,
); err != nil {
return nil, fmt.Errorf("scan forecast period row: %w", err)
}
out = append(out, mapForecastPeriodRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forecast period rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,46 @@
// forecast_rows.go defines row DTOs for forecast reads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import (
"database/sql"
"time"
)
type forecastParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
IssuedAt time.Time
UpdatedAt sql.NullTime
Product string
Latitude sql.NullFloat64
Longitude sql.NullFloat64
ElevationMeters sql.NullFloat64
}
type forecastPeriodRow struct {
PeriodIndex int
StartTime time.Time
EndTime time.Time
Name sql.NullString
IsDay sql.NullBool
ConditionCode int
TextDescription sql.NullString
TemperatureC sql.NullFloat64
TemperatureCMin sql.NullFloat64
TemperatureCMax sql.NullFloat64
DewpointC sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
CloudCoverPercent sql.NullFloat64
ProbabilityOfPrecipitationPercent sql.NullFloat64
PrecipitationAmountMM sql.NullFloat64
SnowfallDepthMM sql.NullFloat64
UVIndex sql.NullFloat64
}

View File

@@ -0,0 +1,42 @@
// observations_mapper.go maps observation rows into weather model payloads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"encoding/json"
"strings"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapObservationParentRow(row observationParentRow) model.WeatherObservation {
return model.WeatherObservation{
StationID: stringValue(row.StationID),
StationName: stringValue(row.StationName),
Timestamp: row.ObservedAt.UTC(),
ConditionCode: model.WMOCode(row.ConditionCode),
IsDay: boolPtr(row.IsDay),
TextDescription: stringValue(row.TextDescription),
TemperatureC: float64Ptr(row.TemperatureC),
DewpointC: float64Ptr(row.DewpointC),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
}
}
func mapObservationPresentWeatherRow(row observationPresentWeatherRow) (model.PresentWeather, error) {
if !row.RawText.Valid || strings.TrimSpace(row.RawText.String) == "" {
return model.PresentWeather{}, nil
}
var raw map[string]any
if err := json.Unmarshal([]byte(row.RawText.String), &raw); err != nil {
return model.PresentWeather{}, err
}
return model.PresentWeather{Raw: raw}, nil
}

View File

@@ -0,0 +1,33 @@
// observations_queries.go contains SQL text for observation reads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
const (
queryLatestObservation = `
SELECT
event_id,
station_id,
station_name,
observed_at,
condition_code,
is_day,
text_description,
temperature_c,
dewpoint_c,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
relative_humidity_percent,
apparent_temperature_c
FROM observations
ORDER BY observed_at DESC, event_emitted_at DESC
LIMIT 1`
queryObservationPresentWeather = `
SELECT weather_index, raw_text
FROM observation_present_weather
WHERE event_id = $1
ORDER BY weather_index ASC`
)

View File

@@ -0,0 +1,79 @@
// observations_read.go executes observation and present-weather queries.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row observationParentRow
err := r.db.QueryRowContext(ctx, queryLatestObservation).Scan(
&row.EventID,
&row.StationID,
&row.StationName,
&row.ObservedAt,
&row.ConditionCode,
&row.IsDay,
&row.TextDescription,
&row.TemperatureC,
&row.DewpointC,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.RelativeHumidityPercent,
&row.ApparentTemperatureC,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest observation: %w", err)
}
obs := mapObservationParentRow(row)
presentWeather, err := r.loadObservationPresentWeather(ctx, row.EventID)
if err != nil {
return nil, err
}
obs.PresentWeather = presentWeather
return &obs, nil
}
func (r *Repository) loadObservationPresentWeather(ctx context.Context, eventID string) ([]model.PresentWeather, error) {
rows, err := r.db.QueryContext(ctx, queryObservationPresentWeather, eventID)
if err != nil {
return nil, fmt.Errorf("query observation present weather: %w", err)
}
defer rows.Close()
out := make([]model.PresentWeather, 0)
for rows.Next() {
var row observationPresentWeatherRow
if err := rows.Scan(&row.WeatherIndex, &row.RawText); err != nil {
return nil, fmt.Errorf("scan observation present weather row: %w", err)
}
pw, err := mapObservationPresentWeatherRow(row)
if err != nil {
return nil, fmt.Errorf("decode observation present weather row (index=%d): %w", row.WeatherIndex, err)
}
out = append(out, pw)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,32 @@
// observations_rows.go defines row DTOs for observation reads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"database/sql"
"time"
)
type observationParentRow struct {
EventID string
StationID sql.NullString
StationName sql.NullString
ObservedAt time.Time
ConditionCode int
IsDay sql.NullBool
TextDescription sql.NullString
TemperatureC sql.NullFloat64
DewpointC sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
}
type observationPresentWeatherRow struct {
WeatherIndex int
RawText sql.NullString
}

View File

@@ -1,143 +1,11 @@
// repository.go defines the Postgres repository shell and constructor.
// Layer: adapters/outbound/postgres repository root.
package postgres package postgres
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core" "gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
const (
queryLatestObservation = `
SELECT
event_id,
station_id,
station_name,
observed_at,
condition_code,
is_day,
text_description,
temperature_c,
dewpoint_c,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
relative_humidity_percent,
apparent_temperature_c
FROM observations
ORDER BY observed_at DESC, event_emitted_at DESC
LIMIT 1`
queryObservationPresentWeather = `
SELECT weather_index, raw_text
FROM observation_present_weather
WHERE event_id = $1
ORDER BY weather_index ASC`
queryLatestHourlyForecast = `
SELECT
event_id,
location_id,
location_name,
issued_at,
updated_at,
product,
latitude,
longitude,
elevation_meters
FROM forecasts
WHERE product = 'hourly'
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryForecastPeriods = `
SELECT
period_index,
start_time,
end_time,
name,
is_day,
condition_code,
condition_text,
provider_raw_description,
text_description,
detailed_text,
icon_url,
temperature_c,
temperature_c_min,
temperature_c_max,
dewpoint_c,
relative_humidity_percent,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
apparent_temperature_c,
cloud_cover_percent,
probability_of_precipitation_percent,
precipitation_amount_mm,
snowfall_depth_mm,
uv_index
FROM forecast_periods
WHERE run_event_id = $1
ORDER BY period_index ASC`
queryLatestAlertRun = `
SELECT
event_id,
location_id,
location_name,
as_of,
latitude,
longitude
FROM alert_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryAlerts = `
SELECT
alert_index,
alert_id,
event,
headline,
severity,
urgency,
certainty,
status,
message_type,
category,
response,
description,
instruction,
sent,
effective,
onset,
expires,
area_description,
sender_name
FROM alerts
WHERE run_event_id = $1
ORDER BY alert_index ASC`
queryAlertReferences = `
SELECT
alert_index,
id,
identifier,
sender,
sent
FROM alert_references
WHERE run_event_id = $1
ORDER BY alert_index ASC, reference_index ASC`
) )
// Repository is a Postgres-backed implementation of weatherapi read ports. // Repository is a Postgres-backed implementation of weatherapi read ports.
@@ -145,544 +13,8 @@ type Repository struct {
db *sql.DB db *sql.DB
} }
var _ core.Repository = (*Repository)(nil) var _ app.Repository = (*Repository)(nil)
func NewRepository(db *sql.DB) *Repository { func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db} return &Repository{db: db}
} }
func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row observationParentRow
err := r.db.QueryRowContext(ctx, queryLatestObservation).Scan(
&row.EventID,
&row.StationID,
&row.StationName,
&row.ObservedAt,
&row.ConditionCode,
&row.IsDay,
&row.TextDescription,
&row.TemperatureC,
&row.DewpointC,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.RelativeHumidityPercent,
&row.ApparentTemperatureC,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest observation: %w", err)
}
obs := mapObservationParentRow(row)
presentWeather, err := r.loadObservationPresentWeather(ctx, row.EventID)
if err != nil {
return nil, err
}
obs.PresentWeather = presentWeather
return &obs, nil
}
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row forecastParentRow
err := r.db.QueryRowContext(ctx, queryLatestHourlyForecast).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.IssuedAt,
&row.UpdatedAt,
&row.Product,
&row.Latitude,
&row.Longitude,
&row.ElevationMeters,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest hourly forecast: %w", err)
}
run := mapForecastParentRow(row)
periods, err := r.loadForecastPeriods(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Periods = periods
return &run, nil
}
func (r *Repository) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row alertRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestAlertRun).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.AsOf,
&row.Latitude,
&row.Longitude,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest alert run: %w", err)
}
run := mapAlertRunParentRow(row)
alerts, err := r.loadAlerts(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Alerts = alerts
return &run, nil
}
func (r *Repository) loadObservationPresentWeather(ctx context.Context, eventID string) ([]model.PresentWeather, error) {
rows, err := r.db.QueryContext(ctx, queryObservationPresentWeather, eventID)
if err != nil {
return nil, fmt.Errorf("query observation present weather: %w", err)
}
defer rows.Close()
out := make([]model.PresentWeather, 0)
for rows.Next() {
var row observationPresentWeatherRow
if err := rows.Scan(&row.WeatherIndex, &row.RawText); err != nil {
return nil, fmt.Errorf("scan observation present weather row: %w", err)
}
pw, err := mapObservationPresentWeatherRow(row)
if err != nil {
return nil, fmt.Errorf("decode observation present weather row (index=%d): %w", row.WeatherIndex, err)
}
out = append(out, pw)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
}
return out, nil
}
func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([]model.WeatherForecastPeriod, error) {
rows, err := r.db.QueryContext(ctx, queryForecastPeriods, eventID)
if err != nil {
return nil, fmt.Errorf("query forecast periods: %w", err)
}
defer rows.Close()
out := make([]model.WeatherForecastPeriod, 0)
for rows.Next() {
var row forecastPeriodRow
if err := rows.Scan(
&row.PeriodIndex,
&row.StartTime,
&row.EndTime,
&row.Name,
&row.IsDay,
&row.ConditionCode,
&row.ConditionText,
&row.ProviderRawDescription,
&row.TextDescription,
&row.DetailedText,
&row.IconURL,
&row.TemperatureC,
&row.TemperatureCMin,
&row.TemperatureCMax,
&row.DewpointC,
&row.RelativeHumidityPercent,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.ApparentTemperatureC,
&row.CloudCoverPercent,
&row.ProbabilityOfPrecipitationPercent,
&row.PrecipitationAmountMM,
&row.SnowfallDepthMM,
&row.UVIndex,
); err != nil {
return nil, fmt.Errorf("scan forecast period row: %w", err)
}
out = append(out, mapForecastPeriodRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forecast period rows: %w", err)
}
return out, nil
}
func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.WeatherAlert, error) {
alertsRows, err := r.db.QueryContext(ctx, queryAlerts, eventID)
if err != nil {
return nil, fmt.Errorf("query alerts: %w", err)
}
defer alertsRows.Close()
indexedAlerts := make([]indexedAlert, 0)
for alertsRows.Next() {
var row alertRow
if err := alertsRows.Scan(
&row.AlertIndex,
&row.AlertID,
&row.Event,
&row.Headline,
&row.Severity,
&row.Urgency,
&row.Certainty,
&row.Status,
&row.MessageType,
&row.Category,
&row.Response,
&row.Description,
&row.Instruction,
&row.Sent,
&row.Effective,
&row.Onset,
&row.Expires,
&row.AreaDescription,
&row.SenderName,
); err != nil {
return nil, fmt.Errorf("scan alerts row: %w", err)
}
indexedAlerts = append(indexedAlerts, mapAlertRow(row))
}
if err := alertsRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alerts rows: %w", err)
}
referenceRows, err := r.db.QueryContext(ctx, queryAlertReferences, eventID)
if err != nil {
return nil, fmt.Errorf("query alert references: %w", err)
}
defer referenceRows.Close()
indexedReferences := make([]indexedAlertReference, 0)
for referenceRows.Next() {
var row alertReferenceRow
if err := referenceRows.Scan(
&row.AlertIndex,
&row.ID,
&row.Identifier,
&row.Sender,
&row.Sent,
); err != nil {
return nil, fmt.Errorf("scan alert reference row: %w", err)
}
indexedReferences = append(indexedReferences, mapAlertReferenceRow(row))
}
if err := referenceRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alert reference rows: %w", err)
}
return attachAlertReferences(indexedAlerts, indexedReferences), nil
}
type observationParentRow struct {
EventID string
StationID sql.NullString
StationName sql.NullString
ObservedAt time.Time
ConditionCode int
IsDay sql.NullBool
TextDescription sql.NullString
TemperatureC sql.NullFloat64
DewpointC sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
}
func mapObservationParentRow(row observationParentRow) model.WeatherObservation {
return model.WeatherObservation{
StationID: stringValue(row.StationID),
StationName: stringValue(row.StationName),
Timestamp: row.ObservedAt.UTC(),
ConditionCode: model.WMOCode(row.ConditionCode),
IsDay: boolPtr(row.IsDay),
TextDescription: stringValue(row.TextDescription),
TemperatureC: float64Ptr(row.TemperatureC),
DewpointC: float64Ptr(row.DewpointC),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
}
}
type observationPresentWeatherRow struct {
WeatherIndex int
RawText sql.NullString
}
func mapObservationPresentWeatherRow(row observationPresentWeatherRow) (model.PresentWeather, error) {
if !row.RawText.Valid || strings.TrimSpace(row.RawText.String) == "" {
return model.PresentWeather{}, nil
}
var raw map[string]any
if err := json.Unmarshal([]byte(row.RawText.String), &raw); err != nil {
return model.PresentWeather{}, err
}
return model.PresentWeather{Raw: raw}, nil
}
type forecastParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
IssuedAt time.Time
UpdatedAt sql.NullTime
Product string
Latitude sql.NullFloat64
Longitude sql.NullFloat64
ElevationMeters sql.NullFloat64
}
func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun {
return model.WeatherForecastRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
IssuedAt: row.IssuedAt.UTC(),
UpdatedAt: timePtr(row.UpdatedAt),
Product: model.ForecastProduct(row.Product),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
ElevationMeters: float64Ptr(row.ElevationMeters),
}
}
type forecastPeriodRow struct {
PeriodIndex int
StartTime time.Time
EndTime time.Time
Name sql.NullString
IsDay sql.NullBool
ConditionCode int
ConditionText sql.NullString
ProviderRawDescription sql.NullString
TextDescription sql.NullString
DetailedText sql.NullString
IconURL sql.NullString
TemperatureC sql.NullFloat64
TemperatureCMin sql.NullFloat64
TemperatureCMax sql.NullFloat64
DewpointC sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
CloudCoverPercent sql.NullFloat64
ProbabilityOfPrecipitationPercent sql.NullFloat64
PrecipitationAmountMM sql.NullFloat64
SnowfallDepthMM sql.NullFloat64
UVIndex sql.NullFloat64
}
func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod {
return model.WeatherForecastPeriod{
StartTime: row.StartTime.UTC(),
EndTime: row.EndTime.UTC(),
Name: stringValue(row.Name),
IsDay: boolPtr(row.IsDay),
ConditionCode: model.WMOCode(row.ConditionCode),
ConditionText: stringValue(row.ConditionText),
ProviderRawDescription: stringValue(row.ProviderRawDescription),
TextDescription: stringValue(row.TextDescription),
DetailedText: stringValue(row.DetailedText),
IconURL: stringValue(row.IconURL),
TemperatureC: float64Ptr(row.TemperatureC),
TemperatureCMin: float64Ptr(row.TemperatureCMin),
TemperatureCMax: float64Ptr(row.TemperatureCMax),
DewpointC: float64Ptr(row.DewpointC),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
CloudCoverPercent: float64Ptr(row.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: float64Ptr(row.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: float64Ptr(row.PrecipitationAmountMM),
SnowfallDepthMM: float64Ptr(row.SnowfallDepthMM),
UVIndex: float64Ptr(row.UVIndex),
}
}
type alertRunParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
AsOf time.Time
Latitude sql.NullFloat64
Longitude sql.NullFloat64
}
func mapAlertRunParentRow(row alertRunParentRow) model.WeatherAlertRun {
return model.WeatherAlertRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
AsOf: row.AsOf.UTC(),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
}
}
type alertRow struct {
AlertIndex int
AlertID string
Event sql.NullString
Headline sql.NullString
Severity sql.NullString
Urgency sql.NullString
Certainty sql.NullString
Status sql.NullString
MessageType sql.NullString
Category sql.NullString
Response sql.NullString
Description sql.NullString
Instruction sql.NullString
Sent sql.NullTime
Effective sql.NullTime
Onset sql.NullTime
Expires sql.NullTime
AreaDescription sql.NullString
SenderName sql.NullString
}
type indexedAlert struct {
Index int
Alert model.WeatherAlert
}
func mapAlertRow(row alertRow) indexedAlert {
return indexedAlert{
Index: row.AlertIndex,
Alert: model.WeatherAlert{
ID: row.AlertID,
Event: stringValue(row.Event),
Headline: stringValue(row.Headline),
Severity: stringValue(row.Severity),
Urgency: stringValue(row.Urgency),
Certainty: stringValue(row.Certainty),
Status: stringValue(row.Status),
MessageType: stringValue(row.MessageType),
Category: stringValue(row.Category),
Response: stringValue(row.Response),
Description: stringValue(row.Description),
Instruction: stringValue(row.Instruction),
Sent: timePtr(row.Sent),
Effective: timePtr(row.Effective),
Onset: timePtr(row.Onset),
Expires: timePtr(row.Expires),
AreaDescription: stringValue(row.AreaDescription),
SenderName: stringValue(row.SenderName),
},
}
}
type alertReferenceRow struct {
AlertIndex int
ID sql.NullString
Identifier sql.NullString
Sender sql.NullString
Sent sql.NullTime
}
type indexedAlertReference struct {
AlertIndex int
Reference model.AlertReference
}
func mapAlertReferenceRow(row alertReferenceRow) indexedAlertReference {
return indexedAlertReference{
AlertIndex: row.AlertIndex,
Reference: model.AlertReference{
ID: stringValue(row.ID),
Identifier: stringValue(row.Identifier),
Sender: stringValue(row.Sender),
Sent: timePtr(row.Sent),
},
}
}
func attachAlertReferences(alerts []indexedAlert, references []indexedAlertReference) []model.WeatherAlert {
refsByAlertIndex := make(map[int][]model.AlertReference, len(alerts))
for _, ref := range references {
refsByAlertIndex[ref.AlertIndex] = append(refsByAlertIndex[ref.AlertIndex], ref.Reference)
}
out := make([]model.WeatherAlert, 0, len(alerts))
for _, alert := range alerts {
mapped := alert.Alert
if refs := refsByAlertIndex[alert.Index]; len(refs) > 0 {
mapped.References = refs
}
out = append(out, mapped)
}
return out
}
func stringValue(v sql.NullString) string {
if !v.Valid {
return ""
}
return v.String
}
func boolPtr(v sql.NullBool) *bool {
if !v.Valid {
return nil
}
b := v.Bool
return &b
}
func float64Ptr(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}
func timePtr(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time.UTC()
return &t
}

View File

@@ -1,9 +1,13 @@
// repository_test.go validates Postgres row mapping and attachment helpers.
// Layer: adapters/outbound/postgres mapper regression tests.
package postgres package postgres
import ( import (
"database/sql" "database/sql"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
func TestMapObservationParentRowNullables(t *testing.T) { func TestMapObservationParentRowNullables(t *testing.T) {
@@ -82,6 +86,65 @@ func TestMapForecastPeriodRowNullables(t *testing.T) {
} }
} }
func TestMapDiscussionParentRowNullables(t *testing.T) {
issuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.FixedZone("CDT", -5*3600))
updatedAt := issuedAt.Add(time.Hour)
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
discussion := mapDiscussionParentRow(discussionParentRow{
OfficeID: sql.NullString{String: "LSX", Valid: true},
OfficeName: sql.NullString{String: "National Weather Service Saint Louis MO", Valid: true},
IssuedAt: issuedAt,
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
Product: "afd",
ShortTermQualifier: sql.NullString{String: "(Tonight)", Valid: true},
ShortTermIssuedAt: sql.NullTime{Time: shortIssuedAt, Valid: true},
ShortTermText: sql.NullString{String: "Short term text", Valid: true},
})
if discussion.OfficeID != "LSX" {
t.Fatalf("expected office id LSX, got %q", discussion.OfficeID)
}
if discussion.Product != model.ForecastDiscussionProductAFD {
t.Fatalf("expected product afd, got %q", discussion.Product)
}
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected updatedAt to be UTC-normalized, got %v", discussion.UpdatedAt)
}
if discussion.ShortTerm == nil {
t.Fatalf("expected shortTerm section to be populated")
}
if discussion.ShortTerm.Qualifier != "(Tonight)" || discussion.ShortTerm.Text != "Short term text" {
t.Fatalf("unexpected shortTerm section: %+v", discussion.ShortTerm)
}
if discussion.LongTerm != nil {
t.Fatalf("expected longTerm nil, got %+v", discussion.LongTerm)
}
}
func TestMapDiscussionSectionNilWhenAllFieldsMissing(t *testing.T) {
got := discussionSectionPtr(sql.NullString{}, sql.NullTime{}, sql.NullString{})
if got != nil {
t.Fatalf("expected nil discussion section, got %+v", got)
}
}
func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
rows := []discussionKeyMessageRow{
{MessageIndex: 0, MessageText: sql.NullString{String: "first", Valid: true}},
{MessageIndex: 1, MessageText: sql.NullString{String: "second", Valid: true}},
}
got := make([]string, 0, len(rows))
for _, row := range rows {
got = append(got, mapDiscussionKeyMessageRow(row))
}
if len(got) != 2 || got[0] != "first" || got[1] != "second" {
t.Fatalf("unexpected key message order: %#v", got)
}
}
func TestAttachAlertReferencesPreservesOrder(t *testing.T) { func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC) sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
sent2 := sent1.Add(10 * time.Minute) sent2 := sent1.Add(10 * time.Minute)
@@ -110,3 +173,54 @@ func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
t.Fatalf("unexpected second alert references: %+v", out[1].References) t.Fatalf("unexpected second alert references: %+v", out[1].References)
} }
} }
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 0,
})
if got != nil {
t.Fatalf("expected nil for empty sample window, got %+v", got)
}
}
func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
isDay := true
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 12,
TemperatureC: sql.NullFloat64{Float64: 15.5, Valid: true},
ApparentTemperatureC: sql.NullFloat64{Float64: 14.2, Valid: true},
DewpointC: sql.NullFloat64{Float64: 10.1, Valid: true},
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
IsDay: sql.NullBool{Bool: isDay, Valid: true},
})
if got == nil {
t.Fatalf("expected mapped current conditions")
}
if got.TemperatureC == nil || *got.TemperatureC != 15.5 {
t.Fatalf("expected temperature pointer 15.5, got %v", got.TemperatureC)
}
if got.ApparentTemperatureC == nil || *got.ApparentTemperatureC != 14.2 {
t.Fatalf("expected apparent temp pointer 14.2, got %v", got.ApparentTemperatureC)
}
if got.DewpointC == nil || *got.DewpointC != 10.1 {
t.Fatalf("expected dewpoint pointer 10.1, got %v", got.DewpointC)
}
if got.RelativeHumidityPercent == nil || *got.RelativeHumidityPercent != 72 {
t.Fatalf("expected rh pointer 72, got %v", got.RelativeHumidityPercent)
}
if got.WindSpeedKmh == nil || *got.WindSpeedKmh != 24.8 {
t.Fatalf("expected wind speed pointer 24.8, got %v", got.WindSpeedKmh)
}
if got.WindDirectionDegrees == nil || *got.WindDirectionDegrees != 182.5 {
t.Fatalf("expected wind direction pointer 182.5, got %v", got.WindDirectionDegrees)
}
if got.ConditionCode != 65 {
t.Fatalf("expected condition code 65, got %d", got.ConditionCode)
}
if got.IsDay == nil || !*got.IsDay {
t.Fatalf("expected isDay pointer true, got %v", got.IsDay)
}
}

View File

@@ -0,0 +1,39 @@
// scan_helpers.go provides shared sql.Null* conversion helpers.
// Layer: adapters/outbound/postgres helper utilities.
package postgres
import (
"database/sql"
"time"
)
func stringValue(v sql.NullString) string {
if !v.Valid {
return ""
}
return v.String
}
func boolPtr(v sql.NullBool) *bool {
if !v.Valid {
return nil
}
b := v.Bool
return &b
}
func float64Ptr(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}
func timePtr(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time.UTC()
return &t
}

View File

@@ -0,0 +1,7 @@
// constants.go defines shared application-level constants.
// Layer: internal/app service behavior defaults.
package app
const (
ObservationWindowMinutesDefault = 30
)

View File

@@ -0,0 +1,17 @@
// current_conditions.go defines the current-conditions aggregate model.
// Layer: internal/app domain-adjacent read model.
package app
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
// CurrentConditions is an averaged current-conditions aggregate over a recent window.
type CurrentConditions struct {
TemperatureC *float64
ApparentTemperatureC *float64
DewpointC *float64
RelativeHumidityPercent *float64
WindSpeedKmh *float64
WindDirectionDegrees *float64
ConditionCode model.WMOCode
IsDay *bool
}

52
internal/app/service.go Normal file
View File

@@ -0,0 +1,52 @@
// service.go defines application read ports and use-case orchestration.
// Layer: internal/app core business-facing API.
package app
import (
"context"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Repository defines outbound data access used by weatherapi use cases.
type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error)
}
// Service provides weather read use-cases.
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
return s.repo.LatestObservation(ctx)
}
func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestHourlyForecast(ctx)
}
func (s *Service) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestNarrativeForecast(ctx)
}
func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
return s.repo.LatestForecastDiscussion(ctx)
}
func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
return s.repo.LatestAlertRun(ctx)
}
func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) {
return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault)
}

View File

@@ -0,0 +1,136 @@
// service_test.go validates application service delegation behavior.
// Layer: internal/app tests for read use-case orchestration.
package app
import (
"context"
"errors"
"testing"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type fakeRepository struct {
observation *model.WeatherObservation
forecast *model.WeatherForecastRun
narrative *model.WeatherForecastRun
discussion *model.WeatherForecastDiscussion
alerts *model.WeatherAlertRun
conditions *CurrentConditions
err error
currentConditionsWindow int
}
func (r *fakeRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
return r.observation, r.err
}
func (r *fakeRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
return r.forecast, r.err
}
func (r *fakeRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
return r.narrative, r.err
}
func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
return r.discussion, r.err
}
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
return r.alerts, r.err
}
func (r *fakeRepository) CurrentConditions(_ context.Context, observationWindowMinutes int) (*CurrentConditions, error) {
r.currentConditionsWindow = observationWindowMinutes
return r.conditions, r.err
}
func TestServiceDelegatesObservation(t *testing.T) {
repo := &fakeRepository{observation: &model.WeatherObservation{StationID: "KSTL"}}
svc := NewService(repo)
obs, err := svc.LatestObservation(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if obs == nil || obs.StationID != "KSTL" {
t.Fatalf("unexpected observation: %+v", obs)
}
}
func TestServiceDelegatesForecast(t *testing.T) {
repo := &fakeRepository{forecast: &model.WeatherForecastRun{LocationID: "stl"}}
svc := NewService(repo)
run, err := svc.LatestHourlyForecast(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected forecast: %+v", run)
}
}
func TestServiceDelegatesNarrativeForecast(t *testing.T) {
repo := &fakeRepository{narrative: &model.WeatherForecastRun{LocationID: "stl-narrative"}}
svc := NewService(repo)
run, err := svc.LatestNarrativeForecast(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl-narrative" {
t.Fatalf("unexpected forecast: %+v", run)
}
}
func TestServiceDelegatesAlerts(t *testing.T) {
repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}}
svc := NewService(repo)
run, err := svc.LatestAlertRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected alert run: %+v", run)
}
}
func TestServiceDelegatesForecastDiscussion(t *testing.T) {
repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}}
svc := NewService(repo)
run, err := svc.LatestForecastDiscussion(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.OfficeID != "LSX" {
t.Fatalf("unexpected forecast discussion: %+v", run)
}
}
func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) {
repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}}
svc := NewService(repo)
_, err := svc.CurrentConditions(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.currentConditionsWindow != ObservationWindowMinutesDefault {
t.Fatalf("expected observation window %d, got %d", ObservationWindowMinutesDefault, repo.currentConditionsWindow)
}
}
func TestServicePropagatesErrors(t *testing.T) {
want := errors.New("boom")
repo := &fakeRepository{err: want}
svc := NewService(repo)
if _, err := svc.LatestObservation(context.Background()); !errors.Is(err, want) {
t.Fatalf("expected error %v, got %v", want, err)
}
}

View File

@@ -1,35 +0,0 @@
package core
import (
"context"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Repository defines outbound data access used by weatherapi use cases.
type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
}
// Service provides weather read use-cases.
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
return s.repo.LatestObservation(ctx)
}
func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestHourlyForecast(ctx)
}
func (s *Service) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) {
return s.repo.LatestActiveAlerts(ctx)
}

View File

@@ -0,0 +1,41 @@
{{- if .Data -}}
Current Conditions
{{- if .Data.ConditionText}}
Condition: {{.Data.ConditionText}}
{{- end}}
{{- if .Data.IsDayText}}
Is Day: {{.Data.IsDayText}}
{{- end}}
{{- if .Data.TemperatureC}}
Temperature (C): {{.Data.TemperatureC}}
{{- end}}
{{- if .Data.ApparentTemperatureC}}
Apparent Temperature (C): {{.Data.ApparentTemperatureC}}
{{- end}}
{{- if .Data.DewpointC}}
Dewpoint (C): {{.Data.DewpointC}}
{{- end}}
{{- if .Data.WindSpeedKmh}}
Wind Speed (km/h): {{.Data.WindSpeedKmh}}
{{- end}}
{{- if .Data.TemperatureF}}
Temperature (F): {{.Data.TemperatureF}}
{{- end}}
{{- if .Data.ApparentTemperatureF}}
Apparent Temperature (F): {{.Data.ApparentTemperatureF}}
{{- end}}
{{- if .Data.DewpointF}}
Dewpoint (F): {{.Data.DewpointF}}
{{- end}}
{{- if .Data.WindSpeedMph}}
Wind Speed (mph): {{.Data.WindSpeedMph}}
{{- end}}
{{- if .Data.RelativeHumidityPercent}}
Relative Humidity (%): {{.Data.RelativeHumidityPercent}}
{{- end}}
{{- if .Data.WindDirectionDegrees}}
Wind Direction (deg): {{.Data.WindDirectionDegrees}}
{{- end}}
{{- else -}}
No current conditions data available.
{{- end}}

View File

@@ -0,0 +1,43 @@
{{- if .Data -}}
Forecast Discussion
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
Product: {{.Data.Product}}
Issued At: {{.Data.IssuedAt}}
{{- if .Data.UpdatedAt}}
Updated At: {{.Data.UpdatedAt}}
{{- end}}
Key Messages: {{len .Data.KeyMessages}}
{{- range $i, $message := .Data.KeyMessages}}
[{{$i}}] {{$message}}
{{- end}}
{{- if .Data.ShortTerm}}
Short Term
{{- if .Data.ShortTerm.Qualifier}}
Qualifier: {{.Data.ShortTerm.Qualifier}}
{{- end}}
{{- if .Data.ShortTerm.IssuedAt}}
Issued At: {{.Data.ShortTerm.IssuedAt}}
{{- end}}
{{- if .Data.ShortTerm.Text}}
Text: {{.Data.ShortTerm.Text}}
{{- end}}
{{- end}}
{{- if .Data.LongTerm}}
Long Term
{{- if .Data.LongTerm.Qualifier}}
Qualifier: {{.Data.LongTerm.Qualifier}}
{{- end}}
{{- if .Data.LongTerm.IssuedAt}}
Issued At: {{.Data.LongTerm.IssuedAt}}
{{- end}}
{{- if .Data.LongTerm.Text}}
Text: {{.Data.LongTerm.Text}}
{{- end}}
{{- end}}
{{- else -}}
No forecast discussion data available.
{{- end}}

View File

@@ -0,0 +1,20 @@
{{- if .Data -}}
Narrative Forecast
Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}}
Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}}
Issued At: {{.Data.IssuedAt}}
Periods: {{len .Data.Periods}}
{{- range $i, $period := .Data.Periods}}
[{{$i}}] {{$period.StartTime}} -> {{$period.EndTime}}
{{- if $period.Name}}
Name: {{$period.Name}}
{{- end}}
Condition Code: {{$period.ConditionCode}}
{{- if $period.TextDescription}}
Summary: {{$period.TextDescription}}
{{- end}}
{{- end}}
{{- else -}}
No narrative forecast data available.
{{- end}}