PageSourceSearch

https://slowdownpractice.com/assets/controllers/practice_timer_controller-dcf80d30.js

js slowdownpractice.com collected 2026-10-02 00:57:07 UTC 5,997 bytes, 197 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3// Explicit practice timer: user clicks Start, a stopwatch runs, click Stop to
4// log the elapsed time. Logs once per session via POST to the configured URL.
5// On page hide while running, sends a best-effort beacon so a closed tab
6// doesn't lose the session.
7export default class extends Controller {
8  static targets = [
9    "display",
10    "startLabel",
11    "runningLabel",
12    "status",
13    "button",
14    // The control that opens the panel the timer lives in, when it lives in
15    // one. It carries the recording dot, because a timer folded away behind an
16    // icon has to say it is running without being opened.
17    "toggle",
18  ];
19  static values = {
20    url: String,
21    maxSeconds: { type: Number, default: 14400 }, // 4 hours
22    autostart: { type: Boolean, default: false },
23  };
24  static classes = ["idle", "running"];
25
26  connect() {
27    this.running = false;
28    this.startedAt = null;
29    this.boundBeforeUnload = () => this.flushBeacon();
30    // pagehide covers full page reloads/closes; turbo:before-visit covers
31    // in-app navigation via Turbo that doesn't fire pagehide.
32    window.addEventListener("pagehide", this.boundBeforeUnload);
33    document.addEventListener("turbo:before-visit", this.boundBeforeUnload);
34    if (this.autostartValue) this.start();
35  }
36
37  disconnect() {
38    window.removeEventListener("pagehide", this.boundBeforeUnload);
39    document.removeEventListener("turbo:before-visit", this.boundBeforeUnload);
40    this.stopTicking();
41  }
42
43  toggle(event) {
44    event.preventDefault();
45    if (this.running) this.stop();
46    else this.start();
47  }
48
49  start() {
50    this.running = true;
51    this.startedAt = Date.now();
52    this.applyRunning(true);
53    this.setStatus("");
54    this.render();
55    this.tickId = window.setInterval(() => this.render(), 1000);
56  }
57
58  stop() {
59    if (!this.running) return;
60    const seconds = this.elapsedSeconds();
61    this.running = false;
62    this.stopTicking();
63    this.applyRunning(false);
64    this.resetDisplay();
65    if (seconds > 0) {
66      this.sendLog(seconds)
67        .then((logged) => {
68          if (logged === 0) {
69            this.setStatus("Daily limit reached");
70          } else if (logged != null && logged < seconds) {
71            this.setStatus(`Logged ${formatLogged(logged)} (daily cap)`);
72          } else {
73            this.setStatus(`Logged ${formatLogged(logged ?? seconds)}`);
74          }
75        })
76        .catch(() => this.setStatus("Couldn't save session"));
77    }
78  }
79
80  flushBeacon() {
81    if (!this.running) return;
82    const seconds = this.elapsedSeconds();
83    if (seconds <= 0 || !this.urlValue) return;
84    const token = document
85      .querySelector('meta[name="csrf-token"]')
86      ?.getAttribute("content");
87    if (navigator.sendBeacon) {
88      const fd = new FormData();
89      fd.append("practice_seconds", seconds);
90      if (token) fd.append("authenticity_token", token);
91      navigator.sendBeacon(this.urlValue, fd);
92    }
93    this.running = false;
94  }
95
96  elapsedSeconds() {
97    if (!this.startedAt) return 0;
98    return Math.min(
99      Math.floor((Date.now() - this.startedAt) / 1000),
100      this.maxSecondsValue,
101    );
102  }
103
104  stopTicking() {
105    if (this.tickId) {
106      clearInterval(this.tickId);
107      this.tickId = null;
108    }
109  }
110
111  render() {
112    if (!this.hasDisplayTarget) return;
113    this.displayTarget.textContent = formatTime(this.elapsedSeconds());
114  }
115
116  resetDisplay() {
117    if (this.hasDisplayTarget) this.displayTarget.textContent = "";
118  }
119
120  applyRunning(running) {
121    if (this.hasStartLabelTarget) {
122      this.startLabelTarget.classList.toggle("hidden", running);
123      this.startLabelTarget.classList.toggle("inline-flex", !running);
124    }
125    if (this.hasRunningLabelTarget) {
126      this.runningLabelTarget.classList.toggle("hidden", !running);
127      this.runningLabelTarget.classList.toggle("inline-flex", running);
128    }
129    const target = this.hasButtonTarget ? this.buttonTarget : this.element;
130    if (this.hasIdleClass) {
131      this.idleClasses.forEach((c) => target.classList.toggle(c, !running));
132    }
133    if (this.hasRunningClass) {
134      this.runningClasses.forEach((c) => target.classList.toggle(c, running));
135    }
136    if (this.hasToggleTarget) {
137      this.toggleTarget.classList.toggle("action-bar-recording", running);
138      this.toggleTarget.setAttribute(
139        "title",
140        running ? "Practice time (recording)" : "Practice time",
141      );
142    }
143  }
144
145  setStatus(message) {
146    if (!this.hasStatusTarget) return;
147    this.statusTarget.textContent = message;
148    if (message) {
149      clearTimeout(this.statusTimeoutId);
150      this.statusTimeoutId = window.setTimeout(() => {
151        this.statusTarget.textContent = "";
152      }, 4000);
153    }
154  }
155
156  async sendLog(seconds) {
157    if (!this.urlValue) return null;
158    const token = document
159      .querySelector('meta[name="csrf-token"]')
160      ?.getAttribute("content");
161    const body = new URLSearchParams({ practice_seconds: seconds });
162    const response = await fetch(this.urlValue, {
163      method: "POST",
164      headers: {
165        "Content-Type": "application/x-www-form-urlencoded",
166        "X-CSRF-Token": token || "",
167        Accept: "application/json",
168      },
169      body,
170      keepalive: true,
171    });
172    if (!response.ok) throw new Error(`Log failed: ${response.status}`);
173    // 204 No Content = server didn't record anything (e.g. daily cap hit).
174    if (response.status === 204) return 0;
175    try {
176      const json = await response.json();
177      return typeof json.logged_seconds === "number"
178        ? json.logged_seconds
179        : null;
180    } catch {
181      return null;
182    }
183  }
184}
185
186function formatTime(seconds) {
187  const m = Math.floor(seconds / 60);
188  const s = seconds % 60;
189  return `${m}:${String(s).padStart(2, "0")}`;
190}
191
192function formatLogged(seconds) {
193  if (seconds < 60) return `${seconds}s`;
194  const m = Math.floor(seconds / 60);
195  const s = seconds % 60;
196  return s === 0 ? `${m}m` : `${m}m ${s}s`;
197}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.