initial commit

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

36
.devcontainer/Dockerfile Normal file
View File

@@ -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

View File

@@ -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"
}

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.DS_Store
*.log
.env
.env.local
.turbo
coverage
*.p8

138
README.md Normal file
View File

@@ -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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

198
apps/dashboard/src/App.tsx Normal file
View File

@@ -0,0 +1,198 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import cn from "./components/lib/cn"
import {
DEFAULT_LATITUDE,
DEFAULT_LONGITUDE,
currentWeatherQuery,
dailyForecastQuery,
getWeatherIcon,
weatherDescriptionQuery,
} from "./weather"
function App() {
return (
<div className="h-screen bg-black gap-4 text-neutral-200 grid grid-cols-4 grid-rows-5 p-4">
<DateTimeTile />
<WeatherTile />
</div>
)
}
function Tile({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("relative bg-neutral-900 flex flex-col justify-end items-start", className)}>
<div className="absolute top-0 left-0 w-4 h-[1px] bg-neutral-200" />
<div className="absolute top-0 left-0 w-[1px] h-4 bg-neutral-200" />
<div className="absolute bottom-0 right-0 w-4 h-[1px] bg-neutral-200" />
<div className="absolute bottom-0 right-0 w-[1px] h-4 bg-neutral-200" />
{children}
</div>
)
}
function DateTimeTile() {
const [time, setTime] = useState(new Date())
const formattedDate = time.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})
const formattedTime = time.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
return () => clearInterval(interval)
}, [])
return (
<Tile className="col-start-1 row-start-1 col-span-2 row-span-3 p-6">
<p className="text-4xl mb-2 font-extralight">{formattedDate}</p>
<p className="text-8xl font-bold">{formattedTime}</p>
</Tile>
)
}
function WeatherTile() {
const {
data: currentWeatherData,
isLoading: isLoadingCurrentWeather,
error: errorCurrentWeather,
} = useQuery({
...currentWeatherQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
refetchInterval: 5 * 60 * 1000, // 5 minutes
refetchIntervalInBackground: true,
})
const {
data: dailyForecastData,
isLoading: isLoadingDailyForecast,
error: errorDailyForecast,
} = useQuery({
...dailyForecastQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
refetchInterval: 5 * 60 * 1000, // 5 minutes
refetchIntervalInBackground: true,
})
const {
data: weatherDescriptionData,
isLoading: isLoadingWeatherDescription,
error: errorWeatherDescription,
} = useQuery({
...weatherDescriptionQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
refetchInterval: 60 * 60 * 1000, // 1 hour
refetchIntervalInBackground: true,
})
const isLoading = isLoadingCurrentWeather || isLoadingDailyForecast
const error = errorCurrentWeather || errorDailyForecast
if (isLoading) {
return (
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-row justify-center items-center p-8">
<p className="text-2xl font-light animate-pulse">Loading weather</p>
</Tile>
)
}
if (error || !currentWeatherData?.currentWeather) {
return (
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-col justify-center items-center p-8">
<p className="text-2xl text-red-400 font-light">Error loading weather</p>
<p className=" text-neutral-400">{error?.message ?? "Unknown error"}</p>
</Tile>
)
}
const currentWeather = currentWeatherData.currentWeather
const temperature = Math.round(currentWeather.temperature)
const lowTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMin ?? 0)
const highTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMax ?? 0)
const percentage = lowTemp && highTemp ? (temperature - lowTemp) / (highTemp - lowTemp) : 0
const highlightIndexStart = Math.floor((1 - percentage) * 23)
const WeatherIcon = getWeatherIcon(currentWeather.conditionCode)
let weatherDescriptionContent: string
if (isLoadingWeatherDescription) {
weatherDescriptionContent = "Loading weather description"
} else if (errorWeatherDescription) {
weatherDescriptionContent = `Error: ${errorWeatherDescription.message}`
} else if (!weatherDescriptionData?.description) {
weatherDescriptionContent = "No weather description available"
} else {
weatherDescriptionContent = weatherDescriptionData.description
}
return (
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-row justify-start items-center p-8">
<div className="flex flex-row h-full items-center space-x-2 flex-[2]">
<div className="flex flex-col justify-between items-end h-full">
<p className={cn("leading-none text-sm text-neutral-400", temperature === highTemp && "invisible")}>
H:{highTemp}°
</p>
<p className={cn("leading-none text-sm text-neutral-400", temperature === lowTemp && "invisible")}>
L:{lowTemp}°
</p>
</div>
<div className="flex flex-col space-y-2 flex-[1]">
{Array.from({ length: 24 }).map((_, index) => {
if (index === highlightIndexStart) {
return (
<div className="relative w-fit">
<div
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={index}
className={cn("w-10 bg-teal-400 h-[2px]")}
/>
<div
className={cn(
"absolute flex flex-row items-center space-x-1 top-0 right-0 bg-teal-400 text-neutral-900 px-2 py-1 text-2xl font-bold rounded-r-sm translate-x-[calc(100%-1px)]",
percentage < 0.3
? "-translate-y-[calc(100%-2px)] rounded-tl-sm"
: "rounded-bl-sm",
)}
>
<p className="leading-none translate-y-px">{temperature}°</p>
<WeatherIcon className="size-6" strokeWidth={3} />
</div>
</div>
)
}
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={index}
className={cn(
"w-4",
index >= highlightIndexStart
? "bg-teal-400 w-8 h-[2px]"
: "bg-neutral-400 w-4 h-[1px]",
)}
/>
)
})}
</div>
<div className="flex flex-col justify-start h-full space-y-2 flex-[3]">
<p
className={cn("text-3xl leading-none tracking-tight font-light", {
"text-red-400": errorWeatherDescription,
"animate-pulse": isLoadingWeatherDescription,
})}
>
{weatherDescriptionContent}
</p>
</div>
</div>
</Tile>
)
}
export default App

