Skip to content

Commit 237ea8e

Browse files
committed
feat(calendar): lazy-load events by date range with cache-first refresh
Replace full-table LiveQuery with range-based loading. LiveQuery's reactivity added no value here — only our own code writes to comp_calendar_event after API fetches, so it was re-scanning all events on every write we already had the data for. - datesSet callback drives lazy-load per visible date range - 5-minute TTL for cache freshness, SQLite for instant paint on revisit - Generation counter discards stale API responses on rapid navigation - Reactive $state array replaces imperative setOption for event updates - Pure utility functions extracted to calendar-utils.ts with 16 unit tests - fetchGroupEvents accepts optional startDate/endDate query params Addresses feedback about calendar events appearing out-of-date.
1 parent 017e57a commit 237ea8e

5 files changed

Lines changed: 483 additions & 143 deletions

File tree

packages/app/src/lib/queries/calendar.svelte.ts

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,3 @@ export function calendarLinkQuery(streamId: string | undefined) {
2020
`,
2121
);
2222
}
23-
24-
export interface CalendarEvent {
25-
[key: string]: unknown;
26-
entity: string;
27-
slug: string;
28-
name: string;
29-
startDate: string;
30-
endDate: string | null;
31-
location: string | null;
32-
locationOnline: string | null;
33-
status: string;
34-
syncedAt: number;
35-
}
36-
37-
export function calendarEventsQuery(streamId: string | undefined) {
38-
return new LiveQuery<CalendarEvent>(
39-
() => sql`
40-
select
41-
entity,
42-
slug,
43-
name,
44-
start_date as startDate,
45-
end_date as endDate,
46-
location,
47-
location_online as locationOnline,
48-
status,
49-
synced_at as syncedAt
50-
from comp_calendar_event
51-
where entity like ${streamId + ":%"}
52-
order by start_date asc
53-
`,
54-
);
55-
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { describe, test, expect } from "vitest";
2+
import {
3+
statusClass,
4+
mapToCalEvent,
5+
isRangeFresh,
6+
pruneExpiredRanges,
7+
type FetchedRange,
8+
} from "./calendar-utils";
9+
10+
describe("statusClass", () => {
11+
test("defaults to ec-status-scheduled for undefined", () => {
12+
expect(statusClass(undefined)).toBe("ec-status-scheduled");
13+
});
14+
15+
test("normalizes lexicon token with hash", () => {
16+
expect(statusClass("community.lexicon.calendar.event#cancelled")).toBe(
17+
"ec-status-cancelled",
18+
);
19+
});
20+
21+
test("lowercases plain status", () => {
22+
expect(statusClass("Published")).toBe("ec-status-published");
23+
});
24+
});
25+
26+
describe("mapToCalEvent", () => {
27+
test("maps API response (camelCase fields)", () => {
28+
const event = mapToCalEvent({
29+
slug: "my-event",
30+
name: "My Event",
31+
startDate: "2026-03-15T10:00:00.000Z",
32+
endDate: "2026-03-15T11:00:00.000Z",
33+
location: "Room A",
34+
locationOnline: "https://meet.example.com",
35+
status: "Published",
36+
});
37+
38+
expect(event.id).toBe("my-event");
39+
expect(event.title).toBe("My Event");
40+
expect(event.start).toEqual(new Date("2026-03-15T10:00:00.000Z"));
41+
expect(event.end).toEqual(new Date("2026-03-15T11:00:00.000Z"));
42+
expect(event.className).toBe("ec-status-published");
43+
expect(event.extendedProps.location).toBe("Room A");
44+
expect(event.extendedProps.locationOnline).toBe("https://meet.example.com");
45+
});
46+
47+
test("maps SQLite row (snake_case fields)", () => {
48+
const event = mapToCalEvent({
49+
slug: "db-event",
50+
name: "DB Event",
51+
start_date: "2026-03-15T10:00:00.000Z",
52+
end_date: "2026-03-15T11:00:00.000Z",
53+
location: null,
54+
location_online: "https://zoom.us/123",
55+
status: "Published",
56+
});
57+
58+
expect(event.id).toBe("db-event");
59+
expect(event.start).toEqual(new Date("2026-03-15T10:00:00.000Z"));
60+
expect(event.extendedProps.location).toBeNull();
61+
expect(event.extendedProps.locationOnline).toBe("https://zoom.us/123");
62+
});
63+
64+
test("prefixes cancelled events with (Cancelled)", () => {
65+
const event = mapToCalEvent({
66+
slug: "cancelled-event",
67+
name: "Old Meetup",
68+
startDate: "2026-03-15T10:00:00.000Z",
69+
status: "Cancelled",
70+
});
71+
72+
expect(event.title).toBe("(Cancelled) Old Meetup");
73+
expect(event.className).toBe("ec-status-cancelled");
74+
});
75+
76+
test("uses startDate as endDate fallback", () => {
77+
const event = mapToCalEvent({
78+
slug: "no-end",
79+
name: "Quick Event",
80+
startDate: "2026-03-15T10:00:00.000Z",
81+
});
82+
83+
expect(event.end).toEqual(event.start);
84+
});
85+
86+
test("defaults status to Published when missing", () => {
87+
const event = mapToCalEvent({
88+
slug: "no-status",
89+
name: "Event",
90+
startDate: "2026-03-15T10:00:00.000Z",
91+
});
92+
93+
expect(event.extendedProps.status).toBe("Published");
94+
expect(event.className).toBe("ec-status-published");
95+
});
96+
});
97+
98+
describe("isRangeFresh", () => {
99+
const STALE_TTL = 5 * 60 * 1000;
100+
101+
test("returns true when range is covered and within TTL", () => {
102+
const ranges: FetchedRange[] = [
103+
{ start: 100, end: 200, fetchedAt: Date.now() },
104+
];
105+
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(true);
106+
});
107+
108+
test("returns true when range is a subset of a fetched range", () => {
109+
const ranges: FetchedRange[] = [
110+
{ start: 0, end: 300, fetchedAt: Date.now() },
111+
];
112+
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(true);
113+
});
114+
115+
test("returns false when range is stale", () => {
116+
const ranges: FetchedRange[] = [
117+
{ start: 100, end: 200, fetchedAt: Date.now() - STALE_TTL - 1 },
118+
];
119+
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(false);
120+
});
121+
122+
test("returns false when range is not fully covered", () => {
123+
const ranges: FetchedRange[] = [
124+
{ start: 100, end: 150, fetchedAt: Date.now() },
125+
];
126+
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(false);
127+
});
128+
129+
test("returns false when no ranges exist", () => {
130+
expect(isRangeFresh([], 100, 200, STALE_TTL)).toBe(false);
131+
});
132+
});
133+
134+
describe("pruneExpiredRanges", () => {
135+
const STALE_TTL = 5 * 60 * 1000;
136+
137+
test("removes expired ranges", () => {
138+
const ranges: FetchedRange[] = [
139+
{ start: 0, end: 100, fetchedAt: Date.now() - STALE_TTL - 1 },
140+
{ start: 100, end: 200, fetchedAt: Date.now() },
141+
];
142+
const result = pruneExpiredRanges(ranges, STALE_TTL);
143+
expect(result).toHaveLength(1);
144+
expect(result[0]!.start).toBe(100);
145+
});
146+
147+
test("keeps all fresh ranges", () => {
148+
const ranges: FetchedRange[] = [
149+
{ start: 0, end: 100, fetchedAt: Date.now() },
150+
{ start: 100, end: 200, fetchedAt: Date.now() },
151+
];
152+
expect(pruneExpiredRanges(ranges, STALE_TTL)).toHaveLength(2);
153+
});
154+
155+
test("returns empty array when all expired", () => {
156+
const ranges: FetchedRange[] = [
157+
{ start: 0, end: 100, fetchedAt: Date.now() - STALE_TTL - 1 },
158+
];
159+
expect(pruneExpiredRanges(ranges, STALE_TTL)).toHaveLength(0);
160+
});
161+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export type CalEvent = {
2+
id: string;
3+
title: string;
4+
start: Date;
5+
end: Date;
6+
className?: string;
7+
extendedProps: Record<string, unknown>;
8+
};
9+
10+
export type FetchedRange = {
11+
start: number;
12+
end: number;
13+
fetchedAt: number;
14+
};
15+
16+
export function statusClass(status: string | undefined): string {
17+
if (!status) return "ec-status-scheduled";
18+
const normalized = status.includes("#") ? status.split("#").pop()! : status;
19+
return `ec-status-${normalized.toLowerCase()}`;
20+
}
21+
22+
export function mapToCalEvent(e: Record<string, unknown>): CalEvent {
23+
const status = (e.status as string) ?? "Published";
24+
const cancelled = status.toLowerCase() === "cancelled";
25+
const name = e.name as string;
26+
return {
27+
id: e.slug as string,
28+
title: cancelled ? `(Cancelled) ${name}` : name,
29+
start: new Date((e.startDate ?? e.start_date) as string),
30+
end: new Date(
31+
((e.endDate ?? e.end_date) as string) ||
32+
((e.startDate ?? e.start_date) as string),
33+
),
34+
className: statusClass(status),
35+
extendedProps: {
36+
slug: e.slug,
37+
location: e.location ?? null,
38+
locationOnline: e.locationOnline ?? e.location_online,
39+
status,
40+
},
41+
};
42+
}
43+
44+
export function isRangeFresh(
45+
ranges: FetchedRange[],
46+
startMs: number,
47+
endMs: number,
48+
staleTtl: number,
49+
): boolean {
50+
const now = Date.now();
51+
return ranges.some(
52+
(r) => r.start <= startMs && r.end >= endMs && now - r.fetchedAt < staleTtl,
53+
);
54+
}
55+
56+
export function pruneExpiredRanges(
57+
ranges: FetchedRange[],
58+
staleTtl: number,
59+
): FetchedRange[] {
60+
const now = Date.now();
61+
return ranges.filter((r) => now - r.fetchedAt < staleTtl);
62+
}

packages/app/src/lib/services/openmeet.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,18 @@ export function openOnOpenMeet(
245245

246246
export async function fetchGroupEvents(
247247
link: CalendarLink,
248+
startDate?: string,
249+
endDate?: string,
248250
): Promise<Record<string, unknown>[]> {
249-
const response = (await openmeetFetch(
250-
link,
251-
`/api/groups/${link.groupSlug}/events`,
252-
)) as Record<string, unknown> | Record<string, unknown>[];
251+
const params = new URLSearchParams();
252+
if (startDate) params.set("startDate", startDate);
253+
if (endDate) params.set("endDate", endDate);
254+
const qs = params.toString();
255+
const path = `/api/groups/${link.groupSlug}/events${qs ? `?${qs}` : ""}`;
256+
257+
const response = (await openmeetFetch(link, path)) as
258+
| Record<string, unknown>
259+
| Record<string, unknown>[];
253260
return Array.isArray(response)
254261
? response
255262
: ((response as Record<string, unknown>).data as Record<

0 commit comments

Comments
 (0)