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

18
apps/backend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "backend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"start": "bun run dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.6.14"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.6.3"
}
}

9
apps/backend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare namespace NodeJS {
interface ProcessEnv {
ADP_TEAM_ID: string
ADP_SERVICE_ID: string
ADP_KEY_ID: string
ADP_KEY_PATH: string
GEMINI_API_KEY: string
}
}

25
apps/backend/src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { logger } from "hono/logger"
import weather from "./weather"
const app = new Hono()
app.use("*", logger())
app.use("*", cors())
app.get("/", (c) => {
return c.json({ message: "Hello from Bun + Hono!" })
})
app.get("/api/health", (c) => {
return c.json({ status: "ok", timestamp: new Date().toISOString() })
})
// Mount weather routes
app.route("/api/weather", weather)
export default {
port: 8000,
fetch: app.fetch,
}

View File

@@ -0,0 +1,131 @@
interface WeatherKitTokenOptions {
teamId: string
serviceId: string
keyId: string
privateKeyPath: string
expiresIn?: number // in seconds, default 3600 (1 hour)
}
/**
* Generates a JWT token for WeatherKit REST API authentication.
*
* WeatherKit requires a JWT signed with ES256 algorithm using a private key
* from Apple Developer portal in p8 format.
*
* @param options - Configuration for token generation
* @returns JWT token string
*/
export async function generateWeatherKitToken({
teamId,
serviceId,
keyId,
privateKeyPath,
expiresIn = 3600,
}: WeatherKitTokenOptions): Promise<string> {
const now = Math.floor(Date.now() / 1000)
const exp = now + expiresIn
// JWT Header
const header = {
alg: "ES256",
kid: keyId,
id: `${teamId}.${serviceId}`,
}
// JWT Payload
const payload = {
iss: teamId,
iat: now,
exp: exp,
sub: serviceId,
}
// Read and parse the p8 private key using Bun's file API
const file = Bun.file(privateKeyPath)
const privateKeyPem = await file.text()
const privateKey = await importPrivateKey(privateKeyPem)
// Create JWT
const token = await signJWT(header, payload, privateKey)
return token
}
/**
* Imports a private key from p8 (PKCS#8) format.
* The p8 file contains a PEM-encoded PKCS#8 private key.
*/
async function importPrivateKey(pem: string): Promise<CryptoKey> {
// Remove PEM header/footer and whitespace
const pemContents = pem
.replace(/-----BEGIN PRIVATE KEY-----/, "")
.replace(/-----END PRIVATE KEY-----/, "")
.replace(/\s/g, "")
// Decode base64 to binary
const binaryDer = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0))
// Import the key using Web Crypto API
const key = await crypto.subtle.importKey(
"pkcs8",
binaryDer,
{
name: "ECDSA",
namedCurve: "P-256",
},
false,
["sign"],
)
return key
}
/**
* Signs a JWT using ES256 algorithm.
*/
async function signJWT(
header: Record<string, unknown>,
payload: Record<string, unknown>,
privateKey: CryptoKey,
): Promise<string> {
// Encode header and payload
const encodedHeader = base64UrlEncode(JSON.stringify(header))
const encodedPayload = base64UrlEncode(JSON.stringify(payload))
// Create signing input
const signingInput = `${encodedHeader}.${encodedPayload}`
const messageBuffer = new TextEncoder().encode(signingInput)
// Sign the message
const signature = await crypto.subtle.sign(
{
name: "ECDSA",
hash: { name: "SHA-256" },
},
privateKey,
messageBuffer,
)
// Encode signature
const encodedSignature = base64UrlEncode(signature)
// Return complete JWT
return `${signingInput}.${encodedSignature}`
}
/**
* Base64 URL-safe encoding (without padding).
*/
function base64UrlEncode(input: string | ArrayBuffer): string {
let base64: string
if (typeof input === "string") {
base64 = btoa(input)
} else {
const bytes = new Uint8Array(input)
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("")
base64 = btoa(binary)
}
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}

View File

