openvibers/openvibe.shared dock-timer.js

dock-timer.js

main 12.8 KB · 186 lines · javascript

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 'use strict';
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