refactor: rename aris to aelis (#59)

Rename all references across the codebase: package names,
imports, source IDs, directory names, docs, and configs.

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-10 19:19:23 +00:00
committed by GitHub
parent 230116d9f7
commit 863c298bd3
201 changed files with 891 additions and 647 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
{
"name": "@aelis/source-tfl",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test src/",
"fetch-fixtures": "bun run scripts/fetch-fixtures.ts"
},
"dependencies": {
"@aelis/core": "workspace:*",
"@aelis/source-location": "workspace:*",
"arktype": "^2.1.0"
}
}

View File

@@ -0,0 +1,35 @@
// Fetches real TfL API responses and saves them as test fixtures
const TEST_LINES = ["northern", "central", "elizabeth"]
const BASE_URL = "https://api.tfl.gov.uk"
async function fetchFixtures() {
console.log("Fetching line statuses...")
const statusRes = await fetch(`${BASE_URL}/Line/${TEST_LINES.join(",")}/Status`)
const lineStatuses = await statusRes.json()
console.log("Fetching stop points...")
const stopPoints: Record<string, unknown> = {}
for (const lineId of TEST_LINES) {
console.log(` Fetching ${lineId}...`)
const res = await fetch(`${BASE_URL}/Line/${lineId}/StopPoints`)
stopPoints[lineId] = await res.json()
}
const fixtures = {
fetchedAt: new Date().toISOString(),
lineStatuses,
stopPoints,
}
const path = new URL("../fixtures/tfl-responses.json", import.meta.url)
await Bun.write(path, JSON.stringify(fixtures))
console.log(`\nFixtures saved to fixtures/tfl-responses.json`)
console.log(` Line statuses: ${(lineStatuses as unknown[]).length} lines`)
for (const [lineId, stops] of Object.entries(stopPoints)) {
console.log(` ${lineId} stops: ${(stops as unknown[]).length}`)
}
}
fetchFixtures().catch(console.error)

View File

@@ -0,0 +1,13 @@
export { TflSource } from "./tfl-source.ts"
export { TflApi } from "./tfl-api.ts"
export type { TflLineId } from "./tfl-api.ts"
export {
TflFeedItemType,
type ITflApi,
type StationLocation,
type TflAlertData,
type TflAlertFeedItem,
type TflAlertSeverity,
type TflLineStatus,
type TflSourceOptions,
} from "./types.ts"

View File

