Compare commits

..

7 Commits

Author SHA1 Message Date
4097470656 feat: switch default LLM to glm-4.7-flash (#108)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-30 00:00:53 +01:00
f549859a44 feat: combine TFL alerts into single feed item (#107)
TflSource.fetchItems() now returns one TflStatusFeedItem with an
alerts array instead of separate items per line disruption. Signals
use the highest severity. Alerts sorted by station distance.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-30 00:00:41 +01:00
1483805f13 fix: handle empty lines array in TFL source (#106)
Empty lines array caused fetchLineStatuses to build /Line//Status
URL, resulting in a 404 from the TFL API. Now defaults to all
lines when the array is empty.

Also switches fetchStations to Promise.allSettled so individual
line failures don't break the entire station fetch.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 23:19:34 +01:00
68932f83c3 feat: enable bun debugger for backend dev server (#105)
Add --inspect flag to the dev script and print the
debug.bun.sh URL with the Tailscale IP in the automation.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 22:19:02 +01:00
4916886adf feat: combine daily weather into single feed item (#102)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:40:47 +01:00
f1c2f399f2 feat: add TfL source config to admin dashboard (#104)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:36:32 +01:00
7a85990c24 feat: register TfL source provider (#103)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:34:50 +01:00
15 changed files with 451 additions and 200 deletions

View File

@@ -26,6 +26,12 @@ services:
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:

View File

@@ -4,7 +4,7 @@
"type": "module",
"main": "src/server.ts",
"scripts": {
"dev": "bun run --watch src/server.ts",
"dev": "bun run --watch --inspect=0.0.0.0:6499 src/server.ts",
"start": "bun run src/server.ts",
"test": "bun test src/",
"db:generate": "bunx drizzle-kit generate",

View File

@@ -4,7 +4,7 @@ import type { EnhancementResult } from "./schema.ts"
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
const DEFAULT_MODEL = "openai/gpt-4.1-mini"
const DEFAULT_MODEL = "z-ai/glm-4.7-flash"
const DEFAULT_TIMEOUT_MS = 30_000
export interface LlmClientConfig {

View File

@@ -14,6 +14,7 @@ import { registerLocationHttpHandlers } from "./location/http.ts"
import { LocationSourceProvider } from "./location/provider.ts"
import { UserSessionManager } from "./session/index.ts"
import { registerSourcesHttpHandlers } from "./sources/http.ts"
import { TflSourceProvider } from "./tfl/provider.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
function main() {
@@ -45,6 +46,7 @@ function main() {
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
},
}),
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
],
feedEnhancer,
})

View File

@@ -10,5 +10,7 @@ export {
type TflAlertSeverity,
type TflLineStatus,
type TflSourceOptions,
type TflStatusData,
type TflStatusFeedItem,
} from "./types.ts"
export { renderTflAlert } from "./renderer.tsx"
export { renderTflStatus } from "./renderer.tsx"

View File

@@ -2,102 +2,140 @@
import { render } from "@nym.sh/jrx"
import { describe, expect, test } from "bun:test"
import type { TflAlertFeedItem } from "./types.ts"
import type { TflAlertData, TflStatusFeedItem } from "./types.ts"
import { renderTflAlert } from "./renderer.tsx"
import { renderTflStatus } from "./renderer.tsx"
function makeItem(overrides: Partial<TflAlertFeedItem["data"]> = {}): TflAlertFeedItem {
function makeAlert(overrides: Partial<TflAlertData> = {}): TflAlertData {
return {
id: "tfl-alert-northern-minor-delays",
type: "tfl-alert",
timestamp: new Date("2026-01-15T12:00:00Z"),
data: {
line: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays due to signal failure",
closestStationDistance: null,
...overrides,
},
line: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays due to signal failure",
closestStationDistance: null,
...overrides,
}
}
describe("renderTflAlert", () => {
test("renders a FeedCard with title and description", () => {
const node = renderTflAlert(makeItem())
function makeItem(alerts: TflAlertData[]): TflStatusFeedItem {
return {
id: "tfl-status",
sourceId: "aelis.tfl",
type: "tfl-status",
timestamp: new Date("2026-01-15T12:00:00Z"),
data: { alerts },
}
}
/** Collect all SansSerifText elements from a rendered spec, filtering out Fragments. */
function collectTextElements(spec: ReturnType<typeof render>) {
return Object.values(spec.elements).filter((el) => el.type === "SansSerifText")
}
describe("renderTflStatus", () => {
test("renders a single FeedCard", () => {
const node = renderTflStatus(makeItem([makeAlert()]))
const spec = render(node)
const root = spec.elements[spec.root]!
expect(root.type).toBe("FeedCard")
expect(root.children!.length).toBeGreaterThanOrEqual(2)
})
const title = spec.elements[root.children![0]!]!
expect(title.type).toBe("SansSerifText")
expect(title.props.content).toBe("Northern · Minor delays")
test("renders one alert with title and description", () => {
const node = renderTflStatus(makeItem([makeAlert()]))
const spec = render(node)
const body = spec.elements[root.children![1]!]!
expect(body.type).toBe("SansSerifText")
expect(body.props.content).toBe("Minor delays due to signal failure")
const texts = collectTextElements(spec)
const titleText = texts.find((el) => el.props.content === "Northern · Minor delays")
const bodyText = texts.find((el) => el.props.content === "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", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 }))
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.35 })]))
const spec = render(node)
const root = spec.elements[spec.root]!
expect(root.children).toHaveLength(3)
const caption = spec.elements[root.children![2]!]!
expect(caption.type).toBe("SansSerifText")
expect(caption.props.content).toBe("Nearest station: 350m away")
const texts = collectTextElements(spec)
const caption = texts.find((el) => el.props.content === "Nearest station: 350m away")
expect(caption).toBeDefined()
})
test("formats distance in km when >= 1km", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 }))
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 2.456 })]))
const spec = render(node)
const root = spec.elements[spec.root]!
const caption = spec.elements[root.children![2]!]!
expect(caption.props.content).toBe("Nearest station: 2.5km away")
const texts = collectTextElements(spec)
const caption = texts.find((el) => el.props.content === "Nearest station: 2.5km away")
expect(caption).toBeDefined()
})
test("formats near-1km boundary as km not meters", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 0.9999 }))
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.9999 })]))
const spec = render(node)
const root = spec.elements[spec.root]!
const caption = spec.elements[root.children![2]!]!
expect(caption.props.content).toBe("Nearest station: 1.0km away")
const texts = collectTextElements(spec)
const caption = texts.find((el) => el.props.content === "Nearest station: 1.0km away")
expect(caption).toBeDefined()
})
test("omits station distance when null", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: null }))
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: null })]))
const spec = render(node)
const root = spec.elements[spec.root]!
// Title + body only, no caption (empty fragment doesn't produce a child)
const children = root.children!.filter((key) => {
const el = spec.elements[key]
return el && el.type !== "Fragment"
})
expect(children).toHaveLength(2)
const texts = collectTextElements(spec)
const distanceTexts = texts.filter((el) =>
(el.props.content as string).startsWith("Nearest station:"),
)
expect(distanceTexts).toHaveLength(0)
})
test("renders closure severity label", () => {
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" }))
const node = renderTflStatus(
makeItem([makeAlert({ severity: "closure", lineName: "Central" })]),
)
const spec = render(node)
const root = spec.elements[spec.root]!
const title = spec.elements[root.children![0]!]!
expect(title.props.content).toBe("Central · Closed")
const texts = collectTextElements(spec)
const title = texts.find((el) => el.props.content === "Central · Closed")
expect(title).toBeDefined()
})
test("renders major delays severity label", () => {
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" }))
const node = renderTflStatus(
makeItem([makeAlert({ severity: "major-delays", lineName: "Jubilee" })]),
)
const spec = render(node)
const root = spec.elements[spec.root]!
const title = spec.elements[root.children![0]!]!
expect(title.props.content).toBe("Jubilee · Major delays")
const texts = collectTextElements(spec)
const title = texts.find((el) => el.props.content === "Jubilee · Major delays")
expect(title).toBeDefined()
})
})

