164 lines
5.4 KiB
TypeScript
164 lines
5.4 KiB
TypeScript
'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>
|
|
)
|
|
}
|