mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 00:51:20 +00:00
feat: add Google Calendar data source
Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
72
packages/aris-source-google-calendar/fixtures/events.json
Normal file
72
packages/aris-source-google-calendar/fixtures/events.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"kind": "calendar#events",
|
||||
"summary": "primary",
|
||||
"items": [
|
||||
{
|
||||
"id": "evt-ongoing",
|
||||
"status": "confirmed",
|
||||
"htmlLink": "https://calendar.google.com/event?eid=evt-ongoing",
|
||||
"summary": "Team Standup",
|
||||
"description": "Daily standup meeting",
|
||||
"location": "Room 3A",
|
||||
"start": {
|
||||
"dateTime": "2026-01-20T09:30:00Z"
|
||||
},
|
||||
"end": {
|
||||
"dateTime": "2026-01-20T10:15:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "evt-soon",
|
||||
"status": "confirmed",
|
||||
"htmlLink": "https://calendar.google.com/event?eid=evt-soon",
|
||||
"summary": "1:1 with Manager",
|
||||
"start": {
|
||||
"dateTime": "2026-01-20T10:10:00Z"
|
||||
},
|
||||
"end": {
|
||||
"dateTime": "2026-01-20T10:40:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "evt-later",
|
||||
"status": "confirmed",
|
||||
"htmlLink": "https://calendar.google.com/event?eid=evt-later",
|
||||
"summary": "Design Review",
|
||||
"description": "Review new dashboard designs",
|
||||
"location": "Conference Room B",
|
||||
"start": {
|
||||
"dateTime": "2026-01-20T14:00:00Z"
|
||||
},
|
||||
"end": {
|
||||
"dateTime": "2026-01-20T15:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "evt-tentative",
|
||||
"status": "tentative",
|
||||
"htmlLink": "https://calendar.google.com/event?eid=evt-tentative",
|
||||
"summary": "Lunch with Alex",
|
||||
"location": "Cafe Nero",
|
||||
"start": {
|
||||
"dateTime": "2026-01-20T12:00:00Z"
|
||||
},
|
||||
"end": {
|
||||
"dateTime": "2026-01-20T13:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "evt-allday",
|
||||
"status": "confirmed",
|
||||
"htmlLink": "https://calendar.google.com/event?eid=evt-allday",
|
||||
"summary": "Company Holiday",
|
||||
"description": "Office closed",
|
||||
"start": {
|
||||
"date": "2026-01-20"
|
||||
},
|
||||
"end": {
|
||||
"date": "2026-01-21"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
packages/aris-source-google-calendar/package.json
Normal file
14
packages/aris-source-google-calendar/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@aris/source-google-calendar",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"arktype": "^2.1.0"
|
||||
}
|
||||
}
|
||||
13
packages/aris-source-google-calendar/src/calendar-context.ts
Normal file
13
packages/aris-source-google-calendar/src/calendar-context.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ContextKey } from "@aris/core"
|
||||
|
||||
import { contextKey } from "@aris/core"
|
||||
|
||||
export interface NextEvent {
|
||||
title: string
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
minutesUntilStart: number
|
||||
location: string | null
|
||||
}
|
||||
|
||||
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")
|
||||
22
packages/aris-source-google-calendar/src/feed-items.ts
Normal file
22
packages/aris-source-google-calendar/src/feed-items.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { FeedItem } from "@aris/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/aris-source-google-calendar/src/google-calendar-api.ts
Normal file
122
packages/aris-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,290 @@
|
||||
import { contextValue, type Context } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||
|
||||
import fixture from "../fixtures/events.json"
|
||||
import { NextEventKey } 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 { time: time ?? NOW }
|
||||
}
|
||||
|
||||
describe("GoogleCalendarSource", () => {
|
||||
describe("constructor", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
expect(source.id).toBe("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 priority (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!.priority).toBe(1.0)
|
||||
})
|
||||
|
||||
test("upcoming events get higher priority 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!.priority).toBeGreaterThan(later!.priority)
|
||||
})
|
||||
|
||||
test("all-day events get flat priority (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!.priority).toBe(0.4)
|
||||
})
|
||||
|
||||
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 empty when no events", async () => {
|
||||
const source = new GoogleCalendarSource({ client: createMockClient({ primary: [] }) })
|
||||
const result = await source.fetchContext(createContext())
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("returns empty 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).toEqual({})
|
||||
})
|
||||
|
||||
test("returns next upcoming timed event (not ongoing)", async () => {
|
||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||
const result = await source.fetchContext(createContext())
|
||||
|
||||
const nextEvent = contextValue(result as Context, NextEventKey)
|
||||
expect(nextEvent).toBeDefined()
|
||||
// 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 result = await source.fetchContext(createContext())
|
||||
|
||||
const nextEvent = contextValue(result as Context, NextEventKey)
|
||||
expect(nextEvent).toBeDefined()
|
||||
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).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("priority 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.priority).toBeGreaterThan(upcoming.priority)
|
||||
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { Context, FeedSource } from "@aris/core"
|
||||
|
||||
import type {
|
||||
ApiCalendarEvent,
|
||||
CalendarEventData,
|
||||
GoogleCalendarClient,
|
||||
GoogleCalendarSourceOptions,
|
||||
} from "./types"
|
||||
|
||||
import { NextEventKey, type NextEvent } from "./calendar-context"
|
||||
import { CalendarFeedItemType, type CalendarFeedItem } from "./feed-items"
|
||||
import { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
||||
|
||||
const DEFAULT_LOOKAHEAD_HOURS = 24
|
||||
|
||||
const PRIORITY_ONGOING = 1.0
|
||||
const PRIORITY_UPCOMING_MAX = 0.9
|
||||
const PRIORITY_UPCOMING_MIN = 0.3
|
||||
const PRIORITY_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 = contextValue(context, NextEventKey)
|
||||
* if (next && next.minutesUntilStart < 15) {
|
||||
* // remind user
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
||||
readonly id = "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 fetchContext(context: Context): Promise<Partial<Context>> {
|
||||
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 {}
|
||||
}
|
||||
|
||||
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 computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
|
||||
if (event.isAllDay) {
|
||||
return PRIORITY_ALL_DAY
|
||||
}
|
||||
|
||||
const startMs = event.startTime.getTime()
|
||||
const endMs = event.endTime.getTime()
|
||||
|
||||
// Ongoing: start <= now < end
|
||||
if (startMs <= nowMs && nowMs < endMs) {
|
||||
return PRIORITY_ONGOING
|
||||
}
|
||||
|
||||
// Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
|
||||
const msUntilStart = startMs - nowMs
|
||||
if (msUntilStart <= 0) {
|
||||
return PRIORITY_UPCOMING_MIN
|
||||
}
|
||||
|
||||
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
|
||||
return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
|
||||
}
|
||||
|
||||
function createFeedItem(
|
||||
event: CalendarEventData,
|
||||
nowMs: number,
|
||||
lookaheadMs: number,
|
||||
): CalendarFeedItem {
|
||||
const priority = computePriority(event, nowMs, lookaheadMs)
|
||||
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
||||
|
||||
return {
|
||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||
type: itemType,
|
||||
priority,
|
||||
timestamp: new Date(nowMs),
|
||||
data: event,
|
||||
}
|
||||
}
|
||||
21
packages/aris-source-google-calendar/src/index.ts
Normal file
21
packages/aris-source-google-calendar/src/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { NextEventKey, type NextEvent } from "./calendar-context"
|
||||
export {
|
||||
CalendarFeedItemType,
|
||||
type CalendarFeedItemType as CalendarFeedItemTypeType,
|
||||
type CalendarAllDayFeedItem,
|
||||
type CalendarEventFeedItem,
|
||||
type CalendarFeedItem,
|
||||
} from "./feed-items"
|
||||
export { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
||||
export { GoogleCalendarSource } from "./google-calendar-source"
|
||||
export {
|
||||
EventStatus,
|
||||
type EventStatus as EventStatusType,
|
||||
type ApiCalendarEvent,
|
||||
type ApiEventDateTime,
|
||||
type CalendarEventData,
|
||||
type GoogleCalendarClient,
|
||||
type GoogleCalendarSourceOptions,
|
||||
type GoogleOAuthProvider,
|
||||
type ListEventsOptions,
|
||||
} from "./types"
|
||||
93
packages/aris-source-google-calendar/src/types.ts
Normal file
93
packages/aris-source-google-calendar/src/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Provider interface for Google OAuth credentials.
|
||||
* Consumers implement this to supply tokens from their auth storage/flow.
|
||||
* The source never stores or manages tokens itself.
|
||||
*/
|
||||
export interface GoogleOAuthProvider {
|
||||
/** Return a valid access token, refreshing internally if necessary. */
|
||||
fetchAccessToken(): Promise<string>
|
||||
/** Force a token refresh and return the new access token. */
|
||||
refresh(): Promise<string>
|
||||
/** Revoke the current credentials. */
|
||||
revoke(): Promise<void>
|
||||
}
|
||||
|
||||
export const EventStatus = {
|
||||
Confirmed: "confirmed",
|
||||
Tentative: "tentative",
|
||||
Cancelled: "cancelled",
|
||||
} as const
|
||||
|
||||
export type EventStatus = (typeof EventStatus)[keyof typeof EventStatus]
|
||||
|
||||
/** Google Calendar API event datetime object. Exactly one of dateTime or date is present. */
|
||||
export interface ApiEventDateTime {
|
||||
dateTime?: string
|
||||
date?: string
|
||||
timeZone?: string
|
||||
}
|
||||
|
||||
/** Google Calendar API event resource shape. */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstraction over the Google Calendar REST API.
|
||||
* Inject a mock for testing.
|
||||
*/
|
||||
export interface GoogleCalendarClient {
|
||||
/** List all calendar IDs accessible by the user. */
|
||||
listCalendarIds(): Promise<string[]>
|
||||
/** List events matching the given options. Returns raw API event objects. */
|
||||
listEvents(options: ListEventsOptions): Promise<ApiCalendarEvent[]>
|
||||
}
|
||||
|
||||
interface GoogleCalendarSourceBaseOptions {
|
||||
/** Calendar IDs to fetch. Defaults to all user calendars. */
|
||||
calendarIds?: string[]
|
||||
/** How far ahead to look for events, in hours. Default: 24 */
|
||||
lookaheadHours?: number
|
||||
}
|
||||
|
||||
interface GoogleCalendarSourceWithProvider extends GoogleCalendarSourceBaseOptions {
|
||||
/** OAuth provider for authenticating with Google Calendar API */
|
||||
oauthProvider: GoogleOAuthProvider
|
||||
client?: never
|
||||
}
|
||||
|
||||
interface GoogleCalendarSourceWithClient extends GoogleCalendarSourceBaseOptions {
|
||||
oauthProvider?: never
|
||||
/** Injectable API client (for testing) */
|
||||
client: GoogleCalendarClient
|
||||
}
|
||||
|
||||
export type GoogleCalendarSourceOptions =
|
||||
| GoogleCalendarSourceWithProvider
|
||||
| GoogleCalendarSourceWithClient
|
||||
Reference in New Issue
Block a user