refactor: consolidate and debloat calendar sync implementation

Merge duplicated Google/Outlook engines, background handlers, and API
layers into shared calendarSync modules to cut complexity without changing
user-facing sync behavior.
This commit is contained in:
SethBurkart123
2026-06-28 10:45:45 +10:00
parent f4230e02b9
commit 7779edb063
52 changed files with 1965 additions and 3258 deletions
@@ -1,144 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { portalToBody } from "./calendarSyncPortal";
let {
open = false,
busy = false,
providerLabel = "Google",
onConfirm,
onCancel,
} = $props<{
open?: boolean;
busy?: boolean;
providerLabel?: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}>();
</script>
{#if open}
<div
class="bsplus-cal-modal-backdrop"
use:portalToBody
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onCancel();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !busy) onCancel();
}}
role="presentation"
transition:fade={{ duration: 150 }}
>
<div
class="bsplus-cal-modal"
role="dialog"
aria-modal="true"
aria-labelledby="bsplus-cal-delete-title"
transition:fade={{ duration: 180 }}
>
<h2 id="bsplus-cal-delete-title" class="bsplus-cal-modal-title">
Delete synced classes?
</h2>
<p class="bsplus-cal-modal-body">
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
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={busy}
onclick={onCancel}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={busy}
onclick={() => void onConfirm()}
>
{busy ? "Deleting…" : "Delete synced classes"}
</button>
</div>
</div>
</div>
{/if}
<style>
.bsplus-cal-modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--bsplus-cal-z-modal, 2147483647);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.bsplus-cal-modal {
width: min(100%, 400px);
padding: 20px;
border-radius: 16px;
background: var(--bsplus-cal-surface, #fff);
color: var(--bsplus-cal-text, #111);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.22);
border: 1px solid color-mix(in srgb, var(--bsplus-cal-text, #111) 12%, transparent);
}
.bsplus-cal-modal-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
}
.bsplus-cal-modal-body {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 72%, transparent);
}
.bsplus-cal-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.bsplus-cal-btn {
padding: 8px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-cal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bsplus-cal-btn--ghost {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 8%, transparent);
color: var(--bsplus-cal-text, #111);
}
.bsplus-cal-btn--ghost:hover:not(:disabled) {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 14%, transparent);
}
.bsplus-cal-btn--danger {
background: #dc2626;
color: #fff;
}
.bsplus-cal-btn--danger:hover:not(:disabled) {
background: #b91c1c;
}
</style>
@@ -1,144 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { portalToBody } from "./calendarSyncPortal";
let {
open = false,
busy = false,
providerLabel = "Google",
onConfirm,
onCancel,
} = $props<{
open?: boolean;
busy?: boolean;
providerLabel?: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}>();
</script>
{#if open}
<div
class="bsplus-cal-modal-backdrop"
use:portalToBody
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onCancel();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !busy) onCancel();
}}
role="presentation"
transition:fade={{ duration: 150 }}
>
<div
class="bsplus-cal-modal"
role="dialog"
aria-modal="true"
aria-labelledby="bsplus-cal-disconnect-title"
transition:fade={{ duration: 180 }}
>
<h2 id="bsplus-cal-disconnect-title" class="bsplus-cal-modal-title">
Disconnect {providerLabel} Calendar?
</h2>
<p class="bsplus-cal-modal-body">
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
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={busy}
onclick={onCancel}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={busy}
onclick={() => void onConfirm()}
>
{busy ? "Disconnecting…" : "Disconnect account"}
</button>
</div>
</div>
</div>
{/if}
<style>
.bsplus-cal-modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--bsplus-cal-z-modal, 2147483647);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.bsplus-cal-modal {
width: min(100%, 400px);
padding: 20px;
border-radius: 16px;
background: var(--bsplus-cal-surface, #fff);
color: var(--bsplus-cal-text, #111);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.22);
border: 1px solid color-mix(in srgb, var(--bsplus-cal-text, #111) 12%, transparent);
}
.bsplus-cal-modal-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
}
.bsplus-cal-modal-body {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 72%, transparent);
}
.bsplus-cal-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.bsplus-cal-btn {
padding: 8px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-cal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bsplus-cal-btn--ghost {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 8%, transparent);
color: var(--bsplus-cal-text, #111);
}
.bsplus-cal-btn--ghost:hover:not(:disabled) {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 14%, transparent);
}
.bsplus-cal-btn--danger {
background: #dc2626;
color: #fff;
}
.bsplus-cal-btn--danger:hover:not(:disabled) {
background: #b91c1c;
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import { fade, fly } from "svelte/transition";
import browser from "webextension-polyfill";
import {
@@ -7,40 +8,41 @@
GOOGLE_CALENDAR_SYNC_WEEKS_MIN,
} from "@/config/googleCalendar";
import { maybeRunDueWeeklySync } from "@/seqta/utils/googleCalendar/calendarSyncListener";
import { deleteSyncedEventsFromGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine";
import {
formatOutlookSyncResultMessage,
runOutlookCalendarSync,
} from "@/seqta/utils/outlookCalendar/syncRunner";
import {
formatSyncResultMessage,
runGoogleCalendarSync,
} from "@/seqta/utils/googleCalendar/syncRunner";
deleteSyncedEventsFromGoogleCalendar,
deleteSyncedEventsFromOutlookCalendar,
} from "@/seqta/utils/calendarSync/syncEngine";
import { formatLessonSyncResultMessage } from "@/seqta/utils/calendarSync/lessonSyncShared";
import { runGoogleCalendarSync, runOutlookCalendarSync } from "@/seqta/utils/calendarSync/syncRunner";
import type {
GoogleCalendarStatus,
GoogleCalendarSyncProgress,
GoogleCalendarSyncResult,
} from "@/seqta/utils/googleCalendar/types";
import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/types";
import { deleteSyncedEventsFromOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine";
import CalendarDeleteEventsModal from "./CalendarDeleteEventsModal.svelte";
import CalendarDisconnectModal from "./CalendarDisconnectModal.svelte";
import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/storage";
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";
import {
formatLastSync,
isCalendarSyncModalTarget,
portalToBody,
providerLabel as calendarProviderLabel,
syncCalendarSyncTheme,
syncProgressPercent,
type CalendarProvider,
} from "./calendarSyncUi";
type BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null;
type BusyState = { provider: CalendarProvider; phase: BusyPhase } | null;
type ProviderStatus = { configured: boolean; connected: boolean; lastSyncAt?: number };
function setProviderStatus(provider: CalendarProvider, patch: Partial<ProviderStatus>) {
if (provider === "google") googleStatus = { ...googleStatus, ...patch };
else outlookStatus = { ...outlookStatus, ...patch };
}
function providerStatus(provider: CalendarProvider): ProviderStatus {
return provider === "google" ? googleStatus : outlookStatus;
}
let googleStatus = $state<GoogleCalendarStatus>({ configured: true, connected: false });
let outlookStatus = $state<OutlookCalendarStatus>({ configured: true, connected: false });
@@ -62,6 +64,11 @@
const isBusy = $derived(busy !== null);
const anyConnected = $derived(googleStatus.connected || outlookStatus.connected);
const modalOpen = $derived(showDisconnect || showDeleteEvents);
const modalBusy = $derived(
showDisconnect ? busy?.phase === "disconnect" : busy?.phase === "delete",
);
const providerLabel = $derived(calendarProviderLabel(modalProvider ?? "google"));
const showTriggerProgress = $derived(
isBusy &&
(busy?.phase === "sync" ||
@@ -96,14 +103,22 @@
});
const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))";
function isProviderBusy(provider: CalendarProvider): boolean {
return busy?.provider === provider;
}
function providerPhase(provider: CalendarProvider): BusyPhase {
return busy?.provider === provider ? busy.phase : null;
}
function providerStatusText(status: ProviderStatus, notConfigured: string): string {
if (!status.configured) return notConfigured;
if (!status.connected) return "Not connected";
const lastSync = formatLastSync(status.lastSyncAt);
return lastSync ? `Connected · ${lastSync}` : "Connected";
}
function closeModal() {
if (showDisconnect && busy?.phase !== "disconnect") showDisconnect = false;
if (showDeleteEvents && busy?.phase !== "delete") showDeleteEvents = false;
}
function showToastMessage(message: string, isError = false) {
toast = { message, error: isError };
if (toastTimer) clearTimeout(toastTimer);
@@ -159,7 +174,11 @@
mode: "full" | "incremental" = "full",
): Promise<boolean> {
const run = provider === "google" ? runGoogleCalendarSync : runOutlookCalendarSync;
const format = provider === "google" ? formatSyncResultMessage : formatOutlookSyncResultMessage;
const format = (result: GoogleCalendarSyncResult) =>
formatLessonSyncResultMessage(
result,
`${calendarProviderLabel(provider)} Calendar`,
);
const result = await run({ mode, onProgress: handleSyncProgress });
syncProgress = null;
@@ -169,26 +188,17 @@
return false;
}
if (provider === "google") {
googleStatus = {
...googleStatus,
connected: true,
lastSyncAt: result.lastSyncAt ?? googleStatus.lastSyncAt,
};
} else {
outlookStatus = {
...outlookStatus,
connected: true,
lastSyncAt: result.lastSyncAt ?? outlookStatus.lastSyncAt,
};
}
setProviderStatus(provider, {
connected: true,
lastSyncAt: result.lastSyncAt ?? providerStatus(provider).lastSyncAt,
});
showToastMessage(format(result));
return true;
}
async function connectProvider(provider: CalendarProvider) {
const status = provider === "google" ? googleStatus : outlookStatus;
const status = providerStatus(provider);
if (!status.configured || isBusy) return;
menuOpen = false;
busy = { provider, phase: "connect" };
@@ -199,15 +209,13 @@
type: connectType,
})) as GoogleCalendarSyncResult;
if (!result.success) {
const label = provider === "google" ? "Google" : "Outlook";
showToastMessage(result.error ?? `Could not connect to ${label} Calendar.`, true);
showToastMessage(
result.error ?? `Could not connect to ${calendarProviderLabel(provider)} Calendar.`,
true,
);
return;
}
if (provider === "google") {
googleStatus = { ...googleStatus, connected: true };
} else {
outlookStatus = { ...outlookStatus, connected: true };
}
setProviderStatus(provider, { connected: true });
busy = { provider, phase: "sync" };
await performSync(provider);
} catch (err) {
@@ -219,7 +227,7 @@
}
async function syncProvider(provider: CalendarProvider) {
const status = provider === "google" ? googleStatus : outlookStatus;
const status = providerStatus(provider);
if (!status.configured || isBusy) return;
if (!status.connected) {
await connectProvider(provider);
@@ -266,7 +274,7 @@
const removed = result.deleted ?? 0;
modalProvider = null;
const label = provider === "google" ? "Google" : "Outlook";
const label = calendarProviderLabel(provider);
if (removed === 0) {
showToastMessage("No synced events to remove.");
} else {
@@ -298,7 +306,7 @@
busy = { provider, phase: "disconnect" };
const disconnectType =
provider === "google" ? "googleCalendarDisconnect" : "outlookCalendarDisconnect";
const label = provider === "google" ? "Google" : "Outlook";
const label = calendarProviderLabel(provider);
try {
const result = (await browser.runtime.sendMessage({
type: disconnectType,
@@ -307,11 +315,7 @@
showToastMessage(`Could not disconnect ${label} Calendar.`, true);
return;
}
if (provider === "google") {
googleStatus = { ...googleStatus, connected: false, lastSyncAt: undefined };
} else {
outlookStatus = { ...outlookStatus, connected: false, lastSyncAt: undefined };
}
setProviderStatus(provider, { connected: false, lastSyncAt: undefined });
showDisconnect = false;
menuOpen = false;
modalProvider = null;
@@ -323,56 +327,30 @@
}
}
function openDeleteModal(provider: CalendarProvider) {
function openModal(provider: CalendarProvider, kind: "delete" | "disconnect") {
if (isBusy) return;
modalProvider = provider;
menuOpen = false;
showDeleteEvents = true;
}
function openDisconnectModal(provider: CalendarProvider) {
if (isBusy) return;
modalProvider = provider;
menuOpen = false;
showDisconnect = true;
showDisconnect = kind === "disconnect";
showDeleteEvents = kind === "delete";
}
function toggleMenu() {
menuOpen = !menuOpen;
if (menuOpen) {
queueMicrotask(() => syncMenuTheme());
}
}
function formatLastSync(ts?: number): string | null {
if (!ts) return null;
const diff = Date.now() - ts;
if (diff < 60_000) return "Synced just now";
if (diff < 3_600_000) return `Synced ${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `Synced ${Math.floor(diff / 3_600_000)}h ago`;
return `Synced ${new Date(ts).toLocaleDateString()}`;
if (menuOpen) queueMicrotask(syncHostTheme);
}
function updateMenuPosition() {
if (!triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
menuStyle = `top:${rect.bottom + 8}px;right:${window.innerWidth - rect.right}px;`;
syncMenuTheme();
syncHostTheme();
}
function syncMenuTheme() {
if (!menuEl) return;
syncCalendarSyncTheme(menuEl);
}
function syncMountedTheme() {
const themeHost = rootEl?.closest(".bsplus-calendar-sync-mount") as HTMLElement | null;
if (themeHost) syncCalendarSyncTheme(themeHost);
if (menuOpen) syncMenuTheme();
}
function portalMenu(node: HTMLElement) {
return portalToBody(node);
function syncHostTheme() {
const themeHost = rootEl?.closest(".bsplus-calendar-sync-mount");
if (themeHost instanceof HTMLElement) syncCalendarSyncTheme(themeHost);
if (menuEl) syncCalendarSyncTheme(menuEl);
}
$effect(() => {
@@ -382,7 +360,7 @@
});
$effect(() => {
if (menuOpen && menuEl) syncMenuTheme();
if (menuOpen && menuEl) syncHostTheme();
});
$effect(() => {
@@ -413,7 +391,7 @@
"adaptiveThemeGradient",
"selectedTheme",
] as const;
const onThemeChange = () => syncMountedTheme();
const onThemeChange = () => syncHostTheme();
for (const key of themeKeys) {
settingsState.register(key, onThemeChange);
}
@@ -445,6 +423,72 @@
});
</script>
{#snippet providerPanel(
provider: CalendarProvider,
status: ProviderStatus,
notConfiguredMsg: string,
icon: Snippet,
name: Snippet,
)}
<div class="bsplus-cal-provider" role="none">
<div class="bsplus-cal-provider-row">
<span class="bsplus-cal-provider-icon" aria-hidden="true">
{@render icon()}
</span>
<div class="bsplus-cal-provider-copy">
<span class="bsplus-cal-provider-name">{@render name()}</span>
<span class="bsplus-cal-provider-status">
{providerStatusText(status, notConfiguredMsg)}
</span>
</div>
</div>
<div class="bsplus-cal-provider-actions">
{#if !status.connected}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={!status.configured || isBusy}
onclick={() => void connectProvider(provider)}
>
{providerPhase(provider) === "connect" ? "Connecting…" : "Connect & sync"}
</button>
{:else}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={isBusy}
onclick={() => void syncProvider(provider)}
>
{providerPhase(provider) === "sync" ? "Updating…" : "Update calendar"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openModal(provider, "delete")}
>
{providerPhase(provider) === "delete" ? "Deleting…" : "Delete synced classes"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openModal(provider, "disconnect")}
>
Disconnect account
</button>
{/if}
</div>
</div>
{/snippet}
<div class="bsplus-cal-sync" bind:this={rootEl}>
<button
type="button"
@@ -473,7 +517,7 @@
role="menu"
bind:this={menuEl}
style={menuStyle}
use:portalMenu
use:portalToBody
transition:fly={{ y: -6, duration: 160 }}
>
<div class="bsplus-cal-menu-header">
@@ -481,214 +525,136 @@
<span class="bsplus-cal-menu-sub">Copy your SEQTA timetable classes to Google or Outlook</span>
</div>
<div class="bsplus-cal-provider" role="none">
<div class="bsplus-cal-provider-row">
<span class="bsplus-cal-provider-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path
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
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
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"
/>
</svg>
</span>
<div class="bsplus-cal-provider-copy">
<span class="bsplus-cal-provider-name">
<span class="bsplus-google-word bsplus-google-word--sm">
<span class="bsplus-google-g">G</span><span class="bsplus-google-o1">o</span><span class="bsplus-google-o2">o</span><span class="bsplus-google-g2">g</span><span class="bsplus-google-l">l</span><span class="bsplus-google-e">e</span>
</span>
<span> Calendar</span>
</span>
<span class="bsplus-cal-provider-status">
{#if !googleStatus.configured}
Not available in this build
{:else if googleStatus.connected}
Connected{formatLastSync(googleStatus.lastSyncAt) ? ` · ${formatLastSync(googleStatus.lastSyncAt)}` : ""}
{:else}
Not connected
{/if}
</span>
</div>
</div>
<div class="bsplus-cal-provider-actions">
{#if !googleStatus.connected}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={!googleStatus.configured || isBusy}
onclick={() => void connectProvider("google")}
>
{providerPhase("google") === "connect" ? "Connecting…" : "Connect & sync"}
</button>
{:else}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={isBusy}
onclick={() => void syncProvider("google")}
>
{providerPhase("google") === "sync" ? "Updating…" : "Update calendar"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openDeleteModal("google")}
>
{providerPhase("google") === "delete" ? "Deleting…" : "Delete synced classes"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openDisconnectModal("google")}
>
Disconnect account
</button>
{/if}
</div>
</div>
<div class="bsplus-cal-provider" role="none">
<div class="bsplus-cal-provider-row">
<span class="bsplus-cal-provider-icon" aria-hidden="true">
<OutlookCalendarIcon />
</span>
<div class="bsplus-cal-provider-copy">
<span class="bsplus-cal-provider-name">Outlook Calendar</span>
<span class="bsplus-cal-provider-status">
{#if !outlookStatus.configured}
Set OUTLOOK_OAUTH_CLIENT_ID to enable
{:else if outlookStatus.connected}
Connected{formatLastSync(outlookStatus.lastSyncAt) ? ` · ${formatLastSync(outlookStatus.lastSyncAt)}` : ""}
{:else}
Not connected
{/if}
</span>
</div>
</div>
<div class="bsplus-cal-provider-actions">
{#if !outlookStatus.connected}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={!outlookStatus.configured || isBusy}
onclick={() => void connectProvider("outlook")}
>
{providerPhase("outlook") === "connect" ? "Connecting…" : "Connect & sync"}
</button>
{:else}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={isBusy}
onclick={() => void syncProvider("outlook")}
>
{providerPhase("outlook") === "sync" ? "Updating…" : "Update calendar"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openDeleteModal("outlook")}
>
{providerPhase("outlook") === "delete" ? "Deleting…" : "Delete synced classes"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openDisconnectModal("outlook")}
>
Disconnect account
</button>
{/if}
</div>
</div>
{@render providerPanel(
"google",
googleStatus,
"Not available in this build",
googleIcon,
googleName,
)}
{@render providerPanel(
"outlook",
outlookStatus,
"Set OUTLOOK_OAUTH_CLIENT_ID to enable",
outlookIcon,
outlookName,
)}
{#if anyConnected}
<div class="bsplus-cal-settings" role="group" aria-label="Sync options">
<label class="bsplus-cal-setting">
<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"
min={GOOGLE_CALENDAR_SYNC_WEEKS_MIN}
max={GOOGLE_CALENDAR_SYNC_WEEKS_MAX}
value={syncWeeksAhead}
disabled={isBusy}
onchange={(e) => void onWeeksAheadChange(e)}
/>
</label>
<label class="bsplus-cal-setting bsplus-cal-setting--toggle">
<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"
checked={autoSyncWeekly}
disabled={isBusy}
onchange={(e) => void onAutoSyncToggle(e)}
/>
</label>
</div>
{/if}
<label class="bsplus-cal-setting">
<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"
min={GOOGLE_CALENDAR_SYNC_WEEKS_MIN}
max={GOOGLE_CALENDAR_SYNC_WEEKS_MAX}
value={syncWeeksAhead}
disabled={isBusy}
onchange={(e) => void onWeeksAheadChange(e)}
/>
</label>
<label class="bsplus-cal-setting bsplus-cal-setting--toggle">
<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"
checked={autoSyncWeekly}
disabled={isBusy}
onchange={(e) => void onAutoSyncToggle(e)}
/>
</label>
</div>
{/if}
</div>
{/if}
<CalendarDeleteEventsModal
open={showDeleteEvents}
busy={busy?.phase === "delete"}
providerLabel={modalProvider === "outlook" ? "Outlook" : "Google"}
onCancel={() => {
if (busy?.phase !== "delete") showDeleteEvents = false;
}}
onConfirm={confirmDeleteEvents}
/>
<CalendarDisconnectModal
open={showDisconnect}
busy={busy?.phase === "disconnect"}
providerLabel={modalProvider === "outlook" ? "Outlook" : "Google"}
onCancel={() => {
if (busy?.phase !== "disconnect") showDisconnect = false;
}}
onConfirm={confirmDisconnect}
/>
{#if modalOpen}
<div
class="bsplus-cal-modal-backdrop"
use:portalToBody
onclick={(e) => {
if (e.target === e.currentTarget && !modalBusy) closeModal();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !modalBusy) closeModal();
}}
role="presentation"
transition:fade={{ duration: 150 }}
>
<div
class="bsplus-cal-modal"
role="dialog"
aria-modal="true"
aria-labelledby="bsplus-cal-modal-title"
transition:fade={{ duration: 180 }}
>
{#if showDisconnect}
<h2 id="bsplus-cal-modal-title" class="bsplus-cal-modal-title">
Disconnect {providerLabel} Calendar?
</h2>
<p class="bsplus-cal-modal-body">
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
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={modalBusy}
onclick={closeModal}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={modalBusy}
onclick={() => void confirmDisconnect()}
>
{modalBusy ? "Disconnecting…" : "Disconnect account"}
</button>
</div>
{:else}
<h2 id="bsplus-cal-modal-title" class="bsplus-cal-modal-title">
Delete synced classes?
</h2>
<p class="bsplus-cal-modal-body">
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
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={modalBusy}
onclick={closeModal}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={modalBusy}
onclick={() => void confirmDeleteEvents()}
>
{modalBusy ? "Deleting…" : "Delete synced classes"}
</button>
</div>
{/if}
</div>
</div>
{/if}
{#if toast}
<div
@@ -702,6 +668,39 @@
{/if}
</div>
{#snippet googleIcon()}
<svg viewBox="0 0 24 24">
<path
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
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
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"
/>
</svg>
{/snippet}
{#snippet googleName()}
Google Calendar
{/snippet}
{#snippet outlookIcon()}
<OutlookCalendarIcon />
{/snippet}
{#snippet outlookName()}
Outlook Calendar
{/snippet}
<style>
.bsplus-cal-sync {
position: relative;
@@ -765,35 +764,6 @@
white-space: nowrap;
}
.bsplus-google-word {
display: inline-flex;
font-size: 13px;
font-weight: 700;
letter-spacing: -0.02em;
}
.bsplus-google-word--sm {
font-size: 13px;
}
.bsplus-google-g,
.bsplus-google-g2 {
color: #4285f4;
}
.bsplus-google-o1,
.bsplus-google-e {
color: #ea4335;
}
.bsplus-google-o2 {
color: #fbbc05;
}
.bsplus-google-l {
color: #34a853;
}
.bsplus-cal-trigger:hover:not(.bsplus-cal-trigger--busy) {
transform: scale(1.03);
}
@@ -999,6 +969,81 @@
background: color-mix(in srgb, var(--bsplus-cal-text) 14%, var(--bsplus-cal-surface));
}
.bsplus-cal-modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--bsplus-cal-z-modal, 2147483647);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.bsplus-cal-modal {
width: min(100%, 400px);
padding: 20px;
border-radius: 16px;
background: var(--bsplus-cal-surface, #fff);
color: var(--bsplus-cal-text, #111);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.22);
border: 1px solid color-mix(in srgb, var(--bsplus-cal-text, #111) 12%, transparent);
}
.bsplus-cal-modal-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
}
.bsplus-cal-modal-body {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 72%, transparent);
}
.bsplus-cal-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.bsplus-cal-btn {
padding: 8px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-cal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bsplus-cal-btn--ghost {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 8%, transparent);
color: var(--bsplus-cal-text, #111);
}
.bsplus-cal-btn--ghost:hover:not(:disabled) {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 14%, transparent);
}
.bsplus-cal-btn--danger {
background: #dc2626;
color: #fff;
}
.bsplus-cal-btn--danger:hover:not(:disabled) {
background: #b91c1c;
}
.bsplus-cal-toast {
position: fixed;
right: 16px;
@@ -1,67 +0,0 @@
<script lang="ts">
import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types";
let {
progress = null,
} = $props<{
progress?: GoogleCalendarSyncProgress | null;
}>();
const percent = $derived(
progress && progress.total > 0
? Math.min(100, Math.round((progress.current / progress.total) * 100))
: progress?.phase === "preparing"
? 8
: 0,
);
</script>
{#if progress && progress.phase !== "done"}
<div class="bsplus-cal-progress" role="status" aria-live="polite" aria-busy="true">
<div class="bsplus-cal-progress-label">{progress.message}</div>
<div class="bsplus-cal-progress-track" aria-hidden="true">
<div class="bsplus-cal-progress-bar" style:width={`${percent}%`}></div>
</div>
{#if progress.total > 0}
<div class="bsplus-cal-progress-meta">{progress.current} / {progress.total}</div>
{/if}
</div>
{/if}
<style>
.bsplus-cal-progress {
margin-top: 8px;
padding: 10px;
border-radius: 10px;
background: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 22%, transparent);
}
.bsplus-cal-progress-label {
font-size: 11px;
font-weight: 600;
color: var(--bsplus-cal-text, var(--text-primary, #111));
margin-bottom: 8px;
}
.bsplus-cal-progress-track {
height: 8px;
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 10%, transparent);
}
.bsplus-cal-progress-bar {
height: 100%;
border-radius: 999px;
background: var(--bsplus-cal-accent, var(--better-main, #3b82f6));
transition: width 0.25s ease;
}
.bsplus-cal-progress-meta {
margin-top: 6px;
font-size: 10px;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 62%, transparent);
text-align: right;
}
</style>
@@ -25,5 +25,4 @@
.bsplus-calendar-sync-mount {
display: inline-flex;
font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif);
}
@@ -1,16 +0,0 @@
/** 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"));
}
@@ -1,88 +0,0 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor";
import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager";
import { getFontPreset } from "@/seqta/ui/fonts/presets";
export const CALENDAR_THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
export function isCalendarDarkMode(): boolean {
return !!settingsState.DarkMode || document.documentElement.classList.contains("dark");
}
function resolvePageAccentColor(): string {
const computed = getComputedStyle(document.documentElement);
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) return solid;
}
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) return solid;
}
return "#3b82f6";
}
/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */
export function syncCalendarSyncTheme(target: HTMLElement): void {
const computed = getComputedStyle(document.documentElement);
const dark = isCalendarDarkMode();
const fontPreset = getFontPreset(settingsState.selectedFont);
ensureFontLoaded(fontPreset);
target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack);
for (const name of CALENDAR_THEME_CSS_VARS) {
const value =
document.documentElement.style.getPropertyValue(name).trim() ||
computed.getPropertyValue(name).trim();
if (value) target.style.setProperty(name, value);
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-cal-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle("dark", dark);
const textPrimary =
computed.getPropertyValue("--text-primary").trim() ||
computed.getPropertyValue("--text-color").trim();
const bgPrimary =
computed.getPropertyValue("--background-primary").trim() ||
computed.getPropertyValue("--background-secondary").trim() ||
computed.getPropertyValue("--theme-offset-bg").trim();
target.style.setProperty(
"--bsplus-cal-text",
textPrimary || (dark ? "#f4f4f5" : "#18181b"),
);
target.style.setProperty(
"--bsplus-cal-surface",
bgPrimary || (dark ? "#27272a" : "#ffffff"),
);
target.style.setProperty(
"--bsplus-cal-surface-muted",
dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))",
);
target.style.setProperty(
"--bsplus-cal-border",
dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)",
);
}
@@ -1,14 +1,133 @@
import { mount, unmount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor";
import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager";
import { getFontPreset } from "@/seqta/ui/fonts/presets";
import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types";
import CalendarSyncControl from "./CalendarSyncControl.svelte";
import { syncCalendarSyncTheme } from "./calendarSyncTheme";
import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener";
import hostStyles from "./calendarSyncHost.css?inline";
export type CalendarProvider = "google" | "outlook";
export function providerLabel(provider: CalendarProvider): string {
return provider === "google" ? "Google" : "Outlook";
}
export function formatLastSync(ts?: number): string | null {
if (!ts) return null;
const diff = Date.now() - ts;
if (diff < 60_000) return "Synced just now";
if (diff < 3_600_000) return `Synced ${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `Synced ${Math.floor(diff / 3_600_000)}h ago`;
return `Synced ${new Date(ts).toLocaleDateString()}`;
}
export 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;
}
const CONTROLS_CLASS = "timetable-calendar-controls";
const HOST_STYLE_ID = "bsplus-calendar-sync-host-styles";
const THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
let currentApp: ReturnType<typeof mount> | null = null;
let mountRoot: HTMLElement | null = null;
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"));
}
/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */
export function syncCalendarSyncTheme(target: HTMLElement): void {
const computed = getComputedStyle(document.documentElement);
const dark = !!settingsState.DarkMode || document.documentElement.classList.contains("dark");
const fontPreset = getFontPreset(settingsState.selectedFont);
ensureFontLoaded(fontPreset);
target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack);
for (const name of THEME_CSS_VARS) {
const value =
document.documentElement.style.getPropertyValue(name).trim() ||
computed.getPropertyValue(name).trim();
if (value) target.style.setProperty(name, value);
}
let accent = "#3b82f6";
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) {
accent = solid;
break;
}
}
if (accent === "#3b82f6") {
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) accent = solid;
}
}
target.style.setProperty("--bsplus-cal-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle("dark", dark);
const textPrimary =
computed.getPropertyValue("--text-primary").trim() ||
computed.getPropertyValue("--text-color").trim();
const bgPrimary =
computed.getPropertyValue("--background-primary").trim() ||
computed.getPropertyValue("--background-secondary").trim() ||
computed.getPropertyValue("--theme-offset-bg").trim();
target.style.setProperty(
"--bsplus-cal-text",
textPrimary || (dark ? "#f4f4f5" : "#18181b"),
);
target.style.setProperty(
"--bsplus-cal-surface",
bgPrimary || (dark ? "#27272a" : "#ffffff"),
);
target.style.setProperty(
"--bsplus-cal-surface-muted",
dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))",
);
target.style.setProperty(
"--bsplus-cal-border",
dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)",
);
}
function ensureHostStyles() {
if (document.getElementById(HOST_STYLE_ID)) return;
@@ -23,8 +142,6 @@ function teardown() {
unmount(currentApp);
currentApp = null;
}
mountRoot = null;
document.querySelector(`.${CONTROLS_CLASS}`)?.remove();
document.getElementById(HOST_STYLE_ID)?.remove();
}
@@ -42,7 +159,7 @@ export async function mountGoogleCalendarButton(): Promise<void> {
controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`;
toolbar.appendChild(controls);
mountRoot = document.createElement("div");
const mountRoot = document.createElement("div");
mountRoot.className = "bsplus-calendar-sync-mount";
syncCalendarSyncTheme(mountRoot);
controls.appendChild(mountRoot);