mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-30 14:51:17 +01:00
Compare commits
3 Commits
feat/wire-
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
39ced53900
|
|||
|
c1d9ec9399
|
|||
|
34214f5f3e
|
@@ -11,7 +11,7 @@
|
|||||||
"dockerfile": "Dockerfile"
|
"dockerfile": "Dockerfile"
|
||||||
},
|
},
|
||||||
"postCreateCommand": "bun install",
|
"postCreateCommand": "bun install",
|
||||||
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh && ./scripts/setup-tailscale.sh",
|
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh",
|
||||||
// Features add additional features to your environment. See https://containers.dev/features
|
// Features add additional features to your environment. See https://containers.dev/features
|
||||||
// Beware: features are not supported on all platforms and may have unintended side-effects.
|
// Beware: features are not supported on all platforms and may have unintended side-effects.
|
||||||
"features": {
|
"features": {
|
||||||
|
|||||||
@@ -17,23 +17,3 @@ services:
|
|||||||
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
|
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
|
||||||
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
|
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
|
||||||
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
|
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
|
||||||
|
|
||||||
aelis-backend:
|
|
||||||
name: Aelis Backend
|
|
||||||
description: Hono API server for aelis-backend (port 3000)
|
|
||||||
triggeredBy:
|
|
||||||
- manual
|
|
||||||
commands:
|
|
||||||
start: |
|
|
||||||
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
|
|
||||||
cd apps/aelis-backend && bun run dev
|
|
||||||
|
|
||||||
admin-dashboard:
|
|
||||||
name: Admin Dashboard
|
|
||||||
description: Vite dev server for admin-dashboard (port 5174)
|
|
||||||
triggeredBy:
|
|
||||||
- manual
|
|
||||||
commands:
|
|
||||||
start: |
|
|
||||||
gitpod --context environment environment port open 5174 --name "Admin Dashboard" --protocol http
|
|
||||||
cd apps/admin-dashboard && bun run dev --host
|
|
||||||
|
|||||||
@@ -47,15 +47,10 @@ export const Route = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
id: "dashboard",
|
id: "dashboard",
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
let session: Awaited<ReturnType<typeof getSession>> | null = null
|
const session = await context.queryClient.ensureQueryData({
|
||||||
try {
|
queryKey: ["session"],
|
||||||
session = await context.queryClient.ensureQueryData({
|
queryFn: getSession,
|
||||||
queryKey: ["session"],
|
})
|
||||||
queryFn: getSession,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
throw redirect({ to: "/login" })
|
|
||||||
}
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
throw redirect({ to: "/login" })
|
throw redirect({ to: "/login" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ export function createAuth(db: Database) {
|
|||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema,
|
schema,
|
||||||
}),
|
}),
|
||||||
advanced: {
|
|
||||||
disableCSRFCheck: process.env.NODE_ENV !== "production",
|
|
||||||
},
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
|
|||||||
type: "json_schema" as const,
|
type: "json_schema" as const,
|
||||||
jsonSchema: {
|
jsonSchema: {
|
||||||
name: "enhancement_result",
|
name: "enhancement_result",
|
||||||
strict: false,
|
strict: true,
|
||||||
schema: enhancementResultJsonSchema,
|
schema: enhancementResultJsonSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -166,12 +166,11 @@ describe("schema sync", () => {
|
|||||||
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
|
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
|
||||||
|
|
||||||
// JSON Schema only allows string or null for slot values
|
// JSON Schema only allows string or null for slot values
|
||||||
const slotValueSchema =
|
const slotValueTypes =
|
||||||
enhancementResultJsonSchema.properties.slotFills.additionalProperties
|
enhancementResultJsonSchema.properties.slotFills.additionalProperties
|
||||||
.additionalProperties
|
.additionalProperties.type
|
||||||
expect(slotValueSchema.anyOf).toEqual([
|
expect(slotValueTypes).toContain("string")
|
||||||
{ type: "string" },
|
expect(slotValueTypes).toContain("null")
|
||||||
{ type: "null" },
|
expect(slotValueTypes).not.toContain("number")
|
||||||
])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const enhancementResultJsonSchema = {
|
|||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
anyOf: [{ type: "string" }, { type: "null" }],
|
type: ["string", "null"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { cors } from "hono/cors"
|
|
||||||
|
|
||||||
import { registerAdminHttpHandlers } from "./admin/http.ts"
|
import { registerAdminHttpHandlers } from "./admin/http.ts"
|
||||||
import { createRequireAdmin } from "./auth/admin-middleware.ts"
|
import { createRequireAdmin } from "./auth/admin-middleware.ts"
|
||||||
@@ -14,7 +13,6 @@ import { registerLocationHttpHandlers } from "./location/http.ts"
|
|||||||
import { LocationSourceProvider } from "./location/provider.ts"
|
import { LocationSourceProvider } from "./location/provider.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||||
import { TflSourceProvider } from "./tfl/provider.ts"
|
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
@@ -46,41 +44,12 @@ function main() {
|
|||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
|
||||||
],
|
],
|
||||||
feedEnhancer,
|
feedEnhancer,
|
||||||
})
|
})
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV !== "production"
|
|
||||||
const allowedOrigins = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()) ?? []
|
|
||||||
|
|
||||||
function resolveOrigin(origin: string): string | undefined {
|
|
||||||
if (isDev) return origin
|
|
||||||
return allowedOrigins.includes(origin) ? origin : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
"/api/auth/*",
|
|
||||||
cors({
|
|
||||||
origin: resolveOrigin,
|
|
||||||
allowHeaders: ["Content-Type", "Authorization"],
|
|
||||||
allowMethods: ["POST", "GET", "OPTIONS"],
|
|
||||||
exposeHeaders: ["Content-Length"],
|
|
||||||
maxAge: 600,
|
|
||||||
credentials: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
"*",
|
|
||||||
cors({
|
|
||||||
origin: resolveOrigin,
|
|
||||||
credentials: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
const authSessionMiddleware = createRequireSession(auth)
|
const authSessionMiddleware = createRequireSession(auth)
|
||||||
|
|||||||
10
bun.lock
10
bun.lock
@@ -170,6 +170,14 @@
|
|||||||
"@nym.sh/jrx": "*",
|
"@nym.sh/jrx": "*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/aelis-data-source-weatherkit": {
|
||||||
|
"name": "@aelis/data-source-weatherkit",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aelis/core": "workspace:*",
|
||||||
|
"arktype": "^2.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/aelis-feed-enhancers": {
|
"packages/aelis-feed-enhancers": {
|
||||||
"name": "@aelis/feed-enhancers",
|
"name": "@aelis/feed-enhancers",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -240,6 +248,8 @@
|
|||||||
|
|
||||||
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
||||||
|
|
||||||
|
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
|
||||||
|
|
||||||
"@aelis/feed-enhancers": ["@aelis/feed-enhancers@workspace:packages/aelis-feed-enhancers"],
|
"@aelis/feed-enhancers": ["@aelis/feed-enhancers@workspace:packages/aelis-feed-enhancers"],
|
||||||
|
|
||||||
"@aelis/source-caldav": ["@aelis/source-caldav@workspace:packages/aelis-source-caldav"],
|
"@aelis/source-caldav": ["@aelis/source-caldav@workspace:packages/aelis-source-caldav"],
|
||||||
|
|||||||
4
packages/aelis-data-source-weatherkit/.env.example
Normal file
4
packages/aelis-data-source-weatherkit/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
WEATHERKIT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||||
|
WEATHERKIT_KEY_ID=ABC123DEFG
|
||||||
|
WEATHERKIT_TEAM_ID=TEAM123456
|
||||||
|
WEATHERKIT_SERVICE_ID=com.example.weatherkit.test
|
||||||
58
packages/aelis-data-source-weatherkit/README.md
Normal file
58
packages/aelis-data-source-weatherkit/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# @aelis/data-source-weatherkit
|
||||||
|
|
||||||
|
Weather data source using Apple WeatherKit REST API.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { WeatherKitDataSource, Units } from "@aelis/data-source-weatherkit"
|
||||||
|
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: {
|
||||||
|
privateKey: "-----BEGIN PRIVATE KEY-----\n...",
|
||||||
|
keyId: "ABC123",
|
||||||
|
teamId: "DEF456",
|
||||||
|
serviceId: "com.example.weatherkit",
|
||||||
|
},
|
||||||
|
hourlyLimit: 12, // optional, default: 12
|
||||||
|
dailyLimit: 7, // optional, default: 7
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = await dataSource.query(context, {
|
||||||
|
units: Units.metric, // or Units.imperial
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feed Items
|
||||||
|
|
||||||
|
The data source returns four types of feed items:
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
| ----------------- | -------------------------- |
|
||||||
|
| `weather-current` | Current weather conditions |
|
||||||
|
| `weather-hourly` | Hourly forecast |
|
||||||
|
| `weather-daily` | Daily forecast |
|
||||||
|
| `weather-alert` | Weather alerts |
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
Base priorities are adjusted based on weather conditions:
|
||||||
|
|
||||||
|
- Severe conditions (tornado, hurricane, blizzard, etc.): +0.3
|
||||||
|
- Moderate conditions (thunderstorm, heavy rain, etc.): +0.15
|
||||||
|
- Alert severity: extreme=1.0, severe=0.9, moderate=0.75, minor=0.7
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
WeatherKit requires Apple Developer credentials. Generate a private key in the Apple Developer portal under Certificates, Identifiers & Profiles > Keys.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
API responses are validated using [arktype](https://arktype.io) schemas.
|
||||||
|
|
||||||
|
## Generating Test Fixtures
|
||||||
|
|
||||||
|
To regenerate fixture data from the real API:
|
||||||
|
|
||||||
|
1. Create a `.env` file with your credentials (see `.env.example`)
|
||||||
|
2. Run `bun run scripts/generate-fixtures.ts`
|
||||||
6457
packages/aelis-data-source-weatherkit/fixtures/san-francisco.json
Normal file
6457
packages/aelis-data-source-weatherkit/fixtures/san-francisco.json
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/aelis-data-source-weatherkit/package.json
Normal file
14
packages/aelis-data-source-weatherkit/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@aelis/data-source-weatherkit",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aelis/core": "workspace:*",
|
||||||
|
"arktype": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { DefaultWeatherKitClient } from "../src/weatherkit"
|
||||||
|
|
||||||
|
function loadEnv(): Record<string, string> {
|
||||||
|
const content = require("fs").readFileSync(".env", "utf-8")
|
||||||
|
const env: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
|
||||||
|
const eqIndex = trimmed.indexOf("=")
|
||||||
|
if (eqIndex === -1) continue
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, eqIndex)
|
||||||
|
let value = trimmed.slice(eqIndex + 1)
|
||||||
|
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
env[key] = value.replace(/\\n/g, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = loadEnv()
|
||||||
|
|
||||||
|
const client = new DefaultWeatherKitClient({
|
||||||
|
privateKey: env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
|
keyId: env.WEATHERKIT_KEY_ID!,
|
||||||
|
teamId: env.WEATHERKIT_TEAM_ID!,
|
||||||
|
serviceId: env.WEATHERKIT_SERVICE_ID!,
|
||||||
|
})
|
||||||
|
|
||||||
|
const locations = {
|
||||||
|
sanFrancisco: { lat: 37.7749, lng: -122.4194 },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Fetching weather data for San Francisco...")
|
||||||
|
|
||||||
|
const response = await client.fetch({
|
||||||
|
lat: locations.sanFrancisco.lat,
|
||||||
|
lng: locations.sanFrancisco.lng,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fixture = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
location: locations.sanFrancisco,
|
||||||
|
response,
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = JSON.stringify(fixture)
|
||||||
|
await Bun.write("fixtures/san-francisco.json", output)
|
||||||
|
|
||||||
|
console.log("Fixture written to fixtures/san-francisco.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
233
packages/aelis-data-source-weatherkit/src/data-source.test.ts
Normal file
233
packages/aelis-data-source-weatherkit/src/data-source.test.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import type { ContextKey } from "@aelis/core"
|
||||||
|
|
||||||
|
import { Context, contextKey } from "@aelis/core"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||||
|
|
||||||
|
import fixture from "../fixtures/san-francisco.json"
|
||||||
|
import { WeatherKitDataSource, Units } from "./data-source"
|
||||||
|
import { WeatherFeedItemType } from "./feed-items"
|
||||||
|
|
||||||
|
const mockCredentials = {
|
||||||
|
privateKey: "mock",
|
||||||
|
keyId: "mock",
|
||||||
|
teamId: "mock",
|
||||||
|
serviceId: "mock",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
accuracy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationKey: ContextKey<LocationData> = contextKey("aelis.location", "location")
|
||||||
|
|
||||||
|
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
||||||
|
fetch: async () => response,
|
||||||
|
})
|
||||||
|
|
||||||
|
function createMockContext(location?: { lat: number; lng: number }): Context {
|
||||||
|
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
||||||
|
if (location) {
|
||||||
|
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WeatherKitDataSource", () => {
|
||||||
|
test("returns empty array when location is missing", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: mockCredentials,
|
||||||
|
})
|
||||||
|
const items = await dataSource.query(createMockContext())
|
||||||
|
|
||||||
|
expect(items).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("type is weather-current", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: mockCredentials,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dataSource.type).toBe(WeatherFeedItemType.Current)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error if neither client nor credentials provided", () => {
|
||||||
|
expect(() => new WeatherKitDataSource({})).toThrow(
|
||||||
|
"Either client or credentials must be provided",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("WeatherKitDataSource with fixture", () => {
|
||||||
|
const response = fixture.response
|
||||||
|
|
||||||
|
test("parses current weather from fixture", () => {
|
||||||
|
const current = response.currentWeather
|
||||||
|
|
||||||
|
expect(typeof current.conditionCode).toBe("string")
|
||||||
|
expect(typeof current.temperature).toBe("number")
|
||||||
|
expect(typeof current.humidity).toBe("number")
|
||||||
|
expect(current.pressureTrend).toMatch(/^(rising|falling|steady)$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses hourly forecast from fixture", () => {
|
||||||
|
const hours = response.forecastHourly.hours
|
||||||
|
|
||||||
|
expect(hours.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const firstHour = hours[0]!
|
||||||
|
expect(firstHour.forecastStart).toBeDefined()
|
||||||
|
expect(typeof firstHour.temperature).toBe("number")
|
||||||
|
expect(typeof firstHour.precipitationChance).toBe("number")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses daily forecast from fixture", () => {
|
||||||
|
const days = response.forecastDaily.days
|
||||||
|
|
||||||
|
expect(days.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const firstDay = days[0]!
|
||||||
|
expect(firstDay.forecastStart).toBeDefined()
|
||||||
|
expect(typeof firstDay.temperatureMax).toBe("number")
|
||||||
|
expect(typeof firstDay.temperatureMin).toBe("number")
|
||||||
|
expect(firstDay.sunrise).toBeDefined()
|
||||||
|
expect(firstDay.sunset).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hourly limit is respected", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: mockCredentials,
|
||||||
|
hourlyLimit: 6,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dataSource["hourlyLimit"]).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("daily limit is respected", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: mockCredentials,
|
||||||
|
dailyLimit: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dataSource["dailyLimit"]).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("default limits are applied", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: mockCredentials,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dataSource["hourlyLimit"]).toBe(12)
|
||||||
|
expect(dataSource["dailyLimit"]).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("unit conversion", () => {
|
||||||
|
test("Units enum has metric and imperial", () => {
|
||||||
|
expect(Units.metric).toBe("metric")
|
||||||
|
expect(Units.imperial).toBe("imperial")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("query() with mocked client", () => {
|
||||||
|
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
|
||||||
|
|
||||||
|
test("transforms API response into feed items", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
|
expect(items.length).toBeGreaterThan(0)
|
||||||
|
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
|
||||||
|
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
|
||||||
|
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies hourly and daily limits", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
client: mockClient,
|
||||||
|
hourlyLimit: 3,
|
||||||
|
dailyLimit: 2,
|
||||||
|
})
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
|
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
||||||
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
||||||
|
|
||||||
|
expect(hourlyItems.length).toBe(3)
|
||||||
|
expect(dailyItems.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sets timestamp from context.time", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||||
|
const queryTime = new Date("2026-01-17T12:00:00Z")
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
context.time = queryTime
|
||||||
|
|
||||||
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.timestamp).toEqual(queryTime)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("converts temperatures to imperial", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
|
const metricItems = await dataSource.query(context, {
|
||||||
|
units: Units.metric,
|
||||||
|
})
|
||||||
|
const imperialItems = await dataSource.query(context, {
|
||||||
|
units: Units.imperial,
|
||||||
|
})
|
||||||
|
|
||||||
|
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current)
|
||||||
|
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current)
|
||||||
|
|
||||||
|
expect(metricCurrent).toBeDefined()
|
||||||
|
expect(imperialCurrent).toBeDefined()
|
||||||
|
|
||||||
|
const metricTemp = (metricCurrent!.data as { temperature: number }).temperature
|
||||||
|
const imperialTemp = (imperialCurrent!.data as { temperature: number }).temperature
|
||||||
|
|
||||||
|
// Verify conversion: F = C * 9/5 + 32
|
||||||
|
const expectedImperial = (metricTemp * 9) / 5 + 32
|
||||||
|
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assigns signals based on weather conditions", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.signals).toBeDefined()
|
||||||
|
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
||||||
|
expect(item.signals!.timeRelevance).toBeDefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
||||||
|
expect(currentItem).toBeDefined()
|
||||||
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generates unique IDs for each item", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
|
const items = await dataSource.query(context)
|
||||||
|
const ids = items.map((i) => i.id)
|
||||||
|
const uniqueIds = new Set(ids)
|
||||||
|
|
||||||
|
expect(uniqueIds.size).toBe(ids.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
356
packages/aelis-data-source-weatherkit/src/data-source.ts
Normal file
356
packages/aelis-data-source-weatherkit/src/data-source.ts
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aelis/core"
|
||||||
|
|
||||||
|
import { TimeRelevance, contextKey } from "@aelis/core"
|
||||||
|
|
||||||
|
import {
|
||||||
|
WeatherFeedItemType,
|
||||||
|
type CurrentWeatherFeedItem,
|
||||||
|
type DailyWeatherFeedItem,
|
||||||
|
type HourlyWeatherFeedItem,
|
||||||
|
type WeatherAlertFeedItem,
|
||||||
|
type WeatherFeedItem,
|
||||||
|
} from "./feed-items"
|
||||||
|
import {
|
||||||
|
ConditionCode,
|
||||||
|
DefaultWeatherKitClient,
|
||||||
|
Severity,
|
||||||
|
type CurrentWeather,
|
||||||
|
type DailyForecast,
|
||||||
|
type HourlyForecast,
|
||||||
|
type WeatherAlert,
|
||||||
|
type WeatherKitClient,
|
||||||
|
type WeatherKitCredentials,
|
||||||
|
} from "./weatherkit"
|
||||||
|
|
||||||
|
export const Units = {
|
||||||
|
metric: "metric",
|
||||||
|
imperial: "imperial",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Units = (typeof Units)[keyof typeof Units]
|
||||||
|
|
||||||
|
export interface WeatherKitDataSourceOptions {
|
||||||
|
credentials?: WeatherKitCredentials
|
||||||
|
client?: WeatherKitClient
|
||||||
|
hourlyLimit?: number
|
||||||
|
dailyLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherKitQueryConfig {
|
||||||
|
units?: Units
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationKey: ContextKey<LocationData> = contextKey("aelis.location", "location")
|
||||||
|
|
||||||
|
const SOURCE_ID = "aelis.weather"
|
||||||
|
|
||||||
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||||
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||||
|
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||||
|
|
||||||
|
readonly type = WeatherFeedItemType.Current
|
||||||
|
private readonly client: WeatherKitClient
|
||||||
|
private readonly hourlyLimit: number
|
||||||
|
private readonly dailyLimit: number
|
||||||
|
|
||||||
|
constructor(options: WeatherKitDataSourceOptions) {
|
||||||
|
if (!options.client && !options.credentials) {
|
||||||
|
throw new Error("Either client or credentials must be provided")
|
||||||
|
}
|
||||||
|
this.client = options.client ?? new DefaultWeatherKitClient(options.credentials!)
|
||||||
|
this.hourlyLimit = options.hourlyLimit ?? this.DEFAULT_HOURLY_LIMIT
|
||||||
|
this.dailyLimit = options.dailyLimit ?? this.DEFAULT_DAILY_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||||
|
const location = context.get(LocationKey)
|
||||||
|
if (!location) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = config.units ?? Units.metric
|
||||||
|
const timestamp = context.time
|
||||||
|
|
||||||
|
const response = await this.client.fetch({
|
||||||
|
lat: location.lat,
|
||||||
|
lng: location.lng,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items: WeatherFeedItem[] = []
|
||||||
|
|
||||||
|
if (response.currentWeather) {
|
||||||
|
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, units))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.forecastHourly?.hours) {
|
||||||
|
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
|
||||||
|
for (let i = 0; i < hours.length; i++) {
|
||||||
|
const hour = hours[i]
|
||||||
|
if (hour) {
|
||||||
|
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, units))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.forecastDaily?.days) {
|
||||||
|
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
||||||
|
for (let i = 0; i < days.length; i++) {
|
||||||
|
const day = days[i]
|
||||||
|
if (day) {
|
||||||
|
items.push(createDailyWeatherFeedItem(day, i, timestamp, units))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.weatherAlerts?.alerts) {
|
||||||
|
for (const alert of response.weatherAlerts.alerts) {
|
||||||
|
items.push(createWeatherAlertFeedItem(alert, timestamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URGENCY = {
|
||||||
|
current: 0.5,
|
||||||
|
hourly: 0.3,
|
||||||
|
daily: 0.2,
|
||||||
|
alert: 0.7,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SEVERE_CONDITIONS = new Set<ConditionCode>([
|
||||||
|
ConditionCode.SevereThunderstorm,
|
||||||
|
ConditionCode.Hurricane,
|
||||||
|
ConditionCode.Tornado,
|
||||||
|
ConditionCode.TropicalStorm,
|
||||||
|
ConditionCode.Blizzard,
|
||||||
|
ConditionCode.FreezingRain,
|
||||||
|
ConditionCode.Hail,
|
||||||
|
ConditionCode.Frigid,
|
||||||
|
ConditionCode.Hot,
|
||||||
|
])
|
||||||
|
|
||||||
|
const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
||||||
|
ConditionCode.Thunderstorm,
|
||||||
|
ConditionCode.IsolatedThunderstorms,
|
||||||
|
ConditionCode.ScatteredThunderstorms,
|
||||||
|
ConditionCode.HeavyRain,
|
||||||
|
ConditionCode.HeavySnow,
|
||||||
|
ConditionCode.FreezingDrizzle,
|
||||||
|
ConditionCode.BlowingSnow,
|
||||||
|
])
|
||||||
|
|
||||||
|
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||||
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return Math.min(1, baseUrgency + 0.3)
|
||||||
|
}
|
||||||
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return Math.min(1, baseUrgency + 0.15)
|
||||||
|
}
|
||||||
|
return baseUrgency
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||||
|
switch (severity) {
|
||||||
|
case Severity.Extreme:
|
||||||
|
return 1
|
||||||
|
case Severity.Severe:
|
||||||
|
return 0.9
|
||||||
|
case Severity.Moderate:
|
||||||
|
return 0.75
|
||||||
|
case Severity.Minor:
|
||||||
|
return BASE_URGENCY.alert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
|
||||||
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return TimeRelevance.Imminent
|
||||||
|
}
|
||||||
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return TimeRelevance.Upcoming
|
||||||
|
}
|
||||||
|
return TimeRelevance.Ambient
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
|
||||||
|
switch (severity) {
|
||||||
|
case Severity.Extreme:
|
||||||
|
case Severity.Severe:
|
||||||
|
return TimeRelevance.Imminent
|
||||||
|
case Severity.Moderate:
|
||||||
|
return TimeRelevance.Upcoming
|
||||||
|
case Severity.Minor:
|
||||||
|
return TimeRelevance.Ambient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTemperature(celsius: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return (celsius * 9) / 5 + 32
|
||||||
|
}
|
||||||
|
return celsius
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertSpeed(kmh: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return kmh * 0.621371
|
||||||
|
}
|
||||||
|
return kmh
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDistance(km: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return km * 0.621371
|
||||||
|
}
|
||||||
|
return km
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPrecipitation(mm: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return mm * 0.0393701
|
||||||
|
}
|
||||||
|
return mm
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPressure(mb: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return mb * 0.02953
|
||||||
|
}
|
||||||
|
return mb
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCurrentWeatherFeedItem(
|
||||||
|
current: CurrentWeather,
|
||||||
|
timestamp: Date,
|
||||||
|
units: Units,
|
||||||
|
): CurrentWeatherFeedItem {
|
||||||
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
|
sourceId: SOURCE_ID,
|
||||||
|
type: WeatherFeedItemType.Current,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
conditionCode: current.conditionCode,
|
||||||
|
daylight: current.daylight,
|
||||||
|
humidity: current.humidity,
|
||||||
|
precipitationIntensity: convertPrecipitation(current.precipitationIntensity, units),
|
||||||
|
pressure: convertPressure(current.pressure, units),
|
||||||
|
pressureTrend: current.pressureTrend,
|
||||||
|
temperature: convertTemperature(current.temperature, units),
|
||||||
|
temperatureApparent: convertTemperature(current.temperatureApparent, units),
|
||||||
|
uvIndex: current.uvIndex,
|
||||||
|
visibility: convertDistance(current.visibility, units),
|
||||||
|
windDirection: current.windDirection,
|
||||||
|
windGust: convertSpeed(current.windGust, units),
|
||||||
|
windSpeed: convertSpeed(current.windSpeed, units),
|
||||||
|
},
|
||||||
|
signals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHourlyWeatherFeedItem(
|
||||||
|
hourly: HourlyForecast,
|
||||||
|
index: number,
|
||||||
|
timestamp: Date,
|
||||||
|
units: Units,
|
||||||
|
): HourlyWeatherFeedItem {
|
||||||
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
|
sourceId: SOURCE_ID,
|
||||||
|
type: WeatherFeedItemType.Hourly,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
|
conditionCode: hourly.conditionCode,
|
||||||
|
daylight: hourly.daylight,
|
||||||
|
humidity: hourly.humidity,
|
||||||
|
precipitationAmount: convertPrecipitation(hourly.precipitationAmount, units),
|
||||||
|
precipitationChance: hourly.precipitationChance,
|
||||||
|
precipitationType: hourly.precipitationType,
|
||||||
|
temperature: convertTemperature(hourly.temperature, units),
|
||||||
|
temperatureApparent: convertTemperature(hourly.temperatureApparent, units),
|
||||||
|
uvIndex: hourly.uvIndex,
|
||||||
|
windDirection: hourly.windDirection,
|
||||||
|
windGust: convertSpeed(hourly.windGust, units),
|
||||||
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||||
|
},
|
||||||
|
signals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDailyWeatherFeedItem(
|
||||||
|
daily: DailyForecast,
|
||||||
|
index: number,
|
||||||
|
timestamp: Date,
|
||||||
|
units: Units,
|
||||||
|
): DailyWeatherFeedItem {
|
||||||
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
|
sourceId: SOURCE_ID,
|
||||||
|
type: WeatherFeedItemType.Daily,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
forecastDate: new Date(daily.forecastStart),
|
||||||
|
conditionCode: daily.conditionCode,
|
||||||
|
maxUvIndex: daily.maxUvIndex,
|
||||||
|
precipitationAmount: convertPrecipitation(daily.precipitationAmount, units),
|
||||||
|
precipitationChance: daily.precipitationChance,
|
||||||
|
precipitationType: daily.precipitationType,
|
||||||
|
snowfallAmount: convertPrecipitation(daily.snowfallAmount, units),
|
||||||
|
sunrise: new Date(daily.sunrise),
|
||||||
|
sunset: new Date(daily.sunset),
|
||||||
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||||
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||||
|
},
|
||||||
|
signals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
||||||
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||||
|
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-alert-${alert.id}`,
|
||||||
|
sourceId: SOURCE_ID,
|
||||||
|
type: WeatherFeedItemType.Alert,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
alertId: alert.id,
|
||||||
|
areaName: alert.areaName,
|
||||||
|
certainty: alert.certainty,
|
||||||
|
description: alert.description,
|
||||||
|
detailsUrl: alert.detailsUrl,
|
||||||
|
effectiveTime: new Date(alert.effectiveTime),
|
||||||
|
expireTime: new Date(alert.expireTime),
|
||||||
|
severity: alert.severity,
|
||||||
|
source: alert.source,
|
||||||
|
urgency: alert.urgency,
|
||||||
|
},
|
||||||
|
signals,
|
||||||
|
}
|
||||||
|
}
|
||||||
97
packages/aelis-data-source-weatherkit/src/feed-items.ts
Normal file
97
packages/aelis-data-source-weatherkit/src/feed-items.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { FeedItem } from "@aelis/core"
|
||||||
|
|
||||||
|
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||||
|
|
||||||
|
export const WeatherFeedItemType = {
|
||||||
|
Current: "weather-current",
|
||||||
|
Hourly: "weather-hourly",
|
||||||
|
Daily: "weather-daily",
|
||||||
|
Alert: "weather-alert",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||||
|
|
||||||
|
export type CurrentWeatherData = {
|
||||||
|
conditionCode: ConditionCode
|
||||||
|
daylight: boolean
|
||||||
|
humidity: number
|
||||||
|
precipitationIntensity: number
|
||||||
|
pressure: number
|
||||||
|
pressureTrend: "rising" | "falling" | "steady"
|
||||||
|
temperature: number
|
||||||
|
temperatureApparent: number
|
||||||
|
uvIndex: number
|
||||||
|
visibility: number
|
||||||
|
windDirection: number
|
||||||
|
windGust: number
|
||||||
|
windSpeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.Current,
|
||||||
|
CurrentWeatherData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type HourlyWeatherData = {
|
||||||
|
forecastTime: Date
|
||||||
|
conditionCode: ConditionCode
|
||||||
|
daylight: boolean
|
||||||
|
humidity: number
|
||||||
|
precipitationAmount: number
|
||||||
|
precipitationChance: number
|
||||||
|
precipitationType: PrecipitationType
|
||||||
|
temperature: number
|
||||||
|
temperatureApparent: number
|
||||||
|
uvIndex: number
|
||||||
|
windDirection: number
|
||||||
|
windGust: number
|
||||||
|
windSpeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.Hourly,
|
||||||
|
HourlyWeatherData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type DailyWeatherData = {
|
||||||
|
forecastDate: Date
|
||||||
|
conditionCode: ConditionCode
|
||||||
|
maxUvIndex: number
|
||||||
|
precipitationAmount: number
|
||||||
|
precipitationChance: number
|
||||||
|
precipitationType: PrecipitationType
|
||||||
|
snowfallAmount: number
|
||||||
|
sunrise: Date
|
||||||
|
sunset: Date
|
||||||
|
temperatureMax: number
|
||||||
|
temperatureMin: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.Daily,
|
||||||
|
DailyWeatherData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type WeatherAlertData = {
|
||||||
|
alertId: string
|
||||||
|
areaName: string
|
||||||
|
certainty: Certainty
|
||||||
|
description: string
|
||||||
|
detailsUrl: string
|
||||||
|
effectiveTime: Date
|
||||||
|
expireTime: Date
|
||||||
|
severity: Severity
|
||||||
|
source: string
|
||||||
|
urgency: Urgency
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherAlertFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.Alert,
|
||||||
|
WeatherAlertData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type WeatherFeedItem =
|
||||||
|
| CurrentWeatherFeedItem
|
||||||
|
| HourlyWeatherFeedItem
|
||||||
|
| DailyWeatherFeedItem
|
||||||
|
| WeatherAlertFeedItem
|
||||||
38
packages/aelis-data-source-weatherkit/src/index.ts
Normal file
38
packages/aelis-data-source-weatherkit/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export {
|
||||||
|
WeatherKitDataSource,
|
||||||
|
Units,
|
||||||
|
type Units as UnitsType,
|
||||||
|
type WeatherKitDataSourceOptions,
|
||||||
|
type WeatherKitQueryConfig,
|
||||||
|
} from "./data-source"
|
||||||
|
|
||||||
|
export {
|
||||||
|
WeatherFeedItemType,
|
||||||
|
type WeatherFeedItemType as WeatherFeedItemTypeType,
|
||||||
|
type CurrentWeatherData,
|
||||||
|
type CurrentWeatherFeedItem,
|
||||||
|
type DailyWeatherData,
|
||||||
|
type DailyWeatherFeedItem,
|
||||||
|
type HourlyWeatherData,
|
||||||
|
type HourlyWeatherFeedItem,
|
||||||
|
type WeatherAlertData,
|
||||||
|
type WeatherAlertFeedItem,
|
||||||
|
type WeatherFeedItem,
|
||||||
|
} from "./feed-items"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Severity,
|
||||||
|
Urgency,
|
||||||
|
Certainty,
|
||||||
|
PrecipitationType,
|
||||||
|
ConditionCode,
|
||||||
|
DefaultWeatherKitClient,
|
||||||
|
type Severity as SeverityType,
|
||||||
|
type Urgency as UrgencyType,
|
||||||
|
type Certainty as CertaintyType,
|
||||||
|
type PrecipitationType as PrecipitationTypeType,
|
||||||
|
type ConditionCode as ConditionCodeType,
|
||||||
|
type WeatherKitCredentials,
|
||||||
|
type WeatherKitClient,
|
||||||
|
type WeatherKitQueryOptions,
|
||||||
|
} from "./weatherkit"
|
||||||
367
packages/aelis-data-source-weatherkit/src/weatherkit.ts
Normal file
367
packages/aelis-data-source-weatherkit/src/weatherkit.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
// WeatherKit REST API client and response types
|
||||||
|
// https://developer.apple.com/documentation/weatherkitrestapi
|
||||||
|
|
||||||
|
import { type } from "arktype"
|
||||||
|
|
||||||
|
export interface WeatherKitCredentials {
|
||||||
|
privateKey: string
|
||||||
|
keyId: string
|
||||||
|
teamId: string
|
||||||
|
serviceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherKitQueryOptions {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
language?: string
|
||||||
|
timezone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherKitClient {
|
||||||
|
fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DefaultWeatherKitClient implements WeatherKitClient {
|
||||||
|
private readonly credentials: WeatherKitCredentials
|
||||||
|
|
||||||
|
constructor(credentials: WeatherKitCredentials) {
|
||||||
|
this.credentials = credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse> {
|
||||||
|
const token = await generateJwt(this.credentials)
|
||||||
|
|
||||||
|
const dataSets = ["currentWeather", "forecastHourly", "forecastDaily", "weatherAlerts"].join(
|
||||||
|
",",
|
||||||
|
)
|
||||||
|
|
||||||
|
const url = new URL(
|
||||||
|
`${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`,
|
||||||
|
)
|
||||||
|
url.searchParams.set("dataSets", dataSets)
|
||||||
|
if (query.timezone) {
|
||||||
|
url.searchParams.set("timezone", query.timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text()
|
||||||
|
throw new Error(`WeatherKit API error: ${response.status} ${response.statusText}: ${body}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const result = weatherKitResponseSchema(json)
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
throw new Error(`WeatherKit API response validation failed: ${result.summary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Severity = {
|
||||||
|
Minor: "minor",
|
||||||
|
Moderate: "moderate",
|
||||||
|
Severe: "severe",
|
||||||
|
Extreme: "extreme",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Severity = (typeof Severity)[keyof typeof Severity]
|
||||||
|
|
||||||
|
export const Urgency = {
|
||||||
|
Immediate: "immediate",
|
||||||
|
Expected: "expected",
|
||||||
|
Future: "future",
|
||||||
|
Past: "past",
|
||||||
|
Unknown: "unknown",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Urgency = (typeof Urgency)[keyof typeof Urgency]
|
||||||
|
|
||||||
|
export const Certainty = {
|
||||||
|
Observed: "observed",
|
||||||
|
Likely: "likely",
|
||||||
|
Possible: "possible",
|
||||||
|
Unlikely: "unlikely",
|
||||||
|
Unknown: "unknown",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Certainty = (typeof Certainty)[keyof typeof Certainty]
|
||||||
|
|
||||||
|
export const PrecipitationType = {
|
||||||
|
Clear: "clear",
|
||||||
|
Precipitation: "precipitation",
|
||||||
|
Rain: "rain",
|
||||||
|
Snow: "snow",
|
||||||
|
Sleet: "sleet",
|
||||||
|
Hail: "hail",
|
||||||
|
Mixed: "mixed",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PrecipitationType = (typeof PrecipitationType)[keyof typeof PrecipitationType]
|
||||||
|
|
||||||
|
export const ConditionCode = {
|
||||||
|
Clear: "Clear",
|
||||||
|
Cloudy: "Cloudy",
|
||||||
|
Dust: "Dust",
|
||||||
|
Fog: "Fog",
|
||||||
|
Haze: "Haze",
|
||||||
|
MostlyClear: "MostlyClear",
|
||||||
|
MostlyCloudy: "MostlyCloudy",
|
||||||
|
PartlyCloudy: "PartlyCloudy",
|
||||||
|
ScatteredThunderstorms: "ScatteredThunderstorms",
|
||||||
|
Smoke: "Smoke",
|
||||||
|
Breezy: "Breezy",
|
||||||
|
Windy: "Windy",
|
||||||
|
Drizzle: "Drizzle",
|
||||||
|
HeavyRain: "HeavyRain",
|
||||||
|
Rain: "Rain",
|
||||||
|
Showers: "Showers",
|
||||||
|
Flurries: "Flurries",
|
||||||
|
HeavySnow: "HeavySnow",
|
||||||
|
MixedRainAndSleet: "MixedRainAndSleet",
|
||||||
|
MixedRainAndSnow: "MixedRainAndSnow",
|
||||||
|
MixedRainfall: "MixedRainfall",
|
||||||
|
MixedSnowAndSleet: "MixedSnowAndSleet",
|
||||||
|
ScatteredShowers: "ScatteredShowers",
|
||||||
|
ScatteredSnowShowers: "ScatteredSnowShowers",
|
||||||
|
Sleet: "Sleet",
|
||||||
|
Snow: "Snow",
|
||||||
|
SnowShowers: "SnowShowers",
|
||||||
|
Blizzard: "Blizzard",
|
||||||
|
BlowingSnow: "BlowingSnow",
|
||||||
|
FreezingDrizzle: "FreezingDrizzle",
|
||||||
|
FreezingRain: "FreezingRain",
|
||||||
|
Frigid: "Frigid",
|
||||||
|
Hail: "Hail",
|
||||||
|
Hot: "Hot",
|
||||||
|
Hurricane: "Hurricane",
|
||||||
|
IsolatedThunderstorms: "IsolatedThunderstorms",
|
||||||
|
SevereThunderstorm: "SevereThunderstorm",
|
||||||
|
Thunderstorm: "Thunderstorm",
|
||||||
|
Tornado: "Tornado",
|
||||||
|
TropicalStorm: "TropicalStorm",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ConditionCode = (typeof ConditionCode)[keyof typeof ConditionCode]
|
||||||
|
|
||||||
|
const WEATHERKIT_API_BASE = "https://weatherkit.apple.com/api/v1"
|
||||||
|
|
||||||
|
const severitySchema = type.enumerated(
|
||||||
|
Severity.Minor,
|
||||||
|
Severity.Moderate,
|
||||||
|
Severity.Severe,
|
||||||
|
Severity.Extreme,
|
||||||
|
)
|
||||||
|
|
||||||
|
const urgencySchema = type.enumerated(
|
||||||
|
Urgency.Immediate,
|
||||||
|
Urgency.Expected,
|
||||||
|
Urgency.Future,
|
||||||
|
Urgency.Past,
|
||||||
|
Urgency.Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
const certaintySchema = type.enumerated(
|
||||||
|
Certainty.Observed,
|
||||||
|
Certainty.Likely,
|
||||||
|
Certainty.Possible,
|
||||||
|
Certainty.Unlikely,
|
||||||
|
Certainty.Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
const precipitationTypeSchema = type.enumerated(
|
||||||
|
PrecipitationType.Clear,
|
||||||
|
PrecipitationType.Precipitation,
|
||||||
|
PrecipitationType.Rain,
|
||||||
|
PrecipitationType.Snow,
|
||||||
|
PrecipitationType.Sleet,
|
||||||
|
PrecipitationType.Hail,
|
||||||
|
PrecipitationType.Mixed,
|
||||||
|
)
|
||||||
|
|
||||||
|
const conditionCodeSchema = type.enumerated(...Object.values(ConditionCode))
|
||||||
|
|
||||||
|
const pressureTrendSchema = type.enumerated("rising", "falling", "steady")
|
||||||
|
|
||||||
|
const currentWeatherSchema = type({
|
||||||
|
asOf: "string",
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
daylight: "boolean",
|
||||||
|
humidity: "number",
|
||||||
|
precipitationIntensity: "number",
|
||||||
|
pressure: "number",
|
||||||
|
pressureTrend: pressureTrendSchema,
|
||||||
|
temperature: "number",
|
||||||
|
temperatureApparent: "number",
|
||||||
|
temperatureDewPoint: "number",
|
||||||
|
uvIndex: "number",
|
||||||
|
visibility: "number",
|
||||||
|
windDirection: "number",
|
||||||
|
windGust: "number",
|
||||||
|
windSpeed: "number",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CurrentWeather = typeof currentWeatherSchema.infer
|
||||||
|
|
||||||
|
const hourlyForecastSchema = type({
|
||||||
|
forecastStart: "string",
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
daylight: "boolean",
|
||||||
|
humidity: "number",
|
||||||
|
precipitationAmount: "number",
|
||||||
|
precipitationChance: "number",
|
||||||
|
precipitationType: precipitationTypeSchema,
|
||||||
|
pressure: "number",
|
||||||
|
snowfallIntensity: "number",
|
||||||
|
temperature: "number",
|
||||||
|
temperatureApparent: "number",
|
||||||
|
temperatureDewPoint: "number",
|
||||||
|
uvIndex: "number",
|
||||||
|
visibility: "number",
|
||||||
|
windDirection: "number",
|
||||||
|
windGust: "number",
|
||||||
|
windSpeed: "number",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type HourlyForecast = typeof hourlyForecastSchema.infer
|
||||||
|
|
||||||
|
const dayWeatherConditionsSchema = type({
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
humidity: "number",
|
||||||
|
precipitationAmount: "number",
|
||||||
|
precipitationChance: "number",
|
||||||
|
precipitationType: precipitationTypeSchema,
|
||||||
|
snowfallAmount: "number",
|
||||||
|
temperatureMax: "number",
|
||||||
|
temperatureMin: "number",
|
||||||
|
windDirection: "number",
|
||||||
|
"windGust?": "number",
|
||||||
|
windSpeed: "number",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DayWeatherConditions = typeof dayWeatherConditionsSchema.infer
|
||||||
|
|
||||||
|
const dailyForecastSchema = type({
|
||||||
|
forecastStart: "string",
|
||||||
|
forecastEnd: "string",
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
maxUvIndex: "number",
|
||||||
|
moonPhase: "string",
|
||||||
|
"moonrise?": "string",
|
||||||
|
"moonset?": "string",
|
||||||
|
precipitationAmount: "number",
|
||||||
|
precipitationChance: "number",
|
||||||
|
precipitationType: precipitationTypeSchema,
|
||||||
|
snowfallAmount: "number",
|
||||||
|
sunrise: "string",
|
||||||
|
sunriseCivil: "string",
|
||||||
|
sunriseNautical: "string",
|
||||||
|
sunriseAstronomical: "string",
|
||||||
|
sunset: "string",
|
||||||
|
sunsetCivil: "string",
|
||||||
|
sunsetNautical: "string",
|
||||||
|
sunsetAstronomical: "string",
|
||||||
|
temperatureMax: "number",
|
||||||
|
temperatureMin: "number",
|
||||||
|
"daytimeForecast?": dayWeatherConditionsSchema,
|
||||||
|
"overnightForecast?": dayWeatherConditionsSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DailyForecast = typeof dailyForecastSchema.infer
|
||||||
|
|
||||||
|
const weatherAlertSchema = type({
|
||||||
|
id: "string",
|
||||||
|
areaId: "string",
|
||||||
|
areaName: "string",
|
||||||
|
certainty: certaintySchema,
|
||||||
|
countryCode: "string",
|
||||||
|
description: "string",
|
||||||
|
detailsUrl: "string",
|
||||||
|
effectiveTime: "string",
|
||||||
|
expireTime: "string",
|
||||||
|
issuedTime: "string",
|
||||||
|
responses: "string[]",
|
||||||
|
severity: severitySchema,
|
||||||
|
source: "string",
|
||||||
|
urgency: urgencySchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type WeatherAlert = typeof weatherAlertSchema.infer
|
||||||
|
|
||||||
|
const weatherKitResponseSchema = type({
|
||||||
|
"currentWeather?": currentWeatherSchema,
|
||||||
|
"forecastHourly?": type({
|
||||||
|
hours: hourlyForecastSchema.array(),
|
||||||
|
}),
|
||||||
|
"forecastDaily?": type({
|
||||||
|
days: dailyForecastSchema.array(),
|
||||||
|
}),
|
||||||
|
"weatherAlerts?": type({
|
||||||
|
alerts: weatherAlertSchema.array(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type WeatherKitResponse = typeof weatherKitResponseSchema.infer
|
||||||
|
|
||||||
|
async function generateJwt(credentials: WeatherKitCredentials): Promise<string> {
|
||||||
|
const header = {
|
||||||
|
alg: "ES256",
|
||||||
|
kid: credentials.keyId,
|
||||||
|
id: `${credentials.teamId}.${credentials.serviceId}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const payload = {
|
||||||
|
iss: credentials.teamId,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600,
|
||||||
|
sub: credentials.serviceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const headerB64 = btoa(JSON.stringify(header))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "")
|
||||||
|
const payloadB64 = btoa(JSON.stringify(payload))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "")
|
||||||
|
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`
|
||||||
|
|
||||||
|
const pemContents = credentials.privateKey
|
||||||
|
.replace(/-----BEGIN PRIVATE KEY-----/, "")
|
||||||
|
.replace(/-----END PRIVATE KEY-----/, "")
|
||||||
|
.replace(/\s/g, "")
|
||||||
|
|
||||||
|
const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0))
|
||||||
|
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
binaryKey,
|
||||||
|
{ name: "ECDSA", namedCurve: "P-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
)
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
{ name: "ECDSA", hash: "SHA-256" },
|
||||||
|
cryptoKey,
|
||||||
|
encoder.encode(signingInput),
|
||||||
|
)
|
||||||
|
|
||||||
|
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "")
|
||||||
|
|
||||||
|
return `${signingInput}.${signatureB64}`
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export interface CurrentWeatherFeedItem extends FeedItem<
|
|||||||
CurrentWeatherData
|
CurrentWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export type HourlyWeatherEntry = {
|
export type HourlyWeatherData = {
|
||||||
forecastTime: Date
|
forecastTime: Date
|
||||||
conditionCode: ConditionCode
|
conditionCode: ConditionCode
|
||||||
daylight: boolean
|
daylight: boolean
|
||||||
@@ -48,10 +48,6 @@ export type HourlyWeatherEntry = {
|
|||||||
windSpeed: number
|
windSpeed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HourlyWeatherData = {
|
|
||||||
hours: HourlyWeatherEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HourlyWeatherFeedItem extends FeedItem<
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Hourly,
|
typeof WeatherFeedItemType.Hourly,
|
||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export {
|
|||||||
type CurrentWeatherData,
|
type CurrentWeatherData,
|
||||||
type HourlyWeatherFeedItem,
|
type HourlyWeatherFeedItem,
|
||||||
type HourlyWeatherData,
|
type HourlyWeatherData,
|
||||||
type HourlyWeatherEntry,
|
|
||||||
type DailyWeatherFeedItem,
|
type DailyWeatherFeedItem,
|
||||||
type DailyWeatherData,
|
type DailyWeatherData,
|
||||||
type WeatherAlertFeedItem,
|
type WeatherAlertFeedItem,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
|
|||||||
import { LocationKey } from "@aelis/source-location"
|
import { LocationKey } from "@aelis/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast } from "./weatherkit"
|
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||||
|
|
||||||
import fixture from "../fixtures/san-francisco.json"
|
import fixture from "../fixtures/san-francisco.json"
|
||||||
import { WeatherFeedItemType, type HourlyWeatherData } from "./feed-items"
|
import { WeatherFeedItemType } from "./feed-items"
|
||||||
import { WeatherKey, type Weather } from "./weather-context"
|
import { WeatherKey, type Weather } from "./weather-context"
|
||||||
import { WeatherSource, Units } from "./weather-source"
|
import { WeatherSource, Units } from "./weather-source"
|
||||||
|
|
||||||
@@ -131,67 +131,10 @@ describe("WeatherSource", () => {
|
|||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
||||||
|
|
||||||
expect(hourlyItems.length).toBe(1)
|
expect(hourlyItems.length).toBe(3)
|
||||||
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
|
|
||||||
expect(dailyItems.length).toBe(2)
|
expect(dailyItems.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("produces a single hourly item with hours array", async () => {
|
|
||||||
const source = new WeatherSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
|
|
||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
|
||||||
expect(hourlyItems.length).toBe(1)
|
|
||||||
|
|
||||||
const hourlyData = hourlyItems[0]!.data as HourlyWeatherData
|
|
||||||
expect(Array.isArray(hourlyData.hours)).toBe(true)
|
|
||||||
expect(hourlyData.hours.length).toBeGreaterThan(0)
|
|
||||||
expect(hourlyData.hours.length).toBeLessThanOrEqual(12)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("averages urgency across hours with mixed conditions", async () => {
|
|
||||||
const mildHour: HourlyForecast = {
|
|
||||||
forecastStart: "2026-01-17T01:00:00Z",
|
|
||||||
conditionCode: "Clear",
|
|
||||||
daylight: true,
|
|
||||||
humidity: 0.5,
|
|
||||||
precipitationAmount: 0,
|
|
||||||
precipitationChance: 0,
|
|
||||||
precipitationType: "clear",
|
|
||||||
pressure: 1013,
|
|
||||||
snowfallIntensity: 0,
|
|
||||||
temperature: 20,
|
|
||||||
temperatureApparent: 20,
|
|
||||||
temperatureDewPoint: 10,
|
|
||||||
uvIndex: 3,
|
|
||||||
visibility: 20000,
|
|
||||||
windDirection: 180,
|
|
||||||
windGust: 10,
|
|
||||||
windSpeed: 5,
|
|
||||||
}
|
|
||||||
const severeHour: HourlyForecast = {
|
|
||||||
...mildHour,
|
|
||||||
forecastStart: "2026-01-17T02:00:00Z",
|
|
||||||
conditionCode: "SevereThunderstorm",
|
|
||||||
}
|
|
||||||
const mixedResponse: WeatherKitResponse = {
|
|
||||||
forecastHourly: { hours: [mildHour, severeHour] },
|
|
||||||
}
|
|
||||||
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
const hourlyItem = items.find((i) => i.type === WeatherFeedItemType.Hourly)
|
|
||||||
|
|
||||||
expect(hourlyItem).toBeDefined()
|
|
||||||
// Mild urgency = 0.3, severe urgency = 0.6, average = 0.45
|
|
||||||
expect(hourlyItem!.signals!.urgency).toBeCloseTo(0.45, 5)
|
|
||||||
// Worst-case: SevereThunderstorm → Imminent
|
|
||||||
expect(hourlyItem!.signals!.timeRelevance).toBe("imminent")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sets timestamp from context.time", async () => {
|
test("sets timestamp from context.time", async () => {
|
||||||
const source = new WeatherSource({ client: mockClient })
|
const source = new WeatherSource({ client: mockClient })
|
||||||
const queryTime = new Date("2026-01-17T12:00:00Z")
|
const queryTime = new Date("2026-01-17T12:00:00Z")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
|
|||||||
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
||||||
import { LocationKey } from "@aelis/source-location"
|
import { LocationKey } from "@aelis/source-location"
|
||||||
|
|
||||||
import { WeatherFeedItemType, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||||
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
|
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
|
||||||
import { WeatherKey, type Weather } from "./weather-context"
|
import { WeatherKey, type Weather } from "./weather-context"
|
||||||
import {
|
import {
|
||||||
@@ -174,8 +174,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
|
|
||||||
if (response.forecastHourly?.hours) {
|
if (response.forecastHourly?.hours) {
|
||||||
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
|
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
|
||||||
if (hours.length > 0) {
|
for (let i = 0; i < hours.length; i++) {
|
||||||
items.push(createHourlyForecastFeedItem(hours, timestamp, this.units, this.id))
|
const hour = hours[i]
|
||||||
|
if (hour) {
|
||||||
|
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units, this.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,18 +323,24 @@ function createCurrentWeatherFeedItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHourlyForecastFeedItem(
|
function createHourlyWeatherFeedItem(
|
||||||
hourlyForecasts: HourlyForecast[],
|
hourly: HourlyForecast,
|
||||||
|
index: number,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const hours: HourlyWeatherEntry[] = []
|
const signals: FeedItemSignals = {
|
||||||
let totalUrgency = 0
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||||
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
|
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
for (const hourly of hourlyForecasts) {
|
return {
|
||||||
hours.push({
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
|
sourceId,
|
||||||
|
type: WeatherFeedItemType.Hourly,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
conditionCode: hourly.conditionCode,
|
conditionCode: hourly.conditionCode,
|
||||||
daylight: hourly.daylight,
|
daylight: hourly.daylight,
|
||||||
@@ -345,27 +354,7 @@ function createHourlyForecastFeedItem(
|
|||||||
windDirection: hourly.windDirection,
|
windDirection: hourly.windDirection,
|
||||||
windGust: convertSpeed(hourly.windGust, units),
|
windGust: convertSpeed(hourly.windGust, units),
|
||||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||||
})
|
},
|
||||||
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode)
|
|
||||||
const rel = timeRelevanceForCondition(hourly.conditionCode)
|
|
||||||
if (rel === TimeRelevance.Imminent) {
|
|
||||||
worstTimeRelevance = TimeRelevance.Imminent
|
|
||||||
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
|
|
||||||
worstTimeRelevance = TimeRelevance.Upcoming
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const signals: FeedItemSignals = {
|
|
||||||
urgency: totalUrgency / hours.length,
|
|
||||||
timeRelevance: worstTimeRelevance,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `weather-hourly-${timestamp.getTime()}`,
|
|
||||||
sourceId,
|
|
||||||
type: WeatherFeedItemType.Hourly,
|
|
||||||
timestamp,
|
|
||||||
data: { hours },
|
|
||||||
signals,
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Tailscale setup script
|
|
||||||
# Authenticates with Tailscale if TS_AUTH_KEY is set and Tailscale is not already logged in
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$TS_AUTH_KEY" ]; then
|
|
||||||
echo "TS_AUTH_KEY is not set, skipping Tailscale login."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
STATUS=$(tailscale status 2>&1 || true)
|
|
||||||
|
|
||||||
if echo "$STATUS" | grep -qi "logged out\|stopped"; then
|
|
||||||
echo "Tailscale is not authenticated. Logging in..."
|
|
||||||
sudo tailscale up --accept-routes --auth-key="$TS_AUTH_KEY"
|
|
||||||
echo "Tailscale login complete."
|
|
||||||
else
|
|
||||||
echo "Tailscale is already authenticated, skipping."
|
|
||||||
fi
|
|
||||||
Reference in New Issue
Block a user