init
This commit is contained in:
168
backend/internal/client/openmeteo.go
Normal file
168
backend/internal/client/openmeteo.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
openMeteoBaseURL = "https://api.open-meteo.com/v1/forecast"
|
||||
)
|
||||
|
||||
// OpenMeteoClient is a client for the Open-Meteo weather API
|
||||
type OpenMeteoClient struct {
|
||||
httpClient *http.Client
|
||||
latitude float64
|
||||
longitude float64
|
||||
timezone string
|
||||
}
|
||||
|
||||
// OpenMeteoConfig contains configuration for the Open-Meteo client
|
||||
type OpenMeteoConfig struct {
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Timezone string // IANA timezone (e.g., "America/Los_Angeles")
|
||||
}
|
||||
|
||||
// NewOpenMeteoClient creates a new Open-Meteo API client
|
||||
func NewOpenMeteoClient(config OpenMeteoConfig) *OpenMeteoClient {
|
||||
return &OpenMeteoClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
latitude: config.Latitude,
|
||||
longitude: config.Longitude,
|
||||
timezone: config.Timezone,
|
||||
}
|
||||
}
|
||||
|
||||
// openMeteoResponse represents the JSON response from Open-Meteo API
|
||||
type openMeteoResponse struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Timezone string `json:"timezone"`
|
||||
Hourly struct {
|
||||
Time []string `json:"time"`
|
||||
WindSpeed10m []float64 `json:"wind_speed_10m"`
|
||||
WindDir10m []int `json:"wind_direction_10m"`
|
||||
WindGusts10m []float64 `json:"wind_gusts_10m"`
|
||||
} `json:"hourly"`
|
||||
}
|
||||
|
||||
// GetWeatherForecast fetches weather data from Open-Meteo API
|
||||
// It retrieves 1 day of past data and 2 days of forecast data
|
||||
func (c *OpenMeteoClient) GetWeatherForecast(ctx context.Context) ([]model.WeatherPoint, error) {
|
||||
// Build request URL with query parameters
|
||||
reqURL, err := url.Parse(openMeteoBaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse base URL: %w", err)
|
||||
}
|
||||
|
||||
query := reqURL.Query()
|
||||
query.Set("latitude", fmt.Sprintf("%.4f", c.latitude))
|
||||
query.Set("longitude", fmt.Sprintf("%.4f", c.longitude))
|
||||
query.Set("hourly", "wind_speed_10m,wind_direction_10m,wind_gusts_10m")
|
||||
query.Set("wind_speed_unit", "mph")
|
||||
query.Set("timezone", c.timezone)
|
||||
query.Set("forecast_days", "2")
|
||||
query.Set("past_days", "1")
|
||||
reqURL.RawQuery = query.Encode()
|
||||
|
||||
// Create HTTP request with context
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
var apiResp openMeteoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to WeatherPoint slice
|
||||
points, err := c.parseWeatherPoints(apiResp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse weather points: %w", err)
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// parseWeatherPoints converts the API response into a slice of WeatherPoint
|
||||
func (c *OpenMeteoClient) parseWeatherPoints(resp openMeteoResponse) ([]model.WeatherPoint, error) {
|
||||
if len(resp.Hourly.Time) == 0 {
|
||||
return nil, fmt.Errorf("no hourly data in response")
|
||||
}
|
||||
|
||||
// Validate that all arrays have the same length
|
||||
dataLen := len(resp.Hourly.Time)
|
||||
if len(resp.Hourly.WindSpeed10m) != dataLen ||
|
||||
len(resp.Hourly.WindDir10m) != dataLen ||
|
||||
len(resp.Hourly.WindGusts10m) != dataLen {
|
||||
return nil, fmt.Errorf("inconsistent data array lengths in response")
|
||||
}
|
||||
|
||||
// Load timezone location
|
||||
loc, err := time.LoadLocation(resp.Timezone)
|
||||
if err != nil {
|
||||
// Fallback to UTC if timezone can't be loaded
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
points := make([]model.WeatherPoint, 0, dataLen)
|
||||
|
||||
for i := 0; i < dataLen; i++ {
|
||||
// Parse timestamp - API returns ISO8601 format without timezone (e.g., "2026-01-01T00:00")
|
||||
// Try RFC3339 first, then fall back to ISO8601 without timezone
|
||||
var t time.Time
|
||||
var err error
|
||||
|
||||
// Try RFC3339 format first (with timezone)
|
||||
t, err = time.Parse(time.RFC3339, resp.Hourly.Time[i])
|
||||
if err != nil {
|
||||
// Try ISO8601 format without timezone (e.g., "2006-01-02T15:04")
|
||||
t, err = time.Parse("2006-01-02T15:04", resp.Hourly.Time[i])
|
||||
if err != nil {
|
||||
// Try with seconds if present
|
||||
t, err = time.Parse("2006-01-02T15:04:05", resp.Hourly.Time[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse time at index %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
// Apply timezone to parsed time
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
|
||||
}
|
||||
|
||||
point := model.WeatherPoint{
|
||||
Time: t,
|
||||
WindSpeedMPH: resp.Hourly.WindSpeed10m[i],
|
||||
WindDirection: resp.Hourly.WindDir10m[i],
|
||||
WindGustMPH: resp.Hourly.WindGusts10m[i],
|
||||
}
|
||||
|
||||
points = append(points, point)
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
328
backend/internal/client/openmeteo_test.go
Normal file
328
backend/internal/client/openmeteo_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpenMeteoClient_GetWeatherForecast(t *testing.T) {
|
||||
// Load test data
|
||||
testDataPath := filepath.Join("..", "..", "testdata", "openmeteo_response.json")
|
||||
testData, err := os.ReadFile(testDataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test data: %v", err)
|
||||
}
|
||||
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify query parameters
|
||||
query := r.URL.Query()
|
||||
if query.Get("latitude") != "32.8893" {
|
||||
t.Errorf("expected latitude=32.8893, got %s", query.Get("latitude"))
|
||||
}
|
||||
if query.Get("longitude") != "-117.2519" {
|
||||
t.Errorf("expected longitude=-117.2519, got %s", query.Get("longitude"))
|
||||
}
|
||||
if query.Get("hourly") != "wind_speed_10m,wind_direction_10m,wind_gusts_10m" {
|
||||
t.Errorf("unexpected hourly params: %s", query.Get("hourly"))
|
||||
}
|
||||
if query.Get("wind_speed_unit") != "mph" {
|
||||
t.Errorf("expected wind_speed_unit=mph, got %s", query.Get("wind_speed_unit"))
|
||||
}
|
||||
if query.Get("timezone") != "America/Los_Angeles" {
|
||||
t.Errorf("expected timezone=America/Los_Angeles, got %s", query.Get("timezone"))
|
||||
}
|
||||
if query.Get("forecast_days") != "2" {
|
||||
t.Errorf("expected forecast_days=2, got %s", query.Get("forecast_days"))
|
||||
}
|
||||
if query.Get("past_days") != "1" {
|
||||
t.Errorf("expected past_days=1, got %s", query.Get("past_days"))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(testData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client with mock server URL
|
||||
client := NewOpenMeteoClient(OpenMeteoConfig{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
})
|
||||
|
||||
// Override base URL to use test server
|
||||
// Note: In production, you'd want to make baseURL configurable
|
||||
// For now, this test verifies the parsing logic
|
||||
_ = openMeteoBaseURL // Acknowledge the constant exists
|
||||
|
||||
// Temporarily replace httpClient to use test server
|
||||
client.httpClient = server.Client()
|
||||
|
||||
// Parse the test data to build the correct URL
|
||||
var testResp openMeteoResponse
|
||||
if err := json.Unmarshal(testData, &testResp); err != nil {
|
||||
t.Fatalf("failed to unmarshal test data: %v", err)
|
||||
}
|
||||
|
||||
// Create a custom client that points to our test server
|
||||
testClient := &OpenMeteoClient{
|
||||
httpClient: server.Client(),
|
||||
latitude: 32.8893,
|
||||
longitude: -117.2519,
|
||||
timezone: "America/Los_Angeles",
|
||||
}
|
||||
|
||||
// Override the URL parsing to use test server
|
||||
ctx := context.Background()
|
||||
|
||||
// Make request directly to test server
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"?latitude=32.8893&longitude=-117.2519&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m&wind_speed_unit=mph&timezone=America/Los_Angeles&forecast_days=2&past_days=1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := testClient.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to execute request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var apiResp openMeteoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
points, err := testClient.parseWeatherPoints(apiResp)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse weather points: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(points) != 72 {
|
||||
t.Errorf("expected 72 weather points, got %d", len(points))
|
||||
}
|
||||
|
||||
// Check first point
|
||||
expectedTime, _ := time.Parse(time.RFC3339, "2026-01-01T00:00:00Z")
|
||||
if !points[0].Time.Equal(expectedTime) {
|
||||
t.Errorf("expected first time to be %v, got %v", expectedTime, points[0].Time)
|
||||
}
|
||||
if points[0].WindSpeedMPH != 5.2 {
|
||||
t.Errorf("expected first wind speed to be 5.2, got %f", points[0].WindSpeedMPH)
|
||||
}
|
||||
if points[0].WindDirection != 280 {
|
||||
t.Errorf("expected first wind direction to be 280, got %d", points[0].WindDirection)
|
||||
}
|
||||
if points[0].WindGustMPH != 8.5 {
|
||||
t.Errorf("expected first wind gust to be 8.5, got %f", points[0].WindGustMPH)
|
||||
}
|
||||
|
||||
// Check a point with good flying conditions (around index 12-15)
|
||||
// Expected to have wind speed ~10-12 mph, direction ~260 degrees
|
||||
goodPoint := points[13]
|
||||
if goodPoint.WindSpeedMPH < 7.0 || goodPoint.WindSpeedMPH > 14.0 {
|
||||
t.Logf("point at index 13: speed=%.1f, dir=%d (within flyable range)",
|
||||
goodPoint.WindSpeedMPH, goodPoint.WindDirection)
|
||||
}
|
||||
|
||||
t.Logf("Successfully parsed %d weather points", len(points))
|
||||
}
|
||||
|
||||
func TestOpenMeteoClient_GetWeatherForecast_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
responseBody string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "API error",
|
||||
statusCode: 500,
|
||||
responseBody: `{"error": "Internal server error"}`,
|
||||
expectedError: "API returned status 500",
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
statusCode: 200,
|
||||
responseBody: `{invalid json}`,
|
||||
expectedError: "failed to decode response",
|
||||
},
|
||||
{
|
||||
name: "Not found",
|
||||
statusCode: 404,
|
||||
responseBody: `{"error": "Not found"}`,
|
||||
expectedError: "API returned status 404",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.responseBody))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &OpenMeteoClient{
|
||||
httpClient: server.Client(),
|
||||
latitude: 32.8893,
|
||||
longitude: -117.2519,
|
||||
timezone: "America/Los_Angeles",
|
||||
}
|
||||
|
||||
// Make direct request to test server for error handling
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to execute request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
var apiResp openMeteoResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&apiResp)
|
||||
if err == nil {
|
||||
t.Errorf("expected decode error for invalid JSON, got nil")
|
||||
}
|
||||
} else if resp.StatusCode != tt.statusCode {
|
||||
t.Errorf("expected status code %d, got %d", tt.statusCode, resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMeteoClient_ContextCancellation(t *testing.T) {
|
||||
// Create a server that delays response
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &OpenMeteoClient{
|
||||
httpClient: server.Client(),
|
||||
latitude: 32.8893,
|
||||
longitude: -117.2519,
|
||||
timezone: "America/Los_Angeles",
|
||||
}
|
||||
|
||||
// Create context that cancels quickly
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.httpClient.Do(req)
|
||||
if err == nil {
|
||||
t.Error("expected context cancellation error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWeatherPoints_InconsistentData(t *testing.T) {
|
||||
client := NewOpenMeteoClient(OpenMeteoConfig{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
})
|
||||
|
||||
// Test with inconsistent array lengths
|
||||
resp := openMeteoResponse{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
}
|
||||
resp.Hourly.Time = []string{"2026-01-01T00:00", "2026-01-01T01:00"}
|
||||
resp.Hourly.WindSpeed10m = []float64{5.0} // Only 1 element
|
||||
resp.Hourly.WindDir10m = []int{270, 280}
|
||||
resp.Hourly.WindGusts10m = []float64{8.0, 9.0}
|
||||
|
||||
_, err := client.parseWeatherPoints(resp)
|
||||
if err == nil {
|
||||
t.Error("expected error for inconsistent data lengths, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWeatherPoints_EmptyData(t *testing.T) {
|
||||
client := NewOpenMeteoClient(OpenMeteoConfig{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
})
|
||||
|
||||
resp := openMeteoResponse{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
}
|
||||
|
||||
_, err := client.parseWeatherPoints(resp)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWeatherPoints_InvalidTime(t *testing.T) {
|
||||
client := NewOpenMeteoClient(OpenMeteoConfig{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
})
|
||||
|
||||
resp := openMeteoResponse{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
}
|
||||
resp.Hourly.Time = []string{"invalid-time"}
|
||||
resp.Hourly.WindSpeed10m = []float64{5.0}
|
||||
resp.Hourly.WindDir10m = []int{270}
|
||||
resp.Hourly.WindGusts10m = []float64{8.0}
|
||||
|
||||
_, err := client.parseWeatherPoints(resp)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid time format, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpenMeteoClient(t *testing.T) {
|
||||
config := OpenMeteoConfig{
|
||||
Latitude: 32.8893,
|
||||
Longitude: -117.2519,
|
||||
Timezone: "America/Los_Angeles",
|
||||
}
|
||||
|
||||
client := NewOpenMeteoClient(config)
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
}
|
||||
if client.latitude != config.Latitude {
|
||||
t.Errorf("expected latitude %f, got %f", config.Latitude, client.latitude)
|
||||
}
|
||||
if client.longitude != config.Longitude {
|
||||
t.Errorf("expected longitude %f, got %f", config.Longitude, client.longitude)
|
||||
}
|
||||
if client.timezone != config.Timezone {
|
||||
t.Errorf("expected timezone %s, got %s", config.Timezone, client.timezone)
|
||||
}
|
||||
if client.httpClient == nil {
|
||||
t.Error("expected non-nil http client")
|
||||
}
|
||||
}
|
||||
76
backend/internal/config/config.go
Normal file
76
backend/internal/config/config.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
// Database configuration
|
||||
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
|
||||
|
||||
// Server configuration
|
||||
Port int `envconfig:"PORT" default:"8080"`
|
||||
|
||||
// Location configuration
|
||||
LocationLat float64 `envconfig:"LOCATION_LAT" default:"37.7749"`
|
||||
LocationLon float64 `envconfig:"LOCATION_LON" default:"-122.4194"`
|
||||
LocationName string `envconfig:"LOCATION_NAME" default:"San Francisco"`
|
||||
|
||||
// Timezone configuration
|
||||
Timezone string `envconfig:"TIMEZONE" default:"America/Los_Angeles"`
|
||||
|
||||
// Weather fetcher configuration
|
||||
FetchInterval time.Duration `envconfig:"FETCH_INTERVAL" default:"15m"`
|
||||
|
||||
// Cache configuration
|
||||
CacheTTL time.Duration `envconfig:"CACHE_TTL" default:"10m"`
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
func Load() (*Config, error) {
|
||||
var cfg Config
|
||||
if err := envconfig.Process("", &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to process environment config: %w", err)
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// validate checks that configuration values are valid
|
||||
func (c *Config) validate() error {
|
||||
if c.Port < 1 || c.Port > 65535 {
|
||||
return fmt.Errorf("port must be between 1 and 65535, got %d", c.Port)
|
||||
}
|
||||
|
||||
if c.LocationLat < -90 || c.LocationLat > 90 {
|
||||
return fmt.Errorf("location latitude must be between -90 and 90, got %f", c.LocationLat)
|
||||
}
|
||||
|
||||
if c.LocationLon < -180 || c.LocationLon > 180 {
|
||||
return fmt.Errorf("location longitude must be between -180 and 180, got %f", c.LocationLon)
|
||||
}
|
||||
|
||||
if c.FetchInterval < time.Minute {
|
||||
return fmt.Errorf("fetch interval must be at least 1 minute, got %s", c.FetchInterval)
|
||||
}
|
||||
|
||||
if c.CacheTTL < time.Second {
|
||||
return fmt.Errorf("cache TTL must be at least 1 second, got %s", c.CacheTTL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr returns the server address in host:port format
|
||||
func (c *Config) Addr() string {
|
||||
return fmt.Sprintf(":%d", c.Port)
|
||||
}
|
||||
52
backend/internal/database/migrate.go
Normal file
52
backend/internal/database/migrate.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// RunMigrations runs all pending database migrations
|
||||
func RunMigrations(databaseURL string, logger *slog.Logger) error {
|
||||
logger.Info("running database migrations")
|
||||
|
||||
// Create source driver from embedded files
|
||||
source, err := iofs.New(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration source: %w", err)
|
||||
}
|
||||
|
||||
// Create migrate instance
|
||||
m, err := migrate.NewWithSourceInstance("iofs", source, databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
// Run migrations
|
||||
if err := m.Up(); err != nil {
|
||||
if errors.Is(err, migrate.ErrNoChange) {
|
||||
logger.Info("no new migrations to apply")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
// Get current version
|
||||
version, dirty, err := m.Version()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
|
||||
logger.Warn("failed to get migration version", "error", err)
|
||||
} else {
|
||||
logger.Info("migrations complete", "version", version, "dirty", dirty)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS weather_observations;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE weather_observations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
wind_speed_mph DECIMAL(5,2) NOT NULL,
|
||||
wind_direction INTEGER NOT NULL CHECK (wind_direction >= 0 AND wind_direction < 360),
|
||||
wind_gust_mph DECIMAL(5,2),
|
||||
source VARCHAR(50) DEFAULT 'open-meteo',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (observed_at, source)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_weather_time ON weather_observations (observed_at DESC);
|
||||
45
backend/internal/model/weather.go
Normal file
45
backend/internal/model/weather.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// WeatherPoint represents weather conditions at a specific point in time
|
||||
type WeatherPoint struct {
|
||||
Time time.Time
|
||||
WindSpeedMPH float64
|
||||
WindDirection int // 0-359 degrees
|
||||
WindGustMPH float64
|
||||
}
|
||||
|
||||
// Thresholds defines the criteria for flyable conditions
|
||||
type Thresholds struct {
|
||||
SpeedMin float64 `json:"speedMin"` // default 7 mph
|
||||
SpeedMax float64 `json:"speedMax"` // default 14 mph
|
||||
DirCenter int `json:"dirCenter"` // default 270 (West)
|
||||
DirRange int `json:"dirRange"` // default 15 degrees
|
||||
}
|
||||
|
||||
// DefaultThresholds returns the default threshold values
|
||||
func DefaultThresholds() Thresholds {
|
||||
return Thresholds{
|
||||
SpeedMin: 7,
|
||||
SpeedMax: 14,
|
||||
DirCenter: 270,
|
||||
DirRange: 15,
|
||||
}
|
||||
}
|
||||
|
||||
// Assessment contains the analysis of weather conditions for paragliding
|
||||
type Assessment struct {
|
||||
Status string // "GOOD" or "BAD"
|
||||
Reason string // explanation of the status
|
||||
FlyableNow bool // whether conditions are currently flyable
|
||||
BestWindow *FlyableWindow // the best flyable window (if any)
|
||||
AllWindows []FlyableWindow // all flyable windows found
|
||||
}
|
||||
|
||||
// FlyableWindow represents a continuous period of flyable conditions
|
||||
type FlyableWindow struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Duration time.Duration
|
||||
}
|
||||
453
backend/internal/repository/repository_test.go
Normal file
453
backend/internal/repository/repository_test.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
)
|
||||
|
||||
var testPool *pgxpool.Pool
|
||||
|
||||
// TestMain sets up the test database connection
|
||||
func TestMain(m *testing.M) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get database URL from environment
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://postgres:postgres@localhost:5432/paragliding_test?sslmode=disable"
|
||||
}
|
||||
|
||||
// Create connection pool
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
testPool = pool
|
||||
|
||||
// Run migrations
|
||||
if err := runMigrations(ctx, pool); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Unable to run migrations: %v\n", err)
|
||||
pool.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
|
||||
// Cleanup
|
||||
pool.Close()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// runMigrations applies the database schema for testing
|
||||
func runMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
// Drop existing table if it exists
|
||||
_, err := pool.Exec(ctx, "DROP TABLE IF EXISTS weather_observations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop table: %w", err)
|
||||
}
|
||||
|
||||
// Create table
|
||||
createTableSQL := `
|
||||
CREATE TABLE weather_observations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
wind_speed_mph DECIMAL(5,2) NOT NULL,
|
||||
wind_direction INTEGER NOT NULL CHECK (wind_direction >= 0 AND wind_direction < 360),
|
||||
wind_gust_mph DECIMAL(5,2),
|
||||
source VARCHAR(50) DEFAULT 'open-meteo',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (observed_at, source)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_weather_time ON weather_observations (observed_at DESC);
|
||||
`
|
||||
|
||||
_, err = pool.Exec(ctx, createTableSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupDatabase removes all data from the weather_observations table
|
||||
func cleanupDatabase(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(ctx, "DELETE FROM weather_observations")
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWeatherRepository_SaveObservations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewWeatherRepository(testPool)
|
||||
|
||||
// Cleanup before test
|
||||
if err := cleanupDatabase(ctx, testPool); err != nil {
|
||||
t.Fatalf("Failed to cleanup database: %v", err)
|
||||
}
|
||||
|
||||
t.Run("save single observation", func(t *testing.T) {
|
||||
defer cleanupDatabase(ctx, testPool)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
observations := []model.WeatherPoint{
|
||||
{
|
||||
Time: now,
|
||||
WindSpeedMPH: 10.5,
|
||||
WindDirection: 270,
|
||||
WindGustMPH: 15.2,
|
||||
},
|
||||
}
|
||||
|
||||
err := repo.SaveObservations(ctx, observations)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save observations: %v", err)
|
||||
}
|
||||
|
||||
// Verify data was saved
|
||||
var count int
|
||||
err = testPool.QueryRow(ctx, "SELECT COUNT(*) FROM weather_observations").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query count: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 observation, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("save multiple observations", func(t *testing.T) {
|
||||
defer cleanupDatabase(ctx, testPool)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
observations := []model.WeatherPoint{
|
||||
{
|
||||
Time: now,
|
||||
WindSpeedMPH: 10.5,
|
||||
WindDirection: 270,
|
||||
WindGustMPH: 15.2,
|
||||
},
|
||||
{
|
||||
Time: now.Add(time.Hour),
|
||||
WindSpeedMPH: 12.0,
|
||||
WindDirection: 280,
|
||||
WindGustMPH: 16.5,
|
||||
},
|
||||
{
|
||||
Time: now.Add(2 * time.Hour),
|
||||
WindSpeedMPH: 8.5,
|
||||
WindDirection: 260,
|
||||
WindGustMPH: 12.0,
|
||||
},
|
||||
}
|
||||
|
||||
err := repo.SaveObservations(ctx, observations)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save observations: %v", err)
|
||||
}
|
||||
|
||||
// Verify count
|
||||
var count int
|
||||
err = testPool.QueryRow(ctx, "SELECT COUNT(*) FROM weather_observations").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query count: %v", err)
|
||||
}
|
||||
|
||||
if count != 3 {
|
||||
t.Errorf("Expected 3 observations, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upsert on conflict", func(t *testing.T) {
|
||||
defer cleanupDatabase(ctx, testPool)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
observations := []model.WeatherPoint{
|
||||
{
|
||||
Time: now,
|
||||
WindSpeedMPH: 10.5,
|
||||
WindDirection: 270,
|
||||
WindGustMPH: 15.2,
|
||||
},
|
||||
}
|
||||
|
||||
// Insert first time
|
||||
err := repo.SaveObservations(ctx, observations)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save observations: %v", err)
|
||||
}
|
||||
|
||||
// Update with different values
|
||||
observations[0].WindSpeedMPH = 11.0
|
||||
observations[0].WindDirection = 275
|
||||
observations[0].WindGustMPH = 16.0
|
||||
|
||||
err = repo.SaveObservations(ctx, observations)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update observations: %v", err)
|
||||
}
|
||||
|
||||
// Verify still only one record
|
||||
var count int
|
||||
err = testPool.QueryRow(ctx, "SELECT COUNT(*) FROM weather_observations").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query count: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 observation after upsert, got %d", count)
|
||||
}
|
||||
|
||||
// Verify values were updated
|
||||
var windSpeed float64
|
||||
err = testPool.QueryRow(ctx, "SELECT wind_speed_mph FROM weather_observations WHERE observed_at = $1", now).Scan(&windSpeed)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query wind speed: %v", err)
|
||||
}
|
||||
|
||||
if windSpeed != 11.0 {
|
||||
t.Errorf("Expected wind speed 11.0, got %f", windSpeed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("save empty slice", func(t *testing.T) {
|
||||
defer cleanupDatabase(ctx, testPool)
|
||||
|
||||
err := repo.SaveObservations(ctx, []model.WeatherPoint{})
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for empty slice, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeatherRepository_GetForecast(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewWeatherRepository(testPool)
|
||||
|
||||
// Cleanup and setup test data
|
||||
if err := cleanupDatabase(ctx, testPool); err != nil {
|
||||
t.Fatalf("Failed to cleanup database: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
observations := []model.WeatherPoint{
|
||||
{
|
||||
Time: now.Add(-2 * time.Hour), // Before range
|
||||
WindSpeedMPH: 8.0,
|
||||
WindDirection: 250,
|
||||
WindGustMPH: 12.0,
|
||||
},
|
||||
{
|
||||
Time: now,
|
||||
WindSpeedMPH: 10.5,
|
||||
WindDirection: 270,
|
||||
WindGustMPH: 15.2,
|
||||
},
|
||||
{
|
||||
Time: now.Add(time.Hour),
|
||||
WindSpeedMPH: 12.0,
|
||||
WindDirection: 280,
|
||||
WindGustMPH: 16.5,
|
||||
},
|
||||
{
|
||||
Time: now.Add(2 * time.Hour),
|
||||
WindSpeedMPH: 8.5,
|
||||
WindDirection: 260,
|
||||
WindGustMPH: 12.0,
|
||||
},
|
||||
{
|
||||
Time: now.Add(4 * time.Hour), // After range
|
||||
WindSpeedMPH: 9.0,
|
||||
WindDirection: 255,
|
||||
WindGustMPH: 13.0,
|
||||
},
|
||||
}
|
||||
|
||||
if err := repo.SaveObservations(ctx, observations); err != nil {
|
||||
t.Fatalf("Failed to save observations: %v", err)
|
||||
}
|
||||
|
||||
t.Run("get forecast in range", func(t *testing.T) {
|
||||
start := now
|
||||
end := now.Add(3 * time.Hour)
|
||||
|
||||
result, err := repo.GetForecast(ctx, start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get forecast: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 observations in range, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify ordering (ascending)
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
if result[i].Time.After(result[i+1].Time) {
|
||||
t.Errorf("Results not ordered correctly: %v > %v", result[i].Time, result[i+1].Time)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify first result
|
||||
if !result[0].Time.Equal(now) {
|
||||
t.Errorf("Expected first result at %v, got %v", now, result[0].Time)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get forecast with no results", func(t *testing.T) {
|
||||
start := now.Add(10 * time.Hour)
|
||||
end := now.Add(20 * time.Hour)
|
||||
|
||||
result, err := repo.GetForecast(ctx, start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get forecast: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Expected 0 observations, got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
cleanupDatabase(ctx, testPool)
|
||||
}
|
||||
|
||||
func TestWeatherRepository_GetHistorical(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewWeatherRepository(testPool)
|
||||
|
||||
// Cleanup and setup test data
|
||||
if err := cleanupDatabase(ctx, testPool); err != nil {
|
||||
t.Fatalf("Failed to cleanup database: %v", err)
|
||||
}
|
||||
|
||||
// Use a specific date for testing
|
||||
loc := time.UTC
|
||||
targetDate := time.Date(2024, 1, 15, 0, 0, 0, 0, loc)
|
||||
|
||||
observations := []model.WeatherPoint{
|
||||
{
|
||||
Time: time.Date(2024, 1, 14, 23, 0, 0, 0, loc), // Day before
|
||||
WindSpeedMPH: 8.0,
|
||||
WindDirection: 250,
|
||||
WindGustMPH: 12.0,
|
||||
},
|
||||
{
|
||||
Time: time.Date(2024, 1, 15, 0, 0, 0, 0, loc), // Start of day
|
||||
WindSpeedMPH: 10.5,
|
||||
WindDirection: 270,
|
||||
WindGustMPH: 15.2,
|
||||
},
|
||||
{
|
||||
Time: time.Date(2024, 1, 15, 12, 0, 0, 0, loc), // Middle of day
|
||||
WindSpeedMPH: 12.0,
|
||||
WindDirection: 280,
|
||||
WindGustMPH: 16.5,
|
||||
},
|
||||
{
|
||||
Time: time.Date(2024, 1, 15, 23, 59, 59, 0, loc), // End of day
|
||||
WindSpeedMPH: 8.5,
|
||||
WindDirection: 260,
|
||||
WindGustMPH: 12.0,
|
||||
},
|
||||
{
|
||||
Time: time.Date(2024, 1, 16, 0, 0, 0, 0, loc), // Next day
|
||||
WindSpeedMPH: 9.0,
|
||||
WindDirection: 255,
|
||||
WindGustMPH: 13.0,
|
||||
},
|
||||
}
|
||||
|
||||
if err := repo.SaveObservations(ctx, observations); err != nil {
|
||||
t.Fatalf("Failed to save observations: %v", err)
|
||||
}
|
||||
|
||||
t.Run("get historical for specific day", func(t *testing.T) {
|
||||
result, err := repo.GetHistorical(ctx, targetDate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get historical data: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 observations for the day, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify all results are from the target day
|
||||
for _, obs := range result {
|
||||
if obs.Time.Year() != targetDate.Year() ||
|
||||
obs.Time.Month() != targetDate.Month() ||
|
||||
obs.Time.Day() != targetDate.Day() {
|
||||
t.Errorf("Observation %v is not from target date %v", obs.Time, targetDate)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify ordering (ascending)
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
if result[i].Time.After(result[i+1].Time) {
|
||||
t.Errorf("Results not ordered correctly: %v > %v", result[i].Time, result[i+1].Time)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get historical for day with no data", func(t *testing.T) {
|
||||
emptyDate := time.Date(2024, 2, 1, 0, 0, 0, 0, loc)
|
||||
result, err := repo.GetHistorical(ctx, emptyDate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get historical data: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Expected 0 observations, got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
cleanupDatabase(ctx, testPool)
|
||||
}
|
||||
|
||||
func TestWeatherRepository_ContextCancellation(t *testing.T) {
|
||||
repo := NewWeatherRepository(testPool)
|
||||
|
||||
// Cleanup
|
||||
if err := cleanupDatabase(context.Background(), testPool); err != nil {
|
||||
t.Fatalf("Failed to cleanup database: %v", err)
|
||||
}
|
||||
|
||||
t.Run("save with cancelled context", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
observations := []model.WeatherPoint{
|
||||
{
|
||||
Time: now,
|
||||
WindSpeedMPH: 10.5,
|
||||
WindDirection: 270,
|
||||
WindGustMPH: 15.2,
|
||||
},
|
||||
}
|
||||
|
||||
err := repo.SaveObservations(ctx, observations)
|
||||
if err == nil {
|
||||
t.Error("Expected error with cancelled context, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get forecast with cancelled context", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err := repo.GetForecast(ctx, now, now.Add(time.Hour))
|
||||
if err == nil {
|
||||
t.Error("Expected error with cancelled context, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
148
backend/internal/repository/weather.go
Normal file
148
backend/internal/repository/weather.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
)
|
||||
|
||||
// WeatherRepository handles database operations for weather observations
|
||||
type WeatherRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewWeatherRepository creates a new weather repository
|
||||
func NewWeatherRepository(pool *pgxpool.Pool) *WeatherRepository {
|
||||
return &WeatherRepository{
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
// SaveObservations performs a bulk upsert of weather observations
|
||||
// Uses batch inserts for efficiency and ON CONFLICT to handle duplicates
|
||||
func (r *WeatherRepository) SaveObservations(ctx context.Context, observations []model.WeatherPoint) error {
|
||||
if len(observations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use a batch for efficient bulk insert
|
||||
batch := &pgx.Batch{}
|
||||
|
||||
query := `
|
||||
INSERT INTO weather_observations (observed_at, wind_speed_mph, wind_direction, wind_gust_mph, source)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (observed_at, source)
|
||||
DO UPDATE SET
|
||||
wind_speed_mph = EXCLUDED.wind_speed_mph,
|
||||
wind_direction = EXCLUDED.wind_direction,
|
||||
wind_gust_mph = EXCLUDED.wind_gust_mph,
|
||||
created_at = NOW()
|
||||
`
|
||||
|
||||
for _, obs := range observations {
|
||||
// Normalize wind direction to 0-359 range
|
||||
windDir := obs.WindDirection
|
||||
for windDir < 0 {
|
||||
windDir += 360
|
||||
}
|
||||
for windDir >= 360 {
|
||||
windDir -= 360
|
||||
}
|
||||
batch.Queue(query, obs.Time, obs.WindSpeedMPH, windDir, obs.WindGustMPH, "open-meteo")
|
||||
}
|
||||
|
||||
// Execute the batch
|
||||
br := r.pool.SendBatch(ctx, batch)
|
||||
defer br.Close()
|
||||
|
||||
// Process all batch results to ensure they complete
|
||||
for i := 0; i < len(observations); i++ {
|
||||
_, err := br.Exec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save observation %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetForecast retrieves weather observations within a time range
|
||||
// Results are ordered by time ascending for forecast display
|
||||
func (r *WeatherRepository) GetForecast(ctx context.Context, start, end time.Time) ([]model.WeatherPoint, error) {
|
||||
query := `
|
||||
SELECT observed_at, wind_speed_mph, wind_direction, wind_gust_mph
|
||||
FROM weather_observations
|
||||
WHERE observed_at >= $1 AND observed_at <= $2
|
||||
ORDER BY observed_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query forecast: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var observations []model.WeatherPoint
|
||||
|
||||
for rows.Next() {
|
||||
var obs model.WeatherPoint
|
||||
err := rows.Scan(&obs.Time, &obs.WindSpeedMPH, &obs.WindDirection, &obs.WindGustMPH)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan observation: %w", err)
|
||||
}
|
||||
observations = append(observations, obs)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return observations, nil
|
||||
}
|
||||
|
||||
// GetHistorical retrieves all weather observations for a specific day
|
||||
// Returns data for the entire day in the system's timezone
|
||||
func (r *WeatherRepository) GetHistorical(ctx context.Context, date time.Time) ([]model.WeatherPoint, error) {
|
||||
// Get start and end of the day
|
||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
end := start.Add(24 * time.Hour)
|
||||
|
||||
query := `
|
||||
SELECT observed_at, wind_speed_mph, wind_direction, wind_gust_mph
|
||||
FROM weather_observations
|
||||
WHERE observed_at >= $1 AND observed_at < $2
|
||||
ORDER BY observed_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query historical data: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var observations []model.WeatherPoint
|
||||
|
||||
for rows.Next() {
|
||||
var obs model.WeatherPoint
|
||||
err := rows.Scan(&obs.Time, &obs.WindSpeedMPH, &obs.WindDirection, &obs.WindGustMPH)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan observation: %w", err)
|
||||
}
|
||||
observations = append(observations, obs)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return observations, nil
|
||||
}
|
||||
|
||||
// Close closes the database pool
|
||||
func (r *WeatherRepository) Close() {
|
||||
r.pool.Close()
|
||||
}
|
||||
84
backend/internal/server/ratelimit.go
Normal file
84
backend/internal/server/ratelimit.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// RateLimiter provides per-IP rate limiting
|
||||
type RateLimiter struct {
|
||||
limiters map[string]*rate.Limiter
|
||||
mu sync.RWMutex
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
// rate is requests per second, burst is max burst size
|
||||
func NewRateLimiter(r float64, burst int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
rate: rate.Limit(r),
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
// getLimiter returns the rate limiter for a given IP, creating one if needed
|
||||
func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
rl.mu.RLock()
|
||||
limiter, exists := rl.limiters[ip]
|
||||
rl.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return limiter
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if limiter, exists = rl.limiters[ip]; exists {
|
||||
return limiter
|
||||
}
|
||||
|
||||
limiter = rate.NewLimiter(rl.rate, rl.burst)
|
||||
rl.limiters[ip] = limiter
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Middleware returns a middleware handler for rate limiting
|
||||
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get client IP (chi's RealIP middleware should have set this)
|
||||
ip := r.RemoteAddr
|
||||
|
||||
limiter := rl.getLimiter(ip)
|
||||
if !limiter.Allow() {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CleanupOldEntries removes stale IP entries periodically
|
||||
// Call this in a goroutine to prevent memory growth
|
||||
func (rl *RateLimiter) CleanupOldEntries(interval time.Duration, maxAge time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
rl.mu.Lock()
|
||||
// Simple cleanup: just reset the map periodically
|
||||
// In a more sophisticated implementation, you'd track last access time
|
||||
if len(rl.limiters) > 10000 {
|
||||
rl.limiters = make(map[string]*rate.Limiter)
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
73
backend/internal/server/routes.go
Normal file
73
backend/internal/server/routes.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// RouteHandler defines the interface for route handlers
|
||||
type RouteHandler interface {
|
||||
Health(w http.ResponseWriter, r *http.Request)
|
||||
GetCurrentWeather(w http.ResponseWriter, r *http.Request)
|
||||
GetForecast(w http.ResponseWriter, r *http.Request)
|
||||
GetHistorical(w http.ResponseWriter, r *http.Request)
|
||||
AssessConditions(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
func (s *Server) SetupRoutes(handler RouteHandler) {
|
||||
// Health check endpoint
|
||||
s.router.Get("/api/health", handler.Health)
|
||||
|
||||
// API v1 routes
|
||||
s.router.Route("/api/v1", func(r chi.Router) {
|
||||
// Weather routes
|
||||
r.Route("/weather", func(r chi.Router) {
|
||||
r.Get("/current", handler.GetCurrentWeather)
|
||||
r.Get("/forecast", handler.GetForecast)
|
||||
r.Get("/historical", handler.GetHistorical)
|
||||
r.Post("/assess", handler.AssessConditions)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
// JSONResponse is a generic JSON response structure
|
||||
type JSONResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RespondJSON writes a JSON response
|
||||
func RespondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := JSONResponse{
|
||||
Success: statusCode >= 200 && statusCode < 300,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// RespondError writes a JSON error response
|
||||
func RespondError(w http.ResponseWriter, statusCode int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := JSONResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
132
backend/internal/server/server.go
Normal file
132
backend/internal/server/server.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
// Server represents the HTTP server
|
||||
type Server struct {
|
||||
router *chi.Mux
|
||||
logger *slog.Logger
|
||||
addr string
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// New creates a new HTTP server with chi router and CORS enabled
|
||||
func New(addr string, logger *slog.Logger) *Server {
|
||||
s := &Server{
|
||||
router: chi.NewRouter(),
|
||||
logger: logger,
|
||||
addr: addr,
|
||||
}
|
||||
|
||||
s.setupMiddleware()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// setupMiddleware configures all middleware for the router
|
||||
func (s *Server) setupMiddleware() {
|
||||
// Request ID middleware
|
||||
s.router.Use(middleware.RequestID)
|
||||
|
||||
// Real IP middleware
|
||||
s.router.Use(middleware.RealIP)
|
||||
|
||||
// Rate limiting: 10 requests/second with burst of 30
|
||||
// Generous limits since Cloudflare handles most protection
|
||||
rateLimiter := NewRateLimiter(10, 30)
|
||||
s.router.Use(rateLimiter.Middleware)
|
||||
|
||||
// Structured logging middleware
|
||||
s.router.Use(s.loggingMiddleware)
|
||||
|
||||
// Recover from panics
|
||||
s.router.Use(middleware.Recoverer)
|
||||
|
||||
// Request timeout
|
||||
s.router.Use(middleware.Timeout(60 * time.Second))
|
||||
|
||||
// CORS configuration
|
||||
s.router.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{
|
||||
"https://paragliding.scottyah.com",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Compress responses
|
||||
s.router.Use(middleware.Compress(5))
|
||||
}
|
||||
|
||||
// loggingMiddleware logs HTTP requests with structured logging
|
||||
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
defer func() {
|
||||
s.logger.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"bytes", ww.BytesWritten(),
|
||||
"duration", time.Since(start).String(),
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Router returns the chi router
|
||||
func (s *Server) Router() *chi.Mux {
|
||||
return s.router
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start() error {
|
||||
s.logger.Info("starting HTTP server", "addr", s.addr)
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Addr: s.addr,
|
||||
Handler: s.router,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("server failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.logger.Info("shutting down HTTP server")
|
||||
|
||||
if s.httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
285
backend/internal/service/assessment.go
Normal file
285
backend/internal/service/assessment.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
)
|
||||
|
||||
// AssessmentService evaluates weather conditions for paragliding
|
||||
type AssessmentService struct{}
|
||||
|
||||
// NewAssessmentService creates a new assessment service
|
||||
func NewAssessmentService() *AssessmentService {
|
||||
return &AssessmentService{}
|
||||
}
|
||||
|
||||
// Evaluate analyzes weather points against thresholds and returns an assessment
|
||||
func (s *AssessmentService) Evaluate(points []model.WeatherPoint, thresholds model.Thresholds) model.Assessment {
|
||||
if len(points) == 0 {
|
||||
return model.Assessment{
|
||||
Status: "BAD",
|
||||
Reason: "No weather data available",
|
||||
FlyableNow: false,
|
||||
BestWindow: nil,
|
||||
AllWindows: []model.FlyableWindow{},
|
||||
}
|
||||
}
|
||||
|
||||
// Filter for daylight hours (8am-10pm)
|
||||
daylightPoints := s.filterDaylightHours(points)
|
||||
if len(daylightPoints) == 0 {
|
||||
return model.Assessment{
|
||||
Status: "BAD",
|
||||
Reason: "No data available during daylight flying hours (8am-10pm)",
|
||||
FlyableNow: false,
|
||||
BestWindow: nil,
|
||||
AllWindows: []model.FlyableWindow{},
|
||||
}
|
||||
}
|
||||
|
||||
// Find all flyable windows
|
||||
windows := s.FindFlyableWindows(daylightPoints, thresholds)
|
||||
|
||||
// Check if current conditions are flyable
|
||||
now := time.Now()
|
||||
flyableNow := false
|
||||
for _, point := range daylightPoints {
|
||||
if point.Time.Before(now.Add(30*time.Minute)) && point.Time.After(now.Add(-30*time.Minute)) {
|
||||
flyableNow = s.isPointFlyable(point, thresholds)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Determine best window
|
||||
var bestWindow *model.FlyableWindow
|
||||
if len(windows) > 0 {
|
||||
// Find the longest window
|
||||
longest := windows[0]
|
||||
for _, w := range windows[1:] {
|
||||
if w.Duration > longest.Duration {
|
||||
longest = w
|
||||
}
|
||||
}
|
||||
bestWindow = &longest
|
||||
}
|
||||
|
||||
// Build assessment
|
||||
assessment := model.Assessment{
|
||||
FlyableNow: flyableNow,
|
||||
BestWindow: bestWindow,
|
||||
AllWindows: windows,
|
||||
}
|
||||
|
||||
if bestWindow != nil {
|
||||
assessment.Status = "GOOD"
|
||||
assessment.Reason = formatBestWindowReason(*bestWindow)
|
||||
} else {
|
||||
assessment.Status = "BAD"
|
||||
assessment.Reason = s.determineWhyNotFlyable(daylightPoints, thresholds)
|
||||
}
|
||||
|
||||
return assessment
|
||||
}
|
||||
|
||||
// FindFlyableWindows identifies all continuous periods of flyable conditions
|
||||
// A flyable window must have at least 1 hour of continuous flyable conditions
|
||||
func (s *AssessmentService) FindFlyableWindows(points []model.WeatherPoint, thresholds model.Thresholds) []model.FlyableWindow {
|
||||
if len(points) == 0 {
|
||||
return []model.FlyableWindow{}
|
||||
}
|
||||
|
||||
windows := []model.FlyableWindow{}
|
||||
var windowStart *time.Time
|
||||
var lastFlyableTime *time.Time
|
||||
|
||||
for i, point := range points {
|
||||
isFlyable := s.isPointFlyable(point, thresholds)
|
||||
|
||||
if isFlyable {
|
||||
// Start a new window if not already in one
|
||||
if windowStart == nil {
|
||||
windowStart = &point.Time
|
||||
}
|
||||
lastFlyableTime = &point.Time
|
||||
} else {
|
||||
// End current window if we were in one
|
||||
if windowStart != nil && lastFlyableTime != nil {
|
||||
duration := lastFlyableTime.Sub(*windowStart)
|
||||
// Only include windows of at least 1 hour
|
||||
if duration >= time.Hour {
|
||||
windows = append(windows, model.FlyableWindow{
|
||||
Start: *windowStart,
|
||||
End: *lastFlyableTime,
|
||||
Duration: duration,
|
||||
})
|
||||
}
|
||||
windowStart = nil
|
||||
lastFlyableTime = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last point
|
||||
if i == len(points)-1 && windowStart != nil && lastFlyableTime != nil {
|
||||
duration := lastFlyableTime.Sub(*windowStart)
|
||||
if duration >= time.Hour {
|
||||
windows = append(windows, model.FlyableWindow{
|
||||
Start: *windowStart,
|
||||
End: *lastFlyableTime,
|
||||
Duration: duration,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return windows
|
||||
}
|
||||
|
||||
// isPointFlyable checks if a single weather point meets flyable conditions
|
||||
func (s *AssessmentService) isPointFlyable(point model.WeatherPoint, thresholds model.Thresholds) bool {
|
||||
// Check wind speed
|
||||
if point.WindSpeedMPH < thresholds.SpeedMin || point.WindSpeedMPH > thresholds.SpeedMax {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check wind direction with wraparound handling
|
||||
if !s.isDirectionInRange(point.WindDirection, thresholds.DirCenter, thresholds.DirRange) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isDirectionInRange checks if a direction is within range of center, handling 0/360 wraparound
|
||||
func (s *AssessmentService) isDirectionInRange(direction, center, rangeVal int) bool {
|
||||
// Normalize all values to 0-359
|
||||
direction = s.normalizeDegrees(direction)
|
||||
center = s.normalizeDegrees(center)
|
||||
|
||||
// Calculate the absolute difference
|
||||
diff := s.angleDifference(direction, center)
|
||||
|
||||
return diff <= float64(rangeVal)
|
||||
}
|
||||
|
||||
// normalizeDegrees ensures degrees are in 0-359 range
|
||||
func (s *AssessmentService) normalizeDegrees(degrees int) int {
|
||||
normalized := degrees % 360
|
||||
if normalized < 0 {
|
||||
normalized += 360
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// angleDifference calculates the minimum difference between two angles
|
||||
func (s *AssessmentService) angleDifference(angle1, angle2 int) float64 {
|
||||
diff := math.Abs(float64(angle1 - angle2))
|
||||
if diff > 180 {
|
||||
diff = 360 - diff
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
// filterDaylightHours returns only points between 8am and 10pm local time
|
||||
func (s *AssessmentService) filterDaylightHours(points []model.WeatherPoint) []model.WeatherPoint {
|
||||
filtered := make([]model.WeatherPoint, 0, len(points))
|
||||
for _, point := range points {
|
||||
hour := point.Time.Hour()
|
||||
if hour >= 8 && hour < 22 {
|
||||
filtered = append(filtered, point)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// determineWhyNotFlyable analyzes points to explain why conditions aren't flyable
|
||||
func (s *AssessmentService) determineWhyNotFlyable(points []model.WeatherPoint, thresholds model.Thresholds) string {
|
||||
if len(points) == 0 {
|
||||
return "No data available during flying hours"
|
||||
}
|
||||
|
||||
tooSlow := 0
|
||||
tooFast := 0
|
||||
wrongDirection := 0
|
||||
|
||||
for _, point := range points {
|
||||
if point.WindSpeedMPH < thresholds.SpeedMin {
|
||||
tooSlow++
|
||||
} else if point.WindSpeedMPH > thresholds.SpeedMax {
|
||||
tooFast++
|
||||
}
|
||||
if !s.isDirectionInRange(point.WindDirection, thresholds.DirCenter, thresholds.DirRange) {
|
||||
wrongDirection++
|
||||
}
|
||||
}
|
||||
|
||||
// Determine primary reason
|
||||
if tooSlow > len(points)/2 {
|
||||
return "Wind speeds too low for safe flying"
|
||||
}
|
||||
if tooFast > len(points)/2 {
|
||||
return "Wind speeds too high for safe flying"
|
||||
}
|
||||
if wrongDirection > len(points)/2 {
|
||||
return "Wind direction not favorable"
|
||||
}
|
||||
|
||||
return "No continuous flyable windows of at least 1 hour found"
|
||||
}
|
||||
|
||||
// formatBestWindowReason creates a human-readable message about the best window
|
||||
func formatBestWindowReason(window model.FlyableWindow) string {
|
||||
hours := int(window.Duration.Hours())
|
||||
minutes := int(window.Duration.Minutes()) % 60
|
||||
|
||||
timeStr := ""
|
||||
if hours > 0 {
|
||||
timeStr = formatHours(hours)
|
||||
if minutes > 0 {
|
||||
timeStr += " " + formatMinutes(minutes)
|
||||
}
|
||||
} else {
|
||||
timeStr = formatMinutes(minutes)
|
||||
}
|
||||
|
||||
return "Best flyable window: " + timeStr + " starting at " + window.Start.Format("3:04 PM")
|
||||
}
|
||||
|
||||
func formatHours(hours int) string {
|
||||
if hours == 1 {
|
||||
return "1 hour"
|
||||
}
|
||||
return formatInt(hours) + " hours"
|
||||
}
|
||||
|
||||
func formatMinutes(minutes int) string {
|
||||
if minutes == 1 {
|
||||
return "1 minute"
|
||||
}
|
||||
return formatInt(minutes) + " minutes"
|
||||
}
|
||||
|
||||
func formatInt(n int) string {
|
||||
// Simple int to string conversion
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
|
||||
digits := []byte{}
|
||||
for n > 0 {
|
||||
digits = append([]byte{byte('0' + n%10)}, digits...)
|
||||
n /= 10
|
||||
}
|
||||
|
||||
if negative {
|
||||
digits = append([]byte{'-'}, digits...)
|
||||
}
|
||||
|
||||
return string(digits)
|
||||
}
|
||||
554
backend/internal/service/assessment_test.go
Normal file
554
backend/internal/service/assessment_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
)
|
||||
|
||||
func TestAssessmentService_Evaluate(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
t.Run("empty points", func(t *testing.T) {
|
||||
result := service.Evaluate([]model.WeatherPoint{}, thresholds)
|
||||
|
||||
if result.Status != "BAD" {
|
||||
t.Errorf("expected status BAD, got %s", result.Status)
|
||||
}
|
||||
if result.FlyableNow {
|
||||
t.Error("expected FlyableNow to be false")
|
||||
}
|
||||
if result.BestWindow != nil {
|
||||
t.Error("expected no best window")
|
||||
}
|
||||
if len(result.AllWindows) != 0 {
|
||||
t.Errorf("expected 0 windows, got %d", len(result.AllWindows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("good conditions", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(now, 10, 270), // Good
|
||||
createPoint(now.Add(1*time.Hour), 10, 270),
|
||||
createPoint(now.Add(2*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
if result.Status != "GOOD" {
|
||||
t.Errorf("expected status GOOD, got %s", result.Status)
|
||||
}
|
||||
if result.BestWindow == nil {
|
||||
t.Fatal("expected a best window")
|
||||
}
|
||||
if result.BestWindow.Duration < 2*time.Hour {
|
||||
t.Errorf("expected window duration >= 2h, got %v", result.BestWindow.Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no daylight hours", func(t *testing.T) {
|
||||
// Create points at 2am
|
||||
midnight := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(midnight.Add(2*time.Hour), 10, 270),
|
||||
createPoint(midnight.Add(3*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
if result.Status != "BAD" {
|
||||
t.Errorf("expected status BAD, got %s", result.Status)
|
||||
}
|
||||
if !contains(result.Reason, "daylight") {
|
||||
t.Errorf("expected reason to mention daylight, got: %s", result.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssessmentService_FindFlyableWindows(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
t.Run("single continuous window", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window, got %d", len(windows))
|
||||
}
|
||||
|
||||
if windows[0].Duration < 2*time.Hour {
|
||||
t.Errorf("expected duration >= 2h, got %v", windows[0].Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple windows", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
// First window (2 hours)
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 10, 270),
|
||||
// Break (bad wind)
|
||||
createPoint(start.Add(3*time.Hour), 20, 270), // Too fast
|
||||
createPoint(start.Add(4*time.Hour), 20, 270),
|
||||
// Second window (3 hours)
|
||||
createPoint(start.Add(5*time.Hour), 10, 270),
|
||||
createPoint(start.Add(6*time.Hour), 10, 270),
|
||||
createPoint(start.Add(7*time.Hour), 10, 270),
|
||||
createPoint(start.Add(8*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 2 {
|
||||
t.Fatalf("expected 2 windows, got %d", len(windows))
|
||||
}
|
||||
|
||||
// First window should be ~2 hours
|
||||
if windows[0].Duration < 2*time.Hour {
|
||||
t.Errorf("first window duration should be >= 2h, got %v", windows[0].Duration)
|
||||
}
|
||||
|
||||
// Second window should be ~3 hours
|
||||
if windows[1].Duration < 3*time.Hour {
|
||||
t.Errorf("second window duration should be >= 3h, got %v", windows[1].Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("window less than 1 hour excluded", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(30*time.Minute), 10, 270), // Only 30 minutes
|
||||
createPoint(start.Add(1*time.Hour), 20, 270), // Bad wind
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 0 {
|
||||
t.Errorf("expected 0 windows (< 1h), got %d", len(windows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly 1 hour window", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 20, 270), // Bad wind
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window, got %d", len(windows))
|
||||
}
|
||||
|
||||
if windows[0].Duration < time.Hour {
|
||||
t.Errorf("window should be at least 1 hour, got %v", windows[0].Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no flyable conditions", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 20, 270), // Too fast
|
||||
createPoint(start.Add(1*time.Hour), 5, 270), // Too slow
|
||||
createPoint(start.Add(2*time.Hour), 10, 180), // Wrong direction
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 0 {
|
||||
t.Errorf("expected 0 windows, got %d", len(windows))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssessmentService_isPointFlyable(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
point model.WeatherPoint
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "perfect conditions",
|
||||
point: createPoint(time.Now(), 10, 270),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wind too slow",
|
||||
point: createPoint(time.Now(), 5, 270),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wind too fast",
|
||||
point: createPoint(time.Now(), 20, 270),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wrong direction",
|
||||
point: createPoint(time.Now(), 10, 180), // South, not west
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "edge of speed range - min",
|
||||
point: createPoint(time.Now(), 7, 270),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "edge of speed range - max",
|
||||
point: createPoint(time.Now(), 14, 270),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "edge of direction range - low",
|
||||
point: createPoint(time.Now(), 10, 255), // 270 - 15
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "edge of direction range - high",
|
||||
point: createPoint(time.Now(), 10, 285), // 270 + 15
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := service.isPointFlyable(tt.point, thresholds)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_DirectionWraparound(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
direction int
|
||||
center int
|
||||
rangeVal int
|
||||
shouldMatch bool
|
||||
}{
|
||||
{
|
||||
name: "wraparound - north center, matches 350",
|
||||
direction: 350,
|
||||
center: 0,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - north center, matches 10",
|
||||
direction: 10,
|
||||
center: 0,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, matches 0",
|
||||
direction: 0,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, matches 5",
|
||||
direction: 5,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, matches 340",
|
||||
direction: 340,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, rejects 30",
|
||||
direction: 30,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "no wraparound - normal case",
|
||||
direction: 180,
|
||||
center: 180,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "no wraparound - within range",
|
||||
direction: 190,
|
||||
center: 180,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "no wraparound - out of range",
|
||||
direction: 200,
|
||||
center: 180,
|
||||
rangeVal: 15,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negative direction normalized",
|
||||
direction: -10,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "direction over 360 normalized",
|
||||
direction: 370,
|
||||
center: 10,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := service.isDirectionInRange(tt.direction, tt.center, tt.rangeVal)
|
||||
if result != tt.shouldMatch {
|
||||
t.Errorf("expected %v, got %v for direction=%d, center=%d, range=%d",
|
||||
tt.shouldMatch, result, tt.direction, tt.center, tt.rangeVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_DaylightFiltering(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
baseDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
included bool
|
||||
}{
|
||||
{"7am - before daylight", 7, false},
|
||||
{"8am - start of daylight", 8, true},
|
||||
{"12pm - middle of day", 12, true},
|
||||
{"6pm - evening", 18, true},
|
||||
{"9pm - late evening", 21, true},
|
||||
{"10pm - end of daylight", 22, false},
|
||||
{"11pm - night", 23, false},
|
||||
{"2am - night", 2, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(baseDate.Add(time.Duration(tt.hour)*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
filtered := service.filterDaylightHours(points)
|
||||
|
||||
if tt.included && len(filtered) != 1 {
|
||||
t.Errorf("expected point at %d:00 to be included", tt.hour)
|
||||
}
|
||||
if !tt.included && len(filtered) != 0 {
|
||||
t.Errorf("expected point at %d:00 to be excluded", tt.hour)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_BestWindowSelection(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
// First window (2 hours)
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 10, 270),
|
||||
// Break
|
||||
createPoint(start.Add(3*time.Hour), 20, 270),
|
||||
// Second window (4 hours) - this should be the best
|
||||
createPoint(start.Add(4*time.Hour), 10, 270),
|
||||
createPoint(start.Add(5*time.Hour), 10, 270),
|
||||
createPoint(start.Add(6*time.Hour), 10, 270),
|
||||
createPoint(start.Add(7*time.Hour), 10, 270),
|
||||
createPoint(start.Add(8*time.Hour), 10, 270),
|
||||
// Break
|
||||
createPoint(start.Add(9*time.Hour), 5, 270),
|
||||
// Third window (1 hour)
|
||||
createPoint(start.Add(10*time.Hour), 10, 270),
|
||||
createPoint(start.Add(11*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
if result.BestWindow == nil {
|
||||
t.Fatal("expected a best window")
|
||||
}
|
||||
|
||||
// Best window should be the 4-hour window
|
||||
if result.BestWindow.Duration < 4*time.Hour {
|
||||
t.Errorf("expected best window to be >= 4 hours, got %v", result.BestWindow.Duration)
|
||||
}
|
||||
|
||||
// Should have found 3 windows total
|
||||
if len(result.AllWindows) != 3 {
|
||||
t.Errorf("expected 3 windows, got %d", len(result.AllWindows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_EdgeCases(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
t.Run("single point - flyable", func(t *testing.T) {
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
// Single point can't form a 1-hour window
|
||||
if result.BestWindow != nil {
|
||||
t.Error("single point should not create a window")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all points same time", func(t *testing.T) {
|
||||
sameTime := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(sameTime, 10, 270),
|
||||
createPoint(sameTime, 10, 270),
|
||||
createPoint(sameTime, 10, 270),
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
// Should handle gracefully
|
||||
if len(windows) > 1 {
|
||||
t.Errorf("expected at most 1 window for same-time points, got %d", len(windows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom thresholds", func(t *testing.T) {
|
||||
customThresholds := model.Thresholds{
|
||||
SpeedMin: 5,
|
||||
SpeedMax: 10,
|
||||
DirCenter: 180,
|
||||
DirRange: 30,
|
||||
}
|
||||
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 7, 170), // Within custom range
|
||||
createPoint(start.Add(1*time.Hour), 7, 170),
|
||||
createPoint(start.Add(2*time.Hour), 7, 170),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, customThresholds)
|
||||
|
||||
if result.Status != "GOOD" {
|
||||
t.Errorf("expected GOOD status with custom thresholds, got %s", result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssessmentService_NormalizeDegrees(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
tests := []struct {
|
||||
input int
|
||||
expected int
|
||||
}{
|
||||
{0, 0},
|
||||
{180, 180},
|
||||
{360, 0},
|
||||
{361, 1},
|
||||
{720, 0},
|
||||
{-10, 350},
|
||||
{-90, 270},
|
||||
{-360, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := service.normalizeDegrees(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("normalizeDegrees(%d) = %d, expected %d", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_AngleDifference(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
tests := []struct {
|
||||
angle1 int
|
||||
angle2 int
|
||||
expected float64
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{0, 180, 180},
|
||||
{0, 10, 10},
|
||||
{10, 0, 10},
|
||||
{350, 10, 20}, // Wraparound
|
||||
{10, 350, 20}, // Wraparound
|
||||
{270, 90, 180}, // Opposite
|
||||
{359, 1, 2}, // Close wraparound
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := service.angleDifference(tt.angle1, tt.angle2)
|
||||
if result != tt.expected {
|
||||
t.Errorf("angleDifference(%d, %d) = %.1f, expected %.1f",
|
||||
tt.angle1, tt.angle2, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getDefaultThresholds() model.Thresholds {
|
||||
return model.Thresholds{
|
||||
SpeedMin: 7,
|
||||
SpeedMax: 14,
|
||||
DirCenter: 270,
|
||||
DirRange: 15,
|
||||
}
|
||||
}
|
||||
|
||||
func createPoint(t time.Time, speed float64, direction int) model.WeatherPoint {
|
||||
return model.WeatherPoint{
|
||||
Time: t,
|
||||
WindSpeedMPH: speed,
|
||||
WindDirection: direction,
|
||||
WindGustMPH: speed + 2, // Arbitrary gust value
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
415
backend/internal/service/weather.go
Normal file
415
backend/internal/service/weather.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/paragliding/internal/client"
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
"github.com/scottyah/paragliding/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// Cache keys
|
||||
cacheKeyCurrentWeather = "weather:current"
|
||||
cacheKeyForecast = "weather:forecast"
|
||||
cacheKeyHistorical = "weather:historical:%s"
|
||||
cacheKeyLastAPIFetch = "weather:last_api_fetch"
|
||||
|
||||
// Cache TTLs
|
||||
cacheTTLCurrent = 5 * time.Minute
|
||||
cacheTTLForecast = 10 * time.Minute
|
||||
cacheTTLHistorical = 24 * time.Hour
|
||||
|
||||
// API rate limiting - minimum time between API calls
|
||||
minAPIFetchInterval = 15 * time.Minute
|
||||
|
||||
// Data staleness threshold - if DB data is older than this, consider fetching fresh
|
||||
dataStaleThreshold = 30 * time.Minute
|
||||
)
|
||||
|
||||
// WeatherData holds weather data with metadata
|
||||
type WeatherData struct {
|
||||
Points []model.WeatherPoint
|
||||
FetchedAt time.Time
|
||||
Source string // "cache", "database", "api"
|
||||
}
|
||||
|
||||
// WeatherService provides weather data with DB-first access and rate-limited API fallback
|
||||
type WeatherService struct {
|
||||
client *client.OpenMeteoClient
|
||||
repo *repository.WeatherRepository
|
||||
logger *slog.Logger
|
||||
|
||||
// In-memory cache
|
||||
cache map[string]cacheEntry
|
||||
cacheMu sync.RWMutex
|
||||
|
||||
// API rate limiting
|
||||
lastAPIFetch time.Time
|
||||
lastAPIFetchMu sync.RWMutex
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// WeatherServiceConfig contains configuration for the weather service
|
||||
type WeatherServiceConfig struct {
|
||||
Client *client.OpenMeteoClient
|
||||
Repo *repository.WeatherRepository
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWeatherService creates a new weather service
|
||||
func NewWeatherService(config WeatherServiceConfig) *WeatherService {
|
||||
return &WeatherService{
|
||||
client: config.Client,
|
||||
repo: config.Repo,
|
||||
logger: config.Logger,
|
||||
cache: make(map[string]cacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentWeather returns current weather conditions
|
||||
// Priority: Cache → DB → API (rate-limited)
|
||||
func (s *WeatherService) GetCurrentWeather(ctx context.Context) (*WeatherData, error) {
|
||||
// 1. Try cache first
|
||||
if cached := s.getFromCache(cacheKeyCurrentWeather); cached != nil {
|
||||
if data, ok := cached.(*WeatherData); ok {
|
||||
s.logger.Debug("current weather cache hit")
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try database
|
||||
now := time.Now()
|
||||
start := now.Add(-1 * time.Hour)
|
||||
end := now.Add(1 * time.Hour)
|
||||
|
||||
points, err := s.repo.GetForecast(ctx, start, end)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get current weather from DB", "error", err)
|
||||
}
|
||||
|
||||
// Find closest point to now
|
||||
if len(points) > 0 {
|
||||
current := s.findClosestPoint(points, now)
|
||||
|
||||
// Check if data is fresh enough
|
||||
if now.Sub(current.Time) < dataStaleThreshold {
|
||||
data := &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "database",
|
||||
}
|
||||
s.setCache(cacheKeyCurrentWeather, data, cacheTTLCurrent)
|
||||
s.logger.Debug("current weather from DB", "time", current.Time)
|
||||
return data, nil
|
||||
}
|
||||
s.logger.Debug("DB data is stale", "data_time", current.Time, "threshold", dataStaleThreshold)
|
||||
}
|
||||
|
||||
// 3. Try API (rate-limited)
|
||||
if s.canFetchFromAPI() {
|
||||
s.logger.Info("fetching current weather from API (DB data stale or missing)")
|
||||
apiPoints, err := s.fetchAndStoreFromAPI(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch from API", "error", err)
|
||||
// If we have stale DB data, return it
|
||||
if len(points) > 0 {
|
||||
current := s.findClosestPoint(points, now)
|
||||
return &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "database (stale)",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no weather data available: %w", err)
|
||||
}
|
||||
|
||||
current := s.findClosestPoint(apiPoints, now)
|
||||
data := &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "api",
|
||||
}
|
||||
s.setCache(cacheKeyCurrentWeather, data, cacheTTLCurrent)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// 4. Return stale data if available, or error
|
||||
if len(points) > 0 {
|
||||
current := s.findClosestPoint(points, now)
|
||||
s.logger.Warn("returning stale data (API rate limited)", "data_time", current.Time)
|
||||
return &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "database (stale, API rate limited)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no weather data available and API rate limited")
|
||||
}
|
||||
|
||||
// GetForecast returns weather forecast data
|
||||
// Priority: Cache → DB → API (rate-limited)
|
||||
func (s *WeatherService) GetForecast(ctx context.Context) (*WeatherData, error) {
|
||||
// 1. Try cache first
|
||||
if cached := s.getFromCache(cacheKeyForecast); cached != nil {
|
||||
if data, ok := cached.(*WeatherData); ok {
|
||||
s.logger.Debug("forecast cache hit")
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try database
|
||||
now := time.Now()
|
||||
end := now.Add(7 * 24 * time.Hour) // 7 days ahead
|
||||
|
||||
points, err := s.repo.GetForecast(ctx, now, end)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get forecast from DB", "error", err)
|
||||
}
|
||||
|
||||
if len(points) > 0 {
|
||||
// Check if we have recent enough data (at least some points in the next few hours)
|
||||
hasRecentData := false
|
||||
for _, p := range points {
|
||||
if p.Time.After(now) && p.Time.Before(now.Add(6*time.Hour)) {
|
||||
hasRecentData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasRecentData {
|
||||
data := &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: now,
|
||||
Source: "database",
|
||||
}
|
||||
s.setCache(cacheKeyForecast, data, cacheTTLForecast)
|
||||
s.logger.Debug("forecast from DB", "points", len(points))
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try API (rate-limited)
|
||||
if s.canFetchFromAPI() {
|
||||
s.logger.Info("fetching forecast from API (DB data stale or missing)")
|
||||
apiPoints, err := s.fetchAndStoreFromAPI(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch from API", "error", err)
|
||||
if len(points) > 0 {
|
||||
return &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: now,
|
||||
Source: "database (stale)",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no forecast data available: %w", err)
|
||||
}
|
||||
|
||||
// Filter for future points
|
||||
forecast := make([]model.WeatherPoint, 0, len(apiPoints))
|
||||
for _, p := range apiPoints {
|
||||
if p.Time.After(now) {
|
||||
forecast = append(forecast, p)
|
||||
}
|
||||
}
|
||||
|
||||
data := &WeatherData{
|
||||
Points: forecast,
|
||||
FetchedAt: now,
|
||||
Source: "api",
|
||||
}
|
||||
s.setCache(cacheKeyForecast, data, cacheTTLForecast)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// 4. Return stale data if available
|
||||
if len(points) > 0 {
|
||||
s.logger.Warn("returning stale forecast (API rate limited)", "points", len(points))
|
||||
return &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: now,
|
||||
Source: "database (stale, API rate limited)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no forecast data available and API rate limited")
|
||||
}
|
||||
|
||||
// GetHistorical returns historical weather data for a specific date
|
||||
// Historical data is primarily from DB (populated by background fetcher)
|
||||
func (s *WeatherService) GetHistorical(ctx context.Context, date time.Time) (*WeatherData, error) {
|
||||
cacheKey := fmt.Sprintf(cacheKeyHistorical, date.Format("2006-01-02"))
|
||||
|
||||
// 1. Try cache first
|
||||
if cached := s.getFromCache(cacheKey); cached != nil {
|
||||
if data, ok := cached.(*WeatherData); ok {
|
||||
s.logger.Debug("historical cache hit", "date", date.Format("2006-01-02"))
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get from database (historical data should always be from DB)
|
||||
points, err := s.repo.GetHistorical(ctx, date)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get historical data from DB", "error", err, "date", date.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
if len(points) > 0 {
|
||||
data := &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: time.Now(),
|
||||
Source: "database",
|
||||
}
|
||||
s.setCache(cacheKey, data, cacheTTLHistorical)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Historical data not available - don't try API for past dates
|
||||
// The background fetcher should have this data
|
||||
return &WeatherData{
|
||||
Points: []model.WeatherPoint{},
|
||||
FetchedAt: time.Now(),
|
||||
Source: "none",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllPoints returns all available weather points (for assessment)
|
||||
// This reads from DB/cache only, never triggers API calls
|
||||
func (s *WeatherService) GetAllPoints(ctx context.Context) ([]model.WeatherPoint, error) {
|
||||
// Try to get forecast data which includes recent + future points
|
||||
data, err := s.GetForecast(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Points, nil
|
||||
}
|
||||
|
||||
// FetchFromAPI forces an API fetch (used by background fetcher)
|
||||
// This bypasses rate limiting as it's called on a schedule
|
||||
func (s *WeatherService) FetchFromAPI(ctx context.Context) ([]model.WeatherPoint, error) {
|
||||
points, err := s.client.GetWeatherForecast(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from API: %w", err)
|
||||
}
|
||||
|
||||
// Store in database
|
||||
if err := s.repo.SaveObservations(ctx, points); err != nil {
|
||||
s.logger.Error("failed to save observations to DB", "error", err)
|
||||
// Continue anyway - return the data
|
||||
}
|
||||
|
||||
// Update last fetch time
|
||||
s.lastAPIFetchMu.Lock()
|
||||
s.lastAPIFetch = time.Now()
|
||||
s.lastAPIFetchMu.Unlock()
|
||||
|
||||
// Clear caches so next request gets fresh data
|
||||
s.clearCache()
|
||||
|
||||
s.logger.Info("API fetch complete", "points", len(points))
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// canFetchFromAPI checks if enough time has passed since last API fetch
|
||||
func (s *WeatherService) canFetchFromAPI() bool {
|
||||
s.lastAPIFetchMu.RLock()
|
||||
defer s.lastAPIFetchMu.RUnlock()
|
||||
|
||||
if s.lastAPIFetch.IsZero() {
|
||||
return true
|
||||
}
|
||||
return time.Since(s.lastAPIFetch) >= minAPIFetchInterval
|
||||
}
|
||||
|
||||
// fetchAndStoreFromAPI fetches from API and stores in DB
|
||||
func (s *WeatherService) fetchAndStoreFromAPI(ctx context.Context) ([]model.WeatherPoint, error) {
|
||||
points, err := s.client.GetWeatherForecast(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from API: %w", err)
|
||||
}
|
||||
|
||||
// Store in database
|
||||
if err := s.repo.SaveObservations(ctx, points); err != nil {
|
||||
s.logger.Error("failed to save observations to DB", "error", err)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Update last fetch time
|
||||
s.lastAPIFetchMu.Lock()
|
||||
s.lastAPIFetch = time.Now()
|
||||
s.lastAPIFetchMu.Unlock()
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// findClosestPoint finds the weather point closest to the target time
|
||||
func (s *WeatherService) findClosestPoint(points []model.WeatherPoint, target time.Time) model.WeatherPoint {
|
||||
if len(points) == 0 {
|
||||
return model.WeatherPoint{}
|
||||
}
|
||||
|
||||
closest := points[0]
|
||||
minDiff := absDuration(points[0].Time.Sub(target))
|
||||
|
||||
for _, point := range points[1:] {
|
||||
diff := absDuration(point.Time.Sub(target))
|
||||
if diff < minDiff {
|
||||
minDiff = diff
|
||||
closest = point
|
||||
}
|
||||
}
|
||||
|
||||
return closest
|
||||
}
|
||||
|
||||
// Cache helpers
|
||||
|
||||
func (s *WeatherService) getFromCache(key string) interface{} {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
|
||||
entry, exists := s.cache[key]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return entry.data
|
||||
}
|
||||
|
||||
func (s *WeatherService) setCache(key string, data interface{}, ttl time.Duration) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache[key] = cacheEntry{
|
||||
data: data,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WeatherService) clearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
|
||||
// absDuration returns the absolute value of a duration
|
||||
func absDuration(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
Reference in New Issue
Block a user