mirror of
https://github.com/kennethnym/aris.git
synced 2026-06-13 11:01:18 +01:00
chore: rename aelis to freya
This commit is contained in:
1
packages/freya-source-tfl/fixtures/tfl-responses.json
Normal file
1
packages/freya-source-tfl/fixtures/tfl-responses.json
Normal file
File diff suppressed because one or more lines are too long
21
packages/freya-source-tfl/package.json
Normal file
21
packages/freya-source-tfl/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@freya/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": {
|
||||
"@freya/components": "workspace:*",
|
||||
"@freya/core": "workspace:*",
|
||||
"@freya/source-location": "workspace:*",
|
||||
"arktype": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@json-render/core": "*",
|
||||
"@nym.sh/jrx": "*"
|
||||
}
|
||||
}
|
||||
35
packages/freya-source-tfl/scripts/fetch-fixtures.ts
Normal file
35
packages/freya-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)
|
||||
16
packages/freya-source-tfl/src/index.ts
Normal file
16
packages/freya-source-tfl/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
type TflStatusData,
|
||||
type TflStatusFeedItem,
|
||||
} from "./types.ts"
|
||||
export { renderTflStatus } from "./renderer.tsx"
|
||||
141
packages/freya-source-tfl/src/renderer.test.tsx
Normal file
141
packages/freya-source-tfl/src/renderer.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/** @jsxImportSource @nym.sh/jrx */
|
||||
import { render } from "@nym.sh/jrx"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { TflAlertData, TflStatusFeedItem } from "./types.ts"
|
||||
|
||||
import { renderTflStatus } from "./renderer.tsx"
|
||||
|
||||
function makeAlert(overrides: Partial<TflAlertData> = {}): TflAlertData {
|
||||
return {
|
||||
line: "northern",
|
||||
lineName: "Northern",
|
||||
severity: "minor-delays",
|
||||
description: "Minor delays due to signal failure",
|
||||
closestStationDistance: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeItem(alerts: TflAlertData[]): TflStatusFeedItem {
|
||||
return {
|
||||
id: "tfl-status",
|
||||
sourceId: "freya.tfl",
|
||||
type: "tfl-status",
|
||||
timestamp: new Date("2026-01-15T12:00:00Z"),
|
||||
data: { alerts },
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect all SansSerifText elements from a rendered spec, filtering out Fragments. */
|
||||
function collectTextElements(spec: ReturnType<typeof render>) {
|
||||
return Object.values(spec.elements).filter((el) => el.type === "SansSerifText")
|
||||
}
|
||||
|
||||
describe("renderTflStatus", () => {
|
||||
test("renders a single FeedCard", () => {
|
||||
const node = renderTflStatus(makeItem([makeAlert()]))
|
||||
const spec = render(node)
|
||||
|
||||
const root = spec.elements[spec.root]!
|
||||
expect(root.type).toBe("FeedCard")
|
||||
})
|
||||
|
||||
test("renders one alert with title and description", () => {
|
||||
const node = renderTflStatus(makeItem([makeAlert()]))
|
||||
const spec = render(node)
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const titleText = texts.find((el) => el.props.content === "Northern · Minor delays")
|
||||
const bodyText = texts.find((el) => el.props.content === "Minor delays due to signal failure")
|
||||
|
||||
expect(titleText).toBeDefined()
|
||||
expect(bodyText).toBeDefined()
|
||||
})
|
||||
|
||||
test("renders multiple alerts stacked in one card", () => {
|
||||
const alerts = [
|
||||
makeAlert({ line: "northern", lineName: "Northern", severity: "minor-delays" }),
|
||||
makeAlert({
|
||||
line: "central",
|
||||
lineName: "Central",
|
||||
severity: "closure",
|
||||
description: "Closed due to strike",
|
||||
}),
|
||||
]
|
||||
const node = renderTflStatus(makeItem(alerts))
|
||||
const spec = render(node)
|
||||
|
||||
const root = spec.elements[spec.root]!
|
||||
expect(root.type).toBe("FeedCard")
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const northernTitle = texts.find((el) => el.props.content === "Northern · Minor delays")
|
||||
const centralTitle = texts.find((el) => el.props.content === "Central · Closed")
|
||||
const centralBody = texts.find((el) => el.props.content === "Closed due to strike")
|
||||
|
||||
expect(northernTitle).toBeDefined()
|
||||
expect(centralTitle).toBeDefined()
|
||||
expect(centralBody).toBeDefined()
|
||||
})
|
||||
|
||||
test("shows nearest station distance when available", () => {
|
||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.35 })]))
|
||||
const spec = render(node)
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const caption = texts.find((el) => el.props.content === "Nearest station: 350m away")
|
||||
expect(caption).toBeDefined()
|
||||
})
|
||||
|
||||
test("formats distance in km when >= 1km", () => {
|
||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 2.456 })]))
|
||||
const spec = render(node)
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const caption = texts.find((el) => el.props.content === "Nearest station: 2.5km away")
|
||||
expect(caption).toBeDefined()
|
||||
})
|
||||
|
||||
test("formats near-1km boundary as km not meters", () => {
|
||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.9999 })]))
|
||||
const spec = render(node)
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const caption = texts.find((el) => el.props.content === "Nearest station: 1.0km away")
|
||||
expect(caption).toBeDefined()
|
||||
})
|
||||
|
||||
test("omits station distance when null", () => {
|
||||
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: null })]))
|
||||
const spec = render(node)
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const distanceTexts = texts.filter((el) =>
|
||||
(el.props.content as string).startsWith("Nearest station:"),
|
||||
)
|
||||
expect(distanceTexts).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("renders closure severity label", () => {
|
||||
const node = renderTflStatus(
|
||||
makeItem([makeAlert({ severity: "closure", lineName: "Central" })]),
|
||||
)
|
||||
const spec = render(node)
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const title = texts.find((el) => el.props.content === "Central · Closed")
|
||||
expect(title).toBeDefined()
|
||||
})
|
||||
|
||||
test("renders major delays severity label", () => {
|
||||
const node = renderTflStatus(
|
||||
makeItem([makeAlert({ severity: "major-delays", lineName: "Jubilee" })]),
|
||||
)
|
||||
const spec = render(node)
|
||||
|
||||
const texts = collectTextElements(spec)
|
||||
const title = texts.find((el) => el.props.content === "Jubilee · Major delays")
|
||||
expect(title).toBeDefined()
|
||||
})
|
||||
})
|
||||
46
packages/freya-source-tfl/src/renderer.tsx
Normal file
46
packages/freya-source-tfl/src/renderer.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/** @jsxImportSource @nym.sh/jrx */
|
||||
import type { FeedItemRenderer } from "@freya/core"
|
||||
|
||||
import { FeedCard, SansSerifText } from "@freya/components"
|
||||
|
||||
import type { TflAlertData, TflStatusData } from "./types.ts"
|
||||
|
||||
import { TflAlertSeverity } from "./types.ts"
|
||||
|
||||
const SEVERITY_LABEL: Record<TflAlertSeverity, string> = {
|
||||
[TflAlertSeverity.Closure]: "Closed",
|
||||
[TflAlertSeverity.MajorDelays]: "Major delays",
|
||||
[TflAlertSeverity.MinorDelays]: "Minor delays",
|
||||
}
|
||||
|
||||
function formatDistance(km: number): string {
|
||||
const meters = Math.round(km * 1000)
|
||||
if (meters < 1000) {
|
||||
return `${meters}m away`
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)}km away`
|
||||
}
|
||||
|
||||
function renderAlertRow(alert: TflAlertData) {
|
||||
const severityLabel = SEVERITY_LABEL[alert.severity]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SansSerifText
|
||||
content={`${alert.lineName} · ${severityLabel}`}
|
||||
style="text-base font-semibold"
|
||||
/>
|
||||
<SansSerifText content={alert.description} style="text-sm" />
|
||||
{alert.closestStationDistance !== null ? (
|
||||
<SansSerifText
|
||||
content={`Nearest station: ${formatDistance(alert.closestStationDistance)}`}
|
||||
style="text-xs text-stone-500"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const renderTflStatus: FeedItemRenderer<"tfl-status", TflStatusData> = (item) => {
|
||||
return <FeedCard>{item.data.alerts.map((alert) => renderAlertRow(alert))}</FeedCard>
|
||||
}
|
||||
184
packages/freya-source-tfl/src/tfl-api.ts
Normal file
184
packages/freya-source-tfl/src/tfl-api.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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?.length ? 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, tolerating individual failures
|
||||
const results = await Promise.allSettled(
|
||||
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 result of results) {
|
||||
if (result.status === "rejected") {
|
||||
continue
|
||||
}
|
||||
|
||||
const { lineId: currentLineId, stops } = result.value
|
||||
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],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only cache if all requests succeeded — partial results shouldn't persist
|
||||
const allSucceeded = results.every((r) => r.status === "fulfilled")
|
||||
const stations = Array.from(stationMap.values())
|
||||
|
||||
if (allSucceeded) {
|
||||
this.stationsCache = stations
|
||||
}
|
||||
|
||||
return stations
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
463
packages/freya-source-tfl/src/tfl-source.test.ts
Normal file
463
packages/freya-source-tfl/src/tfl-source.test.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { Context } from "@freya/core"
|
||||
import { LocationKey, type Location } from "@freya/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("freya.tfl")
|
||||
})
|
||||
|
||||
test("depends on location", () => {
|
||||
const source = new TflSource({ client: api })
|
||||
expect(source.dependencies).toEqual(["freya.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).toHaveLength(1)
|
||||
expect(before[0]!.data.alerts).toHaveLength(2)
|
||||
|
||||
source.setLinesOfInterest(["northern"])
|
||||
const after = await source.fetchItems(createContext())
|
||||
|
||||
expect(after).toHaveLength(1)
|
||||
expect(after[0]!.data.alerts).toHaveLength(1)
|
||||
expect(after[0]!.data.alerts[0]!.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[0]!.data.alerts).toHaveLength(1)
|
||||
|
||||
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
||||
const all = await source.fetchItems(createContext())
|
||||
|
||||
expect(all[0]!.data.alerts).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchItems", () => {
|
||||
test("returns at most one feed item", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
expect(items).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("returns empty array when no disruptions", async () => {
|
||||
const emptyApi: ITflApi = {
|
||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||
return []
|
||||
},
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
return []
|
||||
},
|
||||
}
|
||||
const source = new TflSource({ client: emptyApi })
|
||||
const items = await source.fetchItems(createContext())
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("combined item has correct base structure", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const item = items[0]!
|
||||
expect(item.id).toBe("tfl-status")
|
||||
expect(item.type).toBe("tfl-status")
|
||||
expect(item.sourceId).toBe("freya.tfl")
|
||||
expect(item.signals).toBeDefined()
|
||||
expect(typeof item.signals!.urgency).toBe("number")
|
||||
expect(item.timestamp).toBeInstanceOf(Date)
|
||||
expect(Array.isArray(item.data.alerts)).toBe(true)
|
||||
expect(item.data.alerts.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("alerts 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))
|
||||
const alerts = items[0]!.data.alerts
|
||||
|
||||
for (const alert of alerts) {
|
||||
expect(typeof alert.line).toBe("string")
|
||||
expect(typeof alert.lineName).toBe("string")
|
||||
expect(["minor-delays", "major-delays", "closure"]).toContain(alert.severity)
|
||||
expect(typeof alert.description).toBe("string")
|
||||
expect(
|
||||
alert.closestStationDistance === null || typeof alert.closestStationDistance === "number",
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("signals use highest severity urgency", async () => {
|
||||
const mixedApi: ITflApi = {
|
||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||
return [
|
||||
{
|
||||
lineId: "northern",
|
||||
lineName: "Northern",
|
||||
severity: "minor-delays",
|
||||
description: "Minor delays",
|
||||
},
|
||||
{
|
||||
lineId: "central",
|
||||
lineName: "Central",
|
||||
severity: "closure",
|
||||
description: "Closed",
|
||||
},
|
||||
{
|
||||
lineId: "jubilee",
|
||||
lineName: "Jubilee",
|
||||
severity: "major-delays",
|
||||
description: "Major delays",
|
||||
},
|
||||
]
|
||||
},
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
return []
|
||||
},
|
||||
}
|
||||
const source = new TflSource({ client: mixedApi })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
expect(items[0]!.signals!.urgency).toBe(1.0) // closure urgency
|
||||
expect(items[0]!.signals!.timeRelevance).toBe("imminent") // closure time relevance
|
||||
})
|
||||
|
||||
test("signals use single alert severity when only one disruption", async () => {
|
||||
const singleApi: ITflApi = {
|
||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||
return [
|
||||
{
|
||||
lineId: "northern",
|
||||
lineName: "Northern",
|
||||
severity: "minor-delays",
|
||||
description: "Minor delays",
|
||||
},
|
||||
]
|
||||
},
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
return []
|
||||
},
|
||||
}
|
||||
const source = new TflSource({ client: singleApi })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
expect(items[0]!.signals!.urgency).toBe(0.6) // minor-delays urgency
|
||||
expect(items[0]!.signals!.timeRelevance).toBe("upcoming")
|
||||
})
|
||||
|
||||
test("alerts sorted by closestStationDistance ascending, nulls last", async () => {
|
||||
const distanceApi: ITflApi = {
|
||||
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||
return [
|
||||
{
|
||||
lineId: "northern",
|
||||
lineName: "Northern",
|
||||
severity: "minor-delays",
|
||||
description: "Delays",
|
||||
},
|
||||
{
|
||||
lineId: "central",
|
||||
lineName: "Central",
|
||||
severity: "minor-delays",
|
||||
description: "Delays",
|
||||
},
|
||||
{
|
||||
lineId: "jubilee",
|
||||
lineName: "Jubilee",
|
||||
severity: "minor-delays",
|
||||
description: "Delays",
|
||||
},
|
||||
]
|
||||
},
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
return [
|
||||
{ id: "s1", name: "Station A", lat: 51.51, lng: -0.13, lines: ["central"] },
|
||||
{ id: "s2", name: "Station B", lat: 51.52, lng: -0.14, lines: ["northern"] },
|
||||
// No stations for jubilee — its distance will be null
|
||||
]
|
||||
},
|
||||
}
|
||||
const source = new TflSource({ client: distanceApi })
|
||||
const location: Location = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
const items = await source.fetchItems(createContext(location))
|
||||
const alerts = items[0]!.data.alerts
|
||||
|
||||
// Alerts with distances should come before nulls
|
||||
const withDistance = alerts.filter((a) => a.closestStationDistance !== null)
|
||||
const withoutDistance = alerts.filter((a) => a.closestStationDistance === null)
|
||||
|
||||
// All distance alerts come first
|
||||
const firstNullIndex = alerts.findIndex((a) => a.closestStationDistance === null)
|
||||
if (firstNullIndex !== -1) {
|
||||
for (let i = 0; i < firstNullIndex; i++) {
|
||||
expect(alerts[i]!.closestStationDistance).not.toBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
// Distance alerts are in ascending order
|
||||
for (let i = 1; i < withDistance.length; i++) {
|
||||
expect(withDistance[i]!.closestStationDistance!).toBeGreaterThanOrEqual(
|
||||
withDistance[i - 1]!.closestStationDistance!,
|
||||
)
|
||||
}
|
||||
|
||||
expect(withoutDistance.length).toBe(1)
|
||||
expect(withoutDistance[0]!.line).toBe("jubilee")
|
||||
})
|
||||
|
||||
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 alert of items[0]!.data.alerts) {
|
||||
expect(typeof alert.closestStationDistance).toBe("number")
|
||||
expect(alert.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 alert of items[0]!.data.alerts) {
|
||||
expect(alert.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).toHaveLength(1)
|
||||
expect(items[0]!.data.alerts).toHaveLength(1)
|
||||
expect(items[0]!.data.alerts[0]!.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")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
213
packages/freya-source-tfl/src/tfl-source.ts
Normal file
213
packages/freya-source-tfl/src/tfl-source.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@freya/core"
|
||||
|
||||
import { Context, TimeRelevance, UnknownActionError } from "@freya/core"
|
||||
import { LocationKey } from "@freya/source-location"
|
||||
import { type } from "arktype"
|
||||
|
||||
import type {
|
||||
ITflApi,
|
||||
StationLocation,
|
||||
TflAlertData,
|
||||
TflAlertSeverity,
|
||||
TflLineId,
|
||||
TflSourceOptions,
|
||||
TflStatusFeedItem,
|
||||
} 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<TflStatusFeedItem> {
|
||||
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 = "freya.tfl"
|
||||
readonly dependencies = ["freya.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?.length ? 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<TflStatusFeedItem[]> {
|
||||
const [statuses, stations] = await Promise.all([
|
||||
this.client.fetchLineStatuses(this.lines),
|
||||
this.client.fetchStations(),
|
||||
])
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const location = context.get(LocationKey)
|
||||
|
||||
const alerts: TflAlertData[] = statuses.map((status) => ({
|
||||
line: status.lineId,
|
||||
lineName: status.lineName,
|
||||
severity: status.severity,
|
||||
description: status.description,
|
||||
closestStationDistance: location
|
||||
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
||||
: null,
|
||||
}))
|
||||
|
||||
// Sort by closest station distance ascending, nulls last
|
||||
alerts.sort((a, b) => {
|
||||
if (a.closestStationDistance === null && b.closestStationDistance === null) return 0
|
||||
if (a.closestStationDistance === null) return 1
|
||||
if (b.closestStationDistance === null) return -1
|
||||
return a.closestStationDistance - b.closestStationDistance
|
||||
})
|
||||
|
||||
// Signals from the highest-severity alert
|
||||
const highestSeverity = alerts.reduce<TflAlertSeverity>(
|
||||
(worst, alert) =>
|
||||
SEVERITY_URGENCY[alert.severity] > SEVERITY_URGENCY[worst] ? alert.severity : worst,
|
||||
alerts[0]!.severity,
|
||||
)
|
||||
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: SEVERITY_URGENCY[highestSeverity],
|
||||
timeRelevance: SEVERITY_TIME_RELEVANCE[highestSeverity],
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "tfl-status",
|
||||
sourceId: this.id,
|
||||
type: TflFeedItemType.Status,
|
||||
timestamp: context.time,
|
||||
data: { alerts },
|
||||
signals,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
63
packages/freya-source-tfl/src/types.ts
Normal file
63
packages/freya-source-tfl/src/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { FeedItem } from "@freya/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",
|
||||
Status: "tfl-status",
|
||||
} as const
|
||||
|
||||
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
||||
|
||||
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
||||
|
||||
export interface TflStatusData extends Record<string, unknown> {
|
||||
alerts: TflAlertData[]
|
||||
}
|
||||
|
||||
export type TflStatusFeedItem = FeedItem<typeof TflFeedItemType.Status, TflStatusData>
|
||||
|
||||
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
|
||||
}
|
||||
7
packages/freya-source-tfl/tsconfig.json
Normal file
7
packages/freya-source-tfl/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsxImportSource": "@nym.sh/jrx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user