mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
9 Commits
3d492a5d56
...
feat/post-
| Author | SHA1 | Date | |
|---|---|---|---|
|
013bf46e1f
|
|||
| 40ad90aa2d | |||
| 82ac2b577d | |||
| ffea38b986 | |||
| 28d26b3c87 | |||
| 78b0ed94bd | |||
| ee957ea7b1 | |||
| 6ae0ad1d40 | |||
|
941acb826c
|
@@ -1,4 +1,4 @@
|
||||
import type { Context, Next } from "hono"
|
||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||
|
||||
import type { AuthSession, AuthUser } from "./session.ts"
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface SessionVariables {
|
||||
session: AuthSession | null
|
||||
}
|
||||
|
||||
export type AuthSessionEnv = { Variables: SessionVariables }
|
||||
|
||||
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap extends SessionVariables {}
|
||||
}
|
||||
@@ -55,3 +59,18 @@ export async function getSessionFromHeaders(
|
||||
const session = await auth.api.getSession({ headers })
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only middleware that injects a fake user and session.
|
||||
* Pass userId to simulate an authenticated request, or omit to get 401.
|
||||
*/
|
||||
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||
if (!userId) {
|
||||
return c.json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
c.set("user", { id: userId } as AuthUser)
|
||||
c.set("session", { id: "mock-session" } as AuthSession)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
|
||||
140
apps/aris-backend/src/feed/http.test.ts
Normal file
140
apps/aris-backend/src/feed/http.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import { UserSessionManager } from "../session/index.ts"
|
||||
import { registerFeedHttpHandlers } from "./http.ts"
|
||||
|
||||
interface FeedResponse {
|
||||
items: Array<{
|
||||
id: string
|
||||
type: string
|
||||
priority: number
|
||||
timestamp: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
errors: Array<{ sourceId: string; error: string }>
|
||||
}
|
||||
|
||||
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||
return {
|
||||
id,
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
},
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<Partial<Context> | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||
const app = new Hono()
|
||||
registerFeedHttpHandlers(app, {
|
||||
sessionManager,
|
||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
describe("GET /api/feed", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
const manager = new UserSessionManager([])
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("returns cached feed when available", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
type: "test",
|
||||
priority: 0.8,
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// Prime the cache
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.refresh()
|
||||
expect(session.engine.lastFeed()).not.toBeNull()
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("item-1")
|
||||
expect(body.items[0]!.type).toBe("test")
|
||||
expect(body.items[0]!.priority).toBe(0.8)
|
||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("forces refresh when no cached feed", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "fresh-1",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||
data: { fresh: true },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("fresh-1")
|
||||
expect(body.items[0]!.data.fresh).toBe(true)
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("serializes source errors as message strings", async () => {
|
||||
const failingSource: FeedSource = {
|
||||
id: "failing",
|
||||
async listActions() {
|
||||
return {}
|
||||
},
|
||||
async executeAction() {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
throw new Error("connection timeout")
|
||||
},
|
||||
}
|
||||
const manager = new UserSessionManager([() => failingSource])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(0)
|
||||
expect(body.errors).toHaveLength(1)
|
||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
||||
})
|
||||
})
|
||||
41
apps/aris-backend/src/feed/http.ts
Normal file
41
apps/aris-backend/src/feed/http.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
interface FeedHttpHandlersDeps {
|
||||
sessionManager: UserSessionManager
|
||||
authSessionMiddleware: AuthSessionMiddleware
|
||||
}
|
||||
|
||||
export function registerFeedHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||
}
|
||||
|
||||
async function handleGetFeed(c: Context<Env>) {
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
|
||||
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
|
||||
|
||||
return c.json({
|
||||
items: feed.items,
|
||||
errors: feed.errors.map((e) => ({
|
||||
sourceId: e.sourceId,
|
||||
error: e.error.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { LocationSource } from "@aris/source-location"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { registerAuthHandlers } from "./auth/http.ts"
|
||||
import { requireSession } from "./auth/session-middleware.ts"
|
||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
@@ -24,6 +26,7 @@ function main() {
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
registerAuthHandlers(app)
|
||||
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
|
||||
registerLocationHttpHandlers(app, { sessionManager })
|
||||
|
||||
return app
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -89,8 +89,8 @@
|
||||
"arktype": "^2.1.0",
|
||||
},
|
||||
},
|
||||
"packages/aris-source-apple-calendar": {
|
||||
"name": "@aris/source-apple-calendar",
|
||||
"packages/aris-source-caldav": {
|
||||
"name": "@aris/source-caldav",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||
|
||||
"@aris/source-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
|
||||
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
|
||||
|
||||
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@ Examples of feed items:
|
||||
## Design Principles
|
||||
|
||||
1. **Extensibility**: The core must support different data sources, including third-party sources.
|
||||
2. **Separation of concerns**: Core handles data only. UI rendering is a separate system.
|
||||
3. **Parallel execution**: Sources run in parallel; no inter-source dependencies.
|
||||
2. **Separation of concerns**: Core handles data and UI description. The client is a thin renderer.
|
||||
3. **Dependency graph**: Sources declare dependencies on other sources. The engine resolves the graph and runs independent sources in parallel.
|
||||
4. **Graceful degradation**: Failed sources are skipped; partial results are returned.
|
||||
|
||||
## Architecture
|
||||
@@ -25,26 +25,28 @@ Examples of feed items:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Backend │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ aris-core │ │ Sources │ │ UI Registry │ │
|
||||
│ │ │ │ (plugins) │ │ (schemas from │ │
|
||||
│ │ - Reconciler│◄───│ - Calendar │ │ third parties)│ │
|
||||
│ │ - Context │ │ - Weather │ │ │ │
|
||||
│ │ - FeedItem │ │ - Spotify │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ Feed (data only) UI Schemas (JSON) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ aris-core │ │ Sources │ │
|
||||
│ │ │ │ (plugins) │ │
|
||||
│ │ - FeedEngine│◄───│ - Calendar │ │
|
||||
│ │ - Context │ │ - Weather │ │
|
||||
│ │ - FeedItem │ │ - TfL │ │
|
||||
│ │ - Actions │ │ - Spotify │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Feed items (data + ui trees + slots) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
│
|
||||
▼ (WebSocket / JSON-RPC)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
│ Client (React Native) │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Renderer │ │
|
||||
│ │ - Receives feed items │ │
|
||||
│ │ - Fetches UI schema by item type │ │
|
||||
│ │ - Renders using json-render or similar │ │
|
||||
│ │ json-render + twrnc component map │ │
|
||||
│ │ - Receives feed items with ui trees │ │
|
||||
│ │ - Renders using registered RN components + twrnc │ │
|
||||
│ │ - User interactions trigger source actions │ │
|
||||
│ │ - Bespoke native components for rich interactions │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -54,15 +56,16 @@ Examples of feed items:
|
||||
The core is responsible for:
|
||||
|
||||
- Defining the context and feed item interfaces
|
||||
- Providing a reconciler that orchestrates data sources
|
||||
- Providing a `FeedEngine` that orchestrates sources via a dependency graph
|
||||
- Returning a flat list of prioritized feed items
|
||||
- Routing action execution to the correct source
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Context**: Time and location (with accuracy) passed to all sources
|
||||
- **FeedItem**: Has an ID (source-generated, stable), type, priority, timestamp, and JSON-serializable data
|
||||
- **DataSource**: Interface that third parties implement to provide feed items
|
||||
- **Reconciler**: Orchestrates sources, runs them in parallel, returns items and any errors
|
||||
- **Context**: Time and location (with accuracy) passed to all sources. Sources can contribute to context (e.g., location source provides coordinates, weather source provides conditions).
|
||||
- **FeedItem**: Has an ID (source-generated, stable), type, timestamp, JSON-serializable data, optional actions, an optional `ui` tree, and optional `slots` for LLM-fillable content.
|
||||
- **FeedSource**: Interface that first and third parties implement to provide context, feed items, and actions. Uses reverse-domain IDs (e.g., `aris.weather`, `com.spotify`).
|
||||
- **FeedEngine**: Orchestrates sources respecting their dependency graph, runs independent sources in parallel, returns items and any errors. Routes action execution to the correct source.
|
||||
|
||||
## Data Sources
|
||||
|
||||
@@ -71,10 +74,13 @@ Key decisions:
|
||||
- Sources receive the full context and decide internally what to use
|
||||
- Each source returns a single item type (e.g., separate "Calendar Source" and "Location Suggestion Source" rather than a combined "Google Source")
|
||||
- Sources live in separate packages, not in the core
|
||||
- Sources declare dependencies on other sources (e.g., weather depends on location)
|
||||
- Sources are responsible for:
|
||||
- Transforming their domain data into feed items
|
||||
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
|
||||
- Returning empty arrays when nothing is relevant
|
||||
- Providing a `ui` tree for each feed item
|
||||
- Declaring and handling actions (e.g., RSVP, complete task, play/pause)
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -83,28 +89,323 @@ Configuration is passed at source registration time, not per reconcile call. Sou
|
||||
## Feed Output
|
||||
|
||||
- Flat list of `FeedItem` objects
|
||||
- No UI information (no icons, card types, etc.)
|
||||
- Items carry data, an optional `ui` field describing their layout, and optional `slots` for LLM enhancement
|
||||
- Items are a discriminated union by `type` field
|
||||
- Reconciler sorts by priority; can act as tiebreaker
|
||||
|
||||
## UI Rendering (Separate from Core)
|
||||
## UI Rendering: Server-Driven UI
|
||||
|
||||
The core does not handle UI. For extensible third-party UI:
|
||||
The UI for feed items is **server-driven**. Sources describe how their items look using a JSON tree (the `ui` field on `FeedItem`). The client renders these trees using [json-render](https://json-render.dev/) with a registered set of React Native components styled via [twrnc](https://github.com/jaredh159/tailwind-react-native-classnames).
|
||||
|
||||
1. Third-party apps register their UI schemas through the backend (UI Registry)
|
||||
2. Frontend fetches UI schemas from the backend
|
||||
3. Frontend matches feed items to schemas by `type` and renders accordingly
|
||||
### How it works
|
||||
|
||||
This approach:
|
||||
1. Sources return feed items with a `ui` field — a JSON tree describing the card layout using Tailwind class strings.
|
||||
2. The client passes a component map to json-render. Each component wraps a React Native primitive and resolves `className` via twrnc.
|
||||
3. json-render walks the tree and renders native components. twrnc parses Tailwind classes at runtime — no build step, arbitrary values work.
|
||||
4. User interactions (tap, etc.) map to source actions via the `actions` field on `FeedItem`. The client sends action requests to the backend, which routes them to the correct source via `FeedEngine.executeAction()`.
|
||||
|
||||
- Keeps the core focused on data
|
||||
- Works across platforms (web, React Native)
|
||||
- Avoids the need for third parties to inject code into the app
|
||||
- Uses a json-render style approach for declarative UI from JSON schemas
|
||||
### Styling
|
||||
|
||||
Reference: https://github.com/vercel-labs/json-render
|
||||
- Sources use Tailwind CSS class strings via the `className` prop (e.g., `"p-4 bg-white dark:bg-black rounded-xl"`).
|
||||
- twrnc resolves classes to React Native style objects at runtime. Supports arbitrary values (`mt-[31px]`, `bg-[#eaeaea]`), dark mode (`dark:bg-black`), and platform prefixes (`ios:pt-4 android:pt-2`).
|
||||
- Custom colors and spacing are configured via `tailwind.config.js` on the client.
|
||||
- No compile-time constraint — all styles resolve at runtime.
|
||||
|
||||
### Two tiers of UI
|
||||
|
||||
- **Server-driven (default):** Any source can return a `ui` tree. Covers most cards — weather, tasks, alerts, package tracking, news, etc. Simple interactions go through source actions. This is the default path for both first-party and third-party sources.
|
||||
- **Bespoke native:** For cards that need rich client interaction (gestures, animations, real-time updates), a native React Native component is registered in the json-render component map and referenced by type. Third parties that need this level of richness work with the ARIS team to get it integrated.
|
||||
|
||||
### Why server-driven
|
||||
|
||||
- Feed items are inherently server-driven — the data comes from sources on the backend. Attaching the layout alongside the data is a natural extension.
|
||||
- Card designs can be updated without shipping an app update.
|
||||
- Third-party sources can ship their own UI without bundling anything new into the app.
|
||||
|
||||
Reference: https://json-render.dev/
|
||||
|
||||
## Feed Items with UI and Slots
|
||||
|
||||
> Note: the codebase has evolved since the sections above. The engine now uses a dependency graph with topological ordering (`FeedEngine`, `FeedSource`), not the parallel reconciler described above. The `priority` field is being replaced by post-processing (see the ideas doc). This section describes the UI and enhancement architecture going forward.
|
||||
|
||||
Feed items carry an optional `ui` field containing a json-render tree, and an optional `slots` field for LLM-fillable content.
|
||||
|
||||
```typescript
|
||||
interface FeedItem<TType, TData> {
|
||||
id: string
|
||||
type: TType
|
||||
timestamp: Date
|
||||
data: TData
|
||||
ui?: JsonRenderNode
|
||||
slots?: Record<string, Slot>
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
/** Tells the LLM what this slot wants — the source writes this */
|
||||
description: string
|
||||
/** LLM-filled text content, null until enhanced */
|
||||
content: string | null
|
||||
}
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
The source produces the item with a UI tree and empty slots:
|
||||
|
||||
```typescript
|
||||
// Weather source produces:
|
||||
{
|
||||
id: "weather-current-123",
|
||||
type: "weather-current",
|
||||
data: { temperature: 18, condition: "cloudy" },
|
||||
ui: {
|
||||
component: "VStack",
|
||||
children: [
|
||||
{ component: "WeatherHeader", props: { temp: 18, condition: "cloudy" } },
|
||||
{ component: "Slot", props: { name: "insight" } },
|
||||
{ component: "HourlyChart", props: { hours: [...] } },
|
||||
{ component: "Slot", props: { name: "cross-source" } },
|
||||
]
|
||||
},
|
||||
slots: {
|
||||
"insight": {
|
||||
description: "A short contextual insight about the current weather and how it affects the user's day",
|
||||
content: null
|
||||
},
|
||||
"cross-source": {
|
||||
description: "Connection between weather and the user's calendar events or plans",
|
||||
content: null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The LLM enhancement harness fills `content`:
|
||||
|
||||
```typescript
|
||||
slots: {
|
||||
"insight": {
|
||||
description: "...",
|
||||
content: "Rain after 3pm — grab a jacket before your walk"
|
||||
},
|
||||
"cross-source": {
|
||||
description: "...",
|
||||
content: "Should be dry by 7pm for your dinner at The Ivy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The client renders the `ui` tree. When it hits a `Slot` node, it looks up `slots[name].content`. If non-null, render the text. If null, render nothing.
|
||||
|
||||
### Separation of concerns
|
||||
|
||||
- **Sources** own the UI layout and declare what slots exist with descriptions.
|
||||
- **The LLM** fills slot content. It doesn't know about layout or positioning.
|
||||
- **The client** renders the UI tree and resolves slots to their content.
|
||||
|
||||
Sources define the prompt for each slot via the `description` field. The harness doesn't need to know what slots any source type has — it reads them dynamically from the items.
|
||||
|
||||
Each source defines its own slots. The harness handles them automatically — no central registry needed.
|
||||
|
||||
## Enhancement Harness
|
||||
|
||||
The LLM enhancement harness fills slots and produces synthetic feed items. It runs reactively — triggered by context changes, not by a timer.
|
||||
|
||||
### Execution model
|
||||
|
||||
```
|
||||
FeedEngine.refresh()
|
||||
→ sources produce items with ui + empty slots
|
||||
↓
|
||||
Fast path (rule-based post-processors, <10ms)
|
||||
→ group, dedup, affinity, time-adjust
|
||||
→ merge LAST cached slot fills + synthetic items
|
||||
→ return feed to UI immediately
|
||||
↓
|
||||
Background: has context changed since last LLM run?
|
||||
(hash of: item IDs + data + slot descriptions + user memory)
|
||||
↓
|
||||
No → done, cache is still valid
|
||||
Yes → run LLM harness async
|
||||
→ fill slots + generate synthetic items
|
||||
→ cache result
|
||||
→ push updated feed to UI via WebSocket
|
||||
```
|
||||
|
||||
The user never waits for the LLM. They see the feed instantly with the previous enhancement applied. If the LLM produces new slot content or synthetic items, the feed updates in place.
|
||||
|
||||
### LLM input
|
||||
|
||||
The harness serializes items with their unfilled slots into a single prompt. Items without slots are excluded. The LLM sees everything at once and fills whatever slots are relevant.
|
||||
|
||||
```typescript
|
||||
function buildHarnessInput(
|
||||
items: FeedItem[],
|
||||
context: AgentContext,
|
||||
): HarnessInput {
|
||||
const itemsWithSlots = items
|
||||
.filter(item => item.slots && Object.keys(item.slots).length > 0)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
data: item.data,
|
||||
slots: Object.fromEntries(
|
||||
Object.entries(item.slots!).map(
|
||||
([name, slot]) => [name, slot.description]
|
||||
)
|
||||
),
|
||||
}))
|
||||
|
||||
return {
|
||||
items: itemsWithSlots,
|
||||
userMemory: context.preferences,
|
||||
currentTime: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The LLM sees:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "weather-current-123",
|
||||
"type": "weather-current",
|
||||
"data": { "temperature": 18, "condition": "cloudy" },
|
||||
"slots": {
|
||||
"insight": "A short contextual insight about the current weather and how it affects the user's day",
|
||||
"cross-source": "Connection between weather and the user's calendar events or plans"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "calendar-event-456",
|
||||
"type": "calendar-event",
|
||||
"data": { "title": "Dinner at The Ivy", "startTime": "19:00", "location": "The Ivy, West St" },
|
||||
"slots": {
|
||||
"context": "Background on this event, attendees, or previous meetings with these people",
|
||||
"logistics": "Travel time, parking, directions to the venue",
|
||||
"weather": "Weather conditions relevant to this event's time and location"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userMemory": { "commute": "victoria-line", "preference.walking_distance": "1 mile" },
|
||||
"currentTime": "2025-02-26T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### LLM output
|
||||
|
||||
A flat map of item ID → slot name → text content. Slots left null are unfilled.
|
||||
|
||||
```json
|
||||
{
|
||||
"slotFills": {
|
||||
"weather-current-123": {
|
||||
"insight": "Rain after 3pm — grab a jacket before your walk",
|
||||
"cross-source": "Should be dry by 7pm for your dinner at The Ivy"
|
||||
},
|
||||
"calendar-event-456": {
|
||||
"context": null,
|
||||
"logistics": "20-minute walk from home — leave by 18:40",
|
||||
"weather": "Rain clears by evening, you'll be fine"
|
||||
}
|
||||
},
|
||||
"syntheticItems": [
|
||||
{
|
||||
"id": "briefing-morning",
|
||||
"type": "briefing",
|
||||
"data": {},
|
||||
"ui": { "component": "Text", "props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } }
|
||||
}
|
||||
],
|
||||
"suppress": [],
|
||||
"rankingHints": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Enhancement manager
|
||||
|
||||
One per user, living in the `FeedEngineManager` on the backend:
|
||||
|
||||
```typescript
|
||||
class EnhancementManager {
|
||||
private cache: EnhancementResult | null = null
|
||||
private lastInputHash: string | null = null
|
||||
private running = false
|
||||
|
||||
async enhance(
|
||||
items: FeedItem[],
|
||||
context: AgentContext,
|
||||
): Promise<EnhancementResult> {
|
||||
const hash = computeHash(items, context)
|
||||
|
||||
if (hash === this.lastInputHash && this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
if (this.running) {
|
||||
return this.cache ?? emptyResult()
|
||||
}
|
||||
|
||||
this.running = true
|
||||
this.runHarness(items, context)
|
||||
.then(result => {
|
||||
this.cache = result
|
||||
this.lastInputHash = hash
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
.finally(() => { this.running = false })
|
||||
|
||||
return this.cache ?? emptyResult()
|
||||
}
|
||||
}
|
||||
|
||||
interface EnhancementResult {
|
||||
slotFills: Record<string, Record<string, string | null>>
|
||||
syntheticItems: FeedItem[]
|
||||
suppress: string[]
|
||||
rankingHints: Record<string, number>
|
||||
}
|
||||
```
|
||||
|
||||
### Merging
|
||||
|
||||
After the harness runs, the engine merges slot fills into items:
|
||||
|
||||
```typescript
|
||||
function mergeEnhancement(
|
||||
items: FeedItem[],
|
||||
result: EnhancementResult,
|
||||
): FeedItem[] {
|
||||
return items.map(item => {
|
||||
const fills = result.slotFills[item.id]
|
||||
if (!fills || !item.slots) return item
|
||||
|
||||
const mergedSlots = { ...item.slots }
|
||||
for (const [name, content] of Object.entries(fills)) {
|
||||
if (name in mergedSlots && content !== null) {
|
||||
mergedSlots[name] = { ...mergedSlots[name], content }
|
||||
}
|
||||
}
|
||||
|
||||
return { ...item, slots: mergedSlots }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Cost control
|
||||
|
||||
- **Hash-based cache gate.** Most refreshes reuse the cached result.
|
||||
- **Debounce.** Rapid context changes (location updates) settle before triggering a run.
|
||||
- **Skip inactive users.** Don't run if the user hasn't opened the app in 2+ hours.
|
||||
- **Exclude slotless items.** Only items with slots are sent to the LLM.
|
||||
- **Text-only output.** Slots produce strings, not UI trees — fewer output tokens, less variance.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Exact schema format for UI registry
|
||||
- How third parties authenticate/register their sources and UI schemas
|
||||
- How third parties authenticate/register their sources
|
||||
- Exact set of React Native components exposed in the json-render component map
|
||||
- Validation/sandboxing of third-party ui trees
|
||||
- How synthetic items define their UI (full json-render tree vs. registered component)
|
||||
- Should slots support rich content (json-render nodes) in the future, or stay text-only?
|
||||
- How to handle slot content that references other items (e.g., "your dinner at The Ivy" linking to the calendar card)
|
||||
|
||||
@@ -125,7 +125,7 @@ interface FeedSource<TItem extends FeedItem = FeedItem> {
|
||||
|
||||
### Changes to FeedItem
|
||||
|
||||
One optional field added.
|
||||
Optional fields added for actions, server-driven UI, and LLM slots.
|
||||
|
||||
```typescript
|
||||
interface FeedItem<
|
||||
@@ -140,6 +140,12 @@ interface FeedItem<
|
||||
|
||||
/** Actions the user can take on this item. */
|
||||
actions?: readonly ItemAction[]
|
||||
|
||||
/** Server-driven UI tree rendered by json-render on the client. */
|
||||
ui?: JsonRenderNode
|
||||
|
||||
/** Named slots for LLM-fillable content. See architecture-draft.md. */
|
||||
slots?: Record<string, Slot>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -222,6 +228,25 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
||||
{ actionId: "skip-track" },
|
||||
{ actionId: "like-track", params: { trackId: track.id } },
|
||||
],
|
||||
ui: {
|
||||
type: "View",
|
||||
className: "flex-row items-center p-3 gap-3 bg-white dark:bg-black rounded-xl",
|
||||
children: [
|
||||
{
|
||||
type: "Image",
|
||||
source: { uri: track.albumArt },
|
||||
className: "w-12 h-12 rounded-lg",
|
||||
},
|
||||
{
|
||||
type: "View",
|
||||
className: "flex-1",
|
||||
children: [
|
||||
{ type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
|
||||
{ type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -236,6 +261,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
||||
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
|
||||
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
|
||||
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
|
||||
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
|
||||
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
|
||||
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
|
||||
8. `FeedEngine.listActions()` aggregates actions from all sources
|
||||
9. Existing tests pass unchanged (all changes are additive)
|
||||
|
||||
@@ -18,9 +18,9 @@ import type { FeedItem } from "./feed"
|
||||
* return [{
|
||||
* id: `weather-${Date.now()}`,
|
||||
* type: this.type,
|
||||
* priority: 0.5,
|
||||
* timestamp: context.time,
|
||||
* data: { temp: data.temperature },
|
||||
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
|
||||
import { FeedEngine } from "./feed-engine"
|
||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
||||
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||
|
||||
// No-op action methods for test sources
|
||||
const noActions = {
|
||||
@@ -100,12 +100,12 @@ function createWeatherSource(
|
||||
{
|
||||
id: `weather-${Date.now()}`,
|
||||
type: "weather",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {
|
||||
temperature: weather.temperature,
|
||||
condition: weather.condition,
|
||||
},
|
||||
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -131,9 +131,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
||||
{
|
||||
id: "alert-storm",
|
||||
type: "alert",
|
||||
priority: 1.0,
|
||||
timestamp: new Date(),
|
||||
data: { message: "Storm warning!" },
|
||||
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -322,7 +322,7 @@ describe("FeedEngine", () => {
|
||||
expect(items[0]!.type).toBe("weather")
|
||||
})
|
||||
|
||||
test("sorts items by priority descending", async () => {
|
||||
test("returns items in source graph order (no engine-level sorting)", async () => {
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
@@ -338,8 +338,12 @@ describe("FeedEngine", () => {
|
||||
const { items } = await engine.refresh()
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
||||
// Items returned in topological order (weather before alert)
|
||||
expect(items[0]!.type).toBe("weather")
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ActionDefinition } from "./action"
|
||||
import type { Context } from "./context"
|
||||
import type { FeedItem } from "./feed"
|
||||
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||
import type { FeedSource } from "./feed-source"
|
||||
|
||||
export interface SourceError {
|
||||
@@ -12,6 +13,8 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
|
||||
context: Context
|
||||
items: TItem[]
|
||||
errors: SourceError[]
|
||||
/** Item groups produced by post-processors */
|
||||
groupedItems?: ItemGroup[]
|
||||
}
|
||||
|
||||
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
||||
@@ -66,6 +69,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||
private cleanups: Array<() => void> = []
|
||||
private started = false
|
||||
private postProcessors: FeedPostProcessor[] = []
|
||||
|
||||
private readonly cacheTtlMs: number
|
||||
private cachedResult: FeedResult<TItems> | null = null
|
||||
@@ -108,6 +112,23 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
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.
|
||||
* Calls fetchContext() then fetchItems() on each source.
|
||||
@@ -150,12 +171,20 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority descending
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
this.context = context
|
||||
|
||||
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
|
||||
const {
|
||||
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)
|
||||
|
||||
return result
|
||||
@@ -263,6 +292,59 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
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]
|
||||
|
||||
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)
|
||||
}
|
||||
} catch (err) {
|
||||
const sourceId = processor.name || "anonymous"
|
||||
allErrors.push({
|
||||
sourceId,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
})
|
||||
currentItems = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (!this.graph) {
|
||||
this.graph = buildGraph(Array.from(this.sources.values()))
|
||||
@@ -314,12 +396,17 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
const {
|
||||
items: processedItems,
|
||||
groupedItems,
|
||||
errors: postProcessorErrors,
|
||||
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
|
||||
|
||||
const result: FeedResult<TItems> = {
|
||||
context: this.context,
|
||||
items: items as TItems[],
|
||||
errors,
|
||||
items: processedItems,
|
||||
errors: postProcessorErrors,
|
||||
...(groupedItems.length > 0 ? { groupedItems } : {}),
|
||||
}
|
||||
this.updateCache(result)
|
||||
|
||||
|
||||
439
packages/aris-core/src/feed-post-processor.test.ts
Normal file
439
packages/aris-core/src/feed-post-processor.test.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import type { ActionDefinition, 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()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 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: ((update: Record<string, unknown>) => 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!({ foo: "bar" })
|
||||
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: (() => 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!()
|
||||
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" }])
|
||||
})
|
||||
})
|
||||
})
|
||||
24
packages/aris-core/src/feed-post-processor.ts
Normal file
24
packages/aris-core/src/feed-post-processor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
|
||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
||||
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||
|
||||
// No-op action methods for test sources
|
||||
const noActions = {
|
||||
@@ -99,12 +99,12 @@ function createWeatherSource(
|
||||
{
|
||||
id: `weather-${Date.now()}`,
|
||||
type: "weather",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {
|
||||
temperature: weather.temperature,
|
||||
condition: weather.condition,
|
||||
},
|
||||
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -130,9 +130,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
||||
{
|
||||
id: "alert-storm",
|
||||
type: "alert",
|
||||
priority: 1.0,
|
||||
timestamp: new Date(),
|
||||
data: { message: "Storm warning!" },
|
||||
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -226,9 +226,6 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority descending
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
return { context, items }
|
||||
}
|
||||
|
||||
@@ -441,8 +438,12 @@ describe("FeedSource", () => {
|
||||
const { items } = await refreshGraph(graph)
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
||||
// Items returned in topological order (weather before alert)
|
||||
expect(items[0]!.type).toBe("weather")
|
||||
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 () => {
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -8,9 +33,9 @@
|
||||
* const item: WeatherItem = {
|
||||
* id: "weather-123",
|
||||
* type: "weather",
|
||||
* priority: 0.5,
|
||||
* timestamp: new Date(),
|
||||
* data: { temp: 18, condition: "cloudy" },
|
||||
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@@ -22,10 +47,10 @@ export interface FeedItem<
|
||||
id: string
|
||||
/** Item type, matches the data source type */
|
||||
type: TType
|
||||
/** Sort priority (higher = more important, shown first) */
|
||||
priority: number
|
||||
/** When this item was generated */
|
||||
timestamp: Date
|
||||
/** Type-specific payload */
|
||||
data: TData
|
||||
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
|
||||
signals?: FeedItemSignals
|
||||
}
|
||||
|
||||
@@ -7,11 +7,15 @@ export type { ActionDefinition } from "./action"
|
||||
export { UnknownActionError } from "./action"
|
||||
|
||||
// Feed
|
||||
export type { FeedItem } from "./feed"
|
||||
export type { FeedItem, FeedItemSignals } from "./feed"
|
||||
export { TimeRelevance } from "./feed"
|
||||
|
||||
// Feed Source
|
||||
export type { FeedSource } from "./feed-source"
|
||||
|
||||
// Feed Post-Processor
|
||||
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||
|
||||
// Feed Engine
|
||||
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||
export { FeedEngine } from "./feed-engine"
|
||||
|
||||
@@ -72,8 +72,6 @@ export class Reconciler<TItems extends FeedItem = never> {
|
||||
}
|
||||
})
|
||||
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
return { items, errors } as ReconcileResult<TItems>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,21 +190,22 @@ describe("query() with mocked client", () => {
|
||||
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
||||
})
|
||||
|
||||
test("assigns priority based on weather conditions", async () => {
|
||||
test("assigns signals based on weather conditions", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await dataSource.query(context)
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
||||
expect(item.priority).toBeLessThanOrEqual(1)
|
||||
expect(item.signals).toBeDefined()
|
||||
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
||||
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
||||
expect(item.signals!.timeRelevance).toBeDefined()
|
||||
}
|
||||
|
||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||
expect(currentItem).toBeDefined()
|
||||
// Base priority for current is 0.5, may be adjusted for conditions
|
||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Context, DataSource } from "@aris/core"
|
||||
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
|
||||
|
||||
import { TimeRelevance } from "@aris/core"
|
||||
|
||||
import {
|
||||
WeatherFeedItemType,
|
||||
@@ -105,7 +107,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_PRIORITY = {
|
||||
const BASE_URGENCY = {
|
||||
current: 0.5,
|
||||
hourly: 0.3,
|
||||
daily: 0.2,
|
||||
@@ -134,17 +136,17 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
||||
ConditionCode.BlowingSnow,
|
||||
])
|
||||
|
||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
||||
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, basePriority + 0.3)
|
||||
return Math.min(1, baseUrgency + 0.3)
|
||||
}
|
||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, basePriority + 0.15)
|
||||
return Math.min(1, baseUrgency + 0.15)
|
||||
}
|
||||
return basePriority
|
||||
return baseUrgency
|
||||
}
|
||||
|
||||
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||
switch (severity) {
|
||||
case Severity.Extreme:
|
||||
return 1
|
||||
@@ -153,7 +155,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||
case Severity.Moderate:
|
||||
return 0.75
|
||||
case Severity.Minor:
|
||||
return BASE_PRIORITY.alert
|
||||
return BASE_URGENCY.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,12 +221,14 @@ function createCurrentWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): CurrentWeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-current-${timestamp.getTime()}`,
|
||||
type: WeatherFeedItemType.current,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
conditionCode: current.conditionCode,
|
||||
@@ -219,6 +245,7 @@ function createCurrentWeatherFeedItem(
|
||||
windGust: convertSpeed(current.windGust, units),
|
||||
windSpeed: convertSpeed(current.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,12 +255,14 @@ function createHourlyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): HourlyWeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.hourly,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastTime: new Date(hourly.forecastStart),
|
||||
@@ -250,6 +279,7 @@ function createHourlyWeatherFeedItem(
|
||||
windGust: convertSpeed(hourly.windGust, units),
|
||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,12 +289,14 @@ function createDailyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): DailyWeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.daily,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastDate: new Date(daily.forecastStart),
|
||||
@@ -279,16 +311,19 @@ function createDailyWeatherFeedItem(
|
||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-alert-${alert.id}`,
|
||||
type: WeatherFeedItemType.alert,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
alertId: alert.id,
|
||||
@@ -302,5 +337,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
||||
source: alert.source,
|
||||
urgency: alert.urgency,
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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"
|
||||
58
packages/aris-source-caldav/README.md
Normal file
58
packages/aris-source-caldav/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# @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.
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@aris/source-apple-calendar",
|
||||
"name": "@aris/source-caldav",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test ."
|
||||
"test": "bun test .",
|
||||
"test:live": "bun run scripts/test-live.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
62
packages/aris-source-caldav/scripts/test-live.ts
Normal file
62
packages/aris-source-caldav/scripts/test-live.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Live test script for CalDavSource.
|
||||
*
|
||||
* Usage:
|
||||
* bun run test-live.ts
|
||||
*/
|
||||
|
||||
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 = { time: new Date() }
|
||||
|
||||
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)")
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import type { Context } from "@aris/core"
|
||||
|
||||
import { contextValue } from "@aris/core"
|
||||
import { TimeRelevance, contextValue } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import type {
|
||||
CalendarCredentialProvider,
|
||||
CalendarCredentials,
|
||||
CalendarDAVCalendar,
|
||||
CalendarDAVClient,
|
||||
CalendarDAVObject,
|
||||
CalendarEventData,
|
||||
CalDavDAVCalendar,
|
||||
CalDavDAVClient,
|
||||
CalDavDAVObject,
|
||||
CalDavEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
import { CalendarKey } from "./calendar-context.ts"
|
||||
import { CalendarSource, computePriority } from "./calendar-source.ts"
|
||||
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
||||
import { CalDavCalendarKey } from "./calendar-context.ts"
|
||||
|
||||
function loadFixture(name: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||
@@ -25,36 +23,16 @@ function createContext(time: Date): Context {
|
||||
return { time }
|
||||
}
|
||||
|
||||
const mockCredentials: CalendarCredentials = {
|
||||
accessToken: "mock-access-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
tokenUrl: "https://appleid.apple.com/auth/token",
|
||||
clientId: "com.example.aris",
|
||||
clientSecret: "mock-secret",
|
||||
}
|
||||
|
||||
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 {
|
||||
class MockDAVClient implements CalDavDAVClient {
|
||||
credentials: Record<string, unknown> = {}
|
||||
fetchCalendarsCallCount = 0
|
||||
private calendars: CalendarDAVCalendar[]
|
||||
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
|
||||
lastTimeRange: { start: string; end: string } | null = null
|
||||
private calendars: CalDavDAVCalendar[]
|
||||
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
|
||||
|
||||
constructor(
|
||||
calendars: CalendarDAVCalendar[],
|
||||
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
|
||||
calendars: CalDavDAVCalendar[],
|
||||
objectsByCalendarUrl: Record<string, CalDavDAVObject[]>,
|
||||
) {
|
||||
this.calendars = calendars
|
||||
this.objectsByCalendarUrl = objectsByCalendarUrl
|
||||
@@ -62,54 +40,57 @@ class MockDAVClient implements CalendarDAVClient {
|
||||
|
||||
async login(): Promise<void> {}
|
||||
|
||||
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
|
||||
async fetchCalendars(): Promise<CalDavDAVCalendar[]> {
|
||||
this.fetchCalendarsCallCount++
|
||||
return this.calendars
|
||||
}
|
||||
|
||||
async fetchCalendarObjects(params: {
|
||||
calendar: CalendarDAVCalendar
|
||||
calendar: CalDavDAVCalendar
|
||||
timeRange: { start: string; end: string }
|
||||
}): Promise<CalendarDAVObject[]> {
|
||||
}): Promise<CalDavDAVObject[]> {
|
||||
this.lastTimeRange = params.timeRange
|
||||
return this.objectsByCalendarUrl[params.calendar.url] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
describe("CalendarSource", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
expect(source.id).toBe("aris.apple-calendar")
|
||||
function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource {
|
||||
return new CalDavSource({
|
||||
serverUrl: "https://caldav.example.com",
|
||||
authMethod: "basic",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
davClient: client,
|
||||
lookAheadDays,
|
||||
})
|
||||
}
|
||||
|
||||
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([])
|
||||
describe("CalDavSource", () => {
|
||||
test("has correct id", () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = createSource(client)
|
||||
expect(source.id).toBe("aris.caldav")
|
||||
})
|
||||
|
||||
test("returns empty array when no calendars exist", async () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("returns feed items from a single calendar", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
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 = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.type).toBe("calendar-event")
|
||||
expect(items[0]!.id).toBe("calendar-event-single-event-001@test")
|
||||
expect(items[0]!.type).toBe("caldav-event")
|
||||
expect(items[0]!.id).toBe("caldav-event-single-event-001@test")
|
||||
expect(items[0]!.data.title).toBe("Team Standup")
|
||||
expect(items[0]!.data.location).toBe("Conference Room A")
|
||||
expect(items[0]!.data.calendarName).toBe("Work")
|
||||
@@ -118,7 +99,7 @@ describe("CalendarSource", () => {
|
||||
})
|
||||
|
||||
test("returns feed items from multiple calendars", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
"/cal/personal": [
|
||||
{
|
||||
@@ -134,9 +115,7 @@ describe("CalendarSource", () => {
|
||||
],
|
||||
objects,
|
||||
)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
@@ -154,7 +133,7 @@ describe("CalendarSource", () => {
|
||||
})
|
||||
|
||||
test("skips objects with non-string data", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||
{ url: "/cal/work/bad.ics", data: 12345 },
|
||||
@@ -162,9 +141,7 @@ describe("CalendarSource", () => {
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toHaveLength(1)
|
||||
@@ -172,30 +149,26 @@ describe("CalendarSource", () => {
|
||||
})
|
||||
|
||||
test("uses context time as feed item timestamp", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
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 = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const now = new Date("2026-01-15T12:00:00Z")
|
||||
const items = await source.fetchItems(createContext(now))
|
||||
expect(items[0]!.timestamp).toEqual(now)
|
||||
})
|
||||
|
||||
test("assigns priority based on event proximity", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
test("assigns signals based on event proximity", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
// 2 hours before the event at 14:00
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
@@ -203,12 +176,14 @@ describe("CalendarSource", () => {
|
||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
||||
|
||||
expect(standup!.priority).toBe(0.7) // within 2 hours
|
||||
expect(holiday!.priority).toBe(0.3) // all-day
|
||||
expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours
|
||||
expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming)
|
||||
expect(holiday!.signals!.urgency).toBe(0.3) // all-day
|
||||
expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
})
|
||||
|
||||
test("handles calendar with non-string displayName", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/weird": [
|
||||
{
|
||||
url: "/cal/weird/event1.ics",
|
||||
@@ -220,16 +195,14 @@ describe("CalendarSource", () => {
|
||||
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
|
||||
objects,
|
||||
)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items[0]!.data.calendarName).toBeNull()
|
||||
})
|
||||
|
||||
test("handles recurring events with exceptions", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{
|
||||
url: "/cal/work/recurring.ics",
|
||||
@@ -238,9 +211,7 @@ describe("CalendarSource", () => {
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||
|
||||
@@ -258,13 +229,11 @@ describe("CalendarSource", () => {
|
||||
})
|
||||
|
||||
test("caches events within the same refresh cycle", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
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 = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const context = createContext(new Date("2026-01-15T12:00:00Z"))
|
||||
|
||||
@@ -275,15 +244,52 @@ describe("CalendarSource", () => {
|
||||
expect(client.fetchCalendarsCallCount).toBe(1)
|
||||
})
|
||||
|
||||
test("refetches events for a different context time", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
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)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
|
||||
// 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 () => {
|
||||
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-15T12:00:00Z")))
|
||||
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
|
||||
|
||||
@@ -292,11 +298,12 @@ describe("CalendarSource", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("CalendarSource.fetchContext", () => {
|
||||
test("returns empty context when credentials are null", async () => {
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
describe("CalDavSource.fetchContext", () => {
|
||||
test("returns empty context when no calendars exist", async () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = createSource(client)
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
|
||||
expect(calendar).toBeDefined()
|
||||
expect(calendar!.inProgress).toEqual([])
|
||||
@@ -306,34 +313,30 @@ describe("CalendarSource.fetchContext", () => {
|
||||
})
|
||||
|
||||
test("identifies in-progress events", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
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 = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
// 14:30 is during the 14:00-15:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(1)
|
||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||
})
|
||||
|
||||
test("identifies next upcoming event", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
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 = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
// 12:00 is before the 14:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).not.toBeNull()
|
||||
@@ -341,16 +344,14 @@ describe("CalendarSource.fetchContext", () => {
|
||||
})
|
||||
|
||||
test("excludes all-day events from inProgress and nextEvent", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).toBeNull()
|
||||
@@ -359,29 +360,27 @@ describe("CalendarSource.fetchContext", () => {
|
||||
})
|
||||
|
||||
test("counts all events including all-day in todayEventCount", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const source = createSource(client)
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
|
||||
expect(calendar!.todayEventCount).toBe(2)
|
||||
expect(calendar!.hasTodayEvents).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computePriority", () => {
|
||||
describe("computeSignals", () => {
|
||||
const now = new Date("2026-01-15T12:00:00Z")
|
||||
|
||||
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
|
||||
function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData {
|
||||
return {
|
||||
uid: "test-uid",
|
||||
title: "Test",
|
||||
@@ -401,73 +400,108 @@ describe("computePriority", () => {
|
||||
}
|
||||
}
|
||||
|
||||
test("all-day events get priority 0.3", () => {
|
||||
test("all-day events get urgency 0.3 and ambient relevance", () => {
|
||||
const event = makeEvent({ isAllDay: true })
|
||||
expect(computePriority(event, now)).toBe(0.3)
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.3)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
})
|
||||
|
||||
test("events starting within 30 minutes get priority 0.9", () => {
|
||||
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:20:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.9)
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.9)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||
})
|
||||
|
||||
test("events starting exactly at 30 minutes get priority 0.9", () => {
|
||||
test("events starting exactly at 30 minutes get urgency 0.9", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:30:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.9)
|
||||
expect(computeSignals(event, now).urgency).toBe(0.9)
|
||||
})
|
||||
|
||||
test("events starting within 2 hours get priority 0.7", () => {
|
||||
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T13:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.7)
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.7)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
|
||||
})
|
||||
|
||||
test("events later today get priority 0.5", () => {
|
||||
test("events later today get urgency 0.5", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T20:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.5)
|
||||
expect(computeSignals(event, now).urgency).toBe(0.5)
|
||||
})
|
||||
|
||||
test("in-progress events get priority 0.8", () => {
|
||||
test("in-progress events get urgency 0.8 and imminent relevance", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T11:00:00Z"),
|
||||
endDate: new Date("2026-01-15T13:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.8)
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.8)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||
})
|
||||
|
||||
test("fully past events get priority 0.2", () => {
|
||||
test("fully past events get urgency 0.2 and ambient relevance", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T09:00:00Z"),
|
||||
endDate: new Date("2026-01-15T10:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.2)
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.2)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
})
|
||||
|
||||
test("events on future days get priority 0.2", () => {
|
||||
test("events on future days get urgency 0.2", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-16T10:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.2)
|
||||
expect(computeSignals(event, now).urgency).toBe(0.2)
|
||||
})
|
||||
|
||||
test("priority boundaries are correct", () => {
|
||||
test("urgency boundaries are correct", () => {
|
||||
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
|
||||
const event31min = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:31:00Z"),
|
||||
})
|
||||
expect(computePriority(event31min, now)).toBe(0.7)
|
||||
expect(computeSignals(event31min, now).urgency).toBe(0.7)
|
||||
|
||||
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
|
||||
const event2h1m = makeEvent({
|
||||
startDate: new Date("2026-01-15T14:01:00Z"),
|
||||
})
|
||||
expect(computePriority(event2h1m, now)).toBe(0.5)
|
||||
expect(computeSignals(event2h1m, now).urgency).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)
|
||||
})
|
||||
})
|
||||
348
packages/aris-source-caldav/src/caldav-source.ts
Normal file
348
packages/aris-source-caldav/src/caldav-source.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { DAVClient } from "tsdav"
|
||||
|
||||
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
||||
import { CalDavEventStatus } from "./types.ts"
|
||||
|
||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
import { parseICalEvents } from "./ical-parser.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<Partial<Context> | 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: "caldav-event",
|
||||
timestamp: now,
|
||||
data: event,
|
||||
signals: computeSignals(event, now, timeZone),
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,23 @@ import type { ContextKey } from "@aris/core"
|
||||
|
||||
import { contextKey } from "@aris/core"
|
||||
|
||||
import type { CalendarEventData } from "./types.ts"
|
||||
import type { CalDavEventData } from "./types.ts"
|
||||
|
||||
/**
|
||||
* Calendar context for downstream sources.
|
||||
*
|
||||
* Provides a snapshot of the user's upcoming events so other sources
|
||||
* Provides a snapshot of the user's upcoming CalDAV events so other sources
|
||||
* can adapt (e.g. a commute source checking if there's a meeting soon).
|
||||
*/
|
||||
export interface CalendarContext {
|
||||
/** Events happening right now */
|
||||
inProgress: CalendarEventData[]
|
||||
inProgress: CalDavEventData[]
|
||||
/** Next upcoming event, if any */
|
||||
nextEvent: CalendarEventData | null
|
||||
nextEvent: CalDavEventData | null
|
||||
/** Whether the user has any events today */
|
||||
hasTodayEvents: boolean
|
||||
/** Total number of events today */
|
||||
todayEventCount: number
|
||||
}
|
||||
|
||||
export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar")
|
||||
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")
|
||||
@@ -3,20 +3,20 @@ import ICAL from "ical.js"
|
||||
import {
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
CalendarEventStatus,
|
||||
type CalendarAlarm,
|
||||
type CalendarAttendee,
|
||||
type CalendarEventData,
|
||||
CalDavEventStatus,
|
||||
type CalDavAlarm,
|
||||
type CalDavAttendee,
|
||||
type CalDavEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
/**
|
||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||
* into CalendarEventData objects.
|
||||
* into CalDavEventData objects.
|
||||
*
|
||||
* @param icsData - Raw iCalendar string from a CalDAV response
|
||||
* @param calendarName - Display name of the calendar this event belongs to
|
||||
*/
|
||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalendarEventData[] {
|
||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
|
||||
const jcal = ICAL.parse(icsData)
|
||||
const comp = new ICAL.Component(jcal)
|
||||
const vevents = comp.getAllSubcomponents("vevent")
|
||||
@@ -29,7 +29,7 @@ export function parseICalEvents(icsData: string, calendarName: string | null): C
|
||||
function parseVEvent(
|
||||
vevent: InstanceType<typeof ICAL.Component>,
|
||||
calendarName: string | null,
|
||||
): CalendarEventData {
|
||||
): CalDavEventData {
|
||||
const event = new ICAL.Event(vevent)
|
||||
|
||||
return {
|
||||
@@ -50,15 +50,15 @@ function parseVEvent(
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatus(raw: string | null): CalendarEventStatus | null {
|
||||
function parseStatus(raw: string | null): CalDavEventStatus | null {
|
||||
if (!raw) return null
|
||||
switch (raw.toLowerCase()) {
|
||||
case "confirmed":
|
||||
return CalendarEventStatus.Confirmed
|
||||
return CalDavEventStatus.Confirmed
|
||||
case "tentative":
|
||||
return CalendarEventStatus.Tentative
|
||||
return CalDavEventStatus.Tentative
|
||||
case "cancelled":
|
||||
return CalendarEventStatus.Cancelled
|
||||
return CalDavEventStatus.Cancelled
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -81,22 +81,25 @@ function parseOrganizer(
|
||||
return value.replace(/^mailto:/i, "")
|
||||
}
|
||||
|
||||
function parseAttendees(properties: unknown[]): CalendarAttendee[] {
|
||||
function parseAttendees(properties: unknown[]): CalDavAttendee[] {
|
||||
if (properties.length === 0) return []
|
||||
|
||||
return properties.map((prop) => {
|
||||
return properties.flatMap((prop) => {
|
||||
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
|
||||
const p = prop as InstanceType<typeof ICAL.Property>
|
||||
const value = asStringOrNull(p.getFirstValue())
|
||||
const cn = asStringOrNull(p.getParameter("cn"))
|
||||
const role = asStringOrNull(p.getParameter("role"))
|
||||
const partstat = asStringOrNull(p.getParameter("partstat"))
|
||||
|
||||
return {
|
||||
name: cn,
|
||||
email: value ? value.replace(/^mailto:/i, "") : null,
|
||||
role: parseAttendeeRole(role),
|
||||
status: parseAttendeeStatus(partstat),
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: cn,
|
||||
email: value ? value.replace(/^mailto:/i, "") : null,
|
||||
role: parseAttendeeRole(role),
|
||||
status: parseAttendeeStatus(partstat),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,7 +133,7 @@ function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {
|
||||
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
|
||||
const valarms = vevent.getAllSubcomponents("valarm")
|
||||
if (!valarms || valarms.length === 0) return []
|
||||
|
||||
15
packages/aris-source-caldav/src/index.ts
Normal file
15
packages/aris-source-caldav/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
type CalDavAlarm,
|
||||
type CalDavAttendee,
|
||||
type CalDavDAVCalendar,
|
||||
type CalDavDAVClient,
|
||||
type CalDavDAVObject,
|
||||
type CalDavEventData,
|
||||
type CalDavFeedItem,
|
||||
} from "./types.ts"
|
||||
@@ -1,30 +1,16 @@
|
||||
import type { FeedItem } from "@aris/core"
|
||||
|
||||
// -- Credential provider --
|
||||
// -- Event status --
|
||||
|
||||
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 = {
|
||||
export const CalDavEventStatus = {
|
||||
Confirmed: "confirmed",
|
||||
Tentative: "tentative",
|
||||
Cancelled: "cancelled",
|
||||
} as const
|
||||
|
||||
export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus]
|
||||
export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus]
|
||||
|
||||
// -- Attendee types --
|
||||
|
||||
export const AttendeeRole = {
|
||||
Chair: "chair",
|
||||
@@ -43,21 +29,25 @@ export const AttendeeStatus = {
|
||||
|
||||
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
||||
|
||||
export interface CalendarAttendee {
|
||||
export interface CalDavAttendee {
|
||||
name: string | null
|
||||
email: string | null
|
||||
role: AttendeeRole | null
|
||||
status: AttendeeStatus | null
|
||||
}
|
||||
|
||||
export interface CalendarAlarm {
|
||||
// -- Alarm --
|
||||
|
||||
export interface CalDavAlarm {
|
||||
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
||||
trigger: string
|
||||
/** e.g. "DISPLAY", "AUDIO" */
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface CalendarEventData extends Record<string, unknown> {
|
||||
// -- Event data --
|
||||
|
||||
export interface CalDavEventData extends Record<string, unknown> {
|
||||
uid: string
|
||||
title: string
|
||||
startDate: Date
|
||||
@@ -66,36 +56,38 @@ export interface CalendarEventData extends Record<string, unknown> {
|
||||
location: string | null
|
||||
description: string | null
|
||||
calendarName: string | null
|
||||
status: CalendarEventStatus | null
|
||||
status: CalDavEventStatus | null
|
||||
url: string | null
|
||||
organizer: string | null
|
||||
attendees: CalendarAttendee[]
|
||||
alarms: CalendarAlarm[]
|
||||
attendees: CalDavAttendee[]
|
||||
alarms: CalDavAlarm[]
|
||||
recurrenceId: string | null
|
||||
}
|
||||
|
||||
export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData>
|
||||
// -- Feed item --
|
||||
|
||||
export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
|
||||
|
||||
// -- DAV client interface --
|
||||
|
||||
export interface CalendarDAVObject {
|
||||
export interface CalDavDAVObject {
|
||||
data?: unknown
|
||||
etag?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface CalendarDAVCalendar {
|
||||
export interface CalDavDAVCalendar {
|
||||
displayName?: string | Record<string, unknown>
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Subset of DAVClient used by CalendarSource. */
|
||||
export interface CalendarDAVClient {
|
||||
/** Subset of tsdav's DAVClient used by CalDavSource. */
|
||||
export interface CalDavDAVClient {
|
||||
login(): Promise<void>
|
||||
fetchCalendars(): Promise<CalendarDAVCalendar[]>
|
||||
fetchCalendars(): Promise<CalDavDAVCalendar[]>
|
||||
fetchCalendarObjects(params: {
|
||||
calendar: CalendarDAVCalendar
|
||||
calendar: CalDavDAVCalendar
|
||||
timeRange: { start: string; end: string }
|
||||
}): Promise<CalendarDAVObject[]>
|
||||
}): Promise<CalDavDAVObject[]>
|
||||
credentials: Record<string, unknown>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextValue, type Context } from "@aris/core"
|
||||
import { TimeRelevance, contextValue, type Context } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||
@@ -81,16 +81,17 @@ describe("GoogleCalendarSource", () => {
|
||||
expect(allDayItems.length).toBe(1)
|
||||
})
|
||||
|
||||
test("ongoing events get highest priority (1.0)", async () => {
|
||||
test("ongoing events get highest urgency (1.0)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
||||
expect(ongoing).toBeDefined()
|
||||
expect(ongoing!.priority).toBe(1.0)
|
||||
expect(ongoing!.signals!.urgency).toBe(1.0)
|
||||
expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||
})
|
||||
|
||||
test("upcoming events get higher priority when sooner", async () => {
|
||||
test("upcoming events get higher urgency when sooner", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
@@ -99,16 +100,17 @@ describe("GoogleCalendarSource", () => {
|
||||
|
||||
expect(soon).toBeDefined()
|
||||
expect(later).toBeDefined()
|
||||
expect(soon!.priority).toBeGreaterThan(later!.priority)
|
||||
expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!)
|
||||
})
|
||||
|
||||
test("all-day events get flat priority (0.4)", async () => {
|
||||
test("all-day events get flat urgency (0.4)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
||||
expect(allDay).toBeDefined()
|
||||
expect(allDay!.priority).toBe(0.4)
|
||||
expect(allDay!.signals!.urgency).toBe(0.4)
|
||||
expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
@@ -280,7 +282,7 @@ describe("GoogleCalendarSource", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("priority ordering", () => {
|
||||
describe("urgency ordering", () => {
|
||||
test("ongoing > upcoming > all-day", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
@@ -289,8 +291,8 @@ describe("GoogleCalendarSource", () => {
|
||||
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
||||
|
||||
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
|
||||
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
|
||||
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!)
|
||||
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { UnknownActionError } from "@aris/core"
|
||||
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
|
||||
import type {
|
||||
ApiCalendarEvent,
|
||||
@@ -35,10 +35,10 @@ import { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
||||
|
||||
const DEFAULT_LOOKAHEAD_HOURS = 24
|
||||
|
||||
const PRIORITY_ONGOING = 1.0
|
||||
const PRIORITY_UPCOMING_MAX = 0.9
|
||||
const PRIORITY_UPCOMING_MIN = 0.3
|
||||
const PRIORITY_ALL_DAY = 0.4
|
||||
const URGENCY_ONGOING = 1.0
|
||||
const URGENCY_UPCOMING_MAX = 0.9
|
||||
const URGENCY_UPCOMING_MIN = 0.3
|
||||
const URGENCY_ALL_DAY = 0.4
|
||||
|
||||
/**
|
||||
* A FeedSource that provides Google Calendar events and next-event context.
|
||||
@@ -171,9 +171,13 @@ function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventD
|
||||
}
|
||||
}
|
||||
|
||||
function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
|
||||
function computeSignals(
|
||||
event: CalendarEventData,
|
||||
nowMs: number,
|
||||
lookaheadMs: number,
|
||||
): FeedItemSignals {
|
||||
if (event.isAllDay) {
|
||||
return PRIORITY_ALL_DAY
|
||||
return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient }
|
||||
}
|
||||
|
||||
const startMs = event.startTime.getTime()
|
||||
@@ -181,17 +185,23 @@ function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: n
|
||||
|
||||
// Ongoing: start <= now < end
|
||||
if (startMs <= nowMs && nowMs < endMs) {
|
||||
return PRIORITY_ONGOING
|
||||
return { urgency: URGENCY_ONGOING, timeRelevance: TimeRelevance.Imminent }
|
||||
}
|
||||
|
||||
// Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
|
||||
// Upcoming: linear decay from URGENCY_UPCOMING_MAX to URGENCY_UPCOMING_MIN
|
||||
const msUntilStart = startMs - nowMs
|
||||
if (msUntilStart <= 0) {
|
||||
return PRIORITY_UPCOMING_MIN
|
||||
return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient }
|
||||
}
|
||||
|
||||
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
|
||||
return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
|
||||
const urgency = URGENCY_UPCOMING_MAX - ratio * (URGENCY_UPCOMING_MAX - URGENCY_UPCOMING_MIN)
|
||||
|
||||
// Within 30 minutes = imminent, otherwise upcoming
|
||||
const timeRelevance =
|
||||
msUntilStart <= 30 * 60 * 1000 ? TimeRelevance.Imminent : TimeRelevance.Upcoming
|
||||
|
||||
return { urgency, timeRelevance }
|
||||
}
|
||||
|
||||
function createFeedItem(
|
||||
@@ -199,14 +209,13 @@ function createFeedItem(
|
||||
nowMs: number,
|
||||
lookaheadMs: number,
|
||||
): CalendarFeedItem {
|
||||
const priority = computePriority(event, nowMs, lookaheadMs)
|
||||
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
||||
|
||||
return {
|
||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||
type: itemType,
|
||||
priority,
|
||||
timestamp: new Date(nowMs),
|
||||
data: event,
|
||||
signals: computeSignals(event, nowMs, lookaheadMs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,8 @@ describe("TflSource", () => {
|
||||
expect(typeof item.id).toBe("string")
|
||||
expect(item.id).toMatch(/^tfl-alert-/)
|
||||
expect(item.type).toBe("tfl-alert")
|
||||
expect(typeof item.priority).toBe("number")
|
||||
expect(item.signals).toBeDefined()
|
||||
expect(typeof item.signals!.urgency).toBe("number")
|
||||
expect(item.timestamp).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
@@ -220,29 +221,29 @@ describe("TflSource", () => {
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test("feed items are sorted by priority descending", async () => {
|
||||
test("feed items are sorted by urgency descending", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const prev = items[i - 1]!
|
||||
const curr = items[i]!
|
||||
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
||||
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
|
||||
}
|
||||
})
|
||||
|
||||
test("priority values match severity levels", async () => {
|
||||
test("urgency values match severity levels", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const severityPriority: Record<string, number> = {
|
||||
const severityUrgency: Record<string, number> = {
|
||||
closure: 1.0,
|
||||
"major-delays": 0.8,
|
||||
"minor-delays": 0.6,
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
||||
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -316,9 +317,7 @@ describe("TflSource", () => {
|
||||
test("executeAction throws on invalid input", async () => {
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { UnknownActionError, contextValue } from "@aris/core"
|
||||
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
import { type } from "arktype"
|
||||
|
||||
@@ -18,12 +18,18 @@ import { TflApi, lineId } from "./tfl-api.ts"
|
||||
|
||||
const setLinesInput = lineId.array()
|
||||
|
||||
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||
const SEVERITY_URGENCY: Record<TflAlertSeverity, number> = {
|
||||
closure: 1.0,
|
||||
"major-delays": 0.8,
|
||||
"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.
|
||||
*
|
||||
@@ -137,19 +143,26 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
closestStationDistance,
|
||||
}
|
||||
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: SEVERITY_URGENCY[status.severity],
|
||||
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
|
||||
}
|
||||
|
||||
return {
|
||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||
type: "tfl-alert",
|
||||
priority: SEVERITY_PRIORITY[status.severity],
|
||||
timestamp: context.time,
|
||||
data,
|
||||
signals,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by severity (desc), then by proximity (asc) if location available
|
||||
// Sort by urgency (desc), then by proximity (asc) if location available
|
||||
items.sort((a, b) => {
|
||||
if (b.priority !== a.priority) {
|
||||
return b.priority - a.priority
|
||||
const aUrgency = a.signals?.urgency ?? 0
|
||||
const bUrgency = b.signals?.urgency ?? 0
|
||||
if (bUrgency !== aUrgency) {
|
||||
return bUrgency - aUrgency
|
||||
}
|
||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||
|
||||
@@ -145,20 +145,22 @@ describe("WeatherSource", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("assigns priority based on weather conditions", async () => {
|
||||
test("assigns signals based on weather conditions", async () => {
|
||||
const source = new WeatherSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await source.fetchItems(context)
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
||||
expect(item.priority).toBeLessThanOrEqual(1)
|
||||
expect(item.signals).toBeDefined()
|
||||
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
||||
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
||||
expect(item.signals!.timeRelevance).toBeDefined()
|
||||
}
|
||||
|
||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||
expect(currentItem).toBeDefined()
|
||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { UnknownActionError, contextValue } from "@aris/core"
|
||||
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
|
||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||
@@ -38,7 +38,7 @@ export interface WeatherSourceOptions {
|
||||
const DEFAULT_HOURLY_LIMIT = 12
|
||||
const DEFAULT_DAILY_LIMIT = 7
|
||||
|
||||
const BASE_PRIORITY = {
|
||||
const BASE_URGENCY = {
|
||||
current: 0.5,
|
||||
hourly: 0.3,
|
||||
daily: 0.2,
|
||||
@@ -199,17 +199,17 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
||||
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, basePriority + 0.3)
|
||||
return Math.min(1, baseUrgency + 0.3)
|
||||
}
|
||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, basePriority + 0.15)
|
||||
return Math.min(1, baseUrgency + 0.15)
|
||||
}
|
||||
return basePriority
|
||||
return baseUrgency
|
||||
}
|
||||
|
||||
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||
switch (severity) {
|
||||
case "extreme":
|
||||
return 1
|
||||
@@ -218,7 +218,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||
case "moderate":
|
||||
return 0.75
|
||||
case "minor":
|
||||
return BASE_PRIORITY.alert
|
||||
return BASE_URGENCY.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,12 +284,14 @@ function createCurrentWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): WeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-current-${timestamp.getTime()}`,
|
||||
type: WeatherFeedItemType.current,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
conditionCode: current.conditionCode,
|
||||
@@ -284,6 +308,7 @@ function createCurrentWeatherFeedItem(
|
||||
windGust: convertSpeed(current.windGust, units),
|
||||
windSpeed: convertSpeed(current.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,12 +318,14 @@ function createHourlyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): WeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.hourly,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastTime: new Date(hourly.forecastStart),
|
||||
@@ -315,6 +342,7 @@ function createHourlyWeatherFeedItem(
|
||||
windGust: convertSpeed(hourly.windGust, units),
|
||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,12 +352,14 @@ function createDailyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): WeatherFeedItem {
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.daily,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastDate: new Date(daily.forecastStart),
|
||||
@@ -344,16 +374,19 @@ function createDailyWeatherFeedItem(
|
||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
|
||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||
}
|
||||
|
||||
return {
|
||||
id: `weather-alert-${alert.id}`,
|
||||
type: WeatherFeedItemType.alert,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
alertId: alert.id,
|
||||
@@ -367,5 +400,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
||||
source: alert.source,
|
||||
urgency: alert.urgency,
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user