Compare commits

...

3 Commits

Author SHA1 Message Date
13c411c842 perf: cache fetched events within a refresh cycle
FeedEngine calls fetchContext then fetchItems with the same
context. Cache events by context.time reference to avoid
duplicate CalDAV round-trips.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:36:36 +00:00
e8ba49d7bb refactor: use switch/case in parser, move options
- Replace if/else chains with switch/case in ical-parser
- Move CalendarSourceOptions to calendar-source.ts

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:29:49 +00:00
3010eb8990 refactor: replace Map with Record in tests
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:20:23 +00:00
5 changed files with 128 additions and 84 deletions

View File

@@ -48,12 +48,13 @@ class MockCredentialProvider implements CalendarCredentialProvider {
class MockDAVClient implements CalendarDAVClient { class MockDAVClient implements CalendarDAVClient {
credentials: Record<string, unknown> = {} credentials: Record<string, unknown> = {}
fetchCalendarsCallCount = 0
private calendars: CalendarDAVCalendar[] private calendars: CalendarDAVCalendar[]
private objectsByCalendarUrl: Map<string, CalendarDAVObject[]> private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
constructor( constructor(
calendars: CalendarDAVCalendar[], calendars: CalendarDAVCalendar[],
objectsByCalendarUrl: Map<string, CalendarDAVObject[]>, objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
) { ) {
this.calendars = calendars this.calendars = calendars
this.objectsByCalendarUrl = objectsByCalendarUrl this.objectsByCalendarUrl = objectsByCalendarUrl
@@ -62,6 +63,7 @@ class MockDAVClient implements CalendarDAVClient {
async login(): Promise<void> {} async login(): Promise<void> {}
async fetchCalendars(): Promise<CalendarDAVCalendar[]> { async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
this.fetchCalendarsCallCount++
return this.calendars return this.calendars
} }
@@ -69,7 +71,7 @@ class MockDAVClient implements CalendarDAVClient {
calendar: CalendarDAVCalendar calendar: CalendarDAVCalendar
timeRange: { start: string; end: string } timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]> { }): Promise<CalendarDAVObject[]> {
return this.objectsByCalendarUrl.get(params.calendar.url) ?? [] return this.objectsByCalendarUrl[params.calendar.url] ?? []
} }
} }
@@ -86,7 +88,7 @@ describe("CalendarSource", () => {
}) })
test("returns empty array when no calendars exist", async () => { test("returns empty array when no calendars exist", async () => {
const client = new MockDAVClient([], new Map()) const client = new MockDAVClient([], {})
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
}) })
@@ -95,9 +97,9 @@ describe("CalendarSource", () => {
}) })
test("returns feed items from a single calendar", async () => { test("returns feed items from a single calendar", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -116,13 +118,12 @@ describe("CalendarSource", () => {
}) })
test("returns feed items from multiple calendars", async () => { test("returns feed items from multiple calendars", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
[ "/cal/personal": [
"/cal/personal", { url: "/cal/personal/event2.ics", data: loadFixture("all-day-event.ics") },
[{ url: "/cal/personal/event2.ics", data: loadFixture("all-day-event.ics") }],
], ],
]) }
const client = new MockDAVClient( const client = new MockDAVClient(
[ [
{ url: "/cal/work", displayName: "Work" }, { url: "/cal/work", displayName: "Work" },
@@ -150,16 +151,13 @@ describe("CalendarSource", () => {
}) })
test("skips objects with non-string data", async () => { test("skips objects with non-string data", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
[ "/cal/work": [
"/cal/work", { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
[ { url: "/cal/work/bad.ics", data: 12345 },
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, { url: "/cal/work/empty.ics" },
{ url: "/cal/work/bad.ics", data: 12345 },
{ url: "/cal/work/empty.ics" },
],
], ],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -171,9 +169,9 @@ describe("CalendarSource", () => {
}) })
test("uses context time as feed item timestamp", async () => { test("uses context time as feed item timestamp", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -185,15 +183,12 @@ describe("CalendarSource", () => {
}) })
test("assigns priority based on event proximity", async () => { test("assigns priority based on event proximity", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
[ "/cal/work": [
"/cal/work", { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
[ { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
], ],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -210,9 +205,9 @@ describe("CalendarSource", () => {
}) })
test("handles calendar with non-string displayName", async () => { test("handles calendar with non-string displayName", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/weird", [{ url: "/cal/weird/event1.ics", data: loadFixture("minimal-event.ics") }]], "/cal/weird": [{ url: "/cal/weird/event1.ics", data: loadFixture("minimal-event.ics") }],
]) }
const client = new MockDAVClient( const client = new MockDAVClient(
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }], [{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
objects, objects,
@@ -226,9 +221,9 @@ describe("CalendarSource", () => {
}) })
test("handles recurring events with exceptions", async () => { test("handles recurring events with exceptions", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/work", [{ url: "/cal/work/recurring.ics", data: loadFixture("recurring-event.ics") }]], "/cal/work": [{ url: "/cal/work/recurring.ics", data: loadFixture("recurring-event.ics") }],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -248,6 +243,40 @@ describe("CalendarSource", () => {
expect(exception!.data.recurrenceId).not.toBeNull() expect(exception!.data.recurrenceId).not.toBeNull()
expect(exception!.id).toContain("-") expect(exception!.id).toContain("-")
}) })
test("caches events within the same refresh cycle", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const context = createContext(new Date("2026-01-15T12:00:00Z"))
await source.fetchContext(context)
await source.fetchItems(context)
// Same context.time reference — fetchEvents should only hit the client once
expect(client.fetchCalendarsCallCount).toBe(1)
})
test("refetches events for a different context time", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
// Different context.time references — should fetch twice
expect(client.fetchCalendarsCallCount).toBe(2)
})
}) })
describe("CalendarSource.fetchContext", () => { describe("CalendarSource.fetchContext", () => {
@@ -264,9 +293,9 @@ describe("CalendarSource.fetchContext", () => {
}) })
test("identifies in-progress events", async () => { test("identifies in-progress events", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -281,9 +310,9 @@ describe("CalendarSource.fetchContext", () => {
}) })
test("identifies next upcoming event", async () => { test("identifies next upcoming event", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -299,9 +328,9 @@ describe("CalendarSource.fetchContext", () => {
}) })
test("excludes all-day events from inProgress and nextEvent", async () => { test("excludes all-day events from inProgress and nextEvent", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
["/cal/work", [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }]], "/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,
@@ -317,15 +346,12 @@ describe("CalendarSource.fetchContext", () => {
}) })
test("counts all events including all-day in todayEventCount", async () => { test("counts all events including all-day in todayEventCount", async () => {
const objects = new Map<string, CalendarDAVObject[]>([ const objects: Record<string, CalendarDAVObject[]> = {
[ "/cal/work": [
"/cal/work", { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
[ { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
], ],
]) }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client, davClient: client,

View File

@@ -8,9 +8,15 @@ import type {
CalendarDAVClient, CalendarDAVClient,
CalendarEventData, CalendarEventData,
CalendarFeedItem, CalendarFeedItem,
CalendarSourceOptions,
} from "./types.ts" } from "./types.ts"
export interface CalendarSourceOptions {
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
davClient?: CalendarDAVClient
}
import { CalendarKey, type CalendarContext } from "./calendar-context.ts" import { CalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts" import { parseICalEvents } from "./ical-parser.ts"
@@ -39,6 +45,7 @@ export class CalendarSource implements FeedSource<CalendarFeedItem> {
private readonly injectedClient: CalendarDAVClient | null private readonly injectedClient: CalendarDAVClient | null
private davClient: CalendarDAVClient | null = null private davClient: CalendarDAVClient | null = null
private lastAccessToken: string | null = null private lastAccessToken: string | null = null
private cachedEvents: { time: Date; events: CalendarEventData[] } | null = null
constructor( constructor(
credentialProvider: CalendarCredentialProvider, credentialProvider: CalendarCredentialProvider,
@@ -88,6 +95,10 @@ export class CalendarSource implements FeedSource<CalendarFeedItem> {
} }
private async fetchEvents(context: Context): Promise<CalendarEventData[]> { private async fetchEvents(context: Context): Promise<CalendarEventData[]> {
if (this.cachedEvents && this.cachedEvents.time === context.time) {
return this.cachedEvents.events
}
const credentials = await this.credentialProvider.fetchCredentials(this.userId) const credentials = await this.credentialProvider.fetchCredentials(this.userId)
if (!credentials) { if (!credentials) {
return [] return []
@@ -128,6 +139,7 @@ export class CalendarSource implements FeedSource<CalendarFeedItem> {
} }
} }
this.cachedEvents = { time: context.time, events: allEvents }
return allEvents return allEvents
} }

View File

@@ -52,11 +52,16 @@ function parseVEvent(
function parseStatus(raw: string | null): CalendarEventStatus | null { function parseStatus(raw: string | null): CalendarEventStatus | null {
if (!raw) return null if (!raw) return null
const lower = raw.toLowerCase() switch (raw.toLowerCase()) {
if (lower === "confirmed") return CalendarEventStatus.Confirmed case "confirmed":
if (lower === "tentative") return CalendarEventStatus.Tentative return CalendarEventStatus.Confirmed
if (lower === "cancelled") return CalendarEventStatus.Cancelled case "tentative":
return null return CalendarEventStatus.Tentative
case "cancelled":
return CalendarEventStatus.Cancelled
default:
return null
}
} }
function parseOrganizer( function parseOrganizer(
@@ -97,21 +102,32 @@ function parseAttendees(properties: unknown[]): CalendarAttendee[] {
function parseAttendeeRole(raw: string | null): AttendeeRole | null { function parseAttendeeRole(raw: string | null): AttendeeRole | null {
if (!raw) return null if (!raw) return null
const upper = raw.toUpperCase() switch (raw.toUpperCase()) {
if (upper === "CHAIR") return AttendeeRole.Chair case "CHAIR":
if (upper === "REQ-PARTICIPANT") return AttendeeRole.Required return AttendeeRole.Chair
if (upper === "OPT-PARTICIPANT") return AttendeeRole.Optional case "REQ-PARTICIPANT":
return null return AttendeeRole.Required
case "OPT-PARTICIPANT":
return AttendeeRole.Optional
default:
return null
}
} }
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null { function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
if (!raw) return null if (!raw) return null
const upper = raw.toUpperCase() switch (raw.toUpperCase()) {
if (upper === "ACCEPTED") return AttendeeStatus.Accepted case "ACCEPTED":
if (upper === "DECLINED") return AttendeeStatus.Declined return AttendeeStatus.Accepted
if (upper === "TENTATIVE") return AttendeeStatus.Tentative case "DECLINED":
if (upper === "NEEDS-ACTION") return AttendeeStatus.NeedsAction return AttendeeStatus.Declined
return null case "TENTATIVE":
return AttendeeStatus.Tentative
case "NEEDS-ACTION":
return AttendeeStatus.NeedsAction
default:
return null
}
} }
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] { function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {

View File

@@ -1,5 +1,5 @@
export { CalendarKey, type CalendarContext } from "./calendar-context.ts" export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalendarSource } from "./calendar-source.ts" export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts"
export { export {
CalendarEventStatus, CalendarEventStatus,
AttendeeRole, AttendeeRole,
@@ -13,5 +13,4 @@ export {
type CalendarAlarm, type CalendarAlarm,
type CalendarEventData, type CalendarEventData,
type CalendarFeedItem, type CalendarFeedItem,
type CalendarSourceOptions,
} from "./types.ts" } from "./types.ts"

View File

@@ -99,12 +99,3 @@ export interface CalendarDAVClient {
}): Promise<CalendarDAVObject[]> }): Promise<CalendarDAVObject[]>
credentials: Record<string, unknown> credentials: Record<string, unknown>
} }
// -- Source options --
export interface CalendarSourceOptions {
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
davClient?: CalendarDAVClient
}