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

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