6 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
31 changed files with 2265 additions and 118 deletions

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)

2
go.mod
View File

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

4
go.sum
View File

@@ -1,7 +1,7 @@
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY= gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw= gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2 h1:GJdZ9x54HLTPidPGhD/dmq7efvGjBltK1ywCIRU1w6c= gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 h1:vH5p8zKiJ6D7JnbpA43iWKNEdgQg/VwKKaWlwF3AXXs=
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2/go.mod h1:P7rP7XftJjBFzNEIkAiX+z2YqXOtnZjy3BaF6kX+sO4= 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

@@ -14,13 +14,13 @@ import (
func conditionsDefinition(svc Service) endpoint.Definition { func conditionsDefinition(svc Service) endpoint.Definition {
return endpoint.GET( return endpoint.GET(
"/conditions/current", "/conditions/current",
bindQuery, bindPrecisionQuery,
func(ctx context.Context, req queryRequest) (any, error) { func(ctx context.Context, req precisionQueryRequest) (any, error) {
conditions, err := svc.CurrentConditions(ctx) conditions, err := svc.CurrentConditions(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return response.Envelope{Data: presenter.CurrentConditionsPayload(conditions, req.Units)}, nil return response.Envelope{Data: presenter.CurrentConditionsPayload(conditions, req.Units, req.Precision)}, nil
}, },
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("conditions_current.txt.tmpl"), 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

@@ -5,10 +5,12 @@ package httpapi
import "gitea.maximumdirect.net/ejr/feedapi/endpoint" import "gitea.maximumdirect.net/ejr/feedapi/endpoint"
func Definitions(svc Service) []endpoint.Definition { func Definitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{ defs := []endpoint.Definition{
observationDefinition(svc), observationDefinition(svc),
forecastDefinition(svc),
alertsDefinition(svc), alertsDefinition(svc),
discussionDefinition(svc),
conditionsDefinition(svc), conditionsDefinition(svc),
} }
defs = append(defs, forecastDefinitions(svc)...)
return defs
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,108 @@
// forecast_endpoint.go defines the /forecast/hourly endpoint behavior. // forecast_endpoint.go defines forecast endpoint behavior.
// Layer: adapters/inbound/httpapi forecast route. // Layer: adapters/inbound/httpapi forecast route.
package httpapi package httpapi
import ( import (
"context" "context"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint" "gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render" "gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response" "gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
func forecastDefinition(svc Service) endpoint.Definition { type forecastDaySlice int
return endpoint.GET(
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",
bindQuery, "forecast_hourly.txt.tmpl",
func(ctx context.Context, req queryRequest) (any, error) { svc.LatestHourlyForecast,
run, err := svc.LatestHourlyForecast(ctx) )...)
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 { if err != nil {
return nil, err return nil, err
} }
return response.Envelope{Data: presenter.ForecastPayload(run, req.Units)}, nil
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.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("forecast_hourly.txt.tmpl"), 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

@@ -14,13 +14,13 @@ import (
func observationDefinition(svc Service) endpoint.Definition { func observationDefinition(svc Service) endpoint.Definition {
return endpoint.GET( return endpoint.GET(
"/observations", "/observations",
bindQuery, bindPrecisionQuery,
func(ctx context.Context, req queryRequest) (any, error) { func(ctx context.Context, req precisionQueryRequest) (any, error) {
obs, err := svc.LatestObservation(ctx) obs, err := svc.LatestObservation(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return response.Envelope{Data: presenter.ObservationPayload(obs, req.Units)}, nil return response.Envelope{Data: presenter.ObservationPayload(obs, req.Units, req.Precision)}, nil
}, },
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("observations.txt.tmpl"), endpoint.WithTemplate("observations.txt.tmpl"),

View File

@@ -25,30 +25,30 @@ type CurrentConditionsResponse struct {
IsDayText string `json:"-" xml:"-"` IsDayText string `json:"-" xml:"-"`
} }
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units) any { func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
if conditions == nil { if conditions == nil {
return nil return nil
} }
out := CurrentConditionsResponse{ out := CurrentConditionsResponse{
RelativeHumidityPercent: copyFloat64Ptr(conditions.RelativeHumidityPercent), RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
WindDirectionDegrees: copyFloat64Ptr(conditions.WindDirectionDegrees), WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay), ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
IsDay: copyBoolPtr(conditions.IsDay), IsDay: copyBoolPtr(conditions.IsDay),
IsDayText: boolText(conditions.IsDay), IsDayText: boolText(conditions.IsDay),
} }
if units == UnitsUS { if units == UnitsUS {
out.TemperatureF = celsiusToFahrenheitPtr(conditions.TemperatureC) out.TemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.TemperatureC), precision)
out.ApparentTemperatureF = celsiusToFahrenheitPtr(conditions.ApparentTemperatureC) out.ApparentTemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.ApparentTemperatureC), precision)
out.DewpointF = celsiusToFahrenheitPtr(conditions.DewpointC) out.DewpointF = roundedPtr(celsiusToFahrenheitPtr(conditions.DewpointC), precision)
out.WindSpeedMph = scalePtr(conditions.WindSpeedKmh, kmhToMphFactor) out.WindSpeedMph = roundedPtr(scalePtr(conditions.WindSpeedKmh, kmhToMphFactor), precision)
return out return out
} }
out.TemperatureC = copyFloat64Ptr(conditions.TemperatureC) out.TemperatureC = roundedPtr(copyFloat64Ptr(conditions.TemperatureC), precision)
out.ApparentTemperatureC = copyFloat64Ptr(conditions.ApparentTemperatureC) out.ApparentTemperatureC = roundedPtr(copyFloat64Ptr(conditions.ApparentTemperatureC), precision)
out.DewpointC = copyFloat64Ptr(conditions.DewpointC) out.DewpointC = roundedPtr(copyFloat64Ptr(conditions.DewpointC), precision)
out.WindSpeedKmh = copyFloat64Ptr(conditions.WindSpeedKmh) out.WindSpeedKmh = roundedPtr(copyFloat64Ptr(conditions.WindSpeedKmh), precision)
return out return out
} }

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

@@ -1,4 +1,4 @@
// forecast.go presents hourly forecast payloads in metric and US shapes. // forecast.go presents forecast payloads in metric and US shapes.
// Layer: adapters/inbound/httpapi/presenter forecast payload mapping. // Layer: adapters/inbound/httpapi/presenter forecast payload mapping.
package presenter package presenter
@@ -8,7 +8,7 @@ import (
"gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
// WeatherForecastRunUS is the US-customary response shape for hourly forecasts. // WeatherForecastRunUS is the US-customary response shape for forecasts.
type WeatherForecastRunUS struct { type WeatherForecastRunUS struct {
LocationID string `json:"locationId,omitempty" xml:"locationId,omitempty"` LocationID string `json:"locationId,omitempty" xml:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty" xml:"locationName,omitempty"` LocationName string `json:"locationName,omitempty" xml:"locationName,omitempty"`
@@ -28,11 +28,7 @@ type WeatherForecastPeriodUS struct {
Name string `json:"name,omitempty" xml:"name,omitempty"` Name string `json:"name,omitempty" xml:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"` ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
ProviderRawDescription string `json:"providerRawDescription,omitempty" xml:"providerRawDescription,omitempty"`
TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"` TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"`
DetailedText string `json:"detailedText,omitempty" xml:"detailedText,omitempty"`
IconURL string `json:"iconUrl,omitempty" xml:"iconUrl,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty" xml:"temperatureFMin,omitempty"` TemperatureFMin *float64 `json:"temperatureFMin,omitempty" xml:"temperatureFMin,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty" xml:"temperatureFMax,omitempty"` TemperatureFMax *float64 `json:"temperatureFMax,omitempty" xml:"temperatureFMax,omitempty"`
@@ -51,7 +47,7 @@ type WeatherForecastPeriodUS struct {
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
} }
func ForecastPayload(run *model.WeatherForecastRun, units Units) any { func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, tz *time.Location) any {
if run == nil { if run == nil {
return nil return nil
} }
@@ -59,45 +55,80 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units) any {
out := WeatherForecastRunUS{ out := WeatherForecastRunUS{
LocationID: run.LocationID, LocationID: run.LocationID,
LocationName: run.LocationName, LocationName: run.LocationName,
IssuedAt: run.IssuedAt, IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: copyTimePtr(run.UpdatedAt), UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
Product: run.Product, Product: run.Product,
Latitude: copyFloat64Ptr(run.Latitude), Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude), Longitude: copyFloat64Ptr(run.Longitude),
ElevationFeet: scalePtr(run.ElevationMeters, metersToFeetFactor), ElevationFeet: roundedPtr(scalePtr(run.ElevationMeters, metersToFeetFactor), precision),
Periods: make([]WeatherForecastPeriodUS, 0, len(run.Periods)), Periods: make([]WeatherForecastPeriodUS, 0, len(run.Periods)),
} }
for _, p := range run.Periods { for _, p := range run.Periods {
out.Periods = append(out.Periods, WeatherForecastPeriodUS{ out.Periods = append(out.Periods, WeatherForecastPeriodUS{
StartTime: p.StartTime, StartTime: inLocationTime(p.StartTime, tz),
EndTime: p.EndTime, EndTime: inLocationTime(p.EndTime, tz),
Name: p.Name, Name: p.Name,
IsDay: copyBoolPtr(p.IsDay), IsDay: copyBoolPtr(p.IsDay),
ConditionCode: p.ConditionCode, ConditionCode: p.ConditionCode,
ConditionText: p.ConditionText,
ProviderRawDescription: p.ProviderRawDescription,
TextDescription: p.TextDescription, TextDescription: p.TextDescription,
DetailedText: p.DetailedText, TemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureC), precision),
IconURL: p.IconURL, TemperatureFMin: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMin), precision),
TemperatureF: celsiusToFahrenheitPtr(p.TemperatureC), TemperatureFMax: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMax), precision),
TemperatureFMin: celsiusToFahrenheitPtr(p.TemperatureCMin), DewpointF: roundedPtr(celsiusToFahrenheitPtr(p.DewpointC), precision),
TemperatureFMax: celsiusToFahrenheitPtr(p.TemperatureCMax), RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(p.RelativeHumidityPercent), precision),
DewpointF: celsiusToFahrenheitPtr(p.DewpointC), WindDirectionDegrees: roundedPtr(copyFloat64Ptr(p.WindDirectionDegrees), precision),
RelativeHumidityPercent: copyFloat64Ptr(p.RelativeHumidityPercent), WindSpeedMph: roundedPtr(scalePtr(p.WindSpeedKmh, kmhToMphFactor), precision),
WindDirectionDegrees: copyFloat64Ptr(p.WindDirectionDegrees), WindGustMph: roundedPtr(scalePtr(p.WindGustKmh, kmhToMphFactor), precision),
WindSpeedMph: scalePtr(p.WindSpeedKmh, kmhToMphFactor), BarometricPressureInHg: roundedPtr(scalePtr(p.BarometricPressurePa, paToInHgFactor), precision),
WindGustMph: scalePtr(p.WindGustKmh, kmhToMphFactor), VisibilityMiles: roundedPtr(scalePtr(p.VisibilityMeters, metersToMilesFactor), precision),
BarometricPressureInHg: scalePtr(p.BarometricPressurePa, paToInHgFactor), ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.ApparentTemperatureC), precision),
VisibilityMiles: scalePtr(p.VisibilityMeters, metersToMilesFactor), CloudCoverPercent: roundedPtr(copyFloat64Ptr(p.CloudCoverPercent), precision),
ApparentTemperatureF: celsiusToFahrenheitPtr(p.ApparentTemperatureC), ProbabilityOfPrecipitationPercent: roundedPtr(copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), precision),
CloudCoverPercent: copyFloat64Ptr(p.CloudCoverPercent), PrecipitationAmountIn: roundedPtr(scalePtr(p.PrecipitationAmountMm, mmToInchesFactor), precision),
ProbabilityOfPrecipitationPercent: copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), SnowfallDepthIn: roundedPtr(scalePtr(p.SnowfallDepthMM, mmToInchesFactor), precision),
PrecipitationAmountIn: scalePtr(p.PrecipitationAmountMm, mmToInchesFactor), UVIndex: roundedPtr(copyFloat64Ptr(p.UVIndex), precision),
SnowfallDepthIn: scalePtr(p.SnowfallDepthMM, mmToInchesFactor),
UVIndex: copyFloat64Ptr(p.UVIndex),
}) })
} }
return out return out
} }
return run
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

@@ -2,7 +2,10 @@
// Layer: adapters/inbound/httpapi/presenter helper functions. // Layer: adapters/inbound/httpapi/presenter helper functions.
package presenter package presenter
import "time" import (
"math"
"time"
)
func celsiusToFahrenheitPtr(v *float64) *float64 { func celsiusToFahrenheitPtr(v *float64) *float64 {
if v == nil { if v == nil {
@@ -44,6 +47,21 @@ func copyTimePtr(v *time.Time) *time.Time {
return &out 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 { func boolText(v *bool) string {
if v == nil { if v == nil {
return "" return ""
@@ -53,3 +71,19 @@ func boolText(v *bool) string {
} }
return "false" 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

@@ -28,7 +28,7 @@ type WeatherObservationUS struct {
PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"` PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"`
} }
func ObservationPayload(obs *model.WeatherObservation, units Units) any { func ObservationPayload(obs *model.WeatherObservation, units Units, precision int) any {
if obs == nil { if obs == nil {
return nil return nil
} }
@@ -40,18 +40,37 @@ func ObservationPayload(obs *model.WeatherObservation, units Units) any {
ConditionCode: obs.ConditionCode, ConditionCode: obs.ConditionCode,
IsDay: copyBoolPtr(obs.IsDay), IsDay: copyBoolPtr(obs.IsDay),
TextDescription: obs.TextDescription, TextDescription: obs.TextDescription,
TemperatureF: celsiusToFahrenheitPtr(obs.TemperatureC), TemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.TemperatureC), precision),
DewpointF: celsiusToFahrenheitPtr(obs.DewpointC), DewpointF: roundedPtr(celsiusToFahrenheitPtr(obs.DewpointC), precision),
WindDirectionDegrees: copyFloat64Ptr(obs.WindDirectionDegrees), WindDirectionDegrees: roundedPtr(copyFloat64Ptr(obs.WindDirectionDegrees), precision),
WindSpeedMph: scalePtr(obs.WindSpeedKmh, kmhToMphFactor), WindSpeedMph: roundedPtr(scalePtr(obs.WindSpeedKmh, kmhToMphFactor), precision),
WindGustMph: scalePtr(obs.WindGustKmh, kmhToMphFactor), WindGustMph: roundedPtr(scalePtr(obs.WindGustKmh, kmhToMphFactor), precision),
BarometricPressureInHg: scalePtr(obs.BarometricPressurePa, paToInHgFactor), BarometricPressureInHg: roundedPtr(scalePtr(obs.BarometricPressurePa, paToInHgFactor), precision),
VisibilityMiles: scalePtr(obs.VisibilityMeters, metersToMilesFactor), VisibilityMiles: roundedPtr(scalePtr(obs.VisibilityMeters, metersToMilesFactor), precision),
RelativeHumidityPercent: copyFloat64Ptr(obs.RelativeHumidityPercent), RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(obs.RelativeHumidityPercent), precision),
ApparentTemperatureF: celsiusToFahrenheitPtr(obs.ApparentTemperatureC), ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.ApparentTemperatureC), precision),
PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...), PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...),
} }
return converted return converted
} }
return obs
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

@@ -3,6 +3,7 @@
package presenter package presenter
import ( import (
"encoding/json"
"math" "math"
"testing" "testing"
"time" "time"
@@ -26,15 +27,15 @@ func TestObservationPayloadUS(t *testing.T) {
RelativeHumidityPercent: float64Ptr(50), RelativeHumidityPercent: float64Ptr(50),
} }
payload := ObservationPayload(obs, UnitsUS) payload := ObservationPayload(obs, UnitsUS, 2)
converted, ok := payload.(WeatherObservationUS) converted, ok := payload.(WeatherObservationUS)
if !ok { if !ok {
t.Fatalf("expected WeatherObservationUS payload, got %T", payload) t.Fatalf("expected WeatherObservationUS payload, got %T", payload)
} }
assertApprox(t, converted.TemperatureF, 68.0, 0.0001) assertApprox(t, converted.TemperatureF, 68.0, 0.0001)
assertApprox(t, converted.WindSpeedMph, 62.1371192237, 0.0001) assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001)
assertApprox(t, converted.BarometricPressureInHg, 29.9212524019, 0.0001) assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001)
assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001) assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001)
if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil { if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil {
@@ -64,13 +65,13 @@ func TestForecastPayloadUS(t *testing.T) {
}}, }},
} }
payload := ForecastPayload(run, UnitsUS) payload := ForecastPayload(run, UnitsUS, 2, nil)
converted, ok := payload.(WeatherForecastRunUS) converted, ok := payload.(WeatherForecastRunUS)
if !ok { if !ok {
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload) t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
} }
assertApprox(t, converted.ElevationFeet, 3280.839895, 0.0001) assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001)
if len(converted.Periods) != 1 { if len(converted.Periods) != 1 {
t.Fatalf("expected 1 period, got %d", len(converted.Periods)) t.Fatalf("expected 1 period, got %d", len(converted.Periods))
} }
@@ -83,27 +84,162 @@ func TestForecastPayloadUS(t *testing.T) {
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001) assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
} }
func TestMetricPassthroughAndNilHandling(t *testing.T) { func TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) {
obs := &model.WeatherObservation{} run := &model.WeatherForecastRun{
metric := ObservationPayload(obs, UnitsMetric) 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) metricObs, ok := metric.(*model.WeatherObservation)
if !ok { if !ok {
t.Fatalf("expected metric payload to remain model type, got %T", metric) t.Fatalf("expected metric payload to remain model type, got %T", metric)
} }
if metricObs != obs { if metricObs == obs {
t.Fatalf("expected metric payload to be original pointer") 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) != nil { if ObservationPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil observation input to return nil payload") t.Fatalf("expected nil observation input to return nil payload")
} }
if ForecastPayload(nil, UnitsUS) != nil { if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
t.Fatalf("expected nil forecast input to return nil payload") t.Fatalf("expected nil forecast input to return nil payload")
} }
if AlertsPayload(nil, UnitsUS) != nil { if AlertsPayload(nil, UnitsUS) != nil {
t.Fatalf("expected nil alerts input to return nil payload") t.Fatalf("expected nil alerts input to return nil payload")
} }
if CurrentConditionsPayload(nil, UnitsUS) != nil { 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") t.Fatalf("expected nil current conditions input to return nil payload")
} }
} }
@@ -120,7 +256,7 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
IsDay: boolPtr(true), IsDay: boolPtr(true),
} }
metricPayload := CurrentConditionsPayload(conditions, UnitsMetric) metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2)
metric, ok := metricPayload.(CurrentConditionsResponse) metric, ok := metricPayload.(CurrentConditionsResponse)
if !ok { if !ok {
t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload) t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
@@ -134,13 +270,13 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText) t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
} }
usPayload := CurrentConditionsPayload(conditions, UnitsUS) usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse) us, ok := usPayload.(CurrentConditionsResponse)
if !ok { if !ok {
t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload) t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
} }
assertApprox(t, us.TemperatureF, 68, 0.0001) assertApprox(t, us.TemperatureF, 68, 0.0001)
assertApprox(t, us.WindSpeedMph, 62.1371192237, 0.0001) assertApprox(t, us.WindSpeedMph, 62.14, 0.0001)
if us.TemperatureC != nil || us.WindSpeedKmh != nil { if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload") t.Fatalf("expected metric fields omitted for US payload")
} }
@@ -151,7 +287,7 @@ func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
payload := CurrentConditionsPayload(&app.CurrentConditions{ payload := CurrentConditionsPayload(&app.CurrentConditions{
ConditionCode: 0, ConditionCode: 0,
IsDay: &night, IsDay: &night,
}, UnitsMetric) }, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse) metric, ok := payload.(CurrentConditionsResponse)
if !ok { if !ok {
@@ -162,6 +298,45 @@ func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
} }
} }
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 { func float64Ptr(v float64) *float64 {
return &v return &v
} }
@@ -179,3 +354,42 @@ func assertApprox(t *testing.T, got *float64, want, eps float64) {
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got) 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

