mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-30 14:51:17 +01:00
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:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user