Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/tracker.js

57.4 KiB, 91 runs

created by r2519314175:1453, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — the Tracker view (tracker.js)
3 ------------------------------------------------------------
4 A DECISION-QUEUE BOARD onto Daimond's own development, which
5 is tracked as PROPOSALS on the Oregami forge repository
6 `oxedyne/daimond`. The board is four columns, left to right the
7 life of a proposal:
8
9 Awaiting you (open) a decision is wanted
10 Greenlit (accepted) taken on, with its latest activity
11 Shipped (done) out, stamped with the build it went in
12 Dropped (declined) not being done
13
14 Settling happens FROM the board: an Accept, Decline or Mark done
15 on an Awaiting-you card; a Reopen or Mark done further along. A
16 card opens in full — its statement, revisions and comments — when
17 its title is pressed.
18
19 NOT THE SOCIAL ▸ PROPOSALS CHIP. That chip (js/improve.js,
20 `DaimondImprove`) is where a user MAKES a proposal from a note and
21 votes on one, with the pull voice; this board is where the owner
22 and operators DECIDE one, with a settle voice. Same forge repo,
23 two surfaces, two stores — see dev/IMPROVE_CONTRACT.md §3a.
24
25 IT REACHES THE FORGE THROUGH THE DAIMOND GATEWAY, exactly as
26 improve.js does: every request goes to the same-origin
27 `/api/improve` route, which forwards over loopback to the forge
28 and translates a Daimond voice header (`x-daimond-voice`) into
29 the forge's own (`x-ore-voice`). READING NEEDS NO VOICE — the
30 repository is public — so a read is a bare same-origin GET.
31 `request()` below is the single place that path is decided.
32
33 VOTES ARE DARK. The replicated log does not attribute a vote, so
34 the forge answers a tally — `{for, against}` — and never a voter.
35
36 THE OWNER (AND OPERATORS) MAY SETTLE. Accepting, declining,
37 marking done or reopening is an `admin` decision, so it is offered
38 ONLY when an admin voice is held. That voice is HAND-MINTED and
39 pasted once, stored wrapped under the passphrase like the pull
40 voice in voice.js — the auto-provisioned voice improve.js gets is
41 pull-only and cannot settle. When none is held the board shows a
42 terse "add your settle voice" affordance rather than settle
43 buttons that would fail. Settle posts just the decide field —
44 `state=<word>` — and sends the admin voice as `x-daimond-voice`.
45
46 THE SHIPPED STAMP IS READ, NEVER INVENTED. An agent that ships a
47 proposal comments the real deployed build id onto it (see
48 dev/forge.mjs `ship`); the board parses that stamp out of the
49 proposal's comments and draws it, clickable to the transparency
50 log. A done proposal with no stamp yet reads "awaiting build
51 stamp" — the board never guesses an id.
52
53 Attaches one global, `window.DaimondTracker`.
54 ============================================================ */
55(function () {
56 'use strict';
57
58 // ── Saying things ──────────────────────────────────────────
59
60 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
61
62 function tOr(k, fallback, v) {
63 var s = t(k, v);
64 if (s !== k) return s;
65 if (!v) return fallback;
66 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
67 return v[name] != null ? String(v[name]) : whole;
68 });
69 }
70
71 function log(/* ...args */) {
72 try { if (window.console && console.debug) console.debug.apply(console, ['[tracker]'].concat([].slice.call(arguments))); }
73 catch (e) { /* no console */ }
74 }
75
76 // ── What this view reads, and through what ─────────────────
77 //
78 // One account, one repository: Daimond's own forge repository, the same for
79 // every reader, so none of these is a setting a person would ever change.
80 // `base` is the Daimond gateway route the requests go through, the same door
81 // improve.js uses; `configure()` lets a verifier point the whole view at a
82 // stand-in without any of the drawing code learning that it moved.
83
84 var cfg = {
85 base: '/api/improve', // the Daimond gateway, same-origin; it forwards to the forge
86 account: 'oxedyne',
87 repo: 'daimond',
88 // An in-memory admin voice, for a test or a session that already has one to
89 // hand. When empty, the wrapped store below is consulted instead. Held only
90 // in memory, never drawn, never logged.
91 voice: '',
92 };
93
94 /// Point the view at a gateway route, a repository, or an in-memory admin
95 /// voice. Any field left out keeps what it had.
96 function configure(next) {
97 if (!next || typeof next !== 'object') return cfg;
98 if (typeof next.base === 'string') cfg.base = next.base;
99 if (typeof next.account === 'string') cfg.account = next.account;
100 if (typeof next.repo === 'string') cfg.repo = next.repo;
101 if (typeof next.voice === 'string') cfg.voice = next.voice;
102 return cfg;
103 }
104
105 // ── The settle voice (hand-minted admin) ───────────────────
106 //
107 // Held wrapped under the passphrase, the same shape voice.js keeps the pull
108 // voice in, and for the same reasons: a secret at rest is sealed, and reading
109 // it needs an unlocked identity. This view keeps its OWN store because the
110 // admin voice is a DIFFERENT voice from the pull one improve.js provisions —
111 // the pull voice may not settle, so it cannot stand in here.
112
113 var ADMIN_LS = 'daimond-tracker-admin'; // namespaced per account by accounts.js, like other daimond-* keys
114 var ADMIN_MIN = 16; // a truncated paste is caught where the person can still see it
115 var ADMIN_V = 1;
116
117 function adminRec() {
118 try { return JSON.parse(localStorage.getItem(ADMIN_LS) || 'null'); }
119 catch (e) { return null; }
120 }
121
122 /// Is an admin voice held on this device? Presence only; reading it needs the
123 /// passphrase, asked at the moment of a settle.
124 function adminHas() {
125 var r = adminRec();
126 return !!(r && typeof r.s === 'string' && r.s);
127 }
128
129 /// Trim the label the forge prints around a minted secret, so a pasted line
130 /// stores as the secret alone.
131 function tidy(secret) { return String(secret == null ? '' : secret).trim(); }
132
133 /// What is wrong with a pasted secret, or '' — for validating as it is typed.
134 function adminCheck(secret) {
135 var s = tidy(secret);
136 if (!s) return tOr('tracker.admin_empty', 'Paste your settle voice.');
137 if (s.length < ADMIN_MIN) return tOr('tracker.admin_short', 'That looks too short to be a whole voice.');
138 return '';
139 }
140
141 /// Hold the admin voice, wrapped under the passphrase. Throws the sentence a
142 /// person should read: the secret is invalid, or the identity is locked.
143 async function adminSet(secret) {
144 var why = adminCheck(secret);
145 if (why) throw new Error(why);
146 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
147 throw new Error(tOr('tracker.admin_locked',
148 'Unlock Daimond first: your settle voice is kept encrypted under your passphrase.'));
149 }
150 var wrapped = await DaimondIdentity.wrap(tidy(secret));
151 try { localStorage.setItem(ADMIN_LS, JSON.stringify({ v: ADMIN_V, s: wrapped, at: Date.now() })); }
152 catch (e) { throw new Error(tOr('tracker.admin_store', 'That voice could not be stored on this device.')); }
153 }
154
155 /// Forget the admin voice, destructively. The record goes, not a flag beside it.
156 function adminClear() {
157 try { localStorage.removeItem(ADMIN_LS); } catch (e) { /* private mode: nothing stored */ }
158 }
159
160 /// The admin secret in the clear, for the length of one settle, or '' when
161 /// none is held. `cfg.voice` wins when set — a test or a session that already
162 /// holds one to hand. Nothing caches the plaintext; each call unwraps afresh.
163 async function adminSecret() {
164 if (cfg.voice) return cfg.voice;
165 var r = adminRec();
166 if (!r || !r.s) return '';
167 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
168 throw new Error(tOr('tracker.admin_locked_send',
169 'Unlock Daimond to settle: your settle voice is encrypted under your passphrase.'));
170 }
171 try { return await DaimondIdentity.unwrap(r.s); }
172 catch (e) {
173 throw new Error(tOr('tracker.admin_unreadable',
174 'Your settle voice cannot be read with this passphrase. Set it again.'));
175 }
176 }
177
178 /// Whether the settle controls are offered: an admin voice held here or one
179 /// handed in. Presence only, and nothing about its value.
180 function canSettle() { return !!cfg.voice || adminHas(); }
181
182 // ── The pull voice (an ordinary reader's) ──────────────────────
183 //
184 // A DIFFERENT VOICE from the settle one above. The pull voice may vote and
185 // comment but not settle; it is the auto-provisioned voice the Social panel
186 // (js/improve.js) holds, and reading it, posting with it and provisioning it
187 // are all DaimondImprove's job. The board never grows its own copy of any of
188 // that -- it asks whether one is held, and sends votes and comments through
189 // DaimondImprove's own doors. Reading the board still needs no voice at all.
190
191 /// Is a pull voice held on this device? Never throws: an absent or misbehaving
192 /// DaimondImprove is read as "no voice", so the board offers the "set a voice"
193 /// affordance rather than a control the forge would refuse.
194 function pullVoice() {
195 try { return !!(window.DaimondImprove && DaimondImprove.hasVoice && DaimondImprove.hasVoice()); }
196 catch (e) { return false; }
197 }
198
199 /// The Social panel's vote door, or null when there is no capture surface.
200 function voteDoor() {
201 try {
202 var d = window.DaimondImprove && DaimondImprove.forge && DaimondImprove.forge.vote;
203 return (typeof d === 'function') ? d : null;
204 } catch (e) { return null; }
205 }
206
207 /// The Social panel's comment door, or null.
208 function sayDoor() {
209 try {
210 var d = window.DaimondImprove && DaimondImprove.forge && DaimondImprove.forge.say;
211 return (typeof d === 'function') ? d : null;
212 } catch (e) { return null; }
213 }
214
215 // ── The settle voice through a passphrase change ───────────────
216 //
217 // Sealed under the passphrase, so a change to it must read the voice out under
218 // the old one and put it back under the new — the same two phases voice.js runs
219 // for the pull voice. Without this the admin voice would be left wrapped to a
220 // key that no longer exists, and lost. The `at` stamp is kept: this is a
221 // re-wrapping, not a fresh paste.
222
223 var heldAdmin = null; // plaintext, ONLY for the length of one change
224
225 /// Read the admin voice out under the OLD passphrase, before the key changes.
226 async function readForRekey() {
227 heldAdmin = null;
228 if (!adminHas()) return { held: 0, failed: [] };
229 var r = adminRec();
230 try { heldAdmin = await DaimondIdentity.unwrap(r.s); }
231 catch (e) { return { held: 0, failed: [tOr('tracker.the_settle_voice', 'your settle voice')] }; }
232 if (!heldAdmin) return { held: 0, failed: [] };
233 return { held: 1, failed: [] };
234 }
235
236 /// Put it back under the NEW passphrase, and forget it either way. Wrapped
237 /// directly rather than through `adminSet`, which re-validates: a voice stored
238 /// by an older build must survive a change rather than be dropped by it.
239 async function resealForRekey() {
240 if (!heldAdmin) return { failed: [] };
241 var failed = [];
242 try {
243 var r = adminRec();
244 var when = (r && typeof r.at === 'number') ? r.at : Date.now();
245 localStorage.setItem(ADMIN_LS, JSON.stringify({
246 v: ADMIN_V, s: await DaimondIdentity.wrap(heldAdmin), at: when,
247 }));
248 } catch (e) { failed.push(tOr('tracker.the_settle_voice', 'your settle voice')); }
249 finally { heldAdmin = null; } // in the clear; never held past here
250 return { failed: failed };
251 }
252
253 /// Drop the plaintext unused, for a change that did not happen.
254 function forgetRekey() { heldAdmin = null; }
255
256 if (window.DaimondRekey) {
257 DaimondRekey.register({
258 name: 'settle',
259 read: readForRekey,
260 reseal: resealForRekey,
261 forget: forgetRekey,
262 /// One secret, so the list is not named: there is only ever one settle
263 /// voice on a device.
264 sentence: function (kind) {
265 return kind === 'unread'
266 ? tOr('changepass.settle_not_unsealed',
267 'Your settle voice could not be read under the old passphrase, so it '
268 + 'still needs pasting again from the line the forge printed for you.')
269 : tOr('changepass.settle_not_resealed',
270 'Your settle voice could not be re-encrypted under the new passphrase. '
271 + 'Paste it again from the line the forge printed for you.');
272 },
273 });
274 }
275
276 // ── THE ONE DOOR ───────────────────────────────────────────
277 //
278 // Every request goes through here, and this is the single place the path is
279 // decided. It mirrors improve.js: the account and repository ride in the
280 // QUERY on the same-origin `/api/improve` route; a READ carries no voice — the
281 // repository is public; a settle carries the admin voice in `x-daimond-voice`,
282 // which the gateway translates to the forge's `x-ore-voice`. A voiced write
283 // goes through `DaimondGateway.gwFetch` — the one copy of the session rule —
284 // when the gateway module is present; a plain read is a bare `fetch`.
285
286 /// The route, with the repository this view reads. Built here and nowhere
287 /// else, and the voice is never in it: a query string is written into every
288 /// access log it passes.
289 function route(extra) {
290 var q = 'account=' + encodeURIComponent(cfg.account) + '&repo=' + encodeURIComponent(cfg.repo);
291 return String(cfg.base || '/api/improve') + '?' + q + (extra ? '&' + extra : '');
292 }
293
294 /// One exchange, read the way this view needs it: `{ ok, data }` on success,
295 /// or `{ ok:false, why, because, status }` where `why` is the forge's stable
296 /// token, `gateway` for a refusal with no token, or `offline` for no answer.
297 ///
298 /// `voiceSecret` is the admin voice for a settle, or falsy for a read.
299 async function request(path, opts, voiceSecret) {
300 var o = Object.assign({}, opts || {});
301 o.headers = Object.assign({}, (opts && opts.headers) || {});
302 var g = window.DaimondGateway;
303 var useGw = false;
304 if (voiceSecret) {
305 o.headers['x-daimond-voice'] = voiceSecret;
306 if (g && g.gwFetch) {
307 useGw = true;
308 o.headers['x-daimond-api'] = String(g.clientApi ? g.clientApi() : '');
309 o.credentials = 'same-origin';
310 }
311 }
312 var r;
313 try {
314 r = useGw ? await g.gwFetch(path, o) : await fetch(path, o);
315 } catch (e) {
316 return { ok: false, why: 'offline' };
317 }
318 var text = '';
319 try { text = await r.text(); } catch (e) { text = ''; }
320 var data = null;
321 try { data = text ? JSON.parse(text) : null; } catch (e) { data = null; }
322 if (data && typeof data === 'object' && typeof data.error === 'string' && TOKENS[data.error]) {
323 return {
324 ok: false,
325 why: data.error,
326 because: (typeof data.because === 'string' && BECAUSE[data.because]) ? data.because : '',
327 status: r.status,
328 };
329 }
330 if (r.ok && data && typeof data === 'object') return { ok: true, data: data };
331 return { ok: false, why: 'gateway', status: r.status };
332 }
333
334 var TOKENS = {
335 absent: 1, unvoiced: 1, unknown: 1, unpermitted: 1, throttled: 1,
336 malformed: 1, no_proposal: 1, unsupported: 1, internal: 1,
337 };
338 var BECAUSE = { address: 1, voice: 1, failing: 1 };
339
340 /// What a refusal says on screen. `absent` covers "no such repository" and
341 /// "this repository is private" alike — true in both, leaks in neither.
342 function saying(a) {
343 if (!a) return tOr('tracker.err_offline', 'Nothing could be read just now.');
344 switch (a.why) {
345 case 'absent': return tOr('tracker.err_absent', 'This repository is not available.');
346 case 'unvoiced': return tOr('tracker.err_unvoiced', 'The forge was given no voice.');
347 case 'unknown': return tOr('tracker.err_unknown', 'The forge does not recognise that voice.');
348 case 'unpermitted': return tOr('tracker.err_unpermitted', 'That voice may not settle proposals here.');
349 case 'throttled':
350 if (a.because === 'address') return tOr('tracker.err_throttled_address', 'Too many requests from this address. Wait, then try again.');
351 return tOr('tracker.err_throttled', 'Too many requests just now. Wait, then try again.');
352 case 'malformed': return tOr('tracker.err_malformed', 'The forge could not read that request.');
353 case 'no_proposal': return tOr('tracker.err_no_proposal', 'There is no such proposal.');
354 case 'unsupported': return tOr('tracker.err_unsupported', 'The forge does not answer that.');
355 case 'internal': return tOr('tracker.err_internal', 'Something went wrong at the forge.');
356 case 'gateway':
357 if (a.status === 401) return tOr('tracker.err_session', 'Not signed in, so the forge could not be reached.');
358 return tOr('tracker.err_gateway', 'The forge could not be reached just now.');
359 default: return tOr('tracker.err_offline', 'Nothing could be read just now.');
360 }
361 }
362
363 // ── The record ─────────────────────────────────────────────
364
365 var STATES = { open: 1, accepted: 1, declined: 1, done: 1 };
366
367 function whole(v) { return (typeof v === 'number' && isFinite(v)) ? Math.floor(v) : 0; }
368
369 function clean(p) {
370 if (!p || typeof p !== 'object') return null;
371 var n = whole(p.number);
372 if (n < 1) return null;
373 var rec = {
374 n: n,
375 title: (typeof p.title === 'string') ? p.title : '',
376 state: STATES[p.state] ? p.state : 'open',
377 author: (typeof p.author === 'string') ? p.author : '',
378 comments: Math.max(0, whole(p.comments)),
379 opened: Math.max(0, whole(p.opened)),
380 changed: (typeof p.changed === 'number') ? Math.max(0, whole(p.changed)) : null,
381 mark: (typeof p.mark === 'string') ? p.mark : '',
382 build: (typeof p.build === 'string') ? p.build : '',
383 body: (typeof p.body === 'string') ? p.body : '',
384 detail: false,
385 // A TALLY, never a voter. Two counts, and nothing that says who.
386 votes: null,
387 // THIS ASKER'S OWN VOTE, present only when the answer was voiced. A board
388 // READ carries no voice, so a listing card has neither -- the upvote is
389 // drawn unpressed until this device casts one, whose voiced answer carries
390 // them. Presence first, value second, the same rule improve.js's cleanProp
391 // keeps: `asked` absent is "no voice asked", not "asked and did not vote".
392 asked: false,
393 mine: null,
394 revisions: null,
395 discussion: null,
396 // Set from the discussion once it is read: the last comment, drawn on a
397 // Greenlit card as its latest activity, and the shipped build id parsed
398 // out of the ship stamp. Never from the wire — the board derives them.
399 latest: null,
400 shipped: '',
401 enriched: false,
402 };
403 if (p.votes && typeof p.votes === 'object' && !Array.isArray(p.votes)) {
404 rec.votes = { for: Math.max(0, whole(p.votes.for)), against: Math.max(0, whole(p.votes.against)) };
405 }
406 if (Object.prototype.hasOwnProperty.call(p, 'mine')) {
407 rec.asked = true;
408 rec.mine = (p.mine === 1 || p.mine === -1) ? p.mine : null;
409 }
410 if (typeof p.body === 'string') rec.detail = true;
411 if (Array.isArray(p.discussion)) {
412 rec.detail = true;
413 rec.discussion = p.discussion.map(function (d) {
414 return {
415 author: (d && typeof d.author === 'string') ? d.author : '',
416 said: (d && typeof d.said === 'string') ? d.said : '',
417 when: Math.max(0, whole(d && d.when)),
418 };
419 });
420 }
421 if (Array.isArray(p.revisions)) {
422 rec.revisions = p.revisions.map(function (r) {
423 return {
424 title: (r && typeof r.title === 'string') ? r.title : '',
425 body: (r && typeof r.body === 'string') ? r.body : '',
426 when: Math.max(0, whole(r && r.when)),
427 };
428 });
429 }
430 return rec;
431 }
432
433 // ── The shipped-build stamp ────────────────────────────────
434 //
435 // An agent that ships a proposal comments the real deployed build id onto it,
436 // in a fixed line (dev/forge.mjs `ship`): "Shipped in build <id>". The board
437 // parses that id out of the proposal's comments. It reads the id; it never
438 // invents one, so a done proposal that has not been stamped shows no id at all.
439
440 var SHIP_RE = /shipped in build\s+`?([0-9a-f]{8,40})`?/i;
441
442 /// The shipped build id from a discussion, or '' when none is stamped. The
443 /// LAST stamp wins, so a re-ship supersedes an earlier one.
444 function parseShip(discussion) {
445 if (!Array.isArray(discussion)) return '';
446 for (var i = discussion.length - 1; i >= 0; i--) {
447 var said = discussion[i] && discussion[i].said;
448 var m = (typeof said === 'string') ? SHIP_RE.exec(said) : null;
449 if (m) return m[1];
450 }
451 return '';
452 }
453
454 /// Derive the latest comment and the shipped id from a record's own discussion,
455 /// once it has been read. Marks the record enriched so it is not fetched again.
456 function deriveFrom(n) {
457 var p = _by[n];
458 if (!p || !Array.isArray(p.discussion)) return;
459 p.latest = p.discussion.length ? p.discussion[p.discussion.length - 1] : null;
460 p.shipped = parseShip(p.discussion);
461 p.enriched = true;
462 }
463
464 // ── The store ──────────────────────────────────────────────
465
466 var PAGE = 50;
467
468 var _by = {};
469 var _order = [];
470 var _open = null;
471 var _st = { total: 0, loading: false, err: null, read: false };
472 var _voiceOpen = false; // the admin-voice paste form is showing
473 var _filter = 'all'; // board filter: 'all', or 'mine' (raised from this device)
474 var _lastLoad = 0; // when the listing last loaded, so a re-show refetches a stale board
475
476 function absorb(rec) {
477 if (!rec) return null;
478 var cur = _by[rec.n];
479 if (cur && !rec.detail) {
480 rec.body = cur.body;
481 rec.discussion = cur.discussion;
482 rec.revisions = cur.revisions;
483 rec.detail = cur.detail;
484 // Carry the derived board fields forward, so a listing refresh does not
485 // blank a card's shipped stamp or latest-activity line.
486 rec.latest = cur.latest;
487 rec.shipped = cur.shipped;
488 rec.enriched = cur.enriched;
489 }
490 // KEEP THIS ASKER'S OWN VOTE across an UNVOICED re-read. An enrich or a
491 // listing refresh reads with no voice, so its record carries no `mine`; the
492 // upvote a person just cast (whose voiced answer did) must not be forgotten
493 // because the board looked again without a voice. Presence, not value: only
494 // an answer that actually carried `mine` replaces it.
495 if (cur && cur.asked && !rec.asked) { rec.asked = cur.asked; rec.mine = cur.mine; }
496 if (!cur) _order.push(rec.n);
497 _by[rec.n] = rec;
498 return rec;
499 }
500
501 // ── Reading ────────────────────────────────────────────────
502
503 /// Read the listing, newest first. From the top only: this board shows recent
504 /// development at a glance and opens one proposal in full, rather than paging
505 /// the whole history the Social panel walks.
506 async function load() {
507 if (_st.loading) return false;
508 _st.loading = true;
509 _st.err = null;
510 draw();
511 var a = await request(route('limit=' + PAGE), { method: 'GET' });
512 _st.loading = false;
513 // Stamp the throttle on a FAILED read too, not just a success: otherwise a
514 // forge that is erroring is refetched on every re-entry with no floor at all,
515 // and a down forge gets pounded once per panel show.
516 if (!a.ok) { _st.err = a; _lastLoad = Date.now(); draw(); return false; }
517 // Keep the pre-refetch records so a just-cast local vote survives the rebuild.
518 // The listing read is unvoiced, so `a.data` carries no `mine`/`asked`; clearing
519 // _by outright would drop a fresh upvote's highlight until the next voiced read.
520 var prev = _by;
521 _by = {}; _order = [];
522 var raw = Array.isArray(a.data.proposals) ? a.data.proposals : [];
523 raw.forEach(function (p) {
524 var rec = clean(p);
525 if (!rec) return;
526 var was = prev[rec.n];
527 if (was && was.asked && !rec.asked) { rec.asked = was.asked; rec.mine = was.mine; }
528 absorb(rec);
529 });
530 _st.total = Math.max(0, whole(a.data.total));
531 _st.read = true;
532 _lastLoad = Date.now();
533 draw();
534 return true;
535 }
536
537 /// Read one proposal in full and show it. Deriving its board fields from the
538 /// discussion in the same breath, so opening a card is also how it is enriched.
539 async function open(n) {
540 var a = await request(route('n=' + n), { method: 'GET' });
541 if (!a.ok) { _st.err = a; draw(); return false; }
542 absorb(clean(a.data));
543 deriveFrom(whole(n));
544 _open = whole(n);
545 _st.err = null;
546 draw();
547 return true;
548 }
549
550 /// A Greenlit or Shipped card wants its latest comment and its shipped stamp,
551 /// which live in the discussion and not in the listing. Read the proposal in
552 /// full, once, and derive them. A read is public and unvoiced, like every read.
553 async function enrich(n) {
554 var p = _by[n];
555 if (!p || p.enriched) return;
556 p.enriched = true; // once; a failed read does not loop
557 var a = await request(route('n=' + n), { method: 'GET' });
558 if (!a.ok) return; // leave the listing-only card as it stands
559 absorb(clean(a.data));
560 deriveFrom(n);
561 draw();
562 }
563
564 /// Back to the board.
565 function back() { _open = null; _settleAsk = null; draw(); }
566
567 // How stale a listing may be before showing the panel refetches it. Small, so a
568 // re-shown board reflects declines, greenlights and new posts made meanwhile,
569 // but enough to swallow an IntersectionObserver re-entry from a scroll.
570 var REFRESH_MS = 4000;
571
572 /// Read the listing when the panel is shown. The FIRST show reads it (a panel
573 /// nobody opened costs no request); a later show REFETCHES if the snapshot has
574 /// gone stale, because a board that reads once and freezes shows declines that
575 /// were made elsewhere still sitting in "Awaiting you".
576 function onOpen() {
577 if (_st.loading) return true;
578 if (!_st.read || Date.now() - _lastLoad >= REFRESH_MS) load();
579 return true;
580 }
581
582 // ── Settling (admin voice only) ────────────────────────────
583
584 // FOUR TOKENS AND NO MORE: state is open, accepted, declined or done. There is
585 // no "reopen" token — a Reopen sends `state=open`. Accept and decline now carry
586 // a REQUIRED one-line `reason` beside the state — folded into the accept and
587 // posted back to the proposer on a decline; done and reopen carry state alone.
588 var DECISIONS = { accept: 'accepted', decline: 'declined', done: 'done', reopen: 'open' };
589
590 // Which decisions need a reason before they can be sent, and the longest a
591 // reason may be. The forge rejects a longer one as "too long"; the input is
592 // capped here as well so a person is stopped before the round trip.
593 var NEEDS_REASON = { accept: 1, decline: 1 };
594 var REASON_LIMIT = 1000;
595
596 // The card whose accept/decline reason box is open: `{ n, which }`, or null.
597 // One at a time, so a second press replaces the first rather than stacking.
598 var _settleAsk = null;
599 var _reasonKeep = null; // a half-typed reason, kept across one redraw
600
601 /// Post the decide field for proposal `n` under the admin voice. `which` is
602 /// one of accept, decline, done, reopen — the last of which sends `state=open`.
603 /// `reason` is the required one-line note for accept and decline, and is
604 /// ignored (and never sent) for done and reopen.
605 async function settle(n, which, reason) {
606 if (!canSettle()) return false;
607 var state = DECISIONS[which];
608 if (!state) return false;
609 // A required reason that arrived empty is refused here rather than sent: the
610 // forge would reject it, but a person is told at the press instead.
611 var need = !!NEEDS_REASON[which];
612 var note = String(reason == null ? '' : reason).trim();
613 if (need && !note) { flash(tOr('tracker.reason_empty', 'Give a one-line reason first.')); return false; }
614 var secret;
615 try { secret = await adminSecret(); }
616 catch (e) { _st.err = { why: 'gateway' }; flash(String((e && e.message) || e)); return false; }
617 if (!secret) { flash(tOr('tracker.admin_need', 'Add your settle voice first.')); return false; }
618 var f = new URLSearchParams();
619 f.set('state', state);
620 if (need) f.set('reason', note);
621 var a = await request(route('n=' + n), {
622 method: 'POST',
623 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
624 body: f.toString(),
625 }, secret);
626 if (!a.ok) { _st.err = a; draw(); return false; }
627 absorb(clean(a.data));
628 deriveFrom(n);
629 _st.err = null;
630 _settleAsk = null; // the decision landed; the reason box goes with it
631 draw();
632 return true;
633 }
634
635 /// Read the reason box for card `n` and settle, or refuse an empty one. THE ONE
636 /// PLACE the required reason is enforced on this side; `settle` guards it again
637 /// so a caller that reaches it directly cannot skip the rule.
638 function confirmSettle(n, which) {
639 var inp = _host ? _host.querySelector('.trk-reason[data-prop="' + n + '"]') : null;
640 var reason = inp ? String(inp.value || '').trim() : '';
641 if (!reason) { flash(tOr('tracker.reason_empty', 'Give a one-line reason first.')); return false; }
642 return settle(n, which, reason);
643 }
644
645 // ── Voting and commenting (pull voice, through the Social panel) ──
646 //
647 // READING A TALLY OR A COMMENT NEEDS NO VOICE; casting one or saying something
648 // needs the pull voice. The board holds neither the voice nor the POST: it
649 // asks DaimondImprove whether a voice is held, and sends through its vote and
650 // comment DOORS, which carry the pull voice and speak the one copy of the wire
651 // improve.js keeps. The answer is the detail shape of the record it changed --
652 // tally, `mine` and discussion -- so the board ABSORBS it into its own store
653 // and redraws, exactly as a settle does, and never asks a second time.
654
655 /// Upvote proposal `n`, or take the upvote back. An idempotent up-vote per the
656 /// contract: the forge tallies, and this holds no second copy. `mine === 1`
657 /// means the upvote is already cast, so pressing again withdraws it (`d=0`);
658 /// otherwise it casts one (`d=1`). Down-voting is not offered on the board.
659 async function voteOn(n) {
660 if (!pullVoice()) { needVoice('vote'); return false; }
661 var door = voteDoor();
662 if (!door) { needVoice('vote'); return false; }
663 var p = _by[n];
664 if (!p || !p.votes) return false;
665 var d = (p.asked && p.mine === 1) ? 0 : 1;
666 var a;
667 try { a = await door(n, d); }
668 catch (e) { a = { ok: false, why: 'gateway' }; }
669 if (!a || !a.ok) { _st.err = a || { why: 'gateway' }; draw(); return false; }
670 absorb(clean(a.data));
671 deriveFrom(whole(n));
672 _st.err = null;
673 draw();
674 return true;
675 }
676
677 /// Say something on proposal `n`, from the reply box under its opened card. The
678 /// same one rule the note box keeps: exactly the characters in that box at the
679 /// press, nothing queued. The box is emptied only when the forge took it, so a
680 /// refusal leaves the words where they can still be read and copied.
681 async function commentOn(n) {
682 if (!pullVoice()) { needVoice('comment'); return false; }
683 var door = sayDoor();
684 if (!door) { needVoice('comment'); return false; }
685 var box = _host ? _host.querySelector('.trk-reply[data-prop="' + n + '"]') : null;
686 if (!box) return false;
687 var text = String(box.value || '').trim();
688 if (!text) { flash(tOr('tracker.say_empty', 'Write something first.')); return false; }
689 var a;
690 try { a = await door(n, text); }
691 catch (e) { a = { ok: false, why: 'gateway' }; }
692 if (!a || !a.ok) { _st.err = a || { why: 'gateway' }; draw(); return false; }
693 box.value = '';
694 absorb(clean(a.data));
695 deriveFrom(whole(n));
696 _st.err = null;
697 draw();
698 return true;
699 }
700
701 /// Send an unvoiced reader to where a voice is set. The one voice flow lives in
702 /// the Social panel (js/improve.js), so the board opens it when the layout
703 /// engine is present, and falls back to the exposed one-tap provision otherwise
704 /// -- never a vote or comment control that the forge would refuse.
705 function getVoice() {
706 try { if (window.DaimondPanels && DaimondPanels.show) { DaimondPanels.show('social'); return; } }
707 catch (e) { /* no engine: try provisioning in place */ }
708 try {
709 if (window.DaimondImprove && DaimondImprove.provision) {
710 Promise.resolve(DaimondImprove.provision()).then(function (ok) { if (ok) draw(); }, function () { /* said on its own surface */ });
711 }
712 } catch (e) { /* nothing to do */ }
713 }
714
715 /// Say a vote or comment needs a voice, and point at where one is set. Never a
716 /// dead control: the affordance is drawn inline, and this is the press path.
717 function needVoice(act) {
718 flash(act === 'comment'
719 ? tOr('tracker.say_novoice', 'Set a voice in Social to comment.')
720 : tOr('tracker.vote_novoice', 'Set a voice in Social to vote.'));
721 }
722
723 // ── Drawing ────────────────────────────────────────────────
724
725 var _host = null;
726
727 function fmtWhen(secs) {
728 if (!secs) return '';
729 var loc;
730 try { loc = window.DaimondI18n ? DaimondI18n.locale() : undefined; } catch (e) { loc = undefined; }
731 try { return new Date(secs * 1000).toLocaleDateString(loc || undefined, { day: 'numeric', month: 'short' }); }
732 catch (e) { return ''; }
733 }
734
735 function stateWord(s) {
736 if (s === 'accepted') return tOr('tracker.state_taken', 'Being done');
737 if (s === 'done') return tOr('tracker.state_done', 'Done');
738 if (s === 'declined') return tOr('tracker.state_declined', 'Declined');
739 return tOr('tracker.state_open', 'Open');
740 }
741
742 function el(tag, cls, text) {
743 var e = document.createElement(tag);
744 if (cls) e.className = cls;
745 if (text != null) e.textContent = text;
746 return e;
747 }
748
749 function button(cls, act, text, title) {
750 var b = document.createElement('button');
751 b.type = 'button';
752 b.className = cls;
753 if (act) b.dataset.act = act;
754 b.textContent = text;
755 if (title) b.title = title;
756 return b;
757 }
758
759 /// A comment cut to a card's width. The whole thing is one press away.
760 function snippet(s) {
761 var x = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
762 return x.length > 90 ? x.slice(0, 89) + '…' : x;
763 }
764
765 /// The tally, drawn as two counts and never as a name.
766 function tallyLine(p) {
767 if (!p.votes) return null;
768 return el('span', 'trk-tally', tOr('tracker.tally', '{yes} for, {no} against',
769 { yes: p.votes.for, no: p.votes.against }));
770 }
771
772 /// The upvote control: the for-count as an affordance, so a vote is cast where
773 /// the count is read. Drawn ONLY when the record carries a tally -- the same
774 /// rule improve.js's vote control keeps, gated on the answer and never on a
775 /// date. With a pull voice it is a live button that toggles; without one it is
776 /// the count beside a terse "set a voice" affordance, never a button that the
777 /// forge would refuse. `mine === 1` shows the upvote already cast.
778 function drawVote(p) {
779 if (!p.votes) return null;
780 var box = el('div', 'trk-vote');
781 if (pullVoice() && voteDoor()) {
782 var up = button('trk-vote-btn', 'tracker-vote',
783 tOr('tracker.upvotes', '▲ {n}', { n: p.votes.for }),
784 tOr('tracker.upvote_help', 'Upvote this. Press again to take it back.'));
785 up.dataset.prop = p.n;
786 var on = !!(p.asked && p.mine === 1);
787 if (on) up.classList.add('on');
788 up.setAttribute('aria-pressed', on ? 'true' : 'false');
789 box.appendChild(up);
790 } else {
791 box.appendChild(el('span', 'trk-vote-count', tOr('tracker.upvotes', '▲ {n}', { n: p.votes.for })));
792 box.appendChild(setVoiceBtn());
793 }
794 return box;
795 }
796
797 /// The reply box under an opened card: with a pull voice, a box and Say it;
798 /// without one, the count of comments is already shown above, so this offers
799 /// the set-a-voice affordance in place of a box that could not be sent.
800 function drawSay(p) {
801 var box = el('div', 'trk-say-box');
802 if (pullVoice() && sayDoor()) {
803 var ta = document.createElement('textarea');
804 ta.className = 'trk-reply';
805 ta.rows = 2;
806 ta.dataset.prop = p.n;
807 ta.placeholder = tOr('tracker.reply_ph', 'Say something about this proposal.');
808 ta.setAttribute('aria-label', tOr('tracker.reply_ph', 'Say something about this proposal.'));
809 if (_replyKeep && _replyKeep[String(p.n)]) ta.value = _replyKeep[String(p.n)];
810 box.appendChild(ta);
811 var acts = el('div', 'trk-say-acts');
812 var b = button('trk-say-btn', 'tracker-comment', tOr('tracker.say', 'Say it'),
813 tOr('tracker.say_help', 'Sends exactly this box. Nothing else.'));
814 b.dataset.prop = p.n;
815 acts.appendChild(b);
816 box.appendChild(acts);
817 } else {
818 box.appendChild(el('span', 'trk-say-novoice',
819 tOr('tracker.say_novoice', 'Set a voice in Social to comment.')));
820 box.appendChild(setVoiceBtn());
821 }
822 return box;
823 }
824
825 /// The "set a voice" affordance, shared by the vote and comment controls when
826 /// no pull voice is held.
827 function setVoiceBtn() {
828 return button('trk-setvoice', 'tracker-getvoice',
829 tOr('tracker.set_voice', 'Set a voice'),
830 tOr('tracker.set_voice_help', 'A voice lets you vote and comment. Set it in Social.'));
831 }
832
833 // ── The board ──────────────────────────────────────────────
834 //
835 // Four columns, the life of a proposal left to right. The order in `_order` is
836 // newest-first, so filtering it per column keeps each column newest-first too.
837
838 var COLUMNS = [
839 { state: 'open', key: 'col_open', label: 'Awaiting you' },
840 { state: 'accepted', key: 'col_green', label: 'Greenlit' },
841 { state: 'done', key: 'col_ship', label: 'Shipped' },
842 { state: 'declined', key: 'col_drop', label: 'Dropped' },
843 ];
844
845 // ── All / Mine ─────────────────────────────────────────────
846 //
847 // "Mine" is the proposals THIS DEVICE raised. The local voice has no name --
848 // the secret is the identity and this client never learns its own author (see
849 // voice.js) -- so "mine" CANNOT be matched against a card's `author`. It is
850 // matched by number instead: js/improve.js records, on each note it holds, the
851 // proposals a draft written from it became, and publishes the de-duped set as
852 // `DaimondImprove.raisedProposalNumbers()`. This board reads that set and shows
853 // only the numbers in it. Purely client-side: no author query rides to the forge.
854
855 /// The numbers this device raised, as a lookup, or null when there is no capture
856 /// surface to ask. Never throws: an absent or misbehaving `DaimondImprove` is
857 /// read as "nothing raised here" rather than an error on the board.
858 function raisedSet() {
859 try {
860 if (window.DaimondImprove && DaimondImprove.raisedProposalNumbers) {
861 var arr = DaimondImprove.raisedProposalNumbers();
862 if (!Array.isArray(arr)) return null;
863 var set = {};
864 arr.forEach(function (n) { var w = whole(n); if (w > 0) set[w] = 1; });
865 return set;
866 }
867 } catch (e) { /* absent or threw: nothing raised from here */ }
868 return null;
869 }
870
871 function drawBoard() {
872 // In Mine, keep only the numbers this device raised. `mine` null means the
873 // capture surface is absent -- which shows NOTHING here, not everything: Mine
874 // is an allow-list, so a card is drawn only when it is on that list.
875 var mine = (_filter === 'mine') ? raisedSet() : null;
876 var board = el('div', 'trk-board');
877 var shown = 0;
878 COLUMNS.forEach(function (col) {
879 var ns = _order.filter(function (n) {
880 if (!_by[n] || _by[n].state !== col.state) return false;
881 if (_filter === 'mine') return !!(mine && mine[n]);
882 return true;
883 });
884 shown += ns.length;
885 board.appendChild(drawColumn(col, ns));
886 });
887 // Mine with nothing to show -- nothing raised here, no capture surface, or the
888 // raised ones sit below the page this board reads -- says so rather than four
889 // empty columns.
890 if (_filter === 'mine' && shown === 0) {
891 return el('div', 'trk-none trk-mine-empty',
892 tOr('tracker.mine_empty', 'Nothing raised from this device yet.'));
893 }
894 return board;
895 }
896
897 /// The All / Mine control, above the board. Drawn only where the board is.
898 function drawFilter() {
899 var bar = el('div', 'trk-filter');
900 bar.setAttribute('role', 'group');
901 [['all', tOr('tracker.filter_all', 'All')], ['mine', tOr('tracker.filter_mine', 'Mine')]]
902 .forEach(function (pair) {
903 var b = button('trk-filter-btn', 'tracker-filter', pair[1]);
904 b.dataset.filter = pair[0];
905 var on = _filter === pair[0];
906 if (on) b.classList.add('on');
907 b.setAttribute('aria-pressed', on ? 'true' : 'false');
908 bar.appendChild(b);
909 });
910 return bar;
911 }
912
913 function drawColumn(col, ns) {
914 var c = el('div', 'trk-col');
915 c.dataset.state = col.state;
916 var head = el('div', 'trk-col-head');
917 head.appendChild(el('span', 'trk-col-label', tOr('tracker.' + col.key, col.label)));
918 head.appendChild(el('span', 'trk-col-count', String(ns.length)));
919 c.appendChild(head);
920 if (!ns.length) {
921 c.appendChild(el('div', 'trk-col-empty', tOr('tracker.col_empty', 'Nothing here.')));
922 } else {
923 ns.forEach(function (n) { c.appendChild(drawCard(_by[n], col.state)); });
924 }
925 return c;
926 }
927
928 function drawCard(p, state) {
929 var card = el('div', 'trk-card');
930 card.dataset.prop = p.n;
931
932 var top = el('div', 'trk-card-top');
933 top.appendChild(el('span', 'trk-num', '#' + p.n));
934 var title = el('button', 'trk-title', p.title || ('#' + p.n));
935 title.type = 'button';
936 title.dataset.act = 'tracker-open';
937 title.dataset.prop = p.n;
938 top.appendChild(title);
939 card.appendChild(top);
940
941 var meta = el('div', 'trk-card-meta');
942 meta.appendChild(el('span', 'trk-comments', tOr('tracker.comments', '{n} comments', { n: p.comments })));
943 var tl = tallyLine(p);
944 if (tl) meta.appendChild(tl);
945 card.appendChild(meta);
946
947 // The upvote, read and cast from the board itself. Comments are read and
948 // added in the opened card (drawDetail), the fuller surface the thread needs.
949 var vote = drawVote(p);
950 if (vote) card.appendChild(vote);
951
952 // The column's own body. Greenlit shows its latest activity; Shipped shows
953 // the build it went out in. Both need the discussion, so both enrich.
954 if (state === 'accepted') {
955 card.appendChild(drawActivity(p));
956 enrich(p.n);
957 } else if (state === 'done') {
958 card.appendChild(drawShip(p));
959 enrich(p.n);
960 }
961
962 var acts = drawSettle(p, state);
963 if (acts) card.appendChild(acts);
964 return card;
965 }
966
967 /// A Greenlit card's latest agent activity: the last comment once read, or the
968 /// count and when it last moved until then.
969 function drawActivity(p) {
970 var line = el('div', 'trk-activity');
971 if (p.latest && p.latest.said) {
972 var who = p.latest.author ? (p.latest.author + ': ') : '';
973 line.textContent = who + snippet(p.latest.said);
974 } else {
975 var parts = [tOr('tracker.comments', '{n} comments', { n: p.comments })];
976 if (p.changed) parts.push(tOr('tracker.active', 'active {when}', { when: fmtWhen(p.changed) }));
977 line.textContent = parts.join(' · ');
978 }
979 return line;
980 }
981
982 /// A Shipped card's build stamp: the real parsed id, clickable to the
983 /// transparency log, or a plain "awaiting build stamp" when none is stamped.
984 function drawShip(p) {
985 var line = el('div', 'trk-ship');
986 if (p.shipped) {
987 line.appendChild(el('span', 'trk-ship-say', tOr('tracker.shipped_in', 'Shipped in')));
988 var b = button('trk-ship-id', 'tracker-transparency', p.shipped,
989 tOr('tracker.ship_help', 'Open the transparency log.'));
990 b.dataset.build = p.shipped;
991 line.appendChild(b);
992 } else {
993 line.appendChild(el('span', 'trk-ship-say', tOr('tracker.shipped_await', 'Shipped — awaiting build stamp.')));
994 }
995 return line;
996 }
997
998 /// The settle controls for one card, or nothing when there is no admin voice.
999 /// Per column: the three forward decisions on an Awaiting-you card; Mark done
1000 /// or Reopen on a Greenlit one; Reopen on a Shipped or Dropped one.
1001 function drawSettle(p, state) {
1002 if (!canSettle()) return null;
1003 var acts = el('div', 'trk-settle');
1004 // Accept and decline ask for their one-line reason in place of the buttons;
1005 // the confirm refuses an empty one. done and reopen settle at a press.
1006 if (_settleAsk && _settleAsk.n === p.n) {
1007 acts.appendChild(drawReason(p, _settleAsk.which));
1008 return acts;
1009 }
1010 if (state === 'open') {
1011 acts.appendChild(settleBtn('accept', p.n, tOr('tracker.accept', 'Accept')));
1012 acts.appendChild(settleBtn('decline', p.n, tOr('tracker.decline', 'Decline')));
1013 acts.appendChild(settleBtn('done', p.n, tOr('tracker.done', 'Mark done')));
1014 } else if (state === 'accepted') {
1015 acts.appendChild(settleBtn('done', p.n, tOr('tracker.done', 'Mark done')));
1016 acts.appendChild(settleBtn('reopen', p.n, tOr('tracker.reopen', 'Reopen')));
1017 } else {
1018 acts.appendChild(settleBtn('reopen', p.n, tOr('tracker.reopen', 'Reopen')));
1019 }
1020 return acts;
1021 }
1022
1023 /// One settle button. A decision that needs a reason opens the reason box
1024 /// rather than settling; one that does not settles at the press.
1025 function settleBtn(which, n, text) {
1026 var b = button('trk-settle-btn', NEEDS_REASON[which] ? 'tracker-settle-ask' : 'tracker-settle', text);
1027 b.dataset.which = which;
1028 b.dataset.prop = n;
1029 return b;
1030 }
1031
1032 /// The required one-line reason for an accept or a decline, shown in place of
1033 /// the settle buttons: a box, the decision as its confirm, and Cancel. Empty
1034 /// is refused at the confirm (see `confirmSettle`).
1035 function drawReason(p, which) {
1036 var box = el('div', 'trk-reason-box');
1037 box.dataset.which = which;
1038 var lab = (which === 'accept')
1039 ? tOr('tracker.reason_accept', 'Why accept it? Shown to the proposer.')
1040 : tOr('tracker.reason_decline', 'Why decline it? Sent back to the proposer.');
1041 var inp = document.createElement('input');
1042 inp.type = 'text';
1043 inp.className = 'trk-reason';
1044 inp.maxLength = REASON_LIMIT;
1045 inp.dataset.prop = p.n;
1046 inp.placeholder = lab;
1047 inp.setAttribute('aria-label', lab);
1048 if (_reasonKeep && _reasonKeep[String(p.n)]) inp.value = _reasonKeep[String(p.n)];
1049 box.appendChild(inp);
1050 var row = el('div', 'trk-reason-acts');
1051 var ok = button('trk-reason-do', 'tracker-settle-do',
1052 (which === 'accept') ? tOr('tracker.accept', 'Accept') : tOr('tracker.decline', 'Decline'));
1053 ok.dataset.which = which;
1054 ok.dataset.prop = p.n;
1055 row.appendChild(ok);
1056 var cancel = button('trk-reason-cancel', 'tracker-settle-cancel', tOr('tracker.cancel', 'Cancel'));
1057 cancel.dataset.prop = p.n;
1058 row.appendChild(cancel);
1059 box.appendChild(row);
1060 return box;
1061 }
1062
1063 function drawDetail(p) {
1064 var box = el('div', 'trk-detail');
1065 box.dataset.prop = p.n;
1066
1067 var head = el('div', 'trk-detail-head');
1068 head.appendChild(button('trk-back', 'tracker-back', tOr('tracker.back', 'Back')));
1069 var badge = el('span', 'trk-state', stateWord(p.state));
1070 badge.dataset.state = p.state;
1071 head.appendChild(badge);
1072 head.appendChild(el('span', 'trk-num', '#' + p.n));
1073 box.appendChild(head);
1074
1075 box.appendChild(el('h3', 'trk-detail-title', p.title || ('#' + p.n)));
1076
1077 var meta = el('div', 'trk-detail-meta');
1078 if (p.author) meta.appendChild(el('span', 'trk-author', p.author));
1079 if (p.opened) meta.appendChild(el('span', 'trk-when', fmtWhen(p.opened)));
1080 var tl = tallyLine(p);
1081 if (tl) meta.appendChild(tl);
1082 if (p.mark) meta.appendChild(el('span', 'trk-mark', p.mark));
1083 box.appendChild(meta);
1084
1085 // The upvote, on the opened card as well as the board.
1086 var vote = drawVote(p);
1087 if (vote) box.appendChild(vote);
1088
1089 // The build it shipped in, if it is done and has been stamped.
1090 if (p.state === 'done') box.appendChild(drawShip(p));
1091
1092 box.appendChild(el('p', 'trk-body', p.body || ''));
1093
1094 var settle = drawSettle(p, p.state);
1095 if (settle) box.appendChild(settle);
1096
1097 if (p.revisions && p.revisions.length) {
1098 var revs = el('div', 'trk-revisions');
1099 revs.appendChild(el('h4', 'trk-sub', tOr('tracker.revisions', 'Revisions')));
1100 p.revisions.forEach(function (r) {
1101 var one = el('div', 'trk-rev');
1102 one.appendChild(el('span', 'trk-when', fmtWhen(r.when)));
1103 one.appendChild(el('span', 'trk-rev-title', r.title));
1104 revs.appendChild(one);
1105 });
1106 box.appendChild(revs);
1107 }
1108
1109 var comments = el('div', 'trk-comments-list');
1110 comments.appendChild(el('h4', 'trk-sub', tOr('tracker.discussion', 'Comments')));
1111 var disc = p.discussion || [];
1112 if (!disc.length) {
1113 comments.appendChild(el('div', 'trk-none', tOr('tracker.no_comments', 'No comments.')));
1114 } else {
1115 disc.forEach(function (d) {
1116 var c = el('div', 'trk-comment');
1117 var foot = el('div', 'trk-comment-foot');
1118 if (d.author) foot.appendChild(el('span', 'trk-author', d.author));
1119 if (d.when) foot.appendChild(el('span', 'trk-when', fmtWhen(d.when)));
1120 c.appendChild(el('div', 'trk-comment-said', d.said));
1121 c.appendChild(foot);
1122 comments.appendChild(c);
1123 });
1124 }
1125 box.appendChild(comments);
1126
1127 // Saying something back, in the opened card where the thread is read.
1128 box.appendChild(drawSay(p));
1129 return box;
1130 }
1131
1132 /// The settle-voice control, drawn under the head. Shown only where an identity
1133 /// exists to wrap under — in a build without one, `cfg.voice` is the only way a
1134 /// voice is held, and there is nothing to paste. Presence, replace, forget: the
1135 /// same three states voice.js draws for the pull voice. When no voice is held
1136 /// this is the terse "add your settle voice" affordance the owner asked for, so
1137 /// the board never shows a settle button that would fail.
1138 function drawAdmin() {
1139 if (!window.DaimondIdentity) return null; // nothing to wrap under; tests use cfg.voice
1140 var host = el('div', 'trk-admin');
1141 if (_voiceOpen) {
1142 var input = document.createElement('input');
1143 input.type = 'password';
1144 input.className = 'trk-admin-in';
1145 input.id = 'tracker-admin-in';
1146 input.autocomplete = 'off';
1147 input.spellcheck = false;
1148 input.placeholder = tOr('tracker.admin_ph', 'Paste your settle voice');
1149 input.setAttribute('aria-label', tOr('tracker.admin_ph', 'Paste your settle voice'));
1150 host.appendChild(input);
1151 host.appendChild(button('trk-admin-save', 'tracker-admin-save', tOr('tracker.admin_save', 'Save')));
1152 host.appendChild(button('trk-admin-cancel', 'tracker-admin-cancel', tOr('common.cancel', 'Cancel')));
1153 return host;
1154 }
1155 if (adminHas()) {
1156 host.appendChild(el('span', 'trk-admin-say', tOr('tracker.admin_held', 'Settle voice held, encrypted.')));
1157 host.appendChild(button('trk-admin-btn', 'tracker-admin-forget', tOr('tracker.admin_forget', 'Forget it')));
1158 return host;
1159 }
1160 // NO SETTLE VOICE HELD. Reading and the board are open to everyone; settling
1161 // is owner-and-operator only and needs a hand-minted admin voice, pasted once.
1162 // This is the affordance that opens the paste, in place of dead settle buttons.
1163 host.appendChild(el('span', 'trk-admin-say', tOr('tracker.admin_none',
1164 'Reading only. Settling needs your admin voice.')));
1165 host.appendChild(button('trk-admin-btn', 'tracker-admin-open', tOr('tracker.admin_add', 'Add your settle voice')));
1166 return host;
1167 }
1168
1169 var _replyKeep = null; // a half-typed comment, kept across one redraw
1170
1171 function draw() {
1172 if (!_host) return;
1173 // WHAT SOMEBODY IS HALF-WAY THROUGH TYPING SURVIVES THE REDRAW, the same care
1174 // improve.js takes on its reply box: a vote or a language change redraws the
1175 // whole view, and a comment three sentences in would go with it.
1176 var keep = {};
1177 _host.querySelectorAll('.trk-reply').forEach(function (b) { if (b.value) keep[b.dataset.prop] = b.value; });
1178 _replyKeep = keep;
1179 // The same care for a half-typed settle reason, so a background refetch that
1180 // redraws mid-decision does not take the words with it.
1181 var rkeep = {};
1182 _host.querySelectorAll('.trk-reason').forEach(function (b) { if (b.value) rkeep[b.dataset.prop] = b.value; });
1183 _reasonKeep = rkeep;
1184 _host.innerHTML = '';
1185
1186 var head = el('div', 'trk-head');
1187 head.appendChild(el('span', 'trk-title-main', tOr('tracker.title', 'Development')));
1188 if (_st.read) head.appendChild(el('span', 'trk-count', tOr('tracker.count', '{n} proposals', { n: _st.total })));
1189 _host.appendChild(head);
1190
1191 var admin = drawAdmin();
1192 if (admin) _host.appendChild(admin);
1193
1194 if (_st.err) _host.appendChild(el('div', 'trk-err', saying(_st.err)));
1195
1196 var say = el('div', 'trk-say');
1197 say.id = 'tracker-say';
1198 _host.appendChild(say);
1199
1200 if (_st.loading && !_order.length) {
1201 _host.appendChild(el('div', 'trk-loading', tOr('tracker.loading', 'Reading…')));
1202 return;
1203 }
1204
1205 if (_open && _by[_open]) { _host.appendChild(drawDetail(_by[_open])); return; }
1206
1207 if (!_order.length) {
1208 if (_st.read) _host.appendChild(el('div', 'trk-none', tOr('tracker.empty', 'No proposals yet.')));
1209 return;
1210 }
1211 _host.appendChild(drawFilter());
1212 _host.appendChild(drawBoard());
1213 }
1214
1215 /// One line for the answers not worth a dialog.
1216 function flash(text) {
1217 var n = document.getElementById('tracker-say');
1218 if (!n) return;
1219 n.textContent = text;
1220 clearTimeout(flash._t);
1221 flash._t = setTimeout(function () { if (n.textContent === text) n.textContent = ''; }, 8000);
1222 }
1223
1224 // ── Admin-voice actions ────────────────────────────────────
1225
1226 async function saveAdmin() {
1227 var input = document.getElementById('tracker-admin-in');
1228 if (!input) return false;
1229 var raw = String(input.value || '');
1230 input.value = '';
1231 try { await adminSet(raw); }
1232 catch (e) { flash(String((e && e.message) || e)); return false; }
1233 _voiceOpen = false;
1234 draw();
1235 flash(tOr('tracker.admin_saved', 'Settle voice held, encrypted.'));
1236 return true;
1237 }
1238
1239 async function forgetAdmin() {
1240 var ok = true;
1241 try {
1242 if (window.DaimondCore && DaimondCore.confirm) {
1243 ok = await DaimondCore.confirm(
1244 tOr('tracker.admin_forget_ask', 'Forget your settle voice here? It was shown once.'),
1245 tOr('tracker.admin_forget', 'Forget it'),
1246 { title: tOr('tracker.admin_forget', 'Forget it') });
1247 }
1248 } catch (e) { ok = true; }
1249 if (!ok) return false;
1250 adminClear();
1251 draw();
1252 flash(tOr('tracker.admin_forgotten', 'The copy on this device is gone.'));
1253 return true;
1254 }
1255
1256 // ── The transparency log ───────────────────────────────────
1257
1258 /// Open the transparency log (the version history), where the shipped build's
1259 /// own entry lives. The Admin panel already draws it; the stamp only opens it.
1260 function openTransparency() {
1261 try {
1262 if (window.DaimondAdmin && DaimondAdmin.release) { DaimondAdmin.release(); return true; }
1263 } catch (e) { /* no admin panel in this build */ }
1264 return false;
1265 }
1266
1267 // ── Wiring ─────────────────────────────────────────────────
1268
1269 function onClick(e) {
1270 var b = e.target.closest ? e.target.closest('[data-act]') : null;
1271 if (!b || !_host || !_host.contains(b)) return;
1272 var act = b.dataset.act;
1273 // `dataset` values are strings; `whole()` rejects a non-number, so parse here.
1274 var n = parseInt(b.dataset.prop, 10) || 0;
1275 if (act === 'tracker-open' && n) { open(n); return; }
1276 if (act === 'tracker-back') { back(); return; }
1277 if (act === 'tracker-filter') {
1278 var f = (b.dataset.filter === 'mine') ? 'mine' : 'all';
1279 if (f !== _filter) { _filter = f; draw(); }
1280 return;
1281 }
1282 if (act === 'tracker-settle' && n) { settle(n, b.dataset.which); return; }
1283 if (act === 'tracker-settle-ask' && n) { _settleAsk = { n: n, which: b.dataset.which }; draw(); return; }
1284 if (act === 'tracker-settle-do' && n) { confirmSettle(n, b.dataset.which); return; }
1285 if (act === 'tracker-settle-cancel') { _settleAsk = null; draw(); return; }
1286 if (act === 'tracker-vote' && n) { voteOn(n); return; }
1287 if (act === 'tracker-comment' && n) { commentOn(n); return; }
1288 if (act === 'tracker-getvoice') { getVoice(); return; }
1289 if (act === 'tracker-transparency') { openTransparency(); return; }
1290 if (act === 'tracker-admin-open') { _voiceOpen = true; draw(); return; }
1291 if (act === 'tracker-admin-cancel') { _voiceOpen = false; draw(); return; }
1292 if (act === 'tracker-admin-save') { saveAdmin(); return; }
1293 if (act === 'tracker-admin-forget') { forgetAdmin(); return; }
1294 }
1295
1296 /// Draw the view into `host`. Does NOT read: `onOpen()` does, once the panel
1297 /// is shown, so a panel nobody opened costs no request.
1298 function mount(host) {
1299 if (_host) { try { _host.removeEventListener('click', onClick); } catch (e) { /* gone */ } }
1300 _host = host || null;
1301 if (!_host) return;
1302 _host.addEventListener('click', onClick);
1303 draw();
1304 }
1305
1306 // ── Self-mount into the app's panel, and read on first reveal ──
1307 //
1308 // The panel's markup is `#tracker-view`, an empty mount point in index.html.
1309 // This finds it, draws the shell, and reads the listing the first time the
1310 // panel becomes visible — so nothing is fetched until a person opens it, and
1311 // no hand-wired `onOpen` hook in daimond.js is needed.
1312
1313 function init() {
1314 var el = document.getElementById('tracker-view');
1315 if (!el) return;
1316 mount(el);
1317 try {
1318 if (typeof IntersectionObserver === 'function') {
1319 // Stay connected, so every time the panel comes back into view
1320 // `onOpen` runs and refetches a stale board -- a one-shot observer
1321 // that disconnected was why a re-shown panel never saw a decline.
1322 var io = new IntersectionObserver(function (entries) {
1323 for (var i = 0; i < entries.length; i++) {
1324 if (entries[i].isIntersecting) { onOpen(); return; }
1325 }
1326 });
1327 io.observe(el);
1328 } else {
1329 onOpen();
1330 }
1331 } catch (e) { onOpen(); }
1332 }
1333
1334 if (document.readyState === 'loading') {
1335 document.addEventListener('DOMContentLoaded', init);
1336 } else {
1337 init();
1338 }
1339
1340 window.DaimondTracker = {
1341 configure: configure,
1342 mount: mount,
1343 onOpen: onOpen,
1344 load: load,
1345 open: open,
1346 back: back,
1347 settle: settle,
1348 canSettle: canSettle,
1349 /// The shipped-build stamp parser, published so a test drives the same
1350 /// parse the board does rather than a second copy of the rule.
1351 parseShip: parseShip,
1352 /// The admin voice, published so a settings surface or a test can drive the
1353 /// same store the paste field writes.
1354 adminHas: adminHas,
1355 adminSet: adminSet,
1356 adminClear: adminClear,
1357 state: function () {
1358 return {
1359 total: _st.total,
1360 read: _st.read,
1361 loading: _st.loading,
1362 err: _st.err ? _st.err.why : '',
1363 open: _open,
1364 settle: canSettle(),
1365 filter: _filter,
1366 shown: _order.slice(),
1367 };
1368 },
1369 proposal: function (n) { return _by[n] ? JSON.parse(JSON.stringify(_by[n])) : null; },
1370 reset: function () { _by = {}; _order = []; _open = null; _voiceOpen = false; _settleAsk = null; _filter = 'all'; _st = { total: 0, loading: false, err: null, read: false }; draw(); },
1371 };
1372})();