Initial MVP commit
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-17 08:35:16 -05:00
parent 8c092c2247
commit 27817f9e43
29 changed files with 1890 additions and 1 deletions

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

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

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

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

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