Compare commits

..

4 Commits

Author SHA1 Message Date
b68331904f fix: use TimeRelevance enum in all tests
Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:02:04 +00:00
bbefd01fe0 feat: replace FeedItem.priority with signals
Remove priority field from FeedItem and engine-level sorting.
Add FeedItemSignals with urgency and timeRelevance fields.
Update all source packages to emit signals instead of priority.

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

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

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

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

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:48:05 +00:00
19 changed files with 366 additions and 187 deletions

View File

@@ -16,8 +16,8 @@ Examples of feed items:
## Design Principles
1. **Extensibility**: The core must support different data sources, including third-party sources.
2. **Separation of concerns**: Core handles data only. UI rendering is a separate system.
3. **Parallel execution**: Sources run in parallel; no inter-source dependencies.
2. **Separation of concerns**: Core handles data and UI description. The client is a thin renderer.
3. **Dependency graph**: Sources declare dependencies on other sources. The engine resolves the graph and runs independent sources in parallel.
4. **Graceful degradation**: Failed sources are skipped; partial results are returned.
## Architecture
@@ -25,26 +25,28 @@ Examples of feed items:
```
┌─────────────────────────────────────────────────────────────┐
│ Backend │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐
│ │ aris-core │ │ Sources │ UI Registry │
│ │ │ │ (plugins) │ (schemas from │
│ │ - Reconciler│◄───│ - Calendar │ third parties)│
│ │ - Context │ │ - Weather │
│ │ - FeedItem │ │ - Spotify
└─────────────┘ └─────────────┘ └─────────────────┘
Feed (data only) UI Schemas (JSON)
│ ┌─────────────┐ ┌─────────────┐
│ │ aris-core │ │ Sources │
│ │ │ │ (plugins) │
│ │ - FeedEngine│◄───│ - Calendar │
│ │ - Context │ │ - Weather │
│ │ - FeedItem │ │ - TfL
│ - Actions │ │ - Spotify │
└─────────────┘ └─────────────┘
│ Feed items (data + ui trees + slots) │
└─────────────────────────────────────────────────────────────┘
(WebSocket / JSON-RPC)
┌─────────────────────────────────────────────────────────────┐
Frontend
Client (React Native)
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Renderer │ │
│ │ - Receives feed items │ │
│ │ - Fetches UI schema by item type │ │
│ │ - Renders using json-render or similar │ │
│ │ json-render + twrnc component map │ │
│ │ - Receives feed items with ui trees │ │
│ │ - Renders using registered RN components + twrnc │ │
│ │ - User interactions trigger source actions │ │
│ │ - Bespoke native components for rich interactions │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
@@ -54,15 +56,16 @@ Examples of feed items:
The core is responsible for:
- Defining the context and feed item interfaces
- Providing a reconciler that orchestrates data sources
- Providing a `FeedEngine` that orchestrates sources via a dependency graph
- Returning a flat list of prioritized feed items
- Routing action execution to the correct source
### Key Concepts
- **Context**: Time and location (with accuracy) passed to all sources
- **FeedItem**: Has an ID (source-generated, stable), type, priority, timestamp, and JSON-serializable data
- **DataSource**: Interface that third parties implement to provide feed items
- **Reconciler**: Orchestrates sources, runs them in parallel, returns items and any errors
- **Context**: Time and location (with accuracy) passed to all sources. Sources can contribute to context (e.g., location source provides coordinates, weather source provides conditions).
- **FeedItem**: Has an ID (source-generated, stable), type, timestamp, JSON-serializable data, optional actions, an optional `ui` tree, and optional `slots` for LLM-fillable content.
- **FeedSource**: Interface that first and third parties implement to provide context, feed items, and actions. Uses reverse-domain IDs (e.g., `aris.weather`, `com.spotify`).
- **FeedEngine**: Orchestrates sources respecting their dependency graph, runs independent sources in parallel, returns items and any errors. Routes action execution to the correct source.
## Data Sources
@@ -71,10 +74,13 @@ Key decisions:
- Sources receive the full context and decide internally what to use
- Each source returns a single item type (e.g., separate "Calendar Source" and "Location Suggestion Source" rather than a combined "Google Source")
- Sources live in separate packages, not in the core
- Sources declare dependencies on other sources (e.g., weather depends on location)
- Sources are responsible for:
- Transforming their domain data into feed items
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
- Returning empty arrays when nothing is relevant
- Providing a `ui` tree for each feed item
- Declaring and handling actions (e.g., RSVP, complete task, play/pause)
### Configuration
@@ -83,26 +89,39 @@ Configuration is passed at source registration time, not per reconcile call. Sou
## Feed Output
- Flat list of `FeedItem` objects
- No UI information (no icons, card types, etc.)
- Items carry data, an optional `ui` field describing their layout, and optional `slots` for LLM enhancement
- Items are a discriminated union by `type` field
- Reconciler sorts by priority; can act as tiebreaker
## UI Rendering (Separate from Core)
## UI Rendering: Server-Driven UI
The core does not handle UI. For extensible third-party UI:
The UI for feed items is **server-driven**. Sources describe how their items look using a JSON tree (the `ui` field on `FeedItem`). The client renders these trees using [json-render](https://json-render.dev/) with a registered set of React Native components styled via [twrnc](https://github.com/jaredh159/tailwind-react-native-classnames).
1. Third-party apps register their UI schemas through the backend (UI Registry)
2. Frontend fetches UI schemas from the backend
3. Frontend matches feed items to schemas by `type` and renders accordingly
### How it works
This approach:
1. Sources return feed items with a `ui` field — a JSON tree describing the card layout using Tailwind class strings.
2. The client passes a component map to json-render. Each component wraps a React Native primitive and resolves `className` via twrnc.
3. json-render walks the tree and renders native components. twrnc parses Tailwind classes at runtime — no build step, arbitrary values work.
4. User interactions (tap, etc.) map to source actions via the `actions` field on `FeedItem`. The client sends action requests to the backend, which routes them to the correct source via `FeedEngine.executeAction()`.
- Keeps the core focused on data
- Works across platforms (web, React Native)
- Avoids the need for third parties to inject code into the app
- Uses a json-render style approach for declarative UI from JSON schemas
### Styling
Reference: https://github.com/vercel-labs/json-render
- Sources use Tailwind CSS class strings via the `className` prop (e.g., `"p-4 bg-white dark:bg-black rounded-xl"`).
- twrnc resolves classes to React Native style objects at runtime. Supports arbitrary values (`mt-[31px]`, `bg-[#eaeaea]`), dark mode (`dark:bg-black`), and platform prefixes (`ios:pt-4 android:pt-2`).
- Custom colors and spacing are configured via `tailwind.config.js` on the client.
- No compile-time constraint — all styles resolve at runtime.
### Two tiers of UI
- **Server-driven (default):** Any source can return a `ui` tree. Covers most cards — weather, tasks, alerts, package tracking, news, etc. Simple interactions go through source actions. This is the default path for both first-party and third-party sources.
- **Bespoke native:** For cards that need rich client interaction (gestures, animations, real-time updates), a native React Native component is registered in the json-render component map and referenced by type. Third parties that need this level of richness work with the ARIS team to get it integrated.
### Why server-driven
- Feed items are inherently server-driven — the data comes from sources on the backend. Attaching the layout alongside the data is a natural extension.
- Card designs can be updated without shipping an app update.
- Third-party sources can ship their own UI without bundling anything new into the app.
Reference: https://json-render.dev/
## Feed Items with UI and Slots
@@ -384,9 +403,9 @@ function mergeEnhancement(
## Open Questions
- 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)
- How third parties authenticate/register their sources
- Exact set of React Native components exposed in the json-render component map
- Validation/sandboxing of third-party ui trees
- How synthetic items define their UI (full json-render tree vs. registered component)
- Should slots support rich content (json-render nodes) in the future, or stay text-only?
- How to handle slot content that references other items (e.g., "your dinner at The Ivy" linking to the calendar card)

View File

@@ -125,7 +125,7 @@ interface FeedSource<TItem extends FeedItem = FeedItem> {
### Changes to FeedItem
One optional field added.
Optional fields added for actions, server-driven UI, and LLM slots.
```typescript
interface FeedItem<
@@ -140,6 +140,12 @@ interface FeedItem<
/** Actions the user can take on this item. */
actions?: readonly ItemAction[]
/** Server-driven UI tree rendered by json-render on the client. */
ui?: JsonRenderNode
/** Named slots for LLM-fillable content. See architecture-draft.md. */
slots?: Record<string, Slot>
}
```
@@ -222,6 +228,25 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
{ actionId: "skip-track" },
{ actionId: "like-track", params: { trackId: track.id } },
],
ui: {
type: "View",
className: "flex-row items-center p-3 gap-3 bg-white dark:bg-black rounded-xl",
children: [
{
type: "Image",
source: { uri: track.albumArt },
className: "w-12 h-12 rounded-lg",
},
{
type: "View",
className: "flex-1",
children: [
{ type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
{ type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
],
},
],
},
},
]
}
@@ -236,6 +261,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
8. `FeedEngine.listActions()` aggregates actions from all sources
9. Existing tests pass unchanged (all changes are additive)

