create-pmos 0.152.0 → 0.154.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/briefing-cards.md +1 -1
- package/workspace/guide/console-calendar.md +28 -5
- package/workspace/guide/console3-sessions.md +5 -1
- package/workspace/lib/calendar-layers.js +130 -11
- package/workspace/lib/calendar-links.js +64 -0
- package/workspace/lib/engine/boards.js +4 -2
- package/workspace/lib/notes.js +22 -4
- package/workspace/lib/routes-boards.js +51 -1
- package/workspace/lib/routes-calendar-views.js +42 -3
- package/workspace/lib/routes-notes.js +2 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +105 -6
- package/workspace/public/pm/c3-demo-cal4.js +43 -3
- package/workspace/public/pm/c3-demo.js +1 -0
- package/workspace/public/pm/c3-dock.js +8 -15
- package/workspace/public/pm/c3-notes.js +97 -13
- package/workspace/public/pm/c3-shell.js +5 -6
- package/workspace/public/pm/c3-terminal-icons.js +0 -1
- package/workspace/public/pm/console3.css +17 -4
- package/workspace/test-lists/c3-meeting-board.json +5 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* POST /api/notes/bullet { id, bullet, cat?, drop?, text?, owner?, due? } → { ok, bullet, summary } the PM's correction to one bullet (Run 8)
|
|
11
11
|
* POST /api/notes/new { title?, meeting?, initiative? } → { ok, note, reused } a calendar event that already has a note
|
|
12
12
|
* gets that note back (reused: true), so one meeting keeps one note (Run 4)
|
|
13
|
-
* POST /api/notes/link { id, meeting?, initiative?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
|
|
13
|
+
* POST /api/notes/link { id, meeting?, initiative?, board?, portfolio?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
|
|
14
14
|
* GET /api/notes/initiatives?id=…&ws=… → { ok, open: [{ id, title }], pick } the repository's open initiatives, and the local suggestion
|
|
15
15
|
* POST /api/notes/save { id, title?, body? } → { ok, updated_at, versions, added } each new line of the notes is also
|
|
16
16
|
* a transcript line, source 'typed' (`added`, Run 8)
|
|
@@ -691,7 +691,7 @@ async function handle(req, res, url, ctx) {
|
|
|
691
691
|
return done(r);
|
|
692
692
|
}
|
|
693
693
|
// from_event: the initiative is the calendar event's own, and never replaces the PM's choice
|
|
694
|
-
if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, by: body.from_event === true ? 'event' : 'pm' }));
|
|
694
|
+
if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, board: body.board, portfolio: typeof body.portfolio === 'boolean' ? body.portfolio : undefined, by: body.from_event === true ? 'event' : 'pm' }));
|
|
695
695
|
if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
|
|
696
696
|
if (p === '/api/notes/save') {
|
|
697
697
|
const r = notes.save(dir, { id, title: body.title, body: body.body });
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.154.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.154.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -21,6 +21,7 @@ import { el } from './dom.js';
|
|
|
21
21
|
import { c3ic } from './pm-work-view.js';
|
|
22
22
|
import { button, ensureComponentsCss } from './primitives.js';
|
|
23
23
|
import { toast } from './c3-toast.js';
|
|
24
|
+
import { pmPost } from './http.js';
|
|
24
25
|
import { notesView, openNote, noteOfEvent, primeNotes } from './c3-notes.js'; // Calendar › Notes (initiative c3-notes-place)
|
|
25
26
|
import { onNotes } from './notes-links.js';
|
|
26
27
|
|
|
@@ -36,7 +37,8 @@ const KIND = {
|
|
|
36
37
|
};
|
|
37
38
|
const RANK = { milestone: 0, release: 1, report: 2, due: 3, own: 4 };
|
|
38
39
|
const TONE = { bad: 'Late or at risk of missing', warn: 'At risk', ok: 'On track' };
|
|
39
|
-
const REF = { initiative: 'Open the initiative', 'plan-item': 'Open the initiative', item: 'Open in Work', release: 'Open the release', report: 'Open in Reports'
|
|
40
|
+
const REF = { initiative: 'Open the initiative', 'plan-item': 'Open the initiative', item: 'Open in Work', release: 'Open the release', report: 'Open in Reports',
|
|
41
|
+
jira: 'Open the sprint', ask: 'Open in Triage', portfolio: 'Open the Portfolio' };
|
|
40
42
|
const CTXWORD = { decision: 'Decision', action: 'Action', moved: 'Moved', prepared: 'Prepared', todo: 'To prepare' };
|
|
41
43
|
const NO_SUGGESTION = 'Free, nothing specific to suggest';
|
|
42
44
|
/* the tidy-up suggestions (lib/calendar-tidy.js): the severity groups in order, and each rule in words */
|
|
@@ -528,6 +530,8 @@ function openRef(ui, ref, extra) {
|
|
|
528
530
|
if (ref.type === 'initiative') { if (!h.goInitiative(ref.id)) h.openInit(host, r, ref.id, extra.title); return; }
|
|
529
531
|
if (ref.type === 'plan-item' || ref.type === 'release') { const ini = extra.initiative || ref.initiative; if (!(ini && h.goInitiative(ini))) h.gotoTab('init'); return; }
|
|
530
532
|
if (ref.type === 'item') { h.gotoTab('work'); return; }
|
|
533
|
+
if (ref.type === 'jira' || ref.type === 'ask') { openBoardTab(ui, ref.board, ref.type === 'jira' ? 'sprints' : 'triage'); return; }
|
|
534
|
+
if (ref.type === PORTFOLIO) { openBoardTab(ui, null); return; }
|
|
531
535
|
if (ref.type === 'report') h.gotoTab('rep');
|
|
532
536
|
}
|
|
533
537
|
|
|
@@ -585,7 +589,8 @@ function meetingDetail(ui, hit) {
|
|
|
585
589
|
else if (past) body = [list(['decision', 'action'], 'What came out of it', 'out', 'No decision or action is recorded.'), list(['moved'], 'What moved after it', 'moved', 'Nothing moved within 48 hours that refers to it.')];
|
|
586
590
|
else body = upcomingBody(ui, rec, items);
|
|
587
591
|
const b = !past && rec && rec.brief ? rec.brief : null;
|
|
588
|
-
const
|
|
592
|
+
const lv = !past && rec ? rec.level || null : null;
|
|
593
|
+
const iniId = lv && lv.kind !== 'initiative' ? null : typeof e.initiative === 'string' && !(lv && lv.by === 'pm') ? e.initiative : b && b.initiative ? b.initiative.id : null;
|
|
589
594
|
const iniTitle = iniId ? (b && b.initiative && b.initiative.id === iniId && b.initiative.title) || (((ui.d.gantt || []).find((g) => g.id === iniId) || {}).title) || iniId : null;
|
|
590
595
|
const iniStatus = b && b.initiative && b.initiative.id === iniId ? b.initiative.status : (((ui.d.gantt || []).find((g) => g.id === iniId) || {}).status || null);
|
|
591
596
|
const sources = b && b.sources && b.sources.length ? 'Put together from ' + listWords(b.sources) + '. Each line opens its source. ' : '';
|
|
@@ -596,6 +601,7 @@ function meetingDetail(ui, hit) {
|
|
|
596
601
|
el('div', { class: 'chips2', 'data-cal3': 'meeting-chips' }, homeChip('Calendar'), ...peopleOf(e).map((n) => el('span', { class: 'tag', 'data-cal3': 'person' }, n)),
|
|
597
602
|
iniId ? el('span', { class: 'tag', 'data-cal3': 'meeting-initiative' }, 'Initiative: ' + iniTitle) : null,
|
|
598
603
|
iniStatus && STATUS_TONE[iniStatus] ? el('span', { class: 'tag cal3-tone-' + STATUS_TONE[iniStatus] }, iniStatus) : null),
|
|
604
|
+
past || !M.ctx ? null : levelRow(ui, e, lv),
|
|
599
605
|
noteLink(ui, e),
|
|
600
606
|
...body,
|
|
601
607
|
el('p', { class: 'dim cal3-note', 'data-cal3': past ? 'past-note' : 'sources' }, past ? 'Only what PMOS holds a record for: items created or closed within 48 hours of the meeting. Each line opens its source.' : sources + 'PMOS proposes changes. It never edits your calendar.'),
|
|
@@ -611,13 +617,106 @@ const ctxBox = (key, title, ...kids) => el('div', { class: 'cal3-ctx', 'data-cal
|
|
|
611
617
|
const ul = (rows) => el('ul', { class: 'cal3-cl', role: 'list' }, ...rows);
|
|
612
618
|
const ci = (attrs, ...kids) => el('li', { class: 'cal3-ci', ...attrs }, ...kids);
|
|
613
619
|
|
|
620
|
+
/* ── what a meeting is about: the Portfolio, a board or an initiative (initiative c3-meeting-board) ──
|
|
621
|
+
* The switch says which level the card reads and why PMOS chose it (lib/calendar-layers.js levelOf). Pressing a level links every
|
|
622
|
+
* occurrence of the meeting to it (POST /api/calendar-links); Board and Initiative ask which one when there is more than one. */
|
|
623
|
+
// the meeting's level, not the old initiative list's Portfolio mode (cuts.test.js keeps that mode out)
|
|
624
|
+
const PORTFOLIO = 'portfolio';
|
|
625
|
+
const LEVELS = [[PORTFOLIO, 'Portfolio', 'Read every board'], ['board', 'Board', 'Read one board and its sprint'], ['initiative', 'Initiative', 'Read one initiative']];
|
|
626
|
+
const linkPick = new Map(); // event id → the level whose picker is open
|
|
627
|
+
|
|
628
|
+
function levelRow(ui, e, lv) {
|
|
629
|
+
const open = linkPick.get(e.id) || null;
|
|
630
|
+
const seg = el('div', { class: 'seg cal3-lvseg', role: 'group', 'aria-label': 'This meeting is about', 'data-cal3': 'level' },
|
|
631
|
+
...LEVELS.map(([k, label, title]) => { const x = button({ variant: 'ghost', attrs: { class: 'cal3-b', 'aria-pressed': String(!!lv && lv.kind === k), 'data-cal3-level': k, title },
|
|
632
|
+
onClick: () => pickLevel(ui, e, lv, k) }); x.replaceChildren(el('span', null, label)); return x; }));
|
|
633
|
+
const what = !lv ? 'Not linked yet. Pick a level and PMOS reads it before the meeting.'
|
|
634
|
+
: lv.kind === PORTFOLIO ? 'Reads every board, linked because ' + lv.why + '.'
|
|
635
|
+
: lv.kind === 'board' ? 'Reads the ' + (lv.name || lv.id) + ' board and its sprint, linked because ' + lv.why + '.'
|
|
636
|
+
: 'Reads ' + (lv.name || lv.id) + ', linked because ' + lv.why + '.';
|
|
637
|
+
const unlink = lv && lv.by === 'pm' ? el('a', { href: '#', class: 'cal3-ref', 'data-cal3': 'unlink', onClick: (ev) => { ev.preventDefault(); saveLink(ui, e, { kind: 'none' }, 'The link is removed.'); } }, 'Unlink') : null;
|
|
638
|
+
return el('div', { class: 'cal3-level', 'data-cal3-ctx': 'level', 'data-cal3-level-by': lv ? lv.by : 'none' }, seg, el('span', { class: 'dim cal3-note' }, what), unlink, open ? pickerOf(ui, e, open) : null);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function pickLevel(ui, e, lv, kind) {
|
|
642
|
+
if (lv && lv.kind === kind && lv.by === 'pm' && kind === PORTFOLIO) return;
|
|
643
|
+
if (kind === PORTFOLIO) { saveLink(ui, e, { kind }, 'This meeting now reads every board.'); return; }
|
|
644
|
+
const choices = choicesOf(ui, kind);
|
|
645
|
+
if (choices.length === 1) { saveLink(ui, e, linkBody(kind, choices[0]), 'This meeting now reads ' + choices[0].name + '.'); return; }
|
|
646
|
+
linkPick.set(e.id, linkPick.get(e.id) === kind ? null : kind);
|
|
647
|
+
ui.redraw();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const choicesOf = (ui, kind) => kind === 'board' ? (ui.d.link_boards || []).map((b) => ({ ws: b.ws, id: b.id, name: b.name }))
|
|
651
|
+
: (ui.d.gantt || []).filter((g) => g && g.id).map((g) => ({ id: g.id, name: g.title || g.id }));
|
|
652
|
+
const linkBody = (kind, c) => kind === 'board' ? { kind, ws: c.ws, board: c.id, title: c.name } : { kind, initiative: c.id, title: c.name };
|
|
653
|
+
|
|
654
|
+
function pickerOf(ui, e, kind) {
|
|
655
|
+
const choices = choicesOf(ui, kind);
|
|
656
|
+
if (!choices.length) return el('span', { class: 'dim cal3-note', 'data-cal3': 'level-none' }, kind === 'board' ? 'No board to link yet.' : 'No initiative to link in this range.');
|
|
657
|
+
const sel = el('select', { 'aria-label': kind === 'board' ? 'Pick the board' : 'Pick the initiative', 'data-cal3': 'level-pick',
|
|
658
|
+
onChange: () => { const c = choices[Number(sel.value)]; if (c) saveLink(ui, e, linkBody(kind, c), 'This meeting now reads ' + c.name + '.'); } },
|
|
659
|
+
el('option', { value: '' }, kind === 'board' ? 'Pick the board' : 'Pick the initiative'), ...choices.map((c, i) => el('option', { value: String(i) }, c.name)));
|
|
660
|
+
return sel;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function saveLink(ui, e, body, said) {
|
|
664
|
+
linkPick.delete(e.id);
|
|
665
|
+
let r = null;
|
|
666
|
+
try { r = await pmPost('/api/calendar-links', { event: { title: e.title || e.id, series: e.series || null }, ...body }); } catch (err) { r = { ok: false, error: String((err && err.message) || err) }; }
|
|
667
|
+
if (!r || !r.ok) { toast('The link was not saved: ' + ((r && r.error) || 'no answer') + '.'); ui.redraw(); return; }
|
|
668
|
+
toast(said);
|
|
669
|
+
ui.h.go(ui.host, ui.r, {});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* the sprint's days left in words: working days after today up to its end */
|
|
673
|
+
function sprintLeft(h, end, today) {
|
|
674
|
+
if (!end) return '';
|
|
675
|
+
if (end < today) return 'ended ' + h.dayLabel(end);
|
|
676
|
+
let n = 0;
|
|
677
|
+
for (let t = new Date(today + 'T12:00:00Z').getTime() + 86400000; ; t += 86400000) {
|
|
678
|
+
const d = new Date(t).toISOString().slice(0, 10); if (d > end) break;
|
|
679
|
+
const w = new Date(t).getUTCDay(); if (w > 0 && w < 6) n++;
|
|
680
|
+
}
|
|
681
|
+
return (n === 0 ? 'last day' : plural(n, 'working day') + ' left') + ', ends ' + h.dayLabel(end);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const HEALTH = { ok: ['', 'On track'], warn: ['warn', 'Needs attention'], bad: ['bad', 'Off track'] };
|
|
685
|
+
|
|
686
|
+
function boardParts(ui, b) {
|
|
687
|
+
const { h } = ui;
|
|
688
|
+
const out = [];
|
|
689
|
+
if (b.boards && b.boards.length) out.push(ctxBox('boards', [c3ic('cols', 'sm'), 'Boards'], ul(b.boards.map((x) => {
|
|
690
|
+
const [tone, word] = HEALTH[x.health] || ['', 'Not read'];
|
|
691
|
+
return ci({ 'data-cal3-board': x.id }, el('span', { class: 'tag' + (tone ? ' cal3-tone-' + tone : '') }, word), el('span', { class: 'cal3-ct' }, x.name), el('span', { class: 'dim mono cal3-at' }, x.line),
|
|
692
|
+
el('a', { href: '#', class: 'cal3-ref', title: 'Open the board: ' + x.name, onClick: (ev) => { ev.preventDefault(); openBoardTab(ui, x.id, 'summary'); } }, c3ic('out', 'sm'), 'Open the board'));
|
|
693
|
+
}))));
|
|
694
|
+
if (b.sprint) {
|
|
695
|
+
const sp = b.sprint; const c = sp.counts || { todo: 0, doing: 0, done: 0 };
|
|
696
|
+
out.push(ctxBox('sprint', [c3ic('cols', 'sm'), sp.name, el('span', { class: 'dim mono cal3-h4r' }, sprintLeft(h, sp.end, ui.d.today || ''))],
|
|
697
|
+
el('div', { class: 'cal3-bar', role: 'img', 'aria-label': c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do' },
|
|
698
|
+
setVar(el('i', { class: 'd' }), 'n', c.done), setVar(el('i', { class: 'p' }), 'n', c.doing), setVar(el('i', { class: 't' }), 'n', c.todo)),
|
|
699
|
+
el('div', { class: 'chips2 dim mono cal3-counts' }, el('span', null, c.done + ' done'), el('span', null, c.doing + ' in progress'), el('span', null, c.todo + ' to do'),
|
|
700
|
+
b.next ? el('span', { 'data-cal3': 'next-sprint' }, '· ' + b.next.name + ': ' + b.next.planned + ' planned, ' + b.next.carry + ' may carry over') : null)));
|
|
701
|
+
}
|
|
702
|
+
return out;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* a board's home on Initiatives: the page reads ?init= when it opens (c3-initiatives.js parseLoc) */
|
|
706
|
+
function openBoardTab(ui, board, tab) {
|
|
707
|
+
try { const u = new URL(location.href); u.searchParams.set('init', board ? 'board:' + encodeURIComponent(board) + ':' + tab : ''); if (!board) u.searchParams.delete('init'); history.replaceState(history.state, '', u); } catch { /* no history */ }
|
|
708
|
+
ui.h.gotoTab('init');
|
|
709
|
+
}
|
|
710
|
+
|
|
614
711
|
function upcomingBody(ui, rec, items) {
|
|
615
712
|
const { h } = ui;
|
|
616
713
|
const b = rec && rec.brief ? rec.brief : null;
|
|
714
|
+
const lv = rec && rec.level ? rec.level : null;
|
|
617
715
|
const prep = items.filter((x) => x.kind === 'prepared' || x.kind === 'todo');
|
|
618
716
|
const out = [];
|
|
619
717
|
if (b && b.gist && b.gist.length) out.push(el('div', { class: 'cal3-ctx cal3-gist', 'data-cal3-ctx': 'gist' }, el('h4', null, c3ic('spark', 'sm'), 'In short'), el('p', { class: 'cal3-p' }, b.gist.join(' '))));
|
|
620
|
-
if (b
|
|
718
|
+
if (b) out.push(...boardParts(ui, b));
|
|
719
|
+
if (b && b.matters && b.matters.length) out.push(ctxBox('matters', b.planning ? 'To settle in planning' : 'What matters today', ul(b.matters.map((x) => ci({ 'data-cal3-matter': x.tag },
|
|
621
720
|
el('span', { class: 'tag cal3-tone-' + x.tone }, x.tag), el('span', { class: 'cal3-ct' }, x.text), x.meta ? el('span', { class: 'dim mono cal3-at' }, x.meta) : null,
|
|
622
721
|
refLink(ui, x.ref, { initiative: x.ref.initiative, title: x.text }))))));
|
|
623
722
|
if (b && b.last) {
|
|
@@ -643,11 +742,11 @@ function upcomingBody(ui, rec, items) {
|
|
|
643
742
|
...(b.links || []).map((l) => el('a', { class: 'chip2', href: l.url, target: '_blank', rel: 'noopener noreferrer' }, c3ic('out', 'sm'), l.label)),
|
|
644
743
|
...(b.refs || []).map((r) => r.link ? el('a', { class: 'chip2', href: r.link, target: '_blank', rel: 'noopener noreferrer' }, c3ic('out', 'sm'), r.label, r.source ? el('span', { class: 'dim' }, ' ' + r.source) : null)
|
|
645
744
|
: el('span', { class: 'chip2' }, r.label, r.source ? el('span', { class: 'dim' }, ' ' + r.source) : null))));
|
|
646
|
-
const noIni = !
|
|
745
|
+
const noIni = !lv; const noLast = !(b && b.last);
|
|
647
746
|
if (noIni || noLast) {
|
|
648
|
-
const missing = noIni && noLast ? '
|
|
747
|
+
const missing = noIni && noLast ? 'This meeting is not linked and there is no earlier note from it.' : noIni ? 'This meeting is not linked to the Portfolio, a board or an initiative.' : 'There is no earlier note from this meeting.';
|
|
649
748
|
out.push(ctxBox('knows', 'What PMOS knows', fold('info', missing + (out.length ? '' : ' Nothing is recorded to prepare.'), { 'data-cal3': 'knows' }),
|
|
650
|
-
ul([noIni ? ci({}, el('span', { class: 'tag' }, 'Link'), el('span', { class: 'cal3-ct' }, '
|
|
749
|
+
ul([noIni ? ci({}, el('span', { class: 'tag' }, 'Link'), el('span', { class: 'cal3-ct' }, 'Pick Portfolio, Board or Initiative above, and what matters there shows here.')) : null,
|
|
651
750
|
ci({}, el('span', { class: 'tag' }, 'Note'), el('span', { class: 'cal3-ct' }, 'Take a note in this meeting, and the next one opens with its summary and next steps.'))].filter(Boolean))));
|
|
652
751
|
}
|
|
653
752
|
return out;
|
|
@@ -90,7 +90,23 @@ const CONTEXT = {
|
|
|
90
90
|
'm3@2026-10-06': { phase: 'past', held: false, items: [] },
|
|
91
91
|
'm1@2026-10-08': { phase: 'upcoming', held: true, items: [
|
|
92
92
|
{ kind: 'prepared', text: 'Async summary of the 3 open tickets', ref: init('search') },
|
|
93
|
-
]
|
|
93
|
+
],
|
|
94
|
+
// a meeting about a board (initiative c3-meeting-board): the title names no board, so the PM linked it
|
|
95
|
+
level: { kind: 'board', ws: D.WS_ID, id: 'idpf', name: 'IDPF', why: 'you linked it to the board', by: 'pm' },
|
|
96
|
+
brief: {
|
|
97
|
+
initiative: null,
|
|
98
|
+
gist: ['A meeting of the IDPF board.', 'IDPF Sprint 42 has 11 of 20 tickets still open, and 2 are blocked.', 'On the board: 1 initiative at risk.', '1 new ask waits in triage.'],
|
|
99
|
+
board: { ws: D.WS_ID, id: 'idpf', name: 'IDPF', health: 'warn', counts: { total: 3, open: 3, risk: 1, late: 0 }, next_release: null },
|
|
100
|
+
sprint: { name: 'IDPF Sprint 42', start: '2026-10-05', end: '2026-10-16', counts: { todo: 5, doing: 6, done: 9 }, total: 20, open: 11, blocked: 2, read_at: '2026-10-08T08:10:00' },
|
|
101
|
+
matters: [
|
|
102
|
+
{ tone: 'bad', tag: 'Blocked', text: 'IDPF-1190 QA test plan for the beta: QA capacity', meta: 'In Progress · flagged in Jira', ref: { type: 'jira', id: 'IDPF-1190', board: 'idpf', ws: D.WS_ID } },
|
|
103
|
+
{ tone: 'bad', tag: 'Blocked', text: 'IDPF-1204 Partner sandbox keys', meta: 'Blocked', ref: { type: 'jira', id: 'IDPF-1204', board: 'idpf', ws: D.WS_ID } },
|
|
104
|
+
{ tone: 'warn', tag: 'At risk', text: 'Self-serve onboarding v2', meta: 'initiative on this board · due 2026-10-26', ref: init('IDPF-12') },
|
|
105
|
+
{ tone: 'warn', tag: 'New ask', text: 'SSO for enterprise trials, from Lina Kusuma (Sales)', meta: 'waiting in triage', ref: { type: 'ask', id: 'ask-demo1', board: 'idpf', ws: D.WS_ID } },
|
|
106
|
+
],
|
|
107
|
+
last: null, earlier: 0, purpose: null, prep: null, links: [], refs: [], planning: false,
|
|
108
|
+
sources: ['the calendar', 'the IDPF board', 'its IDPF Sprint 42 tickets from Jira'],
|
|
109
|
+
} },
|
|
94
110
|
'm2@2026-10-08': { phase: 'upcoming', held: true, items: [
|
|
95
111
|
{ kind: 'prepared', text: 'Agenda: scope cut, blockers, next steps', ref: init('checkout') },
|
|
96
112
|
{ kind: 'prepared', text: 'Pre-read: the Checkout v2 PRD with 2 open comments', ref: { type: 'plan-item', id: 'checkout-prd' } },
|
|
@@ -104,6 +120,7 @@ const CONTEXT = {
|
|
|
104
120
|
{ kind: 'prepared', text: 'Three scope options with their dates', ref: init('checkout') },
|
|
105
121
|
{ kind: 'todo', text: 'Cut Checkout v2 to 3 core flows to hold 24 Oct?', ref: item('item-demo010') },
|
|
106
122
|
],
|
|
123
|
+
level: { kind: 'initiative', id: 'checkout', name: 'Checkout v2', why: "today's brief names the initiative", by: 'brief' },
|
|
107
124
|
// the card before a meeting (initiative c3-meeting-card): what the server's briefOf puts together
|
|
108
125
|
brief: {
|
|
109
126
|
initiative: { id: 'checkout', title: 'Checkout v2', status: 'at risk' },
|
|
@@ -118,12 +135,35 @@ const CONTEXT = {
|
|
|
118
135
|
earlier: 3, purpose: null, prep: null, links: [], refs: [],
|
|
119
136
|
sources: ['the calendar', '3 earlier notes of this meeting', 'the checkout plan', 'your items'],
|
|
120
137
|
} },
|
|
121
|
-
|
|
138
|
+
// a meeting about the Portfolio: the brief names no initiative and the PM linked it
|
|
139
|
+
'm5@2026-10-08': { phase: 'upcoming', held: false, items: [],
|
|
140
|
+
level: { kind: 'portfolio', name: 'Portfolio', why: 'you linked it to the Portfolio', by: 'pm' },
|
|
141
|
+
brief: {
|
|
142
|
+
initiative: null,
|
|
143
|
+
gist: ['A review of 5 boards.', 'Across the boards: 1 initiative late and 2 initiatives at risk.', '1 release in the next 14 days.'],
|
|
144
|
+
boards: [
|
|
145
|
+
{ ws: D.WS_ID, id: 'idpf', name: 'IDPF', health: 'warn', line: '3 open initiatives · 1 at risk · 1 ask' },
|
|
146
|
+
{ ws: D.WS_ID, id: 'pay', name: 'Payments', health: 'ok', line: '2 open initiatives' },
|
|
147
|
+
{ ws: D.WS_ID, id: 'growth', name: 'Growth', health: 'ok', line: '2 open initiatives' },
|
|
148
|
+
{ ws: D.WS_ID, id: 'office', name: 'Office move', health: 'bad', line: '2 open initiatives · 1 late · 1 at risk' },
|
|
149
|
+
{ ws: D.WS_ID, id: 'hiring', name: 'Hiring Q4', health: 'ok', line: '1 open initiative' },
|
|
150
|
+
],
|
|
151
|
+
matters: [
|
|
152
|
+
{ tone: 'bad', tag: 'Late', text: 'Fit-out contractor selection', meta: 'Office move · due 2026-10-06', ref: init('OFF-01') },
|
|
153
|
+
{ tone: 'warn', tag: 'At risk', text: 'Self-serve onboarding v2', meta: 'IDPF · due 2026-10-26', ref: init('IDPF-12') },
|
|
154
|
+
{ tone: 'ok', tag: 'Release', text: 'Tokenisation 1.4', meta: 'Payments · 2026-10-15', ref: { type: 'release', id: 'rel-pay-14' } },
|
|
155
|
+
],
|
|
156
|
+
last: null, earlier: 0, purpose: null, prep: null, links: [], refs: [], planning: false,
|
|
157
|
+
sources: ['the calendar', 'every board on the Portfolio'],
|
|
158
|
+
} },
|
|
122
159
|
};
|
|
123
160
|
|
|
161
|
+
/* the boards a meeting can be linked to (routes-calendar-views.js link_boards), as c3-demo-boards.js names them */
|
|
162
|
+
const LINK_BOARDS = [['idpf', 'IDPF'], ['pay', 'Payments'], ['growth', 'Growth'], ['office', 'Office move'], ['hiring', 'Hiring Q4']].map(([id, name]) => ({ ws: D.WS_ID, id, name }));
|
|
163
|
+
|
|
124
164
|
/** The answer of GET /api/calendar-views with the Calendar page's keys added. */
|
|
125
165
|
export function withCalendarPage(out) {
|
|
126
166
|
if (!out || !out.range) return out;
|
|
127
167
|
const { start, end } = out.range;
|
|
128
|
-
return withCalendarTidy({ ...out, layers: layersIn(start, end), free: freeIn(start, end), context: CONTEXT });
|
|
168
|
+
return withCalendarTidy({ ...out, layers: layersIn(start, end), free: freeIn(start, end), context: CONTEXT, link_boards: LINK_BOARDS });
|
|
129
169
|
}
|
|
@@ -76,6 +76,7 @@ function postRoute(path, body) {
|
|
|
76
76
|
try { b = body ? JSON.parse(body) : {}; } catch { b = {}; }
|
|
77
77
|
if (path === '/api/pm-items/add') return { ok: true, item: { id: 'item-demo-new' + (++added), title: String(b.line || '').trim() || 'New item', due: null, priority: null, waiting_on: null, job: null, steps: [], created_at: D.NOW_ISO + 'Z' } };
|
|
78
78
|
if (path === '/api/pm-items/remove') return { ok: true, undo_ms: 8000 };
|
|
79
|
+
if (path === '/api/calendar-links') return (window.__c3links = (window.__c3links || []).concat([b])), { ok: true, link: b.kind === 'none' ? null : { kind: b.kind, ws: b.ws || null, board: b.board || null, initiative: b.initiative || null, title: b.title || null, by: 'pm:wawan' } }; // nothing is saved
|
|
79
80
|
/* the Work page's routes (initiative console3-work-page): each answers as the real route does; pm-work-view.js lays the press over the fixtures */
|
|
80
81
|
if (path === '/api/pm-items/notyet') {
|
|
81
82
|
const when = String(b.when || ''); const n = when === 'tomorrow' ? 1 : when === 'next-week' ? 7 : 0;
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* GET /api/runs the needs-you count only (a run that asks, holds a permission or needs the terminal); the cards are on the Terminal page
|
|
10
10
|
* GET and POST /api/scratchpad the session's pad
|
|
11
11
|
* The Message box, Send and End are DISABLED (drawn as the mockup's): no page press route sends a prompt to a session, and none is invented here.
|
|
12
|
-
* Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is the
|
|
13
|
-
*
|
|
12
|
+
* Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is every "Open the session" link.
|
|
13
|
+
* A folded dock leaves no rail (PM, 2026-10-12): Ctrl J and the More menu open it again. */
|
|
14
14
|
import { el } from './dom.js';
|
|
15
15
|
import { pmGet, pmPost } from './http.js';
|
|
16
16
|
import { c3ic } from './pm-work-view.js';
|
|
@@ -53,12 +53,11 @@ export function createDock(opts) {
|
|
|
53
53
|
el('a', { class: 'ib sm', href: '/', title: 'Start a branch and session in the main app. A session starts over the terminal connection; the page has no route for it.', 'aria-label': 'Start a branch and session in the main app' }, c3ic('plus', 'sm')),
|
|
54
54
|
el('button', { type: 'button', class: 'ib sm', title: 'Fold the dock (Ctrl J)', 'aria-label': 'Fold the dock', onclick: () => o.setOpen && o.setOpen(false) }, c3ic('fold'))),
|
|
55
55
|
body);
|
|
56
|
-
const rail = el('div', { class: 'c3dock dock rail', 'data-c3pg': 'dock-rail' });
|
|
57
56
|
|
|
58
57
|
const withEnd = (s) => (S.ended.has(s.id) ? { ...s, shown: 'exited', label: 'ended' } : s);
|
|
59
58
|
const mine = () => S.sessions.filter((s) => s.ws === S.ws && !s.automatic).map(withEnd);
|
|
60
59
|
const inTree = (dir) => mine().filter((s) => s.tree === dir).sort((a, b) => (a.shown === 'exited') - (b.shown === 'exited') || String(b.created).localeCompare(String(a.created)));
|
|
61
|
-
const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail();
|
|
60
|
+
const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); if (o.setOpen) o.setOpen(true); };
|
|
62
61
|
|
|
63
62
|
function row(s) {
|
|
64
63
|
const g = smOf(s);
|
|
@@ -129,15 +128,9 @@ export function createDock(opts) {
|
|
|
129
128
|
const r = await pmPost('/api/session/remove', { id: s.id });
|
|
130
129
|
if (r && r.ok) { S.ended.add(s.id); toast({ text: r.removed === false ? 'Asked it to end. Press End again to remove it.' : 'Ended. Any pending change stays a proposal.', timeout: 4200 }); }
|
|
131
130
|
else toast({ text: (r && r.error) ? 'Not ended: ' + r.error : NOEND, tone: 'error' });
|
|
132
|
-
drawTree(); drawDetail();
|
|
131
|
+
drawTree(); drawDetail(); refresh();
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
function drawRail() {
|
|
136
|
-
const all = S.sessions.filter((s) => !s.automatic); // the mockup's rail lists the first six sessions of every repository
|
|
137
|
-
const dots = all.slice(0, 6).map((s) => el('button', { type: 'button', class: 'ib sm', 'data-c3-rail': s.id, title: s.name + ' \u00b7 ' + (s.label || s.shown), 'aria-label': 'Show the session ' + s.name + ' in the dock', onclick: () => pick(s.id) }, el('span', { class: 'mk ' + smOf(s)[1] }, smOf(s)[0])));
|
|
138
|
-
/* the TERMINAL glyph: this button opens the dock (the terminals of your branches). The needs-you badge is on the agent button, not here. */
|
|
139
|
-
rail.replaceChildren(el('button', { type: 'button', class: 'ib', title: TIPS.terminalRail, 'aria-label': 'Open the sessions dock', 'data-c3pg': 'rail-open', onclick: () => o.setOpen && o.setOpen(true) }, ic('term')), ...dots);
|
|
140
|
-
}
|
|
141
134
|
|
|
142
135
|
async function refresh() {
|
|
143
136
|
const seq = ++S.seq; const ws = S.ws;
|
|
@@ -148,7 +141,7 @@ export function createDock(opts) {
|
|
|
148
141
|
if (wt && wt.ok) { S.trees = wt.worktrees || []; S.err = null; } else if (!S.trees) S.trees = [];
|
|
149
142
|
const m = mine();
|
|
150
143
|
if (!m.some((x) => x.id === S.sel)) S.sel = (m.find((x) => x.shown !== 'exited') || m[0] || {}).id || null;
|
|
151
|
-
drawTree();
|
|
144
|
+
drawTree();
|
|
152
145
|
const cur = detail.getAttribute('data-sel');
|
|
153
146
|
if (cur !== String(S.sel)) { detail.setAttribute('data-sel', String(S.sel)); drawDetail(); }
|
|
154
147
|
if (o.onCount) { const all = S.sessions.filter((x) => !x.automatic); o.onCount(all.filter((x) => x.shown === 'working').length, S.need); }
|
|
@@ -157,7 +150,7 @@ export function createDock(opts) {
|
|
|
157
150
|
/* the mockup's keys: Ctrl or Cmd J folds and opens the dock (title "Open sessions (Ctrl J)"); the arrows move along the tree */
|
|
158
151
|
const onKey = (e) => {
|
|
159
152
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'j' || e.key === 'J')) {
|
|
160
|
-
if (!side.isConnected
|
|
153
|
+
if (!side.isConnected) return;
|
|
161
154
|
e.preventDefault(); if (o.setOpen) o.setOpen(side.hidden); return;
|
|
162
155
|
}
|
|
163
156
|
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target.closest && e.target.closest('.dk-tree')) {
|
|
@@ -167,13 +160,13 @@ export function createDock(opts) {
|
|
|
167
160
|
};
|
|
168
161
|
let timer = null;
|
|
169
162
|
const api = {
|
|
170
|
-
side,
|
|
163
|
+
side,
|
|
171
164
|
setWorkspaces(list, ws) {
|
|
172
165
|
S.ws = ws;
|
|
173
166
|
sel.replaceChildren(...(list.length ? list : [{ id: ws, name: ws || 'workspace' }]).map((w) => el('option', { value: w.id, selected: w.id === ws }, w.name || w.id)));
|
|
174
167
|
sel.onchange = (e) => { S.ws = e.target.value; S.trees = null; S.sel = null; detail.removeAttribute('data-sel'); if (o.onWs) o.onWs(S.ws); drawTree(); refresh(); };
|
|
175
168
|
},
|
|
176
|
-
start() { document.addEventListener('keydown', onKey); drawTree();
|
|
169
|
+
start() { document.addEventListener('keydown', onKey); drawTree(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
|
|
177
170
|
stop() { if (timer) clearInterval(timer); if (S.unsub) S.unsub(); document.removeEventListener('keydown', onKey); },
|
|
178
171
|
refresh,
|
|
179
172
|
};
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* c3-calendar.js draws it when the Calendar | Notes switch is on Notes: notesView(ui) returns one persistent box, which this
|
|
4
4
|
* file redraws on its own (a read, a choice, a stage, a search), so the grid's data call is not repeated.
|
|
5
5
|
*
|
|
6
|
-
* the list "
|
|
7
|
-
*
|
|
6
|
+
* the list "Notes" with a count and a search field, grouped by what a note is about: Portfolio first, then each board in the
|
|
7
|
+
* portfolio's order, then "Not linked" (scopeGroups). A row: the time or day, the length, its initiative or a "board"
|
|
8
|
+
* tag, recording or paused, "no meeting", and a dot for a conflict.
|
|
9
|
+
* the head the note's meeting, then what it is about (the Portfolio, a board, or an initiative on its board) with one picker
|
|
10
|
+
* (aboutMenu: /api/boards and each board's Now and next initiatives), the mockup the PM approved on 2026-10-12.
|
|
8
11
|
* the note the Calendar stage's layout (calendar-views.js stageNode, the mockup the PM approved on 2026-10-12): a head with
|
|
9
12
|
* Before, During and After, then the stage's cards.
|
|
10
13
|
* Before the meeting context the note kept (meeting.context): agenda (an item heard is ticked), what it is for, to be ready,
|
|
@@ -20,13 +23,14 @@
|
|
|
20
23
|
* noteOfEvent(e) the note linked to a calendar event, from the shared notes read (notes-links.js), or null
|
|
21
24
|
*/
|
|
22
25
|
import { el } from './dom.js';
|
|
23
|
-
import { button } from './primitives.js';
|
|
26
|
+
import { button, menu } from './primitives.js';
|
|
27
|
+
import { icon } from './icons.js';
|
|
24
28
|
import { c3ic } from './pm-work-view.js';
|
|
25
29
|
import { pmGet, pmPost } from './http.js';
|
|
26
30
|
import { toast } from './c3-toast.js';
|
|
27
31
|
import { onNotes, notesFor, refreshNotes } from './notes-links.js';
|
|
28
32
|
import { agendaHeard, peopleOf } from './notes-context.js';
|
|
29
|
-
import {
|
|
33
|
+
import { partsOf, conflictsOf, whenShort } from './dest-notes.js';
|
|
30
34
|
import * as dock from './note-dock.js';
|
|
31
35
|
import * as V from './view-notes.js';
|
|
32
36
|
import { makeTask } from './notes-task.js';
|
|
@@ -35,6 +39,10 @@ import { screenStrip } from './notes-screen-view.js';
|
|
|
35
39
|
const PHASES = [['before', 'clip', 'Before'], ['during', 'mic', 'During'], ['after', 'notes', 'After']];
|
|
36
40
|
const startOf = (n) => (n.meeting && n.meeting.start) || n.recorded_from || n.created_at || n.updated_at;
|
|
37
41
|
const iniName = (i) => (i ? i.title || i.id : null);
|
|
42
|
+
const boardName = (b) => (b ? b.title || b.id : null);
|
|
43
|
+
/* what a note is about, as the head's path: "Portfolio", "IDPF", or "IDPF › Partner API rate limits" */
|
|
44
|
+
const pathOf = (n) => (n.initiative ? [boardName(n.board), iniName(n.initiative)].filter(Boolean) : n.board ? [boardName(n.board)] : n.portfolio ? ['Portfolio'] : []);
|
|
45
|
+
const boardKey = (b) => (b.ws || '') + '/' + b.id;
|
|
38
46
|
const mins = (ms) => (ms >= 60000 ? Math.round(ms / 60000) + ' min' : '');
|
|
39
47
|
const lenOf = (n) => n.meeting_ms || Math.max(0, ...(n.transcript || []).map((x) => x.t || 0));
|
|
40
48
|
const dayOf = (iso) => { const t = new Date(iso); return Number.isFinite(t.getTime()) ? t.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : ''; };
|
|
@@ -81,6 +89,7 @@ export function notesView(ui) {
|
|
|
81
89
|
const s = stateOf(ui.r);
|
|
82
90
|
s.ui = ui;
|
|
83
91
|
if (!s.unsub) s.unsub = onNotes(() => { if (s.box.isConnected) read(s); });
|
|
92
|
+
if (!s.boards) loadBoards(s);
|
|
84
93
|
if (!s.rows || (s.open && (!s.note || s.note.id !== s.open))) read(s);
|
|
85
94
|
draw(s);
|
|
86
95
|
return s.box;
|
|
@@ -96,6 +105,13 @@ async function read(s) {
|
|
|
96
105
|
draw(s);
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
/* the portfolio's boards, for the list's group order and the board names; a board the read misses keeps the note's own title */
|
|
109
|
+
async function loadBoards(s) {
|
|
110
|
+
s.boards = [];
|
|
111
|
+
const r = await pmGet('/api/boards');
|
|
112
|
+
if (r && r.ok && Array.isArray(r.boards)) { s.boards = r.boards.filter((b) => b && b.id && !b.error); if (s.box.isConnected) draw(s); }
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
function choose(s, id) {
|
|
100
116
|
if (s.open === id) return;
|
|
101
117
|
s.open = id; s.note = null; s.phase = null; s.shots = []; s.rec = null;
|
|
@@ -118,7 +134,7 @@ function draw(s) {
|
|
|
118
134
|
|
|
119
135
|
function listCard(s) {
|
|
120
136
|
const qtext = s.q.trim().toLowerCase();
|
|
121
|
-
const shown = (s.rows || []).filter((r) => !qtext || String(r.title || '').toLowerCase().includes(qtext) ||
|
|
137
|
+
const shown = (s.rows || []).filter((r) => !qtext || String(r.title || '').toLowerCase().includes(qtext) || pathOf(r).some((x) => String(x).toLowerCase().includes(qtext))
|
|
122
138
|
|| (r.summary_lines || []).some((x) => String(x).toLowerCase().includes(qtext)));
|
|
123
139
|
const search = el('input', { type: 'text', class: 'cal3-nsearch', placeholder: 'Search notes', 'aria-label': 'Search notes', value: s.q, 'data-cal3-notes': 'search' });
|
|
124
140
|
search.addEventListener('input', () => { s.q = search.value; draw(s); });
|
|
@@ -126,13 +142,28 @@ function listCard(s) {
|
|
|
126
142
|
if (!s.rows) body = fold('clock', 'Reading the notes', { role: 'status' });
|
|
127
143
|
else if (!s.rows.length) body = fold('mic', s.err || 'No notes yet. Press Record at the top right to start one.', { 'data-cal3-notes': 'none' });
|
|
128
144
|
else if (!shown.length) body = fold('search', 'No note matches “' + s.q.trim() + '”.', { 'data-cal3-notes': 'no-match' });
|
|
129
|
-
else body =
|
|
145
|
+
else body = scopeGroups(s, shown).map((g) => el('div', { class: 'cal3-ctx', 'data-cal3-notes-group': g.label }, el('h4', null, g.label),
|
|
130
146
|
el('ul', { class: 'list', role: 'list' }, g.rows.map((r) => noteRow(s, r)))));
|
|
131
147
|
return el('section', { class: 'card sect', 'data-cal3-notes': 'list', 'aria-label': 'Notes' },
|
|
132
|
-
el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), '
|
|
148
|
+
el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), 'Notes ', el('span', { class: 'bdg g', 'data-cal3-notes': 'count' }, String(shown.length)))),
|
|
133
149
|
el('div', { class: 'cal3-nsearchw' }, search), body);
|
|
134
150
|
}
|
|
135
151
|
|
|
152
|
+
/* the list's groups: Portfolio, each board in the portfolio's order (a board it does not list goes after them by name), an
|
|
153
|
+
* initiative linked before boards were (no board on the note) under "Initiatives", then "Not linked" */
|
|
154
|
+
function scopeGroups(s, rows) {
|
|
155
|
+
const order = new Map((s.boards || []).map((b, i) => [boardKey(b), i]));
|
|
156
|
+
const known = new Map((s.boards || []).map((b) => [boardKey(b), b.name || b.id]));
|
|
157
|
+
const g = new Map();
|
|
158
|
+
for (const r of rows) {
|
|
159
|
+
const k = r.portfolio ? '@portfolio' : r.board ? boardKey(r.board) : r.initiative ? '@initiative' : '';
|
|
160
|
+
if (!g.has(k)) g.set(k, { key: k, label: k === '@portfolio' ? 'Portfolio' : k === '@initiative' ? 'Initiatives' : !k ? 'Not linked' : known.get(k) || boardName(r.board), rows: [] });
|
|
161
|
+
g.get(k).rows.push(r);
|
|
162
|
+
}
|
|
163
|
+
const rank = (x) => (x.key === '@portfolio' ? [0, 0] : x.key === '@initiative' ? [3, 0] : !x.key ? [4, 0] : order.has(x.key) ? [1, order.get(x.key)] : [2, 0]);
|
|
164
|
+
return [...g.values()].sort((a, b) => { const p = rank(a); const q = rank(b); return p[0] - q[0] || p[1] - q[1] || a.label.localeCompare(b.label); });
|
|
165
|
+
}
|
|
166
|
+
|
|
136
167
|
function noteRow(s, r) {
|
|
137
168
|
const mode = held(r.id) || (r.state === 'recording' || r.state === 'paused' ? r.state : null);
|
|
138
169
|
const on = s.open === r.id;
|
|
@@ -142,7 +173,9 @@ function noteRow(s, r) {
|
|
|
142
173
|
btn('cal3-nopen', { title: 'Open ' + (r.title || 'Untitled note'), 'aria-current': on ? 'true' : 'false' }, [
|
|
143
174
|
el('span', { class: 'tt' }, r.title || 'Untitled note'),
|
|
144
175
|
el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, [mode ? 'now' : whenShort(startOf(r)), len].filter(Boolean).join(' · ')),
|
|
145
|
-
mode ? el('span', { class: 'tag', 'data-cal3-notes': 'live' }, mode === 'paused' ? 'paused' : 'recording')
|
|
176
|
+
mode ? el('span', { class: 'tag', 'data-cal3-notes': 'live' }, mode === 'paused' ? 'paused' : 'recording')
|
|
177
|
+
: r.initiative ? el('span', { class: 'chip2', title: 'Initiative', 'data-cal3-notes': 'row-initiative' }, c3ic('init', 'sm'), iniName(r.initiative))
|
|
178
|
+
: r.board ? el('span', { class: 'tag', title: 'Linked to the board, not to one initiative', 'data-cal3-notes': 'row-board' }, 'board') : null,
|
|
146
179
|
!linked(r) && !mode ? el('span', { class: 'tag', title: 'Not linked to a meeting', 'data-cal3-notes': 'no-meeting' }, 'no meeting') : null,
|
|
147
180
|
r.conflicts ? el('span', { class: 'st warn', title: r.conflicts + (r.conflicts === 1 ? ' statement differs' : ' statements differ') + ' from a file' }, c3ic('alert', 'sm'), String(r.conflicts)) : null)],
|
|
148
181
|
() => choose(s, r.id)),
|
|
@@ -172,7 +205,7 @@ function noteCol(s) {
|
|
|
172
205
|
return el('div', { class: 'cal3-ncol', 'data-cal3-notes': 'note', 'data-cal3-note': n.id, 'data-cal3-stage': ph }, head, ...[].concat(body));
|
|
173
206
|
}
|
|
174
207
|
|
|
175
|
-
/* the note's meeting and
|
|
208
|
+
/* the note's meeting (view-notes.js picker) and what it is about (aboutMenu), each with what it is linked to, then Link or Change */
|
|
176
209
|
function links(s, n, ev) {
|
|
177
210
|
const adhoc = n.meeting && n.meeting.kind === 'adhoc';
|
|
178
211
|
const when = ev ? [dayOf(ev.start), hm(ev.start) + (ev.end ? ' to ' + hm(ev.end) : '')].filter(Boolean).join(' ') : '';
|
|
@@ -183,9 +216,60 @@ function links(s, n, ev) {
|
|
|
183
216
|
!ev && !adhoc ? el('span', { class: 'tag' }, 'no meeting') : null,
|
|
184
217
|
btn('chip2 lk', { 'data-cal3-notes': 'meeting-link', title: ev || adhoc ? 'Link the note to another meeting' : 'Link the note to a meeting on the calendar' }, ev || adhoc ? 'Change' : 'Link to a meeting', () => V.linkMeeting(vctx(s), n.id)),
|
|
185
218
|
ev ? btn('chip2 lk', { 'data-cal3-notes': 'show-meeting', title: 'Show the meeting in the calendar' }, [c3ic('next', 'sm'), 'Show in the calendar'], () => showMeeting(s, ev)) : null),
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
aboutLine(s, n));
|
|
220
|
+
}
|
|
221
|
+
function aboutLine(s, n) {
|
|
222
|
+
const path = pathOf(n);
|
|
223
|
+
const b = btn('chip2 lk', { 'data-cal3-notes': 'about-link', title: 'Choose what the note is about: the Portfolio, a board or an initiative' }, path.length ? 'Change' : 'Choose what it is about', () => aboutMenu(s, n, b));
|
|
224
|
+
return el('span', { class: 'chips2' }, c3ic('init', 'sm'),
|
|
225
|
+
path.length ? el('span', { 'data-cal3-notes': 'about' }, el('b', null, path.join(' › '))) : el('span', { class: 'dim', 'data-cal3-notes': 'about' }, 'Not linked to the Portfolio, a board or an initiative'),
|
|
226
|
+
b);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* each board with its Now and next initiatives (GET /api/boards/backlog), kept for a minute once the repository is read */
|
|
230
|
+
async function boardsWithInitiatives(s) {
|
|
231
|
+
if (s.about && Date.now() - s.about.at < 60000) return s.about.boards;
|
|
232
|
+
const r = await pmGet('/api/boards');
|
|
233
|
+
if (!r || !r.ok || !Array.isArray(r.boards)) throw new Error((r && r.error) || 'the boards could not be read');
|
|
234
|
+
const boards = r.boards.filter((b) => b && b.id && !b.error);
|
|
235
|
+
let pending = !!r.pending;
|
|
236
|
+
await Promise.all(boards.map(async (b) => {
|
|
237
|
+
const x = await pmGet('/api/boards/backlog?ws=' + encodeURIComponent(b.ws || '') + '&board=' + encodeURIComponent(b.id) + '&limit=100');
|
|
238
|
+
if (!x || !x.ok || x.pending || b.pending) pending = true;
|
|
239
|
+
b.inis = x && x.ok && Array.isArray(x.rows) ? x.rows : [];
|
|
240
|
+
}));
|
|
241
|
+
// while the repository is still being read, the initiatives are not all known: the next open reads them again
|
|
242
|
+
s.about = pending ? null : { at: Date.now(), boards };
|
|
243
|
+
return boards;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* What is this note about? One menu: Portfolio (every board), each board (the whole board) with its initiatives under it, and
|
|
247
|
+
* Not linked when the note has a link. Choosing one saves it (POST /api/notes/link) and the note moves into its group. */
|
|
248
|
+
async function aboutMenu(s, n, anchor) {
|
|
249
|
+
let boards;
|
|
250
|
+
try { boards = await boardsWithInitiatives(s); } catch (e) { toast('The boards could not be read: ' + e.message); return; }
|
|
251
|
+
if (!anchor.isConnected) return;
|
|
252
|
+
const cur = n.initiative ? 'i:' + (n.initiative.ws || '') + '/' + n.initiative.id : n.board ? 'b:' + boardKey(n.board) : n.portfolio ? 'p' : '';
|
|
253
|
+
const save = async (body, said) => {
|
|
254
|
+
const r = await pmPost('/api/notes/link', { id: n.id, ...body });
|
|
255
|
+
if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return; }
|
|
256
|
+
toast(said);
|
|
257
|
+
refreshNotes();
|
|
258
|
+
read(s);
|
|
259
|
+
};
|
|
260
|
+
const item = (k, label, detail, ic, on, sub) => ({ label, detail, role: 'menuitemradio', iconNode: sub ? el('span', { class: 'cal3-mind' }, icon(ic)) : icon(ic),
|
|
261
|
+
attrs: { 'data-cal3-about': k, 'aria-checked': String(cur === k) }, onSelect: on });
|
|
262
|
+
const items = [item('p', 'Portfolio', 'every board', 'chart-column', () => save({ portfolio: true }, 'About the Portfolio. The note moved into its group.'))];
|
|
263
|
+
for (const b of boards) {
|
|
264
|
+
const board = { id: b.id, ws: b.ws || null, title: b.name || b.id };
|
|
265
|
+
items.push(item('b:' + boardKey(b), board.title, 'the whole board', 'square-kanban', () => save({ board, initiative: null }, 'About ' + board.title + '. The note moved into its group.')));
|
|
266
|
+
for (const i of b.inis || []) {
|
|
267
|
+
items.push(item('i:' + (b.ws || '') + '/' + i.id, i.title || i.id, null, 'target',
|
|
268
|
+
() => save({ board, initiative: { id: i.id, ws: b.ws || null, title: i.title || i.id } }, 'About ' + (i.title || i.id) + ' on ' + board.title + '. The note moved into its group.'), true));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (cur) items.push({ sep: true }, item('none', 'Not linked', 'remove the link', 'x', () => save({ portfolio: false, board: null, initiative: null }, 'Not linked to the Portfolio, a board or an initiative.')));
|
|
272
|
+
menu({ anchor, items, ariaLabel: 'What is this note about?' });
|
|
189
273
|
}
|
|
190
274
|
|
|
191
275
|
/* the replay of the note's kept audio: one <audio> per note, kept across redraws so playback goes on (as dest-notes.js does) */
|
|
@@ -207,7 +291,7 @@ function seek(s, ms) {
|
|
|
207
291
|
const vctx = (s) => ({ state: {}, setState: () => { if (s.box.isConnected) { read(s); } } });
|
|
208
292
|
const linkButtons = (s, n) => el('div', { class: 'chips2' },
|
|
209
293
|
btn('chip2 lk', { 'data-cal3-notes': 'link-meeting' }, [c3ic('cal', 'sm'), 'Link to a meeting'], () => V.linkMeeting(vctx(s), n.id)),
|
|
210
|
-
btn('chip2 lk', { 'data-cal3-notes': 'link-
|
|
294
|
+
btn('chip2 lk', { 'data-cal3-notes': 'link-about' }, [c3ic('init', 'sm'), 'Choose what it is about'], (e) => aboutMenu(s, n, e.currentTarget)));
|
|
211
295
|
|
|
212
296
|
/* a row of the stage's prepared list: the icon, the label, what it holds */
|
|
213
297
|
const prepRow = (icon, label, detail, done) => el('div', { class: 'chips2 cal3-prep', 'data-cal3-prep': label },
|
|
@@ -102,8 +102,8 @@ function header() {
|
|
|
102
102
|
S.ttl = el('h1', { 'data-c3pg': 'title' });
|
|
103
103
|
S.sub = el('div', { class: 'sub', 'data-c3pg': 'date' }, dateLine());
|
|
104
104
|
S.termBdg = el('span', { class: 'bdg', hidden: true, 'data-c3pg': 'term-count' }, '0');
|
|
105
|
-
/* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge.
|
|
106
|
-
S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'),
|
|
105
|
+
/* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge. It is an icon only (PM, 2026-10-12: no "Sessions" label); its tooltip and aria-label name it. Ctrl J and the More menu fold and open the dock. */
|
|
106
|
+
S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'), S.termBdg], onClick: () => { import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
|
|
107
107
|
attrs: { class: 'psess', title: 'Agent surfaces: sessions, results to accept, questions for you', 'aria-label': 'Agent surfaces: sessions, results to accept, questions for you', 'aria-haspopup': 'dialog', 'data-c3pg': 'agent-button' } }); // the mockup's header button (agent-surfaces.html #act)
|
|
108
108
|
S.tools = el('div', { class: 'tools', 'data-c3pg': 'tools' });
|
|
109
109
|
if (S.demoMod) S.tools.append(S.demoMod.demoPill());
|
|
@@ -423,15 +423,14 @@ function dock() {
|
|
|
423
423
|
/* the needs-you badge on the agent button: runs that ask, hold a permission or need the terminal (not the running ones) */
|
|
424
424
|
onCount: (live, need) => { S.termBdg.textContent = String(need); S.termBdg.hidden = need === 0; import('./c3-terminal-icons.js').then((m) => { const t = m.TIPS.agentHeader(need); S.termBtn.title = t; S.termBtn.setAttribute('aria-label', t); }); }, // the badge counts runs that need you, never running ones
|
|
425
425
|
});
|
|
426
|
-
S.dockEl = S.dock.side;
|
|
426
|
+
S.dockEl = S.dock.side;
|
|
427
427
|
// a session opens in its own terminal sheet on this page (c3-session-term.js), not in the main app
|
|
428
428
|
window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
|
|
429
|
-
return [S.dock.side
|
|
429
|
+
return [S.dock.side];
|
|
430
430
|
}
|
|
431
|
-
/* The
|
|
431
|
+
/* The dock is open on the Briefing and folded away on the other tabs. A folded dock leaves no rail (PM, 2026-10-12): the header's Sessions button, Ctrl J and the More menu reach it. */
|
|
432
432
|
function syncDock() {
|
|
433
433
|
S.dockEl.hidden = !S.dockOpen;
|
|
434
|
-
S.rail.hidden = S.dockOpen || !S.wide;
|
|
435
434
|
}
|
|
436
435
|
|
|
437
436
|
/* ── the other tabs ── */
|