mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-31 15:11:18 +01:00
Compare commits
7 Commits
kn/remove-
...
feat/combi
| Author | SHA1 | Date | |
|---|---|---|---|
|
5cadcd1559
|
|||
| 1483805f13 | |||
| 68932f83c3 | |||
| 4916886adf | |||
| f1c2f399f2 | |||
| 7a85990c24 | |||
| f126afc3ca |
@@ -26,6 +26,12 @@ services:
|
|||||||
commands:
|
commands:
|
||||||
start: |
|
start: |
|
||||||
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
|
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
|
||||||
|
TS_IP=$(tailscale ip -4)
|
||||||
|
echo ""
|
||||||
|
echo "------------------ Bun Debugger ------------------"
|
||||||
|
echo "https://debug.bun.sh/#${TS_IP}:6499"
|
||||||
|
echo "------------------ Bun Debugger ------------------"
|
||||||
|
echo ""
|
||||||
cd apps/aelis-backend && bun run dev
|
cd apps/aelis-backend && bun run dev
|
||||||
|
|
||||||
admin-dashboard:
|
admin-dashboard:
|
||||||
|
|||||||
@@ -408,6 +408,40 @@ function FieldInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === "multiselect" && field.options) {
|
||||||
|
const selected = Array.isArray(value) ? (value as string[]) : []
|
||||||
|
|
||||||
|
function toggle(optValue: string) {
|
||||||
|
const next = selected.includes(optValue)
|
||||||
|
? selected.filter((v) => v !== optValue)
|
||||||
|
: [...selected, optValue]
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">
|
||||||
|
{labelContent}
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{field.options!.map((opt) => {
|
||||||
|
const isSelected = selected.includes(opt.value)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={opt.value}
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
className={`cursor-pointer select-none ${isSelected ? "" : "opacity-60 hover:opacity-100"}`}
|
||||||
|
onClick={() => !disabled && toggle(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === "number") {
|
if (field.type === "number") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -456,6 +490,8 @@ function buildInitialValues(
|
|||||||
values[name] = saved[name]
|
values[name] = saved[name]
|
||||||
} else if (field.defaultValue !== undefined) {
|
} else if (field.defaultValue !== undefined) {
|
||||||
values[name] = field.defaultValue
|
values[name] = field.defaultValue
|
||||||
|
} else if (field.type === "multiselect") {
|
||||||
|
values[name] = []
|
||||||
} else {
|
} else {
|
||||||
values[name] = field.type === "number" ? undefined : ""
|
values[name] = field.type === "number" ? undefined : ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ function serverBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigFieldDef {
|
export interface ConfigFieldDef {
|
||||||
type: "string" | "number" | "select"
|
type: "string" | "number" | "select" | "multiselect"
|
||||||
label: string
|
label: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
description?: string
|
description?: string
|
||||||
secret?: boolean
|
secret?: boolean
|
||||||
defaultValue?: string | number
|
defaultValue?: string | number | string[]
|
||||||
options?: { label: string; value: string }[]
|
options?: { label: string; value: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,39 @@ const sourceDefinitions: SourceDefinition[] = [
|
|||||||
dailyLimit: { type: "number", label: "Daily Forecast Limit", defaultValue: 7, description: "Number of daily forecasts to include" },
|
dailyLimit: { type: "number", label: "Daily Forecast Limit", defaultValue: 7, description: "Number of daily forecasts to include" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "aelis.tfl",
|
||||||
|
name: "TfL",
|
||||||
|
description: "Transport for London tube line status alerts.",
|
||||||
|
fields: {
|
||||||
|
lines: {
|
||||||
|
type: "multiselect",
|
||||||
|
label: "Lines",
|
||||||
|
description: "Lines to monitor. Leave empty for all lines.",
|
||||||
|
defaultValue: [],
|
||||||
|
options: [
|
||||||
|
{ label: "Bakerloo", value: "bakerloo" },
|
||||||
|
{ label: "Central", value: "central" },
|
||||||
|
{ label: "Circle", value: "circle" },
|
||||||
|
{ label: "District", value: "district" },
|
||||||
|
{ label: "Hammersmith & City", value: "hammersmith-city" },
|
||||||
|
{ label: "Jubilee", value: "jubilee" },
|
||||||
|
{ label: "Metropolitan", value: "metropolitan" },
|
||||||
|
{ label: "Northern", value: "northern" },
|
||||||
|
{ label: "Piccadilly", value: "piccadilly" },
|
||||||
|
{ label: "Victoria", value: "victoria" },
|
||||||
|
{ label: "Waterloo & City", value: "waterloo-city" },
|
||||||
|
{ label: "Lioness", value: "lioness" },
|
||||||
|
{ label: "Mildmay", value: "mildmay" },
|
||||||
|
{ label: "Windrush", value: "windrush" },
|
||||||
|
{ label: "Weaver", value: "weaver" },
|
||||||
|
{ label: "Suffragette", value: "suffragette" },
|
||||||
|
{ label: "Liberty", value: "liberty" },
|
||||||
|
{ label: "Elizabeth", value: "elizabeth" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function fetchSources(): Promise<SourceDefinition[]> {
|
export function fetchSources(): Promise<SourceDefinition[]> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CircleDot,
|
CircleDot,
|
||||||
CloudSun,
|
CloudSun,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
TrainFront,
|
||||||
LogOut,
|
LogOut,
|
||||||
MapPin,
|
MapPin,
|
||||||
Rss,
|
Rss,
|
||||||
@@ -41,6 +42,7 @@ const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
"aelis.weather": CloudSun,
|
"aelis.weather": CloudSun,
|
||||||
"aelis.caldav": CalendarDays,
|
"aelis.caldav": CalendarDays,
|
||||||
"aelis.google-calendar": Calendar,
|
"aelis.google-calendar": Calendar,
|
||||||
|
"aelis.tfl": TrainFront,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRoute({
|
export const Route = createRoute({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.ts",
|
"main": "src/server.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/server.ts",
|
"dev": "bun run --watch --inspect=0.0.0.0:6499 src/server.ts",
|
||||||
"start": "bun run src/server.ts",
|
"start": "bun run src/server.ts",
|
||||||
"test": "bun test src/",
|
"test": "bun test src/",
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { registerLocationHttpHandlers } from "./location/http.ts"
|
|||||||
import { LocationSourceProvider } from "./location/provider.ts"
|
import { LocationSourceProvider } from "./location/provider.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||||
|
import { TflSourceProvider } from "./tfl/provider.ts"
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
@@ -45,6 +46,7 @@ function main() {
|
|||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
||||||
],
|
],
|
||||||
feedEnhancer,
|
feedEnhancer,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,5 +10,7 @@ export {
|
|||||||
type TflAlertSeverity,
|
type TflAlertSeverity,
|
||||||
type TflLineStatus,
|
type TflLineStatus,
|
||||||
type TflSourceOptions,
|
type TflSourceOptions,
|
||||||
|
type TflStatusData,
|
||||||
|
type TflStatusFeedItem,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
export { renderTflAlert } from "./renderer.tsx"
|
export { renderTflStatus } from "./renderer.tsx"
|
||||||
|
|||||||
@@ -2,102 +2,140 @@
|
|||||||
import { render } from "@nym.sh/jrx"
|
import { render } from "@nym.sh/jrx"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { TflAlertFeedItem } from "./types.ts"
|
import type { TflAlertData, TflStatusFeedItem } from "./types.ts"
|
||||||
|
|
||||||
import { renderTflAlert } from "./renderer.tsx"
|
import { renderTflStatus } from "./renderer.tsx"
|
||||||
|
|
||||||
function makeItem(overrides: Partial<TflAlertFeedItem["data"]> = {}): TflAlertFeedItem {
|
function makeAlert(overrides: Partial<TflAlertData> = {}): TflAlertData {
|
||||||
return {
|
return {
|
||||||
id: "tfl-alert-northern-minor-delays",
|
line: "northern",
|
||||||
type: "tfl-alert",
|
lineName: "Northern",
|
||||||
timestamp: new Date("2026-01-15T12:00:00Z"),
|
severity: "minor-delays",
|
||||||
data: {
|
description: "Minor delays due to signal failure",
|
||||||
line: "northern",
|
closestStationDistance: null,
|
||||||
lineName: "Northern",
|
...overrides,
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Minor delays due to signal failure",
|
|
||||||
closestStationDistance: null,
|
|
||||||
...overrides,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("renderTflAlert", () => {
|
function makeItem(alerts: TflAlertData[]): TflStatusFeedItem {
|
||||||
test("renders a FeedCard with title and description", () => {
|
return {
|
||||||
const node = renderTflAlert(makeItem())
|
id: "tfl-status",
|
||||||
|
sourceId: "aelis.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 spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const root = spec.elements[spec.root]!
|
||||||
expect(root.type).toBe("FeedCard")
|
expect(root.type).toBe("FeedCard")
|
||||||
expect(root.children!.length).toBeGreaterThanOrEqual(2)
|
})
|
||||||
|
|
||||||
const title = spec.elements[root.children![0]!]!
|
test("renders one alert with title and description", () => {
|
||||||
expect(title.type).toBe("SansSerifText")
|
const node = renderTflStatus(makeItem([makeAlert()]))
|
||||||
expect(title.props.content).toBe("Northern · Minor delays")
|
const spec = render(node)
|
||||||
|
|
||||||
const body = spec.elements[root.children![1]!]!
|
const texts = collectTextElements(spec)
|
||||||
expect(body.type).toBe("SansSerifText")
|
const titleText = texts.find((el) => el.props.content === "Northern · Minor delays")
|
||||||
expect(body.props.content).toBe("Minor delays due to signal failure")
|
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", () => {
|
test("shows nearest station distance when available", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.35 })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
expect(root.children).toHaveLength(3)
|
const caption = texts.find((el) => el.props.content === "Nearest station: 350m away")
|
||||||
|
expect(caption).toBeDefined()
|
||||||
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", () => {
|
test("formats distance in km when >= 1km", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 2.456 })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const caption = spec.elements[root.children![2]!]!
|
const caption = texts.find((el) => el.props.content === "Nearest station: 2.5km away")
|
||||||
expect(caption.props.content).toBe("Nearest station: 2.5km away")
|
expect(caption).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats near-1km boundary as km not meters", () => {
|
test("formats near-1km boundary as km not meters", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 0.9999 }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.9999 })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const caption = spec.elements[root.children![2]!]!
|
const caption = texts.find((el) => el.props.content === "Nearest station: 1.0km away")
|
||||||
expect(caption.props.content).toBe("Nearest station: 1.0km away")
|
expect(caption).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits station distance when null", () => {
|
test("omits station distance when null", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: null }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: null })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
// Title + body only, no caption (empty fragment doesn't produce a child)
|
const distanceTexts = texts.filter((el) =>
|
||||||
const children = root.children!.filter((key) => {
|
(el.props.content as string).startsWith("Nearest station:"),
|
||||||
const el = spec.elements[key]
|
)
|
||||||
return el && el.type !== "Fragment"
|
expect(distanceTexts).toHaveLength(0)
|
||||||
})
|
|
||||||
expect(children).toHaveLength(2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders closure severity label", () => {
|
test("renders closure severity label", () => {
|
||||||
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" }))
|
const node = renderTflStatus(
|
||||||
|
makeItem([makeAlert({ severity: "closure", lineName: "Central" })]),
|
||||||
|
)
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const title = spec.elements[root.children![0]!]!
|
const title = texts.find((el) => el.props.content === "Central · Closed")
|
||||||
expect(title.props.content).toBe("Central · Closed")
|
expect(title).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders major delays severity label", () => {
|
test("renders major delays severity label", () => {
|
||||||
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" }))
|
const node = renderTflStatus(
|
||||||
|
makeItem([makeAlert({ severity: "major-delays", lineName: "Jubilee" })]),
|
||||||
|
)
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const title = spec.elements[root.children![0]!]!
|
const title = texts.find((el) => el.props.content === "Jubilee · Major delays")
|
||||||
expect(title.props.content).toBe("Jubilee · Major delays")
|
expect(title).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FeedItemRenderer } from "@aelis/core"
|
|||||||
|
|
||||||
import { FeedCard, SansSerifText } from "@aelis/components"
|
import { FeedCard, SansSerifText } from "@aelis/components"
|
||||||
|
|
||||||
import type { TflAlertData } from "./types.ts"
|
import type { TflAlertData, TflStatusData } from "./types.ts"
|
||||||
|
|
||||||
import { TflAlertSeverity } from "./types.ts"
|
import { TflAlertSeverity } from "./types.ts"
|
||||||
|
|
||||||
@@ -21,20 +21,26 @@ function formatDistance(km: number): string {
|
|||||||
return `${(meters / 1000).toFixed(1)}km away`
|
return `${(meters / 1000).toFixed(1)}km away`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => {
|
function renderAlertRow(alert: TflAlertData) {
|
||||||
const { lineName, severity, description, closestStationDistance } = item.data
|
const severityLabel = SEVERITY_LABEL[alert.severity]
|
||||||
const severityLabel = SEVERITY_LABEL[severity]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeedCard>
|
<>
|
||||||
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" />
|
<SansSerifText
|
||||||
<SansSerifText content={description} style="text-sm" />
|
content={`${alert.lineName} · ${severityLabel}`}
|
||||||
{closestStationDistance !== null ? (
|
style="text-base font-semibold"
|
||||||
|
/>
|
||||||
|
<SansSerifText content={alert.description} style="text-sm" />
|
||||||
|
{alert.closestStationDistance !== null ? (
|
||||||
<SansSerifText
|
<SansSerifText
|
||||||
content={`Nearest station: ${formatDistance(closestStationDistance)}`}
|
content={`Nearest station: ${formatDistance(alert.closestStationDistance)}`}
|
||||||
style="text-xs text-stone-500"
|
style="text-xs text-stone-500"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</FeedCard>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const renderTflStatus: FeedItemRenderer<"tfl-status", TflStatusData> = (item) => {
|
||||||
|
return <FeedCard>{item.data.alerts.map((alert) => renderAlertRow(alert))}</FeedCard>
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class TflApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||||
const lineIds = lines ?? ALL_LINE_IDS
|
const lineIds = lines?.length ? lines : ALL_LINE_IDS
|
||||||
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
|
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
|
||||||
|
|
||||||
const parsed = lineResponseArray(data)
|
const parsed = lineResponseArray(data)
|
||||||
@@ -101,8 +101,8 @@ export class TflApi {
|
|||||||
return this.stationsCache
|
return this.stationsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stations for all lines in parallel
|
// Fetch stations for all lines in parallel, tolerating individual failures
|
||||||
const responses = await Promise.all(
|
const results = await Promise.allSettled(
|
||||||
ALL_LINE_IDS.map(async (id) => {
|
ALL_LINE_IDS.map(async (id) => {
|
||||||
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
|
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
|
||||||
const parsed = lineStopPointsArray(data)
|
const parsed = lineStopPointsArray(data)
|
||||||
@@ -116,7 +116,12 @@ export class TflApi {
|
|||||||
// Merge stations, combining lines for shared stations
|
// Merge stations, combining lines for shared stations
|
||||||
const stationMap = new Map<string, StationLocation>()
|
const stationMap = new Map<string, StationLocation>()
|
||||||
|
|
||||||
for (const { lineId: currentLineId, stops } of responses) {
|
for (const result of results) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lineId: currentLineId, stops } = result.value
|
||||||
for (const stop of stops) {
|
for (const stop of stops) {
|
||||||
const existing = stationMap.get(stop.naptanId)
|
const existing = stationMap.get(stop.naptanId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -135,8 +140,15 @@ export class TflApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stationsCache = Array.from(stationMap.values())
|
// Only cache if all requests succeeded — partial results shouldn't persist
|
||||||
return this.stationsCache
|
const allSucceeded = results.every((r) => r.status === "fulfilled")
|
||||||
|
const stations = Array.from(stationMap.values())
|
||||||
|
|
||||||
|
if (allSucceeded) {
|
||||||
|
this.stationsCache = stations
|
||||||
|
}
|
||||||
|
|
||||||
|
return stations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,13 +138,15 @@ describe("TflSource", () => {
|
|||||||
test("changes which lines are fetched", async () => {
|
test("changes which lines are fetched", async () => {
|
||||||
const source = new TflSource({ client: lineFilteringApi })
|
const source = new TflSource({ client: lineFilteringApi })
|
||||||
const before = await source.fetchItems(createContext())
|
const before = await source.fetchItems(createContext())
|
||||||
expect(before.length).toBe(2)
|
expect(before).toHaveLength(1)
|
||||||
|
expect(before[0]!.data.alerts).toHaveLength(2)
|
||||||
|
|
||||||
source.setLinesOfInterest(["northern"])
|
source.setLinesOfInterest(["northern"])
|
||||||
const after = await source.fetchItems(createContext())
|
const after = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(after.length).toBe(1)
|
expect(after).toHaveLength(1)
|
||||||
expect(after[0]!.data.line).toBe("northern")
|
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 () => {
|
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
|
||||||
@@ -153,23 +155,52 @@ describe("TflSource", () => {
|
|||||||
lines: ["northern"],
|
lines: ["northern"],
|
||||||
})
|
})
|
||||||
const filtered = await source.fetchItems(createContext())
|
const filtered = await source.fetchItems(createContext())
|
||||||
expect(filtered.length).toBe(1)
|
expect(filtered[0]!.data.alerts).toHaveLength(1)
|
||||||
|
|
||||||
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
||||||
const all = await source.fetchItems(createContext())
|
const all = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(all.length).toBe(2)
|
expect(all[0]!.data.alerts).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("fetchItems", () => {
|
describe("fetchItems", () => {
|
||||||
test("returns feed items array", async () => {
|
test("returns at most one feed item", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
expect(Array.isArray(items)).toBe(true)
|
expect(items).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("feed items have correct base structure", async () => {
|
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("aelis.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 source = new TflSource({ client: api })
|
||||||
const location: Location = {
|
const location: Location = {
|
||||||
lat: 51.5074,
|
lat: 51.5074,
|
||||||
@@ -178,72 +209,140 @@ describe("TflSource", () => {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}
|
}
|
||||||
const items = await source.fetchItems(createContext(location))
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
const alerts = items[0]!.data.alerts
|
||||||
|
|
||||||
for (const item of items) {
|
for (const alert of alerts) {
|
||||||
expect(typeof item.id).toBe("string")
|
expect(typeof alert.line).toBe("string")
|
||||||
expect(item.id).toMatch(/^tfl-alert-/)
|
expect(typeof alert.lineName).toBe("string")
|
||||||
expect(item.type).toBe("tfl-alert")
|
expect(["minor-delays", "major-delays", "closure"]).toContain(alert.severity)
|
||||||
expect(item.signals).toBeDefined()
|
expect(typeof alert.description).toBe("string")
|
||||||
expect(typeof item.signals!.urgency).toBe("number")
|
|
||||||
expect(item.timestamp).toBeInstanceOf(Date)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed items have correct data structure", async () => {
|
|
||||||
const source = new TflSource({ client: api })
|
|
||||||
const location: Location = {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
const items = await source.fetchItems(createContext(location))
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(typeof item.data.line).toBe("string")
|
|
||||||
expect(typeof item.data.lineName).toBe("string")
|
|
||||||
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
|
||||||
expect(typeof item.data.description).toBe("string")
|
|
||||||
expect(
|
expect(
|
||||||
item.data.closestStationDistance === null ||
|
alert.closestStationDistance === null || typeof alert.closestStationDistance === "number",
|
||||||
typeof item.data.closestStationDistance === "number",
|
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("feed item ids are unique", async () => {
|
test("signals use highest severity urgency", async () => {
|
||||||
const source = new TflSource({ client: api })
|
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())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const ids = items.map((item) => item.id)
|
expect(items[0]!.signals!.urgency).toBe(1.0) // closure urgency
|
||||||
const uniqueIds = new Set(ids)
|
expect(items[0]!.signals!.timeRelevance).toBe("imminent") // closure time relevance
|
||||||
expect(uniqueIds.size).toBe(ids.length)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("feed items are sorted by urgency descending", async () => {
|
test("signals use single alert severity when only one disruption", async () => {
|
||||||
const source = new TflSource({ client: api })
|
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())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
for (let i = 1; i < items.length; i++) {
|
expect(items[0]!.signals!.urgency).toBe(0.6) // minor-delays urgency
|
||||||
const prev = items[i - 1]!
|
expect(items[0]!.signals!.timeRelevance).toBe("upcoming")
|
||||||
const curr = items[i]!
|
|
||||||
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("urgency values match severity levels", async () => {
|
test("alerts sorted by closestStationDistance ascending, nulls last", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const distanceApi: ITflApi = {
|
||||||
const items = await source.fetchItems(createContext())
|
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
|
||||||
|
|
||||||
const severityUrgency: Record<string, number> = {
|
// Alerts with distances should come before nulls
|
||||||
closure: 1.0,
|
const withDistance = alerts.filter((a) => a.closestStationDistance !== null)
|
||||||
"major-delays": 0.8,
|
const withoutDistance = alerts.filter((a) => a.closestStationDistance === null)
|
||||||
"minor-delays": 0.6,
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
// Distance alerts are in ascending order
|
||||||
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
|
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 () => {
|
test("closestStationDistance is number when location provided", async () => {
|
||||||
@@ -256,9 +355,9 @@ describe("TflSource", () => {
|
|||||||
}
|
}
|
||||||
const items = await source.fetchItems(createContext(location))
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
for (const item of items) {
|
for (const alert of items[0]!.data.alerts) {
|
||||||
expect(typeof item.data.closestStationDistance).toBe("number")
|
expect(typeof alert.closestStationDistance).toBe("number")
|
||||||
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
expect(alert.closestStationDistance!).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,8 +365,8 @@ describe("TflSource", () => {
|
|||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
for (const item of items) {
|
for (const alert of items[0]!.data.alerts) {
|
||||||
expect(item.data.closestStationDistance).toBeNull()
|
expect(alert.closestStationDistance).toBeNull()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -309,8 +408,9 @@ describe("TflSource", () => {
|
|||||||
await source.executeAction("set-lines-of-interest", ["northern"])
|
await source.executeAction("set-lines-of-interest", ["northern"])
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
expect(items.length).toBe(1)
|
expect(items).toHaveLength(1)
|
||||||
expect(items[0]!.data.line).toBe("northern")
|
expect(items[0]!.data.alerts).toHaveLength(1)
|
||||||
|
expect(items[0]!.data.alerts[0]!.line).toBe("northern")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("executeAction throws on invalid input", async () => {
|
test("executeAction throws on invalid input", async () => {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import type {
|
|||||||
ITflApi,
|
ITflApi,
|
||||||
StationLocation,
|
StationLocation,
|
||||||
TflAlertData,
|
TflAlertData,
|
||||||
TflAlertFeedItem,
|
|
||||||
TflAlertSeverity,
|
TflAlertSeverity,
|
||||||
TflLineId,
|
TflLineId,
|
||||||
TflSourceOptions,
|
TflSourceOptions,
|
||||||
|
TflStatusFeedItem,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { TflApi, lineId } from "./tfl-api.ts"
|
import { TflApi, lineId } from "./tfl-api.ts"
|
||||||
@@ -51,7 +51,7 @@ const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
|
|||||||
* const { items } = await engine.refresh()
|
* const { items } = await engine.refresh()
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
export class TflSource implements FeedSource<TflStatusFeedItem> {
|
||||||
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
||||||
"bakerloo",
|
"bakerloo",
|
||||||
"central",
|
"central",
|
||||||
@@ -84,7 +84,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
throw new Error("Either client or apiKey must be provided")
|
throw new Error("Either client or apiKey must be provided")
|
||||||
}
|
}
|
||||||
this.client = options.client ?? new TflApi(options.apiKey!)
|
this.client = options.client ?? new TflApi(options.apiKey!)
|
||||||
this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
this.lines = options.lines?.length ? options.lines : [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
@@ -123,56 +123,58 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
this.lines = lines
|
this.lines = lines
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
async fetchItems(context: Context): Promise<TflStatusFeedItem[]> {
|
||||||
const [statuses, stations] = await Promise.all([
|
const [statuses, stations] = await Promise.all([
|
||||||
this.client.fetchLineStatuses(this.lines),
|
this.client.fetchLineStatuses(this.lines),
|
||||||
this.client.fetchStations(),
|
this.client.fetchStations(),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const location = context.get(LocationKey)
|
const location = context.get(LocationKey)
|
||||||
|
|
||||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
const alerts: TflAlertData[] = statuses.map((status) => ({
|
||||||
const closestStationDistance = location
|
line: status.lineId,
|
||||||
|
lineName: status.lineName,
|
||||||
|
severity: status.severity,
|
||||||
|
description: status.description,
|
||||||
|
closestStationDistance: location
|
||||||
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
||||||
: null
|
: null,
|
||||||
|
}))
|
||||||
|
|
||||||
const data: TflAlertData = {
|
// Sort by closest station distance ascending, nulls last
|
||||||
line: status.lineId,
|
alerts.sort((a, b) => {
|
||||||
lineName: status.lineName,
|
if (a.closestStationDistance === null && b.closestStationDistance === null) return 0
|
||||||
severity: status.severity,
|
if (a.closestStationDistance === null) return 1
|
||||||
description: status.description,
|
if (b.closestStationDistance === null) return -1
|
||||||
closestStationDistance,
|
return a.closestStationDistance - b.closestStationDistance
|
||||||
}
|
})
|
||||||
|
|
||||||
const signals: FeedItemSignals = {
|
// Signals from the highest-severity alert
|
||||||
urgency: SEVERITY_URGENCY[status.severity],
|
const highestSeverity = alerts.reduce<TflAlertSeverity>(
|
||||||
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
|
(worst, alert) =>
|
||||||
}
|
SEVERITY_URGENCY[alert.severity] > SEVERITY_URGENCY[worst] ? alert.severity : worst,
|
||||||
|
alerts[0]!.severity,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
const signals: FeedItemSignals = {
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
urgency: SEVERITY_URGENCY[highestSeverity],
|
||||||
|
timeRelevance: SEVERITY_TIME_RELEVANCE[highestSeverity],
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "tfl-status",
|
||||||
sourceId: this.id,
|
sourceId: this.id,
|
||||||
type: TflFeedItemType.Alert,
|
type: TflFeedItemType.Status,
|
||||||
timestamp: context.time,
|
timestamp: context.time,
|
||||||
data,
|
data: { alerts },
|
||||||
signals,
|
signals,
|
||||||
}
|
},
|
||||||
})
|
]
|
||||||
|
|
||||||
// Sort by urgency (desc), then by proximity (asc) if location available
|
|
||||||
items.sort((a, b) => {
|
|
||||||
const aUrgency = a.signals?.urgency ?? 0
|
|
||||||
const bUrgency = b.signals?.urgency ?? 0
|
|
||||||
if (bUrgency !== aUrgency) {
|
|
||||||
return bUrgency - aUrgency
|
|
||||||
}
|
|
||||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
|
||||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,19 @@ export interface TflAlertData extends Record<string, unknown> {
|
|||||||
|
|
||||||
export const TflFeedItemType = {
|
export const TflFeedItemType = {
|
||||||
Alert: "tfl-alert",
|
Alert: "tfl-alert",
|
||||||
|
Status: "tfl-status",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
||||||
|
|
||||||
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
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 {
|
export interface TflSourceOptions {
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
client?: ITflApi
|
client?: ITflApi
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export interface HourlyWeatherFeedItem extends FeedItem<
|
|||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export type DailyWeatherData = {
|
export type DailyWeatherEntry = {
|
||||||
forecastDate: Date
|
forecastDate: Date
|
||||||
conditionCode: ConditionCode
|
conditionCode: ConditionCode
|
||||||
maxUvIndex: number
|
maxUvIndex: number
|
||||||
@@ -71,6 +71,10 @@ export type DailyWeatherData = {
|
|||||||
temperatureMin: number
|
temperatureMin: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DailyWeatherData = {
|
||||||
|
days: DailyWeatherEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface DailyWeatherFeedItem extends FeedItem<
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Daily,
|
typeof WeatherFeedItemType.Daily,
|
||||||
DailyWeatherData
|
DailyWeatherData
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
type HourlyWeatherEntry,
|
type HourlyWeatherEntry,
|
||||||
type DailyWeatherFeedItem,
|
type DailyWeatherFeedItem,
|
||||||
type DailyWeatherData,
|
type DailyWeatherData,
|
||||||
|
type DailyWeatherEntry,
|
||||||
type WeatherAlertFeedItem,
|
type WeatherAlertFeedItem,
|
||||||
type WeatherAlertData,
|
type WeatherAlertData,
|
||||||
} from "./feed-items"
|
} from "./feed-items"
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
|
|||||||
import { LocationKey } from "@aelis/source-location"
|
import { LocationKey } from "@aelis/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast } from "./weatherkit"
|
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit"
|
||||||
|
|
||||||
import fixture from "../fixtures/san-francisco.json"
|
import fixture from "../fixtures/san-francisco.json"
|
||||||
import { WeatherFeedItemType, type HourlyWeatherData } from "./feed-items"
|
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"
|
||||||
import { WeatherKey, type Weather } from "./weather-context"
|
import { WeatherKey, type Weather } from "./weather-context"
|
||||||
import { WeatherSource, Units } from "./weather-source"
|
import { WeatherSource, Units } from "./weather-source"
|
||||||
|
|
||||||
@@ -133,7 +133,8 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
expect(hourlyItems.length).toBe(1)
|
expect(hourlyItems.length).toBe(1)
|
||||||
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
|
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
|
||||||
expect(dailyItems.length).toBe(2)
|
expect(dailyItems.length).toBe(1)
|
||||||
|
expect((dailyItems[0]!.data as DailyWeatherData).days.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("produces a single hourly item with hours array", async () => {
|
test("produces a single hourly item with hours array", async () => {
|
||||||
@@ -192,6 +193,65 @@ describe("WeatherSource", () => {
|
|||||||
expect(hourlyItem!.signals!.timeRelevance).toBe("imminent")
|
expect(hourlyItem!.signals!.timeRelevance).toBe("imminent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("produces a single daily item with days array", async () => {
|
||||||
|
const source = new WeatherSource({ client: mockClient })
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
|
const items = await source.fetchItems(context)
|
||||||
|
|
||||||
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
||||||
|
expect(dailyItems.length).toBe(1)
|
||||||
|
|
||||||
|
const dailyData = dailyItems[0]!.data as DailyWeatherData
|
||||||
|
expect(Array.isArray(dailyData.days)).toBe(true)
|
||||||
|
expect(dailyData.days.length).toBeGreaterThan(0)
|
||||||
|
expect(dailyData.days.length).toBeLessThanOrEqual(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("averages urgency across days with mixed conditions", async () => {
|
||||||
|
const mildDay: DailyForecast = {
|
||||||
|
forecastStart: "2026-01-17T00:00:00Z",
|
||||||
|
forecastEnd: "2026-01-18T00:00:00Z",
|
||||||
|
conditionCode: "Clear",
|
||||||
|
maxUvIndex: 3,
|
||||||
|
moonPhase: "firstQuarter",
|
||||||
|
precipitationAmount: 0,
|
||||||
|
precipitationChance: 0,
|
||||||
|
precipitationType: "clear",
|
||||||
|
snowfallAmount: 0,
|
||||||
|
sunrise: "2026-01-17T07:00:00Z",
|
||||||
|
sunriseCivil: "2026-01-17T06:30:00Z",
|
||||||
|
sunriseNautical: "2026-01-17T06:00:00Z",
|
||||||
|
sunriseAstronomical: "2026-01-17T05:30:00Z",
|
||||||
|
sunset: "2026-01-17T17:00:00Z",
|
||||||
|
sunsetCivil: "2026-01-17T17:30:00Z",
|
||||||
|
sunsetNautical: "2026-01-17T18:00:00Z",
|
||||||
|
sunsetAstronomical: "2026-01-17T18:30:00Z",
|
||||||
|
temperatureMax: 15,
|
||||||
|
temperatureMin: 5,
|
||||||
|
}
|
||||||
|
const severeDay: DailyForecast = {
|
||||||
|
...mildDay,
|
||||||
|
forecastStart: "2026-01-18T00:00:00Z",
|
||||||
|
forecastEnd: "2026-01-19T00:00:00Z",
|
||||||
|
conditionCode: "SevereThunderstorm",
|
||||||
|
}
|
||||||
|
const mixedResponse: WeatherKitResponse = {
|
||||||
|
forecastDaily: { days: [mildDay, severeDay] },
|
||||||
|
}
|
||||||
|
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
|
||||||
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
|
const items = await source.fetchItems(context)
|
||||||
|
const dailyItem = items.find((i) => i.type === WeatherFeedItemType.Daily)
|
||||||
|
|
||||||
|
expect(dailyItem).toBeDefined()
|
||||||
|
// Mild urgency = 0.2, severe urgency = 0.5, average = 0.35
|
||||||
|
expect(dailyItem!.signals!.urgency).toBeCloseTo(0.35, 5)
|
||||||
|
// Worst-case: SevereThunderstorm → Imminent
|
||||||
|
expect(dailyItem!.signals!.timeRelevance).toBe("imminent")
|
||||||
|
})
|
||||||
|
|
||||||
test("sets timestamp from context.time", async () => {
|
test("sets timestamp from context.time", async () => {
|
||||||
const source = new WeatherSource({ client: mockClient })
|
const source = new WeatherSource({ client: mockClient })
|
||||||
const queryTime = new Date("2026-01-17T12:00:00Z")
|
const queryTime = new Date("2026-01-17T12:00:00Z")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
|
|||||||
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
||||||
import { LocationKey } from "@aelis/source-location"
|
import { LocationKey } from "@aelis/source-location"
|
||||||
|
|
||||||
import { WeatherFeedItemType, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
|
||||||
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
|
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
|
||||||
import { WeatherKey, type Weather } from "./weather-context"
|
import { WeatherKey, type Weather } from "./weather-context"
|
||||||
import {
|
import {
|
||||||
@@ -181,11 +181,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
|
|
||||||
if (response.forecastDaily?.days) {
|
if (response.forecastDaily?.days) {
|
||||||
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
||||||
for (let i = 0; i < days.length; i++) {
|
if (days.length > 0) {
|
||||||
const day = days[i]
|
items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
|
||||||
if (day) {
|
|
||||||
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,24 +367,18 @@ function createHourlyForecastFeedItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDailyWeatherFeedItem(
|
function createDailyForecastFeedItem(
|
||||||
daily: DailyForecast,
|
dailyForecasts: DailyForecast[],
|
||||||
index: number,
|
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const signals: FeedItemSignals = {
|
const days: DailyWeatherEntry[] = []
|
||||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
let totalUrgency = 0
|
||||||
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
for (const daily of dailyForecasts) {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
days.push({
|
||||||
sourceId,
|
|
||||||
type: WeatherFeedItemType.Daily,
|
|
||||||
timestamp,
|
|
||||||
data: {
|
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
conditionCode: daily.conditionCode,
|
conditionCode: daily.conditionCode,
|
||||||
maxUvIndex: daily.maxUvIndex,
|
maxUvIndex: daily.maxUvIndex,
|
||||||
@@ -399,7 +390,27 @@ function createDailyWeatherFeedItem(
|
|||||||
sunset: new Date(daily.sunset),
|
sunset: new Date(daily.sunset),
|
||||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||||
},
|
})
|
||||||
|
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode)
|
||||||
|
const rel = timeRelevanceForCondition(daily.conditionCode)
|
||||||
|
if (rel === TimeRelevance.Imminent) {
|
||||||
|
worstTimeRelevance = TimeRelevance.Imminent
|
||||||
|
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
|
||||||
|
worstTimeRelevance = TimeRelevance.Upcoming
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: totalUrgency / days.length,
|
||||||
|
timeRelevance: worstTimeRelevance,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-daily-${timestamp.getTime()}`,
|
||||||
|
sourceId,
|
||||||
|
type: WeatherFeedItemType.Daily,
|
||||||
|
timestamp,
|
||||||
|
data: { days },
|
||||||
signals,
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user