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"]
|
||||
}
|
||||
Reference in New Issue
Block a user