Compare commits

..

1 Commits

Author SHA1 Message Date
5b48df2778 feat: register TfL source provider
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 14:32:00 +00:00
17 changed files with 203 additions and 523 deletions

View File

@@ -26,12 +26,6 @@ services:
commands: commands:
start: | start: |
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http 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 cd apps/aelis-backend && bun run dev
admin-dashboard: admin-dashboard:

View File

@@ -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 : ""
} }

View File

@@ -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[]> {

View File

@@ -6,7 +6,6 @@ import {
CircleDot, CircleDot,
CloudSun, CloudSun,
Loader2, Loader2,
TrainFront,
LogOut, LogOut,
MapPin, MapPin,
Rss, Rss,
@@ -42,7 +41,6 @@ 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({

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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")
}) })
}) })

View File

@@ -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>
}

View File

@@ -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
} }
} }

View File

@@ -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 () => {

View File

@@ -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,
},
]
} }
} }

View File

@@ -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

View File

@@ -57,7 +57,7 @@ export interface HourlyWeatherFeedItem extends FeedItem<
HourlyWeatherData HourlyWeatherData
> {} > {}
export type DailyWeatherEntry = { export type DailyWeatherData = {
forecastDate: Date forecastDate: Date
conditionCode: ConditionCode conditionCode: ConditionCode
maxUvIndex: number maxUvIndex: number
@@ -71,10 +71,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

View File

@@ -11,7 +11,6 @@ export {
type HourlyWeatherEntry, 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"

View File

@@ -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, HourlyForecast } 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, type HourlyWeatherData } 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"
@@ -133,8 +133,7 @@ describe("WeatherSource", () => {
expect(hourlyItems.length).toBe(1) expect(hourlyItems.length).toBe(1)
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3) expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
expect(dailyItems.length).toBe(1) expect(dailyItems.length).toBe(2)
expect((dailyItems[0]!.data as DailyWeatherData).days.length).toBe(2)
}) })
test("produces a single hourly item with hours array", async () => { test("produces a single hourly item with hours array", async () => {
@@ -193,65 +192,6 @@ describe("WeatherSource", () => {
expect(hourlyItem!.signals!.timeRelevance).toBe("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 () => {
const source = new WeatherSource({ client: mockClient }) const source = new WeatherSource({ client: mockClient })
const queryTime = new Date("2026-01-17T12:00:00Z") const queryTime = new Date("2026-01-17T12:00:00Z")

View File

@@ -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 HourlyWeatherEntry, 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 {
@@ -181,8 +181,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
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))
}
} }
} }
@@ -367,18 +370,24 @@ function createHourlyForecastFeedItem(
} }
} }
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 +399,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,
} }
} }