diff --git a/README.md b/README.md index 3f165ce..41655fa 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ bun install ## Packages -### @aris/data-source-tfl +### @aris/source-tfl -TfL (Transport for London) data source for tube, overground, and Elizabeth line alerts. +TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts. #### Testing ```bash -cd packages/aris-data-source-tfl +cd packages/aris-source-tfl bun run test ``` diff --git a/bun.lock b/bun.lock index 2b5c0e7..3706966 100644 --- a/bun.lock +++ b/bun.lock @@ -17,14 +17,6 @@ "name": "@aris/core", "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": { "name": "@aris/data-source-weatherkit", "version": "0.0.0", @@ -40,6 +32,15 @@ "@aris/core": "workspace:*", }, }, + "packages/aris-source-tfl": { + "name": "@aris/source-tfl", + "version": "0.0.0", + "dependencies": { + "@aris/core": "workspace:*", + "@aris/source-location": "workspace:*", + "arktype": "^2.1.0", + }, + }, "packages/aris-source-weatherkit": { "name": "@aris/source-weatherkit", "version": "0.0.0", @@ -53,12 +54,12 @@ "packages": { "@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/source-location": ["@aris/source-location@workspace:packages/aris-source-location"], + "@aris/source-tfl": ["@aris/source-tfl@workspace:packages/aris-source-tfl"], + "@aris/source-weatherkit": ["@aris/source-weatherkit@workspace:packages/aris-source-weatherkit"], "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], diff --git a/packages/aris-data-source-tfl/src/index.ts b/packages/aris-data-source-tfl/src/index.ts deleted file mode 100644 index 4da5d20..0000000 --- a/packages/aris-data-source-tfl/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -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" diff --git a/packages/aris-data-source-tfl/src/integration.test.ts b/packages/aris-data-source-tfl/src/integration.test.ts deleted file mode 100644 index 94896a5..0000000 --- a/packages/aris-data-source-tfl/src/integration.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { Context } from "@aris/core" - -import { describe, expect, test } from "bun:test" - -import type { ITflApi, TflLineStatus } from "./tfl-api.ts" -import type { StationLocation, TflLineId } from "./types.ts" - -import fixtures from "../fixtures/tfl-responses.json" -import { TflDataSource } from "./data-source.ts" - -// Mock API that returns fixture data -class FixtureTflApi implements ITflApi { - async fetchLineStatuses(_lines?: TflLineId[]): Promise { - const statuses: TflLineStatus[] = [] - - for (const line of fixtures.lineStatuses as Record[]) { - for (const status of line.lineStatuses as Record[]) { - 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 { - const stationMap = new Map() - - for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) { - for (const stop of stops as Record[]) { - 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 = { - 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 = { - 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[]) { - 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[]) { - 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[]) { - expect(typeof stop.naptanId).toBe("string") - expect(typeof stop.commonName).toBe("string") - expect(typeof stop.lat).toBe("number") - expect(typeof stop.lon).toBe("number") - } - } - }) -}) diff --git a/packages/aris-data-source-tfl/src/types.ts b/packages/aris-data-source-tfl/src/types.ts deleted file mode 100644 index 36cf098..0000000 --- a/packages/aris-data-source-tfl/src/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 { - 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[] -} diff --git a/packages/aris-data-source-tfl/fixtures/tfl-responses.json b/packages/aris-source-tfl/fixtures/tfl-responses.json similarity index 100% rename from packages/aris-data-source-tfl/fixtures/tfl-responses.json rename to packages/aris-source-tfl/fixtures/tfl-responses.json diff --git a/packages/aris-data-source-tfl/package.json b/packages/aris-source-tfl/package.json similarity index 79% rename from packages/aris-data-source-tfl/package.json rename to packages/aris-source-tfl/package.json index 1c4b876..4b2c5b4 100644 --- a/packages/aris-data-source-tfl/package.json +++ b/packages/aris-source-tfl/package.json @@ -1,5 +1,5 @@ { - "name": "@aris/data-source-tfl", + "name": "@aris/source-tfl", "version": "0.0.0", "type": "module", "main": "src/index.ts", @@ -10,6 +10,7 @@ }, "dependencies": { "@aris/core": "workspace:*", + "@aris/source-location": "workspace:*", "arktype": "^2.1.0" } } diff --git a/packages/aris-data-source-tfl/scripts/fetch-fixtures.ts b/packages/aris-source-tfl/scripts/fetch-fixtures.ts similarity index 100% rename from packages/aris-data-source-tfl/scripts/fetch-fixtures.ts rename to packages/aris-source-tfl/scripts/fetch-fixtures.ts diff --git a/packages/aris-source-tfl/src/index.ts b/packages/aris-source-tfl/src/index.ts new file mode 100644 index 0000000..c0a8d2b --- /dev/null +++ b/packages/aris-source-tfl/src/index.ts @@ -0,0 +1,11 @@ +export { TflSource } from "./tfl-source.ts" +export { TflApi } from "./tfl-api.ts" +export type { TflLineId } from "./tfl-api.ts" +export type { + StationLocation, + TflAlertData, + TflAlertFeedItem, + TflAlertSeverity, + TflLineStatus, + TflSourceOptions, +} from "./types.ts" diff --git a/packages/aris-data-source-tfl/src/tfl-api.ts b/packages/aris-source-tfl/src/tfl-api.ts similarity index 92% rename from packages/aris-data-source-tfl/src/tfl-api.ts rename to packages/aris-source-tfl/src/tfl-api.ts index 70aec52..614398c 100644 --- a/packages/aris-data-source-tfl/src/tfl-api.ts +++ b/packages/aris-source-tfl/src/tfl-api.ts @@ -1,6 +1,6 @@ import { type } from "arktype" -import type { StationLocation, TflAlertSeverity } from "./types.ts" +import type { StationLocation, TflAlertSeverity, TflLineStatus } from "./types.ts" const TFL_API_BASE = "https://api.tfl.gov.uk" @@ -50,19 +50,7 @@ const SEVERITY_MAP: Record = { 20: null, // Service Closed } -export interface TflLineStatus { - lineId: TflLineId - lineName: string - severity: TflAlertSeverity - description: string -} - -export interface ITflApi { - fetchLineStatuses(lines?: TflLineId[]): Promise - fetchStations(): Promise -} - -export class TflApi implements ITflApi { +export class TflApi { private apiKey: string private stationsCache: StationLocation[] | null = null diff --git a/packages/aris-source-tfl/src/tfl-source.test.ts b/packages/aris-source-tfl/src/tfl-source.test.ts new file mode 100644 index 0000000..6a463be --- /dev/null +++ b/packages/aris-source-tfl/src/tfl-source.test.ts @@ -0,0 +1,243 @@ +import type { Context } from "@aris/core" + +import { LocationKey, type Location } from "@aris/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 { + const statuses: TflLineStatus[] = [] + + for (const line of fixtures.lineStatuses as Record[]) { + for (const status of line.lineStatuses as Record[]) { + 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 { + const stationMap = new Map() + + for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) { + for (const stop of stops as Record[]) { + 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 = { + 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: Context = { time: new Date("2026-01-15T12:00:00Z") } + if (location) { + ctx[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("tfl") + }) + + test("depends on location", () => { + const source = new TflSource({ client: api }) + expect(source.dependencies).toEqual(["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("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(typeof item.priority).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 priority 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.priority).toBeGreaterThanOrEqual(curr.priority) + } + }) + + test("priority values match severity levels", async () => { + const source = new TflSource({ client: api }) + const items = await source.fetchItems(createContext()) + + const severityPriority: Record = { + closure: 1.0, + "major-delays": 0.8, + "minor-delays": 0.6, + } + + for (const item of items) { + expect(item.priority).toBe(severityPriority[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("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[]) { + 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[]) { + 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[]) { + expect(typeof stop.naptanId).toBe("string") + expect(typeof stop.commonName).toBe("string") + expect(typeof stop.lat).toBe("number") + expect(typeof stop.lon).toBe("number") + } + } + }) +}) diff --git a/packages/aris-data-source-tfl/src/data-source.ts b/packages/aris-source-tfl/src/tfl-source.ts similarity index 56% rename from packages/aris-data-source-tfl/src/data-source.ts rename to packages/aris-source-tfl/src/tfl-source.ts index ed299c3..a1e15c2 100644 --- a/packages/aris-data-source-tfl/src/data-source.ts +++ b/packages/aris-source-tfl/src/tfl-source.ts @@ -1,21 +1,104 @@ -import type { Context, DataSource } from "@aris/core" +import type { Context, FeedSource } from "@aris/core" + +import { contextValue } from "@aris/core" +import { LocationKey } from "@aris/source-location" import type { + ITflApi, StationLocation, TflAlertData, TflAlertFeedItem, TflAlertSeverity, - TflDataSourceConfig, - TflDataSourceOptions, TflLineId, + TflSourceOptions, } from "./types.ts" -import { TflApi, type ITflApi } from "./tfl-api.ts" +import { TflApi } from "./tfl-api.ts" const SEVERITY_PRIORITY: Record = { - closure: 100, - "major-delays": 80, - "minor-delays": 60, + closure: 1.0, + "major-delays": 0.8, + "minor-delays": 0.6, +} + +/** + * 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 { + readonly id = "tfl" + readonly dependencies = ["location"] + + private readonly client: ITflApi + private readonly 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 + } + + async fetchItems(context: Context): Promise { + const [statuses, stations] = await Promise.all([ + this.client.fetchLineStatuses(this.lines), + this.client.fetchStations(), + ]) + + const location = contextValue(context, 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, + } + + return { + id: `tfl-alert-${status.lineId}-${status.severity}`, + type: "tfl-alert", + 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 + } } function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { @@ -51,65 +134,3 @@ function findClosestStationDistance( return minDistance } - -export class TflDataSource implements DataSource { - 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 { - 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 - } -} diff --git a/packages/aris-source-tfl/src/types.ts b/packages/aris-source-tfl/src/types.ts new file mode 100644 index 0000000..0fa01d7 --- /dev/null +++ b/packages/aris-source-tfl/src/types.ts @@ -0,0 +1,50 @@ +import type { FeedItem } from "@aris/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 { + line: TflLineId + lineName: string + severity: TflAlertSeverity + description: string + closestStationDistance: number | null +} + +export type TflAlertFeedItem = FeedItem<"tfl-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 + fetchStations(): Promise +} + +export interface TflLineStatus { + lineId: TflLineId + lineName: string + severity: TflAlertSeverity + description: string +}