Files
aris/packages/aelis-feed-enhancers/src/time-of-day-enhancer.ts
Kenneth 863c298bd3 refactor: rename aris to aelis (#59)
Rename all references across the codebase: package names,
imports, source IDs, directory names, docs, and configs.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-10 19:19:23 +00:00

596 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } from "@aelis/core"
import { TimeRelevance } from "@aelis/core"
import type { CalDavEventData } from "@aelis/source-caldav"
import type { CalendarEventData } from "@aelis/source-google-calendar"
import type { CurrentWeatherData } from "@aelis/source-weatherkit"
import { CalDavFeedItemType } from "@aelis/source-caldav"
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
import { TflFeedItemType } from "@aelis/source-tfl"
import { WeatherFeedItemType } from "@aelis/source-weatherkit"
export const TimePeriod = {
Morning: "morning",
Afternoon: "afternoon",
Evening: "evening",
Night: "night",
} as const
export type TimePeriod = (typeof TimePeriod)[keyof typeof TimePeriod]
export const DayType = {
Weekday: "weekday",
Weekend: "weekend",
} as const
export type DayType = (typeof DayType)[keyof typeof DayType]
const PRE_MEETING_WINDOW_MS = 30 * 60 * 1000
const TRANSITION_WINDOW_MS = 30 * 60 * 1000
const PERIOD_BOUNDARIES = [
{ hour: 6, period: TimePeriod.Morning },
{ hour: 12, period: TimePeriod.Afternoon },
{ hour: 17, period: TimePeriod.Evening },
{ hour: 22, period: TimePeriod.Night },
] as const
/** All calendar event types across sources */
const CALENDAR_EVENT_TYPES: ReadonlySet<string> = new Set([
CalendarFeedItemType.Event,
CalDavFeedItemType.Event,
])
/**
* Creates a post-processor that reranks feed items based on time of day.
*
* Prioritizes items that matter right now and pushes down items that don't:
*
* - Morning: weather and first meeting rise, hourly forecasts sink.
* Weekends flip — weather stays up but work calendar and commute alerts drop.
* - Afternoon: imminent meetings rise. Stale weather sinks.
* - Evening: work calendar is suppressed, tomorrow's forecast and personal
* events rise. Weekends suppress work more aggressively.
* - Night: almost everything sinks except high-urgency alerts.
* - Pre-meeting (30 min before any event): that meeting dominates, low-urgency
* noise is suppressed, commute/weather context rises if the meeting has a location.
* - Wind-down (weekday 2022h): work items progressively sink as night approaches.
* - Transition lookahead (30 min before a period boundary): items relevant to
* the next period get a head start.
* - Weather-time correlation: precipitation boosts morning weather, evening
* events with locations boost current weather, alerts always stay high.
*/
export interface TimeOfDayEnhancerOptions {
/** Override clock for testing. Defaults to reading context.time. */
clock?: () => Date
}
export function createTimeOfDayEnhancer(options?: TimeOfDayEnhancerOptions): FeedPostProcessor {
const clock = options?.clock
function timeOfDayEnhancer(items: FeedItem[], context: Context): Promise<FeedEnhancement> {
if (items.length === 0) return Promise.resolve({})
const now = clock ? clock() : context.time
const period = getTimePeriod(now)
const dayType = getDayType(now)
const boost: Record<string, number> = {}
const suppress: string[] = []
// 1. Apply period-based rules
const firstEventId = findFirstEventOfDay(items, now)
switch (period) {
case TimePeriod.Morning:
if (dayType === DayType.Weekday) {
applyMorningWeekday(items, boost, firstEventId)
} else {
applyMorningWeekend(items, boost)
}
break
case TimePeriod.Afternoon:
if (dayType === DayType.Weekday) {
applyAfternoonWeekday(items, boost)
} else {
applyAfternoonWeekend(items, boost)
}
break
case TimePeriod.Evening:
if (dayType === DayType.Weekday) {
applyEveningWeekday(items, boost, suppress)
} else {
applyEveningWeekend(items, boost, suppress)
}
break
case TimePeriod.Night:
applyNight(items, boost, suppress)
break
}
// 2. Pre-meeting overrides (can override period rules)
const preMeeting = detectPreMeetingItems(items, now)
applyPreMeetingOverrides(items, preMeeting, boost, suppress)
// 3. Wind-down gradient
applyWindDown(items, now, dayType, boost)
// 4. Transition lookahead
applyTransitionLookahead(items, now, period, dayType, boost)
// 5. Weather-time correlation
const eveningLocation = hasEveningCalendarEventWithLocation(items, now)
applyWeatherTimeCorrelation(items, period, dayType, eveningLocation, boost)
// Clamp boost values to [-1, 1] — additive layers can exceed the range
for (const id in boost) {
boost[id] = Math.max(-1, Math.min(1, boost[id]!))
}
const result: FeedEnhancement = {}
if (Object.keys(boost).length > 0) {
result.boost = boost
}
const uniqueSuppress = [...new Set(suppress)]
if (uniqueSuppress.length > 0) {
result.suppress = uniqueSuppress
}
return Promise.resolve(result)
}
return timeOfDayEnhancer
}
export function getTimePeriod(date: Date): TimePeriod {
const hour = date.getHours()
if (hour >= 22 || hour < 6) return TimePeriod.Night
if (hour >= 17) return TimePeriod.Evening
if (hour >= 12) return TimePeriod.Afternoon
return TimePeriod.Morning
}
export function getDayType(date: Date): DayType {
const day = date.getDay()
return day === 0 || day === 6 ? DayType.Weekend : DayType.Weekday
}
/**
* Returns the next period boundary as { hour, period } and the ms until it.
*/
function getNextPeriodBoundary(date: Date): { period: TimePeriod; msUntil: number } {
const hour = date.getHours()
const minuteMs = date.getMinutes() * 60_000 + date.getSeconds() * 1000 + date.getMilliseconds()
for (const boundary of PERIOD_BOUNDARIES) {
if (hour < boundary.hour) {
const msUntil = (boundary.hour - hour) * 3_600_000 - minuteMs
return { period: boundary.period, msUntil }
}
}
// Past 22:00 — next boundary is morning at 06:00
const hoursUntil6 = (24 - hour + 6) * 3_600_000 - minuteMs
return { period: TimePeriod.Morning, msUntil: hoursUntil6 }
}
/**
* Extract start time from calendar event data.
* Google Calendar uses `startTime`, CalDAV uses `startDate`.
*/
function getEventStartTime(data: CalendarEventData | CalDavEventData): Date {
return "startTime" in data ? (data as CalendarEventData).startTime : (data as CalDavEventData).startDate
}
/**
* Check if a current weather item indicates precipitation or extreme conditions.
* Only meaningful for weather-current items.
*/
function hasPrecipitationOrExtreme(item: FeedItem): boolean {
const data = item.data as CurrentWeatherData
if (data.precipitationIntensity > 0) return true
if (data.temperature < 0 || data.temperature > 35) return true
return false
}
interface PreMeetingInfo {
/** IDs of calendar items starting within the pre-meeting window */
upcomingMeetingIds: Set<string>
/** Whether any upcoming meeting has a location */
hasLocationMeeting: boolean
}
function detectPreMeetingItems(items: FeedItem[], now: Date): PreMeetingInfo {
const nowMs = now.getTime()
const upcomingMeetingIds = new Set<string>()
let hasLocationMeeting = false
for (const item of items) {
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
const data = item.data as CalendarEventData | CalDavEventData
const msUntil = getEventStartTime(data).getTime() - nowMs
if (msUntil > 0 && msUntil <= PRE_MEETING_WINDOW_MS) {
upcomingMeetingIds.add(item.id)
if (data.location) {
hasLocationMeeting = true
}
}
}
return { upcomingMeetingIds, hasLocationMeeting }
}
function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
let earliest: { id: string; time: number } | null = null
for (const item of items) {
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
const data = item.data as CalendarEventData | CalDavEventData
const startTime = getEventStartTime(data)
const startMs = startTime.getTime()
// Must be today and in the future
const sameDay =
startTime.getFullYear() === now.getFullYear() &&
startTime.getMonth() === now.getMonth() &&
startTime.getDate() === now.getDate()
if (!sameDay) continue
if (startMs <= now.getTime()) continue
if (!earliest || startMs < earliest.time) {
earliest = { id: item.id, time: startMs }
}
}
return earliest?.id ?? null
}
function applyMorningWeekday(
items: FeedItem[],
boost: Record<string, number>,
firstEventId: string | null,
): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.7
break
case WeatherFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + 0.8
break
case WeatherFeedItemType.Hourly:
case WeatherFeedItemType.Daily:
boost[item.id] = (boost[item.id] ?? 0) - 0.3
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + 0.6
break
}
}
if (firstEventId) {
boost[firstEventId] = (boost[firstEventId] ?? 0) + 0.6
}
}
function applyMorningWeekend(items: FeedItem[], boost: Record<string, number>): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.5
break
case WeatherFeedItemType.Daily:
boost[item.id] = (boost[item.id] ?? 0) + 0.4
break
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
boost[item.id] = (boost[item.id] ?? 0) - 0.4
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.3
break
}
}
}
function applyAfternoonWeekday(items: FeedItem[], boost: Record<string, number>): void {
for (const item of items) {
switch (item.type) {
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
if (item.signals?.timeRelevance === TimeRelevance.Imminent) {
boost[item.id] = (boost[item.id] ?? 0) + 0.5
}
break
case WeatherFeedItemType.Current:
case WeatherFeedItemType.Hourly:
boost[item.id] = (boost[item.id] ?? 0) - 0.2
break
}
}
}
function applyAfternoonWeekend(items: FeedItem[], boost: Record<string, number>): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
boost[item.id] = (boost[item.id] ?? 0) - 0.5
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.2
break
}
}
}
function applyEveningWeekday(
items: FeedItem[],
boost: Record<string, number>,
suppress: string[],
): void {
for (const item of items) {
switch (item.type) {
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
suppress.push(item.id)
}
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.4
break
case WeatherFeedItemType.Daily:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
case CalendarFeedItemType.AllDay:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
}
}
}
function applyEveningWeekend(
items: FeedItem[],
boost: Record<string, number>,
suppress: string[],
): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
suppress.push(item.id)
}
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.5
break
}
}
}
function applyNight(items: FeedItem[], boost: Record<string, number>, suppress: string[]): void {
for (const item of items) {
// Suppress all ambient items
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
suppress.push(item.id)
continue
}
// High-urgency alerts survive unboosted
if (
(item.type === WeatherFeedItemType.Alert || item.type === TflFeedItemType.Alert) &&
(item.signals?.urgency ?? 0) >= 0.8
) {
continue
}
// Demote everything else
switch (item.type) {
case CalendarFeedItemType.Event:
case CalendarFeedItemType.AllDay:
case CalDavFeedItemType.Event:
boost[item.id] = (boost[item.id] ?? 0) - 0.6
break
case WeatherFeedItemType.Current:
case WeatherFeedItemType.Hourly:
boost[item.id] = (boost[item.id] ?? 0) - 0.5
break
}
}
}
function applyPreMeetingOverrides(
items: FeedItem[],
preMeeting: PreMeetingInfo,
boost: Record<string, number>,
suppress: string[],
): void {
if (preMeeting.upcomingMeetingIds.size === 0) return
// Intentional override, not additive — the upcoming meeting should dominate
// regardless of what period rules assigned. Don't reorder this before period rules.
for (const meetingId of preMeeting.upcomingMeetingIds) {
boost[meetingId] = 0.9
}
for (const item of items) {
if (preMeeting.upcomingMeetingIds.has(item.id)) continue
switch (item.type) {
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + 0.5
break
case WeatherFeedItemType.Current:
if (preMeeting.hasLocationMeeting) {
boost[item.id] = (boost[item.id] ?? 0) + 0.4
}
break
}
// Suppress items that explicitly declare low urgency.
// Items without signals are left alone — absence of urgency is not low urgency.
if (item.signals && item.signals.urgency !== undefined && item.signals.urgency < 0.3) {
suppress.push(item.id)
}
}
}
function applyWindDown(
items: FeedItem[],
now: Date,
dayType: DayType,
boost: Record<string, number>,
): void {
if (dayType !== DayType.Weekday) return
const hour = now.getHours()
const minutes = now.getMinutes()
if (hour < 20 || hour >= 22) return
// Gradient: 20:00 → -0.1, 21:00 → -0.2, 21:30+ → -0.3
let additionalDemotion: number
if (hour === 20) {
additionalDemotion = -0.1
} else if (hour === 21 && minutes < 30) {
additionalDemotion = -0.2
} else {
additionalDemotion = -0.3
}
for (const item of items) {
switch (item.type) {
case CalendarFeedItemType.Event:
case CalendarFeedItemType.AllDay:
case CalDavFeedItemType.Event:
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + additionalDemotion
break
}
}
}
function applyTransitionLookahead(
items: FeedItem[],
now: Date,
currentPeriod: TimePeriod,
dayType: DayType,
boost: Record<string, number>,
): void {
const next = getNextPeriodBoundary(now)
if (next.msUntil > TRANSITION_WINDOW_MS) return
// Apply a +0.2 secondary boost to items that would be boosted in the next period
const nextPeriodBoost = getNextPeriodBoostTargets(next.period, dayType)
for (const item of items) {
if (nextPeriodBoost.has(item.type)) {
boost[item.id] = (boost[item.id] ?? 0) + 0.2
}
}
}
/**
* Returns the set of item types that get boosted in a given period+dayType.
*/
function getNextPeriodBoostTargets(period: TimePeriod, dayType: DayType): ReadonlySet<string> {
const targets = new Set<string>()
switch (period) {
case TimePeriod.Morning:
targets.add(WeatherFeedItemType.Current)
if (dayType === DayType.Weekday) {
targets.add(WeatherFeedItemType.Alert)
targets.add(TflFeedItemType.Alert)
} else {
targets.add(WeatherFeedItemType.Daily)
}
break
case TimePeriod.Afternoon:
if (dayType === DayType.Weekend) {
targets.add(WeatherFeedItemType.Current)
}
break
case TimePeriod.Evening:
targets.add(WeatherFeedItemType.Daily)
if (dayType === DayType.Weekend) {
targets.add(WeatherFeedItemType.Current)
}
break
case TimePeriod.Night:
// Night doesn't boost much — transition toward night means demoting,
// which is handled by wind-down. No positive targets here.
break
}
return targets
}
function applyWeatherTimeCorrelation(
items: FeedItem[],
period: TimePeriod,
dayType: DayType,
hasEveningEventWithLocation: boolean,
boost: Record<string, number>,
): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Alert: {
const current = boost[item.id] ?? 0
if (current < 0.5) {
boost[item.id] = 0.5
}
break
}
case WeatherFeedItemType.Current:
if (period === TimePeriod.Morning && dayType === DayType.Weekday && hasPrecipitationOrExtreme(item)) {
boost[item.id] = (boost[item.id] ?? 0) + 0.1
}
if (period === TimePeriod.Evening && hasEveningEventWithLocation) {
boost[item.id] = (boost[item.id] ?? 0) + 0.2
}
break
}
}
}
function hasEveningCalendarEventWithLocation(items: FeedItem[], now: Date): boolean {
const todayEvening17 = new Date(now)
todayEvening17.setHours(17, 0, 0, 0)
const todayNight22 = new Date(now)
todayNight22.setHours(22, 0, 0, 0)
for (const item of items) {
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
const data = item.data as CalendarEventData | CalDavEventData
const startMs = getEventStartTime(data).getTime()
if (startMs >= todayEvening17.getTime() && startMs < todayNight22.getTime()) {
if (data.location) return true
}
}
return false
}