From b1fb8e8f108c03a9dae46f8708d4a2031f2006fc Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Mon, 13 Jul 2026 16:30:08 +0930 Subject: [PATCH] fix: fix minor bugs --- .../timetable/CalendarSyncControl.svelte | 17 +- .../calendarSync/lessonSyncShared.test.ts | 98 +++++++++++ .../utils/calendarSync/lessonSyncShared.ts | 157 +++++++++++++----- src/seqta/utils/calendarSync/remoteEvents.ts | 68 ++++++-- src/seqta/utils/calendarSync/syncEngine.ts | 106 ++++++++---- .../utils/googleCalendar/syncDateRange.ts | 10 ++ .../utils/googleCalendar/syncEngine.test.ts | 28 ++++ 7 files changed, 394 insertions(+), 90 deletions(-) diff --git a/src/plugins/built-in/timetable/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte index 642687e9..4572f569 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -669,22 +669,27 @@ {#snippet googleIcon()} - + {/snippet} diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.test.ts b/src/seqta/utils/calendarSync/lessonSyncShared.test.ts index 7b86f0c7..6206fa9e 100644 --- a/src/seqta/utils/calendarSync/lessonSyncShared.test.ts +++ b/src/seqta/utils/calendarSync/lessonSyncShared.test.ts @@ -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); + }); }); diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.ts b/src/seqta/utils/calendarSync/lessonSyncShared.ts index d010a371..74ffd537 100644 --- a/src/seqta/utils/calendarSync/lessonSyncShared.ts +++ b/src/seqta/utils/calendarSync/lessonSyncShared.ts @@ -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( + items: readonly T[], + concurrency: number, + worker: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) return; + const limit = Math.max(1, Math.min(concurrency, items.length)); + let nextIndex = 0; + + async function runWorker(): Promise { + 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(); + + 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 = 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 = { }) => Promise; onProgress?: GoogleCalendarSyncOptions["onProgress"]; logLabel: string; + concurrency?: number; }; export async function upsertLessonEvents( @@ -301,60 +376,66 @@ export async function upsertLessonEvents( 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 = 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 }; diff --git a/src/seqta/utils/calendarSync/remoteEvents.ts b/src/seqta/utils/calendarSync/remoteEvents.ts index 7be3a39c..3f1156fa 100644 --- a/src/seqta/utils/calendarSync/remoteEvents.ts +++ b/src/seqta/utils/calendarSync/remoteEvents.ts @@ -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 { + 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, ): Promise { - 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 }; }; -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, + options: ListSyncedEventsOptions = {}, ): Promise { + 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, + options: ListSyncedEventsOptions = {}, ): Promise { + 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"]; diff --git a/src/seqta/utils/calendarSync/syncEngine.ts b/src/seqta/utils/calendarSync/syncEngine.ts index 0dd140ce..c1f031a4 100644 --- a/src/seqta/utils/calendarSync/syncEngine.ts +++ b/src/seqta/utils/calendarSync/syncEngine.ts @@ -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, + options?: ListSyncedEventsOptions, ) => Promise; toApiBody: (event: GoogleCalendarEventInput) => Record; }; @@ -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, { diff --git a/src/seqta/utils/googleCalendar/syncDateRange.ts b/src/seqta/utils/googleCalendar/syncDateRange.ts index 35057ece..213b274f 100644 --- a/src/seqta/utils/googleCalendar/syncDateRange.ts +++ b/src/seqta/utils/googleCalendar/syncDateRange.ts @@ -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) }; +} diff --git a/src/seqta/utils/googleCalendar/syncEngine.test.ts b/src/seqta/utils/googleCalendar/syncEngine.test.ts index 66d46fc3..16b52238 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.test.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.test.ts @@ -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 }); + }); });