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