@@ -0,0 +1,172 @@
import { type } from "arktype"
import type { StationLocation, TflAlertSeverity, TflLineStatus } from "./types.ts"
const TFL_API_BASE = "https://api.tfl.gov.uk"
const ALL_LINE_IDS: TflLineId[] = [
"bakerloo",
"central",
"circle",
"district",
"hammersmith-city",
"jubilee",
"metropolitan",
"northern",
"piccadilly",
"victoria",
"waterloo-city",
"lioness",
"mildmay",
"windrush",
"weaver",
"suffragette",
"liberty",
"elizabeth",
]
// TfL severity codes: https://api.tfl.gov.uk/Line/Meta/Severity
// 0 = Special Service, 1 = Closed, 6 = Severe Delays, 9 = Minor Delays, 10 = Good Service
const SEVERITY_MAP: Record<number, TflAlertSeverity | null> = {
1: "closure",
2: "closure", // Suspended
3: "closure", // Part Suspended
4: "closure", // Planned Closure
5: "closure", // Part Closure
6: "major-delays", // Severe Delays
7: "major-delays", // Reduced Service
8: "major-delays", // Bus Service
9: "minor-delays", // Minor Delays
10: null, // Good Service
11: null, // Part Closed
12: null, // Exit Only
13: null, // No Step Free Access
14: null, // Change of frequency
15: null, // Diverted
16: null, // Not Running
17: null, // Issues Reported
18: null, // No Issues
19: null, // Information
20: null, // Service Closed
}
export class TflApi {
private apiKey: string
private stationsCache: StationLocation[] | null = null
constructor(apiKey: string) {
this.apiKey = apiKey
}
private async fetch<T>(path: string): Promise<T> {
const url = new URL(path, TFL_API_BASE)
url.searchParams.set("app_key", this.apiKey)
const response = await fetch(url.toString())
if (!response.ok) {
throw new Error(`TfL API error: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
const lineIds = lines ?? ALL_LINE_IDS
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
const parsed = lineResponseArray(data)
if (parsed instanceof type.errors) {
throw new Error(`Invalid TfL API response: ${parsed.summary}`)
}
const statuses: TflLineStatus[] = []
for (const line of parsed) {
for (const status of line.lineStatuses) {
const severity = SEVERITY_MAP[status.statusSeverity]
if (severity) {
statuses.push({
lineId: line.id,
lineName: line.name,
severity,
description: status.reason ?? status.statusSeverityDescription,
})
}
}
}
return statuses
}
async fetchStations(): Promise<StationLocation[]> {
if (this.stationsCache) {
return this.stationsCache
}
// Fetch stations for all lines in parallel
const responses = await Promise.all(
ALL_LINE_IDS.map(async (id) => {
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
const parsed = lineStopPointsArray(data)
if (parsed instanceof type.errors) {
throw new Error(`Invalid TfL API response for line ${id}: ${parsed.summary}`)
}
return { lineId: id, stops: parsed }
}),
)
// Merge stations, combining lines for shared stations
const stationMap = new Map<string, StationLocation>()
for (const { lineId: currentLineId, stops } of responses) {
for (const stop of stops) {
const existing = stationMap.get(stop.naptanId)
if (existing) {
if (!existing.lines.includes(currentLineId)) {
existing.lines.push(currentLineId)
}
} else {
stationMap.set(stop.naptanId, {
id: stop.naptanId,
name: stop.commonName,
lat: stop.lat,
lng: stop.lon,
lines: [currentLineId],
})
}
}
}
this.stationsCache = Array.from(stationMap.values())
return this.stationsCache
}
}
// Schemas
export const lineId = type(
"'bakerloo' | 'central' | 'circle' | 'district' | 'hammersmith-city' | 'jubilee' | 'metropolitan' | 'northern' | 'piccadilly' | 'victoria' | 'waterloo-city' | 'lioness' | 'mildmay' | 'windrush' | 'weaver' | 'suffragette' | 'liberty' | 'elizabeth'",
)
export type TflLineId = typeof lineId.infer
const lineStatus = type({
statusSeverity: "number",
statusSeverityDescription: "string",
"reason?": "string",
})
const lineResponse = type({
id: lineId,
name: "string",
lineStatuses: lineStatus.array(),
})
const lineResponseArray = lineResponse.array()
const lineStopPoint = type({
naptanId: "string",
commonName: "string",
lat: "number",
lon: "number",
})
const lineStopPointsArray = lineStopPoint.array()

View File

@@ -0,0 +1,363 @@
import { Context } from "@aelis/core"
import { LocationKey, type Location } from "@aelis/source-location"
import { describe, expect, test } from "bun:test"
import type {
ITflApi,
StationLocation,
TflAlertSeverity,
TflLineId,
TflLineStatus,
} from "./types.ts"
import fixtures from "../fixtures/tfl-responses.json"
import { TflSource } from "./tfl-source.ts"
// Mock API that returns fixture data
class FixtureTflApi implements ITflApi {
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
const statuses: TflLineStatus[] = []
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
for (const status of line.lineStatuses as Record<string, unknown>[]) {
const severityCode = status.statusSeverity as number
const severity = this.mapSeverity(severityCode)
if (severity) {
statuses.push({
lineId: line.id as TflLineId,
lineName: line.name as string,
severity,
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
})
}
}
}
return statuses
}
async fetchStations(): Promise<StationLocation[]> {
const stationMap = new Map<string, StationLocation>()
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
for (const stop of stops as Record<string, unknown>[]) {
const id = stop.naptanId as string
const existing = stationMap.get(id)
if (existing) {
if (!existing.lines.includes(lineId as TflLineId)) {
existing.lines.push(lineId as TflLineId)
}
} else {
stationMap.set(id, {
id,
name: stop.commonName as string,
lat: stop.lat as number,
lng: stop.lon as number,
lines: [lineId as TflLineId],
})
}
}
}
return Array.from(stationMap.values())
}
private mapSeverity(code: number): TflAlertSeverity | null {
const map: Record<number, TflAlertSeverity | null> = {
1: "closure",
2: "closure",
3: "closure",
4: "closure",
5: "closure",
6: "major-delays",
7: "major-delays",
8: "major-delays",
9: "minor-delays",
10: null,
}
return map[code] ?? null
}
}
function createContext(location?: Location): Context {
const ctx = new Context(new Date("2026-01-15T12:00:00Z"))
if (location) {
ctx.set([[LocationKey, location]])
}
return ctx
}
describe("TflSource", () => {
const api = new FixtureTflApi()
describe("interface", () => {
test("has correct id", () => {
const source = new TflSource({ client: api })
expect(source.id).toBe("aelis.tfl")
})
test("depends on location", () => {
const source = new TflSource({ client: api })
expect(source.dependencies).toEqual(["aelis.location"])
})
test("implements fetchItems", () => {
const source = new TflSource({ client: api })
expect(source.fetchItems).toBeDefined()
})
test("throws if neither client nor apiKey provided", () => {
expect(() => new TflSource({})).toThrow("Either client or apiKey must be provided")
})
})
describe("setLinesOfInterest", () => {
const lineFilteringApi: ITflApi = {
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
const all: TflLineStatus[] = [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Delays",
},
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Closed",
},
]
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
},
async fetchStations(): Promise<StationLocation[]> {
return []
},
}
test("changes which lines are fetched", async () => {
const source = new TflSource({ client: lineFilteringApi })
const before = await source.fetchItems(createContext())
expect(before.length).toBe(2)
source.setLinesOfInterest(["northern"])
const after = await source.fetchItems(createContext())
expect(after.length).toBe(1)
expect(after[0]!.data.line).toBe("northern")
})
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
const source = new TflSource({
client: lineFilteringApi,
lines: ["northern"],
})
const filtered = await source.fetchItems(createContext())
expect(filtered.length).toBe(1)
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
const all = await source.fetchItems(createContext())
expect(all.length).toBe(2)
})
})
describe("fetchItems", () => {
test("returns feed items array", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
expect(Array.isArray(items)).toBe(true)
})
test("feed items have correct base 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.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")
expect(
item.data.closestStationDistance === null ||
typeof item.data.closestStationDistance === "number",
).toBe(true)
}
})
test("feed item ids are unique", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
const ids = items.map((item) => item.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
test("feed items are sorted by urgency descending", async () => {
const source = new TflSource({ client: api })
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!)
}
})
test("urgency values match severity levels", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
const severityUrgency: Record<string, number> = {
closure: 1.0,
"major-delays": 0.8,
"minor-delays": 0.6,
}
for (const item of items) {
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
}
})
test("closestStationDistance is number when location provided", 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.closestStationDistance).toBe("number")
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
}
})
test("closestStationDistance is null when no location provided", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
for (const item of items) {
expect(item.data.closestStationDistance).toBeNull()
}
})
})
describe("actions", () => {
test("listActions returns set-lines-of-interest", async () => {
const source = new TflSource({ client: api })
const actions = await source.listActions()
expect(actions["set-lines-of-interest"]).toBeDefined()
expect(actions["set-lines-of-interest"]!.id).toBe("set-lines-of-interest")
})
test("executeAction set-lines-of-interest updates lines", async () => {
const lineFilteringApi: ITflApi = {
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
const all: TflLineStatus[] = [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Delays",
},
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Closed",
},
]
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
},
async fetchStations(): Promise<StationLocation[]> {
return []
},
}
const source = new TflSource({ client: lineFilteringApi })
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")
})
test("executeAction throws on invalid input", async () => {
const source = new TflSource({ client: api })
await expect(source.executeAction("set-lines-of-interest", "not-an-array")).rejects.toThrow()
})
test("executeAction throws for unknown action", async () => {
const source = new TflSource({ client: api })
await expect(source.executeAction("nonexistent", {})).rejects.toThrow("Unknown action")
})
})
})
describe("TfL Fixture Data Shape", () => {
test("fixtures have expected structure", () => {
expect(typeof fixtures.fetchedAt).toBe("string")
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
expect(typeof fixtures.stopPoints).toBe("object")
})
test("line statuses have required fields", () => {
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
expect(typeof line.id).toBe("string")
expect(typeof line.name).toBe("string")
expect(Array.isArray(line.lineStatuses)).toBe(true)
for (const status of line.lineStatuses as Record<string, unknown>[]) {
expect(typeof status.statusSeverity).toBe("number")
expect(typeof status.statusSeverityDescription).toBe("string")
}
}
})
test("stop points have required fields", () => {
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
expect(typeof lineId).toBe("string")
expect(Array.isArray(stops)).toBe(true)
for (const stop of stops as Record<string, unknown>[]) {
expect(typeof stop.naptanId).toBe("string")
expect(typeof stop.commonName).toBe("string")
expect(typeof stop.lat).toBe("number")
expect(typeof stop.lon).toBe("number")
}
}
})
})

View File

@@ -0,0 +1,210 @@
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 ?? [...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
}

View File

@@ -0,0 +1,56 @@
import type { FeedItem } from "@aelis/core"
import type { TflLineId } from "./tfl-api.ts"
export type { TflLineId } from "./tfl-api.ts"
export const TflAlertSeverity = {
MinorDelays: "minor-delays",
MajorDelays: "major-delays",
Closure: "closure",
} as const
export type TflAlertSeverity = (typeof TflAlertSeverity)[keyof typeof TflAlertSeverity]
export interface TflAlertData extends Record<string, unknown> {
line: TflLineId
lineName: string
severity: TflAlertSeverity
description: string
closestStationDistance: number | null
}
export const TflFeedItemType = {
Alert: "tfl-alert",
} as const
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
export interface TflSourceOptions {
apiKey?: string
client?: ITflApi
/** Lines to monitor. Defaults to all lines. */
lines?: TflLineId[]
}
export interface StationLocation {
id: string
name: string
lat: number
lng: number
lines: TflLineId[]
}
export interface ITflApi {
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
fetchStations(): Promise<StationLocation[]>
}
export interface TflLineStatus {
lineId: TflLineId
lineName: string
severity: TflAlertSeverity
description: string
}