From 1dd14da71dd077a2a2c744efcae983cae224c930 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sat, 14 Mar 2026 23:55:43 +0000 Subject: [PATCH] feat(tfl): add FeedItemRenderer for TfL alerts Implement renderTflAlert using JRX and @aelis/components. Upgrade @nym.sh/jrx to 0.2.0 for null child support. Co-authored-by: Ona --- bun.lock | 9 +- package.json | 2 +- packages/aelis-source-tfl/package.json | 5 + packages/aelis-source-tfl/src/index.ts | 1 + .../aelis-source-tfl/src/renderer.test.tsx | 93 +++++++++++++++++++ packages/aelis-source-tfl/src/renderer.tsx | 38 ++++++++ packages/aelis-source-tfl/tsconfig.json | 7 ++ 7 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 packages/aelis-source-tfl/src/renderer.test.tsx create mode 100644 packages/aelis-source-tfl/src/renderer.tsx create mode 100644 packages/aelis-source-tfl/tsconfig.json diff --git a/bun.lock b/bun.lock index a864034..156e506 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "aelis", "devDependencies": { "@json-render/core": "^0.12.1", - "@nym.sh/jrx": "^0.1.0", + "@nym.sh/jrx": "^0.2.0", "@types/bun": "latest", "oxfmt": "^0.24.0", "oxlint": "^1.39.0", @@ -177,10 +177,15 @@ "name": "@aelis/source-tfl", "version": "0.0.0", "dependencies": { + "@aelis/components": "workspace:*", "@aelis/core": "workspace:*", "@aelis/source-location": "workspace:*", "arktype": "^2.1.0", }, + "peerDependencies": { + "@json-render/core": "*", + "@nym.sh/jrx": "*", + }, }, "packages/aelis-source-weatherkit": { "name": "@aelis/source-weatherkit", @@ -691,7 +696,7 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], - "@nym.sh/jrx": ["@nym.sh/jrx@0.1.0", "", { "peerDependencies": { "@json-render/core": ">=0.10.0" } }, "sha512-mu6fkAP/TI9FuP8A4WMCrcucpUtWF5xBTcETnrjOtvEED9i+7sQKuoOyhJeF6QaSuUkAA/8t3Xx3kYUjcAPFbw=="], + "@nym.sh/jrx": ["@nym.sh/jrx@0.2.0", "", { "peerDependencies": { "@json-render/core": ">=0.10.0" } }, "sha512-jd7Z1Q6T21366MtSUnwCFiu6Yl1AdNc9s5m6HxeUg265P+0enZCiyyxOuHsFwvpUcSEs/2DVBsqfMptdca44lA=="], "@oclif/core": ["@oclif/core@4.8.4", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.4", "semver": "^7.7.3", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.14", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-UTAqwXJJyRvLBvosL+1uPZYSpr8lEHgUb/EVGbPXo5WZqUIBHfJ0sR2bkBEsrj00/ar4IegKxx4YK0wn2c8SQg=="], diff --git a/package.json b/package.json index 205ca86..c509849 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@json-render/core": "^0.12.1", - "@nym.sh/jrx": "^0.1.0", + "@nym.sh/jrx": "^0.2.0", "@types/bun": "latest", "oxfmt": "^0.24.0", "oxlint": "^1.39.0" diff --git a/packages/aelis-source-tfl/package.json b/packages/aelis-source-tfl/package.json index 579f415..54b3297 100644 --- a/packages/aelis-source-tfl/package.json +++ b/packages/aelis-source-tfl/package.json @@ -10,7 +10,12 @@ }, "dependencies": { "@aelis/core": "workspace:*", + "@aelis/components": "workspace:*", "@aelis/source-location": "workspace:*", "arktype": "^2.1.0" + }, + "peerDependencies": { + "@json-render/core": "*", + "@nym.sh/jrx": "*" } } diff --git a/packages/aelis-source-tfl/src/index.ts b/packages/aelis-source-tfl/src/index.ts index f445054..08fdf35 100644 --- a/packages/aelis-source-tfl/src/index.ts +++ b/packages/aelis-source-tfl/src/index.ts @@ -11,3 +11,4 @@ export { type TflLineStatus, type TflSourceOptions, } from "./types.ts" +export { renderTflAlert } from "./renderer.tsx" diff --git a/packages/aelis-source-tfl/src/renderer.test.tsx b/packages/aelis-source-tfl/src/renderer.test.tsx new file mode 100644 index 0000000..93c5035 --- /dev/null +++ b/packages/aelis-source-tfl/src/renderer.test.tsx @@ -0,0 +1,93 @@ +import { render } from "@nym.sh/jrx" +import { describe, expect, test } from "bun:test" + +import type { TflAlertFeedItem } from "./types.ts" + +import { renderTflAlert } from "./renderer.tsx" + +function makeItem(overrides: Partial = {}): TflAlertFeedItem { + return { + id: "tfl-alert-northern-minor-delays", + type: "tfl-alert", + timestamp: new Date("2026-01-15T12:00:00Z"), + data: { + line: "northern", + lineName: "Northern", + severity: "minor-delays", + description: "Minor delays due to signal failure", + closestStationDistance: null, + ...overrides, + }, + } +} + +describe("renderTflAlert", () => { + test("renders a FeedCard with title and description", () => { + const node = renderTflAlert(makeItem()) + const spec = render(node) + + const root = spec.elements[spec.root]! + expect(root.type).toBe("FeedCard") + expect(root.children!.length).toBeGreaterThanOrEqual(2) + + const title = spec.elements[root.children![0]!]! + expect(title.type).toBe("SansSerifText") + expect(title.props.content).toBe("Northern · Minor delays") + + const body = spec.elements[root.children![1]!]! + expect(body.type).toBe("SansSerifText") + expect(body.props.content).toBe("Minor delays due to signal failure") + }) + + test("shows nearest station distance when available", () => { + const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 })) + const spec = render(node) + + const root = spec.elements[spec.root]! + expect(root.children).toHaveLength(3) + + const caption = spec.elements[root.children![2]!]! + expect(caption.type).toBe("SansSerifText") + expect(caption.props.content).toBe("Nearest station: 350m away") + }) + + test("formats distance in km when >= 1km", () => { + const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 })) + const spec = render(node) + + const root = spec.elements[spec.root]! + const caption = spec.elements[root.children![2]!]! + expect(caption.props.content).toBe("Nearest station: 2.5km away") + }) + + test("omits station distance when null", () => { + const node = renderTflAlert(makeItem({ closestStationDistance: null })) + const spec = render(node) + + const root = spec.elements[spec.root]! + // Title + body only, no caption (empty fragment doesn't produce a child) + const children = root.children!.filter((key) => { + const el = spec.elements[key] + return el && el.type !== "Fragment" + }) + expect(children).toHaveLength(2) + }) + + test("renders closure severity label", () => { + const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" })) + const spec = render(node) + + const root = spec.elements[spec.root]! + const title = spec.elements[root.children![0]!]! + expect(title.props.content).toBe("Central · Closed") + }) + + test("renders major delays severity label", () => { + const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" })) + const spec = render(node) + + const root = spec.elements[spec.root]! + const title = spec.elements[root.children![0]!]! + expect(title.props.content).toBe("Jubilee · Major delays") + }) +}) diff --git a/packages/aelis-source-tfl/src/renderer.tsx b/packages/aelis-source-tfl/src/renderer.tsx new file mode 100644 index 0000000..387b6f4 --- /dev/null +++ b/packages/aelis-source-tfl/src/renderer.tsx @@ -0,0 +1,38 @@ +import type { FeedItemRenderer } from "@aelis/core" + +import { FeedCard, SansSerifText } from "@aelis/components" + +import type { TflAlertData } from "./types.ts" + +import { TflAlertSeverity } from "./types.ts" + +const SEVERITY_LABEL: Record = { + [TflAlertSeverity.Closure]: "Closed", + [TflAlertSeverity.MajorDelays]: "Major delays", + [TflAlertSeverity.MinorDelays]: "Minor delays", +} + +function formatDistance(km: number): string { + if (km < 1) { + return `${Math.round(km * 1000)}m away` + } + return `${km.toFixed(1)}km away` +} + +export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => { + const { lineName, severity, description, closestStationDistance } = item.data + const severityLabel = SEVERITY_LABEL[severity] + + return ( + + + + {closestStationDistance !== null ? ( + + ) : null} + + ) +} diff --git a/packages/aelis-source-tfl/tsconfig.json b/packages/aelis-source-tfl/tsconfig.json new file mode 100644 index 0000000..8d65648 --- /dev/null +++ b/packages/aelis-source-tfl/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsxImportSource": "@nym.sh/jrx" + }, + "include": ["src"] +}