mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-21 09:31:18 +00:00
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>
211 lines
5.3 KiB
TypeScript
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
|
|
}
|