Compare commits

..

1 Commits

Author SHA1 Message Date
cdf5a52c2e docs: update UI rendering to server-driven twrnc
Replace outdated UI Registry model with server-driven
json-render + twrnc approach. Update architecture diagram,
terminology (DataSource→FeedSource, Reconciler→FeedEngine),
and design principles to match current codebase.

Add ui, slots fields to FeedItem in actions spec. Add
Spotify example with twrnc className-based ui tree.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:54:46 +00:00
54 changed files with 837 additions and 2338 deletions

View File

@@ -1,43 +0,0 @@
---
name: gpg-commit-signing
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
---
# GPG Commit Signing
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
## Workflow
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
```bash
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
```
If not set, skip signing — commit without `-S`.
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
```bash
git commit -S -m "message"
```
If this succeeds, no further steps are needed.
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
```bash
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
chmod +x /tmp/gpg-sign.sh
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
rm /tmp/gpg-sign.sh
```
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
## Anti-patterns
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
- Do not leave wrapper scripts on disk after committing.

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
async executeAction(): Promise<unknown> { async executeAction(): Promise<unknown> {
return undefined return undefined
}, },
async fetchContext(): Promise<readonly ContextEntry[] | null> { async fetchContext(): Promise<Partial<Context> | null> {
return null return null
}, },
async fetchItems() { async fetchItems() {

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { LocationSource } from "@aris/source-location" import { LocationSource } from "@aris/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
async executeAction(): Promise<unknown> { async executeAction(): Promise<unknown> {
return undefined return undefined
}, },
async fetchContext(): Promise<readonly ContextEntry[] | null> { async fetchContext(): Promise<Partial<Context> | null> {
return null return null
}, },
async fetchItems() { async fetchItems() {

View File

@@ -89,8 +89,8 @@
"arktype": "^2.1.0", "arktype": "^2.1.0",
}, },
}, },
"packages/aris-source-caldav": { "packages/aris-source-apple-calendar": {
"name": "@aris/source-caldav", "name": "@aris/source-apple-calendar",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@aris/core": "workspace:*", "@aris/core": "workspace:*",
@@ -144,7 +144,7 @@
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"], "@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"], "@aris/source-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"], "@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],

View File

@@ -1,10 +1,8 @@
import type { ContextEntry } from "./context" import type { Context } from "./context"
import type { ContextProvider } from "./context-provider" import type { ContextProvider } from "./context-provider"
import { contextKey } from "./context"
interface ContextUpdatable { interface ContextUpdatable {
pushContextUpdate(entries: readonly ContextEntry[]): void pushContextUpdate(update: Partial<Context>): void
} }
export interface ProviderError { export interface ProviderError {
@@ -56,7 +54,7 @@ export class ContextBridge {
this.providers.set(provider.key, provider as ContextProvider) this.providers.set(provider.key, provider as ContextProvider)
const cleanup = provider.onUpdate((value) => { const cleanup = provider.onUpdate((value) => {
this.controller.pushContextUpdate([[contextKey(provider.key), value]]) this.controller.pushContextUpdate({ [provider.key]: value })
}) })
this.cleanups.push(cleanup) this.cleanups.push(cleanup)
@@ -69,7 +67,7 @@ export class ContextBridge {
* Returns errors from providers that failed to fetch. * Returns errors from providers that failed to fetch.
*/ */
async refresh(): Promise<RefreshResult> { async refresh(): Promise<RefreshResult> {
const collected: ContextEntry[] = [] const updates: Partial<Context> = {}
const errors: ProviderError[] = [] const errors: ProviderError[] = []
const entries = Array.from(this.providers.entries()) const entries = Array.from(this.providers.entries())
@@ -80,7 +78,7 @@ export class ContextBridge {
entries.forEach(([key], i) => { entries.forEach(([key], i) => {
const result = results[i] const result = results[i]
if (result?.status === "fulfilled") { if (result?.status === "fulfilled") {
collected.push([contextKey(key), result.value]) updates[key] = result.value
} else if (result?.status === "rejected") { } else if (result?.status === "rejected") {
errors.push({ errors.push({
key, key,
@@ -89,7 +87,7 @@ export class ContextBridge {
} }
}) })
this.controller.pushContextUpdate(collected) this.controller.pushContextUpdate(updates)
return { errors } return { errors }
} }

View File

@@ -1,184 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ContextKey } from "./context"
import { Context, contextKey } from "./context"
interface Weather {
temperature: number
}
interface NextEvent {
title: string
}
const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "current")
const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
describe("Context", () => {
describe("get", () => {
test("returns undefined for missing key", () => {
const ctx = new Context()
expect(ctx.get(WeatherKey)).toBeUndefined()
})
test("returns value for exact key match", () => {
const ctx = new Context()
const weather: Weather = { temperature: 20 }
ctx.set([[WeatherKey, weather]])
expect(ctx.get(WeatherKey)).toEqual(weather)
})
test("distinguishes keys with different parts", () => {
const ctx = new Context()
ctx.set([
[WeatherKey, { temperature: 20 }],
[NextEventKey, { title: "Standup" }],
])
expect(ctx.get(WeatherKey)).toEqual({ temperature: 20 })
expect(ctx.get(NextEventKey)).toEqual({ title: "Standup" })
})
test("last write wins for same key", () => {
const ctx = new Context()
ctx.set([[WeatherKey, { temperature: 20 }]])
ctx.set([[WeatherKey, { temperature: 25 }]])
expect(ctx.get(WeatherKey)).toEqual({ temperature: 25 })
})
})
describe("find", () => {
test("returns empty array when no keys match", () => {
const ctx = new Context()
expect(ctx.find(WeatherKey)).toEqual([])
})
test("returns exact match as single result", () => {
const ctx = new Context()
ctx.set([[NextEventKey, { title: "Standup" }]])
const results = ctx.find(NextEventKey)
expect(results).toHaveLength(1)
expect(results[0]!.value).toEqual({ title: "Standup" })
})
test("prefix match returns multiple instances", () => {
const workKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "work",
})
const personalKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "personal",
})
const ctx = new Context()
ctx.set([
[workKey, { title: "Sprint Planning" }],
[personalKey, { title: "Dentist" }],
])
const prefix = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
const results = ctx.find(prefix)
expect(results).toHaveLength(2)
const titles = results.map((r) => r.value.title).sort()
expect(titles).toEqual(["Dentist", "Sprint Planning"])
})
test("prefix match includes exact match and longer keys", () => {
const baseKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
const instanceKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "work",
})
const ctx = new Context()
ctx.set([
[baseKey, { title: "Base" }],
[instanceKey, { title: "Instance" }],
])
const results = ctx.find(baseKey)
expect(results).toHaveLength(2)
})
test("does not match keys that share a string prefix but differ at segment boundary", () => {
const keyA = contextKey<string>("aris.calendar", "next")
const keyB = contextKey<string>("aris.calendar", "nextEvent")
const ctx = new Context()
ctx.set([
[keyA, "a"],
[keyB, "b"],
])
const results = ctx.find(keyA)
expect(results).toHaveLength(1)
expect(results[0]!.value).toBe("a")
})
test("object key parts with different property order match", () => {
const key1 = contextKey<string>("source", "ctx", { b: 2, a: 1 })
const key2 = contextKey<string>("source", "ctx", { a: 1, b: 2 })
const ctx = new Context()
ctx.set([[key1, "value"]])
// Exact match via get should work regardless of property order
expect(ctx.get(key2)).toBe("value")
// find with the reordered key as prefix should also match
const prefix = contextKey<string>("source", "ctx")
const results = ctx.find(prefix)
expect(results).toHaveLength(1)
})
test("single-segment prefix matches all keys starting with that segment", () => {
const ctx = new Context()
ctx.set([
[contextKey("aris.weather", "current"), { temperature: 20 }],
[contextKey("aris.weather", "forecast"), { high: 25 }],
[contextKey("aris.calendar", "nextEvent"), { title: "Meeting" }],
])
const results = ctx.find(contextKey("aris.weather"))
expect(results).toHaveLength(2)
})
test("does not match shorter keys", () => {
const ctx = new Context()
ctx.set([[contextKey("aris.weather"), "short"]])
const results = ctx.find(contextKey("aris.weather", "current"))
expect(results).toHaveLength(0)
})
test("numeric key parts match correctly", () => {
const ctx = new Context()
ctx.set([
[contextKey("source", 1, "data"), "one"],
[contextKey("source", 2, "data"), "two"],
])
const results = ctx.find(contextKey("source", 1))
expect(results).toHaveLength(1)
expect(results[0]!.value).toBe("one")
})
})
describe("size", () => {
test("returns 0 for empty context", () => {
expect(new Context().size).toBe(0)
})
test("reflects number of entries", () => {
const ctx = new Context()
ctx.set([
[WeatherKey, { temperature: 20 }],
[NextEventKey, { title: "Standup" }],
])
expect(ctx.size).toBe(2)
})
})
})

View File

@@ -1,131 +1,46 @@
/** /**
* Tuple-keyed context system inspired by React Query's query keys. * Branded type for type-safe context keys.
* *
* Context keys are arrays that form a hierarchy. Sources write to specific * Each package defines its own keys with associated value types:
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }]) * ```ts
* and consumers can query by exact match or prefix match to get all values * const LocationKey: ContextKey<Location> = contextKey("location")
* of a given type across source instances. * ```
*/ */
export type ContextKey<T> = string & { __contextValue?: T }
// -- Key types --
/** A single segment of a context key: string, number, or a record of primitives. */
export type ContextKeyPart = string | number | Record<string, unknown>
/** A context key is a readonly tuple of parts, branded with the value type. */
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
/** Creates a typed context key. */
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
return parts as ContextKey<T>
}
// -- Serialization --
/** /**
* Deterministic serialization of a context key for use as a Map key. * Creates a typed context key.
* Object parts have their keys sorted for stable comparison.
*/
export function serializeKey(key: readonly ContextKeyPart[]): string {
return JSON.stringify(key, (_key, value) => {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const sorted: Record<string, unknown> = {}
for (const k of Object.keys(value).sort()) {
sorted[k] = value[k]
}
return sorted
}
return value
})
}
// -- Key matching --
/** Returns true if `key` starts with all parts of `prefix`. */
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
if (key.length < prefix.length) return false
for (let i = 0; i < prefix.length; i++) {
if (!partsEqual(key[i]!, prefix[i]!)) return false
}
return true
}
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
function partsEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== typeof b) return false
if (a && b && typeof a === "object" && typeof b === "object") {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key) =>
partsEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
),
)
}
return false
}
// -- Context store --
/** A single context entry: a key-value pair. */
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
/**
* Mutable context store with tuple keys.
*
* Supports exact-match lookups and prefix-match queries.
* Sources write context in topological order during refresh.
*/
export class Context {
time: Date
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
constructor(time: Date = new Date()) {
this.time = time
this.store = new Map()
}
/** Merges entries into this context. */
set(entries: readonly ContextEntry[]): void {
for (const [key, value] of entries) {
this.store.set(serializeKey(key), { key, value })
}
}
/** Exact-match lookup. Returns the value for the given key, or undefined. */
get<T>(key: ContextKey<T>): T | undefined {
const entry = this.store.get(serializeKey(key))
return entry?.value as T | undefined
}
/**
* Prefix-match query. Returns all entries whose key starts with the given prefix.
* *
* @example * @example
* ```ts * ```ts
* // Get all "nextEvent" values across calendar source instances * interface Location { lat: number; lng: number; accuracy: number }
* const events = context.find(contextKey("nextEvent")) * const LocationKey: ContextKey<Location> = contextKey("location")
* ``` * ```
*/ */
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> { export function contextKey<T>(key: string): ContextKey<T> {
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = [] return key as ContextKey<T>
}
for (const entry of this.store.values()) {
if (keyStartsWith(entry.key, prefix)) { /**
results.push({ key: entry.key, value: entry.value as T }) * Type-safe accessor for context values.
} *
} * @example
* ```ts
return results * const location = contextValue(context, LocationKey)
} * if (location) {
* console.log(location.lat, location.lng)
/** Returns the number of entries (excluding time). */ * }
get size(): number { * ```
return this.store.size */
} export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
return context[key] as T | undefined
}
/**
* Arbitrary key-value bag representing the current state.
* Always includes `time`. Other keys are added by context providers.
*/
export interface Context {
time: Date
[key: string]: unknown
} }

View File

@@ -12,15 +12,15 @@ import type { FeedItem } from "./feed"
* readonly type = "weather" * readonly type = "weather"
* *
* async query(context: Context): Promise<WeatherItem[]> { * async query(context: Context): Promise<WeatherItem[]> {
* const location = context.get(LocationKey) * const location = contextValue(context, LocationKey)
* if (!location) return [] * if (!location) return []
* const data = await fetchWeather(location) * const data = await fetchWeather(location)
* return [{ * return [{
* id: `weather-${Date.now()}`, * id: `weather-${Date.now()}`,
* type: this.type, * type: this.type,
* priority: 0.5,
* timestamp: context.time, * timestamp: context.time,
* data: { temp: data.temperature }, * data: { temp: data.temperature },
* signals: { urgency: 0.5, timeRelevance: "ambient" },
* }] * }]
* } * }
* } * }

View File

@@ -1,9 +1,8 @@
import type { ContextEntry } from "./context" import type { Context } from "./context"
import type { DataSource } from "./data-source" import type { DataSource } from "./data-source"
import type { FeedItem } from "./feed" import type { FeedItem } from "./feed"
import type { ReconcileResult } from "./reconciler" import type { ReconcileResult } from "./reconciler"
import { Context } from "./context"
import { Reconciler } from "./reconciler" import { Reconciler } from "./reconciler"
export interface FeedControllerConfig { export interface FeedControllerConfig {
@@ -41,7 +40,7 @@ const DEFAULT_DEBOUNCE_MS = 100
* }) * })
* *
* // Context update triggers debounced reconcile * // Context update triggers debounced reconcile
* controller.pushContextUpdate([[LocationKey, location]]) * controller.pushContextUpdate({ [LocationKey]: location })
* *
* // Direct reconcile (no debounce) * // Direct reconcile (no debounce)
* const result = await controller.reconcile() * const result = await controller.reconcile()
@@ -60,7 +59,7 @@ export class FeedController<TItems extends FeedItem = never> {
private stopped = false private stopped = false
constructor(config?: FeedControllerConfig) { constructor(config?: FeedControllerConfig) {
this.context = config?.initialContext ?? new Context() this.context = config?.initialContext ?? { time: new Date() }
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
this.timeout = config?.timeout this.timeout = config?.timeout
} }
@@ -95,10 +94,9 @@ export class FeedController<TItems extends FeedItem = never> {
} }
} }
/** Merges entries into context and schedules a debounced reconcile. */ /** Merges update into context and schedules a debounced reconcile. */
pushContextUpdate(entries: readonly ContextEntry[]): void { pushContextUpdate(update: Partial<Context>): void {
this.context.time = new Date() this.context = { ...this.context, ...update, time: new Date() }
this.context.set(entries)
this.scheduleReconcile() this.scheduleReconcile()
} }

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index" import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine" import { FeedEngine } from "./feed-engine"
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index" import { UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources // No-op action methods for test sources
const noActions = { const noActions = {
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
} }
function createLocationSource(): SimulatedLocationSource { function createLocationSource(): SimulatedLocationSource {
let callback: ((entries: readonly ContextEntry[]) => void) | null = null let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 } let currentLocation: Location = { lat: 0, lng: 0 }
return { return {
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
}, },
async fetchContext() { async fetchContext() {
return [[LocationKey, currentLocation]] return { [LocationKey]: currentLocation }
}, },
simulateUpdate(location: Location) { simulateUpdate(location: Location) {
currentLocation = location currentLocation = location
callback?.([[LocationKey, location]]) callback?.({ [LocationKey]: location })
}, },
} }
} }
@@ -85,27 +85,27 @@ function createWeatherSource(
...noActions, ...noActions,
async fetchContext(context) { async fetchContext(context) {
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
if (!location) return null if (!location) return null
const weather = await fetchWeather(location) const weather = await fetchWeather(location)
return [[WeatherKey, weather]] return { [WeatherKey]: weather }
}, },
async fetchItems(context) { async fetchItems(context) {
const weather = context.get(WeatherKey) const weather = contextValue(context, WeatherKey)
if (!weather) return [] if (!weather) return []
return [ return [
{ {
id: `weather-${Date.now()}`, id: `weather-${Date.now()}`,
type: "weather", type: "weather",
priority: 0.5,
timestamp: new Date(), timestamp: new Date(),
data: { data: {
temperature: weather.temperature, temperature: weather.temperature,
condition: weather.condition, condition: weather.condition,
}, },
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
}, },
] ]
}, },
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
}, },
async fetchItems(context) { async fetchItems(context) {
const weather = context.get(WeatherKey) const weather = contextValue(context, WeatherKey)
if (!weather) return [] if (!weather) return []
if (weather.condition === "storm") { if (weather.condition === "storm") {
@@ -131,9 +131,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
{ {
id: "alert-storm", id: "alert-storm",
type: "alert", type: "alert",
priority: 1.0,
timestamp: new Date(), timestamp: new Date(),
data: { message: "Storm warning!" }, data: { message: "Storm warning!" },
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
}, },
] ]
} }
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
...noActions, ...noActions,
async fetchContext() { async fetchContext() {
order.push("location") order.push("location")
return [[LocationKey, { lat: 51.5, lng: -0.1 }]] return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
}, },
} }
@@ -275,9 +275,9 @@ describe("FeedEngine", () => {
...noActions, ...noActions,
async fetchContext(ctx) { async fetchContext(ctx) {
order.push("weather") order.push("weather")
const loc = ctx.get(LocationKey) const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined() expect(loc).toBeDefined()
return [[WeatherKey, { temperature: 20, condition: "sunny" }]] return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
}, },
} }
@@ -298,11 +298,11 @@ describe("FeedEngine", () => {
const { context } = await engine.refresh() const { context } = await engine.refresh()
expect(context.get(LocationKey)).toEqual({ expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5, lat: 51.5,
lng: -0.1, lng: -0.1,
}) })
expect(context.get(WeatherKey)).toEqual({ expect(contextValue(context, WeatherKey)).toEqual({
temperature: 20, temperature: 20,
condition: "sunny", condition: "sunny",
}) })
@@ -322,7 +322,7 @@ describe("FeedEngine", () => {
expect(items[0]!.type).toBe("weather") expect(items[0]!.type).toBe("weather")
}) })
test("returns items in source graph order (no engine-level sorting)", async () => { test("sorts items by priority descending", async () => {
const location = createLocationSource() const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 }) location.simulateUpdate({ lat: 51.5, lng: -0.1 })
@@ -338,12 +338,8 @@ describe("FeedEngine", () => {
const { items } = await engine.refresh() const { items } = await engine.refresh()
expect(items).toHaveLength(2) expect(items).toHaveLength(2)
// Items returned in topological order (weather before alert) expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[0]!.type).toBe("weather") expect(items[1]!.type).toBe("weather") // priority 0.5
expect(items[1]!.type).toBe("alert")
// Signals are preserved for post-processors to consume
expect(items[0]!.signals?.urgency).toBe(0.5)
expect(items[1]!.signals?.urgency).toBe(1.0)
}) })
test("handles missing upstream context gracefully", async () => { test("handles missing upstream context gracefully", async () => {
@@ -361,7 +357,7 @@ describe("FeedEngine", () => {
const { context, items } = await engine.refresh() const { context, items } = await engine.refresh()
expect(context.get(WeatherKey)).toBeUndefined() expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0) expect(items).toHaveLength(0)
}) })
@@ -459,7 +455,7 @@ describe("FeedEngine", () => {
await engine.refresh() await engine.refresh()
const context = engine.currentContext() const context = engine.currentContext()
expect(context.get(LocationKey)).toEqual({ expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5, lat: 51.5,
lng: -0.1, lng: -0.1,
}) })
@@ -734,7 +730,7 @@ describe("FeedEngine", () => {
}) })
test("reactive item update refreshes cache", async () => { test("reactive item update refreshes cache", async () => {
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null let itemUpdateCallback: (() => void) | null = null
const source: FeedSource = { const source: FeedSource = {
id: "reactive-items", id: "reactive-items",
@@ -765,7 +761,7 @@ describe("FeedEngine", () => {
engine.start() engine.start()
// Trigger item update // Trigger item update
itemUpdateCallback!([]) itemUpdateCallback!()
// Wait for async refresh // Wait for async refresh
await new Promise((resolve) => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50))
@@ -885,12 +881,12 @@ describe("FeedEngine", () => {
...noActions, ...noActions,
async fetchContext(ctx) { async fetchContext(ctx) {
fetchCount++ fetchCount++
const loc = ctx.get(LocationKey) const loc = contextValue(ctx, LocationKey)
if (!loc) return null if (!loc) return null
return [[WeatherKey, { temperature: 20, condition: "sunny" }]] return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
}, },
async fetchItems(ctx) { async fetchItems(ctx) {
const weather = ctx.get(WeatherKey) const weather = contextValue(ctx, WeatherKey)
if (!weather) return [] if (!weather) return []
return [ return [
{ {

View File

@@ -1,11 +1,8 @@
import type { ActionDefinition } from "./action" import type { ActionDefinition } from "./action"
import type { ContextEntry } from "./context" import type { Context } from "./context"
import type { FeedItem } from "./feed" import type { FeedItem } from "./feed"
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
import type { FeedSource } from "./feed-source" import type { FeedSource } from "./feed-source"
import { Context } from "./context"
export interface SourceError { export interface SourceError {
sourceId: string sourceId: string
error: Error error: Error
@@ -15,8 +12,6 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
context: Context context: Context
items: TItem[] items: TItem[]
errors: SourceError[] errors: SourceError[]
/** Item groups produced by post-processors */
groupedItems?: ItemGroup[]
} }
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
@@ -67,11 +62,10 @@ interface SourceGraph {
export class FeedEngine<TItems extends FeedItem = FeedItem> { export class FeedEngine<TItems extends FeedItem = FeedItem> {
private sources = new Map<string, FeedSource>() private sources = new Map<string, FeedSource>()
private graph: SourceGraph | null = null private graph: SourceGraph | null = null
private context: Context = new Context() private context: Context = { time: new Date() }
private subscribers = new Set<FeedSubscriber<TItems>>() private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = [] private cleanups: Array<() => void> = []
private started = false private started = false
private postProcessors: FeedPostProcessor[] = []
private readonly cacheTtlMs: number private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null private cachedResult: FeedResult<TItems> | null = null
@@ -114,23 +108,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this return this
} }
/**
* Registers a post-processor. Processors run in registration order
* after items are collected, on every update path.
*/
registerPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors.push(processor)
return this
}
/**
* Unregisters a post-processor by reference.
*/
unregisterPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors = this.postProcessors.filter((p) => p !== processor)
return this
}
/** /**
* Refreshes the feed by running all sources in dependency order. * Refreshes the feed by running all sources in dependency order.
* Calls fetchContext() then fetchItems() on each source. * Calls fetchContext() then fetchItems() on each source.
@@ -140,14 +117,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const errors: SourceError[] = [] const errors: SourceError[] = []
// Reset context with fresh time // Reset context with fresh time
const context = new Context() let context: Context = { time: new Date() }
// Run fetchContext in topological order // Run fetchContext in topological order
for (const source of graph.sorted) { for (const source of graph.sorted) {
try { try {
const entries = await source.fetchContext(context) const update = await source.fetchContext(context)
if (entries) { if (update) {
context.set(entries) context = { ...context, ...update }
} }
} catch (err) { } catch (err) {
errors.push({ errors.push({
@@ -173,20 +150,12 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
} }
} }
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
this.context = context this.context = context
const { const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], context, errors)
const result: FeedResult<TItems> = {
context,
items: processedItems,
errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
}
this.updateCache(result) this.updateCache(result)
return result return result
@@ -215,8 +184,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
for (const source of graph.sorted) { for (const source of graph.sorted) {
if (source.onContextUpdate) { if (source.onContextUpdate) {
const cleanup = source.onContextUpdate( const cleanup = source.onContextUpdate(
(entries) => { (update) => {
this.handleContextUpdate(source.id, entries) this.handleContextUpdate(source.id, update)
}, },
() => this.context, () => this.context,
) )
@@ -294,72 +263,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return actions return actions
} }
private async applyPostProcessors(
items: TItems[],
context: Context,
errors: SourceError[],
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
let currentItems = items
const allGroupedItems: ItemGroup[] = []
const allErrors = [...errors]
const boostScores = new Map<string, number>()
for (const processor of this.postProcessors) {
const snapshot = currentItems
try {
const enhancement = await processor(currentItems, context)
if (enhancement.additionalItems?.length) {
// Post-processors operate on FeedItem[] without knowledge of TItems.
// Additional items are merged untyped — this is intentional. The
// processor contract is "FeedItem in, FeedItem out"; type narrowing
// is the caller's responsibility when consuming FeedResult.
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
}
if (enhancement.suppress?.length) {
const suppressSet = new Set(enhancement.suppress)
currentItems = currentItems.filter((item) => !suppressSet.has(item.id))
}
if (enhancement.groupedItems?.length) {
allGroupedItems.push(...enhancement.groupedItems)
}
if (enhancement.boost) {
for (const [id, score] of Object.entries(enhancement.boost)) {
boostScores.set(id, (boostScores.get(id) ?? 0) + score)
}
}
} catch (err) {
const sourceId = processor.name || "anonymous"
allErrors.push({
sourceId,
error: err instanceof Error ? err : new Error(String(err)),
})
currentItems = snapshot
}
}
// Apply boost reordering: positive-boost first (desc), then zero, then negative (desc).
// Stable sort within each tier preserves original relative order.
if (boostScores.size > 0) {
currentItems = applyBoostOrder(currentItems, boostScores)
}
// Remove stale item IDs from groups and drop empty groups
const itemIds = new Set(currentItems.map((item) => item.id))
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
const ids = group.itemIds.filter((id) => itemIds.has(id))
if (ids.length > 0) {
acc.push({ ...group, itemIds: ids })
}
return acc
}, [])
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
}
private ensureGraph(): SourceGraph { private ensureGraph(): SourceGraph {
if (!this.graph) { if (!this.graph) {
this.graph = buildGraph(Array.from(this.sources.values())) this.graph = buildGraph(Array.from(this.sources.values()))
@@ -367,9 +270,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this.graph return this.graph
} }
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void { private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
this.context.time = new Date() this.context = { ...this.context, ...update, time: new Date() }
this.context.set(entries)
// Re-run dependents and notify // Re-run dependents and notify
this.refreshDependents(sourceId) this.refreshDependents(sourceId)
@@ -384,9 +286,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const source = graph.sources.get(id) const source = graph.sources.get(id)
if (source) { if (source) {
try { try {
const entries = await source.fetchContext(this.context) const update = await source.fetchContext(this.context)
if (entries) { if (update) {
this.context.set(entries) this.context = { ...this.context, ...update }
} }
} catch { } catch {
// Errors during reactive updates are logged but don't stop propagation // Errors during reactive updates are logged but don't stop propagation
@@ -412,17 +314,12 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
} }
} }
const { items.sort((a, b) => b.priority - a.priority)
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
const result: FeedResult<TItems> = { const result: FeedResult<TItems> = {
context: this.context, context: this.context,
items: processedItems, items: items as TItems[],
errors: postProcessorErrors, errors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
} }
this.updateCache(result) this.updateCache(result)
@@ -503,47 +400,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
} }
} }
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function applyBoostOrder<T extends FeedItem>(items: T[], boostScores: Map<string, number>): T[] {
const positive: T[] = []
const neutral: T[] = []
const negative: T[] = []
for (const item of items) {
const raw = boostScores.get(item.id)
if (raw === undefined || raw === 0) {
neutral.push(item)
} else {
const clamped = clamp(raw, -1, 1)
if (clamped > 0) {
positive.push(item)
} else if (clamped < 0) {
negative.push(item)
} else {
neutral.push(item)
}
}
}
// Sort positive descending by boost, negative descending (least negative first, most negative last)
positive.sort((a, b) => {
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
return bScore - aScore
})
negative.sort((a, b) => {
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
return bScore - aScore
})
return [...positive, ...neutral, ...negative]
}
function buildGraph(sources: FeedSource[]): SourceGraph { function buildGraph(sources: FeedSource[]): SourceGraph {
const byId = new Map<string, FeedSource>() const byId = new Map<string, FeedSource>()
for (const source of sources) { for (const source of sources) {

View File

@@ -1,602 +0,0 @@
import { describe, expect, mock, test } from "bun:test"
import type {
ActionDefinition,
ContextEntry,
FeedItem,
FeedPostProcessor,
FeedSource,
} from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherItem = FeedItem<"weather", { temp: number }>
type CalendarItem = FeedItem<"calendar", { title: string }>
function weatherItem(id: string, temp: number): WeatherItem {
return { id, type: "weather", timestamp: new Date(), data: { temp } }
}
function calendarItem(id: string, title: string): CalendarItem {
return { id, type: "calendar", timestamp: new Date(), data: { title } }
}
// =============================================================================
// TEST SOURCES
// =============================================================================
function createWeatherSource(items: WeatherItem[]) {
return {
id: "aris.weather",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<WeatherItem[]> {
return items
},
}
}
function createCalendarSource(items: CalendarItem[]) {
return {
id: "aris.calendar",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<CalendarItem[]> {
return items
},
}
}
// =============================================================================
// REGISTRATION
// =============================================================================
describe("FeedPostProcessor", () => {
describe("registration", () => {
test("registerPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.registerPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregisterPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.unregisterPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregistered processor does not run", async () => {
const processor = mock(async () => ({}))
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(processor)
.unregisterPostProcessor(processor)
await engine.refresh()
expect(processor).not.toHaveBeenCalled()
})
})
// =============================================================================
// ADDITIONAL ITEMS
// =============================================================================
describe("additionalItems", () => {
test("injects additional items into the feed", async () => {
const extra = calendarItem("c1", "Meeting")
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ additionalItems: [extra] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items.find((i) => i.id === "c1")).toBeDefined()
})
})
// =============================================================================
// SUPPRESS
// =============================================================================
describe("suppress", () => {
test("removes suppressed items from the feed", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w2")
})
test("suppressing nonexistent ID is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ suppress: ["nonexistent"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
})
})
// =============================================================================
// GROUPED ITEMS
// =============================================================================
describe("groupedItems", () => {
test("accumulates grouped items on FeedResult", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }])
})
test("multiple processors accumulate groups", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Group A" }],
}))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c2"], summary: "Group B" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([
{ itemIds: ["c1"], summary: "Group A" },
{ itemIds: ["c2"], summary: "Group B" },
])
})
test("stale item IDs are removed from groups after suppression", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
})
test("groups with all items suppressed are dropped", async () => {
const engine = new FeedEngine()
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
test("groupedItems is omitted when no processors produce groups", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({}))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// BOOST
// =============================================================================
describe("boost", () => {
test("positive boost moves item before non-boosted items", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w2: 0.8 } }))
const result = await engine.refresh()
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
})
test("negative boost moves item after non-boosted items", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w1: -0.5 } }))
const result = await engine.refresh()
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
})
test("multiple boosted items are sorted by boost descending", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({
boost: { w3: 0.3, w1: 0.9 },
}))
const result = await engine.refresh()
// w1 (0.9) first, w3 (0.3) second, w2 (no boost) last
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
})
test("multiple processors accumulate boost scores", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w1: 0.3 } }))
.registerPostProcessor(async () => ({ boost: { w1: 0.4 } }))
const result = await engine.refresh()
// w1 accumulated boost = 0.7, moves before w2
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
})
test("accumulated boost is clamped to [-1, 1]", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({ boost: { w1: 0.8, w2: 0.9 } }))
.registerPostProcessor(async () => ({ boost: { w1: 0.8 } }))
const result = await engine.refresh()
// w1 accumulated = 1.6 clamped to 1, w2 = 0.9 — w1 still first
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2", "w3"])
})
test("out-of-range boost values are clamped", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w1: 5.0 } }))
const result = await engine.refresh()
// Clamped to 1, still boosted to front
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
})
test("boosting a suppressed item is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
suppress: ["w1"],
boost: { w1: 1.0 },
}))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w2")
})
test("boosting a nonexistent item ID is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ boost: { nonexistent: 1.0 } }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w1")
})
test("items with equal boost retain original relative order", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({
boost: { w1: 0.5, w3: 0.5 },
}))
const result = await engine.refresh()
// w1 and w3 have equal boost — original order preserved: w1 before w3
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
})
test("negative boosts preserve relative order among demoted items", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({
boost: { w1: -0.3, w2: -0.3 },
}))
const result = await engine.refresh()
// w3 (neutral) first, then w1 and w2 (equal negative) in original order
expect(result.items.map((i) => i.id)).toEqual(["w3", "w1", "w2"])
})
test("boost works alongside additionalItems and groupedItems", async () => {
const extra = calendarItem("c1", "Meeting")
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
additionalItems: [extra],
boost: { c1: 1.0 },
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
}))
const result = await engine.refresh()
// c1 boosted to front
expect(result.items[0].id).toBe("c1")
expect(result.items).toHaveLength(3)
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
})
})
// =============================================================================
// PIPELINE ORDERING
// =============================================================================
describe("pipeline ordering", () => {
test("each processor sees items as modified by the previous processor", async () => {
const seen: string[] = []
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({
additionalItems: [calendarItem("c1", "Injected")],
}))
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
await engine.refresh()
expect(seen).toEqual(["w1", "c1"])
})
test("suppression in first processor affects second processor", async () => {
const seen: string[] = []
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
await engine.refresh()
expect(seen).toEqual(["w2"])
})
})
// =============================================================================
// ERROR HANDLING
// =============================================================================
describe("error handling", () => {
test("throwing processor is recorded in errors and pipeline continues", async () => {
const seen: string[] = []
async function failingProcessor(): Promise<never> {
throw new Error("processor failed")
}
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(failingProcessor)
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
expect(ppError).toBeDefined()
expect(ppError!.error.message).toBe("processor failed")
// Pipeline continued — observer still saw the original item
expect(seen).toEqual(["w1"])
expect(result.items).toHaveLength(1)
})
test("anonymous throwing processor uses 'anonymous' as sourceId", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => {
throw new Error("anon failed")
})
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "anonymous")
expect(ppError).toBeDefined()
})
test("non-Error throw is wrapped", async () => {
async function failingProcessor(): Promise<never> {
throw "string error"
}
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(failingProcessor)
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
expect(ppError).toBeDefined()
expect(ppError!.error).toBeInstanceOf(Error)
})
})
// =============================================================================
// REACTIVE PATHS
// =============================================================================
describe("reactive updates", () => {
test("post-processors run during reactive context updates", async () => {
let callCount = 0
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onContextUpdate(callback, _getContext) {
triggerUpdate = callback
return () => {
triggerUpdate = null
}
},
}
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
// Wait for initial periodic refresh
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive context update
triggerUpdate!([])
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
test("post-processors run during reactive item updates", async () => {
let callCount = 0
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onItemsUpdate(callback, _getContext) {
triggerItemsUpdate = callback
return () => {
triggerItemsUpdate = null
}
},
}
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive items update
triggerItemsUpdate!([weatherItem("w1", 25)])
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
})
// =============================================================================
// NO PROCESSORS = NO CHANGE
// =============================================================================
describe("no processors", () => {
test("engine without post-processors returns raw items unchanged", async () => {
const items = [weatherItem("w1", 20), weatherItem("w2", 25)]
const engine = new FeedEngine().register(createWeatherSource(items))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items[0].id).toBe("w1")
expect(result.items[1].id).toBe("w2")
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// COMBINED ENHANCEMENT
// =============================================================================
describe("combined enhancement", () => {
test("single processor can use all enhancement fields at once", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
additionalItems: [calendarItem("c1", "Injected")],
suppress: ["w2"],
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
}))
const result = await engine.refresh()
// w2 suppressed, c1 injected → w1 + c1
expect(result.items).toHaveLength(2)
expect(result.items.map((i) => i.id)).toEqual(["w1", "c1"])
// Groups on result
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
})
})
})

View File

@@ -1,26 +0,0 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
export interface ItemGroup {
/** IDs of items to present together */
itemIds: string[]
/** Summary text for the group */
summary: string
}
export interface FeedEnhancement {
/** New items to inject into the feed */
additionalItems?: FeedItem[]
/** Groups of items to present together with a summary */
groupedItems?: ItemGroup[]
/** Item IDs to remove from the feed */
suppress?: string[]
/** Map of item ID to boost score (-1 to 1). Positive promotes, negative demotes. */
boost?: Record<string, number>
}
/**
* A function that transforms feed items and produces enhancement directives.
* Use named functions for meaningful error attribution.
*/
export type FeedPostProcessor = (items: FeedItem[], context: Context) => Promise<FeedEnhancement>

View File

@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index" import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index" import { UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources // No-op action methods for test sources
const noActions = { const noActions = {
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
} }
function createLocationSource(): SimulatedLocationSource { function createLocationSource(): SimulatedLocationSource {
let callback: ((entries: readonly ContextEntry[]) => void) | null = null let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 } let currentLocation: Location = { lat: 0, lng: 0 }
return { return {
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
}, },
async fetchContext() { async fetchContext() {
return [[LocationKey, currentLocation]] return { [LocationKey]: currentLocation }
}, },
simulateUpdate(location: Location) { simulateUpdate(location: Location) {
currentLocation = location currentLocation = location
callback?.([[LocationKey, location]]) callback?.({ [LocationKey]: location })
}, },
} }
} }
@@ -84,27 +84,27 @@ function createWeatherSource(
...noActions, ...noActions,
async fetchContext(context) { async fetchContext(context) {
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
if (!location) return null if (!location) return null
const weather = await fetchWeather(location) const weather = await fetchWeather(location)
return [[WeatherKey, weather]] return { [WeatherKey]: weather }
}, },
async fetchItems(context) { async fetchItems(context) {
const weather = context.get(WeatherKey) const weather = contextValue(context, WeatherKey)
if (!weather) return [] if (!weather) return []
return [ return [
{ {
id: `weather-${Date.now()}`, id: `weather-${Date.now()}`,
type: "weather", type: "weather",
priority: 0.5,
timestamp: new Date(), timestamp: new Date(),
data: { data: {
temperature: weather.temperature, temperature: weather.temperature,
condition: weather.condition, condition: weather.condition,
}, },
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
}, },
] ]
}, },
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
}, },
async fetchItems(context) { async fetchItems(context) {
const weather = context.get(WeatherKey) const weather = contextValue(context, WeatherKey)
if (!weather) return [] if (!weather) return []
if (weather.condition === "storm") { if (weather.condition === "storm") {
@@ -130,9 +130,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
{ {
id: "alert-storm", id: "alert-storm",
type: "alert", type: "alert",
priority: 1.0,
timestamp: new Date(), timestamp: new Date(),
data: { message: "Storm warning!" }, data: { message: "Storm warning!" },
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
}, },
] ]
} }
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
} }
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> { async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
const context = new Context() let context: Context = { time: new Date() }
// Run fetchContext in topological order // Run fetchContext in topological order
for (const source of graph.sorted) { for (const source of graph.sorted) {
const entries = await source.fetchContext(context) const update = await source.fetchContext(context)
if (entries) { if (update) {
context.set(entries) context = { ...context, ...update }
} }
} }
@@ -226,6 +226,9 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
} }
} }
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
return { context, items } return { context, items }
} }
@@ -265,7 +268,7 @@ describe("FeedSource", () => {
test("source without context returns null from fetchContext", async () => { test("source without context returns null from fetchContext", async () => {
const source = createAlertSource() const source = createAlertSource()
const result = await source.fetchContext(new Context()) const result = await source.fetchContext({ time: new Date() })
expect(result).toBeNull() expect(result).toBeNull()
}) })
}) })
@@ -369,7 +372,7 @@ describe("FeedSource", () => {
...noActions, ...noActions,
async fetchContext() { async fetchContext() {
order.push("location") order.push("location")
return [[LocationKey, { lat: 51.5, lng: -0.1 }]] return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
}, },
} }
@@ -379,9 +382,9 @@ describe("FeedSource", () => {
...noActions, ...noActions,
async fetchContext(ctx) { async fetchContext(ctx) {
order.push("weather") order.push("weather")
const loc = ctx.get(LocationKey) const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined() expect(loc).toBeDefined()
return [[WeatherKey, { temperature: 20, condition: "sunny" }]] return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
}, },
} }
@@ -400,11 +403,11 @@ describe("FeedSource", () => {
const graph = buildGraph([location, weather]) const graph = buildGraph([location, weather])
const { context } = await refreshGraph(graph) const { context } = await refreshGraph(graph)
expect(context.get(LocationKey)).toEqual({ expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5, lat: 51.5,
lng: -0.1, lng: -0.1,
}) })
expect(context.get(WeatherKey)).toEqual({ expect(contextValue(context, WeatherKey)).toEqual({
temperature: 20, temperature: 20,
condition: "sunny", condition: "sunny",
}) })
@@ -438,19 +441,17 @@ describe("FeedSource", () => {
const { items } = await refreshGraph(graph) const { items } = await refreshGraph(graph)
expect(items).toHaveLength(2) expect(items).toHaveLength(2)
// Items returned in topological order (weather before alert) expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[0]!.type).toBe("weather") expect(items[1]!.type).toBe("weather") // priority 0.5
expect(items[1]!.type).toBe("alert")
// Signals preserved for post-processors
expect(items[0]!.signals?.urgency).toBe(0.5)
expect(items[1]!.signals?.urgency).toBe(1.0)
}) })
test("source without location context returns empty items", async () => { test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated
const location: FeedSource = { const location: FeedSource = {
id: "location", id: "location",
...noActions, ...noActions,
async fetchContext() { async fetchContext() {
// Simulate no location available
return null return null
}, },
} }
@@ -460,7 +461,7 @@ describe("FeedSource", () => {
const graph = buildGraph([location, weather]) const graph = buildGraph([location, weather])
const { context, items } = await refreshGraph(graph) const { context, items } = await refreshGraph(graph)
expect(context.get(WeatherKey)).toBeUndefined() expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0) expect(items).toHaveLength(0)
}) })
}) })
@@ -474,7 +475,7 @@ describe("FeedSource", () => {
() => { () => {
updateCount++ updateCount++
}, },
() => new Context(), () => ({ time: new Date() }),
) )
location.simulateUpdate({ lat: 1, lng: 1 }) location.simulateUpdate({ lat: 1, lng: 1 })

View File

@@ -1,5 +1,5 @@
import type { ActionDefinition } from "./action" import type { ActionDefinition } from "./action"
import type { Context, ContextEntry } from "./context" import type { Context } from "./context"
import type { FeedItem } from "./feed" import type { FeedItem } from "./feed"
/** /**
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
* Maps to: source/contextUpdated (notification, source → host) * Maps to: source/contextUpdated (notification, source → host)
*/ */
onContextUpdate?( onContextUpdate?(
callback: (entries: readonly ContextEntry[]) => void, callback: (update: Partial<Context>) => void,
getContext: () => Context, getContext: () => Context,
): () => void ): () => void
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
* Return null if this source cannot provide context. * Return null if this source cannot provide context.
* Maps to: source/fetchContext * Maps to: source/fetchContext
*/ */
fetchContext(context: Context): Promise<readonly ContextEntry[] | null> fetchContext(context: Context): Promise<Partial<Context> | null>
/** /**
* Subscribe to reactive feed item updates. * Subscribe to reactive feed item updates.

View File

@@ -1,28 +1,3 @@
/**
* Source-provided hints for post-processors.
*
* Sources express domain-specific relevance without determining final ranking.
* Post-processors consume these signals alongside other inputs (user affinity,
* time of day, interaction history) to produce the final feed order.
*/
export const TimeRelevance = {
/** Needs attention now (e.g., event starting in minutes, severe alert) */
Imminent: "imminent",
/** Relevant soon (e.g., event in the next hour, approaching deadline) */
Upcoming: "upcoming",
/** Background information (e.g., daily forecast, low-priority status) */
Ambient: "ambient",
} as const
export type TimeRelevance = (typeof TimeRelevance)[keyof typeof TimeRelevance]
export interface FeedItemSignals {
/** Source-assessed urgency (0-1). Post-processors use this as one ranking input. */
urgency?: number
/** How time-sensitive this item is relative to now. */
timeRelevance?: TimeRelevance
}
/** /**
* A single item in the feed. * A single item in the feed.
* *
@@ -33,9 +8,9 @@ export interface FeedItemSignals {
* const item: WeatherItem = { * const item: WeatherItem = {
* id: "weather-123", * id: "weather-123",
* type: "weather", * type: "weather",
* priority: 0.5,
* timestamp: new Date(), * timestamp: new Date(),
* data: { temp: 18, condition: "cloudy" }, * data: { temp: 18, condition: "cloudy" },
* signals: { urgency: 0.5, timeRelevance: "ambient" },
* } * }
* ``` * ```
*/ */
@@ -47,10 +22,10 @@ export interface FeedItem<
id: string id: string
/** Item type, matches the data source type */ /** Item type, matches the data source type */
type: TType type: TType
/** Sort priority (higher = more important, shown first) */
priority: number
/** When this item was generated */ /** When this item was generated */
timestamp: Date timestamp: Date
/** Type-specific payload */ /** Type-specific payload */
data: TData data: TData
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
signals?: FeedItemSignals
} }

View File

@@ -1,21 +1,17 @@
// Context // Context
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context" export type { Context, ContextKey } from "./context"
export { Context, contextKey, serializeKey } from "./context" export { contextKey, contextValue } from "./context"
// Actions // Actions
export type { ActionDefinition } from "./action" export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action" export { UnknownActionError } from "./action"
// Feed // Feed
export type { FeedItem, FeedItemSignals } from "./feed" export type { FeedItem } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source // Feed Source
export type { FeedSource } from "./feed-source" export type { FeedSource } from "./feed-source"
// Feed Post-Processor
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
// Feed Engine // Feed Engine
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine" export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine" export { FeedEngine } from "./feed-engine"

View File

@@ -72,6 +72,8 @@ export class Reconciler<TItems extends FeedItem = never> {
} }
}) })
items.sort((a, b) => b.priority - a.priority)
return { items, errors } as ReconcileResult<TItems> return { items, errors } as ReconcileResult<TItems>
} }
} }

View File

@@ -1,6 +1,5 @@
import type { ContextKey } from "@aris/core" import type { Context } from "@aris/core"
import { Context, contextKey } from "@aris/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit" import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
@@ -16,25 +15,14 @@ const mockCredentials = {
serviceId: "mock", serviceId: "mock",
} }
interface LocationData {
lat: number
lng: number
accuracy: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({ const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
fetch: async () => response, fetch: async () => response,
}) })
function createMockContext(location?: { lat: number; lng: number }): Context { const createMockContext = (location?: { lat: number; lng: number }): Context => ({
const ctx = new Context(new Date("2026-01-17T00:00:00Z")) time: new Date("2026-01-17T00:00:00Z"),
if (location) { location: location ? { ...location, accuracy: 10 } : undefined,
ctx.set([[LocationKey, { ...location, accuracy: 10 }]]) })
}
return ctx
}
describe("WeatherKitDataSource", () => { describe("WeatherKitDataSource", () => {
test("returns empty array when location is missing", async () => { test("returns empty array when location is missing", async () => {
@@ -51,7 +39,7 @@ describe("WeatherKitDataSource", () => {
credentials: mockCredentials, credentials: mockCredentials,
}) })
expect(dataSource.type).toBe(WeatherFeedItemType.Current) expect(dataSource.type).toBe(WeatherFeedItemType.current)
}) })
test("throws error if neither client nor credentials provided", () => { test("throws error if neither client nor credentials provided", () => {
@@ -142,9 +130,9 @@ describe("query() with mocked client", () => {
const items = await dataSource.query(context) const items = await dataSource.query(context)
expect(items.length).toBeGreaterThan(0) expect(items.length).toBeGreaterThan(0)
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true) expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true) expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true) expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
}) })
test("applies hourly and daily limits", async () => { test("applies hourly and daily limits", async () => {
@@ -157,8 +145,8 @@ describe("query() with mocked client", () => {
const items = await dataSource.query(context) const items = await dataSource.query(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly) const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily) const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourlyItems.length).toBe(3) expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2) expect(dailyItems.length).toBe(2)
@@ -188,8 +176,8 @@ describe("query() with mocked client", () => {
units: Units.imperial, units: Units.imperial,
}) })
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current) const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current) const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
expect(metricCurrent).toBeDefined() expect(metricCurrent).toBeDefined()
expect(imperialCurrent).toBeDefined() expect(imperialCurrent).toBeDefined()
@@ -202,22 +190,21 @@ describe("query() with mocked client", () => {
expect(imperialTemp).toBeCloseTo(expectedImperial, 2) expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
}) })
test("assigns signals based on weather conditions", async () => { test("assigns priority based on weather conditions", async () => {
const dataSource = new WeatherKitDataSource({ client: mockClient }) const dataSource = new WeatherKitDataSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context) const items = await dataSource.query(context)
for (const item of items) { for (const item of items) {
expect(item.signals).toBeDefined() expect(item.priority).toBeGreaterThanOrEqual(0)
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0) expect(item.priority).toBeLessThanOrEqual(1)
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
expect(item.signals!.timeRelevance).toBeDefined()
} }
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current) const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined() expect(currentItem).toBeDefined()
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5) // Base priority for current is 0.5, may be adjusted for conditions
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
}) })
test("generates unique IDs for each item", async () => { test("generates unique IDs for each item", async () => {

View File

@@ -1,6 +1,4 @@
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core" import type { Context, DataSource } from "@aris/core"
import { TimeRelevance, contextKey } from "@aris/core"
import { import {
WeatherFeedItemType, WeatherFeedItemType,
@@ -40,18 +38,11 @@ export interface WeatherKitQueryConfig {
units?: Units units?: Units
} }
interface LocationData {
lat: number
lng: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> { export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
private readonly DEFAULT_HOURLY_LIMIT = 12 private readonly DEFAULT_HOURLY_LIMIT = 12
private readonly DEFAULT_DAILY_LIMIT = 7 private readonly DEFAULT_DAILY_LIMIT = 7
readonly type = WeatherFeedItemType.Current readonly type = WeatherFeedItemType.current
private readonly client: WeatherKitClient private readonly client: WeatherKitClient
private readonly hourlyLimit: number private readonly hourlyLimit: number
private readonly dailyLimit: number private readonly dailyLimit: number
@@ -66,8 +57,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
} }
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> { async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
const location = context.get(LocationKey) if (!context.location) {
if (!location) {
return [] return []
} }
@@ -75,8 +65,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
const timestamp = context.time const timestamp = context.time
const response = await this.client.fetch({ const response = await this.client.fetch({
lat: location.lat, lat: context.location.lat,
lng: location.lng, lng: context.location.lng,
}) })
const items: WeatherFeedItem[] = [] const items: WeatherFeedItem[] = []
@@ -115,7 +105,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
} }
} }
const BASE_URGENCY = { const BASE_PRIORITY = {
current: 0.5, current: 0.5,
hourly: 0.3, hourly: 0.3,
daily: 0.2, daily: 0.2,
@@ -144,17 +134,17 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
ConditionCode.BlowingSnow, ConditionCode.BlowingSnow,
]) ])
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number { function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
if (SEVERE_CONDITIONS.has(conditionCode)) { if (SEVERE_CONDITIONS.has(conditionCode)) {
return Math.min(1, baseUrgency + 0.3) return Math.min(1, basePriority + 0.3)
} }
if (MODERATE_CONDITIONS.has(conditionCode)) { if (MODERATE_CONDITIONS.has(conditionCode)) {
return Math.min(1, baseUrgency + 0.15) return Math.min(1, basePriority + 0.15)
} }
return baseUrgency return basePriority
} }
function adjustUrgencyForAlertSeverity(severity: Severity): number { function adjustPriorityForAlertSeverity(severity: Severity): number {
switch (severity) { switch (severity) {
case Severity.Extreme: case Severity.Extreme:
return 1 return 1
@@ -163,29 +153,7 @@ function adjustUrgencyForAlertSeverity(severity: Severity): number {
case Severity.Moderate: case Severity.Moderate:
return 0.75 return 0.75
case Severity.Minor: case Severity.Minor:
return BASE_URGENCY.alert return BASE_PRIORITY.alert
}
}
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
if (SEVERE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Imminent
}
if (MODERATE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Upcoming
}
return TimeRelevance.Ambient
}
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
switch (severity) {
case Severity.Extreme:
case Severity.Severe:
return TimeRelevance.Imminent
case Severity.Moderate:
return TimeRelevance.Upcoming
case Severity.Minor:
return TimeRelevance.Ambient
} }
} }
@@ -229,14 +197,12 @@ function createCurrentWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): CurrentWeatherFeedItem { ): CurrentWeatherFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
timeRelevance: timeRelevanceForCondition(current.conditionCode),
}
return { return {
id: `weather-current-${timestamp.getTime()}`, id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.Current, type: WeatherFeedItemType.current,
priority,
timestamp, timestamp,
data: { data: {
conditionCode: current.conditionCode, conditionCode: current.conditionCode,
@@ -253,7 +219,6 @@ function createCurrentWeatherFeedItem(
windGust: convertSpeed(current.windGust, units), windGust: convertSpeed(current.windGust, units),
windSpeed: convertSpeed(current.windSpeed, units), windSpeed: convertSpeed(current.windSpeed, units),
}, },
signals,
} }
} }
@@ -263,14 +228,12 @@ function createHourlyWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): HourlyWeatherFeedItem { ): HourlyWeatherFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
}
return { return {
id: `weather-hourly-${timestamp.getTime()}-${index}`, id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Hourly, type: WeatherFeedItemType.hourly,
priority,
timestamp, timestamp,
data: { data: {
forecastTime: new Date(hourly.forecastStart), forecastTime: new Date(hourly.forecastStart),
@@ -287,7 +250,6 @@ function createHourlyWeatherFeedItem(
windGust: convertSpeed(hourly.windGust, units), windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units), windSpeed: convertSpeed(hourly.windSpeed, units),
}, },
signals,
} }
} }
@@ -297,14 +259,12 @@ function createDailyWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): DailyWeatherFeedItem { ): DailyWeatherFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
}
return { return {
id: `weather-daily-${timestamp.getTime()}-${index}`, id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Daily, type: WeatherFeedItemType.daily,
priority,
timestamp, timestamp,
data: { data: {
forecastDate: new Date(daily.forecastStart), forecastDate: new Date(daily.forecastStart),
@@ -319,19 +279,16 @@ function createDailyWeatherFeedItem(
temperatureMax: convertTemperature(daily.temperatureMax, units), temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units), temperatureMin: convertTemperature(daily.temperatureMin, units),
}, },
signals,
} }
} }
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem { function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForAlertSeverity(alert.severity)
urgency: adjustUrgencyForAlertSeverity(alert.severity),
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
}
return { return {
id: `weather-alert-${alert.id}`, id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.Alert, type: WeatherFeedItemType.alert,
priority,
timestamp, timestamp,
data: { data: {
alertId: alert.id, alertId: alert.id,
@@ -345,6 +302,5 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
source: alert.source, source: alert.source,
urgency: alert.urgency, urgency: alert.urgency,
}, },
signals,
} }
} }

View File

@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit" import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
export const WeatherFeedItemType = { export const WeatherFeedItemType = {
Current: "weather-current", current: "weather-current",
Hourly: "weather-hourly", hourly: "weather-hourly",
Daily: "weather-daily", daily: "weather-daily",
Alert: "weather-alert", alert: "weather-alert",
} as const } as const
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType] export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
} }
export interface CurrentWeatherFeedItem extends FeedItem< export interface CurrentWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Current, typeof WeatherFeedItemType.current,
CurrentWeatherData CurrentWeatherData
> {} > {}
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
} }
export interface HourlyWeatherFeedItem extends FeedItem< export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Hourly, typeof WeatherFeedItemType.hourly,
HourlyWeatherData HourlyWeatherData
> {} > {}
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
} }
export interface DailyWeatherFeedItem extends FeedItem< export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily, typeof WeatherFeedItemType.daily,
DailyWeatherData DailyWeatherData
> {} > {}
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
} }
export interface WeatherAlertFeedItem extends FeedItem< export interface WeatherAlertFeedItem extends FeedItem<
typeof WeatherFeedItemType.Alert, typeof WeatherFeedItemType.alert,
WeatherAlertData WeatherAlertData
> {} > {}

View File

@@ -1,12 +1,11 @@
{ {
"name": "@aris/source-caldav", "name": "@aris/source-apple-calendar",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"scripts": { "scripts": {
"test": "bun test .", "test": "bun test ."
"test:live": "bun run scripts/test-live.ts"
}, },
"dependencies": { "dependencies": {
"@aris/core": "workspace:*", "@aris/core": "workspace:*",

View File

@@ -2,23 +2,23 @@ import type { ContextKey } from "@aris/core"
import { contextKey } from "@aris/core" import { contextKey } from "@aris/core"
import type { CalDavEventData } from "./types.ts" import type { CalendarEventData } from "./types.ts"
/** /**
* Calendar context for downstream sources. * Calendar context for downstream sources.
* *
* Provides a snapshot of the user's upcoming CalDAV events so other sources * Provides a snapshot of the user's upcoming events so other sources
* can adapt (e.g. a commute source checking if there's a meeting soon). * can adapt (e.g. a commute source checking if there's a meeting soon).
*/ */
export interface CalendarContext { export interface CalendarContext {
/** Events happening right now */ /** Events happening right now */
inProgress: CalDavEventData[] inProgress: CalendarEventData[]
/** Next upcoming event, if any */ /** Next upcoming event, if any */
nextEvent: CalDavEventData | null nextEvent: CalendarEventData | null
/** Whether the user has any events today */ /** Whether the user has any events today */
hasTodayEvents: boolean hasTodayEvents: boolean
/** Total number of events today */ /** Total number of events today */
todayEventCount: number todayEventCount: number
} }
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar") export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar")

View File

@@ -1,45 +1,60 @@
import type { ContextEntry } from "@aris/core" import type { Context } from "@aris/core"
import { Context, TimeRelevance } from "@aris/core" import { contextValue } from "@aris/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs" import { readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { import type {
CalDavDAVCalendar, CalendarCredentialProvider,
CalDavDAVClient, CalendarCredentials,
CalDavDAVObject, CalendarDAVCalendar,
CalDavEventData, CalendarDAVClient,
CalendarDAVObject,
CalendarEventData,
} from "./types.ts" } from "./types.ts"
import { CalDavSource, computeSignals } from "./caldav-source.ts" import { CalendarKey } from "./calendar-context.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" import { CalendarSource, computePriority } from "./calendar-source.ts"
function loadFixture(name: string): string { function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8") return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
} }
function createContext(time: Date): Context { function createContext(time: Date): Context {
return new Context(time) return { time }
} }
/** Extract the CalendarContext value from fetchContext entries. */ const mockCredentials: CalendarCredentials = {
function extractCalendar(entries: readonly ContextEntry[] | null): CalendarContext | undefined { accessToken: "mock-access-token",
if (!entries) return undefined refreshToken: "mock-refresh-token",
const entry = entries.find(([key]) => key === CalDavCalendarKey) expiresAt: Date.now() + 3600000,
return entry?.[1] as CalendarContext | undefined tokenUrl: "https://appleid.apple.com/auth/token",
clientId: "com.example.aris",
clientSecret: "mock-secret",
} }
class MockDAVClient implements CalDavDAVClient { class NullCredentialProvider implements CalendarCredentialProvider {
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
return null
}
}
class MockCredentialProvider implements CalendarCredentialProvider {
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
return mockCredentials
}
}
class MockDAVClient implements CalendarDAVClient {
credentials: Record<string, unknown> = {} credentials: Record<string, unknown> = {}
fetchCalendarsCallCount = 0 fetchCalendarsCallCount = 0
lastTimeRange: { start: string; end: string } | null = null private calendars: CalendarDAVCalendar[]
private calendars: CalDavDAVCalendar[] private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
constructor( constructor(
calendars: CalDavDAVCalendar[], calendars: CalendarDAVCalendar[],
objectsByCalendarUrl: Record<string, CalDavDAVObject[]>, objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
) { ) {
this.calendars = calendars this.calendars = calendars
this.objectsByCalendarUrl = objectsByCalendarUrl this.objectsByCalendarUrl = objectsByCalendarUrl
@@ -47,57 +62,54 @@ class MockDAVClient implements CalDavDAVClient {
async login(): Promise<void> {} async login(): Promise<void> {}
async fetchCalendars(): Promise<CalDavDAVCalendar[]> { async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
this.fetchCalendarsCallCount++ this.fetchCalendarsCallCount++
return this.calendars return this.calendars
} }
async fetchCalendarObjects(params: { async fetchCalendarObjects(params: {
calendar: CalDavDAVCalendar calendar: CalendarDAVCalendar
timeRange: { start: string; end: string } timeRange: { start: string; end: string }
}): Promise<CalDavDAVObject[]> { }): Promise<CalendarDAVObject[]> {
this.lastTimeRange = params.timeRange
return this.objectsByCalendarUrl[params.calendar.url] ?? [] return this.objectsByCalendarUrl[params.calendar.url] ?? []
} }
} }
function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource { describe("CalendarSource", () => {
return new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
davClient: client,
lookAheadDays,
})
}
describe("CalDavSource", () => {
test("has correct id", () => { test("has correct id", () => {
const client = new MockDAVClient([], {}) const source = new CalendarSource(new NullCredentialProvider(), "user-1")
const source = createSource(client) expect(source.id).toBe("aris.apple-calendar")
expect(source.id).toBe("aris.caldav") })
test("returns empty array when credentials are null", async () => {
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toEqual([])
}) })
test("returns empty array when no calendars exist", async () => { test("returns empty array when no calendars exist", async () => {
const client = new MockDAVClient([], {}) const client = new MockDAVClient([], {})
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toEqual([]) expect(items).toEqual([])
}) })
test("returns feed items from a single calendar", async () => { test("returns feed items from a single calendar", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1) expect(items).toHaveLength(1)
expect(items[0]!.type).toBe("caldav-event") expect(items[0]!.type).toBe("calendar-event")
expect(items[0]!.id).toBe("caldav-event-single-event-001@test") expect(items[0]!.id).toBe("calendar-event-single-event-001@test")
expect(items[0]!.data.title).toBe("Team Standup") expect(items[0]!.data.title).toBe("Team Standup")
expect(items[0]!.data.location).toBe("Conference Room A") expect(items[0]!.data.location).toBe("Conference Room A")
expect(items[0]!.data.calendarName).toBe("Work") expect(items[0]!.data.calendarName).toBe("Work")
@@ -106,7 +118,7 @@ describe("CalDavSource", () => {
}) })
test("returns feed items from multiple calendars", async () => { test("returns feed items from multiple calendars", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
"/cal/personal": [ "/cal/personal": [
{ {
@@ -122,7 +134,9 @@ describe("CalDavSource", () => {
], ],
objects, objects,
) )
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
@@ -140,7 +154,7 @@ describe("CalDavSource", () => {
}) })
test("skips objects with non-string data", async () => { test("skips objects with non-string data", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/bad.ics", data: 12345 }, { url: "/cal/work/bad.ics", data: 12345 },
@@ -148,7 +162,9 @@ describe("CalDavSource", () => {
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1) expect(items).toHaveLength(1)
@@ -156,26 +172,30 @@ describe("CalDavSource", () => {
}) })
test("uses context time as feed item timestamp", async () => { test("uses context time as feed item timestamp", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const now = new Date("2026-01-15T12:00:00Z") const now = new Date("2026-01-15T12:00:00Z")
const items = await source.fetchItems(createContext(now)) const items = await source.fetchItems(createContext(now))
expect(items[0]!.timestamp).toEqual(now) expect(items[0]!.timestamp).toEqual(now)
}) })
test("assigns signals based on event proximity", async () => { test("assigns priority based on event proximity", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }, { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
// 2 hours before the event at 14:00 // 2 hours before the event at 14:00
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
@@ -183,14 +203,12 @@ describe("CalDavSource", () => {
const standup = items.find((i) => i.data.title === "Team Standup") const standup = items.find((i) => i.data.title === "Team Standup")
const holiday = items.find((i) => i.data.title === "Company Holiday") const holiday = items.find((i) => i.data.title === "Company Holiday")
expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours expect(standup!.priority).toBe(0.7) // within 2 hours
expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming) expect(holiday!.priority).toBe(0.3) // all-day
expect(holiday!.signals!.urgency).toBe(0.3) // all-day
expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
}) })
test("handles calendar with non-string displayName", async () => { test("handles calendar with non-string displayName", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/weird": [ "/cal/weird": [
{ {
url: "/cal/weird/event1.ics", url: "/cal/weird/event1.ics",
@@ -202,14 +220,16 @@ describe("CalDavSource", () => {
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }], [{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
objects, objects,
) )
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items[0]!.data.calendarName).toBeNull() expect(items[0]!.data.calendarName).toBeNull()
}) })
test("handles recurring events with exceptions", async () => { test("handles recurring events with exceptions", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ {
url: "/cal/work/recurring.ics", url: "/cal/work/recurring.ics",
@@ -218,7 +238,9 @@ describe("CalDavSource", () => {
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
@@ -236,11 +258,13 @@ describe("CalDavSource", () => {
}) })
test("caches events within the same refresh cycle", async () => { test("caches events within the same refresh cycle", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const context = createContext(new Date("2026-01-15T12:00:00Z")) const context = createContext(new Date("2026-01-15T12:00:00Z"))
@@ -251,51 +275,14 @@ describe("CalDavSource", () => {
expect(client.fetchCalendarsCallCount).toBe(1) expect(client.fetchCalendarsCallCount).toBe(1)
}) })
test("uses timezone for time range when provided", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
// 2026-01-15T22:00:00Z = 2026-01-16T09:00:00 in Australia/Sydney (AEDT, UTC+11)
const source = new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
davClient: client,
timeZone: "Australia/Sydney",
})
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
// "Today" in Sydney is Jan 16, so start should be Jan 15 13:00 UTC (midnight Jan 16 AEDT)
expect(client.lastTimeRange).not.toBeNull()
expect(client.lastTimeRange!.start).toBe("2026-01-15T13:00:00.000Z")
// End should be Jan 16 13:00 UTC (midnight Jan 17 AEDT) — 1 day window
expect(client.lastTimeRange!.end).toBe("2026-01-16T13:00:00.000Z")
})
test("defaults to UTC midnight when no timezone provided", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
expect(client.lastTimeRange).not.toBeNull()
expect(client.lastTimeRange!.start).toBe("2026-01-15T00:00:00.000Z")
expect(client.lastTimeRange!.end).toBe("2026-01-16T00:00:00.000Z")
})
test("refetches events for a different context time", async () => { test("refetches events for a different context time", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z"))) await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
@@ -305,12 +292,11 @@ describe("CalDavSource", () => {
}) })
}) })
describe("CalDavSource.fetchContext", () => { describe("CalendarSource.fetchContext", () => {
test("returns empty context when no calendars exist", async () => { test("returns empty context when credentials are null", async () => {
const client = new MockDAVClient([], {}) const source = new CalendarSource(new NullCredentialProvider(), "user-1")
const source = createSource(client) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const calendar = contextValue(ctx as Context, CalendarKey)
const calendar = extractCalendar(entries)
expect(calendar).toBeDefined() expect(calendar).toBeDefined()
expect(calendar!.inProgress).toEqual([]) expect(calendar!.inProgress).toEqual([])
@@ -320,30 +306,34 @@ describe("CalDavSource.fetchContext", () => {
}) })
test("identifies in-progress events", async () => { test("identifies in-progress events", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
// 14:30 is during the 14:00-15:00 event // 14:30 is during the 14:00-15:00 event
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.inProgress).toHaveLength(1) expect(calendar!.inProgress).toHaveLength(1)
expect(calendar!.inProgress[0]!.title).toBe("Team Standup") expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
}) })
test("identifies next upcoming event", async () => { test("identifies next upcoming event", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
// 12:00 is before the 14:00 event // 12:00 is before the 14:00 event
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.inProgress).toHaveLength(0) expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).not.toBeNull() expect(calendar!.nextEvent).not.toBeNull()
@@ -351,14 +341,16 @@ describe("CalDavSource.fetchContext", () => {
}) })
test("excludes all-day events from inProgress and nextEvent", async () => { test("excludes all-day events from inProgress and nextEvent", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }], "/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.inProgress).toHaveLength(0) expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).toBeNull() expect(calendar!.nextEvent).toBeNull()
@@ -367,27 +359,29 @@ describe("CalDavSource.fetchContext", () => {
}) })
test("counts all events including all-day in todayEventCount", async () => { test("counts all events including all-day in todayEventCount", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }, { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.todayEventCount).toBe(2) expect(calendar!.todayEventCount).toBe(2)
expect(calendar!.hasTodayEvents).toBe(true) expect(calendar!.hasTodayEvents).toBe(true)
}) })
}) })
describe("computeSignals", () => { describe("computePriority", () => {
const now = new Date("2026-01-15T12:00:00Z") const now = new Date("2026-01-15T12:00:00Z")
function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData { function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
return { return {
uid: "test-uid", uid: "test-uid",
title: "Test", title: "Test",
@@ -407,108 +401,73 @@ describe("computeSignals", () => {
} }
} }
test("all-day events get urgency 0.3 and ambient relevance", () => { test("all-day events get priority 0.3", () => {
const event = makeEvent({ isAllDay: true }) const event = makeEvent({ isAllDay: true })
const signals = computeSignals(event, now) expect(computePriority(event, now)).toBe(0.3)
expect(signals.urgency).toBe(0.3)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
}) })
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => { test("events starting within 30 minutes get priority 0.9", () => {
const event = makeEvent({ const event = makeEvent({
startDate: new Date("2026-01-15T12:20:00Z"), startDate: new Date("2026-01-15T12:20:00Z"),
}) })
const signals = computeSignals(event, now) expect(computePriority(event, now)).toBe(0.9)
expect(signals.urgency).toBe(0.9)
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
}) })
test("events starting exactly at 30 minutes get urgency 0.9", () => { test("events starting exactly at 30 minutes get priority 0.9", () => {
const event = makeEvent({ const event = makeEvent({
startDate: new Date("2026-01-15T12:30:00Z"), startDate: new Date("2026-01-15T12:30:00Z"),
}) })
expect(computeSignals(event, now).urgency).toBe(0.9) expect(computePriority(event, now)).toBe(0.9)
}) })
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => { test("events starting within 2 hours get priority 0.7", () => {
const event = makeEvent({ const event = makeEvent({
startDate: new Date("2026-01-15T13:00:00Z"), startDate: new Date("2026-01-15T13:00:00Z"),
}) })
const signals = computeSignals(event, now) expect(computePriority(event, now)).toBe(0.7)
expect(signals.urgency).toBe(0.7)
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
}) })
test("events later today get urgency 0.5", () => { test("events later today get priority 0.5", () => {
const event = makeEvent({ const event = makeEvent({
startDate: new Date("2026-01-15T20:00:00Z"), startDate: new Date("2026-01-15T20:00:00Z"),
}) })
expect(computeSignals(event, now).urgency).toBe(0.5) expect(computePriority(event, now)).toBe(0.5)
}) })
test("in-progress events get urgency 0.8 and imminent relevance", () => { test("in-progress events get priority 0.8", () => {
const event = makeEvent({ const event = makeEvent({
startDate: new Date("2026-01-15T11:00:00Z"), startDate: new Date("2026-01-15T11:00:00Z"),
endDate: new Date("2026-01-15T13:00:00Z"), endDate: new Date("2026-01-15T13:00:00Z"),
}) })
const signals = computeSignals(event, now) expect(computePriority(event, now)).toBe(0.8)
expect(signals.urgency).toBe(0.8)
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
}) })
test("fully past events get urgency 0.2 and ambient relevance", () => { test("fully past events get priority 0.2", () => {
const event = makeEvent({ const event = makeEvent({
startDate: new Date("2026-01-15T09:00:00Z"), startDate: new Date("2026-01-15T09:00:00Z"),
endDate: new Date("2026-01-15T10:00:00Z"), endDate: new Date("2026-01-15T10:00:00Z"),
}) })
const signals = computeSignals(event, now) expect(computePriority(event, now)).toBe(0.2)
expect(signals.urgency).toBe(0.2)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
}) })
test("events on future days get urgency 0.2", () => { test("events on future days get priority 0.2", () => {
const event = makeEvent({ const event = makeEvent({
startDate: new Date("2026-01-16T10:00:00Z"), startDate: new Date("2026-01-16T10:00:00Z"),
}) })
expect(computeSignals(event, now).urgency).toBe(0.2) expect(computePriority(event, now)).toBe(0.2)
}) })
test("urgency boundaries are correct", () => { test("priority boundaries are correct", () => {
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min) // 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
const event31min = makeEvent({ const event31min = makeEvent({
startDate: new Date("2026-01-15T12:31:00Z"), startDate: new Date("2026-01-15T12:31:00Z"),
}) })
expect(computeSignals(event31min, now).urgency).toBe(0.7) expect(computePriority(event31min, now)).toBe(0.7)
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours) // 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
const event2h1m = makeEvent({ const event2h1m = makeEvent({
startDate: new Date("2026-01-15T14:01:00Z"), startDate: new Date("2026-01-15T14:01:00Z"),
}) })
expect(computeSignals(event2h1m, now).urgency).toBe(0.5) expect(computePriority(event2h1m, now)).toBe(0.5)
})
test("cancelled events get urgency 0.1 regardless of timing", () => {
const event = makeEvent({
status: "cancelled",
startDate: new Date("2026-01-15T12:20:00Z"), // would be 0.9 if not cancelled
})
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.1)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("uses timezone for 'later today' boundary", () => {
// now = 2026-01-15T12:00:00Z = 2026-01-15T21:00:00 JST (UTC+9)
// event at 2026-01-15T15:30:00Z = 2026-01-16T00:30:00 JST — next day in JST
const event = makeEvent({
startDate: new Date("2026-01-15T15:30:00Z"),
})
// Without timezone: UTC day ends at 2026-01-16T00:00:00Z, event is before that → "later today"
expect(computeSignals(event, now).urgency).toBe(0.5)
// With Asia/Tokyo: local day ends at 2026-01-15T15:00:00Z (midnight Jan 16 JST),
// event is after that → "future days"
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
}) })
}) })

View File

@@ -0,0 +1,251 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
import type {
CalendarCredentialProvider,
CalendarCredentials,
CalendarDAVClient,
CalendarEventData,
CalendarFeedItem,
} from "./types.ts"
export interface CalendarSourceOptions {
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
davClient?: CalendarDAVClient
}
import { CalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts"
const ICLOUD_CALDAV_URL = "https://caldav.icloud.com"
const DEFAULT_LOOK_AHEAD_DAYS = 0
/**
* A FeedSource that fetches Apple Calendar events via CalDAV.
*
* Credentials are provided by an injected CalendarCredentialProvider.
* The server is responsible for managing OAuth tokens and storage.
*
* @example
* ```ts
* const source = new CalendarSource(credentialProvider, "user-123")
* const engine = new FeedEngine()
* engine.register(source)
* ```
*/
export class CalendarSource implements FeedSource<CalendarFeedItem> {
readonly id = "aris.apple-calendar"
private readonly credentialProvider: CalendarCredentialProvider
private readonly userId: string
private readonly lookAheadDays: number
private readonly injectedClient: CalendarDAVClient | null
private davClient: CalendarDAVClient | null = null
private lastAccessToken: string | null = null
private cachedEvents: { time: Date; events: CalendarEventData[] } | null = null
constructor(
credentialProvider: CalendarCredentialProvider,
userId: string,
options?: CalendarSourceOptions,
) {
this.credentialProvider = credentialProvider
this.userId = userId
this.lookAheadDays = options?.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
this.injectedClient = options?.davClient ?? null
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const events = await this.fetchEvents(context)
if (events.length === 0) {
return {
[CalendarKey]: {
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
}
}
const now = context.time
const inProgress = events.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
const upcoming = events
.filter((e) => !e.isAllDay && e.startDate > now)
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
const calendarContext: CalendarContext = {
inProgress,
nextEvent: upcoming[0] ?? null,
hasTodayEvents: events.length > 0,
todayEventCount: events.length,
}
return { [CalendarKey]: calendarContext }
}
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
const now = context.time
const events = await this.fetchEvents(context)
return events.map((event) => createFeedItem(event, now))
}
private async fetchEvents(context: Context): Promise<CalendarEventData[]> {
if (this.cachedEvents && this.cachedEvents.time === context.time) {
return this.cachedEvents.events
}
const credentials = await this.credentialProvider.fetchCredentials(this.userId)
if (!credentials) {
return []
}
const client = await this.connectClient(credentials)
const calendars = await client.fetchCalendars()
const { start, end } = computeTimeRange(context.time, this.lookAheadDays)
const results = await Promise.allSettled(
calendars.map(async (calendar) => {
const objects = await client.fetchCalendarObjects({
calendar,
timeRange: {
start: start.toISOString(),
end: end.toISOString(),
},
})
// tsdav types displayName as string | Record<string, unknown> | undefined
// because the XML parser can return an object for some responses
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
return { objects, calendarName }
}),
)
const allEvents: CalendarEventData[] = []
for (const result of results) {
if (result.status !== "fulfilled") continue
const { objects, calendarName } = result.value
for (const obj of objects) {
if (typeof obj.data !== "string") continue
const events = parseICalEvents(obj.data, calendarName)
for (const event of events) {
allEvents.push(event)
}
}
}
this.cachedEvents = { time: context.time, events: allEvents }
return allEvents
}
/**
* Returns a ready-to-use DAVClient. Creates and logs in a new client
* on first call; reuses the existing one on subsequent calls, updating
* credentials if the access token has changed.
*/
private async connectClient(credentials: CalendarCredentials): Promise<CalendarDAVClient> {
if (this.injectedClient) {
return this.injectedClient
}
const davCredentials = {
tokenUrl: credentials.tokenUrl,
refreshToken: credentials.refreshToken,
accessToken: credentials.accessToken,
expiration: credentials.expiresAt,
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
}
if (!this.davClient) {
this.davClient = new DAVClient({
serverUrl: ICLOUD_CALDAV_URL,
credentials: davCredentials,
authMethod: "Oauth",
defaultAccountType: "caldav",
})
await this.davClient.login()
this.lastAccessToken = credentials.accessToken
return this.davClient
}
if (credentials.accessToken !== this.lastAccessToken) {
this.davClient.credentials = davCredentials
this.lastAccessToken = credentials.accessToken
}
return this.davClient
}
}
function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end: Date } {
const start = new Date(now)
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start)
end.setUTCDate(end.getUTCDate() + 1 + lookAheadDays)
return { start, end }
}
export function computePriority(event: CalendarEventData, now: Date): number {
if (event.isAllDay) {
return 0.3
}
const msUntilStart = event.startDate.getTime() - now.getTime()
// Event already started
if (msUntilStart < 0) {
const isInProgress = now.getTime() < event.endDate.getTime()
// Currently happening events are high priority; fully past events are low
return isInProgress ? 0.8 : 0.2
}
// Starting within 30 minutes
if (msUntilStart <= 30 * 60 * 1000) {
return 0.9
}
// Starting within 2 hours
if (msUntilStart <= 2 * 60 * 60 * 1000) {
return 0.7
}
// Later today (within 24 hours from start of day)
const startOfDay = new Date(now)
startOfDay.setUTCHours(0, 0, 0, 0)
const endOfDay = new Date(startOfDay)
endOfDay.setUTCDate(endOfDay.getUTCDate() + 1)
if (event.startDate.getTime() < endOfDay.getTime()) {
return 0.5
}
// Future days
return 0.2
}
function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem {
return {
id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
type: "calendar-event",
priority: computePriority(event, now),
timestamp: now,
data: event,
}
}

View File

@@ -3,20 +3,20 @@ import ICAL from "ical.js"
import { import {
AttendeeRole, AttendeeRole,
AttendeeStatus, AttendeeStatus,
CalDavEventStatus, CalendarEventStatus,
type CalDavAlarm, type CalendarAlarm,
type CalDavAttendee, type CalendarAttendee,
type CalDavEventData, type CalendarEventData,
} from "./types.ts" } from "./types.ts"
/** /**
* Parses a raw iCalendar string and extracts all VEVENT components * Parses a raw iCalendar string and extracts all VEVENT components
* into CalDavEventData objects. * into CalendarEventData objects.
* *
* @param icsData - Raw iCalendar string from a CalDAV response * @param icsData - Raw iCalendar string from a CalDAV response
* @param calendarName - Display name of the calendar this event belongs to * @param calendarName - Display name of the calendar this event belongs to
*/ */
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] { export function parseICalEvents(icsData: string, calendarName: string | null): CalendarEventData[] {
const jcal = ICAL.parse(icsData) const jcal = ICAL.parse(icsData)
const comp = new ICAL.Component(jcal) const comp = new ICAL.Component(jcal)
const vevents = comp.getAllSubcomponents("vevent") const vevents = comp.getAllSubcomponents("vevent")
@@ -29,7 +29,7 @@ export function parseICalEvents(icsData: string, calendarName: string | null): C
function parseVEvent( function parseVEvent(
vevent: InstanceType<typeof ICAL.Component>, vevent: InstanceType<typeof ICAL.Component>,
calendarName: string | null, calendarName: string | null,
): CalDavEventData { ): CalendarEventData {
const event = new ICAL.Event(vevent) const event = new ICAL.Event(vevent)
return { return {
@@ -50,15 +50,15 @@ function parseVEvent(
} }
} }
function parseStatus(raw: string | null): CalDavEventStatus | null { function parseStatus(raw: string | null): CalendarEventStatus | null {
if (!raw) return null if (!raw) return null
switch (raw.toLowerCase()) { switch (raw.toLowerCase()) {
case "confirmed": case "confirmed":
return CalDavEventStatus.Confirmed return CalendarEventStatus.Confirmed
case "tentative": case "tentative":
return CalDavEventStatus.Tentative return CalendarEventStatus.Tentative
case "cancelled": case "cancelled":
return CalDavEventStatus.Cancelled return CalendarEventStatus.Cancelled
default: default:
return null return null
} }
@@ -81,25 +81,22 @@ function parseOrganizer(
return value.replace(/^mailto:/i, "") return value.replace(/^mailto:/i, "")
} }
function parseAttendees(properties: unknown[]): CalDavAttendee[] { function parseAttendees(properties: unknown[]): CalendarAttendee[] {
if (properties.length === 0) return [] if (properties.length === 0) return []
return properties.flatMap((prop) => { return properties.map((prop) => {
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
const p = prop as InstanceType<typeof ICAL.Property> const p = prop as InstanceType<typeof ICAL.Property>
const value = asStringOrNull(p.getFirstValue()) const value = asStringOrNull(p.getFirstValue())
const cn = asStringOrNull(p.getParameter("cn")) const cn = asStringOrNull(p.getParameter("cn"))
const role = asStringOrNull(p.getParameter("role")) const role = asStringOrNull(p.getParameter("role"))
const partstat = asStringOrNull(p.getParameter("partstat")) const partstat = asStringOrNull(p.getParameter("partstat"))
return [ return {
{
name: cn, name: cn,
email: value ? value.replace(/^mailto:/i, "") : null, email: value ? value.replace(/^mailto:/i, "") : null,
role: parseAttendeeRole(role), role: parseAttendeeRole(role),
status: parseAttendeeStatus(partstat), status: parseAttendeeStatus(partstat),
}, }
]
}) })
} }
@@ -133,7 +130,7 @@ function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
} }
} }
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] { function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {
const valarms = vevent.getAllSubcomponents("valarm") const valarms = vevent.getAllSubcomponents("valarm")
if (!valarms || valarms.length === 0) return [] if (!valarms || valarms.length === 0) return []

View File

@@ -0,0 +1,16 @@
export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts"
export {
CalendarEventStatus,
AttendeeRole,
AttendeeStatus,
type CalendarCredentials,
type CalendarCredentialProvider,
type CalendarDAVClient,
type CalendarDAVCalendar,
type CalendarDAVObject,
type CalendarAttendee,
type CalendarAlarm,
type CalendarEventData,
type CalendarFeedItem,
} from "./types.ts"

View File

@@ -1,16 +1,30 @@
import type { FeedItem } from "@aris/core" import type { FeedItem } from "@aris/core"
// -- Event status -- // -- Credential provider --
export const CalDavEventStatus = { export interface CalendarCredentials {
accessToken: string
refreshToken: string
/** Unix timestamp in milliseconds when the access token expires */
expiresAt: number
tokenUrl: string
clientId: string
clientSecret: string
}
export interface CalendarCredentialProvider {
fetchCredentials(userId: string): Promise<CalendarCredentials | null>
}
// -- Feed item types --
export const CalendarEventStatus = {
Confirmed: "confirmed", Confirmed: "confirmed",
Tentative: "tentative", Tentative: "tentative",
Cancelled: "cancelled", Cancelled: "cancelled",
} as const } as const
export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus] export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus]
// -- Attendee types --
export const AttendeeRole = { export const AttendeeRole = {
Chair: "chair", Chair: "chair",
@@ -29,25 +43,21 @@ export const AttendeeStatus = {
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus] export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
export interface CalDavAttendee { export interface CalendarAttendee {
name: string | null name: string | null
email: string | null email: string | null
role: AttendeeRole | null role: AttendeeRole | null
status: AttendeeStatus | null status: AttendeeStatus | null
} }
// -- Alarm -- export interface CalendarAlarm {
export interface CalDavAlarm {
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */ /** ISO 8601 duration relative to event start, e.g. "-PT15M" */
trigger: string trigger: string
/** e.g. "DISPLAY", "AUDIO" */ /** e.g. "DISPLAY", "AUDIO" */
action: string action: string
} }
// -- Event data -- export interface CalendarEventData extends Record<string, unknown> {
export interface CalDavEventData extends Record<string, unknown> {
uid: string uid: string
title: string title: string
startDate: Date startDate: Date
@@ -56,46 +66,36 @@ export interface CalDavEventData extends Record<string, unknown> {
location: string | null location: string | null
description: string | null description: string | null
calendarName: string | null calendarName: string | null
status: CalDavEventStatus | null status: CalendarEventStatus | null
url: string | null url: string | null
organizer: string | null organizer: string | null
attendees: CalDavAttendee[] attendees: CalendarAttendee[]
alarms: CalDavAlarm[] alarms: CalendarAlarm[]
recurrenceId: string | null recurrenceId: string | null
} }
// -- Feed item type -- export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData>
export const CalDavFeedItemType = {
Event: "caldav-event",
} as const
export type CalDavFeedItemType = (typeof CalDavFeedItemType)[keyof typeof CalDavFeedItemType]
// -- Feed item --
export type CalDavFeedItem = FeedItem<typeof CalDavFeedItemType.Event, CalDavEventData>
// -- DAV client interface -- // -- DAV client interface --
export interface CalDavDAVObject { export interface CalendarDAVObject {
data?: unknown data?: unknown
etag?: string etag?: string
url: string url: string
} }
export interface CalDavDAVCalendar { export interface CalendarDAVCalendar {
displayName?: string | Record<string, unknown> displayName?: string | Record<string, unknown>
url: string url: string
} }
/** Subset of tsdav's DAVClient used by CalDavSource. */ /** Subset of DAVClient used by CalendarSource. */
export interface CalDavDAVClient { export interface CalendarDAVClient {
login(): Promise<void> login(): Promise<void>
fetchCalendars(): Promise<CalDavDAVCalendar[]> fetchCalendars(): Promise<CalendarDAVCalendar[]>
fetchCalendarObjects(params: { fetchCalendarObjects(params: {
calendar: CalDavDAVCalendar calendar: CalendarDAVCalendar
timeRange: { start: string; end: string } timeRange: { start: string; end: string }
}): Promise<CalDavDAVObject[]> }): Promise<CalendarDAVObject[]>
credentials: Record<string, unknown> credentials: Record<string, unknown>
} }

View File

@@ -1,58 +0,0 @@
# @aris/source-caldav
A FeedSource that fetches calendar events from any CalDAV server.
## Usage
```ts
import { CalDavSource } from "@aris/source-caldav"
// Basic auth (Nextcloud, Radicale, Baikal, iCloud, etc.)
const source = new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
lookAheadDays: 7, // optional, default: 0 (today only)
timeZone: "America/New_York", // optional, default: UTC
})
// OAuth
const source = new CalDavSource({
serverUrl: "https://caldav.provider.com",
authMethod: "oauth",
accessToken: "...",
refreshToken: "...",
tokenUrl: "https://provider.com/oauth/token",
})
```
### iCloud
Use your Apple ID email as the username and an [app-specific password](https://support.apple.com/en-us/102654):
```ts
const source = new CalDavSource({
serverUrl: "https://caldav.icloud.com",
authMethod: "basic",
username: "you@icloud.com",
password: "<app-specific-password>",
})
```
## Testing
```bash
bun test
```
### Live test
`bun run test:live` connects to a real CalDAV server and prints all events to the console. It prompts for:
- **CalDAV server URL** — e.g. `https://caldav.icloud.com`
- **Username** — your account email
- **Password** — your password (or app-specific password for iCloud)
- **Look-ahead days** — how many days beyond today to fetch (default: 0)
The script runs both `fetchContext` and `fetchItems`, printing the calendar context (in-progress events, next event, today's count) followed by each event with its title, time, location, signals, and attendees.

View File

@@ -1,64 +0,0 @@
/**
* Live test script for CalDavSource.
*
* Usage:
* bun run test-live.ts
*/
import { Context } from "@aris/core"
import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:")
const username = prompt("Username:")
const password = prompt("Password:")
const lookAheadRaw = prompt("Look-ahead days (default 0):")
if (!serverUrl || !username || !password) {
console.error("Server URL, username, and password are required.")
process.exit(1)
}
const lookAheadDays = Number(lookAheadRaw) || 0
const source = new CalDavSource({
serverUrl,
authMethod: "basic",
username,
password,
lookAheadDays,
})
const context = new Context()
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
const contextResult = await source.fetchContext(context)
const items = await source.fetchItems(context)
console.log("=== Context ===")
console.log(JSON.stringify(contextResult, null, 2))
console.log(`\n=== Feed Items (${items.length}) ===`)
for (const item of items) {
console.log(`\n--- ${item.data.title} ---`)
console.log(` ID: ${item.id}`)
console.log(` Calendar: ${item.data.calendarName ?? "(unknown)"}`)
console.log(` Start: ${item.data.startDate.toISOString()}`)
console.log(` End: ${item.data.endDate.toISOString()}`)
console.log(` All-day: ${item.data.isAllDay}`)
console.log(` Location: ${item.data.location ?? "(none)"}`)
console.log(` Status: ${item.data.status ?? "(none)"}`)
console.log(` Urgency: ${item.signals?.urgency}`)
console.log(` Relevance: ${item.signals?.timeRelevance}`)
if (item.data.attendees.length > 0) {
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
}
if (item.data.description) {
console.log(` Desc: ${item.data.description.slice(0, 100)}`)
}
}
if (items.length === 0) {
console.log("(no events found in the time window)")
}

View File

@@ -1,351 +0,0 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts"
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
// -- Source options --
interface CalDavSourceBaseOptions {
serverUrl: string
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** IANA timezone for determining "today" (e.g. "America/New_York"). Default: UTC. */
timeZone?: string
/** Optional DAV client for testing. */
davClient?: CalDavDAVClient
}
interface CalDavSourceBasicAuthOptions extends CalDavSourceBaseOptions {
authMethod: "basic"
username: string
password: string
}
interface CalDavSourceOAuthOptions extends CalDavSourceBaseOptions {
authMethod: "oauth"
accessToken: string
refreshToken: string
tokenUrl: string
expiration?: number
clientId?: string
clientSecret?: string
}
export type CalDavSourceOptions = CalDavSourceBasicAuthOptions | CalDavSourceOAuthOptions
const DEFAULT_LOOK_AHEAD_DAYS = 0
/**
* A FeedSource that fetches calendar events from any CalDAV server.
*
* Supports Basic auth (username/password) and OAuth (access token + refresh token).
* The server URL is provided at construction time.
*
* @example
* ```ts
* // Basic auth (self-hosted servers)
* const source = new CalDavSource({
* serverUrl: "https://nextcloud.example.com/remote.php/dav",
* authMethod: "basic",
* username: "user",
* password: "pass",
* })
*
* // OAuth (cloud providers)
* const source = new CalDavSource({
* serverUrl: "https://caldav.provider.com",
* authMethod: "oauth",
* accessToken: "...",
* refreshToken: "...",
* tokenUrl: "https://provider.com/oauth/token",
* })
* ```
*/
export class CalDavSource implements FeedSource<CalDavFeedItem> {
readonly id = "aris.caldav"
private options: CalDavSourceOptions | null
private readonly lookAheadDays: number
private readonly timeZone: string | undefined
private readonly injectedClient: CalDavDAVClient | null
private clientPromise: Promise<CalDavDAVClient> | null = null
private cachedEvents: { time: Date; events: CalDavEventData[] } | null = null
private pendingFetch: { time: Date; promise: Promise<CalDavEventData[]> } | null = null
constructor(options: CalDavSourceOptions) {
this.options = options
this.lookAheadDays = options.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
this.timeZone = options.timeZone
this.injectedClient = options.davClient ?? null
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
const events = await this.fetchEvents(context)
if (events.length === 0) {
return [
[
CalDavCalendarKey,
{
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
],
]
}
const now = context.time
const active = events.filter((e) => e.status !== CalDavEventStatus.Cancelled)
const inProgress = active.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
const upcoming = active
.filter((e) => !e.isAllDay && e.startDate > now)
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
const calendarContext: CalendarContext = {
inProgress,
nextEvent: upcoming[0] ?? null,
hasTodayEvents: events.length > 0,
todayEventCount: events.length,
}
return [[CalDavCalendarKey, calendarContext]]
}
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
const now = context.time
const events = await this.fetchEvents(context)
return events.map((event) => createFeedItem(event, now, this.timeZone))
}
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
if (this.cachedEvents && this.cachedEvents.time === context.time) {
return Promise.resolve(this.cachedEvents.events)
}
// Deduplicate concurrent fetches for the same context.time reference
if (this.pendingFetch && this.pendingFetch.time === context.time) {
return this.pendingFetch.promise
}
const promise = this.doFetchEvents(context).finally(() => {
if (this.pendingFetch?.promise === promise) {
this.pendingFetch = null
}
})
this.pendingFetch = { time: context.time, promise }
return promise
}
private async doFetchEvents(context: Context): Promise<CalDavEventData[]> {
const client = await this.connectClient()
const calendars = await client.fetchCalendars()
const { start, end } = computeTimeRange(context.time, this.lookAheadDays, this.timeZone)
const results = await Promise.allSettled(
calendars.map(async (calendar) => {
const objects = await client.fetchCalendarObjects({
calendar,
timeRange: {
start: start.toISOString(),
end: end.toISOString(),
},
})
// tsdav types displayName as string | Record<string, unknown> | undefined
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
return { objects, calendarName }
}),
)
const allEvents: CalDavEventData[] = []
for (const result of results) {
if (result.status === "rejected") {
console.warn("[aris.caldav] Failed to fetch calendar:", result.reason)
continue
}
const { objects, calendarName } = result.value
for (const obj of objects) {
if (typeof obj.data !== "string") continue
const events = parseICalEvents(obj.data, calendarName)
for (const event of events) {
allEvents.push(event)
}
}
}
this.cachedEvents = { time: context.time, events: allEvents }
return allEvents
}
private connectClient(): Promise<CalDavDAVClient> {
if (this.injectedClient) {
return Promise.resolve(this.injectedClient)
}
if (!this.clientPromise) {
this.clientPromise = this.createAndLoginClient().catch((err) => {
this.clientPromise = null
throw err
})
}
return this.clientPromise
}
private async createAndLoginClient(): Promise<CalDavDAVClient> {
const opts = this.options
if (!opts) {
throw new Error("CalDavSource options have already been consumed")
}
let client: CalDavDAVClient
if (opts.authMethod === "basic") {
client = new DAVClient({
serverUrl: opts.serverUrl,
credentials: {
username: opts.username,
password: opts.password,
},
authMethod: "Basic",
defaultAccountType: "caldav",
})
} else {
client = new DAVClient({
serverUrl: opts.serverUrl,
credentials: {
tokenUrl: opts.tokenUrl,
refreshToken: opts.refreshToken,
accessToken: opts.accessToken,
expiration: opts.expiration,
clientId: opts.clientId,
clientSecret: opts.clientSecret,
},
authMethod: "Oauth",
defaultAccountType: "caldav",
})
}
await client.login()
this.options = null
return client
}
}
function computeTimeRange(
now: Date,
lookAheadDays: number,
timeZone?: string,
): { start: Date; end: Date } {
const start = startOfDay(now, timeZone)
const end = new Date(start.getTime() + (1 + lookAheadDays) * 24 * 60 * 60 * 1000)
return { start, end }
}
/**
* Returns midnight (start of day) as a UTC Date.
* When timeZone is provided, "midnight" is local midnight in that timezone
* converted to UTC. Otherwise, UTC midnight.
*/
function startOfDay(date: Date, timeZone?: string): Date {
if (!timeZone) {
const d = new Date(date)
d.setUTCHours(0, 0, 0, 0)
return d
}
// Extract the local year/month/day in the target timezone
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date)
const year = Number(parts.find((p) => p.type === "year")!.value)
const month = Number(parts.find((p) => p.type === "month")!.value)
const day = Number(parts.find((p) => p.type === "day")!.value)
// Binary-search-free approach: construct a UTC date at the local date's noon,
// then use the timezone offset at that moment to find local midnight in UTC.
const noonUtc = Date.UTC(year, month - 1, day, 12, 0, 0)
const noonLocal = new Date(noonUtc).toLocaleString("sv-SE", { timeZone, hour12: false })
// sv-SE locale formats as "YYYY-MM-DD HH:MM:SS" which Date can parse
const noonLocalMs = new Date(noonLocal + "Z").getTime()
const offsetMs = noonLocalMs - noonUtc
return new Date(Date.UTC(year, month - 1, day) - offsetMs)
}
export function computeSignals(
event: CalDavEventData,
now: Date,
timeZone?: string,
): FeedItemSignals {
if (event.status === CalDavEventStatus.Cancelled) {
return { urgency: 0.1, timeRelevance: TimeRelevance.Ambient }
}
if (event.isAllDay) {
return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient }
}
const msUntilStart = event.startDate.getTime() - now.getTime()
// Event already started
if (msUntilStart < 0) {
const isInProgress = now.getTime() < event.endDate.getTime()
return isInProgress
? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent }
: { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
// Starting within 30 minutes
if (msUntilStart <= 30 * 60 * 1000) {
return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent }
}
// Starting within 2 hours
if (msUntilStart <= 2 * 60 * 60 * 1000) {
return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming }
}
// Later today (using local day boundary when timeZone is set)
const todayStart = startOfDay(now, timeZone)
const endOfDay = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000)
if (event.startDate.getTime() < endOfDay.getTime()) {
return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming }
}
// Future days
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
type: CalDavFeedItemType.Event,
timestamp: now,
data: event,
signals: computeSignals(event, now, timeZone),
}
}

