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

30
backend/.air.toml Normal file
View File

@@ -0,0 +1,30 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/api"
bin = "tmp/main"
full_bin = "./tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "migrations"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = true
follow_symlink = true
log = "air.log"
delay = 1000 # ms
stop_on_error = true
send_interrupt = false
kill_delay = 500 # ms
[log]
time = true
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

19
backend/.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Database Configuration
DATABASE_URL=postgres://dev:devpass@localhost:5432/paragliding?sslmode=disable
# Server Configuration
PORT=8080
# Location Configuration (Torrey Pines Gliderport by default)
LOCATION_LAT=32.8893
LOCATION_LON=-117.2519
LOCATION_NAME="Torrey Pines Gliderport"
# Timezone
TIMEZONE=America/Los_Angeles
# Weather Fetcher Configuration
FETCH_INTERVAL=15m
# Cache Configuration
CACHE_TTL=10m

37
backend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Install git for fetching dependencies
RUN apk add --no-cache git ca-certificates
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /api ./cmd/api
# Final stage
FROM alpine:3.19
WORKDIR /app
# Install ca-certificates for HTTPS requests
RUN apk --no-cache add ca-certificates tzdata
# Copy binary from builder (migrations are embedded in the binary)
COPY --from=builder /api /app/api
# Non-root user
RUN adduser -D -g '' appuser
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
CMD ["/app/api"]

27
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,27 @@
# Development Dockerfile with hot reload
FROM golang:1.24-alpine
WORKDIR /app
# Install air for hot reload
# Use GOTOOLCHAIN=auto to allow Go to download a newer toolchain if needed
ENV GOTOOLCHAIN=auto
RUN go install github.com/air-verse/air@latest
# Install migrate for database migrations
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# Copy go mod files first for caching
COPY go.mod ./
RUN go mod download
# Copy source code (will be overridden by volume mount)
COPY . .
# Create data directory
RUN mkdir -p /app/data
EXPOSE 8080
# Use air for hot reload
CMD ["air", "-c", ".air.toml"]

421
backend/cmd/api/main.go Normal file
View File

