summaryrefslogtreecommitdiff
path: root/web/frontend/sync.ts
blob: 000dcfc991838f1664edd280686ddd266637a449 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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();