View File

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

View File

@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError, contextKey, contextValue } from "./index"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -100,12 +100,12 @@ function createWeatherSource(
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
},
]
},
@@ -131,9 +131,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
},
]
}
@@ -322,7 +322,7 @@ describe("FeedEngine", () => {
expect(items[0]!.type).toBe("weather")
})
test("sorts items by priority descending", async () => {
test("returns items in source graph order (no engine-level sorting)", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
@@ -338,8 +338,12 @@ describe("FeedEngine", () => {
const { items } = await engine.refresh()
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
// Items returned in topological order (weather before alert)
expect(items[0]!.type).toBe("weather")
expect(items[1]!.type).toBe("alert")
// Signals are preserved for post-processors to consume
expect(items[0]!.signals?.urgency).toBe(0.5)
expect(items[1]!.signals?.urgency).toBe(1.0)
})
test("handles missing upstream context gracefully", async () => {

View File

@@ -150,9 +150,6 @@ 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 }
@@ -314,8 +311,6 @@ 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[],

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { UnknownActionError, contextKey, contextValue } from "./index"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -99,12 +99,12 @@ function createWeatherSource(
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
},
]
},
@@ -130,9 +130,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
},
]
}
@@ -226,9 +226,6 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
}
}
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
return { context, items }
}
@@ -441,8 +438,12 @@ describe("FeedSource", () => {
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
// Items returned in topological order (weather before alert)
expect(items[0]!.type).toBe("weather")
expect(items[1]!.type).toBe("alert")
// Signals preserved for post-processors
expect(items[0]!.signals?.urgency).toBe(0.5)
expect(items[1]!.signals?.urgency).toBe(1.0)
})
test("source without location context returns empty items", async () => {

View File

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

View File

@@ -7,7 +7,8 @@ export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type { FeedItem } from "./feed"
export type { FeedItem, FeedItemSignals } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source
export type { FeedSource } from "./feed-source"

View File

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

View File

@@ -190,21 +190,22 @@ describe("query() with mocked client", () => {
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
})
test("assigns priority based on weather conditions", async () => {
test("assigns signals based on weather conditions", async () => {
const dataSource = new WeatherKitDataSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context)
for (const item of items) {
expect(item.priority).toBeGreaterThanOrEqual(0)
expect(item.priority).toBeLessThanOrEqual(1)
expect(item.signals).toBeDefined()
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
expect(item.signals!.timeRelevance).toBeDefined()
}
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined()
// Base priority for current is 0.5, may be adjusted for conditions
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
})
test("generates unique IDs for each item", async () => {

View File

@@ -1,4 +1,6 @@
import type { Context, DataSource } from "@aris/core"
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
import { TimeRelevance } from "@aris/core"
import {
WeatherFeedItemType,
@@ -105,7 +107,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
}
}
const BASE_PRIORITY = {
const BASE_URGENCY = {
current: 0.5,
hourly: 0.3,
daily: 0.2,
@@ -134,17 +136,17 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
ConditionCode.BlowingSnow,
])
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
if (SEVERE_CONDITIONS.has(conditionCode)) {
return Math.min(1, basePriority + 0.3)
return Math.min(1, baseUrgency + 0.3)
}
if (MODERATE_CONDITIONS.has(conditionCode)) {
return Math.min(1, basePriority + 0.15)
return Math.min(1, baseUrgency + 0.15)
}
return basePriority
return baseUrgency
}
function adjustPriorityForAlertSeverity(severity: Severity): number {
function adjustUrgencyForAlertSeverity(severity: Severity): number {
switch (severity) {
case Severity.Extreme:
return 1
@@ -153,7 +155,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number {
case Severity.Moderate:
return 0.75
case Severity.Minor:
return BASE_PRIORITY.alert
return BASE_URGENCY.alert
}
}
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
if (SEVERE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Imminent
}
if (MODERATE_CONDITIONS.has(conditionCode)) {
return TimeRelevance.Upcoming
}
return TimeRelevance.Ambient
}
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
switch (severity) {
case Severity.Extreme:
case Severity.Severe:
return TimeRelevance.Imminent
case Severity.Moderate:
return TimeRelevance.Upcoming
case Severity.Minor:
return TimeRelevance.Ambient
}
}
@@ -197,12 +221,14 @@ function createCurrentWeatherFeedItem(
timestamp: Date,
units: Units,
): CurrentWeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
timeRelevance: timeRelevanceForCondition(current.conditionCode),
}
return {
id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.current,
priority,
timestamp,
data: {
conditionCode: current.conditionCode,
@@ -219,6 +245,7 @@ function createCurrentWeatherFeedItem(
windGust: convertSpeed(current.windGust, units),
windSpeed: convertSpeed(current.windSpeed, units),
},
signals,
}
}
@@ -228,12 +255,14 @@ function createHourlyWeatherFeedItem(
timestamp: Date,
units: Units,
): HourlyWeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
}
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.hourly,
priority,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart),
@@ -250,6 +279,7 @@ function createHourlyWeatherFeedItem(
windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units),
},
signals,
}
}
@@ -259,12 +289,14 @@ function createDailyWeatherFeedItem(
timestamp: Date,
units: Units,
): DailyWeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
}
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.daily,
priority,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart),
@@ -279,16 +311,19 @@ function createDailyWeatherFeedItem(
temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units),
},
signals,
}
}
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
const priority = adjustPriorityForAlertSeverity(alert.severity)
const signals: FeedItemSignals = {
urgency: adjustUrgencyForAlertSeverity(alert.severity),
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
}
return {
id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.alert,
priority,
timestamp,
data: {
alertId: alert.id,
@@ -302,5 +337,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
source: alert.source,
urgency: alert.urgency,
},
signals,
}
}

