Compare commits

..

1 Commits

Author SHA1 Message Date
57b38cafaf feat: replace flat context with tuple-keyed store
Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.

Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:51:29 +00:00
8 changed files with 1 additions and 1440 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { createTimeOfDayEnhancer, type TimeOfDayEnhancerOptions } from "./time-of-day-enhancer.ts"

View File

@@ -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:0011: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:0016: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:0021: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:0005: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: MondayFriday", () => {
// 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: SaturdaySunday", () => {
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)
}
})
})

View File

@@ -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 2022h): 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
}