This commit is contained in:
2026-01-03 14:16:16 -08:00
commit 1f0e678d47
71 changed files with 16127 additions and 0 deletions

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

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

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

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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS weather_observations;

View File

@@ -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);

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

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

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

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

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

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

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

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

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