@@ -5,6 +5,7 @@ package httpapi
import ( import (
"net/http" "net/http"
"strings" "strings"
"time"
"gitea.maximumdirect.net/ejr/feedapi/bind" "gitea.maximumdirect.net/ejr/feedapi/bind"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
@@ -14,6 +15,17 @@ type queryRequest struct {
Units presenter.Units 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) { func bindQuery(r *http.Request) (queryRequest, error) {
normalizeCommonQueryValue(r, "units") normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format") normalizeCommonQueryValue(r, "format")
@@ -34,3 +46,92 @@ func bindQuery(r *http.Request) (queryRequest, error) {
} }
return queryRequest{Units: units}, nil 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,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

@@ -13,6 +13,8 @@ import (
type Service interface { type Service interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, 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) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error) CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
} }

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

@@ -24,11 +24,7 @@ func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod {
Name: stringValue(row.Name), Name: stringValue(row.Name),
IsDay: boolPtr(row.IsDay), IsDay: boolPtr(row.IsDay),
ConditionCode: model.WMOCode(row.ConditionCode), ConditionCode: model.WMOCode(row.ConditionCode),
ConditionText: stringValue(row.ConditionText),
ProviderRawDescription: stringValue(row.ProviderRawDescription),
TextDescription: stringValue(row.TextDescription), TextDescription: stringValue(row.TextDescription),
DetailedText: stringValue(row.DetailedText),
IconURL: stringValue(row.IconURL),
TemperatureC: float64Ptr(row.TemperatureC), TemperatureC: float64Ptr(row.TemperatureC),
TemperatureCMin: float64Ptr(row.TemperatureCMin), TemperatureCMin: float64Ptr(row.TemperatureCMin),
TemperatureCMax: float64Ptr(row.TemperatureCMax), TemperatureCMax: float64Ptr(row.TemperatureCMax),

View File

@@ -1,4 +1,4 @@
// forecast_queries.go contains SQL text for hourly forecast reads. // forecast_queries.go contains SQL text for forecast reads.
// Layer: adapters/outbound/postgres forecast feature. // Layer: adapters/outbound/postgres forecast feature.
package postgres package postgres
@@ -17,6 +17,22 @@ SELECT
FROM forecasts FROM forecasts
WHERE product = 'hourly' WHERE product = 'hourly'
ORDER BY issued_at DESC, event_emitted_at DESC 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` LIMIT 1`
queryForecastPeriods = ` queryForecastPeriods = `
@@ -27,11 +43,7 @@ SELECT
name, name,
is_day, is_day,
condition_code, condition_code,
condition_text,
provider_raw_description,
text_description, text_description,
detailed_text,
icon_url,
temperature_c, temperature_c,
temperature_c_min, temperature_c_min,
temperature_c_max, temperature_c_max,

View File

@@ -1,4 +1,4 @@
// forecast_read.go executes hourly forecast and period queries. // forecast_read.go executes forecast and period queries.
// Layer: adapters/outbound/postgres forecast feature. // Layer: adapters/outbound/postgres forecast feature.
package postgres package postgres
@@ -12,12 +12,24 @@ import (
) )
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) { 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 { if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured") return nil, fmt.Errorf("postgres repository is not configured")
} }
var row forecastParentRow var row forecastParentRow
err := r.db.QueryRowContext(ctx, queryLatestHourlyForecast).Scan( err := r.db.QueryRowContext(ctx, parentQuery).Scan(
&row.EventID, &row.EventID,
&row.LocationID, &row.LocationID,
&row.LocationName, &row.LocationName,
@@ -32,7 +44,7 @@ func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherFo
return nil, nil return nil, nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("query latest hourly forecast: %w", err) return nil, fmt.Errorf("query latest %s forecast: %w", productLabel, err)
} }
run := mapForecastParentRow(row) run := mapForecastParentRow(row)
@@ -63,11 +75,7 @@ func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([
&row.Name, &row.Name,
&row.IsDay, &row.IsDay,
&row.ConditionCode, &row.ConditionCode,
&row.ConditionText,
&row.ProviderRawDescription,
&row.TextDescription, &row.TextDescription,
&row.DetailedText,
&row.IconURL,
&row.TemperatureC, &row.TemperatureC,
&row.TemperatureCMin, &row.TemperatureCMin,
&row.TemperatureCMax, &row.TemperatureCMax,

View File

@@ -26,11 +26,7 @@ type forecastPeriodRow struct {
Name sql.NullString Name sql.NullString
IsDay sql.NullBool IsDay sql.NullBool
ConditionCode int ConditionCode int
ConditionText sql.NullString
ProviderRawDescription sql.NullString
TextDescription sql.NullString TextDescription sql.NullString
DetailedText sql.NullString
IconURL sql.NullString
TemperatureC sql.NullFloat64 TemperatureC sql.NullFloat64
TemperatureCMin sql.NullFloat64 TemperatureCMin sql.NullFloat64
TemperatureCMax sql.NullFloat64 TemperatureCMax sql.NullFloat64

View File

@@ -6,6 +6,8 @@ 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) {
@@ -84,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)

View File

@@ -12,6 +12,8 @@ import (
type Repository interface { type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, 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) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error)
} }
@@ -33,6 +35,14 @@ func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForec
return s.repo.LatestHourlyForecast(ctx) 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) { func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
return s.repo.LatestAlertRun(ctx) return s.repo.LatestAlertRun(ctx)
} }

View File

@@ -13,6 +13,8 @@ import (
type fakeRepository struct { type fakeRepository struct {
observation *model.WeatherObservation observation *model.WeatherObservation
forecast *model.WeatherForecastRun forecast *model.WeatherForecastRun
narrative *model.WeatherForecastRun
discussion *model.WeatherForecastDiscussion
alerts *model.WeatherAlertRun alerts *model.WeatherAlertRun
conditions *CurrentConditions conditions *CurrentConditions
err error err error
@@ -28,6 +30,14 @@ func (r *fakeRepository) LatestHourlyForecast(context.Context) (*model.WeatherFo
return r.forecast, r.err 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) { func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
return r.alerts, r.err return r.alerts, r.err
} }
@@ -63,6 +73,19 @@ func TestServiceDelegatesForecast(t *testing.T) {
} }
} }
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) { func TestServiceDelegatesAlerts(t *testing.T) {
repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}} repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}}
svc := NewService(repo) svc := NewService(repo)
@@ -76,6 +99,19 @@ func TestServiceDelegatesAlerts(t *testing.T) {
} }
} }
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) { func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) {
repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}} repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}}
svc := NewService(repo) svc := NewService(repo)

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}}