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

207
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,207 @@
import {
CurrentWeatherResponse,
ForecastResponse,
HistoricalResponse,
AssessmentResponse,
Thresholds,
APIError,
} from './types'
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1'
class APIClient {
private baseURL: string
constructor(baseURL: string) {
this.baseURL = baseURL
}
private async request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const url = `${this.baseURL}${endpoint}`
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!response.ok) {
const error: APIError = await response.json().catch(() => ({
error: 'An error occurred',
detail: response.statusText,
}))
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`)
}
const json = await response.json()
// Unwrap {success, data} response if present
return (json.data || json) as T
} catch (error) {
if (error instanceof Error) {
throw error
}
throw new Error('An unexpected error occurred')
}
}
/**
* Get current weather conditions and flyability assessment
*/
async getCurrentWeather(
lat?: number,
lon?: number
): Promise<CurrentWeatherResponse> {
const params = new URLSearchParams()
if (lat !== undefined) params.append('lat', lat.toString())
if (lon !== undefined) params.append('lon', lon.toString())
const query = params.toString() ? `?${params.toString()}` : ''
const data = await this.request<any>(`/weather/current${query}`)
// Transform backend response to frontend format
return {
location: data.location || { lat: 0, lon: 0 },
current: {
timestamp: data.current?.time || data.current?.timestamp,
wind_speed: data.current?.windSpeed || data.current?.wind_speed || 0,
wind_direction: data.current?.windDirection || data.current?.wind_direction || 0,
wind_gust: data.current?.windGust || data.current?.wind_gust || 0,
temperature: data.current?.temperature || 0,
cloud_cover: data.current?.cloudCover || data.current?.cloud_cover || 0,
precipitation: data.current?.precipitation || 0,
visibility: data.current?.visibility || 0,
pressure: data.current?.pressure || 0,
humidity: data.current?.humidity || 0,
},
assessment: {
is_flyable: data.assessment?.FlyableNow || data.assessment?.is_flyable || false,
reasons: data.assessment?.Reason ? [data.assessment.Reason] : data.assessment?.reasons || [],
score: data.assessment?.score || 0,
},
last_updated: data.timestamp || data.last_updated || new Date().toISOString(),
}
}
/**
* Get weather forecast for the next 7 days
*/
async getForecast(
lat?: number,
lon?: number
): Promise<ForecastResponse> {
const params = new URLSearchParams()
if (lat !== undefined) params.append('lat', lat.toString())
if (lon !== undefined) params.append('lon', lon.toString())
const query = params.toString() ? `?${params.toString()}` : ''
const data = await this.request<any>(`/weather/forecast${query}`)
// Transform backend response to frontend format
return {
location: data.location || { lat: 0, lon: 0 },
forecast: (data.forecast || []).map((point: any) => ({
timestamp: point.Time || point.timestamp,
wind_speed: point.WindSpeedMPH || point.wind_speed || 0,
wind_direction: point.WindDirection || point.wind_direction || 0,
wind_gust: point.WindGustMPH || point.wind_gust || 0,
temperature: point.temperature || 0,
cloud_cover: point.cloud_cover || 0,
precipitation: point.precipitation || 0,
visibility: point.visibility || 0,
pressure: point.pressure || 0,
humidity: point.humidity || 0,
})),
flyable_windows: (data.flyableWindows || data.flyable_windows || []).map((win: any) => ({
start: win.start,
end: win.end,
duration_hours: win.durationHours || win.duration_hours || 0,
avg_conditions: {
wind_speed: win.avgConditions?.windSpeed || win.avg_conditions?.wind_speed || 0,
wind_gust: win.avgConditions?.windGust || win.avg_conditions?.wind_gust || 0,
temperature: win.avgConditions?.temperature || win.avg_conditions?.temperature || 0,
cloud_cover: win.avgConditions?.cloudCover || win.avg_conditions?.cloud_cover || 0,
},
})),
generated_at: data.generated || data.generated_at || new Date().toISOString(),
}
}
/**
* Get historical weather data for a specific date
* @param date - Date in YYYY-MM-DD format
*/
async getHistorical(
date: string,
lat?: number,
lon?: number
): Promise<HistoricalResponse> {
const params = new URLSearchParams({ date })
if (lat !== undefined) params.append('lat', lat.toString())
if (lon !== undefined) params.append('lon', lon.toString())
const data = await this.request<any>(`/weather/historical?${params.toString()}`)
// Transform backend response to frontend format
return {
location: data.location || { lat: 0, lon: 0 },
date: data.date || date,
data: (data.data || []).map((point: any) => ({
timestamp: point.Time || point.timestamp,
wind_speed: point.WindSpeedMPH || point.wind_speed || 0,
wind_direction: point.WindDirection || point.wind_direction || 0,
wind_gust: point.WindGustMPH || point.wind_gust || 0,
temperature: point.temperature || 0,
cloud_cover: point.cloud_cover || 0,
precipitation: point.precipitation || 0,
visibility: point.visibility || 0,
pressure: point.pressure || 0,
humidity: point.humidity || 0,
})),
}
}
/**
* Assess current conditions with custom thresholds
*/
async assessWithThresholds(
thresholds: Thresholds,
lat?: number,
lon?: number
): Promise<AssessmentResponse> {
const params = new URLSearchParams()
if (lat !== undefined) params.append('lat', lat.toString())
if (lon !== undefined) params.append('lon', lon.toString())
const query = params.toString() ? `?${params.toString()}` : ''
return this.request<AssessmentResponse>(`/weather/assess${query}`, {
method: 'POST',
body: JSON.stringify({ thresholds }),
})
}
}
// Export singleton instance
export const apiClient = new APIClient(API_BASE_URL)
// Export individual functions for convenience
export const getCurrentWeather = (lat?: number, lon?: number) =>
apiClient.getCurrentWeather(lat, lon)
export const getForecast = (lat?: number, lon?: number) =>
apiClient.getForecast(lat, lon)
export const getHistorical = (date: string, lat?: number, lon?: number) =>
apiClient.getHistorical(date, lat, lon)
export const assessWithThresholds = (
thresholds: Thresholds,
lat?: number,
lon?: number
) => apiClient.assessWithThresholds(thresholds, lat, lon)

92
frontend/lib/types.ts Normal file
View File

@@ -0,0 +1,92 @@
// Core weather data types matching backend models
export interface WeatherPoint {
timestamp: string
temperature: number
wind_speed: number
wind_gust: number
wind_direction: number
cloud_cover: number
precipitation: number
visibility: number
pressure: number
humidity: number
}
export interface Thresholds {
max_wind_speed: number
max_wind_gust: number
max_precipitation: number
min_visibility: number
max_cloud_cover: number
min_temperature?: number
max_temperature?: number
}
export interface Assessment {
is_flyable: boolean
reasons: string[]
score: number
}
export interface FlyableWindow {
start: string
end: string
duration_hours: number
avg_conditions: {
wind_speed: number
wind_gust: number
temperature: number
cloud_cover: number
}
}
// API Response types
export interface CurrentWeatherResponse {
location: {
lat: number
lon: number
name?: string
}
current: WeatherPoint
assessment: Assessment
last_updated: string
}
export interface ForecastResponse {
location: {
lat: number
lon: number
name?: string
}
forecast: WeatherPoint[]
flyable_windows: FlyableWindow[]
generated_at: string
}
export interface HistoricalResponse {
location: {
lat: number
lon: number
name?: string
}
date: string
data: WeatherPoint[]
}
export interface AssessmentRequest {
thresholds: Thresholds
}
export interface AssessmentResponse {
current: WeatherPoint
assessment: Assessment
thresholds_used: Thresholds
}
// Error response type
export interface APIError {
error: string
detail?: string
}

6
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}