mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
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>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { ContextKey } from "@aelis/core"
|
||||
|
||||
import { contextKey } from "@aelis/core"
|
||||
|
||||
export interface NextEvent {
|
||||
title: string
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
minutesUntilStart: number
|
||||
location: string | null
|
||||
}
|
||||
|
||||
export const NextEventKey: ContextKey<NextEvent> = contextKey("aelis.google-calendar", "nextEvent")
|
||||
22
packages/aelis-source-google-calendar/src/feed-items.ts
Normal file
22
packages/aelis-source-google-calendar/src/feed-items.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { FeedItem } from "@aelis/core"
|
||||
|
||||
import type { CalendarEventData } from "./types"
|
||||
|
||||
export const CalendarFeedItemType = {
|
||||
Event: "calendar-event",
|
||||
AllDay: "calendar-all-day",
|
||||
} as const
|
||||
|
||||
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
|
||||
|
||||
export interface CalendarEventFeedItem extends FeedItem<
|
||||
typeof CalendarFeedItemType.Event,
|
||||
CalendarEventData
|
||||
> {}
|
||||
|
||||
export interface CalendarAllDayFeedItem extends FeedItem<
|
||||
typeof CalendarFeedItemType.AllDay,
|
||||
CalendarEventData
|
||||
> {}
|
||||
|
||||
export type CalendarFeedItem = CalendarEventFeedItem | CalendarAllDayFeedItem
|
||||
122
packages/aelis-source-google-calendar/src/google-calendar-api.ts
Normal file
122
packages/aelis-source-google-calendar/src/google-calendar-api.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Google Calendar REST API v3 client
|
||||
// https://developers.google.com/calendar/api/v3/reference/events/list
|
||||
|
||||
import { type } from "arktype"
|
||||
|
||||
import type {
|
||||
ApiCalendarEvent,
|
||||
GoogleCalendarClient,
|
||||
GoogleOAuthProvider,
|
||||
ListEventsOptions,
|
||||
} from "./types"
|
||||
|
||||
import { EventStatus } from "./types"
|
||||
|
||||
const eventStatusSchema = type.enumerated(
|
||||
EventStatus.Confirmed,
|
||||
EventStatus.Tentative,
|
||||
EventStatus.Cancelled,
|
||||
)
|
||||
|
||||
const eventDateTimeSchema = type({
|
||||
"dateTime?": "string",
|
||||
"date?": "string",
|
||||
"timeZone?": "string",
|
||||
})
|
||||
|
||||
const eventSchema = type({
|
||||
id: "string",
|
||||
status: eventStatusSchema,
|
||||
htmlLink: "string",
|
||||
"summary?": "string",
|
||||
"description?": "string",
|
||||
"location?": "string",
|
||||
start: eventDateTimeSchema,
|
||||
end: eventDateTimeSchema,
|
||||
})
|
||||
|
||||
const calendarListEntrySchema = type({
|
||||
id: "string",
|
||||
})
|
||||
|
||||
const calendarListResponseSchema = type({
|
||||
"items?": calendarListEntrySchema.array(),
|
||||
"nextPageToken?": "string",
|
||||
})
|
||||
|
||||
const eventsResponseSchema = type({
|
||||
"items?": eventSchema.array(),
|
||||
"nextPageToken?": "string",
|
||||
})
|
||||
|
||||
export class DefaultGoogleCalendarClient implements GoogleCalendarClient {
|
||||
private static readonly API_BASE = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
private readonly oauthProvider: GoogleOAuthProvider
|
||||
|
||||
constructor(oauthProvider: GoogleOAuthProvider) {
|
||||
this.oauthProvider = oauthProvider
|
||||
}
|
||||
|
||||
async listCalendarIds(): Promise<string[]> {
|
||||
const url = `${DefaultGoogleCalendarClient.API_BASE}/users/me/calendarList?fields=items(id)`
|
||||
const json = await this.request(url)
|
||||
const result = calendarListResponseSchema(json)
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
throw new Error(`Google Calendar API response validation failed: ${result.summary}`)
|
||||
}
|
||||
|
||||
if (!result.items) {
|
||||
return []
|
||||
}
|
||||
return result.items.map((entry) => entry.id)
|
||||
}
|
||||
|
||||
async listEvents(options: ListEventsOptions): Promise<ApiCalendarEvent[]> {
|
||||
const url = new URL(
|
||||
`${DefaultGoogleCalendarClient.API_BASE}/calendars/${encodeURIComponent(options.calendarId)}/events`,
|
||||
)
|
||||
url.searchParams.set("timeMin", options.timeMin.toISOString())
|
||||
url.searchParams.set("timeMax", options.timeMax.toISOString())
|
||||
url.searchParams.set("singleEvents", "true")
|
||||
url.searchParams.set("orderBy", "startTime")
|
||||
|
||||
const json = await this.request(url.toString())
|
||||
const result = eventsResponseSchema(json)
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
throw new Error(`Google Calendar API response validation failed: ${result.summary}`)
|
||||
}
|
||||
|
||||
if (!result.items) {
|
||||
return []
|
||||
}
|
||||
|
||||
return result.items
|
||||
}
|
||||
|
||||
/** Authenticated GET with auto token refresh on 401. */
|
||||
private async request(url: string): Promise<unknown> {
|
||||
const token = await this.oauthProvider.fetchAccessToken()
|
||||
let response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
const newToken = await this.oauthProvider.refresh()
|
||||
response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${newToken}` },
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(
|
||||
`Google Calendar API error: ${response.status} ${response.statusText}: ${body}`,
|
||||
)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Context, TimeRelevance } from "@aelis/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||
|
||||
import fixture from "../fixtures/events.json"
|
||||
import { NextEventKey, type NextEvent } from "./calendar-context"
|
||||
import { CalendarFeedItemType } from "./feed-items"
|
||||
import { GoogleCalendarSource } from "./google-calendar-source"
|
||||
|
||||
const NOW = new Date("2026-01-20T10:00:00Z")
|
||||
|
||||
function fixtureEvents(): ApiCalendarEvent[] {
|
||||
return fixture.items as unknown as ApiCalendarEvent[]
|
||||
}
|
||||
|
||||
function createMockClient(
|
||||
eventsByCalendar: Record<string, ApiCalendarEvent[]>,
|
||||
): GoogleCalendarClient {
|
||||
return {
|
||||
listCalendarIds: async () => Object.keys(eventsByCalendar),
|
||||
listEvents: async (options: ListEventsOptions) => {
|
||||
const events = eventsByCalendar[options.calendarId] ?? []
|
||||
return events.filter((e) => {
|
||||
const startRaw = e.start.dateTime ?? e.start.date ?? ""
|
||||
const endRaw = e.end.dateTime ?? e.end.date ?? ""
|
||||
return (
|
||||
new Date(startRaw).getTime() < options.timeMax.getTime() &&
|
||||
new Date(endRaw).getTime() > options.timeMin.getTime()
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMockClient(): GoogleCalendarClient {
|
||||
return createMockClient({ primary: fixtureEvents() })
|
||||
}
|
||||
|
||||
function createContext(time?: Date): Context {
|
||||
return new Context(time ?? NOW)
|
||||
}
|
||||
|
||||
describe("GoogleCalendarSource", () => {
|
||||
describe("constructor", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
expect(source.id).toBe("aelis.google-calendar")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchItems", () => {
|
||||
test("returns empty array when no events", async () => {
|
||||
const source = new GoogleCalendarSource({
|
||||
client: createMockClient({ primary: [] }),
|
||||
})
|
||||
const items = await source.fetchItems(createContext())
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("returns feed items for all events in window", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
expect(items.length).toBe(fixture.items.length)
|
||||
})
|
||||
|
||||
test("assigns calendar-event type to timed events", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.Event)
|
||||
expect(timedItems.length).toBe(4)
|
||||
})
|
||||
|
||||
test("assigns calendar-all-day type to all-day events", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.AllDay)
|
||||
expect(allDayItems.length).toBe(1)
|
||||
})
|
||||
|
||||
test("ongoing events get highest urgency (1.0)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
||||
expect(ongoing).toBeDefined()
|
||||
expect(ongoing!.signals!.urgency).toBe(1.0)
|
||||
expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||
})
|
||||
|
||||
test("upcoming events get higher urgency when sooner", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const soon = items.find((i) => i.data.eventId === "evt-soon")
|
||||
const later = items.find((i) => i.data.eventId === "evt-later")
|
||||
|
||||
expect(soon).toBeDefined()
|
||||
expect(later).toBeDefined()
|
||||
expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!)
|
||||
})
|
||||
|
||||
test("all-day events get flat urgency (0.4)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
||||
expect(allDay).toBeDefined()
|
||||
expect(allDay!.signals!.urgency).toBe(0.4)
|
||||
expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||
})
|
||||
|
||||
test("generates unique IDs for each item", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const ids = items.map((i) => i.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test("sets timestamp from context.time", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.timestamp).toEqual(NOW)
|
||||
}
|
||||
})
|
||||
|
||||
test("respects lookaheadHours", async () => {
|
||||
// Only 2 hours lookahead from 10:00 → events before 12:00
|
||||
const source = new GoogleCalendarSource({
|
||||
client: defaultMockClient(),
|
||||
lookaheadHours: 2,
|
||||
})
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
// Should include: ongoing (09:30-10:15), soon (10:10-10:40), allday (00:00-next day)
|
||||
// Should exclude: later (14:00), tentative lunch (12:00)
|
||||
const eventIds = items.map((i) => i.data.eventId)
|
||||
expect(eventIds).toContain("evt-ongoing")
|
||||
expect(eventIds).toContain("evt-soon")
|
||||
expect(eventIds).toContain("evt-allday")
|
||||
expect(eventIds).not.toContain("evt-later")
|
||||
expect(eventIds).not.toContain("evt-tentative")
|
||||
})
|
||||
|
||||
test("defaults to all user calendars via listCalendarIds", async () => {
|
||||
const workEvent: ApiCalendarEvent = {
|
||||
id: "evt-work",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event?eid=evt-work",
|
||||
summary: "Work Meeting",
|
||||
start: { dateTime: "2026-01-20T11:00:00Z" },
|
||||
end: { dateTime: "2026-01-20T12:00:00Z" },
|
||||
}
|
||||
|
||||
const client = createMockClient({
|
||||
primary: fixtureEvents(),
|
||||
"work@example.com": [workEvent],
|
||||
})
|
||||
|
||||
// No calendarIds provided — should discover both calendars
|
||||
const source = new GoogleCalendarSource({ client })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const eventIds = items.map((i) => i.data.eventId)
|
||||
expect(eventIds).toContain("evt-work")
|
||||
expect(eventIds).toContain("evt-ongoing")
|
||||
})
|
||||
|
||||
test("fetches from explicit calendar IDs", async () => {
|
||||
const workEvent: ApiCalendarEvent = {
|
||||
id: "evt-work",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event?eid=evt-work",
|
||||
summary: "Work Meeting",
|
||||
start: { dateTime: "2026-01-20T11:00:00Z" },
|
||||
end: { dateTime: "2026-01-20T12:00:00Z" },
|
||||
}
|
||||
|
||||
const client = createMockClient({
|
||||
primary: fixtureEvents(),
|
||||
"work@example.com": [workEvent],
|
||||
})
|
||||
|
||||
const source = new GoogleCalendarSource({
|
||||
client,
|
||||
calendarIds: ["primary", "work@example.com"],
|
||||
})
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const eventIds = items.map((i) => i.data.eventId)
|
||||
expect(eventIds).toContain("evt-work")
|
||||
expect(eventIds).toContain("evt-ongoing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchContext", () => {
|
||||
test("returns null when no events", async () => {
|
||||
const source = new GoogleCalendarSource({
|
||||
client: createMockClient({ primary: [] }),
|
||||
})
|
||||
const result = await source.fetchContext(createContext())
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when only all-day events", async () => {
|
||||
const allDayOnly: ApiCalendarEvent[] = [
|
||||
{
|
||||
id: "evt-allday",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event?eid=evt-allday",
|
||||
summary: "Holiday",
|
||||
start: { date: "2026-01-20" },
|
||||
end: { date: "2026-01-21" },
|
||||
},
|
||||
]
|
||||
const source = new GoogleCalendarSource({
|
||||
client: createMockClient({ primary: allDayOnly }),
|
||||
})
|
||||
const result = await source.fetchContext(createContext())
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns next upcoming timed event (not ongoing)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const entries = await source.fetchContext(createContext())
|
||||
|
||||
expect(entries).not.toBeNull()
|
||||
expect(entries).toHaveLength(1)
|
||||
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
||||
expect(key).toEqual(NextEventKey)
|
||||
// evt-soon starts at 10:10, which is the nearest future timed event
|
||||
expect(nextEvent.title).toBe("1:1 with Manager")
|
||||
expect(nextEvent.minutesUntilStart).toBe(10)
|
||||
expect(nextEvent.location).toBeNull()
|
||||
})
|
||||
|
||||
test("includes location when available", async () => {
|
||||
const events: ApiCalendarEvent[] = [
|
||||
{
|
||||
id: "evt-loc",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event?eid=evt-loc",
|
||||
summary: "Offsite",
|
||||
location: "123 Main St",
|
||||
start: { dateTime: "2026-01-20T11:00:00Z" },
|
||||
end: { dateTime: "2026-01-20T12:00:00Z" },
|
||||
},
|
||||
]
|
||||
const source = new GoogleCalendarSource({
|
||||
client: createMockClient({ primary: events }),
|
||||
})
|
||||
const entries = await source.fetchContext(createContext())
|
||||
|
||||
expect(entries).not.toBeNull()
|
||||
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
||||
expect(nextEvent.location).toBe("123 Main St")
|
||||
})
|
||||
|
||||
test("skips ongoing events for next-event context", async () => {
|
||||
const events: ApiCalendarEvent[] = [
|
||||
{
|
||||
id: "evt-now",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event?eid=evt-now",
|
||||
summary: "Current Meeting",
|
||||
start: { dateTime: "2026-01-20T09:30:00Z" },
|
||||
end: { dateTime: "2026-01-20T10:30:00Z" },
|
||||
},
|
||||
]
|
||||
const source = new GoogleCalendarSource({
|
||||
client: createMockClient({ primary: events }),
|
||||
})
|
||||
const result = await source.fetchContext(createContext())
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("urgency ordering", () => {
|
||||
test("ongoing > upcoming > all-day", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")!
|
||||
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
||||
|
||||
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!)
|
||||
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aelis/core"
|
||||
|
||||
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
||||
|
||||
import type {
|
||||
ApiCalendarEvent,
|
||||
CalendarEventData,
|
||||
GoogleCalendarClient,
|
||||
GoogleOAuthProvider,
|
||||
} from "./types"
|
||||
|
||||
import { NextEventKey, type NextEvent } from "./calendar-context"
|
||||
|
||||
interface GoogleCalendarSourceBaseOptions {
|
||||
calendarIds?: string[]
|
||||
/** Default: 24 */
|
||||
lookaheadHours?: number
|
||||
}
|
||||
|
||||
interface GoogleCalendarSourceWithProvider extends GoogleCalendarSourceBaseOptions {
|
||||
oauthProvider: GoogleOAuthProvider
|
||||
client?: never
|
||||
}
|
||||
|
||||
interface GoogleCalendarSourceWithClient extends GoogleCalendarSourceBaseOptions {
|
||||
oauthProvider?: never
|
||||
client: GoogleCalendarClient
|
||||
}
|
||||
|
||||
export type GoogleCalendarSourceOptions =
|
||||
| GoogleCalendarSourceWithProvider
|
||||
| GoogleCalendarSourceWithClient
|
||||
import { CalendarFeedItemType, type CalendarFeedItem } from "./feed-items"
|
||||
import { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
||||
|
||||
const DEFAULT_LOOKAHEAD_HOURS = 24
|
||||
|
||||
const URGENCY_ONGOING = 1.0
|
||||
const URGENCY_UPCOMING_MAX = 0.9
|
||||
const URGENCY_UPCOMING_MIN = 0.3
|
||||
const URGENCY_ALL_DAY = 0.4
|
||||
|
||||
/**
|
||||
* A FeedSource that provides Google Calendar events and next-event context.
|
||||
*
|
||||
* Fetches upcoming and all-day events within a configurable lookahead window.
|
||||
* Provides a NextEvent context for downstream sources to react to the user's schedule.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const calendarSource = new GoogleCalendarSource({
|
||||
* oauthProvider: myOAuthProvider,
|
||||
* calendarIds: ["primary", "work@example.com"],
|
||||
* lookaheadHours: 12,
|
||||
* })
|
||||
*
|
||||
* const engine = new FeedEngine()
|
||||
* .register(calendarSource)
|
||||
*
|
||||
* // Access next-event context in downstream sources
|
||||
* const next = context.get(NextEventKey)
|
||||
* if (next && next.minutesUntilStart < 15) {
|
||||
* // remind user
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
||||
readonly id = "aelis.google-calendar"
|
||||
|
||||
private readonly client: GoogleCalendarClient
|
||||
private readonly calendarIds: string[] | undefined
|
||||
private readonly lookaheadHours: number
|
||||
|
||||
constructor(options: GoogleCalendarSourceOptions) {
|
||||
this.client = options.client ?? new DefaultGoogleCalendarClient(options.oauthProvider)
|
||||
this.calendarIds = options.calendarIds
|
||||
this.lookaheadHours = options.lookaheadHours ?? DEFAULT_LOOKAHEAD_HOURS
|
||||
}
|
||||
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
}
|
||||
|
||||
async executeAction(actionId: string): Promise<void> {
|
||||
throw new UnknownActionError(actionId)
|
||||
}
|
||||
|
||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
||||
const events = await this.fetchAllEvents(context.time)
|
||||
|
||||
const now = context.time.getTime()
|
||||
const nextTimedEvent = events.find((e) => !e.isAllDay && e.startTime.getTime() > now)
|
||||
|
||||
if (!nextTimedEvent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const minutesUntilStart = (nextTimedEvent.startTime.getTime() - now) / 60_000
|
||||
|
||||
const nextEvent: NextEvent = {
|
||||
title: nextTimedEvent.title,
|
||||
startTime: nextTimedEvent.startTime,
|
||||
endTime: nextTimedEvent.endTime,
|
||||
minutesUntilStart,
|
||||
location: nextTimedEvent.location,
|
||||
}
|
||||
|
||||
return [[NextEventKey, nextEvent]]
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||
const events = await this.fetchAllEvents(context.time)
|
||||
const now = context.time.getTime()
|
||||
const lookaheadMs = this.lookaheadHours * 60 * 60 * 1000
|
||||
|
||||
return events.map((event) => createFeedItem(event, now, lookaheadMs))
|
||||
}
|
||||
|
||||
private async resolveCalendarIds(): Promise<string[]> {
|
||||
if (this.calendarIds) {
|
||||
return this.calendarIds
|
||||
}
|
||||
return this.client.listCalendarIds()
|
||||
}
|
||||
|
||||
private async fetchAllEvents(time: Date): Promise<CalendarEventData[]> {
|
||||
const timeMax = new Date(time.getTime() + this.lookaheadHours * 60 * 60 * 1000)
|
||||
const calendarIds = await this.resolveCalendarIds()
|
||||
|
||||
const results = await Promise.all(
|
||||
calendarIds.map(async (calendarId) => {
|
||||
const raw = await this.client.listEvents({
|
||||
calendarId,
|
||||
timeMin: time,
|
||||
timeMax,
|
||||
})
|
||||
return raw.map((event) => parseEvent(event, calendarId))
|
||||
}),
|
||||
)
|
||||
|
||||
const allEvents = results.flat()
|
||||
|
||||
// Sort by start time ascending
|
||||
allEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
||||
|
||||
return allEvents
|
||||
}
|
||||
}
|
||||
|
||||
function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventData {
|
||||
const startRaw = event.start.dateTime ?? event.start.date
|
||||
const endRaw = event.end.dateTime ?? event.end.date
|
||||
|
||||
if (!startRaw || !endRaw) {
|
||||
throw new Error(`Event ${event.id} is missing start or end date`)
|
||||
}
|
||||
|
||||
const isAllDay = !event.start.dateTime
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
calendarId,
|
||||
title: event.summary ?? "(No title)",
|
||||
description: event.description ?? null,
|
||||
location: event.location ?? null,
|
||||
startTime: new Date(startRaw),
|
||||
endTime: new Date(endRaw),
|
||||
isAllDay,
|
||||
status: event.status,
|
||||
htmlLink: event.htmlLink,
|
||||
}
|
||||
}
|
||||
|
||||
function computeSignals(
|
||||
event: CalendarEventData,
|
||||
nowMs: number,
|
||||
lookaheadMs: number,
|
||||
): FeedItemSignals {
|
||||
if (event.isAllDay) {
|
||||
return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient }
|
||||
}
|
||||
|
||||
const startMs = event.startTime.getTime()
|
||||
const endMs = event.endTime.getTime()
|
||||
|
||||
// Ongoing: start <= now < end
|
||||
if (startMs <= nowMs && nowMs < endMs) {
|
||||
return { urgency: URGENCY_ONGOING, timeRelevance: TimeRelevance.Imminent }
|
||||
}
|
||||
|
||||
// Upcoming: linear decay from URGENCY_UPCOMING_MAX to URGENCY_UPCOMING_MIN
|
||||
const msUntilStart = startMs - nowMs
|
||||
if (msUntilStart <= 0) {
|
||||
return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient }
|
||||
}
|
||||
|
||||
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
|
||||
const urgency = URGENCY_UPCOMING_MAX - ratio * (URGENCY_UPCOMING_MAX - URGENCY_UPCOMING_MIN)
|
||||
|
||||
// Within 30 minutes = imminent, otherwise upcoming
|
||||
const timeRelevance =
|
||||
msUntilStart <= 30 * 60 * 1000 ? TimeRelevance.Imminent : TimeRelevance.Upcoming
|
||||
|
||||
return { urgency, timeRelevance }
|
||||
}
|
||||
|
||||
function createFeedItem(
|
||||
event: CalendarEventData,
|
||||
nowMs: number,
|
||||
lookaheadMs: number,
|
||||
): CalendarFeedItem {
|
||||
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
|
||||
|
||||
return {
|
||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||
type: itemType,
|
||||
timestamp: new Date(nowMs),
|
||||
data: event,
|
||||
signals: computeSignals(event, nowMs, lookaheadMs),
|
||||
}
|
||||
}
|
||||
18
packages/aelis-source-google-calendar/src/index.ts
Normal file
18
packages/aelis-source-google-calendar/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { NextEventKey, type NextEvent } from "./calendar-context"
|
||||
export {
|
||||
CalendarFeedItemType,
|
||||
type CalendarAllDayFeedItem,
|
||||
type CalendarEventFeedItem,
|
||||
type CalendarFeedItem,
|
||||
} from "./feed-items"
|
||||
export { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
||||
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
|
||||
export {
|
||||
EventStatus,
|
||||
type ApiCalendarEvent,
|
||||
type ApiEventDateTime,
|
||||
type CalendarEventData,
|
||||
type GoogleCalendarClient,
|
||||
type GoogleOAuthProvider,
|
||||
type ListEventsOptions,
|
||||
} from "./types"
|
||||
55
packages/aelis-source-google-calendar/src/types.ts
Normal file
55
packages/aelis-source-google-calendar/src/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface GoogleOAuthProvider {
|
||||
fetchAccessToken(): Promise<string>
|
||||
refresh(): Promise<string>
|
||||
revoke(): Promise<void>
|
||||
}
|
||||
|
||||
export const EventStatus = {
|
||||
Confirmed: "confirmed",
|
||||
Tentative: "tentative",
|
||||
Cancelled: "cancelled",
|
||||
} as const
|
||||
|
||||
export type EventStatus = (typeof EventStatus)[keyof typeof EventStatus]
|
||||
|
||||
/** Exactly one of dateTime or date is present. */
|
||||
export interface ApiEventDateTime {
|
||||
dateTime?: string
|
||||
date?: string
|
||||
timeZone?: string
|
||||
}
|
||||
|
||||
export interface ApiCalendarEvent {
|
||||
id: string
|
||||
status: EventStatus
|
||||
htmlLink: string
|
||||
summary?: string
|
||||
description?: string
|
||||
location?: string
|
||||
start: ApiEventDateTime
|
||||
end: ApiEventDateTime
|
||||
}
|
||||
|
||||
export type CalendarEventData = {
|
||||
eventId: string
|
||||
calendarId: string
|
||||
title: string
|
||||
description: string | null
|
||||
location: string | null
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
isAllDay: boolean
|
||||
status: EventStatus
|
||||
htmlLink: string
|
||||
}
|
||||
|
||||
export interface ListEventsOptions {
|
||||
calendarId: string
|
||||
timeMin: Date
|
||||
timeMax: Date
|
||||
}
|
||||
|
||||
export interface GoogleCalendarClient {
|
||||
listCalendarIds(): Promise<string[]>
|
||||
listEvents(options: ListEventsOptions): Promise<ApiCalendarEvent[]>
|
||||
}
|
||||
Reference in New Issue
Block a user