View File

@@ -0,0 +1,8 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export default cn

11
apps/dashboard/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_DEFAULT_LATITUDE: string;
readonly VITE_DEFAULT_LONGITUDE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,530 @@
/**
* WeatherKit REST API TypeScript Types
* Based on Apple's WeatherKit REST API documentation
* https://developer.apple.com/documentation/weatherkitrestapi/
*/
import { queryOptions } from "@tanstack/react-query"
import type { LucideIcon } from "lucide-react"
import {
Cloud,
CloudDrizzle,
CloudFog,
CloudHail,
CloudLightning,
CloudMoon,
CloudRain,
CloudSnow,
CloudSun,
Snowflake,
Sun,
Thermometer,
ThermometerSnowflake,
ThermometerSun,
Tornado,
Waves,
Wind,
} from "lucide-react"
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"
export const DEFAULT_LATITUDE = Number(import.meta.env.VITE_DEFAULT_LATITUDE) || 37.7749
export const DEFAULT_LONGITUDE = Number(import.meta.env.VITE_DEFAULT_LONGITUDE) || -122.4194
// Main Weather Response
export interface WeatherResponse {
currentWeather?: CurrentWeather
forecastDaily?: DailyForecast
forecastHourly?: HourlyForecast
forecastNextHour?: NextHourForecast
weatherAlerts?: WeatherAlertCollection
aiDescription?: string | null
}
// Current Weather
export interface CurrentWeather {
name: "CurrentWeather"
metadata: WeatherMetadata
asOf: string // ISO 8601 date
cloudCover: number // 0-1
cloudCoverLowAltPct?: number
cloudCoverMidAltPct?: number
cloudCoverHighAltPct?: number
conditionCode: WeatherCondition
daylight: boolean
humidity: number // 0-1
precipitationIntensity: number // mm/hr
pressure: number // millibars
pressureTrend: PressureTrend
temperature: number // celsius
temperatureApparent: number // celsius
temperatureDewPoint: number // celsius
uvIndex: number
visibility: number // meters
windDirection: number // degrees
windGust: number // km/h
windSpeed: number // km/h
}
// Daily Forecast
export interface DailyForecast {
name: "DailyForecast"
metadata: WeatherMetadata
days: DayWeatherConditions[]
}
export interface DayWeatherConditions {
forecastStart: string // ISO 8601 date
forecastEnd: string // ISO 8601 date
conditionCode: WeatherCondition
maxUvIndex: number
moonPhase: MoonPhase
moonrise?: string // ISO 8601 date
moonset?: string // ISO 8601 date
precipitationAmount: number // mm
precipitationChance: number // 0-1
precipitationType: PrecipitationType
snowfallAmount: number // cm
solarMidnight?: string // ISO 8601 date
solarNoon?: string // ISO 8601 date
sunrise?: string // ISO 8601 date
sunriseCivil?: string // ISO 8601 date
sunriseNautical?: string // ISO 8601 date
sunriseAstronomical?: string // ISO 8601 date
sunset?: string // ISO 8601 date
sunsetCivil?: string // ISO 8601 date
sunsetNautical?: string // ISO 8601 date
sunsetAstronomical?: string // ISO 8601 date
temperatureMax: number // celsius
temperatureMin: number // celsius
daytimeForecast?: DayPartForecast
overnightForecast?: DayPartForecast
restOfDayForecast?: DayPartForecast
}
export interface DayPartForecast {
forecastStart: string // ISO 8601 date
forecastEnd: string // ISO 8601 date
cloudCover: number // 0-1
conditionCode: WeatherCondition
humidity: number // 0-1
precipitationAmount: number // mm
precipitationChance: number // 0-1
precipitationType: PrecipitationType
snowfallAmount: number // cm
windDirection: number // degrees
windSpeed: number // km/h
}
// Hourly Forecast
export interface HourlyForecast {
name: "HourlyForecast"
metadata: WeatherMetadata
hours: HourWeatherConditions[]
}
export interface HourWeatherConditions {
forecastStart: string // ISO 8601 date
cloudCover: number // 0-1
cloudCoverLowAltPct?: number
cloudCoverMidAltPct?: number
cloudCoverHighAltPct?: number
conditionCode: WeatherCondition
daylight: boolean
humidity: number // 0-1
precipitationAmount: number // mm
precipitationIntensity: number // mm/hr
precipitationChance: number // 0-1
precipitationType: PrecipitationType
pressure: number // millibars
pressureTrend: PressureTrend
snowfallIntensity?: number // mm/hr
snowfallAmount?: number // cm
temperature: number // celsius
temperatureApparent: number // celsius
temperatureDewPoint: number // celsius
uvIndex: number
visibility: number // meters
windDirection: number // degrees
windGust: number // km/h
windSpeed: number // km/h
}
// Next Hour Forecast (Minute-by-minute precipitation)
export interface NextHourForecast {
name: "NextHourForecast"
metadata: WeatherMetadata
forecastStart: string // ISO 8601 date
forecastEnd: string // ISO 8601 date
minutes: MinuteWeatherConditions[]
summary: NextHourForecastSummary[]
}
export interface MinuteWeatherConditions {
startTime: string // ISO 8601 date
precipitationChance: number // 0-1
precipitationIntensity: number // mm/hr
}
export interface NextHourForecastSummary {
startTime: string // ISO 8601 date
condition: PrecipitationCondition
precipitationChance: number // 0-1
precipitationIntensity: number // mm/hr
}
// Weather Alerts
export interface WeatherAlertCollection {
name: "WeatherAlertCollection"
metadata: WeatherMetadata
alerts: WeatherAlert[]
detailsUrl: string
}
export interface WeatherAlert {
name: "WeatherAlert"
id: string
areaId?: string
areaName?: string
countryCode: string
description: string
effectiveTime: string // ISO 8601 date
expireTime: string // ISO 8601 date
issuedTime: string // ISO 8601 date
eventOnsetTime?: string // ISO 8601 date
eventEndTime?: string // ISO 8601 date
severity: AlertSeverity
source: string
urgency: AlertUrgency
certainty: AlertCertainty
importance?: AlertImportance
responses?: AlertResponse[]
detailsUrl: string
}
// Metadata
export interface WeatherMetadata {
attributionURL: string
expireTime: string // ISO 8601 date
latitude: number
longitude: number
readTime: string // ISO 8601 date
reportedTime: string // ISO 8601 date
units: "m" | "e" // metric or imperial
version: number
sourceType?: string
}
// Enums and Types
export type WeatherCondition =
| "Clear"
| "Cloudy"
| "Dust"
| "Fog"
| "Haze"
| "MostlyClear"
| "MostlyCloudy"
| "PartlyCloudy"
| "ScatteredThunderstorms"
| "Smoke"
| "Breezy"
| "Windy"
| "Drizzle"
| "HeavyRain"
| "Rain"
| "Showers"
| "Flurries"
| "HeavySnow"
| "MixedRainAndSleet"
| "MixedRainAndSnow"
| "MixedRainfall"
| "MixedSnowAndSleet"
| "ScatteredShowers"
| "ScatteredSnowShowers"
| "Sleet"
| "Snow"
| "SnowShowers"
| "Blizzard"
| "BlowingSnow"
| "FreezingDrizzle"
| "FreezingRain"
| "Frigid"
| "Hail"
| "Hot"
| "Hurricane"
| "IsolatedThunderstorms"
| "SevereThunderstorm"
| "Thunderstorm"
| "Tornado"
| "TropicalStorm"
export type PrecipitationType = "clear" | "precipitation" | "rain" | "snow" | "sleet" | "hail" | "mixed"
export type PrecipitationCondition = "clear" | "precipitation"
export type PressureTrend = "rising" | "falling" | "steady"
export type MoonPhase =
| "new"
| "waxingCrescent"
| "firstQuarter"
| "waxingGibbous"
| "full"
| "waningGibbous"
| "lastQuarter"
| "waningCrescent"
export type AlertSeverity = "extreme" | "severe" | "moderate" | "minor" | "unknown"
export type AlertUrgency = "immediate" | "expected" | "future" | "past" | "unknown"
export type AlertCertainty = "observed" | "likely" | "possible" | "unlikely" | "unknown"
export type AlertImportance = "high" | "normal" | "low"
export type AlertResponse =
| "shelter"
| "evacuate"
| "prepare"
| "execute"
| "avoid"
| "monitor"
| "assess"
| "allClear"
| "none"
// Helper function to format temperature
export function formatTemperature(celsius: number, unit: "C" | "F" = "C"): string {
if (unit === "F") {
return `${Math.round((celsius * 9) / 5 + 32)}°F`
}
return `${Math.round(celsius)}°C`
}
// Helper function to format wind speed
export function formatWindSpeed(kmh: number, unit: "kmh" | "mph" = "kmh"): string {
if (unit === "mph") {
return `${Math.round(kmh * 0.621371)} mph`
}
return `${Math.round(kmh)} km/h`
}
// Helper function to format precipitation
export function formatPrecipitation(mm: number, unit: "mm" | "in" = "mm"): string {
if (unit === "in") {
return `${(mm * 0.0393701).toFixed(2)} in`
}
return `${mm.toFixed(1)} mm`
}
// Helper function to get today's high/low from daily forecast
export function getTodayHighLow(forecast?: DailyForecast): {
high: number | null
low: number | null
} {
if (!forecast?.days || forecast.days.length === 0) {
return { high: null, low: null }
}
const today = forecast.days[0]
return {
high: today.temperatureMax,
low: today.temperatureMin,
}
}
// Weather condition to Lucide icon mapping
export const weatherConditionIcons: Record<WeatherCondition, LucideIcon> = {
Clear: Sun,
MostlyClear: CloudSun,
PartlyCloudy: CloudSun,
MostlyCloudy: Cloud,
Cloudy: Cloud,
Fog: CloudFog,
Haze: CloudFog,
Smoke: CloudFog,
Dust: Wind,
Breezy: Wind,
Windy: Wind,
Drizzle: CloudDrizzle,
Rain: CloudRain,
Showers: CloudRain,
ScatteredShowers: CloudRain,
HeavyRain: CloudRain,
Flurries: CloudSnow,
Snow: CloudSnow,
SnowShowers: CloudSnow,
ScatteredSnowShowers: CloudSnow,
HeavySnow: Snowflake,
Blizzard: Snowflake,
BlowingSnow: Snowflake,
Sleet: CloudSnow,
MixedRainAndSleet: CloudSnow,
MixedRainAndSnow: CloudSnow,
MixedRainfall: CloudRain,
MixedSnowAndSleet: CloudSnow,
FreezingDrizzle: CloudSnow,
FreezingRain: CloudSnow,
Hail: CloudHail,
Thunderstorm: CloudLightning,
IsolatedThunderstorms: CloudLightning,
ScatteredThunderstorms: CloudLightning,
SevereThunderstorm: CloudLightning,
Tornado: Tornado,
TropicalStorm: Waves,
Hurricane: Waves,
Hot: ThermometerSun,
Frigid: ThermometerSnowflake,
}
// Helper function to get weather icon for a condition
export function getWeatherIcon(condition: WeatherCondition): LucideIcon {
return weatherConditionIcons[condition] || Cloud
}
// Helper function to get condition icon/description
export function getConditionDescription(condition: WeatherCondition): string {
const descriptions: Record<WeatherCondition, string> = {
Clear: "Clear",
Cloudy: "Cloudy",
Dust: "Dust",
Fog: "Fog",
Haze: "Haze",
MostlyClear: "Mostly Clear",
MostlyCloudy: "Mostly Cloudy",
PartlyCloudy: "Partly Cloudy",
ScatteredThunderstorms: "Scattered Thunderstorms",
Smoke: "Smoke",
Breezy: "Breezy",
Windy: "Windy",
Drizzle: "Drizzle",
HeavyRain: "Heavy Rain",
Rain: "Rain",
Showers: "Showers",
Flurries: "Flurries",
HeavySnow: "Heavy Snow",
MixedRainAndSleet: "Mixed Rain and Sleet",
MixedRainAndSnow: "Mixed Rain and Snow",
MixedRainfall: "Mixed Rainfall",
MixedSnowAndSleet: "Mixed Snow and Sleet",
ScatteredShowers: "Scattered Showers",
ScatteredSnowShowers: "Scattered Snow Showers",
Sleet: "Sleet",
Snow: "Snow",
SnowShowers: "Snow Showers",
Blizzard: "Blizzard",
BlowingSnow: "Blowing Snow",
FreezingDrizzle: "Freezing Drizzle",
FreezingRain: "Freezing Rain",
Frigid: "Frigid",
Hail: "Hail",
Hot: "Hot",
Hurricane: "Hurricane",
IsolatedThunderstorms: "Isolated Thunderstorms",
SevereThunderstorm: "Severe Thunderstorm",
Thunderstorm: "Thunderstorm",
Tornado: "Tornado",
TropicalStorm: "Tropical Storm",
}
return descriptions[condition] || condition
}
// TanStack Query Options
/**
* Query options for fetching current weather + daily forecast + AI description
* This is a combined endpoint that returns all three in one API call
*/
export function currentWeatherQuery(lat: number, lon: number) {
return queryOptions({
queryKey: ["weather", "current", lat, lon],
queryFn: async (): Promise<WeatherResponse> => {
const response = await fetch(`${API_BASE_URL}/api/weather/current/${lat}/${lon}`)
if (!response.ok) {
throw new Error("Failed to fetch current weather")
}
return response.json()
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
})
}
/**
* Query options for fetching daily forecast
*/
export function dailyForecastQuery(lat: number, lon: number) {
return queryOptions({
queryKey: ["weather", "forecast", "daily", lat, lon],
queryFn: async (): Promise<WeatherResponse> => {
const response = await fetch(`${API_BASE_URL}/api/weather/forecast/${lat}/${lon}`)
if (!response.ok) {
throw new Error("Failed to fetch daily forecast")
}
return response.json()
},
staleTime: 30 * 60 * 1000, // 30 minutes
gcTime: 60 * 60 * 1000, // 1 hour
})
}
/**
* Query options for fetching hourly forecast
*/
export function hourlyForecastQuery(lat: number, lon: number) {
return queryOptions({
queryKey: ["weather", "forecast", "hourly", lat, lon],
queryFn: async (): Promise<WeatherResponse> => {
const response = await fetch(`${API_BASE_URL}/api/weather/hourly/${lat}/${lon}`)
if (!response.ok) {
throw new Error("Failed to fetch hourly forecast")
}
return response.json()
},
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
})
}
/**
* Query options for fetching complete weather data (all data sets)
*/
export function completeWeatherQuery(lat: number, lon: number) {
return queryOptions({
queryKey: ["weather", "complete", lat, lon],
queryFn: async (): Promise<WeatherResponse> => {
const response = await fetch(`${API_BASE_URL}/api/weather/complete/${lat}/${lon}`)
if (!response.ok) {
throw new Error("Failed to fetch complete weather data")
}
return response.json()
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
})
}
// AI Weather Description Response
export interface WeatherDescriptionResponse {
description: string
cached: boolean
}
/**
* Query options for fetching AI-generated weather description
* Backend caches descriptions for 1 hour per location
*/
export function weatherDescriptionQuery(lat: number, lon: number) {
return queryOptions({
queryKey: ["weather", "description", lat, lon],
queryFn: async (): Promise<WeatherDescriptionResponse> => {
const response = await fetch(`${API_BASE_URL}/api/weather/description/${lat}/${lon}`)
if (!response.ok) {
throw new Error("Failed to fetch weather description")
}
return response.json()
},
staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache)
gcTime: 2 * 60 * 60 * 1000, // 2 hours
})
}

View File

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

View File

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

View File

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

View File

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

42
biome.json Normal file
View File

@@ -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"
}
}
}

BIN
bun.lockb Executable file

Binary file not shown.

20
package.json Normal file
View File

@@ -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"
}
}

151
scripts/setup-git.sh Executable file
View File

@@ -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 <<EOF"
echo "%echo Generating GPG key for $GIT_USER"
echo "Key-Type: RSA"
echo "Key-Length: 4096"
echo "Subkey-Type: RSA"
echo "Subkey-Length: 4096"
echo "Name-Real: $GIT_USER"
echo "Name-Email: $GIT_EMAIL"
echo "Expire-Date: 2y"
echo "Passphrase: "
echo "%commit"
echo "%echo GPG key generation complete"
echo "EOF"
echo ""
echo "After generating the key, configure Git to use it:"
echo "git config --global user.signingkey \$(gpg --list-secret-keys --keyid-format=long $GIT_EMAIL | grep 'sec' | cut -d'/' -f2 | cut -d' ' -f1)"
echo "git config --global commit.gpgsign true"
fi