mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
32 Commits
refactor/r
...
2717ec1b30
| Author | SHA1 | Date | |
|---|---|---|---|
|
2717ec1b30
|
|||
|
a4baef521f
|
|||
| caf48484bf | |||
| ac80e0cdac | |||
| 96e22e227c | |||
| 8ca8a0d1d2 | |||
| 4c9ac2c61a | |||
| be3fc41a00 | |||
| 2e9c600e93 | |||
| d616fd52d3 | |||
| 2d7544500d | |||
| 9dc0cc3d2f | |||
| fe1d261f56 | |||
| 40ad90aa2d | |||
| 82ac2b577d | |||
| ffea38b986 | |||
| 28d26b3c87 | |||
| 78b0ed94bd | |||
| ee957ea7b1 | |||
| 6ae0ad1d40 | |||
|
941acb826c
|
|||
| 3d492a5d56 | |||
|
08dd437952
|
|||
| 2fc20759dd | |||
| 769e2d4eb0 | |||
|
5e9094710d
|
|||
|
5556f3fbf9
|
|||
|
0176979925
|
|||
|
971aba0932
|
|||
|
68e319e4b8
|
|||
| c042af88f3 | |||
|
0608f2ac61
|
43
.claude/skills/gpg-commit-signing/SKILL.md
Normal file
43
.claude/skills/gpg-commit-signing/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: gpg-commit-signing
|
||||||
|
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
|
||||||
|
---
|
||||||
|
|
||||||
|
# GPG Commit Signing
|
||||||
|
|
||||||
|
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
|
||||||
|
```
|
||||||
|
|
||||||
|
If not set, skip signing — commit without `-S`.
|
||||||
|
|
||||||
|
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -S -m "message"
|
||||||
|
```
|
||||||
|
|
||||||
|
If this succeeds, no further steps are needed.
|
||||||
|
|
||||||
|
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
|
||||||
|
chmod +x /tmp/gpg-sign.sh
|
||||||
|
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
|
||||||
|
rm /tmp/gpg-sign.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
|
||||||
|
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
|
||||||
|
- Do not leave wrapper scripts on disk after committing.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Context, Next } from "hono"
|
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||||
|
|
||||||
import type { AuthSession, AuthUser } from "./session.ts"
|
import type { AuthSession, AuthUser } from "./session.ts"
|
||||||
|
|
||||||
@@ -9,6 +9,10 @@ export interface SessionVariables {
|
|||||||
session: AuthSession | null
|
session: AuthSession | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthSessionEnv = { Variables: SessionVariables }
|
||||||
|
|
||||||
|
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||||
|
|
||||||
declare module "hono" {
|
declare module "hono" {
|
||||||
interface ContextVariableMap extends SessionVariables {}
|
interface ContextVariableMap extends SessionVariables {}
|
||||||
}
|
}
|
||||||
@@ -55,3 +59,18 @@ export async function getSessionFromHeaders(
|
|||||||
const session = await auth.api.getSession({ headers })
|
const session = await auth.api.getSession({ headers })
|
||||||
return session
|
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, ContextEntry, 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<readonly ContextEntry[] | 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 { Hono } from "hono"
|
||||||
|
|
||||||
import { registerAuthHandlers } from "./auth/http.ts"
|
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 { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
@@ -24,6 +26,7 @@ function main() {
|
|||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
registerAuthHandlers(app)
|
registerAuthHandlers(app)
|
||||||
|
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
|
||||||
registerLocationHttpHandlers(app, { sessionManager })
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aris/source-location"
|
import { LocationSource } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
|
|||||||
async executeAction(): Promise<unknown> {
|
async executeAction(): Promise<unknown> {
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<Partial<Context> | null> {
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
|
|||||||
@@ -46,7 +46,97 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-font"
|
[
|
||||||
|
"expo-font",
|
||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"fontFamily": "Inter",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
|
||||||
|
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
|
||||||
|
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
|
||||||
|
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
|
||||||
|
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
|
||||||
|
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
|
||||||
|
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
|
||||||
|
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
|
||||||
|
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
|
||||||
|
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fontFamily": "Source Serif 4",
|
||||||
|
"fontDefinitions": [
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
|
||||||
|
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"fonts": [
|
||||||
|
"./assets/fonts/Inter_100Thin.ttf",
|
||||||
|
"./assets/fonts/Inter_100Thin_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_200ExtraLight.ttf",
|
||||||
|
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_300Light.ttf",
|
||||||
|
"./assets/fonts/Inter_300Light_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_400Regular.ttf",
|
||||||
|
"./assets/fonts/Inter_400Regular_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_500Medium.ttf",
|
||||||
|
"./assets/fonts/Inter_500Medium_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_600SemiBold.ttf",
|
||||||
|
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_700Bold.ttf",
|
||||||
|
"./assets/fonts/Inter_700Bold_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_800ExtraBold.ttf",
|
||||||
|
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
|
||||||
|
"./assets/fonts/Inter_900Black.ttf",
|
||||||
|
"./assets/fonts/Inter_900Black_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_300Light.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_400Regular.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_500Medium.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_700Bold.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_900Black.ttf",
|
||||||
|
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
|
|||||||
BIN
apps/aris-client/assets/fonts/Inter_100Thin.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_100Thin.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_100Thin_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_100Thin_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_300Light.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_300Light.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_300Light_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_300Light_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_400Regular.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_400Regular.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_400Regular_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_400Regular_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_500Medium.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_500Medium.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_500Medium_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_500Medium_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_700Bold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_700Bold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_700Bold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_700Bold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_900Black.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_900Black.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_900Black_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_900Black_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_200ExtraLight.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_200ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_600SemiBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_600SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_800ExtraBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_800ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black_Italic.ttf
Normal file
Binary file not shown.
@@ -8,6 +8,12 @@
|
|||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
|
"development-simulator": {
|
||||||
|
"extends": "development",
|
||||||
|
"ios": {
|
||||||
|
"simulator": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"build:ios": "eas build --profile development --platform ios --non-interactive",
|
"build:ios": "eas build --profile development --platform ios --non-interactive",
|
||||||
|
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
|
||||||
"debugger": "bun run scripts/open-debugger.ts"
|
"debugger": "bun run scripts/open-debugger.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
|
|||||||
22
bun.lock
22
bun.lock
@@ -35,6 +35,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
@@ -88,8 +89,19 @@
|
|||||||
"arktype": "^2.1.0",
|
"arktype": "^2.1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aris-source-apple-calendar": {
|
"packages/aris-feed-enhancers": {
|
||||||
"name": "@aris/source-apple-calendar",
|
"name": "@aris/feed-enhancers",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"@aris/source-caldav": "workspace:*",
|
||||||
|
"@aris/source-google-calendar": "workspace:*",
|
||||||
|
"@aris/source-tfl": "workspace:*",
|
||||||
|
"@aris/source-weatherkit": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/aris-source-caldav": {
|
||||||
|
"name": "@aris/source-caldav",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
@@ -143,7 +155,9 @@
|
|||||||
|
|
||||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
"@aris/source-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
|
"@aris/feed-enhancers": ["@aris/feed-enhancers@workspace:packages/aris-feed-enhancers"],
|
||||||
|
|
||||||
|
"@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"],
|
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
||||||
|
|
||||||
@@ -383,6 +397,8 @@
|
|||||||
|
|
||||||
"@expo-google-fonts/inter": ["@expo-google-fonts/inter@0.4.2", "", {}, "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ=="],
|
"@expo-google-fonts/inter": ["@expo-google-fonts/inter@0.4.2", "", {}, "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ=="],
|
||||||
|
|
||||||
|
"@expo-google-fonts/source-serif-4": ["@expo-google-fonts/source-serif-4@0.4.1", "", {}, "sha512-Ej4UXDjW1kwYPHG8YLq6fK1bqnJGb3K35J3S5atSL0ScKFAFLKvndxoTWeCls7mybtlS9x99hzwDeXCBkiI3rA=="],
|
||||||
|
|
||||||
"@expo/apple-utils": ["@expo/apple-utils@2.1.13", "", { "bin": { "apple-utils": "bin.js" } }, "sha512-nt3efiJhAWTHl9ikKYrHEuv3dhqCdicsHFRE9LmvtcVsPhXl9bAsm0gbACoLPr7ClP8664H/S6SdVJOD/tw0jg=="],
|
"@expo/apple-utils": ["@expo/apple-utils@2.1.13", "", { "bin": { "apple-utils": "bin.js" } }, "sha512-nt3efiJhAWTHl9ikKYrHEuv3dhqCdicsHFRE9LmvtcVsPhXl9bAsm0gbACoLPr7ClP8664H/S6SdVJOD/tw0jg=="],
|
||||||
|
|
||||||
"@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="],
|
"@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@ Examples of feed items:
|
|||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
1. **Extensibility**: The core must support different data sources, including third-party sources.
|
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.
|
2. **Separation of concerns**: Core handles data and UI description. The client is a thin renderer.
|
||||||
3. **Parallel execution**: Sources run in parallel; no inter-source dependencies.
|
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.
|
4. **Graceful degradation**: Failed sources are skipped; partial results are returned.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -25,26 +25,28 @@ Examples of feed items:
|
|||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Backend │
|
│ Backend │
|
||||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
│ │ aris-core │ │ Sources │ │ UI Registry │ │
|
│ │ aris-core │ │ Sources │ │
|
||||||
│ │ │ │ (plugins) │ │ (schemas from │ │
|
│ │ │ │ (plugins) │ │
|
||||||
│ │ - Reconciler│◄───│ - Calendar │ │ third parties)│ │
|
│ │ - FeedEngine│◄───│ - Calendar │ │
|
||||||
│ │ - Context │ │ - Weather │ │ │ │
|
│ │ - Context │ │ - Weather │ │
|
||||||
│ │ - FeedItem │ │ - Spotify │ │ │ │
|
│ │ - FeedItem │ │ - TfL │ │
|
||||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
│ │ - Actions │ │ - Spotify │ │
|
||||||
│ │ │ │
|
│ └─────────────┘ └─────────────┘ │
|
||||||
│ ▼ ▼ │
|
│ │ │
|
||||||
│ Feed (data only) UI Schemas (JSON) │
|
│ ▼ │
|
||||||
|
│ Feed items (data + ui trees + slots) │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│ │
|
│
|
||||||
▼ ▼
|
▼ (WebSocket / JSON-RPC)
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Frontend │
|
│ Client (React Native) │
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
│ │ Renderer │ │
|
│ │ json-render + twrnc component map │ │
|
||||||
│ │ - Receives feed items │ │
|
│ │ - Receives feed items with ui trees │ │
|
||||||
│ │ - Fetches UI schema by item type │ │
|
│ │ - Renders using registered RN components + twrnc │ │
|
||||||
│ │ - Renders using json-render or similar │ │
|
│ │ - User interactions trigger source actions │ │
|
||||||
|
│ │ - Bespoke native components for rich interactions │ │
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -54,15 +56,16 @@ Examples of feed items:
|
|||||||
The core is responsible for:
|
The core is responsible for:
|
||||||
|
|
||||||
- Defining the context and feed item interfaces
|
- 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
|
- Returning a flat list of prioritized feed items
|
||||||
|
- Routing action execution to the correct source
|
||||||
|
|
||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
|
||||||
- **Context**: Time and location (with accuracy) passed to all sources
|
- **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, priority, timestamp, and JSON-serializable data
|
- **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.
|
||||||
- **DataSource**: Interface that third parties implement to provide feed items
|
- **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`).
|
||||||
- **Reconciler**: Orchestrates sources, runs them in parallel, returns items and any errors
|
- **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
|
## Data Sources
|
||||||
|
|
||||||
@@ -71,10 +74,13 @@ Key decisions:
|
|||||||
- Sources receive the full context and decide internally what to use
|
- 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")
|
- 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 live in separate packages, not in the core
|
||||||
|
- Sources declare dependencies on other sources (e.g., weather depends on location)
|
||||||
- Sources are responsible for:
|
- Sources are responsible for:
|
||||||
- Transforming their domain data into feed items
|
- Transforming their domain data into feed items
|
||||||
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
|
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
|
||||||
- Returning empty arrays when nothing is relevant
|
- 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
|
### Configuration
|
||||||
|
|
||||||
@@ -83,28 +89,323 @@ Configuration is passed at source registration time, not per reconcile call. Sou
|
|||||||
## Feed Output
|
## Feed Output
|
||||||
|
|
||||||
- Flat list of `FeedItem` objects
|
- 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
|
- 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)
|
### How it works
|
||||||
2. Frontend fetches UI schemas from the backend
|
|
||||||
3. Frontend matches feed items to schemas by `type` and renders accordingly
|
|
||||||
|
|
||||||
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
|
### Styling
|
||||||
- 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
|
|
||||||
|
|
||||||
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
|
## Open Questions
|
||||||
|
|
||||||
- Exact schema format for UI registry
|
- How third parties authenticate/register their sources
|
||||||
- How third parties authenticate/register their sources and UI schemas
|
- 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
|
### Changes to FeedItem
|
||||||
|
|
||||||
One optional field added.
|
Optional fields added for actions, server-driven UI, and LLM slots.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface FeedItem<
|
interface FeedItem<
|
||||||
@@ -140,6 +140,12 @@ interface FeedItem<
|
|||||||
|
|
||||||
/** Actions the user can take on this item. */
|
/** Actions the user can take on this item. */
|
||||||
actions?: readonly ItemAction[]
|
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: "skip-track" },
|
||||||
{ actionId: "like-track", params: { trackId: track.id } },
|
{ 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)
|
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)
|
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
|
||||||
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
|
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`
|
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
|
||||||
8. `FeedEngine.listActions()` aggregates actions from all sources
|
8. `FeedEngine.listActions()` aggregates actions from all sources
|
||||||
9. Existing tests pass unchanged (all changes are additive)
|
9. Existing tests pass unchanged (all changes are additive)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Context } from "./context"
|
import type { ContextEntry } from "./context"
|
||||||
import type { ContextProvider } from "./context-provider"
|
import type { ContextProvider } from "./context-provider"
|
||||||
|
|
||||||
|
import { contextKey } from "./context"
|
||||||
|
|
||||||
interface ContextUpdatable {
|
interface ContextUpdatable {
|
||||||
pushContextUpdate(update: Partial<Context>): void
|
pushContextUpdate(entries: readonly ContextEntry[]): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderError {
|
export interface ProviderError {
|
||||||
@@ -54,7 +56,7 @@ export class ContextBridge {
|
|||||||
this.providers.set(provider.key, provider as ContextProvider)
|
this.providers.set(provider.key, provider as ContextProvider)
|
||||||
|
|
||||||
const cleanup = provider.onUpdate((value) => {
|
const cleanup = provider.onUpdate((value) => {
|
||||||
this.controller.pushContextUpdate({ [provider.key]: value })
|
this.controller.pushContextUpdate([[contextKey(provider.key), value]])
|
||||||
})
|
})
|
||||||
this.cleanups.push(cleanup)
|
this.cleanups.push(cleanup)
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ export class ContextBridge {
|
|||||||
* Returns errors from providers that failed to fetch.
|
* Returns errors from providers that failed to fetch.
|
||||||
*/
|
*/
|
||||||
async refresh(): Promise<RefreshResult> {
|
async refresh(): Promise<RefreshResult> {
|
||||||
const updates: Partial<Context> = {}
|
const collected: ContextEntry[] = []
|
||||||
const errors: ProviderError[] = []
|
const errors: ProviderError[] = []
|
||||||
|
|
||||||
const entries = Array.from(this.providers.entries())
|
const entries = Array.from(this.providers.entries())
|
||||||
@@ -78,7 +80,7 @@ export class ContextBridge {
|
|||||||
entries.forEach(([key], i) => {
|
entries.forEach(([key], i) => {
|
||||||
const result = results[i]
|
const result = results[i]
|
||||||
if (result?.status === "fulfilled") {
|
if (result?.status === "fulfilled") {
|
||||||
updates[key] = result.value
|
collected.push([contextKey(key), result.value])
|
||||||
} else if (result?.status === "rejected") {
|
} else if (result?.status === "rejected") {
|
||||||
errors.push({
|
errors.push({
|
||||||
key,
|
key,
|
||||||
@@ -87,7 +89,7 @@ export class ContextBridge {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.controller.pushContextUpdate(updates)
|
this.controller.pushContextUpdate(collected)
|
||||||
|
|
||||||
return { errors }
|
return { errors }
|
||||||
}
|
}
|
||||||
|
|||||||
184
packages/aris-core/src/context.test.ts
Normal file
184
packages/aris-core/src/context.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { ContextKey } from "./context"
|
||||||
|
|
||||||
|
import { Context, contextKey } from "./context"
|
||||||
|
|
||||||
|
interface Weather {
|
||||||
|
temperature: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NextEvent {
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "current")
|
||||||
|
const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
||||||
|
|
||||||
|
describe("Context", () => {
|
||||||
|
describe("get", () => {
|
||||||
|
test("returns undefined for missing key", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
expect(ctx.get(WeatherKey)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns value for exact key match", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const weather: Weather = { temperature: 20 }
|
||||||
|
ctx.set([[WeatherKey, weather]])
|
||||||
|
|
||||||
|
expect(ctx.get(WeatherKey)).toEqual(weather)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("distinguishes keys with different parts", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([
|
||||||
|
[WeatherKey, { temperature: 20 }],
|
||||||
|
[NextEventKey, { title: "Standup" }],
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(ctx.get(WeatherKey)).toEqual({ temperature: 20 })
|
||||||
|
expect(ctx.get(NextEventKey)).toEqual({ title: "Standup" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("last write wins for same key", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([[WeatherKey, { temperature: 20 }]])
|
||||||
|
ctx.set([[WeatherKey, { temperature: 25 }]])
|
||||||
|
|
||||||
|
expect(ctx.get(WeatherKey)).toEqual({ temperature: 25 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("find", () => {
|
||||||
|
test("returns empty array when no keys match", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
expect(ctx.find(WeatherKey)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns exact match as single result", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([[NextEventKey, { title: "Standup" }]])
|
||||||
|
|
||||||
|
const results = ctx.find(NextEventKey)
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0]!.value).toEqual({ title: "Standup" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefix match returns multiple instances", () => {
|
||||||
|
const workKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
||||||
|
account: "work",
|
||||||
|
})
|
||||||
|
const personalKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
||||||
|
account: "personal",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([
|
||||||
|
[workKey, { title: "Sprint Planning" }],
|
||||||
|
[personalKey, { title: "Dentist" }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const prefix = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
||||||
|
const results = ctx.find(prefix)
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2)
|
||||||
|
const titles = results.map((r) => r.value.title).sort()
|
||||||
|
expect(titles).toEqual(["Dentist", "Sprint Planning"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefix match includes exact match and longer keys", () => {
|
||||||
|
const baseKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
||||||
|
const instanceKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
||||||
|
account: "work",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([
|
||||||
|
[baseKey, { title: "Base" }],
|
||||||
|
[instanceKey, { title: "Instance" }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const results = ctx.find(baseKey)
|
||||||
|
expect(results).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not match keys that share a string prefix but differ at segment boundary", () => {
|
||||||
|
const keyA = contextKey<string>("aris.calendar", "next")
|
||||||
|
const keyB = contextKey<string>("aris.calendar", "nextEvent")
|
||||||
|
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([
|
||||||
|
[keyA, "a"],
|
||||||
|
[keyB, "b"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const results = ctx.find(keyA)
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0]!.value).toBe("a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("object key parts with different property order match", () => {
|
||||||
|
const key1 = contextKey<string>("source", "ctx", { b: 2, a: 1 })
|
||||||
|
const key2 = contextKey<string>("source", "ctx", { a: 1, b: 2 })
|
||||||
|
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([[key1, "value"]])
|
||||||
|
|
||||||
|
// Exact match via get should work regardless of property order
|
||||||
|
expect(ctx.get(key2)).toBe("value")
|
||||||
|
|
||||||
|
// find with the reordered key as prefix should also match
|
||||||
|
const prefix = contextKey<string>("source", "ctx")
|
||||||
|
const results = ctx.find(prefix)
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("single-segment prefix matches all keys starting with that segment", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([
|
||||||
|
[contextKey("aris.weather", "current"), { temperature: 20 }],
|
||||||
|
[contextKey("aris.weather", "forecast"), { high: 25 }],
|
||||||
|
[contextKey("aris.calendar", "nextEvent"), { title: "Meeting" }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const results = ctx.find(contextKey("aris.weather"))
|
||||||
|
expect(results).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not match shorter keys", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([[contextKey("aris.weather"), "short"]])
|
||||||
|
|
||||||
|
const results = ctx.find(contextKey("aris.weather", "current"))
|
||||||
|
expect(results).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("numeric key parts match correctly", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([
|
||||||
|
[contextKey("source", 1, "data"), "one"],
|
||||||
|
[contextKey("source", 2, "data"), "two"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const results = ctx.find(contextKey("source", 1))
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0]!.value).toBe("one")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("size", () => {
|
||||||
|
test("returns 0 for empty context", () => {
|
||||||
|
expect(new Context().size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reflects number of entries", () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.set([
|
||||||
|
[WeatherKey, { temperature: 20 }],
|
||||||
|
[NextEventKey, { title: "Standup" }],
|
||||||
|
])
|
||||||
|
expect(ctx.size).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,46 +1,131 @@
|
|||||||
/**
|
/**
|
||||||
* Branded type for type-safe context keys.
|
* Tuple-keyed context system inspired by React Query's query keys.
|
||||||
*
|
*
|
||||||
* Each package defines its own keys with associated value types:
|
* Context keys are arrays that form a hierarchy. Sources write to specific
|
||||||
* ```ts
|
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
|
||||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
* and consumers can query by exact match or prefix match to get all values
|
||||||
* ```
|
* of a given type across source instances.
|
||||||
*/
|
*/
|
||||||
export type ContextKey<T> = string & { __contextValue?: T }
|
|
||||||
|
|
||||||
/**
|
// -- Key types --
|
||||||
* Creates a typed context key.
|
|
||||||
*
|
/** A single segment of a context key: string, number, or a record of primitives. */
|
||||||
* @example
|
export type ContextKeyPart = string | number | Record<string, unknown>
|
||||||
* ```ts
|
|
||||||
* interface Location { lat: number; lng: number; accuracy: number }
|
/** A context key is a readonly tuple of parts, branded with the value type. */
|
||||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
|
||||||
* ```
|
|
||||||
*/
|
/** Creates a typed context key. */
|
||||||
export function contextKey<T>(key: string): ContextKey<T> {
|
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
|
||||||
return key as ContextKey<T>
|
return parts as ContextKey<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -- Serialization --
|
||||||
* Type-safe accessor for context values.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const location = contextValue(context, LocationKey)
|
|
||||||
* if (location) {
|
|
||||||
* console.log(location.lat, location.lng)
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
|
|
||||||
return context[key] as T | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arbitrary key-value bag representing the current state.
|
* Deterministic serialization of a context key for use as a Map key.
|
||||||
* Always includes `time`. Other keys are added by context providers.
|
* Object parts have their keys sorted for stable comparison.
|
||||||
*/
|
*/
|
||||||
export interface Context {
|
export function serializeKey(key: readonly ContextKeyPart[]): string {
|
||||||
|
return JSON.stringify(key, (_key, value) => {
|
||||||
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const sorted: Record<string, unknown> = {}
|
||||||
|
for (const k of Object.keys(value).sort()) {
|
||||||
|
sorted[k] = value[k]
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Key matching --
|
||||||
|
|
||||||
|
/** Returns true if `key` starts with all parts of `prefix`. */
|
||||||
|
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
|
||||||
|
if (key.length < prefix.length) return false
|
||||||
|
|
||||||
|
for (let i = 0; i < prefix.length; i++) {
|
||||||
|
if (!partsEqual(key[i]!, prefix[i]!)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
|
||||||
|
function partsEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
if (typeof a !== typeof b) return false
|
||||||
|
if (a && b && typeof a === "object" && typeof b === "object") {
|
||||||
|
const aKeys = Object.keys(a)
|
||||||
|
const bKeys = Object.keys(b)
|
||||||
|
if (aKeys.length !== bKeys.length) return false
|
||||||
|
return aKeys.every((key) =>
|
||||||
|
partsEqual(
|
||||||
|
(a as Record<string, unknown>)[key],
|
||||||
|
(b as Record<string, unknown>)[key],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Context store --
|
||||||
|
|
||||||
|
/** A single context entry: a key-value pair. */
|
||||||
|
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable context store with tuple keys.
|
||||||
|
*
|
||||||
|
* Supports exact-match lookups and prefix-match queries.
|
||||||
|
* Sources write context in topological order during refresh.
|
||||||
|
*/
|
||||||
|
export class Context {
|
||||||
time: Date
|
time: Date
|
||||||
[key: string]: unknown
|
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
|
||||||
|
|
||||||
|
constructor(time: Date = new Date()) {
|
||||||
|
this.time = time
|
||||||
|
this.store = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges entries into this context. */
|
||||||
|
set(entries: readonly ContextEntry[]): void {
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
this.store.set(serializeKey(key), { key, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact-match lookup. Returns the value for the given key, or undefined. */
|
||||||
|
get<T>(key: ContextKey<T>): T | undefined {
|
||||||
|
const entry = this.store.get(serializeKey(key))
|
||||||
|
return entry?.value as T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix-match query. Returns all entries whose key starts with the given prefix.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Get all "nextEvent" values across calendar source instances
|
||||||
|
* const events = context.find(contextKey("nextEvent"))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> {
|
||||||
|
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = []
|
||||||
|
|
||||||
|
for (const entry of this.store.values()) {
|
||||||
|
if (keyStartsWith(entry.key, prefix)) {
|
||||||
|
results.push({ key: entry.key, value: entry.value as T })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the number of entries (excluding time). */
|
||||||
|
get size(): number {
|
||||||
|
return this.store.size
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import type { FeedItem } from "./feed"
|
|||||||
* readonly type = "weather"
|
* readonly type = "weather"
|
||||||
*
|
*
|
||||||
* async query(context: Context): Promise<WeatherItem[]> {
|
* async query(context: Context): Promise<WeatherItem[]> {
|
||||||
* const location = contextValue(context, LocationKey)
|
* const location = context.get(LocationKey)
|
||||||
* if (!location) return []
|
* if (!location) return []
|
||||||
* const data = await fetchWeather(location)
|
* const data = await fetchWeather(location)
|
||||||
* return [{
|
* return [{
|
||||||
* id: `weather-${Date.now()}`,
|
* id: `weather-${Date.now()}`,
|
||||||
* type: this.type,
|
* type: this.type,
|
||||||
* priority: 0.5,
|
|
||||||
* timestamp: context.time,
|
* timestamp: context.time,
|
||||||
* data: { temp: data.temperature },
|
* data: { temp: data.temperature },
|
||||||
|
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||||
* }]
|
* }]
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Context } from "./context"
|
import type { ContextEntry } from "./context"
|
||||||
import type { DataSource } from "./data-source"
|
import type { DataSource } from "./data-source"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
import type { ReconcileResult } from "./reconciler"
|
import type { ReconcileResult } from "./reconciler"
|
||||||
|
|
||||||
|
import { Context } from "./context"
|
||||||
import { Reconciler } from "./reconciler"
|
import { Reconciler } from "./reconciler"
|
||||||
|
|
||||||
export interface FeedControllerConfig {
|
export interface FeedControllerConfig {
|
||||||
@@ -40,7 +41,7 @@ const DEFAULT_DEBOUNCE_MS = 100
|
|||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Context update triggers debounced reconcile
|
* // Context update triggers debounced reconcile
|
||||||
* controller.pushContextUpdate({ [LocationKey]: location })
|
* controller.pushContextUpdate([[LocationKey, location]])
|
||||||
*
|
*
|
||||||
* // Direct reconcile (no debounce)
|
* // Direct reconcile (no debounce)
|
||||||
* const result = await controller.reconcile()
|
* const result = await controller.reconcile()
|
||||||
@@ -59,7 +60,7 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
private stopped = false
|
private stopped = false
|
||||||
|
|
||||||
constructor(config?: FeedControllerConfig) {
|
constructor(config?: FeedControllerConfig) {
|
||||||
this.context = config?.initialContext ?? { time: new Date() }
|
this.context = config?.initialContext ?? new Context()
|
||||||
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
||||||
this.timeout = config?.timeout
|
this.timeout = config?.timeout
|
||||||
}
|
}
|
||||||
@@ -94,9 +95,10 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merges update into context and schedules a debounced reconcile. */
|
/** Merges entries into context and schedules a debounced reconcile. */
|
||||||
pushContextUpdate(update: Partial<Context>): void {
|
pushContextUpdate(entries: readonly ContextEntry[]): void {
|
||||||
this.context = { ...this.context, ...update, time: new Date() }
|
this.context.time = new Date()
|
||||||
|
this.context.set(entries)
|
||||||
this.scheduleReconcile()
|
this.scheduleReconcile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { FeedEngine } from "./feed-engine"
|
import { FeedEngine } from "./feed-engine"
|
||||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
function createLocationSource(): SimulatedLocationSource {
|
||||||
let callback: ((update: Partial<Context>) => void) | null = null
|
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
||||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return { [LocationKey]: currentLocation }
|
return [[LocationKey, currentLocation]]
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.({ [LocationKey]: location })
|
callback?.([[LocationKey, location]])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,27 +85,27 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = contextValue(context, LocationKey)
|
const location = context.get(LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return { [WeatherKey]: weather }
|
return [[WeatherKey, weather]]
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `weather-${Date.now()}`,
|
id: `weather-${Date.now()}`,
|
||||||
type: "weather",
|
type: "weather",
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
temperature: weather.temperature,
|
temperature: weather.temperature,
|
||||||
condition: weather.condition,
|
condition: weather.condition,
|
||||||
},
|
},
|
||||||
|
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -131,9 +131,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
{
|
{
|
||||||
id: "alert-storm",
|
id: "alert-storm",
|
||||||
type: "alert",
|
type: "alert",
|
||||||
priority: 1.0,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { message: "Storm warning!" },
|
data: { message: "Storm warning!" },
|
||||||
|
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
order.push("location")
|
||||||
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +275,9 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = contextValue(ctx, LocationKey)
|
const loc = ctx.get(LocationKey)
|
||||||
expect(loc).toBeDefined()
|
expect(loc).toBeDefined()
|
||||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +298,11 @@ describe("FeedEngine", () => {
|
|||||||
|
|
||||||
const { context } = await engine.refresh()
|
const { context } = await engine.refresh()
|
||||||
|
|
||||||
expect(contextValue(context, LocationKey)).toEqual({
|
expect(context.get(LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(contextValue(context, WeatherKey)).toEqual({
|
expect(context.get(WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -322,7 +322,7 @@ describe("FeedEngine", () => {
|
|||||||
expect(items[0]!.type).toBe("weather")
|
expect(items[0]!.type).toBe("weather")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sorts items by priority descending", async () => {
|
test("returns items in source graph order (no engine-level sorting)", async () => {
|
||||||
const location = createLocationSource()
|
const location = createLocationSource()
|
||||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
|
||||||
@@ -338,8 +338,12 @@ describe("FeedEngine", () => {
|
|||||||
const { items } = await engine.refresh()
|
const { items } = await engine.refresh()
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
expect(items).toHaveLength(2)
|
||||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
// Items returned in topological order (weather before alert)
|
||||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
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 () => {
|
test("handles missing upstream context gracefully", async () => {
|
||||||
@@ -357,7 +361,7 @@ describe("FeedEngine", () => {
|
|||||||
|
|
||||||
const { context, items } = await engine.refresh()
|
const { context, items } = await engine.refresh()
|
||||||
|
|
||||||
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
expect(context.get(WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -455,7 +459,7 @@ describe("FeedEngine", () => {
|
|||||||
await engine.refresh()
|
await engine.refresh()
|
||||||
|
|
||||||
const context = engine.currentContext()
|
const context = engine.currentContext()
|
||||||
expect(contextValue(context, LocationKey)).toEqual({
|
expect(context.get(LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
@@ -638,4 +642,290 @@ describe("FeedEngine", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("lastFeed", () => {
|
||||||
|
test("returns null before any refresh", () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
|
||||||
|
expect(engine.lastFeed()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns cached result after refresh", async () => {
|
||||||
|
const location = createLocationSource()
|
||||||
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
|
||||||
|
const weather = createWeatherSource()
|
||||||
|
const engine = new FeedEngine().register(location).register(weather)
|
||||||
|
|
||||||
|
const refreshResult = await engine.refresh()
|
||||||
|
|
||||||
|
const cached = engine.lastFeed()
|
||||||
|
expect(cached).not.toBeNull()
|
||||||
|
expect(cached!.items).toEqual(refreshResult.items)
|
||||||
|
expect(cached!.context).toEqual(refreshResult.context)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null after TTL expires", async () => {
|
||||||
|
const engine = new FeedEngine({ cacheTtlMs: 50 })
|
||||||
|
const location = createLocationSource()
|
||||||
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
|
||||||
|
engine.register(location)
|
||||||
|
await engine.refresh()
|
||||||
|
|
||||||
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||||
|
|
||||||
|
expect(engine.lastFeed()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults to 5 minute TTL", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
const location = createLocationSource()
|
||||||
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
|
||||||
|
engine.register(location)
|
||||||
|
await engine.refresh()
|
||||||
|
|
||||||
|
// Should still be cached immediately
|
||||||
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refresh always fetches from sources", async () => {
|
||||||
|
let fetchCount = 0
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "counter",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
fetchCount++
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine().register(source)
|
||||||
|
|
||||||
|
await engine.refresh()
|
||||||
|
await engine.refresh()
|
||||||
|
await engine.refresh()
|
||||||
|
|
||||||
|
expect(fetchCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reactive context update refreshes cache", async () => {
|
||||||
|
const location = createLocationSource()
|
||||||
|
const weather = createWeatherSource()
|
||||||
|
|
||||||
|
const engine = new FeedEngine({ cacheTtlMs: 5000 }).register(location).register(weather)
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
// Simulate location update which triggers reactive refresh
|
||||||
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
|
||||||
|
// Wait for async reactive refresh to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const cached = engine.lastFeed()
|
||||||
|
expect(cached).not.toBeNull()
|
||||||
|
expect(cached!.items.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reactive item update refreshes cache", async () => {
|
||||||
|
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null
|
||||||
|
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "reactive-items",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
priority: 0.5,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
onItemsUpdate(callback) {
|
||||||
|
itemUpdateCallback = callback
|
||||||
|
return () => {
|
||||||
|
itemUpdateCallback = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine().register(source)
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
// Trigger item update
|
||||||
|
itemUpdateCallback!([])
|
||||||
|
|
||||||
|
// Wait for async refresh
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const cached = engine.lastFeed()
|
||||||
|
expect(cached).not.toBeNull()
|
||||||
|
expect(cached!.items).toHaveLength(1)
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("TTL resets after reactive update", async () => {
|
||||||
|
const location = createLocationSource()
|
||||||
|
const weather = createWeatherSource()
|
||||||
|
|
||||||
|
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
// Initial reactive update
|
||||||
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
// Wait 70ms (total 120ms from first update, past original TTL)
|
||||||
|
// but trigger another update at 50ms to reset TTL
|
||||||
|
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
// Should still be cached because TTL was reset by second update
|
||||||
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cacheTtlMs is configurable", async () => {
|
||||||
|
const engine = new FeedEngine({ cacheTtlMs: 30 })
|
||||||
|
const location = createLocationSource()
|
||||||
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
|
||||||
|
engine.register(location)
|
||||||
|
await engine.refresh()
|
||||||
|
|
||||||
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||||
|
|
||||||
|
expect(engine.lastFeed()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("auto-refreshes on TTL interval after start", async () => {
|
||||||
|
let fetchCount = 0
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "counter",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
fetchCount++
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `item-${fetchCount}`,
|
||||||
|
type: "test",
|
||||||
|
priority: 0.5,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
// Wait for two TTL intervals to elapse
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||||
|
|
||||||
|
// Should have auto-refreshed at least twice
|
||||||
|
expect(fetchCount).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stop cancels periodic refresh", async () => {
|
||||||
|
let fetchCount = 0
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "counter",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
fetchCount++
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
|
||||||
|
engine.start()
|
||||||
|
engine.stop()
|
||||||
|
|
||||||
|
const countAfterStop = fetchCount
|
||||||
|
|
||||||
|
// Wait past TTL
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
|
// No additional fetches after stop
|
||||||
|
expect(fetchCount).toBe(countAfterStop)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reactive update resets periodic refresh timer", async () => {
|
||||||
|
let fetchCount = 0
|
||||||
|
const location = createLocationSource()
|
||||||
|
const countingWeather: FeedSource<WeatherFeedItem> = {
|
||||||
|
id: "weather",
|
||||||
|
dependencies: ["location"],
|
||||||
|
...noActions,
|
||||||
|
async fetchContext(ctx) {
|
||||||
|
fetchCount++
|
||||||
|
const loc = ctx.get(LocationKey)
|
||||||
|
if (!loc) return null
|
||||||
|
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
||||||
|
},
|
||||||
|
async fetchItems(ctx) {
|
||||||
|
const weather = ctx.get(WeatherKey)
|
||||||
|
if (!weather) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `weather-${Date.now()}`,
|
||||||
|
type: "weather",
|
||||||
|
priority: 0.5,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { temperature: weather.temperature, condition: weather.condition },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine({ cacheTtlMs: 100 })
|
||||||
|
.register(location)
|
||||||
|
.register(countingWeather)
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
// At 40ms, push a reactive update — this resets the timer
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||||
|
const countBeforeUpdate = fetchCount
|
||||||
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
|
|
||||||
|
// Reactive update triggered a fetch
|
||||||
|
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
|
||||||
|
const countAfterUpdate = fetchCount
|
||||||
|
|
||||||
|
// At 100ms from start (60ms after reactive update), the original
|
||||||
|
// timer would have fired, but it was reset. No extra fetch yet.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||||
|
expect(fetchCount).toBe(countAfterUpdate)
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context } from "./context"
|
import type { ContextEntry } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
|
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||||
import type { FeedSource } from "./feed-source"
|
import type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
|
import { Context } from "./context"
|
||||||
|
|
||||||
export interface SourceError {
|
export interface SourceError {
|
||||||
sourceId: string
|
sourceId: string
|
||||||
error: Error
|
error: Error
|
||||||
@@ -12,10 +15,20 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
|
|||||||
context: Context
|
context: Context
|
||||||
items: TItem[]
|
items: TItem[]
|
||||||
errors: SourceError[]
|
errors: SourceError[]
|
||||||
|
/** Item groups produced by post-processors */
|
||||||
|
groupedItems?: ItemGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
||||||
|
|
||||||
|
const DEFAULT_CACHE_TTL_MS = 300_000 // 5 minutes
|
||||||
|
const MIN_CACHE_TTL_MS = 10 // prevent spin from zero/negative values
|
||||||
|
|
||||||
|
export interface FeedEngineConfig {
|
||||||
|
/** Cache TTL in milliseconds. Default: 300_000 (5 minutes). Minimum: 10. */
|
||||||
|
cacheTtlMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface SourceGraph {
|
interface SourceGraph {
|
||||||
sources: Map<string, FeedSource>
|
sources: Map<string, FeedSource>
|
||||||
sorted: FeedSource[]
|
sorted: FeedSource[]
|
||||||
@@ -54,10 +67,34 @@ interface SourceGraph {
|
|||||||
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
private graph: SourceGraph | null = null
|
private graph: SourceGraph | null = null
|
||||||
private context: Context = { time: new Date() }
|
private context: Context = new Context()
|
||||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||||
private cleanups: Array<() => void> = []
|
private cleanups: Array<() => void> = []
|
||||||
private started = false
|
private started = false
|
||||||
|
private postProcessors: FeedPostProcessor[] = []
|
||||||
|
|
||||||
|
private readonly cacheTtlMs: number
|
||||||
|
private cachedResult: FeedResult<TItems> | null = null
|
||||||
|
private cachedAt: number | null = null
|
||||||
|
private refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
constructor(config?: FeedEngineConfig) {
|
||||||
|
this.cacheTtlMs = Math.max(config?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, MIN_CACHE_TTL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached FeedResult if available and not expired.
|
||||||
|
* Returns null if no refresh has completed or the cache TTL has elapsed.
|
||||||
|
*/
|
||||||
|
lastFeed(): FeedResult<TItems> | null {
|
||||||
|
if (this.cachedResult === null || this.cachedAt === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (Date.now() - this.cachedAt > this.cacheTtlMs) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.cachedResult
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a FeedSource. Invalidates the cached graph.
|
* Registers a FeedSource. Invalidates the cached graph.
|
||||||
@@ -77,6 +114,23 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a post-processor. Processors run in registration order
|
||||||
|
* after items are collected, on every update path.
|
||||||
|
*/
|
||||||
|
registerPostProcessor(processor: FeedPostProcessor): this {
|
||||||
|
this.postProcessors.push(processor)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters a post-processor by reference.
|
||||||
|
*/
|
||||||
|
unregisterPostProcessor(processor: FeedPostProcessor): this {
|
||||||
|
this.postProcessors = this.postProcessors.filter((p) => p !== processor)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes the feed by running all sources in dependency order.
|
* Refreshes the feed by running all sources in dependency order.
|
||||||
* Calls fetchContext() then fetchItems() on each source.
|
* Calls fetchContext() then fetchItems() on each source.
|
||||||
@@ -86,14 +140,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const errors: SourceError[] = []
|
const errors: SourceError[] = []
|
||||||
|
|
||||||
// Reset context with fresh time
|
// Reset context with fresh time
|
||||||
let context: Context = { time: new Date() }
|
const context = new Context()
|
||||||
|
|
||||||
// Run fetchContext in topological order
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
try {
|
try {
|
||||||
const update = await source.fetchContext(context)
|
const entries = await source.fetchContext(context)
|
||||||
if (update) {
|
if (entries) {
|
||||||
context = { ...context, ...update }
|
context.set(entries)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -119,12 +173,23 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority descending
|
|
||||||
items.sort((a, b) => b.priority - a.priority)
|
|
||||||
|
|
||||||
this.context = context
|
this.context = context
|
||||||
|
|
||||||
return { 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,7 +203,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts reactive subscriptions on all sources.
|
* Starts reactive subscriptions on all sources and begins periodic refresh.
|
||||||
* Sources with onContextUpdate will trigger re-computation of dependents.
|
* Sources with onContextUpdate will trigger re-computation of dependents.
|
||||||
*/
|
*/
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -150,8 +215,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
if (source.onContextUpdate) {
|
if (source.onContextUpdate) {
|
||||||
const cleanup = source.onContextUpdate(
|
const cleanup = source.onContextUpdate(
|
||||||
(update) => {
|
(entries) => {
|
||||||
this.handleContextUpdate(source.id, update)
|
this.handleContextUpdate(source.id, entries)
|
||||||
},
|
},
|
||||||
() => this.context,
|
() => this.context,
|
||||||
)
|
)
|
||||||
@@ -168,13 +233,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
this.cleanups.push(cleanup)
|
this.cleanups.push(cleanup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.scheduleNextRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops all reactive subscriptions.
|
* Stops all reactive subscriptions and the periodic refresh timer.
|
||||||
*/
|
*/
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.started = false
|
this.started = false
|
||||||
|
this.cancelScheduledRefresh()
|
||||||
for (const cleanup of this.cleanups) {
|
for (const cleanup of this.cleanups) {
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
@@ -226,6 +294,72 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyPostProcessors(
|
||||||
|
items: TItems[],
|
||||||
|
context: Context,
|
||||||
|
errors: SourceError[],
|
||||||
|
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
|
||||||
|
let currentItems = items
|
||||||
|
const allGroupedItems: ItemGroup[] = []
|
||||||
|
const allErrors = [...errors]
|
||||||
|
const boostScores = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const processor of this.postProcessors) {
|
||||||
|
const snapshot = currentItems
|
||||||
|
try {
|
||||||
|
const enhancement = await processor(currentItems, context)
|
||||||
|
|
||||||
|
if (enhancement.additionalItems?.length) {
|
||||||
|
// Post-processors operate on FeedItem[] without knowledge of TItems.
|
||||||
|
// Additional items are merged untyped — this is intentional. The
|
||||||
|
// processor contract is "FeedItem in, FeedItem out"; type narrowing
|
||||||
|
// is the caller's responsibility when consuming FeedResult.
|
||||||
|
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enhancement.suppress?.length) {
|
||||||
|
const suppressSet = new Set(enhancement.suppress)
|
||||||
|
currentItems = currentItems.filter((item) => !suppressSet.has(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enhancement.groupedItems?.length) {
|
||||||
|
allGroupedItems.push(...enhancement.groupedItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enhancement.boost) {
|
||||||
|
for (const [id, score] of Object.entries(enhancement.boost)) {
|
||||||
|
boostScores.set(id, (boostScores.get(id) ?? 0) + score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const sourceId = processor.name || "anonymous"
|
||||||
|
allErrors.push({
|
||||||
|
sourceId,
|
||||||
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
|
})
|
||||||
|
currentItems = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply boost reordering: positive-boost first (desc), then zero, then negative (desc).
|
||||||
|
// Stable sort within each tier preserves original relative order.
|
||||||
|
if (boostScores.size > 0) {
|
||||||
|
currentItems = applyBoostOrder(currentItems, boostScores)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale item IDs from groups and drop empty groups
|
||||||
|
const itemIds = new Set(currentItems.map((item) => item.id))
|
||||||
|
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
|
||||||
|
const ids = group.itemIds.filter((id) => itemIds.has(id))
|
||||||
|
if (ids.length > 0) {
|
||||||
|
acc.push({ ...group, itemIds: ids })
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
|
||||||
|
}
|
||||||
|
|
||||||
private ensureGraph(): SourceGraph {
|
private ensureGraph(): SourceGraph {
|
||||||
if (!this.graph) {
|
if (!this.graph) {
|
||||||
this.graph = buildGraph(Array.from(this.sources.values()))
|
this.graph = buildGraph(Array.from(this.sources.values()))
|
||||||
@@ -233,8 +367,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return this.graph
|
return this.graph
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
|
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void {
|
||||||
this.context = { ...this.context, ...update, time: new Date() }
|
this.context.time = new Date()
|
||||||
|
this.context.set(entries)
|
||||||
|
|
||||||
// Re-run dependents and notify
|
// Re-run dependents and notify
|
||||||
this.refreshDependents(sourceId)
|
this.refreshDependents(sourceId)
|
||||||
@@ -249,9 +384,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const source = graph.sources.get(id)
|
const source = graph.sources.get(id)
|
||||||
if (source) {
|
if (source) {
|
||||||
try {
|
try {
|
||||||
const update = await source.fetchContext(this.context)
|
const entries = await source.fetchContext(this.context)
|
||||||
if (update) {
|
if (entries) {
|
||||||
this.context = { ...this.context, ...update }
|
this.context.set(entries)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Errors during reactive updates are logged but don't stop propagation
|
// Errors during reactive updates are logged but don't stop propagation
|
||||||
@@ -277,13 +412,21 @@ 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)
|
||||||
|
|
||||||
this.notifySubscribers({
|
const result: FeedResult<TItems> = {
|
||||||
context: this.context,
|
context: this.context,
|
||||||
items: items as TItems[],
|
items: processedItems,
|
||||||
errors,
|
errors: postProcessorErrors,
|
||||||
})
|
...(groupedItems.length > 0 ? { groupedItems } : {}),
|
||||||
|
}
|
||||||
|
this.updateCache(result)
|
||||||
|
|
||||||
|
this.notifySubscribers(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
|
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
|
||||||
@@ -307,11 +450,46 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
|
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateCache(result: FeedResult<TItems>): void {
|
||||||
|
this.cachedResult = result
|
||||||
|
this.cachedAt = Date.now()
|
||||||
|
if (this.started) {
|
||||||
|
this.scheduleNextRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextRefresh(): void {
|
||||||
|
this.cancelScheduledRefresh()
|
||||||
|
this.refreshTimer = setTimeout(() => {
|
||||||
|
this.refresh()
|
||||||
|
.then((result) => {
|
||||||
|
this.notifySubscribers(result)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Periodic refresh errors are non-fatal; schedule next attempt
|
||||||
|
if (this.started) {
|
||||||
|
this.scheduleNextRefresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, this.cacheTtlMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelScheduledRefresh(): void {
|
||||||
|
if (this.refreshTimer !== null) {
|
||||||
|
clearTimeout(this.refreshTimer)
|
||||||
|
this.refreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleRefresh(): void {
|
private scheduleRefresh(): void {
|
||||||
// Simple immediate refresh for now - could add debouncing later
|
// Simple immediate refresh for now - could add debouncing later
|
||||||
this.refresh().then((result) => {
|
this.refresh()
|
||||||
this.notifySubscribers(result)
|
.then((result) => {
|
||||||
})
|
this.notifySubscribers(result)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Reactive refresh errors are non-fatal
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifySubscribers(result: FeedResult<TItems>): void {
|
private notifySubscribers(result: FeedResult<TItems>): void {
|
||||||
@@ -325,6 +503,47 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBoostOrder<T extends FeedItem>(items: T[], boostScores: Map<string, number>): T[] {
|
||||||
|
const positive: T[] = []
|
||||||
|
const neutral: T[] = []
|
||||||
|
const negative: T[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const raw = boostScores.get(item.id)
|
||||||
|
if (raw === undefined || raw === 0) {
|
||||||
|
neutral.push(item)
|
||||||
|
} else {
|
||||||
|
const clamped = clamp(raw, -1, 1)
|
||||||
|
if (clamped > 0) {
|
||||||
|
positive.push(item)
|
||||||
|
} else if (clamped < 0) {
|
||||||
|
negative.push(item)
|
||||||
|
} else {
|
||||||
|
neutral.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort positive descending by boost, negative descending (least negative first, most negative last)
|
||||||
|
positive.sort((a, b) => {
|
||||||
|
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
||||||
|
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
||||||
|
return bScore - aScore
|
||||||
|
})
|
||||||
|
|
||||||
|
negative.sort((a, b) => {
|
||||||
|
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
||||||
|
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
||||||
|
return bScore - aScore
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...positive, ...neutral, ...negative]
|
||||||
|
}
|
||||||
|
|
||||||
function buildGraph(sources: FeedSource[]): SourceGraph {
|
function buildGraph(sources: FeedSource[]): SourceGraph {
|
||||||
const byId = new Map<string, FeedSource>()
|
const byId = new Map<string, FeedSource>()
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
|
|||||||
602
packages/aris-core/src/feed-post-processor.test.ts
Normal file
602
packages/aris-core/src/feed-post-processor.test.ts
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ActionDefinition,
|
||||||
|
ContextEntry,
|
||||||
|
FeedItem,
|
||||||
|
FeedPostProcessor,
|
||||||
|
FeedSource,
|
||||||
|
} from "./index"
|
||||||
|
|
||||||
|
import { FeedEngine } from "./feed-engine"
|
||||||
|
import { UnknownActionError } from "./index"
|
||||||
|
|
||||||
|
// No-op action methods for test sources
|
||||||
|
const noActions = {
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(actionId: string): Promise<void> {
|
||||||
|
throw new UnknownActionError(actionId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FEED ITEMS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type WeatherItem = FeedItem<"weather", { temp: number }>
|
||||||
|
type CalendarItem = FeedItem<"calendar", { title: string }>
|
||||||
|
|
||||||
|
function weatherItem(id: string, temp: number): WeatherItem {
|
||||||
|
return { id, type: "weather", timestamp: new Date(), data: { temp } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarItem(id: string, title: string): CalendarItem {
|
||||||
|
return { id, type: "calendar", timestamp: new Date(), data: { title } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TEST SOURCES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function createWeatherSource(items: WeatherItem[]) {
|
||||||
|
return {
|
||||||
|
id: "aris.weather",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems(): Promise<WeatherItem[]> {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCalendarSource(items: CalendarItem[]) {
|
||||||
|
return {
|
||||||
|
id: "aris.calendar",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems(): Promise<CalendarItem[]> {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGISTRATION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("FeedPostProcessor", () => {
|
||||||
|
describe("registration", () => {
|
||||||
|
test("registerPostProcessor is chainable", () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
const processor: FeedPostProcessor = async () => ({})
|
||||||
|
const result = engine.registerPostProcessor(processor)
|
||||||
|
expect(result).toBe(engine)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unregisterPostProcessor is chainable", () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
const processor: FeedPostProcessor = async () => ({})
|
||||||
|
const result = engine.unregisterPostProcessor(processor)
|
||||||
|
expect(result).toBe(engine)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unregistered processor does not run", async () => {
|
||||||
|
const processor = mock(async () => ({}))
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(processor)
|
||||||
|
.unregisterPostProcessor(processor)
|
||||||
|
|
||||||
|
await engine.refresh()
|
||||||
|
expect(processor).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ADDITIONAL ITEMS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("additionalItems", () => {
|
||||||
|
test("injects additional items into the feed", async () => {
|
||||||
|
const extra = calendarItem("c1", "Meeting")
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({ additionalItems: [extra] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items.find((i) => i.id === "c1")).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SUPPRESS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("suppress", () => {
|
||||||
|
test("removes suppressed items from the feed", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0].id).toBe("w2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppressing nonexistent ID is a no-op", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["nonexistent"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GROUPED ITEMS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("groupedItems", () => {
|
||||||
|
test("accumulates grouped items on FeedResult", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toEqual([{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("multiple processors accumulate groups", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1"], summary: "Group A" }],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c2"], summary: "Group B" }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toEqual([
|
||||||
|
{ itemIds: ["c1"], summary: "Group A" },
|
||||||
|
{ itemIds: ["c2"], summary: "Group B" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stale item IDs are removed from groups after suppression", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groups with all items suppressed are dropped", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groupedItems is omitted when no processors produce groups", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BOOST
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("boost", () => {
|
||||||
|
test("positive boost moves item before non-boosted items", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ boost: { w2: 0.8 } }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("negative boost moves item after non-boosted items", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ boost: { w1: -0.5 } }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("multiple boosted items are sorted by boost descending", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createWeatherSource([
|
||||||
|
weatherItem("w1", 20),
|
||||||
|
weatherItem("w2", 25),
|
||||||
|
weatherItem("w3", 30),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
boost: { w3: 0.3, w1: 0.9 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
// w1 (0.9) first, w3 (0.3) second, w2 (no boost) last
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("multiple processors accumulate boost scores", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ boost: { w1: 0.3 } }))
|
||||||
|
.registerPostProcessor(async () => ({ boost: { w1: 0.4 } }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
// w1 accumulated boost = 0.7, moves before w2
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accumulated boost is clamped to [-1, 1]", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createWeatherSource([
|
||||||
|
weatherItem("w1", 20),
|
||||||
|
weatherItem("w2", 25),
|
||||||
|
weatherItem("w3", 30),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({ boost: { w1: 0.8, w2: 0.9 } }))
|
||||||
|
.registerPostProcessor(async () => ({ boost: { w1: 0.8 } }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
// w1 accumulated = 1.6 clamped to 1, w2 = 0.9 — w1 still first
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2", "w3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("out-of-range boost values are clamped", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ boost: { w1: 5.0 } }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
// Clamped to 1, still boosted to front
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boosting a suppressed item is a no-op", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
suppress: ["w1"],
|
||||||
|
boost: { w1: 1.0 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0].id).toBe("w2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boosting a nonexistent item ID is a no-op", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({ boost: { nonexistent: 1.0 } }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0].id).toBe("w1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("items with equal boost retain original relative order", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createWeatherSource([
|
||||||
|
weatherItem("w1", 20),
|
||||||
|
weatherItem("w2", 25),
|
||||||
|
weatherItem("w3", 30),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
boost: { w1: 0.5, w3: 0.5 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
// w1 and w3 have equal boost — original order preserved: w1 before w3
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("negative boosts preserve relative order among demoted items", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createWeatherSource([
|
||||||
|
weatherItem("w1", 20),
|
||||||
|
weatherItem("w2", 25),
|
||||||
|
weatherItem("w3", 30),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
boost: { w1: -0.3, w2: -0.3 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
// w3 (neutral) first, then w1 and w2 (equal negative) in original order
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w3", "w1", "w2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boost works alongside additionalItems and groupedItems", async () => {
|
||||||
|
const extra = calendarItem("c1", "Meeting")
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
additionalItems: [extra],
|
||||||
|
boost: { c1: 1.0 },
|
||||||
|
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
// c1 boosted to front
|
||||||
|
expect(result.items[0].id).toBe("c1")
|
||||||
|
expect(result.items).toHaveLength(3)
|
||||||
|
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PIPELINE ORDERING
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("pipeline ordering", () => {
|
||||||
|
test("each processor sees items as modified by the previous processor", async () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
additionalItems: [calendarItem("c1", "Injected")],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async (items) => {
|
||||||
|
seen.push(...items.map((i) => i.id))
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
await engine.refresh()
|
||||||
|
expect(seen).toEqual(["w1", "c1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppression in first processor affects second processor", async () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
|
||||||
|
.registerPostProcessor(async (items) => {
|
||||||
|
seen.push(...items.map((i) => i.id))
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
await engine.refresh()
|
||||||
|
expect(seen).toEqual(["w2"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ERROR HANDLING
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
test("throwing processor is recorded in errors and pipeline continues", async () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
async function failingProcessor(): Promise<never> {
|
||||||
|
throw new Error("processor failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(failingProcessor)
|
||||||
|
.registerPostProcessor(async (items) => {
|
||||||
|
seen.push(...items.map((i) => i.id))
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
|
||||||
|
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
|
||||||
|
expect(ppError).toBeDefined()
|
||||||
|
expect(ppError!.error.message).toBe("processor failed")
|
||||||
|
|
||||||
|
// Pipeline continued — observer still saw the original item
|
||||||
|
expect(seen).toEqual(["w1"])
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("anonymous throwing processor uses 'anonymous' as sourceId", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => {
|
||||||
|
throw new Error("anon failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
const ppError = result.errors.find((e) => e.sourceId === "anonymous")
|
||||||
|
expect(ppError).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-Error throw is wrapped", async () => {
|
||||||
|
async function failingProcessor(): Promise<never> {
|
||||||
|
throw "string error"
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(failingProcessor)
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
|
||||||
|
expect(ppError).toBeDefined()
|
||||||
|
expect(ppError!.error).toBeInstanceOf(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REACTIVE PATHS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("reactive updates", () => {
|
||||||
|
test("post-processors run during reactive context updates", async () => {
|
||||||
|
let callCount = 0
|
||||||
|
|
||||||
|
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null
|
||||||
|
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "aris.reactive",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return [weatherItem("w1", 20)]
|
||||||
|
},
|
||||||
|
onContextUpdate(callback, _getContext) {
|
||||||
|
triggerUpdate = callback
|
||||||
|
return () => {
|
||||||
|
triggerUpdate = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
||||||
|
callCount++
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
// Wait for initial periodic refresh
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
const countAfterStart = callCount
|
||||||
|
|
||||||
|
// Trigger a reactive context update
|
||||||
|
triggerUpdate!([])
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("post-processors run during reactive item updates", async () => {
|
||||||
|
let callCount = 0
|
||||||
|
|
||||||
|
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null
|
||||||
|
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "aris.reactive",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return [weatherItem("w1", 20)]
|
||||||
|
},
|
||||||
|
onItemsUpdate(callback, _getContext) {
|
||||||
|
triggerItemsUpdate = callback
|
||||||
|
return () => {
|
||||||
|
triggerItemsUpdate = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
||||||
|
callCount++
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
const countAfterStart = callCount
|
||||||
|
|
||||||
|
// Trigger a reactive items update
|
||||||
|
triggerItemsUpdate!([weatherItem("w1", 25)])
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NO PROCESSORS = NO CHANGE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("no processors", () => {
|
||||||
|
test("engine without post-processors returns raw items unchanged", async () => {
|
||||||
|
const items = [weatherItem("w1", 20), weatherItem("w2", 25)]
|
||||||
|
const engine = new FeedEngine().register(createWeatherSource(items))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items[0].id).toBe("w1")
|
||||||
|
expect(result.items[1].id).toBe("w2")
|
||||||
|
expect(result.groupedItems).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMBINED ENHANCEMENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("combined enhancement", () => {
|
||||||
|
test("single processor can use all enhancement fields at once", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
additionalItems: [calendarItem("c1", "Injected")],
|
||||||
|
suppress: ["w2"],
|
||||||
|
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
|
||||||
|
// w2 suppressed, c1 injected → w1 + c1
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w1", "c1"])
|
||||||
|
|
||||||
|
// Groups on result
|
||||||
|
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
26
packages/aris-core/src/feed-post-processor.ts
Normal file
26
packages/aris-core/src/feed-post-processor.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Context } from "./context"
|
||||||
|
import type { FeedItem } from "./feed"
|
||||||
|
|
||||||
|
export interface ItemGroup {
|
||||||
|
/** IDs of items to present together */
|
||||||
|
itemIds: string[]
|
||||||
|
/** Summary text for the group */
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedEnhancement {
|
||||||
|
/** New items to inject into the feed */
|
||||||
|
additionalItems?: FeedItem[]
|
||||||
|
/** Groups of items to present together with a summary */
|
||||||
|
groupedItems?: ItemGroup[]
|
||||||
|
/** Item IDs to remove from the feed */
|
||||||
|
suppress?: string[]
|
||||||
|
/** Map of item ID to boost score (-1 to 1). Positive promotes, negative demotes. */
|
||||||
|
boost?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that transforms feed items and produces enhancement directives.
|
||||||
|
* Use named functions for meaningful error attribution.
|
||||||
|
*/
|
||||||
|
export type FeedPostProcessor = (items: FeedItem[], context: Context) => Promise<FeedEnhancement>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
function createLocationSource(): SimulatedLocationSource {
|
||||||
let callback: ((update: Partial<Context>) => void) | null = null
|
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
||||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return { [LocationKey]: currentLocation }
|
return [[LocationKey, currentLocation]]
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.({ [LocationKey]: location })
|
callback?.([[LocationKey, location]])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,27 +84,27 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = contextValue(context, LocationKey)
|
const location = context.get(LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return { [WeatherKey]: weather }
|
return [[WeatherKey, weather]]
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `weather-${Date.now()}`,
|
id: `weather-${Date.now()}`,
|
||||||
type: "weather",
|
type: "weather",
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
temperature: weather.temperature,
|
temperature: weather.temperature,
|
||||||
condition: weather.condition,
|
condition: weather.condition,
|
||||||
},
|
},
|
||||||
|
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -130,9 +130,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
{
|
{
|
||||||
id: "alert-storm",
|
id: "alert-storm",
|
||||||
type: "alert",
|
type: "alert",
|
||||||
priority: 1.0,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { message: "Storm warning!" },
|
data: { message: "Storm warning!" },
|
||||||
|
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
||||||
let context: Context = { time: new Date() }
|
const context = new Context()
|
||||||
|
|
||||||
// Run fetchContext in topological order
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
const update = await source.fetchContext(context)
|
const entries = await source.fetchContext(context)
|
||||||
if (update) {
|
if (entries) {
|
||||||
context = { ...context, ...update }
|
context.set(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }
|
return { context, items }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +265,7 @@ describe("FeedSource", () => {
|
|||||||
|
|
||||||
test("source without context returns null from fetchContext", async () => {
|
test("source without context returns null from fetchContext", async () => {
|
||||||
const source = createAlertSource()
|
const source = createAlertSource()
|
||||||
const result = await source.fetchContext({ time: new Date() })
|
const result = await source.fetchContext(new Context())
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -372,7 +369,7 @@ describe("FeedSource", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
order.push("location")
|
||||||
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,9 +379,9 @@ describe("FeedSource", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = contextValue(ctx, LocationKey)
|
const loc = ctx.get(LocationKey)
|
||||||
expect(loc).toBeDefined()
|
expect(loc).toBeDefined()
|
||||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,11 +400,11 @@ describe("FeedSource", () => {
|
|||||||
const graph = buildGraph([location, weather])
|
const graph = buildGraph([location, weather])
|
||||||
const { context } = await refreshGraph(graph)
|
const { context } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(contextValue(context, LocationKey)).toEqual({
|
expect(context.get(LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(contextValue(context, WeatherKey)).toEqual({
|
expect(context.get(WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -441,17 +438,19 @@ describe("FeedSource", () => {
|
|||||||
const { items } = await refreshGraph(graph)
|
const { items } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
expect(items).toHaveLength(2)
|
||||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
// Items returned in topological order (weather before alert)
|
||||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
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 () => {
|
test("source without location context returns empty items", async () => {
|
||||||
// Location source exists but hasn't been updated
|
|
||||||
const location: FeedSource = {
|
const location: FeedSource = {
|
||||||
id: "location",
|
id: "location",
|
||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
// Simulate no location available
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -461,7 +460,7 @@ describe("FeedSource", () => {
|
|||||||
const graph = buildGraph([location, weather])
|
const graph = buildGraph([location, weather])
|
||||||
const { context, items } = await refreshGraph(graph)
|
const { context, items } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
expect(context.get(WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -475,7 +474,7 @@ describe("FeedSource", () => {
|
|||||||
() => {
|
() => {
|
||||||
updateCount++
|
updateCount++
|
||||||
},
|
},
|
||||||
() => ({ time: new Date() }),
|
() => new Context(),
|
||||||
)
|
)
|
||||||
|
|
||||||
location.simulateUpdate({ lat: 1, lng: 1 })
|
location.simulateUpdate({ lat: 1, lng: 1 })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context } from "./context"
|
import type { Context, ContextEntry } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Maps to: source/contextUpdated (notification, source → host)
|
* Maps to: source/contextUpdated (notification, source → host)
|
||||||
*/
|
*/
|
||||||
onContextUpdate?(
|
onContextUpdate?(
|
||||||
callback: (update: Partial<Context>) => void,
|
callback: (entries: readonly ContextEntry[]) => void,
|
||||||
getContext: () => Context,
|
getContext: () => Context,
|
||||||
): () => void
|
): () => void
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Return null if this source cannot provide context.
|
* Return null if this source cannot provide context.
|
||||||
* Maps to: source/fetchContext
|
* Maps to: source/fetchContext
|
||||||
*/
|
*/
|
||||||
fetchContext(context: Context): Promise<Partial<Context> | null>
|
fetchContext(context: Context): Promise<readonly ContextEntry[] | null>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to reactive feed item updates.
|
* Subscribe to reactive feed item updates.
|
||||||
|
|||||||
87
packages/aris-core/src/feed.test.ts
Normal file
87
packages/aris-core/src/feed.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { FeedItem, Slot } from "./feed"
|
||||||
|
|
||||||
|
describe("FeedItem slots", () => {
|
||||||
|
test("FeedItem without slots is valid", () => {
|
||||||
|
const item: FeedItem<"test", { value: number }> = {
|
||||||
|
id: "test-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { value: 42 },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(item.slots).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("FeedItem with unfilled slots", () => {
|
||||||
|
const item: FeedItem<"weather", { temp: number }> = {
|
||||||
|
id: "weather-1",
|
||||||
|
type: "weather",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { temp: 18 },
|
||||||
|
slots: {
|
||||||
|
insight: {
|
||||||
|
description: "A short contextual insight about the current weather",
|
||||||
|
content: null,
|
||||||
|
},
|
||||||
|
"cross-source": {
|
||||||
|
description: "Connection between weather and calendar events",
|
||||||
|
content: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(item.slots).toBeDefined()
|
||||||
|
expect(Object.keys(item.slots!)).toEqual(["insight", "cross-source"])
|
||||||
|
expect(item.slots!.insight!.content).toBeNull()
|
||||||
|
expect(item.slots!["cross-source"]!.content).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("FeedItem with filled slots", () => {
|
||||||
|
const item: FeedItem<"weather", { temp: number }> = {
|
||||||
|
id: "weather-1",
|
||||||
|
type: "weather",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { temp: 18 },
|
||||||
|
slots: {
|
||||||
|
insight: {
|
||||||
|
description: "A short contextual insight about the current weather",
|
||||||
|
content: "Rain after 3pm — grab a jacket before your walk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(item.slots!.insight!.content).toBe("Rain after 3pm — grab a jacket before your walk")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Slot interface enforces required fields", () => {
|
||||||
|
const slot: Slot = {
|
||||||
|
description: "Test slot description",
|
||||||
|
content: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(slot.description).toBe("Test slot description")
|
||||||
|
expect(slot.content).toBeNull()
|
||||||
|
|
||||||
|
const filledSlot: Slot = {
|
||||||
|
description: "Test slot description",
|
||||||
|
content: "Filled content",
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(filledSlot.content).toBe("Filled content")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("FeedItem with empty slots record", () => {
|
||||||
|
const item: FeedItem<"test", { value: number }> = {
|
||||||
|
id: "test-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { value: 1 },
|
||||||
|
slots: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(item.slots).toEqual({})
|
||||||
|
expect(Object.keys(item.slots!)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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 named slot for LLM-fillable content on a feed item.
|
||||||
|
*
|
||||||
|
* Sources declare slots with a description that tells the LLM what content
|
||||||
|
* to generate. The enhancement harness fills `content` asynchronously;
|
||||||
|
* until then it remains `null`.
|
||||||
|
*/
|
||||||
|
export interface Slot {
|
||||||
|
/** Tells the LLM what this slot wants — written by the source */
|
||||||
|
description: string
|
||||||
|
/** LLM-filled text content, null until enhanced */
|
||||||
|
content: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single item in the feed.
|
* A single item in the feed.
|
||||||
*
|
*
|
||||||
@@ -8,9 +47,15 @@
|
|||||||
* const item: WeatherItem = {
|
* const item: WeatherItem = {
|
||||||
* id: "weather-123",
|
* id: "weather-123",
|
||||||
* type: "weather",
|
* type: "weather",
|
||||||
* priority: 0.5,
|
|
||||||
* timestamp: new Date(),
|
* timestamp: new Date(),
|
||||||
* data: { temp: 18, condition: "cloudy" },
|
* data: { temp: 18, condition: "cloudy" },
|
||||||
|
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||||
|
* slots: {
|
||||||
|
* insight: {
|
||||||
|
* description: "A short contextual insight about the current weather",
|
||||||
|
* content: null,
|
||||||
|
* },
|
||||||
|
* },
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -22,10 +67,12 @@ export interface FeedItem<
|
|||||||
id: string
|
id: string
|
||||||
/** Item type, matches the data source type */
|
/** Item type, matches the data source type */
|
||||||
type: TType
|
type: TType
|
||||||
/** Sort priority (higher = more important, shown first) */
|
|
||||||
priority: number
|
|
||||||
/** When this item was generated */
|
/** When this item was generated */
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
/** Type-specific payload */
|
/** Type-specific payload */
|
||||||
data: TData
|
data: TData
|
||||||
|
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
|
||||||
|
signals?: FeedItemSignals
|
||||||
|
/** Named slots for LLM-fillable content. Keys are slot names. */
|
||||||
|
slots?: Record<string, Slot>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
// Context
|
// Context
|
||||||
export type { Context, ContextKey } from "./context"
|
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context"
|
||||||
export { contextKey, contextValue } from "./context"
|
export { Context, contextKey, serializeKey } from "./context"
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
export type { ActionDefinition } from "./action"
|
export type { ActionDefinition } from "./action"
|
||||||
export { UnknownActionError } from "./action"
|
export { UnknownActionError } from "./action"
|
||||||
|
|
||||||
// Feed
|
// Feed
|
||||||
export type { FeedItem } from "./feed"
|
export type { FeedItem, FeedItemSignals, Slot } from "./feed"
|
||||||
|
export { TimeRelevance } from "./feed"
|
||||||
|
|
||||||
// Feed Source
|
// Feed Source
|
||||||
export type { FeedSource } from "./feed-source"
|
export type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
|
// Feed Post-Processor
|
||||||
|
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||||
|
|
||||||
// Feed Engine
|
// Feed Engine
|
||||||
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||||
export { FeedEngine } from "./feed-engine"
|
export { FeedEngine } from "./feed-engine"
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -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>
|
return { items, errors } as ReconcileResult<TItems>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "@aris/core"
|
import type { ContextKey } from "@aris/core"
|
||||||
|
|
||||||
|
import { Context, contextKey } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||||
@@ -15,14 +16,25 @@ const mockCredentials = {
|
|||||||
serviceId: "mock",
|
serviceId: "mock",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
accuracy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
||||||
|
|
||||||
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
||||||
fetch: async () => response,
|
fetch: async () => response,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
|
function createMockContext(location?: { lat: number; lng: number }): Context {
|
||||||
time: new Date("2026-01-17T00:00:00Z"),
|
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
||||||
location: location ? { ...location, accuracy: 10 } : undefined,
|
if (location) {
|
||||||
})
|
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
describe("WeatherKitDataSource", () => {
|
describe("WeatherKitDataSource", () => {
|
||||||
test("returns empty array when location is missing", async () => {
|
test("returns empty array when location is missing", async () => {
|
||||||
@@ -39,7 +51,7 @@ describe("WeatherKitDataSource", () => {
|
|||||||
credentials: mockCredentials,
|
credentials: mockCredentials,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(dataSource.type).toBe(WeatherFeedItemType.current)
|
expect(dataSource.type).toBe(WeatherFeedItemType.Current)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("throws error if neither client nor credentials provided", () => {
|
test("throws error if neither client nor credentials provided", () => {
|
||||||
@@ -130,9 +142,9 @@ describe("query() with mocked client", () => {
|
|||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
expect(items.length).toBeGreaterThan(0)
|
expect(items.length).toBeGreaterThan(0)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies hourly and daily limits", async () => {
|
test("applies hourly and daily limits", async () => {
|
||||||
@@ -145,8 +157,8 @@ describe("query() with mocked client", () => {
|
|||||||
|
|
||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
|
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
||||||
|
|
||||||
expect(hourlyItems.length).toBe(3)
|
expect(hourlyItems.length).toBe(3)
|
||||||
expect(dailyItems.length).toBe(2)
|
expect(dailyItems.length).toBe(2)
|
||||||
@@ -176,8 +188,8 @@ describe("query() with mocked client", () => {
|
|||||||
units: Units.imperial,
|
units: Units.imperial,
|
||||||
})
|
})
|
||||||
|
|
||||||
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
|
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current)
|
||||||
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
|
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current)
|
||||||
|
|
||||||
expect(metricCurrent).toBeDefined()
|
expect(metricCurrent).toBeDefined()
|
||||||
expect(imperialCurrent).toBeDefined()
|
expect(imperialCurrent).toBeDefined()
|
||||||
@@ -190,21 +202,22 @@ describe("query() with mocked client", () => {
|
|||||||
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
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 dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
expect(item.signals).toBeDefined()
|
||||||
expect(item.priority).toBeLessThanOrEqual(1)
|
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)
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
||||||
expect(currentItem).toBeDefined()
|
expect(currentItem).toBeDefined()
|
||||||
// Base priority for current is 0.5, may be adjusted for conditions
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("generates unique IDs for each item", async () => {
|
test("generates unique IDs for each item", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Context, DataSource } from "@aris/core"
|
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core"
|
||||||
|
|
||||||
|
import { TimeRelevance, contextKey } from "@aris/core"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WeatherFeedItemType,
|
WeatherFeedItemType,
|
||||||
@@ -38,11 +40,18 @@ export interface WeatherKitQueryConfig {
|
|||||||
units?: Units
|
units?: Units
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
||||||
|
|
||||||
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||||
private readonly DEFAULT_HOURLY_LIMIT = 12
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||||
private readonly DEFAULT_DAILY_LIMIT = 7
|
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||||
|
|
||||||
readonly type = WeatherFeedItemType.current
|
readonly type = WeatherFeedItemType.Current
|
||||||
private readonly client: WeatherKitClient
|
private readonly client: WeatherKitClient
|
||||||
private readonly hourlyLimit: number
|
private readonly hourlyLimit: number
|
||||||
private readonly dailyLimit: number
|
private readonly dailyLimit: number
|
||||||
@@ -57,7 +66,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
}
|
}
|
||||||
|
|
||||||
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||||
if (!context.location) {
|
const location = context.get(LocationKey)
|
||||||
|
if (!location) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +75,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
const timestamp = context.time
|
const timestamp = context.time
|
||||||
|
|
||||||
const response = await this.client.fetch({
|
const response = await this.client.fetch({
|
||||||
lat: context.location.lat,
|
lat: location.lat,
|
||||||
lng: context.location.lng,
|
lng: location.lng,
|
||||||
})
|
})
|
||||||
|
|
||||||
const items: WeatherFeedItem[] = []
|
const items: WeatherFeedItem[] = []
|
||||||
@@ -105,7 +115,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_PRIORITY = {
|
const BASE_URGENCY = {
|
||||||
current: 0.5,
|
current: 0.5,
|
||||||
hourly: 0.3,
|
hourly: 0.3,
|
||||||
daily: 0.2,
|
daily: 0.2,
|
||||||
@@ -134,17 +144,17 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
|||||||
ConditionCode.BlowingSnow,
|
ConditionCode.BlowingSnow,
|
||||||
])
|
])
|
||||||
|
|
||||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
return Math.min(1, basePriority + 0.3)
|
return Math.min(1, baseUrgency + 0.3)
|
||||||
}
|
}
|
||||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
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) {
|
switch (severity) {
|
||||||
case Severity.Extreme:
|
case Severity.Extreme:
|
||||||
return 1
|
return 1
|
||||||
@@ -153,7 +163,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number {
|
|||||||
case Severity.Moderate:
|
case Severity.Moderate:
|
||||||
return 0.75
|
return 0.75
|
||||||
case Severity.Minor:
|
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 +229,14 @@ function createCurrentWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): CurrentWeatherFeedItem {
|
): CurrentWeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
type: WeatherFeedItemType.current,
|
type: WeatherFeedItemType.Current,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
conditionCode: current.conditionCode,
|
conditionCode: current.conditionCode,
|
||||||
@@ -219,6 +253,7 @@ function createCurrentWeatherFeedItem(
|
|||||||
windGust: convertSpeed(current.windGust, units),
|
windGust: convertSpeed(current.windGust, units),
|
||||||
windSpeed: convertSpeed(current.windSpeed, units),
|
windSpeed: convertSpeed(current.windSpeed, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +263,14 @@ function createHourlyWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): HourlyWeatherFeedItem {
|
): HourlyWeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.hourly,
|
type: WeatherFeedItemType.Hourly,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
@@ -250,6 +287,7 @@ function createHourlyWeatherFeedItem(
|
|||||||
windGust: convertSpeed(hourly.windGust, units),
|
windGust: convertSpeed(hourly.windGust, units),
|
||||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +297,14 @@ function createDailyWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): DailyWeatherFeedItem {
|
): DailyWeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.daily,
|
type: WeatherFeedItemType.Daily,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
@@ -279,16 +319,19 @@ function createDailyWeatherFeedItem(
|
|||||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
||||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||||
|
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
type: WeatherFeedItemType.alert,
|
type: WeatherFeedItemType.Alert,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
alertId: alert.id,
|
alertId: alert.id,
|
||||||
@@ -302,5 +345,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
source: alert.source,
|
source: alert.source,
|
||||||
urgency: alert.urgency,
|
urgency: alert.urgency,
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||||
|
|
||||||
export const WeatherFeedItemType = {
|
export const WeatherFeedItemType = {
|
||||||
current: "weather-current",
|
Current: "weather-current",
|
||||||
hourly: "weather-hourly",
|
Hourly: "weather-hourly",
|
||||||
daily: "weather-daily",
|
Daily: "weather-daily",
|
||||||
alert: "weather-alert",
|
Alert: "weather-alert",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||||
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentWeatherFeedItem extends FeedItem<
|
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.current,
|
typeof WeatherFeedItemType.Current,
|
||||||
CurrentWeatherData
|
CurrentWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HourlyWeatherFeedItem extends FeedItem<
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.hourly,
|
typeof WeatherFeedItemType.Hourly,
|
||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyWeatherFeedItem extends FeedItem<
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.daily,
|
typeof WeatherFeedItemType.Daily,
|
||||||
DailyWeatherData
|
DailyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherAlertFeedItem extends FeedItem<
|
export interface WeatherAlertFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.alert,
|
typeof WeatherFeedItemType.Alert,
|
||||||
WeatherAlertData
|
WeatherAlertData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
17
packages/aris-feed-enhancers/package.json
Normal file
17
packages/aris-feed-enhancers/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@aris/feed-enhancers",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"@aris/source-caldav": "workspace:*",
|
||||||
|
"@aris/source-google-calendar": "workspace:*",
|
||||||
|
"@aris/source-tfl": "workspace:*",
|
||||||
|
"@aris/source-weatherkit": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/aris-feed-enhancers/src/index.ts
Normal file
1
packages/aris-feed-enhancers/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createTimeOfDayEnhancer, type TimeOfDayEnhancerOptions } from "./time-of-day-enhancer.ts"
|
||||||
704
packages/aris-feed-enhancers/src/time-of-day-enhancer.test.ts
Normal file
704
packages/aris-feed-enhancers/src/time-of-day-enhancer.test.ts
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
import type { FeedItem, FeedItemSignals } from "@aris/core"
|
||||||
|
|
||||||
|
import { Context, TimeRelevance } from "@aris/core"
|
||||||
|
import { CalDavFeedItemType } from "@aris/source-caldav"
|
||||||
|
import { CalendarFeedItemType } from "@aris/source-google-calendar"
|
||||||
|
import { TflFeedItemType } from "@aris/source-tfl"
|
||||||
|
import { WeatherFeedItemType } from "@aris/source-weatherkit"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTimeOfDayEnhancer,
|
||||||
|
getTimePeriod,
|
||||||
|
getDayType,
|
||||||
|
TimePeriod,
|
||||||
|
DayType,
|
||||||
|
} from "./time-of-day-enhancer"
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function makeContext(date: Date): Context {
|
||||||
|
return new Context(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
|
||||||
|
return new Date(year, month - 1, day, hour, minute, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tuesday 2025-07-08 at given hour:minute */
|
||||||
|
function tuesday(hour: number, minute = 0): Date {
|
||||||
|
return makeDate(2025, 7, 8, hour, minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saturday 2025-07-12 at given hour:minute */
|
||||||
|
function saturday(hour: number, minute = 0): Date {
|
||||||
|
return makeDate(2025, 7, 12, hour, minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherCurrent(id = "w-current"): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: WeatherFeedItemType.Current,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { temperature: 18, precipitationIntensity: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: WeatherFeedItemType.Current,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { temperature: 12, precipitationIntensity: 2.5 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: WeatherFeedItemType.Current,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { temperature: -5, precipitationIntensity: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherHourly(id = "w-hourly"): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: WeatherFeedItemType.Hourly,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { forecastTime: new Date(), temperature: 20 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherDaily(id = "w-daily"): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: WeatherFeedItemType.Daily,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { forecastDate: new Date() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherAlert(id = "w-alert", urgency = 0.9): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: WeatherFeedItemType.Alert,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { severity: "extreme" },
|
||||||
|
signals: { urgency, timeRelevance: TimeRelevance.Imminent },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarEvent(
|
||||||
|
id: string,
|
||||||
|
startTime: Date,
|
||||||
|
options: { location?: string; signals?: FeedItemSignals } = {},
|
||||||
|
): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: CalendarFeedItemType.Event,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: {
|
||||||
|
eventId: id,
|
||||||
|
calendarId: "primary",
|
||||||
|
title: `Event ${id}`,
|
||||||
|
description: null,
|
||||||
|
location: options.location ?? null,
|
||||||
|
startTime,
|
||||||
|
endTime: new Date(startTime.getTime() + 3_600_000),
|
||||||
|
isAllDay: false,
|
||||||
|
status: "confirmed",
|
||||||
|
htmlLink: "",
|
||||||
|
},
|
||||||
|
signals: options.signals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarAllDay(id: string): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: CalendarFeedItemType.AllDay,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: {
|
||||||
|
eventId: id,
|
||||||
|
calendarId: "primary",
|
||||||
|
title: `All Day ${id}`,
|
||||||
|
description: null,
|
||||||
|
location: null,
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
isAllDay: true,
|
||||||
|
status: "confirmed",
|
||||||
|
htmlLink: "",
|
||||||
|
},
|
||||||
|
signals: { timeRelevance: TimeRelevance.Ambient },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function caldavEvent(
|
||||||
|
id: string,
|
||||||
|
startDate: Date,
|
||||||
|
options: { location?: string; signals?: FeedItemSignals } = {},
|
||||||
|
): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: CalDavFeedItemType.Event,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: {
|
||||||
|
uid: id,
|
||||||
|
title: `CalDAV ${id}`,
|
||||||
|
startDate,
|
||||||
|
endDate: new Date(startDate.getTime() + 3_600_000),
|
||||||
|
isAllDay: false,
|
||||||
|
location: options.location ?? null,
|
||||||
|
description: null,
|
||||||
|
calendarName: null,
|
||||||
|
status: "confirmed",
|
||||||
|
url: null,
|
||||||
|
organizer: null,
|
||||||
|
attendees: [],
|
||||||
|
alarms: [],
|
||||||
|
recurrenceId: null,
|
||||||
|
},
|
||||||
|
signals: options.signals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: TflFeedItemType.Alert,
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: {
|
||||||
|
line: "northern",
|
||||||
|
lineName: "Northern",
|
||||||
|
severity: "major-delays",
|
||||||
|
description: "Delays",
|
||||||
|
},
|
||||||
|
signals: { urgency, timeRelevance: TimeRelevance.Imminent },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unknownItem(id = "unknown-1"): FeedItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "some-future-type",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { foo: "bar" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Period detection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("getTimePeriod", () => {
|
||||||
|
test("morning: 06:00–11:59", () => {
|
||||||
|
expect(getTimePeriod(tuesday(6))).toBe(TimePeriod.Morning)
|
||||||
|
expect(getTimePeriod(tuesday(8))).toBe(TimePeriod.Morning)
|
||||||
|
expect(getTimePeriod(tuesday(11, 59))).toBe(TimePeriod.Morning)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("afternoon: 12:00–16:59", () => {
|
||||||
|
expect(getTimePeriod(tuesday(12))).toBe(TimePeriod.Afternoon)
|
||||||
|
expect(getTimePeriod(tuesday(14))).toBe(TimePeriod.Afternoon)
|
||||||
|
expect(getTimePeriod(tuesday(16, 59))).toBe(TimePeriod.Afternoon)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("evening: 17:00–21:59", () => {
|
||||||
|
expect(getTimePeriod(tuesday(17))).toBe(TimePeriod.Evening)
|
||||||
|
expect(getTimePeriod(tuesday(19))).toBe(TimePeriod.Evening)
|
||||||
|
expect(getTimePeriod(tuesday(21, 59))).toBe(TimePeriod.Evening)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("night: 22:00–05:59", () => {
|
||||||
|
expect(getTimePeriod(tuesday(22))).toBe(TimePeriod.Night)
|
||||||
|
expect(getTimePeriod(tuesday(0))).toBe(TimePeriod.Night)
|
||||||
|
expect(getTimePeriod(tuesday(3))).toBe(TimePeriod.Night)
|
||||||
|
expect(getTimePeriod(tuesday(5, 59))).toBe(TimePeriod.Night)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getDayType", () => {
|
||||||
|
test("weekday: Monday–Friday", () => {
|
||||||
|
// 2025-07-07 is Monday, 2025-07-08 is Tuesday, 2025-07-11 is Friday
|
||||||
|
expect(getDayType(makeDate(2025, 7, 7, 10))).toBe(DayType.Weekday)
|
||||||
|
expect(getDayType(tuesday(10))).toBe(DayType.Weekday)
|
||||||
|
expect(getDayType(makeDate(2025, 7, 11, 10))).toBe(DayType.Weekday)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("weekend: Saturday–Sunday", () => {
|
||||||
|
expect(getDayType(saturday(10))).toBe(DayType.Weekend)
|
||||||
|
expect(getDayType(makeDate(2025, 7, 13, 10))).toBe(DayType.Weekend) // Sunday
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Morning
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("morning weekday", () => {
|
||||||
|
const now = tuesday(8)
|
||||||
|
const ctx = makeContext(now)
|
||||||
|
|
||||||
|
test("boosts weather-current and weather-alert, demotes weather-hourly", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const items = [weatherCurrent(), weatherHourly(), weatherAlert()]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
||||||
|
expect(result.boost!["w-alert"]).toBeGreaterThan(0)
|
||||||
|
expect(result.boost!["w-hourly"]).toBeLessThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boosts first calendar event of the day", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event1 = calendarEvent("c1", tuesday(9))
|
||||||
|
const event2 = calendarEvent("c2", tuesday(14))
|
||||||
|
const result = await enhancer([event1, event2], ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["c1"]).toBeGreaterThan(0)
|
||||||
|
// Second event should not get the first-event boost
|
||||||
|
expect(result.boost?.["c2"] ?? 0).toBeLessThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boosts TfL alerts", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const result = await enhancer([tflAlert()], ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["tfl-1"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("morning weekend", () => {
|
||||||
|
const now = saturday(9)
|
||||||
|
const ctx = makeContext(now)
|
||||||
|
|
||||||
|
test("boosts weather-current and weather-daily", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const items = [weatherCurrent(), weatherDaily()]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
||||||
|
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("demotes calendar events and TfL alerts", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", saturday(10))
|
||||||
|
const items = [event, tflAlert()]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["c1"]).toBeLessThan(0)
|
||||||
|
expect(result.boost!["tfl-1"]).toBeLessThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Afternoon
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("afternoon weekday", () => {
|
||||||
|
const now = tuesday(14)
|
||||||
|
const ctx = makeContext(now)
|
||||||
|
|
||||||
|
test("boosts imminent calendar events", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", tuesday(14, 10), {
|
||||||
|
signals: { timeRelevance: TimeRelevance.Imminent },
|
||||||
|
})
|
||||||
|
const result = await enhancer([event], ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["c1"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("demotes weather-current and weather-hourly", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const items = [weatherCurrent(), weatherHourly()]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["w-current"]).toBeLessThan(0)
|
||||||
|
expect(result.boost!["w-hourly"]).toBeLessThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("afternoon weekend", () => {
|
||||||
|
const now = saturday(14)
|
||||||
|
const ctx = makeContext(now)
|
||||||
|
|
||||||
|
test("boosts weather-current, demotes calendar events", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", saturday(15))
|
||||||
|
const items = [weatherCurrent(), event]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
||||||
|
expect(result.boost!["c1"]).toBeLessThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Evening
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("evening weekday", () => {
|
||||||
|
const now = tuesday(19)
|
||||||
|
const ctx = makeContext(now)
|
||||||
|
|
||||||
|
test("suppresses ambient work calendar events", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", tuesday(9), {
|
||||||
|
signals: { timeRelevance: TimeRelevance.Ambient },
|
||||||
|
})
|
||||||
|
const result = await enhancer([event], ctx)
|
||||||
|
|
||||||
|
expect(result.suppress).toContain("c1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("demotes TfL alerts", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const result = await enhancer([tflAlert()], ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["tfl-1"]).toBeLessThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boosts weather-daily and all-day calendar events", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const items = [weatherDaily(), calendarAllDay("ad1")]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
|
||||||
|
expect(result.boost!["ad1"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("evening weekend", () => {
|
||||||
|
const now = saturday(19)
|
||||||
|
const ctx = makeContext(now)
|
||||||
|
|
||||||
|
test("boosts weather-current, suppresses ambient calendar events", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", saturday(9), {
|
||||||
|
signals: { timeRelevance: TimeRelevance.Ambient },
|
||||||
|
})
|
||||||
|
const items = [weatherCurrent(), event]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
||||||
|
expect(result.suppress).toContain("c1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("demotes TfL alerts more aggressively", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const result = await enhancer([tflAlert()], ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["tfl-1"]).toBeLessThan(-0.3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Night
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("night", () => {
|
||||||
|
const now = tuesday(23)
|
||||||
|
const ctx = makeContext(now)
|
||||||
|
|
||||||
|
test("suppresses ambient items", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", tuesday(9), {
|
||||||
|
signals: { timeRelevance: TimeRelevance.Ambient },
|
||||||
|
})
|
||||||
|
const result = await enhancer([event], ctx)
|
||||||
|
|
||||||
|
expect(result.suppress).toContain("c1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("demotes calendar events and weather-current", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", makeDate(2025, 7, 9, 9)) // tomorrow
|
||||||
|
const items = [event, weatherCurrent()]
|
||||||
|
const result = await enhancer(items, ctx)
|
||||||
|
|
||||||
|
expect(result.boost!["c1"]).toBeLessThan(0)
|
||||||
|
expect(result.boost!["w-current"]).toBeLessThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("high-urgency alerts survive unboosted", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const alert = weatherAlert("w-alert", 0.9)
|
||||||
|
const result = await enhancer([alert], ctx)
|
||||||
|
|
||||||
|
// Should not be demoted — either no boost entry or >= 0
|
||||||
|
const alertBoost = result.boost?.["w-alert"] ?? 0
|
||||||
|
expect(alertBoost).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Pre-meeting window
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("pre-meeting window", () => {
|
||||||
|
test("boosts upcoming meeting to +0.9", async () => {
|
||||||
|
const now = tuesday(9, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(10))
|
||||||
|
const result = await enhancer([meeting], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["c1"]).toBe(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppresses low-urgency items during pre-meeting", async () => {
|
||||||
|
const now = tuesday(9, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(10))
|
||||||
|
const lowPriority = weatherHourly()
|
||||||
|
lowPriority.signals = { urgency: 0.1 }
|
||||||
|
const result = await enhancer([meeting, lowPriority], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.suppress).toContain("w-hourly")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not suppress items without signals during pre-meeting", async () => {
|
||||||
|
const now = tuesday(9, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(10))
|
||||||
|
const noSignals = weatherDaily()
|
||||||
|
const result = await enhancer([meeting, noSignals], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.suppress ?? []).not.toContain("w-daily")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boosts TfL alerts during pre-meeting", async () => {
|
||||||
|
const now = tuesday(9, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(10))
|
||||||
|
const result = await enhancer([meeting, tflAlert()], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["tfl-1"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boosts weather-current if meeting has a location", async () => {
|
||||||
|
const now = tuesday(9, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(10), { location: "Office, London" })
|
||||||
|
const result = await enhancer([meeting, weatherCurrent()], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("works with CalDAV events", async () => {
|
||||||
|
const now = tuesday(9, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = caldavEvent("cd1", tuesday(10))
|
||||||
|
const result = await enhancer([meeting], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["cd1"]).toBe(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not trigger for events more than 30 minutes away", async () => {
|
||||||
|
const now = tuesday(9)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(10))
|
||||||
|
const result = await enhancer([meeting], makeContext(now))
|
||||||
|
|
||||||
|
// Should not get the +0.9 pre-meeting boost
|
||||||
|
expect(result.boost?.["c1"] ?? 0).not.toBe(0.9)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Wind-down gradient
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("wind-down gradient", () => {
|
||||||
|
test("20:00 weekday: additional -0.1 on work items", async () => {
|
||||||
|
const now = tuesday(20)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
// Non-ambient calendar event — evening rules don't boost or suppress it,
|
||||||
|
// so the only demotion comes from wind-down at 20:00 (-0.1).
|
||||||
|
const event = calendarEvent("c1", makeDate(2025, 7, 9, 9))
|
||||||
|
const result = await enhancer([event], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["c1"]).toBe(-0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("21:00 weekday: additional -0.2 on work items", async () => {
|
||||||
|
const now = tuesday(21)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const alert = tflAlert("tfl-1", 0.5)
|
||||||
|
const result = await enhancer([alert], makeContext(now))
|
||||||
|
|
||||||
|
// Evening demotes TfL by -0.4, wind-down adds -0.2 = -0.6
|
||||||
|
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(-0.6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("21:30 weekday: additional -0.3 on work items", async () => {
|
||||||
|
const now = tuesday(21, 30)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const alert = tflAlert("tfl-1", 0.5)
|
||||||
|
const result = await enhancer([alert], makeContext(now))
|
||||||
|
|
||||||
|
// Evening demotes TfL by -0.4, wind-down adds -0.3 = -0.7
|
||||||
|
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(-0.7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not apply on weekends", async () => {
|
||||||
|
const now = saturday(21)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const alert = tflAlert("tfl-1", 0.5)
|
||||||
|
const result = await enhancer([alert], makeContext(now))
|
||||||
|
|
||||||
|
// Weekend evening demotes TfL by -0.5, but no wind-down
|
||||||
|
expect(result.boost!["tfl-1"]).toBe(-0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Transition lookahead
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("transition lookahead", () => {
|
||||||
|
test("Saturday 11:40 boosts afternoon-relevant weather-current", async () => {
|
||||||
|
const now = saturday(11, 40)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const items = [weatherCurrent()]
|
||||||
|
const result = await enhancer(items, makeContext(now))
|
||||||
|
|
||||||
|
// Weekend morning boosts weather-current by +0.5.
|
||||||
|
// Transition to afternoon adds +0.2 (weekend afternoon boosts weather-current).
|
||||||
|
expect(result.boost!["w-current"]).toBe(0.7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("16:40 weekday boosts evening-relevant items (weather-daily)", async () => {
|
||||||
|
const now = tuesday(16, 40)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const items = [weatherDaily()]
|
||||||
|
const result = await enhancer(items, makeContext(now))
|
||||||
|
|
||||||
|
// Afternoon weekday doesn't boost weather-daily, but transition to evening does (+0.2)
|
||||||
|
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not apply when far from boundary", async () => {
|
||||||
|
const now = tuesday(14)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const items = [weatherDaily()]
|
||||||
|
const result = await enhancer(items, makeContext(now))
|
||||||
|
|
||||||
|
// Afternoon weekday doesn't boost or demote weather-daily, and no transition
|
||||||
|
expect(result.boost?.["w-daily"]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Weather-time correlation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("weather-time correlation", () => {
|
||||||
|
test("morning weekday: extra boost for precipitation", async () => {
|
||||||
|
const now = tuesday(8)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const rainy = weatherCurrentRainy()
|
||||||
|
const dry = weatherCurrent("w-dry")
|
||||||
|
const result = await enhancer([rainy, dry], makeContext(now))
|
||||||
|
|
||||||
|
// Both get morning boost, but rainy gets extra +0.1
|
||||||
|
expect(result.boost!["w-current-rain"]).toBeGreaterThan(result.boost!["w-dry"] ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("morning weekday: extra boost for extreme temperature", async () => {
|
||||||
|
const now = tuesday(8)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const extreme = weatherCurrentExtreme()
|
||||||
|
const normal = weatherCurrent("w-normal")
|
||||||
|
const result = await enhancer([extreme, normal], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["w-current-extreme"]).toBeGreaterThan(result.boost!["w-normal"] ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("evening with location event: extra boost for weather-current", async () => {
|
||||||
|
const now = tuesday(19)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const event = calendarEvent("c1", tuesday(19, 30), { location: "The Ivy, London" })
|
||||||
|
const items = [weatherCurrent(), event]
|
||||||
|
const result = await enhancer(items, makeContext(now))
|
||||||
|
|
||||||
|
// Weather-current gets evening weather-time correlation boost (+0.2)
|
||||||
|
// Note: evening weekday doesn't normally boost weather-current
|
||||||
|
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("weather-alert always gets at least +0.5", async () => {
|
||||||
|
const now = tuesday(14) // afternoon — no special weather boost
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const alert = weatherAlert("w-alert", 0.5)
|
||||||
|
const result = await enhancer([alert], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["w-alert"]).toBeGreaterThanOrEqual(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Edge cases
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
test("empty items returns empty enhancement", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => tuesday(8) })
|
||||||
|
const result = await enhancer([], makeContext(tuesday(8)))
|
||||||
|
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unknown item types get no boost", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => tuesday(8) })
|
||||||
|
const result = await enhancer([unknownItem()], makeContext(tuesday(8)))
|
||||||
|
|
||||||
|
expect(result.boost?.["unknown-1"]).toBeUndefined()
|
||||||
|
expect(result.suppress).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses context.time when no clock provided", async () => {
|
||||||
|
const enhancer = createTimeOfDayEnhancer()
|
||||||
|
const morningCtx = makeContext(tuesday(8))
|
||||||
|
const items = [weatherCurrent()]
|
||||||
|
const result = await enhancer(items, morningCtx)
|
||||||
|
|
||||||
|
// Should apply morning rules — weather-current boosted
|
||||||
|
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("boost values are clamped to [-1, 1]", async () => {
|
||||||
|
// Morning weekday: TfL alert gets +0.6 from period rules.
|
||||||
|
// Pre-meeting adds +0.5. Total would be +1.1 without clamping.
|
||||||
|
const now = tuesday(8, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(9))
|
||||||
|
const alert = tflAlert("tfl-1", 0.8)
|
||||||
|
const result = await enhancer([meeting, alert], makeContext(now))
|
||||||
|
|
||||||
|
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(1)
|
||||||
|
expect(result.boost!["tfl-1"]).toBeGreaterThanOrEqual(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppress list is deduplicated", async () => {
|
||||||
|
// An item that would be suppressed by both evening rules and pre-meeting low-urgency
|
||||||
|
const now = tuesday(19, 45)
|
||||||
|
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
||||||
|
const meeting = calendarEvent("c1", tuesday(20))
|
||||||
|
const ambientEvent = calendarEvent("c2", tuesday(9), {
|
||||||
|
signals: { urgency: 0.1, timeRelevance: TimeRelevance.Ambient },
|
||||||
|
})
|
||||||
|
const result = await enhancer([meeting, ambientEvent], makeContext(now))
|
||||||
|
|
||||||
|
if (result.suppress) {
|
||||||
|
const c2Count = result.suppress.filter((id) => id === "c2").length
|
||||||
|
expect(c2Count).toBeLessThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
595
packages/aris-feed-enhancers/src/time-of-day-enhancer.ts
Normal file
595
packages/aris-feed-enhancers/src/time-of-day-enhancer.ts
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } from "@aris/core"
|
||||||
|
|
||||||
|
import { TimeRelevance } from "@aris/core"
|
||||||
|
|
||||||
|
import type { CalDavEventData } from "@aris/source-caldav"
|
||||||
|
import type { CalendarEventData } from "@aris/source-google-calendar"
|
||||||
|
import type { CurrentWeatherData } from "@aris/source-weatherkit"
|
||||||
|
|
||||||
|
import { CalDavFeedItemType } from "@aris/source-caldav"
|
||||||
|
import { CalendarFeedItemType } from "@aris/source-google-calendar"
|
||||||
|
import { TflFeedItemType } from "@aris/source-tfl"
|
||||||
|
import { WeatherFeedItemType } from "@aris/source-weatherkit"
|
||||||
|
|
||||||
|
|
||||||
|
export const TimePeriod = {
|
||||||
|
Morning: "morning",
|
||||||
|
Afternoon: "afternoon",
|
||||||
|
Evening: "evening",
|
||||||
|
Night: "night",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TimePeriod = (typeof TimePeriod)[keyof typeof TimePeriod]
|
||||||
|
|
||||||
|
export const DayType = {
|
||||||
|
Weekday: "weekday",
|
||||||
|
Weekend: "weekend",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type DayType = (typeof DayType)[keyof typeof DayType]
|
||||||
|
|
||||||
|
|
||||||
|
const PRE_MEETING_WINDOW_MS = 30 * 60 * 1000
|
||||||
|
const TRANSITION_WINDOW_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
|
const PERIOD_BOUNDARIES = [
|
||||||
|
{ hour: 6, period: TimePeriod.Morning },
|
||||||
|
{ hour: 12, period: TimePeriod.Afternoon },
|
||||||
|
{ hour: 17, period: TimePeriod.Evening },
|
||||||
|
{ hour: 22, period: TimePeriod.Night },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** All calendar event types across sources */
|
||||||
|
const CALENDAR_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||||
|
CalendarFeedItemType.Event,
|
||||||
|
CalDavFeedItemType.Event,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a post-processor that reranks feed items based on time of day.
|
||||||
|
*
|
||||||
|
* Prioritizes items that matter right now and pushes down items that don't:
|
||||||
|
*
|
||||||
|
* - Morning: weather and first meeting rise, hourly forecasts sink.
|
||||||
|
* Weekends flip — weather stays up but work calendar and commute alerts drop.
|
||||||
|
* - Afternoon: imminent meetings rise. Stale weather sinks.
|
||||||
|
* - Evening: work calendar is suppressed, tomorrow's forecast and personal
|
||||||
|
* events rise. Weekends suppress work more aggressively.
|
||||||
|
* - Night: almost everything sinks except high-urgency alerts.
|
||||||
|
* - Pre-meeting (30 min before any event): that meeting dominates, low-urgency
|
||||||
|
* noise is suppressed, commute/weather context rises if the meeting has a location.
|
||||||
|
* - Wind-down (weekday 20–22h): work items progressively sink as night approaches.
|
||||||
|
* - Transition lookahead (30 min before a period boundary): items relevant to
|
||||||
|
* the next period get a head start.
|
||||||
|
* - Weather-time correlation: precipitation boosts morning weather, evening
|
||||||
|
* events with locations boost current weather, alerts always stay high.
|
||||||
|
*/
|
||||||
|
export interface TimeOfDayEnhancerOptions {
|
||||||
|
/** Override clock for testing. Defaults to reading context.time. */
|
||||||
|
clock?: () => Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTimeOfDayEnhancer(options?: TimeOfDayEnhancerOptions): FeedPostProcessor {
|
||||||
|
const clock = options?.clock
|
||||||
|
|
||||||
|
function timeOfDayEnhancer(items: FeedItem[], context: Context): Promise<FeedEnhancement> {
|
||||||
|
if (items.length === 0) return Promise.resolve({})
|
||||||
|
|
||||||
|
const now = clock ? clock() : context.time
|
||||||
|
const period = getTimePeriod(now)
|
||||||
|
const dayType = getDayType(now)
|
||||||
|
const boost: Record<string, number> = {}
|
||||||
|
const suppress: string[] = []
|
||||||
|
|
||||||
|
// 1. Apply period-based rules
|
||||||
|
const firstEventId = findFirstEventOfDay(items, now)
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case TimePeriod.Morning:
|
||||||
|
if (dayType === DayType.Weekday) {
|
||||||
|
applyMorningWeekday(items, boost, firstEventId)
|
||||||
|
} else {
|
||||||
|
applyMorningWeekend(items, boost)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TimePeriod.Afternoon:
|
||||||
|
if (dayType === DayType.Weekday) {
|
||||||
|
applyAfternoonWeekday(items, boost)
|
||||||
|
} else {
|
||||||
|
applyAfternoonWeekend(items, boost)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TimePeriod.Evening:
|
||||||
|
if (dayType === DayType.Weekday) {
|
||||||
|
applyEveningWeekday(items, boost, suppress)
|
||||||
|
} else {
|
||||||
|
applyEveningWeekend(items, boost, suppress)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TimePeriod.Night:
|
||||||
|
applyNight(items, boost, suppress)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pre-meeting overrides (can override period rules)
|
||||||
|
const preMeeting = detectPreMeetingItems(items, now)
|
||||||
|
applyPreMeetingOverrides(items, preMeeting, boost, suppress)
|
||||||
|
|
||||||
|
// 3. Wind-down gradient
|
||||||
|
applyWindDown(items, now, dayType, boost)
|
||||||
|
|
||||||
|
// 4. Transition lookahead
|
||||||
|
applyTransitionLookahead(items, now, period, dayType, boost)
|
||||||
|
|
||||||
|
// 5. Weather-time correlation
|
||||||
|
const eveningLocation = hasEveningCalendarEventWithLocation(items, now)
|
||||||
|
applyWeatherTimeCorrelation(items, period, dayType, eveningLocation, boost)
|
||||||
|
|
||||||
|
// Clamp boost values to [-1, 1] — additive layers can exceed the range
|
||||||
|
for (const id in boost) {
|
||||||
|
boost[id] = Math.max(-1, Math.min(1, boost[id]!))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: FeedEnhancement = {}
|
||||||
|
if (Object.keys(boost).length > 0) {
|
||||||
|
result.boost = boost
|
||||||
|
}
|
||||||
|
const uniqueSuppress = [...new Set(suppress)]
|
||||||
|
if (uniqueSuppress.length > 0) {
|
||||||
|
result.suppress = uniqueSuppress
|
||||||
|
}
|
||||||
|
return Promise.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeOfDayEnhancer
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getTimePeriod(date: Date): TimePeriod {
|
||||||
|
const hour = date.getHours()
|
||||||
|
if (hour >= 22 || hour < 6) return TimePeriod.Night
|
||||||
|
if (hour >= 17) return TimePeriod.Evening
|
||||||
|
if (hour >= 12) return TimePeriod.Afternoon
|
||||||
|
return TimePeriod.Morning
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayType(date: Date): DayType {
|
||||||
|
const day = date.getDay()
|
||||||
|
return day === 0 || day === 6 ? DayType.Weekend : DayType.Weekday
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next period boundary as { hour, period } and the ms until it.
|
||||||
|
*/
|
||||||
|
function getNextPeriodBoundary(date: Date): { period: TimePeriod; msUntil: number } {
|
||||||
|
const hour = date.getHours()
|
||||||
|
const minuteMs = date.getMinutes() * 60_000 + date.getSeconds() * 1000 + date.getMilliseconds()
|
||||||
|
|
||||||
|
for (const boundary of PERIOD_BOUNDARIES) {
|
||||||
|
if (hour < boundary.hour) {
|
||||||
|
const msUntil = (boundary.hour - hour) * 3_600_000 - minuteMs
|
||||||
|
return { period: boundary.period, msUntil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past 22:00 — next boundary is morning at 06:00
|
||||||
|
const hoursUntil6 = (24 - hour + 6) * 3_600_000 - minuteMs
|
||||||
|
return { period: TimePeriod.Morning, msUntil: hoursUntil6 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract start time from calendar event data.
|
||||||
|
* Google Calendar uses `startTime`, CalDAV uses `startDate`.
|
||||||
|
*/
|
||||||
|
function getEventStartTime(data: CalendarEventData | CalDavEventData): Date {
|
||||||
|
return "startTime" in data ? (data as CalendarEventData).startTime : (data as CalDavEventData).startDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a current weather item indicates precipitation or extreme conditions.
|
||||||
|
* Only meaningful for weather-current items.
|
||||||
|
*/
|
||||||
|
function hasPrecipitationOrExtreme(item: FeedItem): boolean {
|
||||||
|
const data = item.data as CurrentWeatherData
|
||||||
|
if (data.precipitationIntensity > 0) return true
|
||||||
|
if (data.temperature < 0 || data.temperature > 35) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface PreMeetingInfo {
|
||||||
|
/** IDs of calendar items starting within the pre-meeting window */
|
||||||
|
upcomingMeetingIds: Set<string>
|
||||||
|
/** Whether any upcoming meeting has a location */
|
||||||
|
hasLocationMeeting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPreMeetingItems(items: FeedItem[], now: Date): PreMeetingInfo {
|
||||||
|
const nowMs = now.getTime()
|
||||||
|
const upcomingMeetingIds = new Set<string>()
|
||||||
|
let hasLocationMeeting = false
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
|
||||||
|
|
||||||
|
const data = item.data as CalendarEventData | CalDavEventData
|
||||||
|
const msUntil = getEventStartTime(data).getTime() - nowMs
|
||||||
|
if (msUntil > 0 && msUntil <= PRE_MEETING_WINDOW_MS) {
|
||||||
|
upcomingMeetingIds.add(item.id)
|
||||||
|
if (data.location) {
|
||||||
|
hasLocationMeeting = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { upcomingMeetingIds, hasLocationMeeting }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
|
||||||
|
let earliest: { id: string; time: number } | null = null
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
|
||||||
|
|
||||||
|
const data = item.data as CalendarEventData | CalDavEventData
|
||||||
|
const startTime = getEventStartTime(data)
|
||||||
|
const startMs = startTime.getTime()
|
||||||
|
|
||||||
|
// Must be today and in the future
|
||||||
|
const sameDay =
|
||||||
|
startTime.getFullYear() === now.getFullYear() &&
|
||||||
|
startTime.getMonth() === now.getMonth() &&
|
||||||
|
startTime.getDate() === now.getDate()
|
||||||
|
if (!sameDay) continue
|
||||||
|
if (startMs <= now.getTime()) continue
|
||||||
|
|
||||||
|
if (!earliest || startMs < earliest.time) {
|
||||||
|
earliest = { id: item.id, time: startMs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return earliest?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function applyMorningWeekday(
|
||||||
|
items: FeedItem[],
|
||||||
|
boost: Record<string, number>,
|
||||||
|
firstEventId: string | null,
|
||||||
|
): void {
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.7
|
||||||
|
break
|
||||||
|
case WeatherFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.8
|
||||||
|
break
|
||||||
|
case WeatherFeedItemType.Hourly:
|
||||||
|
case WeatherFeedItemType.Daily:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.3
|
||||||
|
break
|
||||||
|
case TflFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.6
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEventId) {
|
||||||
|
boost[firstEventId] = (boost[firstEventId] ?? 0) + 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMorningWeekend(items: FeedItem[], boost: Record<string, number>): void {
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.5
|
||||||
|
break
|
||||||
|
case WeatherFeedItemType.Daily:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.4
|
||||||
|
break
|
||||||
|
case CalendarFeedItemType.Event:
|
||||||
|
case CalDavFeedItemType.Event:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.4
|
||||||
|
break
|
||||||
|
case TflFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.3
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAfternoonWeekday(items: FeedItem[], boost: Record<string, number>): void {
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case CalendarFeedItemType.Event:
|
||||||
|
case CalDavFeedItemType.Event:
|
||||||
|
if (item.signals?.timeRelevance === TimeRelevance.Imminent) {
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.5
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
case WeatherFeedItemType.Hourly:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAfternoonWeekend(items: FeedItem[], boost: Record<string, number>): void {
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
||||||
|
break
|
||||||
|
case CalendarFeedItemType.Event:
|
||||||
|
case CalDavFeedItemType.Event:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.5
|
||||||
|
break
|
||||||
|
case TflFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEveningWeekday(
|
||||||
|
items: FeedItem[],
|
||||||
|
boost: Record<string, number>,
|
||||||
|
suppress: string[],
|
||||||
|
): void {
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case CalendarFeedItemType.Event:
|
||||||
|
case CalDavFeedItemType.Event:
|
||||||
|
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
|
||||||
|
suppress.push(item.id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TflFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.4
|
||||||
|
break
|
||||||
|
case WeatherFeedItemType.Daily:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
||||||
|
break
|
||||||
|
case CalendarFeedItemType.AllDay:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEveningWeekend(
|
||||||
|
items: FeedItem[],
|
||||||
|
boost: Record<string, number>,
|
||||||
|
suppress: string[],
|
||||||
|
): void {
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
||||||
|
break
|
||||||
|
case CalendarFeedItemType.Event:
|
||||||
|
case CalDavFeedItemType.Event:
|
||||||
|
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
|
||||||
|
suppress.push(item.id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TflFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNight(items: FeedItem[], boost: Record<string, number>, suppress: string[]): void {
|
||||||
|
for (const item of items) {
|
||||||
|
// Suppress all ambient items
|
||||||
|
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
|
||||||
|
suppress.push(item.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-urgency alerts survive unboosted
|
||||||
|
if (
|
||||||
|
(item.type === WeatherFeedItemType.Alert || item.type === TflFeedItemType.Alert) &&
|
||||||
|
(item.signals?.urgency ?? 0) >= 0.8
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demote everything else
|
||||||
|
switch (item.type) {
|
||||||
|
case CalendarFeedItemType.Event:
|
||||||
|
case CalendarFeedItemType.AllDay:
|
||||||
|
case CalDavFeedItemType.Event:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.6
|
||||||
|
break
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
case WeatherFeedItemType.Hourly:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) - 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function applyPreMeetingOverrides(
|
||||||
|
items: FeedItem[],
|
||||||
|
preMeeting: PreMeetingInfo,
|
||||||
|
boost: Record<string, number>,
|
||||||
|
suppress: string[],
|
||||||
|
): void {
|
||||||
|
if (preMeeting.upcomingMeetingIds.size === 0) return
|
||||||
|
|
||||||
|
// Intentional override, not additive — the upcoming meeting should dominate
|
||||||
|
// regardless of what period rules assigned. Don't reorder this before period rules.
|
||||||
|
for (const meetingId of preMeeting.upcomingMeetingIds) {
|
||||||
|
boost[meetingId] = 0.9
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (preMeeting.upcomingMeetingIds.has(item.id)) continue
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case TflFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.5
|
||||||
|
break
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
if (preMeeting.hasLocationMeeting) {
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.4
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress items that explicitly declare low urgency.
|
||||||
|
// Items without signals are left alone — absence of urgency is not low urgency.
|
||||||
|
if (item.signals && item.signals.urgency !== undefined && item.signals.urgency < 0.3) {
|
||||||
|
suppress.push(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWindDown(
|
||||||
|
items: FeedItem[],
|
||||||
|
now: Date,
|
||||||
|
dayType: DayType,
|
||||||
|
boost: Record<string, number>,
|
||||||
|
): void {
|
||||||
|
if (dayType !== DayType.Weekday) return
|
||||||
|
|
||||||
|
const hour = now.getHours()
|
||||||
|
const minutes = now.getMinutes()
|
||||||
|
|
||||||
|
if (hour < 20 || hour >= 22) return
|
||||||
|
|
||||||
|
// Gradient: 20:00 → -0.1, 21:00 → -0.2, 21:30+ → -0.3
|
||||||
|
let additionalDemotion: number
|
||||||
|
if (hour === 20) {
|
||||||
|
additionalDemotion = -0.1
|
||||||
|
} else if (hour === 21 && minutes < 30) {
|
||||||
|
additionalDemotion = -0.2
|
||||||
|
} else {
|
||||||
|
additionalDemotion = -0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case CalendarFeedItemType.Event:
|
||||||
|
case CalendarFeedItemType.AllDay:
|
||||||
|
case CalDavFeedItemType.Event:
|
||||||
|
case TflFeedItemType.Alert:
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + additionalDemotion
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function applyTransitionLookahead(
|
||||||
|
items: FeedItem[],
|
||||||
|
now: Date,
|
||||||
|
currentPeriod: TimePeriod,
|
||||||
|
dayType: DayType,
|
||||||
|
boost: Record<string, number>,
|
||||||
|
): void {
|
||||||
|
const next = getNextPeriodBoundary(now)
|
||||||
|
if (next.msUntil > TRANSITION_WINDOW_MS) return
|
||||||
|
|
||||||
|
// Apply a +0.2 secondary boost to items that would be boosted in the next period
|
||||||
|
const nextPeriodBoost = getNextPeriodBoostTargets(next.period, dayType)
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (nextPeriodBoost.has(item.type)) {
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of item types that get boosted in a given period+dayType.
|
||||||
|
*/
|
||||||
|
function getNextPeriodBoostTargets(period: TimePeriod, dayType: DayType): ReadonlySet<string> {
|
||||||
|
const targets = new Set<string>()
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case TimePeriod.Morning:
|
||||||
|
targets.add(WeatherFeedItemType.Current)
|
||||||
|
if (dayType === DayType.Weekday) {
|
||||||
|
targets.add(WeatherFeedItemType.Alert)
|
||||||
|
targets.add(TflFeedItemType.Alert)
|
||||||
|
} else {
|
||||||
|
targets.add(WeatherFeedItemType.Daily)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TimePeriod.Afternoon:
|
||||||
|
if (dayType === DayType.Weekend) {
|
||||||
|
targets.add(WeatherFeedItemType.Current)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TimePeriod.Evening:
|
||||||
|
targets.add(WeatherFeedItemType.Daily)
|
||||||
|
if (dayType === DayType.Weekend) {
|
||||||
|
targets.add(WeatherFeedItemType.Current)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case TimePeriod.Night:
|
||||||
|
// Night doesn't boost much — transition toward night means demoting,
|
||||||
|
// which is handled by wind-down. No positive targets here.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function applyWeatherTimeCorrelation(
|
||||||
|
items: FeedItem[],
|
||||||
|
period: TimePeriod,
|
||||||
|
dayType: DayType,
|
||||||
|
hasEveningEventWithLocation: boolean,
|
||||||
|
boost: Record<string, number>,
|
||||||
|
): void {
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case WeatherFeedItemType.Alert: {
|
||||||
|
const current = boost[item.id] ?? 0
|
||||||
|
if (current < 0.5) {
|
||||||
|
boost[item.id] = 0.5
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case WeatherFeedItemType.Current:
|
||||||
|
if (period === TimePeriod.Morning && dayType === DayType.Weekday && hasPrecipitationOrExtreme(item)) {
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.1
|
||||||
|
}
|
||||||
|
if (period === TimePeriod.Evening && hasEveningEventWithLocation) {
|
||||||
|
boost[item.id] = (boost[item.id] ?? 0) + 0.2
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEveningCalendarEventWithLocation(items: FeedItem[], now: Date): boolean {
|
||||||
|
const todayEvening17 = new Date(now)
|
||||||
|
todayEvening17.setHours(17, 0, 0, 0)
|
||||||
|
const todayNight22 = new Date(now)
|
||||||
|
todayNight22.setHours(22, 0, 0, 0)
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
|
||||||
|
|
||||||
|
const data = item.data as CalendarEventData | CalDavEventData
|
||||||
|
const startMs = getEventStartTime(data).getTime()
|
||||||
|
if (startMs >= todayEvening17.getTime() && startMs < todayNight22.getTime()) {
|
||||||
|
if (data.location) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
import type { Context } from "@aris/core"
|
|
||||||
|
|
||||||
import { 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,
|
|
||||||
} from "./types.ts"
|
|
||||||
|
|
||||||
import { CalendarKey } from "./calendar-context.ts"
|
|
||||||
import { CalendarSource, computePriority } from "./calendar-source.ts"
|
|
||||||
|
|
||||||
function loadFixture(name: string): string {
|
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
credentials: Record<string, unknown> = {}
|
|
||||||
fetchCalendarsCallCount = 0
|
|
||||||
private calendars: CalendarDAVCalendar[]
|
|
||||||
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
calendars: CalendarDAVCalendar[],
|
|
||||||
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
|
|
||||||
) {
|
|
||||||
this.calendars = calendars
|
|
||||||
this.objectsByCalendarUrl = objectsByCalendarUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(): Promise<void> {}
|
|
||||||
|
|
||||||
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
|
|
||||||
this.fetchCalendarsCallCount++
|
|
||||||
return this.calendars
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchCalendarObjects(params: {
|
|
||||||
calendar: CalendarDAVCalendar
|
|
||||||
timeRange: { start: string; end: string }
|
|
||||||
}): Promise<CalendarDAVObject[]> {
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns empty array when credentials are null", async () => {
|
|
||||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
expect(items).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns empty array when no calendars exist", async () => {
|
|
||||||
const client = new MockDAVClient([], {})
|
|
||||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
|
||||||
davClient: 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[]> = {
|
|
||||||
"/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 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]!.data.title).toBe("Team Standup")
|
|
||||||
expect(items[0]!.data.location).toBe("Conference Room A")
|
|
||||||
expect(items[0]!.data.calendarName).toBe("Work")
|
|
||||||
expect(items[0]!.data.attendees).toHaveLength(2)
|
|
||||||
expect(items[0]!.data.alarms).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns feed items from multiple calendars", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
"/cal/personal": [
|
|
||||||
{
|
|
||||||
url: "/cal/personal/event2.ics",
|
|
||||||
data: loadFixture("all-day-event.ics"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient(
|
|
||||||
[
|
|
||||||
{ url: "/cal/work", displayName: "Work" },
|
|
||||||
{ url: "/cal/personal", displayName: "Personal" },
|
|
||||||
],
|
|
||||||
objects,
|
|
||||||
)
|
|
||||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
|
||||||
davClient: client,
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
|
||||||
|
|
||||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
|
||||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
|
||||||
|
|
||||||
expect(standup).toBeDefined()
|
|
||||||
expect(standup!.data.calendarName).toBe("Work")
|
|
||||||
|
|
||||||
expect(holiday).toBeDefined()
|
|
||||||
expect(holiday!.data.calendarName).toBe("Personal")
|
|
||||||
expect(holiday!.data.isAllDay).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("skips objects with non-string data", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/cal/work": [
|
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
|
||||||
{ url: "/cal/work/bad.ics", data: 12345 },
|
|
||||||
{ url: "/cal/work/empty.ics" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
|
||||||
davClient: client,
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
expect(items[0]!.data.title).toBe("Team Standup")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses context time as feed item timestamp", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/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 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[]> = {
|
|
||||||
"/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,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2 hours before the event at 14:00
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles calendar with non-string displayName", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/cal/weird": [
|
|
||||||
{
|
|
||||||
url: "/cal/weird/event1.ics",
|
|
||||||
data: loadFixture("minimal-event.ics"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient(
|
|
||||||
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
|
|
||||||
objects,
|
|
||||||
)
|
|
||||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
|
||||||
davClient: 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[]> = {
|
|
||||||
"/cal/work": [
|
|
||||||
{
|
|
||||||
url: "/cal/work/recurring.ics",
|
|
||||||
data: loadFixture("recurring-event.ics"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
|
||||||
davClient: client,
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
|
||||||
|
|
||||||
const base = items.find((i) => i.data.title === "Weekly Sync")
|
|
||||||
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
|
|
||||||
|
|
||||||
expect(base).toBeDefined()
|
|
||||||
expect(base!.data.recurrenceId).toBeNull()
|
|
||||||
|
|
||||||
expect(exception).toBeDefined()
|
|
||||||
expect(exception!.data.recurrenceId).not.toBeNull()
|
|
||||||
expect(exception!.id).toContain("-")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caches events within the same refresh cycle", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/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 context = createContext(new Date("2026-01-15T12:00:00Z"))
|
|
||||||
|
|
||||||
await source.fetchContext(context)
|
|
||||||
await source.fetchItems(context)
|
|
||||||
|
|
||||||
// Same context.time reference — fetchEvents should only hit the client once
|
|
||||||
expect(client.fetchCalendarsCallCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("refetches events for a different context time", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/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,
|
|
||||||
})
|
|
||||||
|
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
|
|
||||||
|
|
||||||
// Different context.time references — should fetch twice
|
|
||||||
expect(client.fetchCalendarsCallCount).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("CalendarSource.fetchContext", () => {
|
|
||||||
test("returns empty context when credentials are null", async () => {
|
|
||||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
|
||||||
|
|
||||||
expect(calendar).toBeDefined()
|
|
||||||
expect(calendar!.inProgress).toEqual([])
|
|
||||||
expect(calendar!.nextEvent).toBeNull()
|
|
||||||
expect(calendar!.hasTodayEvents).toBe(false)
|
|
||||||
expect(calendar!.todayEventCount).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("identifies in-progress events", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/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,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(1)
|
|
||||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("identifies next upcoming event", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/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,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
|
||||||
expect(calendar!.nextEvent).not.toBeNull()
|
|
||||||
expect(calendar!.nextEvent!.title).toBe("Team Standup")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("excludes all-day events from inProgress and nextEvent", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/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 ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
|
||||||
expect(calendar!.nextEvent).toBeNull()
|
|
||||||
expect(calendar!.hasTodayEvents).toBe(true)
|
|
||||||
expect(calendar!.todayEventCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("counts all events including all-day in todayEventCount", async () => {
|
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
|
||||||
"/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 ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
|
||||||
|
|
||||||
expect(calendar!.todayEventCount).toBe(2)
|
|
||||||
expect(calendar!.hasTodayEvents).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("computePriority", () => {
|
|
||||||
const now = new Date("2026-01-15T12:00:00Z")
|
|
||||||
|
|
||||||
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
|
|
||||||
return {
|
|
||||||
uid: "test-uid",
|
|
||||||
title: "Test",
|
|
||||||
startDate: new Date("2026-01-15T14:00:00Z"),
|
|
||||||
endDate: new Date("2026-01-15T15:00:00Z"),
|
|
||||||
isAllDay: false,
|
|
||||||
location: null,
|
|
||||||
description: null,
|
|
||||||
calendarName: null,
|
|
||||||
status: null,
|
|
||||||
url: null,
|
|
||||||
organizer: null,
|
|
||||||
attendees: [],
|
|
||||||
alarms: [],
|
|
||||||
recurrenceId: null,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("all-day events get priority 0.3", () => {
|
|
||||||
const event = makeEvent({ isAllDay: true })
|
|
||||||
expect(computePriority(event, now)).toBe(0.3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events starting within 30 minutes get priority 0.9", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T12:20:00Z"),
|
|
||||||
})
|
|
||||||
expect(computePriority(event, now)).toBe(0.9)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events starting exactly at 30 minutes get priority 0.9", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T12:30:00Z"),
|
|
||||||
})
|
|
||||||
expect(computePriority(event, now)).toBe(0.9)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events starting within 2 hours get priority 0.7", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T13:00:00Z"),
|
|
||||||
})
|
|
||||||
expect(computePriority(event, now)).toBe(0.7)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events later today get priority 0.5", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T20:00:00Z"),
|
|
||||||
})
|
|
||||||
expect(computePriority(event, now)).toBe(0.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("in-progress events get priority 0.8", () => {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("fully past events get priority 0.2", () => {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events on future days get priority 0.2", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-16T10:00:00Z"),
|
|
||||||
})
|
|
||||||
expect(computePriority(event, now)).toBe(0.2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("priority 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)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,107 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { readFileSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
|
|
||||||
import { parseICalEvents } from "./ical-parser.ts"
|
|
||||||
|
|
||||||
function loadFixture(name: string): string {
|
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("parseICalEvents", () => {
|
|
||||||
test("parses a full event with all fields", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("single-event.ics"), "Work")
|
|
||||||
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
const event = events[0]!
|
|
||||||
|
|
||||||
expect(event.uid).toBe("single-event-001@test")
|
|
||||||
expect(event.title).toBe("Team Standup")
|
|
||||||
expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z"))
|
|
||||||
expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z"))
|
|
||||||
expect(event.isAllDay).toBe(false)
|
|
||||||
expect(event.location).toBe("Conference Room A")
|
|
||||||
expect(event.description).toBe("Daily standup meeting")
|
|
||||||
expect(event.calendarName).toBe("Work")
|
|
||||||
expect(event.status).toBe("confirmed")
|
|
||||||
expect(event.url).toBe("https://example.com/meeting/123")
|
|
||||||
expect(event.organizer).toBe("Alice Smith")
|
|
||||||
expect(event.recurrenceId).toBeNull()
|
|
||||||
|
|
||||||
expect(event.attendees).toHaveLength(2)
|
|
||||||
expect(event.attendees[0]).toEqual({
|
|
||||||
name: "Bob Jones",
|
|
||||||
email: "bob@example.com",
|
|
||||||
role: "required",
|
|
||||||
status: "accepted",
|
|
||||||
})
|
|
||||||
expect(event.attendees[1]).toEqual({
|
|
||||||
name: "Carol White",
|
|
||||||
email: "carol@example.com",
|
|
||||||
role: "optional",
|
|
||||||
status: "tentative",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(event.alarms).toHaveLength(2)
|
|
||||||
expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" })
|
|
||||||
expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses an all-day event with optional fields as null", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("all-day-event.ics"), null)
|
|
||||||
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
const event = events[0]!
|
|
||||||
|
|
||||||
expect(event.isAllDay).toBe(true)
|
|
||||||
expect(event.title).toBe("Company Holiday")
|
|
||||||
expect(event.calendarName).toBeNull()
|
|
||||||
expect(event.location).toBeNull()
|
|
||||||
expect(event.description).toBeNull()
|
|
||||||
expect(event.url).toBeNull()
|
|
||||||
expect(event.organizer).toBeNull()
|
|
||||||
expect(event.attendees).toEqual([])
|
|
||||||
expect(event.alarms).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses recurring event with exception", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
|
|
||||||
|
|
||||||
expect(events).toHaveLength(2)
|
|
||||||
expect(events[0]!.uid).toBe("recurring-001@test")
|
|
||||||
expect(events[1]!.uid).toBe("recurring-001@test")
|
|
||||||
|
|
||||||
const base = events.find((e) => e.title === "Weekly Sync")
|
|
||||||
expect(base).toBeDefined()
|
|
||||||
expect(base!.recurrenceId).toBeNull()
|
|
||||||
|
|
||||||
const exception = events.find((e) => e.title === "Weekly Sync (moved)")
|
|
||||||
expect(exception).toBeDefined()
|
|
||||||
expect(exception!.recurrenceId).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses minimal event with defaults", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("minimal-event.ics"), null)
|
|
||||||
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
const event = events[0]!
|
|
||||||
|
|
||||||
expect(event.uid).toBe("minimal-001@test")
|
|
||||||
expect(event.title).toBe("Quick Chat")
|
|
||||||
expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z"))
|
|
||||||
expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z"))
|
|
||||||
expect(event.location).toBeNull()
|
|
||||||
expect(event.description).toBeNull()
|
|
||||||
expect(event.status).toBeNull()
|
|
||||||
expect(event.url).toBeNull()
|
|
||||||
expect(event.organizer).toBeNull()
|
|
||||||
expect(event.attendees).toEqual([])
|
|
||||||
expect(event.alarms).toEqual([])
|
|
||||||
expect(event.recurrenceId).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses cancelled status", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("cancelled-event.ics"), null)
|
|
||||||
expect(events[0]!.status).toBe("cancelled")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import ICAL from "ical.js"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AttendeeRole,
|
|
||||||
AttendeeStatus,
|
|
||||||
CalendarEventStatus,
|
|
||||||
type CalendarAlarm,
|
|
||||||
type CalendarAttendee,
|
|
||||||
type CalendarEventData,
|
|
||||||
} from "./types.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
|
||||||
* into CalendarEventData 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[] {
|
|
||||||
const jcal = ICAL.parse(icsData)
|
|
||||||
const comp = new ICAL.Component(jcal)
|
|
||||||
const vevents = comp.getAllSubcomponents("vevent")
|
|
||||||
|
|
||||||
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
|
||||||
parseVEvent(vevent, calendarName),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVEvent(
|
|
||||||
vevent: InstanceType<typeof ICAL.Component>,
|
|
||||||
calendarName: string | null,
|
|
||||||
): CalendarEventData {
|
|
||||||
const event = new ICAL.Event(vevent)
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid: event.uid ?? "",
|
|
||||||
title: event.summary ?? "",
|
|
||||||
startDate: event.startDate?.toJSDate() ?? new Date(0),
|
|
||||||
endDate: event.endDate?.toJSDate() ?? new Date(0),
|
|
||||||
isAllDay: event.startDate?.isDate ?? false,
|
|
||||||
location: event.location ?? null,
|
|
||||||
description: event.description ?? null,
|
|
||||||
calendarName,
|
|
||||||
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
|
||||||
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
|
||||||
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
|
||||||
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
|
||||||
alarms: parseAlarms(vevent),
|
|
||||||
recurrenceId: event.recurrenceId ? event.recurrenceId.toString() : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStatus(raw: string | null): CalendarEventStatus | null {
|
|
||||||
if (!raw) return null
|
|
||||||
switch (raw.toLowerCase()) {
|
|
||||||
case "confirmed":
|
|
||||||
return CalendarEventStatus.Confirmed
|
|
||||||
case "tentative":
|
|
||||||
return CalendarEventStatus.Tentative
|
|
||||||
case "cancelled":
|
|
||||||
return CalendarEventStatus.Cancelled
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOrganizer(
|
|
||||||
value: string | null,
|
|
||||||
vevent: InstanceType<typeof ICAL.Component>,
|
|
||||||
): string | null {
|
|
||||||
if (!value) return null
|
|
||||||
|
|
||||||
// Try CN parameter first
|
|
||||||
const prop = vevent.getFirstProperty("organizer")
|
|
||||||
if (prop) {
|
|
||||||
const cn = prop.getParameter("cn") as string | undefined
|
|
||||||
if (cn) return cn
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to mailto: value
|
|
||||||
return value.replace(/^mailto:/i, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttendees(properties: unknown[]): CalendarAttendee[] {
|
|
||||||
if (properties.length === 0) return []
|
|
||||||
|
|
||||||
return properties.map((prop) => {
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
|
|
||||||
if (!raw) return null
|
|
||||||
switch (raw.toUpperCase()) {
|
|
||||||
case "CHAIR":
|
|
||||||
return AttendeeRole.Chair
|
|
||||||
case "REQ-PARTICIPANT":
|
|
||||||
return AttendeeRole.Required
|
|
||||||
case "OPT-PARTICIPANT":
|
|
||||||
return AttendeeRole.Optional
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
|
|
||||||
if (!raw) return null
|
|
||||||
switch (raw.toUpperCase()) {
|
|
||||||
case "ACCEPTED":
|
|
||||||
return AttendeeStatus.Accepted
|
|
||||||
case "DECLINED":
|
|
||||||
return AttendeeStatus.Declined
|
|
||||||
case "TENTATIVE":
|
|
||||||
return AttendeeStatus.Tentative
|
|
||||||
case "NEEDS-ACTION":
|
|
||||||
return AttendeeStatus.NeedsAction
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {
|
|
||||||
const valarms = vevent.getAllSubcomponents("valarm")
|
|
||||||
if (!valarms || valarms.length === 0) return []
|
|
||||||
|
|
||||||
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
|
|
||||||
const trigger = valarm.getFirstPropertyValue("trigger")
|
|
||||||
const action = asStringOrNull(valarm.getFirstPropertyValue("action"))
|
|
||||||
|
|
||||||
return {
|
|
||||||
trigger: trigger ? trigger.toString() : "",
|
|
||||||
action: action ?? "DISPLAY",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStringOrNull(value: unknown): string | null {
|
|
||||||
return typeof value === "string" ? value : null
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Test//Test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:daily-allday-001@test
|
||||||
|
DTSTART;VALUE=DATE:20260112
|
||||||
|
DTEND;VALUE=DATE:20260113
|
||||||
|
SUMMARY:Daily Reminder
|
||||||
|
RRULE:FREQ=DAILY;COUNT=7
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Test//Test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:weekly-exc-001@test
|
||||||
|
DTSTART:20260101T140000Z
|
||||||
|
DTEND:20260101T150000Z
|
||||||
|
SUMMARY:Standup
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=8
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:weekly-exc-001@test
|
||||||
|
RECURRENCE-ID:20260115T140000Z
|
||||||
|
DTSTART:20260115T160000Z
|
||||||
|
DTEND:20260115T170000Z
|
||||||
|
SUMMARY:Standup (rescheduled)
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
13
packages/aris-source-caldav/fixtures/weekly-recurring.ics
Normal file
13
packages/aris-source-caldav/fixtures/weekly-recurring.ics
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Test//Test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:weekly-001@test
|
||||||
|
DTSTART:20260101T100000Z
|
||||||
|
DTEND:20260101T110000Z
|
||||||
|
SUMMARY:Weekly Team Meeting
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=10
|
||||||
|
LOCATION:Room B
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@aris/source-apple-calendar",
|
"name": "@aris/source-caldav",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test ."
|
"test": "bun test .",
|
||||||
|
"test:live": "bun run scripts/test-live.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
80
packages/aris-source-caldav/scripts/test-live.ts
Normal file
80
packages/aris-source-caldav/scripts/test-live.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Live test script for CalDavSource.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run test-live.ts
|
||||||
|
*
|
||||||
|
* Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
import { Context } from "@aris/core"
|
||||||
|
|
||||||
|
import { CalDavSource } from "../src/index.ts"
|
||||||
|
|
||||||
|
const serverUrl = prompt("CalDAV server URL:")
|
||||||
|
const username = prompt("Username:")
|
||||||
|
const password = prompt("Password:")
|
||||||
|
const lookAheadRaw = prompt("Look-ahead days (default 0):")
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !password) {
|
||||||
|
console.error("Server URL, username, and password are required.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookAheadDays = Number(lookAheadRaw) || 0
|
||||||
|
|
||||||
|
const source = new CalDavSource({
|
||||||
|
serverUrl,
|
||||||
|
authMethod: "basic",
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
lookAheadDays,
|
||||||
|
})
|
||||||
|
|
||||||
|
const context = new Context()
|
||||||
|
|
||||||
|
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
|
||||||
|
|
||||||
|
const contextResult = await source.fetchContext(context)
|
||||||
|
const items = await source.fetchItems(context)
|
||||||
|
|
||||||
|
console.log("=== Context ===")
|
||||||
|
console.log(JSON.stringify(contextResult, null, 2))
|
||||||
|
|
||||||
|
console.log(`\n=== Feed Items (${items.length}) ===`)
|
||||||
|
for (const item of items) {
|
||||||
|
console.log(`\n--- ${item.data.title} ---`)
|
||||||
|
console.log(` ID: ${item.id}`)
|
||||||
|
console.log(` Calendar: ${item.data.calendarName ?? "(unknown)"}`)
|
||||||
|
console.log(` Start: ${item.data.startDate.toISOString()}`)
|
||||||
|
console.log(` End: ${item.data.endDate.toISOString()}`)
|
||||||
|
console.log(` All-day: ${item.data.isAllDay}`)
|
||||||
|
console.log(` Location: ${item.data.location ?? "(none)"}`)
|
||||||
|
console.log(` Status: ${item.data.status ?? "(none)"}`)
|
||||||
|
console.log(` Urgency: ${item.signals?.urgency}`)
|
||||||
|
console.log(` Relevance: ${item.signals?.timeRelevance}`)
|
||||||
|
if (item.slots) {
|
||||||
|
console.log(` Slots: ${Object.keys(item.slots).join(", ")}`)
|
||||||
|
}
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write feed items to .cache for slot testing
|
||||||
|
const cacheDir = join(import.meta.dir, ".cache")
|
||||||
|
mkdirSync(cacheDir, { recursive: true })
|
||||||
|
|
||||||
|
const outPath = join(cacheDir, "feed-items.json")
|
||||||
|
writeFileSync(outPath, JSON.stringify(items, null, 2))
|
||||||
|
console.log(`\nFeed items written to ${outPath}`)
|
||||||
601
packages/aris-source-caldav/src/caldav-source.test.ts
Normal file
601
packages/aris-source-caldav/src/caldav-source.test.ts
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
import type { ContextEntry } from "@aris/core"
|
||||||
|
|
||||||
|
import { Context, TimeRelevance } from "@aris/core"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CalDavDAVCalendar,
|
||||||
|
CalDavDAVClient,
|
||||||
|
CalDavDAVObject,
|
||||||
|
CalDavEventData,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
||||||
|
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
|
|
||||||
|
function loadFixture(name: string): string {
|
||||||
|
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(time: Date): Context {
|
||||||
|
return new Context(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the CalendarContext value from fetchContext entries. */
|
||||||
|
function extractCalendar(entries: readonly ContextEntry[] | null): CalendarContext | undefined {
|
||||||
|
if (!entries) return undefined
|
||||||
|
const entry = entries.find(([key]) => key === CalDavCalendarKey)
|
||||||
|
return entry?.[1] as CalendarContext | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockDAVClient implements CalDavDAVClient {
|
||||||
|
credentials: Record<string, unknown> = {}
|
||||||
|
fetchCalendarsCallCount = 0
|
||||||
|
lastTimeRange: { start: string; end: string } | null = null
|
||||||
|
private calendars: CalDavDAVCalendar[]
|
||||||
|
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
calendars: CalDavDAVCalendar[],
|
||||||
|
objectsByCalendarUrl: Record<string, CalDavDAVObject[]>,
|
||||||
|
) {
|
||||||
|
this.calendars = calendars
|
||||||
|
this.objectsByCalendarUrl = objectsByCalendarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(): Promise<void> {}
|
||||||
|
|
||||||
|
async fetchCalendars(): Promise<CalDavDAVCalendar[]> {
|
||||||
|
this.fetchCalendarsCallCount++
|
||||||
|
return this.calendars
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCalendarObjects(params: {
|
||||||
|
calendar: CalDavDAVCalendar
|
||||||
|
timeRange: { start: string; end: string }
|
||||||
|
}): Promise<CalDavDAVObject[]> {
|
||||||
|
this.lastTimeRange = params.timeRange
|
||||||
|
return this.objectsByCalendarUrl[params.calendar.url] ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource {
|
||||||
|
return new CalDavSource({
|
||||||
|
serverUrl: "https://caldav.example.com",
|
||||||
|
authMethod: "basic",
|
||||||
|
username: "user",
|
||||||
|
password: "pass",
|
||||||
|
davClient: client,
|
||||||
|
lookAheadDays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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, 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)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
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")
|
||||||
|
expect(items[0]!.data.attendees).toHaveLength(2)
|
||||||
|
expect(items[0]!.data.alarms).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns feed items from multiple calendars", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
|
"/cal/personal": [
|
||||||
|
{
|
||||||
|
url: "/cal/personal/event2.ics",
|
||||||
|
data: loadFixture("all-day-event.ics"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient(
|
||||||
|
[
|
||||||
|
{ url: "/cal/work", displayName: "Work" },
|
||||||
|
{ url: "/cal/personal", displayName: "Personal" },
|
||||||
|
],
|
||||||
|
objects,
|
||||||
|
)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
|
||||||
|
const standup = items.find((i) => i.data.title === "Team Standup")
|
||||||
|
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
||||||
|
|
||||||
|
expect(standup).toBeDefined()
|
||||||
|
expect(standup!.data.calendarName).toBe("Work")
|
||||||
|
|
||||||
|
expect(holiday).toBeDefined()
|
||||||
|
expect(holiday!.data.calendarName).toBe("Personal")
|
||||||
|
expect(holiday!.data.isAllDay).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips objects with non-string data", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [
|
||||||
|
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||||
|
{ url: "/cal/work/bad.ics", data: 12345 },
|
||||||
|
{ url: "/cal/work/empty.ics" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
expect(items[0]!.data.title).toBe("Team Standup")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses context time as feed item timestamp", 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)
|
||||||
|
|
||||||
|
const now = new Date("2026-01-15T12:00:00Z")
|
||||||
|
const items = await source.fetchItems(createContext(now))
|
||||||
|
expect(items[0]!.timestamp).toEqual(now)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = createSource(client)
|
||||||
|
|
||||||
|
// 2 hours before the event at 14:00
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
const standup = items.find((i) => i.data.title === "Team Standup")
|
||||||
|
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
||||||
|
|
||||||
|
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, CalDavDAVObject[]> = {
|
||||||
|
"/cal/weird": [
|
||||||
|
{
|
||||||
|
url: "/cal/weird/event1.ics",
|
||||||
|
data: loadFixture("minimal-event.ics"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient(
|
||||||
|
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
|
||||||
|
objects,
|
||||||
|
)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
expect(items[0]!.data.calendarName).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("expands recurring events within the time range", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [
|
||||||
|
{
|
||||||
|
url: "/cal/work/recurring.ics",
|
||||||
|
data: loadFixture("recurring-event.ics"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
// lookAheadDays=0 → range is Jan 15 only
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||||
|
|
||||||
|
// Only the Jan 15 occurrence falls in the single-day window
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
expect(items[0]!.data.title).toBe("Weekly Sync")
|
||||||
|
expect(items[0]!.data.startDate).toEqual(new Date("2026-01-15T09:00:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes exception overrides when they fall in range", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [
|
||||||
|
{
|
||||||
|
url: "/cal/work/recurring.ics",
|
||||||
|
data: loadFixture("recurring-event.ics"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
// lookAheadDays=8 → range covers Jan 15 through Jan 23, includes the Jan 22 exception
|
||||||
|
const source = createSource(client, 8)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||||
|
|
||||||
|
const base = items.filter((i) => i.data.title === "Weekly Sync")
|
||||||
|
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
|
||||||
|
|
||||||
|
// Jan 15 base occurrence
|
||||||
|
expect(base.length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
// Jan 22 exception replaces the base occurrence
|
||||||
|
expect(exception).toBeDefined()
|
||||||
|
expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
|
||||||
|
expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caches events within the same refresh cycle", 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)
|
||||||
|
|
||||||
|
const context = createContext(new Date("2026-01-15T12:00:00Z"))
|
||||||
|
|
||||||
|
await source.fetchContext(context)
|
||||||
|
await source.fetchItems(context)
|
||||||
|
|
||||||
|
// Same context.time reference — fetchEvents should only hit the client once
|
||||||
|
expect(client.fetchCalendarsCallCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses timezone for time range when provided", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
|
||||||
|
// 2026-01-15T22:00:00Z = 2026-01-16T09:00:00 in Australia/Sydney (AEDT, UTC+11)
|
||||||
|
const source = new CalDavSource({
|
||||||
|
serverUrl: "https://caldav.example.com",
|
||||||
|
authMethod: "basic",
|
||||||
|
username: "user",
|
||||||
|
password: "pass",
|
||||||
|
davClient: client,
|
||||||
|
timeZone: "Australia/Sydney",
|
||||||
|
})
|
||||||
|
|
||||||
|
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
|
||||||
|
|
||||||
|
// "Today" in Sydney is Jan 16, so start should be Jan 15 13:00 UTC (midnight Jan 16 AEDT)
|
||||||
|
expect(client.lastTimeRange).not.toBeNull()
|
||||||
|
expect(client.lastTimeRange!.start).toBe("2026-01-15T13:00:00.000Z")
|
||||||
|
// End should be Jan 16 13:00 UTC (midnight Jan 17 AEDT) — 1 day window
|
||||||
|
expect(client.lastTimeRange!.end).toBe("2026-01-16T13:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults to UTC midnight when no timezone provided", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
|
||||||
|
|
||||||
|
expect(client.lastTimeRange).not.toBeNull()
|
||||||
|
expect(client.lastTimeRange!.start).toBe("2026-01-15T00:00:00.000Z")
|
||||||
|
expect(client.lastTimeRange!.end).toBe("2026-01-16T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refetches events for a different context time", async () => {
|
||||||
|
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")))
|
||||||
|
|
||||||
|
// Different context.time references — should fetch twice
|
||||||
|
expect(client.fetchCalendarsCallCount).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("CalDavSource.fetchContext", () => {
|
||||||
|
test("returns empty context when no calendars exist", async () => {
|
||||||
|
const client = new MockDAVClient([], {})
|
||||||
|
const source = createSource(client)
|
||||||
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
|
expect(calendar).toBeDefined()
|
||||||
|
expect(calendar!.inProgress).toEqual([])
|
||||||
|
expect(calendar!.nextEvent).toBeNull()
|
||||||
|
expect(calendar!.hasTodayEvents).toBe(false)
|
||||||
|
expect(calendar!.todayEventCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("identifies in-progress events", 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)
|
||||||
|
|
||||||
|
// 14:30 is during the 14:00-15:00 event
|
||||||
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||||
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
|
expect(calendar!.inProgress).toHaveLength(1)
|
||||||
|
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("identifies next upcoming event", 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)
|
||||||
|
|
||||||
|
// 12:00 is before the 14:00 event
|
||||||
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
|
expect(calendar!.nextEvent).not.toBeNull()
|
||||||
|
expect(calendar!.nextEvent!.title).toBe("Team Standup")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("excludes all-day events from inProgress and nextEvent", async () => {
|
||||||
|
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 = createSource(client)
|
||||||
|
|
||||||
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
|
expect(calendar!.nextEvent).toBeNull()
|
||||||
|
expect(calendar!.hasTodayEvents).toBe(true)
|
||||||
|
expect(calendar!.todayEventCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("counts all events including all-day in todayEventCount", 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 = createSource(client)
|
||||||
|
|
||||||
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
|
expect(calendar!.todayEventCount).toBe(2)
|
||||||
|
expect(calendar!.hasTodayEvents).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("computeSignals", () => {
|
||||||
|
const now = new Date("2026-01-15T12:00:00Z")
|
||||||
|
|
||||||
|
function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData {
|
||||||
|
return {
|
||||||
|
uid: "test-uid",
|
||||||
|
title: "Test",
|
||||||
|
startDate: new Date("2026-01-15T14:00:00Z"),
|
||||||
|
endDate: new Date("2026-01-15T15:00:00Z"),
|
||||||
|
isAllDay: false,
|
||||||
|
location: null,
|
||||||
|
description: null,
|
||||||
|
calendarName: null,
|
||||||
|
status: null,
|
||||||
|
url: null,
|
||||||
|
organizer: null,
|
||||||
|
attendees: [],
|
||||||
|
alarms: [],
|
||||||
|
recurrenceId: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("all-day events get urgency 0.3 and ambient relevance", () => {
|
||||||
|
const event = makeEvent({ isAllDay: true })
|
||||||
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.3)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
startDate: new Date("2026-01-15T12:20:00Z"),
|
||||||
|
})
|
||||||
|
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 urgency 0.9", () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
startDate: new Date("2026-01-15T12:30:00Z"),
|
||||||
|
})
|
||||||
|
expect(computeSignals(event, now).urgency).toBe(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
startDate: new Date("2026-01-15T13:00:00Z"),
|
||||||
|
})
|
||||||
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.7)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events later today get urgency 0.5", () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
startDate: new Date("2026-01-15T20:00:00Z"),
|
||||||
|
})
|
||||||
|
expect(computeSignals(event, now).urgency).toBe(0.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
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"),
|
||||||
|
})
|
||||||
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.8)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||||
|
})
|
||||||
|
|
||||||
|
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"),
|
||||||
|
})
|
||||||
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.2)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events on future days get urgency 0.2", () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
startDate: new Date("2026-01-16T10:00:00Z"),
|
||||||
|
})
|
||||||
|
expect(computeSignals(event, now).urgency).toBe(0.2)
|
||||||
|
})
|
||||||
|
|
||||||
|
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(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(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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("CalDavSource feed item slots", () => {
|
||||||
|
const EXPECTED_SLOT_NAMES = ["insight", "preparation", "crossSource"]
|
||||||
|
|
||||||
|
test("timed event has all three slots with null content", 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)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const item = items[0]!
|
||||||
|
expect(item.slots).toBeDefined()
|
||||||
|
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||||
|
|
||||||
|
for (const name of EXPECTED_SLOT_NAMES) {
|
||||||
|
const slot = item.slots![name]!
|
||||||
|
expect(slot.content).toBeNull()
|
||||||
|
expect(typeof slot.description).toBe("string")
|
||||||
|
expect(slot.description.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("all-day event has all three slots with null content", async () => {
|
||||||
|
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 = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const item = items[0]!
|
||||||
|
expect(item.data.isAllDay).toBe(true)
|
||||||
|
expect(item.slots).toBeDefined()
|
||||||
|
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||||
|
|
||||||
|
for (const name of EXPECTED_SLOT_NAMES) {
|
||||||
|
expect(item.slots![name]!.content).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancelled event has all three slots with null content", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [{ url: "/cal/work/cancelled.ics", data: loadFixture("cancelled-event.ics") }],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const item = items[0]!
|
||||||
|
expect(item.data.status).toBe("cancelled")
|
||||||
|
expect(item.slots).toBeDefined()
|
||||||
|
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||||
|
|
||||||
|
for (const name of EXPECTED_SLOT_NAMES) {
|
||||||
|
expect(item.slots![name]!.content).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
363
packages/aris-source-caldav/src/caldav-source.ts
Normal file
363
packages/aris-source-caldav/src/caldav-source.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource, Slot } from "@aris/core"
|
||||||
|
|
||||||
|
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
|
import { DAVClient } from "tsdav"
|
||||||
|
|
||||||
|
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
||||||
|
|
||||||
|
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
|
import { parseICalEvents } from "./ical-parser.ts"
|
||||||
|
import crossSourcePrompt from "./prompts/cross-source.txt"
|
||||||
|
import insightPrompt from "./prompts/insight.txt"
|
||||||
|
import preparationPrompt from "./prompts/preparation.txt"
|
||||||
|
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
||||||
|
|
||||||
|
// -- Source options --
|
||||||
|
|
||||||
|
interface CalDavSourceBaseOptions {
|
||||||
|
serverUrl: string
|
||||||
|
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
|
||||||
|
lookAheadDays?: number
|
||||||
|
/** IANA timezone for determining "today" (e.g. "America/New_York"). Default: UTC. */
|
||||||
|
timeZone?: string
|
||||||
|
/** Optional DAV client for testing. */
|
||||||
|
davClient?: CalDavDAVClient
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalDavSourceBasicAuthOptions extends CalDavSourceBaseOptions {
|
||||||
|
authMethod: "basic"
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalDavSourceOAuthOptions extends CalDavSourceBaseOptions {
|
||||||
|
authMethod: "oauth"
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
tokenUrl: string
|
||||||
|
expiration?: number
|
||||||
|
clientId?: string
|
||||||
|
clientSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalDavSourceOptions = CalDavSourceBasicAuthOptions | CalDavSourceOAuthOptions
|
||||||
|
|
||||||
|
const DEFAULT_LOOK_AHEAD_DAYS = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FeedSource that fetches calendar events from any CalDAV server.
|
||||||
|
*
|
||||||
|
* Supports Basic auth (username/password) and OAuth (access token + refresh token).
|
||||||
|
* The server URL is provided at construction time.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Basic auth (self-hosted servers)
|
||||||
|
* const source = new CalDavSource({
|
||||||
|
* serverUrl: "https://nextcloud.example.com/remote.php/dav",
|
||||||
|
* authMethod: "basic",
|
||||||
|
* username: "user",
|
||||||
|
* password: "pass",
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // OAuth (cloud providers)
|
||||||
|
* const source = new CalDavSource({
|
||||||
|
* serverUrl: "https://caldav.provider.com",
|
||||||
|
* authMethod: "oauth",
|
||||||
|
* accessToken: "...",
|
||||||
|
* refreshToken: "...",
|
||||||
|
* tokenUrl: "https://provider.com/oauth/token",
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
||||||
|
readonly id = "aris.caldav"
|
||||||
|
|
||||||
|
private options: CalDavSourceOptions | null
|
||||||
|
private readonly lookAheadDays: number
|
||||||
|
private readonly timeZone: string | undefined
|
||||||
|
private readonly injectedClient: CalDavDAVClient | null
|
||||||
|
private clientPromise: Promise<CalDavDAVClient> | null = null
|
||||||
|
private cachedEvents: { time: Date; events: CalDavEventData[] } | null = null
|
||||||
|
private pendingFetch: { time: Date; promise: Promise<CalDavEventData[]> } | null = null
|
||||||
|
|
||||||
|
constructor(options: CalDavSourceOptions) {
|
||||||
|
this.options = options
|
||||||
|
this.lookAheadDays = options.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
|
||||||
|
this.timeZone = options.timeZone
|
||||||
|
this.injectedClient = options.davClient ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeAction(actionId: string): Promise<void> {
|
||||||
|
throw new UnknownActionError(actionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
||||||
|
const events = await this.fetchEvents(context)
|
||||||
|
if (events.length === 0) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
CalDavCalendarKey,
|
||||||
|
{
|
||||||
|
inProgress: [],
|
||||||
|
nextEvent: null,
|
||||||
|
hasTodayEvents: false,
|
||||||
|
todayEventCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = context.time
|
||||||
|
const active = events.filter((e) => e.status !== CalDavEventStatus.Cancelled)
|
||||||
|
const inProgress = active.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
|
||||||
|
|
||||||
|
const upcoming = active
|
||||||
|
.filter((e) => !e.isAllDay && e.startDate > now)
|
||||||
|
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||||
|
|
||||||
|
const calendarContext: CalendarContext = {
|
||||||
|
inProgress,
|
||||||
|
nextEvent: upcoming[0] ?? null,
|
||||||
|
hasTodayEvents: events.length > 0,
|
||||||
|
todayEventCount: events.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[CalDavCalendarKey, calendarContext]]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
||||||
|
const now = context.time
|
||||||
|
const events = await this.fetchEvents(context)
|
||||||
|
return events.map((event) => createFeedItem(event, now, this.timeZone))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
|
||||||
|
if (this.cachedEvents && this.cachedEvents.time === context.time) {
|
||||||
|
return Promise.resolve(this.cachedEvents.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate concurrent fetches for the same context.time reference
|
||||||
|
if (this.pendingFetch && this.pendingFetch.time === context.time) {
|
||||||
|
return this.pendingFetch.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this.doFetchEvents(context).finally(() => {
|
||||||
|
if (this.pendingFetch?.promise === promise) {
|
||||||
|
this.pendingFetch = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.pendingFetch = { time: context.time, promise }
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doFetchEvents(context: Context): Promise<CalDavEventData[]> {
|
||||||
|
const client = await this.connectClient()
|
||||||
|
const calendars = await client.fetchCalendars()
|
||||||
|
|
||||||
|
const { start, end } = computeTimeRange(context.time, this.lookAheadDays, this.timeZone)
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
calendars.map(async (calendar) => {
|
||||||
|
const objects = await client.fetchCalendarObjects({
|
||||||
|
calendar,
|
||||||
|
timeRange: {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// tsdav types displayName as string | Record<string, unknown> | undefined
|
||||||
|
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
|
||||||
|
return { objects, calendarName }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const allEvents: CalDavEventData[] = []
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
console.warn("[aris.caldav] Failed to fetch calendar:", result.reason)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const { objects, calendarName } = result.value
|
||||||
|
for (const obj of objects) {
|
||||||
|
if (typeof obj.data !== "string") continue
|
||||||
|
|
||||||
|
const events = parseICalEvents(obj.data, calendarName, { start, end })
|
||||||
|
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 createEventSlots(): Record<string, Slot> {
|
||||||
|
return {
|
||||||
|
insight: { description: insightPrompt, content: null },
|
||||||
|
preparation: { description: preparationPrompt, content: null },
|
||||||
|
crossSource: { description: crossSourcePrompt, content: null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
||||||
|
return {
|
||||||
|
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||||
|
type: CalDavFeedItemType.Event,
|
||||||
|
timestamp: now,
|
||||||
|
data: event,
|
||||||
|
signals: computeSignals(event, now, timeZone),
|
||||||
|
slots: createEventSlots(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,23 @@ import type { ContextKey } from "@aris/core"
|
|||||||
|
|
||||||
import { 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.
|
* 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).
|
* can adapt (e.g. a commute source checking if there's a meeting soon).
|
||||||
*/
|
*/
|
||||||
export interface CalendarContext {
|
export interface CalendarContext {
|
||||||
/** Events happening right now */
|
/** Events happening right now */
|
||||||
inProgress: CalendarEventData[]
|
inProgress: CalDavEventData[]
|
||||||
/** Next upcoming event, if any */
|
/** Next upcoming event, if any */
|
||||||
nextEvent: CalendarEventData | null
|
nextEvent: CalDavEventData | null
|
||||||
/** Whether the user has any events today */
|
/** Whether the user has any events today */
|
||||||
hasTodayEvents: boolean
|
hasTodayEvents: boolean
|
||||||
/** Total number of events today */
|
/** Total number of events today */
|
||||||
todayEventCount: number
|
todayEventCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar")
|
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar")
|
||||||
198
packages/aris-source-caldav/src/ical-parser.test.ts
Normal file
198
packages/aris-source-caldav/src/ical-parser.test.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
import { parseICalEvents } from "./ical-parser.ts"
|
||||||
|
|
||||||
|
function loadFixture(name: string): string {
|
||||||
|
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseICalEvents", () => {
|
||||||
|
test("parses a full event with all fields", () => {
|
||||||
|
const events = parseICalEvents(loadFixture("single-event.ics"), "Work")
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
const event = events[0]!
|
||||||
|
|
||||||
|
expect(event.uid).toBe("single-event-001@test")
|
||||||
|
expect(event.title).toBe("Team Standup")
|
||||||
|
expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z"))
|
||||||
|
expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z"))
|
||||||
|
expect(event.isAllDay).toBe(false)
|
||||||
|
expect(event.location).toBe("Conference Room A")
|
||||||
|
expect(event.description).toBe("Daily standup meeting")
|
||||||
|
expect(event.calendarName).toBe("Work")
|
||||||
|
expect(event.status).toBe("confirmed")
|
||||||
|
expect(event.url).toBe("https://example.com/meeting/123")
|
||||||
|
expect(event.organizer).toBe("Alice Smith")
|
||||||
|
expect(event.recurrenceId).toBeNull()
|
||||||
|
|
||||||
|
expect(event.attendees).toHaveLength(2)
|
||||||
|
expect(event.attendees[0]).toEqual({
|
||||||
|
name: "Bob Jones",
|
||||||
|
email: "bob@example.com",
|
||||||
|
role: "required",
|
||||||
|
status: "accepted",
|
||||||
|
})
|
||||||
|
expect(event.attendees[1]).toEqual({
|
||||||
|
name: "Carol White",
|
||||||
|
email: "carol@example.com",
|
||||||
|
role: "optional",
|
||||||
|
status: "tentative",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(event.alarms).toHaveLength(2)
|
||||||
|
expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" })
|
||||||
|
expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses an all-day event with optional fields as null", () => {
|
||||||
|
const events = parseICalEvents(loadFixture("all-day-event.ics"), null)
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
const event = events[0]!
|
||||||
|
|
||||||
|
expect(event.isAllDay).toBe(true)
|
||||||
|
expect(event.title).toBe("Company Holiday")
|
||||||
|
expect(event.calendarName).toBeNull()
|
||||||
|
expect(event.location).toBeNull()
|
||||||
|
expect(event.description).toBeNull()
|
||||||
|
expect(event.url).toBeNull()
|
||||||
|
expect(event.organizer).toBeNull()
|
||||||
|
expect(event.attendees).toEqual([])
|
||||||
|
expect(event.alarms).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses recurring event with exception", () => {
|
||||||
|
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
|
||||||
|
|
||||||
|
expect(events).toHaveLength(2)
|
||||||
|
expect(events[0]!.uid).toBe("recurring-001@test")
|
||||||
|
expect(events[1]!.uid).toBe("recurring-001@test")
|
||||||
|
|
||||||
|
const base = events.find((e) => e.title === "Weekly Sync")
|
||||||
|
expect(base).toBeDefined()
|
||||||
|
expect(base!.recurrenceId).toBeNull()
|
||||||
|
|
||||||
|
const exception = events.find((e) => e.title === "Weekly Sync (moved)")
|
||||||
|
expect(exception).toBeDefined()
|
||||||
|
expect(exception!.recurrenceId).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses minimal event with defaults", () => {
|
||||||
|
const events = parseICalEvents(loadFixture("minimal-event.ics"), null)
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
const event = events[0]!
|
||||||
|
|
||||||
|
expect(event.uid).toBe("minimal-001@test")
|
||||||
|
expect(event.title).toBe("Quick Chat")
|
||||||
|
expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z"))
|
||||||
|
expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z"))
|
||||||
|
expect(event.location).toBeNull()
|
||||||
|
expect(event.description).toBeNull()
|
||||||
|
expect(event.status).toBeNull()
|
||||||
|
expect(event.url).toBeNull()
|
||||||
|
expect(event.organizer).toBeNull()
|
||||||
|
expect(event.attendees).toEqual([])
|
||||||
|
expect(event.alarms).toEqual([])
|
||||||
|
expect(event.recurrenceId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses cancelled status", () => {
|
||||||
|
const events = parseICalEvents(loadFixture("cancelled-event.ics"), null)
|
||||||
|
expect(events[0]!.status).toBe("cancelled")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("parseICalEvents with timeRange (recurrence expansion)", () => {
|
||||||
|
test("expands weekly recurring event into occurrences within range", () => {
|
||||||
|
// weekly-recurring.ics: DTSTART 2026-01-01 (Thu), FREQ=WEEKLY;BYDAY=TH;COUNT=10
|
||||||
|
// Occurrences: Jan 1, 8, 15, 22, 29, Feb 5, 12, 19, 26, Mar 5
|
||||||
|
// Query window: Jan 14 – Jan 23 → should get Jan 15 and Jan 22
|
||||||
|
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), "Work", {
|
||||||
|
start: new Date("2026-01-14T00:00:00Z"),
|
||||||
|
end: new Date("2026-01-23T00:00:00Z"),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events).toHaveLength(2)
|
||||||
|
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T10:00:00Z"))
|
||||||
|
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T11:00:00Z"))
|
||||||
|
expect(events[1]!.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
|
||||||
|
expect(events[1]!.endDate).toEqual(new Date("2026-01-22T11:00:00Z"))
|
||||||
|
|
||||||
|
// All occurrences share the same UID and metadata
|
||||||
|
for (const event of events) {
|
||||||
|
expect(event.uid).toBe("weekly-001@test")
|
||||||
|
expect(event.title).toBe("Weekly Team Meeting")
|
||||||
|
expect(event.location).toBe("Room B")
|
||||||
|
expect(event.calendarName).toBe("Work")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty array when no occurrences fall in range", () => {
|
||||||
|
// Query window: Dec 2025 — before the first occurrence
|
||||||
|
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), null, {
|
||||||
|
start: new Date("2025-12-01T00:00:00Z"),
|
||||||
|
end: new Date("2025-12-31T00:00:00Z"),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies exception overrides during expansion", () => {
|
||||||
|
// weekly-recurring-with-exception.ics:
|
||||||
|
// Master: DTSTART 2026-01-01 (Thu) 14:00, FREQ=WEEKLY;BYDAY=TH;COUNT=8
|
||||||
|
// Exception: RECURRENCE-ID 2026-01-15T14:00 → moved to 16:00-17:00, title changed
|
||||||
|
// Query window: Jan 14 – Jan 16 → should get the exception occurrence for Jan 15
|
||||||
|
const events = parseICalEvents(loadFixture("weekly-recurring-with-exception.ics"), "Work", {
|
||||||
|
start: new Date("2026-01-14T00:00:00Z"),
|
||||||
|
end: new Date("2026-01-16T00:00:00Z"),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]!.title).toBe("Standup (rescheduled)")
|
||||||
|
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T16:00:00Z"))
|
||||||
|
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T17:00:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("expands recurring all-day events", () => {
|
||||||
|
// daily-recurring-allday.ics: DTSTART 2026-01-12, FREQ=DAILY;COUNT=7
|
||||||
|
// Occurrences: Jan 12, 13, 14, 15, 16, 17, 18
|
||||||
|
// Query window: Jan 14 – Jan 17 → should get Jan 14, 15, 16
|
||||||
|
const events = parseICalEvents(loadFixture("daily-recurring-allday.ics"), null, {
|
||||||
|
start: new Date("2026-01-14T00:00:00Z"),
|
||||||
|
end: new Date("2026-01-17T00:00:00Z"),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events).toHaveLength(3)
|
||||||
|
for (const event of events) {
|
||||||
|
expect(event.isAllDay).toBe(true)
|
||||||
|
expect(event.title).toBe("Daily Reminder")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-recurring events are filtered by range", () => {
|
||||||
|
// single-event.ics: 2026-01-15T14:00 – 15:00
|
||||||
|
// Query window that includes it
|
||||||
|
const included = parseICalEvents(loadFixture("single-event.ics"), null, {
|
||||||
|
start: new Date("2026-01-15T00:00:00Z"),
|
||||||
|
end: new Date("2026-01-16T00:00:00Z"),
|
||||||
|
})
|
||||||
|
expect(included).toHaveLength(1)
|
||||||
|
|
||||||
|
// Query window that excludes it
|
||||||
|
const excluded = parseICalEvents(loadFixture("single-event.ics"), null, {
|
||||||
|
start: new Date("2026-01-16T00:00:00Z"),
|
||||||
|
end: new Date("2026-01-17T00:00:00Z"),
|
||||||
|
})
|
||||||
|
expect(excluded).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("without timeRange, recurring events return raw VEVENTs (legacy)", () => {
|
||||||
|
// Legacy behavior: no expansion, just returns the VEVENT components as-is
|
||||||
|
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
|
||||||
|
expect(events).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
323
packages/aris-source-caldav/src/ical-parser.ts
Normal file
323
packages/aris-source-caldav/src/ical-parser.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import ICAL from "ical.js"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AttendeeRole,
|
||||||
|
AttendeeStatus,
|
||||||
|
CalDavEventStatus,
|
||||||
|
type CalDavAlarm,
|
||||||
|
type CalDavAttendee,
|
||||||
|
type CalDavEventData,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
export interface ICalTimeRange {
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety cap to prevent runaway iteration on pathological recurrence rules.
|
||||||
|
* Each iteration is pure date math (no I/O), so a high cap is fine.
|
||||||
|
* 10,000 covers a daily event with DTSTART ~27 years in the past.
|
||||||
|
*/
|
||||||
|
const MAX_RECURRENCE_ITERATIONS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a raw iCalendar string and extracts VEVENT components
|
||||||
|
* into CalDavEventData objects.
|
||||||
|
*
|
||||||
|
* When a timeRange is provided, recurring events are expanded into
|
||||||
|
* individual occurrences within that range. Without a timeRange,
|
||||||
|
* each VEVENT component is returned as-is (legacy behavior).
|
||||||
|
*
|
||||||
|
* @param icsData - Raw iCalendar string from a CalDAV response
|
||||||
|
* @param calendarName - Display name of the calendar this event belongs to
|
||||||
|
* @param timeRange - When set, expand recurrences and filter to this window
|
||||||
|
*/
|
||||||
|
export function parseICalEvents(
|
||||||
|
icsData: string,
|
||||||
|
calendarName: string | null,
|
||||||
|
timeRange?: ICalTimeRange,
|
||||||
|
): CalDavEventData[] {
|
||||||
|
const jcal = ICAL.parse(icsData)
|
||||||
|
const comp = new ICAL.Component(jcal)
|
||||||
|
const vevents = comp.getAllSubcomponents("vevent")
|
||||||
|
|
||||||
|
if (!timeRange) {
|
||||||
|
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
||||||
|
parseVEvent(vevent, calendarName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group VEVENTs by UID: master + exceptions
|
||||||
|
const byUid = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
master: InstanceType<typeof ICAL.Component> | null
|
||||||
|
exceptions: InstanceType<typeof ICAL.Component>[]
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const vevent of vevents as InstanceType<typeof ICAL.Component>[]) {
|
||||||
|
const uid = vevent.getFirstPropertyValue("uid") as string | null
|
||||||
|
if (!uid) continue
|
||||||
|
|
||||||
|
const hasRecurrenceId = vevent.getFirstPropertyValue("recurrence-id") !== null
|
||||||
|
let group = byUid.get(uid)
|
||||||
|
if (!group) {
|
||||||
|
group = { master: null, exceptions: [] }
|
||||||
|
byUid.set(uid, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRecurrenceId) {
|
||||||
|
group.exceptions.push(vevent)
|
||||||
|
} else {
|
||||||
|
group.master = vevent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CalDavEventData[] = []
|
||||||
|
const rangeStart = ICAL.Time.fromJSDate(timeRange.start, true)
|
||||||
|
const rangeEnd = ICAL.Time.fromJSDate(timeRange.end, true)
|
||||||
|
|
||||||
|
for (const group of byUid.values()) {
|
||||||
|
if (!group.master) {
|
||||||
|
// Orphan exceptions — parse them directly if they fall in range
|
||||||
|
for (const exc of group.exceptions) {
|
||||||
|
const parsed = parseVEvent(exc, calendarName)
|
||||||
|
if (overlapsRange(parsed, timeRange)) {
|
||||||
|
results.push(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterEvent = new ICAL.Event(group.master)
|
||||||
|
|
||||||
|
// Register exceptions so getOccurrenceDetails resolves them
|
||||||
|
for (const exc of group.exceptions) {
|
||||||
|
masterEvent.relateException(exc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!masterEvent.isRecurring()) {
|
||||||
|
const parsed = parseVEvent(group.master, calendarName)
|
||||||
|
if (overlapsRange(parsed, timeRange)) {
|
||||||
|
results.push(parsed)
|
||||||
|
}
|
||||||
|
// Also include standalone exceptions for non-recurring events
|
||||||
|
for (const exc of group.exceptions) {
|
||||||
|
const parsedExc = parseVEvent(exc, calendarName)
|
||||||
|
if (overlapsRange(parsedExc, timeRange)) {
|
||||||
|
results.push(parsedExc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand recurring event occurrences within the time range.
|
||||||
|
// The iterator must start from DTSTART (not rangeStart) because
|
||||||
|
// ical.js needs to walk the recurrence rule grid from the original
|
||||||
|
// anchor. We cap iterations to avoid runaway expansion on
|
||||||
|
// pathological rules.
|
||||||
|
const iter = masterEvent.iterator()
|
||||||
|
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
|
||||||
|
let iterations = 0
|
||||||
|
|
||||||
|
while (next) {
|
||||||
|
if (++iterations > MAX_RECURRENCE_ITERATIONS) {
|
||||||
|
console.warn(
|
||||||
|
`[aris.caldav] Recurrence expansion for "${masterEvent.uid}" hit iteration limit (${MAX_RECURRENCE_ITERATIONS}), stopping`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop once we're past the range end
|
||||||
|
if (next.compare(rangeEnd) >= 0) break
|
||||||
|
|
||||||
|
const details = masterEvent.getOccurrenceDetails(next)
|
||||||
|
const occEnd = details.endDate
|
||||||
|
|
||||||
|
// Skip occurrences that end before the range starts
|
||||||
|
if (occEnd.compare(rangeStart) <= 0) {
|
||||||
|
next = iter.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const occEvent = details.item
|
||||||
|
const occComponent = occEvent.component
|
||||||
|
|
||||||
|
const parsed = parseVEventWithDates(
|
||||||
|
occComponent,
|
||||||
|
calendarName,
|
||||||
|
details.startDate.toJSDate(),
|
||||||
|
details.endDate.toJSDate(),
|
||||||
|
details.recurrenceId ? details.recurrenceId.toString() : null,
|
||||||
|
)
|
||||||
|
results.push(parsed)
|
||||||
|
|
||||||
|
next = iter.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapsRange(event: CalDavEventData, range: ICalTimeRange): boolean {
|
||||||
|
return event.startDate < range.end && event.endDate > range.start
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a VEVENT component, overriding start/end/recurrenceId with
|
||||||
|
* values from recurrence expansion.
|
||||||
|
*/
|
||||||
|
function parseVEventWithDates(
|
||||||
|
vevent: InstanceType<typeof ICAL.Component>,
|
||||||
|
calendarName: string | null,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
recurrenceId: string | null,
|
||||||
|
): CalDavEventData {
|
||||||
|
const event = new ICAL.Event(vevent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: event.uid ?? "",
|
||||||
|
title: event.summary ?? "",
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
isAllDay: event.startDate?.isDate ?? false,
|
||||||
|
location: event.location ?? null,
|
||||||
|
description: event.description ?? null,
|
||||||
|
calendarName,
|
||||||
|
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
||||||
|
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
||||||
|
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
||||||
|
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
||||||
|
alarms: parseAlarms(vevent),
|
||||||
|
recurrenceId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVEvent(
|
||||||
|
vevent: InstanceType<typeof ICAL.Component>,
|
||||||
|
calendarName: string | null,
|
||||||
|
): CalDavEventData {
|
||||||
|
const event = new ICAL.Event(vevent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: event.uid ?? "",
|
||||||
|
title: event.summary ?? "",
|
||||||
|
startDate: event.startDate?.toJSDate() ?? new Date(0),
|
||||||
|
endDate: event.endDate?.toJSDate() ?? new Date(0),
|
||||||
|
isAllDay: event.startDate?.isDate ?? false,
|
||||||
|
location: event.location ?? null,
|
||||||
|
description: event.description ?? null,
|
||||||
|
calendarName,
|
||||||
|
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
||||||
|
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
||||||
|
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
||||||
|
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
||||||
|
alarms: parseAlarms(vevent),
|
||||||
|
recurrenceId: event.recurrenceId ? event.recurrenceId.toString() : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatus(raw: string | null): CalDavEventStatus | null {
|
||||||
|
if (!raw) return null
|
||||||
|
switch (raw.toLowerCase()) {
|
||||||
|
case "confirmed":
|
||||||
|
return CalDavEventStatus.Confirmed
|
||||||
|
case "tentative":
|
||||||
|
return CalDavEventStatus.Tentative
|
||||||
|
case "cancelled":
|
||||||
|
return CalDavEventStatus.Cancelled
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrganizer(
|
||||||
|
value: string | null,
|
||||||
|
vevent: InstanceType<typeof ICAL.Component>,
|
||||||
|
): string | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
// Try CN parameter first
|
||||||
|
const prop = vevent.getFirstProperty("organizer")
|
||||||
|
if (prop) {
|
||||||
|
const cn = prop.getParameter("cn") as string | undefined
|
||||||
|
if (cn) return cn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to mailto: value
|
||||||
|
return value.replace(/^mailto:/i, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttendees(properties: unknown[]): CalDavAttendee[] {
|
||||||
|
if (properties.length === 0) return []
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
|
||||||
|
if (!raw) return null
|
||||||
|
switch (raw.toUpperCase()) {
|
||||||
|
case "CHAIR":
|
||||||
|
return AttendeeRole.Chair
|
||||||
|
case "REQ-PARTICIPANT":
|
||||||
|
return AttendeeRole.Required
|
||||||
|
case "OPT-PARTICIPANT":
|
||||||
|
return AttendeeRole.Optional
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
|
||||||
|
if (!raw) return null
|
||||||
|
switch (raw.toUpperCase()) {
|
||||||
|
case "ACCEPTED":
|
||||||
|
return AttendeeStatus.Accepted
|
||||||
|
case "DECLINED":
|
||||||
|
return AttendeeStatus.Declined
|
||||||
|
case "TENTATIVE":
|
||||||
|
return AttendeeStatus.Tentative
|
||||||
|
case "NEEDS-ACTION":
|
||||||
|
return AttendeeStatus.NeedsAction
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
|
||||||
|
const valarms = vevent.getAllSubcomponents("valarm")
|
||||||
|
if (!valarms || valarms.length === 0) return []
|
||||||
|
|
||||||
|
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
|
||||||
|
const trigger = valarm.getFirstPropertyValue("trigger")
|
||||||
|
const action = asStringOrNull(valarm.getFirstPropertyValue("action"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
trigger: trigger ? trigger.toString() : "",
|
||||||
|
action: action ?? "DISPLAY",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringOrNull(value: unknown): string | null {
|
||||||
|
return typeof value === "string" ? value : null
|
||||||
|
}
|
||||||
16
packages/aris-source-caldav/src/index.ts
Normal file
16
packages/aris-source-caldav/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
|
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
||||||
|
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
|
||||||
|
export {
|
||||||
|
AttendeeRole,
|
||||||
|
AttendeeStatus,
|
||||||
|
CalDavEventStatus,
|
||||||
|
CalDavFeedItemType,
|
||||||
|
type CalDavAlarm,
|
||||||
|
type CalDavAttendee,
|
||||||
|
type CalDavDAVCalendar,
|
||||||
|
type CalDavDAVClient,
|
||||||
|
type CalDavDAVObject,
|
||||||
|
type CalDavEventData,
|
||||||
|
type CalDavFeedItem,
|
||||||
|
} from "./types.ts"
|
||||||
8
packages/aris-source-caldav/src/prompts/cross-source.txt
Normal file
8
packages/aris-source-caldav/src/prompts/cross-source.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
If other feed data (weather, transit, nearby events) would disrupt or materially affect this event, state the connection in one sentence. Infer whether the event is indoor/outdoor/virtual from the title and location. Weather is only relevant if it affects getting to the event or the activity itself (e.g., rain for outdoor events, extreme conditions for physical activities). Return null for indoor or virtual events where weather has no impact. Do not fabricate information you don't have — only reference data present in the feed.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "rain expected at 5pm — bring an umbrella for the walk to Tooley Street"
|
||||||
|
- "Northern line has delays — leave 15 minutes early"
|
||||||
|
- "your next event is across town — the 40 min gap may not be enough"
|
||||||
|
- null (indoor guitar class with wind outside — weather doesn't affect the event)
|
||||||
|
- null
|
||||||
7
packages/aris-source-caldav/src/prompts/insight.txt
Normal file
7
packages/aris-source-caldav/src/prompts/insight.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
One sentence of actionable insight the user can't already see from the event title, time, and location. Do not restate event details. Do not fabricate information you don't have. Return null if there's nothing non-obvious to say.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "you have 2 hours free before this starts"
|
||||||
|
- "all 8 attendees accepted — expect a full room"
|
||||||
|
- "third time this has been rescheduled"
|
||||||
|
- null
|
||||||
6
packages/aris-source-caldav/src/prompts/preparation.txt
Normal file
6
packages/aris-source-caldav/src/prompts/preparation.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
A concrete preparation step — something the user should do, bring, or review before this event. Infer only from available event and feed data. Do not restate event details. Do not fabricate information you don't have. Return null if no useful preparation comes to mind.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "different building from your previous meeting — allow travel time"
|
||||||
|
- "recurring meeting you declined last week — check if you need to attend"
|
||||||
|
- null
|
||||||
4
packages/aris-source-caldav/src/text.d.ts
vendored
Normal file
4
packages/aris-source-caldav/src/text.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.txt" {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
@@ -1,30 +1,16 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
// -- Credential provider --
|
// -- Event status --
|
||||||
|
|
||||||
export interface CalendarCredentials {
|
export const CalDavEventStatus = {
|
||||||
accessToken: string
|
|
||||||
refreshToken: string
|
|
||||||
/** Unix timestamp in milliseconds when the access token expires */
|
|
||||||
expiresAt: number
|
|
||||||
tokenUrl: string
|
|
||||||
clientId: string
|
|
||||||
clientSecret: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalendarCredentialProvider {
|
|
||||||
fetchCredentials(userId: string): Promise<CalendarCredentials | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Feed item types --
|
|
||||||
|
|
||||||
export const CalendarEventStatus = {
|
|
||||||
Confirmed: "confirmed",
|
Confirmed: "confirmed",
|
||||||
Tentative: "tentative",
|
Tentative: "tentative",
|
||||||
Cancelled: "cancelled",
|
Cancelled: "cancelled",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus]
|
export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus]
|
||||||
|
|
||||||
|
// -- Attendee types --
|
||||||
|
|
||||||
export const AttendeeRole = {
|
export const AttendeeRole = {
|
||||||
Chair: "chair",
|
Chair: "chair",
|
||||||
@@ -43,21 +29,25 @@ export const AttendeeStatus = {
|
|||||||
|
|
||||||
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
||||||
|
|
||||||
export interface CalendarAttendee {
|
export interface CalDavAttendee {
|
||||||
name: string | null
|
name: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
role: AttendeeRole | null
|
role: AttendeeRole | null
|
||||||
status: AttendeeStatus | null
|
status: AttendeeStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarAlarm {
|
// -- Alarm --
|
||||||
|
|
||||||
|
export interface CalDavAlarm {
|
||||||
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
||||||
trigger: string
|
trigger: string
|
||||||
/** e.g. "DISPLAY", "AUDIO" */
|
/** e.g. "DISPLAY", "AUDIO" */
|
||||||
action: string
|
action: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarEventData extends Record<string, unknown> {
|
// -- Event data --
|
||||||
|
|
||||||
|
export interface CalDavEventData extends Record<string, unknown> {
|
||||||
uid: string
|
uid: string
|
||||||
title: string
|
title: string
|
||||||
startDate: Date
|
startDate: Date
|
||||||
@@ -66,36 +56,46 @@ export interface CalendarEventData extends Record<string, unknown> {
|
|||||||
location: string | null
|
location: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
calendarName: string | null
|
calendarName: string | null
|
||||||
status: CalendarEventStatus | null
|
status: CalDavEventStatus | null
|
||||||
url: string | null
|
url: string | null
|
||||||
organizer: string | null
|
organizer: string | null
|
||||||
attendees: CalendarAttendee[]
|
attendees: CalDavAttendee[]
|
||||||
alarms: CalendarAlarm[]
|
alarms: CalDavAlarm[]
|
||||||
recurrenceId: string | null
|
recurrenceId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData>
|
// -- Feed item type --
|
||||||
|
|
||||||
|
export const CalDavFeedItemType = {
|
||||||
|
Event: "caldav-event",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CalDavFeedItemType = (typeof CalDavFeedItemType)[keyof typeof CalDavFeedItemType]
|
||||||
|
|
||||||
|
// -- Feed item --
|
||||||
|
|
||||||
|
export type CalDavFeedItem = FeedItem<typeof CalDavFeedItemType.Event, CalDavEventData>
|
||||||
|
|
||||||
// -- DAV client interface --
|
// -- DAV client interface --
|
||||||
|
|
||||||
export interface CalendarDAVObject {
|
export interface CalDavDAVObject {
|
||||||
data?: unknown
|
data?: unknown
|
||||||
etag?: string
|
etag?: string
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarDAVCalendar {
|
export interface CalDavDAVCalendar {
|
||||||
displayName?: string | Record<string, unknown>
|
displayName?: string | Record<string, unknown>
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subset of DAVClient used by CalendarSource. */
|
/** Subset of tsdav's DAVClient used by CalDavSource. */
|
||||||
export interface CalendarDAVClient {
|
export interface CalDavDAVClient {
|
||||||
login(): Promise<void>
|
login(): Promise<void>
|
||||||
fetchCalendars(): Promise<CalendarDAVCalendar[]>
|
fetchCalendars(): Promise<CalDavDAVCalendar[]>
|
||||||
fetchCalendarObjects(params: {
|
fetchCalendarObjects(params: {
|
||||||
calendar: CalendarDAVCalendar
|
calendar: CalDavDAVCalendar
|
||||||
timeRange: { start: string; end: string }
|
timeRange: { start: string; end: string }
|
||||||
}): Promise<CalendarDAVObject[]>
|
}): Promise<CalDavDAVObject[]>
|
||||||
credentials: Record<string, unknown>
|
credentials: Record<string, unknown>
|
||||||
}
|
}
|
||||||
@@ -10,4 +10,4 @@ export interface NextEvent {
|
|||||||
location: string | null
|
location: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")
|
export const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { CalendarEventData } from "./types"
|
import type { CalendarEventData } from "./types"
|
||||||
|
|
||||||
export const CalendarFeedItemType = {
|
export const CalendarFeedItemType = {
|
||||||
event: "calendar-event",
|
Event: "calendar-event",
|
||||||
allDay: "calendar-all-day",
|
AllDay: "calendar-all-day",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
|
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
|
||||||
|
|
||||||
export interface CalendarEventFeedItem extends FeedItem<
|
export interface CalendarEventFeedItem extends FeedItem<
|
||||||
typeof CalendarFeedItemType.event,
|
typeof CalendarFeedItemType.Event,
|
||||||
CalendarEventData
|
CalendarEventData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export interface CalendarAllDayFeedItem extends FeedItem<
|
export interface CalendarAllDayFeedItem extends FeedItem<
|
||||||
typeof CalendarFeedItemType.allDay,
|
typeof CalendarFeedItemType.AllDay,
|
||||||
CalendarEventData
|
CalendarEventData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { contextValue, type Context } from "@aris/core"
|
import { Context, TimeRelevance } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||||
|
|
||||||
import fixture from "../fixtures/events.json"
|
import fixture from "../fixtures/events.json"
|
||||||
import { NextEventKey } from "./calendar-context"
|
import { NextEventKey, type NextEvent } from "./calendar-context"
|
||||||
import { CalendarFeedItemType } from "./feed-items"
|
import { CalendarFeedItemType } from "./feed-items"
|
||||||
import { GoogleCalendarSource } from "./google-calendar-source"
|
import { GoogleCalendarSource } from "./google-calendar-source"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createContext(time?: Date): Context {
|
function createContext(time?: Date): Context {
|
||||||
return { time: time ?? NOW }
|
return new Context(time ?? NOW)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("GoogleCalendarSource", () => {
|
describe("GoogleCalendarSource", () => {
|
||||||
@@ -69,7 +69,7 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
|
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.Event)
|
||||||
expect(timedItems.length).toBe(4)
|
expect(timedItems.length).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,20 +77,21 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
|
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.AllDay)
|
||||||
expect(allDayItems.length).toBe(1)
|
expect(allDayItems.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ongoing events get highest priority (1.0)", async () => {
|
test("ongoing events get highest urgency (1.0)", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
||||||
expect(ongoing).toBeDefined()
|
expect(ongoing).toBeDefined()
|
||||||
expect(ongoing!.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 source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
@@ -99,16 +100,17 @@ describe("GoogleCalendarSource", () => {
|
|||||||
|
|
||||||
expect(soon).toBeDefined()
|
expect(soon).toBeDefined()
|
||||||
expect(later).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 source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
||||||
expect(allDay).toBeDefined()
|
expect(allDay).toBeDefined()
|
||||||
expect(allDay!.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 () => {
|
test("generates unique IDs for each item", async () => {
|
||||||
@@ -227,15 +229,16 @@ describe("GoogleCalendarSource", () => {
|
|||||||
|
|
||||||
test("returns next upcoming timed event (not ongoing)", async () => {
|
test("returns next upcoming timed event (not ongoing)", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const result = await source.fetchContext(createContext())
|
const entries = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
expect(entries).not.toBeNull()
|
||||||
const nextEvent = contextValue(result! as Context, NextEventKey)
|
expect(entries).toHaveLength(1)
|
||||||
expect(nextEvent).toBeDefined()
|
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
||||||
|
expect(key).toEqual(NextEventKey)
|
||||||
// evt-soon starts at 10:10, which is the nearest future timed event
|
// evt-soon starts at 10:10, which is the nearest future timed event
|
||||||
expect(nextEvent!.title).toBe("1:1 with Manager")
|
expect(nextEvent.title).toBe("1:1 with Manager")
|
||||||
expect(nextEvent!.minutesUntilStart).toBe(10)
|
expect(nextEvent.minutesUntilStart).toBe(10)
|
||||||
expect(nextEvent!.location).toBeNull()
|
expect(nextEvent.location).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes location when available", async () => {
|
test("includes location when available", async () => {
|
||||||
@@ -253,12 +256,11 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({
|
const source = new GoogleCalendarSource({
|
||||||
client: createMockClient({ primary: events }),
|
client: createMockClient({ primary: events }),
|
||||||
})
|
})
|
||||||
const result = await source.fetchContext(createContext())
|
const entries = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
expect(entries).not.toBeNull()
|
||||||
const nextEvent = contextValue(result! as Context, NextEventKey)
|
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
||||||
expect(nextEvent).toBeDefined()
|
expect(nextEvent.location).toBe("123 Main St")
|
||||||
expect(nextEvent!.location).toBe("123 Main St")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips ongoing events for next-event context", async () => {
|
test("skips ongoing events for next-event context", async () => {
|
||||||
@@ -280,7 +282,7 @@ describe("GoogleCalendarSource", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("priority ordering", () => {
|
describe("urgency ordering", () => {
|
||||||
test("ongoing > upcoming > all-day", async () => {
|
test("ongoing > upcoming > all-day", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
@@ -289,8 +291,8 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
||||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
||||||
|
|
||||||
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
|
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!)
|
||||||
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
|
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { UnknownActionError } from "@aris/core"
|
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiCalendarEvent,
|
ApiCalendarEvent,
|
||||||
@@ -35,10 +35,10 @@ import { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
|||||||
|
|
||||||
const DEFAULT_LOOKAHEAD_HOURS = 24
|
const DEFAULT_LOOKAHEAD_HOURS = 24
|
||||||
|
|
||||||
const PRIORITY_ONGOING = 1.0
|
const URGENCY_ONGOING = 1.0
|
||||||
const PRIORITY_UPCOMING_MAX = 0.9
|
const URGENCY_UPCOMING_MAX = 0.9
|
||||||
const PRIORITY_UPCOMING_MIN = 0.3
|
const URGENCY_UPCOMING_MIN = 0.3
|
||||||
const PRIORITY_ALL_DAY = 0.4
|
const URGENCY_ALL_DAY = 0.4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A FeedSource that provides Google Calendar events and next-event context.
|
* A FeedSource that provides Google Calendar events and next-event context.
|
||||||
@@ -58,7 +58,7 @@ const PRIORITY_ALL_DAY = 0.4
|
|||||||
* .register(calendarSource)
|
* .register(calendarSource)
|
||||||
*
|
*
|
||||||
* // Access next-event context in downstream sources
|
* // Access next-event context in downstream sources
|
||||||
* const next = contextValue(context, NextEventKey)
|
* const next = context.get(NextEventKey)
|
||||||
* if (next && next.minutesUntilStart < 15) {
|
* if (next && next.minutesUntilStart < 15) {
|
||||||
* // remind user
|
* // remind user
|
||||||
* }
|
* }
|
||||||
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
throw new UnknownActionError(actionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
||||||
const events = await this.fetchAllEvents(context.time)
|
const events = await this.fetchAllEvents(context.time)
|
||||||
|
|
||||||
const now = context.time.getTime()
|
const now = context.time.getTime()
|
||||||
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
location: nextTimedEvent.location,
|
location: nextTimedEvent.location,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { [NextEventKey]: nextEvent }
|
return [[NextEventKey, nextEvent]]
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||||
@@ -171,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) {
|
if (event.isAllDay) {
|
||||||
return PRIORITY_ALL_DAY
|
return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
const startMs = event.startTime.getTime()
|
const startMs = event.startTime.getTime()
|
||||||
@@ -181,17 +185,23 @@ function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: n
|
|||||||
|
|
||||||
// Ongoing: start <= now < end
|
// Ongoing: start <= now < end
|
||||||
if (startMs <= nowMs && nowMs < endMs) {
|
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
|
const msUntilStart = startMs - nowMs
|
||||||
if (msUntilStart <= 0) {
|
if (msUntilStart <= 0) {
|
||||||
return PRIORITY_UPCOMING_MIN
|
return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
|
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(
|
function createFeedItem(
|
||||||
@@ -199,14 +209,13 @@ function createFeedItem(
|
|||||||
nowMs: number,
|
nowMs: number,
|
||||||
lookaheadMs: number,
|
lookaheadMs: number,
|
||||||
): CalendarFeedItem {
|
): CalendarFeedItem {
|
||||||
const priority = computePriority(event, nowMs, lookaheadMs)
|
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
|
||||||
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||||
type: itemType,
|
type: itemType,
|
||||||
priority,
|
|
||||||
timestamp: new Date(nowMs),
|
timestamp: new Date(nowMs),
|
||||||
data: event,
|
data: event,
|
||||||
|
signals: computeSignals(event, nowMs, lookaheadMs),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user