From 30986e0292f1e51a82f3b317864ef9b025270065 Mon Sep 17 00:00:00 2001 From: kenneth Date: Fri, 24 Oct 2025 19:36:05 +0000 Subject: [PATCH] initial commit Co-authored-by: Ona --- .devcontainer/Dockerfile | 36 ++ .devcontainer/devcontainer.json | 25 + .gitignore | 9 + README.md | 138 ++++++ apps/backend/package.json | 18 + apps/backend/src/env.d.ts | 9 + apps/backend/src/index.ts | 25 + apps/backend/src/weather-kit/auth.ts | 131 +++++ apps/backend/src/weather-kit/cache.ts | 74 +++ apps/backend/src/weather-kit/gemini.ts | 145 ++++++ apps/backend/src/weather-kit/middleware.ts | 71 +++ apps/backend/src/weather.ts | 232 +++++++++ apps/backend/tsconfig.json | 18 + apps/dashboard/.env.example | 3 + apps/dashboard/index.html | 13 + apps/dashboard/package.json | 28 ++ apps/dashboard/postcss.config.js | 6 + apps/dashboard/src/App.tsx | 198 ++++++++ apps/dashboard/src/components/lib/cn.ts | 8 + apps/dashboard/src/env.d.ts | 11 + apps/dashboard/src/index.css | 12 + apps/dashboard/src/main.tsx | 15 + apps/dashboard/src/weather.ts | 530 +++++++++++++++++++++ apps/dashboard/tailwind.config.js | 11 + apps/dashboard/tsconfig.json | 21 + apps/dashboard/tsconfig.node.json | 11 + apps/dashboard/vite.config.ts | 10 + biome.json | 42 ++ bun.lockb | Bin 0 -> 95000 bytes package.json | 20 + scripts/setup-git.sh | 151 ++++++ 31 files changed, 2021 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/backend/package.json create mode 100644 apps/backend/src/env.d.ts create mode 100644 apps/backend/src/index.ts create mode 100644 apps/backend/src/weather-kit/auth.ts create mode 100644 apps/backend/src/weather-kit/cache.ts create mode 100644 apps/backend/src/weather-kit/gemini.ts create mode 100644 apps/backend/src/weather-kit/middleware.ts create mode 100644 apps/backend/src/weather.ts create mode 100644 apps/backend/tsconfig.json create mode 100644 apps/dashboard/.env.example create mode 100644 apps/dashboard/index.html create mode 100644 apps/dashboard/package.json create mode 100644 apps/dashboard/postcss.config.js create mode 100644 apps/dashboard/src/App.tsx create mode 100644 apps/dashboard/src/components/lib/cn.ts create mode 100644 apps/dashboard/src/env.d.ts create mode 100644 apps/dashboard/src/index.css create mode 100644 apps/dashboard/src/main.tsx create mode 100644 apps/dashboard/src/weather.ts create mode 100644 apps/dashboard/tailwind.config.js create mode 100644 apps/dashboard/tsconfig.json create mode 100644 apps/dashboard/tsconfig.node.json create mode 100644 apps/dashboard/vite.config.ts create mode 100644 biome.json create mode 100755 bun.lockb create mode 100644 package.json create mode 100755 scripts/setup-git.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..2a61c60 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,36 @@ +FROM mcr.microsoft.com/devcontainers/javascript-node:24 + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + ca-certificates \ + gnupg \ + lsb-release \ + && rm -rf /var/lib/apt/lists/* + +# Install lazygit +RUN LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep -Po '"tag_name": "v\K[^"]*') \ + && curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" \ + && tar xf lazygit.tar.gz lazygit \ + && install lazygit /usr/local/bin \ + && rm lazygit.tar.gz lazygit + +# Install Bun as the node user +USER node +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/home/node/.bun/bin:$PATH" + +# Switch back to root for any remaining setup +USER root + +# Ensure the node user owns their home directory +RUN chown -R node:node /home/node + +# Set the default user back to node +USER node + +# Set working directory +WORKDIR /workspace + +# Verify installations +RUN bun --version diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..bfd9419 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +{ + "name": "Monorepo - Bun + React", + "build": { + "context": ".", + "dockerfile": "Dockerfile" + }, + "customizations": { + "vscode": { + "extensions": [ + "biomejs.biome", + "oven.bun-vscode" + ], + "settings": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "quickfix.biome": "explicit", + "source.organizeImports.biome": "explicit" + } + } + } + }, + "forwardPorts": [3000, 5173], + "postCreateCommand": "bun install && ./scripts/setup-git.sh" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c108d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +dist +.DS_Store +*.log +.env +.env.local +.turbo +coverage +*.p8 diff --git a/README.md b/README.md new file mode 100644 index 0000000..df3d67f --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Monorepo with Bun + +A modern monorepo setup using Bun as the package manager, featuring a Hono backend and React dashboard. + +## Project Structure + +``` +. +├── apps/ +│ ├── backend/ # Bun + Hono API server +│ └── dashboard/ # Vite + React frontend +├── .devcontainer/ # Dev Container configuration +├── biome.json # Biome.js formatter and linter config +└── package.json # Root workspace configuration +``` + +## Tech Stack + +### Backend (`apps/backend`) +- **Runtime**: Bun +- **Framework**: Hono +- **Port**: 3000 + +### Dashboard (`apps/dashboard`) +- **Build Tool**: Vite +- **Framework**: React 18 +- **Styling**: Tailwind CSS +- **State Management**: Jotai +- **Data Fetching**: TanStack Query (React Query) +- **Port**: 5173 + +### Development Tools +- **Package Manager**: Bun +- **Formatter/Linter**: Biome.js +- **Dev Container**: Custom Dockerfile with Bun and Node.js + +## Getting Started + +### Prerequisites +- Docker (for Dev Container) +- Or Bun installed locally + +### Using Dev Container (Recommended) +1. Open this project in VS Code +2. When prompted, click "Reopen in Container" +3. Wait for the container to build and dependencies to install +4. Start developing! + +### Local Development +If you have Bun installed locally: + +```bash +# Install dependencies +bun install + +# Run both apps in development mode +bun run dev + +# Or run individually: +cd apps/backend && bun run dev +cd apps/dashboard && bun run dev +``` + +## Available Scripts + +### Root Level +- `bun run dev` - Start all apps in development mode +- `bun run build` - Build all apps +- `bun run lint` - Lint all code with Biome +- `bun run lint:fix` - Lint and fix issues +- `bun run format` - Format all code with Biome + +### Backend (`apps/backend`) +- `bun run dev` - Start backend in watch mode (port 3000) +- `bun run build` - Build backend for production +- `bun run start` - Run production build + +### Dashboard (`apps/dashboard`) +- `bun run dev` - Start Vite dev server (port 5173) +- `bun run build` - Build for production +- `bun run preview` - Preview production build + +## API Endpoints + +### Backend +- `GET /` - Welcome message +- `GET /api/health` - Health check endpoint + +## Features + +### Backend +- ✅ Fast Bun runtime +- ✅ Lightweight Hono framework +- ✅ CORS enabled +- ✅ Request logging +- ✅ TypeScript support + +### Dashboard +- ✅ Modern React 18 with TypeScript +- ✅ Tailwind CSS for styling +- ✅ Jotai for state management (counter example) +- ✅ TanStack Query for API calls (health check example) +- ✅ Hot Module Replacement (HMR) + +## Development Container + +The project includes a Dev Container configuration that provides: +- Bun runtime (latest) +- Node.js 20 (for compatibility) +- Git, curl, wget, and build tools +- VS Code extensions: + - Biome.js (formatter/linter) + - Bun for VS Code +- Auto-formatting on save +- Port forwarding for backend (3000) and dashboard (5173) + +## Code Quality + +This project uses Biome.js for: +- Code formatting (consistent style) +- Linting (catch errors and enforce best practices) +- Import organization + +Configuration is in `biome.json` at the root level. + +## Building for Production + +```bash +# Build all apps +bun run build + +# Backend output: apps/backend/dist/ +# Dashboard output: apps/dashboard/dist/ +``` + +## License + +MIT diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..abda10b --- /dev/null +++ b/apps/backend/package.json @@ -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" + } +} diff --git a/apps/backend/src/env.d.ts b/apps/backend/src/env.d.ts new file mode 100644 index 0000000..a2827f3 --- /dev/null +++ b/apps/backend/src/env.d.ts @@ -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 + } +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 0000000..f064536 --- /dev/null +++ b/apps/backend/src/index.ts @@ -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, +} diff --git a/apps/backend/src/weather-kit/auth.ts b/apps/backend/src/weather-kit/auth.ts new file mode 100644 index 0000000..14878c0 --- /dev/null +++ b/apps/backend/src/weather-kit/auth.ts @@ -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 { + 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 { + // 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, + payload: Record, + privateKey: CryptoKey, +): Promise { + // 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, "") +} diff --git a/apps/backend/src/weather-kit/cache.ts b/apps/backend/src/weather-kit/cache.ts new file mode 100644 index 0000000..dd139ae --- /dev/null +++ b/apps/backend/src/weather-kit/cache.ts @@ -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(); +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 + })), + }; +} diff --git a/apps/backend/src/weather-kit/gemini.ts b/apps/backend/src/weather-kit/gemini.ts new file mode 100644 index 0000000..8688d87 --- /dev/null +++ b/apps/backend/src/weather-kit/gemini.ts @@ -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 { + 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:`; +} diff --git a/apps/backend/src/weather-kit/middleware.ts b/apps/backend/src/weather-kit/middleware.ts new file mode 100644 index 0000000..508e460 --- /dev/null +++ b/apps/backend/src/weather-kit/middleware.ts @@ -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 => { + 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 + } +} diff --git a/apps/backend/src/weather.ts b/apps/backend/src/weather.ts new file mode 100644 index 0000000..d98190e --- /dev/null +++ b/apps/backend/src/weather.ts @@ -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() + +// 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 diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..bcd211b --- /dev/null +++ b/apps/backend/tsconfig.json @@ -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"] +} diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example new file mode 100644 index 0000000..e7da9eb --- /dev/null +++ b/apps/dashboard/.env.example @@ -0,0 +1,3 @@ +VITE_API_URL=http://localhost:3000 +VITE_DEFAULT_LATITUDE=37.7749 +VITE_DEFAULT_LONGITUDE=-122.4194 diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 0000000..4a8433d --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Dashboard + + +
+ + + diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 0000000..c27daea --- /dev/null +++ b/apps/dashboard/package.json @@ -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" + } +} diff --git a/apps/dashboard/postcss.config.js b/apps/dashboard/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx new file mode 100644 index 0000000..488b7cf --- /dev/null +++ b/apps/dashboard/src/App.tsx @@ -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 ( +
+ + +
+ ) +} + +function Tile({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+
+
+
+
+ {children} +
+ ) +} + +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 ( + +

{formattedDate}

+

{formattedTime}

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

Loading weather

+
+ ) + } + + if (error || !currentWeatherData?.currentWeather) { + return ( + +

Error loading weather

+

{error?.message ?? "Unknown error"}

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

+ H:{highTemp}° +

+

+ L:{lowTemp}° +

+
+
+ {Array.from({ length: 24 }).map((_, index) => { + if (index === highlightIndexStart) { + return ( +
+
+ key={index} + className={cn("w-10 bg-teal-400 h-[2px]")} + /> +
+

{temperature}°

+ +
+
+ ) + } + return ( +
+ key={index} + className={cn( + "w-4", + index >= highlightIndexStart + ? "bg-teal-400 w-8 h-[2px]" + : "bg-neutral-400 w-4 h-[1px]", + )} + /> + ) + })} +
+
+

+ {weatherDescriptionContent} +

+
+
+ + ) +} + +export default App diff --git a/apps/dashboard/src/components/lib/cn.ts b/apps/dashboard/src/components/lib/cn.ts new file mode 100644 index 0000000..1608cec --- /dev/null +++ b/apps/dashboard/src/components/lib/cn.ts @@ -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 diff --git a/apps/dashboard/src/env.d.ts b/apps/dashboard/src/env.d.ts new file mode 100644 index 0000000..9be35e0 --- /dev/null +++ b/apps/dashboard/src/env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_DEFAULT_LATITUDE: string; + readonly VITE_DEFAULT_LONGITUDE: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/dashboard/src/index.css b/apps/dashboard/src/index.css new file mode 100644 index 0000000..634141e --- /dev/null +++ b/apps/dashboard/src/index.css @@ -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; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx new file mode 100644 index 0000000..876e954 --- /dev/null +++ b/apps/dashboard/src/main.tsx @@ -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( + + + + + , +); diff --git a/apps/dashboard/src/weather.ts b/apps/dashboard/src/weather.ts new file mode 100644 index 0000000..774f833 --- /dev/null +++ b/apps/dashboard/src/weather.ts @@ -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 = { + 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 = { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 + }) +} diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/apps/dashboard/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/apps/dashboard/tsconfig.json @@ -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" }] +} diff --git a/apps/dashboard/tsconfig.node.json b/apps/dashboard/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/apps/dashboard/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts new file mode 100644 index 0000000..7322405 --- /dev/null +++ b/apps/dashboard/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + }, +}); diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..709dc73 --- /dev/null +++ b/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "ignore": ["node_modules", "dist", ".git"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 4, + "lineWidth": 120 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useConst": "warn", + "noNonNullAssertion": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "asNeeded", + "arrowParentheses": "always" + } + } +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..774aa0605d505440cfe637559f00863b3f979ed9 GIT binary patch literal 95000 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 "$CREDENTIAL_FILE" + +# Additional Git configurations for better experience +git config --global init.defaultBranch main +git config --global pull.rebase false +git config --global push.default simple +git config --global core.autocrlf input + +echo "Git configuration completed successfully!" +echo "User: $(git config --global user.name)" +echo "Email: $(git config --global user.email)" +echo "Credential helper: $(git config --global credential.helper)" + +# Verify setup by testing credential access (optional) +echo "Git setup complete. Credentials are stored for automatic authentication." + +# GPG key setup +echo "" +echo "Setting up GPG key for commit signing..." + +if [ -n "$GPG_PRIVATE_KEY" ]; then + echo "Importing GPG private key from environment variable..." + + # Import the private key with passphrase if provided + if [ -n "$GPG_PRIVATE_KEY_PASSPHRASE" ]; then + echo "Using provided passphrase for key import..." + # Create temporary file for the key + TEMP_KEY_FILE=$(mktemp) + echo -e "$GPG_PRIVATE_KEY" > "$TEMP_KEY_FILE" + chmod 600 "$TEMP_KEY_FILE" + gpg --batch --yes --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" --import "$TEMP_KEY_FILE" + rm -f "$TEMP_KEY_FILE" + else + echo "No passphrase provided, importing key..." + # Create temporary file for the key + TEMP_KEY_FILE=$(mktemp) + echo -e "$GPG_PRIVATE_KEY" > "$TEMP_KEY_FILE" + chmod 600 "$TEMP_KEY_FILE" + gpg --batch --import "$TEMP_KEY_FILE" + rm -f "$TEMP_KEY_FILE" + fi + + if [ $? -eq 0 ]; then + echo "GPG key imported successfully!" + + # Get the key ID + KEY_ID=$(gpg --list-secret-keys --keyid-format=long "$GIT_EMAIL" | grep 'sec' | cut -d'/' -f2 | cut -d' ' -f1) + + if [ -n "$KEY_ID" ]; then + # Configure Git to use the imported key + git config --global user.signingkey "$KEY_ID" + git config --global commit.gpgsign true + git config --global gpg.program gpg + + echo "Git configured to use GPG key: $KEY_ID" + + # Set ultimate trust for the imported key (since it's our own key) + if [ -n "$GPG_PRIVATE_KEY_PASSPHRASE" ]; then + echo -e "5\ny\n" | gpg --batch --command-fd 0 --expert --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" --edit-key "$KEY_ID" trust quit 2>/dev/null + else + echo -e "5\ny\n" | gpg --batch --command-fd 0 --expert --edit-key "$KEY_ID" trust quit 2>/dev/null + fi + + # Configure GPG agent for passphrase caching if passphrase is provided + if [ -n "$GPG_PRIVATE_KEY_PASSPHRASE" ]; then + echo "Configuring GPG agent for passphrase caching..." + mkdir -p ~/.gnupg + cat > ~/.gnupg/gpg-agent.conf << EOF +default-cache-ttl 28800 +max-cache-ttl 28800 +pinentry-program /usr/bin/pinentry-curses +EOF + # Restart GPG agent + gpg-connect-agent reloadagent /bye 2>/dev/null || true + fi + + echo "GPG key setup complete!" + else + echo "Warning: Could not find key ID for $GIT_EMAIL" + fi + else + echo "Error: Failed to import GPG key" + fi +else + echo "GPG_PRIVATE_KEY environment variable not set." + echo "To generate a new GPG key for commit signing, run:" + echo "gpg --batch --full-generate-key <