mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 09:01:19 +00:00
Compare commits
1 Commits
fix/caldav
...
feat/tuple
| Author | SHA1 | Date | |
|---|---|---|---|
|
57b38cafaf
|
13
bun.lock
13
bun.lock
@@ -89,17 +89,6 @@
|
|||||||
"arktype": "^2.1.0",
|
"arktype": "^2.1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aris-feed-enhancers": {
|
|
||||||
"name": "@aris/feed-enhancers",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@aris/core": "workspace:*",
|
|
||||||
"@aris/source-caldav": "workspace:*",
|
|
||||||
"@aris/source-google-calendar": "workspace:*",
|
|
||||||
"@aris/source-tfl": "workspace:*",
|
|
||||||
"@aris/source-weatherkit": "workspace:*",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/aris-source-caldav": {
|
"packages/aris-source-caldav": {
|
||||||
"name": "@aris/source-caldav",
|
"name": "@aris/source-caldav",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -155,8 +144,6 @@
|
|||||||
|
|
||||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
"@aris/feed-enhancers": ["@aris/feed-enhancers@workspace:packages/aris-feed-enhancers"],
|
|
||||||
|
|
||||||
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
|
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
|
||||||
|
|
||||||
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
||||||
|
|||||||
@@ -1,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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,13 +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 { Context } from "@aris/core"
|
||||||
|
|
||||||
import { CalDavSource } from "../src/index.ts"
|
import { CalDavSource } from "../src/index.ts"
|
||||||
@@ -56,9 +51,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 +62,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}`)
|
|
||||||
|
|||||||
@@ -208,7 +208,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 +218,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 () => {
|
||||||
@@ -533,5 +512,3 @@ describe("computeSignals", () => {
|
|||||||
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user