Compare commits

..

9 Commits

Author SHA1 Message Date
f806b78fb7 fix: correct misleading sort order comments
Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:25:03 +00:00
65ca50bf36 feat: add boost directive to FeedEnhancement
Post-processors can now return a boost map (item ID -> score)
to promote or demote items in the feed ordering. Scores from
multiple processors are summed and clamped to [-1, 1].

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:20:36 +00:00
40ad90aa2d feat: add generic CalDAV calendar data source (#42)
* feat: add generic CalDAV calendar data source

Add @aris/source-caldav package that fetches calendar events from any
CalDAV server via tsdav + ical.js.

- Supports Basic auth and OAuth via explicit authMethod discriminant
- serverUrl provided at construction time, not hardcoded
- Optional timeZone for correct local day boundaries
- Credentials cleared from memory after client login
- Failed calendar fetches logged, not silently dropped
- Login promise cached with retry on failure

Co-authored-by: Ona <no-reply@ona.com>

* fix: deduplicate concurrent fetchEvents calls

Co-authored-by: Ona <no-reply@ona.com>

* fix: timezone-aware signals, low-priority cancelled events

- computeSignals uses startOfDay(timeZone) for 'later today' boundary
- Cancelled events get urgency 0.1, excluded from context inProgress/nextEvent

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 16:09:11 +00:00
82ac2b577d feat: add post-processor pipeline to FeedEngine (#41)
* feat: add post-processor pipeline to FeedEngine

Add FeedPostProcessor type and FeedEnhancement interface.
Post-processors run after item collection on all update
paths (refresh, reactive context, reactive items).

Pipeline is chained — each processor sees items as modified
by the previous one. Enhancement merging handles additional
items, suppression, and grouped items. Throwing processors
are caught and recorded in FeedResult.errors.

Co-authored-by: Ona <no-reply@ona.com>

* docs: document intentional TItems cast in post-processor merge

Co-authored-by: Ona <no-reply@ona.com>

* fix: filter stale item IDs from groups after pipeline

Groups accumulated during the pipeline can reference items
that a later processor suppressed. The engine now strips
stale IDs and drops empty groups before returning.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: use reduce for stale group filtering

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:57:01 +00:00
ffea38b986 fix: remove apple calendar data source (#40)
The CalDAV-based approach doesn't work as expected for
Apple Calendar integration.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:30:23 +00:00
28d26b3c87 Replace FeedItem.priority with signals (#39)
* feat: replace FeedItem.priority with signals

Remove priority field from FeedItem and engine-level sorting.
Add FeedItemSignals with urgency and timeRelevance fields.
Update all source packages to emit signals instead of priority.

Ranking is now the post-processing layer's responsibility.
Urgency values are unchanged from the old priority values.

Co-authored-by: Ona <no-reply@ona.com>

* fix: use TimeRelevance enum in all tests

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:02:57 +00:00
78b0ed94bd docs: update UI rendering to server-driven twrnc (#38)
Replace outdated UI Registry model with server-driven
json-render + twrnc approach. Update architecture diagram,
terminology (DataSource→FeedSource, Reconciler→FeedEngine),
and design principles to match current codebase.

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

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:55:46 +00:00
ee957ea7b1 docs: agent vision and enhancement architecture (#37)
Rewrite ai-agent-ideas.md with focus on proactive,
personable assistant behaviors. Add slot-based LLM
enhancement system to architecture-draft.md.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:48:05 +00:00
6ae0ad1d40 Merge pull request #36 from kennethnym/feat/feed-endpoint
feat: add GET /api/feed endpoint
2026-02-24 22:33:49 +00:00
38 changed files with 3218 additions and 810 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -18,9 +18,9 @@ import type { FeedItem } from "./feed"
* return [{ * return [{
* id: `weather-${Date.now()}`, * id: `weather-${Date.now()}`,
* type: this.type, * type: this.type,
* priority: 0.5,
* timestamp: context.time, * timestamp: context.time,
* data: { temp: data.temperature }, * data: { temp: data.temperature },
* signals: { urgency: 0.5, timeRelevance: "ambient" },
* }] * }]
* } * }
* } * }

View File

@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index" import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine" import { FeedEngine } from "./feed-engine"
import { UnknownActionError, contextKey, contextValue } from "./index" import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources // No-op action methods for test sources
const noActions = { const noActions = {
@@ -100,12 +100,12 @@ function createWeatherSource(
{ {
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 },
}, },
] ]
}, },
@@ -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 },
}, },
] ]
} }
@@ -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 () => {

View File

@@ -1,6 +1,7 @@
import type { ActionDefinition } from "./action" import type { ActionDefinition } from "./action"
import type { Context } from "./context" import type { Context } from "./context"
import type { FeedItem } from "./feed" import type { FeedItem } from "./feed"
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
import type { FeedSource } from "./feed-source" import type { FeedSource } from "./feed-source"
export interface SourceError { export interface SourceError {
@@ -12,6 +13,8 @@ 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
@@ -66,6 +69,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private subscribers = new Set<FeedSubscriber<TItems>>() private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = [] private cleanups: Array<() => void> = []
private started = false private started = false
private postProcessors: FeedPostProcessor[] = []
private readonly cacheTtlMs: number private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null private cachedResult: FeedResult<TItems> | null = null
@@ -108,6 +112,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.
@@ -150,12 +171,20 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
} }
} }
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
this.context = context this.context = context
const result: FeedResult<TItems> = { context, items: items as TItems[], errors } const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = {
context,
items: processedItems,
errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
}
this.updateCache(result) this.updateCache(result)
return result return result
@@ -263,6 +292,71 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return actions return actions
} }
private async applyPostProcessors(
items: TItems[],
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)
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()))
@@ -314,12 +408,17 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
} }
} }
items.sort((a, b) => b.priority - a.priority) const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = { 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.updateCache(result)
@@ -400,6 +499,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) {

View File

@@ -0,0 +1,600 @@
import { describe, expect, mock, test } from "bun:test"
import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherItem = FeedItem<"weather", { temp: number }>
type CalendarItem = FeedItem<"calendar", { title: string }>
function weatherItem(id: string, temp: number): WeatherItem {
return { id, type: "weather", timestamp: new Date(), data: { temp } }
}
function calendarItem(id: string, title: string): CalendarItem {
return { id, type: "calendar", timestamp: new Date(), data: { title } }
}
// =============================================================================
// TEST SOURCES
// =============================================================================
function createWeatherSource(items: WeatherItem[]) {
return {
id: "aris.weather",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<WeatherItem[]> {
return items
},
}
}
function createCalendarSource(items: CalendarItem[]) {
return {
id: "aris.calendar",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<CalendarItem[]> {
return items
},
}
}
// =============================================================================
// REGISTRATION
// =============================================================================
describe("FeedPostProcessor", () => {
describe("registration", () => {
test("registerPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.registerPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregisterPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.unregisterPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregistered processor does not run", async () => {
const processor = mock(async () => ({}))
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(processor)
.unregisterPostProcessor(processor)
await engine.refresh()
expect(processor).not.toHaveBeenCalled()
})
})
// =============================================================================
// ADDITIONAL ITEMS
// =============================================================================
describe("additionalItems", () => {
test("injects additional items into the feed", async () => {
const extra = calendarItem("c1", "Meeting")
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ additionalItems: [extra] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items.find((i) => i.id === "c1")).toBeDefined()
})
})
// =============================================================================
// SUPPRESS
// =============================================================================
describe("suppress", () => {
test("removes suppressed items from the feed", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w2")
})
test("suppressing nonexistent ID is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ suppress: ["nonexistent"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
})
})
// =============================================================================
// GROUPED ITEMS
// =============================================================================
describe("groupedItems", () => {
test("accumulates grouped items on FeedResult", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }])
})
test("multiple processors accumulate groups", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Group A" }],
}))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c2"], summary: "Group B" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([
{ itemIds: ["c1"], summary: "Group A" },
{ itemIds: ["c2"], summary: "Group B" },
])
})
test("stale item IDs are removed from groups after suppression", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
})
test("groups with all items suppressed are dropped", async () => {
const engine = new FeedEngine()
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
test("groupedItems is omitted when no processors produce groups", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({}))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// 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: ((update: Record<string, unknown>) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onContextUpdate(callback, _getContext) {
triggerUpdate = callback
return () => {
triggerUpdate = null
}
},
}
const engine = new FeedEngine()
.register(source)
.registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
// Wait for initial periodic refresh
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive context update
triggerUpdate!({ foo: "bar" })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
test("post-processors run during reactive item updates", async () => {
let callCount = 0
let triggerItemsUpdate: (() => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onItemsUpdate(callback, _getContext) {
triggerItemsUpdate = callback
return () => {
triggerItemsUpdate = null
}
},
}
const engine = new FeedEngine()
.register(source)
.registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive items update
triggerItemsUpdate!()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
})
// =============================================================================
// NO PROCESSORS = NO CHANGE
// =============================================================================
describe("no processors", () => {
test("engine without post-processors returns raw items unchanged", async () => {
const items = [weatherItem("w1", 20), weatherItem("w2", 25)]
const engine = new FeedEngine().register(createWeatherSource(items))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items[0].id).toBe("w1")
expect(result.items[1].id).toBe("w2")
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// COMBINED ENHANCEMENT
// =============================================================================
describe("combined enhancement", () => {
test("single processor can use all enhancement fields at once", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
additionalItems: [calendarItem("c1", "Injected")],
suppress: ["w2"],
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
}))
const result = await engine.refresh()
// w2 suppressed, c1 injected → w1 + c1
expect(result.items).toHaveLength(2)
expect(result.items.map((i) => i.id)).toEqual(["w1", "c1"])
// Groups on result
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
})
})
})

