create-pmos 0.72.1 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +1 -1
- package/workspace/guide/today.md +23 -2
- package/workspace/lib/brief.js +88 -2
- package/workspace/lib/day-marks.js +163 -0
- package/workspace/lib/day-run.js +38 -4
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -0
- package/workspace/public/pm/components.css +99 -8
- package/workspace/public/pm/note-dock.js +137 -0
- package/workspace/public/pm/notes-links.js +16 -37
- package/workspace/public/pm/today-copy.js +82 -4
- package/workspace/public/pm/today-day.js +349 -19
- package/workspace/public/pm/view-notes.js +39 -9
- package/workspace/server.js +81 -3
- package/workspace/skills/day-task.skill +1 -0
- package/workspace/skills/morning-brief.skill +13 -2
- package/workspace/test-lists/today-act-prepare.json +5 -0
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { el } from './dom.js';
|
|
28
|
-
import { button, iconButton, badge, toast, row, tabs, field } from './primitives.js';
|
|
28
|
+
import { button, iconButton, badge, toast, row, tabs, field, menu, meter } from './primitives.js';
|
|
29
29
|
import { pmGet, pmPost } from './http.js';
|
|
30
30
|
import { FIXTURE } from './state.js';
|
|
31
31
|
import { COPY } from './today-copy.js';
|
|
@@ -35,6 +35,7 @@ import { agendaPanel, meetingDetail, memoryBlock, refreshAgenda, agendaSig, onAg
|
|
|
35
35
|
import { notesBlock, onNotes } from './notes-links.js';
|
|
36
36
|
|
|
37
37
|
const D = COPY.day;
|
|
38
|
+
const A = COPY.act;
|
|
38
39
|
const SLOW_MS = 30000;
|
|
39
40
|
const FAST_MS = 4000;
|
|
40
41
|
const MAX_REVISIONS = 3;
|
|
@@ -58,6 +59,8 @@ const ui = {
|
|
|
58
59
|
open: (() => { const v = store.get(OPEN_KEY); return v === 'true' ? true : v === 'false' ? false : null; })(),
|
|
59
60
|
sel: null, mtab: 'all', openSrc: null, showIgn: {}, showIgnDigests: false,
|
|
60
61
|
revising: new Set(), notes: new Map(), siteDraft: '', placeDraft: {}, busy: new Set(), scroll: false,
|
|
62
|
+
// today-act-prepare: the folded groups the PM opened, the item whose hold form is open, and its draft
|
|
63
|
+
groupsOpen: new Set(), holding: null, holdDraft: { who: '', when: 'h2' }, compose: new Map(), verdict: new Map(),
|
|
61
64
|
};
|
|
62
65
|
let rerender = () => {};
|
|
63
66
|
let notesSub = null; // the redraw when the meeting notes list is read (pm/notes-links.js)
|
|
@@ -119,7 +122,8 @@ const isUrl = (s) => typeof s === 'string' && s.length <= 500 && /^https?:\/\/\S
|
|
|
119
122
|
const srcName = (k) => D.srcName[k] || (k === 'derived' ? 'PMOS' : String(k || ''));
|
|
120
123
|
|
|
121
124
|
function jobOf(item, task, worth) {
|
|
122
|
-
|
|
125
|
+
// a job the PM gave to the agent (Have the agent draft it) has the task's kind: approve
|
|
126
|
+
const kind = (task && task.kind) || (item && item.kind) || (item && item.owner === 'pm' ? 'you' : 'approve');
|
|
123
127
|
const status = task ? task.status : kind === 'you' ? 'you' : 'notStarted';
|
|
124
128
|
const group = worth ? 'worth' : ['you', 'review', 'failed'].includes(status) ? 'needs' : ['done', 'sent'].includes(status) ? 'done' : 'working';
|
|
125
129
|
return {
|
|
@@ -160,10 +164,20 @@ function model(d, view) {
|
|
|
160
164
|
for (const x of (brief && brief.worth_asking) || []) add(x, true);
|
|
161
165
|
for (const t of tasks) if (!used.has(t.id)) jobs.push(jobOf(null, t, false)); // an item a check took off: its task stays
|
|
162
166
|
const now = d && d.now ? (typeof d.now === 'number' ? d.now : Date.parse(d.now)) : Date.now();
|
|
167
|
+
const marks = (d && d.marks) || {};
|
|
163
168
|
const events = ((brief && brief.calendar) || []).map((e, i) => {
|
|
164
169
|
const s = Date.parse(e.start);
|
|
165
170
|
const f = e.end ? Date.parse(e.end) : s + 30 * 60000;
|
|
166
|
-
|
|
171
|
+
const mark = marks[e.id] || null;
|
|
172
|
+
const checked = new Set((mark && mark.steps) || []);
|
|
173
|
+
const before = ((e.steps && e.steps.before) || []).map((st) => ({ ...st, done: !!st.done || checked.has(String(st.t || '')) }));
|
|
174
|
+
const ended = Number.isFinite(f) && f <= now;
|
|
175
|
+
// a meeting needs prep when the brief gave a prep line or Before steps (the explainer's contradiction: Before steps
|
|
176
|
+
// with no prep line read "Nothing to prepare" while the Agenda counted them as prep left)
|
|
177
|
+
const needsPrep = !ended && (!!e.prep || before.length > 0);
|
|
178
|
+
const st = mark && mark.state;
|
|
179
|
+
const group = !needsPrep ? null : st === 'done' || st === 'dismissed' ? 'done' : st === 'snoozed' ? 'later' : 'needs';
|
|
180
|
+
return { ...e, id: e.id || 'ev-' + i, s, f, time: hhmm(e.start), ended, mark, before, needsPrep, group };
|
|
167
181
|
}).sort((a, b) => a.s - b.s);
|
|
168
182
|
const settings = (d && d.settings) || null;
|
|
169
183
|
const scopes = (settings && settings.scopes) || {};
|
|
@@ -176,6 +190,13 @@ function model(d, view) {
|
|
|
176
190
|
const repos = (d && Array.isArray(d.repos)) ? d.repos : [];
|
|
177
191
|
const repoName = (id) => { const r = repos.find((x) => x.id === id); return r ? r.name : id; };
|
|
178
192
|
for (const j of jobs) j.repo = itemRepos[j.id] || null;
|
|
193
|
+
// the PM's marks (today-act-prepare, PRD M1 to M6): a job the PM owns leaves Needs you when it is done, snoozed, held
|
|
194
|
+
// or not the PM's; a snooze or hold whose time has passed comes back as open (the server reads it so)
|
|
195
|
+
for (const j of jobs) {
|
|
196
|
+
j.mark = marks[j.id] || null;
|
|
197
|
+
const st = j.mark && j.mark.state;
|
|
198
|
+
if (j.status === 'you' && st) j.group = { done: 'done', dismissed: 'done', snoozed: 'later', waiting: 'waiting' }[st] || j.group;
|
|
199
|
+
}
|
|
179
200
|
for (const g of digests) g.repo = placeRepo(g.source, g.name);
|
|
180
201
|
const v = view || {};
|
|
181
202
|
const narrow = !v.all && !!v.here;
|
|
@@ -193,8 +214,9 @@ function model(d, view) {
|
|
|
193
214
|
readFailed: (d && d.read_failed) || null,
|
|
194
215
|
free: plan && plan.time ? plan.time.free_minutes : null,
|
|
195
216
|
maxGoals: (d && d.max_goals) || 7,
|
|
196
|
-
|
|
197
|
-
|
|
217
|
+
preps: events.filter((e) => e.needsPrep),
|
|
218
|
+
needs: shownJobs.filter((j) => j.group === 'needs').length + events.filter((e) => e.group === 'needs').length,
|
|
219
|
+
done: shownJobs.filter((j) => j.group === 'done').length + events.filter((e) => e.group === 'done').length,
|
|
198
220
|
};
|
|
199
221
|
}
|
|
200
222
|
|
|
@@ -226,7 +248,7 @@ function statusPill(j) {
|
|
|
226
248
|
const map = {
|
|
227
249
|
notStarted: [s.notStarted, ''], waiting: [s.waiting, ''], working: [s.working, 'info'], sending: [s.sending, 'info'],
|
|
228
250
|
review: [j.kind === 'auto' ? s.reviewAuto : s.review, 'purple'], sent: [s.sent, 'ok'], done: [s.done, 'ok'],
|
|
229
|
-
failed: [s.failed, 'bad'],
|
|
251
|
+
failed: [s.failed, 'bad'],
|
|
230
252
|
};
|
|
231
253
|
const [t, tone] = map[j.status] || [j.status, ''];
|
|
232
254
|
return pill(t, tone, { 'data-day-status': j.status });
|
|
@@ -306,6 +328,48 @@ async function decide(ws, task, action, note) {
|
|
|
306
328
|
load(ws);
|
|
307
329
|
}
|
|
308
330
|
|
|
331
|
+
/* The PM's marks (today-act-prepare, PRD M1 to M6). Every press but a step check says what it did, with Undo: the
|
|
332
|
+
* answer carries the mark before the press, and Undo puts it back. */
|
|
333
|
+
async function markPress(ws, id, op, said) {
|
|
334
|
+
const key = 'mark:' + id;
|
|
335
|
+
if (ui.busy.has(key)) return;
|
|
336
|
+
ui.busy.add(key); rerender();
|
|
337
|
+
const r = await pmPost('/api/proactive/day/mark', { id, ...op });
|
|
338
|
+
ui.busy.delete(key);
|
|
339
|
+
if (r && r.ok) {
|
|
340
|
+
if (op.action !== 'step') ui.holding = null;
|
|
341
|
+
if (said) toast({ text: said, actionLabel: A.undo, onAction: () => undoMark(ws, id, r.prev) });
|
|
342
|
+
} else toast({ text: (op.action === 'step' ? A.stepFailed : D.refused)((r && r.error) || D.unreached), tone: 'destructive' });
|
|
343
|
+
rerender();
|
|
344
|
+
load(ws);
|
|
345
|
+
}
|
|
346
|
+
async function undoMark(ws, id, prev) {
|
|
347
|
+
const r = await pmPost('/api/proactive/day/mark', { id, action: 'restore', mark: prev === undefined ? null : prev });
|
|
348
|
+
toast(r && r.ok ? { text: A.undone } : { text: D.refused((r && r.error) || D.unreached), tone: 'destructive' });
|
|
349
|
+
load(ws);
|
|
350
|
+
}
|
|
351
|
+
/** "Have the agent draft it" (PRD C2): one briefed task for this job; the job then shows the draft to approve. */
|
|
352
|
+
async function sendPress(ws, j, body, to) {
|
|
353
|
+
const key = 'send:' + j.id;
|
|
354
|
+
if (ui.busy.has(key)) return;
|
|
355
|
+
ui.busy.add(key); rerender();
|
|
356
|
+
const r = await pmPost('/api/proactive/day/send', { item: j.id, ws, ...body });
|
|
357
|
+
ui.busy.delete(key);
|
|
358
|
+
if (r && r.ok) { ui.compose.delete(j.id); ui.verdict.delete(j.id); toast({ text: A.sendStarted(to) }); } else toast({ text: D.refused((r && r.error) || D.unreached), tone: 'destructive' });
|
|
359
|
+
rerender();
|
|
360
|
+
load(ws);
|
|
361
|
+
}
|
|
362
|
+
async function draftPress(ws, j) {
|
|
363
|
+
const key = 'draft:' + j.id;
|
|
364
|
+
if (ui.busy.has(key)) return;
|
|
365
|
+
ui.busy.add(key); rerender();
|
|
366
|
+
const r = await pmPost('/api/proactive/day/draft', { item: j.id, ws });
|
|
367
|
+
ui.busy.delete(key);
|
|
368
|
+
toast(r && r.ok ? { text: A.draftStarted } : { text: D.refused((r && r.error) || D.unreached), tone: 'destructive' });
|
|
369
|
+
rerender();
|
|
370
|
+
load(ws);
|
|
371
|
+
}
|
|
372
|
+
|
|
309
373
|
async function setSettings(ws, patch) {
|
|
310
374
|
const r = await pmPost('/api/proactive/settings', patch);
|
|
311
375
|
if (!r || r.ok === false) { toast({ text: D.refused((r && r.error) || D.unreached), tone: 'destructive' }); return false; }
|
|
@@ -421,7 +485,11 @@ function jobRow(ctx, j) {
|
|
|
421
485
|
const n = j.goal ? ctx.m.goalNo.get(j.goal) : null;
|
|
422
486
|
const sub = j.status === 'failed' && j.task && j.task.error ? j.task.error
|
|
423
487
|
: [j.item && j.item.where, srcName(j.source)].filter(Boolean).join(' · ');
|
|
424
|
-
|
|
488
|
+
const mine = j.status === 'you';
|
|
489
|
+
const due = mine && j.item && j.item.due ? Date.parse(j.item.due) : NaN;
|
|
490
|
+
const meta = mine && j.mark && j.mark.state === 'waiting' ? `${A.hold.replace('…', '')} ${j.mark.on}` : sub;
|
|
491
|
+
return rowBtn(ctx, 'job', j.id, { lead: mine ? verbChip(verbOf(j.item)) : n ? D.pill.goal(n) : '·', title: j.title, meta,
|
|
492
|
+
trail: el('span', { class: 'td-day__chips' }, repoTag(ctx.m, j.repo), mine ? dueChip(ctx.m, j.mark, due, false) : statusPill(j)),
|
|
425
493
|
dot: j.isNew && !seen.has(j.id) ? el('span', { class: 'td-day__newdot', 'data-day': 'new', role: 'img', 'aria-label': D.newItem }) : null });
|
|
426
494
|
}
|
|
427
495
|
|
|
@@ -438,18 +506,38 @@ function owedItems(owed) {
|
|
|
438
506
|
});
|
|
439
507
|
}
|
|
440
508
|
|
|
509
|
+
/** A meeting to prepare for, as a row of the jobs (PRD D4): it selects the meeting. */
|
|
510
|
+
function prepRow(ctx, e) {
|
|
511
|
+
const total = e.before.length; const d = e.before.filter((x) => x.done).length;
|
|
512
|
+
return rowBtn(ctx, 'event', e.id, { lead: verbChip('prepare'), title: e.title, meta: [total ? A.readiness(d, total) : null, e.with || null].filter(Boolean).join(' · '),
|
|
513
|
+
trail: dueChip(ctx.m, e.mark, e.s, true) }, { 'data-day-prep': e.id });
|
|
514
|
+
}
|
|
515
|
+
|
|
441
516
|
function owedRow(ctx, o) {
|
|
442
|
-
return rowBtn(ctx, 'owed', o.id, { lead: '·', title: o.title, meta: o.meta, trail: pill(
|
|
517
|
+
return rowBtn(ctx, 'owed', o.id, { lead: '·', title: o.title, meta: o.meta, trail: pill(A.chip.today, 'warn', { 'data-day-status': 'you' }) });
|
|
443
518
|
}
|
|
444
519
|
|
|
520
|
+
// Needs you holds only what the PM can do now (PRD D1): snoozed, held and done items fold into groups of their own.
|
|
521
|
+
const JOB_GROUPS = ['needs', 'later', 'waiting', 'working', 'done', 'worth'];
|
|
522
|
+
const FOLDED = new Set(['later', 'waiting', 'done']);
|
|
445
523
|
function jobsPanel(ctx) {
|
|
446
524
|
const { m } = ctx;
|
|
447
525
|
const owed = ctx.owed;
|
|
448
|
-
if (!m.jobs.length && !owed.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
|
|
449
|
-
const groups =
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
526
|
+
if (!m.jobs.length && !owed.length && !m.preps.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
|
|
527
|
+
const groups = JOB_GROUPS.map((g) => [g, m.jobs.filter((j) => j.group === g), m.preps.filter((e) => e.group === g)])
|
|
528
|
+
.filter(([g, l, p]) => l.length || p.length || (g === 'needs' && owed.length));
|
|
529
|
+
return panel('jobs', D.jobsCount(m.needs + owed.length, m.done), ...groups.map(([g, l, p]) => {
|
|
530
|
+
const count = l.length + p.length + (g === 'needs' ? owed.length : 0);
|
|
531
|
+
const label = A.groups[g] && g !== 'done' ? A.groups[g] : g === 'done' ? A.groups.done : D.groups[g];
|
|
532
|
+
const foldable = FOLDED.has(g);
|
|
533
|
+
const shut = foldable && !ui.groupsOpen.has(g);
|
|
534
|
+
const head = foldable
|
|
535
|
+
? button({ label: `${label} · ${count}`, variant: 'ghost', size: 'sm', iconEnd: shut ? 'chevron-right' : 'chevron-down', attrs: { class: 'td-day__fold-head', 'aria-expanded': shut ? 'false' : 'true', 'data-day-fold': g },
|
|
536
|
+
onClick: () => { if (shut) ui.groupsOpen.add(g); else ui.groupsOpen.delete(g); rerender(); } })
|
|
537
|
+
: el('div', { class: 'td-day__gh' }, label, pill(String(count), ''));
|
|
538
|
+
return el('div', { class: 'td-day__group', 'data-day-group': g }, head,
|
|
539
|
+
shut ? null : el('ul', { class: 'td-day__rows', 'data-day-list': 'jobs-' + g }, ...p.map((e) => prepRow(ctx, e)), ...l.map((j) => jobRow(ctx, j)), ...(g === 'needs' ? owed.map((o) => owedRow(ctx, o)) : [])));
|
|
540
|
+
}));
|
|
453
541
|
}
|
|
454
542
|
|
|
455
543
|
/* The coordinator's view of the run: what every agent job came back with, in one place. The server runs the jobs as one
|
|
@@ -489,7 +577,8 @@ function goalsPanel(ctx) {
|
|
|
489
577
|
|
|
490
578
|
function mentionStatus(j) {
|
|
491
579
|
if (j.group === 'done') return pill(D.status2.handled, 'ok');
|
|
492
|
-
if (j.status === 'you'
|
|
580
|
+
if (j.status === 'you' && j.mark && j.mark.state) return dueChip({ now: Date.now() }, j.mark, NaN, false);
|
|
581
|
+
if (j.status === 'you') return j.item && j.item.reply_needed ? pill(D.status2.replyNeeded, 'warn', { 'data-day-status': 'you' }) : verbChip(verbOf(j.item));
|
|
493
582
|
return statusPill(j);
|
|
494
583
|
}
|
|
495
584
|
|
|
@@ -523,6 +612,243 @@ function mentionsPanel(ctx) {
|
|
|
523
612
|
return panel('mentions', m.mentions.length ? D.mentionsCount(m.mentions.length, need) : null, mtabs, rows, dig);
|
|
524
613
|
}
|
|
525
614
|
|
|
615
|
+
/* ── act and prepare (initiative today-act-prepare) ─────────────────────────────────────────────────────────────── */
|
|
616
|
+
|
|
617
|
+
const MIN_MS = 60000;
|
|
618
|
+
const clockAt = (ms, hh, mm) => { const d = new Date(ms); return new Date(d.getFullYear(), d.getMonth(), d.getDate(), hh, mm).getTime(); };
|
|
619
|
+
const sameDay = (a, b) => new Date(a).toDateString() === new Date(b).toDateString();
|
|
620
|
+
const verbOf = (it) => (it && A.verb[it.verb] ? it.verb : it && it.reply_needed ? 'reply' : 'do');
|
|
621
|
+
function verbChip(verb) {
|
|
622
|
+
return el('span', { class: 'td-day__verb', 'data-verb': verb, 'data-day': 'verb' }, A.verb[verb] || A.verb.do);
|
|
623
|
+
}
|
|
624
|
+
/** The chip at the end of a row the PM acts on: where its mark left it, else its due time, coloured by time left. */
|
|
625
|
+
function dueChip(m, mark, due, prep) {
|
|
626
|
+
const st = mark && mark.state;
|
|
627
|
+
const at = (v) => hhmm(v);
|
|
628
|
+
if (st === 'snoozed') return pill(A.back(at(mark.until)), '', { 'data-day-status': 'snoozed' });
|
|
629
|
+
if (st === 'waiting') return pill(A.check(at(mark.until)), '', { 'data-day-status': 'waiting' });
|
|
630
|
+
if (st === 'done') return pill(prep ? A.chip.ready : A.chip.done, 'ok', { 'data-day-status': 'marked-done' });
|
|
631
|
+
if (st === 'dismissed') return pill(prep ? A.chip.skipped : A.chip.dismissed, '', { 'data-day-status': 'dismissed' });
|
|
632
|
+
const status = prep ? 'prepare' : 'you';
|
|
633
|
+
if (!Number.isFinite(due)) return pill(A.chip.today, 'warn', { 'data-day-status': status });
|
|
634
|
+
const left = Math.round((due - m.now) / MIN_MS);
|
|
635
|
+
return pill(prep ? A.at(at(due)) : A.by(at(due)), left < 60 ? 'bad' : left < 180 ? 'warn' : '', { 'data-day-status': status, title: A.left(left) });
|
|
636
|
+
}
|
|
637
|
+
/** The times a snooze can take, later today: [label, ms]. */
|
|
638
|
+
function snoozeTimes(m, { due, start }) {
|
|
639
|
+
const out = [];
|
|
640
|
+
const add = (label, t) => { if (Number.isFinite(t) && t > m.now + 5 * MIN_MS && sameDay(t, m.now) && !out.some((x) => x[1] === t)) out.push([label, t]); };
|
|
641
|
+
if (Number.isFinite(start)) { add(A.snoozeTo.m30, start - 30 * MIN_MS); add(A.snoozeTo.m15, start - 15 * MIN_MS); return out; }
|
|
642
|
+
add(A.snoozeTo.hour, m.now + 60 * MIN_MS);
|
|
643
|
+
if (Number.isFinite(due)) add(A.snoozeTo.due, due - 30 * MIN_MS);
|
|
644
|
+
add(A.snoozeTo.later, clockAt(m.now, 16, 0));
|
|
645
|
+
add(A.snoozeTo.end, clockAt(m.now, 17, 30));
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
/** The banner of a marked item: what the mark is, and the press that undoes it. */
|
|
649
|
+
function markBanner(ctx, id, mark, prep) {
|
|
650
|
+
if (!mark) return null;
|
|
651
|
+
if (mark.back) return note(A.state.back, { 'data-day': 'mark-back' });
|
|
652
|
+
const st = mark.state;
|
|
653
|
+
if (!st) return null;
|
|
654
|
+
const text = st === 'snoozed' ? A.state.snoozed(hhmm(mark.until)) : st === 'waiting' ? A.state.waiting(mark.on, hhmm(mark.until))
|
|
655
|
+
: st === 'done' ? (prep ? A.state.ready : A.state.done)(hhmm(mark.at)) : A.state.dismissed(A.why[mark.why] || mark.why);
|
|
656
|
+
return el('div', { class: 'td-day__mark', 'data-day': 'mark', 'data-day-mark-state': st }, el('span', null, text),
|
|
657
|
+
button({ label: st === 'done' ? A.reopen : A.bringBack, size: 'sm', attrs: { 'data-day-mark': 'reopen' }, onClick: () => markPress(ctx.ws, id, { action: 'reopen' }, A.did.reopen) }));
|
|
658
|
+
}
|
|
659
|
+
/** The steps the PM checks off. A step the brief already marked done stays done. A step that reads a source shows what
|
|
660
|
+
* the source says under it (PRD X2), and a note step holds the PM's own note (PRD F3), so no other file needs opening. */
|
|
661
|
+
function stepChecks(ctx, id, steps, { refs = [], mark = null } = {}) {
|
|
662
|
+
if (!steps.length) return null;
|
|
663
|
+
return el('ul', { class: 'td-day__steps2', 'data-day': 'steps' }, ...steps.map((st) => {
|
|
664
|
+
const f = field({ kind: 'check', label: st.t, value: !!st.done, attrs: { 'data-day-step': st.t, disabled: st.fixed ? true : null },
|
|
665
|
+
onCommit: (v) => markPress(ctx.ws, id, { action: 'step', step: st.t, done: !!v }) });
|
|
666
|
+
const r = Number.isInteger(st.ref) ? refs[st.ref] : null;
|
|
667
|
+
return el('li', { class: 'td-day__step2', 'data-done': st.done ? 'true' : 'false' },
|
|
668
|
+
el('div', { class: 'td-day__steprow' }, f, st.who ? el('span', { class: 'td-day__who' }, D.who[st.who] || st.who) : null),
|
|
669
|
+
r ? refBlock(r, { open: !st.done, step: true }) : null,
|
|
670
|
+
st.note ? noteBox(ctx, id, mark) : null);
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
/** One source in a few lines, in the card: what it says, and a small link to the source for when the PM wants more. */
|
|
674
|
+
function refBlock(r, { open, step } = {}) {
|
|
675
|
+
return el('details', { class: 'td-day__ref', 'data-day': 'ref', open: open ? true : null },
|
|
676
|
+
el('summary', null, el('span', null, (step ? A.refRead : '') + r.label), r.source ? el('span', { class: 'td-day__src' }, srcName(r.source)) : null),
|
|
677
|
+
el('ul', { class: 'td-day__reflines' }, ...(r.lines || []).map((l) => el('li', null, l))),
|
|
678
|
+
isUrl(r.link) ? extLink(A.refOpen, r.link, { 'data-day-link': 'ref' }) : null);
|
|
679
|
+
}
|
|
680
|
+
/** The sources no step names, under one heading. */
|
|
681
|
+
function refsBlock(refs, steps) {
|
|
682
|
+
const named = new Set(steps.map((st) => st.ref).filter(Number.isInteger));
|
|
683
|
+
const rest = refs.filter((_, i) => !named.has(i));
|
|
684
|
+
return rest.length ? el('div', { class: 'td-day__phase', 'data-day': 'refs' }, el('h4', null, A.refsTitle), ...rest.map((r) => refBlock(r, { open: false }))) : null;
|
|
685
|
+
}
|
|
686
|
+
/** The conversation, as the brief read it: at most 4 messages, the last one first in weight. */
|
|
687
|
+
function threadBlock(thread) {
|
|
688
|
+
if (!Array.isArray(thread) || !thread.length) return null;
|
|
689
|
+
return el('div', { class: 'td-day__phase td-day__thread', 'data-day': 'thread' }, el('h4', null, A.threadTitle),
|
|
690
|
+
...thread.map((x, i) => el('div', { class: 'td-day__msg' + (i === thread.length - 1 ? ' td-day__msg--last' : '') },
|
|
691
|
+
el('div', { class: 'td-day__src' }, [x.who, x.at].filter(Boolean).join(' · ')), el('p', null, x.text))));
|
|
692
|
+
}
|
|
693
|
+
/** The PM's own note on a meeting, saved with its marks when the box loses focus. Nothing is sent. */
|
|
694
|
+
function noteBox(ctx, id, mark) {
|
|
695
|
+
const f = field({ label: A.noteTitle, kind: 'textarea', value: (mark && mark.note) || '', placeholder: A.noteHint, attrs: { maxlength: '1000', 'data-day-input': 'note' },
|
|
696
|
+
onCommit: (v) => markPress(ctx.ws, id, { action: 'note', text: v }, A.noteSaved) });
|
|
697
|
+
return el('div', { class: 'td-day__notebox', 'data-day': 'note' }, f, el('span', { class: 'td-day__dim' }, A.noteStays));
|
|
698
|
+
}
|
|
699
|
+
/** Where the PM's own answer goes, and the box to write it in (PRD F1, F2). The press is the approval: the agent posts
|
|
700
|
+
* exactly this text, as the PM, to the place the item names. */
|
|
701
|
+
function composer(ctx, j, verb) {
|
|
702
|
+
const it = j.item || {};
|
|
703
|
+
const sd = it.send || {};
|
|
704
|
+
const to = sd.to || it.where || srcName(j.source);
|
|
705
|
+
const review = !!sd.review;
|
|
706
|
+
const busy = ui.busy.has('send:' + j.id);
|
|
707
|
+
const f = field({ label: A.compose[verb] || A.compose.reply, kind: 'textarea', value: ui.compose.get(j.id) || '', placeholder: A.composeHint,
|
|
708
|
+
attrs: { maxlength: '4000', rows: '4', 'data-day-input': 'compose' } });
|
|
709
|
+
const ta = f.control;
|
|
710
|
+
ta.addEventListener('input', () => { ui.compose.set(j.id, ta.value); });
|
|
711
|
+
const chips = (sd.suggest || []).length ? el('div', { class: 'td-day__chipsrow', 'data-day': 'suggest' }, el('span', { class: 'td-day__dim' }, A.startFrom),
|
|
712
|
+
...sd.suggest.map((t, i) => button({ label: t.length > 60 ? t.slice(0, 57) + '…' : t, size: 'sm', variant: 'ghost', attrs: { 'data-day-suggest': String(i), title: t },
|
|
713
|
+
onClick: () => { ta.value = t; ui.compose.set(j.id, t); ta.focus(); } }))) : null;
|
|
714
|
+
const verdict = ui.verdict.get(j.id) || null;
|
|
715
|
+
const toggle = review ? tabs({ variant: 'segmented', role: 'radiogroup', ariaLabel: A.verdictLabel, value: verdict, attrs: { 'data-day': 'verdict' },
|
|
716
|
+
items: [{ id: 'approve', label: A.verdict.approve }, { id: 'changes', label: A.verdict.changes }], onChange: (k) => { ui.verdict.set(j.id, k); rerender(); } }) : null;
|
|
717
|
+
const go = () => {
|
|
718
|
+
const text = (ui.compose.get(j.id) || ta.value || '').trim();
|
|
719
|
+
if (!text) { toast({ text: A.sendNeed, tone: 'destructive' }); return; }
|
|
720
|
+
if (review && !verdict) { toast({ text: A.verdictNeed, tone: 'destructive' }); return; }
|
|
721
|
+
sendPress(ctx.ws, j, { text, verdict: review ? verdict : undefined }, to);
|
|
722
|
+
};
|
|
723
|
+
return el('div', { class: 'td-day__compose', 'data-day': 'compose' }, f, chips, toggle,
|
|
724
|
+
el('div', { class: 'td-day__acts' }, button({ label: review ? A.postReview(to) : A.sendTo(to), variant: 'primary', size: 'sm', iconEnd: 'arrow-right', disabled: busy, attrs: { 'data-day-act': 'send' }, onClick: go })),
|
|
725
|
+
el('span', { class: 'td-day__dim' }, A.sendNote));
|
|
726
|
+
}
|
|
727
|
+
/** The four presses of a job (Mark done, Snooze, Waiting on…, Not mine) or the three of a meeting (Mark ready, Remind
|
|
728
|
+
* me, Skip prep). `strong` makes the first one the primary button: every step is checked. */
|
|
729
|
+
function marksBar(ctx, { id, title, kind, due, start, strong }) {
|
|
730
|
+
const { m, ws } = ctx;
|
|
731
|
+
const prep = kind === 'event';
|
|
732
|
+
const busy = ui.busy.has('mark:' + id);
|
|
733
|
+
const st = (m.d && m.d.marks && m.d.marks[id] && m.d.marks[id].state) || null;
|
|
734
|
+
const times = snoozeTimes(m, { due, start });
|
|
735
|
+
const kids = [];
|
|
736
|
+
if (st !== 'done') kids.push(button({ label: prep ? A.ready : A.done, variant: strong ? 'primary' : 'outline', size: 'sm', icon: 'check', disabled: busy, attrs: { 'data-day-mark': 'done' },
|
|
737
|
+
onClick: () => markPress(ws, id, { action: 'done' }, (prep ? A.did.ready : A.did.done)(title)) }));
|
|
738
|
+
if (times.length) {
|
|
739
|
+
const b = button({ label: prep ? A.remind : A.snooze, size: 'sm', iconEnd: 'chevron-down', disabled: busy, attrs: { 'data-day-mark': 'snooze', 'aria-haspopup': 'menu' },
|
|
740
|
+
onClick: () => menu({ anchor: b, ariaLabel: prep ? A.remind : A.snooze, items: times.map(([l, t]) => ({ label: `${l} · ${hhmm(t)}`, attrs: { 'data-day-snooze': hhmm(t) },
|
|
741
|
+
onSelect: () => markPress(ws, id, { action: 'snooze', until: new Date(t).toISOString() }, A.did.snooze(hhmm(t))) })) }) });
|
|
742
|
+
kids.push(b);
|
|
743
|
+
}
|
|
744
|
+
if (!prep) kids.push(button({ label: A.hold, size: 'sm', disabled: busy, attrs: { 'data-day-mark': 'hold', 'aria-expanded': ui.holding === id ? 'true' : 'false' },
|
|
745
|
+
onClick: () => { ui.holding = ui.holding === id ? null : id; ui.holdDraft = { who: '', when: 'h2' }; rerender(); } }));
|
|
746
|
+
const reasons = prep ? ['nothing', 'elsewhere'] : ['no-action', 'someone-else', 'done-elsewhere'];
|
|
747
|
+
if (st !== 'dismissed') {
|
|
748
|
+
const nb = button({ label: prep ? A.skip : A.notMine, size: 'sm', variant: 'ghost', iconEnd: 'chevron-down', disabled: busy, attrs: { 'data-day-mark': 'dismiss', 'aria-haspopup': 'menu' },
|
|
749
|
+
onClick: () => menu({ anchor: nb, ariaLabel: prep ? A.skip : A.notMine, items: reasons.map((w) => ({ label: A.why[w], attrs: { 'data-day-why': w },
|
|
750
|
+
onSelect: () => markPress(ws, id, { action: 'dismiss', why: w }, A.did.dismiss(A.why[w])) })) }) });
|
|
751
|
+
kids.push(el('span', { class: 'td-day__grow', 'aria-hidden': 'true' }), nb);
|
|
752
|
+
}
|
|
753
|
+
return [el('div', { class: 'td-day__marks', 'data-day': 'marks', role: 'group', 'aria-label': title }, ...kids), ui.holding === id && !prep ? holdForm(ctx, id) : null];
|
|
754
|
+
}
|
|
755
|
+
function holdForm(ctx, id) {
|
|
756
|
+
const { m } = ctx;
|
|
757
|
+
const h2 = m.now + 120 * MIN_MS; const end = clockAt(m.now, 17, 30);
|
|
758
|
+
const opts = [['h2', A.holdWhenOpts.h2, h2], ['end', A.holdWhenOpts.end, end]].filter(([, , t]) => t > m.now && sameDay(t, m.now));
|
|
759
|
+
if (!opts.some((o) => o[0] === ui.holdDraft.when) && opts.length) ui.holdDraft.when = opts[0][0];
|
|
760
|
+
const who = field({ label: A.holdWho, value: ui.holdDraft.who, placeholder: A.holdHint, attrs: { maxlength: '80', 'data-day-input': 'hold-who' } });
|
|
761
|
+
who.control.addEventListener('input', () => { ui.holdDraft.who = who.control.value; });
|
|
762
|
+
const go = () => {
|
|
763
|
+
const v = (ui.holdDraft.who || '').trim();
|
|
764
|
+
const o = opts.find((x) => x[0] === ui.holdDraft.when);
|
|
765
|
+
if (!v || v.length > 80) { toast({ text: A.holdNeed, tone: 'destructive' }); return; }
|
|
766
|
+
if (!o) return;
|
|
767
|
+
markPress(ctx.ws, id, { action: 'hold', on: v, until: new Date(o[2]).toISOString() }, A.did.hold(v, hhmm(o[2])));
|
|
768
|
+
};
|
|
769
|
+
return el('div', { class: 'td-day__revise', 'data-day': 'hold' }, who,
|
|
770
|
+
opts.length ? el('div', { class: 'td-day__ch' }, el('span', null, A.holdWhen), tabs({ variant: 'segmented', role: 'radiogroup', ariaLabel: A.holdWhen, value: ui.holdDraft.when,
|
|
771
|
+
attrs: { 'data-day': 'hold-when' }, items: opts.map(([k, l, t]) => ({ id: k, label: `${l} (${hhmm(t)})` })), onChange: (k) => { ui.holdDraft.when = k; rerender(); } })) : null,
|
|
772
|
+
el('div', { class: 'td-day__acts' }, button({ label: A.holdGo, variant: 'primary', size: 'sm', disabled: !opts.length, attrs: { 'data-day-act': 'hold-go' }, onClick: go }),
|
|
773
|
+
button({ label: D.cancel, size: 'sm', variant: 'ghost', onClick: () => { ui.holding = null; rerender(); } })));
|
|
774
|
+
}
|
|
775
|
+
const open0 = (mark) => !mark || !mark.state;
|
|
776
|
+
/** A link drawn as the primary button of a card. */
|
|
777
|
+
function primaryLink(label, href, attrs) {
|
|
778
|
+
return extLink(label, href, { class: 'td-day__a td-day__a--primary', ...(attrs || {}) });
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** The card of a job only the PM can do (PRD A1, C1, C2, M1, D3): the verb and one sentence, From and Why now, the
|
|
782
|
+
* steps, the button that fits, the marks, and the rest of the context folded. */
|
|
783
|
+
function actCard(ctx, j) {
|
|
784
|
+
const { m } = ctx;
|
|
785
|
+
const it = j.item || {};
|
|
786
|
+
const mark = j.mark || null;
|
|
787
|
+
const verb = verbOf(it);
|
|
788
|
+
const todo = it.todo || it.how || it.ask || null;
|
|
789
|
+
const checked = new Set((mark && mark.steps) || []);
|
|
790
|
+
const steps = (Array.isArray(it.steps) ? it.steps : []).map((x) => (typeof x === 'string' ? { t: x } : x)).map((x) => ({ ...x, done: checked.has(x.t) }));
|
|
791
|
+
const refs = Array.isArray(it.refs) ? it.refs : [];
|
|
792
|
+
const canSend = open0(mark) && it.owner === 'pm' && isUrl(j.link) && (!!it.send || ['reply', 'review', 'decide', 'sign-off'].includes(verb));
|
|
793
|
+
const due = it.due ? Date.parse(it.due) : NaN;
|
|
794
|
+
const n = j.goal ? m.goalNo.get(j.goal) : null;
|
|
795
|
+
const open = !mark || !mark.state;
|
|
796
|
+
const allDone = steps.length > 0 && steps.every((x) => x.done);
|
|
797
|
+
const reply = typeof it.reply_needed === 'boolean' ? (it.reply_needed ? D.yes : D.no) : null;
|
|
798
|
+
const more = dl([[D.about, it.about || null], [D.ask, it.ask || null], [D.reply, reply], [D.how, todo === it.how ? null : it.how || null]]);
|
|
799
|
+
return el('article', { class: 'td-day__job td-day__job--act', 'data-day-job': j.id, 'data-day-act-card': verb },
|
|
800
|
+
el('div', { class: 'td-day__jobtop' }, el('span', { class: 'td-day__chips' }, verbChip(verb), el('span', { class: 'td-day__src' }, [srcName(j.source), n ? D.pill.goal(n) : null].filter(Boolean).join(' · '))),
|
|
801
|
+
dueChip(m, mark, due, false)),
|
|
802
|
+
el('h3', { class: 'td-day__acth' }, j.title),
|
|
803
|
+
todo ? el('p', { class: 'td-day__todo', 'data-day': 'todo' }, todo) : null,
|
|
804
|
+
markBanner(ctx, j.id, mark, false),
|
|
805
|
+
dl([[D.where, A.from(it.who, it.where)], [D.why, it.why_now || null]]),
|
|
806
|
+
threadBlock(it.thread),
|
|
807
|
+
steps.length ? el('div', { class: 'td-day__phase' }, el('h4', null, A.howTitle(steps.filter((x) => x.done).length, steps.length)), stepChecks(ctx, j.id, steps, { refs })) : null,
|
|
808
|
+
refsBlock(refs, steps),
|
|
809
|
+
canSend ? composer(ctx, j, verb) : null,
|
|
810
|
+
open ? el('div', { class: 'td-day__acts', 'data-day': 'cta' },
|
|
811
|
+
(canSend ? extLink : primaryLink)(A.open[j.source] || A.openLink, j.link, { 'data-day-link': 'conversation' }),
|
|
812
|
+
j.item && j.item.owner === 'pm' ? button({ label: A.draft, size: 'sm', disabled: ui.busy.has('draft:' + j.id), attrs: { 'data-day-act': 'draft' }, onClick: () => draftPress(ctx.ws, j) }) : null) : null,
|
|
813
|
+
marksBar(ctx, { id: j.id, title: j.title, kind: 'item', due, strong: allDone }),
|
|
814
|
+
more ? el('details', { class: 'td-day__fold', 'data-day': 'more' }, el('summary', null, A.more), more, note(A.readOnly)) : null);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Free minutes from now to the meeting's start, less the other meetings in between. */
|
|
818
|
+
function freeBefore(m, e) {
|
|
819
|
+
if (!Number.isFinite(e.s) || e.s <= m.now) return 0;
|
|
820
|
+
let busy = 0;
|
|
821
|
+
for (const o of m.events) {
|
|
822
|
+
if (o.id === e.id) continue;
|
|
823
|
+
const a = Math.max(o.s, m.now); const b = Math.min(o.f, e.s);
|
|
824
|
+
if (b > a) busy += b - a;
|
|
825
|
+
}
|
|
826
|
+
return Math.max(0, Math.round((e.s - m.now - busy) / MIN_MS));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** A meeting's prep (PRD A1, C1, M1, M6, D2): the verb and the prep line, how ready the PM is, the Before steps to
|
|
830
|
+
* check off, the button that fits, and the marks. */
|
|
831
|
+
function prepBlock(ctx, e) {
|
|
832
|
+
const { m } = ctx;
|
|
833
|
+
const steps = e.before.map((st) => ({ t: String(st.t || ''), who: st.who, done: st.done, ref: st.ref, note: !!st.note, fixed: !!(e.steps && e.steps.before && e.steps.before.find((x) => x.t === st.t && x.done)) }));
|
|
834
|
+
const refs = Array.isArray(e.refs) ? e.refs : [];
|
|
835
|
+
const d = steps.filter((x) => x.done).length; const total = steps.length;
|
|
836
|
+
const left = total - d;
|
|
837
|
+
const free = freeBefore(m, e);
|
|
838
|
+
const fit = !total ? null : !left ? A.fit.ok : free >= left * 15 ? A.fit.time(left, free) : A.fit.short(left, free);
|
|
839
|
+
const open = !e.mark || !e.mark.state;
|
|
840
|
+
const link = Array.isArray(e.links) ? e.links.find((l) => isUrl(l.url)) : null;
|
|
841
|
+
return el('div', { class: 'td-day__prep', 'data-day': 'prep' },
|
|
842
|
+
el('div', { class: 'td-day__jobtop' }, verbChip('prepare'), dueChip(m, e.mark, e.s, true)),
|
|
843
|
+
e.prep ? el('p', { class: 'td-day__todo', 'data-day': 'todo' }, e.prep) : null,
|
|
844
|
+
markBanner(ctx, e.id, e.mark, true),
|
|
845
|
+
total ? el('div', { class: 'td-day__ready', 'data-day': 'readiness' }, meter({ done: d, total, label: A.readiness(d, total) }), fit ? el('span', { class: 'td-day__dim' }, fit) : null) : null,
|
|
846
|
+
total ? el('div', { class: 'td-day__phase' }, el('h4', null, A.beforeTitle(e.time)), stepChecks(ctx, e.id, steps, { refs, mark: e.mark })) : null,
|
|
847
|
+
refsBlock(refs, steps),
|
|
848
|
+
open && link && left ? el('div', { class: 'td-day__acts', 'data-day': 'cta' }, primaryLink((link.label || link.url) + ' ↗', link.url, { 'data-day-link': 'prep' })) : null,
|
|
849
|
+
marksBar(ctx, { id: e.id, title: e.title, kind: 'event', start: e.s, strong: total > 0 && !left }));
|
|
850
|
+
}
|
|
851
|
+
|
|
526
852
|
/* ── the right column ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
527
853
|
|
|
528
854
|
function dhead(kind, title) {
|
|
@@ -563,27 +889,31 @@ function eventDetail(ctx, e) {
|
|
|
563
889
|
el('p', { class: 'td-day__dim td-day__mono' }, `${e.time}${e.end ? '–' + hhmm(e.end) : ''}${e.with ? ' · ' + e.with : ''}${e.ended ? ' · ' + D.ended : ''}`),
|
|
564
890
|
e.with ? peopleLinks(ctx.ws, e.with, { 'data-day': 'people' }) : null,
|
|
565
891
|
e.purpose ? el('p', null, el('b', null, D.purpose + ': '), e.purpose) : null,
|
|
566
|
-
e.
|
|
892
|
+
e.needsPrep ? prepBlock(ctx, e) : null,
|
|
567
893
|
ini ? el('div', { class: 'td-day__ini', 'data-day': 'initiative' }, el('span', null, D.initiative + ': ', el('b', { class: 'td-day__mono' }, ini.id), ini.why ? el('span', { class: 'td-day__dim' }, ' (' + D.suggested(ini.why) + ')') : null),
|
|
568
894
|
ctx.openIni ? button({ label: D.openInitiative, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'open-initiative' }, onClick: () => ctx.openIni(ini.id) }) : null) : null,
|
|
569
895
|
tie,
|
|
570
896
|
memoryBlock(e.id),
|
|
571
|
-
!e.ended ? el('div', { class: 'td-day__acts' }, e.prep ? pill(D.needPrep, 'warn') : steps.before && steps.before.length ? pill(D.nothingToPrep, 'ok') : null) : null,
|
|
572
897
|
after,
|
|
573
898
|
notesBlock(e, { ws: ctx.ws, initiative: ini }),
|
|
574
|
-
|
|
899
|
+
// with prep, the Before steps are the checklist above; During, After and the mail fold away (PRD D2)
|
|
900
|
+
!e.ended && e.needsPrep && ((steps.during || []).length || (steps.after || []).length || mailBlock)
|
|
901
|
+
? el('details', { class: 'td-day__fold', 'data-day': 'more' }, el('summary', null, A.moreMeeting((steps.during || []).length, (steps.after || []).length)),
|
|
902
|
+
list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock) : null,
|
|
903
|
+
!e.ended && !e.needsPrep ? [list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock] : null,
|
|
575
904
|
Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null,
|
|
576
905
|
];
|
|
577
906
|
}
|
|
578
907
|
|
|
579
908
|
function jobCard(ctx, j) {
|
|
909
|
+
if (j.status === 'you') return actCard(ctx, j);
|
|
580
910
|
const { m, ws } = ctx;
|
|
581
911
|
const t = j.task;
|
|
582
912
|
const it = j.item || {};
|
|
583
913
|
const n = j.goal ? m.goalNo.get(j.goal) : null;
|
|
584
914
|
const busy = t && ui.busy.has(t.id);
|
|
585
915
|
const revs = t && Array.isArray(t.revisions) ? t.revisions.length : 0;
|
|
586
|
-
const statusNote = { notStarted: D.notTask, waiting: D.waitingNote, working: D.workingNote, sending: D.sendingNote
|
|
916
|
+
const statusNote = { notStarted: D.notTask, waiting: D.waitingNote, working: D.workingNote, sending: D.sendingNote }[j.status]
|
|
587
917
|
|| (j.status === 'done' && j.kind === 'auto' ? D.autoNote : null);
|
|
588
918
|
const draft = t && t.draft ? el('div', { class: 'td-day__draftbox', 'data-day': 'draft' }, el('h4', null, D.draft),
|
|
589
919
|
t.draft.summary ? el('p', null, t.draft.summary) : null,
|
|
@@ -902,6 +1232,6 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
|
|
|
902
1232
|
|
|
903
1233
|
export function daySig(ws) {
|
|
904
1234
|
const c = cache.get(ws) || {};
|
|
905
|
-
return JSON.stringify([forced, ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], c.error, runs.get(ws) || null, [...seen].length])
|
|
1235
|
+
return JSON.stringify([forced, ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], [...ui.groupsOpen], ui.holding, ui.holdDraft.when, [...ui.verdict], c.error, runs.get(ws) || null, [...seen].length])
|
|
906
1236
|
+ dataSig(c.data) + goalsSig() + agendaSig();
|
|
907
1237
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to
|
|
1
|
+
/* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 6).
|
|
2
2
|
*
|
|
3
3
|
* The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
|
|
4
4
|
* header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
|
|
@@ -28,8 +28,10 @@
|
|
|
28
28
|
* calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
|
|
29
29
|
* or chosen by the PM. Change opens a sheet for each.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* MINIMIZED AT THE BOTTOM RIGHT (Run 6). Take notes on a calendar event starts listening at once (listenNow: no chooser) and
|
|
32
|
+
* keeps the note as a pill at the bottom right (pm/note-dock.js). The pill opens the note as a pop-up, which renderInline
|
|
33
|
+
* draws in one column. While the pop-up shows a note, the Notes tab says so and does not draw it a second time.
|
|
34
|
+
* Open in Notes shows it on the full screen.
|
|
33
35
|
*
|
|
34
36
|
* Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
|
|
35
37
|
* GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
|
|
@@ -67,7 +69,8 @@ let busy = '';
|
|
|
67
69
|
let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
|
|
68
70
|
let people = null; // { ws, names } — the repository's people, offered in the line editor
|
|
69
71
|
let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
|
|
70
|
-
let inlineSwitch = null; // the note
|
|
72
|
+
let inlineSwitch = null; // the note the pop-up is switching to (renderInline), so a redraw does not start it twice
|
|
73
|
+
let popped = null; // the note the pop-up shows (pm/note-dock.js), which the Notes tab then does not draw
|
|
71
74
|
const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
|
|
72
75
|
|
|
73
76
|
const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|
|
@@ -1021,7 +1024,12 @@ export function renderBoard(inner, ctx) {
|
|
|
1021
1024
|
if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
|
|
1022
1025
|
const n = currentId ? cache.get(currentId) : null;
|
|
1023
1026
|
if (currentId && !n) { loadNote(ctx, currentId); wrap.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); }
|
|
1024
|
-
else if (
|
|
1027
|
+
else if (n && popped === n.id) {
|
|
1028
|
+
wrap.appendChild(el('div', { class: 'nt-start', 'data-nt-popped': '' },
|
|
1029
|
+
el('h2', { class: 'nt-h' }, n.title || 'Untitled note'),
|
|
1030
|
+
el('p', { class: 'nt-hint' }, 'This note is open in the pop-up at the bottom right.'),
|
|
1031
|
+
button({ label: 'Show it here', variant: 'primary', onClick: () => openFull(ctx, n.id), attrs: { 'data-nt-unpop': '' } })));
|
|
1032
|
+
} else if (!n) {
|
|
1025
1033
|
wrap.appendChild(el('div', { class: 'nt-start' },
|
|
1026
1034
|
el('h2', { class: 'nt-h' }, 'Notes'),
|
|
1027
1035
|
el('p', { class: 'nt-hint', 'data-nt-about': '' }, 'Take notes in a meeting. PMOS listens on this machine, catches the decisions, actions and questions, and checks them against your repository. No audio leaves this machine, and notes are kept outside every repository.'),
|
|
@@ -1055,17 +1063,39 @@ function keepFocus() {
|
|
|
1055
1063
|
}
|
|
1056
1064
|
async function openFull(ctx, id) {
|
|
1057
1065
|
await flushSave(ctx);
|
|
1058
|
-
|
|
1066
|
+
popped = null;
|
|
1067
|
+
const [c, d] = await Promise.all([import('./panel-cockpit.js'), import('./note-dock.js')]);
|
|
1068
|
+
d.minimize();
|
|
1059
1069
|
pendingOpen = id;
|
|
1060
1070
|
c.openView('notes');
|
|
1061
1071
|
}
|
|
1062
1072
|
|
|
1073
|
+
/** The note the pop-up shows (pm/note-dock.js), or null. */
|
|
1074
|
+
export function setPopped(id) { popped = id || null; }
|
|
1075
|
+
/** The note's title as last read or typed, or ''. */
|
|
1076
|
+
export const titleOf = (id) => (editor && editor.id === id && editor.title.value.trim()) || ((cache.get(id) || {}).title) || '';
|
|
1077
|
+
/** When the note that is listening started, or null. */
|
|
1078
|
+
export const liveStarted = () => (audio && audio.state === 'on' ? audio.started : speech && speech.mode === 'record' && speech.state === 'on' ? speech.started : null);
|
|
1079
|
+
/** Stop the note that is listening, as its Stop does. */
|
|
1080
|
+
export const stopListening = (ctx) => stopRecording(ctx);
|
|
1081
|
+
/** Start listening on this note at once, with no chooser (Run 6, Take notes): the microphone and the meeting when Whisper is set
|
|
1082
|
+
* up, else the microphone through the browser's on-device recognition. False when the note cannot listen (private, gone). */
|
|
1083
|
+
export async function listenNow(ctx, id) {
|
|
1084
|
+
if (id !== currentId) await selectNote(ctx, id);
|
|
1085
|
+
let n = cache.get(id);
|
|
1086
|
+
if (!n) { await loadNote(ctx, id); n = cache.get(id); }
|
|
1087
|
+
if (!n || n.private) return false;
|
|
1088
|
+
const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
|
|
1089
|
+
if (eng.whisper && eng.whisper.ready) await startAudio(ctx, n, ['mic', 'meeting']); else await startSpeech(ctx, n, 'record');
|
|
1090
|
+
return listeningId() === id;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1063
1093
|
/** The note that is listening now, or null. */
|
|
1064
1094
|
export const listeningId = () => (audio && audio.id) || (speech && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking') ? speech.id : null);
|
|
1065
1095
|
|
|
1066
|
-
/** One note drawn
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1096
|
+
/** One note drawn in the pop-up (meeting-notes Run 6, pm/note-dock.js): the same note, editor and listening as the Notes tab,
|
|
1097
|
+
* in one column, with Open in Notes for the full screen. It never stops a meeting that is being heard: when another note is
|
|
1098
|
+
* listening it draws nothing and returns false. */
|
|
1069
1099
|
export function renderInline(host, ctx, id) {
|
|
1070
1100
|
if (ctx.FIXTURE) return false;
|
|
1071
1101
|
const other = listeningId();
|