openvibers/openvibe.shared dock-timer.js
dock-timer.js
| 1 | /** |
| 2 | * openvibe-shared/dock-timer.js: the Dock's timer (plan L48 R2; the owner, 11-01: "a reminder system… timer or however |
| 3 | * often they want"). dock.js loads it when the person's Dock state carries a timer, or when they open the timer menu. |
| 4 | * |
| 5 | * OpenVibeDockTimer.attach({ state, save, host, icon }) → { sync(), pillText(), menu(anchor), stop() } |
| 6 | * |
| 7 | * The timer lives in the Network's Dock state (network.dock-state@1 `timer`, contracts 0.271.0), so it counts down on |
| 8 | * every OpenVibe site the person has open and follows them to the next. Each page counts from the same end time; the |
| 9 | * page that sees the end rings (a soft chime, only on a page that is showing, and a notification when the person |
| 10 | * allowed them) and moves a focus timer on to its break or its next round. Every page computes that next phase the same |
| 11 | * way from the same end time, so two pages writing it at once write the same thing. Nothing spins: the pill shows the |
| 12 | * minutes, and a line fills as the time goes. |
| 13 | */ |
| 14 | (function (root) { |
| 15 | ; |
| 16 | if (typeof document === 'undefined' || root.OpenVibeDockTimer) return; |
| 17 | |
| 18 | const PRESETS = [[5, 'timer'], [10, 'timer'], [25, 'timer']]; |
| 19 | const CSS = ` |
| 20 | .ovdk-timer { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 10px 12px 12px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); } |
| 21 | .ovdk-timer-t { min-width: 0; display: grid; gap: 1px; } |
| 22 | .ovdk-timer-t b { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: .01em; line-height: 1.1; } |
| 23 | .ovdk-timer-t span { color: var(--text-muted, #8b8b96); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 24 | .ovdk-timer-acts { display: flex; gap: 6px; } |
| 25 | .ovdk-timer-acts button, .ovdk-tmenu button { min-height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; } |
| 26 | .ovdk-timer-acts button:hover, .ovdk-tmenu button:hover { border-color: var(--accent, #3b82f6); } |
| 27 | .ovdk-timer-bar { grid-column: 1 / -1; height: 4px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #ececf1) 9%, transparent); overflow: hidden; } |
| 28 | .ovdk-timer-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--accent, #3b82f6); transition: width 1s linear; } |
| 29 | .ovdk-timer[data-kind="break"] .ovdk-timer-bar i { background: #22c55e; } |
| 30 | .ovdk-timer.is-done b { color: var(--accent, #3b82f6); } |
| 31 | .ovdk-tmenu { position: absolute; z-index: 5; right: 8px; top: 52px; width: min(300px, calc(100% - 16px)); display: grid; gap: 10px; padding: 12px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-card, var(--bg-secondary, #16161d)); box-shadow: 0 16px 40px rgba(0,0,0,.4); } |
| 32 | .ovdk-tmenu p { margin: 0; color: var(--text-muted, #8b8b96); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } |
| 33 | .ovdk-tmenu .row { display: flex; flex-wrap: wrap; gap: 6px; } |
| 34 | .ovdk-tmenu .own { display: grid; grid-template-columns: 70px minmax(0, 1fr) auto; gap: 6px; } |
| 35 | .ovdk-tmenu input { min-height: 32px; min-width: 0; box-sizing: border-box; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: inherit; font: inherit; font-size: 13px; } |
| 36 | .ovdk-tmenu .go { background: var(--accent, #3b82f6); border-color: transparent; color: var(--on-accent, #fff); } |
| 37 | .ovdk-pill[data-tone="timer"] .ovdk-t { font-variant-numeric: tabular-nums; } |
| 38 | .ovdk-pill[data-tone="timer-done"] { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); } |
| 39 | `; |
| 40 | const pad = (n) => String(n).padStart(2, '0'); |
| 41 | const left = (ms) => { |
| 42 | const s = Math.max(0, Math.ceil(ms / 1000)); |
| 43 | const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60; |
| 44 | return h ? `${h}:${pad(m)}:${pad(r)}` : `${m}:${pad(r)}`; |
| 45 | }; |
| 46 | const iso = (ms) => new Date(ms).toISOString(); |
| 47 | |
| 48 | /** What comes after a timer that has ended: the break after a focus round, the next round, or nothing. */ |
| 49 | function nextOf(t) { |
| 50 | if (!t || !t.rounds || t.kind === 'timer') return null; |
| 51 | const end = Date.parse(t.ends_at); |
| 52 | const round = t.round || 1; |
| 53 | if (t.kind === 'focus' && round < t.rounds) return { ...t, kind: 'break', started_at: iso(end), ends_at: iso(end + (t.break_min || 5) * 60000) }; |
| 54 | if (t.kind === 'break') return { ...t, kind: 'focus', round: round + 1, started_at: iso(end), ends_at: iso(end + (t.focus_min || 25) * 60000) }; |
| 55 | return null; |
| 56 | } |
| 57 | const titleOf = (t) => (t.kind === 'break' ? 'Break' : t.kind === 'focus' ? `Focus${t.rounds > 1 ? ` ${t.round || 1} of ${t.rounds}` : ''}` : 'Timer'); |
| 58 | |
| 59 | function chime() { |
| 60 | try { |
| 61 | const A = root.AudioContext || root.webkitAudioContext; |
| 62 | if (!A) return; |
| 63 | const ctx = new A(); |
| 64 | [[660, 0], [880, 0.22], [990, 0.44]].forEach(([f, at]) => { |
| 65 | const o = ctx.createOscillator(), g = ctx.createGain(); |
| 66 | o.type = 'sine'; o.frequency.value = f; |
| 67 | g.gain.setValueAtTime(0.0001, ctx.currentTime + at); |
| 68 | g.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + at + 0.02); |
| 69 | g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + 0.5); |
| 70 | o.connect(g).connect(ctx.destination); |
| 71 | o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + 0.55); |
| 72 | }); |
| 73 | setTimeout(() => { try { ctx.close(); } catch { /* */ } }, 1500); |
| 74 | } catch { /* no sound here */ } |
| 75 | } |
| 76 | |
| 77 | function attach(c) { |
| 78 | const ctx = c || {}; |
| 79 | let tick = 0, rang = '', doneUntil = 0, doneLabel = ''; |
| 80 | const now = () => Date.now(); |
| 81 | const timer = () => { const t = (ctx.state() || {}).timer; return t && Date.parse(t.ends_at) ? t : null; }; |
| 82 | const style = () => { |
| 83 | const st = ctx.host && ctx.host() && ctx.host().ownerDocument.getElementById('ovdk-styles') || document.getElementById('ovdk-styles'); |
| 84 | if (st && !st.textContent.includes('.ovdk-timer {')) st.textContent += CSS; |
| 85 | }; |
| 86 | |
| 87 | function ended(t) { |
| 88 | if (rang === t.ends_at) return; |
| 89 | rang = t.ends_at; |
| 90 | const next = nextOf(t); |
| 91 | const said = next ? (next.kind === 'break' ? `Focus done: a ${next.break_min || 5}-minute break` : `Break over: focus ${next.round} of ${next.rounds}`) : `${t.label || titleOf(t)}: time's up`; |
| 92 | if (!document.hidden) chime(); |
| 93 | try { if (root.Notification && Notification.permission === 'granted' && !document.hidden) new Notification('OpenVibe', { body: said, tag: `ov-timer-${t.ends_at}` }); } catch { /* */ } |
| 94 | doneUntil = now() + 60000; doneLabel = said; |
| 95 | ctx.save({ timer: next }); |
| 96 | } |
| 97 | |
| 98 | function paintStrip() { |
| 99 | const host = ctx.host && ctx.host(); |
| 100 | if (!host) return; |
| 101 | const body = host.querySelector('.ovdk-body'); |
| 102 | let strip = host.querySelector('.ovdk-timer'); |
| 103 | const t = timer(); |
| 104 | const done = !t && now() < doneUntil; |
| 105 | if (!t && !done) { if (strip) strip.remove(); return; } |
| 106 | if (!strip) { |
| 107 | strip = document.createElement('div'); strip.className = 'ovdk-timer'; strip.setAttribute('role', 'timer'); strip.setAttribute('aria-live', 'off'); |
| 108 | strip.innerHTML = '<div class="ovdk-timer-t"><b></b><span></span></div><div class="ovdk-timer-acts"><button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button></div><div class="ovdk-timer-bar"><i></i></div>'; |
| 109 | strip.addEventListener('click', (e) => { |
| 110 | const b = e.target.closest('[data-t]'); if (!b) return; |
| 111 | const cur = timer(); |
| 112 | if (b.dataset.t === 'stop') { doneUntil = 0; ctx.save({ timer: null }); } |
| 113 | if (b.dataset.t === 'more' && cur) ctx.save({ timer: { ...cur, ends_at: iso(Math.max(Date.parse(cur.ends_at), now()) + 5 * 60000) } }); |
| 114 | if (b.dataset.t === 'ok') { doneUntil = 0; } |
| 115 | paintStrip(); |
| 116 | }); |
| 117 | if (body) body.parentNode.insertBefore(strip, body); else host.appendChild(strip); |
| 118 | } |
| 119 | strip.classList.toggle('is-done', done); |
| 120 | strip.dataset.kind = t ? t.kind : 'done'; |
| 121 | strip.querySelector('b').textContent = t ? left(Date.parse(t.ends_at) - now()) : 'Done'; |
| 122 | strip.querySelector('span').textContent = t ? `${titleOf(t)}${t.label ? ` · ${t.label}` : ''}` : doneLabel; |
| 123 | const total = t ? Date.parse(t.ends_at) - Date.parse(t.started_at) : 1; |
| 124 | strip.querySelector('.ovdk-timer-bar i').style.width = t ? `${Math.min(100, Math.max(0, 100 * (now() - Date.parse(t.started_at)) / Math.max(1, total)))}%` : '100%'; |
| 125 | const acts = strip.querySelector('.ovdk-timer-acts'); |
| 126 | acts.innerHTML = t ? '<button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button>' : '<button type="button" data-t="ok">OK</button>'; |
| 127 | } |
| 128 | |
| 129 | function step() { |
| 130 | const t = timer(); |
| 131 | if (t && now() >= Date.parse(t.ends_at)) ended(t); |
| 132 | paintStrip(); |
| 133 | if (ctx.repaint) ctx.repaint(); |
| 134 | if (!timer() && now() >= doneUntil) { clearInterval(tick); tick = 0; } |
| 135 | } |
| 136 | function sync() { |
| 137 | style(); |
| 138 | if ((timer() || now() < doneUntil) && !tick) tick = setInterval(step, 1000); |
| 139 | step(); |
| 140 | } |
| 141 | /** The pill's words while a timer runs (null when there is none): minutes left, or what just ended. */ |
| 142 | function pillText() { |
| 143 | const t = timer(); |
| 144 | if (t) return { text: left(Date.parse(t.ends_at) - now()), tone: 'timer', label: `${titleOf(t)}${t.label ? `, ${t.label}` : ''}: ${left(Date.parse(t.ends_at) - now())} left` }; |
| 145 | if (now() < doneUntil) return { text: "Time's up", tone: 'timer-done', label: doneLabel }; |
| 146 | return null; |
| 147 | } |
| 148 | function start(minutes, kind, label, rounds) { |
| 149 | const m = Math.max(1, Math.min(180, Math.round(Number(minutes) || 0))); |
| 150 | const at = now(); |
| 151 | const t = { kind, started_at: iso(at), ends_at: iso(at + m * 60000), ...(label ? { label: String(label).slice(0, 60) } : {}) }; |
| 152 | if (kind === 'focus') Object.assign(t, { round: 1, rounds: rounds || 4, focus_min: m, break_min: 5 }); |
| 153 | rang = ''; doneUntil = 0; |
| 154 | // Asked once, at the moment the person starts one (a click): may the end show as a notification? |
| 155 | try { if (root.Notification && Notification.permission === 'default') Notification.requestPermission().catch(() => {}); } catch { /* */ } |
| 156 | ctx.save({ timer: t }); |
| 157 | sync(); |
| 158 | } |
| 159 | function menu(anchor) { |
| 160 | style(); |
| 161 | const host = ctx.host && ctx.host(); |
| 162 | if (!host) return; |
| 163 | let m = host.querySelector('.ovdk-tmenu'); |
| 164 | if (m) { m.remove(); return; } |
| 165 | m = document.createElement('div'); m.className = 'ovdk-tmenu'; m.setAttribute('role', 'dialog'); m.setAttribute('aria-label', 'Start a timer'); |
| 166 | m.innerHTML = `<p>Timer</p><div class="row">${PRESETS.map(([n]) => `<button type="button" data-min="${n}">${n} min</button>`).join('')}</div> |
| 167 | <p>Focus</p><div class="row"><button type="button" data-focus="25">25 min, 5 min break, ×4</button><button type="button" data-focus="50">50 / 5, ×2</button></div> |
| 168 | <p>Your own</p><div class="own"><input type="number" min="1" max="180" value="15" aria-label="Minutes" data-own-min><input type="text" maxlength="60" placeholder="What for (optional)" aria-label="What it is for" data-own-label><button type="button" class="go" data-own>Start</button></div>`; |
| 169 | m.addEventListener('click', (e) => { |
| 170 | const b = e.target.closest('button'); if (!b) return; |
| 171 | if (b.dataset.min) start(b.dataset.min, 'timer'); |
| 172 | else if (b.dataset.focus) start(b.dataset.focus, 'focus', '', b.dataset.focus === '50' ? 2 : 4); |
| 173 | else if (b.hasAttribute('data-own')) start(m.querySelector('[data-own-min]').value, 'timer', m.querySelector('[data-own-label]').value.trim()); |
| 174 | else return; |
| 175 | m.remove(); |
| 176 | }); |
| 177 | m.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); m.remove(); if (anchor) anchor.focus(); } }); |
| 178 | host.appendChild(m); |
| 179 | const first = m.querySelector('button'); if (first) first.focus(); |
| 180 | } |
| 181 | return { sync, pillText, menu, start, stop: () => { clearInterval(tick); tick = 0; } }; |
| 182 | } |
| 183 | |
| 184 | root.OpenVibeDockTimer = { attach, nextOf }; |
| 185 | })(typeof window !== 'undefined' ? window : globalThis); |
| 186 |