Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 0 additions & 33 deletions packages/app/src/lib/queries/calendar.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,3 @@ export function calendarLinkQuery(streamId: string | undefined) {
`,
);
}

export interface CalendarEvent {
[key: string]: unknown;
entity: string;
slug: string;
name: string;
startDate: string;
endDate: string | null;
location: string | null;
locationOnline: string | null;
status: string;
syncedAt: number;
}

export function calendarEventsQuery(streamId: string | undefined) {
return new LiveQuery<CalendarEvent>(
() => sql`
select
entity,
slug,
name,
start_date as startDate,
end_date as endDate,
location,
location_online as locationOnline,
status,
synced_at as syncedAt
from comp_calendar_event
where entity like ${streamId + ":%"}
order by start_date asc
`,
);
}
161 changes: 161 additions & 0 deletions packages/app/src/lib/services/calendar-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, test, expect } from "vitest";
import {
statusClass,
mapToCalEvent,
isRangeFresh,
pruneExpiredRanges,
type FetchedRange,
} from "./calendar-utils";

describe("statusClass", () => {
test("defaults to ec-status-scheduled for undefined", () => {
expect(statusClass(undefined)).toBe("ec-status-scheduled");
});

test("normalizes lexicon token with hash", () => {
expect(statusClass("community.lexicon.calendar.event#cancelled")).toBe(
"ec-status-cancelled",
);
});

test("lowercases plain status", () => {
expect(statusClass("Published")).toBe("ec-status-published");
});
});

describe("mapToCalEvent", () => {
test("maps API response (camelCase fields)", () => {
const event = mapToCalEvent({
slug: "my-event",
name: "My Event",
startDate: "2026-03-15T10:00:00.000Z",
endDate: "2026-03-15T11:00:00.000Z",
location: "Room A",
locationOnline: "https://meet.example.com",
status: "Published",
});

expect(event.id).toBe("my-event");
expect(event.title).toBe("My Event");
expect(event.start).toEqual(new Date("2026-03-15T10:00:00.000Z"));
expect(event.end).toEqual(new Date("2026-03-15T11:00:00.000Z"));
expect(event.className).toBe("ec-status-published");
expect(event.extendedProps.location).toBe("Room A");
expect(event.extendedProps.locationOnline).toBe("https://meet.example.com");
});

test("maps SQLite row (snake_case fields)", () => {
const event = mapToCalEvent({
slug: "db-event",
name: "DB Event",
start_date: "2026-03-15T10:00:00.000Z",
end_date: "2026-03-15T11:00:00.000Z",
location: null,
location_online: "https://zoom.us/123",
status: "Published",
});

expect(event.id).toBe("db-event");
expect(event.start).toEqual(new Date("2026-03-15T10:00:00.000Z"));
expect(event.extendedProps.location).toBeNull();
expect(event.extendedProps.locationOnline).toBe("https://zoom.us/123");
});

test("prefixes cancelled events with (Cancelled)", () => {
const event = mapToCalEvent({
slug: "cancelled-event",
name: "Old Meetup",
startDate: "2026-03-15T10:00:00.000Z",
status: "Cancelled",
});

expect(event.title).toBe("(Cancelled) Old Meetup");
expect(event.className).toBe("ec-status-cancelled");
});

test("uses startDate as endDate fallback", () => {
const event = mapToCalEvent({
slug: "no-end",
name: "Quick Event",
startDate: "2026-03-15T10:00:00.000Z",
});

expect(event.end).toEqual(event.start);
});

test("defaults status to Published when missing", () => {
const event = mapToCalEvent({
slug: "no-status",
name: "Event",
startDate: "2026-03-15T10:00:00.000Z",
});

expect(event.extendedProps.status).toBe("Published");
expect(event.className).toBe("ec-status-published");
});
});

describe("isRangeFresh", () => {
const STALE_TTL = 5 * 60 * 1000;

test("returns true when range is covered and within TTL", () => {
const ranges: FetchedRange[] = [
{ start: 100, end: 200, fetchedAt: Date.now() },
];
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(true);
});

test("returns true when range is a subset of a fetched range", () => {
const ranges: FetchedRange[] = [
{ start: 0, end: 300, fetchedAt: Date.now() },
];
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(true);
});

test("returns false when range is stale", () => {
const ranges: FetchedRange[] = [
{ start: 100, end: 200, fetchedAt: Date.now() - STALE_TTL - 1 },
];
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(false);
});

test("returns false when range is not fully covered", () => {
const ranges: FetchedRange[] = [
{ start: 100, end: 150, fetchedAt: Date.now() },
];
expect(isRangeFresh(ranges, 100, 200, STALE_TTL)).toBe(false);
});

test("returns false when no ranges exist", () => {
expect(isRangeFresh([], 100, 200, STALE_TTL)).toBe(false);
});
});

describe("pruneExpiredRanges", () => {
const STALE_TTL = 5 * 60 * 1000;

test("removes expired ranges", () => {
const ranges: FetchedRange[] = [
{ start: 0, end: 100, fetchedAt: Date.now() - STALE_TTL - 1 },
{ start: 100, end: 200, fetchedAt: Date.now() },
];
const result = pruneExpiredRanges(ranges, STALE_TTL);
expect(result).toHaveLength(1);
expect(result[0]!.start).toBe(100);
});

test("keeps all fresh ranges", () => {
const ranges: FetchedRange[] = [
{ start: 0, end: 100, fetchedAt: Date.now() },
{ start: 100, end: 200, fetchedAt: Date.now() },
];
expect(pruneExpiredRanges(ranges, STALE_TTL)).toHaveLength(2);
});

test("returns empty array when all expired", () => {
const ranges: FetchedRange[] = [
{ start: 0, end: 100, fetchedAt: Date.now() - STALE_TTL - 1 },
];
expect(pruneExpiredRanges(ranges, STALE_TTL)).toHaveLength(0);
});
});
62 changes: 62 additions & 0 deletions packages/app/src/lib/services/calendar-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type CalEvent = {
id: string;
title: string;
start: Date;
end: Date;
className?: string;
extendedProps: Record<string, unknown>;
};

export type FetchedRange = {
start: number;
end: number;
fetchedAt: number;
};

export function statusClass(status: string | undefined): string {
if (!status) return "ec-status-scheduled";
const normalized = status.includes("#") ? status.split("#").pop()! : status;
return `ec-status-${normalized.toLowerCase()}`;
}

export function mapToCalEvent(e: Record<string, unknown>): CalEvent {
const status = (e.status as string) ?? "Published";
const cancelled = status.toLowerCase() === "cancelled";
const name = e.name as string;
return {
id: e.slug as string,
title: cancelled ? `(Cancelled) ${name}` : name,
start: new Date((e.startDate ?? e.start_date) as string),
end: new Date(
((e.endDate ?? e.end_date) as string) ||
((e.startDate ?? e.start_date) as string),
),
className: statusClass(status),
extendedProps: {
slug: e.slug,
location: e.location ?? null,
locationOnline: e.locationOnline ?? e.location_online,
status,
},
};
}

export function isRangeFresh(
ranges: FetchedRange[],
startMs: number,
endMs: number,
staleTtl: number,
): boolean {
const now = Date.now();
return ranges.some(
(r) => r.start <= startMs && r.end >= endMs && now - r.fetchedAt < staleTtl,
);
}

export function pruneExpiredRanges(
ranges: FetchedRange[],
staleTtl: number,
): FetchedRange[] {
const now = Date.now();
return ranges.filter((r) => now - r.fetchedAt < staleTtl);
}
15 changes: 11 additions & 4 deletions packages/app/src/lib/services/openmeet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,18 @@ export function openOnOpenMeet(

export async function fetchGroupEvents(
link: CalendarLink,
startDate?: string,
endDate?: string,
): Promise<Record<string, unknown>[]> {
const response = (await openmeetFetch(
link,
`/api/groups/${link.groupSlug}/events`,
)) as Record<string, unknown> | Record<string, unknown>[];
const params = new URLSearchParams();
if (startDate) params.set("startDate", startDate);
if (endDate) params.set("endDate", endDate);
const qs = params.toString();
const path = `/api/groups/${link.groupSlug}/events${qs ? `?${qs}` : ""}`;

const response = (await openmeetFetch(link, path)) as
| Record<string, unknown>
| Record<string, unknown>[];
return Array.isArray(response)
? response
: ((response as Record<string, unknown>).data as Record<
Expand Down
Loading