create-pmos 0.153.0 → 0.155.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 +25 -2
- package/workspace/guide/console3-sessions.md +5 -1
- package/workspace/guide/document-sheet.md +18 -3
- 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/routes-boards.js +51 -1
- package/workspace/lib/routes-calendar-views.js +42 -3
- 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-boards.js +4 -3
- package/workspace/public/pm/c3-demo-cal4.js +43 -3
- package/workspace/public/pm/c3-demo-init.js +16 -0
- package/workspace/public/pm/c3-demo.js +1 -0
- package/workspace/public/pm/c3-doc-sheet.js +328 -21
- package/workspace/public/pm/c3-dock.js +8 -15
- package/workspace/public/pm/c3-docs.js +2 -1
- package/workspace/public/pm/c3-ib-docs.js +3 -1
- 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 +96 -2
- package/workspace/test-lists/c3-doc-sheet-v2.json +6 -0
- package/workspace/test-lists/c3-meeting-board.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.155.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-12T04:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T04:46:53Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-12T04:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T04:46:53Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-12T04:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T04:46:53Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -13,7 +13,7 @@ Every card in a deck has the same height at rest. Text that does not fit is cut
|
|
|
13
13
|
|
|
14
14
|
The page background is white. A soft, slow glow of colour drifts behind the page. With reduced motion on, it is one still picture.
|
|
15
15
|
|
|
16
|
-
The tabs show counts: Briefing shows how many things need you, Work shows its inbox, and Initiatives shows new asks to triage. The Sessions button and the dock button
|
|
16
|
+
The tabs show counts: Briefing shows how many things need you, Work shows its inbox, and Initiatives shows new asks to triage. The Sessions button is a bolt icon with no label, and the dock button shows a bolt too. In the demo, the Record button and its arrow are visible when nothing is recording.
|
|
17
17
|
|
|
18
18
|
### Kinds of card
|
|
19
19
|
|
|
@@ -91,11 +91,34 @@ Press a meeting to show its context in the side column. Under the title, a row s
|
|
|
91
91
|
- **To prepare**: what is prepared, what is still open, and how to be ready, from today's brief. The links from the brief follow.
|
|
92
92
|
- A last line names the sources that PMOS used.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
### What a meeting is about
|
|
95
|
+
|
|
96
|
+
A coming meeting is about the **Portfolio**, one **board** or one **initiative**. A switch under the title shows the level, and a line next to it tells you why PMOS chose it. PMOS uses the first of these that applies:
|
|
97
|
+
|
|
98
|
+
1. The level you set with the switch.
|
|
99
|
+
2. The initiative in today's brief.
|
|
100
|
+
3. The level of the last note of the meeting.
|
|
101
|
+
4. A board that the title names: its name, its id or one of its Jira project keys, as a whole word. For example, "IDPF - DSU" names the IDPF board.
|
|
102
|
+
5. The word "portfolio" in the title.
|
|
103
|
+
|
|
104
|
+
To set the level, press **Portfolio**, **Board** or **Initiative**. If there is more than one board or initiative, pick one from the list. The level applies to every occurrence of the meeting (the same calendar series, or the same title). To remove a level that you set, press **Unlink**. PMOS keeps the level next to the calendar. It does not change your calendar.
|
|
105
|
+
|
|
106
|
+
The level changes what the detail reads:
|
|
107
|
+
|
|
108
|
+
- **Initiative**: the parts above.
|
|
109
|
+
- **Board**:
|
|
110
|
+
- The active sprint from Jira: its name, the working days left, its end date and a bar of done, in progress and to do.
|
|
111
|
+
- **What matters today**: the blocked tickets of the sprint, the initiatives on the board that are late or at risk, and the new asks in triage. A ticket opens the board's Sprints and an ask opens Triage.
|
|
112
|
+
- For a planning meeting, the list is called **To settle in planning**, and the sprint line also shows the next sprint and how many open tickets can carry over.
|
|
113
|
+
- **Portfolio**:
|
|
114
|
+
- **Boards** lists each board with its health and its counts.
|
|
115
|
+
- **What matters today** shows the late and at-risk initiatives on all boards, a release in the next 14 days and the new asks.
|
|
116
|
+
|
|
117
|
+
At every level, **Since the last one**, **To prepare** and the sources line stay.
|
|
95
118
|
|
|
96
119
|
When the meeting has a note, the detail has **Open the note**. It opens the note in **Notes**.
|
|
97
120
|
|
|
98
|
-
When PMOS holds nothing for a past meeting, the detail says so. For a coming meeting, **What PMOS knows** tells you what is missing (no
|
|
121
|
+
When PMOS holds nothing for a past meeting, the detail says so. For a coming meeting, **What PMOS knows** tells you what is missing (no level, no earlier note) and what to do: set the level with the switch, or take a note in the meeting. It does not guess.
|
|
99
122
|
|
|
100
123
|
## Free time
|
|
101
124
|
|
|
@@ -7,7 +7,11 @@ timestamp: 2026-10-12
|
|
|
7
7
|
|
|
8
8
|
# Sessions in console3: open, read and end your sessions
|
|
9
9
|
|
|
10
|
-
Press **Sessions** in the console3 header to open the panel. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
|
|
10
|
+
Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
|
|
11
|
+
|
|
12
|
+
## The dock
|
|
13
|
+
|
|
14
|
+
The dock is open on the Briefing. On the other tabs it is folded away, and no rail of buttons is left at the right edge. To open the dock again, press Ctrl J (⌘J on a Mac), or use **Open the dock** in the **More** menu.
|
|
11
15
|
|
|
12
16
|
## Read a row
|
|
13
17
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "The document sheet"
|
|
4
|
-
description: "How to read a document as rich text, edit it, review the agent's proposals section by section, read the edit log, and clear a stale document."
|
|
4
|
+
description: "How to read a document as rich text with its outline, edit it, review the agent's proposals section by section, read the edit log, keep its Confluence and Google Doc copies in step, see the documents around it, and clear a stale document."
|
|
5
5
|
timestamp: 2026-10-12
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -16,7 +16,8 @@ Read shows the document as formatted text: headings, bold, links, tables, checkl
|
|
|
16
16
|
- Each section shows who changed it last: a person, or the agent and the person who accepted it, with the revision and the day. Select the name to open that revision in the edit log.
|
|
17
17
|
- **Show who wrote what** colours each section: blue for a person, purple for the agent.
|
|
18
18
|
- **Markdown source** shows the file as text. **Copy the markdown** copies it.
|
|
19
|
-
- **
|
|
19
|
+
- The **outline** beside the text lists the headings, nested by level. The section you read is marked as you scroll. Select a heading to go to it, or the arrow beside a heading to fold the headings under it. A document with more than 12 headings has a **Find a heading** box. An orange dot marks a section that cites a source that changed. A purple dot marks a section that an open proposal changes.
|
|
20
|
+
- **Hide the outline** hides it, and the sheet keeps that choice in this browser. On a narrow screen the outline is a button, **Outline**, that names the section you read and opens the list over the text.
|
|
20
21
|
- When an open proposal changes a section, a note shows above that section. Select **Show change** to see the difference in place.
|
|
21
22
|
|
|
22
23
|
## Edit
|
|
@@ -53,6 +54,20 @@ A document is stale when a document that it links to changed after it. The sheet
|
|
|
53
54
|
- Edit the sections, or ask the agent for an update.
|
|
54
55
|
- If the document is still right, select **Mark as still current** and write why. The mark counts as a change, so the banner clears.
|
|
55
56
|
|
|
57
|
+
## Copies in Confluence and Google Docs
|
|
58
|
+
|
|
59
|
+
The header shows one chip for each copy of the document in Confluence or Google Docs, with its state: in step, behind (the copy has changes), PMOS ahead (PMOS has changes), both changed, or not checked. Select a chip to open **Copies in other tools**. Each copy shows its state, who changed it last and when, and when it was last checked.
|
|
60
|
+
|
|
61
|
+
- **Check now** starts an agent session that reads the copies with your connectors. The states change when it finishes.
|
|
62
|
+
- **Open in Confluence** or **Open in Google Doc** opens the copy.
|
|
63
|
+
- When the copy is behind or both changed, **Bring their change in as a proposal** asks the agent to read the copy and draft its changes as a proposal. Review it in Proposals, section by section. PMOS keeps a fingerprint of the copy, not its text, so the proposal is where you see the difference.
|
|
64
|
+
- When PMOS is ahead, **Update the Google Doc to this version** (or the Confluence page) starts an agent session that writes the copy. You have five seconds to select **Undo**.
|
|
65
|
+
- **Link a copy** takes the address of a Confluence page or a Google Doc. The sheet finds the tool from the address.
|
|
66
|
+
|
|
67
|
+
PMOS never overwrites the document from a copy.
|
|
68
|
+
|
|
56
69
|
## Health and Relations
|
|
57
70
|
|
|
58
|
-
Health shows the checks of the document and how many pass.
|
|
71
|
+
Health shows the checks of the document and how many pass.
|
|
72
|
+
|
|
73
|
+
Relations shows a graph of the documents around this one: the documents it uses on the left, its initiative and goals on top, and the documents that use it and its copies on the right. A dashed orange line shows a document that is out of step. Select a document to open it. The lists under the graph name the same documents. From Initiatives › Documents, **Open the full graph in Docs** shows the whole graph.
|
|
@@ -370,7 +370,11 @@ function contextFrom(events, range, src, opts = {}) {
|
|
|
370
370
|
}
|
|
371
371
|
const kept = capKinds(items);
|
|
372
372
|
context[e.id] = { phase: past ? 'past' : 'upcoming', held: kept.length > 0, items: kept };
|
|
373
|
-
if (!past) {
|
|
373
|
+
if (!past) {
|
|
374
|
+
const b = briefOf(e, notes, s, { today, inis: opts.inis, links: opts.links, boards: opts.boards });
|
|
375
|
+
if (b && b.level) context[e.id].level = b.level;
|
|
376
|
+
if (b && b.brief) context[e.id].brief = b.brief;
|
|
377
|
+
}
|
|
374
378
|
}
|
|
375
379
|
return { context, context_total: inside.length };
|
|
376
380
|
}
|
|
@@ -423,23 +427,29 @@ function mattersOf(iniId, src, today) {
|
|
|
423
427
|
|
|
424
428
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many || one + 's'}`;
|
|
425
429
|
|
|
426
|
-
function briefOf(e, current, s, { today, inis } = {}) {
|
|
430
|
+
function briefOf(e, current, s, { today, inis, links: meetingLinks, boards } = {}) {
|
|
427
431
|
const earlier = seriesNotes(e, s.notes || [], current);
|
|
428
432
|
const lastNote = earlier[0] || null;
|
|
429
|
-
const
|
|
433
|
+
const boardList = boards && typeof boards.list === 'function' ? safe(() => boards.list(), []) : [];
|
|
434
|
+
const level = levelOf(e, { link: meetingLinks && typeof meetingLinks.get === 'function' ? meetingLinks.get(memory.seriesKey({ series: e.series || null, title: e.title })) : null, earlier, boards: boardList, inis });
|
|
435
|
+
const wide = level && (level.kind === 'board' || level.kind === 'portfolio');
|
|
436
|
+
const iniId = level && level.kind === 'initiative' ? level.id : null;
|
|
430
437
|
const row = iniId ? (inis || []).find((g) => g && g.id === iniId) : null;
|
|
431
438
|
const initiative = iniId ? { id: iniId, title: str(row && row.title) || iniId, status: str(row && row.status) || null } : null;
|
|
432
|
-
const
|
|
439
|
+
const part = !wide ? null : level.kind === 'board' ? boardPart(boards && typeof boards.detail === 'function' ? safe(() => boards.detail(level.ws, level.id), null) : null, e, level)
|
|
440
|
+
: portfolioPart(boardList, boards, today);
|
|
441
|
+
const matters = part ? part.matters : mattersOf(iniId, s, today);
|
|
433
442
|
const purpose = str(e.purpose) || null;
|
|
434
443
|
const why = e.initiative && typeof e.initiative === 'object' ? str(e.initiative.why) : str(e.initiative_why);
|
|
435
444
|
const gist = [];
|
|
436
445
|
if (purpose) gist.push(/[.!?]$/.test(purpose) ? purpose : purpose + '.');
|
|
437
|
-
|
|
446
|
+
if (part) gist.push(...part.gist.slice(purpose ? 1 : 0)); // the purpose replaces the part's own "A meeting of …" sentence
|
|
447
|
+
else if (!purpose && initiative) gist.push(`For ${initiative.title}` + (initiative.status && !['no plan', 'on time'].includes(initiative.status) ? `, which is ${initiative.status}.` : '.'));
|
|
438
448
|
if (why) gist.push(/[.!?]$/.test(why) ? why : why + '.');
|
|
439
449
|
const blocked = matters.filter((x) => x.tag === 'Blocked').length; const late = matters.filter((x) => x.tag === 'Late').length;
|
|
440
450
|
const dueNow = matters.filter((x) => x.tag === 'Due today').length;
|
|
441
451
|
const parts = [blocked && plural(blocked, 'item') + ' blocked', late && plural(late, 'item') + ' late', dueNow && plural(dueNow, 'item') + ' due today'].filter(Boolean);
|
|
442
|
-
if (parts.length) gist.push(parts.join(', ').replace(/^./, (c) => c.toUpperCase()) + '.');
|
|
452
|
+
if (parts.length && !part) gist.push(parts.join(', ').replace(/^./, (c) => c.toUpperCase()) + '.');
|
|
443
453
|
let last = null;
|
|
444
454
|
if (lastNote) {
|
|
445
455
|
last = { id: lastNote.id, title: str(lastNote.title) || str(lastNote.meeting.title), at: lastNote.meeting.start, private: !!lastNote.private };
|
|
@@ -456,18 +466,127 @@ function briefOf(e, current, s, { today, inis } = {}) {
|
|
|
456
466
|
if (purpose || why || prep || links.length || refs.length) sources.push("today's brief");
|
|
457
467
|
if (earlier.length) sources.push(plural(earlier.length, 'earlier note') + ' of this meeting');
|
|
458
468
|
if (initiative) sources.push('the ' + initiative.id + ' plan');
|
|
469
|
+
if (part) sources.push(...part.sources);
|
|
459
470
|
if (matters.some((x) => x.ref.type === 'item')) sources.push('your items');
|
|
460
|
-
if (!gist.length && !matters.length && !last && !links.length && !refs.length && !prep && !initiative) return null;
|
|
461
|
-
|
|
471
|
+
if (!gist.length && !matters.length && !last && !links.length && !refs.length && !prep && !initiative && !part) return { level, brief: null };
|
|
472
|
+
const brief = { initiative, gist: gist.slice(0, 4), matters, last, earlier: earlier.length, purpose, prep, links, refs, sources };
|
|
473
|
+
if (part) { brief.planning = !!part.planning; for (const k of ['board', 'sprint', 'next', 'boards']) if (part[k]) brief[k] = part[k]; }
|
|
474
|
+
return { level, brief };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* ── what a meeting is about: the Portfolio, a board or an initiative (initiative c3-meeting-board) ─────────────────────────
|
|
478
|
+
* The first that holds: the link the PM set on the meeting (lib/calendar-links.js), the initiative today's brief names, the
|
|
479
|
+
* latest earlier note's link (note.portfolio, note.initiative, note.board), a board the title names (its name, its id or one of
|
|
480
|
+
* its Jira project keys, as a whole word), and the word "portfolio" in the title. null when none holds.
|
|
481
|
+
* level: { kind: portfolio|board|initiative, ws?, id?, name, why, by: pm|brief|note|title } */
|
|
482
|
+
const safe = (fn, dflt) => { try { const v = fn(); return v == null ? dflt : v; } catch { return dflt; } };
|
|
483
|
+
const PORTFOLIO = { kind: 'portfolio', name: 'Portfolio' };
|
|
484
|
+
const padded = (t) => ' ' + norm(t) + ' ';
|
|
485
|
+
|
|
486
|
+
function levelOf(e, { link, earlier, boards, inis } = {}) {
|
|
487
|
+
const bl = boards || [];
|
|
488
|
+
const boardNamed = (ws, id, fallback) => { const b = bl.find((x) => x.id === id && (!ws || x.ws === ws)) || bl.find((x) => x.id === id); return b ? { ws: b.ws, id: b.id, name: b.name } : { ws: ws || null, id, name: fallback || id }; };
|
|
489
|
+
const iniNamed = (id) => { const r = (inis || []).find((g) => g && g.id === id); return str(r && r.title) || id; };
|
|
490
|
+
if (link && link.kind === 'portfolio') return { ...PORTFOLIO, why: 'you linked it to the Portfolio', by: 'pm' };
|
|
491
|
+
if (link && link.kind === 'board' && link.board) return { kind: 'board', ...boardNamed(link.ws, link.board, link.title), why: 'you linked it to the board', by: 'pm' };
|
|
492
|
+
if (link && link.kind === 'initiative' && link.initiative) return { kind: 'initiative', id: link.initiative, name: iniNamed(link.initiative), why: 'you linked it to the initiative', by: 'pm' };
|
|
493
|
+
const named = iniIdOf(e.initiative);
|
|
494
|
+
if (named) return { kind: 'initiative', id: named, name: iniNamed(named), why: "today's brief names the initiative", by: 'brief' };
|
|
495
|
+
const n = (earlier || []).find((x) => x && (x.portfolio || x.initiative || (x.board && x.board.id)));
|
|
496
|
+
if (n && n.portfolio) return { ...PORTFOLIO, why: 'the last note of this meeting is about the Portfolio', by: 'note' };
|
|
497
|
+
if (n && n.initiative) return { kind: 'initiative', id: n.initiative, name: iniNamed(n.initiative), why: 'the last note of this meeting is about it', by: 'note' };
|
|
498
|
+
if (n && n.board) return { kind: 'board', ...boardNamed(n.board.ws, n.board.id, n.board.title), why: 'the last note of this meeting is about the board', by: 'note' };
|
|
499
|
+
const title = padded(e.title);
|
|
500
|
+
for (const b of bl) {
|
|
501
|
+
const words = [b.name, b.id, ...(b.jiraProjects || [])].map((w) => str(w)).filter((w) => norm(w).length >= 3);
|
|
502
|
+
const hit = words.find((w) => title.includes(padded(w)));
|
|
503
|
+
if (hit) return { kind: 'board', ws: b.ws, id: b.id, name: b.name, why: `the title names the board (${hit})`, by: 'title' };
|
|
504
|
+
}
|
|
505
|
+
if (/\bportfolio\b/i.test(String(e.title || ''))) return { ...PORTFOLIO, why: 'the title says Portfolio', by: 'title' };
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const PLANNING = /\b(sprint\s+)?planning\b/i;
|
|
510
|
+
const BLOCKED_STATUS = /\bblock/i;
|
|
511
|
+
|
|
512
|
+
/** One board, for a meeting about it: the active sprint (and, for a planning meeting, the next one), its blocked tickets, the
|
|
513
|
+
* initiatives that are late or at risk, and the open asks. d is routes-boards.js meetingBoards().detail(ws, id). */
|
|
514
|
+
function boardPart(d, e, level) {
|
|
515
|
+
const name = (d && d.name) || level.name || level.id;
|
|
516
|
+
const gist = [`A meeting of the ${name} board.`];
|
|
517
|
+
if (!d) return { gist: [`A meeting of the ${name} board. PMOS could not read the board.`], matters: [], sources: [] };
|
|
518
|
+
if (d.pending) return { gist: [`A meeting of the ${name} board. The board is still being read.`], matters: [], sources: ['the ' + name + ' board'], board: { ws: d.ws, id: d.id, name } };
|
|
519
|
+
const all = (d.sprints && d.sprints.sprints) || [];
|
|
520
|
+
const active = all.find((x) => x.state === 'active') || null;
|
|
521
|
+
const future = all.find((x) => x.state === 'future') || null;
|
|
522
|
+
const planning = PLANNING.test(String(e.title || '')) || PLANNING.test(String(e.purpose || ''));
|
|
523
|
+
const isBlocked = (i) => i.category !== 'done' && (i.flagged || BLOCKED_STATUS.test(i.status || ''));
|
|
524
|
+
const matters = [];
|
|
525
|
+
let sprint = null;
|
|
526
|
+
if (active) {
|
|
527
|
+
const c = active.counts || { todo: 0, doing: 0, done: 0 };
|
|
528
|
+
const total = c.todo + c.doing + c.done; const open = c.todo + c.doing;
|
|
529
|
+
const blocked = (active.issues || []).filter(isBlocked);
|
|
530
|
+
sprint = { name: active.name, start: active.start ? String(active.start).slice(0, 10) : null, end: active.end ? String(active.end).slice(0, 10) : null, counts: c, total, open, blocked: blocked.length, read_at: d.sprints.readAt || null };
|
|
531
|
+
gist.push(`${active.name} has ${open} of ${plural(total, 'ticket')} still open` + (blocked.length ? `, and ${blocked.length} ${blocked.length === 1 ? 'is' : 'are'} blocked.` : '.'));
|
|
532
|
+
for (const i of blocked) matters.push({ rank: 0, tone: 'bad', tag: 'Blocked', text: i.key + ' ' + (i.summary || '') + (i.reason ? ': ' + i.reason : ''), meta: [i.status, i.flagged && 'flagged in Jira'].filter(Boolean).join(' · '), ref: { type: 'jira', id: i.key, board: d.id, ws: d.ws } });
|
|
533
|
+
}
|
|
534
|
+
const next = planning && future ? { name: future.name, planned: (future.issues || []).length, carry: sprint ? sprint.open : 0 } : null;
|
|
535
|
+
const rows = d.rows || [];
|
|
536
|
+
const late = rows.filter((r) => r.status === 'late'); const risk = rows.filter((r) => r.status === 'at risk');
|
|
537
|
+
for (const r of late) matters.push({ rank: 1, tone: 'bad', tag: 'Late', text: r.title, meta: 'initiative on this board' + (r.end ? ' · due ' + r.end : ''), ref: { type: 'initiative', id: r.id } });
|
|
538
|
+
for (const r of risk) matters.push({ rank: 2, tone: 'warn', tag: 'At risk', text: r.title, meta: 'initiative on this board' + (r.end ? ' · due ' + r.end : ''), ref: { type: 'initiative', id: r.id } });
|
|
539
|
+
for (const a of d.asks || []) matters.push({ rank: 3, tone: 'warn', tag: 'New ask', text: a.line, meta: 'waiting in triage', ref: { type: 'ask', id: a.id, board: d.id, ws: d.ws } });
|
|
540
|
+
const ini = [late.length && plural(late.length, 'initiative') + ' late', risk.length && plural(risk.length, 'initiative') + ' at risk'].filter(Boolean);
|
|
541
|
+
if (ini.length) gist.push('On the board: ' + ini.join(' and ') + '.');
|
|
542
|
+
if ((d.asks || []).length) gist.push(`${plural(d.asks.length, 'new ask')} ${d.asks.length === 1 ? 'waits' : 'wait'} in triage.`);
|
|
543
|
+
const sources = ['the ' + name + ' board'];
|
|
544
|
+
if (sprint) sources.push('its ' + sprint.name + ' tickets from Jira');
|
|
545
|
+
matters.sort((a, b) => a.rank - b.rank);
|
|
546
|
+
return { gist, matters: matters.slice(0, MATTERS_CAP).map(({ rank, ...x }) => x), sources, planning: !!next,
|
|
547
|
+
board: { ws: d.ws, id: d.id, name, health: d.health || null, counts: d.counts || null, next_release: d.nextRelease || null }, sprint, next };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const PORTFOLIO_CAP = 12;
|
|
551
|
+
const RELEASE_DAYS = 14;
|
|
552
|
+
/** Every board, for a meeting about the Portfolio: each board's health in one line, then the late and at-risk initiatives, the
|
|
553
|
+
* releases of the next RELEASE_DAYS days and the open asks across the boards. */
|
|
554
|
+
function portfolioPart(list, boards, today) {
|
|
555
|
+
const ds = (list || []).slice(0, PORTFOLIO_CAP).map((b) => (boards && typeof boards.detail === 'function' ? safe(() => boards.detail(b.ws, b.id), null) : null) || { ws: b.ws, id: b.id, name: b.name, error: 'not read' });
|
|
556
|
+
const rows = ds.map((d) => {
|
|
557
|
+
if (d.pending || d.error || !d.counts) return { ws: d.ws, id: d.id, name: d.name, health: null, line: d.pending ? 'still being read' : 'could not be read' };
|
|
558
|
+
const c = d.counts; const asks = (d.asks || []).length;
|
|
559
|
+
const line = [plural(c.open, 'open initiative'), c.late ? c.late + ' late' : null, c.risk ? c.risk + ' at risk' : null, asks ? plural(asks, 'ask') : null].filter(Boolean).join(' · ');
|
|
560
|
+
return { ws: d.ws, id: d.id, name: d.name, health: d.health || 'ok', line };
|
|
561
|
+
});
|
|
562
|
+
const matters = [];
|
|
563
|
+
let late = 0; let risk = 0; let asks = 0; let rel = 0;
|
|
564
|
+
const soon = today ? addDays(today, RELEASE_DAYS) : null;
|
|
565
|
+
for (const d of ds) {
|
|
566
|
+
for (const r of d.rows || []) {
|
|
567
|
+
if (r.status === 'late') late++; else risk++;
|
|
568
|
+
matters.push({ rank: r.status === 'late' ? 0 : 1, tone: r.status === 'late' ? 'bad' : 'warn', tag: r.status === 'late' ? 'Late' : 'At risk', text: r.title, meta: d.name + (r.end ? ' · due ' + r.end : ''), ref: { type: 'initiative', id: r.id } });
|
|
569
|
+
}
|
|
570
|
+
const nr = d.nextRelease;
|
|
571
|
+
if (nr && nr.at && (!soon || String(nr.at).slice(0, 10) <= soon)) { rel++; matters.push({ rank: 2, tone: 'ok', tag: 'Release', text: (nr.type ? nr.type + ' ' : '') + nr.id, meta: d.name + ' · ' + String(nr.at).slice(0, 10), ref: { type: 'release', id: nr.id } }); }
|
|
572
|
+
asks += (d.asks || []).length;
|
|
573
|
+
}
|
|
574
|
+
if (asks) matters.push({ rank: 3, tone: 'warn', tag: 'New asks', text: `${plural(asks, 'ask')} ${asks === 1 ? 'waits' : 'wait'} in triage`, meta: ds.filter((d) => (d.asks || []).length).map((d) => d.name).join(', '), ref: { type: 'portfolio', id: 'asks' } });
|
|
575
|
+
matters.sort((a, b) => a.rank - b.rank);
|
|
576
|
+
const gist = [`A review of ${plural(ds.length, 'board')}.`];
|
|
577
|
+
const parts = [late && plural(late, 'initiative') + ' late', risk && plural(risk, 'initiative') + ' at risk'].filter(Boolean);
|
|
578
|
+
gist.push(parts.length ? 'Across the boards: ' + parts.join(' and ') + '.' : 'No initiative is late or at risk.');
|
|
579
|
+
if (rel) gist.push(`${plural(rel, 'release')} in the next ${RELEASE_DAYS} days.`);
|
|
580
|
+
return { gist, matters: matters.slice(0, MATTERS_CAP + 1).map(({ rank, ...x }) => x), boards: rows, sources: ['every board on the Portfolio'] };
|
|
462
581
|
}
|
|
463
582
|
|
|
464
583
|
/* ── all three ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
465
584
|
|
|
466
|
-
function build({ range, today, nowMs, events, dated, src, own, planItemsDue, inis }) {
|
|
585
|
+
function build({ range, today, nowMs, events, dated, src, own, planItemsDue, inis, links, boards }) {
|
|
467
586
|
const l = layersFrom(dated, range, today);
|
|
468
587
|
const free = freeFrom(events, range, today, { nowMs, own: own || (dated && dated.own) || [], planItems: planItemsDue || (dated && dated.planItems) || [] });
|
|
469
|
-
const c = contextFrom(events, range, src, { today, nowMs, inis });
|
|
588
|
+
const c = contextFrom(events, range, src, { today, nowMs, inis, links, boards });
|
|
470
589
|
return { layers: l.layers, layers_total: l.layers_total, layers_note: l.layers_note, free, context: c.context, context_total: c.context_total };
|
|
471
590
|
}
|
|
472
591
|
|
|
473
|
-
module.exports = { layersFrom, freeFrom, contextFrom, briefOf, seriesNotes, mattersOf, build, toneOf, prepOf, spanOf, prepRisks, minutesOn, localDay, minuteOfDay, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
|
|
592
|
+
module.exports = { layersFrom, freeFrom, contextFrom, briefOf, levelOf, boardPart, portfolioPart, seriesNotes, mattersOf, build, toneOf, prepOf, spanOf, prepRisks, minutesOn, localDay, minuteOfDay, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/calendar-links.js — what a meeting is about: the Portfolio, one board or one initiative (initiative c3-meeting-board).
|
|
3
|
+
*
|
|
4
|
+
* keyOf(event) → 's:<series>' or 't:<normalized title>' (calendar-memory.js seriesKey): one link holds for every occurrence
|
|
5
|
+
* read(runtimeDir) → Map<key, link> link: { key, kind: portfolio|board|initiative, ws, board, initiative, title, at, by }
|
|
6
|
+
* append(runtimeDir, line, by) → { ok, link } | { ok: false, error } kind 'none' removes the link
|
|
7
|
+
* check(body) → null | the reason the body is refused
|
|
8
|
+
*
|
|
9
|
+
* The calendar is read-only (PMOS never edits it), so the link lives beside it: <runtime>/calendar-links.jsonl, append-only, the last
|
|
10
|
+
* line of a key wins. The shape follows the note's link (lib/notes.js: note.initiative, note.board = { id, ws, title, by },
|
|
11
|
+
* note.portfolio = true), so a meeting and its notes say the same thing in the same words. */
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const calMem = require('./calendar-memory.js');
|
|
15
|
+
|
|
16
|
+
const FILE = 'calendar-links.jsonl';
|
|
17
|
+
const KINDS = ['portfolio', 'board', 'initiative', 'none'];
|
|
18
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
|
|
19
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
20
|
+
|
|
21
|
+
const keyOf = (e) => calMem.seriesKey({ series: e && e.series ? String(e.series) : null, title: e && e.title });
|
|
22
|
+
const fileOf = (runtimeDir) => path.join(runtimeDir, FILE);
|
|
23
|
+
|
|
24
|
+
function read(runtimeDir) {
|
|
25
|
+
const out = new Map();
|
|
26
|
+
if (!runtimeDir) return out;
|
|
27
|
+
let text = '';
|
|
28
|
+
try { text = fs.readFileSync(fileOf(runtimeDir), 'utf8'); } catch { return out; }
|
|
29
|
+
for (const line of text.split('\n')) {
|
|
30
|
+
if (!line.trim()) continue;
|
|
31
|
+
let x; try { x = JSON.parse(line); } catch { continue; }
|
|
32
|
+
if (!x || typeof x.key !== 'string' || !KINDS.includes(x.kind)) continue;
|
|
33
|
+
if (x.kind === 'none') { out.delete(x.key); continue; }
|
|
34
|
+
out.set(x.key, { key: x.key, kind: x.kind, ws: x.ws || null, board: x.board || null, initiative: x.initiative || null, title: x.title || null, at: x.at || null, by: x.by || null });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The reason a POST body is refused, or null. body: { event: { title, series? }, kind, ws?, board?, initiative?, title? } */
|
|
40
|
+
function check(body) {
|
|
41
|
+
if (!body || typeof body !== 'object') return 'the body must be a JSON object';
|
|
42
|
+
const e = body.event;
|
|
43
|
+
if (!e || typeof e !== 'object' || !str(e.title) || str(e.title).length > 300) return 'event.title is required (one line, at most 300 characters)';
|
|
44
|
+
if (e.series != null && (typeof e.series !== 'string' || e.series.length > 300)) return 'event.series must be text';
|
|
45
|
+
if (!KINDS.includes(body.kind)) return 'kind is one of ' + KINDS.join(', ');
|
|
46
|
+
if (body.kind === 'board' && !(ID_RE.test(str(body.board)) && ID_RE.test(str(body.ws)))) return 'a board link needs ws and board';
|
|
47
|
+
if (body.kind === 'initiative' && !ID_RE.test(str(body.initiative))) return 'an initiative link needs initiative';
|
|
48
|
+
if (body.title != null && (typeof body.title !== 'string' || body.title.length > 200 || /[\r\n]/.test(body.title))) return 'title is one line of at most 200 characters';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function append(runtimeDir, body, by) {
|
|
53
|
+
const bad = check(body);
|
|
54
|
+
if (bad) return { ok: false, error: bad };
|
|
55
|
+
const line = { at: new Date().toISOString(), by: by || null, key: keyOf(body.event), kind: body.kind, event_title: str(body.event.title) };
|
|
56
|
+
if (body.kind === 'board') { line.ws = str(body.ws); line.board = str(body.board); }
|
|
57
|
+
if (body.kind === 'initiative') { line.initiative = str(body.initiative); if (ID_RE.test(str(body.ws))) line.ws = str(body.ws); }
|
|
58
|
+
if (str(body.title)) line.title = str(body.title);
|
|
59
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
60
|
+
fs.appendFileSync(fileOf(runtimeDir), JSON.stringify(line) + '\n');
|
|
61
|
+
return { ok: true, link: body.kind === 'none' ? null : { key: line.key, kind: line.kind, ws: line.ws || null, board: line.board || null, initiative: line.initiative || null, title: line.title || null, at: line.at, by: line.by } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { keyOf, read, append, check, KINDS, FILE };
|
|
@@ -530,10 +530,12 @@ function sprintsOf(snapshots, onBoard = () => true) {
|
|
|
530
530
|
for (const t of tickets) {
|
|
531
531
|
if (!t || !t.sprint || !t.sprint.name) continue;
|
|
532
532
|
const name = String(t.sprint.name);
|
|
533
|
-
if (!byName.has(name)) byName.set(name, { name, state: t.sprint.state || null, issues: [], counts: { todo: 0, doing: 0, done: 0 } });
|
|
533
|
+
if (!byName.has(name)) byName.set(name, { name, state: t.sprint.state || null, start: null, end: null, issues: [], counts: { todo: 0, doing: 0, done: 0 } });
|
|
534
534
|
const sp = byName.get(name);
|
|
535
|
+
if (!sp.start && typeof t.sprint.start === 'string' && t.sprint.start) sp.start = t.sprint.start; // the sprint's dates, as Jira holds them
|
|
536
|
+
if (!sp.end && typeof t.sprint.end === 'string' && t.sprint.end) sp.end = t.sprint.end;
|
|
535
537
|
const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
|
|
536
|
-
sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative });
|
|
538
|
+
sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null });
|
|
537
539
|
sp.counts[category]++;
|
|
538
540
|
}
|
|
539
541
|
}
|
|
@@ -778,4 +778,54 @@ function boardIndex(ctx, w) {
|
|
|
778
778
|
return { boards: fr.boards.map((b) => ({ id: b.id, title: b.name })), of: (id) => byId.get(id) || boardIdOf({ id }, fr.boards, fr.folded, w) };
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
-
|
|
781
|
+
/** What a meeting card reads about boards (initiative c3-meeting-board, lib/calendar-layers.js briefOf): every board's name and
|
|
782
|
+
* Jira project keys (a meeting title that names one), and on demand one board's state: its open asks, the initiatives that are
|
|
783
|
+
* late or at risk, the next release and its sprints from the cached Jira readings. Read-only, like the GETs above; a board whose
|
|
784
|
+
* rows are still being read answers { pending: true }. */
|
|
785
|
+
function meetingBoards(ctx) {
|
|
786
|
+
const frames = new Map();
|
|
787
|
+
const frameOf = (w) => { if (!frames.has(w.id)) { try { frames.set(w.id, frameFor(ctx, w)); } catch { frames.set(w.id, null); } } return frames.get(w.id); };
|
|
788
|
+
const list = () => {
|
|
789
|
+
const out = [];
|
|
790
|
+
for (const w of (ctx.workspaceList ? ctx.workspaceList() : [])) {
|
|
791
|
+
const fr = frameOf(w);
|
|
792
|
+
if (fr) for (const b of fr.boards) out.push({ ws: w.id, id: b.id, name: b.name, jiraProjects: b.jiraProjects || [], build: !!b.build });
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
};
|
|
796
|
+
let releases = null;
|
|
797
|
+
const detail = (wsId, boardId) => {
|
|
798
|
+
const w = (ctx.workspaceList ? ctx.workspaceList() : []).find((x) => x.id === wsId);
|
|
799
|
+
const fr = w && frameOf(w);
|
|
800
|
+
const board = fr && fr.boards.find((b) => b.id === boardId);
|
|
801
|
+
if (!board) return null;
|
|
802
|
+
const head = { ws: w.id, id: board.id, name: board.name, build: !!board.build, jiraProjects: board.jiraProjects || [] };
|
|
803
|
+
const asks = [...fr.folded.asks.values()].filter((a) => a.board === board.id && a.state === 'open')
|
|
804
|
+
.sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))).map((a) => ({ id: a.id, line: a.line, at: a.at || null, source: a.source || null }));
|
|
805
|
+
let b;
|
|
806
|
+
try { b = baseRows(w); } catch (e) { return { ...head, asks, error: String((e && e.message) || e).slice(0, 160) }; }
|
|
807
|
+
if (b.pending) return { ...head, asks, pending: true };
|
|
808
|
+
if (!b.ok) return { ...head, asks, error: b.error };
|
|
809
|
+
const rows = rowsOfBoard(fr, board, b.rows);
|
|
810
|
+
const c = B.counts(rows, board.workflow);
|
|
811
|
+
if (!releases) releases = readReleases(ctx);
|
|
812
|
+
const ids = new Set(rows.map((x) => x.id));
|
|
813
|
+
let sprints = { source: 'none', readAt: null, sprints: [] };
|
|
814
|
+
if (board.build) {
|
|
815
|
+
const snaps = [];
|
|
816
|
+
try {
|
|
817
|
+
for (const q of jiraStatus.linkedInitiatives(w.root).filter((x) => x.linked && ids.has(x.initiative))) {
|
|
818
|
+
const s = jiraStatus.readSnapshot(w.root, q.initiative);
|
|
819
|
+
if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
|
|
820
|
+
}
|
|
821
|
+
} catch { /* no snapshot yet */ }
|
|
822
|
+
sprints = B.sprintsOf(snaps, (id) => ids.has(id));
|
|
823
|
+
}
|
|
824
|
+
return { ...head, health: B.health(rows), counts: { total: c.total, open: c.open, risk: c.risk, late: c.late },
|
|
825
|
+
rows: rows.filter((r) => r.status === 'late' || r.status === 'at risk').map((r) => ({ id: r.id, title: r.title, status: r.status, end: r.end || null, owner: r.owner || null })),
|
|
826
|
+
asks, nextRelease: nextReleaseOf(releases, ids), sprints };
|
|
827
|
+
};
|
|
828
|
+
return { list, detail };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
module.exports = { prefixes: ['/api/boards'], handle, boardIndex, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* → { ok, view, range, cells, unit, gantt, strategy, events_source, today,
|
|
5
5
|
* layers, layers_total, layers_note, free, context, context_total, (lib/calendar-layers.js)
|
|
6
|
+
* link_boards } every board a meeting can be linked to: [{ ws, id, name }]
|
|
7
|
+
* POST /api/calendar-links → { ok, link } what a meeting is about (lib/calendar-links.js, below)
|
|
6
8
|
* tidy, tidy_total, tidy_note } (lib/calendar-tidy.js: suggestions in words, no write)
|
|
7
9
|
*
|
|
8
10
|
* READ ONLY. Initiatives come from the signal engine's repo reader (scripts/signals/facts.js readRepoFacts), required inside
|
|
@@ -57,7 +59,15 @@ function handle(query, deps = {}) {
|
|
|
57
59
|
const nowMs = Number.isFinite(deps.nowMs) ? deps.nowMs : (deps.today ? undefined : Date.now());
|
|
58
60
|
// the initiative of a coming meeting: its title from the facts, its status from the Gantt row when the row is in range
|
|
59
61
|
const iniRows = inis.filter((i) => i && i.id).map((i) => { const g = gantt.find((x) => x.id === i.id); return { id: i.id, title: i.title || i.id, status: g ? g.status : null }; });
|
|
60
|
-
|
|
62
|
+
// what a coming meeting is about (initiative c3-meeting-board): the PM's links and the boards, read once per answer
|
|
63
|
+
let links = null;
|
|
64
|
+
if (typeof deps.readLinks === 'function') { try { links = deps.readLinks(); } catch { links = null; } }
|
|
65
|
+
const boards = deps.boards || null;
|
|
66
|
+
let boardList = [];
|
|
67
|
+
if (boards && typeof boards.list === 'function') { try { boardList = boards.list() || []; } catch { boardList = []; } }
|
|
68
|
+
const boardsOnce = boards ? { list: () => boardList, detail: memo(boards.detail) } : null;
|
|
69
|
+
const extra = layersLib.build({ range, today: nowDay, nowMs, events, dated, src, own: items.own, planItemsDue: items.planItems, inis: iniRows, links, boards: boardsOnce });
|
|
70
|
+
extra.link_boards = boardList.map((x) => ({ ws: x.ws, id: x.id, name: x.name }));
|
|
61
71
|
const tidy = tidyLib.tidyFrom({ range, today: nowDay, nowMs, events, eventsSource: source, inis, gantt,
|
|
62
72
|
planItems: dated.planItems || items.planItems || [] });
|
|
63
73
|
return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay, ...extra, ...tidy } };
|
|
@@ -66,6 +76,13 @@ function handle(query, deps = {}) {
|
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
/** fn(ws, id) once per pair: a Portfolio meeting and a board meeting in the same answer read the board once. */
|
|
80
|
+
function memo(fn) {
|
|
81
|
+
if (typeof fn !== 'function') return null;
|
|
82
|
+
const seen = new Map();
|
|
83
|
+
return (ws, id) => { const k = ws + '\u0000' + id; if (!seen.has(k)) seen.set(k, fn(ws, id)); return seen.get(k); };
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
/* ── the readers behind the layers, free time and context (each source on its own: one that cannot be read is named in
|
|
70
87
|
* layers_note and gives nothing; it never takes the answer down) ────────────────────────────────────────────────── */
|
|
71
88
|
const reason = (e) => String((e && e.message) || e).slice(0, 160);
|
|
@@ -169,7 +186,9 @@ function linkedNotes(runtimeDir) {
|
|
|
169
186
|
const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
|
|
170
187
|
const ov = fin && typeof fin.overview === 'string' && fin.overview.trim() ? fin.overview
|
|
171
188
|
: sec && Array.isArray(sec.overview) ? sec.overview.slice(0, 3).map((x) => String((x && x.text) || '')).filter(Boolean).join(' ') : '';
|
|
172
|
-
|
|
189
|
+
// what the note is about (the Notes page writes it): the Portfolio, a board, or an initiative (which names its board too)
|
|
190
|
+
const nb = n.board && typeof n.board === 'object' && n.board.id ? { id: String(n.board.id), ws: n.board.ws ? String(n.board.ws) : null, title: n.board.title ? String(n.board.title) : null } : null;
|
|
191
|
+
out.push({ id: n.id, title: n.title || '', private: !!n.private, initiative: n.initiative && n.initiative.id ? String(n.initiative.id) : null, board: nb, portfolio: n.portfolio === true,
|
|
173
192
|
meeting: { event_id: n.meeting.event_id || null, start: n.meeting.start, title: n.meeting.title, series: n.meeting.context && n.meeting.context.series || null },
|
|
174
193
|
overview: String(ov).replace(/\s+/g, ' ').trim().slice(0, 600),
|
|
175
194
|
decisions: pick(fin && fin.decisions, sec && sec.decisions).map((x) => ({ text: x && x.text })),
|
|
@@ -178,11 +197,29 @@ function linkedNotes(runtimeDir) {
|
|
|
178
197
|
return out;
|
|
179
198
|
}
|
|
180
199
|
|
|
200
|
+
/** POST /api/calendar-links { event: { title, series? }, kind: portfolio|board|initiative|none, ws?, board?, initiative?, title? }
|
|
201
|
+
* → { ok, link }. What a meeting is about, for every occurrence of it (lib/calendar-links.js). The page writes it from the meeting card. */
|
|
202
|
+
async function postLink(req, res, ctx) {
|
|
203
|
+
const got = await ctx.readJson(req);
|
|
204
|
+
if (!got.ok || !got.body || typeof got.body !== 'object' || Array.isArray(got.body)) { ctx.json(res, { ok: false, error: 'the body must be a JSON object' }, 400); return true; }
|
|
205
|
+
const g = require('./pm-guard.js').requirePagePress(req, got.body);
|
|
206
|
+
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
207
|
+
if (!ctx.runtimeDir) { ctx.json(res, { ok: false, error: 'this server has no runtime folder, so the link cannot be saved' }, 409); return true; }
|
|
208
|
+
const by = g.actor && g.actor.by ? g.actor.by : null;
|
|
209
|
+
const out = require('./calendar-links.js').append(ctx.runtimeDir, got.body, by);
|
|
210
|
+
ctx.json(res, out, out.ok ? 200 : 400);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
181
214
|
module.exports = {
|
|
182
215
|
readers,
|
|
183
|
-
prefixes: ['/api/calendar-views'],
|
|
216
|
+
prefixes: ['/api/calendar-views', '/api/calendar-links'],
|
|
184
217
|
handlePure: handle,
|
|
185
218
|
handle: async (req, res, url, ctx) => {
|
|
219
|
+
if (url.pathname === '/api/calendar-links') {
|
|
220
|
+
if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
|
|
221
|
+
return postLink(req, res, ctx);
|
|
222
|
+
}
|
|
186
223
|
if (url.pathname !== '/api/calendar-views') return false;
|
|
187
224
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
188
225
|
const w = ctx.getWs(url.searchParams.get('ws'));
|
|
@@ -194,6 +231,8 @@ module.exports = {
|
|
|
194
231
|
const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
|
|
195
232
|
readFacts: () => facts,
|
|
196
233
|
readEvents: () => require('./calendar-events.js').readAll(ctx.runtimeDir),
|
|
234
|
+
readLinks: () => require('./calendar-links.js').read(ctx.runtimeDir),
|
|
235
|
+
boards: require('./routes-boards.js').meetingBoards(ctx),
|
|
197
236
|
...readers(root, ctx.runtimeDir),
|
|
198
237
|
});
|
|
199
238
|
ctx.json(res, out.body, out.status);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.155.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.155.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED