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
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { portalToBody } from "./calendarSyncPortal";
let { let {
open = false, open = false,
@@ -19,6 +20,7 @@
{#if open} {#if open}
<div <div
class="bsplus-cal-modal-backdrop" class="bsplus-cal-modal-backdrop"
use:portalToBody
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget && !busy) onCancel(); if (e.target === e.currentTarget && !busy) onCancel();
}} }}
@@ -36,11 +38,11 @@
transition:fade={{ duration: 180 }} transition:fade={{ duration: 180 }}
> >
<h2 id="bsplus-cal-delete-title" class="bsplus-cal-modal-title"> <h2 id="bsplus-cal-delete-title" class="bsplus-cal-modal-title">
Remove synced events? Delete synced classes?
</h2> </h2>
<p class="bsplus-cal-modal-body"> <p class="bsplus-cal-modal-body">
This removes all BetterSEQTA+ timetable events from your {providerLabel} Calendar for this school. Removes every BetterSEQTA+ timetable event from your {providerLabel} Calendar for this school.
Your connection stays active — you can sync again later. Your account stays connected — use Update calendar to sync again.
</p> </p>
<div class="bsplus-cal-modal-actions"> <div class="bsplus-cal-modal-actions">
<button <button
@@ -57,7 +59,7 @@
disabled={busy} disabled={busy}
onclick={() => void onConfirm()} onclick={() => void onConfirm()}
> >
{busy ? "Removing…" : "Remove from calendar"} {busy ? "Deleting…" : "Delete synced classes"}
</button> </button>
</div> </div>
</div> </div>
@@ -68,7 +70,7 @@
.bsplus-cal-modal-backdrop { .bsplus-cal-modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2147483647; z-index: var(--bsplus-cal-z-modal, 2147483647);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { portalToBody } from "./calendarSyncPortal";
let { let {
open = false, open = false,
@@ -19,6 +20,7 @@
{#if open} {#if open}
<div <div
class="bsplus-cal-modal-backdrop" class="bsplus-cal-modal-backdrop"
use:portalToBody
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget && !busy) onCancel(); if (e.target === e.currentTarget && !busy) onCancel();
}} }}
@@ -39,8 +41,8 @@
Disconnect {providerLabel} Calendar? Disconnect {providerLabel} Calendar?
</h2> </h2>
<p class="bsplus-cal-modal-body"> <p class="bsplus-cal-modal-body">
Your synced timetable events will stay in {providerLabel} Calendar, but BetterSEQTA+ will stop Stops BetterSEQTA+ from updating your calendar. Synced classes stay in {providerLabel} Calendar
updating them until you connect again. until you delete them or connect again.
</p> </p>
<div class="bsplus-cal-modal-actions"> <div class="bsplus-cal-modal-actions">
<button <button
@@ -57,7 +59,7 @@
disabled={busy} disabled={busy}
onclick={() => void onConfirm()} onclick={() => void onConfirm()}
> >
{busy ? "Disconnecting…" : "Disconnect"} {busy ? "Disconnecting…" : "Disconnect account"}
</button> </button>
</div> </div>
</div> </div>
@@ -68,7 +70,7 @@
.bsplus-cal-modal-backdrop { .bsplus-cal-modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2147483647; z-index: var(--bsplus-cal-z-modal, 2147483647);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -25,10 +25,18 @@
import { deleteSyncedEventsFromOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine"; import { deleteSyncedEventsFromOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine";
import CalendarDeleteEventsModal from "./CalendarDeleteEventsModal.svelte"; import CalendarDeleteEventsModal from "./CalendarDeleteEventsModal.svelte";
import CalendarDisconnectModal from "./CalendarDisconnectModal.svelte"; import CalendarDisconnectModal from "./CalendarDisconnectModal.svelte";
import CalendarSyncProgress from "./CalendarSyncProgress.svelte";
import OutlookCalendarIcon from "./OutlookCalendarIcon.svelte"; import OutlookCalendarIcon from "./OutlookCalendarIcon.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { syncCalendarSyncTheme } from "./calendarSyncTheme"; import { syncCalendarSyncTheme } from "./calendarSyncTheme";
import { isCalendarSyncModalTarget, portalToBody } from "./calendarSyncPortal";
function syncProgressPercent(progress: GoogleCalendarSyncProgress | null): number {
if (!progress || progress.phase === "done") return 0;
if (progress.total > 0) {
return Math.min(100, Math.round((progress.current / progress.total) * 100));
}
return progress.phase === "preparing" ? 8 : 0;
}
type CalendarProvider = "google" | "outlook"; type CalendarProvider = "google" | "outlook";
type BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null; type BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null;
@@ -54,6 +62,38 @@
const isBusy = $derived(busy !== null); const isBusy = $derived(busy !== null);
const anyConnected = $derived(googleStatus.connected || outlookStatus.connected); const anyConnected = $derived(googleStatus.connected || outlookStatus.connected);
const showTriggerProgress = $derived(
isBusy &&
(busy?.phase === "sync" ||
busy?.phase === "delete" ||
busy?.phase === "connect" ||
(syncProgress !== null && syncProgress.phase !== "done")),
);
const triggerProgressPercent = $derived.by(() => {
if (!showTriggerProgress) return 0;
if (syncProgress && syncProgress.phase !== "done") {
return syncProgressPercent(syncProgress);
}
return 10;
});
const triggerStatusText = $derived.by(() => {
if (!showTriggerProgress) return "Sync with Calendar";
const verb = busy?.phase === "delete" ? "Deleting" : "Syncing";
if (syncProgress?.total) return `${verb} ${triggerProgressPercent}%`;
return `${verb}…`;
});
const triggerAriaLabel = $derived.by(() => {
if (!showTriggerProgress) {
return anyConnected ? "Calendar sync options" : "Sync with Calendar";
}
if (syncProgress?.total) {
const verb = busy?.phase === "delete" ? "Deleting" : "Syncing";
return `Calendar ${verb.toLowerCase()} in progress, ${triggerProgressPercent} percent complete`;
}
return busy?.phase === "delete"
? "Calendar deletion in progress"
: "Calendar sync in progress";
});
const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))"; const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))";
function isProviderBusy(provider: CalendarProvider): boolean { function isProviderBusy(provider: CalendarProvider): boolean {
@@ -150,7 +190,7 @@
async function connectProvider(provider: CalendarProvider) { async function connectProvider(provider: CalendarProvider) {
const status = provider === "google" ? googleStatus : outlookStatus; const status = provider === "google" ? googleStatus : outlookStatus;
if (!status.configured || isBusy) return; if (!status.configured || isBusy) return;
menuOpen = true; menuOpen = false;
busy = { provider, phase: "connect" }; busy = { provider, phase: "connect" };
const connectType = const connectType =
provider === "google" ? "googleCalendarConnect" : "outlookCalendarConnect"; provider === "google" ? "googleCalendarConnect" : "outlookCalendarConnect";
@@ -187,6 +227,7 @@
} }
busy = { provider, phase: "sync" }; busy = { provider, phase: "sync" };
menuOpen = false;
try { try {
await performSync(provider); await performSync(provider);
} catch (err) { } catch (err) {
@@ -200,6 +241,8 @@
async function confirmDeleteEvents() { async function confirmDeleteEvents() {
if (isBusy || !modalProvider) return; if (isBusy || !modalProvider) return;
const provider = modalProvider; const provider = modalProvider;
showDeleteEvents = false;
menuOpen = false;
busy = { provider, phase: "delete" }; busy = { provider, phase: "delete" };
syncProgress = { syncProgress = {
phase: "preparing", phase: "preparing",
@@ -222,13 +265,12 @@
} }
const removed = result.deleted ?? 0; const removed = result.deleted ?? 0;
showDeleteEvents = false;
menuOpen = false;
modalProvider = null; modalProvider = null;
const label = provider === "google" ? "Google" : "Outlook";
if (removed === 0) { if (removed === 0) {
showToastMessage("No synced events to remove."); showToastMessage("No synced events to remove.");
} else { } else {
showToastMessage(`Removed ${removed} event${removed === 1 ? "" : "s"} from Google Calendar.`); showToastMessage(`Removed ${removed} event${removed === 1 ? "" : "s"} from ${label} Calendar.`);
} }
} catch (err) { } catch (err) {
showToastMessage(err instanceof Error ? err.message : "Remove failed.", true); showToastMessage(err instanceof Error ? err.message : "Remove failed.", true);
@@ -281,8 +323,21 @@
} }
} }
function toggleMenu() { function openDeleteModal(provider: CalendarProvider) {
if (isBusy) return; if (isBusy) return;
modalProvider = provider;
menuOpen = false;
showDeleteEvents = true;
}
function openDisconnectModal(provider: CalendarProvider) {
if (isBusy) return;
modalProvider = provider;
menuOpen = false;
showDisconnect = true;
}
function toggleMenu() {
menuOpen = !menuOpen; menuOpen = !menuOpen;
if (menuOpen) { if (menuOpen) {
queueMicrotask(() => syncMenuTheme()); queueMicrotask(() => syncMenuTheme());
@@ -317,12 +372,7 @@
} }
function portalMenu(node: HTMLElement) { function portalMenu(node: HTMLElement) {
document.body.appendChild(node); return portalToBody(node);
return {
destroy() {
node.remove();
},
};
} }
$effect(() => { $effect(() => {
@@ -332,8 +382,11 @@
}); });
$effect(() => { $effect(() => {
if (!menuOpen || !menuEl) return; if (menuOpen && menuEl) syncMenuTheme();
syncMenuTheme(); });
$effect(() => {
if (!menuOpen || !triggerEl) return;
updateMenuPosition(); updateMenuPosition();
const onLayout = () => updateMenuPosition(); const onLayout = () => updateMenuPosition();
window.addEventListener("resize", onLayout); window.addEventListener("resize", onLayout);
@@ -376,6 +429,7 @@
const target = event.target as Node; const target = event.target as Node;
if (rootEl?.contains(target)) return; if (rootEl?.contains(target)) return;
if (menuEl?.contains(target)) return; if (menuEl?.contains(target)) return;
if (isCalendarSyncModalTarget(target)) return;
menuOpen = false; menuOpen = false;
}; };
@@ -397,21 +451,20 @@
class="uiButton bsplus-cal-trigger" class="uiButton bsplus-cal-trigger"
bind:this={triggerEl} bind:this={triggerEl}
class:bsplus-cal-trigger--open={menuOpen} class:bsplus-cal-trigger--open={menuOpen}
class:bsplus-cal-trigger--connected={anyConnected}
class:bsplus-cal-trigger--busy={isBusy} class:bsplus-cal-trigger--busy={isBusy}
class:bsplus-cal-trigger--progress={showTriggerProgress}
style:--bsplus-cal-trigger-progress="{triggerProgressPercent}%"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menuOpen} aria-expanded={menuOpen}
aria-busy={isBusy} aria-busy={isBusy}
aria-label={anyConnected ? "Calendar sync options" : "Sync with Calendar"} aria-label={triggerAriaLabel}
onclick={() => { onclick={() => toggleMenu()}
if (!isBusy) toggleMenu();
}}
> >
<span class="bsplus-cal-trigger-icon iconFamily" aria-hidden="true">&#xe9cd;</span> <span class="bsplus-cal-trigger-fill" aria-hidden="true"></span>
<span class="bsplus-cal-trigger-text">Sync with Calendar</span> <span class="bsplus-cal-trigger-content">
{#if anyConnected} <span class="bsplus-cal-trigger-icon iconFamily" aria-hidden="true">&#xe9cd;</span>
<span class="bsplus-cal-status-dot" aria-hidden="true"></span> <span class="bsplus-cal-trigger-text">{triggerStatusText}</span>
{/if} </span>
</button> </button>
{#if menuOpen} {#if menuOpen}
@@ -425,7 +478,7 @@
> >
<div class="bsplus-cal-menu-header"> <div class="bsplus-cal-menu-header">
<span class="bsplus-cal-menu-title">Calendar sync</span> <span class="bsplus-cal-menu-title">Calendar sync</span>
<span class="bsplus-cal-menu-sub">Connect providers to sync your timetable</span> <span class="bsplus-cal-menu-sub">Copy your SEQTA timetable classes to Google or Outlook</span>
</div> </div>
<div class="bsplus-cal-provider" role="none"> <div class="bsplus-cal-provider" role="none">
@@ -479,7 +532,7 @@
disabled={!googleStatus.configured || isBusy} disabled={!googleStatus.configured || isBusy}
onclick={() => void connectProvider("google")} onclick={() => void connectProvider("google")}
> >
{providerPhase("google") === "connect" ? "Connecting…" : "Connect"} {providerPhase("google") === "connect" ? "Connecting…" : "Connect & sync"}
</button> </button>
{:else} {:else}
<button <button
@@ -490,31 +543,25 @@
disabled={isBusy} disabled={isBusy}
onclick={() => void syncProvider("google")} onclick={() => void syncProvider("google")}
> >
{providerPhase("google") === "sync" ? "Syncing…" : "Sync now"} {providerPhase("google") === "sync" ? "Updating…" : "Update calendar"}
</button> </button>
<button <button
type="button" type="button"
class="bsplus-cal-action bsplus-cal-action--ghost" class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem" role="menuitem"
disabled={isBusy} disabled={isBusy}
onclick={() => { onclick={() => openDeleteModal("google")}
modalProvider = "google";
showDeleteEvents = true;
}}
> >
{providerPhase("google") === "delete" ? "Removing…" : "Remove from calendar"} {providerPhase("google") === "delete" ? "Deleting…" : "Delete synced classes"}
</button> </button>
<button <button
type="button" type="button"
class="bsplus-cal-action bsplus-cal-action--ghost" class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem" role="menuitem"
disabled={isBusy} disabled={isBusy}
onclick={() => { onclick={() => openDisconnectModal("google")}
modalProvider = "google";
showDisconnect = true;
}}
> >
Disconnect Disconnect account
</button> </button>
{/if} {/if}
</div> </div>
@@ -549,7 +596,7 @@
disabled={!outlookStatus.configured || isBusy} disabled={!outlookStatus.configured || isBusy}
onclick={() => void connectProvider("outlook")} onclick={() => void connectProvider("outlook")}
> >
{providerPhase("outlook") === "connect" ? "Connecting…" : "Connect"} {providerPhase("outlook") === "connect" ? "Connecting…" : "Connect & sync"}
</button> </button>
{:else} {:else}
<button <button
@@ -560,40 +607,39 @@
disabled={isBusy} disabled={isBusy}
onclick={() => void syncProvider("outlook")} onclick={() => void syncProvider("outlook")}
> >
{providerPhase("outlook") === "sync" ? "Syncing…" : "Sync now"} {providerPhase("outlook") === "sync" ? "Updating…" : "Update calendar"}
</button> </button>
<button <button
type="button" type="button"
class="bsplus-cal-action bsplus-cal-action--ghost" class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem" role="menuitem"
disabled={isBusy} disabled={isBusy}
onclick={() => { onclick={() => openDeleteModal("outlook")}
modalProvider = "outlook";
showDeleteEvents = true;
}}
> >
{providerPhase("outlook") === "delete" ? "Removing…" : "Remove from calendar"} {providerPhase("outlook") === "delete" ? "Deleting…" : "Delete synced classes"}
</button> </button>
<button <button
type="button" type="button"
class="bsplus-cal-action bsplus-cal-action--ghost" class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem" role="menuitem"
disabled={isBusy} disabled={isBusy}
onclick={() => { onclick={() => openDisconnectModal("outlook")}
modalProvider = "outlook";
showDisconnect = true;
}}
> >
Disconnect Disconnect account
</button> </button>
{/if} {/if}
</div> </div>
</div> </div>
{#if anyConnected} {#if anyConnected}
<div class="bsplus-cal-settings" role="group" aria-label="Sync settings"> <div class="bsplus-cal-settings" role="group" aria-label="Sync options">
<label class="bsplus-cal-setting"> <label class="bsplus-cal-setting">
<span class="bsplus-cal-setting-label">Weeks ahead</span> <div class="bsplus-cal-setting-copy">
<span class="bsplus-cal-setting-label">Weeks to sync</span>
<span class="bsplus-cal-setting-desc">
How many weeks of classes to add when you connect or tap Update calendar.
</span>
</div>
<input <input
type="number" type="number"
class="bsplus-cal-setting-input" class="bsplus-cal-setting-input"
@@ -605,7 +651,12 @@
/> />
</label> </label>
<label class="bsplus-cal-setting bsplus-cal-setting--toggle"> <label class="bsplus-cal-setting bsplus-cal-setting--toggle">
<span class="bsplus-cal-setting-label">Auto-sync weekly</span> <div class="bsplus-cal-setting-copy">
<span class="bsplus-cal-setting-label">Sync new weeks automatically</span>
<span class="bsplus-cal-setting-desc">
Each week, add the next week of your timetable without opening this menu.
</span>
</div>
<input <input
type="checkbox" type="checkbox"
class="bsplus-cal-setting-checkbox" class="bsplus-cal-setting-checkbox"
@@ -614,13 +665,8 @@
onchange={(e) => void onAutoSyncToggle(e)} onchange={(e) => void onAutoSyncToggle(e)}
/> />
</label> </label>
<p class="bsplus-cal-setting-hint">
Syncs {syncWeeksAhead} weeks ahead on connect and manual sync. Weekly auto-sync adds each new week forward.
</p>
</div> </div>
{/if} {/if}
<CalendarSyncProgress progress={syncProgress} />
</div> </div>
{/if} {/if}
@@ -676,7 +722,32 @@
margin-left: 4px; margin-left: 4px;
border-radius: 16px !important; border-radius: 16px !important;
font-family: inherit; font-family: inherit;
transition: all 0.2s ease; transition: transform 0.2s ease, opacity 0.2s ease;
overflow: hidden;
isolation: isolate;
}
.bsplus-cal-trigger-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--bsplus-cal-trigger-progress, 0%);
border-radius: inherit;
background: color-mix(
in srgb,
var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 38%,
transparent
);
transition: width 0.25s ease;
pointer-events: none;
}
.bsplus-cal-trigger-content {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
} }
.bsplus-cal-trigger-icon { .bsplus-cal-trigger-icon {
@@ -735,25 +806,17 @@
background: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 14%, transparent) !important; background: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 14%, transparent) !important;
} }
.bsplus-cal-trigger--connected .bsplus-cal-status-dot {
display: block;
}
.bsplus-cal-trigger--busy { .bsplus-cal-trigger--busy {
opacity: 0.85; opacity: 0.95;
cursor: wait;
} }
.bsplus-cal-status-dot { .bsplus-cal-trigger--progress {
position: absolute; cursor: default;
top: 4px; }
right: 4px;
width: 7px; .bsplus-cal-trigger--progress:hover,
height: 7px; .bsplus-cal-trigger--progress:active {
border-radius: 999px; transform: none;
background: #22c55e;
box-shadow: 0 0 0 2px var(--bsplus-cal-surface, #fff);
display: none;
} }
.bsplus-cal-settings { .bsplus-cal-settings {
@@ -768,19 +831,37 @@
.bsplus-cal-setting { .bsplus-cal-setting {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
font-size: 12px; font-size: 12px;
} }
.bsplus-cal-setting-copy {
display: grid;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
.bsplus-cal-setting-label { .bsplus-cal-setting-label {
font-weight: 600; font-weight: 600;
color: var(--bsplus-cal-text, var(--text-primary, #111)); color: var(--bsplus-cal-text, var(--text-primary, #111));
} }
.bsplus-cal-setting-desc {
font-size: 10px;
line-height: 1.4;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 58%, transparent);
}
.bsplus-cal-setting--toggle {
align-items: center;
}
.bsplus-cal-setting-input { .bsplus-cal-setting-input {
width: 64px; width: 64px;
flex: 0 0 auto;
padding: 6px 8px; padding: 6px 8px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 18%, transparent)); border: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 18%, transparent));
@@ -793,19 +874,13 @@
.bsplus-cal-setting-checkbox { .bsplus-cal-setting-checkbox {
width: 16px; width: 16px;
height: 16px; height: 16px;
flex: 0 0 auto;
accent-color: var(--bsplus-cal-accent, var(--better-main, #3b82f6)); accent-color: var(--bsplus-cal-accent, var(--better-main, #3b82f6));
} }
.bsplus-cal-setting-hint {
margin: 0;
font-size: 10px;
line-height: 1.4;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 58%, transparent);
}
.bsplus-cal-menu { .bsplus-cal-menu {
position: fixed; position: fixed;
z-index: 2147483647; z-index: var(--bsplus-cal-z-menu, 2147483646);
width: min(320px, calc(100vw - 24px)); width: min(320px, calc(100vw - 24px));
padding: 10px; padding: 10px;
border-radius: 14px; border-radius: 14px;
@@ -11,11 +11,16 @@
} }
.timetable-calendar-controls.bsplus-cal-menu-open { .timetable-calendar-controls.bsplus-cal-menu-open {
z-index: 2147483646; z-index: 2147483645;
} }
.timetablepage #toolbar:has(.bsplus-cal-menu-open) { .timetablepage #toolbar:has(.bsplus-cal-menu-open) {
z-index: 2147483646 !important; z-index: 2147483645 !important;
}
:root {
--bsplus-cal-z-menu: 2147483646;
--bsplus-cal-z-modal: 2147483647;
} }
.bsplus-calendar-sync-mount { .bsplus-calendar-sync-mount {
@@ -0,0 +1,16 @@
/** Layer order: toolbar boost < menu < modal (all portaled UI uses the upper layers). */
export const CALENDAR_SYNC_Z_MENU = 2_147_483_646;
export const CALENDAR_SYNC_Z_MODAL = 2_147_483_647;
export function portalToBody(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
export function isCalendarSyncModalTarget(target: EventTarget | null): boolean {
return target instanceof Element && Boolean(target.closest(".bsplus-cal-modal-backdrop"));
}
@@ -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"; } from "@/seqta/utils/googleCalendar/types";
export const EVENT_MAP_PERSIST_EVERY = 10; 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 }>; export type EventMapRecord = Record<string, string | { id: string; date: string }>;
@@ -23,11 +25,87 @@ export type MappedLessonEvent = {
startDateTime: string; 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( export function reportSyncProgress(
onProgress: GoogleCalendarSyncOptions["onProgress"], onProgress: GoogleCalendarSyncOptions["onProgress"],
progress: GoogleCalendarSyncProgress, 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 { export function lessonDateForEvent(startDateTime: string, seqtaKey: string): string {
+2 -1
View File
@@ -5,6 +5,7 @@ import {
trailingWeekRange, trailingWeekRange,
} from "@/seqta/utils/googleCalendar/fetchTimetable"; } from "@/seqta/utils/googleCalendar/fetchTimetable";
import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings";
import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared";
import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine";
import type { import type {
GoogleCalendarSyncOptions, GoogleCalendarSyncOptions,
@@ -36,7 +37,7 @@ export async function runGoogleCalendarSync(
const mode = params.mode ?? "full"; const mode = params.mode ?? "full";
const weeksAhead = await getSyncWeeksAhead(); const weeksAhead = await getSyncWeeksAhead();
params.onProgress?.({ reportSyncProgress(params.onProgress, {
phase: "preparing", phase: "preparing",
current: 0, current: 0,
total: 1, total: 1,
@@ -5,6 +5,7 @@ import {
trailingWeekRange, trailingWeekRange,
} from "@/seqta/utils/googleCalendar/fetchTimetable"; } from "@/seqta/utils/googleCalendar/fetchTimetable";
import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings";
import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared";
import { syncLessonsToOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine"; import { syncLessonsToOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine";
import type { import type {
GoogleCalendarSyncOptions, GoogleCalendarSyncOptions,
@@ -36,7 +37,7 @@ export async function runOutlookCalendarSync(
const mode = params.mode ?? "full"; const mode = params.mode ?? "full";
const weeksAhead = await getSyncWeeksAhead(); const weeksAhead = await getSyncWeeksAhead();
params.onProgress?.({ reportSyncProgress(params.onProgress, {
phase: "preparing", phase: "preparing",
current: 0, current: 0,
total: 1, total: 1,