Files
aris/packages/aris-source-tfl/src/tfl-source.ts
Kenneth 96e22e227c feat: replace flat context with tuple-keyed store (#50)
Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.

Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:52:41 +00:00

211 lines
5.3 KiB
TypeScript

import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { LocationKey } from "@aris/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 = "aris.tfl"
readonly dependencies = ["aris.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 ?? [...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}`,
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
}