commit 30986e0292f1e51a82f3b317864ef9b025270065 Author: kenneth Date: Fri Oct 24 19:36:05 2025 +0000 initial commit Co-authored-by: Ona 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 0000000..774aa06 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4f2d98 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "monorepo", + "version": "1.0.0", + "private": true, + "workspaces": ["apps/*"], + "scripts": { + "dev": "bun run --elide-lines 0 --filter './apps/*' dev", + "build": "bun run --filter '*' build", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write ." + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4" + }, + "dependencies": { + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.1" + } +} diff --git a/scripts/setup-git.sh b/scripts/setup-git.sh new file mode 100755 index 0000000..c353871 --- /dev/null +++ b/scripts/setup-git.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +# Git setup script +# Sets up user info, email, and credential helpers with Gitea access token + +set -e + +echo "Setting up Git configuration..." + +# Check if required environment variables are set +if [ -z "$GIT_USER" ]; then + echo "Error: GIT_USER environment variable is not set" + exit 1 +fi + +if [ -z "$GIT_EMAIL" ]; then + echo "Error: GIT_EMAIL environment variable is not set" + exit 1 +fi + +# Set user name and email from environment variables +git config --global user.name "$GIT_USER" +git config --global user.email "$GIT_EMAIL" + +# Set up credential helper for HTTPS authentication +git config --global credential.helper store + +# Check if GITEA_ACCESS_TOKEN is set +if [ -z "$GITEA_ACCESS_TOKEN" ]; then + echo "Warning: GITEA_ACCESS_TOKEN environment variable is not set" + echo "You'll need to set this environment variable for automatic authentication" + exit 1 +fi + +# Set up credential store with the access token +# This assumes your Gitea instance is accessible via HTTPS +# Adjust the URL pattern to match your Gitea instance +echo "Setting up credential store..." + +# Create credentials file if it doesn't exist +CREDENTIAL_FILE="$HOME/.git-credentials" +touch "$CREDENTIAL_FILE" +chmod 600 "$CREDENTIAL_FILE" + +# Add Gitea credentials (adjust URL to match your Gitea instance) +# Format: https://username:token@gitea.example.com +# Using the token as both username and password is common for API tokens +echo "https://$GITEA_USERNAME:$GITEA_ACCESS_TOKEN@code.nym.sh" >> "$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 <