View File

@@ -0,0 +1,25 @@
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[]) => Promise<FeedEnhancement>

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index" import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { UnknownActionError, contextKey, contextValue } from "./index" import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources // No-op action methods for test sources
const noActions = { const noActions = {
@@ -99,12 +99,12 @@ function createWeatherSource(
{ {
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 },
}, },
] ]
}, },
@@ -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 },
}, },
] ]
} }
@@ -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 }
} }
@@ -441,8 +438,12 @@ 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 () => {

View File

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

View File

@@ -7,11 +7,15 @@ export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action" export { UnknownActionError } from "./action"
// Feed // Feed
export type { FeedItem } from "./feed" export type { FeedItem, FeedItemSignals } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source // Feed Source
export type { FeedSource } from "./feed-source" export type { FeedSource } from "./feed-source"
// Feed Post-Processor
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
// Feed Engine // Feed Engine
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine" export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine" export { FeedEngine } from "./feed-engine"

View File

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

View File

@@ -190,21 +190,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 () => {

View File

@@ -1,4 +1,6 @@
import type { Context, DataSource } from "@aris/core" import type { Context, DataSource, FeedItemSignals } from "@aris/core"
import { TimeRelevance } from "@aris/core"
import { import {
WeatherFeedItemType, WeatherFeedItemType,
@@ -105,7 +107,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 +136,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 +155,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 +221,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 +245,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 +255,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 +279,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 +289,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 +311,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 +337,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
source: alert.source, source: alert.source,
urgency: alert.urgency, urgency: alert.urgency,
}, },
signals,
} }
} }

