198
apps/dashboard/src/App.tsx
Normal file
198
apps/dashboard/src/App.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useEffect, useState } from "react"
|
||||
import cn from "./components/lib/cn"
|
||||
import {
|
||||
DEFAULT_LATITUDE,
|
||||
DEFAULT_LONGITUDE,
|
||||
currentWeatherQuery,
|
||||
dailyForecastQuery,
|
||||
getWeatherIcon,
|
||||
weatherDescriptionQuery,
|
||||
} from "./weather"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="h-screen bg-black gap-4 text-neutral-200 grid grid-cols-4 grid-rows-5 p-4">
|
||||
<DateTimeTile />
|
||||
<WeatherTile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("relative bg-neutral-900 flex flex-col justify-end items-start", className)}>
|
||||
<div className="absolute top-0 left-0 w-4 h-[1px] bg-neutral-200" />
|
||||
<div className="absolute top-0 left-0 w-[1px] h-4 bg-neutral-200" />
|
||||
<div className="absolute bottom-0 right-0 w-4 h-[1px] bg-neutral-200" />
|
||||
<div className="absolute bottom-0 right-0 w-[1px] h-4 bg-neutral-200" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DateTimeTile() {
|
||||
const [time, setTime] = useState(new Date())
|
||||
|
||||
const formattedDate = time.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
const formattedTime = time.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTime(new Date())
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Tile className="col-start-1 row-start-1 col-span-2 row-span-3 p-6">
|
||||
<p className="text-4xl mb-2 font-extralight">{formattedDate}</p>
|
||||
<p className="text-8xl font-bold">{formattedTime}</p>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
function WeatherTile() {
|
||||
const {
|
||||
data: currentWeatherData,
|
||||
isLoading: isLoadingCurrentWeather,
|
||||
error: errorCurrentWeather,
|
||||
} = useQuery({
|
||||
...currentWeatherQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
|
||||
refetchInterval: 5 * 60 * 1000, // 5 minutes
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
const {
|
||||
data: dailyForecastData,
|
||||
isLoading: isLoadingDailyForecast,
|
||||
error: errorDailyForecast,
|
||||
} = useQuery({
|
||||
...dailyForecastQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
|
||||
refetchInterval: 5 * 60 * 1000, // 5 minutes
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
const {
|
||||
data: weatherDescriptionData,
|
||||
isLoading: isLoadingWeatherDescription,
|
||||
error: errorWeatherDescription,
|
||||
} = useQuery({
|
||||
...weatherDescriptionQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
|
||||
refetchInterval: 60 * 60 * 1000, // 1 hour
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
const isLoading = isLoadingCurrentWeather || isLoadingDailyForecast
|
||||
const error = errorCurrentWeather || errorDailyForecast
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-row justify-center items-center p-8">
|
||||
<p className="text-2xl font-light animate-pulse">Loading weather</p>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !currentWeatherData?.currentWeather) {
|
||||
return (
|
||||
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-col justify-center items-center p-8">
|
||||
<p className="text-2xl text-red-400 font-light">Error loading weather</p>
|
||||
<p className=" text-neutral-400">{error?.message ?? "Unknown error"}</p>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
const currentWeather = currentWeatherData.currentWeather
|
||||
const temperature = Math.round(currentWeather.temperature)
|
||||
const lowTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMin ?? 0)
|
||||
const highTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMax ?? 0)
|
||||
const percentage = lowTemp && highTemp ? (temperature - lowTemp) / (highTemp - lowTemp) : 0
|
||||
const highlightIndexStart = Math.floor((1 - percentage) * 23)
|
||||
const WeatherIcon = getWeatherIcon(currentWeather.conditionCode)
|
||||
|
||||
let weatherDescriptionContent: string
|
||||
if (isLoadingWeatherDescription) {
|
||||
weatherDescriptionContent = "Loading weather description"
|
||||
} else if (errorWeatherDescription) {
|
||||
weatherDescriptionContent = `Error: ${errorWeatherDescription.message}`
|
||||
} else if (!weatherDescriptionData?.description) {
|
||||
weatherDescriptionContent = "No weather description available"
|
||||
} else {
|
||||
weatherDescriptionContent = weatherDescriptionData.description
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-row justify-start items-center p-8">
|
||||
<div className="flex flex-row h-full items-center space-x-2 flex-[2]">
|
||||
<div className="flex flex-col justify-between items-end h-full">
|
||||
<p className={cn("leading-none text-sm text-neutral-400", temperature === highTemp && "invisible")}>
|
||||
H:{highTemp}°
|
||||
</p>
|
||||
<p className={cn("leading-none text-sm text-neutral-400", temperature === lowTemp && "invisible")}>
|
||||
L:{lowTemp}°
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2 flex-[1]">
|
||||
{Array.from({ length: 24 }).map((_, index) => {
|
||||
if (index === highlightIndexStart) {
|
||||
return (
|
||||
<div className="relative w-fit">
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={index}
|
||||
className={cn("w-10 bg-teal-400 h-[2px]")}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute flex flex-row items-center space-x-1 top-0 right-0 bg-teal-400 text-neutral-900 px-2 py-1 text-2xl font-bold rounded-r-sm translate-x-[calc(100%-1px)]",
|
||||
percentage < 0.3
|
||||
? "-translate-y-[calc(100%-2px)] rounded-tl-sm"
|
||||
: "rounded-bl-sm",
|
||||
)}
|
||||
>
|
||||
<p className="leading-none translate-y-px">{temperature}°</p>
|
||||
<WeatherIcon className="size-6" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-4",
|
||||
index >= highlightIndexStart
|
||||
? "bg-teal-400 w-8 h-[2px]"
|
||||
: "bg-neutral-400 w-4 h-[1px]",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col justify-start h-full space-y-2 flex-[3]">
|
||||
<p
|
||||
className={cn("text-3xl leading-none tracking-tight font-light", {
|
||||
"text-red-400": errorWeatherDescription,
|
||||
"animate-pulse": isLoadingWeatherDescription,
|
||||
})}
|
||||
>
|
||||
{weatherDescriptionContent}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
8
apps/dashboard/src/components/lib/cn.ts
Normal file
8
apps/dashboard/src/components/lib/cn.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export default cn
|
||||
11
apps/dashboard/src/env.d.ts
vendored
Normal file
11
apps/dashboard/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_DEFAULT_LATITUDE: string;
|
||||
readonly VITE_DEFAULT_LONGITUDE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
12
apps/dashboard/src/index.css
Normal file
12
apps/dashboard/src/index.css
Normal file
@@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
15
apps/dashboard/src/main.tsx
Normal file
15
apps/dashboard/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
530
apps/dashboard/src/weather.ts
Normal file
530
apps/dashboard/src/weather.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* WeatherKit REST API TypeScript Types
|
||||
* Based on Apple's WeatherKit REST API documentation
|
||||
* https://developer.apple.com/documentation/weatherkitrestapi/
|
||||
*/
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Cloud,
|
||||
CloudDrizzle,
|
||||
CloudFog,
|
||||
CloudHail,
|
||||
CloudLightning,
|
||||
CloudMoon,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
CloudSun,
|
||||
Snowflake,
|
||||
Sun,
|
||||
Thermometer,
|
||||
ThermometerSnowflake,
|
||||
ThermometerSun,
|
||||
Tornado,
|
||||
Waves,
|
||||
Wind,
|
||||
} from "lucide-react"
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"
|
||||
|
||||
export const DEFAULT_LATITUDE = Number(import.meta.env.VITE_DEFAULT_LATITUDE) || 37.7749
|
||||
export const DEFAULT_LONGITUDE = Number(import.meta.env.VITE_DEFAULT_LONGITUDE) || -122.4194
|
||||
|
||||
// Main Weather Response
|
||||
export interface WeatherResponse {
|
||||
currentWeather?: CurrentWeather
|
||||
forecastDaily?: DailyForecast
|
||||
forecastHourly?: HourlyForecast
|
||||
forecastNextHour?: NextHourForecast
|
||||
weatherAlerts?: WeatherAlertCollection
|
||||
aiDescription?: string | null
|
||||
}
|
||||
|
||||
// Current Weather
|
||||
export interface CurrentWeather {
|
||||
name: "CurrentWeather"
|
||||
metadata: WeatherMetadata
|
||||
asOf: string // ISO 8601 date
|
||||
cloudCover: number // 0-1
|
||||
cloudCoverLowAltPct?: number
|
||||
cloudCoverMidAltPct?: number
|
||||
cloudCoverHighAltPct?: number
|
||||
conditionCode: WeatherCondition
|
||||
daylight: boolean
|
||||
humidity: number // 0-1
|
||||
precipitationIntensity: number // mm/hr
|
||||
pressure: number // millibars
|
||||
pressureTrend: PressureTrend
|
||||
temperature: number // celsius
|
||||
temperatureApparent: number // celsius
|
||||
temperatureDewPoint: number // celsius
|
||||
uvIndex: number
|
||||
visibility: number // meters
|
||||
windDirection: number // degrees
|
||||
windGust: number // km/h
|
||||
windSpeed: number // km/h
|
||||
}
|
||||
|
||||
// Daily Forecast
|
||||
export interface DailyForecast {
|
||||
name: "DailyForecast"
|
||||
metadata: WeatherMetadata
|
||||
days: DayWeatherConditions[]
|
||||
}
|
||||
|
||||
export interface DayWeatherConditions {
|
||||
forecastStart: string // ISO 8601 date
|
||||
forecastEnd: string // ISO 8601 date
|
||||
conditionCode: WeatherCondition
|
||||
maxUvIndex: number
|
||||
moonPhase: MoonPhase
|
||||
moonrise?: string // ISO 8601 date
|
||||
moonset?: string // ISO 8601 date
|
||||
precipitationAmount: number // mm
|
||||
precipitationChance: number // 0-1
|
||||
precipitationType: PrecipitationType
|
||||
snowfallAmount: number // cm
|
||||
solarMidnight?: string // ISO 8601 date
|
||||
solarNoon?: string // ISO 8601 date
|
||||
sunrise?: string // ISO 8601 date
|
||||
sunriseCivil?: string // ISO 8601 date
|
||||
sunriseNautical?: string // ISO 8601 date
|
||||
sunriseAstronomical?: string // ISO 8601 date
|
||||
sunset?: string // ISO 8601 date
|
||||
sunsetCivil?: string // ISO 8601 date
|
||||
sunsetNautical?: string // ISO 8601 date
|
||||
sunsetAstronomical?: string // ISO 8601 date
|
||||
temperatureMax: number // celsius
|
||||
temperatureMin: number // celsius
|
||||
daytimeForecast?: DayPartForecast
|
||||
overnightForecast?: DayPartForecast
|
||||
restOfDayForecast?: DayPartForecast
|
||||
}
|
||||
|
||||
export interface DayPartForecast {
|
||||
forecastStart: string // ISO 8601 date
|
||||
forecastEnd: string // ISO 8601 date
|
||||
cloudCover: number // 0-1
|
||||
conditionCode: WeatherCondition
|
||||
humidity: number // 0-1
|
||||
precipitationAmount: number // mm
|
||||
precipitationChance: number // 0-1
|
||||
precipitationType: PrecipitationType
|
||||
snowfallAmount: number // cm
|
||||
windDirection: number // degrees
|
||||
windSpeed: number // km/h
|
||||
}
|
||||
|
||||
// Hourly Forecast
|
||||
export interface HourlyForecast {
|
||||
name: "HourlyForecast"
|
||||
metadata: WeatherMetadata
|
||||
hours: HourWeatherConditions[]
|
||||
}
|
||||
|
||||
export interface HourWeatherConditions {
|
||||
forecastStart: string // ISO 8601 date
|
||||
cloudCover: number // 0-1
|
||||
cloudCoverLowAltPct?: number
|
||||
cloudCoverMidAltPct?: number
|
||||
cloudCoverHighAltPct?: number
|
||||
conditionCode: WeatherCondition
|
||||
daylight: boolean
|
||||
humidity: number // 0-1
|
||||
precipitationAmount: number // mm
|
||||
precipitationIntensity: number // mm/hr
|
||||
precipitationChance: number // 0-1
|
||||
precipitationType: PrecipitationType
|
||||
pressure: number // millibars
|
||||
pressureTrend: PressureTrend
|
||||
snowfallIntensity?: number // mm/hr
|
||||
snowfallAmount?: number // cm
|
||||
temperature: number // celsius
|
||||
temperatureApparent: number // celsius
|
||||
temperatureDewPoint: number // celsius
|
||||
uvIndex: number
|
||||
visibility: number // meters
|
||||
windDirection: number // degrees
|
||||
windGust: number // km/h
|
||||
windSpeed: number // km/h
|
||||
}
|
||||
|
||||
// Next Hour Forecast (Minute-by-minute precipitation)
|
||||
export interface NextHourForecast {
|
||||
name: "NextHourForecast"
|
||||
metadata: WeatherMetadata
|
||||
forecastStart: string // ISO 8601 date
|
||||
forecastEnd: string // ISO 8601 date
|
||||
minutes: MinuteWeatherConditions[]
|
||||
summary: NextHourForecastSummary[]
|
||||
}
|
||||
|
||||
export interface MinuteWeatherConditions {
|
||||
startTime: string // ISO 8601 date
|
||||
precipitationChance: number // 0-1
|
||||
precipitationIntensity: number // mm/hr
|
||||
}
|
||||
|
||||
export interface NextHourForecastSummary {
|
||||
startTime: string // ISO 8601 date
|
||||
condition: PrecipitationCondition
|
||||
precipitationChance: number // 0-1
|
||||
precipitationIntensity: number // mm/hr
|
||||
}
|
||||
|
||||
// Weather Alerts
|
||||
export interface WeatherAlertCollection {
|
||||
name: "WeatherAlertCollection"
|
||||
metadata: WeatherMetadata
|
||||
alerts: WeatherAlert[]
|
||||
detailsUrl: string
|
||||
}
|
||||
|
||||
export interface WeatherAlert {
|
||||
name: "WeatherAlert"
|
||||
id: string
|
||||
areaId?: string
|
||||
areaName?: string
|
||||
countryCode: string
|
||||
description: string
|
||||
effectiveTime: string // ISO 8601 date
|
||||
expireTime: string // ISO 8601 date
|
||||
issuedTime: string // ISO 8601 date
|
||||
eventOnsetTime?: string // ISO 8601 date
|
||||
eventEndTime?: string // ISO 8601 date
|
||||
severity: AlertSeverity
|
||||
source: string
|
||||
urgency: AlertUrgency
|
||||
certainty: AlertCertainty
|
||||
importance?: AlertImportance
|
||||
responses?: AlertResponse[]
|
||||
detailsUrl: string
|
||||
}
|
||||
|
||||
// Metadata
|
||||
export interface WeatherMetadata {
|
||||
attributionURL: string
|
||||
expireTime: string // ISO 8601 date
|
||||
latitude: number
|
||||
longitude: number
|
||||
readTime: string // ISO 8601 date
|
||||
reportedTime: string // ISO 8601 date
|
||||
units: "m" | "e" // metric or imperial
|
||||
version: number
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
// Enums and Types
|
||||
export type WeatherCondition =
|
||||
| "Clear"
|
||||
| "Cloudy"
|
||||
| "Dust"
|
||||
| "Fog"
|
||||
| "Haze"
|
||||
| "MostlyClear"
|
||||
| "MostlyCloudy"
|
||||
| "PartlyCloudy"
|
||||
| "ScatteredThunderstorms"
|
||||
| "Smoke"
|
||||
| "Breezy"
|
||||
| "Windy"
|
||||
| "Drizzle"
|
||||
| "HeavyRain"
|
||||
| "Rain"
|
||||
| "Showers"
|
||||
| "Flurries"
|
||||
| "HeavySnow"
|
||||
| "MixedRainAndSleet"
|
||||
| "MixedRainAndSnow"
|
||||
| "MixedRainfall"
|
||||
| "MixedSnowAndSleet"
|
||||
| "ScatteredShowers"
|
||||
| "ScatteredSnowShowers"
|
||||
| "Sleet"
|
||||
| "Snow"
|
||||
| "SnowShowers"
|
||||
| "Blizzard"
|
||||
| "BlowingSnow"
|
||||
| "FreezingDrizzle"
|
||||
| "FreezingRain"
|
||||
| "Frigid"
|
||||
| "Hail"
|
||||
| "Hot"
|
||||
| "Hurricane"
|
||||
| "IsolatedThunderstorms"
|
||||
| "SevereThunderstorm"
|
||||
| "Thunderstorm"
|
||||
| "Tornado"
|
||||
| "TropicalStorm"
|
||||
|
||||
export type PrecipitationType = "clear" | "precipitation" | "rain" | "snow" | "sleet" | "hail" | "mixed"
|
||||
|
||||
export type PrecipitationCondition = "clear" | "precipitation"
|
||||
|
||||
export type PressureTrend = "rising" | "falling" | "steady"
|
||||
|
||||
export type MoonPhase =
|
||||
| "new"
|
||||
| "waxingCrescent"
|
||||
| "firstQuarter"
|
||||
| "waxingGibbous"
|
||||
| "full"
|
||||
| "waningGibbous"
|
||||
| "lastQuarter"
|
||||
| "waningCrescent"
|
||||
|
||||
export type AlertSeverity = "extreme" | "severe" | "moderate" | "minor" | "unknown"
|
||||
|
||||
export type AlertUrgency = "immediate" | "expected" | "future" | "past" | "unknown"
|
||||
|
||||
export type AlertCertainty = "observed" | "likely" | "possible" | "unlikely" | "unknown"
|
||||
|
||||
export type AlertImportance = "high" | "normal" | "low"
|
||||
|
||||
export type AlertResponse =
|
||||
| "shelter"
|
||||
| "evacuate"
|
||||
| "prepare"
|
||||
| "execute"
|
||||
| "avoid"
|
||||
| "monitor"
|
||||
| "assess"
|
||||
| "allClear"
|
||||
| "none"
|
||||
|
||||
// Helper function to format temperature
|
||||
export function formatTemperature(celsius: number, unit: "C" | "F" = "C"): string {
|
||||
if (unit === "F") {
|
||||
return `${Math.round((celsius * 9) / 5 + 32)}°F`
|
||||
}
|
||||
return `${Math.round(celsius)}°C`
|
||||
}
|
||||
|
||||
// Helper function to format wind speed
|
||||
export function formatWindSpeed(kmh: number, unit: "kmh" | "mph" = "kmh"): string {
|
||||
if (unit === "mph") {
|
||||
return `${Math.round(kmh * 0.621371)} mph`
|
||||
}
|
||||
return `${Math.round(kmh)} km/h`
|
||||
}
|
||||
|
||||
// Helper function to format precipitation
|
||||
export function formatPrecipitation(mm: number, unit: "mm" | "in" = "mm"): string {
|
||||
if (unit === "in") {
|
||||
return `${(mm * 0.0393701).toFixed(2)} in`
|
||||
}
|
||||
return `${mm.toFixed(1)} mm`
|
||||
}
|
||||
|
||||
// Helper function to get today's high/low from daily forecast
|
||||
export function getTodayHighLow(forecast?: DailyForecast): {
|
||||
high: number | null
|
||||
low: number | null
|
||||
} {
|
||||
if (!forecast?.days || forecast.days.length === 0) {
|
||||
return { high: null, low: null }
|
||||
}
|
||||
|
||||
const today = forecast.days[0]
|
||||
return {
|
||||
high: today.temperatureMax,
|
||||
low: today.temperatureMin,
|
||||
}
|
||||
}
|
||||
|
||||
// Weather condition to Lucide icon mapping
|
||||
export const weatherConditionIcons: Record<WeatherCondition, LucideIcon> = {
|
||||
Clear: Sun,
|
||||
MostlyClear: CloudSun,
|
||||
PartlyCloudy: CloudSun,
|
||||
MostlyCloudy: Cloud,
|
||||
Cloudy: Cloud,
|
||||
Fog: CloudFog,
|
||||
Haze: CloudFog,
|
||||
Smoke: CloudFog,
|
||||
Dust: Wind,
|
||||
Breezy: Wind,
|
||||
Windy: Wind,
|
||||
Drizzle: CloudDrizzle,
|
||||
Rain: CloudRain,
|
||||
Showers: CloudRain,
|
||||
ScatteredShowers: CloudRain,
|
||||
HeavyRain: CloudRain,
|
||||
Flurries: CloudSnow,
|
||||
Snow: CloudSnow,
|
||||
SnowShowers: CloudSnow,
|
||||
ScatteredSnowShowers: CloudSnow,
|
||||
HeavySnow: Snowflake,
|
||||
Blizzard: Snowflake,
|
||||
BlowingSnow: Snowflake,
|
||||
Sleet: CloudSnow,
|
||||
MixedRainAndSleet: CloudSnow,
|
||||
MixedRainAndSnow: CloudSnow,
|
||||
MixedRainfall: CloudRain,
|
||||
MixedSnowAndSleet: CloudSnow,
|
||||
FreezingDrizzle: CloudSnow,
|
||||
FreezingRain: CloudSnow,
|
||||
Hail: CloudHail,
|
||||
Thunderstorm: CloudLightning,
|
||||
IsolatedThunderstorms: CloudLightning,
|
||||
ScatteredThunderstorms: CloudLightning,
|
||||
SevereThunderstorm: CloudLightning,
|
||||
Tornado: Tornado,
|
||||
TropicalStorm: Waves,
|
||||
Hurricane: Waves,
|
||||
Hot: ThermometerSun,
|
||||
Frigid: ThermometerSnowflake,
|
||||
}
|
||||
|
||||
// Helper function to get weather icon for a condition
|
||||
export function getWeatherIcon(condition: WeatherCondition): LucideIcon {
|
||||
return weatherConditionIcons[condition] || Cloud
|
||||
}
|
||||
|
||||
// Helper function to get condition icon/description
|
||||
export function getConditionDescription(condition: WeatherCondition): string {
|
||||
const descriptions: Record<WeatherCondition, string> = {
|
||||
Clear: "Clear",
|
||||
Cloudy: "Cloudy",
|
||||
Dust: "Dust",
|
||||
Fog: "Fog",
|
||||
Haze: "Haze",
|
||||
MostlyClear: "Mostly Clear",
|
||||
MostlyCloudy: "Mostly Cloudy",
|
||||
PartlyCloudy: "Partly Cloudy",
|
||||
ScatteredThunderstorms: "Scattered Thunderstorms",
|
||||
Smoke: "Smoke",
|
||||
Breezy: "Breezy",
|
||||
Windy: "Windy",
|
||||
Drizzle: "Drizzle",
|
||||
HeavyRain: "Heavy Rain",
|
||||
Rain: "Rain",
|
||||
Showers: "Showers",
|
||||
Flurries: "Flurries",
|
||||
HeavySnow: "Heavy Snow",
|
||||
MixedRainAndSleet: "Mixed Rain and Sleet",
|
||||
MixedRainAndSnow: "Mixed Rain and Snow",
|
||||
MixedRainfall: "Mixed Rainfall",
|
||||
MixedSnowAndSleet: "Mixed Snow and Sleet",
|
||||
ScatteredShowers: "Scattered Showers",
|
||||
ScatteredSnowShowers: "Scattered Snow Showers",
|
||||
Sleet: "Sleet",
|
||||
Snow: "Snow",
|
||||
SnowShowers: "Snow Showers",
|
||||
Blizzard: "Blizzard",
|
||||
BlowingSnow: "Blowing Snow",
|
||||
FreezingDrizzle: "Freezing Drizzle",
|
||||
FreezingRain: "Freezing Rain",
|
||||
Frigid: "Frigid",
|
||||
Hail: "Hail",
|
||||
Hot: "Hot",
|
||||
Hurricane: "Hurricane",
|
||||
IsolatedThunderstorms: "Isolated Thunderstorms",
|
||||
SevereThunderstorm: "Severe Thunderstorm",
|
||||
Thunderstorm: "Thunderstorm",
|
||||
Tornado: "Tornado",
|
||||
TropicalStorm: "Tropical Storm",
|
||||
}
|
||||
return descriptions[condition] || condition
|
||||
}
|
||||
|
||||
// TanStack Query Options
|
||||
|
||||
/**
|
||||
* Query options for fetching current weather + daily forecast + AI description
|
||||
* This is a combined endpoint that returns all three in one API call
|
||||
*/
|
||||
export function currentWeatherQuery(lat: number, lon: number) {
|
||||
return queryOptions({
|
||||
queryKey: ["weather", "current", lat, lon],
|
||||
queryFn: async (): Promise<WeatherResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/weather/current/${lat}/${lon}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch current weather")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for fetching daily forecast
|
||||
*/
|
||||
export function dailyForecastQuery(lat: number, lon: number) {
|
||||
return queryOptions({
|
||||
queryKey: ["weather", "forecast", "daily", lat, lon],
|
||||
queryFn: async (): Promise<WeatherResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/weather/forecast/${lat}/${lon}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch daily forecast")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for fetching hourly forecast
|
||||
*/
|
||||
export function hourlyForecastQuery(lat: number, lon: number) {
|
||||
return queryOptions({
|
||||
queryKey: ["weather", "forecast", "hourly", lat, lon],
|
||||
queryFn: async (): Promise<WeatherResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/weather/hourly/${lat}/${lon}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch hourly forecast")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 15 * 60 * 1000, // 15 minutes
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for fetching complete weather data (all data sets)
|
||||
*/
|
||||
export function completeWeatherQuery(lat: number, lon: number) {
|
||||
return queryOptions({
|
||||
queryKey: ["weather", "complete", lat, lon],
|
||||
queryFn: async (): Promise<WeatherResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/weather/complete/${lat}/${lon}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch complete weather data")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||
})
|
||||
}
|
||||
|
||||
// AI Weather Description Response
|
||||
export interface WeatherDescriptionResponse {
|
||||
description: string
|
||||
cached: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for fetching AI-generated weather description
|
||||
* Backend caches descriptions for 1 hour per location
|
||||
*/
|
||||
export function weatherDescriptionQuery(lat: number, lon: number) {
|
||||
return queryOptions({
|
||||
queryKey: ["weather", "description", lat, lon],
|
||||
queryFn: async (): Promise<WeatherDescriptionResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/weather/description/${lat}/${lon}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch weather description")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache)
|
||||
gcTime: 2 * 60 * 60 * 1000, // 2 hours
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user