diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
| commit | 53ab332e6d2afe9818914e0026310e4cadcc4202 (patch) | |
| tree | 198d708fa05d4247bdcc0cf2900fdf551d61b7d6 /web/frontend/sync.ts | |
| parent | 2d18d177f48285eb123e7da8680fc00e7a6fe19c (diff) | |
refactor(web): migrate browser code to TypeScript
Diffstat (limited to 'web/frontend/sync.ts')
| -rw-r--r-- | web/frontend/sync.ts | 105 |
1 files changed, 105 insertions, 0 deletions
diff --git a/web/frontend/sync.ts b/web/frontend/sync.ts new file mode 100644 index 0000000..000dcfc --- /dev/null +++ b/web/frontend/sync.ts @@ -0,0 +1,105 @@ +import type { SyncErrorEvent, SyncProgress } from "./types.js"; +import { requireElement } from "./dom.js"; + +const isSyncProgress = (data: SyncProgress | SyncErrorEvent): data is SyncProgress => "total" in data; + +export const mountSync = (): void => { + const form = document.getElementById("sync-form") as HTMLFormElement | null; + if (!form) return; + const button = requireElement(form.querySelector<HTMLButtonElement>("button[type=submit]"), "sync submit button"); + const progressPanel = requireElement(document.getElementById("sync-progress"), "sync progress"); + const progressBar = requireElement(document.getElementById("sync-progress-bar") as HTMLProgressElement | null, "sync progress bar"); + const progressCount = requireElement(document.getElementById("sync-progress-count"), "sync progress count"); + const progressDetails = requireElement(document.getElementById("sync-progress-details"), "sync progress details"); + const notice = requireElement(document.getElementById("sync-notice"), "sync notice"); + const error = requireElement(document.getElementById("sync-error"), "sync error"); + + const showError = (message: string): void => { + error.textContent = message; + error.hidden = false; + }; + const updateProgress = (progress: SyncProgress): void => { + progressPanel.hidden = false; + progressBar.max = Math.max(progress.total, 1); + progressBar.value = progress.completed; + progressCount.textContent = `${progress.completed} / ${progress.total}`; + progressDetails.textContent = `${progress.imported} imported ยท ${progress.skipped} skipped`; + }; + const handleEvent = (eventName: string, data: SyncProgress | SyncErrorEvent): boolean => { + if (eventName === "progress") { + if (!isSyncProgress(data)) throw new Error("Invalid Strava sync progress event"); + updateProgress(data); + return false; + } + if (eventName === "complete") { + if (!isSyncProgress(data)) throw new Error("Invalid Strava sync completion event"); + updateProgress(data); + notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`; + notice.hidden = false; + return true; + } + if (eventName === "error") { + if ("progress" in data && data.progress) updateProgress(data.progress); + throw new Error("message" in data ? data.message || "Strava sync failed" : "Strava sync failed"); + } + return false; + }; + const consumeEvents = async (response: Response): Promise<void> => { + if (!response.body) throw new Error("The browser does not support streaming responses"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let completed = false; + const consumeBlock = (block: string): void => { + let eventName = "message"; + let data = ""; + for (const line of block.split(/\r?\n/)) { + if (line.startsWith("event: ")) eventName = line.slice(7); + if (line.startsWith("data: ")) data += line.slice(6); + } + if (data) completed = handleEvent(eventName, JSON.parse(data) as SyncProgress | SyncErrorEvent) || completed; + }; + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = blocks.pop() ?? ""; + for (const block of blocks) consumeBlock(block); + if (done) break; + } + if (buffer.trim()) consumeBlock(buffer); + if (!completed) throw new Error("Strava sync ended before completion"); + }; + + form.addEventListener("submit", async (event: SubmitEvent) => { + event.preventDefault(); + button.disabled = true; + button.textContent = "Syncing..."; + notice.hidden = true; + error.hidden = true; + progressPanel.hidden = false; + progressCount.textContent = "0 / 0"; + progressDetails.textContent = "Preparing import..."; + try { + const response = await fetch(form.action, { + method: "POST", + body: new FormData(form), + headers: { Accept: "text/event-stream" }, + }); + if (response.redirected) { + window.location.assign(response.url); + return; + } + if (!response.ok) throw new Error((await response.text()) || `Sync failed (HTTP ${response.status})`); + if (!response.body) throw new Error("The browser does not support streaming responses"); + await consumeEvents(response); + } catch (caught) { + showError(caught instanceof Error ? caught.message : "Strava sync failed"); + } finally { + button.disabled = false; + button.textContent = "Sync rides"; + } + }); +}; + +mountSync(); |