View File

@@ -1,6 +1,6 @@
import type { Context } from "@aris/core"
import { contextValue } from "@aris/core"
import { TimeRelevance, contextValue } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
@@ -15,7 +15,7 @@ import type {
} from "./types.ts"
import { CalendarKey } from "./calendar-context.ts"
import { CalendarSource, computePriority } from "./calendar-source.ts"
import { CalendarSource, computeSignals } from "./calendar-source.ts"
function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
@@ -185,7 +185,7 @@ describe("CalendarSource", () => {
expect(items[0]!.timestamp).toEqual(now)
})
test("assigns priority based on event proximity", async () => {
test("assigns signals based on event proximity", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
@@ -203,8 +203,10 @@ describe("CalendarSource", () => {
const standup = items.find((i) => i.data.title === "Team Standup")
const holiday = items.find((i) => i.data.title === "Company Holiday")
expect(standup!.priority).toBe(0.7) // within 2 hours
expect(holiday!.priority).toBe(0.3) // all-day
expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours
expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming)
expect(holiday!.signals!.urgency).toBe(0.3) // all-day
expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("handles calendar with non-string displayName", async () => {
@@ -378,7 +380,7 @@ describe("CalendarSource.fetchContext", () => {
})
})
describe("computePriority", () => {
describe("computeSignals", () => {
const now = new Date("2026-01-15T12:00:00Z")
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
@@ -401,73 +403,83 @@ describe("computePriority", () => {
}
}
test("all-day events get priority 0.3", () => {
test("all-day events get urgency 0.3 and ambient relevance", () => {
const event = makeEvent({ isAllDay: true })
expect(computePriority(event, now)).toBe(0.3)
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.3)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("events starting within 30 minutes get priority 0.9", () => {
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T12:20:00Z"),
})
expect(computePriority(event, now)).toBe(0.9)
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.9)
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
})
test("events starting exactly at 30 minutes get priority 0.9", () => {
test("events starting exactly at 30 minutes get urgency 0.9", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T12:30:00Z"),
})
expect(computePriority(event, now)).toBe(0.9)
expect(computeSignals(event, now).urgency).toBe(0.9)
})
test("events starting within 2 hours get priority 0.7", () => {
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T13:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.7)
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.7)
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
})
test("events later today get priority 0.5", () => {
test("events later today get urgency 0.5", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T20:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.5)
expect(computeSignals(event, now).urgency).toBe(0.5)
})
test("in-progress events get priority 0.8", () => {
test("in-progress events get urgency 0.8 and imminent relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T11:00:00Z"),
endDate: new Date("2026-01-15T13:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.8)
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.8)
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
})
test("fully past events get priority 0.2", () => {
test("fully past events get urgency 0.2 and ambient relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T09:00:00Z"),
endDate: new Date("2026-01-15T10:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.2)
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.2)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("events on future days get priority 0.2", () => {
test("events on future days get urgency 0.2", () => {
const event = makeEvent({
startDate: new Date("2026-01-16T10:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.2)
expect(computeSignals(event, now).urgency).toBe(0.2)
})
test("priority boundaries are correct", () => {
test("urgency boundaries are correct", () => {
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
const event31min = makeEvent({
startDate: new Date("2026-01-15T12:31:00Z"),
})
expect(computePriority(event31min, now)).toBe(0.7)
expect(computeSignals(event31min, now).urgency).toBe(0.7)
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
const event2h1m = makeEvent({
startDate: new Date("2026-01-15T14:01:00Z"),
})
expect(computePriority(event2h1m, now)).toBe(0.5)
expect(computeSignals(event2h1m, now).urgency).toBe(0.5)
})
})

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { UnknownActionError } from "@aris/core"
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { TimeRelevance, UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
import type {
@@ -202,9 +202,9 @@ function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end:
return { start, end }
}
export function computePriority(event: CalendarEventData, now: Date): number {
export function computeSignals(event: CalendarEventData, now: Date): FeedItemSignals {
if (event.isAllDay) {
return 0.3
return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient }
}
const msUntilStart = event.startDate.getTime() - now.getTime()
@@ -212,40 +212,41 @@ export function computePriority(event: CalendarEventData, now: Date): number {
// 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
return isInProgress
? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent }
: { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
// Starting within 30 minutes
if (msUntilStart <= 30 * 60 * 1000) {
return 0.9
return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent }
}
// Starting within 2 hours
if (msUntilStart <= 2 * 60 * 60 * 1000) {
return 0.7
return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming }
}
// Later today (within 24 hours from start of day)
// Later today
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
return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming }
}
// Future days
return 0.2
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
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,
signals: computeSignals(event, now),
}
}

View File

@@ -1,4 +1,4 @@
import { contextValue, type Context } from "@aris/core"
import { TimeRelevance, contextValue, type Context } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
@@ -81,16 +81,17 @@ describe("GoogleCalendarSource", () => {
expect(allDayItems.length).toBe(1)
})
test("ongoing events get highest priority (1.0)", async () => {
test("ongoing events get highest urgency (1.0)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
expect(ongoing).toBeDefined()
expect(ongoing!.priority).toBe(1.0)
expect(ongoing!.signals!.urgency).toBe(1.0)
expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent)
})
test("upcoming events get higher priority when sooner", async () => {
test("upcoming events get higher urgency when sooner", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
@@ -99,16 +100,17 @@ describe("GoogleCalendarSource", () => {
expect(soon).toBeDefined()
expect(later).toBeDefined()
expect(soon!.priority).toBeGreaterThan(later!.priority)
expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!)
})
test("all-day events get flat priority (0.4)", async () => {
test("all-day events get flat urgency (0.4)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDay = items.find((i) => i.data.eventId === "evt-allday")
expect(allDay).toBeDefined()
expect(allDay!.priority).toBe(0.4)
expect(allDay!.signals!.urgency).toBe(0.4)
expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("generates unique IDs for each item", async () => {
@@ -280,7 +282,7 @@ describe("GoogleCalendarSource", () => {
})
})
describe("priority ordering", () => {
describe("urgency ordering", () => {
test("ongoing > upcoming > all-day", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
@@ -289,8 +291,8 @@ describe("GoogleCalendarSource", () => {
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!)
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!)
})
})
})

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { UnknownActionError } from "@aris/core"
import { TimeRelevance, UnknownActionError } from "@aris/core"
import type {
ApiCalendarEvent,
@@ -35,10 +35,10 @@ import { DefaultGoogleCalendarClient } from "./google-calendar-api"
const DEFAULT_LOOKAHEAD_HOURS = 24
const PRIORITY_ONGOING = 1.0
const PRIORITY_UPCOMING_MAX = 0.9
const PRIORITY_UPCOMING_MIN = 0.3
const PRIORITY_ALL_DAY = 0.4
const URGENCY_ONGOING = 1.0
const URGENCY_UPCOMING_MAX = 0.9
const URGENCY_UPCOMING_MIN = 0.3
const URGENCY_ALL_DAY = 0.4
/**
* A FeedSource that provides Google Calendar events and next-event context.
@@ -171,9 +171,13 @@ function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventD
}
}
function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
function computeSignals(
event: CalendarEventData,
nowMs: number,
lookaheadMs: number,
): FeedItemSignals {
if (event.isAllDay) {
return PRIORITY_ALL_DAY
return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient }
}
const startMs = event.startTime.getTime()
@@ -181,17 +185,23 @@ function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: n
// Ongoing: start <= now < end
if (startMs <= nowMs && nowMs < endMs) {
return PRIORITY_ONGOING
return { urgency: URGENCY_ONGOING, timeRelevance: TimeRelevance.Imminent }
}
// Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
// Upcoming: linear decay from URGENCY_UPCOMING_MAX to URGENCY_UPCOMING_MIN
const msUntilStart = startMs - nowMs
if (msUntilStart <= 0) {
return PRIORITY_UPCOMING_MIN
return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient }
}
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
const urgency = URGENCY_UPCOMING_MAX - ratio * (URGENCY_UPCOMING_MAX - URGENCY_UPCOMING_MIN)
// Within 30 minutes = imminent, otherwise upcoming
const timeRelevance =
msUntilStart <= 30 * 60 * 1000 ? TimeRelevance.Imminent : TimeRelevance.Upcoming
return { urgency, timeRelevance }
}
function createFeedItem(
@@ -199,14 +209,13 @@ function createFeedItem(
nowMs: number,
lookaheadMs: number,
): CalendarFeedItem {
const priority = computePriority(event, nowMs, lookaheadMs)
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
return {
id: `calendar-${event.calendarId}-${event.eventId}`,
type: itemType,
priority,
timestamp: new Date(nowMs),
data: event,
signals: computeSignals(event, nowMs, lookaheadMs),
}
}

View File

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

View File

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

View File

@@ -145,20 +145,22 @@ describe("WeatherSource", () => {
}
})
test("assigns priority based on weather conditions", async () => {
test("assigns signals based on weather conditions", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
for (const item of items) {
expect(item.priority).toBeGreaterThanOrEqual(0)
expect(item.priority).toBeLessThanOrEqual(1)
expect(item.signals).toBeDefined()
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
expect(item.signals!.timeRelevance).toBeDefined()
}
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined()
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
})
test("generates unique IDs for each item", async () => {

View File

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