mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: tweak calendar syncing for final release
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { portalToBody } from "./calendarSyncPortal";
|
||||
|
||||
let {
|
||||
open = false,
|
||||
@@ -19,6 +20,7 @@
|
||||
{#if open}
|
||||
<div
|
||||
class="bsplus-cal-modal-backdrop"
|
||||
use:portalToBody
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) onCancel();
|
||||
}}
|
||||
@@ -36,11 +38,11 @@
|
||||
transition:fade={{ duration: 180 }}
|
||||
>
|
||||
<h2 id="bsplus-cal-delete-title" class="bsplus-cal-modal-title">
|
||||
Remove synced events?
|
||||
Delete synced classes?
|
||||
</h2>
|
||||
<p class="bsplus-cal-modal-body">
|
||||
This removes all BetterSEQTA+ timetable events from your {providerLabel} Calendar for this school.
|
||||
Your connection stays active — you can sync again later.
|
||||
Removes every BetterSEQTA+ timetable event from your {providerLabel} Calendar for this school.
|
||||
Your account stays connected — use Update calendar to sync again.
|
||||
</p>
|
||||
<div class="bsplus-cal-modal-actions">
|
||||
<button
|
||||
@@ -57,7 +59,7 @@
|
||||
disabled={busy}
|
||||
onclick={() => void onConfirm()}
|
||||
>
|
||||
{busy ? "Removing…" : "Remove from calendar"}
|
||||
{busy ? "Deleting…" : "Delete synced classes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +70,7 @@
|
||||
.bsplus-cal-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
z-index: var(--bsplus-cal-z-modal, 2147483647);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { portalToBody } from "./calendarSyncPortal";
|
||||
|
||||
let {
|
||||
open = false,
|
||||
@@ -19,6 +20,7 @@
|
||||
{#if open}
|
||||
<div
|
||||
class="bsplus-cal-modal-backdrop"
|
||||
use:portalToBody
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) onCancel();
|
||||
}}
|
||||
@@ -39,8 +41,8 @@
|
||||
Disconnect {providerLabel} Calendar?
|
||||
</h2>
|
||||
<p class="bsplus-cal-modal-body">
|
||||
Your synced timetable events will stay in {providerLabel} Calendar, but BetterSEQTA+ will stop
|
||||
updating them until you connect again.
|
||||
Stops BetterSEQTA+ from updating your calendar. Synced classes stay in {providerLabel} Calendar
|
||||
until you delete them or connect again.
|
||||
</p>
|
||||
<div class="bsplus-cal-modal-actions">
|
||||
<button
|
||||
@@ -57,7 +59,7 @@
|
||||
disabled={busy}
|
||||
onclick={() => void onConfirm()}
|
||||
>
|
||||
{busy ? "Disconnecting…" : "Disconnect"}
|
||||
{busy ? "Disconnecting…" : "Disconnect account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +70,7 @@
|
||||
.bsplus-cal-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
z-index: var(--bsplus-cal-z-modal, 2147483647);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -25,10 +25,18 @@
|
||||
import { deleteSyncedEventsFromOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine";
|
||||
import CalendarDeleteEventsModal from "./CalendarDeleteEventsModal.svelte";
|
||||
import CalendarDisconnectModal from "./CalendarDisconnectModal.svelte";
|
||||
import CalendarSyncProgress from "./CalendarSyncProgress.svelte";
|
||||
import OutlookCalendarIcon from "./OutlookCalendarIcon.svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
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 BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null;
|
||||
@@ -54,6 +62,38 @@
|
||||
|
||||
const isBusy = $derived(busy !== null);
|
||||
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))";
|
||||
|
||||
function isProviderBusy(provider: CalendarProvider): boolean {
|
||||
@@ -150,7 +190,7 @@
|
||||
async function connectProvider(provider: CalendarProvider) {
|
||||
const status = provider === "google" ? googleStatus : outlookStatus;
|
||||
if (!status.configured || isBusy) return;
|
||||
menuOpen = true;
|
||||
menuOpen = false;
|
||||
busy = { provider, phase: "connect" };
|
||||
const connectType =
|
||||
provider === "google" ? "googleCalendarConnect" : "outlookCalendarConnect";
|
||||
@@ -187,6 +227,7 @@
|
||||
}
|
||||
|
||||
busy = { provider, phase: "sync" };
|
||||
menuOpen = false;
|
||||
try {
|
||||
await performSync(provider);
|
||||
} catch (err) {
|
||||
@@ -200,6 +241,8 @@
|
||||
async function confirmDeleteEvents() {
|
||||
if (isBusy || !modalProvider) return;
|
||||
const provider = modalProvider;
|
||||
showDeleteEvents = false;
|
||||
menuOpen = false;
|
||||
busy = { provider, phase: "delete" };
|
||||
syncProgress = {
|
||||
phase: "preparing",
|
||||
@@ -222,13 +265,12 @@
|
||||
}
|
||||
|
||||
const removed = result.deleted ?? 0;
|
||||
showDeleteEvents = false;
|
||||
menuOpen = false;
|
||||
modalProvider = null;
|
||||
const label = provider === "google" ? "Google" : "Outlook";
|
||||
if (removed === 0) {
|
||||
showToastMessage("No synced events to remove.");
|
||||
} else {
|
||||
showToastMessage(`Removed ${removed} event${removed === 1 ? "" : "s"} from Google Calendar.`);
|
||||
showToastMessage(`Removed ${removed} event${removed === 1 ? "" : "s"} from ${label} Calendar.`);
|
||||
}
|
||||
} catch (err) {
|
||||
showToastMessage(err instanceof Error ? err.message : "Remove failed.", true);
|
||||
@@ -281,8 +323,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
function openDeleteModal(provider: CalendarProvider) {
|
||||
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;
|
||||
if (menuOpen) {
|
||||
queueMicrotask(() => syncMenuTheme());
|
||||
@@ -317,12 +372,7 @@
|
||||
}
|
||||
|
||||
function portalMenu(node: HTMLElement) {
|
||||
document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
return portalToBody(node);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -332,8 +382,11 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen || !menuEl) return;
|
||||
syncMenuTheme();
|
||||
if (menuOpen && menuEl) syncMenuTheme();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen || !triggerEl) return;
|
||||
updateMenuPosition();
|
||||
const onLayout = () => updateMenuPosition();
|
||||
window.addEventListener("resize", onLayout);
|
||||
@@ -376,6 +429,7 @@
|
||||
const target = event.target as Node;
|
||||
if (rootEl?.contains(target)) return;
|
||||
if (menuEl?.contains(target)) return;
|
||||
if (isCalendarSyncModalTarget(target)) return;
|
||||
menuOpen = false;
|
||||
};
|
||||
|
||||
@@ -397,21 +451,20 @@
|
||||
class="uiButton bsplus-cal-trigger"
|
||||
bind:this={triggerEl}
|
||||
class:bsplus-cal-trigger--open={menuOpen}
|
||||
class:bsplus-cal-trigger--connected={anyConnected}
|
||||
class:bsplus-cal-trigger--busy={isBusy}
|
||||
class:bsplus-cal-trigger--progress={showTriggerProgress}
|
||||
style:--bsplus-cal-trigger-progress="{triggerProgressPercent}%"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-busy={isBusy}
|
||||
aria-label={anyConnected ? "Calendar sync options" : "Sync with Calendar"}
|
||||
onclick={() => {
|
||||
if (!isBusy) toggleMenu();
|
||||
}}
|
||||
aria-label={triggerAriaLabel}
|
||||
onclick={() => toggleMenu()}
|
||||
>
|
||||
<span class="bsplus-cal-trigger-icon iconFamily" aria-hidden="true"></span>
|
||||
<span class="bsplus-cal-trigger-text">Sync with Calendar</span>
|
||||
{#if anyConnected}
|
||||
<span class="bsplus-cal-status-dot" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="bsplus-cal-trigger-fill" aria-hidden="true"></span>
|
||||
<span class="bsplus-cal-trigger-content">
|
||||
<span class="bsplus-cal-trigger-icon iconFamily" aria-hidden="true"></span>
|
||||
<span class="bsplus-cal-trigger-text">{triggerStatusText}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
@@ -425,7 +478,7 @@
|
||||
>
|
||||
<div class="bsplus-cal-menu-header">
|
||||
<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 class="bsplus-cal-provider" role="none">
|
||||
@@ -479,7 +532,7 @@
|
||||
disabled={!googleStatus.configured || isBusy}
|
||||
onclick={() => void connectProvider("google")}
|
||||
>
|
||||
{providerPhase("google") === "connect" ? "Connecting…" : "Connect"}
|
||||
{providerPhase("google") === "connect" ? "Connecting…" : "Connect & sync"}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -490,31 +543,25 @@
|
||||
disabled={isBusy}
|
||||
onclick={() => void syncProvider("google")}
|
||||
>
|
||||
{providerPhase("google") === "sync" ? "Syncing…" : "Sync now"}
|
||||
{providerPhase("google") === "sync" ? "Updating…" : "Update calendar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bsplus-cal-action bsplus-cal-action--ghost"
|
||||
role="menuitem"
|
||||
disabled={isBusy}
|
||||
onclick={() => {
|
||||
modalProvider = "google";
|
||||
showDeleteEvents = true;
|
||||
}}
|
||||
onclick={() => openDeleteModal("google")}
|
||||
>
|
||||
{providerPhase("google") === "delete" ? "Removing…" : "Remove from calendar"}
|
||||
{providerPhase("google") === "delete" ? "Deleting…" : "Delete synced classes"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bsplus-cal-action bsplus-cal-action--ghost"
|
||||
role="menuitem"
|
||||
disabled={isBusy}
|
||||
onclick={() => {
|
||||
modalProvider = "google";
|
||||
showDisconnect = true;
|
||||
}}
|
||||
onclick={() => openDisconnectModal("google")}
|
||||
>
|
||||
Disconnect
|
||||
Disconnect account
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -549,7 +596,7 @@
|
||||
disabled={!outlookStatus.configured || isBusy}
|
||||
onclick={() => void connectProvider("outlook")}
|
||||
>
|
||||
{providerPhase("outlook") === "connect" ? "Connecting…" : "Connect"}
|
||||
{providerPhase("outlook") === "connect" ? "Connecting…" : "Connect & sync"}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -560,40 +607,39 @@
|
||||
disabled={isBusy}
|
||||
onclick={() => void syncProvider("outlook")}
|
||||
>
|
||||
{providerPhase("outlook") === "sync" ? "Syncing…" : "Sync now"}
|
||||
{providerPhase("outlook") === "sync" ? "Updating…" : "Update calendar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bsplus-cal-action bsplus-cal-action--ghost"
|
||||
role="menuitem"
|
||||
disabled={isBusy}
|
||||
onclick={() => {
|
||||
modalProvider = "outlook";
|
||||
showDeleteEvents = true;
|
||||
}}
|
||||
onclick={() => openDeleteModal("outlook")}
|
||||
>
|
||||
{providerPhase("outlook") === "delete" ? "Removing…" : "Remove from calendar"}
|
||||
{providerPhase("outlook") === "delete" ? "Deleting…" : "Delete synced classes"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bsplus-cal-action bsplus-cal-action--ghost"
|
||||
role="menuitem"
|
||||
disabled={isBusy}
|
||||
onclick={() => {
|
||||
modalProvider = "outlook";
|
||||
showDisconnect = true;
|
||||
}}
|
||||
onclick={() => openDisconnectModal("outlook")}
|
||||
>
|
||||
Disconnect
|
||||
Disconnect account
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<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
|
||||
type="number"
|
||||
class="bsplus-cal-setting-input"
|
||||
@@ -605,7 +651,12 @@
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="checkbox"
|
||||
class="bsplus-cal-setting-checkbox"
|
||||
@@ -614,13 +665,8 @@
|
||||
onchange={(e) => void onAutoSyncToggle(e)}
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
<CalendarSyncProgress progress={syncProgress} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -676,7 +722,32 @@
|
||||
margin-left: 4px;
|
||||
border-radius: 16px !important;
|
||||
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 {
|
||||
@@ -735,25 +806,17 @@
|
||||
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 {
|
||||
opacity: 0.85;
|
||||
cursor: wait;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.bsplus-cal-status-dot {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 2px var(--bsplus-cal-surface, #fff);
|
||||
display: none;
|
||||
.bsplus-cal-trigger--progress {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bsplus-cal-trigger--progress:hover,
|
||||
.bsplus-cal-trigger--progress:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.bsplus-cal-settings {
|
||||
@@ -768,19 +831,37 @@
|
||||
|
||||
.bsplus-cal-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bsplus-cal-setting-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.bsplus-cal-setting-label {
|
||||
font-weight: 600;
|
||||
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 {
|
||||
width: 64px;
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
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 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
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 {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
z-index: var(--bsplus-cal-z-menu, 2147483646);
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
}
|
||||
|
||||
.timetable-calendar-controls.bsplus-cal-menu-open {
|
||||
z-index: 2147483646;
|
||||
z-index: 2147483645;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user