mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-30 14:51:17 +01:00
Compare commits
5 Commits
kn/remove-
...
feat/backe
| Author | SHA1 | Date | |
|---|---|---|---|
|
710a6b348d
|
|||
| 4916886adf | |||
| f1c2f399f2 | |||
| 7a85990c24 | |||
| f126afc3ca |
@@ -26,6 +26,12 @@ services:
|
||||
commands:
|
||||
start: |
|
||||
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
|
||||
|
||||
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") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -456,6 +490,8 @@ function buildInitialValues(
|
||||
values[name] = saved[name]
|
||||
} else if (field.defaultValue !== undefined) {
|
||||
values[name] = field.defaultValue
|
||||
} else if (field.type === "multiselect") {
|
||||
values[name] = []
|
||||
} else {
|
||||
values[name] = field.type === "number" ? undefined : ""
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ function serverBase() {
|
||||
}
|
||||
|
||||
export interface ConfigFieldDef {
|
||||
type: "string" | "number" | "select"
|
||||
type: "string" | "number" | "select" | "multiselect"
|
||||
label: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
secret?: boolean
|
||||
defaultValue?: string | number
|
||||
defaultValue?: string | number | 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" },
|
||||
},
|
||||
},
|
||||
{
|
||||
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[]> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CircleDot,
|
||||
CloudSun,
|
||||
Loader2,
|
||||
TrainFront,
|
||||
LogOut,
|
||||
MapPin,
|
||||
Rss,
|
||||
@@ -41,6 +42,7 @@ const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
||||
"aelis.weather": CloudSun,
|
||||
"aelis.caldav": CalendarDays,
|
||||
"aelis.google-calendar": Calendar,
|
||||
"aelis.tfl": TrainFront,
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"main": "src/server.ts",
|
||||
"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",
|
||||
"test": "bun test src/",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { LocationSourceProvider } from "./location/provider.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||
import { TflSourceProvider } from "./tfl/provider.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
|
||||
function main() {
|
||||
@@ -45,6 +46,7 @@ function main() {
|
||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||
},
|
||||
}),
|
||||
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
||||
],
|
||||
feedEnhancer,
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface HourlyWeatherFeedItem extends FeedItem<
|
||||
HourlyWeatherData
|
||||
> {}
|
||||
|
||||
export type DailyWeatherData = {
|
||||
export type DailyWeatherEntry = {
|
||||
forecastDate: Date
|
||||
conditionCode: ConditionCode
|
||||
maxUvIndex: number
|
||||
@@ -71,6 +71,10 @@ export type DailyWeatherData = {
|
||||
temperatureMin: number
|
||||
}
|
||||
|
||||
export type DailyWeatherData = {
|
||||
days: DailyWeatherEntry[]
|
||||
}
|
||||
|
||||
export interface DailyWeatherFeedItem extends FeedItem<
|
||||
typeof WeatherFeedItemType.Daily,
|
||||
DailyWeatherData
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
type HourlyWeatherEntry,
|
||||
type DailyWeatherFeedItem,
|
||||
type DailyWeatherData,
|
||||
type DailyWeatherEntry,
|
||||
type WeatherAlertFeedItem,
|
||||
type WeatherAlertData,
|
||||
} from "./feed-items"
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
|
||||
import { LocationKey } from "@aelis/source-location"
|
||||
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 { WeatherFeedItemType, type HourlyWeatherData } from "./feed-items"
|
||||
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"
|
||||
import { WeatherKey, type Weather } from "./weather-context"
|
||||
import { WeatherSource, Units } from "./weather-source"
|
||||
|
||||
@@ -133,7 +133,8 @@ describe("WeatherSource", () => {
|
||||
|
||||
expect(hourlyItems.length).toBe(1)
|
||||
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 () => {
|
||||
@@ -192,6 +193,65 @@ describe("WeatherSource", () => {
|
||||
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 () => {
|
||||
const source = new WeatherSource({ client: mockClient })
|
||||
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 { 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 { WeatherKey, type Weather } from "./weather-context"
|
||||
import {
|
||||
@@ -181,11 +181,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
||||
|
||||
if (response.forecastDaily?.days) {
|
||||
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const day = days[i]
|
||||
if (day) {
|
||||
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
|
||||
}
|
||||
if (days.length > 0) {
|
||||
items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,24 +367,18 @@ function createHourlyForecastFeedItem(
|
||||
}
|
||||
}
|
||||
|
||||
function createDailyWeatherFeedItem(
|
||||
daily: DailyForecast,
|
||||
index: number,
|
||||
function createDailyForecastFeedItem(
|
||||
dailyForecasts: DailyForecast[],
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
sourceId: string,
|
||||
): WeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||
}
|
||||
const days: DailyWeatherEntry[] = []
|
||||
let totalUrgency = 0
|
||||
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
|
||||
|
||||
return {
|
||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||
sourceId,
|
||||
type: WeatherFeedItemType.Daily,
|
||||
timestamp,
|
||||
data: {
|
||||
for (const daily of dailyForecasts) {
|
||||
days.push({
|
||||
forecastDate: new Date(daily.forecastStart),
|
||||
conditionCode: daily.conditionCode,
|
||||
maxUvIndex: daily.maxUvIndex,
|
||||
@@ -399,7 +390,27 @@ function createDailyWeatherFeedItem(
|
||||
sunset: new Date(daily.sunset),
|
||||
temperatureMax: convertTemperature(daily.temperatureMax, 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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user