create-pmos 0.73.0 → 0.75.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/scripts/workflow-state.js +45 -4
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/initiatives-screen.md +10 -0
- package/workspace/guide/today.md +22 -1
- 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/lib/engine/workflow-state.js +45 -4
- package/workspace/lib/jev-documents.js +224 -0
- package/workspace/lib/routes-initiatives.js +76 -3
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +34 -0
- package/workspace/public/pm/initiatives-api.js +8 -2
- package/workspace/public/pm/initiatives-copy.js +19 -0
- package/workspace/public/pm/initiatives-ui.js +88 -21
- package/workspace/public/pm/today-copy.js +82 -4
- package/workspace/public/pm/today-day.js +349 -19
- 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
|
}
|
package/workspace/server.js
CHANGED
|
@@ -98,6 +98,7 @@ const briefLib = require('./lib/brief');
|
|
|
98
98
|
const calMemLib = require('./lib/calendar-memory');
|
|
99
99
|
const goalsLib = require('./lib/goals');
|
|
100
100
|
const dayRunLib = require('./lib/day-run');
|
|
101
|
+
const dayMarksLib = require('./lib/day-marks');
|
|
101
102
|
const todayLib = require('./lib/today');
|
|
102
103
|
const prepareQueue = require('./lib/prepare-queue');
|
|
103
104
|
const harness = require('./lib/harness-parse');
|
|
@@ -3842,7 +3843,7 @@ function startDayTask(run, t, now) {
|
|
|
3842
3843
|
}
|
|
3843
3844
|
/** My work's Run agent (initiative my-work, PRD R5): add the briefed task to today's run and start what may start. A day
|
|
3844
3845
|
* with no run gets one, marked opened by My work, in the repository the PM pressed from. Returns { ok, run } or why not. */
|
|
3845
|
-
function myWorkBrief(spec, wsId) {
|
|
3846
|
+
function myWorkBrief(spec, wsId, add) {
|
|
3846
3847
|
const now = Date.now(); const date = briefLib.localDate(now);
|
|
3847
3848
|
let run = readDayRun(date);
|
|
3848
3849
|
if (!run) {
|
|
@@ -3850,7 +3851,8 @@ function myWorkBrief(spec, wsId) {
|
|
|
3850
3851
|
if (!w) return { ok: false, status: 400, error: 'no workspace to run the agent in' };
|
|
3851
3852
|
run = { ...dayRunLib.newRun(date, now), ws: w.id, opened_by: 'my-work' };
|
|
3852
3853
|
}
|
|
3853
|
-
|
|
3854
|
+
// `add` is how the task joins the run: briefed by default; Send from a Your day card adds the PM's own text (addSend)
|
|
3855
|
+
const a = add ? add(run, spec, now) : dayRunLib.addBriefed(run, spec);
|
|
3854
3856
|
if (!a.ok) return { ok: false, status: 409, error: a.error };
|
|
3855
3857
|
const next = startDayTasks(a.run, now);
|
|
3856
3858
|
if (!writeDayRun(next)) return { ok: false, status: 500, error: 'could not save — nothing changed' };
|
|
@@ -3940,6 +3942,30 @@ function writeCalMemory(m) {
|
|
|
3940
3942
|
fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(tmp, JSON.stringify(m) + '\n'); fs.renameSync(tmp, f);
|
|
3941
3943
|
}
|
|
3942
3944
|
|
|
3945
|
+
/* The PM's marks on today's items and meetings (initiative today-act-prepare, PRD M5): done or ready, snoozed, waiting on
|
|
3946
|
+
* someone, not mine, and the steps checked. goals/<date>.marks.json, written only by POST /api/proactive/day/mark. Kept outside the
|
|
3947
|
+
* proactive, prepare and day-run blocks, whose boundary tests list the only files they write. */
|
|
3948
|
+
const dayMarksFile = (date) => path.join(GOALS_DIR(), date + '.marks.json');
|
|
3949
|
+
function readDayMarks(date) {
|
|
3950
|
+
let raw = null;
|
|
3951
|
+
try { raw = JSON.parse(fs.readFileSync(dayMarksFile(date), 'utf8')); } catch { /* none yet */ }
|
|
3952
|
+
return dayMarksLib.readMarks(raw, date);
|
|
3953
|
+
}
|
|
3954
|
+
function writeDayMarks(rec) {
|
|
3955
|
+
try {
|
|
3956
|
+
fs.mkdirSync(GOALS_DIR(), { recursive: true });
|
|
3957
|
+
const f = dayMarksFile(rec.date); const tmp = `${f}.${process.pid}.tmp`;
|
|
3958
|
+
fs.writeFileSync(tmp, JSON.stringify(rec) + '\n'); fs.renameSync(tmp, f); return true;
|
|
3959
|
+
} catch (e) { warnWriteFail('day marks', e); return false; }
|
|
3960
|
+
}
|
|
3961
|
+
/** The ids a mark may name: today's brief items ('item') and calendar events ('event'). */
|
|
3962
|
+
function dayMarkIds(brief) {
|
|
3963
|
+
const known = new Map();
|
|
3964
|
+
if (!brief) return known;
|
|
3965
|
+
for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) known.set(x.id, 'item');
|
|
3966
|
+
for (const e of brief.calendar || []) known.set(e.id, 'event');
|
|
3967
|
+
return known;
|
|
3968
|
+
}
|
|
3943
3969
|
// ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
|
|
3944
3970
|
// The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
|
|
3945
3971
|
// `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
|
|
@@ -7774,7 +7800,7 @@ async function handleRequest(req, res) {
|
|
|
7774
7800
|
: !bs ? { session: null, why: 'the morning-brief session did not start.' }
|
|
7775
7801
|
: (!!bs.ended_at || !bsess || bsess.state === 'exited') ? { session: bs.id, why: (bsess && bsess.note) || 'the session ended without submitting a brief.' }
|
|
7776
7802
|
: briefSessionDone(run, date, now) === 'idle' ? { session: bs.id, idle: true, why: 'the session has printed nothing for two minutes and has not submitted a brief.' } : null;
|
|
7777
|
-
return json(res, { ok: true, ws: w ? w.id : null, today: date, now: new Date(now).toISOString(), run, stage: dayRunLib.stage(run, brief), brief, plan, cards_error: cardsError, read_failed: readFailed,
|
|
7803
|
+
return json(res, { ok: true, ws: w ? w.id : null, today: date, now: new Date(now).toISOString(), run, stage: dayRunLib.stage(run, brief), brief, plan, marks: dayMarksLib.effective(readDayMarks(date), now), cards_error: cardsError, read_failed: readFailed,
|
|
7778
7804
|
settings: { briefSources: st.briefSources, scopes: st.scopes, placeRepos: st.placeRepos, dayCheck: st.dayCheck, websites: st.websites }, sources,
|
|
7779
7805
|
// one day for every repository: the repositories it can be narrowed to, and the repository of each tagged item
|
|
7780
7806
|
repos: [...workspaces.values()].map((x) => ({ id: x.id, name: x.name || path.basename(x.root) })),
|
|
@@ -7797,6 +7823,58 @@ async function handleRequest(req, res) {
|
|
|
7797
7823
|
if (!writeCalMarks(r.marks)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
|
|
7798
7824
|
return json(res, { ok: true });
|
|
7799
7825
|
}
|
|
7826
|
+
/* The PM's mark on an item or a meeting of today's brief (initiative today-act-prepare, PRD M1 to M6). It needs no day
|
|
7827
|
+
* run: a mark is about the brief, and only this press writes goals/<date>.marks.json. The answer carries `prev`, the
|
|
7828
|
+
* mark before the press, so the page's Undo can put it back with { action: 'restore', mark: prev }. */
|
|
7829
|
+
if (p === '/api/proactive/day/mark' && req.method === 'POST') {
|
|
7830
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7831
|
+
const now = Date.now(); const date = briefLib.localDate(now);
|
|
7832
|
+
const stored = briefOfDay(date);
|
|
7833
|
+
const brief = stored && stored.date === date ? stored : null;
|
|
7834
|
+
if (!brief) return json(res, { ok: false, error: 'no brief today — press Prepare my day first' }, 409);
|
|
7835
|
+
const r = dayMarksLib.edit(readDayMarks(date), body, { now, known: dayMarkIds(brief) });
|
|
7836
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, /no item or meeting with that id/.test(r.error) ? 404 : 400);
|
|
7837
|
+
if (!writeDayMarks(r.rec)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
|
|
7838
|
+
return json(res, { ok: true, mark: r.mark, prev: r.prev, marks: dayMarksLib.effective(r.rec, now) });
|
|
7839
|
+
}
|
|
7840
|
+
/* "Have the agent draft it" on a job the PM owns (PRD C2): one briefed task, kind approve, tied to the item, through the
|
|
7841
|
+
* My work path. The day-task session drafts and sends nothing; the PM approves or revises the draft as for any job. */
|
|
7842
|
+
if (p === '/api/proactive/day/draft' && req.method === 'POST') {
|
|
7843
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7844
|
+
const now = Date.now(); const date = briefLib.localDate(now);
|
|
7845
|
+
const stored = briefOfDay(date);
|
|
7846
|
+
const brief = stored && stored.date === date ? stored : null;
|
|
7847
|
+
const x = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])].find((y) => y.id === String(body.item || '')) : null;
|
|
7848
|
+
if (!x) return json(res, { ok: false, error: 'no item with that id in today\'s brief' }, 404);
|
|
7849
|
+
if (x.owner !== 'pm') return json(res, { ok: false, error: 'the agent already does this job: approve or revise its draft' }, 409);
|
|
7850
|
+
const verb = { reply: 'reply', review: 'review notes', decide: 'answer', 'sign-off': 'sign-off comment', read: 'summary', do: 'draft' }[x.verb] || (x.reply_needed ? 'reply' : 'draft');
|
|
7851
|
+
const spec = { id: 't-' + x.id, item_id: x.id, title: x.title, source: x.source, link: x.link || null,
|
|
7852
|
+
instruction: `Draft the PM's ${verb} for this item: ${x.todo || x.how || x.ask || x.title}. Draft only. Send nothing: the PM approves it first.`,
|
|
7853
|
+
context: { from: x.who || null, where: x.where || null, about: x.about || null, ask: x.ask || null, how: x.how || null, why_now: x.why_now || null } };
|
|
7854
|
+
const r = myWorkBrief(spec, body.ws);
|
|
7855
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, r.status || 400);
|
|
7856
|
+
return json(res, { ok: true, task: r.task });
|
|
7857
|
+
}
|
|
7858
|
+
/* Send from the card (PRD F1 to F3): the PM wrote the reply or the review in the card and pressed Send. The press is the
|
|
7859
|
+
* approval, so the task goes straight to sending with the PM's text, and one send session posts it as written to the
|
|
7860
|
+
* place the item names. Nothing is sent without this press (D65 propose-only). */
|
|
7861
|
+
if (p === '/api/proactive/day/send' && req.method === 'POST') {
|
|
7862
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7863
|
+
const now = Date.now(); const date = briefLib.localDate(now);
|
|
7864
|
+
const stored = briefOfDay(date);
|
|
7865
|
+
const brief = stored && stored.date === date ? stored : null;
|
|
7866
|
+
const x = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])].find((y) => y.id === String(body.item || '')) : null;
|
|
7867
|
+
if (!x) return json(res, { ok: false, error: 'no item with that id in today\'s brief' }, 404);
|
|
7868
|
+
if (x.owner !== 'pm') return json(res, { ok: false, error: 'the agent already does this job: approve or revise its draft' }, 409);
|
|
7869
|
+
if (!x.link) return json(res, { ok: false, error: 'this item has no link, so there is no place to send to — open it and answer there' }, 409);
|
|
7870
|
+
const review = !!(x.send && x.send.review);
|
|
7871
|
+
if (review && !['approve', 'changes'].includes(body.verdict)) return json(res, { ok: false, error: 'a review needs a verdict: approve or changes' }, 400);
|
|
7872
|
+
const spec = { id: 't-' + x.id, item_id: x.id, title: x.title, source: x.source, link: x.link, to: (x.send && x.send.to) || x.where || null,
|
|
7873
|
+
text: body.text, verdict: review ? body.verdict : null };
|
|
7874
|
+
const r = myWorkBrief(spec, body.ws, dayRunLib.addSend);
|
|
7875
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, r.status || 400);
|
|
7876
|
+
return json(res, { ok: true, task: r.task });
|
|
7877
|
+
}
|
|
7800
7878
|
if (p.startsWith('/api/proactive/day/') && req.method === 'POST') {
|
|
7801
7879
|
const verb = p.slice('/api/proactive/day/'.length);
|
|
7802
7880
|
if (!['deliver', 'sent', 'fail', 'decide', 'check'].includes(verb)) return json(res, { ok: false, error: 'unknown day-run action' }, 404);
|