View File

@@ -1,16 +0,0 @@
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
export { parseICalEvents } from "./ical-parser.ts"
export {
AttendeeRole,
AttendeeStatus,
CalDavEventStatus,
CalDavFeedItemType,
type CalDavAlarm,
type CalDavAttendee,
type CalDavDAVCalendar,
type CalDavDAVClient,
type CalDavDAVObject,
type CalDavEventData,
type CalDavFeedItem,
} from "./types.ts"

View File

@@ -10,4 +10,4 @@ export interface NextEvent {
location: string | null location: string | null
} }
export const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent") export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")

View File

@@ -3,19 +3,19 @@ import type { FeedItem } from "@aris/core"
import type { CalendarEventData } from "./types" import type { CalendarEventData } from "./types"
export const CalendarFeedItemType = { export const CalendarFeedItemType = {
Event: "calendar-event", event: "calendar-event",
AllDay: "calendar-all-day", allDay: "calendar-all-day",
} as const } as const
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType] export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
export interface CalendarEventFeedItem extends FeedItem< export interface CalendarEventFeedItem extends FeedItem<
typeof CalendarFeedItemType.Event, typeof CalendarFeedItemType.event,
CalendarEventData CalendarEventData
> {} > {}
export interface CalendarAllDayFeedItem extends FeedItem< export interface CalendarAllDayFeedItem extends FeedItem<
typeof CalendarFeedItemType.AllDay, typeof CalendarFeedItemType.allDay,
CalendarEventData CalendarEventData
> {} > {}

