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.