From f549859a444ff58994123131131032589e1d75fe Mon Sep 17 00:00:00 2001 From: Kenneth Date: Mon, 30 Mar 2026 00:00:41 +0100 Subject: [PATCH] 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 --- packages/aelis-source-tfl/src/index.ts | 4 +- .../aelis-source-tfl/src/renderer.test.tsx | 148 +++++++----- packages/aelis-source-tfl/src/renderer.tsx | 26 +- .../aelis-source-tfl/src/tfl-source.test.ts | 226 +++++++++++++----- packages/aelis-source-tfl/src/tfl-source.ts | 78 +++--- packages/aelis-source-tfl/src/types.ts | 7 + 6 files changed, 322 insertions(+), 167 deletions(-) diff --git a/packages/aelis-source-tfl/src/index.ts b/packages/aelis-source-tfl/src/index.ts index 08fdf35..dfd3458 100644 --- a/packages/aelis-source-tfl/src/index.ts +++ b/packages/aelis-source-tfl/src/index.ts @@ -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" diff --git a/packages/aelis-source-tfl/src/renderer.test.tsx b/packages/aelis-source-tfl/src/renderer.test.tsx index ae3752a..6d4b526 100644 --- a/packages/aelis-source-tfl/src/renderer.test.tsx +++ b/packages/aelis-source-tfl/src/renderer.test.tsx @@ -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 { +function makeAlert(overrides: Partial = {}): 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) { + 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() }) }) diff --git a/packages/aelis-source-tfl/src/renderer.tsx b/packages/aelis-source-tfl/src/renderer.tsx index e0eb02a..b92d5b1 100644 --- a/packages/aelis-source-tfl/src/renderer.tsx +++ b/packages/aelis-source-tfl/src/renderer.tsx @@ -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 ( - - - - {closestStationDistance !== null ? ( + <> + + + {alert.closestStationDistance !== null ? ( ) : null} - + ) } + +export const renderTflStatus: FeedItemRenderer<"tfl-status", TflStatusData> = (item) => { + return {item.data.alerts.map((alert) => renderAlertRow(alert))} +} diff --git a/packages/aelis-source-tfl/src/tfl-source.test.ts b/packages/aelis-source-tfl/src/tfl-source.test.ts index b52d42b..c95f420 100644 --- a/packages/aelis-source-tfl/src/tfl-source.test.ts +++ b/packages/aelis-source-tfl/src/tfl-source.test.ts @@ -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 { + return [] + }, + async fetchStations(): Promise { + 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 { + 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 { + 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 { + return [ + { + lineId: "northern", + lineName: "Northern", + severity: "minor-delays", + description: "Minor delays", + }, + ] + }, + async fetchStations(): Promise { + 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 { + 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 { + 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 = { - 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 () => { diff --git a/packages/aelis-source-tfl/src/tfl-source.ts b/packages/aelis-source-tfl/src/tfl-source.ts index 4d4637f..3165c63 100644 --- a/packages/aelis-source-tfl/src/tfl-source.ts +++ b/packages/aelis-source-tfl/src/tfl-source.ts @@ -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 = { * const { items } = await engine.refresh() * ``` */ -export class TflSource implements FeedSource { +export class TflSource implements FeedSource { static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [ "bakerloo", "central", @@ -123,56 +123,58 @@ export class TflSource implements FeedSource { this.lines = lines } - async fetchItems(context: Context): Promise { + async fetchItems(context: Context): Promise { 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( + (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 + }, + ] } } diff --git a/packages/aelis-source-tfl/src/types.ts b/packages/aelis-source-tfl/src/types.ts index f6f368c..3401263 100644 --- a/packages/aelis-source-tfl/src/types.ts +++ b/packages/aelis-source-tfl/src/types.ts @@ -22,12 +22,19 @@ export interface TflAlertData extends Record { export const TflFeedItemType = { Alert: "tfl-alert", + Status: "tfl-status", } as const export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType] export type TflAlertFeedItem = FeedItem +export interface TflStatusData extends Record { + alerts: TflAlertData[] +} + +export type TflStatusFeedItem = FeedItem + export interface TflSourceOptions { apiKey?: string client?: ITflApi