Initial MVP commit
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
57
internal/application/alerts/service.go
Normal file
57
internal/application/alerts/service.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo ports.AlertRepository
|
||||
}
|
||||
|
||||
func NewService(repo ports.AlertRepository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Alerts []AlertResponse `json:"alerts"`
|
||||
}
|
||||
|
||||
type AlertResponse struct {
|
||||
Effective *time.Time `json:"effective"`
|
||||
Expires *time.Time `json:"expires"`
|
||||
Severity *string `json:"severity"`
|
||||
Event *string `json:"event"`
|
||||
Headline *string `json:"headline"`
|
||||
Instruction *string `json:"instruction"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Service) GetCurrent(ctx context.Context) (Response, error) {
|
||||
if s == nil {
|
||||
return Response{}, fmt.Errorf("alerts service is nil")
|
||||
}
|
||||
|
||||
rows, err := s.repo.ListCurrentAlerts(ctx)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
alerts := make([]AlertResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
alerts = append(alerts, AlertResponse{
|
||||
Effective: row.Effective,
|
||||
Expires: row.Expires,
|
||||
Severity: row.Severity,
|
||||
Event: row.Event,
|
||||
Headline: row.Headline,
|
||||
Instruction: row.Instruction,
|
||||
Description: row.Description,
|
||||
})
|
||||
}
|
||||
|
||||
return Response{Alerts: alerts}, nil
|
||||
}
|
||||
104
internal/application/forecasts/service.go
Normal file
104
internal/application/forecasts/service.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package forecasts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo ports.ForecastRepository
|
||||
converter units.Converter
|
||||
limit int
|
||||
}
|
||||
|
||||
func NewService(repo ports.ForecastRepository, converter units.Converter, limit int) *Service {
|
||||
return &Service{repo: repo, converter: converter, limit: limit}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Periods []PeriodResponse `json:"periods"`
|
||||
}
|
||||
|
||||
type PeriodResponse struct {
|
||||
PeriodIndex int `json:"periodIndex"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
Name *string `json:"name"`
|
||||
IsDay *bool `json:"isDay"`
|
||||
ConditionCode int `json:"conditionCode"`
|
||||
ConditionText *string `json:"conditionText"`
|
||||
ProviderRawDescription *string `json:"providerRawDescription"`
|
||||
TextDescription *string `json:"textDescription"`
|
||||
DetailedText *string `json:"detailedText"`
|
||||
IconURL *string `json:"iconUrl"`
|
||||
TemperatureF *float64 `json:"temperatureF"`
|
||||
TemperatureFMin *float64 `json:"temperatureFMin"`
|
||||
TemperatureFMax *float64 `json:"temperatureFMax"`
|
||||
DewpointF *float64 `json:"dewpointF"`
|
||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent"`
|
||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees"`
|
||||
WindSpeedMph *float64 `json:"windSpeedMph"`
|
||||
WindGustMph *float64 `json:"windGustMph"`
|
||||
BarometricPressurePa *float64 `json:"barometricPressurePa"`
|
||||
VisibilityMeters *float64 `json:"visibilityMeters"`
|
||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF"`
|
||||
CloudCoverPercent *float64 `json:"cloudCoverPercent"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent"`
|
||||
PrecipitationAmountMm *float64 `json:"precipitationAmountMm"`
|
||||
SnowfallDepthMm *float64 `json:"snowfallDepthMm"`
|
||||
UVIndex *float64 `json:"uvIndex"`
|
||||
}
|
||||
|
||||
func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time) (Response, error) {
|
||||
if s == nil {
|
||||
return Response{}, fmt.Errorf("forecasts service is nil")
|
||||
}
|
||||
|
||||
periodsMetric, err := s.repo.ListForecastPeriodsAt(ctx, ts, s.limit)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
periods := make([]PeriodResponse, 0, len(periodsMetric))
|
||||
for _, p := range periodsMetric {
|
||||
periods = append(periods, PeriodResponse{
|
||||
PeriodIndex: p.PeriodIndex,
|
||||
StartTime: p.StartTime,
|
||||
EndTime: p.EndTime,
|
||||
Name: p.Name,
|
||||
IsDay: p.IsDay,
|
||||
ConditionCode: p.ConditionCode,
|
||||
ConditionText: p.ConditionText,
|
||||
ProviderRawDescription: p.ProviderRawDescription,
|
||||
TextDescription: p.TextDescription,
|
||||
DetailedText: p.DetailedText,
|
||||
IconURL: p.IconURL,
|
||||
TemperatureF: s.converter.TemperatureCToOutput(p.TemperatureC),
|
||||
TemperatureFMin: s.converter.TemperatureCToOutput(p.TemperatureCMin),
|
||||
TemperatureFMax: s.converter.TemperatureCToOutput(p.TemperatureCMax),
|
||||
DewpointF: s.converter.TemperatureCToOutput(p.DewpointC),
|
||||
RelativeHumidityPercent: p.RelativeHumidityPercent,
|
||||
WindDirectionDegrees: p.WindDirectionDegrees,
|
||||
WindSpeedMph: s.converter.SpeedKmhToOutput(p.WindSpeedKmh),
|
||||
WindGustMph: s.converter.SpeedKmhToOutput(p.WindGustKmh),
|
||||
BarometricPressurePa: p.BarometricPressurePa,
|
||||
VisibilityMeters: p.VisibilityMeters,
|
||||
ApparentTemperatureF: s.converter.TemperatureCToOutput(p.ApparentTemperatureC),
|
||||
CloudCoverPercent: p.CloudCoverPercent,
|
||||
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitationPercent,
|
||||
PrecipitationAmountMm: p.PrecipitationAmountMm,
|
||||
SnowfallDepthMm: p.SnowfallDepthMm,
|
||||
UVIndex: p.UVIndex,
|
||||
})
|
||||
}
|
||||
|
||||
return Response{
|
||||
Timestamp: ts,
|
||||
Periods: periods,
|
||||
}, nil
|
||||
}
|
||||
84
internal/application/observations/service.go
Normal file
84
internal/application/observations/service.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package observations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo ports.ObservationRepository
|
||||
converter units.Converter
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func NewService(repo ports.ObservationRepository, converter units.Converter, window time.Duration) *Service {
|
||||
return &Service{repo: repo, converter: converter, window: window}
|
||||
}
|
||||
|
||||
type CurrentResponse struct {
|
||||
Summary SummaryResponse `json:"summary"`
|
||||
Conditions []ConditionResponse `json:"conditions"`
|
||||
PrecipitationEvents []string `json:"precipitationEvents"`
|
||||
}
|
||||
|
||||
type SummaryResponse struct {
|
||||
TemperatureF *float64 `json:"temperatureF"`
|
||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF"`
|
||||
WindowMinutes int `json:"windowMinutes"`
|
||||
}
|
||||
|
||||
type ConditionResponse struct {
|
||||
StationID *string `json:"stationId"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
TemperatureF *float64 `json:"temperatureF"`
|
||||
TextDescription *string `json:"textDescription"`
|
||||
ProviderRawDescription *string `json:"providerRawDescription"`
|
||||
ConditionText *string `json:"conditionText"`
|
||||
}
|
||||
|
||||
func (s *Service) GetCurrent(ctx context.Context) (CurrentResponse, error) {
|
||||
if s == nil {
|
||||
return CurrentResponse{}, fmt.Errorf("observations service is nil")
|
||||
}
|
||||
|
||||
summary, err := s.repo.GetCurrentSummary(ctx, s.window)
|
||||
if err != nil {
|
||||
return CurrentResponse{}, err
|
||||
}
|
||||
|
||||
conditionsMetric, err := s.repo.ListCurrentConditions(ctx, s.window)
|
||||
if err != nil {
|
||||
return CurrentResponse{}, err
|
||||
}
|
||||
|
||||
precip, err := s.repo.ListCurrentPrecipitationEvents(ctx, s.window)
|
||||
if err != nil {
|
||||
return CurrentResponse{}, err
|
||||
}
|
||||
|
||||
conditions := make([]ConditionResponse, 0, len(conditionsMetric))
|
||||
for _, c := range conditionsMetric {
|
||||
conditions = append(conditions, ConditionResponse{
|
||||
StationID: c.StationID,
|
||||
ObservedAt: c.ObservedAt,
|
||||
TemperatureF: s.converter.TemperatureCToOutput(c.TemperatureC),
|
||||
TextDescription: c.TextDescription,
|
||||
ProviderRawDescription: c.ProviderRawDescription,
|
||||
ConditionText: c.ConditionText,
|
||||
})
|
||||
}
|
||||
|
||||
return CurrentResponse{
|
||||
Summary: SummaryResponse{
|
||||
TemperatureF: s.converter.TemperatureCToOutput(summary.TemperatureC),
|
||||
ApparentTemperatureF: s.converter.TemperatureCToOutput(summary.ApparentTemperatureC),
|
||||
WindowMinutes: int(s.window / time.Minute),
|
||||
},
|
||||
Conditions: conditions,
|
||||
PrecipitationEvents: precip,
|
||||
}, nil
|
||||
}
|
||||
35
internal/application/units/converter.go
Normal file
35
internal/application/units/converter.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package units
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
|
||||
)
|
||||
|
||||
type Converter interface {
|
||||
TemperatureCToOutput(celsius *float64) *float64
|
||||
SpeedKmhToOutput(kmh *float64) *float64
|
||||
}
|
||||
|
||||
type USConverter struct{}
|
||||
|
||||
func (USConverter) TemperatureCToOutput(celsius *float64) *float64 {
|
||||
if celsius == nil {
|
||||
return nil
|
||||
}
|
||||
f := round(constants.CelsiusToFahrenheit(*celsius), constants.TempFahrenheitPrecision)
|
||||
return &f
|
||||
}
|
||||
|
||||
func (USConverter) SpeedKmhToOutput(kmh *float64) *float64 {
|
||||
if kmh == nil {
|
||||
return nil
|
||||
}
|
||||
mph := round(constants.KmhToMph(*kmh), constants.WindMphPrecision)
|
||||
return &mph
|
||||
}
|
||||
|
||||
func round(v float64, precision int) float64 {
|
||||
pow := math.Pow(10, float64(precision))
|
||||
return math.Round(v*pow) / pow
|
||||
}
|
||||
43
internal/application/units/converter_test.go
Normal file
43
internal/application/units/converter_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package units
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUSConverterTemperatureCToOutput(t *testing.T) {
|
||||
c := USConverter{}
|
||||
|
||||
if got := c.TemperatureCToOutput(nil); got != nil {
|
||||
t.Fatalf("expected nil for nil input")
|
||||
}
|
||||
|
||||
zeroC := 0.0
|
||||
got := c.TemperatureCToOutput(&zeroC)
|
||||
if got == nil || *got != 32.0 {
|
||||
t.Fatalf("expected 32.0F, got %v", got)
|
||||
}
|
||||
|
||||
v := 20.56 // 69.008F -> 69.0 at precision 1
|
||||
got = c.TemperatureCToOutput(&v)
|
||||
if got == nil || *got != 69.0 {
|
||||
t.Fatalf("expected 69.0F, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUSConverterSpeedKmhToOutput(t *testing.T) {
|
||||
c := USConverter{}
|
||||
|
||||
if got := c.SpeedKmhToOutput(nil); got != nil {
|
||||
t.Fatalf("expected nil for nil input")
|
||||
}
|
||||
|
||||
v := 10.0 // 6.21371 mph -> 6.2 at precision 1
|
||||
got := c.SpeedKmhToOutput(&v)
|
||||
if got == nil || *got != 6.2 {
|
||||
t.Fatalf("expected 6.2 mph, got %v", got)
|
||||
}
|
||||
|
||||
exact := 16.09344 // exact 10 mph
|
||||
got = c.SpeedKmhToOutput(&exact)
|
||||
if got == nil || *got != 10.0 {
|
||||
t.Fatalf("expected 10.0 mph, got %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user