View File

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

View File

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

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,38 @@ 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 --
export type CalDavFeedItem = FeedItem<"caldav-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>
} }

View File

@@ -1,4 +1,4 @@
import { contextValue, type Context } from "@aris/core" import { TimeRelevance, contextValue, type Context } from "@aris/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types" import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
@@ -81,16 +81,17 @@ describe("GoogleCalendarSource", () => {
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 () => {
@@ -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!)
}) })
}) })
}) })

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { UnknownActionError } from "@aris/core" import { TimeRelevance, UnknownActionError } from "@aris/core"
import type { 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.
@@ -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),
} }
} }

View File

@@ -184,7 +184,8 @@ describe("TflSource", () => {
expect(typeof item.id).toBe("string") expect(typeof item.id).toBe("string")
expect(item.id).toMatch(/^tfl-alert-/) expect(item.id).toMatch(/^tfl-alert-/)
expect(item.type).toBe("tfl-alert") expect(item.type).toBe("tfl-alert")
expect(typeof item.priority).toBe("number") expect(item.signals).toBeDefined()
expect(typeof item.signals!.urgency).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date) expect(item.timestamp).toBeInstanceOf(Date)
} }
}) })
@@ -220,29 +221,29 @@ describe("TflSource", () => {
expect(uniqueIds.size).toBe(ids.length) expect(uniqueIds.size).toBe(ids.length)
}) })
test("feed items are sorted by priority descending", async () => { test("feed items are sorted by urgency descending", async () => {
const source = new TflSource({ client: api }) const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
for (let i = 1; i < items.length; i++) { for (let i = 1; i < items.length; i++) {
const prev = items[i - 1]! const prev = items[i - 1]!
const curr = items[i]! const curr = items[i]!
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority) expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
} }
}) })
test("priority values match severity levels", async () => { test("urgency values match severity levels", async () => {
const source = new TflSource({ client: api }) const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
const severityPriority: Record<string, number> = { const severityUrgency: Record<string, number> = {
closure: 1.0, closure: 1.0,
"major-delays": 0.8, "major-delays": 0.8,
"minor-delays": 0.6, "minor-delays": 0.6,
} }
for (const item of items) { for (const item of items) {
expect(item.priority).toBe(severityPriority[item.data.severity]!) expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
} }
}) })
@@ -316,9 +317,7 @@ describe("TflSource", () => {
test("executeAction throws on invalid input", async () => { test("executeAction throws on invalid input", async () => {
const source = new TflSource({ client: api }) const source = new TflSource({ client: api })
await expect( await expect(source.executeAction("set-lines-of-interest", "not-an-array")).rejects.toThrow()
source.executeAction("set-lines-of-interest", "not-an-array"),
).rejects.toThrow()
}) })
test("executeAction throws for unknown action", async () => { test("executeAction throws for unknown action", async () => {

View File

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

View File

@@ -145,20 +145,22 @@ describe("WeatherSource", () => {
} }
}) })
test("assigns priority based on weather conditions", async () => { test("assigns signals based on weather conditions", async () => {
const source = new WeatherSource({ client: mockClient }) const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context) const items = await source.fetchItems(context)
for (const item of items) { for (const item of items) {
expect(item.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()
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5) expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
}) })
test("generates unique IDs for each item", async () => { test("generates unique IDs for each item", async () => {

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core" import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { UnknownActionError, contextValue } from "@aris/core" import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
import { LocationKey } from "@aris/source-location" import { LocationKey } from "@aris/source-location"
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
@@ -38,7 +38,7 @@ export interface WeatherSourceOptions {
const DEFAULT_HOURLY_LIMIT = 12 const DEFAULT_HOURLY_LIMIT = 12
const DEFAULT_DAILY_LIMIT = 7 const DEFAULT_DAILY_LIMIT = 7
const BASE_PRIORITY = { const BASE_URGENCY = {
current: 0.5, current: 0.5,
hourly: 0.3, hourly: 0.3,
daily: 0.2, daily: 0.2,
@@ -199,17 +199,17 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
} }
} }
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number { function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
if (SEVERE_CONDITIONS.has(conditionCode)) { 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 "extreme": case "extreme":
return 1 return 1
@@ -218,7 +218,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number {
case "moderate": case "moderate":
return 0.75 return 0.75
case "minor": case "minor":
return BASE_PRIORITY.alert return BASE_URGENCY.alert
}
}
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
if (SEVERE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Imminent
}
if (MODERATE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Upcoming
}
return TimeRelevance.Ambient
}
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
switch (severity) {
case "extreme":
case "severe":
return TimeRelevance.Imminent
case "moderate":
return TimeRelevance.Upcoming
case "minor":
return TimeRelevance.Ambient
} }
} }
@@ -262,12 +284,14 @@ function createCurrentWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): WeatherFeedItem { ): WeatherFeedItem {
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,
@@ -284,6 +308,7 @@ function createCurrentWeatherFeedItem(
windGust: convertSpeed(current.windGust, units), windGust: convertSpeed(current.windGust, units),
windSpeed: convertSpeed(current.windSpeed, units), windSpeed: convertSpeed(current.windSpeed, units),
}, },
signals,
} }
} }
@@ -293,12 +318,14 @@ function createHourlyWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): WeatherFeedItem { ): WeatherFeedItem {
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),
@@ -315,6 +342,7 @@ function createHourlyWeatherFeedItem(
windGust: convertSpeed(hourly.windGust, units), windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units), windSpeed: convertSpeed(hourly.windSpeed, units),
}, },
signals,
} }
} }
@@ -324,12 +352,14 @@ function createDailyWeatherFeedItem(
timestamp: Date, timestamp: Date,
units: Units, units: Units,
): WeatherFeedItem { ): WeatherFeedItem {
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),
@@ -344,16 +374,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): WeatherFeedItem { function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
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,
@@ -367,5 +400,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
source: alert.source, source: alert.source,
urgency: alert.urgency, urgency: alert.urgency,
}, },
signals,
} }
} }