mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-22 18:11:17 +00:00
Rename all references across the codebase: package names, imports, source IDs, directory names, docs, and configs. Co-authored-by: Ona <no-reply@ona.com>
596 lines
17 KiB
TypeScript
596 lines
17 KiB
TypeScript
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 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
|
||
}
|
||
|
||
|