mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
2 Commits
ba0450d0aa
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
f806b78fb7
|
|||
|
65ca50bf36
|
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
name: gpg-commit-signing
|
|
||||||
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
|
|
||||||
---
|
|
||||||
|
|
||||||
# GPG Commit Signing
|
|
||||||
|
|
||||||
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
|
|
||||||
```
|
|
||||||
|
|
||||||
If not set, skip signing — commit without `-S`.
|
|
||||||
|
|
||||||
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -S -m "message"
|
|
||||||
```
|
|
||||||
|
|
||||||
If this succeeds, no further steps are needed.
|
|
||||||
|
|
||||||
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
|
|
||||||
chmod +x /tmp/gpg-sign.sh
|
|
||||||
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
|
|
||||||
rm /tmp/gpg-sign.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
|
|
||||||
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
|
|
||||||
- Do not leave wrapper scripts on disk after committing.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|||||||
async executeAction(): Promise<unknown> {
|
async executeAction(): Promise<unknown> {
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<Partial<Context> | null> {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aris/source-location"
|
import { LocationSource } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
|
|||||||
async executeAction(): Promise<unknown> {
|
async executeAction(): Promise<unknown> {
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<Partial<Context> | null> {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
|
|||||||
13
bun.lock
13
bun.lock
@@ -89,17 +89,6 @@
|
|||||||
"arktype": "^2.1.0",
|
"arktype": "^2.1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aris-feed-enhancers": {
|
|
||||||
"name": "@aris/feed-enhancers",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@aris/core": "workspace:*",
|
|
||||||
"@aris/source-caldav": "workspace:*",
|
|
||||||
"@aris/source-google-calendar": "workspace:*",
|
|
||||||
"@aris/source-tfl": "workspace:*",
|
|
||||||
"@aris/source-weatherkit": "workspace:*",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/aris-source-caldav": {
|
"packages/aris-source-caldav": {
|
||||||
"name": "@aris/source-caldav",
|
"name": "@aris/source-caldav",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -155,8 +144,6 @@
|
|||||||
|
|
||||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
"@aris/feed-enhancers": ["@aris/feed-enhancers@workspace:packages/aris-feed-enhancers"],
|
|
||||||
|
|
||||||
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
|
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
|
||||||
|
|
||||||
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { ContextEntry } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { ContextProvider } from "./context-provider"
|
import type { ContextProvider } from "./context-provider"
|
||||||
|
|
||||||
import { contextKey } from "./context"
|
|
||||||
|
|
||||||
interface ContextUpdatable {
|
interface ContextUpdatable {
|
||||||
pushContextUpdate(entries: readonly ContextEntry[]): void
|
pushContextUpdate(update: Partial<Context>): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderError {
|
export interface ProviderError {
|
||||||
@@ -56,7 +54,7 @@ export class ContextBridge {
|
|||||||
this.providers.set(provider.key, provider as ContextProvider)
|
this.providers.set(provider.key, provider as ContextProvider)
|
||||||
|
|
||||||
const cleanup = provider.onUpdate((value) => {
|
const cleanup = provider.onUpdate((value) => {
|
||||||
this.controller.pushContextUpdate([[contextKey(provider.key), value]])
|
this.controller.pushContextUpdate({ [provider.key]: value })
|
||||||
})
|
})
|
||||||
this.cleanups.push(cleanup)
|
this.cleanups.push(cleanup)
|
||||||
|
|
||||||
@@ -69,7 +67,7 @@ export class ContextBridge {
|
|||||||
* Returns errors from providers that failed to fetch.
|
* Returns errors from providers that failed to fetch.
|
||||||
*/
|
*/
|
||||||
async refresh(): Promise<RefreshResult> {
|
async refresh(): Promise<RefreshResult> {
|
||||||
const collected: ContextEntry[] = []
|
const updates: Partial<Context> = {}
|
||||||
const errors: ProviderError[] = []
|
const errors: ProviderError[] = []
|
||||||
|
|
||||||
const entries = Array.from(this.providers.entries())
|
const entries = Array.from(this.providers.entries())
|
||||||
@@ -80,7 +78,7 @@ export class ContextBridge {
|
|||||||
entries.forEach(([key], i) => {
|
entries.forEach(([key], i) => {
|
||||||
const result = results[i]
|
const result = results[i]
|
||||||
if (result?.status === "fulfilled") {
|
if (result?.status === "fulfilled") {
|
||||||
collected.push([contextKey(key), result.value])
|
updates[key] = result.value
|
||||||
} else if (result?.status === "rejected") {
|
} else if (result?.status === "rejected") {
|
||||||
errors.push({
|
errors.push({
|
||||||
key,
|
key,
|
||||||
@@ -89,7 +87,7 @@ export class ContextBridge {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.controller.pushContextUpdate(collected)
|
this.controller.pushContextUpdate(updates)
|
||||||
|
|
||||||
return { errors }
|
return { errors }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { ContextKey } from "./context"
|
|
||||||
|
|
||||||
import { Context, contextKey } from "./context"
|
|
||||||
|
|
||||||
interface Weather {
|
|
||||||
temperature: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NextEvent {
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "current")
|
|
||||||
const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
|
||||||
|
|
||||||
describe("Context", () => {
|
|
||||||
describe("get", () => {
|
|
||||||
test("returns undefined for missing key", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
expect(ctx.get(WeatherKey)).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns value for exact key match", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
const weather: Weather = { temperature: 20 }
|
|
||||||
ctx.set([[WeatherKey, weather]])
|
|
||||||
|
|
||||||
expect(ctx.get(WeatherKey)).toEqual(weather)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("distinguishes keys with different parts", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[WeatherKey, { temperature: 20 }],
|
|
||||||
[NextEventKey, { title: "Standup" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(ctx.get(WeatherKey)).toEqual({ temperature: 20 })
|
|
||||||
expect(ctx.get(NextEventKey)).toEqual({ title: "Standup" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("last write wins for same key", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[WeatherKey, { temperature: 20 }]])
|
|
||||||
ctx.set([[WeatherKey, { temperature: 25 }]])
|
|
||||||
|
|
||||||
expect(ctx.get(WeatherKey)).toEqual({ temperature: 25 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("find", () => {
|
|
||||||
test("returns empty array when no keys match", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
expect(ctx.find(WeatherKey)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns exact match as single result", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[NextEventKey, { title: "Standup" }]])
|
|
||||||
|
|
||||||
const results = ctx.find(NextEventKey)
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
expect(results[0]!.value).toEqual({ title: "Standup" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prefix match returns multiple instances", () => {
|
|
||||||
const workKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
|
||||||
account: "work",
|
|
||||||
})
|
|
||||||
const personalKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
|
||||||
account: "personal",
|
|
||||||
})
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[workKey, { title: "Sprint Planning" }],
|
|
||||||
[personalKey, { title: "Dentist" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
const prefix = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
|
||||||
const results = ctx.find(prefix)
|
|
||||||
|
|
||||||
expect(results).toHaveLength(2)
|
|
||||||
const titles = results.map((r) => r.value.title).sort()
|
|
||||||
expect(titles).toEqual(["Dentist", "Sprint Planning"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prefix match includes exact match and longer keys", () => {
|
|
||||||
const baseKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
|
||||||
const instanceKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
|
||||||
account: "work",
|
|
||||||
})
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[baseKey, { title: "Base" }],
|
|
||||||
[instanceKey, { title: "Instance" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(baseKey)
|
|
||||||
expect(results).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not match keys that share a string prefix but differ at segment boundary", () => {
|
|
||||||
const keyA = contextKey<string>("aris.calendar", "next")
|
|
||||||
const keyB = contextKey<string>("aris.calendar", "nextEvent")
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[keyA, "a"],
|
|
||||||
[keyB, "b"],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(keyA)
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
expect(results[0]!.value).toBe("a")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("object key parts with different property order match", () => {
|
|
||||||
const key1 = contextKey<string>("source", "ctx", { b: 2, a: 1 })
|
|
||||||
const key2 = contextKey<string>("source", "ctx", { a: 1, b: 2 })
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[key1, "value"]])
|
|
||||||
|
|
||||||
// Exact match via get should work regardless of property order
|
|
||||||
expect(ctx.get(key2)).toBe("value")
|
|
||||||
|
|
||||||
// find with the reordered key as prefix should also match
|
|
||||||
const prefix = contextKey<string>("source", "ctx")
|
|
||||||
const results = ctx.find(prefix)
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("single-segment prefix matches all keys starting with that segment", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[contextKey("aris.weather", "current"), { temperature: 20 }],
|
|
||||||
[contextKey("aris.weather", "forecast"), { high: 25 }],
|
|
||||||
[contextKey("aris.calendar", "nextEvent"), { title: "Meeting" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(contextKey("aris.weather"))
|
|
||||||
expect(results).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not match shorter keys", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[contextKey("aris.weather"), "short"]])
|
|
||||||
|
|
||||||
const results = ctx.find(contextKey("aris.weather", "current"))
|
|
||||||
expect(results).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("numeric key parts match correctly", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[contextKey("source", 1, "data"), "one"],
|
|
||||||
[contextKey("source", 2, "data"), "two"],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(contextKey("source", 1))
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
expect(results[0]!.value).toBe("one")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("size", () => {
|
|
||||||
test("returns 0 for empty context", () => {
|
|
||||||
expect(new Context().size).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reflects number of entries", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[WeatherKey, { temperature: 20 }],
|
|
||||||
[NextEventKey, { title: "Standup" }],
|
|
||||||
])
|
|
||||||
expect(ctx.size).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,131 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* Tuple-keyed context system inspired by React Query's query keys.
|
* Branded type for type-safe context keys.
|
||||||
*
|
*
|
||||||
* Context keys are arrays that form a hierarchy. Sources write to specific
|
* Each package defines its own keys with associated value types:
|
||||||
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
|
* ```ts
|
||||||
* and consumers can query by exact match or prefix match to get all values
|
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||||
* of a given type across source instances.
|
* ```
|
||||||
*/
|
*/
|
||||||
|
export type ContextKey<T> = string & { __contextValue?: T }
|
||||||
// -- Key types --
|
|
||||||
|
|
||||||
/** A single segment of a context key: string, number, or a record of primitives. */
|
|
||||||
export type ContextKeyPart = string | number | Record<string, unknown>
|
|
||||||
|
|
||||||
/** A context key is a readonly tuple of parts, branded with the value type. */
|
|
||||||
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
|
|
||||||
|
|
||||||
/** Creates a typed context key. */
|
|
||||||
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
|
|
||||||
return parts as ContextKey<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Serialization --
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic serialization of a context key for use as a Map key.
|
* Creates a typed context key.
|
||||||
* Object parts have their keys sorted for stable comparison.
|
|
||||||
*/
|
|
||||||
export function serializeKey(key: readonly ContextKeyPart[]): string {
|
|
||||||
return JSON.stringify(key, (_key, value) => {
|
|
||||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
const sorted: Record<string, unknown> = {}
|
|
||||||
for (const k of Object.keys(value).sort()) {
|
|
||||||
sorted[k] = value[k]
|
|
||||||
}
|
|
||||||
return sorted
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Key matching --
|
|
||||||
|
|
||||||
/** Returns true if `key` starts with all parts of `prefix`. */
|
|
||||||
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
|
|
||||||
if (key.length < prefix.length) return false
|
|
||||||
|
|
||||||
for (let i = 0; i < prefix.length; i++) {
|
|
||||||
if (!partsEqual(key[i]!, prefix[i]!)) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
|
|
||||||
function partsEqual(a: unknown, b: unknown): boolean {
|
|
||||||
if (a === b) return true
|
|
||||||
if (typeof a !== typeof b) return false
|
|
||||||
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
||||||
const aKeys = Object.keys(a)
|
|
||||||
const bKeys = Object.keys(b)
|
|
||||||
if (aKeys.length !== bKeys.length) return false
|
|
||||||
return aKeys.every((key) =>
|
|
||||||
partsEqual(
|
|
||||||
(a as Record<string, unknown>)[key],
|
|
||||||
(b as Record<string, unknown>)[key],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Context store --
|
|
||||||
|
|
||||||
/** A single context entry: a key-value pair. */
|
|
||||||
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutable context store with tuple keys.
|
|
||||||
*
|
*
|
||||||
* Supports exact-match lookups and prefix-match queries.
|
* @example
|
||||||
* Sources write context in topological order during refresh.
|
* ```ts
|
||||||
|
* interface Location { lat: number; lng: number; accuracy: number }
|
||||||
|
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export class Context {
|
export function contextKey<T>(key: string): ContextKey<T> {
|
||||||
|
return key as ContextKey<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe accessor for context values.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const location = contextValue(context, LocationKey)
|
||||||
|
* if (location) {
|
||||||
|
* console.log(location.lat, location.lng)
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
|
||||||
|
return context[key] as T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbitrary key-value bag representing the current state.
|
||||||
|
* Always includes `time`. Other keys are added by context providers.
|
||||||
|
*/
|
||||||
|
export interface Context {
|
||||||
time: Date
|
time: Date
|
||||||
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
|
[key: string]: unknown
|
||||||
|
|
||||||
constructor(time: Date = new Date()) {
|
|
||||||
this.time = time
|
|
||||||
this.store = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Merges entries into this context. */
|
|
||||||
set(entries: readonly ContextEntry[]): void {
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
this.store.set(serializeKey(key), { key, value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exact-match lookup. Returns the value for the given key, or undefined. */
|
|
||||||
get<T>(key: ContextKey<T>): T | undefined {
|
|
||||||
const entry = this.store.get(serializeKey(key))
|
|
||||||
return entry?.value as T | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefix-match query. Returns all entries whose key starts with the given prefix.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Get all "nextEvent" values across calendar source instances
|
|
||||||
* const events = context.find(contextKey("nextEvent"))
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> {
|
|
||||||
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = []
|
|
||||||
|
|
||||||
for (const entry of this.store.values()) {
|
|
||||||
if (keyStartsWith(entry.key, prefix)) {
|
|
||||||
results.push({ key: entry.key, value: entry.value as T })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the number of entries (excluding time). */
|
|
||||||
get size(): number {
|
|
||||||
return this.store.size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { FeedItem } from "./feed"
|
|||||||
* readonly type = "weather"
|
* readonly type = "weather"
|
||||||
*
|
*
|
||||||
* async query(context: Context): Promise<WeatherItem[]> {
|
* async query(context: Context): Promise<WeatherItem[]> {
|
||||||
* const location = context.get(LocationKey)
|
* const location = contextValue(context, LocationKey)
|
||||||
* if (!location) return []
|
* if (!location) return []
|
||||||
* const data = await fetchWeather(location)
|
* const data = await fetchWeather(location)
|
||||||
* return [{
|
* return [{
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { ContextEntry } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { DataSource } from "./data-source"
|
import type { DataSource } from "./data-source"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
import type { ReconcileResult } from "./reconciler"
|
import type { ReconcileResult } from "./reconciler"
|
||||||
|
|
||||||
import { Context } from "./context"
|
|
||||||
import { Reconciler } from "./reconciler"
|
import { Reconciler } from "./reconciler"
|
||||||
|
|
||||||
export interface FeedControllerConfig {
|
export interface FeedControllerConfig {
|
||||||
@@ -41,7 +40,7 @@ const DEFAULT_DEBOUNCE_MS = 100
|
|||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Context update triggers debounced reconcile
|
* // Context update triggers debounced reconcile
|
||||||
* controller.pushContextUpdate([[LocationKey, location]])
|
* controller.pushContextUpdate({ [LocationKey]: location })
|
||||||
*
|
*
|
||||||
* // Direct reconcile (no debounce)
|
* // Direct reconcile (no debounce)
|
||||||
* const result = await controller.reconcile()
|
* const result = await controller.reconcile()
|
||||||
@@ -60,7 +59,7 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
private stopped = false
|
private stopped = false
|
||||||
|
|
||||||
constructor(config?: FeedControllerConfig) {
|
constructor(config?: FeedControllerConfig) {
|
||||||
this.context = config?.initialContext ?? new Context()
|
this.context = config?.initialContext ?? { time: new Date() }
|
||||||
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
||||||
this.timeout = config?.timeout
|
this.timeout = config?.timeout
|
||||||
}
|
}
|
||||||
@@ -95,10 +94,9 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merges entries into context and schedules a debounced reconcile. */
|
/** Merges update into context and schedules a debounced reconcile. */
|
||||||
pushContextUpdate(entries: readonly ContextEntry[]): void {
|
pushContextUpdate(update: Partial<Context>): void {
|
||||||
this.context.time = new Date()
|
this.context = { ...this.context, ...update, time: new Date() }
|
||||||
this.context.set(entries)
|
|
||||||
this.scheduleReconcile()
|
this.scheduleReconcile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { FeedEngine } from "./feed-engine"
|
import { FeedEngine } from "./feed-engine"
|
||||||
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
function createLocationSource(): SimulatedLocationSource {
|
||||||
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
let callback: ((update: Partial<Context>) => void) | null = null
|
||||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return [[LocationKey, currentLocation]]
|
return { [LocationKey]: currentLocation }
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.([[LocationKey, location]])
|
callback?.({ [LocationKey]: location })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,15 +85,15 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return [[WeatherKey, weather]]
|
return { [WeatherKey]: weather }
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
order.push("location")
|
||||||
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +275,9 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = ctx.get(LocationKey)
|
const loc = contextValue(ctx, LocationKey)
|
||||||
expect(loc).toBeDefined()
|
expect(loc).toBeDefined()
|
||||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +298,11 @@ describe("FeedEngine", () => {
|
|||||||
|
|
||||||
const { context } = await engine.refresh()
|
const { context } = await engine.refresh()
|
||||||
|
|
||||||
expect(context.get(LocationKey)).toEqual({
|
expect(contextValue(context, LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(context.get(WeatherKey)).toEqual({
|
expect(contextValue(context, WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -361,7 +361,7 @@ describe("FeedEngine", () => {
|
|||||||
|
|
||||||
const { context, items } = await engine.refresh()
|
const { context, items } = await engine.refresh()
|
||||||
|
|
||||||
expect(context.get(WeatherKey)).toBeUndefined()
|
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ describe("FeedEngine", () => {
|
|||||||
await engine.refresh()
|
await engine.refresh()
|
||||||
|
|
||||||
const context = engine.currentContext()
|
const context = engine.currentContext()
|
||||||
expect(context.get(LocationKey)).toEqual({
|
expect(contextValue(context, LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
@@ -734,7 +734,7 @@ describe("FeedEngine", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("reactive item update refreshes cache", async () => {
|
test("reactive item update refreshes cache", async () => {
|
||||||
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null
|
let itemUpdateCallback: (() => void) | null = null
|
||||||
|
|
||||||
const source: FeedSource = {
|
const source: FeedSource = {
|
||||||
id: "reactive-items",
|
id: "reactive-items",
|
||||||
@@ -765,7 +765,7 @@ describe("FeedEngine", () => {
|
|||||||
engine.start()
|
engine.start()
|
||||||
|
|
||||||
// Trigger item update
|
// Trigger item update
|
||||||
itemUpdateCallback!([])
|
itemUpdateCallback!()
|
||||||
|
|
||||||
// Wait for async refresh
|
// Wait for async refresh
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
@@ -885,12 +885,12 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
fetchCount++
|
fetchCount++
|
||||||
const loc = ctx.get(LocationKey)
|
const loc = contextValue(ctx, LocationKey)
|
||||||
if (!loc) return null
|
if (!loc) return null
|
||||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||||
},
|
},
|
||||||
async fetchItems(ctx) {
|
async fetchItems(ctx) {
|
||||||
const weather = ctx.get(WeatherKey)
|
const weather = contextValue(ctx, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { ContextEntry } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||||
import type { FeedSource } from "./feed-source"
|
import type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
import { Context } from "./context"
|
|
||||||
|
|
||||||
export interface SourceError {
|
export interface SourceError {
|
||||||
sourceId: string
|
sourceId: string
|
||||||
error: Error
|
error: Error
|
||||||
@@ -67,7 +65,7 @@ interface SourceGraph {
|
|||||||
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
private graph: SourceGraph | null = null
|
private graph: SourceGraph | null = null
|
||||||
private context: Context = new Context()
|
private context: Context = { time: new Date() }
|
||||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||||
private cleanups: Array<() => void> = []
|
private cleanups: Array<() => void> = []
|
||||||
private started = false
|
private started = false
|
||||||
@@ -140,14 +138,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const errors: SourceError[] = []
|
const errors: SourceError[] = []
|
||||||
|
|
||||||
// Reset context with fresh time
|
// Reset context with fresh time
|
||||||
const context = new Context()
|
let context: Context = { time: new Date() }
|
||||||
|
|
||||||
// Run fetchContext in topological order
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
try {
|
try {
|
||||||
const entries = await source.fetchContext(context)
|
const update = await source.fetchContext(context)
|
||||||
if (entries) {
|
if (update) {
|
||||||
context.set(entries)
|
context = { ...context, ...update }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -179,7 +177,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
items: processedItems,
|
items: processedItems,
|
||||||
groupedItems,
|
groupedItems,
|
||||||
errors: postProcessorErrors,
|
errors: postProcessorErrors,
|
||||||
} = await this.applyPostProcessors(items as TItems[], context, errors)
|
} = await this.applyPostProcessors(items as TItems[], errors)
|
||||||
|
|
||||||
const result: FeedResult<TItems> = {
|
const result: FeedResult<TItems> = {
|
||||||
context,
|
context,
|
||||||
@@ -215,8 +213,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
if (source.onContextUpdate) {
|
if (source.onContextUpdate) {
|
||||||
const cleanup = source.onContextUpdate(
|
const cleanup = source.onContextUpdate(
|
||||||
(entries) => {
|
(update) => {
|
||||||
this.handleContextUpdate(source.id, entries)
|
this.handleContextUpdate(source.id, update)
|
||||||
},
|
},
|
||||||
() => this.context,
|
() => this.context,
|
||||||
)
|
)
|
||||||
@@ -296,7 +294,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
|
|
||||||
private async applyPostProcessors(
|
private async applyPostProcessors(
|
||||||
items: TItems[],
|
items: TItems[],
|
||||||
context: Context,
|
|
||||||
errors: SourceError[],
|
errors: SourceError[],
|
||||||
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
|
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
|
||||||
let currentItems = items
|
let currentItems = items
|
||||||
@@ -307,7 +304,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
for (const processor of this.postProcessors) {
|
for (const processor of this.postProcessors) {
|
||||||
const snapshot = currentItems
|
const snapshot = currentItems
|
||||||
try {
|
try {
|
||||||
const enhancement = await processor(currentItems, context)
|
const enhancement = await processor(currentItems)
|
||||||
|
|
||||||
if (enhancement.additionalItems?.length) {
|
if (enhancement.additionalItems?.length) {
|
||||||
// Post-processors operate on FeedItem[] without knowledge of TItems.
|
// Post-processors operate on FeedItem[] without knowledge of TItems.
|
||||||
@@ -367,9 +364,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return this.graph
|
return this.graph
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void {
|
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
|
||||||
this.context.time = new Date()
|
this.context = { ...this.context, ...update, time: new Date() }
|
||||||
this.context.set(entries)
|
|
||||||
|
|
||||||
// Re-run dependents and notify
|
// Re-run dependents and notify
|
||||||
this.refreshDependents(sourceId)
|
this.refreshDependents(sourceId)
|
||||||
@@ -384,9 +380,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const source = graph.sources.get(id)
|
const source = graph.sources.get(id)
|
||||||
if (source) {
|
if (source) {
|
||||||
try {
|
try {
|
||||||
const entries = await source.fetchContext(this.context)
|
const update = await source.fetchContext(this.context)
|
||||||
if (entries) {
|
if (update) {
|
||||||
this.context.set(entries)
|
this.context = { ...this.context, ...update }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Errors during reactive updates are logged but don't stop propagation
|
// Errors during reactive updates are logged but don't stop propagation
|
||||||
@@ -416,7 +412,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
items: processedItems,
|
items: processedItems,
|
||||||
groupedItems,
|
groupedItems,
|
||||||
errors: postProcessorErrors,
|
errors: postProcessorErrors,
|
||||||
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
|
} = await this.applyPostProcessors(items as TItems[], errors)
|
||||||
|
|
||||||
const result: FeedResult<TItems> = {
|
const result: FeedResult<TItems> = {
|
||||||
context: this.context,
|
context: this.context,
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test"
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
import type {
|
import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
|
||||||
ActionDefinition,
|
|
||||||
ContextEntry,
|
|
||||||
FeedItem,
|
|
||||||
FeedPostProcessor,
|
|
||||||
FeedSource,
|
|
||||||
} from "./index"
|
|
||||||
|
|
||||||
import { FeedEngine } from "./feed-engine"
|
import { FeedEngine } from "./feed-engine"
|
||||||
import { UnknownActionError } from "./index"
|
import { UnknownActionError } from "./index"
|
||||||
@@ -477,7 +471,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
test("post-processors run during reactive context updates", async () => {
|
test("post-processors run during reactive context updates", async () => {
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
|
|
||||||
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null
|
let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
|
||||||
|
|
||||||
const source: FeedSource = {
|
const source: FeedSource = {
|
||||||
id: "aris.reactive",
|
id: "aris.reactive",
|
||||||
@@ -496,10 +490,12 @@ describe("FeedPostProcessor", () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
const engine = new FeedEngine()
|
||||||
callCount++
|
.register(source)
|
||||||
return {}
|
.registerPostProcessor(async () => {
|
||||||
})
|
callCount++
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
engine.start()
|
engine.start()
|
||||||
|
|
||||||
@@ -508,7 +504,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
const countAfterStart = callCount
|
const countAfterStart = callCount
|
||||||
|
|
||||||
// Trigger a reactive context update
|
// Trigger a reactive context update
|
||||||
triggerUpdate!([])
|
triggerUpdate!({ foo: "bar" })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
@@ -519,7 +515,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
test("post-processors run during reactive item updates", async () => {
|
test("post-processors run during reactive item updates", async () => {
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
|
|
||||||
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null
|
let triggerItemsUpdate: (() => void) | null = null
|
||||||
|
|
||||||
const source: FeedSource = {
|
const source: FeedSource = {
|
||||||
id: "aris.reactive",
|
id: "aris.reactive",
|
||||||
@@ -538,10 +534,12 @@ describe("FeedPostProcessor", () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
const engine = new FeedEngine()
|
||||||
callCount++
|
.register(source)
|
||||||
return {}
|
.registerPostProcessor(async () => {
|
||||||
})
|
callCount++
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
engine.start()
|
engine.start()
|
||||||
|
|
||||||
@@ -549,7 +547,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
const countAfterStart = callCount
|
const countAfterStart = callCount
|
||||||
|
|
||||||
// Trigger a reactive items update
|
// Trigger a reactive items update
|
||||||
triggerItemsUpdate!([weatherItem("w1", 25)])
|
triggerItemsUpdate!()
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Context } from "./context"
|
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
|
|
||||||
export interface ItemGroup {
|
export interface ItemGroup {
|
||||||
@@ -23,4 +22,4 @@ export interface FeedEnhancement {
|
|||||||
* A function that transforms feed items and produces enhancement directives.
|
* A function that transforms feed items and produces enhancement directives.
|
||||||
* Use named functions for meaningful error attribution.
|
* Use named functions for meaningful error attribution.
|
||||||
*/
|
*/
|
||||||
export type FeedPostProcessor = (items: FeedItem[], context: Context) => Promise<FeedEnhancement>
|
export type FeedPostProcessor = (items: FeedItem[]) => Promise<FeedEnhancement>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
function createLocationSource(): SimulatedLocationSource {
|
||||||
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
let callback: ((update: Partial<Context>) => void) | null = null
|
||||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return [[LocationKey, currentLocation]]
|
return { [LocationKey]: currentLocation }
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.([[LocationKey, location]])
|
callback?.({ [LocationKey]: location })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,15 +84,15 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return [[WeatherKey, weather]]
|
return { [WeatherKey]: weather }
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
||||||
const context = new Context()
|
let context: Context = { time: new Date() }
|
||||||
|
|
||||||
// Run fetchContext in topological order
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
const entries = await source.fetchContext(context)
|
const update = await source.fetchContext(context)
|
||||||
if (entries) {
|
if (update) {
|
||||||
context.set(entries)
|
context = { ...context, ...update }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ describe("FeedSource", () => {
|
|||||||
|
|
||||||
test("source without context returns null from fetchContext", async () => {
|
test("source without context returns null from fetchContext", async () => {
|
||||||
const source = createAlertSource()
|
const source = createAlertSource()
|
||||||
const result = await source.fetchContext(new Context())
|
const result = await source.fetchContext({ time: new Date() })
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -369,7 +369,7 @@ describe("FeedSource", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
order.push("location")
|
||||||
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,9 +379,9 @@ describe("FeedSource", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = ctx.get(LocationKey)
|
const loc = contextValue(ctx, LocationKey)
|
||||||
expect(loc).toBeDefined()
|
expect(loc).toBeDefined()
|
||||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +400,11 @@ describe("FeedSource", () => {
|
|||||||
const graph = buildGraph([location, weather])
|
const graph = buildGraph([location, weather])
|
||||||
const { context } = await refreshGraph(graph)
|
const { context } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(context.get(LocationKey)).toEqual({
|
expect(contextValue(context, LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(context.get(WeatherKey)).toEqual({
|
expect(contextValue(context, WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -447,10 +447,12 @@ describe("FeedSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("source without location context returns empty items", async () => {
|
test("source without location context returns empty items", async () => {
|
||||||
|
// Location source exists but hasn't been updated
|
||||||
const location: FeedSource = {
|
const location: FeedSource = {
|
||||||
id: "location",
|
id: "location",
|
||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
|
// Simulate no location available
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -460,7 +462,7 @@ describe("FeedSource", () => {
|
|||||||
const graph = buildGraph([location, weather])
|
const graph = buildGraph([location, weather])
|
||||||
const { context, items } = await refreshGraph(graph)
|
const { context, items } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(context.get(WeatherKey)).toBeUndefined()
|
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -474,7 +476,7 @@ describe("FeedSource", () => {
|
|||||||
() => {
|
() => {
|
||||||
updateCount++
|
updateCount++
|
||||||
},
|
},
|
||||||
() => new Context(),
|
() => ({ time: new Date() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
location.simulateUpdate({ lat: 1, lng: 1 })
|
location.simulateUpdate({ lat: 1, lng: 1 })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context, ContextEntry } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Maps to: source/contextUpdated (notification, source → host)
|
* Maps to: source/contextUpdated (notification, source → host)
|
||||||
*/
|
*/
|
||||||
onContextUpdate?(
|
onContextUpdate?(
|
||||||
callback: (entries: readonly ContextEntry[]) => void,
|
callback: (update: Partial<Context>) => void,
|
||||||
getContext: () => Context,
|
getContext: () => Context,
|
||||||
): () => void
|
): () => void
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Return null if this source cannot provide context.
|
* Return null if this source cannot provide context.
|
||||||
* Maps to: source/fetchContext
|
* Maps to: source/fetchContext
|
||||||
*/
|
*/
|
||||||
fetchContext(context: Context): Promise<readonly ContextEntry[] | null>
|
fetchContext(context: Context): Promise<Partial<Context> | null>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to reactive feed item updates.
|
* Subscribe to reactive feed item updates.
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { FeedItem, Slot } from "./feed"
|
|
||||||
|
|
||||||
describe("FeedItem slots", () => {
|
|
||||||
test("FeedItem without slots is valid", () => {
|
|
||||||
const item: FeedItem<"test", { value: number }> = {
|
|
||||||
id: "test-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { value: 42 },
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(item.slots).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("FeedItem with unfilled slots", () => {
|
|
||||||
const item: FeedItem<"weather", { temp: number }> = {
|
|
||||||
id: "weather-1",
|
|
||||||
type: "weather",
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { temp: 18 },
|
|
||||||
slots: {
|
|
||||||
insight: {
|
|
||||||
description: "A short contextual insight about the current weather",
|
|
||||||
content: null,
|
|
||||||
},
|
|
||||||
"cross-source": {
|
|
||||||
description: "Connection between weather and calendar events",
|
|
||||||
content: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(item.slots).toBeDefined()
|
|
||||||
expect(Object.keys(item.slots!)).toEqual(["insight", "cross-source"])
|
|
||||||
expect(item.slots!.insight!.content).toBeNull()
|
|
||||||
expect(item.slots!["cross-source"]!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("FeedItem with filled slots", () => {
|
|
||||||
const item: FeedItem<"weather", { temp: number }> = {
|
|
||||||
id: "weather-1",
|
|
||||||
type: "weather",
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { temp: 18 },
|
|
||||||
slots: {
|
|
||||||
insight: {
|
|
||||||
description: "A short contextual insight about the current weather",
|
|
||||||
content: "Rain after 3pm — grab a jacket before your walk",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(item.slots!.insight!.content).toBe("Rain after 3pm — grab a jacket before your walk")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Slot interface enforces required fields", () => {
|
|
||||||
const slot: Slot = {
|
|
||||||
description: "Test slot description",
|
|
||||||
content: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(slot.description).toBe("Test slot description")
|
|
||||||
expect(slot.content).toBeNull()
|
|
||||||
|
|
||||||
const filledSlot: Slot = {
|
|
||||||
description: "Test slot description",
|
|
||||||
content: "Filled content",
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(filledSlot.content).toBe("Filled content")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("FeedItem with empty slots record", () => {
|
|
||||||
const item: FeedItem<"test", { value: number }> = {
|
|
||||||
id: "test-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { value: 1 },
|
|
||||||
slots: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(item.slots).toEqual({})
|
|
||||||
expect(Object.keys(item.slots!)).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -23,20 +23,6 @@ export interface FeedItemSignals {
|
|||||||
timeRelevance?: TimeRelevance
|
timeRelevance?: TimeRelevance
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A named slot for LLM-fillable content on a feed item.
|
|
||||||
*
|
|
||||||
* Sources declare slots with a description that tells the LLM what content
|
|
||||||
* to generate. The enhancement harness fills `content` asynchronously;
|
|
||||||
* until then it remains `null`.
|
|
||||||
*/
|
|
||||||
export interface Slot {
|
|
||||||
/** Tells the LLM what this slot wants — written by the source */
|
|
||||||
description: string
|
|
||||||
/** LLM-filled text content, null until enhanced */
|
|
||||||
content: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single item in the feed.
|
* A single item in the feed.
|
||||||
*
|
*
|
||||||
@@ -50,12 +36,6 @@ export interface Slot {
|
|||||||
* timestamp: new Date(),
|
* timestamp: new Date(),
|
||||||
* data: { temp: 18, condition: "cloudy" },
|
* data: { temp: 18, condition: "cloudy" },
|
||||||
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||||
* slots: {
|
|
||||||
* insight: {
|
|
||||||
* description: "A short contextual insight about the current weather",
|
|
||||||
* content: null,
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -73,6 +53,4 @@ export interface FeedItem<
|
|||||||
data: TData
|
data: TData
|
||||||
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
|
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
|
||||||
signals?: FeedItemSignals
|
signals?: FeedItemSignals
|
||||||
/** Named slots for LLM-fillable content. Keys are slot names. */
|
|
||||||
slots?: Record<string, Slot>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Context
|
// Context
|
||||||
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context"
|
export type { Context, ContextKey } from "./context"
|
||||||
export { Context, contextKey, serializeKey } from "./context"
|
export { contextKey, contextValue } from "./context"
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
export type { ActionDefinition } from "./action"
|
export type { ActionDefinition } from "./action"
|
||||||
export { UnknownActionError } from "./action"
|
export { UnknownActionError } from "./action"
|
||||||
|
|
||||||
// Feed
|
// Feed
|
||||||
export type { FeedItem, FeedItemSignals, Slot } from "./feed"
|
export type { FeedItem, FeedItemSignals } from "./feed"
|
||||||
export { TimeRelevance } from "./feed"
|
export { TimeRelevance } from "./feed"
|
||||||
|
|
||||||
// Feed Source
|
// Feed Source
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ContextKey } from "@aris/core"
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
import { Context, contextKey } from "@aris/core"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||||
@@ -16,25 +15,14 @@ const mockCredentials = {
|
|||||||
serviceId: "mock",
|
serviceId: "mock",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
accuracy: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
|
||||||
|
|
||||||
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
||||||
fetch: async () => response,
|
fetch: async () => response,
|
||||||
})
|
})
|
||||||
|
|
||||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
|
||||||
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
time: new Date("2026-01-17T00:00:00Z"),
|
||||||
if (location) {
|
location: location ? { ...location, accuracy: 10 } : undefined,
|
||||||
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
|
})
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("WeatherKitDataSource", () => {
|
describe("WeatherKitDataSource", () => {
|
||||||
test("returns empty array when location is missing", async () => {
|
test("returns empty array when location is missing", async () => {
|
||||||
@@ -51,7 +39,7 @@ describe("WeatherKitDataSource", () => {
|
|||||||
credentials: mockCredentials,
|
credentials: mockCredentials,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(dataSource.type).toBe(WeatherFeedItemType.Current)
|
expect(dataSource.type).toBe(WeatherFeedItemType.current)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("throws error if neither client nor credentials provided", () => {
|
test("throws error if neither client nor credentials provided", () => {
|
||||||
@@ -142,9 +130,9 @@ describe("query() with mocked client", () => {
|
|||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
expect(items.length).toBeGreaterThan(0)
|
expect(items.length).toBeGreaterThan(0)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies hourly and daily limits", async () => {
|
test("applies hourly and daily limits", async () => {
|
||||||
@@ -157,8 +145,8 @@ describe("query() with mocked client", () => {
|
|||||||
|
|
||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
|
||||||
|
|
||||||
expect(hourlyItems.length).toBe(3)
|
expect(hourlyItems.length).toBe(3)
|
||||||
expect(dailyItems.length).toBe(2)
|
expect(dailyItems.length).toBe(2)
|
||||||
@@ -188,8 +176,8 @@ describe("query() with mocked client", () => {
|
|||||||
units: Units.imperial,
|
units: Units.imperial,
|
||||||
})
|
})
|
||||||
|
|
||||||
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current)
|
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current)
|
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
|
|
||||||
expect(metricCurrent).toBeDefined()
|
expect(metricCurrent).toBeDefined()
|
||||||
expect(imperialCurrent).toBeDefined()
|
expect(imperialCurrent).toBeDefined()
|
||||||
@@ -215,7 +203,7 @@ describe("query() with mocked client", () => {
|
|||||||
expect(item.signals!.timeRelevance).toBeDefined()
|
expect(item.signals!.timeRelevance).toBeDefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
expect(currentItem).toBeDefined()
|
expect(currentItem).toBeDefined()
|
||||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core"
|
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
|
||||||
|
|
||||||
import { TimeRelevance, contextKey } from "@aris/core"
|
import { TimeRelevance } from "@aris/core"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WeatherFeedItemType,
|
WeatherFeedItemType,
|
||||||
@@ -40,18 +40,11 @@ export interface WeatherKitQueryConfig {
|
|||||||
units?: Units
|
units?: Units
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
|
||||||
|
|
||||||
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||||
private readonly DEFAULT_HOURLY_LIMIT = 12
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||||
private readonly DEFAULT_DAILY_LIMIT = 7
|
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||||
|
|
||||||
readonly type = WeatherFeedItemType.Current
|
readonly type = WeatherFeedItemType.current
|
||||||
private readonly client: WeatherKitClient
|
private readonly client: WeatherKitClient
|
||||||
private readonly hourlyLimit: number
|
private readonly hourlyLimit: number
|
||||||
private readonly dailyLimit: number
|
private readonly dailyLimit: number
|
||||||
@@ -66,8 +59,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
}
|
}
|
||||||
|
|
||||||
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||||
const location = context.get(LocationKey)
|
if (!context.location) {
|
||||||
if (!location) {
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +67,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
const timestamp = context.time
|
const timestamp = context.time
|
||||||
|
|
||||||
const response = await this.client.fetch({
|
const response = await this.client.fetch({
|
||||||
lat: location.lat,
|
lat: context.location.lat,
|
||||||
lng: location.lng,
|
lng: context.location.lng,
|
||||||
})
|
})
|
||||||
|
|
||||||
const items: WeatherFeedItem[] = []
|
const items: WeatherFeedItem[] = []
|
||||||
@@ -236,7 +228,7 @@ function createCurrentWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.current,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
conditionCode: current.conditionCode,
|
conditionCode: current.conditionCode,
|
||||||
@@ -270,7 +262,7 @@ function createHourlyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Hourly,
|
type: WeatherFeedItemType.hourly,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
@@ -304,7 +296,7 @@ function createDailyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Daily,
|
type: WeatherFeedItemType.daily,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
@@ -331,7 +323,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
type: WeatherFeedItemType.Alert,
|
type: WeatherFeedItemType.alert,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
alertId: alert.id,
|
alertId: alert.id,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||||
|
|
||||||
export const WeatherFeedItemType = {
|
export const WeatherFeedItemType = {
|
||||||
Current: "weather-current",
|
current: "weather-current",
|
||||||
Hourly: "weather-hourly",
|
hourly: "weather-hourly",
|
||||||
Daily: "weather-daily",
|
daily: "weather-daily",
|
||||||
Alert: "weather-alert",
|
alert: "weather-alert",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||||
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentWeatherFeedItem extends FeedItem<
|
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Current,
|
typeof WeatherFeedItemType.current,
|
||||||
CurrentWeatherData
|
CurrentWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HourlyWeatherFeedItem extends FeedItem<
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Hourly,
|
typeof WeatherFeedItemType.hourly,
|
||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyWeatherFeedItem extends FeedItem<
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Daily,
|
typeof WeatherFeedItemType.daily,
|
||||||
DailyWeatherData
|
DailyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherAlertFeedItem extends FeedItem<
|
export interface WeatherAlertFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Alert,
|
typeof WeatherFeedItemType.alert,
|
||||||
WeatherAlertData
|
WeatherAlertData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aris/feed-enhancers",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test src/"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@aris/core": "workspace:*",
|
|
||||||
"@aris/source-caldav": "workspace:*",
|
|
||||||
"@aris/source-google-calendar": "workspace:*",
|
|
||||||
"@aris/source-tfl": "workspace:*",
|
|
||||||
"@aris/source-weatherkit": "workspace:*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createTimeOfDayEnhancer, type TimeOfDayEnhancerOptions } from "./time-of-day-enhancer.ts"
|
|
||||||
@@ -1,704 +0,0 @@
|
|||||||
import type { FeedItem, FeedItemSignals } from "@aris/core"
|
|
||||||
|
|
||||||
import { Context, TimeRelevance } from "@aris/core"
|
|
||||||
import { CalDavFeedItemType } from "@aris/source-caldav"
|
|
||||||
import { CalendarFeedItemType } from "@aris/source-google-calendar"
|
|
||||||
import { TflFeedItemType } from "@aris/source-tfl"
|
|
||||||
import { WeatherFeedItemType } from "@aris/source-weatherkit"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import {
|
|
||||||
createTimeOfDayEnhancer,
|
|
||||||
getTimePeriod,
|
|
||||||
getDayType,
|
|
||||||
TimePeriod,
|
|
||||||
DayType,
|
|
||||||
} from "./time-of-day-enhancer"
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Helpers
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function makeContext(date: Date): Context {
|
|
||||||
return new Context(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
|
|
||||||
return new Date(year, month - 1, day, hour, minute, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tuesday 2025-07-08 at given hour:minute */
|
|
||||||
function tuesday(hour: number, minute = 0): Date {
|
|
||||||
return makeDate(2025, 7, 8, hour, minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Saturday 2025-07-12 at given hour:minute */
|
|
||||||
function saturday(hour: number, minute = 0): Date {
|
|
||||||
return makeDate(2025, 7, 12, hour, minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
function weatherCurrent(id = "w-current"): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: WeatherFeedItemType.Current,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { temperature: 18, precipitationIntensity: 0 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: WeatherFeedItemType.Current,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { temperature: 12, precipitationIntensity: 2.5 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: WeatherFeedItemType.Current,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { temperature: -5, precipitationIntensity: 0 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function weatherHourly(id = "w-hourly"): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: WeatherFeedItemType.Hourly,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { forecastTime: new Date(), temperature: 20 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function weatherDaily(id = "w-daily"): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: WeatherFeedItemType.Daily,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { forecastDate: new Date() },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function weatherAlert(id = "w-alert", urgency = 0.9): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: WeatherFeedItemType.Alert,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { severity: "extreme" },
|
|
||||||
signals: { urgency, timeRelevance: TimeRelevance.Imminent },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calendarEvent(
|
|
||||||
id: string,
|
|
||||||
startTime: Date,
|
|
||||||
options: { location?: string; signals?: FeedItemSignals } = {},
|
|
||||||
): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: CalendarFeedItemType.Event,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: {
|
|
||||||
eventId: id,
|
|
||||||
calendarId: "primary",
|
|
||||||
title: `Event ${id}`,
|
|
||||||
description: null,
|
|
||||||
location: options.location ?? null,
|
|
||||||
startTime,
|
|
||||||
endTime: new Date(startTime.getTime() + 3_600_000),
|
|
||||||
isAllDay: false,
|
|
||||||
status: "confirmed",
|
|
||||||
htmlLink: "",
|
|
||||||
},
|
|
||||||
signals: options.signals,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calendarAllDay(id: string): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: CalendarFeedItemType.AllDay,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: {
|
|
||||||
eventId: id,
|
|
||||||
calendarId: "primary",
|
|
||||||
title: `All Day ${id}`,
|
|
||||||
description: null,
|
|
||||||
location: null,
|
|
||||||
startTime: new Date(),
|
|
||||||
endTime: new Date(),
|
|
||||||
isAllDay: true,
|
|
||||||
status: "confirmed",
|
|
||||||
htmlLink: "",
|
|
||||||
},
|
|
||||||
signals: { timeRelevance: TimeRelevance.Ambient },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function caldavEvent(
|
|
||||||
id: string,
|
|
||||||
startDate: Date,
|
|
||||||
options: { location?: string; signals?: FeedItemSignals } = {},
|
|
||||||
): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: CalDavFeedItemType.Event,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: {
|
|
||||||
uid: id,
|
|
||||||
title: `CalDAV ${id}`,
|
|
||||||
startDate,
|
|
||||||
endDate: new Date(startDate.getTime() + 3_600_000),
|
|
||||||
isAllDay: false,
|
|
||||||
location: options.location ?? null,
|
|
||||||
description: null,
|
|
||||||
calendarName: null,
|
|
||||||
status: "confirmed",
|
|
||||||
url: null,
|
|
||||||
organizer: null,
|
|
||||||
attendees: [],
|
|
||||||
alarms: [],
|
|
||||||
recurrenceId: null,
|
|
||||||
},
|
|
||||||
signals: options.signals,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: TflFeedItemType.Alert,
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: {
|
|
||||||
line: "northern",
|
|
||||||
lineName: "Northern",
|
|
||||||
severity: "major-delays",
|
|
||||||
description: "Delays",
|
|
||||||
},
|
|
||||||
signals: { urgency, timeRelevance: TimeRelevance.Imminent },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unknownItem(id = "unknown-1"): FeedItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: "some-future-type",
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { foo: "bar" },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Period detection
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("getTimePeriod", () => {
|
|
||||||
test("morning: 06:00–11:59", () => {
|
|
||||||
expect(getTimePeriod(tuesday(6))).toBe(TimePeriod.Morning)
|
|
||||||
expect(getTimePeriod(tuesday(8))).toBe(TimePeriod.Morning)
|
|
||||||
expect(getTimePeriod(tuesday(11, 59))).toBe(TimePeriod.Morning)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("afternoon: 12:00–16:59", () => {
|
|
||||||
expect(getTimePeriod(tuesday(12))).toBe(TimePeriod.Afternoon)
|
|
||||||
expect(getTimePeriod(tuesday(14))).toBe(TimePeriod.Afternoon)
|
|
||||||
expect(getTimePeriod(tuesday(16, 59))).toBe(TimePeriod.Afternoon)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("evening: 17:00–21:59", () => {
|
|
||||||
expect(getTimePeriod(tuesday(17))).toBe(TimePeriod.Evening)
|
|
||||||
expect(getTimePeriod(tuesday(19))).toBe(TimePeriod.Evening)
|
|
||||||
expect(getTimePeriod(tuesday(21, 59))).toBe(TimePeriod.Evening)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("night: 22:00–05:59", () => {
|
|
||||||
expect(getTimePeriod(tuesday(22))).toBe(TimePeriod.Night)
|
|
||||||
expect(getTimePeriod(tuesday(0))).toBe(TimePeriod.Night)
|
|
||||||
expect(getTimePeriod(tuesday(3))).toBe(TimePeriod.Night)
|
|
||||||
expect(getTimePeriod(tuesday(5, 59))).toBe(TimePeriod.Night)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("getDayType", () => {
|
|
||||||
test("weekday: Monday–Friday", () => {
|
|
||||||
// 2025-07-07 is Monday, 2025-07-08 is Tuesday, 2025-07-11 is Friday
|
|
||||||
expect(getDayType(makeDate(2025, 7, 7, 10))).toBe(DayType.Weekday)
|
|
||||||
expect(getDayType(tuesday(10))).toBe(DayType.Weekday)
|
|
||||||
expect(getDayType(makeDate(2025, 7, 11, 10))).toBe(DayType.Weekday)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("weekend: Saturday–Sunday", () => {
|
|
||||||
expect(getDayType(saturday(10))).toBe(DayType.Weekend)
|
|
||||||
expect(getDayType(makeDate(2025, 7, 13, 10))).toBe(DayType.Weekend) // Sunday
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Morning
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("morning weekday", () => {
|
|
||||||
const now = tuesday(8)
|
|
||||||
const ctx = makeContext(now)
|
|
||||||
|
|
||||||
test("boosts weather-current and weather-alert, demotes weather-hourly", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const items = [weatherCurrent(), weatherHourly(), weatherAlert()]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
|
||||||
expect(result.boost!["w-alert"]).toBeGreaterThan(0)
|
|
||||||
expect(result.boost!["w-hourly"]).toBeLessThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosts first calendar event of the day", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event1 = calendarEvent("c1", tuesday(9))
|
|
||||||
const event2 = calendarEvent("c2", tuesday(14))
|
|
||||||
const result = await enhancer([event1, event2], ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["c1"]).toBeGreaterThan(0)
|
|
||||||
// Second event should not get the first-event boost
|
|
||||||
expect(result.boost?.["c2"] ?? 0).toBeLessThanOrEqual(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosts TfL alerts", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const result = await enhancer([tflAlert()], ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["tfl-1"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("morning weekend", () => {
|
|
||||||
const now = saturday(9)
|
|
||||||
const ctx = makeContext(now)
|
|
||||||
|
|
||||||
test("boosts weather-current and weather-daily", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const items = [weatherCurrent(), weatherDaily()]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
|
||||||
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("demotes calendar events and TfL alerts", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", saturday(10))
|
|
||||||
const items = [event, tflAlert()]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["c1"]).toBeLessThan(0)
|
|
||||||
expect(result.boost!["tfl-1"]).toBeLessThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Afternoon
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("afternoon weekday", () => {
|
|
||||||
const now = tuesday(14)
|
|
||||||
const ctx = makeContext(now)
|
|
||||||
|
|
||||||
test("boosts imminent calendar events", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", tuesday(14, 10), {
|
|
||||||
signals: { timeRelevance: TimeRelevance.Imminent },
|
|
||||||
})
|
|
||||||
const result = await enhancer([event], ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["c1"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("demotes weather-current and weather-hourly", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const items = [weatherCurrent(), weatherHourly()]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["w-current"]).toBeLessThan(0)
|
|
||||||
expect(result.boost!["w-hourly"]).toBeLessThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("afternoon weekend", () => {
|
|
||||||
const now = saturday(14)
|
|
||||||
const ctx = makeContext(now)
|
|
||||||
|
|
||||||
test("boosts weather-current, demotes calendar events", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", saturday(15))
|
|
||||||
const items = [weatherCurrent(), event]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
|
||||||
expect(result.boost!["c1"]).toBeLessThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Evening
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("evening weekday", () => {
|
|
||||||
const now = tuesday(19)
|
|
||||||
const ctx = makeContext(now)
|
|
||||||
|
|
||||||
test("suppresses ambient work calendar events", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", tuesday(9), {
|
|
||||||
signals: { timeRelevance: TimeRelevance.Ambient },
|
|
||||||
})
|
|
||||||
const result = await enhancer([event], ctx)
|
|
||||||
|
|
||||||
expect(result.suppress).toContain("c1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("demotes TfL alerts", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const result = await enhancer([tflAlert()], ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["tfl-1"]).toBeLessThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosts weather-daily and all-day calendar events", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const items = [weatherDaily(), calendarAllDay("ad1")]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
|
|
||||||
expect(result.boost!["ad1"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("evening weekend", () => {
|
|
||||||
const now = saturday(19)
|
|
||||||
const ctx = makeContext(now)
|
|
||||||
|
|
||||||
test("boosts weather-current, suppresses ambient calendar events", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", saturday(9), {
|
|
||||||
signals: { timeRelevance: TimeRelevance.Ambient },
|
|
||||||
})
|
|
||||||
const items = [weatherCurrent(), event]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
|
||||||
expect(result.suppress).toContain("c1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("demotes TfL alerts more aggressively", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const result = await enhancer([tflAlert()], ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["tfl-1"]).toBeLessThan(-0.3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Night
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("night", () => {
|
|
||||||
const now = tuesday(23)
|
|
||||||
const ctx = makeContext(now)
|
|
||||||
|
|
||||||
test("suppresses ambient items", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", tuesday(9), {
|
|
||||||
signals: { timeRelevance: TimeRelevance.Ambient },
|
|
||||||
})
|
|
||||||
const result = await enhancer([event], ctx)
|
|
||||||
|
|
||||||
expect(result.suppress).toContain("c1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("demotes calendar events and weather-current", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", makeDate(2025, 7, 9, 9)) // tomorrow
|
|
||||||
const items = [event, weatherCurrent()]
|
|
||||||
const result = await enhancer(items, ctx)
|
|
||||||
|
|
||||||
expect(result.boost!["c1"]).toBeLessThan(0)
|
|
||||||
expect(result.boost!["w-current"]).toBeLessThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("high-urgency alerts survive unboosted", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const alert = weatherAlert("w-alert", 0.9)
|
|
||||||
const result = await enhancer([alert], ctx)
|
|
||||||
|
|
||||||
// Should not be demoted — either no boost entry or >= 0
|
|
||||||
const alertBoost = result.boost?.["w-alert"] ?? 0
|
|
||||||
expect(alertBoost).toBeGreaterThanOrEqual(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Pre-meeting window
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("pre-meeting window", () => {
|
|
||||||
test("boosts upcoming meeting to +0.9", async () => {
|
|
||||||
const now = tuesday(9, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(10))
|
|
||||||
const result = await enhancer([meeting], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["c1"]).toBe(0.9)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("suppresses low-urgency items during pre-meeting", async () => {
|
|
||||||
const now = tuesday(9, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(10))
|
|
||||||
const lowPriority = weatherHourly()
|
|
||||||
lowPriority.signals = { urgency: 0.1 }
|
|
||||||
const result = await enhancer([meeting, lowPriority], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.suppress).toContain("w-hourly")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not suppress items without signals during pre-meeting", async () => {
|
|
||||||
const now = tuesday(9, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(10))
|
|
||||||
const noSignals = weatherDaily()
|
|
||||||
const result = await enhancer([meeting, noSignals], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.suppress ?? []).not.toContain("w-daily")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosts TfL alerts during pre-meeting", async () => {
|
|
||||||
const now = tuesday(9, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(10))
|
|
||||||
const result = await enhancer([meeting, tflAlert()], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["tfl-1"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosts weather-current if meeting has a location", async () => {
|
|
||||||
const now = tuesday(9, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(10), { location: "Office, London" })
|
|
||||||
const result = await enhancer([meeting, weatherCurrent()], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("works with CalDAV events", async () => {
|
|
||||||
const now = tuesday(9, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = caldavEvent("cd1", tuesday(10))
|
|
||||||
const result = await enhancer([meeting], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["cd1"]).toBe(0.9)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not trigger for events more than 30 minutes away", async () => {
|
|
||||||
const now = tuesday(9)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(10))
|
|
||||||
const result = await enhancer([meeting], makeContext(now))
|
|
||||||
|
|
||||||
// Should not get the +0.9 pre-meeting boost
|
|
||||||
expect(result.boost?.["c1"] ?? 0).not.toBe(0.9)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Wind-down gradient
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("wind-down gradient", () => {
|
|
||||||
test("20:00 weekday: additional -0.1 on work items", async () => {
|
|
||||||
const now = tuesday(20)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
// Non-ambient calendar event — evening rules don't boost or suppress it,
|
|
||||||
// so the only demotion comes from wind-down at 20:00 (-0.1).
|
|
||||||
const event = calendarEvent("c1", makeDate(2025, 7, 9, 9))
|
|
||||||
const result = await enhancer([event], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["c1"]).toBe(-0.1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("21:00 weekday: additional -0.2 on work items", async () => {
|
|
||||||
const now = tuesday(21)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const alert = tflAlert("tfl-1", 0.5)
|
|
||||||
const result = await enhancer([alert], makeContext(now))
|
|
||||||
|
|
||||||
// Evening demotes TfL by -0.4, wind-down adds -0.2 = -0.6
|
|
||||||
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(-0.6)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("21:30 weekday: additional -0.3 on work items", async () => {
|
|
||||||
const now = tuesday(21, 30)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const alert = tflAlert("tfl-1", 0.5)
|
|
||||||
const result = await enhancer([alert], makeContext(now))
|
|
||||||
|
|
||||||
// Evening demotes TfL by -0.4, wind-down adds -0.3 = -0.7
|
|
||||||
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(-0.7)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not apply on weekends", async () => {
|
|
||||||
const now = saturday(21)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const alert = tflAlert("tfl-1", 0.5)
|
|
||||||
const result = await enhancer([alert], makeContext(now))
|
|
||||||
|
|
||||||
// Weekend evening demotes TfL by -0.5, but no wind-down
|
|
||||||
expect(result.boost!["tfl-1"]).toBe(-0.5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Transition lookahead
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("transition lookahead", () => {
|
|
||||||
test("Saturday 11:40 boosts afternoon-relevant weather-current", async () => {
|
|
||||||
const now = saturday(11, 40)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const items = [weatherCurrent()]
|
|
||||||
const result = await enhancer(items, makeContext(now))
|
|
||||||
|
|
||||||
// Weekend morning boosts weather-current by +0.5.
|
|
||||||
// Transition to afternoon adds +0.2 (weekend afternoon boosts weather-current).
|
|
||||||
expect(result.boost!["w-current"]).toBe(0.7)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("16:40 weekday boosts evening-relevant items (weather-daily)", async () => {
|
|
||||||
const now = tuesday(16, 40)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const items = [weatherDaily()]
|
|
||||||
const result = await enhancer(items, makeContext(now))
|
|
||||||
|
|
||||||
// Afternoon weekday doesn't boost weather-daily, but transition to evening does (+0.2)
|
|
||||||
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not apply when far from boundary", async () => {
|
|
||||||
const now = tuesday(14)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const items = [weatherDaily()]
|
|
||||||
const result = await enhancer(items, makeContext(now))
|
|
||||||
|
|
||||||
// Afternoon weekday doesn't boost or demote weather-daily, and no transition
|
|
||||||
expect(result.boost?.["w-daily"]).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Weather-time correlation
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("weather-time correlation", () => {
|
|
||||||
test("morning weekday: extra boost for precipitation", async () => {
|
|
||||||
const now = tuesday(8)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const rainy = weatherCurrentRainy()
|
|
||||||
const dry = weatherCurrent("w-dry")
|
|
||||||
const result = await enhancer([rainy, dry], makeContext(now))
|
|
||||||
|
|
||||||
// Both get morning boost, but rainy gets extra +0.1
|
|
||||||
expect(result.boost!["w-current-rain"]).toBeGreaterThan(result.boost!["w-dry"] ?? 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("morning weekday: extra boost for extreme temperature", async () => {
|
|
||||||
const now = tuesday(8)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const extreme = weatherCurrentExtreme()
|
|
||||||
const normal = weatherCurrent("w-normal")
|
|
||||||
const result = await enhancer([extreme, normal], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["w-current-extreme"]).toBeGreaterThan(result.boost!["w-normal"] ?? 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("evening with location event: extra boost for weather-current", async () => {
|
|
||||||
const now = tuesday(19)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const event = calendarEvent("c1", tuesday(19, 30), { location: "The Ivy, London" })
|
|
||||||
const items = [weatherCurrent(), event]
|
|
||||||
const result = await enhancer(items, makeContext(now))
|
|
||||||
|
|
||||||
// Weather-current gets evening weather-time correlation boost (+0.2)
|
|
||||||
// Note: evening weekday doesn't normally boost weather-current
|
|
||||||
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("weather-alert always gets at least +0.5", async () => {
|
|
||||||
const now = tuesday(14) // afternoon — no special weather boost
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const alert = weatherAlert("w-alert", 0.5)
|
|
||||||
const result = await enhancer([alert], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["w-alert"]).toBeGreaterThanOrEqual(0.5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Edge cases
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
test("empty items returns empty enhancement", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => tuesday(8) })
|
|
||||||
const result = await enhancer([], makeContext(tuesday(8)))
|
|
||||||
|
|
||||||
expect(result).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("unknown item types get no boost", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => tuesday(8) })
|
|
||||||
const result = await enhancer([unknownItem()], makeContext(tuesday(8)))
|
|
||||||
|
|
||||||
expect(result.boost?.["unknown-1"]).toBeUndefined()
|
|
||||||
expect(result.suppress).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses context.time when no clock provided", async () => {
|
|
||||||
const enhancer = createTimeOfDayEnhancer()
|
|
||||||
const morningCtx = makeContext(tuesday(8))
|
|
||||||
const items = [weatherCurrent()]
|
|
||||||
const result = await enhancer(items, morningCtx)
|
|
||||||
|
|
||||||
// Should apply morning rules — weather-current boosted
|
|
||||||
expect(result.boost!["w-current"]).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boost values are clamped to [-1, 1]", async () => {
|
|
||||||
// Morning weekday: TfL alert gets +0.6 from period rules.
|
|
||||||
// Pre-meeting adds +0.5. Total would be +1.1 without clamping.
|
|
||||||
const now = tuesday(8, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(9))
|
|
||||||
const alert = tflAlert("tfl-1", 0.8)
|
|
||||||
const result = await enhancer([meeting, alert], makeContext(now))
|
|
||||||
|
|
||||||
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(1)
|
|
||||||
expect(result.boost!["tfl-1"]).toBeGreaterThanOrEqual(-1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("suppress list is deduplicated", async () => {
|
|
||||||
// An item that would be suppressed by both evening rules and pre-meeting low-urgency
|
|
||||||
const now = tuesday(19, 45)
|
|
||||||
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
|
|
||||||
const meeting = calendarEvent("c1", tuesday(20))
|
|
||||||
const ambientEvent = calendarEvent("c2", tuesday(9), {
|
|
||||||
signals: { urgency: 0.1, timeRelevance: TimeRelevance.Ambient },
|
|
||||||
})
|
|
||||||
const result = await enhancer([meeting, ambientEvent], makeContext(now))
|
|
||||||
|
|
||||||
if (result.suppress) {
|
|
||||||
const c2Count = result.suppress.filter((id) => id === "c2").length
|
|
||||||
expect(c2Count).toBeLessThanOrEqual(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,595 +0,0 @@
|
|||||||
import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } from "@aris/core"
|
|
||||||
|
|
||||||
import { TimeRelevance } from "@aris/core"
|
|
||||||
|
|
||||||
import type { CalDavEventData } from "@aris/source-caldav"
|
|
||||||
import type { CalendarEventData } from "@aris/source-google-calendar"
|
|
||||||
import type { CurrentWeatherData } from "@aris/source-weatherkit"
|
|
||||||
|
|
||||||
import { CalDavFeedItemType } from "@aris/source-caldav"
|
|
||||||
import { CalendarFeedItemType } from "@aris/source-google-calendar"
|
|
||||||
import { TflFeedItemType } from "@aris/source-tfl"
|
|
||||||
import { WeatherFeedItemType } from "@aris/source-weatherkit"
|
|
||||||
|
|
||||||
|
|
||||||
export const TimePeriod = {
|
|
||||||
Morning: "morning",
|
|
||||||
Afternoon: "afternoon",
|
|
||||||
Evening: "evening",
|
|
||||||
Night: "night",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type TimePeriod = (typeof TimePeriod)[keyof typeof TimePeriod]
|
|
||||||
|
|
||||||
export const DayType = {
|
|
||||||
Weekday: "weekday",
|
|
||||||
Weekend: "weekend",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type DayType = (typeof DayType)[keyof typeof DayType]
|
|
||||||
|
|
||||||
|
|
||||||
const PRE_MEETING_WINDOW_MS = 30 * 60 * 1000
|
|
||||||
const TRANSITION_WINDOW_MS = 30 * 60 * 1000
|
|
||||||
|
|
||||||
const PERIOD_BOUNDARIES = [
|
|
||||||
{ hour: 6, period: TimePeriod.Morning },
|
|
||||||
{ hour: 12, period: TimePeriod.Afternoon },
|
|
||||||
{ hour: 17, period: TimePeriod.Evening },
|
|
||||||
{ hour: 22, period: TimePeriod.Night },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/** All calendar event types across sources */
|
|
||||||
const CALENDAR_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
||||||
CalendarFeedItemType.Event,
|
|
||||||
CalDavFeedItemType.Event,
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a post-processor that reranks feed items based on time of day.
|
|
||||||
*
|
|
||||||
* Prioritizes items that matter right now and pushes down items that don't:
|
|
||||||
*
|
|
||||||
* - Morning: weather and first meeting rise, hourly forecasts sink.
|
|
||||||
* Weekends flip — weather stays up but work calendar and commute alerts drop.
|
|
||||||
* - Afternoon: imminent meetings rise. Stale weather sinks.
|
|
||||||
* - Evening: work calendar is suppressed, tomorrow's forecast and personal
|
|
||||||
* events rise. Weekends suppress work more aggressively.
|
|
||||||
* - Night: almost everything sinks except high-urgency alerts.
|
|
||||||
* - Pre-meeting (30 min before any event): that meeting dominates, low-urgency
|
|
||||||
* noise is suppressed, commute/weather context rises if the meeting has a location.
|
|
||||||
* - Wind-down (weekday 20–22h): work items progressively sink as night approaches.
|
|
||||||
* - Transition lookahead (30 min before a period boundary): items relevant to
|
|
||||||
* the next period get a head start.
|
|
||||||
* - Weather-time correlation: precipitation boosts morning weather, evening
|
|
||||||
* events with locations boost current weather, alerts always stay high.
|
|
||||||
*/
|
|
||||||
export interface TimeOfDayEnhancerOptions {
|
|
||||||
/** Override clock for testing. Defaults to reading context.time. */
|
|
||||||
clock?: () => Date
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTimeOfDayEnhancer(options?: TimeOfDayEnhancerOptions): FeedPostProcessor {
|
|
||||||
const clock = options?.clock
|
|
||||||
|
|
||||||
function timeOfDayEnhancer(items: FeedItem[], context: Context): Promise<FeedEnhancement> {
|
|
||||||
if (items.length === 0) return Promise.resolve({})
|
|
||||||
|
|
||||||
const now = clock ? clock() : context.time
|
|
||||||
const period = getTimePeriod(now)
|
|
||||||
const dayType = getDayType(now)
|
|
||||||
const boost: Record<string, number> = {}
|
|
||||||
const suppress: string[] = []
|
|
||||||
|
|
||||||
// 1. Apply period-based rules
|
|
||||||
const firstEventId = findFirstEventOfDay(items, now)
|
|
||||||
|
|
||||||
switch (period) {
|
|
||||||
case TimePeriod.Morning:
|
|
||||||
if (dayType === DayType.Weekday) {
|
|
||||||
applyMorningWeekday(items, boost, firstEventId)
|
|
||||||
} else {
|
|
||||||
applyMorningWeekend(items, boost)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TimePeriod.Afternoon:
|
|
||||||
if (dayType === DayType.Weekday) {
|
|
||||||
applyAfternoonWeekday(items, boost)
|
|
||||||
} else {
|
|
||||||
applyAfternoonWeekend(items, boost)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TimePeriod.Evening:
|
|
||||||
if (dayType === DayType.Weekday) {
|
|
||||||
applyEveningWeekday(items, boost, suppress)
|
|
||||||
} else {
|
|
||||||
applyEveningWeekend(items, boost, suppress)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TimePeriod.Night:
|
|
||||||
applyNight(items, boost, suppress)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Pre-meeting overrides (can override period rules)
|
|
||||||
const preMeeting = detectPreMeetingItems(items, now)
|
|
||||||
applyPreMeetingOverrides(items, preMeeting, boost, suppress)
|
|
||||||
|
|
||||||
// 3. Wind-down gradient
|
|
||||||
applyWindDown(items, now, dayType, boost)
|
|
||||||
|
|
||||||
// 4. Transition lookahead
|
|
||||||
applyTransitionLookahead(items, now, period, dayType, boost)
|
|
||||||
|
|
||||||
// 5. Weather-time correlation
|
|
||||||
const eveningLocation = hasEveningCalendarEventWithLocation(items, now)
|
|
||||||
applyWeatherTimeCorrelation(items, period, dayType, eveningLocation, boost)
|
|
||||||
|
|
||||||
// Clamp boost values to [-1, 1] — additive layers can exceed the range
|
|
||||||
for (const id in boost) {
|
|
||||||
boost[id] = Math.max(-1, Math.min(1, boost[id]!))
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: FeedEnhancement = {}
|
|
||||||
if (Object.keys(boost).length > 0) {
|
|
||||||
result.boost = boost
|
|
||||||
}
|
|
||||||
const uniqueSuppress = [...new Set(suppress)]
|
|
||||||
if (uniqueSuppress.length > 0) {
|
|
||||||
result.suppress = uniqueSuppress
|
|
||||||
}
|
|
||||||
return Promise.resolve(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
return timeOfDayEnhancer
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function getTimePeriod(date: Date): TimePeriod {
|
|
||||||
const hour = date.getHours()
|
|
||||||
if (hour >= 22 || hour < 6) return TimePeriod.Night
|
|
||||||
if (hour >= 17) return TimePeriod.Evening
|
|
||||||
if (hour >= 12) return TimePeriod.Afternoon
|
|
||||||
return TimePeriod.Morning
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDayType(date: Date): DayType {
|
|
||||||
const day = date.getDay()
|
|
||||||
return day === 0 || day === 6 ? DayType.Weekend : DayType.Weekday
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the next period boundary as { hour, period } and the ms until it.
|
|
||||||
*/
|
|
||||||
function getNextPeriodBoundary(date: Date): { period: TimePeriod; msUntil: number } {
|
|
||||||
const hour = date.getHours()
|
|
||||||
const minuteMs = date.getMinutes() * 60_000 + date.getSeconds() * 1000 + date.getMilliseconds()
|
|
||||||
|
|
||||||
for (const boundary of PERIOD_BOUNDARIES) {
|
|
||||||
if (hour < boundary.hour) {
|
|
||||||
const msUntil = (boundary.hour - hour) * 3_600_000 - minuteMs
|
|
||||||
return { period: boundary.period, msUntil }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Past 22:00 — next boundary is morning at 06:00
|
|
||||||
const hoursUntil6 = (24 - hour + 6) * 3_600_000 - minuteMs
|
|
||||||
return { period: TimePeriod.Morning, msUntil: hoursUntil6 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract start time from calendar event data.
|
|
||||||
* Google Calendar uses `startTime`, CalDAV uses `startDate`.
|
|
||||||
*/
|
|
||||||
function getEventStartTime(data: CalendarEventData | CalDavEventData): Date {
|
|
||||||
return "startTime" in data ? (data as CalendarEventData).startTime : (data as CalDavEventData).startDate
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a current weather item indicates precipitation or extreme conditions.
|
|
||||||
* Only meaningful for weather-current items.
|
|
||||||
*/
|
|
||||||
function hasPrecipitationOrExtreme(item: FeedItem): boolean {
|
|
||||||
const data = item.data as CurrentWeatherData
|
|
||||||
if (data.precipitationIntensity > 0) return true
|
|
||||||
if (data.temperature < 0 || data.temperature > 35) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
interface PreMeetingInfo {
|
|
||||||
/** IDs of calendar items starting within the pre-meeting window */
|
|
||||||
upcomingMeetingIds: Set<string>
|
|
||||||
/** Whether any upcoming meeting has a location */
|
|
||||||
hasLocationMeeting: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectPreMeetingItems(items: FeedItem[], now: Date): PreMeetingInfo {
|
|
||||||
const nowMs = now.getTime()
|
|
||||||
const upcomingMeetingIds = new Set<string>()
|
|
||||||
let hasLocationMeeting = false
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
|
|
||||||
|
|
||||||
const data = item.data as CalendarEventData | CalDavEventData
|
|
||||||
const msUntil = getEventStartTime(data).getTime() - nowMs
|
|
||||||
if (msUntil > 0 && msUntil <= PRE_MEETING_WINDOW_MS) {
|
|
||||||
upcomingMeetingIds.add(item.id)
|
|
||||||
if (data.location) {
|
|
||||||
hasLocationMeeting = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { upcomingMeetingIds, hasLocationMeeting }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
|
|
||||||
let earliest: { id: string; time: number } | null = null
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
|
|
||||||
|
|
||||||
const data = item.data as CalendarEventData | CalDavEventData
|
|
||||||
const startTime = getEventStartTime(data)
|
|
||||||
const startMs = startTime.getTime()
|
|
||||||
|
|
||||||
// Must be today and in the future
|
|
||||||
const sameDay =
|
|
||||||
startTime.getFullYear() === now.getFullYear() &&
|
|
||||||
startTime.getMonth() === now.getMonth() &&
|
|
||||||
startTime.getDate() === now.getDate()
|
|
||||||
if (!sameDay) continue
|
|
||||||
if (startMs <= now.getTime()) continue
|
|
||||||
|
|
||||||
if (!earliest || startMs < earliest.time) {
|
|
||||||
earliest = { id: item.id, time: startMs }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return earliest?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function applyMorningWeekday(
|
|
||||||
items: FeedItem[],
|
|
||||||
boost: Record<string, number>,
|
|
||||||
firstEventId: string | null,
|
|
||||||
): void {
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.7
|
|
||||||
break
|
|
||||||
case WeatherFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.8
|
|
||||||
break
|
|
||||||
case WeatherFeedItemType.Hourly:
|
|
||||||
case WeatherFeedItemType.Daily:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.3
|
|
||||||
break
|
|
||||||
case TflFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.6
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstEventId) {
|
|
||||||
boost[firstEventId] = (boost[firstEventId] ?? 0) + 0.6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMorningWeekend(items: FeedItem[], boost: Record<string, number>): void {
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.5
|
|
||||||
break
|
|
||||||
case WeatherFeedItemType.Daily:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.4
|
|
||||||
break
|
|
||||||
case CalendarFeedItemType.Event:
|
|
||||||
case CalDavFeedItemType.Event:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.4
|
|
||||||
break
|
|
||||||
case TflFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.3
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAfternoonWeekday(items: FeedItem[], boost: Record<string, number>): void {
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case CalendarFeedItemType.Event:
|
|
||||||
case CalDavFeedItemType.Event:
|
|
||||||
if (item.signals?.timeRelevance === TimeRelevance.Imminent) {
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.5
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
case WeatherFeedItemType.Hourly:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.2
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAfternoonWeekend(items: FeedItem[], boost: Record<string, number>): void {
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
|
||||||
break
|
|
||||||
case CalendarFeedItemType.Event:
|
|
||||||
case CalDavFeedItemType.Event:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.5
|
|
||||||
break
|
|
||||||
case TflFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.2
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyEveningWeekday(
|
|
||||||
items: FeedItem[],
|
|
||||||
boost: Record<string, number>,
|
|
||||||
suppress: string[],
|
|
||||||
): void {
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case CalendarFeedItemType.Event:
|
|
||||||
case CalDavFeedItemType.Event:
|
|
||||||
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
|
|
||||||
suppress.push(item.id)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TflFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.4
|
|
||||||
break
|
|
||||||
case WeatherFeedItemType.Daily:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
|
||||||
break
|
|
||||||
case CalendarFeedItemType.AllDay:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyEveningWeekend(
|
|
||||||
items: FeedItem[],
|
|
||||||
boost: Record<string, number>,
|
|
||||||
suppress: string[],
|
|
||||||
): void {
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.3
|
|
||||||
break
|
|
||||||
case CalendarFeedItemType.Event:
|
|
||||||
case CalDavFeedItemType.Event:
|
|
||||||
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
|
|
||||||
suppress.push(item.id)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TflFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.5
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyNight(items: FeedItem[], boost: Record<string, number>, suppress: string[]): void {
|
|
||||||
for (const item of items) {
|
|
||||||
// Suppress all ambient items
|
|
||||||
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
|
|
||||||
suppress.push(item.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// High-urgency alerts survive unboosted
|
|
||||||
if (
|
|
||||||
(item.type === WeatherFeedItemType.Alert || item.type === TflFeedItemType.Alert) &&
|
|
||||||
(item.signals?.urgency ?? 0) >= 0.8
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Demote everything else
|
|
||||||
switch (item.type) {
|
|
||||||
case CalendarFeedItemType.Event:
|
|
||||||
case CalendarFeedItemType.AllDay:
|
|
||||||
case CalDavFeedItemType.Event:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.6
|
|
||||||
break
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
case WeatherFeedItemType.Hourly:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) - 0.5
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function applyPreMeetingOverrides(
|
|
||||||
items: FeedItem[],
|
|
||||||
preMeeting: PreMeetingInfo,
|
|
||||||
boost: Record<string, number>,
|
|
||||||
suppress: string[],
|
|
||||||
): void {
|
|
||||||
if (preMeeting.upcomingMeetingIds.size === 0) return
|
|
||||||
|
|
||||||
// Intentional override, not additive — the upcoming meeting should dominate
|
|
||||||
// regardless of what period rules assigned. Don't reorder this before period rules.
|
|
||||||
for (const meetingId of preMeeting.upcomingMeetingIds) {
|
|
||||||
boost[meetingId] = 0.9
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (preMeeting.upcomingMeetingIds.has(item.id)) continue
|
|
||||||
|
|
||||||
switch (item.type) {
|
|
||||||
case TflFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.5
|
|
||||||
break
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
if (preMeeting.hasLocationMeeting) {
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.4
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress items that explicitly declare low urgency.
|
|
||||||
// Items without signals are left alone — absence of urgency is not low urgency.
|
|
||||||
if (item.signals && item.signals.urgency !== undefined && item.signals.urgency < 0.3) {
|
|
||||||
suppress.push(item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWindDown(
|
|
||||||
items: FeedItem[],
|
|
||||||
now: Date,
|
|
||||||
dayType: DayType,
|
|
||||||
boost: Record<string, number>,
|
|
||||||
): void {
|
|
||||||
if (dayType !== DayType.Weekday) return
|
|
||||||
|
|
||||||
const hour = now.getHours()
|
|
||||||
const minutes = now.getMinutes()
|
|
||||||
|
|
||||||
if (hour < 20 || hour >= 22) return
|
|
||||||
|
|
||||||
// Gradient: 20:00 → -0.1, 21:00 → -0.2, 21:30+ → -0.3
|
|
||||||
let additionalDemotion: number
|
|
||||||
if (hour === 20) {
|
|
||||||
additionalDemotion = -0.1
|
|
||||||
} else if (hour === 21 && minutes < 30) {
|
|
||||||
additionalDemotion = -0.2
|
|
||||||
} else {
|
|
||||||
additionalDemotion = -0.3
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case CalendarFeedItemType.Event:
|
|
||||||
case CalendarFeedItemType.AllDay:
|
|
||||||
case CalDavFeedItemType.Event:
|
|
||||||
case TflFeedItemType.Alert:
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + additionalDemotion
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function applyTransitionLookahead(
|
|
||||||
items: FeedItem[],
|
|
||||||
now: Date,
|
|
||||||
currentPeriod: TimePeriod,
|
|
||||||
dayType: DayType,
|
|
||||||
boost: Record<string, number>,
|
|
||||||
): void {
|
|
||||||
const next = getNextPeriodBoundary(now)
|
|
||||||
if (next.msUntil > TRANSITION_WINDOW_MS) return
|
|
||||||
|
|
||||||
// Apply a +0.2 secondary boost to items that would be boosted in the next period
|
|
||||||
const nextPeriodBoost = getNextPeriodBoostTargets(next.period, dayType)
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (nextPeriodBoost.has(item.type)) {
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the set of item types that get boosted in a given period+dayType.
|
|
||||||
*/
|
|
||||||
function getNextPeriodBoostTargets(period: TimePeriod, dayType: DayType): ReadonlySet<string> {
|
|
||||||
const targets = new Set<string>()
|
|
||||||
|
|
||||||
switch (period) {
|
|
||||||
case TimePeriod.Morning:
|
|
||||||
targets.add(WeatherFeedItemType.Current)
|
|
||||||
if (dayType === DayType.Weekday) {
|
|
||||||
targets.add(WeatherFeedItemType.Alert)
|
|
||||||
targets.add(TflFeedItemType.Alert)
|
|
||||||
} else {
|
|
||||||
targets.add(WeatherFeedItemType.Daily)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TimePeriod.Afternoon:
|
|
||||||
if (dayType === DayType.Weekend) {
|
|
||||||
targets.add(WeatherFeedItemType.Current)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TimePeriod.Evening:
|
|
||||||
targets.add(WeatherFeedItemType.Daily)
|
|
||||||
if (dayType === DayType.Weekend) {
|
|
||||||
targets.add(WeatherFeedItemType.Current)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case TimePeriod.Night:
|
|
||||||
// Night doesn't boost much — transition toward night means demoting,
|
|
||||||
// which is handled by wind-down. No positive targets here.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return targets
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function applyWeatherTimeCorrelation(
|
|
||||||
items: FeedItem[],
|
|
||||||
period: TimePeriod,
|
|
||||||
dayType: DayType,
|
|
||||||
hasEveningEventWithLocation: boolean,
|
|
||||||
boost: Record<string, number>,
|
|
||||||
): void {
|
|
||||||
for (const item of items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case WeatherFeedItemType.Alert: {
|
|
||||||
const current = boost[item.id] ?? 0
|
|
||||||
if (current < 0.5) {
|
|
||||||
boost[item.id] = 0.5
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case WeatherFeedItemType.Current:
|
|
||||||
if (period === TimePeriod.Morning && dayType === DayType.Weekday && hasPrecipitationOrExtreme(item)) {
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.1
|
|
||||||
}
|
|
||||||
if (period === TimePeriod.Evening && hasEveningEventWithLocation) {
|
|
||||||
boost[item.id] = (boost[item.id] ?? 0) + 0.2
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasEveningCalendarEventWithLocation(items: FeedItem[], now: Date): boolean {
|
|
||||||
const todayEvening17 = new Date(now)
|
|
||||||
todayEvening17.setHours(17, 0, 0, 0)
|
|
||||||
const todayNight22 = new Date(now)
|
|
||||||
todayNight22.setHours(22, 0, 0, 0)
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
|
|
||||||
|
|
||||||
const data = item.data as CalendarEventData | CalDavEventData
|
|
||||||
const startMs = getEventStartTime(data).getTime()
|
|
||||||
if (startMs >= todayEvening17.getTime() && startMs < todayNight22.getTime()) {
|
|
||||||
if (data.location) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:daily-allday-001@test
|
|
||||||
DTSTART;VALUE=DATE:20260112
|
|
||||||
DTEND;VALUE=DATE:20260113
|
|
||||||
SUMMARY:Daily Reminder
|
|
||||||
RRULE:FREQ=DAILY;COUNT=7
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:weekly-exc-001@test
|
|
||||||
DTSTART:20260101T140000Z
|
|
||||||
DTEND:20260101T150000Z
|
|
||||||
SUMMARY:Standup
|
|
||||||
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=8
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:weekly-exc-001@test
|
|
||||||
RECURRENCE-ID:20260115T140000Z
|
|
||||||
DTSTART:20260115T160000Z
|
|
||||||
DTEND:20260115T170000Z
|
|
||||||
SUMMARY:Standup (rescheduled)
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:weekly-001@test
|
|
||||||
DTSTART:20260101T100000Z
|
|
||||||
DTEND:20260101T110000Z
|
|
||||||
SUMMARY:Weekly Team Meeting
|
|
||||||
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=10
|
|
||||||
LOCATION:Room B
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -3,15 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* bun run test-live.ts
|
* bun run test-live.ts
|
||||||
*
|
|
||||||
* Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdirSync, writeFileSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
|
|
||||||
import { Context } from "@aris/core"
|
|
||||||
|
|
||||||
import { CalDavSource } from "../src/index.ts"
|
import { CalDavSource } from "../src/index.ts"
|
||||||
|
|
||||||
const serverUrl = prompt("CalDAV server URL:")
|
const serverUrl = prompt("CalDAV server URL:")
|
||||||
@@ -34,7 +27,7 @@ const source = new CalDavSource({
|
|||||||
lookAheadDays,
|
lookAheadDays,
|
||||||
})
|
})
|
||||||
|
|
||||||
const context = new Context()
|
const context = { time: new Date() }
|
||||||
|
|
||||||
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
|
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
|
||||||
|
|
||||||
@@ -56,9 +49,6 @@ for (const item of items) {
|
|||||||
console.log(` Status: ${item.data.status ?? "(none)"}`)
|
console.log(` Status: ${item.data.status ?? "(none)"}`)
|
||||||
console.log(` Urgency: ${item.signals?.urgency}`)
|
console.log(` Urgency: ${item.signals?.urgency}`)
|
||||||
console.log(` Relevance: ${item.signals?.timeRelevance}`)
|
console.log(` Relevance: ${item.signals?.timeRelevance}`)
|
||||||
if (item.slots) {
|
|
||||||
console.log(` Slots: ${Object.keys(item.slots).join(", ")}`)
|
|
||||||
}
|
|
||||||
if (item.data.attendees.length > 0) {
|
if (item.data.attendees.length > 0) {
|
||||||
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
|
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
|
||||||
}
|
}
|
||||||
@@ -70,11 +60,3 @@ for (const item of items) {
|
|||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
console.log("(no events found in the time window)")
|
console.log("(no events found in the time window)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write feed items to .cache for slot testing
|
|
||||||
const cacheDir = join(import.meta.dir, ".cache")
|
|
||||||
mkdirSync(cacheDir, { recursive: true })
|
|
||||||
|
|
||||||
const outPath = join(cacheDir, "feed-items.json")
|
|
||||||
writeFileSync(outPath, JSON.stringify(items, null, 2))
|
|
||||||
console.log(`\nFeed items written to ${outPath}`)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ContextEntry } from "@aris/core"
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance } from "@aris/core"
|
import { TimeRelevance, contextValue } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { readFileSync } from "node:fs"
|
import { readFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
@@ -13,21 +13,14 @@ import type {
|
|||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
||||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
import { CalDavCalendarKey } from "./calendar-context.ts"
|
||||||
|
|
||||||
function loadFixture(name: string): string {
|
function loadFixture(name: string): string {
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
function createContext(time: Date): Context {
|
function createContext(time: Date): Context {
|
||||||
return new Context(time)
|
return { time }
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract the CalendarContext value from fetchContext entries. */
|
|
||||||
function extractCalendar(entries: readonly ContextEntry[] | null): CalendarContext | undefined {
|
|
||||||
if (!entries) return undefined
|
|
||||||
const entry = entries.find(([key]) => key === CalDavCalendarKey)
|
|
||||||
return entry?.[1] as CalendarContext | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockDAVClient implements CalDavDAVClient {
|
class MockDAVClient implements CalDavDAVClient {
|
||||||
@@ -208,7 +201,7 @@ describe("CalDavSource", () => {
|
|||||||
expect(items[0]!.data.calendarName).toBeNull()
|
expect(items[0]!.data.calendarName).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("expands recurring events within the time range", async () => {
|
test("handles recurring events with exceptions", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
"/cal/work": [
|
"/cal/work": [
|
||||||
{
|
{
|
||||||
@@ -218,42 +211,21 @@ describe("CalDavSource", () => {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
// lookAheadDays=0 → range is Jan 15 only
|
|
||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||||
|
|
||||||
// Only the Jan 15 occurrence falls in the single-day window
|
expect(items).toHaveLength(2)
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
expect(items[0]!.data.title).toBe("Weekly Sync")
|
|
||||||
expect(items[0]!.data.startDate).toEqual(new Date("2026-01-15T09:00:00Z"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes exception overrides when they fall in range", async () => {
|
const base = items.find((i) => i.data.title === "Weekly Sync")
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [
|
|
||||||
{
|
|
||||||
url: "/cal/work/recurring.ics",
|
|
||||||
data: loadFixture("recurring-event.ics"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
// lookAheadDays=8 → range covers Jan 15 through Jan 23, includes the Jan 22 exception
|
|
||||||
const source = createSource(client, 8)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
|
||||||
|
|
||||||
const base = items.filter((i) => i.data.title === "Weekly Sync")
|
|
||||||
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
|
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
|
||||||
|
|
||||||
// Jan 15 base occurrence
|
expect(base).toBeDefined()
|
||||||
expect(base.length).toBeGreaterThanOrEqual(1)
|
expect(base!.data.recurrenceId).toBeNull()
|
||||||
|
|
||||||
// Jan 22 exception replaces the base occurrence
|
|
||||||
expect(exception).toBeDefined()
|
expect(exception).toBeDefined()
|
||||||
expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
|
expect(exception!.data.recurrenceId).not.toBeNull()
|
||||||
expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z"))
|
expect(exception!.id).toContain("-")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("caches events within the same refresh cycle", async () => {
|
test("caches events within the same refresh cycle", async () => {
|
||||||
@@ -330,8 +302,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
test("returns empty context when no calendars exist", async () => {
|
test("returns empty context when no calendars exist", async () => {
|
||||||
const client = new MockDAVClient([], {})
|
const client = new MockDAVClient([], {})
|
||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||||
|
|
||||||
expect(calendar).toBeDefined()
|
expect(calendar).toBeDefined()
|
||||||
expect(calendar!.inProgress).toEqual([])
|
expect(calendar!.inProgress).toEqual([])
|
||||||
@@ -348,8 +320,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
// 14:30 is during the 14:00-15:00 event
|
// 14:30 is during the 14:00-15:00 event
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(1)
|
expect(calendar!.inProgress).toHaveLength(1)
|
||||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||||
@@ -363,8 +335,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
// 12:00 is before the 14:00 event
|
// 12:00 is before the 14:00 event
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
expect(calendar!.nextEvent).not.toBeNull()
|
expect(calendar!.nextEvent).not.toBeNull()
|
||||||
@@ -378,8 +350,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
expect(calendar!.nextEvent).toBeNull()
|
expect(calendar!.nextEvent).toBeNull()
|
||||||
@@ -397,8 +369,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||||
|
|
||||||
expect(calendar!.todayEventCount).toBe(2)
|
expect(calendar!.todayEventCount).toBe(2)
|
||||||
expect(calendar!.hasTodayEvents).toBe(true)
|
expect(calendar!.hasTodayEvents).toBe(true)
|
||||||
@@ -533,69 +505,3 @@ describe("computeSignals", () => {
|
|||||||
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("CalDavSource feed item slots", () => {
|
|
||||||
const EXPECTED_SLOT_NAMES = ["insight", "preparation", "crossSource"]
|
|
||||||
|
|
||||||
test("timed event has all three slots with null content", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
const item = items[0]!
|
|
||||||
expect(item.slots).toBeDefined()
|
|
||||||
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
|
||||||
|
|
||||||
for (const name of EXPECTED_SLOT_NAMES) {
|
|
||||||
const slot = item.slots![name]!
|
|
||||||
expect(slot.content).toBeNull()
|
|
||||||
expect(typeof slot.description).toBe("string")
|
|
||||||
expect(slot.description.length).toBeGreaterThan(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("all-day event has all three slots with null content", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
const item = items[0]!
|
|
||||||
expect(item.data.isAllDay).toBe(true)
|
|
||||||
expect(item.slots).toBeDefined()
|
|
||||||
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
|
||||||
|
|
||||||
for (const name of EXPECTED_SLOT_NAMES) {
|
|
||||||
expect(item.slots![name]!.content).toBeNull()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cancelled event has all three slots with null content", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/cancelled.ics", data: loadFixture("cancelled-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
const item = items[0]!
|
|
||||||
expect(item.data.status).toBe("cancelled")
|
|
||||||
expect(item.slots).toBeDefined()
|
|
||||||
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
|
||||||
|
|
||||||
for (const name of EXPECTED_SLOT_NAMES) {
|
|
||||||
expect(item.slots![name]!.content).toBeNull()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource, Slot } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
import { DAVClient } from "tsdav"
|
import { DAVClient } from "tsdav"
|
||||||
|
|
||||||
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
||||||
|
import { CalDavEventStatus } from "./types.ts"
|
||||||
|
|
||||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
import { parseICalEvents } from "./ical-parser.ts"
|
import { parseICalEvents } from "./ical-parser.ts"
|
||||||
import crossSourcePrompt from "./prompts/cross-source.txt"
|
|
||||||
import insightPrompt from "./prompts/insight.txt"
|
|
||||||
import preparationPrompt from "./prompts/preparation.txt"
|
|
||||||
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
|
||||||
|
|
||||||
// -- Source options --
|
// -- Source options --
|
||||||
|
|
||||||
@@ -96,20 +93,17 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
throw new UnknownActionError(actionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||||
const events = await this.fetchEvents(context)
|
const events = await this.fetchEvents(context)
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return [
|
return {
|
||||||
[
|
[CalDavCalendarKey]: {
|
||||||
CalDavCalendarKey,
|
inProgress: [],
|
||||||
{
|
nextEvent: null,
|
||||||
inProgress: [],
|
hasTodayEvents: false,
|
||||||
nextEvent: null,
|
todayEventCount: 0,
|
||||||
hasTodayEvents: false,
|
},
|
||||||
todayEventCount: 0,
|
}
|
||||||
},
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = context.time
|
const now = context.time
|
||||||
@@ -127,7 +121,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
|||||||
todayEventCount: events.length,
|
todayEventCount: events.length,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [[CalDavCalendarKey, calendarContext]]
|
return { [CalDavCalendarKey]: calendarContext }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
||||||
@@ -187,7 +181,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
|||||||
for (const obj of objects) {
|
for (const obj of objects) {
|
||||||
if (typeof obj.data !== "string") continue
|
if (typeof obj.data !== "string") continue
|
||||||
|
|
||||||
const events = parseICalEvents(obj.data, calendarName, { start, end })
|
const events = parseICalEvents(obj.data, calendarName)
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
allEvents.push(event)
|
allEvents.push(event)
|
||||||
}
|
}
|
||||||
@@ -343,21 +337,12 @@ export function computeSignals(
|
|||||||
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEventSlots(): Record<string, Slot> {
|
|
||||||
return {
|
|
||||||
insight: { description: insightPrompt, content: null },
|
|
||||||
preparation: { description: preparationPrompt, content: null },
|
|
||||||
crossSource: { description: crossSourcePrompt, content: null },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
||||||
return {
|
return {
|
||||||
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||||
type: CalDavFeedItemType.Event,
|
type: "caldav-event",
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
data: event,
|
data: event,
|
||||||
signals: computeSignals(event, now, timeZone),
|
signals: computeSignals(event, now, timeZone),
|
||||||
slots: createEventSlots(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ export interface CalendarContext {
|
|||||||
todayEventCount: number
|
todayEventCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar")
|
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")
|
||||||
|
|||||||
@@ -105,94 +105,3 @@ describe("parseICalEvents", () => {
|
|||||||
expect(events[0]!.status).toBe("cancelled")
|
expect(events[0]!.status).toBe("cancelled")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("parseICalEvents with timeRange (recurrence expansion)", () => {
|
|
||||||
test("expands weekly recurring event into occurrences within range", () => {
|
|
||||||
// weekly-recurring.ics: DTSTART 2026-01-01 (Thu), FREQ=WEEKLY;BYDAY=TH;COUNT=10
|
|
||||||
// Occurrences: Jan 1, 8, 15, 22, 29, Feb 5, 12, 19, 26, Mar 5
|
|
||||||
// Query window: Jan 14 – Jan 23 → should get Jan 15 and Jan 22
|
|
||||||
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), "Work", {
|
|
||||||
start: new Date("2026-01-14T00:00:00Z"),
|
|
||||||
end: new Date("2026-01-23T00:00:00Z"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(events).toHaveLength(2)
|
|
||||||
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T10:00:00Z"))
|
|
||||||
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T11:00:00Z"))
|
|
||||||
expect(events[1]!.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
|
|
||||||
expect(events[1]!.endDate).toEqual(new Date("2026-01-22T11:00:00Z"))
|
|
||||||
|
|
||||||
// All occurrences share the same UID and metadata
|
|
||||||
for (const event of events) {
|
|
||||||
expect(event.uid).toBe("weekly-001@test")
|
|
||||||
expect(event.title).toBe("Weekly Team Meeting")
|
|
||||||
expect(event.location).toBe("Room B")
|
|
||||||
expect(event.calendarName).toBe("Work")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns empty array when no occurrences fall in range", () => {
|
|
||||||
// Query window: Dec 2025 — before the first occurrence
|
|
||||||
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), null, {
|
|
||||||
start: new Date("2025-12-01T00:00:00Z"),
|
|
||||||
end: new Date("2025-12-31T00:00:00Z"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(events).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("applies exception overrides during expansion", () => {
|
|
||||||
// weekly-recurring-with-exception.ics:
|
|
||||||
// Master: DTSTART 2026-01-01 (Thu) 14:00, FREQ=WEEKLY;BYDAY=TH;COUNT=8
|
|
||||||
// Exception: RECURRENCE-ID 2026-01-15T14:00 → moved to 16:00-17:00, title changed
|
|
||||||
// Query window: Jan 14 – Jan 16 → should get the exception occurrence for Jan 15
|
|
||||||
const events = parseICalEvents(loadFixture("weekly-recurring-with-exception.ics"), "Work", {
|
|
||||||
start: new Date("2026-01-14T00:00:00Z"),
|
|
||||||
end: new Date("2026-01-16T00:00:00Z"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
expect(events[0]!.title).toBe("Standup (rescheduled)")
|
|
||||||
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T16:00:00Z"))
|
|
||||||
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T17:00:00Z"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("expands recurring all-day events", () => {
|
|
||||||
// daily-recurring-allday.ics: DTSTART 2026-01-12, FREQ=DAILY;COUNT=7
|
|
||||||
// Occurrences: Jan 12, 13, 14, 15, 16, 17, 18
|
|
||||||
// Query window: Jan 14 – Jan 17 → should get Jan 14, 15, 16
|
|
||||||
const events = parseICalEvents(loadFixture("daily-recurring-allday.ics"), null, {
|
|
||||||
start: new Date("2026-01-14T00:00:00Z"),
|
|
||||||
end: new Date("2026-01-17T00:00:00Z"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(events).toHaveLength(3)
|
|
||||||
for (const event of events) {
|
|
||||||
expect(event.isAllDay).toBe(true)
|
|
||||||
expect(event.title).toBe("Daily Reminder")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("non-recurring events are filtered by range", () => {
|
|
||||||
// single-event.ics: 2026-01-15T14:00 – 15:00
|
|
||||||
// Query window that includes it
|
|
||||||
const included = parseICalEvents(loadFixture("single-event.ics"), null, {
|
|
||||||
start: new Date("2026-01-15T00:00:00Z"),
|
|
||||||
end: new Date("2026-01-16T00:00:00Z"),
|
|
||||||
})
|
|
||||||
expect(included).toHaveLength(1)
|
|
||||||
|
|
||||||
// Query window that excludes it
|
|
||||||
const excluded = parseICalEvents(loadFixture("single-event.ics"), null, {
|
|
||||||
start: new Date("2026-01-16T00:00:00Z"),
|
|
||||||
end: new Date("2026-01-17T00:00:00Z"),
|
|
||||||
})
|
|
||||||
expect(excluded).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("without timeRange, recurring events return raw VEVENTs (legacy)", () => {
|
|
||||||
// Legacy behavior: no expansion, just returns the VEVENT components as-is
|
|
||||||
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
|
|
||||||
expect(events).toHaveLength(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -9,172 +9,21 @@ import {
|
|||||||
type CalDavEventData,
|
type CalDavEventData,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
export interface ICalTimeRange {
|
|
||||||
start: Date
|
|
||||||
end: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a raw iCalendar string and extracts VEVENT components
|
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||||
* into CalDavEventData objects.
|
* into CalDavEventData objects.
|
||||||
*
|
*
|
||||||
* When a timeRange is provided, recurring events are expanded into
|
|
||||||
* individual occurrences within that range. Without a timeRange,
|
|
||||||
* each VEVENT component is returned as-is (legacy behavior).
|
|
||||||
*
|
|
||||||
* @param icsData - Raw iCalendar string from a CalDAV response
|
* @param icsData - Raw iCalendar string from a CalDAV response
|
||||||
* @param calendarName - Display name of the calendar this event belongs to
|
* @param calendarName - Display name of the calendar this event belongs to
|
||||||
* @param timeRange - When set, expand recurrences and filter to this window
|
|
||||||
*/
|
*/
|
||||||
export function parseICalEvents(
|
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
|
||||||
icsData: string,
|
|
||||||
calendarName: string | null,
|
|
||||||
timeRange?: ICalTimeRange,
|
|
||||||
): CalDavEventData[] {
|
|
||||||
const jcal = ICAL.parse(icsData)
|
const jcal = ICAL.parse(icsData)
|
||||||
const comp = new ICAL.Component(jcal)
|
const comp = new ICAL.Component(jcal)
|
||||||
const vevents = comp.getAllSubcomponents("vevent")
|
const vevents = comp.getAllSubcomponents("vevent")
|
||||||
|
|
||||||
if (!timeRange) {
|
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
||||||
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
parseVEvent(vevent, calendarName),
|
||||||
parseVEvent(vevent, calendarName),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group VEVENTs by UID: master + exceptions
|
|
||||||
const byUid = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
master: InstanceType<typeof ICAL.Component> | null
|
|
||||||
exceptions: InstanceType<typeof ICAL.Component>[]
|
|
||||||
}
|
|
||||||
>()
|
|
||||||
|
|
||||||
for (const vevent of vevents as InstanceType<typeof ICAL.Component>[]) {
|
|
||||||
const uid = vevent.getFirstPropertyValue("uid") as string | null
|
|
||||||
if (!uid) continue
|
|
||||||
|
|
||||||
const hasRecurrenceId = vevent.getFirstPropertyValue("recurrence-id") !== null
|
|
||||||
let group = byUid.get(uid)
|
|
||||||
if (!group) {
|
|
||||||
group = { master: null, exceptions: [] }
|
|
||||||
byUid.set(uid, group)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasRecurrenceId) {
|
|
||||||
group.exceptions.push(vevent)
|
|
||||||
} else {
|
|
||||||
group.master = vevent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: CalDavEventData[] = []
|
|
||||||
const rangeStart = ICAL.Time.fromJSDate(timeRange.start, true)
|
|
||||||
const rangeEnd = ICAL.Time.fromJSDate(timeRange.end, true)
|
|
||||||
|
|
||||||
for (const group of byUid.values()) {
|
|
||||||
if (!group.master) {
|
|
||||||
// Orphan exceptions — parse them directly if they fall in range
|
|
||||||
for (const exc of group.exceptions) {
|
|
||||||
const parsed = parseVEvent(exc, calendarName)
|
|
||||||
if (overlapsRange(parsed, timeRange)) {
|
|
||||||
results.push(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const masterEvent = new ICAL.Event(group.master)
|
|
||||||
|
|
||||||
// Register exceptions so getOccurrenceDetails resolves them
|
|
||||||
for (const exc of group.exceptions) {
|
|
||||||
masterEvent.relateException(exc)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!masterEvent.isRecurring()) {
|
|
||||||
const parsed = parseVEvent(group.master, calendarName)
|
|
||||||
if (overlapsRange(parsed, timeRange)) {
|
|
||||||
results.push(parsed)
|
|
||||||
}
|
|
||||||
// Also include standalone exceptions for non-recurring events
|
|
||||||
for (const exc of group.exceptions) {
|
|
||||||
const parsedExc = parseVEvent(exc, calendarName)
|
|
||||||
if (overlapsRange(parsedExc, timeRange)) {
|
|
||||||
results.push(parsedExc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand recurring event occurrences within the time range
|
|
||||||
const iter = masterEvent.iterator()
|
|
||||||
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
|
|
||||||
|
|
||||||
while (next) {
|
|
||||||
// Stop once we're past the range end
|
|
||||||
if (next.compare(rangeEnd) >= 0) break
|
|
||||||
|
|
||||||
const details = masterEvent.getOccurrenceDetails(next)
|
|
||||||
const occEnd = details.endDate
|
|
||||||
|
|
||||||
// Skip occurrences that end before the range starts
|
|
||||||
if (occEnd.compare(rangeStart) <= 0) {
|
|
||||||
next = iter.next()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const occEvent = details.item
|
|
||||||
const occComponent = occEvent.component
|
|
||||||
|
|
||||||
const parsed = parseVEventWithDates(
|
|
||||||
occComponent,
|
|
||||||
calendarName,
|
|
||||||
details.startDate.toJSDate(),
|
|
||||||
details.endDate.toJSDate(),
|
|
||||||
details.recurrenceId ? details.recurrenceId.toString() : null,
|
|
||||||
)
|
|
||||||
results.push(parsed)
|
|
||||||
|
|
||||||
next = iter.next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlapsRange(event: CalDavEventData, range: ICalTimeRange): boolean {
|
|
||||||
return event.startDate < range.end && event.endDate > range.start
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a VEVENT component, overriding start/end/recurrenceId with
|
|
||||||
* values from recurrence expansion.
|
|
||||||
*/
|
|
||||||
function parseVEventWithDates(
|
|
||||||
vevent: InstanceType<typeof ICAL.Component>,
|
|
||||||
calendarName: string | null,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date,
|
|
||||||
recurrenceId: string | null,
|
|
||||||
): CalDavEventData {
|
|
||||||
const event = new ICAL.Event(vevent)
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid: event.uid ?? "",
|
|
||||||
title: event.summary ?? "",
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
isAllDay: event.startDate?.isDate ?? false,
|
|
||||||
location: event.location ?? null,
|
|
||||||
description: event.description ?? null,
|
|
||||||
calendarName,
|
|
||||||
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
|
||||||
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
|
||||||
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
|
||||||
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
|
||||||
alarms: parseAlarms(vevent),
|
|
||||||
recurrenceId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVEvent(
|
function parseVEvent(
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
||||||
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
|
export { parseICalEvents } from "./ical-parser.ts"
|
||||||
export {
|
export {
|
||||||
AttendeeRole,
|
AttendeeRole,
|
||||||
AttendeeStatus,
|
AttendeeStatus,
|
||||||
CalDavEventStatus,
|
CalDavEventStatus,
|
||||||
CalDavFeedItemType,
|
|
||||||
type CalDavAlarm,
|
type CalDavAlarm,
|
||||||
type CalDavAttendee,
|
type CalDavAttendee,
|
||||||
type CalDavDAVCalendar,
|
type CalDavDAVCalendar,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
If other feed data (weather, transit, nearby events) would disrupt or materially affect this event, state the connection in one sentence. Infer whether the event is indoor/outdoor/virtual from the title and location. Weather is only relevant if it affects getting to the event or the activity itself (e.g., rain for outdoor events, extreme conditions for physical activities). Return null for indoor or virtual events where weather has no impact. Do not fabricate information you don't have — only reference data present in the feed.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "rain expected at 5pm — bring an umbrella for the walk to Tooley Street"
|
|
||||||
- "Northern line has delays — leave 15 minutes early"
|
|
||||||
- "your next event is across town — the 40 min gap may not be enough"
|
|
||||||
- null (indoor guitar class with wind outside — weather doesn't affect the event)
|
|
||||||
- null
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
One sentence of actionable insight the user can't already see from the event title, time, and location. Do not restate event details. Do not fabricate information you don't have. Return null if there's nothing non-obvious to say.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "you have 2 hours free before this starts"
|
|
||||||
- "all 8 attendees accepted — expect a full room"
|
|
||||||
- "third time this has been rescheduled"
|
|
||||||
- null
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
A concrete preparation step — something the user should do, bring, or review before this event. Infer only from available event and feed data. Do not restate event details. Do not fabricate information you don't have. Return null if no useful preparation comes to mind.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "different building from your previous meeting — allow travel time"
|
|
||||||
- "recurring meeting you declined last week — check if you need to attend"
|
|
||||||
- null
|
|
||||||
4
packages/aris-source-caldav/src/text.d.ts
vendored
4
packages/aris-source-caldav/src/text.d.ts
vendored
@@ -1,4 +0,0 @@
|
|||||||
declare module "*.txt" {
|
|
||||||
const content: string
|
|
||||||
export default content
|
|
||||||
}
|
|
||||||
@@ -64,17 +64,9 @@ export interface CalDavEventData extends Record<string, unknown> {
|
|||||||
recurrenceId: string | null
|
recurrenceId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Feed item type --
|
|
||||||
|
|
||||||
export const CalDavFeedItemType = {
|
|
||||||
Event: "caldav-event",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type CalDavFeedItemType = (typeof CalDavFeedItemType)[keyof typeof CalDavFeedItemType]
|
|
||||||
|
|
||||||
// -- Feed item --
|
// -- Feed item --
|
||||||
|
|
||||||
export type CalDavFeedItem = FeedItem<typeof CalDavFeedItemType.Event, CalDavEventData>
|
export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
|
||||||
|
|
||||||
// -- DAV client interface --
|
// -- DAV client interface --
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ export interface NextEvent {
|
|||||||
location: string | null
|
location: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { CalendarEventData } from "./types"
|
import type { CalendarEventData } from "./types"
|
||||||
|
|
||||||
export const CalendarFeedItemType = {
|
export const CalendarFeedItemType = {
|
||||||
Event: "calendar-event",
|
event: "calendar-event",
|
||||||
AllDay: "calendar-all-day",
|
allDay: "calendar-all-day",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
|
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
|
||||||
|
|
||||||
export interface CalendarEventFeedItem extends FeedItem<
|
export interface CalendarEventFeedItem extends FeedItem<
|
||||||
typeof CalendarFeedItemType.Event,
|
typeof CalendarFeedItemType.event,
|
||||||
CalendarEventData
|
CalendarEventData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export interface CalendarAllDayFeedItem extends FeedItem<
|
export interface CalendarAllDayFeedItem extends FeedItem<
|
||||||
typeof CalendarFeedItemType.AllDay,
|
typeof CalendarFeedItemType.allDay,
|
||||||
CalendarEventData
|
CalendarEventData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Context, TimeRelevance } from "@aris/core"
|
import { TimeRelevance, contextValue, type Context } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||||
|
|
||||||
import fixture from "../fixtures/events.json"
|
import fixture from "../fixtures/events.json"
|
||||||
import { NextEventKey, type NextEvent } from "./calendar-context"
|
import { NextEventKey } from "./calendar-context"
|
||||||
import { CalendarFeedItemType } from "./feed-items"
|
import { CalendarFeedItemType } from "./feed-items"
|
||||||
import { GoogleCalendarSource } from "./google-calendar-source"
|
import { GoogleCalendarSource } from "./google-calendar-source"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createContext(time?: Date): Context {
|
function createContext(time?: Date): Context {
|
||||||
return new Context(time ?? NOW)
|
return { time: time ?? NOW }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("GoogleCalendarSource", () => {
|
describe("GoogleCalendarSource", () => {
|
||||||
@@ -69,7 +69,7 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.Event)
|
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
|
||||||
expect(timedItems.length).toBe(4)
|
expect(timedItems.length).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.AllDay)
|
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
|
||||||
expect(allDayItems.length).toBe(1)
|
expect(allDayItems.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -229,16 +229,15 @@ describe("GoogleCalendarSource", () => {
|
|||||||
|
|
||||||
test("returns next upcoming timed event (not ongoing)", async () => {
|
test("returns next upcoming timed event (not ongoing)", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const entries = await source.fetchContext(createContext())
|
const result = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(entries).toHaveLength(1)
|
const nextEvent = contextValue(result! as Context, NextEventKey)
|
||||||
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
expect(nextEvent).toBeDefined()
|
||||||
expect(key).toEqual(NextEventKey)
|
|
||||||
// evt-soon starts at 10:10, which is the nearest future timed event
|
// evt-soon starts at 10:10, which is the nearest future timed event
|
||||||
expect(nextEvent.title).toBe("1:1 with Manager")
|
expect(nextEvent!.title).toBe("1:1 with Manager")
|
||||||
expect(nextEvent.minutesUntilStart).toBe(10)
|
expect(nextEvent!.minutesUntilStart).toBe(10)
|
||||||
expect(nextEvent.location).toBeNull()
|
expect(nextEvent!.location).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes location when available", async () => {
|
test("includes location when available", async () => {
|
||||||
@@ -256,11 +255,12 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({
|
const source = new GoogleCalendarSource({
|
||||||
client: createMockClient({ primary: events }),
|
client: createMockClient({ primary: events }),
|
||||||
})
|
})
|
||||||
const entries = await source.fetchContext(createContext())
|
const result = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
const nextEvent = contextValue(result! as Context, NextEventKey)
|
||||||
expect(nextEvent.location).toBe("123 Main St")
|
expect(nextEvent).toBeDefined()
|
||||||
|
expect(nextEvent!.location).toBe("123 Main St")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips ongoing events for next-event context", async () => {
|
test("skips ongoing events for next-event context", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiCalendarEvent,
|
ApiCalendarEvent,
|
||||||
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
|
|||||||
* .register(calendarSource)
|
* .register(calendarSource)
|
||||||
*
|
*
|
||||||
* // Access next-event context in downstream sources
|
* // Access next-event context in downstream sources
|
||||||
* const next = context.get(NextEventKey)
|
* const next = contextValue(context, NextEventKey)
|
||||||
* if (next && next.minutesUntilStart < 15) {
|
* if (next && next.minutesUntilStart < 15) {
|
||||||
* // remind user
|
* // remind user
|
||||||
* }
|
* }
|
||||||
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
throw new UnknownActionError(actionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||||
const events = await this.fetchAllEvents(context.time)
|
const events = await this.fetchAllEvents(context.time)
|
||||||
|
|
||||||
const now = context.time.getTime()
|
const now = context.time.getTime()
|
||||||
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
location: nextTimedEvent.location,
|
location: nextTimedEvent.location,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [[NextEventKey, nextEvent]]
|
return { [NextEventKey]: nextEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||||
@@ -209,7 +209,7 @@ function createFeedItem(
|
|||||||
nowMs: number,
|
nowMs: number,
|
||||||
lookaheadMs: number,
|
lookaheadMs: number,
|
||||||
): CalendarFeedItem {
|
): CalendarFeedItem {
|
||||||
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
|
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { NextEventKey, type NextEvent } from "./calendar-context"
|
export { NextEventKey, type NextEvent } from "./calendar-context"
|
||||||
export {
|
export {
|
||||||
CalendarFeedItemType,
|
CalendarFeedItemType,
|
||||||
|
type CalendarFeedItemType as CalendarFeedItemTypeType,
|
||||||
type CalendarAllDayFeedItem,
|
type CalendarAllDayFeedItem,
|
||||||
type CalendarEventFeedItem,
|
type CalendarEventFeedItem,
|
||||||
type CalendarFeedItem,
|
type CalendarFeedItem,
|
||||||
@@ -9,6 +10,7 @@ export { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
|||||||
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
|
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
|
||||||
export {
|
export {
|
||||||
EventStatus,
|
EventStatus,
|
||||||
|
type EventStatus as EventStatusType,
|
||||||
type ApiCalendarEvent,
|
type ApiCalendarEvent,
|
||||||
type ApiEventDateTime,
|
type ApiEventDateTime,
|
||||||
type CalendarEventData,
|
type CalendarEventData,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test"
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
import type { Location } from "./types.ts"
|
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
|
||||||
|
|
||||||
import { LocationKey, LocationSource } from "./location-source.ts"
|
|
||||||
|
|
||||||
function createLocation(overrides: Partial<Location> = {}): Location {
|
function createLocation(overrides: Partial<Location> = {}): Location {
|
||||||
return {
|
return {
|
||||||
@@ -41,8 +39,8 @@ describe("LocationSource", () => {
|
|||||||
const location = createLocation()
|
const location = createLocation()
|
||||||
source.pushLocation(location)
|
source.pushLocation(location)
|
||||||
|
|
||||||
const entries = await source.fetchContext()
|
const context = await source.fetchContext()
|
||||||
expect(entries).toEqual([[LocationKey, location]])
|
expect(context).toEqual({ [LocationKey]: location })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,7 +65,7 @@ describe("LocationSource", () => {
|
|||||||
source.pushLocation(location)
|
source.pushLocation(location)
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1)
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
expect(listener).toHaveBeenCalledWith([[LocationKey, location]])
|
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, UnknownActionError, contextKey, type ContextKey } from "@aris/core"
|
import { UnknownActionError, contextKey, type ContextKey } from "@aris/core"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
import { Location, type LocationSourceOptions } from "./types.ts"
|
import { Location, type LocationSourceOptions } from "./types.ts"
|
||||||
|
|
||||||
export const LocationKey: ContextKey<Location> = contextKey("aris.location", "location")
|
export const LocationKey: ContextKey<Location> = contextKey("location")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A FeedSource that provides location context.
|
* A FeedSource that provides location context.
|
||||||
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
|
|||||||
|
|
||||||
private readonly historySize: number
|
private readonly historySize: number
|
||||||
private locations: Location[] = []
|
private locations: Location[] = []
|
||||||
private listeners = new Set<(entries: readonly ContextEntry[]) => void>()
|
private listeners = new Set<(update: Partial<Context>) => void>()
|
||||||
|
|
||||||
constructor(options: LocationSourceOptions = {}) {
|
constructor(options: LocationSourceOptions = {}) {
|
||||||
this.historySize = options.historySize ?? 1
|
this.historySize = options.historySize ?? 1
|
||||||
@@ -59,9 +59,8 @@ export class LocationSource implements FeedSource {
|
|||||||
if (this.locations.length > this.historySize) {
|
if (this.locations.length > this.historySize) {
|
||||||
this.locations.shift()
|
this.locations.shift()
|
||||||
}
|
}
|
||||||
const entries: readonly ContextEntry[] = [[LocationKey, location]]
|
|
||||||
this.listeners.forEach((listener) => {
|
this.listeners.forEach((listener) => {
|
||||||
listener(entries)
|
listener({ [LocationKey]: location })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +78,16 @@ export class LocationSource implements FeedSource {
|
|||||||
return this.locations
|
return this.locations
|
||||||
}
|
}
|
||||||
|
|
||||||
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void {
|
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
|
||||||
this.listeners.add(callback)
|
this.listeners.add(callback)
|
||||||
return () => {
|
return () => {
|
||||||
this.listeners.delete(callback)
|
this.listeners.delete(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<Partial<Context> | null> {
|
||||||
if (this.lastLocation) {
|
if (this.lastLocation) {
|
||||||
return [[LocationKey, this.lastLocation]]
|
return { [LocationKey]: this.lastLocation }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
export { TflSource } from "./tfl-source.ts"
|
export { TflSource } from "./tfl-source.ts"
|
||||||
export { TflApi } from "./tfl-api.ts"
|
export { TflApi } from "./tfl-api.ts"
|
||||||
export type { TflLineId } from "./tfl-api.ts"
|
export type { TflLineId } from "./tfl-api.ts"
|
||||||
export {
|
export type {
|
||||||
TflFeedItemType,
|
ITflApi,
|
||||||
type ITflApi,
|
StationLocation,
|
||||||
type StationLocation,
|
TflAlertData,
|
||||||
type TflAlertData,
|
TflAlertFeedItem,
|
||||||
type TflAlertFeedItem,
|
TflAlertSeverity,
|
||||||
type TflAlertSeverity,
|
TflLineStatus,
|
||||||
type TflLineStatus,
|
TflSourceOptions,
|
||||||
type TflSourceOptions,
|
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Context } from "@aris/core"
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
import { LocationKey, type Location } from "@aris/source-location"
|
import { LocationKey, type Location } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
@@ -80,9 +81,9 @@ class FixtureTflApi implements ITflApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createContext(location?: Location): Context {
|
function createContext(location?: Location): Context {
|
||||||
const ctx = new Context(new Date("2026-01-15T12:00:00Z"))
|
const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
|
||||||
if (location) {
|
if (location) {
|
||||||
ctx.set([[LocationKey, location]])
|
ctx[LocationKey] = location
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { TflApi, lineId } from "./tfl-api.ts"
|
import { TflApi, lineId } from "./tfl-api.ts"
|
||||||
import { TflFeedItemType } from "./types.ts"
|
|
||||||
|
|
||||||
const setLinesInput = lineId.array()
|
const setLinesInput = lineId.array()
|
||||||
|
|
||||||
@@ -112,7 +111,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<null> {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +128,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
this.client.fetchStations(),
|
this.client.fetchStations(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
|
|
||||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||||
const closestStationDistance = location
|
const closestStationDistance = location
|
||||||
@@ -151,7 +150,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
type: TflFeedItemType.Alert,
|
type: "tfl-alert",
|
||||||
timestamp: context.time,
|
timestamp: context.time,
|
||||||
data,
|
data,
|
||||||
signals,
|
signals,
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ export interface TflAlertData extends Record<string, unknown> {
|
|||||||
closestStationDistance: number | null
|
closestStationDistance: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TflFeedItemType = {
|
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
||||||
Alert: "tfl-alert",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
|
||||||
|
|
||||||
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
|
||||||
|
|
||||||
export interface TflSourceOptions {
|
export interface TflSourceOptions {
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||||
|
|
||||||
export const WeatherFeedItemType = {
|
export const WeatherFeedItemType = {
|
||||||
Current: "weather-current",
|
current: "weather-current",
|
||||||
Hourly: "weather-hourly",
|
hourly: "weather-hourly",
|
||||||
Daily: "weather-daily",
|
daily: "weather-daily",
|
||||||
Alert: "weather-alert",
|
alert: "weather-alert",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||||
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentWeatherFeedItem extends FeedItem<
|
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Current,
|
typeof WeatherFeedItemType.current,
|
||||||
CurrentWeatherData
|
CurrentWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HourlyWeatherFeedItem extends FeedItem<
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Hourly,
|
typeof WeatherFeedItemType.hourly,
|
||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyWeatherFeedItem extends FeedItem<
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Daily,
|
typeof WeatherFeedItemType.daily,
|
||||||
DailyWeatherData
|
DailyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherAlertFeedItem extends FeedItem<
|
export interface WeatherAlertFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Alert,
|
typeof WeatherFeedItemType.alert,
|
||||||
WeatherAlertData
|
WeatherAlertData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
export { WeatherKey, type Weather } from "./weather-context"
|
export { WeatherKey, type Weather } from "./weather-context"
|
||||||
export { WeatherSource, Units, type WeatherSourceOptions } from "./weather-source"
|
export {
|
||||||
|
WeatherSource,
|
||||||
|
Units,
|
||||||
|
type Units as UnitsType,
|
||||||
|
type WeatherSourceOptions,
|
||||||
|
} from "./weather-source"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
WeatherFeedItemType,
|
WeatherFeedItemType,
|
||||||
|
type WeatherFeedItemType as WeatherFeedItemTypeType,
|
||||||
type WeatherFeedItem,
|
type WeatherFeedItem,
|
||||||
type CurrentWeatherFeedItem,
|
type CurrentWeatherFeedItem,
|
||||||
type CurrentWeatherData,
|
type CurrentWeatherData,
|
||||||
@@ -21,6 +27,11 @@ export {
|
|||||||
Certainty,
|
Certainty,
|
||||||
PrecipitationType,
|
PrecipitationType,
|
||||||
DefaultWeatherKitClient,
|
DefaultWeatherKitClient,
|
||||||
|
type ConditionCode as ConditionCodeType,
|
||||||
|
type Severity as SeverityType,
|
||||||
|
type Urgency as UrgencyType,
|
||||||
|
type Certainty as CertaintyType,
|
||||||
|
type PrecipitationType as PrecipitationTypeType,
|
||||||
type WeatherKitClient,
|
type WeatherKitClient,
|
||||||
type WeatherKitCredentials,
|
type WeatherKitCredentials,
|
||||||
type WeatherKitQueryOptions,
|
type WeatherKitQueryOptions,
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export interface Weather {
|
|||||||
daylight: boolean
|
daylight: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "weather")
|
export const WeatherKey: ContextKey<Weather> = contextKey("weather")
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { FeedSource } from "@aris/core"
|
import { contextValue, type Context } from "@aris/core"
|
||||||
|
|
||||||
import { Context } from "@aris/core"
|
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
@@ -8,7 +6,7 @@ import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
|||||||
|
|
||||||
import fixture from "../fixtures/san-francisco.json"
|
import fixture from "../fixtures/san-francisco.json"
|
||||||
import { WeatherFeedItemType } from "./feed-items"
|
import { WeatherFeedItemType } from "./feed-items"
|
||||||
import { WeatherKey, type Weather } from "./weather-context"
|
import { WeatherKey } from "./weather-context"
|
||||||
import { WeatherSource, Units } from "./weather-source"
|
import { WeatherSource, Units } from "./weather-source"
|
||||||
|
|
||||||
const mockCredentials = {
|
const mockCredentials = {
|
||||||
@@ -25,9 +23,9 @@ function createMockClient(response: WeatherKitResponse): WeatherKitClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
function createMockContext(location?: { lat: number; lng: number }): Context {
|
||||||
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
|
||||||
if (location) {
|
if (location) {
|
||||||
ctx.set([[LocationKey, { ...location, accuracy: 10, timestamp: new Date() }]])
|
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -65,19 +63,18 @@ describe("WeatherSource", () => {
|
|||||||
const source = new WeatherSource({ client: mockClient })
|
const source = new WeatherSource({ client: mockClient })
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const entries = await source.fetchContext(context)
|
const result = await source.fetchContext(context)
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(entries).toHaveLength(1)
|
const weather = contextValue(result! as Context, WeatherKey)
|
||||||
|
|
||||||
const [key, weather] = entries![0]! as [typeof WeatherKey, Weather]
|
expect(weather).toBeDefined()
|
||||||
expect(key).toEqual(WeatherKey)
|
expect(typeof weather!.temperature).toBe("number")
|
||||||
expect(typeof weather.temperature).toBe("number")
|
expect(typeof weather!.temperatureApparent).toBe("number")
|
||||||
expect(typeof weather.temperatureApparent).toBe("number")
|
expect(typeof weather!.condition).toBe("string")
|
||||||
expect(typeof weather.condition).toBe("string")
|
expect(typeof weather!.humidity).toBe("number")
|
||||||
expect(typeof weather.humidity).toBe("number")
|
expect(typeof weather!.uvIndex).toBe("number")
|
||||||
expect(typeof weather.uvIndex).toBe("number")
|
expect(typeof weather!.windSpeed).toBe("number")
|
||||||
expect(typeof weather.windSpeed).toBe("number")
|
expect(typeof weather!.daylight).toBe("boolean")
|
||||||
expect(typeof weather.daylight).toBe("boolean")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("converts temperature to imperial", async () => {
|
test("converts temperature to imperial", async () => {
|
||||||
@@ -87,12 +84,12 @@ describe("WeatherSource", () => {
|
|||||||
})
|
})
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const entries = await source.fetchContext(context)
|
const result = await source.fetchContext(context)
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
|
const weather = contextValue(result! as Context, WeatherKey)
|
||||||
|
|
||||||
const [, weather] = entries![0]! as [typeof WeatherKey, Weather]
|
|
||||||
// Fixture has temperature around 10°C, imperial should be around 50°F
|
// Fixture has temperature around 10°C, imperial should be around 50°F
|
||||||
expect(weather.temperature).toBeGreaterThan(40)
|
expect(weather!.temperature).toBeGreaterThan(40)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -113,9 +110,9 @@ describe("WeatherSource", () => {
|
|||||||
const items = await source.fetchItems(context)
|
const items = await source.fetchItems(context)
|
||||||
|
|
||||||
expect(items.length).toBeGreaterThan(0)
|
expect(items.length).toBeGreaterThan(0)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies hourly and daily limits", async () => {
|
test("applies hourly and daily limits", async () => {
|
||||||
@@ -128,8 +125,8 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
const items = await source.fetchItems(context)
|
const items = await source.fetchItems(context)
|
||||||
|
|
||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
|
||||||
|
|
||||||
expect(hourlyItems.length).toBe(3)
|
expect(hourlyItems.length).toBe(3)
|
||||||
expect(dailyItems.length).toBe(2)
|
expect(dailyItems.length).toBe(2)
|
||||||
@@ -161,7 +158,7 @@ describe("WeatherSource", () => {
|
|||||||
expect(item.signals!.timeRelevance).toBeDefined()
|
expect(item.signals!.timeRelevance).toBeDefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
expect(currentItem).toBeDefined()
|
expect(currentItem).toBeDefined()
|
||||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
})
|
})
|
||||||
@@ -180,12 +177,12 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
describe("no reactive methods", () => {
|
describe("no reactive methods", () => {
|
||||||
test("does not implement onContextUpdate", () => {
|
test("does not implement onContextUpdate", () => {
|
||||||
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
|
const source = new WeatherSource({ credentials: mockCredentials })
|
||||||
expect(source.onContextUpdate).toBeUndefined()
|
expect(source.onContextUpdate).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not implement onItemsUpdate", () => {
|
test("does not implement onItemsUpdate", () => {
|
||||||
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
|
const source = new WeatherSource({ credentials: mockCredentials })
|
||||||
expect(source.onItemsUpdate).toBeUndefined()
|
expect(source.onItemsUpdate).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
|
|
||||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||||
@@ -86,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
|||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Access weather context in downstream sources
|
* // Access weather context in downstream sources
|
||||||
* const weather = context.get(WeatherKey)
|
* const weather = contextValue(context, WeatherKey)
|
||||||
* if (weather?.condition === "Rain") {
|
* if (weather?.condition === "Rain") {
|
||||||
* // suggest umbrella
|
* // suggest umbrella
|
||||||
* }
|
* }
|
||||||
@@ -119,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
throw new UnknownActionError(actionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
daylight: response.currentWeather.daylight,
|
daylight: response.currentWeather.daylight,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [[WeatherKey, weather]]
|
return { [WeatherKey]: weather }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ function createCurrentWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.current,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
conditionCode: current.conditionCode,
|
conditionCode: current.conditionCode,
|
||||||
@@ -325,7 +325,7 @@ function createHourlyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Hourly,
|
type: WeatherFeedItemType.hourly,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
@@ -359,7 +359,7 @@ function createDailyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Daily,
|
type: WeatherFeedItemType.daily,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
@@ -386,7 +386,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
type: WeatherFeedItemType.Alert,
|
type: WeatherFeedItemType.alert,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
alertId: alert.id,
|
alertId: alert.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user