initial commit

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-10-24 19:36:05 +00:00
commit 30986e0292
31 changed files with 2021 additions and 0 deletions

198
apps/dashboard/src/App.tsx Normal file
View 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

View 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
View 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;
}

View 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;
}

View 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>,
);

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