mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-31 15:11:18 +01:00
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>
212 lines
5.4 KiB
TypeScript
212 lines
5.4 KiB
TypeScript
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aelis/core"
|
|
|
|
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
|
import { LocationKey } from "@aelis/source-location"
|
|
import { type } from "arktype"
|
|
|
|
import type {
|
|
ITflApi,
|
|
StationLocation,
|
|
TflAlertData,
|
|
TflAlertFeedItem,
|
|
TflAlertSeverity,
|
|
TflLineId,
|
|
TflSourceOptions,
|
|
} from "./types.ts"
|
|
|
|
import { TflApi, lineId } from "./tfl-api.ts"
|
|
import { TflFeedItemType } from "./types.ts"
|
|
|
|
const setLinesInput = lineId.array()
|
|
|
|
const SEVERITY_URGENCY: Record<TflAlertSeverity, number> = {
|
|
closure: 1.0,
|
|
"major-delays": 0.8,
|
|
"minor-delays": 0.6,
|
|
}
|
|
|
|
const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
|
|
closure: TimeRelevance.Imminent,
|
|
"major-delays": TimeRelevance.Imminent,
|
|
"minor-delays": TimeRelevance.Upcoming,
|
|
}
|
|
|
|
/**
|
|
* A FeedSource that provides TfL (Transport for London) service alerts.
|
|
*
|
|
* Depends on location source for proximity-based sorting. Produces feed items
|
|
* for tube, overground, and Elizabeth line disruptions.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const tflSource = new TflSource({
|
|
* apiKey: process.env.TFL_API_KEY!,
|
|
* lines: ["northern", "victoria", "jubilee"],
|
|
* })
|
|
*
|
|
* const engine = new FeedEngine()
|
|
* .register(locationSource)
|
|
* .register(tflSource)
|
|
*
|
|
* const { items } = await engine.refresh()
|
|
* ```
|
|
*/
|
|
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
|
"bakerloo",
|
|
"central",
|
|
"circle",
|
|
"district",
|
|
"hammersmith-city",
|
|
"jubilee",
|
|
"metropolitan",
|
|
"northern",
|
|
"piccadilly",
|
|
"victoria",
|
|
"waterloo-city",
|
|
"lioness",
|
|
"mildmay",
|
|
"windrush",
|
|
"weaver",
|
|
"suffragette",
|
|
"liberty",
|
|
"elizabeth",
|
|
]
|
|
|
|
readonly id = "aelis.tfl"
|
|
readonly dependencies = ["aelis.location"]
|
|
|
|
private readonly client: ITflApi
|
|
private lines: TflLineId[]
|
|
|
|
constructor(options: TflSourceOptions) {
|
|
if (!options.client && !options.apiKey) {
|
|
throw new Error("Either client or apiKey must be provided")
|
|
}
|
|
this.client = options.client ?? new TflApi(options.apiKey!)
|
|
this.lines = options.lines?.length ? options.lines : [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
|
}
|
|
|
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
|
return {
|
|
"set-lines-of-interest": {
|
|
id: "set-lines-of-interest",
|
|
description: "Update the set of monitored TfL lines",
|
|
input: setLinesInput,
|
|
},
|
|
}
|
|
}
|
|
|
|
async executeAction(actionId: string, params: unknown): Promise<void> {
|
|
switch (actionId) {
|
|
case "set-lines-of-interest": {
|
|
const result = setLinesInput(params)
|
|
if (result instanceof type.errors) {
|
|
throw new Error(result.summary)
|
|
}
|
|
this.setLinesOfInterest(result)
|
|
return
|
|
}
|
|
default:
|
|
throw new UnknownActionError(actionId)
|
|
}
|
|
}
|
|
|
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Update the set of monitored lines. Takes effect on the next fetchItems call.
|
|
*/
|
|
setLinesOfInterest(lines: TflLineId[]): void {
|
|
this.lines = lines
|
|
}
|
|
|
|
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
|
const [statuses, stations] = await Promise.all([
|
|
this.client.fetchLineStatuses(this.lines),
|
|
this.client.fetchStations(),
|
|
])
|
|
|
|
const location = context.get(LocationKey)
|
|
|
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
|
const closestStationDistance = location
|
|
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
|
: null
|
|
|
|
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,
|
|
}
|
|
})
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
|
const R = 6371 // Earth's radius in km
|
|
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
|
const dLng = ((lng2 - lng1) * Math.PI) / 180
|
|
const a =
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos((lat1 * Math.PI) / 180) *
|
|
Math.cos((lat2 * Math.PI) / 180) *
|
|
Math.sin(dLng / 2) *
|
|
Math.sin(dLng / 2)
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
return R * c
|
|
}
|
|
|
|
function findClosestStationDistance(
|
|
lineId: TflLineId,
|
|
stations: StationLocation[],
|
|
userLat: number,
|
|
userLng: number,
|
|
): number | null {
|
|
const lineStations = stations.filter((s) => s.lines.includes(lineId))
|
|
if (lineStations.length === 0) return null
|
|
|
|
let minDistance = Infinity
|
|
for (const station of lineStations) {
|
|
const distance = haversineDistance(userLat, userLng, station.lat, station.lng)
|
|
if (distance < minDistance) {
|
|
minDistance = distance
|
|
}
|
|
}
|
|
|
|
return minDistance
|
|
}
|