fix: fix minor bugs

This commit is contained in:
2026-07-13 16:30:08 +09:30
parent fdf941b968
commit b1fb8e8f10
7 changed files with 394 additions and 90 deletions
@@ -669,22 +669,27 @@
</div>
{#snippet googleIcon()}
<svg viewBox="0 0 24 24">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-3 0 262 262"
preserveAspectRatio="xMidYMid"
aria-hidden="true"
>
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
d="M22 12c0-.96-.08-1.88-.24-2.76H12v5.22h5.68c-.24 1.28-.96 2.44-2.04 3.18v2.64h3.3c1.92-1.76 3.06-4.36 3.06-7.28z"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
d="M12 22c2.76 0 5.08-.92 6.78-2.5l-3.3-2.64c-.92.62-2.1.98-3.48.98-2.68 0-4.96-1.8-5.78-4.22H2.18v2.72A10 10 0 0 0 12 22z"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
d="M6.22 13.62A5.98 5.98 0 0 1 5.82 12c0-.56.1-1.1.28-1.62V7.66H2.18A10 10 0 0 0 2 12c0 1.62.38 3.16 1.06 4.52l3.16-2.9z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.5 0 2.84.52 3.9 1.54l2.92-2.92C17.08 2.34 14.76 1.2 12 1.2 7.54 1.2 3.72 3.94 2.18 7.66l4.04 3.14c.82-2.42 3.1-4.22 5.78-4.22z"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
{/snippet}
@@ -11,8 +11,10 @@ import {
} from "./eventFingerprint";
import {
buildLessonSyncResult,
collectOriginDeleteEntries,
entriesToPrune,
formatLessonSyncResultMessage,
mapPool,
mergeRemoteEventsIntoMap,
reportSyncProgress,
upsertLessonEvents,
@@ -166,6 +168,45 @@ describe("mergeRemoteEventsIntoMap", () => {
});
});
describe("collectOriginDeleteEntries", () => {
const origin = "https://school.seqta.com.au";
const mapKey = (o: string, seqtaKey: string) => `${o}::${seqtaKey}`;
it("includes local map entries and matching remote events", () => {
const eventMap = {
[`${origin}::${origin}:cal:1`]: { id: "local-1", date: "2026-07-13" },
};
const entries = collectOriginDeleteEntries(
eventMap,
origin,
[
{
seqtaKey: `${origin}:cal:2`,
id: "remote-2",
date: "2026-07-14",
fingerprint: "fp",
},
{
seqtaKey: "",
id: "orphan-3",
date: "2026-07-15",
fingerprint: "fp",
},
{
seqtaKey: "https://other.seqta.com.au:cal:9",
id: "other",
date: "2026-07-15",
fingerprint: "fp",
},
],
mapKey,
);
const ids = entries.map(([, id]) => id).sort();
expect(ids).toEqual(["local-1", "orphan-3", "remote-2"]);
});
});
describe("formatLessonSyncResultMessage", () => {
it("includes unchanged counts", () => {
expect(
@@ -222,4 +263,61 @@ describe("upsertLessonEvents skip unchanged", () => {
expect(upsert).not.toHaveBeenCalled();
expect(result).toMatchObject({ created: 0, updated: 0, skipped: 1, failed: 0 });
});
it("runs upserts concurrently", async () => {
const origin = "https://school.seqta.com.au";
let inFlight = 0;
let maxInFlight = 0;
const events = Array.from({ length: 6 }, (_, i) => ({
seqtaKey: `${origin}:cal:${i}`,
summary: `Class ${i}`,
description: "Synced by BetterSEQTA+",
startDateTime: "2026-07-13T09:00:00",
endDateTime: "2026-07-13T10:00:00",
timeZone: "UTC",
}));
const upsert = jest.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 30));
inFlight -= 1;
return `id-${Math.random()}`;
});
const result = await upsertLessonEvents({
events,
eventMap: {},
origin,
staleEntryCount: 0,
totalSteps: events.length,
lastSyncAt: Date.now(),
initialFailed: 0,
getAccessToken: async () => "token",
mapKey: (o, k) => `${o}::${k}`,
upsert,
writeState: async () => undefined,
logLabel: "Test",
concurrency: 2,
});
expect(result.created).toBe(6);
expect(upsert).toHaveBeenCalledTimes(6);
expect(maxInFlight).toBeGreaterThan(1);
expect(maxInFlight).toBeLessThanOrEqual(2);
});
});
describe("mapPool", () => {
it("limits concurrency", async () => {
let inFlight = 0;
let maxInFlight = 0;
await mapPool([1, 2, 3, 4, 5], 2, async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 20));
inFlight -= 1;
});
expect(maxInFlight).toBe(2);
});
});
+119 -38
View File
@@ -22,6 +22,34 @@ import type {
export const EVENT_MAP_PERSIST_EVERY = 10;
/** Max concurrent Google/Outlook create/update/delete requests during sync. */
export const SYNC_CONCURRENCY = 2;
/** Same cap for deletes to avoid provider rate limits. */
export const SYNC_DELETE_CONCURRENCY = 2;
/** Run async work over items with a fixed concurrency pool. */
export async function mapPool<T>(
items: readonly T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
if (items.length === 0) return;
const limit = Math.max(1, Math.min(concurrency, items.length));
let nextIndex = 0;
async function runWorker(): Promise<void> {
while (true) {
const index = nextIndex;
nextIndex += 1;
if (index >= items.length) return;
await worker(items[index], index);
}
}
await Promise.all(Array.from({ length: limit }, () => runWorker()));
}
export type MappedLessonEvent = {
seqtaKey: string;
summary: string;
@@ -104,6 +132,41 @@ export function mergeRemoteEventsIntoMap(
}
}
/**
* Build a deduped delete list from local map + remote events for an origin.
* Unkeyed remote events (empty seqtaKey) are included as orphan removals.
*/
export function collectOriginDeleteEntries(
eventMap: EventMapRecord,
origin: string,
remoteEvents: RemoteSyncedEvent[],
mapKey: (origin: string, seqtaKey: string) => string,
): Array<[string, string]> {
const byId = new Map<string, string>();
for (const [key, id] of originEventMapEntries(eventMap, origin)) {
byId.set(id, key);
}
for (const remote of remoteEvents) {
if (remote.seqtaKey && !remote.seqtaKey.startsWith(origin)) continue;
if (byId.has(remote.id)) continue;
const key = remote.seqtaKey
? mapKey(origin, remote.seqtaKey)
: `${origin}::__orphan__:${remote.id}`;
byId.set(remote.id, key);
}
return Array.from(byId.entries()).map(([id, key]) => [key, id]);
}
export function clearOriginEventMapEntries(eventMap: EventMapRecord, origin: string): void {
const prefix = `${origin}::`;
for (const key of Object.keys(eventMap)) {
if (key.startsWith(prefix)) delete eventMap[key];
}
}
export function reconcileRangeForMode(
mode: "full" | "incremental",
weeksAhead: number,
@@ -184,6 +247,7 @@ export async function deleteTrackedLessonEvents(
progressOffset?: number;
progressTotal?: number;
logLabel: string;
concurrency?: number;
},
): Promise<{ deleted: number; failed: number }> {
if (entries.length === 0) return { deleted: 0, failed: 0 };
@@ -194,18 +258,22 @@ export async function deleteTrackedLessonEvents(
progressOffset = 0,
progressTotal = 0,
logLabel,
concurrency = SYNC_DELETE_CONCURRENCY,
} = options;
let accessToken = await getAccessToken();
const refreshAccessToken = async () => {
accessToken = await getAccessToken();
return accessToken;
};
let deleted = 0;
let failed = 0;
let completed = 0;
let persistChain: Promise<unknown> = Promise.resolve();
for (const [mapKey, eventId] of entries) {
await mapPool(entries, concurrency, async ([mapKey, eventId]) => {
try {
await deleteEvent(accessToken, eventId, async () => {
accessToken = await getAccessToken();
return accessToken;
});
await deleteEvent(accessToken, eventId, refreshAccessToken);
delete eventMap[mapKey];
deleted += 1;
} catch (err) {
@@ -213,16 +281,22 @@ export async function deleteTrackedLessonEvents(
failed += 1;
}
completed += 1;
reportSyncProgress(onProgress, {
phase: "deleting",
current: progressOffset + deleted + failed,
current: progressOffset + completed,
total: progressTotal,
message: `Removing old events (${deleted + failed}/${entries.length})…`,
message: `Removing old events (${completed}/${entries.length})…`,
});
if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) {
await writeState({ eventMap });
if (persistProgress && completed % EVENT_MAP_PERSIST_EVERY === 0) {
persistChain = persistChain.then(() => writeState({ eventMap }));
await persistChain;
}
});
if (persistProgress) {
await persistChain;
}
return { deleted, failed };
@@ -276,6 +350,7 @@ type UpsertLessonEventsParams<TEvent extends MappedLessonEvent> = {
}) => Promise<unknown>;
onProgress?: GoogleCalendarSyncOptions["onProgress"];
logLabel: string;
concurrency?: number;
};
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
@@ -301,60 +376,66 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
writeState,
onProgress,
logLabel,
concurrency = SYNC_CONCURRENCY,
} = params;
let accessToken = await getAccessToken();
const refreshAccessToken = async () => {
accessToken = await getAccessToken();
return accessToken;
};
let created = 0;
let updated = 0;
let skipped = 0;
let failed = initialFailed;
let completed = 0;
let persistChain: Promise<unknown> = Promise.resolve();
for (let i = 0; i < events.length; i++) {
const event = events[i];
const persistState = () => {
persistChain = persistChain.then(() =>
writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }),
);
return persistChain;
};
await mapPool(events, concurrency, async (event) => {
const key = mapKey(origin, event.seqtaKey);
const existingId = getStoredEventId(eventMap[key]);
const desiredFingerprint = eventFingerprint(event);
const progressCurrent = staleEntryCount + i + 1;
const progressMessage = `Syncing events (${i + 1}/${events.length})…`;
try {
if (existingId && getStoredFingerprint(eventMap[key]) === desiredFingerprint) {
skipped += 1;
reportSyncProgress(onProgress, {
phase: "upserting",
current: progressCurrent,
total: totalSteps,
message: progressMessage,
});
continue;
}
const remoteId = await upsert(accessToken, existingId, event, async () => {
accessToken = await getAccessToken();
return accessToken;
});
if (existingId) updated += 1;
else created += 1;
eventMap[key] = {
id: remoteId,
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
fingerprint: desiredFingerprint,
};
if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) {
await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin });
} else {
const remoteId = await upsert(accessToken, existingId, event, refreshAccessToken);
if (existingId) updated += 1;
else created += 1;
eventMap[key] = {
id: remoteId,
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
fingerprint: desiredFingerprint,
};
}
} catch (err) {
verboseLog(`[BetterSEQTA+] ${logLabel} event sync failed:`, err);
failed += 1;
}
completed += 1;
reportSyncProgress(onProgress, {
phase: "upserting",
current: progressCurrent,
current: staleEntryCount + completed,
total: totalSteps,
message: progressMessage,
message: `Syncing events (${completed}/${events.length})…`,
});
if (completed % EVENT_MAP_PERSIST_EVERY === 0) {
await persistState();
}
});
if (events.length > 0) {
await persistState();
}
return { created, updated, skipped, failed, accessToken };
+53 -15
View File
@@ -16,20 +16,47 @@ export type RemoteSyncedEvent = {
date: string;
};
export type ListSyncedEventsOptions = {
/** Include category/calendar events that are missing a seqta key (legacy orphans). */
includeUnkeyed?: boolean;
};
const MAX_RETRIES = 4;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableStatus(status: number): boolean {
return status === 429 || status === 500 || status === 502 || status === 503 || status === 403;
}
async function authorizedFetch(
accessToken: string,
url: string,
init: RequestInit,
refreshAccessToken?: () => Promise<string>,
): Promise<Response> {
const res = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${accessToken}`, ...init.headers },
});
if (res.status === 401 && refreshAccessToken) {
return authorizedFetch(await refreshAccessToken(), url, init);
let token = accessToken;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const res = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${token}`, ...init.headers },
});
if (res.status === 401 && refreshAccessToken) {
token = await refreshAccessToken();
continue;
}
if (isRetryableStatus(res.status) && attempt < MAX_RETRIES - 1) {
await sleep(250 * 2 ** attempt + Math.floor(Math.random() * 100));
continue;
}
return res;
}
return res;
return fetch(url, {
...init,
headers: { Authorization: `Bearer ${token}`, ...init.headers },
});
}
async function upsertRemoteEvent(
@@ -182,9 +209,13 @@ type GoogleListItem = {
extendedProperties?: { private?: Record<string, string> };
};
function googleItemToRemote(item: GoogleListItem): RemoteSyncedEvent | null {
const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP];
if (!item.id || !seqtaKey) return null;
function googleItemToRemote(
item: GoogleListItem,
includeUnkeyed: boolean,
): RemoteSyncedEvent | null {
if (!item.id) return null;
const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP] ?? "";
if (!seqtaKey && !includeUnkeyed) return null;
const startDateTime = item.start?.dateTime ?? (item.start?.date ? `${item.start.date}T00:00:00` : "");
const endDateTime = item.end?.dateTime ?? (item.end?.date ? `${item.end.date}T00:00:00` : "");
const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC";
@@ -208,7 +239,9 @@ export async function listGoogleSyncedEvents(
calendarId: string,
range: SyncDateRange,
refreshAccessToken?: () => Promise<string>,
options: ListSyncedEventsOptions = {},
): Promise<RemoteSyncedEvent[]> {
const includeUnkeyed = options.includeUnkeyed === true;
const encodedCalendar = encodeURIComponent(calendarId);
const out: RemoteSyncedEvent[] = [];
let pageToken: string | undefined;
@@ -241,7 +274,7 @@ export async function listGoogleSyncedEvents(
}
for (const item of json.items ?? []) {
const mapped = googleItemToRemote(item);
const mapped = googleItemToRemote(item, includeUnkeyed);
if (mapped) out.push(mapped);
}
pageToken = json.nextPageToken;
@@ -260,13 +293,16 @@ type OutlookListItem = {
categories?: string[];
};
function outlookItemToRemote(item: OutlookListItem): RemoteSyncedEvent | null {
function outlookItemToRemote(
item: OutlookListItem,
includeUnkeyed: boolean,
): RemoteSyncedEvent | null {
if (!item.id) return null;
const categories = item.categories ?? [];
if (!categories.includes(BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY)) return null;
const bodyContent = item.body?.content ?? "";
const seqtaKey = parseOutlookSeqtaKey(bodyContent);
if (!seqtaKey) return null;
const seqtaKey = parseOutlookSeqtaKey(bodyContent) ?? "";
if (!seqtaKey && !includeUnkeyed) return null;
const startDateTime = (item.start?.dateTime ?? "").replace(/\.\d+$/, "");
const endDateTime = (item.end?.dateTime ?? "").replace(/\.\d+$/, "");
@@ -300,7 +336,9 @@ export async function listOutlookSyncedEvents(
accessToken: string,
range: SyncDateRange,
refreshAccessToken?: () => Promise<string>,
options: ListSyncedEventsOptions = {},
): Promise<RemoteSyncedEvent[]> {
const includeUnkeyed = options.includeUnkeyed === true;
const out: RemoteSyncedEvent[] = [];
const params = new URLSearchParams({
startDateTime: toRfc3339Start(range.from),
@@ -332,7 +370,7 @@ export async function listOutlookSyncedEvents(
}
for (const item of json.value ?? []) {
const mapped = outlookItemToRemote(item);
const mapped = outlookItemToRemote(item, includeUnkeyed);
if (mapped) out.push(mapped);
}
nextUrl = json["@odata.nextLink"];
+75 -31
View File
@@ -6,33 +6,18 @@ import type { EventMapRecord } from "@/seqta/utils/calendarSync/eventMap";
import {
buildDeleteSyncResult,
buildLessonSyncResult,
clearOriginEventMapEntries,
collectOriginDeleteEntries,
deleteTrackedLessonEvents,
emptyLessonsSyncResult,
entriesToPrune,
mergeRemoteEventsIntoMap,
notConfiguredSyncResult,
notConnectedSyncResult,
originEventMapEntries,
reconcileRangeForMode,
reportSyncProgress,
upsertLessonEvents,
} from "@/seqta/utils/calendarSync/lessonSyncShared";
import {
googleApiEventBody,
mapLessonsToGoogleEvents,
outlookGraphEventBody,
} from "@/seqta/utils/googleCalendar/eventMapper";
import {
readGoogleCalendarState,
writeGoogleCalendarState,
} from "@/seqta/utils/googleCalendar/storage";
import type {
GoogleCalendarDeleteResult,
GoogleCalendarEventInput,
GoogleCalendarSyncOptions,
GoogleCalendarSyncRequest,
GoogleCalendarSyncResult,
} from "@/seqta/utils/googleCalendar/types";
import {
deleteGoogleCalendarEvent,
deleteOutlookCalendarEvent,
@@ -40,14 +25,32 @@ import {
listOutlookSyncedEvents,
upsertGoogleCalendarEvent,
upsertOutlookCalendarEvent,
type ListSyncedEventsOptions,
type RemoteSyncedEvent,
} from "@/seqta/utils/calendarSync/remoteEvents";
import {
googleApiEventBody,
mapLessonsToGoogleEvents,
outlookGraphEventBody,
} from "@/seqta/utils/googleCalendar/eventMapper";
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
import {
readGoogleCalendarState,
writeGoogleCalendarState,
} from "@/seqta/utils/googleCalendar/storage";
import {
syncWindowRange,
trailingWeekRange,
wideCleanupRange,
type SyncDateRange,
} from "@/seqta/utils/googleCalendar/syncDateRange";
import type {
GoogleCalendarDeleteResult,
GoogleCalendarEventInput,
GoogleCalendarSyncOptions,
GoogleCalendarSyncRequest,
GoogleCalendarSyncResult,
} from "@/seqta/utils/googleCalendar/types";
import {
readOutlookCalendarState,
writeOutlookCalendarState,
@@ -86,6 +89,7 @@ export type CalendarLessonSyncProvider = {
accessToken: string,
range: SyncDateRange,
refreshAccessToken: () => Promise<string>,
options?: ListSyncedEventsOptions,
) => Promise<RemoteSyncedEvent[]>;
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
};
@@ -121,12 +125,13 @@ export const googleLessonSyncProvider: CalendarLessonSyncProvider = {
body,
refreshAccessToken,
),
listSyncedEvents: async (accessToken, range, refreshAccessToken) =>
listSyncedEvents: async (accessToken, range, refreshAccessToken, options) =>
listGoogleSyncedEvents(
accessToken,
await getOrProvisionGoogleCalendarId(accessToken),
range,
refreshAccessToken,
options,
),
toApiBody: googleApiEventBody,
};
@@ -142,8 +147,8 @@ export const outlookLessonSyncProvider: CalendarLessonSyncProvider = {
deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken),
upsertEvent: (accessToken, existingId, body, refreshAccessToken) =>
upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken),
listSyncedEvents: (accessToken, range, refreshAccessToken) =>
listOutlookSyncedEvents(accessToken, range, refreshAccessToken),
listSyncedEvents: (accessToken, range, refreshAccessToken, options) =>
listOutlookSyncedEvents(accessToken, range, refreshAccessToken, options),
toApiBody: outlookGraphEventBody,
};
@@ -168,10 +173,6 @@ export async function syncLessonsToCalendar(
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone);
if (events.length === 0 && mode === "full") {
return emptyLessonsSyncResult();
}
reportSyncProgress(options.onProgress, {
phase: "preparing",
current: 0,
@@ -204,7 +205,7 @@ export async function syncLessonsToCalendar(
const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey)));
const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys);
const totalSteps = staleEntries.length + events.length;
const totalSteps = Math.max(staleEntries.length + events.length, 1);
const lastSyncAt = Date.now();
const staleResult = await deleteTrackedLessonEvents(
@@ -220,6 +221,24 @@ export async function syncLessonsToCalendar(
},
);
if (events.length === 0 && mode === "full") {
await provider.writeState({
eventMap,
lastSyncAt,
lastSyncOrigin: request.origin,
});
reportSyncProgress(options.onProgress, {
phase: "done",
current: totalSteps,
total: totalSteps,
message: "Sync complete",
});
if (staleResult.deleted > 0) {
return buildLessonSyncResult(0, 0, staleResult.deleted, 0, staleResult.failed, lastSyncAt);
}
return emptyLessonsSyncResult();
}
const upsertResult = await upsertLessonEvents({
events,
eventMap,
@@ -282,19 +301,40 @@ export async function deleteSyncedEventsFromCalendar(
return { success: false, configured: true, connected: false, error: provider.notConnectedError };
}
const entries = originEventMapEntries(state.eventMap ?? {}, origin);
if (entries.length === 0) {
return { success: true, configured: true, connected: true, deleted: 0, failed: 0 };
}
reportSyncProgress(options.onProgress, {
phase: "preparing",
current: 0,
total: entries.length,
total: 1,
message: "Preparing removal…",
});
const eventMap = { ...(state.eventMap ?? {}) };
let accessToken = await getAccessToken();
const refreshAccessToken = async () => {
accessToken = await getAccessToken();
return accessToken;
};
let remoteEvents: RemoteSyncedEvent[] = [];
try {
remoteEvents = await provider.listSyncedEvents(
accessToken,
wideCleanupRange(),
refreshAccessToken,
{ includeUnkeyed: true },
);
mergeRemoteEventsIntoMap(eventMap, origin, remoteEvents, eventMapKey);
} catch (err) {
verboseLog(`[BetterSEQTA+] ${provider.label} cleanup list failed:`, err);
}
const entries = collectOriginDeleteEntries(eventMap, origin, remoteEvents, eventMapKey);
if (entries.length === 0) {
clearOriginEventMapEntries(eventMap, origin);
await provider.writeState({ eventMap });
return { success: true, configured: true, connected: true, deleted: 0, failed: 0 };
}
const { deleted, failed } = await deleteTrackedLessonEvents(
entries,
eventMap,
@@ -309,6 +349,10 @@ export async function deleteSyncedEventsFromCalendar(
},
);
// Only wipe remaining origin keys when every delete succeeded — keep failed IDs for retry.
if (failed === 0) {
clearOriginEventMapEntries(eventMap, origin);
}
await provider.writeState({ eventMap });
reportSyncProgress(options.onProgress, {
@@ -40,3 +40,13 @@ export function droppedWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS): SyncD
export function isDateInRange(date: string, range: SyncDateRange): boolean {
return date >= range.from && date <= range.until;
}
/** Wide range used when removing all synced events (covers past + future terms). */
export function wideCleanupRange(years = 3): SyncDateRange {
const now = new Date();
const from = new Date(now);
from.setFullYear(from.getFullYear() - years);
const until = new Date(now);
until.setFullYear(until.getFullYear() + years);
return { from: toISODate(from), until: toISODate(until) };
}
@@ -222,6 +222,7 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => {
it("deletes only events for the requested origin", async () => {
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
expect(listGoogleSyncedEvents).toHaveBeenCalled();
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
expect(result).toMatchObject({
success: true,
@@ -229,4 +230,31 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => {
failed: 0,
});
});
it("also deletes remote orphans found by list", async () => {
jest.mocked(readGoogleCalendarState).mockResolvedValue({
refreshToken: "refresh",
calendarId: "app-calendar-id",
eventMap: {},
});
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([
{
seqtaKey: `${ORIGIN}:cal:1`,
id: "remote-a",
date: "2026-06-27",
fingerprint: "fp",
},
{
seqtaKey: "",
id: "orphan-b",
date: "2026-06-28",
fingerprint: "fp",
},
]);
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
expect(result).toMatchObject({ success: true, deleted: 2, failed: 0 });
});
});