mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 13:11:17 +00:00
Compare commits
5 Commits
dev/agents
...
feat/data-
| Author | SHA1 | Date | |
|---|---|---|---|
| 482c1c8b0f | |||
| c90bef0330 | |||
| 20559b92ad | |||
| c2f0b03924 | |||
| 8ec8b9a13e |
21
README.md
21
README.md
@@ -6,10 +6,25 @@ To install dependencies:
|
|||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
## Packages
|
||||||
|
|
||||||
|
### @aris/data-source-tfl
|
||||||
|
|
||||||
|
TfL (Transport for London) data source for tube, overground, and Elizabeth line alerts.
|
||||||
|
|
||||||
|
#### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run index.ts
|
cd packages/aris-data-source-tfl
|
||||||
|
bun run test
|
||||||
```
|
```
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
#### Fixtures
|
||||||
|
|
||||||
|
Tests use fixture data from real TfL API responses stored in `fixtures/tfl-responses.json`.
|
||||||
|
|
||||||
|
To refresh fixtures:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run fetch-fixtures
|
||||||
|
```
|
||||||
|
|||||||
10
bun.lock
10
bun.lock
@@ -17,6 +17,14 @@
|
|||||||
"name": "@aris/core",
|
"name": "@aris/core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
},
|
},
|
||||||
|
"packages/aris-data-source-tfl": {
|
||||||
|
"name": "@aris/data-source-tfl",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"arktype": "^2.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/aris-data-source-weatherkit": {
|
"packages/aris-data-source-weatherkit": {
|
||||||
"name": "@aris/data-source-weatherkit",
|
"name": "@aris/data-source-weatherkit",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -29,6 +37,8 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
||||||
|
|
||||||
|
"@aris/data-source-tfl": ["@aris/data-source-tfl@workspace:packages/aris-data-source-tfl"],
|
||||||
|
|
||||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
15
packages/aris-data-source-tfl/package.json
Normal file
15
packages/aris-data-source-tfl/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@aris/data-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": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"arktype": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/aris-data-source-tfl/scripts/fetch-fixtures.ts
Normal file
35
packages/aris-data-source-tfl/scripts/fetch-fixtures.ts
Normal 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)
|
||||||
103
packages/aris-data-source-tfl/src/data-source.ts
Normal file
103
packages/aris-data-source-tfl/src/data-source.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Context, DataSource } from "@aris/core"
|
||||||
|
import { TflApi, type ITflApi } from "./tfl-api.ts"
|
||||||
|
import type {
|
||||||
|
StationLocation,
|
||||||
|
TflAlertData,
|
||||||
|
TflAlertFeedItem,
|
||||||
|
TflAlertSeverity,
|
||||||
|
TflDataSourceConfig,
|
||||||
|
TflDataSourceOptions,
|
||||||
|
TflLineId,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||||
|
closure: 100,
|
||||||
|
"major-delays": 80,
|
||||||
|
"minor-delays": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TflDataSource implements DataSource<TflAlertFeedItem, TflDataSourceConfig> {
|
||||||
|
readonly type = "tfl-alert"
|
||||||
|
private api: ITflApi
|
||||||
|
|
||||||
|
constructor(options: TflDataSourceOptions)
|
||||||
|
constructor(api: ITflApi)
|
||||||
|
constructor(optionsOrApi: TflDataSourceOptions | ITflApi) {
|
||||||
|
if ("fetchLineStatuses" in optionsOrApi) {
|
||||||
|
this.api = optionsOrApi
|
||||||
|
} else {
|
||||||
|
this.api = new TflApi(optionsOrApi.apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(context: Context, config: TflDataSourceConfig): Promise<TflAlertFeedItem[]> {
|
||||||
|
const [statuses, stations] = await Promise.all([this.api.fetchLineStatuses(config.lines), this.api.fetchStations()])
|
||||||
|
|
||||||
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||||
|
const closestStationDistance =
|
||||||
|
context.location ?
|
||||||
|
findClosestStationDistance(status.lineId, stations, context.location.lat, context.location.lng)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const data: TflAlertData = {
|
||||||
|
line: status.lineId,
|
||||||
|
lineName: status.lineName,
|
||||||
|
severity: status.severity,
|
||||||
|
description: status.description,
|
||||||
|
closestStationDistance,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
|
type: this.type,
|
||||||
|
priority: SEVERITY_PRIORITY[status.severity],
|
||||||
|
timestamp: context.time,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by severity (desc), then by proximity (asc) if location available
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (b.priority !== a.priority) {
|
||||||
|
return b.priority - a.priority
|
||||||
|
}
|
||||||
|
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||||
|
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/aris-data-source-tfl/src/index.ts
Normal file
11
packages/aris-data-source-tfl/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { TflDataSource } from "./data-source.ts"
|
||||||
|
export { TflApi, type ITflApi, type TflLineStatus } from "./tfl-api.ts"
|
||||||
|
export type {
|
||||||
|
TflAlertData,
|
||||||
|
TflAlertFeedItem,
|
||||||
|
TflAlertSeverity,
|
||||||
|
TflDataSourceConfig,
|
||||||
|
TflDataSourceOptions,
|
||||||
|
TflLineId,
|
||||||
|
StationLocation,
|
||||||
|
} from "./types.ts"
|
||||||
206
packages/aris-data-source-tfl/src/integration.test.ts
Normal file
206
packages/aris-data-source-tfl/src/integration.test.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { Context } from "@aris/core"
|
||||||
|
import { TflDataSource } from "./data-source.ts"
|
||||||
|
import type { ITflApi, TflLineStatus } from "./tfl-api.ts"
|
||||||
|
import type { StationLocation, TflLineId } from "./types.ts"
|
||||||
|
|
||||||
|
import fixtures from "../fixtures/tfl-responses.json"
|
||||||
|
|
||||||
|
// 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): "minor-delays" | "major-delays" | "closure" | null {
|
||||||
|
const map: Record<number, "minor-delays" | "major-delays" | "closure" | 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createContext = (location?: { lat: number; lng: number }): Context => ({
|
||||||
|
time: new Date("2026-01-15T12:00:00Z"),
|
||||||
|
location: location ? { ...location, accuracy: 10 } : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("TfL Feed Items (using fixture data)", () => {
|
||||||
|
const api = new FixtureTflApi()
|
||||||
|
|
||||||
|
test("query returns feed items array", async () => {
|
||||||
|
const dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(createContext(), {})
|
||||||
|
expect(Array.isArray(items)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items have correct base structure", async () => {
|
||||||
|
const dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.id).toBe("string")
|
||||||
|
expect(item.id).toMatch(/^tfl-alert-/)
|
||||||
|
expect(item.type).toBe("tfl-alert")
|
||||||
|
expect(typeof item.priority).toBe("number")
|
||||||
|
expect(item.timestamp).toBeInstanceOf(Date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items have correct data structure", async () => {
|
||||||
|
const dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
||||||
|
|
||||||
|
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 dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(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 priority descending", async () => {
|
||||||
|
const dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(createContext(), {})
|
||||||
|
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const prev = items[i - 1]!
|
||||||
|
const curr = items[i]!
|
||||||
|
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("priority values match severity levels", async () => {
|
||||||
|
const dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(createContext(), {})
|
||||||
|
|
||||||
|
const severityPriority: Record<string, number> = {
|
||||||
|
closure: 100,
|
||||||
|
"major-delays": 80,
|
||||||
|
"minor-delays": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closestStationDistance is number when location provided", async () => {
|
||||||
|
const dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
||||||
|
|
||||||
|
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 dataSource = new TflDataSource(api)
|
||||||
|
const items = await dataSource.query(createContext(), {})
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.data.closestStationDistance).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
183
packages/aris-data-source-tfl/src/tfl-api.ts
Normal file
183
packages/aris-data-source-tfl/src/tfl-api.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { type } from "arktype"
|
||||||
|
import type { StationLocation, TflAlertSeverity } 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 interface TflLineStatus {
|
||||||
|
lineId: TflLineId
|
||||||
|
lineName: string
|
||||||
|
severity: TflAlertSeverity
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITflApi {
|
||||||
|
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
|
||||||
|
fetchStations(): Promise<StationLocation[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TflApi implements ITflApi {
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
32
packages/aris-data-source-tfl/src/types.ts
Normal file
32
packages/aris-data-source-tfl/src/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FeedItem } from "@aris/core"
|
||||||
|
import type { TflLineId } from "./tfl-api.ts"
|
||||||
|
|
||||||
|
export type { TflLineId } from "./tfl-api.ts"
|
||||||
|
|
||||||
|
export type TflAlertSeverity = "minor-delays" | "major-delays" | "closure"
|
||||||
|
|
||||||
|
export interface TflAlertData extends Record<string, unknown> {
|
||||||
|
line: TflLineId
|
||||||
|
lineName: string
|
||||||
|
severity: TflAlertSeverity
|
||||||
|
description: string
|
||||||
|
closestStationDistance: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
||||||
|
|
||||||
|
export interface TflDataSourceConfig {
|
||||||
|
lines?: TflLineId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TflDataSourceOptions {
|
||||||
|
apiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StationLocation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
lines: TflLineId[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user