From 4f55439f77db07748c5a45f69945738640162c0b Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 1 Mar 2026 22:32:07 +0000 Subject: [PATCH] feat: add TimeOfDayEnhancer post-processor Rule-based feed post-processor that reranks items by time period, day type, and calendar proximity. New package: @aris/feed-enhancers Co-authored-by: Ona --- bun.lock | 13 + packages/aris-feed-enhancers/package.json | 17 + packages/aris-feed-enhancers/src/index.ts | 1 + .../src/time-of-day-enhancer.test.ts | 691 ++++++++++++++++++ .../src/time-of-day-enhancer.ts | 588 +++++++++++++++ 5 files changed, 1310 insertions(+) create mode 100644 packages/aris-feed-enhancers/package.json create mode 100644 packages/aris-feed-enhancers/src/index.ts create mode 100644 packages/aris-feed-enhancers/src/time-of-day-enhancer.test.ts create mode 100644 packages/aris-feed-enhancers/src/time-of-day-enhancer.ts diff --git a/bun.lock b/bun.lock index 8edf71e..02c33a5 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,17 @@ "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": { "name": "@aris/source-caldav", "version": "0.0.0", @@ -144,6 +155,8 @@ "@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-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"], diff --git a/packages/aris-feed-enhancers/package.json b/packages/aris-feed-enhancers/package.json new file mode 100644 index 0000000..03a838d --- /dev/null +++ b/packages/aris-feed-enhancers/package.json @@ -0,0 +1,17 @@ +{ + "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:*" + } +} diff --git a/packages/aris-feed-enhancers/src/index.ts b/packages/aris-feed-enhancers/src/index.ts new file mode 100644 index 0000000..2b8acbd --- /dev/null +++ b/packages/aris-feed-enhancers/src/index.ts @@ -0,0 +1 @@ +export { createTimeOfDayEnhancer, type TimeOfDayEnhancerOptions } from "./time-of-day-enhancer.ts" diff --git a/packages/aris-feed-enhancers/src/time-of-day-enhancer.test.ts b/packages/aris-feed-enhancers/src/time-of-day-enhancer.test.ts new file mode 100644 index 0000000..3b744c3 --- /dev/null +++ b/packages/aris-feed-enhancers/src/time-of-day-enhancer.test.ts @@ -0,0 +1,691 @@ +import type { Context, FeedItem, FeedItemSignals } from "@aris/core" + +import { 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 { time: 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("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) + } + }) +}) diff --git a/packages/aris-feed-enhancers/src/time-of-day-enhancer.ts b/packages/aris-feed-enhancers/src/time-of-day-enhancer.ts new file mode 100644 index 0000000..2d2a76b --- /dev/null +++ b/packages/aris-feed-enhancers/src/time-of-day-enhancer.ts @@ -0,0 +1,588 @@ +import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } 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 = 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 { + if (items.length === 0) return Promise.resolve({}) + + const now = clock ? clock() : context.time + const period = getTimePeriod(now) + const dayType = getDayType(now) + const boost: Record = {} + 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) + + 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 + /** Whether any upcoming meeting has a location */ + hasLocationMeeting: boolean +} + +function detectPreMeetingItems(items: FeedItem[], now: Date): PreMeetingInfo { + const nowMs = now.getTime() + const upcomingMeetingIds = new Set() + 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, + 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): 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): void { + for (const item of items) { + switch (item.type) { + case CalendarFeedItemType.Event: + case CalDavFeedItemType.Event: + if (item.signals?.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): 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, + suppress: string[], +): void { + for (const item of items) { + switch (item.type) { + case CalendarFeedItemType.Event: + case CalDavFeedItemType.Event: + if (item.signals?.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, + 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 === "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, suppress: string[]): void { + for (const item of items) { + // Suppress all ambient items + if (item.signals?.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, + 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, +): 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, +): 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 { + const targets = new Set() + + 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, +): 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 +} + +