36
.devcontainer/Dockerfile
Normal file
36
.devcontainer/Dockerfile
Normal 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
|
||||
25
.devcontainer/devcontainer.json
Normal file
25
.devcontainer/devcontainer.json
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.turbo
|
||||
coverage
|
||||
*.p8
|
||||
138
README.md
Normal file
138
README.md
Normal 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
18
apps/backend/package.json
Normal 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
9
apps/backend/src/env.d.ts
vendored
Normal 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
25
apps/backend/src/index.ts
Normal 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,
|
||||
}
|
||||
131
apps/backend/src/weather-kit/auth.ts
Normal file
131
apps/backend/src/weather-kit/auth.ts
Normal 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, "")
|
||||
}
|
||||
74
apps/backend/src/weather-kit/cache.ts
Normal file
74
apps/backend/src/weather-kit/cache.ts
Normal 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
|
||||
})),
|
||||
};
|
||||
}
|
||||
145
apps/backend/src/weather-kit/gemini.ts
Normal file
145
apps/backend/src/weather-kit/gemini.ts
Normal 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:`;
|
||||
}
|
||||
71
apps/backend/src/weather-kit/middleware.ts
Normal file
71
apps/backend/src/weather-kit/middleware.ts
Normal 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
232
apps/backend/src/weather.ts
Normal 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
|
||||
18
apps/backend/tsconfig.json
Normal file
18
apps/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
3
apps/dashboard/.env.example
Normal file
3
apps/dashboard/.env.example
Normal 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
13
apps/dashboard/index.html
Normal 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>
|
||||
28
apps/dashboard/package.json
Normal file
28
apps/dashboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/dashboard/postcss.config.js
Normal file
6
apps/dashboard/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
198
apps/dashboard/src/App.tsx
Normal file
198
apps/dashboard/src/App.tsx
Normal 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
|
||||
8
apps/dashboard/src/components/lib/cn.ts
Normal file
8
apps/dashboard/src/components/lib/cn.ts
Normal 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
11
apps/dashboard/src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
12
apps/dashboard/src/index.css
Normal file
12
apps/dashboard/src/index.css
Normal 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;
|
||||
}
|
||||
15
apps/dashboard/src/main.tsx
Normal file
15
apps/dashboard/src/main.tsx
Normal 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>,
|
||||
);
|
||||
530
apps/dashboard/src/weather.ts
Normal file
530
apps/dashboard/src/weather.ts
Normal 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
|
||||
})
|
||||
}
|
||||
11
apps/dashboard/tailwind.config.js
Normal file
11
apps/dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
apps/dashboard/tsconfig.json
Normal file
21
apps/dashboard/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
apps/dashboard/tsconfig.node.json
Normal file
11
apps/dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
apps/dashboard/vite.config.ts
Normal file
10
apps/dashboard/vite.config.ts
Normal 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
42
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
package.json
Normal file
20
package.json
Normal 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
151
scripts/setup-git.sh
Executable 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
|
||||
Reference in New Issue
Block a user