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