Compare commits

..

1 Commits

Author SHA1 Message Date
38c490b80e fix: handle empty lines array in TFL source
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 21:53:56 +00:00
7 changed files with 169 additions and 324 deletions

View File

@@ -4,7 +4,7 @@ import type { EnhancementResult } 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
export interface LlmClientConfig {

View File

@@ -10,7 +10,5 @@ export {
type TflAlertSeverity,
type TflLineStatus,
type TflSourceOptions,
type TflStatusData,
type TflStatusFeedItem,
} 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 { 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 {
line: "northern",
lineName: "Northern",
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",
id: "tfl-alert-northern-minor-delays",
type: "tfl-alert",
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. */
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()]))
describe("renderTflAlert", () => {
test("renders a FeedCard with title and description", () => {
const node = renderTflAlert(makeItem())
const spec = render(node)
const root = spec.elements[spec.root]!
expect(root.type).toBe("FeedCard")
})
expect(root.children!.length).toBeGreaterThanOrEqual(2)
test("renders one alert with title and description", () => {
const node = renderTflStatus(makeItem([makeAlert()]))
const spec = render(node)
const title = spec.elements[root.children![0]!]!
expect(title.type).toBe("SansSerifText")
expect(title.props.content).toBe("Northern · Minor delays")
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()
const body = spec.elements[root.children![1]!]!
expect(body.type).toBe("SansSerifText")
expect(body.props.content).toBe("Minor delays due to signal failure")
})
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 texts = collectTextElements(spec)
const caption = texts.find((el) => el.props.content === "Nearest station: 350m away")
expect(caption).toBeDefined()
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")
})
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 texts = collectTextElements(spec)
const caption = texts.find((el) => el.props.content === "Nearest station: 2.5km away")
expect(caption).toBeDefined()
const root = spec.elements[spec.root]!
const caption = spec.elements[root.children![2]!]!
expect(caption.props.content).toBe("Nearest station: 2.5km away")
})
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 texts = collectTextElements(spec)
const caption = texts.find((el) => el.props.content === "Nearest station: 1.0km away")
expect(caption).toBeDefined()
const root = spec.elements[spec.root]!
const caption = spec.elements[root.children![2]!]!
expect(caption.props.content).toBe("Nearest station: 1.0km away")
})
test("omits station distance when null", () => {
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: null })]))
const node = renderTflAlert(makeItem({ closestStationDistance: null }))
const spec = render(node)
const texts = collectTextElements(spec)
const distanceTexts = texts.filter((el) =>
(el.props.content as string).startsWith("Nearest station:"),
)
expect(distanceTexts).toHaveLength(0)
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)
})
test("renders closure severity label", () => {
const node = renderTflStatus(
makeItem([makeAlert({ severity: "closure", lineName: "Central" })]),
)
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" }))
const spec = render(node)
const texts = collectTextElements(spec)
const title = texts.find((el) => el.props.content === "Central · Closed")
expect(title).toBeDefined()
const root = spec.elements[spec.root]!
const title = spec.elements[root.children![0]!]!
expect(title.props.content).toBe("Central · Closed")
})
test("renders major delays severity label", () => {
const node = renderTflStatus(
makeItem([makeAlert({ severity: "major-delays", lineName: "Jubilee" })]),
)
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" }))
const spec = render(node)
const texts = collectTextElements(spec)
const title = texts.find((el) => el.props.content === "Jubilee · Major delays")
expect(title).toBeDefined()
const root = spec.elements[spec.root]!
const title = spec.elements[root.children![0]!]!
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 type { TflAlertData, TflStatusData } from "./types.ts"
import type { TflAlertData } from "./types.ts"
import { TflAlertSeverity } from "./types.ts"
@@ -21,26 +21,20 @@ function formatDistance(km: number): string {
return `${(meters / 1000).toFixed(1)}km away`
}
function renderAlertRow(alert: TflAlertData) {
const severityLabel = SEVERITY_LABEL[alert.severity]
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => {
const { lineName, severity, description, closestStationDistance } = item.data
const severityLabel = SEVERITY_LABEL[severity]
return (
<>
<SansSerifText
content={`${alert.lineName} · ${severityLabel}`}
style="text-base font-semibold"
/>
<SansSerifText content={alert.description} style="text-sm" />
{alert.closestStationDistance !== null ? (
<FeedCard>
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" />
<SansSerifText content={description} style="text-sm" />
{closestStationDistance !== null ? (
<SansSerifText
content={`Nearest station: ${formatDistance(alert.closestStationDistance)}`}
content={`Nearest station: ${formatDistance(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

@@ -138,15 +138,13 @@ describe("TflSource", () => {
test("changes which lines are fetched", async () => {
const source = new TflSource({ client: lineFilteringApi })
const before = await source.fetchItems(createContext())
expect(before).toHaveLength(1)
expect(before[0]!.data.alerts).toHaveLength(2)
expect(before.length).toBe(2)
source.setLinesOfInterest(["northern"])
const after = await source.fetchItems(createContext())
expect(after).toHaveLength(1)
expect(after[0]!.data.alerts).toHaveLength(1)
expect(after[0]!.data.alerts[0]!.line).toBe("northern")
expect(after.length).toBe(1)
expect(after[0]!.data.line).toBe("northern")
})
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
@@ -155,52 +153,23 @@ describe("TflSource", () => {
lines: ["northern"],
})
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])
const all = await source.fetchItems(createContext())
expect(all[0]!.data.alerts).toHaveLength(2)
expect(all.length).toBe(2)
})
})
describe("fetchItems", () => {
test("returns at most one feed item", async () => {
test("returns feed items array", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
expect(items).toHaveLength(1)
expect(Array.isArray(items)).toBe(true)
})
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 () => {
test("feed items have correct base structure", async () => {
const source = new TflSource({ client: api })
const location: Location = {
lat: 51.5074,
@@ -209,140 +178,72 @@ describe("TflSource", () => {
timestamp: new Date(),
}
const items = await source.fetchItems(createContext(location))
const alerts = items[0]!.data.alerts
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")
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")
expect(
alert.closestStationDistance === null || typeof alert.closestStationDistance === "number",
item.data.closestStationDistance === null ||
typeof item.data.closestStationDistance === "number",
).toBe(true)
}
})
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 })
test("feed item ids are unique", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
expect(items[0]!.signals!.urgency).toBe(1.0) // closure urgency
expect(items[0]!.signals!.timeRelevance).toBe("imminent") // closure time relevance
const ids = items.map((item) => item.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
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 })
test("feed items are sorted by urgency descending", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
expect(items[0]!.signals!.urgency).toBe(0.6) // minor-delays urgency
expect(items[0]!.signals!.timeRelevance).toBe("upcoming")
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!)
}
})
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
test("urgency values match severity levels", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
// 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()
}
const severityUrgency: Record<string, number> = {
closure: 1.0,
"major-delays": 0.8,
"minor-delays": 0.6,
}
// Distance alerts are in ascending order
for (let i = 1; i < withDistance.length; i++) {
expect(withDistance[i]!.closestStationDistance!).toBeGreaterThanOrEqual(
withDistance[i - 1]!.closestStationDistance!,
)
for (const item of items) {
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
}
expect(withoutDistance.length).toBe(1)
expect(withoutDistance[0]!.line).toBe("jubilee")
})
test("closestStationDistance is number when location provided", async () => {
@@ -355,9 +256,9 @@ describe("TflSource", () => {
}
const items = await source.fetchItems(createContext(location))
for (const alert of items[0]!.data.alerts) {
expect(typeof alert.closestStationDistance).toBe("number")
expect(alert.closestStationDistance!).toBeGreaterThan(0)
for (const item of items) {
expect(typeof item.data.closestStationDistance).toBe("number")
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
}
})
@@ -365,8 +266,8 @@ describe("TflSource", () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
for (const alert of items[0]!.data.alerts) {
expect(alert.closestStationDistance).toBeNull()
for (const item of items) {
expect(item.data.closestStationDistance).toBeNull()
}
})
})
@@ -408,9 +309,8 @@ describe("TflSource", () => {
await source.executeAction("set-lines-of-interest", ["northern"])
const items = await source.fetchItems(createContext())
expect(items).toHaveLength(1)
expect(items[0]!.data.alerts).toHaveLength(1)
expect(items[0]!.data.alerts[0]!.line).toBe("northern")
expect(items.length).toBe(1)
expect(items[0]!.data.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<TflStatusFeedItem> {
export class TflSource implements FeedSource<TflAlertFeedItem> {
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
"bakerloo",
"central",
@@ -123,58 +123,56 @@ export class TflSource implements FeedSource<TflStatusFeedItem> {
this.lines = lines
}
async fetchItems(context: Context): Promise<TflStatusFeedItem[]> {
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
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 alerts: TflAlertData[] = statuses.map((status) => ({
line: status.lineId,
lineName: status.lineName,
severity: status.severity,
description: status.description,
closestStationDistance: location
const items: TflAlertFeedItem[] = statuses.map((status) => {
const closestStationDistance = location
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
: null,
}))
: null
// 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 data: TflAlertData = {
line: status.lineId,
lineName: status.lineName,
severity: status.severity,
description: status.description,
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
const highestSeverity = alerts.reduce<TflAlertSeverity>(
(worst, alert) =>
SEVERITY_URGENCY[alert.severity] > SEVERITY_URGENCY[worst] ? alert.severity : worst,
alerts[0]!.severity,
)
// 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
})
const signals: FeedItemSignals = {
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,
},
]
return items
}
}

View File

@@ -22,19 +22,12 @@ 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