mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
1 Commits
bb09de2da6
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
243e84622a
|
6
bun.lock
6
bun.lock
@@ -89,8 +89,8 @@
|
||||
"arktype": "^2.1.0",
|
||||
},
|
||||
},
|
||||
"packages/aris-source-caldav": {
|
||||
"name": "@aris/source-caldav",
|
||||
"packages/aris-source-apple-calendar": {
|
||||
"name": "@aris/source-apple-calendar",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||
|
||||
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
|
||||
"@aris/source-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
|
||||
|
||||
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ Examples of feed items:
|
||||
## Design Principles
|
||||
|
||||
1. **Extensibility**: The core must support different data sources, including third-party sources.
|
||||
2. **Separation of concerns**: Core handles data and UI description. The client is a thin renderer.
|
||||
3. **Dependency graph**: Sources declare dependencies on other sources. The engine resolves the graph and runs independent sources in parallel.
|
||||
2. **Separation of concerns**: Core handles data only. UI rendering is a separate system.
|
||||
3. **Parallel execution**: Sources run in parallel; no inter-source dependencies.
|
||||
4. **Graceful degradation**: Failed sources are skipped; partial results are returned.
|
||||
|
||||
## Architecture
|
||||
@@ -25,28 +25,26 @@ Examples of feed items:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Backend │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ aris-core │ │ Sources │ │
|
||||
│ │ │ │ (plugins) │ │
|
||||
│ │ - FeedEngine│◄───│ - Calendar │ │
|
||||
│ │ - Context │ │ - Weather │ │
|
||||
│ │ - FeedItem │ │ - TfL │ │
|
||||
│ │ - Actions │ │ - Spotify │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Feed items (data + ui trees + slots) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ aris-core │ │ Sources │ │ UI Registry │ │
|
||||
│ │ │ │ (plugins) │ │ (schemas from │ │
|
||||
│ │ - Reconciler│◄───│ - Calendar │ │ third parties)│ │
|
||||
│ │ - Context │ │ - Weather │ │ │ │
|
||||
│ │ - FeedItem │ │ - Spotify │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ Feed (data only) UI Schemas (JSON) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ (WebSocket / JSON-RPC)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Client (React Native) │
|
||||
│ Frontend │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ json-render + twrnc component map │ │
|
||||
│ │ - Receives feed items with ui trees │ │
|
||||
│ │ - Renders using registered RN components + twrnc │ │
|
||||
│ │ - User interactions trigger source actions │ │
|
||||
│ │ - Bespoke native components for rich interactions │ │
|
||||
│ │ Renderer │ │
|
||||
│ │ - Receives feed items │ │
|
||||
│ │ - Fetches UI schema by item type │ │
|
||||
│ │ - Renders using json-render or similar │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -56,16 +54,15 @@ Examples of feed items:
|
||||
The core is responsible for:
|
||||
|
||||
- Defining the context and feed item interfaces
|
||||
- Providing a `FeedEngine` that orchestrates sources via a dependency graph
|
||||
- Providing a reconciler that orchestrates data sources
|
||||
- Returning a flat list of prioritized feed items
|
||||
- Routing action execution to the correct source
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Context**: Time and location (with accuracy) passed to all sources. Sources can contribute to context (e.g., location source provides coordinates, weather source provides conditions).
|
||||
- **FeedItem**: Has an ID (source-generated, stable), type, timestamp, JSON-serializable data, optional actions, an optional `ui` tree, and optional `slots` for LLM-fillable content.
|
||||
- **FeedSource**: Interface that first and third parties implement to provide context, feed items, and actions. Uses reverse-domain IDs (e.g., `aris.weather`, `com.spotify`).
|
||||
- **FeedEngine**: Orchestrates sources respecting their dependency graph, runs independent sources in parallel, returns items and any errors. Routes action execution to the correct source.
|
||||
- **Context**: Time and location (with accuracy) passed to all sources
|
||||
- **FeedItem**: Has an ID (source-generated, stable), type, priority, timestamp, and JSON-serializable data
|
||||
- **DataSource**: Interface that third parties implement to provide feed items
|
||||
- **Reconciler**: Orchestrates sources, runs them in parallel, returns items and any errors
|
||||
|
||||
## Data Sources
|
||||
|
||||
@@ -74,13 +71,10 @@ Key decisions:
|
||||
- Sources receive the full context and decide internally what to use
|
||||
- Each source returns a single item type (e.g., separate "Calendar Source" and "Location Suggestion Source" rather than a combined "Google Source")
|
||||
- Sources live in separate packages, not in the core
|
||||
- Sources declare dependencies on other sources (e.g., weather depends on location)
|
||||
- Sources are responsible for:
|
||||
- Transforming their domain data into feed items
|
||||
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
|
||||
- Returning empty arrays when nothing is relevant
|
||||
- Providing a `ui` tree for each feed item
|
||||
- Declaring and handling actions (e.g., RSVP, complete task, play/pause)
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -89,39 +83,26 @@ Configuration is passed at source registration time, not per reconcile call. Sou
|
||||
## Feed Output
|
||||
|
||||
- Flat list of `FeedItem` objects
|
||||
- Items carry data, an optional `ui` field describing their layout, and optional `slots` for LLM enhancement
|
||||
- No UI information (no icons, card types, etc.)
|
||||
- Items are a discriminated union by `type` field
|
||||
- Reconciler sorts by priority; can act as tiebreaker
|
||||
|
||||
## UI Rendering: Server-Driven UI
|
||||
## UI Rendering (Separate from Core)
|
||||
|
||||
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).
|
||||
The core does not handle UI. For extensible third-party UI:
|
||||
|
||||
### How it works
|
||||
1. Third-party apps register their UI schemas through the backend (UI Registry)
|
||||
2. Frontend fetches UI schemas from the backend
|
||||
3. Frontend matches feed items to schemas by `type` and renders accordingly
|
||||
|
||||
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()`.
|
||||
This approach:
|
||||
|
||||
### Styling
|
||||
- Keeps the core focused on data
|
||||
- Works across platforms (web, React Native)
|
||||
- Avoids the need for third parties to inject code into the app
|
||||
- Uses a json-render style approach for declarative UI from JSON schemas
|
||||
|
||||
- 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/
|
||||
Reference: https://github.com/vercel-labs/json-render
|
||||
|
||||
## Feed Items with UI and Slots
|
||||
|
||||
@@ -403,9 +384,9 @@ function mergeEnhancement(
|
||||
|
||||
## Open Questions
|
||||
|
||||
- How third parties authenticate/register their sources
|
||||
- Exact set of React Native components exposed in the json-render component map
|
||||
- Validation/sandboxing of third-party ui trees
|
||||
- How synthetic items define their UI (full json-render tree vs. registered component)
|
||||
- Exact schema format for UI registry
|
||||
- How third parties authenticate/register their sources and UI schemas
|
||||
- JsonRenderNode type definition and component vocabulary
|
||||
- How synthetic items define their UI (full json-render tree vs. registered schema)
|
||||
- Should slots support rich content (json-render nodes) in the future, or stay text-only?
|
||||
- How to handle slot content that references other items (e.g., "your dinner at The Ivy" linking to the calendar card)
|
||||
|
||||
@@ -125,7 +125,7 @@ interface FeedSource<TItem extends FeedItem = FeedItem> {
|
||||
|
||||
### Changes to FeedItem
|
||||
|
||||
Optional fields added for actions, server-driven UI, and LLM slots.
|
||||
One optional field added.
|
||||
|
||||
```typescript
|
||||
interface FeedItem<
|
||||
@@ -140,12 +140,6 @@ interface FeedItem<
|
||||
|
||||
/** Actions the user can take on this item. */
|
||||
actions?: readonly ItemAction[]
|
||||
|
||||
/** Server-driven UI tree rendered by json-render on the client. */
|
||||
ui?: JsonRenderNode
|
||||
|
||||
/** Named slots for LLM-fillable content. See architecture-draft.md. */
|
||||
slots?: Record<string, Slot>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -228,25 +222,6 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
||||
{ actionId: "skip-track" },
|
||||
{ actionId: "like-track", params: { trackId: track.id } },
|
||||
],
|
||||
ui: {
|
||||
type: "View",
|
||||
className: "flex-row items-center p-3 gap-3 bg-white dark:bg-black rounded-xl",
|
||||
children: [
|
||||
{
|
||||
type: "Image",
|
||||
source: { uri: track.albumArt },
|
||||
className: "w-12 h-12 rounded-lg",
|
||||
},
|
||||
{
|
||||
type: "View",
|
||||
className: "flex-1",
|
||||
children: [
|
||||
{ type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
|
||||
{ type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -261,8 +236,6 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
||||
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
|
||||
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
|
||||
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
|
||||
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
|
||||
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
|
||||
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
|
||||
8. `FeedEngine.listActions()` aggregates actions from all sources
|
||||
9. Existing tests pass unchanged (all changes are additive)
|
||||
|
||||
@@ -18,9 +18,9 @@ import type { FeedItem } from "./feed"
|
||||
* return [{
|
||||
* id: `weather-${Date.now()}`,
|
||||
* type: this.type,
|
||||
* priority: 0.5,
|
||||
* timestamp: context.time,
|
||||
* data: { temp: data.temperature },
|
||||
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
|
||||
import { FeedEngine } from "./feed-engine"
|
||||
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
||||
|
||||
// No-op action methods for test sources
|
||||
const noActions = {
|
||||
@@ -100,12 +100,12 @@ function createWeatherSource(
|
||||
{
|
||||
id: `weather-${Date.now()}`,
|
||||
type: "weather",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {
|
||||
temperature: weather.temperature,
|
||||
condition: weather.condition,
|
||||
},
|
||||
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -131,9 +131,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
||||
{
|
||||
id: "alert-storm",
|
||||
type: "alert",
|
||||
priority: 1.0,
|
||||
timestamp: new Date(),
|
||||
data: { message: "Storm warning!" },
|
||||
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -322,7 +322,7 @@ describe("FeedEngine", () => {
|
||||
expect(items[0]!.type).toBe("weather")
|
||||
})
|
||||
|
||||
test("returns items in source graph order (no engine-level sorting)", async () => {
|
||||
test("sorts items by priority descending", async () => {
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
@@ -338,12 +338,8 @@ describe("FeedEngine", () => {
|
||||
const { items } = await engine.refresh()
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
// Items returned in topological order (weather before alert)
|
||||
expect(items[0]!.type).toBe("weather")
|
||||
expect(items[1]!.type).toBe("alert")
|
||||
// Signals are preserved for post-processors to consume
|
||||
expect(items[0]!.signals?.urgency).toBe(0.5)
|
||||
expect(items[1]!.signals?.urgency).toBe(1.0)
|
||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
||||
})
|
||||
|
||||
test("handles missing upstream context gracefully", async () => {
|
||||
|
||||
@@ -150,6 +150,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority descending
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
this.context = context
|
||||
|
||||
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
|
||||
@@ -311,6 +314,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
const result: FeedResult<TItems> = {
|
||||
context: this.context,
|
||||
items: items as TItems[],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
|
||||
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
||||
|
||||
// No-op action methods for test sources
|
||||
const noActions = {
|
||||
@@ -99,12 +99,12 @@ function createWeatherSource(
|
||||
{
|
||||
id: `weather-${Date.now()}`,
|
||||
type: "weather",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {
|
||||
temperature: weather.temperature,
|
||||
condition: weather.condition,
|
||||
},
|
||||
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -130,9 +130,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
||||
{
|
||||
id: "alert-storm",
|
||||
type: "alert",
|
||||
priority: 1.0,
|
||||
timestamp: new Date(),
|
||||
data: { message: "Storm warning!" },
|
||||
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -226,6 +226,9 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority descending
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
return { context, items }
|
||||
}
|
||||
|
||||
@@ -438,12 +441,8 @@ describe("FeedSource", () => {
|
||||
const { items } = await refreshGraph(graph)
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
// Items returned in topological order (weather before alert)
|
||||
expect(items[0]!.type).toBe("weather")
|
||||
expect(items[1]!.type).toBe("alert")
|
||||
// Signals preserved for post-processors
|
||||
expect(items[0]!.signals?.urgency).toBe(0.5)
|
||||
expect(items[1]!.signals?.urgency).toBe(1.0)
|
||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
||||
})
|
||||
|
||||
test("source without location context returns empty items", async () => {
|
||||
|
||||
@@ -1,28 +1,3 @@
|
||||
/**
|
||||
* Source-provided hints for post-processors.
|
||||
*
|
||||
* Sources express domain-specific relevance without determining final ranking.
|
||||
* Post-processors consume these signals alongside other inputs (user affinity,
|
||||
* time of day, interaction history) to produce the final feed order.
|
||||
*/
|
||||
export const TimeRelevance = {
|
||||
/** Needs attention now (e.g., event starting in minutes, severe alert) */
|
||||
Imminent: "imminent",
|
||||
/** Relevant soon (e.g., event in the next hour, approaching deadline) */
|
||||
Upcoming: "upcoming",
|
||||
/** Background information (e.g., daily forecast, low-priority status) */
|
||||
Ambient: "ambient",
|
||||
} as const
|
||||
|
||||
export type TimeRelevance = (typeof TimeRelevance)[keyof typeof TimeRelevance]
|
||||
|
||||
export interface FeedItemSignals {
|
||||
/** Source-assessed urgency (0-1). Post-processors use this as one ranking input. */
|
||||
urgency?: number
|
||||
/** How time-sensitive this item is relative to now. */
|
||||
timeRelevance?: TimeRelevance
|
||||
}
|
||||
|
||||
/**
|
||||
* A single item in the feed.
|
||||
*
|
||||
@@ -33,9 +8,9 @@ export interface FeedItemSignals {
|
||||
* const item: WeatherItem = {
|
||||
* id: "weather-123",
|
||||
* type: "weather",
|
||||
* priority: 0.5,
|
||||
* timestamp: new Date(),
|
||||
* data: { temp: 18, condition: "cloudy" },
|
||||
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@@ -47,10 +22,10 @@ export interface FeedItem<
|
||||
id: string
|
||||
/** Item type, matches the data source type */
|
||||
type: TType
|
||||
/** Sort priority (higher = more important, shown first) */
|
||||
priority: number
|
||||
/** When this item was generated */
|
||||
timestamp: Date
|
||||
/** Type-specific payload */
|
||||
data: TData
|
||||
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
|
||||
signals?: FeedItemSignals
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ export type { ActionDefinition } from "./action"
|
||||
export { UnknownActionError } from "./action"
|
||||
|
||||
// Feed
|
||||
export type { FeedItem, FeedItemSignals } from "./feed"
|
||||
export { TimeRelevance } from "./feed"
|
||||
export type { FeedItem } from "./feed"
|
||||
|
||||
// Feed Source
|
||||
export type { FeedSource } from "./feed-source"
|
||||
|
||||
@@ -72,6 +72,8 @@ export class Reconciler<TItems extends FeedItem = never> {
|
||||
}
|
||||
})
|
||||
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
return { items, errors } as ReconcileResult<TItems>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,22 +190,21 @@ describe("query() with mocked client", () => {
|
||||
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
||||
})
|
||||
|
||||
test("assigns signals based on weather conditions", async () => {
|
||||
test("assigns priority based on weather conditions", async () => {
|
||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await dataSource.query(context)
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.signals).toBeDefined()
|
||||
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
||||
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
||||
expect(item.signals!.timeRelevance).toBeDefined()
|
||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
||||
expect(item.priority).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||
expect(currentItem).toBeDefined()
|
||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||
// Base priority for current is 0.5, may be adjusted for conditions
|
||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
|
||||
|
||||
import { TimeRelevance } from "@aris/core"
|
||||
import type { Context, DataSource } from "@aris/core"
|
||||
|
||||
import {
|
||||
WeatherFeedItemType,
|
||||
@@ -107,7 +105,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URGENCY = {
|
||||
const BASE_PRIORITY = {
|
||||
current: 0.5,
|
||||
hourly: 0.3,
|
||||
daily: 0.2,
|
||||
@@ -136,17 +134,17 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
||||
ConditionCode.BlowingSnow,
|
||||
])
|
||||
|
||||
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, baseUrgency + 0.3)
|
||||
return Math.min(1, basePriority + 0.3)
|
||||
}
|
||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, baseUrgency + 0.15)
|
||||
return Math.min(1, basePriority + 0.15)
|
||||
}
|
||||
return baseUrgency
|
||||
return basePriority
|
||||
}
|
||||
|
||||
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||
switch (severity) {
|
||||
case Severity.Extreme:
|
||||
return 1
|
||||
@@ -155,29 +153,7 @@ function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||
case Severity.Moderate:
|
||||
return 0.75
|
||||
case Severity.Minor:
|
||||
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
|
||||
return BASE_PRIORITY.alert
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,14 +197,12 @@ function createCurrentWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): CurrentWeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||
}
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-current-${timestamp.getTime()}`,
|
||||
type: WeatherFeedItemType.current,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
conditionCode: current.conditionCode,
|
||||
@@ -245,7 +219,6 @@ function createCurrentWeatherFeedItem(
|
||||
windGust: convertSpeed(current.windGust, units),
|
||||
windSpeed: convertSpeed(current.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,14 +228,12 @@ function createHourlyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): HourlyWeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||
}
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.hourly,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastTime: new Date(hourly.forecastStart),
|
||||
@@ -279,7 +250,6 @@ function createHourlyWeatherFeedItem(
|
||||
windGust: convertSpeed(hourly.windGust, units),
|
||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,14 +259,12 @@ function createDailyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): DailyWeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||
}
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.daily,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastDate: new Date(daily.forecastStart),
|
||||
@@ -311,19 +279,16 @@ function createDailyWeatherFeedItem(
|
||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||
}
|
||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
||||
|
||||
return {
|
||||
id: `weather-alert-${alert.id}`,
|
||||
type: WeatherFeedItemType.alert,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
alertId: alert.id,
|
||||
@@ -337,6 +302,5 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
||||
source: alert.source,
|
||||
urgency: alert.urgency,
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "@aris/source-caldav",
|
||||
"name": "@aris/source-apple-calendar",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test .",
|
||||
"test:live": "bun run scripts/test-live.ts"
|
||||
"test": "bun test ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
@@ -2,23 +2,23 @@ import type { ContextKey } from "@aris/core"
|
||||
|
||||
import { contextKey } from "@aris/core"
|
||||
|
||||
import type { CalDavEventData } from "./types.ts"
|
||||
import type { CalendarEventData } from "./types.ts"
|
||||
|
||||
/**
|
||||
* Calendar context for downstream sources.
|
||||
*
|
||||
* Provides a snapshot of the user's upcoming CalDAV events so other sources
|
||||
* Provides a snapshot of the user's upcoming events so other sources
|
||||
* can adapt (e.g. a commute source checking if there's a meeting soon).
|
||||
*/
|
||||
export interface CalendarContext {
|
||||
/** Events happening right now */
|
||||
inProgress: CalDavEventData[]
|
||||
inProgress: CalendarEventData[]
|
||||
/** Next upcoming event, if any */
|
||||
nextEvent: CalDavEventData | null
|
||||
nextEvent: CalendarEventData | null
|
||||
/** Whether the user has any events today */
|
||||
hasTodayEvents: boolean
|
||||
/** Total number of events today */
|
||||
todayEventCount: number
|
||||
}
|
||||
|
||||
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")
|
||||
export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar")
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { Context } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, contextValue } from "@aris/core"
|
||||
import { contextValue } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import type {
|
||||
CalDavDAVCalendar,
|
||||
CalDavDAVClient,
|
||||
CalDavDAVObject,
|
||||
CalDavEventData,
|
||||
CalendarCredentialProvider,
|
||||
CalendarCredentials,
|
||||
CalendarDAVCalendar,
|
||||
CalendarDAVClient,
|
||||
CalendarDAVObject,
|
||||
CalendarEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
||||
import { CalDavCalendarKey } from "./calendar-context.ts"
|
||||
import { CalendarKey } from "./calendar-context.ts"
|
||||
import { CalendarSource, computePriority } from "./calendar-source.ts"
|
||||
|
||||
function loadFixture(name: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||
@@ -23,16 +25,36 @@ function createContext(time: Date): Context {
|
||||
return { time }
|
||||
}
|
||||
|
||||
class MockDAVClient implements CalDavDAVClient {
|
||||
const mockCredentials: CalendarCredentials = {
|
||||
accessToken: "mock-access-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
tokenUrl: "https://appleid.apple.com/auth/token",
|
||||
clientId: "com.example.aris",
|
||||
clientSecret: "mock-secret",
|
||||
}
|
||||
|
||||
class NullCredentialProvider implements CalendarCredentialProvider {
|
||||
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class MockCredentialProvider implements CalendarCredentialProvider {
|
||||
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
|
||||
return mockCredentials
|
||||
}
|
||||
}
|
||||
|
||||
class MockDAVClient implements CalendarDAVClient {
|
||||
credentials: Record<string, unknown> = {}
|
||||
fetchCalendarsCallCount = 0
|
||||
lastTimeRange: { start: string; end: string } | null = null
|
||||
private calendars: CalDavDAVCalendar[]
|
||||
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
|
||||
private calendars: CalendarDAVCalendar[]
|
||||
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
|
||||
|
||||
constructor(
|
||||
calendars: CalDavDAVCalendar[],
|
||||
objectsByCalendarUrl: Record<string, CalDavDAVObject[]>,
|
||||
calendars: CalendarDAVCalendar[],
|
||||
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
|
||||
) {
|
||||
this.calendars = calendars
|
||||
this.objectsByCalendarUrl = objectsByCalendarUrl
|
||||
@@ -40,57 +62,54 @@ class MockDAVClient implements CalDavDAVClient {
|
||||
|
||||
async login(): Promise<void> {}
|
||||
|
||||
async fetchCalendars(): Promise<CalDavDAVCalendar[]> {
|
||||
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
|
||||
this.fetchCalendarsCallCount++
|
||||
return this.calendars
|
||||
}
|
||||
|
||||
async fetchCalendarObjects(params: {
|
||||
calendar: CalDavDAVCalendar
|
||||
calendar: CalendarDAVCalendar
|
||||
timeRange: { start: string; end: string }
|
||||
}): Promise<CalDavDAVObject[]> {
|
||||
this.lastTimeRange = params.timeRange
|
||||
}): Promise<CalendarDAVObject[]> {
|
||||
return this.objectsByCalendarUrl[params.calendar.url] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource {
|
||||
return new CalDavSource({
|
||||
serverUrl: "https://caldav.example.com",
|
||||
authMethod: "basic",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
davClient: client,
|
||||
lookAheadDays,
|
||||
})
|
||||
}
|
||||
|
||||
describe("CalDavSource", () => {
|
||||
describe("CalendarSource", () => {
|
||||
test("has correct id", () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = createSource(client)
|
||||
expect(source.id).toBe("aris.caldav")
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
expect(source.id).toBe("aris.apple-calendar")
|
||||
})
|
||||
|
||||
test("returns empty array when credentials are null", async () => {
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("returns empty array when no calendars exist", async () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("returns feed items from a single calendar", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.type).toBe("caldav-event")
|
||||
expect(items[0]!.id).toBe("caldav-event-single-event-001@test")
|
||||
expect(items[0]!.type).toBe("calendar-event")
|
||||
expect(items[0]!.id).toBe("calendar-event-single-event-001@test")
|
||||
expect(items[0]!.data.title).toBe("Team Standup")
|
||||
expect(items[0]!.data.location).toBe("Conference Room A")
|
||||
expect(items[0]!.data.calendarName).toBe("Work")
|
||||
@@ -99,7 +118,7 @@ describe("CalDavSource", () => {
|
||||
})
|
||||
|
||||
test("returns feed items from multiple calendars", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
"/cal/personal": [
|
||||
{
|
||||
@@ -115,7 +134,9 @@ describe("CalDavSource", () => {
|
||||
],
|
||||
objects,
|
||||
)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
@@ -133,7 +154,7 @@ describe("CalDavSource", () => {
|
||||
})
|
||||
|
||||
test("skips objects with non-string data", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||
{ url: "/cal/work/bad.ics", data: 12345 },
|
||||
@@ -141,7 +162,9 @@ describe("CalDavSource", () => {
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toHaveLength(1)
|
||||
@@ -149,26 +172,30 @@ describe("CalDavSource", () => {
|
||||
})
|
||||
|
||||
test("uses context time as feed item timestamp", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const now = new Date("2026-01-15T12:00:00Z")
|
||||
const items = await source.fetchItems(createContext(now))
|
||||
expect(items[0]!.timestamp).toEqual(now)
|
||||
})
|
||||
|
||||
test("assigns signals based on event proximity", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
test("assigns priority based on event proximity", async () => {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
// 2 hours before the event at 14:00
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
@@ -176,14 +203,12 @@ describe("CalDavSource", () => {
|
||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
||||
|
||||
expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours
|
||||
expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming)
|
||||
expect(holiday!.signals!.urgency).toBe(0.3) // all-day
|
||||
expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
expect(standup!.priority).toBe(0.7) // within 2 hours
|
||||
expect(holiday!.priority).toBe(0.3) // all-day
|
||||
})
|
||||
|
||||
test("handles calendar with non-string displayName", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/weird": [
|
||||
{
|
||||
url: "/cal/weird/event1.ics",
|
||||
@@ -195,14 +220,16 @@ describe("CalDavSource", () => {
|
||||
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
|
||||
objects,
|
||||
)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items[0]!.data.calendarName).toBeNull()
|
||||
})
|
||||
|
||||
test("handles recurring events with exceptions", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{
|
||||
url: "/cal/work/recurring.ics",
|
||||
@@ -211,7 +238,9 @@ describe("CalDavSource", () => {
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||
|
||||
@@ -229,11 +258,13 @@ describe("CalDavSource", () => {
|
||||
})
|
||||
|
||||
test("caches events within the same refresh cycle", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const context = createContext(new Date("2026-01-15T12:00:00Z"))
|
||||
|
||||
@@ -244,51 +275,14 @@ describe("CalDavSource", () => {
|
||||
expect(client.fetchCalendarsCallCount).toBe(1)
|
||||
})
|
||||
|
||||
test("uses timezone for time range when provided", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
|
||||
// 2026-01-15T22:00:00Z = 2026-01-16T09:00:00 in Australia/Sydney (AEDT, UTC+11)
|
||||
const source = new CalDavSource({
|
||||
serverUrl: "https://caldav.example.com",
|
||||
authMethod: "basic",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
davClient: client,
|
||||
timeZone: "Australia/Sydney",
|
||||
})
|
||||
|
||||
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
|
||||
|
||||
// "Today" in Sydney is Jan 16, so start should be Jan 15 13:00 UTC (midnight Jan 16 AEDT)
|
||||
expect(client.lastTimeRange).not.toBeNull()
|
||||
expect(client.lastTimeRange!.start).toBe("2026-01-15T13:00:00.000Z")
|
||||
// End should be Jan 16 13:00 UTC (midnight Jan 17 AEDT) — 1 day window
|
||||
expect(client.lastTimeRange!.end).toBe("2026-01-16T13:00:00.000Z")
|
||||
})
|
||||
|
||||
test("defaults to UTC midnight when no timezone provided", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
|
||||
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
|
||||
|
||||
expect(client.lastTimeRange).not.toBeNull()
|
||||
expect(client.lastTimeRange!.start).toBe("2026-01-15T00:00:00.000Z")
|
||||
expect(client.lastTimeRange!.end).toBe("2026-01-16T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("refetches events for a different context time", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
|
||||
@@ -298,12 +292,11 @@ describe("CalDavSource", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("CalDavSource.fetchContext", () => {
|
||||
test("returns empty context when no calendars exist", async () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = createSource(client)
|
||||
describe("CalendarSource.fetchContext", () => {
|
||||
test("returns empty context when credentials are null", async () => {
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar).toBeDefined()
|
||||
expect(calendar!.inProgress).toEqual([])
|
||||
@@ -313,30 +306,34 @@ describe("CalDavSource.fetchContext", () => {
|
||||
})
|
||||
|
||||
test("identifies in-progress events", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
// 14:30 is during the 14:00-15:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(1)
|
||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||
})
|
||||
|
||||
test("identifies next upcoming event", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
// 12:00 is before the 14:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).not.toBeNull()
|
||||
@@ -344,14 +341,16 @@ describe("CalDavSource.fetchContext", () => {
|
||||
})
|
||||
|
||||
test("excludes all-day events from inProgress and nextEvent", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).toBeNull()
|
||||
@@ -360,27 +359,29 @@ describe("CalDavSource.fetchContext", () => {
|
||||
})
|
||||
|
||||
test("counts all events including all-day in todayEventCount", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
const objects: Record<string, CalendarDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.todayEventCount).toBe(2)
|
||||
expect(calendar!.hasTodayEvents).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeSignals", () => {
|
||||
describe("computePriority", () => {
|
||||
const now = new Date("2026-01-15T12:00:00Z")
|
||||
|
||||
function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData {
|
||||
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
|
||||
return {
|
||||
uid: "test-uid",
|
||||
title: "Test",
|
||||
@@ -400,108 +401,73 @@ describe("computeSignals", () => {
|
||||
}
|
||||
}
|
||||
|
||||
test("all-day events get urgency 0.3 and ambient relevance", () => {
|
||||
test("all-day events get priority 0.3", () => {
|
||||
const event = makeEvent({ isAllDay: true })
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.3)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
expect(computePriority(event, now)).toBe(0.3)
|
||||
})
|
||||
|
||||
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => {
|
||||
test("events starting within 30 minutes get priority 0.9", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:20:00Z"),
|
||||
})
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.9)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||
expect(computePriority(event, now)).toBe(0.9)
|
||||
})
|
||||
|
||||
test("events starting exactly at 30 minutes get urgency 0.9", () => {
|
||||
test("events starting exactly at 30 minutes get priority 0.9", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:30:00Z"),
|
||||
})
|
||||
expect(computeSignals(event, now).urgency).toBe(0.9)
|
||||
expect(computePriority(event, now)).toBe(0.9)
|
||||
})
|
||||
|
||||
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => {
|
||||
test("events starting within 2 hours get priority 0.7", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T13:00:00Z"),
|
||||
})
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.7)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
|
||||
expect(computePriority(event, now)).toBe(0.7)
|
||||
})
|
||||
|
||||
test("events later today get urgency 0.5", () => {
|
||||
test("events later today get priority 0.5", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T20:00:00Z"),
|
||||
})
|
||||
expect(computeSignals(event, now).urgency).toBe(0.5)
|
||||
expect(computePriority(event, now)).toBe(0.5)
|
||||
})
|
||||
|
||||
test("in-progress events get urgency 0.8 and imminent relevance", () => {
|
||||
test("in-progress events get priority 0.8", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T11:00:00Z"),
|
||||
endDate: new Date("2026-01-15T13:00:00Z"),
|
||||
})
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.8)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||
expect(computePriority(event, now)).toBe(0.8)
|
||||
})
|
||||
|
||||
test("fully past events get urgency 0.2 and ambient relevance", () => {
|
||||
test("fully past events get priority 0.2", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T09:00:00Z"),
|
||||
endDate: new Date("2026-01-15T10:00:00Z"),
|
||||
})
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.2)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
expect(computePriority(event, now)).toBe(0.2)
|
||||
})
|
||||
|
||||
test("events on future days get urgency 0.2", () => {
|
||||
test("events on future days get priority 0.2", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-16T10:00:00Z"),
|
||||
})
|
||||
expect(computeSignals(event, now).urgency).toBe(0.2)
|
||||
expect(computePriority(event, now)).toBe(0.2)
|
||||
})
|
||||
|
||||
test("urgency boundaries are correct", () => {
|
||||
test("priority boundaries are correct", () => {
|
||||
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
|
||||
const event31min = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:31:00Z"),
|
||||
})
|
||||
expect(computeSignals(event31min, now).urgency).toBe(0.7)
|
||||
expect(computePriority(event31min, now)).toBe(0.7)
|
||||
|
||||
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
|
||||
const event2h1m = makeEvent({
|
||||
startDate: new Date("2026-01-15T14:01:00Z"),
|
||||
})
|
||||
expect(computeSignals(event2h1m, now).urgency).toBe(0.5)
|
||||
})
|
||||
|
||||
test("cancelled events get urgency 0.1 regardless of timing", () => {
|
||||
const event = makeEvent({
|
||||
status: "cancelled",
|
||||
startDate: new Date("2026-01-15T12:20:00Z"), // would be 0.9 if not cancelled
|
||||
})
|
||||
const signals = computeSignals(event, now)
|
||||
expect(signals.urgency).toBe(0.1)
|
||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
})
|
||||
|
||||
test("uses timezone for 'later today' boundary", () => {
|
||||
// now = 2026-01-15T12:00:00Z = 2026-01-15T21:00:00 JST (UTC+9)
|
||||
// event at 2026-01-15T15:30:00Z = 2026-01-16T00:30:00 JST — next day in JST
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T15:30:00Z"),
|
||||
})
|
||||
|
||||
// Without timezone: UTC day ends at 2026-01-16T00:00:00Z, event is before that → "later today"
|
||||
expect(computeSignals(event, now).urgency).toBe(0.5)
|
||||
|
||||
// With Asia/Tokyo: local day ends at 2026-01-15T15:00:00Z (midnight Jan 16 JST),
|
||||
// event is after that → "future days"
|
||||
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
||||
expect(computePriority(event2h1m, now)).toBe(0.5)
|
||||
})
|
||||
})
|
||||
251
packages/aris-source-apple-calendar/src/calendar-source.ts
Normal file
251
packages/aris-source-apple-calendar/src/calendar-source.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
import { UnknownActionError } from "@aris/core"
|
||||
|
||||
import { DAVClient } from "tsdav"
|
||||
|
||||
import type {
|
||||
CalendarCredentialProvider,
|
||||
CalendarCredentials,
|
||||
CalendarDAVClient,
|
||||
CalendarEventData,
|
||||
CalendarFeedItem,
|
||||
} from "./types.ts"
|
||||
|
||||
export interface CalendarSourceOptions {
|
||||
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
|
||||
lookAheadDays?: number
|
||||
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
|
||||
davClient?: CalendarDAVClient
|
||||
}
|
||||
|
||||
import { CalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
import { parseICalEvents } from "./ical-parser.ts"
|
||||
|
||||
const ICLOUD_CALDAV_URL = "https://caldav.icloud.com"
|
||||
const DEFAULT_LOOK_AHEAD_DAYS = 0
|
||||
|
||||
/**
|
||||
* A FeedSource that fetches Apple Calendar events via CalDAV.
|
||||
*
|
||||
* Credentials are provided by an injected CalendarCredentialProvider.
|
||||
* The server is responsible for managing OAuth tokens and storage.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const source = new CalendarSource(credentialProvider, "user-123")
|
||||
* const engine = new FeedEngine()
|
||||
* engine.register(source)
|
||||
* ```
|
||||
*/
|
||||
export class CalendarSource implements FeedSource<CalendarFeedItem> {
|
||||
readonly id = "aris.apple-calendar"
|
||||
|
||||
private readonly credentialProvider: CalendarCredentialProvider
|
||||
private readonly userId: string
|
||||
private readonly lookAheadDays: number
|
||||
private readonly injectedClient: CalendarDAVClient | null
|
||||
private davClient: CalendarDAVClient | null = null
|
||||
private lastAccessToken: string | null = null
|
||||
private cachedEvents: { time: Date; events: CalendarEventData[] } | null = null
|
||||
|
||||
constructor(
|
||||
credentialProvider: CalendarCredentialProvider,
|
||||
userId: string,
|
||||
options?: CalendarSourceOptions,
|
||||
) {
|
||||
this.credentialProvider = credentialProvider
|
||||
this.userId = userId
|
||||
this.lookAheadDays = options?.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
|
||||
this.injectedClient = options?.davClient ?? null
|
||||
}
|
||||
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
}
|
||||
|
||||
async executeAction(actionId: string): Promise<void> {
|
||||
throw new UnknownActionError(actionId)
|
||||
}
|
||||
|
||||
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||
const events = await this.fetchEvents(context)
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
[CalendarKey]: {
|
||||
inProgress: [],
|
||||
nextEvent: null,
|
||||
hasTodayEvents: false,
|
||||
todayEventCount: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const now = context.time
|
||||
const inProgress = events.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
|
||||
|
||||
const upcoming = events
|
||||
.filter((e) => !e.isAllDay && e.startDate > now)
|
||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||
|
||||
const calendarContext: CalendarContext = {
|
||||
inProgress,
|
||||
nextEvent: upcoming[0] ?? null,
|
||||
hasTodayEvents: events.length > 0,
|
||||
todayEventCount: events.length,
|
||||
}
|
||||
|
||||
return { [CalendarKey]: calendarContext }
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||
const now = context.time
|
||||
const events = await this.fetchEvents(context)
|
||||
return events.map((event) => createFeedItem(event, now))
|
||||
}
|
||||
|
||||
private async fetchEvents(context: Context): Promise<CalendarEventData[]> {
|
||||
if (this.cachedEvents && this.cachedEvents.time === context.time) {
|
||||
return this.cachedEvents.events
|
||||
}
|
||||
|
||||
const credentials = await this.credentialProvider.fetchCredentials(this.userId)
|
||||
if (!credentials) {
|
||||
return []
|
||||
}
|
||||
|
||||
const client = await this.connectClient(credentials)
|
||||
const calendars = await client.fetchCalendars()
|
||||
|
||||
const { start, end } = computeTimeRange(context.time, this.lookAheadDays)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
calendars.map(async (calendar) => {
|
||||
const objects = await client.fetchCalendarObjects({
|
||||
calendar,
|
||||
timeRange: {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
},
|
||||
})
|
||||
// tsdav types displayName as string | Record<string, unknown> | undefined
|
||||
// because the XML parser can return an object for some responses
|
||||
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
|
||||
return { objects, calendarName }
|
||||
}),
|
||||
)
|
||||
|
||||
const allEvents: CalendarEventData[] = []
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue
|
||||
const { objects, calendarName } = result.value
|
||||
for (const obj of objects) {
|
||||
if (typeof obj.data !== "string") continue
|
||||
|
||||
const events = parseICalEvents(obj.data, calendarName)
|
||||
for (const event of events) {
|
||||
allEvents.push(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedEvents = { time: context.time, events: allEvents }
|
||||
return allEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a ready-to-use DAVClient. Creates and logs in a new client
|
||||
* on first call; reuses the existing one on subsequent calls, updating
|
||||
* credentials if the access token has changed.
|
||||
*/
|
||||
private async connectClient(credentials: CalendarCredentials): Promise<CalendarDAVClient> {
|
||||
if (this.injectedClient) {
|
||||
return this.injectedClient
|
||||
}
|
||||
|
||||
const davCredentials = {
|
||||
tokenUrl: credentials.tokenUrl,
|
||||
refreshToken: credentials.refreshToken,
|
||||
accessToken: credentials.accessToken,
|
||||
expiration: credentials.expiresAt,
|
||||
clientId: credentials.clientId,
|
||||
clientSecret: credentials.clientSecret,
|
||||
}
|
||||
|
||||
if (!this.davClient) {
|
||||
this.davClient = new DAVClient({
|
||||
serverUrl: ICLOUD_CALDAV_URL,
|
||||
credentials: davCredentials,
|
||||
authMethod: "Oauth",
|
||||
defaultAccountType: "caldav",
|
||||
})
|
||||
await this.davClient.login()
|
||||
this.lastAccessToken = credentials.accessToken
|
||||
return this.davClient
|
||||
}
|
||||
|
||||
if (credentials.accessToken !== this.lastAccessToken) {
|
||||
this.davClient.credentials = davCredentials
|
||||
this.lastAccessToken = credentials.accessToken
|
||||
}
|
||||
|
||||
return this.davClient
|
||||
}
|
||||
}
|
||||
|
||||
function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end: Date } {
|
||||
const start = new Date(now)
|
||||
start.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const end = new Date(start)
|
||||
end.setUTCDate(end.getUTCDate() + 1 + lookAheadDays)
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function computePriority(event: CalendarEventData, now: Date): number {
|
||||
if (event.isAllDay) {
|
||||
return 0.3
|
||||
}
|
||||
|
||||
const msUntilStart = event.startDate.getTime() - now.getTime()
|
||||
|
||||
// Event already started
|
||||
if (msUntilStart < 0) {
|
||||
const isInProgress = now.getTime() < event.endDate.getTime()
|
||||
// Currently happening events are high priority; fully past events are low
|
||||
return isInProgress ? 0.8 : 0.2
|
||||
}
|
||||
|
||||
// Starting within 30 minutes
|
||||
if (msUntilStart <= 30 * 60 * 1000) {
|
||||
return 0.9
|
||||
}
|
||||
|
||||
// Starting within 2 hours
|
||||
if (msUntilStart <= 2 * 60 * 60 * 1000) {
|
||||
return 0.7
|
||||
}
|
||||
|
||||
// Later today (within 24 hours from start of day)
|
||||
const startOfDay = new Date(now)
|
||||
startOfDay.setUTCHours(0, 0, 0, 0)
|
||||
const endOfDay = new Date(startOfDay)
|
||||
endOfDay.setUTCDate(endOfDay.getUTCDate() + 1)
|
||||
|
||||
if (event.startDate.getTime() < endOfDay.getTime()) {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// Future days
|
||||
return 0.2
|
||||
}
|
||||
|
||||
function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem {
|
||||
return {
|
||||
id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||
type: "calendar-event",
|
||||
priority: computePriority(event, now),
|
||||
timestamp: now,
|
||||
data: event,
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,20 @@ import ICAL from "ical.js"
|
||||
import {
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
CalDavEventStatus,
|
||||
type CalDavAlarm,
|
||||
type CalDavAttendee,
|
||||
type CalDavEventData,
|
||||
CalendarEventStatus,
|
||||
type CalendarAlarm,
|
||||
type CalendarAttendee,
|
||||
type CalendarEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
/**
|
||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||
* into CalDavEventData objects.
|
||||
* into CalendarEventData objects.
|
||||
*
|
||||
* @param icsData - Raw iCalendar string from a CalDAV response
|
||||
* @param calendarName - Display name of the calendar this event belongs to
|
||||
*/
|
||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
|
||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalendarEventData[] {
|
||||
const jcal = ICAL.parse(icsData)
|
||||
const comp = new ICAL.Component(jcal)
|
||||
const vevents = comp.getAllSubcomponents("vevent")
|
||||
@@ -29,7 +29,7 @@ export function parseICalEvents(icsData: string, calendarName: string | null): C
|
||||
function parseVEvent(
|
||||
vevent: InstanceType<typeof ICAL.Component>,
|
||||
calendarName: string | null,
|
||||
): CalDavEventData {
|
||||
): CalendarEventData {
|
||||
const event = new ICAL.Event(vevent)
|
||||
|
||||
return {
|
||||
@@ -50,15 +50,15 @@ function parseVEvent(
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatus(raw: string | null): CalDavEventStatus | null {
|
||||
function parseStatus(raw: string | null): CalendarEventStatus | null {
|
||||
if (!raw) return null
|
||||
switch (raw.toLowerCase()) {
|
||||
case "confirmed":
|
||||
return CalDavEventStatus.Confirmed
|
||||
return CalendarEventStatus.Confirmed
|
||||
case "tentative":
|
||||
return CalDavEventStatus.Tentative
|
||||
return CalendarEventStatus.Tentative
|
||||
case "cancelled":
|
||||
return CalDavEventStatus.Cancelled
|
||||
return CalendarEventStatus.Cancelled
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -81,25 +81,22 @@ function parseOrganizer(
|
||||
return value.replace(/^mailto:/i, "")
|
||||
}
|
||||
|
||||
function parseAttendees(properties: unknown[]): CalDavAttendee[] {
|
||||
function parseAttendees(properties: unknown[]): CalendarAttendee[] {
|
||||
if (properties.length === 0) return []
|
||||
|
||||
return properties.flatMap((prop) => {
|
||||
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
|
||||
return properties.map((prop) => {
|
||||
const p = prop as InstanceType<typeof ICAL.Property>
|
||||
const value = asStringOrNull(p.getFirstValue())
|
||||
const cn = asStringOrNull(p.getParameter("cn"))
|
||||
const role = asStringOrNull(p.getParameter("role"))
|
||||
const partstat = asStringOrNull(p.getParameter("partstat"))
|
||||
|
||||
return [
|
||||
{
|
||||
name: cn,
|
||||
email: value ? value.replace(/^mailto:/i, "") : null,
|
||||
role: parseAttendeeRole(role),
|
||||
status: parseAttendeeStatus(partstat),
|
||||
},
|
||||
]
|
||||
return {
|
||||
name: cn,
|
||||
email: value ? value.replace(/^mailto:/i, "") : null,
|
||||
role: parseAttendeeRole(role),
|
||||
status: parseAttendeeStatus(partstat),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +130,7 @@ function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
|
||||
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {
|
||||
const valarms = vevent.getAllSubcomponents("valarm")
|
||||
if (!valarms || valarms.length === 0) return []
|
||||
|
||||
16
packages/aris-source-apple-calendar/src/index.ts
Normal file
16
packages/aris-source-apple-calendar/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts"
|
||||
export {
|
||||
CalendarEventStatus,
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
type CalendarCredentials,
|
||||
type CalendarCredentialProvider,
|
||||
type CalendarDAVClient,
|
||||
type CalendarDAVCalendar,
|
||||
type CalendarDAVObject,
|
||||
type CalendarAttendee,
|
||||
type CalendarAlarm,
|
||||
type CalendarEventData,
|
||||
type CalendarFeedItem,
|
||||
} from "./types.ts"
|
||||
@@ -1,16 +1,30 @@
|
||||
import type { FeedItem } from "@aris/core"
|
||||
|
||||
// -- Event status --
|
||||
// -- Credential provider --
|
||||
|
||||
export const CalDavEventStatus = {
|
||||
export interface CalendarCredentials {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
/** Unix timestamp in milliseconds when the access token expires */
|
||||
expiresAt: number
|
||||
tokenUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
export interface CalendarCredentialProvider {
|
||||
fetchCredentials(userId: string): Promise<CalendarCredentials | null>
|
||||
}
|
||||
|
||||
// -- Feed item types --
|
||||
|
||||
export const CalendarEventStatus = {
|
||||
Confirmed: "confirmed",
|
||||
Tentative: "tentative",
|
||||
Cancelled: "cancelled",
|
||||
} as const
|
||||
|
||||
export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus]
|
||||
|
||||
// -- Attendee types --
|
||||
export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus]
|
||||
|
||||
export const AttendeeRole = {
|
||||
Chair: "chair",
|
||||
@@ -29,25 +43,21 @@ export const AttendeeStatus = {
|
||||
|
||||
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
||||
|
||||
export interface CalDavAttendee {
|
||||
export interface CalendarAttendee {
|
||||
name: string | null
|
||||
email: string | null
|
||||
role: AttendeeRole | null
|
||||
status: AttendeeStatus | null
|
||||
}
|
||||
|
||||
// -- Alarm --
|
||||
|
||||
export interface CalDavAlarm {
|
||||
export interface CalendarAlarm {
|
||||
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
||||
trigger: string
|
||||
/** e.g. "DISPLAY", "AUDIO" */
|
||||
action: string
|
||||
}
|
||||
|
||||
// -- Event data --
|
||||
|
||||
export interface CalDavEventData extends Record<string, unknown> {
|
||||
export interface CalendarEventData extends Record<string, unknown> {
|
||||
uid: string
|
||||
title: string
|
||||
startDate: Date
|
||||
@@ -56,38 +66,36 @@ export interface CalDavEventData extends Record<string, unknown> {
|
||||
location: string | null
|
||||
description: string | null
|
||||
calendarName: string | null
|
||||
status: CalDavEventStatus | null
|
||||
status: CalendarEventStatus | null
|
||||
url: string | null
|
||||
organizer: string | null
|
||||
attendees: CalDavAttendee[]
|
||||
alarms: CalDavAlarm[]
|
||||
attendees: CalendarAttendee[]
|
||||
alarms: CalendarAlarm[]
|
||||
recurrenceId: string | null
|
||||
}
|
||||
|
||||
// -- Feed item --
|
||||
|
||||
export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
|
||||
export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData>
|
||||
|
||||
// -- DAV client interface --
|
||||
|
||||
export interface CalDavDAVObject {
|
||||
export interface CalendarDAVObject {
|
||||
data?: unknown
|
||||
etag?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface CalDavDAVCalendar {
|
||||
export interface CalendarDAVCalendar {
|
||||
displayName?: string | Record<string, unknown>
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Subset of tsdav's DAVClient used by CalDavSource. */
|
||||
export interface CalDavDAVClient {
|
||||
/** Subset of DAVClient used by CalendarSource. */
|
||||
export interface CalendarDAVClient {
|
||||
login(): Promise<void>
|
||||
fetchCalendars(): Promise<CalDavDAVCalendar[]>
|
||||
fetchCalendars(): Promise<CalendarDAVCalendar[]>
|
||||
fetchCalendarObjects(params: {
|
||||
calendar: CalDavDAVCalendar
|
||||
calendar: CalendarDAVCalendar
|
||||
timeRange: { start: string; end: string }
|
||||
}): Promise<CalDavDAVObject[]>
|
||||
}): Promise<CalendarDAVObject[]>
|
||||
credentials: Record<string, unknown>
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# @aris/source-caldav
|
||||
|
||||
A FeedSource that fetches calendar events from any CalDAV server.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { CalDavSource } from "@aris/source-caldav"
|
||||
|
||||
// Basic auth (Nextcloud, Radicale, Baikal, iCloud, etc.)
|
||||
const source = new CalDavSource({
|
||||
serverUrl: "https://caldav.example.com",
|
||||
authMethod: "basic",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
lookAheadDays: 7, // optional, default: 0 (today only)
|
||||
timeZone: "America/New_York", // optional, default: UTC
|
||||
})
|
||||
|
||||
// OAuth
|
||||
const source = new CalDavSource({
|
||||
serverUrl: "https://caldav.provider.com",
|
||||
authMethod: "oauth",
|
||||
accessToken: "...",
|
||||
refreshToken: "...",
|
||||
tokenUrl: "https://provider.com/oauth/token",
|
||||
})
|
||||
```
|
||||
|
||||
### iCloud
|
||||
|
||||
Use your Apple ID email as the username and an [app-specific password](https://support.apple.com/en-us/102654):
|
||||
|
||||
```ts
|
||||
const source = new CalDavSource({
|
||||
serverUrl: "https://caldav.icloud.com",
|
||||
authMethod: "basic",
|
||||
username: "you@icloud.com",
|
||||
password: "<app-specific-password>",
|
||||
})
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
### Live test
|
||||
|
||||
`bun run test:live` connects to a real CalDAV server and prints all events to the console. It prompts for:
|
||||
|
||||
- **CalDAV server URL** — e.g. `https://caldav.icloud.com`
|
||||
- **Username** — your account email
|
||||
- **Password** — your password (or app-specific password for iCloud)
|
||||
- **Look-ahead days** — how many days beyond today to fetch (default: 0)
|
||||
|
||||
The script runs both `fetchContext` and `fetchItems`, printing the calendar context (in-progress events, next event, today's count) followed by each event with its title, time, location, signals, and attendees.
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Live test script for CalDavSource.
|
||||
*
|
||||
* Usage:
|
||||
* bun run test-live.ts
|
||||
*/
|
||||
|
||||
import { CalDavSource } from "../src/index.ts"
|
||||
|
||||
const serverUrl = prompt("CalDAV server URL:")
|
||||
const username = prompt("Username:")
|
||||
const password = prompt("Password:")
|
||||
const lookAheadRaw = prompt("Look-ahead days (default 0):")
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
console.error("Server URL, username, and password are required.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const lookAheadDays = Number(lookAheadRaw) || 0
|
||||
|
||||
const source = new CalDavSource({
|
||||
serverUrl,
|
||||
authMethod: "basic",
|
||||
username,
|
||||
password,
|
||||
lookAheadDays,
|
||||
})
|
||||
|
||||
const context = { time: new Date() }
|
||||
|
||||
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
|
||||
|
||||
const contextResult = await source.fetchContext(context)
|
||||
const items = await source.fetchItems(context)
|
||||
|
||||
console.log("=== Context ===")
|
||||
console.log(JSON.stringify(contextResult, null, 2))
|
||||
|
||||
console.log(`\n=== Feed Items (${items.length}) ===`)
|
||||
for (const item of items) {
|
||||
console.log(`\n--- ${item.data.title} ---`)
|
||||
console.log(` ID: ${item.id}`)
|
||||
console.log(` Calendar: ${item.data.calendarName ?? "(unknown)"}`)
|
||||
console.log(` Start: ${item.data.startDate.toISOString()}`)
|
||||
console.log(` End: ${item.data.endDate.toISOString()}`)
|
||||
console.log(` All-day: ${item.data.isAllDay}`)
|
||||
console.log(` Location: ${item.data.location ?? "(none)"}`)
|
||||
console.log(` Status: ${item.data.status ?? "(none)"}`)
|
||||
console.log(` Urgency: ${item.signals?.urgency}`)
|
||||
console.log(` Relevance: ${item.signals?.timeRelevance}`)
|
||||
if (item.data.attendees.length > 0) {
|
||||
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
|
||||
}
|
||||
if (item.data.description) {
|
||||
console.log(` Desc: ${item.data.description.slice(0, 100)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log("(no events found in the time window)")
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
||||
export { parseICalEvents } from "./ical-parser.ts"
|
||||
export {
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
CalDavEventStatus,
|
||||
type CalDavAlarm,
|
||||
type CalDavAttendee,
|
||||
type CalDavDAVCalendar,
|
||||
type CalDavDAVClient,
|
||||
type CalDavDAVObject,
|
||||
type CalDavEventData,
|
||||
type CalDavFeedItem,
|
||||
} from "./types.ts"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TimeRelevance, contextValue, type Context } from "@aris/core"
|
||||
import { contextValue, type Context } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||
@@ -81,17 +81,16 @@ describe("GoogleCalendarSource", () => {
|
||||
expect(allDayItems.length).toBe(1)
|
||||
})
|
||||
|
||||
test("ongoing events get highest urgency (1.0)", async () => {
|
||||
test("ongoing events get highest priority (1.0)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
||||
expect(ongoing).toBeDefined()
|
||||
expect(ongoing!.signals!.urgency).toBe(1.0)
|
||||
expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||
expect(ongoing!.priority).toBe(1.0)
|
||||
})
|
||||
|
||||
test("upcoming events get higher urgency when sooner", async () => {
|
||||
test("upcoming events get higher priority when sooner", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
@@ -100,17 +99,16 @@ describe("GoogleCalendarSource", () => {
|
||||
|
||||
expect(soon).toBeDefined()
|
||||
expect(later).toBeDefined()
|
||||
expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!)
|
||||
expect(soon!.priority).toBeGreaterThan(later!.priority)
|
||||
})
|
||||
|
||||
test("all-day events get flat urgency (0.4)", async () => {
|
||||
test("all-day events get flat priority (0.4)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
||||
expect(allDay).toBeDefined()
|
||||
expect(allDay!.signals!.urgency).toBe(0.4)
|
||||
expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
expect(allDay!.priority).toBe(0.4)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
@@ -282,7 +280,7 @@ describe("GoogleCalendarSource", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("urgency ordering", () => {
|
||||
describe("priority ordering", () => {
|
||||
test("ongoing > upcoming > all-day", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
@@ -291,8 +289,8 @@ describe("GoogleCalendarSource", () => {
|
||||
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
||||
|
||||
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!)
|
||||
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!)
|
||||
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
|
||||
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { UnknownActionError } from "@aris/core"
|
||||
|
||||
import type {
|
||||
ApiCalendarEvent,
|
||||
@@ -35,10 +35,10 @@ import { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
||||
|
||||
const DEFAULT_LOOKAHEAD_HOURS = 24
|
||||
|
||||
const URGENCY_ONGOING = 1.0
|
||||
const URGENCY_UPCOMING_MAX = 0.9
|
||||
const URGENCY_UPCOMING_MIN = 0.3
|
||||
const URGENCY_ALL_DAY = 0.4
|
||||
const PRIORITY_ONGOING = 1.0
|
||||
const PRIORITY_UPCOMING_MAX = 0.9
|
||||
const PRIORITY_UPCOMING_MIN = 0.3
|
||||
const PRIORITY_ALL_DAY = 0.4
|
||||
|
||||
/**
|
||||
* A FeedSource that provides Google Calendar events and next-event context.
|
||||
@@ -171,13 +171,9 @@ function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventD
|
||||
}
|
||||
}
|
||||
|
||||
function computeSignals(
|
||||
event: CalendarEventData,
|
||||
nowMs: number,
|
||||
lookaheadMs: number,
|
||||
): FeedItemSignals {
|
||||
function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
|
||||
if (event.isAllDay) {
|
||||
return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient }
|
||||
return PRIORITY_ALL_DAY
|
||||
}
|
||||
|
||||
const startMs = event.startTime.getTime()
|
||||
@@ -185,23 +181,17 @@ function computeSignals(
|
||||
|
||||
// Ongoing: start <= now < end
|
||||
if (startMs <= nowMs && nowMs < endMs) {
|
||||
return { urgency: URGENCY_ONGOING, timeRelevance: TimeRelevance.Imminent }
|
||||
return PRIORITY_ONGOING
|
||||
}
|
||||
|
||||
// Upcoming: linear decay from URGENCY_UPCOMING_MAX to URGENCY_UPCOMING_MIN
|
||||
// Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
|
||||
const msUntilStart = startMs - nowMs
|
||||
if (msUntilStart <= 0) {
|
||||
return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient }
|
||||
return PRIORITY_UPCOMING_MIN
|
||||
}
|
||||
|
||||
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
|
||||
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 }
|
||||
return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
|
||||
}
|
||||
|
||||
function createFeedItem(
|
||||
@@ -209,13 +199,14 @@ function createFeedItem(
|
||||
nowMs: number,
|
||||
lookaheadMs: number,
|
||||
): CalendarFeedItem {
|
||||
const priority = computePriority(event, nowMs, lookaheadMs)
|
||||
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
||||
|
||||
return {
|
||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||
type: itemType,
|
||||
priority,
|
||||
timestamp: new Date(nowMs),
|
||||
data: event,
|
||||
signals: computeSignals(event, nowMs, lookaheadMs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,7 @@ describe("TflSource", () => {
|
||||
expect(typeof item.id).toBe("string")
|
||||
expect(item.id).toMatch(/^tfl-alert-/)
|
||||
expect(item.type).toBe("tfl-alert")
|
||||
expect(item.signals).toBeDefined()
|
||||
expect(typeof item.signals!.urgency).toBe("number")
|
||||
expect(typeof item.priority).toBe("number")
|
||||
expect(item.timestamp).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
@@ -221,29 +220,29 @@ describe("TflSource", () => {
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test("feed items are sorted by urgency descending", async () => {
|
||||
test("feed items are sorted by priority descending", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const prev = items[i - 1]!
|
||||
const curr = items[i]!
|
||||
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
|
||||
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
||||
}
|
||||
})
|
||||
|
||||
test("urgency values match severity levels", async () => {
|
||||
test("priority values match severity levels", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const severityUrgency: Record<string, number> = {
|
||||
const severityPriority: Record<string, number> = {
|
||||
closure: 1.0,
|
||||
"major-delays": 0.8,
|
||||
"minor-delays": 0.6,
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
|
||||
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -317,7 +316,9 @@ describe("TflSource", () => {
|
||||
test("executeAction throws on invalid input", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
|
||||
await expect(source.executeAction("set-lines-of-interest", "not-an-array")).rejects.toThrow()
|
||||
await expect(
|
||||
source.executeAction("set-lines-of-interest", "not-an-array"),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("executeAction throws for unknown action", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||
import { UnknownActionError, contextValue } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
import { type } from "arktype"
|
||||
|
||||
@@ -18,18 +18,12 @@ import { TflApi, lineId } from "./tfl-api.ts"
|
||||
|
||||
const setLinesInput = lineId.array()
|
||||
|
||||
const SEVERITY_URGENCY: Record<TflAlertSeverity, number> = {
|
||||
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||
closure: 1.0,
|
||||
"major-delays": 0.8,
|
||||
"minor-delays": 0.6,
|
||||
}
|
||||
|
||||
const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
|
||||
closure: TimeRelevance.Imminent,
|
||||
"major-delays": TimeRelevance.Imminent,
|
||||
"minor-delays": TimeRelevance.Upcoming,
|
||||
}
|
||||
|
||||
/**
|
||||
* A FeedSource that provides TfL (Transport for London) service alerts.
|
||||
*
|
||||
@@ -143,26 +137,19 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
closestStationDistance,
|
||||
}
|
||||
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: SEVERITY_URGENCY[status.severity],
|
||||
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
|
||||
}
|
||||
|
||||
return {
|
||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||
type: "tfl-alert",
|
||||
priority: SEVERITY_PRIORITY[status.severity],
|
||||
timestamp: context.time,
|
||||
data,
|
||||
signals,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by urgency (desc), then by proximity (asc) if location available
|
||||
// Sort by severity (desc), then by proximity (asc) if location available
|
||||
items.sort((a, b) => {
|
||||
const aUrgency = a.signals?.urgency ?? 0
|
||||
const bUrgency = b.signals?.urgency ?? 0
|
||||
if (bUrgency !== aUrgency) {
|
||||
return bUrgency - aUrgency
|
||||
if (b.priority !== a.priority) {
|
||||
return b.priority - a.priority
|
||||
}
|
||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||
|
||||
@@ -145,22 +145,20 @@ describe("WeatherSource", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("assigns signals based on weather conditions", async () => {
|
||||
test("assigns priority based on weather conditions", async () => {
|
||||
const source = new WeatherSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await source.fetchItems(context)
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.signals).toBeDefined()
|
||||
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
||||
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
||||
expect(item.signals!.timeRelevance).toBeDefined()
|
||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
||||
expect(item.priority).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||
expect(currentItem).toBeDefined()
|
||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||
import { UnknownActionError, contextValue } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
|
||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||
@@ -38,7 +38,7 @@ export interface WeatherSourceOptions {
|
||||
const DEFAULT_HOURLY_LIMIT = 12
|
||||
const DEFAULT_DAILY_LIMIT = 7
|
||||
|
||||
const BASE_URGENCY = {
|
||||
const BASE_PRIORITY = {
|
||||
current: 0.5,
|
||||
hourly: 0.3,
|
||||
daily: 0.2,
|
||||
@@ -199,17 +199,17 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, baseUrgency + 0.3)
|
||||
return Math.min(1, basePriority + 0.3)
|
||||
}
|
||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||
return Math.min(1, baseUrgency + 0.15)
|
||||
return Math.min(1, basePriority + 0.15)
|
||||
}
|
||||
return baseUrgency
|
||||
return basePriority
|
||||
}
|
||||
|
||||
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||
switch (severity) {
|
||||
case "extreme":
|
||||
return 1
|
||||
@@ -218,29 +218,7 @@ function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||
case "moderate":
|
||||
return 0.75
|
||||
case "minor":
|
||||
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
|
||||
return BASE_PRIORITY.alert
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,14 +262,12 @@ function createCurrentWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): WeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||
}
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-current-${timestamp.getTime()}`,
|
||||
type: WeatherFeedItemType.current,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
conditionCode: current.conditionCode,
|
||||
@@ -308,7 +284,6 @@ function createCurrentWeatherFeedItem(
|
||||
windGust: convertSpeed(current.windGust, units),
|
||||
windSpeed: convertSpeed(current.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,14 +293,12 @@ function createHourlyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): WeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||
}
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.hourly,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastTime: new Date(hourly.forecastStart),
|
||||
@@ -342,7 +315,6 @@ function createHourlyWeatherFeedItem(
|
||||
windGust: convertSpeed(hourly.windGust, units),
|
||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,14 +324,12 @@ function createDailyWeatherFeedItem(
|
||||
timestamp: Date,
|
||||
units: Units,
|
||||
): WeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||
}
|
||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
||||
|
||||
return {
|
||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||
type: WeatherFeedItemType.daily,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
forecastDate: new Date(daily.forecastStart),
|
||||
@@ -374,19 +344,16 @@ function createDailyWeatherFeedItem(
|
||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
|
||||
const signals: FeedItemSignals = {
|
||||
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||
}
|
||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
||||
|
||||
return {
|
||||
id: `weather-alert-${alert.id}`,
|
||||
type: WeatherFeedItemType.alert,
|
||||
priority,
|
||||
timestamp,
|
||||
data: {
|
||||
alertId: alert.id,
|
||||
@@ -400,6 +367,5 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
||||
source: alert.source,
|
||||
urgency: alert.urgency,
|
||||
},
|
||||
signals,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user