@@ -0,0 +1,74 @@
/**
* Simple in-memory cache for AI weather descriptions
* Cache expires after 1 hour
*/
interface CacheEntry {
description: string;
timestamp: number;
}
const cache = new Map<string, CacheEntry>();
const CACHE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds
/**
* Generate cache key from coordinates
*/
function getCacheKey(lat: string, lon: string): string {
// Round to 2 decimal places to group nearby locations
const roundedLat = Math.round(parseFloat(lat) * 100) / 100;
const roundedLon = Math.round(parseFloat(lon) * 100) / 100;
return `${roundedLat},${roundedLon}`;
}
/**
* Get cached description if available and not expired
*/
export function getCachedDescription(lat: string, lon: string): string | null {
const key = getCacheKey(lat, lon);
const entry = cache.get(key);
if (!entry) {
return null;
}
const now = Date.now();
if (now - entry.timestamp > CACHE_TTL) {
// Expired, remove from cache
cache.delete(key);
return null;
}
return entry.description;
}
/**
* Store description in cache
*/
export function setCachedDescription(lat: string, lon: string, description: string): void {
const key = getCacheKey(lat, lon);
cache.set(key, {
description,
timestamp: Date.now(),
});
}
/**
* Clear all cached descriptions (useful for testing)
*/
export function clearCache(): void {
cache.clear();
}
/**
* Get cache statistics
*/
export function getCacheStats() {
return {
size: cache.size,
entries: Array.from(cache.entries()).map(([key, entry]) => ({
location: key,
age: Math.round((Date.now() - entry.timestamp) / 1000 / 60), // minutes
})),
};
}

View File

