initial commit

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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