diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index dca484e..8d8bc21 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -18,6 +18,7 @@ type Service interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) + CurrentConditions(ctx context.Context) (*core.CurrentConditions, error) } type queryRequest struct { @@ -65,6 +66,19 @@ func Definitions(svc Service) []endpoint.Definition { endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("alerts_active.txt.tmpl"), ), + endpoint.GET( + "/conditions/current", + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { + conditions, err := svc.CurrentConditions(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: core.CurrentConditionsPayload(conditions, req.Units)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("conditions_current.txt.tmpl"), + ), } } diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index 12ff037..1d8e091 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -16,6 +16,7 @@ import ( "gitea.maximumdirect.net/ejr/feedapi/render" "gitea.maximumdirect.net/ejr/feedapi/templates" "gitea.maximumdirect.net/ejr/feedapi/transport/httpx" + "gitea.maximumdirect.net/ejr/weatherapi/internal/core" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) @@ -23,6 +24,7 @@ type fakeService struct { observation *model.WeatherObservation forecast *model.WeatherForecastRun alerts *model.WeatherAlertRun + conditions *core.CurrentConditions err error } @@ -38,6 +40,10 @@ func (s *fakeService) LatestActiveAlerts(context.Context) (*model.WeatherAlertRu return s.alerts, s.err } +func (s *fakeService) CurrentConditions(context.Context) (*core.CurrentConditions, error) { + return s.conditions, s.err +} + func TestObservationsRejectUnknownQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/observations") @@ -216,6 +222,218 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) { } } +func TestObservationUSUnitsWithXMLFormat(t *testing.T) { + h := newHandler(t, &fakeService{ + observation: &model.WeatherObservation{ + StationID: "KSTL", + Timestamp: time.Now().UTC(), + ConditionCode: 1, + TemperatureC: float64Ptr(20), + }, + }, "/observations") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/observations?format=xml&units=us", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } + if strings.Contains(w.Body.String(), "temperatureC") { + t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "temperatureF") { + t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String()) + } +} + +func TestForecastUSUnitsWithXMLFormatUppercaseQuery(t *testing.T) { + h := newHandler(t, &fakeService{ + forecast: &model.WeatherForecastRun{ + Product: model.ForecastProductHourly, + IssuedAt: time.Now().UTC(), + Periods: []model.WeatherForecastPeriod{{ + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Hour), + ConditionCode: 1, + TemperatureC: float64Ptr(10), + }}, + }, + }, "/forecast/hourly") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=XML&units=US", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } + body := w.Body.String() + if strings.Contains(body, "temperatureC") { + t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", body) + } + if !strings.Contains(body, "temperatureF") { + t.Fatalf("expected US field temperatureF in XML payload: %s", body) + } +} + +func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) { + h := newHandler(t, &fakeService{}, "/conditions/current") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/conditions/current", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data != nil { + t.Fatalf("expected data null, got %s", string(*payload.Data)) + } +} + +func TestCurrentConditionsMetricDefaultJSON(t *testing.T) { + h := newHandler(t, &fakeService{ + conditions: &core.CurrentConditions{ + TemperatureC: float64Ptr(10), + ApparentTemperatureC: float64Ptr(9), + DewpointC: float64Ptr(5), + RelativeHumidityPercent: float64Ptr(75), + WindSpeedKmh: float64Ptr(18), + WindDirectionDegrees: float64Ptr(135), + ConditionCode: 63, + }, + }, "/conditions/current") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/conditions/current", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if _, ok := payload.Data["temperatureC"]; !ok { + t.Fatalf("expected temperatureC in metric payload") + } + if _, ok := payload.Data["temperatureF"]; ok { + t.Fatalf("expected temperatureF omitted in metric payload") + } + if payload.Data["conditionText"] != "Rain" { + t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"]) + } +} + +func TestCurrentConditionsUSJSON(t *testing.T) { + h := newHandler(t, &fakeService{ + conditions: &core.CurrentConditions{ + TemperatureC: float64Ptr(10), + ApparentTemperatureC: float64Ptr(9), + DewpointC: float64Ptr(5), + RelativeHumidityPercent: float64Ptr(75), + WindSpeedKmh: float64Ptr(18), + WindDirectionDegrees: float64Ptr(135), + ConditionCode: 63, + }, + }, "/conditions/current") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/conditions/current?units=us", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if _, ok := payload.Data["temperatureF"]; !ok { + t.Fatalf("expected temperatureF in us payload") + } + if _, ok := payload.Data["temperatureC"]; ok { + t.Fatalf("expected temperatureC omitted in us payload") + } + if _, ok := payload.Data["windSpeedMph"]; !ok { + t.Fatalf("expected windSpeedMph in us payload") + } + if _, ok := payload.Data["windSpeedKmh"]; ok { + t.Fatalf("expected windSpeedKmh omitted in us payload") + } +} + +func TestCurrentConditionsXMLAndTextFormats(t *testing.T) { + h := newHandler(t, &fakeService{ + conditions: &core.CurrentConditions{ + TemperatureC: float64Ptr(10), + WindSpeedKmh: float64Ptr(18), + ConditionCode: 2, + }, + }, "/conditions/current") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/conditions/current?format=xml&units=us", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Body.String(), "temperatureF") { + t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String()) + } + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for text request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Body.String(), "Conditions text") { + t.Fatalf("expected rendered text template body, got %q", w.Body.String()) + } +} + +func TestCurrentConditionsRejectUnknownQueryParameter(t *testing.T) { + h := newHandler(t, &fakeService{}, "/conditions/current") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/conditions/current?bogus=1", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + func newHandler(t *testing.T, svc Service, path string) http.Handler { t.Helper() @@ -250,9 +468,10 @@ func testRenderers(t *testing.T) *render.Registry { tmplReg := templates.NewRegistry() for name, body := range map[string]string{ - "observations.txt.tmpl": "Observation text", - "forecast_hourly.txt.tmpl": "Forecast text", - "alerts_active.txt.tmpl": "Alerts text", + "observations.txt.tmpl": "Observation text", + "forecast_hourly.txt.tmpl": "Forecast text", + "alerts_active.txt.tmpl": "Alerts text", + "conditions_current.txt.tmpl": "Conditions text", } { tmpl, err := template.New(name).Parse(body) if err != nil { diff --git a/internal/adapters/outbound/postgres/repository.go b/internal/adapters/outbound/postgres/repository.go index a3ad2e5..3fcb066 100644 --- a/internal/adapters/outbound/postgres/repository.go +++ b/internal/adapters/outbound/postgres/repository.go @@ -36,6 +36,51 @@ FROM observations ORDER BY observed_at DESC, event_emitted_at DESC LIMIT 1` + 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` + queryObservationPresentWeather = ` SELECT weather_index, raw_text FROM observation_present_weather @@ -193,6 +238,33 @@ func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObser return &obs, nil } +func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*core.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 +} + 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") @@ -419,6 +491,40 @@ type observationParentRow struct { ApparentTemperatureC sql.NullFloat64 } +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 +} + +func mapCurrentConditionsRow(row currentConditionsRow) *core.CurrentConditions { + if row.SampleCount == 0 { + return nil + } + + conditionCode := model.WMOUnknown + if row.ConditionCode.Valid { + conditionCode = model.WMOCode(row.ConditionCode.Int64) + } + + return &core.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), + } +} + func mapObservationParentRow(row observationParentRow) model.WeatherObservation { return model.WeatherObservation{ StationID: stringValue(row.StationID), diff --git a/internal/adapters/outbound/postgres/repository_test.go b/internal/adapters/outbound/postgres/repository_test.go index aeb1080..9695d55 100644 --- a/internal/adapters/outbound/postgres/repository_test.go +++ b/internal/adapters/outbound/postgres/repository_test.go @@ -110,3 +110,54 @@ func TestAttachAlertReferencesPreservesOrder(t *testing.T) { 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) + } +} diff --git a/internal/core/constants.go b/internal/core/constants.go index 9055eea..534a42b 100644 --- a/internal/core/constants.go +++ b/internal/core/constants.go @@ -8,6 +8,10 @@ const ( UnitsUS Units = "us" ) +const ( + ObservationWindowMinutesDefault = 30 +) + const ( celsiusToFahrenheitScale = 9.0 / 5.0 celsiusToFahrenheitOffset = 32.0 diff --git a/internal/core/current_conditions.go b/internal/core/current_conditions.go new file mode 100644 index 0000000..645fda0 --- /dev/null +++ b/internal/core/current_conditions.go @@ -0,0 +1,15 @@ +package core + +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 +} diff --git a/internal/core/payload.go b/internal/core/payload.go index 0694fc4..a275393 100644 --- a/internal/core/payload.go +++ b/internal/core/payload.go @@ -4,6 +4,7 @@ import ( "time" "gitea.maximumdirect.net/ejr/weatherfeeder/model" + "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) // WeatherObservationUS is the US-customary response shape for observations. @@ -69,6 +70,24 @@ type WeatherForecastPeriodUS struct { UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` } +// 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 ObservationPayload(obs *model.WeatherObservation, units Units) any { if obs == nil { return nil @@ -155,6 +174,34 @@ func AlertsPayload(run *model.WeatherAlertRun, _ Units) any { return run } +func CurrentConditionsPayload(conditions *CurrentConditions, units Units) any { + if conditions == nil { + return nil + } + + out := CurrentConditionsResponse{ + RelativeHumidityPercent: copyFloat64Ptr(conditions.RelativeHumidityPercent), + WindDirectionDegrees: copyFloat64Ptr(conditions.WindDirectionDegrees), + ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay), + IsDay: copyBoolPtr(conditions.IsDay), + IsDayText: boolText(conditions.IsDay), + } + + if units == UnitsUS { + out.TemperatureF = celsiusToFahrenheitPtr(conditions.TemperatureC) + out.ApparentTemperatureF = celsiusToFahrenheitPtr(conditions.ApparentTemperatureC) + out.DewpointF = celsiusToFahrenheitPtr(conditions.DewpointC) + out.WindSpeedMph = scalePtr(conditions.WindSpeedKmh, kmhToMphFactor) + return out + } + + out.TemperatureC = copyFloat64Ptr(conditions.TemperatureC) + out.ApparentTemperatureC = copyFloat64Ptr(conditions.ApparentTemperatureC) + out.DewpointC = copyFloat64Ptr(conditions.DewpointC) + out.WindSpeedKmh = copyFloat64Ptr(conditions.WindSpeedKmh) + return out +} + func celsiusToFahrenheitPtr(v *float64) *float64 { if v == nil { return nil @@ -194,3 +241,13 @@ func copyTimePtr(v *time.Time) *time.Time { out := *v return &out } + +func boolText(v *bool) string { + if v == nil { + return "" + } + if *v { + return "true" + } + return "false" +} diff --git a/internal/core/payload_test.go b/internal/core/payload_test.go index fb3478e..dfa0e57 100644 --- a/internal/core/payload_test.go +++ b/internal/core/payload_test.go @@ -102,12 +102,73 @@ func TestMetricPassthroughAndNilHandling(t *testing.T) { if AlertsPayload(nil, UnitsUS) != nil { t.Fatalf("expected nil alerts input to return nil payload") } + if CurrentConditionsPayload(nil, UnitsUS) != nil { + t.Fatalf("expected nil current conditions input to return nil payload") + } +} + +func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) { + conditions := &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) + 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) + 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.1371192237, 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(&CurrentConditions{ + ConditionCode: 0, + IsDay: &night, + }, UnitsMetric) + + 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 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 { diff --git a/internal/core/service.go b/internal/core/service.go index fab994c..ca522c4 100644 --- a/internal/core/service.go +++ b/internal/core/service.go @@ -11,6 +11,7 @@ type Repository interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) + CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) } // Service provides weather read use-cases. @@ -33,3 +34,7 @@ func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForec func (s *Service) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) { return s.repo.LatestActiveAlerts(ctx) } + +func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) { + return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault) +} diff --git a/templates/conditions_current.txt.tmpl b/templates/conditions_current.txt.tmpl new file mode 100644 index 0000000..eaaa5b2 --- /dev/null +++ b/templates/conditions_current.txt.tmpl @@ -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}}