feat: tweak calendar syncing for final release

This commit is contained in:
2026-06-28 09:52:17 +09:30
parent 01223c466e
commit f4230e02b9
9 changed files with 415 additions and 97 deletions
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
jest.mock("@/utils/verboseLog", () => ({
verboseLog: jest.fn(),
}));
import {
reportSyncProgress,
resetSyncProgressThrottle,
SYNC_PROGRESS_THROTTLE_MS,
} from "./lessonSyncShared";
import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types";
describe("reportSyncProgress", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-06-28T12:00:00.000Z"));
});
afterEach(() => {
jest.useRealTimers();
});
it("reports preparing and done immediately", () => {
const onProgress = jest.fn();
const preparing: GoogleCalendarSyncProgress = {
phase: "preparing",
current: 0,
total: 10,
message: "Preparing…",
};
const done: GoogleCalendarSyncProgress = {
phase: "done",
current: 10,
total: 10,
message: "Done",
};
reportSyncProgress(onProgress, preparing);
reportSyncProgress(onProgress, done);
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenNthCalledWith(1, preparing);
expect(onProgress).toHaveBeenNthCalledWith(2, done);
});
it("throttles upserting progress to at most once per second", () => {
const onProgress = jest.fn();
for (let i = 1; i <= 5; i++) {
reportSyncProgress(onProgress, {
phase: "upserting",
current: i,
total: 5,
message: `Syncing events (${i}/5)…`,
});
}
expect(onProgress).toHaveBeenCalledTimes(1);
expect(onProgress).toHaveBeenCalledWith({
phase: "upserting",
current: 1,
total: 5,
message: "Syncing events (1/5)…",
});
jest.advanceTimersByTime(SYNC_PROGRESS_THROTTLE_MS);
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenLastCalledWith({
phase: "upserting",
current: 5,
total: 5,
message: "Syncing events (5/5)…",
});
});
it("flushes pending progress before reporting done", () => {
const onProgress = jest.fn();
reportSyncProgress(onProgress, {
phase: "upserting",
current: 1,
total: 5,
message: "Syncing events (1/5)…",
});
reportSyncProgress(onProgress, {
phase: "upserting",
current: 4,
total: 5,
message: "Syncing events (4/5)…",
});
reportSyncProgress(onProgress, {
phase: "done",
current: 5,
total: 5,
message: "Sync complete",
});
expect(onProgress).toHaveBeenCalledTimes(3);
expect(onProgress).toHaveBeenNthCalledWith(1, {
phase: "upserting",
current: 1,
total: 5,
message: "Syncing events (1/5)…",
});
expect(onProgress).toHaveBeenNthCalledWith(2, {
phase: "upserting",
current: 4,
total: 5,
message: "Syncing events (4/5)…",
});
expect(onProgress).toHaveBeenNthCalledWith(3, {
phase: "done",
current: 5,
total: 5,
message: "Sync complete",
});
});
it("resetSyncProgressThrottle clears queued updates", () => {
const onProgress = jest.fn();
reportSyncProgress(onProgress, {
phase: "upserting",
current: 1,
total: 3,
message: "Syncing events (1/3)…",
});
resetSyncProgressThrottle(onProgress);
jest.advanceTimersByTime(SYNC_PROGRESS_THROTTLE_MS);
expect(onProgress).toHaveBeenCalledTimes(1);
});
});
@@ -15,6 +15,8 @@ import type {
} from "@/seqta/utils/googleCalendar/types";
export const EVENT_MAP_PERSIST_EVERY = 10;
/** Max UI progress refresh rate during bulk delete/upsert (reduces Svelte re-renders). */
export const SYNC_PROGRESS_THROTTLE_MS = 1000;
export type EventMapRecord = Record<string, string | { id: string; date: string }>;
@@ -23,11 +25,87 @@ export type MappedLessonEvent = {
startDateTime: string;
};
type ProgressThrottleState = {
lastReportAt: number;
pending: GoogleCalendarSyncProgress | null;
timer: ReturnType<typeof setTimeout> | null;
};
const progressThrottleByCallback = new WeakMap<
NonNullable<GoogleCalendarSyncOptions["onProgress"]>,
ProgressThrottleState
>();
function getProgressThrottleState(
onProgress: NonNullable<GoogleCalendarSyncOptions["onProgress"]>,
): ProgressThrottleState {
let state = progressThrottleByCallback.get(onProgress);
if (!state) {
state = { lastReportAt: 0, pending: null, timer: null };
progressThrottleByCallback.set(onProgress, state);
}
return state;
}
function flushPendingSyncProgress(
onProgress: NonNullable<GoogleCalendarSyncOptions["onProgress"]>,
state: ProgressThrottleState,
) {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
if (!state.pending) return;
onProgress(state.pending);
state.pending = null;
state.lastReportAt = Date.now();
}
/** Clears any queued progress for a callback (e.g. when a sync run ends). */
export function resetSyncProgressThrottle(
onProgress: GoogleCalendarSyncOptions["onProgress"],
) {
if (!onProgress) return;
const state = progressThrottleByCallback.get(onProgress);
if (!state) return;
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
state.pending = null;
}
export function reportSyncProgress(
onProgress: GoogleCalendarSyncOptions["onProgress"],
progress: GoogleCalendarSyncProgress,
) {
onProgress?.(progress);
if (!onProgress) return;
const state = getProgressThrottleState(onProgress);
if (progress.phase === "preparing" || progress.phase === "done") {
flushPendingSyncProgress(onProgress, state);
onProgress(progress);
state.lastReportAt = Date.now();
if (progress.phase === "done") {
resetSyncProgressThrottle(onProgress);
}
return;
}
state.pending = progress;
const elapsed = Date.now() - state.lastReportAt;
if (elapsed >= SYNC_PROGRESS_THROTTLE_MS) {
flushPendingSyncProgress(onProgress, state);
return;
}
if (state.timer) return;
state.timer = setTimeout(() => {
state.timer = null;
flushPendingSyncProgress(onProgress, state);
}, SYNC_PROGRESS_THROTTLE_MS - elapsed);
}
export function lessonDateForEvent(startDateTime: string, seqtaKey: string): string {
+2 -1
View File
@@ -5,6 +5,7 @@ import {
trailingWeekRange,
} from "@/seqta/utils/googleCalendar/fetchTimetable";
import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings";
import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared";
import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine";
import type {
GoogleCalendarSyncOptions,
@@ -36,7 +37,7 @@ export async function runGoogleCalendarSync(
const mode = params.mode ?? "full";
const weeksAhead = await getSyncWeeksAhead();
params.onProgress?.({
reportSyncProgress(params.onProgress, {
phase: "preparing",
current: 0,
total: 1,
@@ -5,6 +5,7 @@ import {
trailingWeekRange,
} from "@/seqta/utils/googleCalendar/fetchTimetable";
import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings";
import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared";
import { syncLessonsToOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine";
import type {
GoogleCalendarSyncOptions,
@@ -36,7 +37,7 @@ export async function runOutlookCalendarSync(
const mode = params.mode ?? "full";
const weeksAhead = await getSyncWeeksAhead();
params.onProgress?.({
reportSyncProgress(params.onProgress, {
phase: "preparing",
current: 0,
total: 1,