Compare commits

..

5 Commits

Author SHA1 Message Date
38c490b80e fix: handle empty lines array in TFL source
Empty lines array caused fetchLineStatuses to build /Line//Status
URL, resulting in a 404 from the TFL API. Now defaults to all
lines when the array is empty.

Also switches fetchStations to Promise.allSettled so individual
line failures don't break the entire station fetch.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 21:53:56 +00:00
68932f83c3 feat: enable bun debugger for backend dev server (#105)
Add --inspect flag to the dev script and print the
debug.bun.sh URL with the Tailscale IP in the automation.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 22:19:02 +01:00
4916886adf feat: combine daily weather into single feed item (#102)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:40:47 +01:00
f1c2f399f2 feat: add TfL source config to admin dashboard (#104)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:36:32 +01:00
7a85990c24 feat: register TfL source provider (#103)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:34:50 +01:00
11 changed files with 199 additions and 34 deletions

View File

@@ -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:

View File

@@ -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 : ""
} }

View File

@@ -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[]> {

View File

@@ -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({

View File

@@ -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",

View File

@@ -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
} }
} }

View File

@@ -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>> {

View File

@@ -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

View File

@@ -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"

View File

@@ -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")

View File

@@ -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,
} }
} }