@@ -0,0 +1,145 @@
/**
* Gemini AI integration for generating weather descriptions
*/
interface WeatherData {
condition: string;
temperature: number;
feelsLike: number;
humidity: number;
windSpeed: number;
precipitationChance?: number;
uvIndex: number;
daytimeCondition?: string;
overnightCondition?: string;
isNighttime?: boolean;
tomorrowHighTemp?: number;
tomorrowLowTemp?: number;
tomorrowCondition?: string;
tomorrowPrecipitationChance?: number;
}
/**
* Generates a concise weather description using Gemini 2.5 Flash
*/
export async function generateWeatherDescription(
weatherData: WeatherData,
): Promise<string> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY environment variable is not set");
}
const prompt = buildWeatherPrompt(weatherData);
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [
{
parts: [
{
text: prompt,
},
],
},
],
generationConfig: {
temperature: 0.7,
maxOutputTokens: 120,
topP: 0.95,
},
}),
},
);
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`);
}
const data = (await response.json()) as any;
const description =
data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || "";
return description;
} catch (error) {
console.error("Failed to generate weather description:", error);
// Fallback to basic description
return `${weatherData.condition}, ${Math.round(weatherData.temperature)}°C`;
}
}
/**
* Builds an optimized prompt for Gemini to generate weather descriptions
*/
function buildWeatherPrompt(weatherData: WeatherData): string {
let laterConditions = "";
// If it's nighttime, mention tomorrow's weather
if (weatherData.isNighttime && weatherData.tomorrowCondition) {
laterConditions = `\n\nTomorrow's forecast:
- Condition: ${weatherData.tomorrowCondition}
- High: ${weatherData.tomorrowHighTemp ? Math.round(weatherData.tomorrowHighTemp) : "N/A"}°C
- Low: ${weatherData.tomorrowLowTemp ? Math.round(weatherData.tomorrowLowTemp) : "N/A"}°C
${weatherData.tomorrowPrecipitationChance ? `- Precipitation chance: ${Math.round(weatherData.tomorrowPrecipitationChance * 100)}%` : ""}`;
}
// Otherwise, mention changes later today
else if (weatherData.daytimeCondition || weatherData.overnightCondition) {
laterConditions = `\n- Later today: ${weatherData.daytimeCondition || weatherData.overnightCondition}`;
}
return `Generate a concise, natural weather description for a dashboard. Keep it under 25 words.
Current conditions:
- Condition: ${weatherData.condition}
- Feels like: ${Math.round(weatherData.feelsLike)}°C
- Humidity: ${Math.round(weatherData.humidity * 100)}%
- Wind speed: ${Math.round(weatherData.windSpeed)} km/h
${weatherData.precipitationChance ? `- Precipitation chance: ${Math.round(weatherData.precipitationChance * 100)}%` : ""}
- UV index: ${weatherData.uvIndex}${laterConditions}
Requirements:
- Be conversational and friendly
- Focus on what matters most (condition, any warnings)
- DO NOT mention the current temperature - it will be displayed separately
- CRITICAL: If it's nighttime and tomorrow's forecast is provided, PRIORITIZE tomorrow's weather (e.g., "Cool night. Tomorrow will be partly cloudy with a high of 10°C.")
- If it's daytime and conditions change later, mention it (e.g., "turning cloudy later", "clearing up tonight")
- Tomorrow's temperature is OK to mention
- Mention feels-like only if significantly different (>3°C) and explain WHY (e.g., "due to wind", "due to humidity")
- Include precipitation chance if >30%
- For wind: Use descriptive terms (calm, light, moderate, strong, extreme) - NEVER use specific km/h numbers
- For UV: Use descriptive terms (low, moderate, high, very high, extreme) - NEVER use specific numbers
- Warn about extreme conditions (very hot/cold, high UV, strong winds)
- Use natural language, not technical jargon
- NO emojis
- One or two short sentences maximum
Example good outputs (DAYTIME):
- "Partly cloudy and pleasant. Light winds make it comfortable."
- "Clear skies, but feels hotter. High UV - wear sunscreen."
- "Mostly sunny, turning cloudy later. Comfortable conditions."
- "Rainy with 70% chance of more rain. Bring an umbrella."
- "Feels much colder due to strong winds. Bundle up."
- "Cloudy and mild, clearing up tonight."
- "Feels warmer due to humidity. Stay hydrated."
Example good outputs (NIGHTTIME - focus on tomorrow):
- "Cool night. Tomorrow will be sunny and warm with a high of 24°C."
- "Clear skies. Expect partly cloudy skies tomorrow, high of 10°C."
- "Chilly night. Tomorrow brings rain with a high of 15°C."
- "Mild evening. Tomorrow will be hot and sunny, reaching 32°C."
Example BAD outputs (avoid these):
- "Mostly clear at 7°C, feels like 0°C due to the 21 km/h wind." ❌ (don't mention current temp, don't use specific wind speed)
- "Sunny at 28°C with UV index of 9." ❌ (don't mention current temp, don't use specific UV number)
- "Temperature is 22°C with 65% humidity." ❌ (don't mention current temp, too technical)
Generate description:`;
}

View File

@@ -0,0 +1,71 @@
import type { MiddlewareHandler } from "hono"
import { generateWeatherKitToken } from "./auth"
interface TokenCache {
token: string
expiresAt: number
}
/**
* Hono middleware that adds a WeatherKit token to the context.
* The token is automatically cached and refreshed before expiration.
*
* Usage:
* ```typescript
* import { weatherKitAuth } from "./weather-kit/middleware";
*
* app.use("/weather/*", weatherKitAuth());
*
* app.get("/weather/:lat/:lon", async (c) => {
* const token = c.get("weatherKitToken");
* // use token...
* });
* ```
*/
export function weatherKitAuth(): MiddlewareHandler {
let cache: TokenCache | null = null
const getOrRefreshToken = async (): Promise<string> => {
const now = Math.floor(Date.now() / 1000)
const bufferTime = 300 // 5 minutes buffer before expiration
// Return cached token if still valid
if (cache && cache.expiresAt > now + bufferTime) {
return cache.token
}
// Generate new token
const expiresIn = 3600 // 1 hour
const token = await generateWeatherKitToken({
teamId: process.env.ADP_TEAM_ID,
serviceId: process.env.ADP_SERVICE_ID,
keyId: process.env.ADP_KEY_ID,
privateKeyPath: process.env.ADP_KEY_PATH,
expiresIn,
})
// Cache the token
cache = {
token,
expiresAt: now + expiresIn,
}
return token
}
return async (c, next) => {
const token = await getOrRefreshToken()
c.set("weatherKitToken", token)
await next()
}
}
/**
* Type helper for routes that use the weatherKitAuth middleware.
* Adds type safety for the weatherKitToken context variable.
*/
export type WeatherKitContext = {
Variables: {
weatherKitToken: string
}
}

232
apps/backend/src/weather.ts Normal file
View File

@@ -0,0 +1,232 @@
import { Hono } from "hono"
import { type WeatherKitContext, weatherKitAuth } from "./weather-kit/middleware"
import { generateWeatherDescription } from "./weather-kit/gemini"
import { getCachedDescription, setCachedDescription } from "./weather-kit/cache"
const weather = new Hono<WeatherKitContext>()
// Apply middleware to all weather routes
weather.use("*", weatherKitAuth())
// Current weather + daily forecast (real-time data only)
weather.get("/current/:lat/:lon", async (c) => {
const { lat, lon } = c.req.param()
const token = c.get("weatherKitToken")
try {
// Fetch current weather and daily forecast in one call
const response = await fetch(
`https://weatherkit.apple.com/api/v1/weather/en_US/${lat}/${lon}?dataSets=currentWeather,forecastDaily`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
if (!response.ok) {
const error = await response.text()
return new Response(JSON.stringify({ error: "Failed to fetch weather data", details: error }), {
status: response.status,
headers: { "Content-Type": "application/json" },
})
}
const data = await response.json()
return c.json(data)
} catch (error) {
return c.json({ error: "Internal server error", message: String(error) }, 500)
}
})
// Daily forecast endpoint
weather.get("/forecast/:lat/:lon", async (c) => {
const { lat, lon } = c.req.param()
const token = c.get("weatherKitToken")
try {
const response = await fetch(
`https://weatherkit.apple.com/api/v1/weather/en_US/${lat}/${lon}?dataSets=forecastDaily`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
if (!response.ok) {
return new Response(JSON.stringify({ error: "Failed to fetch forecast" }), {
status: response.status,
headers: { "Content-Type": "application/json" },
})
}
const data = await response.json()
return c.json(data)
} catch (error) {
return c.json({ error: String(error) }, 500)
}
})
// Hourly forecast endpoint
weather.get("/hourly/:lat/:lon", async (c) => {
const { lat, lon } = c.req.param()
const token = c.get("weatherKitToken")
try {
const response = await fetch(
`https://weatherkit.apple.com/api/v1/weather/en_US/${lat}/${lon}?dataSets=forecastHourly`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
if (!response.ok) {
return new Response(JSON.stringify({ error: "Failed to fetch hourly forecast" }), {
status: response.status,
headers: { "Content-Type": "application/json" },
})
}
const data = await response.json()
return c.json(data)
} catch (error) {
return c.json({ error: String(error) }, 500)
}
})
// Availability endpoint - check what datasets are available for a location
weather.get("/availability/:lat/:lon", async (c) => {
const { lat, lon } = c.req.param()
const token = c.get("weatherKitToken")
try {
const url = `https://weatherkit.apple.com/api/v1/availability/${lat}/${lon}`
console.log(`Checking availability: ${url}`)
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
console.log(`Availability response status: ${response.status}`)
if (!response.ok) {
const errorText = await response.text()
console.error(`Availability error:`, errorText)
return c.json({ error: "Failed to check availability", status: response.status }, response.status)
}
const data = await response.json()
console.log(`Availability data:`, JSON.stringify(data, null, 2))
return c.json(data)
} catch (error) {
console.error("Availability exception:", error)
return c.json({ error: String(error) }, 500)
}
})
// Complete weather data (all data sets)
weather.get("/complete/:lat/:lon", async (c) => {
const { lat, lon } = c.req.param()
const token = c.get("weatherKitToken")
try {
const response = await fetch(
`https://weatherkit.apple.com/api/v1/weather/en_US/${lat}/${lon}?dataSets=currentWeather,forecastDaily,forecastHourly`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
if (!response.ok) {
return new Response(JSON.stringify({ error: "Failed to fetch complete weather data" }), {
status: response.status,
headers: { "Content-Type": "application/json" },
})
}
const data = await response.json()
return c.json(data)
} catch (error) {
return c.json({ error: String(error) }, 500)
}
})
// Generate AI weather description (cached for 1 hour)
weather.get("/description/:lat/:lon", async (c) => {
const { lat, lon } = c.req.param()
const token = c.get("weatherKitToken")
try {
// Check cache first
const cached = getCachedDescription(lat, lon)
if (cached) {
return c.json({ description: cached, cached: true })
}
// Fetch current weather and today's forecast
const response = await fetch(
`https://weatherkit.apple.com/api/v1/weather/en_US/${lat}/${lon}?dataSets=currentWeather,forecastDaily`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
if (!response.ok) {
return new Response(
JSON.stringify({ error: "Failed to fetch weather data" }),
{ status: response.status, headers: { "Content-Type": "application/json" } },
)
}
const data = (await response.json()) as any
const current = data.currentWeather
const today = data.forecastDaily?.days?.[0]
if (!current) {
return c.json({ error: "No current weather data available" }, 404)
}
// Determine if it's nighttime (between 8 PM and 6 AM)
const currentHour = new Date().getHours()
const isNighttime = currentHour >= 20 || currentHour < 6
// Get tomorrow's forecast if it's nighttime
const tomorrow = isNighttime ? data.forecastDaily?.days?.[1] : null
// Generate description using Gemini
const description = await generateWeatherDescription({
condition: current.conditionCode,
temperature: current.temperature,
feelsLike: current.temperatureApparent,
humidity: current.humidity,
windSpeed: current.windSpeed,
precipitationChance: today?.precipitationChance,
uvIndex: current.uvIndex,
daytimeCondition: today?.daytimeForecast?.conditionCode,
overnightCondition: today?.overnightForecast?.conditionCode,
isNighttime,
tomorrowHighTemp: tomorrow?.temperatureMax,
tomorrowLowTemp: tomorrow?.temperatureMin,
tomorrowCondition: tomorrow?.conditionCode,
tomorrowPrecipitationChance: tomorrow?.precipitationChance,
})
// Cache the description
setCachedDescription(lat, lon, description)
return c.json({ description, cached: false })
} catch (error) {
return c.json({ error: String(error) }, 500)
}
})
export default weather

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": ["ESNext"],
"moduleResolution": "bundler",
"types": ["@types/bun"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
VITE_API_URL=http://localhost:3000
VITE_DEFAULT_LATITUDE=37.7749
VITE_DEFAULT_LONGITUDE=-122.4194

13
apps/dashboard/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.7",
"jotai": "^2.10.3",
"lucide-react": "^0.546.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^6.0.1"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
},
});