@@ -0,0 +1,421 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/scottyah/paragliding/internal/client"
"github.com/scottyah/paragliding/internal/config"
"github.com/scottyah/paragliding/internal/database"
"github.com/scottyah/paragliding/internal/model"
"github.com/scottyah/paragliding/internal/repository"
"github.com/scottyah/paragliding/internal/server"
"github.com/scottyah/paragliding/internal/service"
)
func main() {
// Setup structured logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Load configuration
cfg, err := config.Load()
if err != nil {
logger.Error("failed to load configuration", "error", err)
os.Exit(1)
}
logger.Info("configuration loaded",
"port", cfg.Port,
"location", cfg.LocationName,
"fetch_interval", cfg.FetchInterval,
)
// Create context for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Connect to PostgreSQL with retry
dbpool, err := connectDatabaseWithRetry(ctx, cfg.DatabaseURL, logger, 30*time.Second)
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer dbpool.Close()
logger.Info("connected to database")
// Run database migrations
if err := database.RunMigrations(cfg.DatabaseURL, logger); err != nil {
logger.Error("failed to run migrations", "error", err)
os.Exit(1)
}
// Initialize services
weatherClient := client.NewOpenMeteoClient(client.OpenMeteoConfig{
Latitude: cfg.LocationLat,
Longitude: cfg.LocationLon,
Timezone: cfg.Timezone,
})
weatherRepo := repository.NewWeatherRepository(dbpool)
assessmentSvc := service.NewAssessmentService()
// Create weather service (handles caching and API rate limiting)
weatherSvc := service.NewWeatherService(service.WeatherServiceConfig{
Client: weatherClient,
Repo: weatherRepo,
Logger: logger,
})
// Create HTTP server
srv := server.New(cfg.Addr(), logger)
// Setup routes with handler
handler := &Handler{
logger: logger,
db: dbpool,
config: cfg,
weatherSvc: weatherSvc,
assessmentSvc: assessmentSvc,
}
srv.SetupRoutes(handler)
// Start background weather fetcher
fetcher := &WeatherFetcher{
logger: logger,
config: cfg,
weatherSvc: weatherSvc,
stopChan: make(chan struct{}),
}
go fetcher.Start(ctx)
// Start HTTP server in a goroutine
serverErrors := make(chan error, 1)
go func() {
serverErrors <- srv.Start()
}()
// Wait for interrupt signal or server error
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
select {
case err := <-serverErrors:
logger.Error("server error", "error", err)
case sig := <-shutdown:
logger.Info("shutdown signal received", "signal", sig)
// Stop background fetcher
close(fetcher.stopChan)
// Give outstanding requests time to complete
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped gracefully")
}
}
// connectDatabaseWithRetry establishes a connection pool to PostgreSQL with exponential backoff
func connectDatabaseWithRetry(ctx context.Context, databaseURL string, logger *slog.Logger, maxWait time.Duration) (*pgxpool.Pool, error) {
pgConfig, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse database URL: %w", err)
}
// Set connection pool settings
pgConfig.MaxConns = 25
pgConfig.MinConns = 5
pgConfig.MaxConnLifetime = time.Hour
pgConfig.MaxConnIdleTime = 30 * time.Minute
pgConfig.HealthCheckPeriod = time.Minute
// Exponential backoff retry
var pool *pgxpool.Pool
backoff := 1 * time.Second
maxBackoff := 8 * time.Second
deadline := time.Now().Add(maxWait)
for {
pool, err = pgxpool.NewWithConfig(ctx, pgConfig)
if err == nil {
// Verify connection
if pingErr := pool.Ping(ctx); pingErr == nil {
return pool, nil
} else {
pool.Close()
err = pingErr
}
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("failed to connect to database after %v: %w", maxWait, err)
}
logger.Warn("database connection failed, retrying",
"error", err,
"retry_in", backoff,
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
// Exponential backoff with cap
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
// Handler implements the RouteHandler interface
type Handler struct {
logger *slog.Logger
db *pgxpool.Pool
config *config.Config
weatherSvc *service.WeatherService
assessmentSvc *service.AssessmentService
}
// Health handles health check requests
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Check database connectivity
if err := h.db.Ping(ctx); err != nil {
h.logger.Error("health check failed", "error", err)
server.RespondError(w, 503, "database unavailable")
return
}
server.RespondJSON(w, 200, map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().UTC(),
"location": h.config.LocationName,
})
}
// GetCurrentWeather handles current weather requests
func (h *Handler) GetCurrentWeather(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get current weather from service (handles caching, DB, and rate-limited API fallback)
weatherData, err := h.weatherSvc.GetCurrentWeather(ctx)
if err != nil {
h.logger.Error("failed to get current weather", "error", err)
server.RespondError(w, 500, "failed to fetch weather data")
return
}
if len(weatherData.Points) == 0 {
server.RespondError(w, 500, "no current weather data available")
return
}
current := weatherData.Points[0]
// Get all points for assessment
allPoints, err := h.weatherSvc.GetAllPoints(ctx)
if err != nil {
h.logger.Warn("failed to get points for assessment", "error", err)
allPoints = weatherData.Points
}
// Get default thresholds and assess conditions
thresholds := model.DefaultThresholds()
assessment := h.assessmentSvc.Evaluate(allPoints, thresholds)
response := map[string]interface{}{
"timestamp": weatherData.FetchedAt.UTC(),
"location": map[string]interface{}{
"name": h.config.LocationName,
"lat": h.config.LocationLat,
"lon": h.config.LocationLon,
},
"current": map[string]interface{}{
"windSpeed": current.WindSpeedMPH,
"windDirection": current.WindDirection,
"windGust": current.WindGustMPH,
"time": current.Time,
},
"assessment": assessment,
"source": weatherData.Source,
}
server.RespondJSON(w, 200, response)
}
// GetForecast handles weather forecast requests
func (h *Handler) GetForecast(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get forecast from service (handles caching, DB, and rate-limited API fallback)
weatherData, err := h.weatherSvc.GetForecast(ctx)
if err != nil {
h.logger.Error("failed to get forecast", "error", err)
server.RespondError(w, 500, "failed to fetch forecast data")
return
}
// Get default thresholds
thresholds := model.DefaultThresholds()
// Find flyable windows
windows := h.assessmentSvc.FindFlyableWindows(weatherData.Points, thresholds)
response := map[string]interface{}{
"generated": weatherData.FetchedAt.UTC(),
"location": map[string]interface{}{
"name": h.config.LocationName,
"lat": h.config.LocationLat,
"lon": h.config.LocationLon,
},
"forecast": weatherData.Points,
"flyableWindows": windows,
"defaultThresholds": thresholds,
"source": weatherData.Source,
}
server.RespondJSON(w, 200, response)
}
// GetHistorical handles historical weather requests
func (h *Handler) GetHistorical(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse date from query param (default: yesterday)
dateStr := r.URL.Query().Get("date")
var date time.Time
if dateStr == "" {
date = time.Now().AddDate(0, 0, -1)
} else {
var err error
date, err = time.Parse("2006-01-02", dateStr)
if err != nil {
server.RespondError(w, 400, "invalid date format, use YYYY-MM-DD")
return
}
}
// Get historical data from service (handles caching and DB lookup)
weatherData, err := h.weatherSvc.GetHistorical(ctx, date)
if err != nil {
h.logger.Error("failed to get historical data", "error", err)
server.RespondError(w, 500, "failed to fetch historical data")
return
}
response := map[string]interface{}{
"date": date.Format("2006-01-02"),
"data": weatherData.Points,
"source": weatherData.Source,
}
server.RespondJSON(w, 200, response)
}
// AssessConditions handles paragliding condition assessment requests
func (h *Handler) AssessConditions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Limit request body size
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB max
// Parse thresholds from request body
var req struct {
Thresholds model.Thresholds `json:"thresholds"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Use default thresholds if not provided or invalid
req.Thresholds = model.DefaultThresholds()
}
// Get weather data from service (DB-first, no API call triggered here)
points, err := h.weatherSvc.GetAllPoints(ctx)
if err != nil {
h.logger.Error("failed to get weather for assessment", "error", err)
server.RespondError(w, 500, "failed to fetch weather data")
return
}
// Run assessment with provided thresholds
assessment := h.assessmentSvc.Evaluate(points, req.Thresholds)
windows := h.assessmentSvc.FindFlyableWindows(points, req.Thresholds)
response := map[string]interface{}{
"assessment": assessment,
"flyableWindows": windows,
"thresholds": req.Thresholds,
}
server.RespondJSON(w, 200, response)
}
// WeatherFetcher runs background weather fetching
type WeatherFetcher struct {
logger *slog.Logger
config *config.Config
weatherSvc *service.WeatherService
stopChan chan struct{}
}
// Start begins the background weather fetching loop
func (f *WeatherFetcher) Start(ctx context.Context) {
f.logger.Info("starting weather fetcher", "interval", f.config.FetchInterval)
ticker := time.NewTicker(f.config.FetchInterval)
defer ticker.Stop()
// Fetch immediately on startup
f.fetch(ctx)
for {
select {
case <-ticker.C:
f.fetch(ctx)
case <-f.stopChan:
f.logger.Info("stopping weather fetcher")
return
case <-ctx.Done():
f.logger.Info("weather fetcher context cancelled")
return
}
}
}
// fetch performs the actual weather data fetching via the weather service
func (f *WeatherFetcher) fetch(ctx context.Context) {
f.logger.Info("fetching weather data",
"lat", f.config.LocationLat,
"lon", f.config.LocationLon,
)
// Use weather service's FetchFromAPI (bypasses rate limiting for scheduled fetches)
points, err := f.weatherSvc.FetchFromAPI(ctx)
if err != nil {
f.logger.Error("failed to fetch weather data", "error", err)
return
}
f.logger.Info("weather data updated successfully", "points", len(points))
}

23
backend/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module github.com/scottyah/paragliding
go 1.24.0
toolchain go1.24.11
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/jackc/pgx/v5 v5.8.0
github.com/kelseyhightower/envconfig v1.4.0
)
require (
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.14.0 // indirect
)

44
backend/go.sum Normal file
View File

@@ -0,0 +1,44 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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
}

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

106
backend/testdata/openmeteo_response.json vendored Normal file
View File

@@ -0,0 +1,106 @@
{
"latitude": 32.8893,
"longitude": -117.2519,
"generationtime_ms": 0.123,
"utc_offset_seconds": -28800,
"timezone": "America/Los_Angeles",
"timezone_abbreviation": "PST",
"elevation": 122.0,
"hourly_units": {
"time": "iso8601",
"wind_speed_10m": "mph",
"wind_direction_10m": "°",
"wind_gusts_10m": "mph"
},
"hourly": {
"time": [
"2026-01-01T00:00",
"2026-01-01T01:00",
"2026-01-01T02:00",
"2026-01-01T03:00",
"2026-01-01T04:00",
"2026-01-01T05:00",
"2026-01-01T06:00",
"2026-01-01T07:00",
"2026-01-01T08:00",
"2026-01-01T09:00",
"2026-01-01T10:00",
"2026-01-01T11:00",
"2026-01-01T12:00",
"2026-01-01T13:00",
"2026-01-01T14:00",
"2026-01-01T15:00",
"2026-01-01T16:00",
"2026-01-01T17:00",
"2026-01-01T18:00",
"2026-01-01T19:00",
"2026-01-01T20:00",
"2026-01-01T21:00",
"2026-01-01T22:00",
"2026-01-01T23:00",
"2026-01-02T00:00",
"2026-01-02T01:00",
"2026-01-02T02:00",
"2026-01-02T03:00",
"2026-01-02T04:00",
"2026-01-02T05:00",
"2026-01-02T06:00",
"2026-01-02T07:00",
"2026-01-02T08:00",
"2026-01-02T09:00",
"2026-01-02T10:00",
"2026-01-02T11:00",
"2026-01-02T12:00",
"2026-01-02T13:00",
"2026-01-02T14:00",
"2026-01-02T15:00",
"2026-01-02T16:00",
"2026-01-02T17:00",
"2026-01-02T18:00",
"2026-01-02T19:00",
"2026-01-02T20:00",
"2026-01-02T21:00",
"2026-01-02T22:00",
"2026-01-02T23:00",
"2026-01-03T00:00",
"2026-01-03T01:00",
"2026-01-03T02:00",
"2026-01-03T03:00",
"2026-01-03T04:00",
"2026-01-03T05:00",
"2026-01-03T06:00",
"2026-01-03T07:00",
"2026-01-03T08:00",
"2026-01-03T09:00",
"2026-01-03T10:00",
"2026-01-03T11:00",
"2026-01-03T12:00",
"2026-01-03T13:00",
"2026-01-03T14:00",
"2026-01-03T15:00",
"2026-01-03T16:00",
"2026-01-03T17:00",
"2026-01-03T18:00",
"2026-01-03T19:00",
"2026-01-03T20:00",
"2026-01-03T21:00",
"2026-01-03T22:00",
"2026-01-03T23:00"
],
"wind_speed_10m": [
5.2, 4.8, 4.5, 4.2, 4.0, 3.8, 3.5, 3.2, 4.5, 6.8, 8.5, 9.2, 10.5, 11.2, 12.0, 11.8, 10.5, 9.2, 7.5, 6.2, 5.5, 5.0, 4.8, 4.5,
4.2, 4.0, 3.8, 3.5, 3.2, 3.0, 2.8, 3.5, 5.2, 7.8, 9.5, 10.8, 11.5, 12.2, 13.0, 12.8, 11.5, 10.2, 8.5, 7.0, 6.0, 5.5, 5.0, 4.8,
4.5, 4.2, 4.0, 3.8, 3.5, 3.2, 3.0, 2.8, 4.0, 6.5, 8.8, 10.5, 11.8, 12.5, 13.2, 13.8, 12.5, 11.0, 9.0, 7.5, 6.5, 6.0, 5.5, 5.2
],
"wind_direction_10m": [
280, 282, 285, 288, 290, 292, 295, 298, 275, 270, 268, 265, 262, 260, 258, 260, 265, 270, 275, 280, 285, 288, 290, 292,
295, 298, 300, 302, 305, 308, 310, 280, 275, 270, 268, 265, 262, 260, 258, 260, 265, 270, 275, 280, 285, 288, 290, 292,
295, 298, 300, 302, 305, 308, 310, 312, 285, 275, 270, 268, 265, 262, 260, 258, 262, 268, 275, 280, 285, 288, 290, 292
],
"wind_gusts_10m": [
8.5, 8.0, 7.5, 7.0, 6.8, 6.5, 6.0, 5.8, 7.5, 10.2, 12.5, 13.8, 15.2, 16.5, 17.8, 17.5, 15.8, 14.2, 12.0, 10.5, 9.5, 9.0, 8.5, 8.0,
7.5, 7.0, 6.8, 6.5, 6.0, 5.8, 5.5, 6.8, 9.2, 11.8, 14.0, 16.2, 17.5, 18.8, 19.5, 19.2, 17.5, 15.8, 13.5, 11.5, 10.5, 10.0, 9.5, 9.0,
8.5, 8.0, 7.5, 7.0, 6.8, 6.5, 6.0, 5.8, 7.8, 10.8, 13.5, 15.8, 17.5, 18.8, 19.8, 20.5, 18.8, 16.5, 14.0, 12.0, 11.0, 10.5, 10.0, 9.5
]
}
}