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

View File

@@ -0,0 +1,2 @@
# Copy this file to .env.local and fill in values
NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1

64
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,64 @@
# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set production environment
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set permissions for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
# Development stage
FROM node:20-alpine AS dev
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
ENV NODE_ENV development
ENV NEXT_TELEMETRY_DISABLED 1
EXPOSE 3000
CMD ["npm", "run", "dev"]

59
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

25
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Paragliding Weather Forecaster',
description: 'Real-time weather analysis for paragliding conditions',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className="dark">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}

161
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,161 @@
'use client'
import { useCurrentWeather, useForecast, useHistorical } from '@/hooks/use-weather'
import { AssessmentBadge } from '@/components/weather/assessment-badge'
import { WindSpeedChart } from '@/components/weather/wind-speed-chart'
import { WindDirectionChart } from '@/components/weather/wind-direction-chart'
import { ThresholdControls } from '@/components/weather/threshold-controls'
import { RefreshCountdown } from '@/components/weather/refresh-countdown'
import { StaleDataBanner } from '@/components/weather/stale-data-banner'
import { Collapsible } from '@/components/ui/collapsible'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, AlertCircle } from 'lucide-react'
import { subDays, format } from 'date-fns'
export default function DashboardPage() {
const queryClient = useQueryClient()
// Fetch current weather and forecast
const {
data: currentWeather,
isLoading: isLoadingCurrent,
error: currentError,
} = useCurrentWeather()
const {
data: forecast,
isLoading: isLoadingForecast,
error: forecastError,
} = useForecast()
// Fetch yesterday's data for comparison
const yesterday = format(subDays(new Date(), 1), 'yyyy-MM-dd')
const {
data: historicalData,
isLoading: isLoadingHistorical,
} = useHistorical(yesterday)
// Handle refresh
const handleRefresh = async () => {
await queryClient.invalidateQueries({ queryKey: ['weather'] })
}
// Loading state
if (isLoadingCurrent || isLoadingForecast) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center space-y-4">
<Loader2 className="h-12 w-12 animate-spin mx-auto text-primary" />
<p className="text-muted-foreground">Loading weather data...</p>
</div>
</div>
)
}
// Error state
if (currentError || forecastError) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<div className="max-w-md w-full">
<div className="flex items-center gap-3 rounded-lg border border-red-500 bg-red-50 dark:bg-red-950 p-6">
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400 flex-shrink-0" />
<div>
<h2 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-1">
Failed to load weather data
</h2>
<p className="text-sm text-red-700 dark:text-red-300">
{(currentError as Error)?.message || (forecastError as Error)?.message || 'An unexpected error occurred'}
</p>
<button
onClick={handleRefresh}
className="mt-3 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
>
Try again
</button>
</div>
</div>
</div>
</div>
)
}
// No data state
if (!currentWeather || !forecast) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center space-y-4">
<AlertCircle className="h-12 w-12 mx-auto text-muted-foreground" />
<p className="text-muted-foreground">No weather data available</p>
</div>
</div>
)
}
// Get best flyable window
const bestWindow = forecast.flyable_windows && forecast.flyable_windows.length > 0
? forecast.flyable_windows[0]
: undefined
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-6 md:py-8 space-y-6">
{/* Header */}
<div className="space-y-2">
<h1 className="text-3xl md:text-4xl font-bold">
#ShouldIFly TPG?
</h1>
{currentWeather.location.name && (
<p className="text-muted-foreground">
{currentWeather.location.name} ({currentWeather.location.lat.toFixed(2)}, {currentWeather.location.lon.toFixed(2)})
</p>
)}
</div>
{/* Stale Data Warning */}
<StaleDataBanner lastUpdated={currentWeather.last_updated} />
{/* Assessment Badge */}
<div className="grid grid-cols-3 gap-6">
<div></div>
<AssessmentBadge
assessment={currentWeather.assessment}
currentWeather={currentWeather.current}
bestWindow={bestWindow}
/>
<div></div>
</div>
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-1">
<WindSpeedChart
data={forecast.forecast}
yesterdayData={historicalData?.data}
/>
<WindDirectionChart
data={forecast.forecast}
yesterdayData={historicalData?.data}
/>
</div>
{/* Threshold Controls - Collapsible */}
<Collapsible title="Threshold Controls" defaultOpen={false}>
<ThresholdControls />
</Collapsible>
{/* Refresh Countdown */}
<RefreshCountdown onRefresh={handleRefresh} />
{/* Footer Info */}
<div className="text-center text-xs text-muted-foreground pt-8 border-t">
<p>
Data updates every 5 minutes. Forecast generated at{' '}
{format(new Date(forecast.generated_at), 'PPpp')}
</p>
{isLoadingHistorical && (
<p className="mt-1">Loading historical comparison data...</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

17
frontend/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -0,0 +1,55 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,41 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from './button'
interface CollapsibleProps {
title: string
children: React.ReactNode
defaultOpen?: boolean
className?: string
}
export function Collapsible({
title,
children,
defaultOpen = false,
className,
}: CollapsibleProps) {
const [isOpen, setIsOpen] = useState(defaultOpen)
return (
<div className={cn('border rounded-lg', className)}>
<Button
onClick={() => setIsOpen(!isOpen)}
variant="ghost"
className="w-full justify-between p-4 h-auto font-semibold hover:bg-accent"
>
<span>{title}</span>
<ChevronDown
className={cn(
'h-5 w-5 transition-transform duration-200',
isOpen && 'transform rotate-180'
)}
/>
</Button>
{isOpen && <div className="p-4 pt-0 border-t">{children}</div>}
</div>
)
}

View File

@@ -0,0 +1,221 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface CompassSelectorProps {
value: number // 0-360 degrees
onChange: (value: number) => void
range?: number // Optional range to display as arc (±degrees)
size?: number
className?: string
}
export function CompassSelector({ value, onChange, range, size = 200, className = '' }: CompassSelectorProps) {
const [isDragging, setIsDragging] = useState(false)
const compassRef = useRef<SVGSVGElement>(null)
const handlePointerMove = (e: PointerEvent) => {
if (!isDragging || !compassRef.current) return
const rect = compassRef.current.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const dx = e.clientX - centerX
const dy = e.clientY - centerY
// Calculate angle in degrees (0° = North/top)
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
if (angle < 0) angle += 360
// Round to nearest 5 degrees for easier selection
angle = Math.round(angle / 5) * 5
onChange(angle % 360)
}
const handlePointerDown = (e: React.PointerEvent) => {
setIsDragging(true)
// Trigger initial update
const rect = compassRef.current!.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const dx = e.clientX - centerX
const dy = e.clientY - centerY
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
if (angle < 0) angle += 360
angle = Math.round(angle / 5) * 5
onChange(angle % 360)
}
const handlePointerUp = () => {
setIsDragging(false)
}
useEffect(() => {
if (isDragging) {
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}
}, [isDragging])
// Convert degrees to radians for calculations
const angleRad = (value - 90) * (Math.PI / 180)
const radius = size / 2 - 10
const needleLength = radius * 0.7
// Calculate needle endpoint
const needleX = size / 2 + Math.cos(angleRad) * needleLength
const needleY = size / 2 + Math.sin(angleRad) * needleLength
// Helper to convert degrees to compass direction
const getCompassDirection = (degrees: number): string => {
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
const index = Math.round(degrees / 22.5) % 16
return directions[index]
}
return (
<div className={`flex flex-col items-center ${className}`}>
<svg
ref={compassRef}
width={size}
height={size}
className="cursor-pointer select-none"
onPointerDown={handlePointerDown}
style={{ touchAction: 'none' }}
>
{/* Outer circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="hsl(var(--secondary))"
stroke="hsl(var(--border))"
strokeWidth="2"
/>
{/* Acceptable range arc (green) */}
{range !== undefined && (() => {
const startAngle = value - range
const endAngle = value + range
// Convert to radians for calculation (adjusting for SVG coordinate system)
const startRad = (startAngle - 90) * (Math.PI / 180)
const endRad = (endAngle - 90) * (Math.PI / 180)
// Calculate start and end points on the circle
const x1 = size / 2 + Math.cos(startRad) * radius
const y1 = size / 2 + Math.sin(startRad) * radius
const x2 = size / 2 + Math.cos(endRad) * radius
const y2 = size / 2 + Math.sin(endRad) * radius
// Determine if the arc should be large (>180°) or small
const largeArcFlag = range * 2 > 180 ? 1 : 0
// Create SVG arc path
const arcPath = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`
return (
<path
d={arcPath}
fill="none"
stroke="#22c55e"
strokeWidth="6"
strokeLinecap="round"
/>
)
})()}
{/* Cardinal direction markers */}
{[
{ angle: 0, label: 'N' },
{ angle: 90, label: 'E' },
{ angle: 180, label: 'S' },
{ angle: 270, label: 'W' },
].map(({ angle, label }) => {
const rad = (angle - 90) * (Math.PI / 180)
const x = size / 2 + Math.cos(rad) * (radius - 20)
const y = size / 2 + Math.sin(rad) * (radius - 20)
return (
<text
key={angle}
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className="text-sm font-bold fill-foreground"
>
{label}
</text>
)
})}
{/* Degree tick marks every 30° */}
{Array.from({ length: 12 }, (_, i) => {
const angle = i * 30
const rad = (angle - 90) * (Math.PI / 180)
const x1 = size / 2 + Math.cos(rad) * (radius - 5)
const y1 = size / 2 + Math.sin(rad) * (radius - 5)
const x2 = size / 2 + Math.cos(rad) * radius
const y2 = size / 2 + Math.sin(rad) * radius
return (
<line
key={angle}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="hsl(var(--border))"
strokeWidth="2"
/>
)
})}
{/* Center dot */}
<circle
cx={size / 2}
cy={size / 2}
r="4"
fill="hsl(var(--primary))"
/>
{/* Direction needle */}
<line
x1={size / 2}
y1={size / 2}
x2={needleX}
y2={needleY}
stroke="hsl(var(--primary))"
strokeWidth="3"
strokeLinecap="round"
/>
{/* Needle tip */}
<circle
cx={needleX}
cy={needleY}
r="6"
fill="hsl(var(--primary))"
stroke="hsl(var(--background))"
strokeWidth="2"
/>
</svg>
{/* Value display */}
<div className="mt-2 text-center">
<div className="text-lg font-bold">{value}° ({getCompassDirection(value)})</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => {
const values = props.value || props.defaultValue || [0]
return (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
{values.map((_, index) => (
<SliderPrimitive.Thumb
key={index}
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
})
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,163 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Check, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Assessment, FlyableWindow, WeatherPoint } from '@/lib/types'
import { format, parseISO } from 'date-fns'
import { useThresholdStore } from '@/store/threshold-store'
interface AssessmentBadgeProps {
assessment: Assessment
currentWeather: WeatherPoint
bestWindow?: FlyableWindow
className?: string
}
// Transform direction to offset from West (270°)
function calculateOffset(direction: number): number {
return ((direction - 270 + 180) % 360) - 180
}
type Rating = 'Great' | 'Good' | 'Okay' | 'Bad'
interface RatingInfo {
text: Rating
color: string
}
export function AssessmentBadge({ assessment, currentWeather, bestWindow, className }: AssessmentBadgeProps) {
const { speedMin, speedMax, dirCenter, dirRange } = useThresholdStore()
// Evaluate wind speed
const evaluateWindSpeed = (speed: number): RatingInfo => {
if (speed < speedMin || speed > speedMax) {
return { text: 'Bad', color: 'text-red-600 dark:text-red-400' }
}
const range = speedMax - speedMin
const distanceFromMin = speed - speedMin
const distanceFromMax = speedMax - speed
const minDistance = Math.min(distanceFromMin, distanceFromMax)
if (minDistance < range * 0.15) {
return { text: 'Okay', color: 'text-yellow-600 dark:text-yellow-400' }
} else if (minDistance < range * 0.35) {
return { text: 'Good', color: 'text-green-600 dark:text-green-400' }
} else {
return { text: 'Great', color: 'text-green-700 dark:text-green-300' }
}
}
// Evaluate wind direction
const evaluateWindDirection = (direction: number): RatingInfo => {
const offset = calculateOffset(direction)
const centerOffset = calculateOffset(dirCenter)
const minOffset = centerOffset - dirRange
const maxOffset = centerOffset + dirRange
if (offset < minOffset || offset > maxOffset) {
return { text: 'Bad', color: 'text-red-600 dark:text-red-400' }
}
const distanceFromCenter = Math.abs(offset - centerOffset)
if (distanceFromCenter > dirRange * 0.7) {
return { text: 'Okay', color: 'text-yellow-600 dark:text-yellow-400' }
} else if (distanceFromCenter > dirRange * 0.4) {
return { text: 'Good', color: 'text-green-600 dark:text-green-400' }
} else {
return { text: 'Great', color: 'text-green-700 dark:text-green-300' }
}
}
const speedRating = evaluateWindSpeed(currentWeather.wind_speed)
const directionRating = evaluateWindDirection(currentWeather.wind_direction)
// Overall assessment is based on the worse of the two metrics
const getOverallAssessment = (): boolean => {
const ratingValues: Record<Rating, number> = {
'Great': 4,
'Good': 3,
'Okay': 2,
'Bad': 1
}
const worstRating = Math.min(
ratingValues[speedRating.text],
ratingValues[directionRating.text]
)
// Only GOOD if both metrics are at least "Good"
return worstRating >= 3
}
const isGood = getOverallAssessment()
return (
<Card
className={cn(
'border-2',
isGood
? 'border-green-500 bg-green-50 dark:bg-green-950'
: 'border-red-500 bg-red-50 dark:bg-red-950',
className
)}
>
<CardContent className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-full',
isGood ? 'bg-green-500' : 'bg-red-500'
)}
aria-hidden="true"
>
{isGood ? (
<Check className="h-8 w-8 text-white" strokeWidth={3} />
) : (
<X className="h-8 w-8 text-white" strokeWidth={3} />
)}
</div>
<div>
<div
className={cn(
'text-3xl font-bold',
isGood ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'
)}
>
{isGood ? 'GOOD' : 'BAD'}
</div>
<div className="text-sm text-muted-foreground">
Flyability Assessment
</div>
</div>
</div>
{bestWindow && isGood && (
<div className="text-right">
<div className="text-sm font-medium text-muted-foreground">
Best window
</div>
<div className="text-lg font-semibold">
{format(parseISO(bestWindow.start), 'ha')} -{' '}
{format(parseISO(bestWindow.end), 'ha')}
</div>
<div className="text-xs text-muted-foreground">
{bestWindow.duration_hours.toFixed(1)}h duration
</div>
</div>
)}
</div>
{/* Individual metric ratings */}
<div className="mt-4 pt-4 border-t space-y-2">
<div className="text-lg font-semibold">
Wind direction is <span className={directionRating.color}>{directionRating.text}</span>
</div>
<div className="text-lg font-semibold">
Wind speed is <span className={speedRating.color}>{speedRating.text}</span>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,6 @@
export { AssessmentBadge } from './assessment-badge'
export { WindSpeedChart } from './wind-speed-chart'
export { WindDirectionChart } from './wind-direction-chart'
export { ThresholdControls } from './threshold-controls'
export { RefreshCountdown } from './refresh-countdown'
export { StaleDataBanner } from './stale-data-banner'

View File

@@ -0,0 +1,78 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
interface RefreshCountdownProps {
onRefresh: () => void
intervalMs?: number
className?: string
}
export function RefreshCountdown({
onRefresh,
intervalMs = 5 * 60 * 1000, // 5 minutes default
className,
}: RefreshCountdownProps) {
const [secondsRemaining, setSecondsRemaining] = useState(intervalMs / 1000)
const [isRefreshing, setIsRefreshing] = useState(false)
useEffect(() => {
const interval = setInterval(() => {
setSecondsRemaining((prev) => {
if (prev <= 1) {
// Auto refresh
handleRefresh()
return intervalMs / 1000
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [intervalMs])
const handleRefresh = async () => {
setIsRefreshing(true)
await onRefresh()
setSecondsRemaining(intervalMs / 1000)
setIsRefreshing(false)
}
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const progressPercentage = ((intervalMs / 1000 - secondsRemaining) / (intervalMs / 1000)) * 100
return (
<div className={cn('flex items-center gap-4', className)}>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Next refresh</span>
<span className="text-sm text-muted-foreground">{formatTime(secondsRemaining)}</span>
</div>
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-1000 ease-linear"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
<Button
onClick={handleRefresh}
disabled={isRefreshing}
size="icon"
variant="outline"
className="min-h-[44px] min-w-[44px]"
aria-label="Refresh now"
>
<RefreshCw className={cn('h-4 w-4', isRefreshing && 'animate-spin')} />
</Button>
</div>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { differenceInMinutes, parseISO } from 'date-fns'
interface StaleDataBannerProps {
lastUpdated: string
thresholdMinutes?: number
className?: string
}
export function StaleDataBanner({
lastUpdated,
thresholdMinutes = 10,
className,
}: StaleDataBannerProps) {
const minutesOld = differenceInMinutes(new Date(), parseISO(lastUpdated))
const isStale = minutesOld > thresholdMinutes
if (!isStale) return null
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-yellow-500 bg-yellow-50 dark:bg-yellow-950 p-4',
className
)}
role="alert"
>
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Data may be outdated
</p>
<p className="text-xs text-yellow-700 dark:text-yellow-300">
Last updated {minutesOld} minutes ago. Live data may differ.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,122 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Slider } from '@/components/ui/slider'
import { CompassSelector } from '@/components/ui/compass-selector'
import { useThresholdStore } from '@/store/threshold-store'
import { useEffect } from 'react'
interface ThresholdControlsProps {
className?: string
}
export function ThresholdControls({ className }: ThresholdControlsProps) {
const {
speedMin,
speedMax,
dirCenter,
dirRange,
setSpeedRange,
setDirCenter,
setDirRange,
initFromURL,
} = useThresholdStore()
// Initialize from URL on mount
useEffect(() => {
initFromURL()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Card className={className}>
<CardHeader>
<CardTitle>Threshold Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
{/* Wind Speed Range */}
<div className="space-y-4">
<div>
<div className="mb-2">
<label className="text-sm font-medium">Wind Speed Range</label>
</div>
<div className="relative">
<Slider
value={[speedMin, speedMax]}
onValueChange={(values) => setSpeedRange(values[0], values[1])}
min={0}
max={30}
step={0.5}
minStepsBetweenThumbs={1}
className="min-h-[44px] py-4"
aria-label="Wind speed range threshold"
/>
<div className="flex justify-between text-xs font-medium mt-1">
<span style={{ position: 'absolute', left: `${(speedMin / 30) * 100}%`, transform: 'translateX(-50%)' }}>
{speedMin} mph
</span>
<span style={{ position: 'absolute', left: `${(speedMax / 30) * 100}%`, transform: 'translateX(-50%)' }}>
{speedMax} mph
</span>
</div>
</div>
</div>
</div>
{/* Wind Direction Center */}
<div className="space-y-4">
<div>
<div className="mb-4">
<label className="text-sm font-medium">Direction Center</label>
</div>
<div className="flex justify-center">
<CompassSelector
value={dirCenter}
onChange={setDirCenter}
range={dirRange}
size={220}
/>
</div>
</div>
</div>
{/* Wind Direction Range */}
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium">Direction Range</label>
<span className="text-sm text-muted-foreground">
±{dirRange}°
</span>
</div>
<Slider
value={[dirRange]}
onValueChange={(values) => setDirRange(values[0])}
min={5}
max={90}
step={5}
className="min-h-[44px] py-4"
aria-label="Wind direction range threshold"
/>
<div className="text-xs text-muted-foreground mt-1">
Acceptable range: {(dirCenter - dirRange + 360) % 360}° to {(dirCenter + dirRange) % 360}°
</div>
</div>
</div>
<div className="pt-4 border-t">
<p className="text-xs text-muted-foreground">
Thresholds are saved to URL and applied to charts automatically.
</p>
</div>
</CardContent>
</Card>
)
}
// Helper function to convert degrees to compass direction
function getCompassDirection(degrees: number): string {
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
const index = Math.round(degrees / 22.5) % 16
return directions[index]
}

View File

@@ -0,0 +1,243 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Legend,
Area,
ComposedChart,
} from 'recharts'
import { format, parseISO } from 'date-fns'
import type { WeatherPoint } from '@/lib/types'
import { useThresholdStore } from '@/store/threshold-store'
interface WindDirectionChartProps {
data: WeatherPoint[]
yesterdayData?: WeatherPoint[]
className?: string
}
interface ChartDataPoint {
timestamp: string
time: string
offset?: number
yesterdayOffset?: number
direction?: number
isInRange: boolean
}
// Transform direction to offset from West (270°)
// offset = ((direction - 270 + 180) % 360) - 180
// This maps: 180° (S) = -90°, 270° (W) = 0°, 360° (N) = 90°
function calculateOffset(direction: number): number {
return ((direction - 270 + 180) % 360) - 180
}
export function WindDirectionChart({ data, yesterdayData, className }: WindDirectionChartProps) {
const { dirCenter, dirRange } = useThresholdStore()
// Calculate acceptable bounds
const centerOffset = calculateOffset(dirCenter)
const minOffset = centerOffset - dirRange
const maxOffset = centerOffset + dirRange
// Filter data for TODAY's 8am-10pm window only
const filterTimeWindow = (points: WeatherPoint[]) => {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0)
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0)
return points.filter((point) => {
const timestamp = parseISO(point.timestamp)
return timestamp >= todayStart && timestamp < todayEnd
})
}
// Generate static time slots for 8am-10pm (14 hours)
const generateTimeSlots = (): { hour: number; label: string }[] => {
const slots = []
for (let hour = 8; hour < 22; hour++) {
slots.push({
hour,
label: format(new Date().setHours(hour, 0, 0, 0), 'ha')
})
}
return slots
}
const filteredData = filterTimeWindow(data)
// Don't filter yesterday's data - show all available historical data
const filteredYesterday = yesterdayData || []
// Helper to clamp values to chart display range
const clampToChartRange = (value: number | undefined): number | undefined => {
if (value === undefined) return undefined
return Math.max(-60, Math.min(60, value))
}
// Generate static time slots and map data to them
const timeSlots = generateTimeSlots()
const chartData: ChartDataPoint[] = timeSlots.map(slot => {
// Find forecast data for this hour
const forecastPoint = filteredData.find(point =>
parseISO(point.timestamp).getHours() === slot.hour
)
// Find yesterday's data for this hour
const yesterdayPoint = filteredYesterday.find(yp =>
parseISO(yp.timestamp).getHours() === slot.hour
)
const rawOffset = forecastPoint ? calculateOffset(forecastPoint.wind_direction) : undefined
const offset = clampToChartRange(rawOffset)
const isInRange = rawOffset !== undefined ? (rawOffset >= minOffset && rawOffset <= maxOffset) : false
return {
timestamp: forecastPoint?.timestamp || '',
time: slot.label,
offset,
yesterdayOffset: clampToChartRange(yesterdayPoint ? calculateOffset(yesterdayPoint.wind_direction) : undefined),
direction: forecastPoint?.wind_direction,
isInRange,
}
})
// Helper to convert offset back to compass direction for display
const offsetToCompass = (offset: number): string => {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const deg = (((offset + 270) % 360) + 360) % 360
const index = Math.round(deg / 45) % 8
return directions[index]
}
// Custom tooltip
const CustomTooltip = ({ active, payload }: any) => {
if (!active || !payload || !payload.length) return null
const data = payload[0].payload
// Don't show tooltip if there's no forecast data for this time slot
if (data.offset === undefined || data.direction === undefined) return null
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="font-medium">{format(parseISO(data.timestamp), 'EEE ha')}</p>
<p className="text-sm">
<span className="font-medium">Direction:</span> {data.direction.toFixed(0)}° ({offsetToCompass(data.offset)})
</p>
<p className="text-sm">
<span className="font-medium">Offset from West:</span> {data.offset.toFixed(1)}°
</p>
{data.yesterdayOffset !== undefined && (
<p className="text-sm text-muted-foreground">
<span className="font-medium">Yesterday:</span> {data.yesterdayOffset.toFixed(1)}°
</p>
)}
<p className="text-xs mt-1">
Range: {minOffset.toFixed(0)}° to {maxOffset.toFixed(0)}°
</p>
</div>
)
}
return (
<Card className={className}>
<CardHeader>
<CardTitle>Wind Direction (Offset from West)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="directionRange" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis
dataKey="time"
tick={{ fontSize: 12 }}
interval={0}
angle={0}
height={40}
/>
<YAxis
label={{ value: 'Offset (°)', angle: -90, position: 'insideLeft' }}
tick={{ fontSize: 12 }}
domain={[() => -60, () => 60]}
ticks={[-60, -30, 0, 30, 60]}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
{/* Reference area for acceptable range */}
<Area
type="monotone"
dataKey={() => maxOffset}
fill="url(#directionRange)"
stroke="none"
fillOpacity={0.3}
/>
{/* Threshold reference lines */}
<ReferenceLine
y={minOffset}
stroke="#22c55e"
strokeDasharray="3 3"
label={{ value: `Min: ${minOffset.toFixed(0)}°`, fontSize: 11, fill: '#22c55e' }}
/>
<ReferenceLine
y={maxOffset}
stroke="#22c55e"
strokeDasharray="3 3"
label={{ value: `Max: ${maxOffset.toFixed(0)}°`, fontSize: 11, fill: '#22c55e' }}
/>
{/* Perfect West reference */}
<ReferenceLine
y={0}
stroke="#6b7280"
strokeDasharray="1 3"
label={{ value: 'W (270°)', fontSize: 10, fill: '#6b7280' }}
/>
{/* Yesterday's data (faded) */}
{yesterdayData && (
<Line
type="monotone"
dataKey="yesterdayOffset"
stroke="#9ca3af"
strokeWidth={1}
dot={false}
name="Yesterday"
opacity={0.5}
/>
)}
{/* Today's data */}
<Line
type="monotone"
dataKey="offset"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
name="Direction Offset"
/>
</ComposedChart>
</ResponsiveContainer>
<div className="mt-4 text-xs text-muted-foreground text-center">
0° = West (270°) | Negative = South | Positive = North
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,200 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
ReferenceLine,
Legend,
Area,
ComposedChart,
Tooltip,
} from 'recharts'
import { format, parseISO } from 'date-fns'
import type { WeatherPoint } from '@/lib/types'
import { useThresholdStore } from '@/store/threshold-store'
interface WindSpeedChartProps {
data: WeatherPoint[]
yesterdayData?: WeatherPoint[]
className?: string
}
interface ChartDataPoint {
timestamp: string
time: string
speed?: number
yesterdaySpeed?: number
isInRange: boolean
}
export function WindSpeedChart({ data, yesterdayData, className }: WindSpeedChartProps) {
const { speedMin, speedMax } = useThresholdStore()
// Filter data for TODAY's 8am-10pm window only
const filterTimeWindow = (points: WeatherPoint[]) => {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0)
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0)
return points.filter((point) => {
const timestamp = parseISO(point.timestamp)
return timestamp >= todayStart && timestamp < todayEnd
})
}
// Generate static time slots for 8am-10pm (14 hours)
const generateTimeSlots = (): { hour: number; label: string }[] => {
const slots = []
for (let hour = 8; hour < 22; hour++) {
slots.push({
hour,
label: format(new Date().setHours(hour, 0, 0, 0), 'ha')
})
}
return slots
}
const filteredData = filterTimeWindow(data)
// Don't filter yesterday's data - show all available historical data
const filteredYesterday = yesterdayData || []
// Generate static time slots and map data to them
const timeSlots = generateTimeSlots()
const chartData: ChartDataPoint[] = timeSlots.map(slot => {
// Find forecast data for this hour
const forecastPoint = filteredData.find(point =>
parseISO(point.timestamp).getHours() === slot.hour
)
// Find yesterday's data for this hour
const yesterdayPoint = filteredYesterday.find(yp =>
parseISO(yp.timestamp).getHours() === slot.hour
)
return {
timestamp: forecastPoint?.timestamp || '',
time: slot.label,
speed: forecastPoint?.wind_speed,
yesterdaySpeed: yesterdayPoint?.wind_speed,
isInRange: forecastPoint ?
(forecastPoint.wind_speed >= speedMin && forecastPoint.wind_speed <= speedMax) :
false
}
})
// Custom tooltip
const CustomTooltip = ({ active, payload }: any) => {
if (!active || !payload || !payload.length) return null
const data = payload[0].payload
// Don't show tooltip if there's no forecast data for this time slot
if (data.speed === undefined) return null
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="font-medium">{data.time}</p>
<p className="text-sm">
<span className="font-medium">Forecast:</span> {data.speed.toFixed(1)} mph
</p>
{data.yesterdaySpeed !== undefined && (
<p className="text-sm text-muted-foreground">
<span className="font-medium">Yesterday:</span> {data.yesterdaySpeed.toFixed(1)} mph
</p>
)}
</div>
)
}
return (
<Card className={className}>
<CardHeader>
<CardTitle>Wind Speed</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="speedRangeGood" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="speedRangeBad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis
dataKey="time"
tick={{ fontSize: 12 }}
interval={0}
angle={0}
height={40}
/>
<YAxis
label={{ value: 'Speed (mph)', angle: -90, position: 'insideLeft' }}
tick={{ fontSize: 12 }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
{/* Shaded area for acceptable speed range (green) */}
<Area
type="monotone"
dataKey={() => speedMax}
fill="url(#speedRangeGood)"
stroke="none"
fillOpacity={0.3}
activeDot={false}
/>
{/* Threshold reference lines */}
<ReferenceLine
y={speedMin}
stroke="#22c55e"
strokeDasharray="3 3"
label={{ value: `Min: ${speedMin}`, fontSize: 11, fill: '#22c55e' }}
/>
<ReferenceLine
y={speedMax}
stroke="#22c55e"
strokeDasharray="3 3"
label={{ value: `Max: ${speedMax}`, fontSize: 11, fill: '#22c55e' }}
/>
{/* Yesterday's data (faded) */}
{yesterdayData && (
<Line
type="monotone"
dataKey="yesterdaySpeed"
stroke="#9ca3af"
strokeWidth={1}
name="Yesterday"
dot={false}
activeDot={true}
isAnimationActive={false}
/>
)}
{/* Today's forecast - single continuous line */}
<Line
type="monotone"
dataKey="speed"
stroke="#3b82f6"
strokeWidth={2}
name="Forecast"
dot={false}
activeDot={true}
/>
</ComposedChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { getCurrentWeather, getForecast, getHistorical } from '@/lib/api'
import type { CurrentWeatherResponse, ForecastResponse, HistoricalResponse } from '@/lib/types'
const STALE_TIME = 5 * 60 * 1000 // 5 minutes
const REFETCH_INTERVAL = 5 * 60 * 1000 // 5 minutes
export function useCurrentWeather(lat?: number, lon?: number) {
return useQuery<CurrentWeatherResponse>({
queryKey: ['weather', 'current', lat, lon],
queryFn: () => getCurrentWeather(lat, lon),
staleTime: STALE_TIME,
refetchInterval: REFETCH_INTERVAL,
refetchOnWindowFocus: true,
})
}
export function useForecast(lat?: number, lon?: number) {
return useQuery<ForecastResponse>({
queryKey: ['weather', 'forecast', lat, lon],
queryFn: () => getForecast(lat, lon),
staleTime: STALE_TIME,
refetchInterval: REFETCH_INTERVAL,
refetchOnWindowFocus: true,
})
}
export function useHistorical(date: string, lat?: number, lon?: number) {
return useQuery<HistoricalResponse>({
queryKey: ['weather', 'historical', date, lat, lon],
queryFn: () => getHistorical(date, lat, lon),
staleTime: STALE_TIME,
enabled: !!date,
})
}

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

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

8
frontend/next.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
swcMinify: true,
}
module.exports = nextConfig

8520
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "paragliding-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest"
},
"dependencies": {
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-query": "^5.45.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.395.0",
"next": "14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.12.7",
"tailwind-merge": "^2.3.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@types/node": "^20.14.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,81 @@
'use client'
import { create } from 'zustand'
export interface ThresholdState {
speedMin: number
speedMax: number
dirCenter: number
dirRange: number
setSpeedMin: (value: number) => void
setSpeedMax: (value: number) => void
setDirCenter: (value: number) => void
setDirRange: (value: number) => void
setSpeedRange: (min: number, max: number) => void
initFromURL: () => void
}
// Helper to update URL params
const updateURLParams = (params: Record<string, string>) => {
if (typeof window === 'undefined') return
const url = new URL(window.location.href)
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
window.history.replaceState({}, '', url.toString())
}
// Helper to read from URL params
const getURLParam = (key: string, defaultValue: number): number => {
if (typeof window === 'undefined') return defaultValue
const params = new URLSearchParams(window.location.search)
const value = params.get(key)
return value ? parseFloat(value) : defaultValue
}
export const useThresholdStore = create<ThresholdState>((set) => ({
// Default values
speedMin: 7,
speedMax: 14,
dirCenter: 270,
dirRange: 15,
setSpeedMin: (value) => {
set({ speedMin: value })
updateURLParams({ speedMin: value.toString() })
},
setSpeedMax: (value) => {
set({ speedMax: value })
updateURLParams({ speedMax: value.toString() })
},
setDirCenter: (value) => {
set({ dirCenter: value })
updateURLParams({ dirCenter: value.toString() })
},
setDirRange: (value) => {
set({ dirRange: value })
updateURLParams({ dirRange: value.toString() })
},
setSpeedRange: (min, max) => {
set({ speedMin: min, speedMax: max })
updateURLParams({
speedMin: min.toString(),
speedMax: max.toString()
})
},
initFromURL: () => {
set({
speedMin: getURLParam('speedMin', 7),
speedMax: getURLParam('speedMax', 14),
dirCenter: getURLParam('dirCenter', 270),
dirRange: getURLParam('dirRange', 15),
})
},
}))

View File

@@ -0,0 +1,75 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
}

28
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}