View File

@@ -3,7 +3,7 @@ import type { FeedItemRenderer } from "@aelis/core"
import { FeedCard, SansSerifText } from "@aelis/components"
import type { TflAlertData } from "./types.ts"
import type { TflAlertData, TflStatusData } from "./types.ts"
import { TflAlertSeverity } from "./types.ts"
@@ -21,20 +21,26 @@ function formatDistance(km: number): string {
return `${(meters / 1000).toFixed(1)}km away`
}
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => {
const { lineName, severity, description, closestStationDistance } = item.data
const severityLabel = SEVERITY_LABEL[severity]
function renderAlertRow(alert: TflAlertData) {
const severityLabel = SEVERITY_LABEL[alert.severity]
return (
<FeedCard>
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" />
<SansSerifText content={description} style="text-sm" />
{closestStationDistance !== null ? (
<>
<SansSerifText
content={`${alert.lineName} · ${severityLabel}`}
style="text-base font-semibold"
/>
<SansSerifText content={alert.description} style="text-sm" />
{alert.closestStationDistance !== null ? (
<SansSerifText
content={`Nearest station: ${formatDistance(closestStationDistance)}`}
content={`Nearest station: ${formatDistance(alert.closestStationDistance)}`}
style="text-xs text-stone-500"
/>
) : 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[]> {
const lineIds = lines ?? ALL_LINE_IDS
const lineIds = lines?.length ? lines : ALL_LINE_IDS
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
const parsed = lineResponseArray(data)
@@ -101,8 +101,8 @@ export class TflApi {
return this.stationsCache
}
// Fetch stations for all lines in parallel
const responses = await Promise.all(
// Fetch stations for all lines in parallel, tolerating individual failures
const results = await Promise.allSettled(
ALL_LINE_IDS.map(async (id) => {
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
const parsed = lineStopPointsArray(data)
@@ -116,7 +116,12 @@ export class TflApi {
// Merge stations, combining lines for shared stations
const stationMap = new Map<string, StationLocation>()
for (const { lineId: currentLineId, stops } of responses) {
for (const result of results) {
if (result.status === "rejected") {
continue
}
const { lineId: currentLineId, stops } = result.value
for (const stop of stops) {
const existing = stationMap.get(stop.naptanId)
if (existing) {
@@ -135,8 +140,15 @@ export class TflApi {
}
}
this.stationsCache = Array.from(stationMap.values())
return this.stationsCache
// Only cache if all requests succeeded — partial results shouldn't persist
const allSucceeded = results.every((r) => r.status === "fulfilled")
const stations = Array.from(stationMap.values())
if (allSucceeded) {
this.stationsCache = stations
}
return stations
}
}

View File

@@ -138,13 +138,15 @@ describe("TflSource", () => {
test("changes which lines are fetched", async () => {
const source = new TflSource({ client: lineFilteringApi })
const before = await source.fetchItems(createContext())
expect(before.length).toBe(2)
expect(before).toHaveLength(1)
expect(before[0]!.data.alerts).toHaveLength(2)
source.setLinesOfInterest(["northern"])
const after = await source.fetchItems(createContext())
expect(after.length).toBe(1)
expect(after[0]!.data.line).toBe("northern")
expect(after).toHaveLength(1)
expect(after[0]!.data.alerts).toHaveLength(1)
expect(after[0]!.data.alerts[0]!.line).toBe("northern")
})
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
@@ -153,23 +155,52 @@ describe("TflSource", () => {
lines: ["northern"],
})
const filtered = await source.fetchItems(createContext())
expect(filtered.length).toBe(1)
expect(filtered[0]!.data.alerts).toHaveLength(1)
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
const all = await source.fetchItems(createContext())
expect(all.length).toBe(2)
expect(all[0]!.data.alerts).toHaveLength(2)
})
})
describe("fetchItems", () => {
test("returns feed items array", async () => {
test("returns at most one feed item", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
expect(Array.isArray(items)).toBe(true)
expect(items).toHaveLength(1)
})
test("feed items have correct base structure", async () => {
test("returns empty array when no disruptions", 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 location: Location = {
lat: 51.5074,
@@ -178,72 +209,140 @@ describe("TflSource", () => {
timestamp: new Date(),
}
const items = await source.fetchItems(createContext(location))
const alerts = items[0]!.data.alerts
for (const item of items) {
expect(typeof item.id).toBe("string")
expect(item.id).toMatch(/^tfl-alert-/)
expect(item.type).toBe("tfl-alert")
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")
for (const alert of alerts) {
expect(typeof alert.line).toBe("string")
expect(typeof alert.lineName).toBe("string")
expect(["minor-delays", "major-delays", "closure"]).toContain(alert.severity)
expect(typeof alert.description).toBe("string")
expect(
item.data.closestStationDistance === null ||
typeof item.data.closestStationDistance === "number",
alert.closestStationDistance === null || typeof alert.closestStationDistance === "number",
).toBe(true)
}
})
test("feed item ids are unique", async () => {
const source = new TflSource({ client: api })
test("signals use highest severity urgency", async () => {
const mixedApi: ITflApi = {
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 ids = items.map((item) => item.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
expect(items[0]!.signals!.urgency).toBe(1.0) // closure urgency
expect(items[0]!.signals!.timeRelevance).toBe("imminent") // closure time relevance
})
test("feed items are sorted by urgency descending", async () => {
const source = new TflSource({ client: api })
test("signals use single alert severity when only one disruption", async () => {
const singleApi: ITflApi = {
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())
for (let i = 1; i < items.length; i++) {
const prev = items[i - 1]!
const curr = items[i]!
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
}
expect(items[0]!.signals!.urgency).toBe(0.6) // minor-delays urgency
expect(items[0]!.signals!.timeRelevance).toBe("upcoming")
})
test("urgency values match severity levels", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
test("alerts sorted by closestStationDistance ascending, nulls last", async () => {
const distanceApi: ITflApi = {
async fetchLineStatuses(): Promise<TflLineStatus[]> {
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
const severityUrgency: Record<string, number> = {
closure: 1.0,
"major-delays": 0.8,
"minor-delays": 0.6,
// Alerts with distances should come before nulls
const withDistance = alerts.filter((a) => a.closestStationDistance !== null)
const withoutDistance = alerts.filter((a) => a.closestStationDistance === null)
// 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()
}
}
for (const item of items) {
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
// Distance alerts are in ascending order
for (let i = 1; i < withDistance.length; i++) {
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 () => {
@@ -256,9 +355,9 @@ describe("TflSource", () => {
}
const items = await source.fetchItems(createContext(location))
for (const item of items) {
expect(typeof item.data.closestStationDistance).toBe("number")
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
for (const alert of items[0]!.data.alerts) {
expect(typeof alert.closestStationDistance).toBe("number")
expect(alert.closestStationDistance!).toBeGreaterThan(0)
}
})
@@ -266,8 +365,8 @@ describe("TflSource", () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
for (const item of items) {
expect(item.data.closestStationDistance).toBeNull()
for (const alert of items[0]!.data.alerts) {
expect(alert.closestStationDistance).toBeNull()
}
})
})
@@ -309,8 +408,9 @@ describe("TflSource", () => {
await source.executeAction("set-lines-of-interest", ["northern"])
const items = await source.fetchItems(createContext())
expect(items.length).toBe(1)
expect(items[0]!.data.line).toBe("northern")
expect(items).toHaveLength(1)
expect(items[0]!.data.alerts).toHaveLength(1)
expect(items[0]!.data.alerts[0]!.line).toBe("northern")
})
test("executeAction throws on invalid input", async () => {

View File

@@ -8,10 +8,10 @@ import type {
ITflApi,
StationLocation,
TflAlertData,
TflAlertFeedItem,
TflAlertSeverity,
TflLineId,
TflSourceOptions,
TflStatusFeedItem,
} from "./types.ts"
import { TflApi, lineId } from "./tfl-api.ts"
@@ -51,7 +51,7 @@ const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
* const { items } = await engine.refresh()
* ```
*/
export class TflSource implements FeedSource<TflAlertFeedItem> {
export class TflSource implements FeedSource<TflStatusFeedItem> {
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
"bakerloo",
"central",
@@ -84,7 +84,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
throw new Error("Either client or apiKey must be provided")
}
this.client = options.client ?? new TflApi(options.apiKey!)
this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST]
this.lines = options.lines?.length ? options.lines : [...TflSource.DEFAULT_LINES_OF_INTEREST]
}
async listActions(): Promise<Record<string, ActionDefinition>> {
@@ -123,56 +123,58 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
this.lines = lines
}
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
async fetchItems(context: Context): Promise<TflStatusFeedItem[]> {
const [statuses, stations] = await Promise.all([
this.client.fetchLineStatuses(this.lines),
this.client.fetchStations(),
])
if (statuses.length === 0) {
return []
}
const location = context.get(LocationKey)
const items: TflAlertFeedItem[] = statuses.map((status) => {
const closestStationDistance = location
const alerts: TflAlertData[] = statuses.map((status) => ({
line: status.lineId,
lineName: status.lineName,
severity: status.severity,
description: status.description,
closestStationDistance: location
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
: null
: null,
}))
const data: TflAlertData = {
line: status.lineId,
lineName: status.lineName,
severity: status.severity,
description: status.description,
closestStationDistance,
}
// Sort by closest station distance ascending, nulls last
alerts.sort((a, b) => {
if (a.closestStationDistance === null && b.closestStationDistance === null) return 0
if (a.closestStationDistance === null) return 1
if (b.closestStationDistance === null) return -1
return a.closestStationDistance - b.closestStationDistance
})
const signals: FeedItemSignals = {
urgency: SEVERITY_URGENCY[status.severity],
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
}
// Signals from the highest-severity alert
const highestSeverity = alerts.reduce<TflAlertSeverity>(
(worst, alert) =>
SEVERITY_URGENCY[alert.severity] > SEVERITY_URGENCY[worst] ? alert.severity : worst,
alerts[0]!.severity,
)
return {
id: `tfl-alert-${status.lineId}-${status.severity}`,
const signals: FeedItemSignals = {
urgency: SEVERITY_URGENCY[highestSeverity],
timeRelevance: SEVERITY_TIME_RELEVANCE[highestSeverity],
}
return [
{
id: "tfl-status",
sourceId: this.id,
type: TflFeedItemType.Alert,
type: TflFeedItemType.Status,
timestamp: context.time,
data,
data: { alerts },
signals,
}
})
// Sort by urgency (desc), then by proximity (asc) if location available
items.sort((a, b) => {
const aUrgency = a.signals?.urgency ?? 0
const bUrgency = b.signals?.urgency ?? 0
if (bUrgency !== aUrgency) {
return bUrgency - aUrgency
}
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
return a.data.closestStationDistance - b.data.closestStationDistance
}
return 0
})
return items
},
]
}
}

View File

@@ -22,12 +22,19 @@ export interface TflAlertData extends Record<string, unknown> {
export const TflFeedItemType = {
Alert: "tfl-alert",
Status: "tfl-status",
} as const
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
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 {
apiKey?: string
client?: ITflApi

View File

@@ -57,7 +57,7 @@ export interface HourlyWeatherFeedItem extends FeedItem<
HourlyWeatherData
> {}
export type DailyWeatherData = {
export type DailyWeatherEntry = {
forecastDate: Date
conditionCode: ConditionCode
maxUvIndex: number
@@ -71,6 +71,10 @@ export type DailyWeatherData = {
temperatureMin: number
}
export type DailyWeatherData = {
days: DailyWeatherEntry[]
}
export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily,
DailyWeatherData

View File

@@ -11,6 +11,7 @@ export {
type HourlyWeatherEntry,
type DailyWeatherFeedItem,
type DailyWeatherData,
type DailyWeatherEntry,
type WeatherAlertFeedItem,
type WeatherAlertData,
} from "./feed-items"

View File

@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast } from "./weatherkit"
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType, type HourlyWeatherData } from "./feed-items"
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context"
import { WeatherSource, Units } from "./weather-source"
@@ -133,7 +133,8 @@ describe("WeatherSource", () => {
expect(hourlyItems.length).toBe(1)
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 () => {
@@ -192,6 +193,65 @@ describe("WeatherSource", () => {
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 () => {
const source = new WeatherSource({ client: mockClient })
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 { LocationKey } from "@aelis/source-location"
import { WeatherFeedItemType, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
import { WeatherKey, type Weather } from "./weather-context"
import {
@@ -181,11 +181,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
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, this.units, this.id))
}
if (days.length > 0) {
items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
}
}
@@ -370,24 +367,18 @@ function createHourlyForecastFeedItem(
}
}
function createDailyWeatherFeedItem(
daily: DailyForecast,
index: number,
function createDailyForecastFeedItem(
dailyForecasts: DailyForecast[],
timestamp: Date,
units: Units,
sourceId: string,
): WeatherFeedItem {
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
}
const days: DailyWeatherEntry[] = []
let totalUrgency = 0
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: {
for (const daily of dailyForecasts) {
days.push({
forecastDate: new Date(daily.forecastStart),
conditionCode: daily.conditionCode,
maxUvIndex: daily.maxUvIndex,
@@ -399,7 +390,27 @@ function createDailyWeatherFeedItem(
sunset: new Date(daily.sunset),
temperatureMax: convertTemperature(daily.temperatureMax, 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,
}
}