feat: combine TFL alerts into single feed item

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>
This commit is contained in:
2026-03-29 22:32:35 +00:00
parent 1483805f13
commit 5cadcd1559
6 changed files with 322 additions and 167 deletions

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

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