mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
1 Commits
feat/waitl
...
feat/gpg-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
507ea29eb8
|
@@ -7,11 +7,6 @@ BETTER_AUTH_SECRET=
|
|||||||
# Base URL of the backend
|
# Base URL of the backend
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# OpenRouter (LLM feed enhancement)
|
|
||||||
OPENROUTER_API_KEY=
|
|
||||||
# Optional: override the default model (default: openai/gpt-4.1-mini)
|
|
||||||
# OPENROUTER_MODEL=openai/gpt-4.1-mini
|
|
||||||
|
|
||||||
# Apple WeatherKit credentials
|
# Apple WeatherKit credentials
|
||||||
WEATHERKIT_PRIVATE_KEY=
|
WEATHERKIT_PRIVATE_KEY=
|
||||||
WEATHERKIT_KEY_ID=
|
WEATHERKIT_KEY_ID=
|
||||||
|
|||||||
@@ -10,12 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
"@aris/source-caldav": "workspace:*",
|
|
||||||
"@aris/source-google-calendar": "workspace:*",
|
|
||||||
"@aris/source-location": "workspace:*",
|
"@aris/source-location": "workspace:*",
|
||||||
"@aris/source-tfl": "workspace:*",
|
"@aris/source-tfl": "workspace:*",
|
||||||
"@aris/source-weatherkit": "workspace:*",
|
"@aris/source-weatherkit": "workspace:*",
|
||||||
"@openrouter/sdk": "^0.9.11",
|
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
import type { LlmClient } from "./llm-client.ts"
|
|
||||||
|
|
||||||
import { mergeEnhancement } from "./merge.ts"
|
|
||||||
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
|
||||||
|
|
||||||
/** Takes feed items, returns enhanced feed items. */
|
|
||||||
export type FeedEnhancer = (items: FeedItem[]) => Promise<FeedItem[]>
|
|
||||||
|
|
||||||
export interface FeedEnhancerConfig {
|
|
||||||
client: LlmClient
|
|
||||||
/** Defaults to Date.now — override for testing */
|
|
||||||
clock?: () => Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a FeedEnhancer that uses the provided LlmClient.
|
|
||||||
*
|
|
||||||
* Skips the LLM call when no items have unfilled slots.
|
|
||||||
* Returns items unchanged on LLM failure.
|
|
||||||
*/
|
|
||||||
export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
|
|
||||||
const { client } = config
|
|
||||||
const clock = config.clock ?? (() => new Date())
|
|
||||||
|
|
||||||
return async function enhanceFeed(items) {
|
|
||||||
if (!hasUnfilledSlots(items)) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = clock()
|
|
||||||
const { systemPrompt, userMessage } = buildPrompt(items, currentTime)
|
|
||||||
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = await client.enhance({ systemPrompt, userMessage })
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[enhancement] LLM call failed:", err)
|
|
||||||
result = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeEnhancement(items, result, currentTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { OpenRouter } from "@openrouter/sdk"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = "openai/gpt-4.1-mini"
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
export interface LlmClientConfig {
|
|
||||||
apiKey: string
|
|
||||||
model?: string
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LlmClientRequest {
|
|
||||||
systemPrompt: string
|
|
||||||
userMessage: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LlmClient {
|
|
||||||
enhance(request: LlmClientRequest): Promise<EnhancementResult | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a reusable LLM client backed by OpenRouter.
|
|
||||||
* The OpenRouter SDK instance is created once and reused across calls.
|
|
||||||
*/
|
|
||||||
export function createLlmClient(config: LlmClientConfig): LlmClient {
|
|
||||||
const client = new OpenRouter({
|
|
||||||
apiKey: config.apiKey,
|
|
||||||
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
})
|
|
||||||
const model = config.model ?? DEFAULT_MODEL
|
|
||||||
|
|
||||||
return {
|
|
||||||
async enhance(request) {
|
|
||||||
const response = await client.chat.send({
|
|
||||||
chatGenerationParams: {
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
{ role: "system" as const, content: request.systemPrompt },
|
|
||||||
{ role: "user" as const, content: request.userMessage },
|
|
||||||
],
|
|
||||||
responseFormat: {
|
|
||||||
type: "json_schema" as const,
|
|
||||||
jsonSchema: {
|
|
||||||
name: "enhancement_result",
|
|
||||||
strict: true,
|
|
||||||
schema: enhancementResultJsonSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stream: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const content = response.choices?.[0]?.message?.content
|
|
||||||
if (typeof content !== "string") {
|
|
||||||
console.warn("[enhancement] LLM returned no content in response")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(content)
|
|
||||||
if (!result) {
|
|
||||||
console.warn("[enhancement] Failed to parse LLM response:", content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
import { mergeEnhancement } from "./merge.ts"
|
|
||||||
|
|
||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
|
||||||
return {
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date("2025-06-01T12:00:00Z")
|
|
||||||
|
|
||||||
describe("mergeEnhancement", () => {
|
|
||||||
test("fills matching slots", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "Weather insight", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {
|
|
||||||
"item-1": { insight: "Rain after 3pm" },
|
|
||||||
},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBe("Rain after 3pm")
|
|
||||||
// Description preserved
|
|
||||||
expect(merged[0]!.slots!.insight!.description).toBe("Weather insight")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not mutate original items", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { insight: "filled" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(item.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores fills for non-existent items", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "non-existent": { insight: "text" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]!.id).toBe("item-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores fills for non-existent slots", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { "non-existent-slot": "text" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("skips null fills", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { insight: null } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes through items without slots unchanged", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]).toBe(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("appends synthetic items with backfilled fields", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [
|
|
||||||
{
|
|
||||||
id: "briefing-morning",
|
|
||||||
type: "briefing",
|
|
||||||
text: "Light afternoon ahead.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(2)
|
|
||||||
expect(merged[1]!.id).toBe("briefing-morning")
|
|
||||||
expect(merged[1]!.type).toBe("briefing")
|
|
||||||
expect(merged[1]!.timestamp).toEqual(now)
|
|
||||||
expect(merged[1]!.data).toEqual({ text: "Light afternoon ahead." })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles empty enhancement result", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]).toBe(item)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges an EnhancementResult into feed items.
|
|
||||||
*
|
|
||||||
* - Writes slot content from slotFills into matching items
|
|
||||||
* - Appends synthetic items to the list
|
|
||||||
* - Returns a new array (no mutation)
|
|
||||||
* - Ignores fills for items/slots that don't exist
|
|
||||||
*/
|
|
||||||
export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] {
|
|
||||||
const merged = items.map((item) => {
|
|
||||||
const fills = result.slotFills[item.id]
|
|
||||||
if (!fills || !item.slots) return item
|
|
||||||
|
|
||||||
const mergedSlots = { ...item.slots }
|
|
||||||
let changed = false
|
|
||||||
|
|
||||||
for (const [slotName, content] of Object.entries(fills)) {
|
|
||||||
if (slotName in mergedSlots && content !== null) {
|
|
||||||
mergedSlots[slotName] = { ...mergedSlots[slotName]!, content }
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? { ...item, slots: mergedSlots } : item
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const synthetic of result.syntheticItems) {
|
|
||||||
merged.push({
|
|
||||||
id: synthetic.id,
|
|
||||||
type: synthetic.type,
|
|
||||||
timestamp: currentTime,
|
|
||||||
data: { text: synthetic.text },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
|
||||||
|
|
||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
|
||||||
return {
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUserMessage(userMessage: string): Record<string, unknown> {
|
|
||||||
return JSON.parse(userMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("hasUnfilledSlots", () => {
|
|
||||||
test("returns false for items without slots", () => {
|
|
||||||
expect(hasUnfilledSlots([makeItem()])).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false for items with all slots filled", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: "filled" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(hasUnfilledSlots([item])).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns true when at least one slot is unfilled", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(hasUnfilledSlots([item])).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false for empty array", () => {
|
|
||||||
expect(hasUnfilledSlots([])).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("buildPrompt", () => {
|
|
||||||
test("puts items with unfilled slots in items", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "Weather insight", content: null },
|
|
||||||
filled: { description: "Already done", content: "done" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([item], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.items).toHaveLength(1)
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
|
|
||||||
expect(parsed.context).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("puts slotless items in context", () => {
|
|
||||||
const withSlots = makeItem({
|
|
||||||
id: "with-slots",
|
|
||||||
slots: { insight: { description: "test", content: null } },
|
|
||||||
})
|
|
||||||
const withoutSlots = makeItem({ id: "no-slots" })
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([withSlots, withoutSlots], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.items).toHaveLength(1)
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("with-slots")
|
|
||||||
expect(parsed.context).toHaveLength(1)
|
|
||||||
expect((parsed.context as Array<Record<string, unknown>>)[0]!.id).toBe("no-slots")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes time in ISO format", () => {
|
|
||||||
const { userMessage } = buildPrompt([], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.time).toBe("2025-06-01T12:00:00.000Z")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("system prompt is non-empty", () => {
|
|
||||||
const { systemPrompt } = buildPrompt([], new Date())
|
|
||||||
expect(systemPrompt.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes schedule in system prompt", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Team standup",
|
|
||||||
startDate: "2025-06-01T10:00:00Z",
|
|
||||||
endDate: "2025-06-01T10:30:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: null,
|
|
||||||
},
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Schedule:\n")
|
|
||||||
expect(systemPrompt).toContain("Team standup")
|
|
||||||
expect(systemPrompt).toContain("10:00")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes location in schedule", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Therapy",
|
|
||||||
startDate: "2025-06-02T18:00:00Z",
|
|
||||||
endDate: "2025-06-02T19:00:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: "92 Tooley Street, London",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Therapy @ 92 Tooley Street, London")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes week calendar but omits schedule when no calendar items", () => {
|
|
||||||
const weatherItem = makeItem({
|
|
||||||
type: "weather-current",
|
|
||||||
data: { temperature: 14 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([weatherItem], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Week:")
|
|
||||||
expect(systemPrompt).not.toContain("Schedule:")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("user message is pure JSON", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Budget Review",
|
|
||||||
startTime: "2025-06-01T14:00:00Z",
|
|
||||||
endTime: "2025-06-01T15:00:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: "https://meet.google.com/abc",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(userMessage.startsWith("{")).toBe(true)
|
|
||||||
expect(() => JSON.parse(userMessage)).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
import { CalDavFeedItemType } from "@aris/source-caldav"
|
|
||||||
import { CalendarFeedItemType } from "@aris/source-google-calendar"
|
|
||||||
|
|
||||||
import systemPromptBase from "./prompts/system.txt"
|
|
||||||
|
|
||||||
const CALENDAR_ITEM_TYPES = new Set<string>([
|
|
||||||
CalDavFeedItemType.Event,
|
|
||||||
CalendarFeedItemType.Event,
|
|
||||||
CalendarFeedItemType.AllDay,
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the system prompt and user message for the enhancement harness.
|
|
||||||
*
|
|
||||||
* Includes a pre-computed mini calendar so the LLM doesn't have to
|
|
||||||
* parse timestamps to understand the user's schedule.
|
|
||||||
*/
|
|
||||||
export function buildPrompt(
|
|
||||||
items: FeedItem[],
|
|
||||||
currentTime: Date,
|
|
||||||
): { systemPrompt: string; userMessage: string } {
|
|
||||||
const schedule = buildSchedule(items, currentTime)
|
|
||||||
|
|
||||||
const enhanceItems: Array<{
|
|
||||||
id: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
slots: Record<string, string>
|
|
||||||
}> = []
|
|
||||||
const contextItems: Array<{
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const hasUnfilledSlots =
|
|
||||||
item.slots &&
|
|
||||||
Object.values(item.slots).some((slot) => slot.content === null)
|
|
||||||
|
|
||||||
if (hasUnfilledSlots) {
|
|
||||||
enhanceItems.push({
|
|
||||||
id: item.id,
|
|
||||||
data: item.data,
|
|
||||||
slots: Object.fromEntries(
|
|
||||||
Object.entries(item.slots!)
|
|
||||||
.filter(([, slot]) => slot.content === null)
|
|
||||||
.map(([name, slot]) => [name, slot.description]),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
contextItems.push({
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
data: item.data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMessage = JSON.stringify({
|
|
||||||
time: currentTime.toISOString(),
|
|
||||||
items: enhanceItems,
|
|
||||||
context: contextItems,
|
|
||||||
})
|
|
||||||
|
|
||||||
const weekCalendar = buildWeekCalendar(currentTime)
|
|
||||||
let systemPrompt = systemPromptBase
|
|
||||||
systemPrompt += `\n\nWeek:\n${weekCalendar}`
|
|
||||||
if (schedule) {
|
|
||||||
systemPrompt += `\n\nSchedule:\n${schedule}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return { systemPrompt, userMessage }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if any item has at least one unfilled slot.
|
|
||||||
*/
|
|
||||||
export function hasUnfilledSlots(items: FeedItem[]): boolean {
|
|
||||||
return items.some(
|
|
||||||
(item) =>
|
|
||||||
item.slots &&
|
|
||||||
Object.values(item.slots).some((slot) => slot.content === null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Helpers --
|
|
||||||
|
|
||||||
interface CalendarEntry {
|
|
||||||
date: Date
|
|
||||||
title: string
|
|
||||||
location: string | null
|
|
||||||
isAllDay: boolean
|
|
||||||
startTime: Date
|
|
||||||
endTime: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
function toValidDate(value: unknown): Date | null {
|
|
||||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
|
||||||
const date = new Date(value)
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
|
|
||||||
if (!CALENDAR_ITEM_TYPES.has(item.type)) return null
|
|
||||||
|
|
||||||
const d = item.data
|
|
||||||
const title = d.title
|
|
||||||
if (typeof title !== "string" || !title) return null
|
|
||||||
|
|
||||||
// CalDAV uses startDate/endDate, Google Calendar uses startTime/endTime
|
|
||||||
const startTime = toValidDate(d.startDate ?? d.startTime)
|
|
||||||
if (!startTime) return null
|
|
||||||
|
|
||||||
const endTime = toValidDate(d.endDate ?? d.endTime) ?? startTime
|
|
||||||
|
|
||||||
return {
|
|
||||||
date: startTime,
|
|
||||||
title,
|
|
||||||
location: typeof d.location === "string" ? d.location : null,
|
|
||||||
isAllDay: typeof d.isAllDay === "boolean" ? d.isAllDay : false,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
|
|
||||||
|
|
||||||
function pad2(n: number): string {
|
|
||||||
return n.toString().padStart(2, "0")
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayShort(date: Date): string {
|
|
||||||
return `${DAYS[date.getUTCDay()]}, ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayLabel(date: Date, currentTime: Date): string {
|
|
||||||
const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
|
|
||||||
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
|
||||||
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
const dayName = formatDayShort(date)
|
|
||||||
|
|
||||||
if (diffDays === 0) return `Today: ${dayName}`
|
|
||||||
if (diffDays === 1) return `Tomorrow: ${dayName}`
|
|
||||||
return dayName
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a week overview mapping day names to dates,
|
|
||||||
* so the LLM can easily match ISO timestamps to days.
|
|
||||||
*/
|
|
||||||
function buildWeekCalendar(currentTime: Date): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const date = new Date(currentTime)
|
|
||||||
date.setUTCDate(date.getUTCDate() + i)
|
|
||||||
const label = i === 0 ? "Today" : i === 1 ? "Tomorrow" : ""
|
|
||||||
const dayStr = formatDayShort(date)
|
|
||||||
const iso = date.toISOString().slice(0, 10)
|
|
||||||
const prefix = label ? `${label}: ` : ""
|
|
||||||
lines.push(`${prefix}${dayStr} = ${iso}`)
|
|
||||||
}
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a compact text calendar from all calendar-type items.
|
|
||||||
* Groups events by day relative to currentTime.
|
|
||||||
*/
|
|
||||||
function buildSchedule(items: FeedItem[], currentTime: Date): string {
|
|
||||||
const entries: CalendarEntry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
const entry = extractCalendarEntry(item)
|
|
||||||
if (entry) entries.push(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entries.length === 0) return ""
|
|
||||||
|
|
||||||
entries.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
|
||||||
|
|
||||||
const byDay = new Map<string, CalendarEntry[]>()
|
|
||||||
for (const entry of entries) {
|
|
||||||
const key = entry.date.toISOString().slice(0, 10)
|
|
||||||
const group = byDay.get(key)
|
|
||||||
if (group) {
|
|
||||||
group.push(entry)
|
|
||||||
} else {
|
|
||||||
byDay.set(key, [entry])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = []
|
|
||||||
for (const [, dayEntries] of byDay) {
|
|
||||||
lines.push(formatDayLabel(dayEntries[0]!.startTime, currentTime))
|
|
||||||
for (const entry of dayEntries) {
|
|
||||||
if (entry.isAllDay) {
|
|
||||||
const loc = entry.location ? ` @ ${entry.location}` : ""
|
|
||||||
lines.push(` all day ${entry.title}${loc}`)
|
|
||||||
} else {
|
|
||||||
const timeRange = `${formatTime(entry.startTime)}–${formatTime(entry.endTime)}`
|
|
||||||
const loc = entry.location ? ` @ ${entry.location}` : ""
|
|
||||||
lines.push(` ${timeRange} ${entry.title}${loc}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
You are ARIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
|
|
||||||
|
|
||||||
The user message is a JSON object with:
|
|
||||||
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.
|
|
||||||
- "context": other feed items (no slots) for cross-source reasoning.
|
|
||||||
- "time": current ISO timestamp.
|
|
||||||
|
|
||||||
Your output has two fields:
|
|
||||||
- "slotFills": map of item ID → slot name → short text (or null if you can't fill it or cannot provide answer). Each item ID appears ONCE with ALL its slots in a single object.
|
|
||||||
- "syntheticItems": array of { id, type, text } for new items (briefings, nudges, insights). Only when genuinely useful and when not redundant.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- DO NOT USE EMDASH OR DASH OR ATTEMPT TO USE SYMBOLS TO CIRCUMVENT THIS RULE.
|
|
||||||
- One sentence per slot. Two max if absolutely necessary. Be direct.
|
|
||||||
- Say "I" not "we."
|
|
||||||
- Hedge when inferring. Don't state guesses as facts.
|
|
||||||
- Use the week and schedule below to understand when events happen. Match weather data to the correct date.
|
|
||||||
- Look for connections across items.
|
|
||||||
- Don't pad — return null for slots you can't meaningfully fill, and skip synthetic items if there's nothing useful to add.
|
|
||||||
- Never fabricate information not present in the feed. If you don't have data to support a fill, return null.
|
|
||||||
- Read each slot's description carefully — it defines when to return null.
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import {
|
|
||||||
emptyEnhancementResult,
|
|
||||||
enhancementResultJsonSchema,
|
|
||||||
parseEnhancementResult,
|
|
||||||
} from "./schema.ts"
|
|
||||||
|
|
||||||
describe("parseEnhancementResult", () => {
|
|
||||||
test("parses valid result", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {
|
|
||||||
"weather-1": {
|
|
||||||
insight: "Rain after 3pm",
|
|
||||||
"cross-source": null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
syntheticItems: [
|
|
||||||
{
|
|
||||||
id: "briefing-morning",
|
|
||||||
type: "briefing",
|
|
||||||
text: "Light afternoon ahead.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(input)
|
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.slotFills["weather-1"]!.insight).toBe("Rain after 3pm")
|
|
||||||
expect(result!.slotFills["weather-1"]!["cross-source"]).toBeNull()
|
|
||||||
expect(result!.syntheticItems).toHaveLength(1)
|
|
||||||
expect(result!.syntheticItems[0]!.id).toBe("briefing-morning")
|
|
||||||
expect(result!.syntheticItems[0]!.text).toBe("Light afternoon ahead.")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses empty result", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(input)
|
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(Object.keys(result!.slotFills)).toHaveLength(0)
|
|
||||||
expect(result!.syntheticItems).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null for invalid JSON", () => {
|
|
||||||
expect(parseEnhancementResult("not json")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null for non-object", () => {
|
|
||||||
expect(parseEnhancementResult('"hello"')).toBeNull()
|
|
||||||
expect(parseEnhancementResult("42")).toBeNull()
|
|
||||||
expect(parseEnhancementResult("null")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when slotFills is missing", () => {
|
|
||||||
const input = JSON.stringify({ syntheticItems: [] })
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when syntheticItems is missing", () => {
|
|
||||||
const input = JSON.stringify({ slotFills: {} })
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when slotFills has non-string values", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: { "item-1": { slot: 42 } },
|
|
||||||
syntheticItems: [],
|
|
||||||
})
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when syntheticItem is missing required fields", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [{ id: "x" }],
|
|
||||||
})
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("emptyEnhancementResult", () => {
|
|
||||||
test("returns empty slotFills and syntheticItems", () => {
|
|
||||||
const result = emptyEnhancementResult()
|
|
||||||
expect(result.slotFills).toEqual({})
|
|
||||||
expect(result.syntheticItems).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("schema sync", () => {
|
|
||||||
const referencePayloads = [
|
|
||||||
{
|
|
||||||
name: "full payload with null slot fill",
|
|
||||||
payload: {
|
|
||||||
slotFills: {
|
|
||||||
"weather-1": { insight: "Rain after 3pm", crossSource: null },
|
|
||||||
"cal-2": { summary: "Busy morning" },
|
|
||||||
},
|
|
||||||
syntheticItems: [
|
|
||||||
{ id: "briefing-morning", type: "briefing", text: "Light day ahead." },
|
|
||||||
{ id: "nudge-umbrella", type: "nudge", text: "Bring an umbrella." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty collections",
|
|
||||||
payload: { slotFills: {}, syntheticItems: [] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "slot fills only",
|
|
||||||
payload: {
|
|
||||||
slotFills: { "item-1": { slot: "filled" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "synthetic items only",
|
|
||||||
payload: {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [{ id: "insight-1", type: "insight", text: "Something." }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const { name, payload } of referencePayloads) {
|
|
||||||
test(`arktype and JSON Schema agree on: ${name}`, () => {
|
|
||||||
// arktype accepts it
|
|
||||||
const parsed = parseEnhancementResult(JSON.stringify(payload))
|
|
||||||
expect(parsed).not.toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema structure matches
|
|
||||||
const jsonSchema = enhancementResultJsonSchema
|
|
||||||
expect(Object.keys(jsonSchema.properties).sort()).toEqual(
|
|
||||||
Object.keys(payload).sort(),
|
|
||||||
)
|
|
||||||
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
|
|
||||||
|
|
||||||
// syntheticItems item schema has the right required fields
|
|
||||||
const itemSchema = jsonSchema.properties.syntheticItems.items
|
|
||||||
expect([...itemSchema.required].sort()).toEqual(["id", "text", "type"])
|
|
||||||
|
|
||||||
// Verify each synthetic item has exactly the fields the JSON Schema expects
|
|
||||||
for (const item of payload.syntheticItems) {
|
|
||||||
expect(Object.keys(item).sort()).toEqual([...itemSchema.required].sort())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
test("JSON Schema rejects what arktype rejects: missing required field", () => {
|
|
||||||
// Missing syntheticItems
|
|
||||||
expect(parseEnhancementResult(JSON.stringify({ slotFills: {} }))).toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema also requires it
|
|
||||||
expect(enhancementResultJsonSchema.required).toContain("syntheticItems")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("JSON Schema rejects what arktype rejects: wrong slot fill value type", () => {
|
|
||||||
const bad = { slotFills: { "item-1": { slot: 42 } }, syntheticItems: [] }
|
|
||||||
|
|
||||||
// arktype rejects it
|
|
||||||
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema only allows string or null for slot values
|
|
||||||
const slotValueTypes =
|
|
||||||
enhancementResultJsonSchema.properties.slotFills.additionalProperties
|
|
||||||
.additionalProperties.type
|
|
||||||
expect(slotValueTypes).toContain("string")
|
|
||||||
expect(slotValueTypes).toContain("null")
|
|
||||||
expect(slotValueTypes).not.toContain("number")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
const SyntheticItem = type({
|
|
||||||
id: "string",
|
|
||||||
type: "string",
|
|
||||||
text: "string",
|
|
||||||
})
|
|
||||||
|
|
||||||
const EnhancementResult = type({
|
|
||||||
slotFills: "Record<string, Record<string, string | null>>",
|
|
||||||
syntheticItems: SyntheticItem.array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type SyntheticItem = typeof SyntheticItem.infer
|
|
||||||
export type EnhancementResult = typeof EnhancementResult.infer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON Schema passed to OpenRouter's structured output.
|
|
||||||
* OpenRouter doesn't support arktype, so this is maintained separately.
|
|
||||||
*
|
|
||||||
* ⚠️ Must stay in sync with EnhancementResult above.
|
|
||||||
* If you add/remove fields, update both schemas.
|
|
||||||
*/
|
|
||||||
export const enhancementResultJsonSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
slotFills: {
|
|
||||||
type: "object",
|
|
||||||
description:
|
|
||||||
"Map of feed item ID to an object of slot name to filled text content. Use null for slots that cannot be meaningfully filled.",
|
|
||||||
additionalProperties: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: {
|
|
||||||
type: ["string", "null"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
syntheticItems: {
|
|
||||||
type: "array",
|
|
||||||
description:
|
|
||||||
"New feed items to inject (briefings, nudges, cross-source insights). Keep these short and actionable.",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
id: {
|
|
||||||
type: "string",
|
|
||||||
description: "Unique ID, e.g. 'briefing-morning'",
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: "string",
|
|
||||||
description: "One of: 'briefing', 'nudge', 'insight'",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
type: "string",
|
|
||||||
description: "Display text, 1-3 sentences",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["id", "type", "text"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["slotFills", "syntheticItems"],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a JSON string into an EnhancementResult.
|
|
||||||
* Returns null if the input is malformed.
|
|
||||||
*/
|
|
||||||
export function parseEnhancementResult(json: string): EnhancementResult | null {
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(json)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = EnhancementResult(parsed)
|
|
||||||
if (result instanceof type.errors) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyEnhancementResult(): EnhancementResult {
|
|
||||||
return { slotFills: {}, syntheticItems: [] }
|
|
||||||
}
|
|
||||||
@@ -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() {
|
||||||
@@ -47,7 +47,7 @@ function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
|||||||
|
|
||||||
describe("GET /api/feed", () => {
|
describe("GET /api/feed", () => {
|
||||||
test("returns 401 without auth", async () => {
|
test("returns 401 without auth", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [] })
|
const manager = new UserSessionManager([])
|
||||||
const app = buildTestApp(manager)
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
const res = await app.request("/api/feed")
|
||||||
@@ -65,9 +65,7 @@ describe("GET /api/feed", () => {
|
|||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
// Prime the cache
|
// Prime the cache
|
||||||
@@ -97,9 +95,7 @@ describe("GET /api/feed", () => {
|
|||||||
data: { fresh: true },
|
data: { fresh: true },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||||
@@ -129,7 +125,7 @@ describe("GET /api/feed", () => {
|
|||||||
throw new Error("connection timeout")
|
throw new Error("connection timeout")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
const manager = new UserSessionManager([() => failingSource])
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
const res = await app.request("/api/feed")
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import { createMiddleware } from "hono/factory"
|
|||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
type Env = {
|
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||||
Variables: {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedHttpHandlersDeps {
|
interface FeedHttpHandlersDeps {
|
||||||
sessionManager: UserSessionManager
|
sessionManager: UserSessionManager
|
||||||
@@ -33,7 +29,7 @@ async function handleGetFeed(c: Context<Env>) {
|
|||||||
const sessionManager = c.get("sessionManager")
|
const sessionManager = c.get("sessionManager")
|
||||||
const session = sessionManager.getOrCreate(user.id)
|
const session = sessionManager.getOrCreate(user.id)
|
||||||
|
|
||||||
const feed = await session.feed()
|
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
items: feed.items,
|
items: feed.items,
|
||||||
|
|||||||
@@ -3,51 +3,30 @@ import { Hono } from "hono"
|
|||||||
|
|
||||||
import { registerAuthHandlers } from "./auth/http.ts"
|
import { registerAuthHandlers } from "./auth/http.ts"
|
||||||
import { requireSession } from "./auth/session-middleware.ts"
|
import { requireSession } from "./auth/session-middleware.ts"
|
||||||
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
|
||||||
import { createLlmClient } from "./enhancement/llm-client.ts"
|
|
||||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
||||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
const sessionManager = new UserSessionManager([
|
||||||
const feedEnhancer = openrouterApiKey
|
() => new LocationSource(),
|
||||||
? createFeedEnhancer({
|
new WeatherSourceProvider({
|
||||||
client: createLlmClient({
|
credentials: {
|
||||||
apiKey: openrouterApiKey,
|
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
model: process.env.OPENROUTER_MODEL || undefined,
|
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||||
}),
|
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||||
})
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
: null
|
},
|
||||||
if (!feedEnhancer) {
|
}),
|
||||||
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
])
|
||||||
}
|
|
||||||
|
|
||||||
const sessionManager = new UserSessionManager({
|
|
||||||
providers: [
|
|
||||||
() => new LocationSource(),
|
|
||||||
new WeatherSourceProvider({
|
|
||||||
credentials: {
|
|
||||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
|
||||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
|
||||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
|
||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
feedEnhancer,
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
registerAuthHandlers(app)
|
registerAuthHandlers(app)
|
||||||
registerFeedHttpHandlers(app, {
|
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
|
||||||
sessionManager,
|
|
||||||
authSessionMiddleware: requireSession,
|
|
||||||
})
|
|
||||||
registerLocationHttpHandlers(app, { sessionManager })
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const mockWeatherClient: WeatherKitClient = {
|
|||||||
|
|
||||||
describe("UserSessionManager", () => {
|
describe("UserSessionManager", () => {
|
||||||
test("getOrCreate creates session on first call", () => {
|
test("getOrCreate creates session on first call", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns same session for same user", () => {
|
test("getOrCreate returns same session for same user", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-1")
|
const session2 = manager.getOrCreate("user-1")
|
||||||
@@ -30,7 +30,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns different sessions for different users", () => {
|
test("getOrCreate returns different sessions for different users", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
@@ -39,7 +39,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("each user gets independent source instances", () => {
|
test("each user gets independent source instances", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
@@ -51,7 +51,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove destroys session and allows re-creation", () => {
|
test("remove destroys session and allows re-creation", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
manager.remove("user-1")
|
manager.remove("user-1")
|
||||||
@@ -61,13 +61,13 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove is no-op for unknown user", () => {
|
test("remove is no-op for unknown user", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
expect(() => manager.remove("unknown")).not.toThrow()
|
expect(() => manager.remove("unknown")).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts function providers", async () => {
|
test("accepts function providers", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
@@ -77,9 +77,7 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
test("accepts object providers", () => {
|
test("accepts object providers", () => {
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||||
providers: [() => new LocationSource(), provider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -88,9 +86,7 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
test("accepts mixed providers", () => {
|
test("accepts mixed providers", () => {
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||||
providers: [() => new LocationSource(), provider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -99,7 +95,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("refresh returns feed result through session", async () => {
|
test("refresh returns feed result through session", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
@@ -111,7 +107,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("location update via executeAction works", async () => {
|
test("location update via executeAction works", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
await session.engine.executeAction("aris.location", "update-location", {
|
await session.engine.executeAction("aris.location", "update-location", {
|
||||||
@@ -126,7 +122,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("subscribe receives updates after location push", async () => {
|
test("subscribe receives updates after location push", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
@@ -146,7 +142,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove stops reactive updates", async () => {
|
test("remove stops reactive updates", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
|
||||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
export interface UserSessionManagerConfig {
|
|
||||||
providers: FeedSourceProviderInput[]
|
|
||||||
feedEnhancer?: FeedEnhancer | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserSessionManager {
|
export class UserSessionManager {
|
||||||
private sessions = new Map<string, UserSession>()
|
private sessions = new Map<string, UserSession>()
|
||||||
private readonly providers: FeedSourceProviderInput[]
|
private readonly providers: FeedSourceProviderInput[]
|
||||||
private readonly feedEnhancer: FeedEnhancer | null
|
|
||||||
|
|
||||||
constructor(config: UserSessionManagerConfig) {
|
constructor(providers: FeedSourceProviderInput[]) {
|
||||||
this.providers = config.providers
|
this.providers = providers
|
||||||
this.feedEnhancer = config.feedEnhancer ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrCreate(userId: string): UserSession {
|
getOrCreate(userId: string): UserSession {
|
||||||
@@ -24,7 +16,7 @@ export class UserSessionManager {
|
|||||||
const sources = this.providers.map((p) =>
|
const sources = this.providers.map((p) =>
|
||||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||||
)
|
)
|
||||||
session = new UserSession(sources, this.feedEnhancer)
|
session = new UserSession(sources)
|
||||||
this.sessions.set(userId, session)
|
this.sessions.set(userId, session)
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, 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"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
function createStubSource(id: string): FeedSource {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
@@ -14,11 +14,11 @@ 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() {
|
||||||
return items
|
return []
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,141 +70,3 @@ describe("UserSession", () => {
|
|||||||
expect(location.lastLocation!.lat).toBe(51.5)
|
expect(location.lastLocation!.lat).toBe(51.5)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("UserSession.feed", () => {
|
|
||||||
test("returns feed items without enhancer", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const session = new UserSession([createStubSource("test", items)])
|
|
||||||
|
|
||||||
const result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.id).toBe("item-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns enhanced items when enhancer is provided", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) =>
|
|
||||||
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.data.enhanced).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caches enhanced items on subsequent calls", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
let enhancerCallCount = 0
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) => {
|
|
||||||
enhancerCallCount++
|
|
||||||
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result1 = await session.feed()
|
|
||||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancerCallCount).toBe(1)
|
|
||||||
|
|
||||||
const result2 = await session.feed()
|
|
||||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancerCallCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("re-enhances after engine refresh with new data", async () => {
|
|
||||||
let currentItems: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { version: 1 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const source = createStubSource("test", currentItems)
|
|
||||||
// Make fetchItems dynamic so refresh returns new data
|
|
||||||
source.fetchItems = async () => currentItems
|
|
||||||
|
|
||||||
const enhancedVersions: number[] = []
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) => {
|
|
||||||
const version = feedItems[0]!.data.version as number
|
|
||||||
enhancedVersions.push(version)
|
|
||||||
return feedItems.map((item) => ({
|
|
||||||
...item,
|
|
||||||
data: { ...item.data, enhanced: true },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession([source], enhancer)
|
|
||||||
|
|
||||||
// First feed triggers refresh + enhancement
|
|
||||||
const result1 = await session.feed()
|
|
||||||
expect(result1.items[0]!.data.version).toBe(1)
|
|
||||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
|
||||||
|
|
||||||
// Update source data and trigger engine refresh
|
|
||||||
currentItems = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
|
||||||
data: { version: 2 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
// Wait for subscriber-triggered background enhancement
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
|
|
||||||
// feed() should now serve re-enhanced items with version 2
|
|
||||||
const result2 = await session.feed()
|
|
||||||
expect(result2.items[0]!.data.version).toBe(2)
|
|
||||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancedVersions).toEqual([1, 2])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back to unenhanced items when enhancer throws", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const enhancer = async () => {
|
|
||||||
throw new Error("enhancement exploded")
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.id).toBe("item-1")
|
|
||||||
expect(result.items[0]!.data.value).toBe(42)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,104 +1,24 @@
|
|||||||
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aris/core"
|
import { FeedEngine, type FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
|
||||||
|
|
||||||
export class UserSession {
|
export class UserSession {
|
||||||
readonly engine: FeedEngine
|
readonly engine: FeedEngine
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
private readonly enhancer: FeedEnhancer | null
|
|
||||||
private enhancedItems: FeedItem[] | null = null
|
|
||||||
/** The FeedResult that enhancedItems was derived from. */
|
|
||||||
private enhancedSource: FeedResult | null = null
|
|
||||||
private enhancingPromise: Promise<void> | null = null
|
|
||||||
private unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
constructor(sources: FeedSource[]) {
|
||||||
this.engine = new FeedEngine()
|
this.engine = new FeedEngine()
|
||||||
this.enhancer = enhancer ?? null
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
this.sources.set(source.id, source)
|
this.sources.set(source.id, source)
|
||||||
this.engine.register(source)
|
this.engine.register(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.enhancer) {
|
|
||||||
this.unsubscribe = this.engine.subscribe((result) => {
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.runEnhancement(result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.engine.start()
|
this.engine.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current feed, refreshing if the engine cache expired.
|
|
||||||
* Enhancement runs eagerly on engine updates; this method awaits
|
|
||||||
* any in-flight enhancement or triggers one if needed.
|
|
||||||
*/
|
|
||||||
async feed(): Promise<FeedResult> {
|
|
||||||
const cached = this.engine.lastFeed()
|
|
||||||
const result = cached ?? (await this.engine.refresh())
|
|
||||||
|
|
||||||
if (!this.enhancer) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for any in-flight background enhancement to finish
|
|
||||||
if (this.enhancingPromise) {
|
|
||||||
await this.enhancingPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve cached enhancement only if it matches the current engine result
|
|
||||||
if (this.enhancedItems && this.enhancedSource === result) {
|
|
||||||
return { ...result, items: this.enhancedItems }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale or missing — re-enhance
|
|
||||||
await this.runEnhancement(result)
|
|
||||||
|
|
||||||
if (this.enhancedItems) {
|
|
||||||
return { ...result, items: this.enhancedItems }
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
||||||
return this.sources.get(sourceId) as T | undefined
|
return this.sources.get(sourceId) as T | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.unsubscribe?.()
|
|
||||||
this.unsubscribe = null
|
|
||||||
this.engine.stop()
|
this.engine.stop()
|
||||||
this.sources.clear()
|
this.sources.clear()
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.enhancingPromise = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private invalidateEnhancement(): void {
|
|
||||||
this.enhancedItems = null
|
|
||||||
this.enhancedSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private runEnhancement(result: FeedResult): Promise<void> {
|
|
||||||
const promise = this.enhance(result)
|
|
||||||
this.enhancingPromise = promise
|
|
||||||
promise.finally(() => {
|
|
||||||
if (this.enhancingPromise === promise) {
|
|
||||||
this.enhancingPromise = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
private async enhance(result: FeedResult): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.enhancedItems = await this.enhancer!(result.items)
|
|
||||||
this.enhancedSource = result
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[enhancement] Unexpected error:", err)
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
.react-router
|
|
||||||
build
|
|
||||||
node_modules
|
|
||||||
.env
|
|
||||||
README.md
|
|
||||||
7
apps/waitlist-website/.gitignore
vendored
7
apps/waitlist-website/.gitignore
vendored
@@ -1,7 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
/node_modules/
|
|
||||||
|
|
||||||
# React Router
|
|
||||||
/.react-router/
|
|
||||||
/build/
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
FROM oven/bun:1 AS development-dependencies-env
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
FROM oven/bun:1 AS production-dependencies-env
|
|
||||||
COPY ./package.json /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun install --production
|
|
||||||
|
|
||||||
FROM oven/bun:1 AS build-env
|
|
||||||
COPY . /app/
|
|
||||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
|
||||||
COPY ./package.json /app/
|
|
||||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
COPY --from=build-env /app/build /app/build
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Welcome to React Router!
|
|
||||||
|
|
||||||
A modern, production-ready template for building full-stack React applications using React Router.
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 🚀 Server-side rendering
|
|
||||||
- ⚡️ Hot Module Replacement (HMR)
|
|
||||||
- 📦 Asset bundling and optimization
|
|
||||||
- 🔄 Data loading and mutations
|
|
||||||
- 🔒 TypeScript by default
|
|
||||||
- 🎉 TailwindCSS for styling
|
|
||||||
- 📖 [React Router docs](https://reactrouter.com/)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Install the dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
Start the development server with HMR:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Your application will be available at `http://localhost:5173`.
|
|
||||||
|
|
||||||
## Building for Production
|
|
||||||
|
|
||||||
Create a production build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Docker Deployment
|
|
||||||
|
|
||||||
To build and run using Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t my-app .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -p 3000:3000 my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
The containerized application can be deployed to any platform that supports Docker, including:
|
|
||||||
|
|
||||||
- AWS ECS
|
|
||||||
- Google Cloud Run
|
|
||||||
- Azure Container Apps
|
|
||||||
- Digital Ocean App Platform
|
|
||||||
- Fly.io
|
|
||||||
- Railway
|
|
||||||
|
|
||||||
### DIY Deployment
|
|
||||||
|
|
||||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
|
||||||
|
|
||||||
Make sure to deploy the output of `npm run build`
|
|
||||||
|
|
||||||
```
|
|
||||||
├── package.json
|
|
||||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
|
||||||
├── build/
|
|
||||||
│ ├── client/ # Static assets
|
|
||||||
│ └── server/ # Server-side code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Built with ❤️ using React Router.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap");
|
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@source "../node_modules/streamdown/dist/*.js";
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--font-sans:
|
|
||||||
"Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--font-serif: "Source Serif 4", ui-serif, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root,
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
@apply w-full h-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes popover-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(4px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes popover-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@apply bg-stone-50 dark:bg-stone-900 text-stone-900 dark:text-stone-200 selection:bg-teal-600 dark:selection:bg-teal-500 selection:text-stone-50 dark:selection:text-stone-900;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import clsx from "clsx"
|
|
||||||
import { ArrowUpIcon, FileIcon, ImageIcon, PlusIcon, XIcon } from "lucide-react"
|
|
||||||
import { motion, useAnimate } from "motion/react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components"
|
|
||||||
|
|
||||||
export function ChatBox({
|
|
||||||
className,
|
|
||||||
validate,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
className?: string
|
|
||||||
validate?: (value: string) => boolean
|
|
||||||
onSubmit: (email: string) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
const [scope, animate] = useAnimate()
|
|
||||||
const [shouldShowInvalid, setShouldShowInvalid] = useState(false)
|
|
||||||
const clearInvalidStateTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (clearInvalidStateTimeout.current) {
|
|
||||||
clearTimeout(clearInvalidStateTimeout.current)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
function showInvalidState() {
|
|
||||||
animate(scope.current, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.4, ease: "easeOut" })
|
|
||||||
if (clearInvalidStateTimeout.current) {
|
|
||||||
clearTimeout(clearInvalidStateTimeout.current)
|
|
||||||
}
|
|
||||||
setShouldShowInvalid(true)
|
|
||||||
clearInvalidStateTimeout.current = setTimeout(() => {
|
|
||||||
setShouldShowInvalid(false)
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const formData = new FormData(e.currentTarget)
|
|
||||||
const email = formData.get("liame")
|
|
||||||
if (typeof email === "string") {
|
|
||||||
const trimmed = email.trim()
|
|
||||||
if (trimmed.length === 0) {
|
|
||||||
showInvalidState()
|
|
||||||
} else if (validate && !validate(trimmed)) {
|
|
||||||
showInvalidState()
|
|
||||||
} else {
|
|
||||||
onSubmit(trimmed)
|
|
||||||
e.currentTarget.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.form
|
|
||||||
ref={scope}
|
|
||||||
onSubmit={onFormSubmit}
|
|
||||||
className={`min-h-20 px-3 pt-2 pb-1.5 flex flex-col justify-between rounded-lg bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 ${className ?? ""} shadow-xs hover:shadow-sm`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="liame"
|
|
||||||
className="w-full bg-transparent outline-none focus:outline-none ring-0 focus:ring-0"
|
|
||||||
/>
|
|
||||||
<div className="w-full flex justify-between">
|
|
||||||
<MenuTrigger>
|
|
||||||
<Button className="bg-transparent hover:bg-stone-200 dark:hover:bg-stone-700 active:bg-stone-300 dark:active:bg-stone-600 data-[pressed]:bg-stone-200 dark:data-[pressed]:bg-stone-700 rounded-full flex items-center justify-center p-1 -ml-1.5 active:inset-shadow-sm outline-none transition-transform duration-200 data-[pressed]:rotate-45">
|
|
||||||
<PlusIcon size={16} />
|
|
||||||
</Button>
|
|
||||||
<Popover
|
|
||||||
offset={4}
|
|
||||||
className="origin-bottom-left rounded-lg border border-stone-200 dark:border-stone-700 bg-stone-100 dark:bg-stone-800 shadow-lg p-1 min-w-40 outline-none data-[entering]:animate-[popover-in_150ms_ease-out] data-[exiting]:animate-[popover-out_100ms_ease-in]"
|
|
||||||
placement="top start"
|
|
||||||
>
|
|
||||||
<AttachmentMenu />
|
|
||||||
</Popover>
|
|
||||||
</MenuTrigger>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={shouldShowInvalid}
|
|
||||||
className={clsx(
|
|
||||||
"transition-all rounded-full flex items-center justify-center p-1 -mr-1.5 active:scale-95",
|
|
||||||
shouldShowInvalid
|
|
||||||
? "bg-red-400 hover:bg-red-300 text-stone-200 dark:text-stone-700"
|
|
||||||
: "bg-teal-600 hover:bg-teal-500 active:bg-teal-600 text-stone-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{shouldShowInvalid ? <XIcon size={16} /> : <ArrowUpIcon size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttachmentMenu() {
|
|
||||||
return (
|
|
||||||
<Menu className="outline-none">
|
|
||||||
<MenuItem
|
|
||||||
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
|
|
||||||
onAction={() => {}}
|
|
||||||
>
|
|
||||||
<ImageIcon size={14} />
|
|
||||||
Photos
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
|
|
||||||
onAction={() => {}}
|
|
||||||
>
|
|
||||||
<FileIcon size={14} />
|
|
||||||
Files
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
export interface UserMessage {
|
|
||||||
role: "user"
|
|
||||||
message: string
|
|
||||||
bubbleLayoutId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemMessage {
|
|
||||||
role: "system"
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Message = UserMessage | SystemMessage
|
|
||||||
|
|
||||||
function timeOfDay() {
|
|
||||||
const hours = new Date().getHours()
|
|
||||||
if (hours >= 5 && hours < 12) {
|
|
||||||
return "morning"
|
|
||||||
} else if (hours >= 12 && hours < 18) {
|
|
||||||
return "afternoon"
|
|
||||||
} else if (hours >= 18 && hours < 22) {
|
|
||||||
return "evening"
|
|
||||||
}
|
|
||||||
return "night"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const INITIAL_MESSAGES: Message[] = [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
message: "Who are you?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
message: `Hey! I'm **Aelis** — your personal assistant that brings you the right thing, at the right time, in the right place.
|
|
||||||
|
|
||||||
- Jubilee line down? I've already found you an alternative route.
|
|
||||||
- Dinner reservation at 8? I'll have the restaurant, directions, and the menu ready before you head out.
|
|
||||||
|
|
||||||
I learn your routines, anticipate what's next, and surface what matters before you even think to look for it.
|
|
||||||
|
|
||||||
I'm not ready yet — [@kennethnym](https://x.com/kennethnym) is still building me. **Drop your email below** and I'll let you know when I'm available.`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function waitListJoinedMessage(email: string): SystemMessage {
|
|
||||||
return {
|
|
||||||
role: "system",
|
|
||||||
message: `Thanks for joining the waitlist! I've sent you a confirmation email.
|
|
||||||
I'll send an email to **${email}** when I'm ready.
|
|
||||||
|
|
||||||
Have a good ${timeOfDay()}!`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function duplicateEmailMessage(): SystemMessage {
|
|
||||||
return {
|
|
||||||
role: "system",
|
|
||||||
message: `I appreciate your excitement! You are already on the waitlist. When I am ready, I will reach out again. Have a good ${timeOfDay()} :)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function troubleMessage(): SystemMessage {
|
|
||||||
return {
|
|
||||||
role: "system",
|
|
||||||
message: `I apologize, but I am having trouble adding you to the waitlist. Could you refresh the page and try again please in a moment?`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
|
|
||||||
export function useFakeStreaming(fullContent: string) {
|
|
||||||
const [currentContent, setCurrentContent] = useState("")
|
|
||||||
const [isStreaming, setIsStreaming] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const words = fullContent.split(" ")
|
|
||||||
|
|
||||||
let i = 0
|
|
||||||
const id = setInterval(() => {
|
|
||||||
if (i > words.length) {
|
|
||||||
setIsStreaming(false)
|
|
||||||
clearInterval(id)
|
|
||||||
} else {
|
|
||||||
setCurrentContent(words.slice(0, i).join(" ") + " ")
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}, 20)
|
|
||||||
}, [fullContent])
|
|
||||||
|
|
||||||
return useMemo(() => ({ currentContent, isStreaming }), [currentContent, isStreaming])
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import Lottie, { type LottieRef } from "lottie-react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
|
|
||||||
import { useColorScheme } from "~/hooks/use-color-scheme"
|
|
||||||
import clickedAnimationDark from "~/lottie/clicked-dark.json"
|
|
||||||
import clickedAnimationLight from "~/lottie/clicked-light.json"
|
|
||||||
import loadingAnimationDark from "~/lottie/loading-dark.json"
|
|
||||||
import loadingAnimationLight from "~/lottie/loading-light.json"
|
|
||||||
import startLoadingAnimationDark from "~/lottie/start-loading-dark.json"
|
|
||||||
import startLoadingAnimationLight from "~/lottie/start-loading-light.json"
|
|
||||||
|
|
||||||
export const AnimatedLogoState = {
|
|
||||||
Idle: "idle",
|
|
||||||
Loading: "loading",
|
|
||||||
} as const
|
|
||||||
export type AnimatedLogoState = (typeof AnimatedLogoState)[keyof typeof AnimatedLogoState]
|
|
||||||
|
|
||||||
interface AnimatedLogoProps {
|
|
||||||
state: AnimatedLogoState
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Animation {
|
|
||||||
loop: boolean
|
|
||||||
reverse: boolean
|
|
||||||
sticky: boolean
|
|
||||||
data: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnimatedLogo({ state, className }: AnimatedLogoProps) {
|
|
||||||
const colorScheme = useColorScheme()
|
|
||||||
const [animationQueue, setAnimationQueue] = useState<Animation[]>([])
|
|
||||||
const lottieRef: LottieRef = useRef(null)
|
|
||||||
|
|
||||||
let currentAnimation: Animation
|
|
||||||
let isIdle = false
|
|
||||||
if (animationQueue.length === 0) {
|
|
||||||
isIdle = true
|
|
||||||
currentAnimation = {
|
|
||||||
loop: false,
|
|
||||||
reverse: false,
|
|
||||||
sticky: false,
|
|
||||||
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isIdle = false
|
|
||||||
currentAnimation = animationQueue[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state === AnimatedLogoState.Loading) {
|
|
||||||
setAnimationQueue((queue) => [
|
|
||||||
...queue,
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
reverse: false,
|
|
||||||
sticky: false,
|
|
||||||
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loop: true,
|
|
||||||
reverse: false,
|
|
||||||
sticky: false,
|
|
||||||
data: colorScheme === "dark" ? loadingAnimationDark : loadingAnimationLight,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
} else if (state === AnimatedLogoState.Idle) {
|
|
||||||
setAnimationQueue((queue) => {
|
|
||||||
const last = queue.at(-1)
|
|
||||||
if (!last) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
last.loop &&
|
|
||||||
(last.data === loadingAnimationDark || last.data === loadingAnimationLight)
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
...queue,
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: false,
|
|
||||||
reverse: false,
|
|
||||||
data: colorScheme === "dark" ? loadingAnimationDark : loadingAnimationLight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: false,
|
|
||||||
reverse: true,
|
|
||||||
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lottieRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentAnimation.reverse) {
|
|
||||||
const frames = lottieRef.current.getDuration(true)
|
|
||||||
if (frames) {
|
|
||||||
lottieRef.current.setDirection(-1)
|
|
||||||
lottieRef.current.goToAndPlay(frames - 1, true)
|
|
||||||
}
|
|
||||||
} else if (!isIdle) {
|
|
||||||
lottieRef.current.setDirection(1)
|
|
||||||
lottieRef.current.play()
|
|
||||||
}
|
|
||||||
}, [currentAnimation])
|
|
||||||
|
|
||||||
function onComplete() {
|
|
||||||
if (animationQueue.length > 0 && !animationQueue[0].sticky) {
|
|
||||||
setAnimationQueue((queue) => queue.slice(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLoopComplete() {
|
|
||||||
const current = animationQueue[0]
|
|
||||||
const next = animationQueue[1]
|
|
||||||
if (current && next && current.data === next.data && current.loop && !next.loop) {
|
|
||||||
setAnimationQueue((queue) => queue.slice(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseDown() {
|
|
||||||
if (state === AnimatedLogoState.Idle) {
|
|
||||||
setAnimationQueue([
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: true,
|
|
||||||
reverse: false,
|
|
||||||
data: colorScheme === "dark" ? clickedAnimationDark : clickedAnimationLight,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseUp() {
|
|
||||||
if (state === AnimatedLogoState.Idle) {
|
|
||||||
setAnimationQueue((queue) => [
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: false,
|
|
||||||
reverse: true,
|
|
||||||
data: colorScheme === "dark" ? clickedAnimationDark : clickedAnimationLight,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Lottie
|
|
||||||
lottieRef={lottieRef}
|
|
||||||
autoplay={false}
|
|
||||||
loop={currentAnimation.loop}
|
|
||||||
className={className}
|
|
||||||
animationData={currentAnimation.data}
|
|
||||||
onComplete={onComplete}
|
|
||||||
onLoopComplete={onLoopComplete}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
onMouseUp={onMouseUp}
|
|
||||||
onMouseLeave={onMouseUp}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
export function ProgressiveBlur({
|
|
||||||
className,
|
|
||||||
direction = "down",
|
|
||||||
}: {
|
|
||||||
className?: string
|
|
||||||
direction?: "down" | "up"
|
|
||||||
}) {
|
|
||||||
if (direction === "up") {
|
|
||||||
return (
|
|
||||||
<div className={`pointer-events-none ${className ?? ""}`}>
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[1px] [mask:linear-gradient(rgba(0,0,0,0)_0%,rgba(0,0,0,1)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,0)_30%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[2px] [mask:linear-gradient(rgba(0,0,0,0)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,1)_40%,rgba(0,0,0,0)_50%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[4px] [mask:linear-gradient(rgba(0,0,0,0)_20%,rgba(0,0,0,1)_30%,rgba(0,0,0,1)_50%,rgba(0,0,0,0)_60%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[8px] [mask:linear-gradient(rgba(0,0,0,0)_30%,rgba(0,0,0,1)_40%,rgba(0,0,0,1)_60%,rgba(0,0,0,0)_70%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[16px] [mask:linear-gradient(rgba(0,0,0,0)_40%,rgba(0,0,0,1)_50%,rgba(0,0,0,1)_70%,rgba(0,0,0,0)_80%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[32px] [mask:linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,1)_60%,rgba(0,0,0,1)_80%,rgba(0,0,0,0)_90%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[64px] [mask:linear-gradient(rgba(0,0,0,0)_70%,rgba(0,0,0,1)_80%,rgba(0,0,0,1)_100%)]" />
|
|
||||||
<div className="absolute inset-0 bg-linear-to-t from-stone-50 dark:from-stone-900 to-transparent" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`pointer-events-none ${className ?? ""}`}>
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[64px] [mask:linear-gradient(rgba(0,0,0,1)_0%,rgba(0,0,0,1)_20%,rgba(0,0,0,0)_30%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[32px] [mask:linear-gradient(rgba(0,0,0,0)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,1)_40%,rgba(0,0,0,0)_50%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[16px] [mask:linear-gradient(rgba(0,0,0,0)_20%,rgba(0,0,0,1)_30%,rgba(0,0,0,1)_50%,rgba(0,0,0,0)_60%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[8px] [mask:linear-gradient(rgba(0,0,0,0)_30%,rgba(0,0,0,1)_40%,rgba(0,0,0,1)_60%,rgba(0,0,0,0)_70%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[4px] [mask:linear-gradient(rgba(0,0,0,0)_40%,rgba(0,0,0,1)_50%,rgba(0,0,0,1)_70%,rgba(0,0,0,0)_80%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[2px] [mask:linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,1)_60%,rgba(0,0,0,1)_80%,rgba(0,0,0,0)_90%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[1px] [mask:linear-gradient(rgba(0,0,0,0)_70%,rgba(0,0,0,1)_80%,rgba(0,0,0,1)_90%,rgba(0,0,0,0)_100%)]" />
|
|
||||||
<div className="absolute inset-0 bg-linear-to-b from-stone-50 dark:from-stone-900 to-transparent" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
export const ColorScheme = {
|
|
||||||
Light: "light",
|
|
||||||
Dark: "dark",
|
|
||||||
} as const
|
|
||||||
export type ColorScheme = (typeof ColorScheme)[keyof typeof ColorScheme]
|
|
||||||
|
|
||||||
export function useColorScheme(): ColorScheme {
|
|
||||||
const [scheme, setScheme] = useState<ColorScheme>(() => {
|
|
||||||
if (typeof window === "undefined") return ColorScheme.Light
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
? ColorScheme.Dark
|
|
||||||
: ColorScheme.Light
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia("(prefers-color-scheme: dark)")
|
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
|
||||||
setScheme(e.matches ? ColorScheme.Dark : ColorScheme.Light)
|
|
||||||
}
|
|
||||||
mql.addEventListener("change", handler)
|
|
||||||
return () => mql.removeEventListener("change", handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return scheme
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"
|
|
||||||
|
|
||||||
import type { Route } from "./+types/root"
|
|
||||||
|
|
||||||
import "./app.css"
|
|
||||||
import "streamdown/styles.css"
|
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
|
||||||
{
|
|
||||||
rel: "preconnect",
|
|
||||||
href: "https://fonts.gstatic.com",
|
|
||||||
crossOrigin: "anonymous",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "stylesheet",
|
|
||||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "icon",
|
|
||||||
href: "/favicon-light.svg",
|
|
||||||
type: "image/svg+xml",
|
|
||||||
media: "(prefers-color-scheme: light)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "icon",
|
|
||||||
href: "/favicon-dark.svg",
|
|
||||||
type: "image/svg+xml",
|
|
||||||
media: "(prefers-color-scheme: dark)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "icon",
|
|
||||||
href: "/favicon.ico",
|
|
||||||
sizes: "any",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charSet="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<Meta />
|
|
||||||
<Links />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{children}
|
|
||||||
<ScrollRestoration />
|
|
||||||
<Scripts />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return <Outlet />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|
||||||
let message = "Oops!"
|
|
||||||
let details = "An unexpected error occurred."
|
|
||||||
let stack: string | undefined
|
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
|
||||||
message = error.status === 404 ? "404" : "Error"
|
|
||||||
details =
|
|
||||||
error.status === 404 ? "The requested page could not be found." : error.statusText || details
|
|
||||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
|
||||||
details = error.message
|
|
||||||
stack = error.stack
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex flex-col items-center justify-center w-full h-full gap-4">
|
|
||||||
<h1 className="text-6xl font-semibold">{message}</h1>
|
|
||||||
<p className="text-stone-600 dark:text-stone-400">{details}</p>
|
|
||||||
<a href="/" className="mt-4 text-sm underline opacity-50 hover:opacity-75">
|
|
||||||
Back to home
|
|
||||||
</a>
|
|
||||||
{stack && (
|
|
||||||
<pre className="mt-8 w-full max-w-2xl p-4 overflow-x-auto text-xs bg-stone-100 dark:bg-stone-800 rounded-lg">
|
|
||||||
<code>{stack}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type RouteConfig, index, route } from "@react-router/dev/routes"
|
|
||||||
|
|
||||||
export default [
|
|
||||||
index("routes/home.tsx"),
|
|
||||||
route("privacy", "routes/privacy-policy.tsx"),
|
|
||||||
] satisfies RouteConfig
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
import { AnimatePresence, motion } from "motion/react"
|
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
||||||
import { Link, useFetcher } from "react-router"
|
|
||||||
import { Resend } from "resend"
|
|
||||||
import { Streamdown } from "streamdown"
|
|
||||||
|
|
||||||
import { ChatBox } from "~/chat/chat-box"
|
|
||||||
import {
|
|
||||||
duplicateEmailMessage,
|
|
||||||
INITIAL_MESSAGES,
|
|
||||||
troubleMessage,
|
|
||||||
waitListJoinedMessage,
|
|
||||||
type Message,
|
|
||||||
type SystemMessage,
|
|
||||||
type UserMessage,
|
|
||||||
} from "~/chat/message"
|
|
||||||
import { useFakeStreaming } from "~/chat/use-fake-streaming"
|
|
||||||
import {
|
|
||||||
AnimatedLogo,
|
|
||||||
AnimatedLogoState,
|
|
||||||
AnimatedLogoState as TAnimatedLogoState,
|
|
||||||
} from "~/components/animated-logo"
|
|
||||||
import { ProgressiveBlur } from "~/components/progressive-blur"
|
|
||||||
|
|
||||||
import type { Route } from "./+types/home"
|
|
||||||
|
|
||||||
const PAGE_TITLE = "Aelis - Next Generation AI Assistant"
|
|
||||||
const PAGE_DESCRIPTION =
|
|
||||||
"Meet Aelis, a personal assistant that stays one step ahead of your day. Join the waitlist now."
|
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
|
||||||
return [
|
|
||||||
{ title: PAGE_TITLE },
|
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
content: PAGE_DESCRIPTION,
|
|
||||||
},
|
|
||||||
{ property: "og:title", content: PAGE_TITLE },
|
|
||||||
{ property: "og:description", content: PAGE_DESCRIPTION },
|
|
||||||
{ property: "og:image", content: "https://ael.is/social-media-preview.png" },
|
|
||||||
{ property: "og:url", content: "https://ael.is" },
|
|
||||||
{ property: "og:type", content: "website" },
|
|
||||||
{ name: "twitter:card", content: "summary_large_image" },
|
|
||||||
{ name: "twitter:title", content: PAGE_TITLE },
|
|
||||||
{ name: "twitter:description", content: PAGE_DESCRIPTION },
|
|
||||||
{ name: "twitter:image", content: "https://ael.is/social-media-preview.png" },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormError = {
|
|
||||||
Duplicate: "duplicate",
|
|
||||||
Resend: "resend",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
|
||||||
const formData = await request.formData()
|
|
||||||
const email = formData.get("email")
|
|
||||||
|
|
||||||
if (typeof email !== "string" || !isValidEmail(email)) {
|
|
||||||
return { error: "Invalid email" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
||||||
|
|
||||||
const segmentId = "b80fb036-74a1-4f7d-bca5-2c035b696071"
|
|
||||||
|
|
||||||
const dup = await resend.contacts.get({
|
|
||||||
email,
|
|
||||||
})
|
|
||||||
if (dup.data) {
|
|
||||||
return { error: FormError.Duplicate }
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await resend.contacts.create({
|
|
||||||
email,
|
|
||||||
segments: [{ id: segmentId }],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.error) {
|
|
||||||
console.log("Error adding contact to Resend:", res.error)
|
|
||||||
return { error: FormError.Resend, message: res.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
const emailRes = await resend.emails.send({
|
|
||||||
from: "Aelis <no-reply@ael.is>",
|
|
||||||
to: email,
|
|
||||||
template: {
|
|
||||||
id: "waitlist-confirmation",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (emailRes.error) {
|
|
||||||
// swallow the error since the user is already added to the waitlist, but log it for debugging
|
|
||||||
console.log("Error sending confirmation email:", emailRes.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { email }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES)
|
|
||||||
const [emailSent, setEmailSent] = useState("")
|
|
||||||
const [isAnimatingSend, setIsAnimatingSend] = useState(false)
|
|
||||||
const [logoState, setLogoState] = useState<TAnimatedLogoState>(AnimatedLogoState.Idle)
|
|
||||||
const chatBoxRef = useRef<HTMLDivElement>(null)
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fetcher.data?.email && !isAnimatingSend) {
|
|
||||||
setMessages((messages) => [...messages, waitListJoinedMessage(fetcher.data.email)])
|
|
||||||
} else if (fetcher.data?.error) {
|
|
||||||
if (!isAnimatingSend) {
|
|
||||||
let errorMessage: SystemMessage
|
|
||||||
switch (fetcher.data.error) {
|
|
||||||
case FormError.Duplicate:
|
|
||||||
errorMessage = duplicateEmailMessage()
|
|
||||||
break
|
|
||||||
default: {
|
|
||||||
console.error(fetcher.data.error)
|
|
||||||
errorMessage = troubleMessage()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMessages((messages) => [...messages, errorMessage])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [fetcher.data?.email, fetcher.data?.error, isAnimatingSend])
|
|
||||||
|
|
||||||
const insertEmailMessage = (email: string) => {
|
|
||||||
setEmailSent(email)
|
|
||||||
setIsAnimatingSend(true)
|
|
||||||
setLogoState(AnimatedLogoState.Loading)
|
|
||||||
setMessages((messages) => [
|
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
message: email,
|
|
||||||
bubbleLayoutId: "test",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
fetcher.submit({ email }, { method: "post" })
|
|
||||||
}
|
|
||||||
|
|
||||||
let chatBox: React.ReactNode
|
|
||||||
if (emailSent && isAnimatingSend) {
|
|
||||||
const chatBoxRect = chatBoxRef.current?.getBoundingClientRect()
|
|
||||||
const mainRect = chatBoxRef.current?.offsetParent?.getBoundingClientRect()
|
|
||||||
chatBox = (
|
|
||||||
<MorphingChatBox
|
|
||||||
chatBoxWidth={chatBoxRef.current?.offsetWidth ?? 0}
|
|
||||||
chatBoxHeight={chatBoxRef.current?.offsetHeight ?? 0}
|
|
||||||
chatBoxLeft={(chatBoxRect?.left ?? 0) - (mainRect?.left ?? 0)}
|
|
||||||
chatBoxTop={(chatBoxRect?.top ?? 0) - (mainRect?.top ?? 0)}
|
|
||||||
onAnimationEnd={() => {
|
|
||||||
setIsAnimatingSend(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emailSent}
|
|
||||||
</MorphingChatBox>
|
|
||||||
)
|
|
||||||
} else if (!emailSent) {
|
|
||||||
chatBox = (
|
|
||||||
<AnimatePresence>
|
|
||||||
{logoState === AnimatedLogoState.Idle && !emailSent && (
|
|
||||||
<motion.div
|
|
||||||
ref={chatBoxRef}
|
|
||||||
key="test"
|
|
||||||
className="w-full max-w-2xl absolute bottom-12 px-6 md:px-0 flex justify-center z-20"
|
|
||||||
initial={{ y: 100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30, mass: 1.5 }}
|
|
||||||
>
|
|
||||||
<ChatBox
|
|
||||||
className="w-full max-w-2xl"
|
|
||||||
validate={isValidEmail}
|
|
||||||
disabled={fetcher.state === "submitting" || fetcher.state === "loading"}
|
|
||||||
onSubmit={insertEmailMessage}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
chatBox = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="relative w-full h-full flex flex-col items-center justify-start gap-4 overflow-hidden">
|
|
||||||
<ProgressiveBlur className="absolute top-0 left-0 right-0 h-24 z-10" />
|
|
||||||
<AnimatedLogo
|
|
||||||
className="absolute top-4 md:top-8 size-10 z-20 cursor-pointer"
|
|
||||||
state={logoState}
|
|
||||||
/>
|
|
||||||
<MessageList
|
|
||||||
messages={messages}
|
|
||||||
showLastMessage={!isAnimatingSend}
|
|
||||||
onMessageStreamStart={() => {
|
|
||||||
setLogoState(AnimatedLogoState.Loading)
|
|
||||||
}}
|
|
||||||
onMessageStreamEnd={() => {
|
|
||||||
setLogoState(AnimatedLogoState.Idle)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{chatBox}
|
|
||||||
<ProgressiveBlur
|
|
||||||
direction="up"
|
|
||||||
className="absolute bottom-0 left-0 right-0 h-24 z-10 pointer-events-none"
|
|
||||||
/>
|
|
||||||
<footer className="absolute bottom-4 z-20">
|
|
||||||
<Link to="/privacy" className="text-xs opacity-50 underline">
|
|
||||||
Privacy policy
|
|
||||||
</Link>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MorphingChatBox({
|
|
||||||
chatBoxWidth,
|
|
||||||
chatBoxHeight,
|
|
||||||
chatBoxLeft,
|
|
||||||
chatBoxTop,
|
|
||||||
onAnimationEnd,
|
|
||||||
children,
|
|
||||||
}: React.PropsWithChildren<{
|
|
||||||
chatBoxWidth: number
|
|
||||||
chatBoxHeight: number
|
|
||||||
chatBoxLeft: number
|
|
||||||
chatBoxTop: number
|
|
||||||
onAnimationEnd: () => void
|
|
||||||
}>) {
|
|
||||||
const [targetWidth, setTargetWidth] = useState(-1)
|
|
||||||
const [targetHeight, setTargetHeight] = useState(-1)
|
|
||||||
const [targetCoords, setTargetCoords] = useState([0, 0])
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const bubble = document.getElementById("test")
|
|
||||||
if (bubble) {
|
|
||||||
const mainRect = bubble.closest("main")?.getBoundingClientRect()
|
|
||||||
const rect = bubble.getBoundingClientRect()
|
|
||||||
setTargetWidth(bubble.offsetWidth)
|
|
||||||
setTargetHeight(bubble.offsetHeight)
|
|
||||||
setTargetCoords([rect.left - (mainRect?.left ?? 0), rect.top - (mainRect?.top ?? 0)])
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (targetWidth < 0 || targetHeight < 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="absolute rounded-lg bg-stone-100 dark:bg-stone-800 px-4 py-2 border border-stone-200 dark:border-stone-700"
|
|
||||||
initial={{
|
|
||||||
width: chatBoxWidth,
|
|
||||||
height: chatBoxHeight,
|
|
||||||
borderRadius: 8,
|
|
||||||
left: chatBoxLeft,
|
|
||||||
top: chatBoxTop,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
width: targetWidth,
|
|
||||||
height: targetHeight,
|
|
||||||
borderTopLeftRadius: 100,
|
|
||||||
borderTopRightRadius: 100,
|
|
||||||
borderBottomRightRadius: 24,
|
|
||||||
borderBottomLeftRadius: 100,
|
|
||||||
left: targetCoords[0],
|
|
||||||
top: targetCoords[1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
left: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
|
||||||
top: { duration: 0.45, ease: [0.3, 0, 0.2, 1] },
|
|
||||||
width: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
|
||||||
height: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
|
||||||
}}
|
|
||||||
onAnimationComplete={onAnimationEnd}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageList({
|
|
||||||
messages,
|
|
||||||
showLastMessage,
|
|
||||||
onMessageStreamStart,
|
|
||||||
onMessageStreamEnd,
|
|
||||||
}: {
|
|
||||||
messages: Message[]
|
|
||||||
showLastMessage: boolean
|
|
||||||
onMessageStreamStart: () => void
|
|
||||||
onMessageStreamEnd: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ul className="w-full flex flex-col gap-8 overflow-auto px-6 pt-20 md:px-0 md:pt-24 pb-34">
|
|
||||||
{messages.map((message, index) => (
|
|
||||||
<li
|
|
||||||
key={index}
|
|
||||||
className={`flex justify-center ${index === messages.length - 1 && !showLastMessage ? "invisible" : ""}`}
|
|
||||||
>
|
|
||||||
<MessageContent
|
|
||||||
message={message}
|
|
||||||
onMessageStreamStart={onMessageStreamStart}
|
|
||||||
onMessageStreamEnd={onMessageStreamEnd}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageContent({
|
|
||||||
message,
|
|
||||||
onMessageStreamStart,
|
|
||||||
onMessageStreamEnd,
|
|
||||||
}: {
|
|
||||||
message: Message
|
|
||||||
onMessageStreamStart: () => void
|
|
||||||
onMessageStreamEnd: () => void
|
|
||||||
}) {
|
|
||||||
switch (message.role) {
|
|
||||||
case "user":
|
|
||||||
return <UserMessageBubble message={message} />
|
|
||||||
case "system":
|
|
||||||
return (
|
|
||||||
<SystemMessageBubble
|
|
||||||
message={message}
|
|
||||||
onStreamStart={onMessageStreamStart}
|
|
||||||
onStreamEnd={onMessageStreamEnd}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserMessageBubble({ message }: { message: UserMessage }) {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-2xl flex justify-end">
|
|
||||||
<div
|
|
||||||
id={message.bubbleLayoutId}
|
|
||||||
className="rounded-[100px_100px_24px_100px] bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 px-4 py-2"
|
|
||||||
>
|
|
||||||
{message.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemMessageBubble({
|
|
||||||
message,
|
|
||||||
onStreamStart,
|
|
||||||
onStreamEnd,
|
|
||||||
}: {
|
|
||||||
message: SystemMessage
|
|
||||||
onStreamStart: () => void
|
|
||||||
onStreamEnd: () => void
|
|
||||||
}) {
|
|
||||||
const { currentContent, isStreaming } = useFakeStreaming(message.message)
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ref.current?.scrollIntoView({ behavior: "smooth", block: "end" })
|
|
||||||
}, [currentContent])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStreaming) {
|
|
||||||
onStreamStart()
|
|
||||||
} else {
|
|
||||||
onStreamEnd()
|
|
||||||
}
|
|
||||||
}, [isStreaming])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="w-full max-w-2xl flex justify-start font-serif text-lg scroll-mb-34">
|
|
||||||
<Streamdown
|
|
||||||
animated={{ animation: "slideUp" }}
|
|
||||||
isAnimating={isStreaming}
|
|
||||||
linkSafety={{ enabled: false }}
|
|
||||||
components={{
|
|
||||||
// @ts-expect-error
|
|
||||||
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentContent}
|
|
||||||
</Streamdown>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidEmail(value: string): boolean {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { Link } from "react-router"
|
|
||||||
import { Streamdown } from "streamdown"
|
|
||||||
|
|
||||||
import { AnimatedLogo, AnimatedLogoState } from "~/components/animated-logo"
|
|
||||||
|
|
||||||
import type { Route } from "./+types/privacy-policy"
|
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
|
||||||
return [
|
|
||||||
{ title: "Privacy Policy — Aelis" },
|
|
||||||
{ name: "description", content: "Aelis privacy policy" },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
|
||||||
return (
|
|
||||||
<main className="relative max-w-2xl mx-auto px-6 py-16">
|
|
||||||
<Link to="/" className="block w-fit mb-8">
|
|
||||||
<AnimatedLogo className="size-10 pointer-events-none" state={AnimatedLogoState.Idle} />
|
|
||||||
</Link>
|
|
||||||
<Streamdown
|
|
||||||
isAnimating={false}
|
|
||||||
linkSafety={{ enabled: false }}
|
|
||||||
components={{
|
|
||||||
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{POLICY}
|
|
||||||
</Streamdown>
|
|
||||||
<footer className="mt-16 pt-8 border-t border-stone-200 dark:border-stone-700">
|
|
||||||
<Link to="/" className="text-sm opacity-50 hover:opacity-75 underline">
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const POLICY = `# Privacy Policy
|
|
||||||
|
|
||||||
**Last updated:** March 5, 2026
|
|
||||||
|
|
||||||
This Privacy Policy describes how **Aelis** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://ael.is** or interact with our services.
|
|
||||||
|
|
||||||
If you do not agree with this Privacy Policy, please do not use the website.
|
|
||||||
|
|
||||||
For any questions, contact: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Information We Collect
|
|
||||||
|
|
||||||
### Personal Information You Provide
|
|
||||||
|
|
||||||
**In Short:** We collect personal information that you provide to us.
|
|
||||||
|
|
||||||
We collect personal information that you voluntarily provide when you express interest in our services, contact us, or sign up for the waitlist.
|
|
||||||
|
|
||||||
We collect your email address when you sign up for the waitlist so we can notify you when the product launches or provide related updates.
|
|
||||||
|
|
||||||
### Personal Information Provided by You
|
|
||||||
|
|
||||||
The personal information we collect may include:
|
|
||||||
|
|
||||||
* email addresses
|
|
||||||
|
|
||||||
You are responsible for ensuring the personal information you provide is accurate and up to date.
|
|
||||||
|
|
||||||
### Sensitive Information
|
|
||||||
|
|
||||||
We **do not collect or process sensitive personal information**.
|
|
||||||
|
|
||||||
### Information From Third Parties
|
|
||||||
|
|
||||||
We **do not collect personal information from third parties**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. How We Use Your Information
|
|
||||||
|
|
||||||
We process your information for the following purposes:
|
|
||||||
|
|
||||||
* To operate and maintain our services
|
|
||||||
* To communicate with you about product updates and launch announcements
|
|
||||||
* To send administrative information such as policy updates
|
|
||||||
* To prevent fraud or abuse
|
|
||||||
* To comply with legal obligations
|
|
||||||
* To protect someone’s safety when necessary
|
|
||||||
|
|
||||||
We only process personal information when we have a valid legal reason to do so.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Legal Bases for Processing (EU / UK)
|
|
||||||
|
|
||||||
If you are located in the European Economic Area (EEA) or the United Kingdom, we rely on the following legal bases to process personal information:
|
|
||||||
|
|
||||||
### Consent
|
|
||||||
|
|
||||||
You have given permission for us to process your personal information for a specific purpose.
|
|
||||||
|
|
||||||
### Contract
|
|
||||||
|
|
||||||
Processing is necessary to provide services you requested.
|
|
||||||
|
|
||||||
### Legal Obligations
|
|
||||||
|
|
||||||
Processing is required to comply with applicable laws.
|
|
||||||
|
|
||||||
### Vital Interests
|
|
||||||
|
|
||||||
Processing is necessary to protect someone's safety.
|
|
||||||
|
|
||||||
You may withdraw consent at any time by contacting us.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. When and With Whom We Share Personal Information
|
|
||||||
|
|
||||||
We may share your personal information in limited situations.
|
|
||||||
|
|
||||||
### Service Providers
|
|
||||||
|
|
||||||
We may share information with trusted service providers that help us operate our website or manage communications.
|
|
||||||
|
|
||||||
### Business Transfers
|
|
||||||
|
|
||||||
We may transfer information during negotiations of a merger, sale of assets, financing, or acquisition of our business.
|
|
||||||
|
|
||||||
We **do not sell your personal information**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. How Long We Keep Your Information
|
|
||||||
|
|
||||||
We retain personal information only as long as necessary to:
|
|
||||||
|
|
||||||
* provide services
|
|
||||||
* comply with legal obligations
|
|
||||||
* resolve disputes
|
|
||||||
* enforce agreements
|
|
||||||
|
|
||||||
When we no longer need personal information, we delete or anonymize it where possible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. How We Keep Your Information Safe
|
|
||||||
|
|
||||||
We implement reasonable technical and organizational safeguards designed to protect personal information.
|
|
||||||
|
|
||||||
However, no electronic transmission or storage system is completely secure. We cannot guarantee absolute security.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Information From Minors
|
|
||||||
|
|
||||||
Our services are **not intended for individuals under 18 years old**.
|
|
||||||
|
|
||||||
We do not knowingly collect personal information from children. If we discover that we have collected such information, we will delete it.
|
|
||||||
|
|
||||||
If you believe a child has provided personal information, contact **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Your Privacy Rights
|
|
||||||
|
|
||||||
Depending on your location, you may have rights regarding your personal information, including:
|
|
||||||
|
|
||||||
* the right to access your data
|
|
||||||
* the right to correct inaccurate data
|
|
||||||
* the right to delete your data
|
|
||||||
* the right to restrict processing
|
|
||||||
* the right to data portability
|
|
||||||
* the right to object to processing
|
|
||||||
* the right to withdraw consent
|
|
||||||
|
|
||||||
To exercise these rights, submit a request:
|
|
||||||
|
|
||||||
https://app.termly.io/dsar/b8633d03-406f-4133-b16e-ded63e893997
|
|
||||||
|
|
||||||
Or contact us at **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Do Not Track (DNT)
|
|
||||||
|
|
||||||
Many browsers include a **Do Not Track (DNT)** feature.
|
|
||||||
|
|
||||||
Because there is currently no consistent standard for responding to DNT signals, we do not respond to them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Global Privacy Control
|
|
||||||
|
|
||||||
We recognize **Global Privacy Control (GPC)** signals.
|
|
||||||
|
|
||||||
If your browser sends a GPC signal, we treat it as a request to opt out of the sale or sharing of personal information where applicable.
|
|
||||||
|
|
||||||
More information: https://globalprivacycontrol.org
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Privacy Rights in Other Regions
|
|
||||||
|
|
||||||
Additional privacy rights may apply depending on your location, including:
|
|
||||||
|
|
||||||
* European Economic Area (EEA)
|
|
||||||
* United Kingdom
|
|
||||||
* Switzerland
|
|
||||||
* Canada
|
|
||||||
* United States
|
|
||||||
* Australia
|
|
||||||
* New Zealand
|
|
||||||
|
|
||||||
If you believe we are processing your personal information unlawfully, you may contact your local data protection authority.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Updates to This Privacy Policy
|
|
||||||
|
|
||||||
We may update this Privacy Policy from time to time.
|
|
||||||
|
|
||||||
When we do, we will update the **Last updated** date at the top of this document.
|
|
||||||
|
|
||||||
We encourage users to review this Privacy Policy regularly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Contact Information
|
|
||||||
|
|
||||||
If you have questions or comments about this Privacy Policy, you may contact us:
|
|
||||||
|
|
||||||
**Aelis**
|
|
||||||
|
|
||||||
Email: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Request Access, Update, or Deletion
|
|
||||||
|
|
||||||
Depending on applicable law, you may request access to, correction of, or deletion of your personal information.
|
|
||||||
|
|
||||||
Email:
|
|
||||||
|
|
||||||
**[kenneth@nym.sh](mailto:kenneth@nym.sh)**
|
|
||||||
`
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# fly.toml app configuration file generated for aelis-waitlist-website on 2026-03-08T01:11:12Z
|
|
||||||
#
|
|
||||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
|
||||||
#
|
|
||||||
|
|
||||||
app = 'aelis-waitlist-website'
|
|
||||||
primary_region = 'lhr'
|
|
||||||
|
|
||||||
[build]
|
|
||||||
|
|
||||||
[http_service]
|
|
||||||
internal_port = 3000
|
|
||||||
force_https = true
|
|
||||||
auto_stop_machines = 'stop'
|
|
||||||
auto_start_machines = true
|
|
||||||
min_machines_running = 0
|
|
||||||
processes = ['app']
|
|
||||||
|
|
||||||
[[vm]]
|
|
||||||
memory = '1gb'
|
|
||||||
cpus = 1
|
|
||||||
memory_mb = 1024
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "waitlist-website",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "react-router build",
|
|
||||||
"dev": "react-router dev",
|
|
||||||
"start": "react-router-serve ./build/server/index.js",
|
|
||||||
"typecheck": "react-router typegen && tsc"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@react-router/node": "7.12.0",
|
|
||||||
"@react-router/serve": "7.12.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"isbot": "^5.1.31",
|
|
||||||
"lottie-react": "^2.4.1",
|
|
||||||
"lucide-react": "^0.577.0",
|
|
||||||
"motion": "^12.35.0",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-aria-components": "^1.16.0",
|
|
||||||
"react-dom": "^19.2.4",
|
|
||||||
"react-router": "7.12.0",
|
|
||||||
"resend": "^6.9.3",
|
|
||||||
"streamdown": "^2.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@react-router/dev": "7.12.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
|
||||||
"@types/node": "^22",
|
|
||||||
"@types/react": "^19.2.7",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"tailwindcss": "^4.1.13",
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
"vite": "^7.1.7",
|
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1667 1667" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="dark" x="0" y="0" width="1666.67" height="1666.67" style="fill:none;"/><path d="M943.75,642.086c318.648,183.972 527.874,419.028 466.934,524.581c-60.941,105.552 -369.119,41.885 -687.767,-142.086c-318.649,-183.972 -527.875,-419.029 -466.934,-524.581c60.941,-105.552 369.119,-41.886 687.767,142.086Z" style="fill:none;stroke:#e7e5e4;stroke-width:62.5px;"/><path d="M722.917,642.086c318.648,-183.972 626.826,-247.638 687.767,-142.086c60.94,105.552 -148.286,340.609 -466.934,524.581c-318.648,183.971 -626.826,247.638 -687.767,142.086c-60.941,-105.553 148.285,-340.609 466.934,-524.581Z" style="fill:none;stroke:#e7e5e4;stroke-width:62.5px;"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1667 1667" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="light" x="0" y="0" width="1666.67" height="1666.67" style="fill:none;"/><g id="light1" serif:id="light"><path d="M943.75,642.086c318.648,183.972 527.874,419.028 466.934,524.581c-60.941,105.552 -369.119,41.885 -687.767,-142.086c-318.649,-183.972 -527.875,-419.029 -466.934,-524.581c60.941,-105.552 369.119,-41.886 687.767,142.086Z" style="fill:none;stroke:#1c1917;stroke-width:62.5px;"/><path d="M722.917,642.086c318.648,-183.972 626.826,-247.638 687.767,-142.086c60.94,105.552 -148.286,340.609 -466.934,524.581c-318.648,183.971 -626.826,247.638 -687.767,142.086c-60.941,-105.553 148.285,-340.609 466.934,-524.581Z" style="fill:none;stroke:#1c1917;stroke-width:62.5px;"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://ael.is/sitemap.xml
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://ael.is/</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ael.is/privacy</loc>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
@@ -1,7 +0,0 @@
|
|||||||
import type { Config } from "@react-router/dev/config"
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Config options...
|
|
||||||
// Server-side render by default, to enable SPA mode set this to `false`
|
|
||||||
ssr: true,
|
|
||||||
} satisfies Config
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
||||||
"types": ["node", "vite/client"],
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"rootDirs": [".", "./.react-router/types"],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"~/*": ["./app/*"]
|
|
||||||
},
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { reactRouter } from "@react-router/dev/vite"
|
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
|
||||||
import { defineConfig } from "vite"
|
|
||||||
import tsconfigPaths from "vite-tsconfig-paths"
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
|
||||||
ssr: {
|
|
||||||
noExternal: ["lottie-react"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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,128 +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,18 +294,16 @@ 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
|
||||||
const allGroupedItems: ItemGroup[] = []
|
const allGroupedItems: ItemGroup[] = []
|
||||||
const allErrors = [...errors]
|
const allErrors = [...errors]
|
||||||
const boostScores = new Map<string, number>()
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -325,12 +321,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
if (enhancement.groupedItems?.length) {
|
if (enhancement.groupedItems?.length) {
|
||||||
allGroupedItems.push(...enhancement.groupedItems)
|
allGroupedItems.push(...enhancement.groupedItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enhancement.boost) {
|
|
||||||
for (const [id, score] of Object.entries(enhancement.boost)) {
|
|
||||||
boostScores.set(id, (boostScores.get(id) ?? 0) + score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const sourceId = processor.name || "anonymous"
|
const sourceId = processor.name || "anonymous"
|
||||||
allErrors.push({
|
allErrors.push({
|
||||||
@@ -341,12 +331,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply boost reordering: positive-boost first (desc), then zero, then negative (desc).
|
|
||||||
// Stable sort within each tier preserves original relative order.
|
|
||||||
if (boostScores.size > 0) {
|
|
||||||
currentItems = applyBoostOrder(currentItems, boostScores)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stale item IDs from groups and drop empty groups
|
// Remove stale item IDs from groups and drop empty groups
|
||||||
const itemIds = new Set(currentItems.map((item) => item.id))
|
const itemIds = new Set(currentItems.map((item) => item.id))
|
||||||
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
|
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
|
||||||
@@ -367,9 +351,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 +367,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 +399,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,
|
||||||
@@ -503,47 +486,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
|
||||||
return Math.min(max, Math.max(min, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyBoostOrder<T extends FeedItem>(items: T[], boostScores: Map<string, number>): T[] {
|
|
||||||
const positive: T[] = []
|
|
||||||
const neutral: T[] = []
|
|
||||||
const negative: T[] = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const raw = boostScores.get(item.id)
|
|
||||||
if (raw === undefined || raw === 0) {
|
|
||||||
neutral.push(item)
|
|
||||||
} else {
|
|
||||||
const clamped = clamp(raw, -1, 1)
|
|
||||||
if (clamped > 0) {
|
|
||||||
positive.push(item)
|
|
||||||
} else if (clamped < 0) {
|
|
||||||
negative.push(item)
|
|
||||||
} else {
|
|
||||||
neutral.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort positive descending by boost, negative descending (least negative first, most negative last)
|
|
||||||
positive.sort((a, b) => {
|
|
||||||
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
|
||||||
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
|
||||||
return bScore - aScore
|
|
||||||
})
|
|
||||||
|
|
||||||
negative.sort((a, b) => {
|
|
||||||
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
|
||||||
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
|
||||||
return bScore - aScore
|
|
||||||
})
|
|
||||||
|
|
||||||
return [...positive, ...neutral, ...negative]
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGraph(sources: FeedSource[]): SourceGraph {
|
function buildGraph(sources: FeedSource[]): SourceGraph {
|
||||||
const byId = new Map<string, FeedSource>()
|
const byId = new Map<string, FeedSource>()
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -215,163 +209,6 @@ describe("FeedPostProcessor", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// BOOST
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("boost", () => {
|
|
||||||
test("positive boost moves item before non-boosted items", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w2: 0.8 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("negative boost moves item after non-boosted items", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: -0.5 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("multiple boosted items are sorted by boost descending", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
boost: { w3: 0.3, w1: 0.9 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 (0.9) first, w3 (0.3) second, w2 (no boost) last
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("multiple processors accumulate boost scores", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.3 } }))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.4 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 accumulated boost = 0.7, moves before w2
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accumulated boost is clamped to [-1, 1]", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.8, w2: 0.9 } }))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.8 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 accumulated = 1.6 clamped to 1, w2 = 0.9 — w1 still first
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2", "w3"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("out-of-range boost values are clamped", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 5.0 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// Clamped to 1, still boosted to front
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosting a suppressed item is a no-op", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
suppress: ["w1"],
|
|
||||||
boost: { w1: 1.0 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0].id).toBe("w2")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosting a nonexistent item ID is a no-op", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { nonexistent: 1.0 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0].id).toBe("w1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("items with equal boost retain original relative order", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
boost: { w1: 0.5, w3: 0.5 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 and w3 have equal boost — original order preserved: w1 before w3
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("negative boosts preserve relative order among demoted items", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
boost: { w1: -0.3, w2: -0.3 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w3 (neutral) first, then w1 and w2 (equal negative) in original order
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w3", "w1", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boost works alongside additionalItems and groupedItems", async () => {
|
|
||||||
const extra = calendarItem("c1", "Meeting")
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
additionalItems: [extra],
|
|
||||||
boost: { c1: 1.0 },
|
|
||||||
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// c1 boosted to front
|
|
||||||
expect(result.items[0].id).toBe("c1")
|
|
||||||
expect(result.items).toHaveLength(3)
|
|
||||||
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// PIPELINE ORDERING
|
// PIPELINE ORDERING
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -477,7 +314,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 +333,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 +347,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 +358,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 +377,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 +390,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 {
|
||||||
@@ -15,12 +14,10 @@ export interface FeedEnhancement {
|
|||||||
groupedItems?: ItemGroup[]
|
groupedItems?: ItemGroup[]
|
||||||
/** Item IDs to remove from the feed */
|
/** Item IDs to remove from the feed */
|
||||||
suppress?: string[]
|
suppress?: string[]
|
||||||
/** Map of item ID to boost score (-1 to 1). Positive promotes, negative demotes. */
|
|
||||||
boost?: Record<string, number>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A function that transforms feed items and produces enhancement directives.
|
* 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,5 +505,3 @@ describe("computeSignals", () => {
|
|||||||
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
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 { 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 { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
|
||||||
|
|
||||||
// -- Source options --
|
// -- Source options --
|
||||||
|
|
||||||
@@ -93,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
|
||||||
@@ -124,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[]> {
|
||||||
@@ -184,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,7 +340,7 @@ export function computeSignals(
|
|||||||
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),
|
||||||
|
|||||||
@@ -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,191 +9,21 @@ import {
|
|||||||
type CalDavEventData,
|
type CalDavEventData,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
export interface ICalTimeRange {
|
|
||||||
start: Date
|
|
||||||
end: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safety cap to prevent runaway iteration on pathological recurrence rules.
|
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||||
* Each iteration is pure date math (no I/O), so a high cap is fine.
|
|
||||||
* 10,000 covers a daily event with DTSTART ~27 years in the past.
|
|
||||||
*/
|
|
||||||
const MAX_RECURRENCE_ITERATIONS = 10_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a raw iCalendar string and extracts VEVENT components
|
|
||||||
* into CalDavEventData objects.
|
* 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.
|
|
||||||
// The iterator must start from DTSTART (not rangeStart) because
|
|
||||||
// ical.js needs to walk the recurrence rule grid from the original
|
|
||||||
// anchor. We cap iterations to avoid runaway expansion on
|
|
||||||
// pathological rules.
|
|
||||||
const iter = masterEvent.iterator()
|
|
||||||
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
|
|
||||||
let iterations = 0
|
|
||||||
|
|
||||||
while (next) {
|
|
||||||
if (++iterations > MAX_RECURRENCE_ITERATIONS) {
|
|
||||||
console.warn(
|
|
||||||
`[aris.caldav] Recurrence expansion for "${masterEvent.uid}" hit iteration limit (${MAX_RECURRENCE_ITERATIONS}), stopping`,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop once we're past the range end
|
|
||||||
if (next.compare(rangeEnd) >= 0) break
|
|
||||||
|
|
||||||
const details = masterEvent.getOccurrenceDetails(next)
|
|
||||||
const occEnd = details.endDate
|
|
||||||
|
|
||||||
// Skip occurrences that end before the range starts
|
|
||||||
if (occEnd.compare(rangeStart) <= 0) {
|
|
||||||
next = iter.next()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const occEvent = details.item
|
|
||||||
const occComponent = occEvent.component
|
|
||||||
|
|
||||||
const parsed = parseVEventWithDates(
|
|
||||||
occComponent,
|
|
||||||
calendarName,
|
|
||||||
details.startDate.toJSDate(),
|
|
||||||
details.endDate.toJSDate(),
|
|
||||||
details.recurrenceId ? details.recurrenceId.toString() : null,
|
|
||||||
)
|
|
||||||
results.push(parsed)
|
|
||||||
|
|
||||||
next = iter.next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlapsRange(event: CalDavEventData, range: ICalTimeRange): boolean {
|
|
||||||
return event.startDate < range.end && event.endDate > range.start
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a VEVENT component, overriding start/end/recurrenceId with
|
|
||||||
* values from recurrence expansion.
|
|
||||||
*/
|
|
||||||
function parseVEventWithDates(
|
|
||||||
vevent: InstanceType<typeof ICAL.Component>,
|
|
||||||
calendarName: string | null,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date,
|
|
||||||
recurrenceId: string | null,
|
|
||||||
): CalDavEventData {
|
|
||||||
const event = new ICAL.Event(vevent)
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid: event.uid ?? "",
|
|
||||||
title: event.summary ?? "",
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
isAllDay: event.startDate?.isDate ?? false,
|
|
||||||
location: event.location ?? null,
|
|
||||||
description: event.description ?? null,
|
|
||||||
calendarName,
|
|
||||||
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
|
||||||
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
|
||||||
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
|
||||||
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
|
||||||
alarms: parseAlarms(vevent),
|
|
||||||
recurrenceId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVEvent(
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interactive CLI script to query WeatherKit directly.
|
|
||||||
* Prompts for credentials, coordinates, and optional settings,
|
|
||||||
* then prints the raw API response and processed feed items.
|
|
||||||
* Caches credentials locally and writes response JSON to a file.
|
|
||||||
*
|
|
||||||
* Usage: bun packages/aris-source-weatherkit/scripts/query.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
import { createInterface } from "node:readline/promises"
|
|
||||||
|
|
||||||
import { Context } from "@aris/core"
|
|
||||||
import { LocationKey } from "@aris/source-location"
|
|
||||||
|
|
||||||
import { DefaultWeatherKitClient } from "../src/weatherkit"
|
|
||||||
import { WeatherSource, Units } from "../src/weather-source"
|
|
||||||
|
|
||||||
const SCRIPT_DIR = import.meta.dirname
|
|
||||||
const CACHE_DIR = join(SCRIPT_DIR, ".cache")
|
|
||||||
const CREDS_PATH = join(CACHE_DIR, "credentials.json")
|
|
||||||
|
|
||||||
interface CachedCredentials {
|
|
||||||
teamId: string
|
|
||||||
serviceId: string
|
|
||||||
keyId: string
|
|
||||||
privateKey: string
|
|
||||||
lat?: number
|
|
||||||
lng?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCachedCredentials(): CachedCredentials | null {
|
|
||||||
if (!existsSync(CREDS_PATH)) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(CREDS_PATH, "utf-8")) as CachedCredentials
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCachedCredentials(creds: CachedCredentials): void {
|
|
||||||
mkdirSync(CACHE_DIR, { recursive: true })
|
|
||||||
writeFileSync(CREDS_PATH, JSON.stringify(creds))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
||||||
|
|
||||||
async function prompt(question: string, defaultValue?: string): Promise<string> {
|
|
||||||
const suffix = defaultValue ? ` [${defaultValue}]` : ""
|
|
||||||
const answer = await rl.question(`${question}${suffix}: `)
|
|
||||||
return answer.trim() || defaultValue || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
console.log("=== WeatherKit Query Tool ===\n")
|
|
||||||
|
|
||||||
const cached = loadCachedCredentials()
|
|
||||||
|
|
||||||
let teamId: string
|
|
||||||
let serviceId: string
|
|
||||||
let keyId: string
|
|
||||||
let privateKey: string
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
console.log(`Using cached credentials from ${CREDS_PATH}`)
|
|
||||||
console.log(` Team ID: ${cached.teamId}`)
|
|
||||||
console.log(` Service ID: ${cached.serviceId}`)
|
|
||||||
console.log(` Key ID: ${cached.keyId}\n`)
|
|
||||||
|
|
||||||
const useCached = await prompt("Use cached credentials? (y/n)", "y")
|
|
||||||
if (useCached.toLowerCase() === "y") {
|
|
||||||
teamId = cached.teamId
|
|
||||||
serviceId = cached.serviceId
|
|
||||||
keyId = cached.keyId
|
|
||||||
privateKey = cached.privateKey
|
|
||||||
} else {
|
|
||||||
;({ teamId, serviceId, keyId, privateKey } = await promptCredentials())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`Credentials will be cached to ${CREDS_PATH}\n`)
|
|
||||||
;({ teamId, serviceId, keyId, privateKey } = await promptCredentials())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Location
|
|
||||||
const defaultLat = cached?.lat?.toString() ?? "37.7749"
|
|
||||||
const defaultLng = cached?.lng?.toString() ?? "-122.4194"
|
|
||||||
const lat = parseFloat(await prompt("Latitude", defaultLat))
|
|
||||||
const lng = parseFloat(await prompt("Longitude", defaultLng))
|
|
||||||
|
|
||||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
|
||||||
console.error("Invalid coordinates")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = { privateKey, keyId, teamId, serviceId }
|
|
||||||
saveCachedCredentials({ ...credentials, lat, lng })
|
|
||||||
|
|
||||||
// Options
|
|
||||||
const unitsInput = await prompt("Units (metric/imperial)", "metric")
|
|
||||||
const units = unitsInput === "imperial" ? Units.imperial : Units.metric
|
|
||||||
|
|
||||||
// Raw API query
|
|
||||||
console.log("\n--- Raw WeatherKit Response ---\n")
|
|
||||||
const client = new DefaultWeatherKitClient(credentials)
|
|
||||||
const raw = await client.fetch({ lat, lng })
|
|
||||||
console.log(JSON.stringify(raw, null, 2))
|
|
||||||
|
|
||||||
// Write JSON to file
|
|
||||||
const outPath = join(CACHE_DIR, "response.json")
|
|
||||||
writeFileSync(outPath, JSON.stringify(raw))
|
|
||||||
console.log(`\nResponse written to ${outPath}`)
|
|
||||||
|
|
||||||
// Processed feed items via WeatherSource
|
|
||||||
console.log("\n--- Processed Feed Items ---\n")
|
|
||||||
const source = new WeatherSource({ client, units })
|
|
||||||
const context = new Context()
|
|
||||||
context.set([[LocationKey, { lat, lng, accuracy: 10, timestamp: new Date() }]])
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
for (const item of items) {
|
|
||||||
console.log(`[${item.type}] ${item.id}`)
|
|
||||||
console.log(` signals: ${JSON.stringify(item.signals)}`)
|
|
||||||
if (item.slots) {
|
|
||||||
console.log(` slots:`)
|
|
||||||
for (const [name, slot] of Object.entries(item.slots)) {
|
|
||||||
console.log(` ${name}: "${slot.description}" -> ${slot.content ?? "(unfilled)"}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` data: ${JSON.stringify(item.data, null, 4)}`)
|
|
||||||
console.log()
|
|
||||||
}
|
|
||||||
|
|
||||||
const feedPath = join(CACHE_DIR, "feed-items.json")
|
|
||||||
writeFileSync(feedPath, JSON.stringify(items, null, 2))
|
|
||||||
console.log(`Feed items written to ${feedPath}`)
|
|
||||||
console.log(`Total: ${items.length} items`)
|
|
||||||
rl.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptCredentials(): Promise<CachedCredentials> {
|
|
||||||
const teamId = await prompt("Apple Team ID")
|
|
||||||
if (!teamId) {
|
|
||||||
console.error("Team ID is required")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceId = await prompt("Service ID")
|
|
||||||
if (!serviceId) {
|
|
||||||
console.error("Service ID is required")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyId = await prompt("Key ID")
|
|
||||||
if (!keyId) {
|
|
||||||
console.error("Key ID is required")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\nPaste your private key (PEM format). Enter an empty line when done:")
|
|
||||||
const keyLines: string[] = []
|
|
||||||
for await (const line of rl) {
|
|
||||||
if (line.trim() === "") break
|
|
||||||
keyLines.push(line)
|
|
||||||
}
|
|
||||||
const privateKey = keyLines.join("\n")
|
|
||||||
if (!privateKey) {
|
|
||||||
console.error("Private key is required")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { teamId, serviceId, keyId, privateKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("Error:", err)
|
|
||||||
rl.close()
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
Max 12 words. Plain language, no hedging. Now + what's next.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "Clear tonight, warming up. Rain by Saturday."
|
|
||||||
- "Clearing soon with strong winds overnight. Light rain Thursday."
|
|
||||||
- "Sunny all day. Grab sunscreen."
|
|
||||||
- "Cloudy tonight, warming to 15°. Rain Monday."
|
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -176,44 +173,16 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
expect(uniqueIds.size).toBe(ids.length)
|
expect(uniqueIds.size).toBe(ids.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("current weather item has insight slot", async () => {
|
|
||||||
const source = new WeatherSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
|
||||||
|
|
||||||
expect(currentItem).toBeDefined()
|
|
||||||
expect(currentItem!.slots).toBeDefined()
|
|
||||||
expect(currentItem!.slots!.insight).toBeDefined()
|
|
||||||
expect(currentItem!.slots!.insight!.description).toBeString()
|
|
||||||
expect(currentItem!.slots!.insight!.description.length).toBeGreaterThan(0)
|
|
||||||
expect(currentItem!.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("non-current items do not have slots", async () => {
|
|
||||||
const source = new WeatherSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
const nonCurrentItems = items.filter((i) => i.type !== WeatherFeedItemType.Current)
|
|
||||||
|
|
||||||
expect(nonCurrentItems.length).toBeGreaterThan(0)
|
|
||||||
for (const item of nonCurrentItems) {
|
|
||||||
expect(item.slots).toBeUndefined()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user