mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-30 14:51:17 +01:00
Compare commits
3 Commits
master
...
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,29 +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
|
|
||||||
TS_IP=$(tailscale ip -4)
|
|
||||||
echo ""
|
|
||||||
echo "------------------ Bun Debugger ------------------"
|
|
||||||
echo "https://debug.bun.sh/#${TS_IP}:6499"
|
|
||||||
echo "------------------ Bun Debugger ------------------"
|
|
||||||
echo ""
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -408,40 +408,6 @@ function FieldInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === "multiselect" && field.options) {
|
|
||||||
const selected = Array.isArray(value) ? (value as string[]) : []
|
|
||||||
|
|
||||||
function toggle(optValue: string) {
|
|
||||||
const next = selected.includes(optValue)
|
|
||||||
? selected.filter((v) => v !== optValue)
|
|
||||||
: [...selected, optValue]
|
|
||||||
onChange(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">
|
|
||||||
{labelContent}
|
|
||||||
</Label>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{field.options!.map((opt) => {
|
|
||||||
const isSelected = selected.includes(opt.value)
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
key={opt.value}
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
className={`cursor-pointer select-none ${isSelected ? "" : "opacity-60 hover:opacity-100"}`}
|
|
||||||
onClick={() => !disabled && toggle(opt.value)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === "number") {
|
if (field.type === "number") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -490,8 +456,6 @@ function buildInitialValues(
|
|||||||
values[name] = saved[name]
|
values[name] = saved[name]
|
||||||
} else if (field.defaultValue !== undefined) {
|
} else if (field.defaultValue !== undefined) {
|
||||||
values[name] = field.defaultValue
|
values[name] = field.defaultValue
|
||||||
} else if (field.type === "multiselect") {
|
|
||||||
values[name] = []
|
|
||||||
} else {
|
} else {
|
||||||
values[name] = field.type === "number" ? undefined : ""
|
values[name] = field.type === "number" ? undefined : ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ function serverBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigFieldDef {
|
export interface ConfigFieldDef {
|
||||||
type: "string" | "number" | "select" | "multiselect"
|
type: "string" | "number" | "select"
|
||||||
label: string
|
label: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
description?: string
|
description?: string
|
||||||
secret?: boolean
|
secret?: boolean
|
||||||
defaultValue?: string | number | string[]
|
defaultValue?: string | number
|
||||||
options?: { label: string; value: string }[]
|
options?: { label: string; value: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,39 +54,6 @@ const sourceDefinitions: SourceDefinition[] = [
|
|||||||
dailyLimit: { type: "number", label: "Daily Forecast Limit", defaultValue: 7, description: "Number of daily forecasts to include" },
|
dailyLimit: { type: "number", label: "Daily Forecast Limit", defaultValue: 7, description: "Number of daily forecasts to include" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "aelis.tfl",
|
|
||||||
name: "TfL",
|
|
||||||
description: "Transport for London tube line status alerts.",
|
|
||||||
fields: {
|
|
||||||
lines: {
|
|
||||||
type: "multiselect",
|
|
||||||
label: "Lines",
|
|
||||||
description: "Lines to monitor. Leave empty for all lines.",
|
|
||||||
defaultValue: [],
|
|
||||||
options: [
|
|
||||||
{ label: "Bakerloo", value: "bakerloo" },
|
|
||||||
{ label: "Central", value: "central" },
|
|
||||||
{ label: "Circle", value: "circle" },
|
|
||||||
{ label: "District", value: "district" },
|
|
||||||
{ label: "Hammersmith & City", value: "hammersmith-city" },
|
|
||||||
{ label: "Jubilee", value: "jubilee" },
|
|
||||||
{ label: "Metropolitan", value: "metropolitan" },
|
|
||||||
{ label: "Northern", value: "northern" },
|
|
||||||
{ label: "Piccadilly", value: "piccadilly" },
|
|
||||||
{ label: "Victoria", value: "victoria" },
|
|
||||||
{ label: "Waterloo & City", value: "waterloo-city" },
|
|
||||||
{ label: "Lioness", value: "lioness" },
|
|
||||||
{ label: "Mildmay", value: "mildmay" },
|
|
||||||
{ label: "Windrush", value: "windrush" },
|
|
||||||
{ label: "Weaver", value: "weaver" },
|
|
||||||
{ label: "Suffragette", value: "suffragette" },
|
|
||||||
{ label: "Liberty", value: "liberty" },
|
|
||||||
{ label: "Elizabeth", value: "elizabeth" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function fetchSources(): Promise<SourceDefinition[]> {
|
export function fetchSources(): Promise<SourceDefinition[]> {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
CircleDot,
|
CircleDot,
|
||||||
CloudSun,
|
CloudSun,
|
||||||
Loader2,
|
Loader2,
|
||||||
TrainFront,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
MapPin,
|
MapPin,
|
||||||
Rss,
|
Rss,
|
||||||
@@ -42,22 +41,16 @@ const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
"aelis.weather": CloudSun,
|
"aelis.weather": CloudSun,
|
||||||
"aelis.caldav": CalendarDays,
|
"aelis.caldav": CalendarDays,
|
||||||
"aelis.google-calendar": Calendar,
|
"aelis.google-calendar": Calendar,
|
||||||
"aelis.tfl": TrainFront,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRoute({
|
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" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.ts",
|
"main": "src/server.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch --inspect=0.0.0.0:6499 src/server.ts",
|
"dev": "bun run --watch src/server.ts",
|
||||||
"start": "bun run src/server.ts",
|
"start": "bun run src/server.ts",
|
||||||
"test": "bun test src/",
|
"test": "bun test src/",
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { EnhancementResult } from "./schema.ts"
|
|||||||
|
|
||||||
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
|
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
|
||||||
|
|
||||||
const DEFAULT_MODEL = "z-ai/glm-4.7-flash"
|
const DEFAULT_MODEL = "openai/gpt-4.1-mini"
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000
|
const DEFAULT_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
export interface LlmClientConfig {
|
export interface LlmClientConfig {
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -10,7 +10,5 @@ export {
|
|||||||
type TflAlertSeverity,
|
type TflAlertSeverity,
|
||||||
type TflLineStatus,
|
type TflLineStatus,
|
||||||
type TflSourceOptions,
|
type TflSourceOptions,
|
||||||
type TflStatusData,
|
|
||||||
type TflStatusFeedItem,
|
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
export { renderTflStatus } from "./renderer.tsx"
|
export { renderTflAlert } from "./renderer.tsx"
|
||||||
|
|||||||
@@ -2,140 +2,102 @@
|
|||||||
import { render } from "@nym.sh/jrx"
|
import { render } from "@nym.sh/jrx"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { TflAlertData, TflStatusFeedItem } from "./types.ts"
|
import type { TflAlertFeedItem } from "./types.ts"
|
||||||
|
|
||||||
import { renderTflStatus } from "./renderer.tsx"
|
import { renderTflAlert } from "./renderer.tsx"
|
||||||
|
|
||||||
function makeAlert(overrides: Partial<TflAlertData> = {}): TflAlertData {
|
function makeItem(overrides: Partial<TflAlertFeedItem["data"]> = {}): TflAlertFeedItem {
|
||||||
return {
|
return {
|
||||||
line: "northern",
|
id: "tfl-alert-northern-minor-delays",
|
||||||
lineName: "Northern",
|
type: "tfl-alert",
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Minor delays due to signal failure",
|
|
||||||
closestStationDistance: null,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeItem(alerts: TflAlertData[]): TflStatusFeedItem {
|
|
||||||
return {
|
|
||||||
id: "tfl-status",
|
|
||||||
sourceId: "aelis.tfl",
|
|
||||||
type: "tfl-status",
|
|
||||||
timestamp: new Date("2026-01-15T12:00:00Z"),
|
timestamp: new Date("2026-01-15T12:00:00Z"),
|
||||||
data: { alerts },
|
data: {
|
||||||
|
line: "northern",
|
||||||
|
lineName: "Northern",
|
||||||
|
severity: "minor-delays",
|
||||||
|
description: "Minor delays due to signal failure",
|
||||||
|
closestStationDistance: null,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect all SansSerifText elements from a rendered spec, filtering out Fragments. */
|
describe("renderTflAlert", () => {
|
||||||
function collectTextElements(spec: ReturnType<typeof render>) {
|
test("renders a FeedCard with title and description", () => {
|
||||||
return Object.values(spec.elements).filter((el) => el.type === "SansSerifText")
|
const node = renderTflAlert(makeItem())
|
||||||
}
|
|
||||||
|
|
||||||
describe("renderTflStatus", () => {
|
|
||||||
test("renders a single FeedCard", () => {
|
|
||||||
const node = renderTflStatus(makeItem([makeAlert()]))
|
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const root = spec.elements[spec.root]!
|
||||||
expect(root.type).toBe("FeedCard")
|
expect(root.type).toBe("FeedCard")
|
||||||
})
|
expect(root.children!.length).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
test("renders one alert with title and description", () => {
|
const title = spec.elements[root.children![0]!]!
|
||||||
const node = renderTflStatus(makeItem([makeAlert()]))
|
expect(title.type).toBe("SansSerifText")
|
||||||
const spec = render(node)
|
expect(title.props.content).toBe("Northern · Minor delays")
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
const body = spec.elements[root.children![1]!]!
|
||||||
const titleText = texts.find((el) => el.props.content === "Northern · Minor delays")
|
expect(body.type).toBe("SansSerifText")
|
||||||
const bodyText = texts.find((el) => el.props.content === "Minor delays due to signal failure")
|
expect(body.props.content).toBe("Minor delays due to signal failure")
|
||||||
|
|
||||||
expect(titleText).toBeDefined()
|
|
||||||
expect(bodyText).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders multiple alerts stacked in one card", () => {
|
|
||||||
const alerts = [
|
|
||||||
makeAlert({ line: "northern", lineName: "Northern", severity: "minor-delays" }),
|
|
||||||
makeAlert({
|
|
||||||
line: "central",
|
|
||||||
lineName: "Central",
|
|
||||||
severity: "closure",
|
|
||||||
description: "Closed due to strike",
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
const node = renderTflStatus(makeItem(alerts))
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("FeedCard")
|
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
|
||||||
const northernTitle = texts.find((el) => el.props.content === "Northern · Minor delays")
|
|
||||||
const centralTitle = texts.find((el) => el.props.content === "Central · Closed")
|
|
||||||
const centralBody = texts.find((el) => el.props.content === "Closed due to strike")
|
|
||||||
|
|
||||||
expect(northernTitle).toBeDefined()
|
|
||||||
expect(centralTitle).toBeDefined()
|
|
||||||
expect(centralBody).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows nearest station distance when available", () => {
|
test("shows nearest station distance when available", () => {
|
||||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.35 })]))
|
const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 }))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
const root = spec.elements[spec.root]!
|
||||||
const caption = texts.find((el) => el.props.content === "Nearest station: 350m away")
|
expect(root.children).toHaveLength(3)
|
||||||
expect(caption).toBeDefined()
|
|
||||||
|
const caption = spec.elements[root.children![2]!]!
|
||||||
|
expect(caption.type).toBe("SansSerifText")
|
||||||
|
expect(caption.props.content).toBe("Nearest station: 350m away")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats distance in km when >= 1km", () => {
|
test("formats distance in km when >= 1km", () => {
|
||||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 2.456 })]))
|
const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 }))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
const root = spec.elements[spec.root]!
|
||||||
const caption = texts.find((el) => el.props.content === "Nearest station: 2.5km away")
|
const caption = spec.elements[root.children![2]!]!
|
||||||
expect(caption).toBeDefined()
|
expect(caption.props.content).toBe("Nearest station: 2.5km away")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats near-1km boundary as km not meters", () => {
|
test("formats near-1km boundary as km not meters", () => {
|
||||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.9999 })]))
|
const node = renderTflAlert(makeItem({ closestStationDistance: 0.9999 }))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
const root = spec.elements[spec.root]!
|
||||||
const caption = texts.find((el) => el.props.content === "Nearest station: 1.0km away")
|
const caption = spec.elements[root.children![2]!]!
|
||||||
expect(caption).toBeDefined()
|
expect(caption.props.content).toBe("Nearest station: 1.0km away")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits station distance when null", () => {
|
test("omits station distance when null", () => {
|
||||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: null })]))
|
const node = renderTflAlert(makeItem({ closestStationDistance: null }))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
const root = spec.elements[spec.root]!
|
||||||
const distanceTexts = texts.filter((el) =>
|
// Title + body only, no caption (empty fragment doesn't produce a child)
|
||||||
(el.props.content as string).startsWith("Nearest station:"),
|
const children = root.children!.filter((key) => {
|
||||||
)
|
const el = spec.elements[key]
|
||||||
expect(distanceTexts).toHaveLength(0)
|
return el && el.type !== "Fragment"
|
||||||
|
})
|
||||||
|
expect(children).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders closure severity label", () => {
|
test("renders closure severity label", () => {
|
||||||
const node = renderTflStatus(
|
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" }))
|
||||||
makeItem([makeAlert({ severity: "closure", lineName: "Central" })]),
|
|
||||||
)
|
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
const root = spec.elements[spec.root]!
|
||||||
const title = texts.find((el) => el.props.content === "Central · Closed")
|
const title = spec.elements[root.children![0]!]!
|
||||||
expect(title).toBeDefined()
|
expect(title.props.content).toBe("Central · Closed")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders major delays severity label", () => {
|
test("renders major delays severity label", () => {
|
||||||
const node = renderTflStatus(
|
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" }))
|
||||||
makeItem([makeAlert({ severity: "major-delays", lineName: "Jubilee" })]),
|
|
||||||
)
|
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const texts = collectTextElements(spec)
|
const root = spec.elements[spec.root]!
|
||||||
const title = texts.find((el) => el.props.content === "Jubilee · Major delays")
|
const title = spec.elements[root.children![0]!]!
|
||||||
expect(title).toBeDefined()
|
expect(title.props.content).toBe("Jubilee · Major delays")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FeedItemRenderer } from "@aelis/core"
|
|||||||
|
|
||||||
import { FeedCard, SansSerifText } from "@aelis/components"
|
import { FeedCard, SansSerifText } from "@aelis/components"
|
||||||
|
|
||||||
import type { TflAlertData, TflStatusData } from "./types.ts"
|
import type { TflAlertData } from "./types.ts"
|
||||||
|
|
||||||
import { TflAlertSeverity } from "./types.ts"
|
import { TflAlertSeverity } from "./types.ts"
|
||||||
|
|
||||||
@@ -21,26 +21,20 @@ function formatDistance(km: number): string {
|
|||||||
return `${(meters / 1000).toFixed(1)}km away`
|
return `${(meters / 1000).toFixed(1)}km away`
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAlertRow(alert: TflAlertData) {
|
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => {
|
||||||
const severityLabel = SEVERITY_LABEL[alert.severity]
|
const { lineName, severity, description, closestStationDistance } = item.data
|
||||||
|
const severityLabel = SEVERITY_LABEL[severity]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<FeedCard>
|
||||||
<SansSerifText
|
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" />
|
||||||
content={`${alert.lineName} · ${severityLabel}`}
|
<SansSerifText content={description} style="text-sm" />
|
||||||
style="text-base font-semibold"
|
{closestStationDistance !== null ? (
|
||||||
/>
|
|
||||||
<SansSerifText content={alert.description} style="text-sm" />
|
|
||||||
{alert.closestStationDistance !== null ? (
|
|
||||||
<SansSerifText
|
<SansSerifText
|
||||||
content={`Nearest station: ${formatDistance(alert.closestStationDistance)}`}
|
content={`Nearest station: ${formatDistance(closestStationDistance)}`}
|
||||||
style="text-xs text-stone-500"
|
style="text-xs text-stone-500"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</FeedCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderTflStatus: FeedItemRenderer<"tfl-status", TflStatusData> = (item) => {
|
|
||||||
return <FeedCard>{item.data.alerts.map((alert) => renderAlertRow(alert))}</FeedCard>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class TflApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||||
const lineIds = lines?.length ? lines : ALL_LINE_IDS
|
const lineIds = lines ?? ALL_LINE_IDS
|
||||||
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
|
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
|
||||||
|
|
||||||
const parsed = lineResponseArray(data)
|
const parsed = lineResponseArray(data)
|
||||||
@@ -101,8 +101,8 @@ export class TflApi {
|
|||||||
return this.stationsCache
|
return this.stationsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stations for all lines in parallel, tolerating individual failures
|
// Fetch stations for all lines in parallel
|
||||||
const results = await Promise.allSettled(
|
const responses = await Promise.all(
|
||||||
ALL_LINE_IDS.map(async (id) => {
|
ALL_LINE_IDS.map(async (id) => {
|
||||||
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
|
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
|
||||||
const parsed = lineStopPointsArray(data)
|
const parsed = lineStopPointsArray(data)
|
||||||
@@ -116,12 +116,7 @@ export class TflApi {
|
|||||||
// Merge stations, combining lines for shared stations
|
// Merge stations, combining lines for shared stations
|
||||||
const stationMap = new Map<string, StationLocation>()
|
const stationMap = new Map<string, StationLocation>()
|
||||||
|
|
||||||
for (const result of results) {
|
for (const { lineId: currentLineId, stops } of responses) {
|
||||||
if (result.status === "rejected") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lineId: currentLineId, stops } = result.value
|
|
||||||
for (const stop of stops) {
|
for (const stop of stops) {
|
||||||
const existing = stationMap.get(stop.naptanId)
|
const existing = stationMap.get(stop.naptanId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -140,15 +135,8 @@ export class TflApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only cache if all requests succeeded — partial results shouldn't persist
|
this.stationsCache = Array.from(stationMap.values())
|
||||||
const allSucceeded = results.every((r) => r.status === "fulfilled")
|
return this.stationsCache
|
||||||
const stations = Array.from(stationMap.values())
|
|
||||||
|
|
||||||
if (allSucceeded) {
|
|
||||||
this.stationsCache = stations
|
|
||||||
}
|
|
||||||
|
|
||||||
return stations
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,15 +138,13 @@ describe("TflSource", () => {
|
|||||||
test("changes which lines are fetched", async () => {
|
test("changes which lines are fetched", async () => {
|
||||||
const source = new TflSource({ client: lineFilteringApi })
|
const source = new TflSource({ client: lineFilteringApi })
|
||||||
const before = await source.fetchItems(createContext())
|
const before = await source.fetchItems(createContext())
|
||||||
expect(before).toHaveLength(1)
|
expect(before.length).toBe(2)
|
||||||
expect(before[0]!.data.alerts).toHaveLength(2)
|
|
||||||
|
|
||||||
source.setLinesOfInterest(["northern"])
|
source.setLinesOfInterest(["northern"])
|
||||||
const after = await source.fetchItems(createContext())
|
const after = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(after).toHaveLength(1)
|
expect(after.length).toBe(1)
|
||||||
expect(after[0]!.data.alerts).toHaveLength(1)
|
expect(after[0]!.data.line).toBe("northern")
|
||||||
expect(after[0]!.data.alerts[0]!.line).toBe("northern")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
|
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
|
||||||
@@ -155,52 +153,23 @@ describe("TflSource", () => {
|
|||||||
lines: ["northern"],
|
lines: ["northern"],
|
||||||
})
|
})
|
||||||
const filtered = await source.fetchItems(createContext())
|
const filtered = await source.fetchItems(createContext())
|
||||||
expect(filtered[0]!.data.alerts).toHaveLength(1)
|
expect(filtered.length).toBe(1)
|
||||||
|
|
||||||
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
||||||
const all = await source.fetchItems(createContext())
|
const all = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(all[0]!.data.alerts).toHaveLength(2)
|
expect(all.length).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("fetchItems", () => {
|
describe("fetchItems", () => {
|
||||||
test("returns at most one feed item", async () => {
|
test("returns feed items array", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
expect(items).toHaveLength(1)
|
expect(Array.isArray(items)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns empty array when no disruptions", async () => {
|
test("feed items have correct base structure", async () => {
|
||||||
const emptyApi: ITflApi = {
|
|
||||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
async fetchStations(): Promise<StationLocation[]> {
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const source = new TflSource({ client: emptyApi })
|
|
||||||
const items = await source.fetchItems(createContext())
|
|
||||||
expect(items).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("combined item has correct base structure", async () => {
|
|
||||||
const source = new TflSource({ client: api })
|
|
||||||
const items = await source.fetchItems(createContext())
|
|
||||||
|
|
||||||
const item = items[0]!
|
|
||||||
expect(item.id).toBe("tfl-status")
|
|
||||||
expect(item.type).toBe("tfl-status")
|
|
||||||
expect(item.sourceId).toBe("aelis.tfl")
|
|
||||||
expect(item.signals).toBeDefined()
|
|
||||||
expect(typeof item.signals!.urgency).toBe("number")
|
|
||||||
expect(item.timestamp).toBeInstanceOf(Date)
|
|
||||||
expect(Array.isArray(item.data.alerts)).toBe(true)
|
|
||||||
expect(item.data.alerts.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("alerts have correct data structure", async () => {
|
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const location: Location = {
|
const location: Location = {
|
||||||
lat: 51.5074,
|
lat: 51.5074,
|
||||||
@@ -209,140 +178,72 @@ describe("TflSource", () => {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}
|
}
|
||||||
const items = await source.fetchItems(createContext(location))
|
const items = await source.fetchItems(createContext(location))
|
||||||
const alerts = items[0]!.data.alerts
|
|
||||||
|
|
||||||
for (const alert of alerts) {
|
for (const item of items) {
|
||||||
expect(typeof alert.line).toBe("string")
|
expect(typeof item.id).toBe("string")
|
||||||
expect(typeof alert.lineName).toBe("string")
|
expect(item.id).toMatch(/^tfl-alert-/)
|
||||||
expect(["minor-delays", "major-delays", "closure"]).toContain(alert.severity)
|
expect(item.type).toBe("tfl-alert")
|
||||||
expect(typeof alert.description).toBe("string")
|
expect(item.signals).toBeDefined()
|
||||||
|
expect(typeof item.signals!.urgency).toBe("number")
|
||||||
|
expect(item.timestamp).toBeInstanceOf(Date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items have correct data structure", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const location: Location = {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.data.line).toBe("string")
|
||||||
|
expect(typeof item.data.lineName).toBe("string")
|
||||||
|
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
||||||
|
expect(typeof item.data.description).toBe("string")
|
||||||
expect(
|
expect(
|
||||||
alert.closestStationDistance === null || typeof alert.closestStationDistance === "number",
|
item.data.closestStationDistance === null ||
|
||||||
|
typeof item.data.closestStationDistance === "number",
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("signals use highest severity urgency", async () => {
|
test("feed item ids are unique", async () => {
|
||||||
const mixedApi: ITflApi = {
|
const source = new TflSource({ client: api })
|
||||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
lineId: "northern",
|
|
||||||
lineName: "Northern",
|
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Minor delays",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lineId: "central",
|
|
||||||
lineName: "Central",
|
|
||||||
severity: "closure",
|
|
||||||
description: "Closed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lineId: "jubilee",
|
|
||||||
lineName: "Jubilee",
|
|
||||||
severity: "major-delays",
|
|
||||||
description: "Major delays",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
async fetchStations(): Promise<StationLocation[]> {
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const source = new TflSource({ client: mixedApi })
|
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(items[0]!.signals!.urgency).toBe(1.0) // closure urgency
|
const ids = items.map((item) => item.id)
|
||||||
expect(items[0]!.signals!.timeRelevance).toBe("imminent") // closure time relevance
|
const uniqueIds = new Set(ids)
|
||||||
|
expect(uniqueIds.size).toBe(ids.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("signals use single alert severity when only one disruption", async () => {
|
test("feed items are sorted by urgency descending", async () => {
|
||||||
const singleApi: ITflApi = {
|
const source = new TflSource({ client: api })
|
||||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
lineId: "northern",
|
|
||||||
lineName: "Northern",
|
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Minor delays",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
async fetchStations(): Promise<StationLocation[]> {
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const source = new TflSource({ client: singleApi })
|
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(items[0]!.signals!.urgency).toBe(0.6) // minor-delays urgency
|
for (let i = 1; i < items.length; i++) {
|
||||||
expect(items[0]!.signals!.timeRelevance).toBe("upcoming")
|
const prev = items[i - 1]!
|
||||||
|
const curr = items[i]!
|
||||||
|
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("alerts sorted by closestStationDistance ascending, nulls last", async () => {
|
test("urgency values match severity levels", async () => {
|
||||||
const distanceApi: ITflApi = {
|
const source = new TflSource({ client: api })
|
||||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
const items = await source.fetchItems(createContext())
|
||||||
return [
|
|
||||||
{
|
|
||||||
lineId: "northern",
|
|
||||||
lineName: "Northern",
|
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Delays",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lineId: "central",
|
|
||||||
lineName: "Central",
|
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Delays",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lineId: "jubilee",
|
|
||||||
lineName: "Jubilee",
|
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Delays",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
async fetchStations(): Promise<StationLocation[]> {
|
|
||||||
return [
|
|
||||||
{ id: "s1", name: "Station A", lat: 51.51, lng: -0.13, lines: ["central"] },
|
|
||||||
{ id: "s2", name: "Station B", lat: 51.52, lng: -0.14, lines: ["northern"] },
|
|
||||||
// No stations for jubilee — its distance will be null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const source = new TflSource({ client: distanceApi })
|
|
||||||
const location: Location = {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
const items = await source.fetchItems(createContext(location))
|
|
||||||
const alerts = items[0]!.data.alerts
|
|
||||||
|
|
||||||
// Alerts with distances should come before nulls
|
const severityUrgency: Record<string, number> = {
|
||||||
const withDistance = alerts.filter((a) => a.closestStationDistance !== null)
|
closure: 1.0,
|
||||||
const withoutDistance = alerts.filter((a) => a.closestStationDistance === null)
|
"major-delays": 0.8,
|
||||||
|
"minor-delays": 0.6,
|
||||||
// All distance alerts come first
|
|
||||||
const firstNullIndex = alerts.findIndex((a) => a.closestStationDistance === null)
|
|
||||||
if (firstNullIndex !== -1) {
|
|
||||||
for (let i = 0; i < firstNullIndex; i++) {
|
|
||||||
expect(alerts[i]!.closestStationDistance).not.toBeNull()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distance alerts are in ascending order
|
for (const item of items) {
|
||||||
for (let i = 1; i < withDistance.length; i++) {
|
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
|
||||||
expect(withDistance[i]!.closestStationDistance!).toBeGreaterThanOrEqual(
|
|
||||||
withDistance[i - 1]!.closestStationDistance!,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(withoutDistance.length).toBe(1)
|
|
||||||
expect(withoutDistance[0]!.line).toBe("jubilee")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("closestStationDistance is number when location provided", async () => {
|
test("closestStationDistance is number when location provided", async () => {
|
||||||
@@ -355,9 +256,9 @@ describe("TflSource", () => {
|
|||||||
}
|
}
|
||||||
const items = await source.fetchItems(createContext(location))
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
for (const alert of items[0]!.data.alerts) {
|
for (const item of items) {
|
||||||
expect(typeof alert.closestStationDistance).toBe("number")
|
expect(typeof item.data.closestStationDistance).toBe("number")
|
||||||
expect(alert.closestStationDistance!).toBeGreaterThan(0)
|
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -365,8 +266,8 @@ describe("TflSource", () => {
|
|||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
for (const alert of items[0]!.data.alerts) {
|
for (const item of items) {
|
||||||
expect(alert.closestStationDistance).toBeNull()
|
expect(item.data.closestStationDistance).toBeNull()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -408,9 +309,8 @@ describe("TflSource", () => {
|
|||||||
await source.executeAction("set-lines-of-interest", ["northern"])
|
await source.executeAction("set-lines-of-interest", ["northern"])
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
expect(items).toHaveLength(1)
|
expect(items.length).toBe(1)
|
||||||
expect(items[0]!.data.alerts).toHaveLength(1)
|
expect(items[0]!.data.line).toBe("northern")
|
||||||
expect(items[0]!.data.alerts[0]!.line).toBe("northern")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("executeAction throws on invalid input", async () => {
|
test("executeAction throws on invalid input", async () => {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import type {
|
|||||||
ITflApi,
|
ITflApi,
|
||||||
StationLocation,
|
StationLocation,
|
||||||
TflAlertData,
|
TflAlertData,
|
||||||
|
TflAlertFeedItem,
|
||||||
TflAlertSeverity,
|
TflAlertSeverity,
|
||||||
TflLineId,
|
TflLineId,
|
||||||
TflSourceOptions,
|
TflSourceOptions,
|
||||||
TflStatusFeedItem,
|
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { TflApi, lineId } from "./tfl-api.ts"
|
import { TflApi, lineId } from "./tfl-api.ts"
|
||||||
@@ -51,7 +51,7 @@ const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
|
|||||||
* const { items } = await engine.refresh()
|
* const { items } = await engine.refresh()
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class TflSource implements FeedSource<TflStatusFeedItem> {
|
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||||
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
||||||
"bakerloo",
|
"bakerloo",
|
||||||
"central",
|
"central",
|
||||||
@@ -84,7 +84,7 @@ export class TflSource implements FeedSource<TflStatusFeedItem> {
|
|||||||
throw new Error("Either client or apiKey must be provided")
|
throw new Error("Either client or apiKey must be provided")
|
||||||
}
|
}
|
||||||
this.client = options.client ?? new TflApi(options.apiKey!)
|
this.client = options.client ?? new TflApi(options.apiKey!)
|
||||||
this.lines = options.lines?.length ? options.lines : [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
@@ -123,58 +123,56 @@ export class TflSource implements FeedSource<TflStatusFeedItem> {
|
|||||||
this.lines = lines
|
this.lines = lines
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<TflStatusFeedItem[]> {
|
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
||||||
const [statuses, stations] = await Promise.all([
|
const [statuses, stations] = await Promise.all([
|
||||||
this.client.fetchLineStatuses(this.lines),
|
this.client.fetchLineStatuses(this.lines),
|
||||||
this.client.fetchStations(),
|
this.client.fetchStations(),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (statuses.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const location = context.get(LocationKey)
|
const location = context.get(LocationKey)
|
||||||
|
|
||||||
const alerts: TflAlertData[] = statuses.map((status) => ({
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||||
line: status.lineId,
|
const closestStationDistance = location
|
||||||
lineName: status.lineName,
|
|
||||||
severity: status.severity,
|
|
||||||
description: status.description,
|
|
||||||
closestStationDistance: location
|
|
||||||
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
||||||
: null,
|
: null
|
||||||
}))
|
|
||||||
|
|
||||||
// Sort by closest station distance ascending, nulls last
|
const data: TflAlertData = {
|
||||||
alerts.sort((a, b) => {
|
line: status.lineId,
|
||||||
if (a.closestStationDistance === null && b.closestStationDistance === null) return 0
|
lineName: status.lineName,
|
||||||
if (a.closestStationDistance === null) return 1
|
severity: status.severity,
|
||||||
if (b.closestStationDistance === null) return -1
|
description: status.description,
|
||||||
return a.closestStationDistance - b.closestStationDistance
|
closestStationDistance,
|
||||||
|
}
|
||||||
|
|
||||||
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: SEVERITY_URGENCY[status.severity],
|
||||||
|
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
|
sourceId: this.id,
|
||||||
|
type: TflFeedItemType.Alert,
|
||||||
|
timestamp: context.time,
|
||||||
|
data,
|
||||||
|
signals,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Signals from the highest-severity alert
|
// Sort by urgency (desc), then by proximity (asc) if location available
|
||||||
const highestSeverity = alerts.reduce<TflAlertSeverity>(
|
items.sort((a, b) => {
|
||||||
(worst, alert) =>
|
const aUrgency = a.signals?.urgency ?? 0
|
||||||
SEVERITY_URGENCY[alert.severity] > SEVERITY_URGENCY[worst] ? alert.severity : worst,
|
const bUrgency = b.signals?.urgency ?? 0
|
||||||
alerts[0]!.severity,
|
if (bUrgency !== aUrgency) {
|
||||||
)
|
return bUrgency - aUrgency
|
||||||
|
}
|
||||||
|
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||||
|
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
const signals: FeedItemSignals = {
|
return items
|
||||||
urgency: SEVERITY_URGENCY[highestSeverity],
|
|
||||||
timeRelevance: SEVERITY_TIME_RELEVANCE[highestSeverity],
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "tfl-status",
|
|
||||||
sourceId: this.id,
|
|
||||||
type: TflFeedItemType.Status,
|
|
||||||
timestamp: context.time,
|
|
||||||
data: { alerts },
|
|
||||||
signals,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,19 +22,12 @@ export interface TflAlertData extends Record<string, unknown> {
|
|||||||
|
|
||||||
export const TflFeedItemType = {
|
export const TflFeedItemType = {
|
||||||
Alert: "tfl-alert",
|
Alert: "tfl-alert",
|
||||||
Status: "tfl-status",
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
||||||
|
|
||||||
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
||||||
|
|
||||||
export interface TflStatusData extends Record<string, unknown> {
|
|
||||||
alerts: TflAlertData[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TflStatusFeedItem = FeedItem<typeof TflFeedItemType.Status, TflStatusData>
|
|
||||||
|
|
||||||
export interface TflSourceOptions {
|
export interface TflSourceOptions {
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
client?: ITflApi
|
client?: ITflApi
|
||||||
|
|||||||
@@ -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,16 +48,12 @@ 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
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export type DailyWeatherEntry = {
|
export type DailyWeatherData = {
|
||||||
forecastDate: Date
|
forecastDate: Date
|
||||||
conditionCode: ConditionCode
|
conditionCode: ConditionCode
|
||||||
maxUvIndex: number
|
maxUvIndex: number
|
||||||
@@ -71,10 +67,6 @@ export type DailyWeatherEntry = {
|
|||||||
temperatureMin: number
|
temperatureMin: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DailyWeatherData = {
|
|
||||||
days: DailyWeatherEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyWeatherFeedItem extends FeedItem<
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Daily,
|
typeof WeatherFeedItemType.Daily,
|
||||||
DailyWeatherData
|
DailyWeatherData
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ export {
|
|||||||
type CurrentWeatherData,
|
type CurrentWeatherData,
|
||||||
type HourlyWeatherFeedItem,
|
type HourlyWeatherFeedItem,
|
||||||
type HourlyWeatherData,
|
type HourlyWeatherData,
|
||||||
type HourlyWeatherEntry,
|
|
||||||
type DailyWeatherFeedItem,
|
type DailyWeatherFeedItem,
|
||||||
type DailyWeatherData,
|
type DailyWeatherData,
|
||||||
type DailyWeatherEntry,
|
|
||||||
type WeatherAlertFeedItem,
|
type WeatherAlertFeedItem,
|
||||||
type WeatherAlertData,
|
type WeatherAlertData,
|
||||||
} from "./feed-items"
|
} from "./feed-items"
|
||||||
|
|||||||
@@ -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, DailyForecast } 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 DailyWeatherData, 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,125 +131,8 @@ 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(1)
|
|
||||||
expect((dailyItems[0]!.data as DailyWeatherData).days.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("produces a single daily item with days array", async () => {
|
|
||||||
const source = new WeatherSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
|
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
|
||||||
expect(dailyItems.length).toBe(1)
|
|
||||||
|
|
||||||
const dailyData = dailyItems[0]!.data as DailyWeatherData
|
|
||||||
expect(Array.isArray(dailyData.days)).toBe(true)
|
|
||||||
expect(dailyData.days.length).toBeGreaterThan(0)
|
|
||||||
expect(dailyData.days.length).toBeLessThanOrEqual(7)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("averages urgency across days with mixed conditions", async () => {
|
|
||||||
const mildDay: DailyForecast = {
|
|
||||||
forecastStart: "2026-01-17T00:00:00Z",
|
|
||||||
forecastEnd: "2026-01-18T00:00:00Z",
|
|
||||||
conditionCode: "Clear",
|
|
||||||
maxUvIndex: 3,
|
|
||||||
moonPhase: "firstQuarter",
|
|
||||||
precipitationAmount: 0,
|
|
||||||
precipitationChance: 0,
|
|
||||||
precipitationType: "clear",
|
|
||||||
snowfallAmount: 0,
|
|
||||||
sunrise: "2026-01-17T07:00:00Z",
|
|
||||||
sunriseCivil: "2026-01-17T06:30:00Z",
|
|
||||||
sunriseNautical: "2026-01-17T06:00:00Z",
|
|
||||||
sunriseAstronomical: "2026-01-17T05:30:00Z",
|
|
||||||
sunset: "2026-01-17T17:00:00Z",
|
|
||||||
sunsetCivil: "2026-01-17T17:30:00Z",
|
|
||||||
sunsetNautical: "2026-01-17T18:00:00Z",
|
|
||||||
sunsetAstronomical: "2026-01-17T18:30:00Z",
|
|
||||||
temperatureMax: 15,
|
|
||||||
temperatureMin: 5,
|
|
||||||
}
|
|
||||||
const severeDay: DailyForecast = {
|
|
||||||
...mildDay,
|
|
||||||
forecastStart: "2026-01-18T00:00:00Z",
|
|
||||||
forecastEnd: "2026-01-19T00:00:00Z",
|
|
||||||
conditionCode: "SevereThunderstorm",
|
|
||||||
}
|
|
||||||
const mixedResponse: WeatherKitResponse = {
|
|
||||||
forecastDaily: { days: [mildDay, severeDay] },
|
|
||||||
}
|
|
||||||
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
const dailyItem = items.find((i) => i.type === WeatherFeedItemType.Daily)
|
|
||||||
|
|
||||||
expect(dailyItem).toBeDefined()
|
|
||||||
// Mild urgency = 0.2, severe urgency = 0.5, average = 0.35
|
|
||||||
expect(dailyItem!.signals!.urgency).toBeCloseTo(0.35, 5)
|
|
||||||
// Worst-case: SevereThunderstorm → Imminent
|
|
||||||
expect(dailyItem!.signals!.timeRelevance).toBe("imminent")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sets timestamp from context.time", async () => {
|
test("sets timestamp from context.time", async () => {
|
||||||
|
|||||||
@@ -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 DailyWeatherEntry, 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,15 +174,21 @@ 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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.forecastDaily?.days) {
|
if (response.forecastDaily?.days) {
|
||||||
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
||||||
if (days.length > 0) {
|
for (let i = 0; i < days.length; i++) {
|
||||||
items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
|
const day = days[i]
|
||||||
|
if (day) {
|
||||||
|
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,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,
|
||||||
@@ -342,43 +354,29 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDailyForecastFeedItem(
|
function createDailyWeatherFeedItem(
|
||||||
dailyForecasts: DailyForecast[],
|
daily: DailyForecast,
|
||||||
|
index: number,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const days: DailyWeatherEntry[] = []
|
const signals: FeedItemSignals = {
|
||||||
let totalUrgency = 0
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||||
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
|
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
for (const daily of dailyForecasts) {
|
return {
|
||||||
days.push({
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
|
sourceId,
|
||||||
|
type: WeatherFeedItemType.Daily,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
conditionCode: daily.conditionCode,
|
conditionCode: daily.conditionCode,
|
||||||
maxUvIndex: daily.maxUvIndex,
|
maxUvIndex: daily.maxUvIndex,
|
||||||
@@ -390,27 +388,7 @@ function createDailyForecastFeedItem(
|
|||||||
sunset: new Date(daily.sunset),
|
sunset: new Date(daily.sunset),
|
||||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||||
})
|
},
|
||||||
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode)
|
|
||||||
const rel = timeRelevanceForCondition(daily.conditionCode)
|
|
||||||
if (rel === TimeRelevance.Imminent) {
|
|
||||||
worstTimeRelevance = TimeRelevance.Imminent
|
|
||||||
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
|
|
||||||
worstTimeRelevance = TimeRelevance.Upcoming
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const signals: FeedItemSignals = {
|
|
||||||
urgency: totalUrgency / days.length,
|
|
||||||
timeRelevance: worstTimeRelevance,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `weather-daily-${timestamp.getTime()}`,
|
|
||||||
sourceId,
|
|
||||||
type: WeatherFeedItemType.Daily,
|
|
||||||
timestamp,
|
|
||||||
data: { days },
|
|
||||||
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