View File

@@ -1,10 +1,10 @@
import { Context, TimeRelevance } from "@aris/core" import { contextValue, type Context } from "@aris/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types" import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
import fixture from "../fixtures/events.json" import fixture from "../fixtures/events.json"
import { NextEventKey, type NextEvent } from "./calendar-context" import { NextEventKey } from "./calendar-context"
import { CalendarFeedItemType } from "./feed-items" import { CalendarFeedItemType } from "./feed-items"
import { GoogleCalendarSource } from "./google-calendar-source" import { GoogleCalendarSource } from "./google-calendar-source"
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
} }
function createContext(time?: Date): Context { function createContext(time?: Date): Context {
return new Context(time ?? NOW) return { time: time ?? NOW }
} }
describe("GoogleCalendarSource", () => { describe("GoogleCalendarSource", () => {
@@ -69,7 +69,7 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.Event) const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
expect(timedItems.length).toBe(4) expect(timedItems.length).toBe(4)
}) })
@@ -77,21 +77,20 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.AllDay) const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
expect(allDayItems.length).toBe(1) expect(allDayItems.length).toBe(1)
}) })
test("ongoing events get highest urgency (1.0)", async () => { test("ongoing events get highest priority (1.0)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing") const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
expect(ongoing).toBeDefined() expect(ongoing).toBeDefined()
expect(ongoing!.signals!.urgency).toBe(1.0) expect(ongoing!.priority).toBe(1.0)
expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent)
}) })
test("upcoming events get higher urgency when sooner", async () => { test("upcoming events get higher priority when sooner", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
@@ -100,17 +99,16 @@ describe("GoogleCalendarSource", () => {
expect(soon).toBeDefined() expect(soon).toBeDefined()
expect(later).toBeDefined() expect(later).toBeDefined()
expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!) expect(soon!.priority).toBeGreaterThan(later!.priority)
}) })
test("all-day events get flat urgency (0.4)", async () => { test("all-day events get flat priority (0.4)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
const allDay = items.find((i) => i.data.eventId === "evt-allday") const allDay = items.find((i) => i.data.eventId === "evt-allday")
expect(allDay).toBeDefined() expect(allDay).toBeDefined()
expect(allDay!.signals!.urgency).toBe(0.4) expect(allDay!.priority).toBe(0.4)
expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
}) })
test("generates unique IDs for each item", async () => { test("generates unique IDs for each item", async () => {
@@ -229,16 +227,15 @@ describe("GoogleCalendarSource", () => {
test("returns next upcoming timed event (not ongoing)", async () => { test("returns next upcoming timed event (not ongoing)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const entries = await source.fetchContext(createContext()) const result = await source.fetchContext(createContext())
expect(entries).not.toBeNull() expect(result).not.toBeNull()
expect(entries).toHaveLength(1) const nextEvent = contextValue(result! as Context, NextEventKey)
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent] expect(nextEvent).toBeDefined()
expect(key).toEqual(NextEventKey)
// evt-soon starts at 10:10, which is the nearest future timed event // evt-soon starts at 10:10, which is the nearest future timed event
expect(nextEvent.title).toBe("1:1 with Manager") expect(nextEvent!.title).toBe("1:1 with Manager")
expect(nextEvent.minutesUntilStart).toBe(10) expect(nextEvent!.minutesUntilStart).toBe(10)
expect(nextEvent.location).toBeNull() expect(nextEvent!.location).toBeNull()
}) })
test("includes location when available", async () => { test("includes location when available", async () => {
@@ -256,11 +253,12 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }), client: createMockClient({ primary: events }),
}) })
const entries = await source.fetchContext(createContext()) const result = await source.fetchContext(createContext())
expect(entries).not.toBeNull() expect(result).not.toBeNull()
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent] const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent.location).toBe("123 Main St") expect(nextEvent).toBeDefined()
expect(nextEvent!.location).toBe("123 Main St")
}) })
test("skips ongoing events for next-event context", async () => { test("skips ongoing events for next-event context", async () => {
@@ -282,7 +280,7 @@ describe("GoogleCalendarSource", () => {
}) })
}) })
describe("urgency ordering", () => { describe("priority ordering", () => {
test("ongoing > upcoming > all-day", async () => { test("ongoing > upcoming > all-day", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
@@ -291,8 +289,8 @@ describe("GoogleCalendarSource", () => {
const upcoming = items.find((i) => i.data.eventId === "evt-soon")! const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
const allDay = items.find((i) => i.data.eventId === "evt-allday")! const allDay = items.find((i) => i.data.eventId === "evt-allday")!
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!) expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!) expect(upcoming.priority).toBeGreaterThan(allDay.priority)
}) })
}) })
}) })

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core" import { UnknownActionError } from "@aris/core"
import type { import type {
ApiCalendarEvent, ApiCalendarEvent,
@@ -35,10 +35,10 @@ import { DefaultGoogleCalendarClient } from "./google-calendar-api"
const DEFAULT_LOOKAHEAD_HOURS = 24 const DEFAULT_LOOKAHEAD_HOURS = 24
const URGENCY_ONGOING = 1.0 const PRIORITY_ONGOING = 1.0
const URGENCY_UPCOMING_MAX = 0.9 const PRIORITY_UPCOMING_MAX = 0.9
const URGENCY_UPCOMING_MIN = 0.3 const PRIORITY_UPCOMING_MIN = 0.3
const URGENCY_ALL_DAY = 0.4 const PRIORITY_ALL_DAY = 0.4
/** /**
* A FeedSource that provides Google Calendar events and next-event context. * A FeedSource that provides Google Calendar events and next-event context.
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
* .register(calendarSource) * .register(calendarSource)
* *
* // Access next-event context in downstream sources * // Access next-event context in downstream sources
* const next = context.get(NextEventKey) * const next = contextValue(context, NextEventKey)
* if (next && next.minutesUntilStart < 15) { * if (next && next.minutesUntilStart < 15) {
* // remind user * // remind user
* } * }
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
throw new UnknownActionError(actionId) throw new UnknownActionError(actionId)
} }
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> { async fetchContext(context: Context): Promise<Partial<Context> | null> {
const events = await this.fetchAllEvents(context.time) const events = await this.fetchAllEvents(context.time)
const now = context.time.getTime() const now = context.time.getTime()
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
location: nextTimedEvent.location, location: nextTimedEvent.location,
} }
return [[NextEventKey, nextEvent]] return { [NextEventKey]: nextEvent }
} }
async fetchItems(context: Context): Promise<CalendarFeedItem[]> { async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
@@ -171,13 +171,9 @@ function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventD
} }
} }
function computeSignals( function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
event: CalendarEventData,
nowMs: number,
lookaheadMs: number,
): FeedItemSignals {
if (event.isAllDay) { if (event.isAllDay) {
return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient } return PRIORITY_ALL_DAY
} }
const startMs = event.startTime.getTime() const startMs = event.startTime.getTime()
@@ -185,23 +181,17 @@ function computeSignals(
// Ongoing: start <= now < end // Ongoing: start <= now < end
if (startMs <= nowMs && nowMs < endMs) { if (startMs <= nowMs && nowMs < endMs) {
return { urgency: URGENCY_ONGOING, timeRelevance: TimeRelevance.Imminent } return PRIORITY_ONGOING
} }
// Upcoming: linear decay from URGENCY_UPCOMING_MAX to URGENCY_UPCOMING_MIN // Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
const msUntilStart = startMs - nowMs const msUntilStart = startMs - nowMs
if (msUntilStart <= 0) { if (msUntilStart <= 0) {
return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient } return PRIORITY_UPCOMING_MIN
} }
const ratio = Math.min(msUntilStart / lookaheadMs, 1) const ratio = Math.min(msUntilStart / lookaheadMs, 1)
const urgency = URGENCY_UPCOMING_MAX - ratio * (URGENCY_UPCOMING_MAX - URGENCY_UPCOMING_MIN) return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
// Within 30 minutes = imminent, otherwise upcoming
const timeRelevance =
msUntilStart <= 30 * 60 * 1000 ? TimeRelevance.Imminent : TimeRelevance.Upcoming
return { urgency, timeRelevance }
} }
function createFeedItem( function createFeedItem(
@@ -209,13 +199,14 @@ function createFeedItem(
nowMs: number, nowMs: number,
lookaheadMs: number, lookaheadMs: number,
): CalendarFeedItem { ): CalendarFeedItem {
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event const priority = computePriority(event, nowMs, lookaheadMs)
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
return { return {
id: `calendar-${event.calendarId}-${event.eventId}`, id: `calendar-${event.calendarId}-${event.eventId}`,
type: itemType, type: itemType,
priority,
timestamp: new Date(nowMs), timestamp: new Date(nowMs),
data: event, data: event,
signals: computeSignals(event, nowMs, lookaheadMs),
} }
} }

View File

@@ -1,6 +1,7 @@
export { NextEventKey, type NextEvent } from "./calendar-context" export { NextEventKey, type NextEvent } from "./calendar-context"
export { export {
CalendarFeedItemType, CalendarFeedItemType,
type CalendarFeedItemType as CalendarFeedItemTypeType,
type CalendarAllDayFeedItem, type CalendarAllDayFeedItem,
type CalendarEventFeedItem, type CalendarEventFeedItem,
type CalendarFeedItem, type CalendarFeedItem,
@@ -9,6 +10,7 @@ export { DefaultGoogleCalendarClient } from "./google-calendar-api"
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source" export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
export { export {
EventStatus, EventStatus,
type EventStatus as EventStatusType,
type ApiCalendarEvent, type ApiCalendarEvent,
type ApiEventDateTime, type ApiEventDateTime,
type CalendarEventData, type CalendarEventData,

View File

@@ -1,8 +1,6 @@
import { describe, expect, mock, test } from "bun:test" import { describe, expect, mock, test } from "bun:test"
import type { Location } from "./types.ts" import { LocationKey, LocationSource, type Location } from "./location-source.ts"
import { LocationKey, LocationSource } from "./location-source.ts"
function createLocation(overrides: Partial<Location> = {}): Location { function createLocation(overrides: Partial<Location> = {}): Location {
return { return {
@@ -41,8 +39,8 @@ describe("LocationSource", () => {
const location = createLocation() const location = createLocation()
source.pushLocation(location) source.pushLocation(location)
const entries = await source.fetchContext() const context = await source.fetchContext()
expect(entries).toEqual([[LocationKey, location]]) expect(context).toEqual({ [LocationKey]: location })
}) })
}) })
@@ -67,7 +65,7 @@ describe("LocationSource", () => {
source.pushLocation(location) source.pushLocation(location)
expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith([[LocationKey, location]]) expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
}) })
}) })

View File

@@ -1,11 +1,11 @@
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { Context, UnknownActionError, contextKey, type ContextKey } from "@aris/core" import { UnknownActionError, contextKey, type ContextKey } from "@aris/core"
import { type } from "arktype" import { type } from "arktype"
import { Location, type LocationSourceOptions } from "./types.ts" import { Location, type LocationSourceOptions } from "./types.ts"
export const LocationKey: ContextKey<Location> = contextKey("aris.location", "location") export const LocationKey: ContextKey<Location> = contextKey("location")
/** /**
* A FeedSource that provides location context. * A FeedSource that provides location context.
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
private readonly historySize: number private readonly historySize: number
private locations: Location[] = [] private locations: Location[] = []
private listeners = new Set<(entries: readonly ContextEntry[]) => void>() private listeners = new Set<(update: Partial<Context>) => void>()
constructor(options: LocationSourceOptions = {}) { constructor(options: LocationSourceOptions = {}) {
this.historySize = options.historySize ?? 1 this.historySize = options.historySize ?? 1
@@ -59,9 +59,8 @@ export class LocationSource implements FeedSource {
if (this.locations.length > this.historySize) { if (this.locations.length > this.historySize) {
this.locations.shift() this.locations.shift()
} }
const entries: readonly ContextEntry[] = [[LocationKey, location]]
this.listeners.forEach((listener) => { this.listeners.forEach((listener) => {
listener(entries) listener({ [LocationKey]: location })
}) })
} }
@@ -79,16 +78,16 @@ export class LocationSource implements FeedSource {
return this.locations return this.locations
} }
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void { onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
this.listeners.add(callback) this.listeners.add(callback)
return () => { return () => {
this.listeners.delete(callback) this.listeners.delete(callback)
} }
} }
async fetchContext(): Promise<readonly ContextEntry[] | null> { async fetchContext(): Promise<Partial<Context> | null> {
if (this.lastLocation) { if (this.lastLocation) {
return [[LocationKey, this.lastLocation]] return { [LocationKey]: this.lastLocation }
} }
return null return null
} }

View File

@@ -1,13 +1,12 @@
export { TflSource } from "./tfl-source.ts" export { TflSource } from "./tfl-source.ts"
export { TflApi } from "./tfl-api.ts" export { TflApi } from "./tfl-api.ts"
export type { TflLineId } from "./tfl-api.ts" export type { TflLineId } from "./tfl-api.ts"
export { export type {
TflFeedItemType, ITflApi,
type ITflApi, StationLocation,
type StationLocation, TflAlertData,
type TflAlertData, TflAlertFeedItem,
type TflAlertFeedItem, TflAlertSeverity,
type TflAlertSeverity, TflLineStatus,
type TflLineStatus, TflSourceOptions,
type TflSourceOptions,
} from "./types.ts" } from "./types.ts"

View File

@@ -1,4 +1,5 @@
import { Context } from "@aris/core" import type { Context } from "@aris/core"
import { LocationKey, type Location } from "@aris/source-location" import { LocationKey, type Location } from "@aris/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -80,9 +81,9 @@ class FixtureTflApi implements ITflApi {
} }
function createContext(location?: Location): Context { function createContext(location?: Location): Context {
const ctx = new Context(new Date("2026-01-15T12:00:00Z")) const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
if (location) { if (location) {
ctx.set([[LocationKey, location]]) ctx[LocationKey] = location
} }
return ctx return ctx
} }
@@ -183,8 +184,7 @@ describe("TflSource", () => {
expect(typeof item.id).toBe("string") expect(typeof item.id).toBe("string")
expect(item.id).toMatch(/^tfl-alert-/) expect(item.id).toMatch(/^tfl-alert-/)
expect(item.type).toBe("tfl-alert") expect(item.type).toBe("tfl-alert")
expect(item.signals).toBeDefined() expect(typeof item.priority).toBe("number")
expect(typeof item.signals!.urgency).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date) expect(item.timestamp).toBeInstanceOf(Date)
} }
}) })
@@ -220,29 +220,29 @@ describe("TflSource", () => {
expect(uniqueIds.size).toBe(ids.length) expect(uniqueIds.size).toBe(ids.length)
}) })
test("feed items are sorted by urgency descending", async () => { test("feed items are sorted by priority descending", 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())
for (let i = 1; i < items.length; i++) { for (let i = 1; i < items.length; i++) {
const prev = items[i - 1]! const prev = items[i - 1]!
const curr = items[i]! const curr = items[i]!
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!) expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
} }
}) })
test("urgency values match severity levels", async () => { test("priority values match severity levels", 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())
const severityUrgency: Record<string, number> = { const severityPriority: Record<string, number> = {
closure: 1.0, closure: 1.0,
"major-delays": 0.8, "major-delays": 0.8,
"minor-delays": 0.6, "minor-delays": 0.6,
} }
for (const item of items) { for (const item of items) {
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!) expect(item.priority).toBe(severityPriority[item.data.severity]!)
} }
}) })
@@ -316,7 +316,9 @@ describe("TflSource", () => {
test("executeAction throws on invalid input", async () => { test("executeAction throws on invalid input", async () => {
const source = new TflSource({ client: api }) const source = new TflSource({ client: api })
await expect(source.executeAction("set-lines-of-interest", "not-an-array")).rejects.toThrow() await expect(
source.executeAction("set-lines-of-interest", "not-an-array"),
).rejects.toThrow()
}) })
test("executeAction throws for unknown action", async () => { test("executeAction throws for unknown action", async () => {

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core" import { UnknownActionError, contextValue } from "@aris/core"
import { LocationKey } from "@aris/source-location" import { LocationKey } from "@aris/source-location"
import { type } from "arktype" import { type } from "arktype"
@@ -15,22 +15,15 @@ import type {
} from "./types.ts" } from "./types.ts"
import { TflApi, lineId } from "./tfl-api.ts" import { TflApi, lineId } from "./tfl-api.ts"
import { TflFeedItemType } from "./types.ts"
const setLinesInput = lineId.array() const setLinesInput = lineId.array()
const SEVERITY_URGENCY: Record<TflAlertSeverity, number> = { const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
closure: 1.0, closure: 1.0,
"major-delays": 0.8, "major-delays": 0.8,
"minor-delays": 0.6, "minor-delays": 0.6,
} }
const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
closure: TimeRelevance.Imminent,
"major-delays": TimeRelevance.Imminent,
"minor-delays": TimeRelevance.Upcoming,
}
/** /**
* A FeedSource that provides TfL (Transport for London) service alerts. * A FeedSource that provides TfL (Transport for London) service alerts.
* *
@@ -112,7 +105,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
} }
} }
async fetchContext(): Promise<readonly ContextEntry[] | null> { async fetchContext(): Promise<null> {
return null return null
} }
@@ -129,7 +122,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
this.client.fetchStations(), this.client.fetchStations(),
]) ])
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
const items: TflAlertFeedItem[] = statuses.map((status) => { const items: TflAlertFeedItem[] = statuses.map((status) => {
const closestStationDistance = location const closestStationDistance = location
@@ -144,26 +137,19 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
closestStationDistance, closestStationDistance,
} }
const signals: FeedItemSignals = {
urgency: SEVERITY_URGENCY[status.severity],
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
}
return { return {
id: `tfl-alert-${status.lineId}-${status.severity}`, id: `tfl-alert-${status.lineId}-${status.severity}`,
type: TflFeedItemType.Alert, type: "tfl-alert",
priority: SEVERITY_PRIORITY[status.severity],
timestamp: context.time, timestamp: context.time,
data, data,
signals,
} }
}) })
// Sort by urgency (desc), then by proximity (asc) if location available // Sort by severity (desc), then by proximity (asc) if location available
items.sort((a, b) => { items.sort((a, b) => {
const aUrgency = a.signals?.urgency ?? 0 if (b.priority !== a.priority) {
const bUrgency = b.signals?.urgency ?? 0 return b.priority - a.priority
if (bUrgency !== aUrgency) {
return bUrgency - aUrgency
} }
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) { if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
return a.data.closestStationDistance - b.data.closestStationDistance return a.data.closestStationDistance - b.data.closestStationDistance

View File

@@ -20,13 +20,7 @@ export interface TflAlertData extends Record<string, unknown> {
closestStationDistance: number | null closestStationDistance: number | null
} }
export const TflFeedItemType = { export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
Alert: "tfl-alert",
} as const
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
export interface TflSourceOptions { export interface TflSourceOptions {
apiKey?: string apiKey?: string

View File

@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit" import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
export const WeatherFeedItemType = { export const WeatherFeedItemType = {
Current: "weather-current", current: "weather-current",
Hourly: "weather-hourly", hourly: "weather-hourly",
Daily: "weather-daily", daily: "weather-daily",
Alert: "weather-alert", alert: "weather-alert",
} as const } as const
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType] export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
} }
export interface CurrentWeatherFeedItem extends FeedItem< export interface CurrentWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Current, typeof WeatherFeedItemType.current,
CurrentWeatherData CurrentWeatherData
> {} > {}
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
} }
export interface HourlyWeatherFeedItem extends FeedItem< export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Hourly, typeof WeatherFeedItemType.hourly,
HourlyWeatherData HourlyWeatherData
> {} > {}
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
} }
export interface DailyWeatherFeedItem extends FeedItem< export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily, typeof WeatherFeedItemType.daily,
DailyWeatherData DailyWeatherData
> {} > {}
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
} }
export interface WeatherAlertFeedItem extends FeedItem< export interface WeatherAlertFeedItem extends FeedItem<
typeof WeatherFeedItemType.Alert, typeof WeatherFeedItemType.alert,
WeatherAlertData WeatherAlertData
> {} > {}

View File

@@ -1,8 +1,14 @@
export { WeatherKey, type Weather } from "./weather-context" export { WeatherKey, type Weather } from "./weather-context"
export { WeatherSource, Units, type WeatherSourceOptions } from "./weather-source" export {
WeatherSource,
Units,
type Units as UnitsType,
type WeatherSourceOptions,
} from "./weather-source"
export { export {
WeatherFeedItemType, WeatherFeedItemType,
type WeatherFeedItemType as WeatherFeedItemTypeType,
type WeatherFeedItem, type WeatherFeedItem,
type CurrentWeatherFeedItem, type CurrentWeatherFeedItem,
type CurrentWeatherData, type CurrentWeatherData,
@@ -21,6 +27,11 @@ export {
Certainty, Certainty,
PrecipitationType, PrecipitationType,
DefaultWeatherKitClient, DefaultWeatherKitClient,
type ConditionCode as ConditionCodeType,
type Severity as SeverityType,
type Urgency as UrgencyType,
type Certainty as CertaintyType,
type PrecipitationType as PrecipitationTypeType,
type WeatherKitClient, type WeatherKitClient,
type WeatherKitCredentials, type WeatherKitCredentials,
type WeatherKitQueryOptions, type WeatherKitQueryOptions,

View File

@@ -24,4 +24,4 @@ export interface Weather {
daylight: boolean daylight: boolean
} }
export const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "weather") export const WeatherKey: ContextKey<Weather> = contextKey("weather")

View File

@@ -1,6 +1,4 @@
import type { FeedSource } from "@aris/core" import { contextValue, type Context } from "@aris/core"
import { Context } from "@aris/core"
import { LocationKey } from "@aris/source-location" import { LocationKey } from "@aris/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -8,7 +6,7 @@ import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json" import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType } from "./feed-items" import { WeatherFeedItemType } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey } from "./weather-context"
import { WeatherSource, Units } from "./weather-source" import { WeatherSource, Units } from "./weather-source"
const mockCredentials = { const mockCredentials = {
@@ -25,9 +23,9 @@ function createMockClient(response: WeatherKitResponse): WeatherKitClient {
} }
function createMockContext(location?: { lat: number; lng: number }): Context { function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx = new Context(new Date("2026-01-17T00:00:00Z")) const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
if (location) { if (location) {
ctx.set([[LocationKey, { ...location, accuracy: 10, timestamp: new Date() }]]) ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
} }
return ctx return ctx
} }
@@ -65,19 +63,18 @@ describe("WeatherSource", () => {
const source = new WeatherSource({ client: mockClient }) const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const entries = await source.fetchContext(context) const result = await source.fetchContext(context)
expect(entries).not.toBeNull() expect(result).not.toBeNull()
expect(entries).toHaveLength(1) const weather = contextValue(result! as Context, WeatherKey)
const [key, weather] = entries![0]! as [typeof WeatherKey, Weather] expect(weather).toBeDefined()
expect(key).toEqual(WeatherKey) expect(typeof weather!.temperature).toBe("number")
expect(typeof weather.temperature).toBe("number") expect(typeof weather!.temperatureApparent).toBe("number")
expect(typeof weather.temperatureApparent).toBe("number") expect(typeof weather!.condition).toBe("string")
expect(typeof weather.condition).toBe("string") expect(typeof weather!.humidity).toBe("number")
expect(typeof weather.humidity).toBe("number") expect(typeof weather!.uvIndex).toBe("number")
expect(typeof weather.uvIndex).toBe("number") expect(typeof weather!.windSpeed).toBe("number")
expect(typeof weather.windSpeed).toBe("number") expect(typeof weather!.daylight).toBe("boolean")
expect(typeof weather.daylight).toBe("boolean")
}) })
test("converts temperature to imperial", async () => { test("converts temperature to imperial", async () => {
@@ -87,12 +84,12 @@ describe("WeatherSource", () => {
}) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const entries = await source.fetchContext(context) const result = await source.fetchContext(context)
expect(entries).not.toBeNull() expect(result).not.toBeNull()
const weather = contextValue(result! as Context, WeatherKey)
const [, weather] = entries![0]! as [typeof WeatherKey, Weather]
// Fixture has temperature around 10°C, imperial should be around 50°F // Fixture has temperature around 10°C, imperial should be around 50°F
expect(weather.temperature).toBeGreaterThan(40) expect(weather!.temperature).toBeGreaterThan(40)
}) })
}) })
@@ -113,9 +110,9 @@ describe("WeatherSource", () => {
const items = await source.fetchItems(context) const items = await source.fetchItems(context)
expect(items.length).toBeGreaterThan(0) expect(items.length).toBeGreaterThan(0)
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true) expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true) expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true) expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
}) })
test("applies hourly and daily limits", async () => { test("applies hourly and daily limits", async () => {
@@ -128,8 +125,8 @@ describe("WeatherSource", () => {
const items = await source.fetchItems(context) const items = await source.fetchItems(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly) const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily) const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourlyItems.length).toBe(3) expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2) expect(dailyItems.length).toBe(2)
@@ -148,22 +145,20 @@ describe("WeatherSource", () => {
} }
}) })
test("assigns signals based on weather conditions", async () => { test("assigns priority based on weather conditions", async () => {
const source = new WeatherSource({ client: mockClient }) const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context) const items = await source.fetchItems(context)
for (const item of items) { for (const item of items) {
expect(item.signals).toBeDefined() expect(item.priority).toBeGreaterThanOrEqual(0)
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0) expect(item.priority).toBeLessThanOrEqual(1)
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
expect(item.signals!.timeRelevance).toBeDefined()
} }
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current) const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined() expect(currentItem).toBeDefined()
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5) expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
}) })
test("generates unique IDs for each item", async () => { test("generates unique IDs for each item", async () => {
@@ -180,12 +175,12 @@ describe("WeatherSource", () => {
describe("no reactive methods", () => { describe("no reactive methods", () => {
test("does not implement onContextUpdate", () => { test("does not implement onContextUpdate", () => {
const source: FeedSource = new WeatherSource({ credentials: mockCredentials }) const source = new WeatherSource({ credentials: mockCredentials })
expect(source.onContextUpdate).toBeUndefined() expect(source.onContextUpdate).toBeUndefined()
}) })
test("does not implement onItemsUpdate", () => { test("does not implement onItemsUpdate", () => {
const source: FeedSource = new WeatherSource({ credentials: mockCredentials }) const source = new WeatherSource({ credentials: mockCredentials })
expect(source.onItemsUpdate).toBeUndefined() expect(source.onItemsUpdate).toBeUndefined()
}) })
}) })

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core" import { UnknownActionError, contextValue } from "@aris/core"
import { LocationKey } from "@aris/source-location" import { LocationKey } from "@aris/source-location"
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
@@ -38,7 +38,7 @@ export interface WeatherSourceOptions {
const DEFAULT_HOURLY_LIMIT = 12 const DEFAULT_HOURLY_LIMIT = 12
const DEFAULT_DAILY_LIMIT = 7 const DEFAULT_DAILY_LIMIT = 7
const BASE_URGENCY = { const BASE_PRIORITY = {
current: 0.5, current: 0.5,
hourly: 0.3, hourly: 0.3,
daily: 0.2, daily: 0.2,
@@ -86,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
* }) * })
* *
* // Access weather context in downstream sources * // Access weather context in downstream sources
* const weather = context.get(WeatherKey) * const weather = contextValue(context, WeatherKey)
* if (weather?.condition === "Rain") { * if (weather?.condition === "Rain") {
* // suggest umbrella * // suggest umbrella
* } * }
@@ -119,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
throw new UnknownActionError(actionId) throw new UnknownActionError(actionId)
} }
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> { async fetchContext(context: Context): Promise<Partial<Context> | null> {
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
if (!location) { if (!location) {
return null return null
} }
@@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
daylight: response.currentWeather.daylight, daylight: response.currentWeather.daylight,
} }
return [[WeatherKey, weather]] return { [WeatherKey]: weather }
} }
async fetchItems(context: Context): Promise<WeatherFeedItem[]> { async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
if (!location) { if (!location) {
return [] return []
} }
@@ -199,17 +199,17 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
} }
} }
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number { function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
if (SEVERE_CONDITIONS.has(conditionCode)) { if (SEVERE_CONDITIONS.has(conditionCode)) {
return Math.min(1, baseUrgency + 0.3) return Math.min(1, basePriority + 0.3)
} }
if (MODERATE_CONDITIONS.has(conditionCode)) { if (MODERATE_CONDITIONS.has(conditionCode)) {
return Math.min(1, baseUrgency + 0.15) return Math.min(1, basePriority + 0.15)
} }
return baseUrgency return basePriority
} }
function adjustUrgencyForAlertSeverity(severity: Severity): number { function adjustPriorityForAlertSeverity(severity: Severity): number {
switch (severity) { switch (severity) {
case "extreme": case "extreme":
return 1 return 1
@@ -218,29 +218,7 @@ function adjustUrgencyForAlertSeverity(severity: Severity): number {
case "moderate": case "moderate":
return 0.75 return 0.75
case "minor": case "minor":
return BASE_URGENCY.alert return BASE_PRIORITY.alert
}
}
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
if (SEVERE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Imminent
}
if (MODERATE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Upcoming
}
return TimeRelevance.Ambient
}
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
switch (severity) {
case "extreme":
case "severe":
return TimeRelevance.Imminent
case "moderate":
return TimeRelevance.Upcoming
case "minor":
return TimeRelevance.Ambient
} }
} }
@@ -284,14 +262,12 @@ function createCurrentWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
timeRelevance: timeRelevanceForCondition(current.conditionCode),
}
return { return {
id: `weather-current-${timestamp.getTime()}`, id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.Current, type: WeatherFeedItemType.current,
priority,
timestamp, timestamp,
data: { data: {
conditionCode: current.conditionCode, conditionCode: current.conditionCode,
@@ -308,7 +284,6 @@ function createCurrentWeatherFeedItem(
windGust: convertSpeed(current.windGust, units), windGust: convertSpeed(current.windGust, units),
windSpeed: convertSpeed(current.windSpeed, units), windSpeed: convertSpeed(current.windSpeed, units),
}, },
signals,
} }
} }
@@ -318,14 +293,12 @@ function createHourlyWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
}
return { return {
id: `weather-hourly-${timestamp.getTime()}-${index}`, id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Hourly, type: WeatherFeedItemType.hourly,
priority,
timestamp, timestamp,
data: { data: {
forecastTime: new Date(hourly.forecastStart), forecastTime: new Date(hourly.forecastStart),
@@ -342,7 +315,6 @@ function createHourlyWeatherFeedItem(
windGust: convertSpeed(hourly.windGust, units), windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units), windSpeed: convertSpeed(hourly.windSpeed, units),
}, },
signals,
} }
} }
@@ -352,14 +324,12 @@ function createDailyWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
}
return { return {
id: `weather-daily-${timestamp.getTime()}-${index}`, id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Daily, type: WeatherFeedItemType.daily,
priority,
timestamp, timestamp,
data: { data: {
forecastDate: new Date(daily.forecastStart), forecastDate: new Date(daily.forecastStart),
@@ -374,19 +344,16 @@ function createDailyWeatherFeedItem(
temperatureMax: convertTemperature(daily.temperatureMax, units), temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units), temperatureMin: convertTemperature(daily.temperatureMin, units),
}, },
signals,
} }
} }
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem { function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
const signals: FeedItemSignals = { const priority = adjustPriorityForAlertSeverity(alert.severity)
urgency: adjustUrgencyForAlertSeverity(alert.severity),
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
}
return { return {
id: `weather-alert-${alert.id}`, id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.Alert, type: WeatherFeedItemType.alert,
priority,
timestamp, timestamp,
data: { data: {
alertId: alert.id, alertId: alert.id,
@@ -400,6 +367,5 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
source: alert.source, source: alert.source,
urgency: alert.urgency, urgency: alert.urgency,
}, },
signals,
} }
} }