create-pmos 0.68.0 → 0.69.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/initiatives-screen.md +1 -1
- package/workspace/guide/notes.md +12 -1
- package/workspace/guide/today.md +3 -1
- package/workspace/lib/notes-links.js +56 -0
- package/workspace/lib/notes.js +87 -5
- package/workspace/lib/routes-notes.js +47 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +87 -0
- package/workspace/public/pm/initiative-sheet.js +10 -2
- package/workspace/public/pm/initiatives-copy.js +1 -0
- package/workspace/public/pm/notes-links.js +110 -0
- package/workspace/public/pm/today-agenda.js +6 -1
- package/workspace/public/pm/today-day.js +6 -0
- package/workspace/public/pm/view-notes.js +113 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.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-07T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
|
|
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-07T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
|
|
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-07T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -77,7 +77,7 @@ From the top:
|
|
|
77
77
|
1. The horizon, the health reason when there is one, and the More button.
|
|
78
78
|
2. Line 1: the stage and who the initiative waits on. Line 2: the stage steps, as a row of stages.
|
|
79
79
|
3. One line of text: "Waiting on you: approval" with **Open gate**, or "Waiting on agent: build" with **Hand to agent**. When nothing is owed it says so.
|
|
80
|
-
4.
|
|
80
|
+
4. The tabs: **Breakdown**, **Files**, **History**, **Jira tickets**, **People** and **Notes**. The Breakdown tab is the same Breakdown as in its own tab, and it shows here too. **Notes** lists the meeting notes linked to the initiative, newest first, each with its summary and **Open**. See [Notes](notes.md).
|
|
81
81
|
|
|
82
82
|
An initiative that is finished reads "Accepted". An initiative on hold, closed or cancelled reads that word instead of a stage. After 5 days with no activity, the line adds "idle 5 d".
|
|
83
83
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
4
|
-
description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, and get a summary."
|
|
4
|
+
description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
|
|
5
5
|
timestamp: 2026-10-06
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -86,6 +86,17 @@ When a meeting was heard with local transcription, its audio is kept on this mac
|
|
|
86
86
|
|
|
87
87
|
To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
|
|
88
88
|
|
|
89
|
+
## Link a note to its meeting and its initiative
|
|
90
|
+
|
|
91
|
+
Under the title, a note shows two lines: **Meeting** and **Initiative**.
|
|
92
|
+
|
|
93
|
+
- **Meeting** is the calendar event the note was taken in, or **Not on the calendar** for a hallway chat or a call. Press **Change** to select an event of the note's day or the day before, or **Not on the calendar**.
|
|
94
|
+
- **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to select a suggestion, any open initiative of the selected repository, or **None**. Press the initiative's name to open it.
|
|
95
|
+
|
|
96
|
+
When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
|
|
97
|
+
|
|
98
|
+
To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. The note is linked to the event, and to the event's initiative when it has one. An event that already has a note shows its summary and **Open notes** instead. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
|
|
99
|
+
|
|
89
100
|
## Dictate into your notes
|
|
90
101
|
|
|
91
102
|
1. Put the cursor where the text must go, then press **Dictate** and speak.
|
package/workspace/guide/today.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Today: what needs you"
|
|
4
|
-
description: "The Agenda month and the calendar it remembers, the two-column day (your jobs and the agent's jobs), the eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
|
|
4
|
+
description: "The Agenda month and the calendar it remembers, the meeting notes of a meeting, the two-column day (your jobs and the agent's jobs), the eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
|
|
5
5
|
timestamp: 2026-10-06
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -50,6 +50,8 @@ Each day of the month answers three questions:
|
|
|
50
50
|
|
|
51
51
|
The switch above the month shows one answer at a time: **Overview** (all three), **Full** (the time in meetings), **Key** (the meeting that matters), **Prep** (how ready you are) or **Free** (the free blocks of 90 minutes or more).
|
|
52
52
|
|
|
53
|
+
A meeting's detail ends with **Meeting notes**. Press **Take notes** to start a note linked to the meeting, and to its initiative when the brief names one. When the meeting has a note, the detail shows its summary and **Open notes**, and the meeting's row shows **notes**. See [Notes](notes.md).
|
|
54
|
+
|
|
53
55
|
The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters.
|
|
54
56
|
|
|
55
57
|
To mark a meeting yourself, open it. Press **Mark as key** to make this time a key meeting, or **Key every time** for every time it happens. On a meeting that is key already, press **Not key** or **Never key**. Your mark is used before what the calendar says. Press **Remove my mark** to go back to what the calendar says. The marks are kept in `calendar-marks.json` in the state folder. **Before this meeting** lists the prep. **Last time** is folded: open it to read the notes and what stayed open.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-links.js — which initiative a note belongs to, suggested locally (meeting-notes Run 4). Local only.
|
|
3
|
+
*
|
|
4
|
+
* initiativesOf(root) → [{ id, title, anchor, intent }] the open initiatives of one repository
|
|
5
|
+
* suggest(note, open) → { pick: { id, title, shared } | null, ranked: [{ id, title, shared }] }
|
|
6
|
+
*
|
|
7
|
+
* HOW IT SUGGESTS. It compares the words of the note (title, body, transcript lines, and the calendar event's title) with the
|
|
8
|
+
* words of each open initiative (id, title and the first paragraph of its record). Short and common words do not count. The
|
|
9
|
+
* initiative that shares the most distinct words is the suggestion when it shares 3 or more and no other initiative shares
|
|
10
|
+
* as many. Otherwise there is no suggestion, and the PM chooses. A suggestion never replaces the PM's own choice
|
|
11
|
+
* (lib/notes.js link refuses `by: 'local'` over `by: 'pm'`).
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT NEVER DOES. It sends nothing and opens no network module. It reads the repository's planning/initiatives records
|
|
14
|
+
* through lib/live-notes.js loadInitiatives and writes nothing.
|
|
15
|
+
*/
|
|
16
|
+
const liveNotes = require('./live-notes.js');
|
|
17
|
+
|
|
18
|
+
const MIN_SHARED = 3;
|
|
19
|
+
const MAX_RANKED = 50;
|
|
20
|
+
const STOP = new Set(('the and for are but not you all any can had her was one our out has his how its may new now old see two way who '
|
|
21
|
+
+ 'did get got let put say she too use with this that from they will would there their what when which while about into than then them '
|
|
22
|
+
+ 'these those have been were being also just like some more most much very only over such each other could should after before where '
|
|
23
|
+
+ 'because does doing done make made need needs want wants yes okay meeting notes note today tomorrow yang dan untuk dengan ini itu '
|
|
24
|
+
+ 'dari ada tidak akan bisa kita kami saya juga sudah belum').split(' '));
|
|
25
|
+
|
|
26
|
+
/** Distinct content words, lower case, stemmed only by a trailing s. */
|
|
27
|
+
function terms(text) {
|
|
28
|
+
const out = new Set();
|
|
29
|
+
for (const w of String(text || '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || []) {
|
|
30
|
+
if (w.length < 3 || STOP.has(w) || /^\d+$/.test(w)) continue;
|
|
31
|
+
out.add(w.length > 4 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function noteText(note) {
|
|
36
|
+
const n = note || {};
|
|
37
|
+
return [n.title, n.body, n.meeting && n.meeting.title, ...(n.transcript || []).map((s) => s && s.text)].join('\n');
|
|
38
|
+
}
|
|
39
|
+
function initiativesOf(root) {
|
|
40
|
+
try { return liveNotes.loadInitiatives(root).open.map((i) => ({ id: i.id, title: i.title, anchor: i.anchor, intent: i.intent })); } catch { return []; }
|
|
41
|
+
}
|
|
42
|
+
/** Pure. `open` is initiativesOf's list. */
|
|
43
|
+
function suggest(note, open) {
|
|
44
|
+
const mine = terms(noteText(note));
|
|
45
|
+
const ranked = (open || []).map((i) => {
|
|
46
|
+
const theirs = terms([String(i.id).replace(/[-_.]/g, ' '), i.title, i.intent].join(' '));
|
|
47
|
+
let shared = 0;
|
|
48
|
+
for (const t of theirs) if (mine.has(t)) shared += 1;
|
|
49
|
+
return { id: i.id, title: i.title, shared };
|
|
50
|
+
}).sort((a, b) => b.shared - a.shared || String(a.title).localeCompare(String(b.title)));
|
|
51
|
+
const top = ranked[0];
|
|
52
|
+
const pick = top && top.shared >= MIN_SHARED && !(ranked[1] && ranked[1].shared === top.shared) ? top : null;
|
|
53
|
+
return { pick, ranked: ranked.slice(0, MAX_RANKED) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { terms, noteText, initiativesOf, suggest, MIN_SHARED };
|
package/workspace/lib/notes.js
CHANGED
|
@@ -83,7 +83,84 @@ const wordsOf = (s) => (String(s || '').match(/[\p{L}\p{N}']+/gu) || []).length;
|
|
|
83
83
|
/** The row a list shows. Never the body or the transcript. */
|
|
84
84
|
function rowOf(n) {
|
|
85
85
|
return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
|
|
86
|
-
words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown))
|
|
86
|
+
words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
|
|
87
|
+
meeting: n.meeting || null, initiative: n.initiative || null, ws: n.ws || null,
|
|
88
|
+
// a private note shows its title on the calendar and the initiative, never its summary
|
|
89
|
+
summary_lines: n.private ? [] : summaryLines(n) };
|
|
90
|
+
}
|
|
91
|
+
/** Up to three lines of the note's summary: the agent's, else the local overview. Plain text. */
|
|
92
|
+
function summaryLines(n) {
|
|
93
|
+
if (n.agent && n.agent.markdown) {
|
|
94
|
+
return String(n.agent.markdown).split(/\r?\n/).map((l) => l.replace(/^\s*(?:#+|[-*]|\d+[.)])\s*/, '').replace(/\*\*|__|`/g, '').trim())
|
|
95
|
+
.filter((l) => l && !/^summary$/i.test(l)).slice(0, 3).map((l) => l.slice(0, 200));
|
|
96
|
+
}
|
|
97
|
+
const ov = n.summary && n.summary.sections && n.summary.sections.overview;
|
|
98
|
+
return Array.isArray(ov) ? ov.slice(0, 3).map((x) => String((x && x.text) || '').slice(0, 200)).filter(Boolean) : [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ── the meeting and the initiative a note belongs to (meeting-notes Run 4) ──────────────────────── */
|
|
102
|
+
const EVENT_ID_RE = /^[A-Za-z0-9_-]{1,40}$/;
|
|
103
|
+
const INI_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
104
|
+
const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
|
|
105
|
+
const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
106
|
+
/** A meeting link: a calendar event (its id, where it came from, its start, end and title) or a meeting that is not on the
|
|
107
|
+
* calendar. null unlinks. Returns { ok, meeting } or an error. */
|
|
108
|
+
function cleanMeeting(m, now) {
|
|
109
|
+
if (m === null) return { ok: true, meeting: null };
|
|
110
|
+
if (!m || typeof m !== 'object') return err(400, 'say which meeting: { kind: "event", … } or { kind: "adhoc" }');
|
|
111
|
+
if (m.kind === 'adhoc') return { ok: true, meeting: { kind: 'adhoc', linked_at: iso(now) } };
|
|
112
|
+
if (m.kind !== 'event') return err(400, 'the meeting kind must be event or adhoc');
|
|
113
|
+
const title = cleanTitle(m.title);
|
|
114
|
+
if (!title) return err(400, 'a calendar event needs its title');
|
|
115
|
+
if (minuteOf(m.start) === null) return err(400, 'a calendar event needs its start time');
|
|
116
|
+
if (m.end != null && minuteOf(m.end) === null) return err(400, 'the end of the event is not a time');
|
|
117
|
+
if (m.event_id != null && !EVENT_ID_RE.test(String(m.event_id))) return err(400, 'that is not a calendar event id');
|
|
118
|
+
return { ok: true, meeting: { kind: 'event', event_id: m.event_id != null ? String(m.event_id) : null, src: m.src === 'memory' ? 'memory' : 'brief',
|
|
119
|
+
start: new Date(Date.parse(m.start)).toISOString(), end: m.end != null ? new Date(Date.parse(m.end)).toISOString() : null, title, linked_at: iso(now) } };
|
|
120
|
+
}
|
|
121
|
+
/** An initiative link: { id, ws, repo, title, by }. `repo` is the repository's name in the page, to open the initiative. `by` is pm (chosen), event (from the calendar event) or local (suggested). */
|
|
122
|
+
function cleanInitiative(x, by) {
|
|
123
|
+
if (x === null) return { ok: true, initiative: null };
|
|
124
|
+
if (!x || typeof x !== 'object' || !INI_ID_RE.test(String(x.id || ''))) return err(400, 'that is not an initiative id');
|
|
125
|
+
return { ok: true, initiative: { id: String(x.id), ws: typeof x.ws === 'string' && x.ws.length <= 40 ? x.ws : null,
|
|
126
|
+
repo: typeof x.repo === 'string' && x.repo.length <= 120 ? x.repo : null, title: x.title ? cleanTitle(x.title) : null, by: ['pm', 'event', 'local'].includes(by) ? by : 'pm', ...(by === 'local' && Number.isFinite(x.shared) ? { shared: x.shared } : {}) } };
|
|
127
|
+
}
|
|
128
|
+
/** Whether a note's meeting is this calendar event: the same id, or the same start minute and title (the brief and the
|
|
129
|
+
* calendar memory give one meeting different ids). Pure. */
|
|
130
|
+
function sameEvent(meeting, e) {
|
|
131
|
+
if (!meeting || meeting.kind !== 'event' || !e) return false;
|
|
132
|
+
if (meeting.event_id && e.event_id && meeting.event_id === e.event_id) return true;
|
|
133
|
+
const a = minuteOf(meeting.start); const b = minuteOf(e.start);
|
|
134
|
+
return a !== null && a === b && normTitle(meeting.title) === normTitle(e.title);
|
|
135
|
+
}
|
|
136
|
+
/** The note already linked to this calendar event, or null. */
|
|
137
|
+
function findByEvent(dir, e) {
|
|
138
|
+
let names = [];
|
|
139
|
+
try { names = fs.readdirSync(notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return null; }
|
|
140
|
+
const hits = names.map((f) => load(dir, f.slice(0, -5))).filter((n) => n && sameEvent(n.meeting, e));
|
|
141
|
+
hits.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
|
|
142
|
+
return hits[0] || null;
|
|
143
|
+
}
|
|
144
|
+
/** Link a note to its meeting and to its initiative. A field left out is kept. The PM's initiative is never replaced by a
|
|
145
|
+
* suggestion: `by: 'local'` is refused when the PM chose one. */
|
|
146
|
+
function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}) {
|
|
147
|
+
const note = load(dir, id);
|
|
148
|
+
if (!note) return err(404, 'no such note');
|
|
149
|
+
if (meeting !== undefined) {
|
|
150
|
+
const m = cleanMeeting(meeting, now);
|
|
151
|
+
if (!m.ok) return m;
|
|
152
|
+
note.meeting = m.meeting;
|
|
153
|
+
if (m.meeting && m.meeting.kind === 'event' && !String(note.title || '').trim()) note.title = m.meeting.title;
|
|
154
|
+
}
|
|
155
|
+
if (initiative !== undefined) {
|
|
156
|
+
if (by !== 'pm' && note.initiative && note.initiative.by === 'pm') return { ok: true, note, kept: true };
|
|
157
|
+
const c = cleanInitiative(initiative, by);
|
|
158
|
+
if (!c.ok) return c;
|
|
159
|
+
note.initiative = c.initiative;
|
|
160
|
+
}
|
|
161
|
+
note.updated_at = iso(now);
|
|
162
|
+
store(dir, note);
|
|
163
|
+
return { ok: true, note };
|
|
87
164
|
}
|
|
88
165
|
function list(dir) {
|
|
89
166
|
let names = [];
|
|
@@ -93,10 +170,15 @@ function list(dir) {
|
|
|
93
170
|
notes.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || (a.id < b.id ? 1 : -1));
|
|
94
171
|
return { ok: true, notes };
|
|
95
172
|
}
|
|
96
|
-
function create(dir, { title = '', now = Date.now() } = {}) {
|
|
173
|
+
function create(dir, { title = '', meeting, initiative, now = Date.now() } = {}) {
|
|
97
174
|
const at = iso(now);
|
|
98
|
-
const
|
|
99
|
-
|
|
175
|
+
const m = meeting === undefined ? { ok: true, meeting: null } : cleanMeeting(meeting, now);
|
|
176
|
+
if (!m.ok) return m;
|
|
177
|
+
// a note taken from a calendar event inherits the event's initiative, marked as coming from the event
|
|
178
|
+
const c = initiative === undefined || initiative === null ? { ok: true, initiative: null } : cleanInitiative(initiative, m.meeting && m.meeting.kind === 'event' ? 'event' : 'pm');
|
|
179
|
+
if (!c.ok) return c;
|
|
180
|
+
const note = { v: 1, id: newId(now), title: cleanTitle(title) || (m.meeting && m.meeting.title) || '', created_at: at, updated_at: at, body: '', private: false,
|
|
181
|
+
recorded_from: null, transcript: [], summary: null, agent: null, meeting: m.meeting, initiative: c.initiative };
|
|
100
182
|
store(dir, note);
|
|
101
183
|
return { ok: true, note };
|
|
102
184
|
}
|
|
@@ -600,5 +682,5 @@ function importAgent(dir, note) {
|
|
|
600
682
|
return true;
|
|
601
683
|
}
|
|
602
684
|
|
|
603
|
-
module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
|
|
685
|
+
module.exports = { list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
|
|
604
686
|
summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf };
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to
|
|
2
|
+
/* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 4; lib/route-modules.js).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
|
|
5
5
|
* GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
|
|
6
|
-
* POST /api/notes/new { title? }
|
|
6
|
+
* POST /api/notes/new { title?, meeting?, initiative? } → { ok, note, reused } a calendar event that already has a note
|
|
7
|
+
* gets that note back (reused: true), so one meeting keeps one note (Run 4)
|
|
8
|
+
* POST /api/notes/link { id, meeting?, initiative?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
|
|
9
|
+
* GET /api/notes/initiatives?id=…&ws=… → { ok, open: [{ id, title }], pick } the repository's open initiatives, and the local suggestion
|
|
7
10
|
* POST /api/notes/save { id, title?, body? } → { ok, updated_at }
|
|
8
11
|
* POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
|
|
9
12
|
* POST /api/notes/record { id, ws? } → { ok, recorded_from } `ws` is the repository the note's lines are checked against
|
|
@@ -19,7 +22,8 @@
|
|
|
19
22
|
* { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
|
|
20
23
|
* POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
|
|
21
24
|
* POST /api/notes/private { id, private } → { ok, private }
|
|
22
|
-
* POST /api/notes/summarize { id }
|
|
25
|
+
* POST /api/notes/summarize { id, ws? } → { ok, summary, initiative } the local summary; with ws and no initiative yet,
|
|
26
|
+
* the local suggestion is linked (by: 'local')
|
|
23
27
|
* POST /api/notes/summarize-agent { id, ws? } → { ok, started, session, handoff, out, withheld, reason }
|
|
24
28
|
* POST /api/notes/delete { id } → { ok }
|
|
25
29
|
*
|
|
@@ -38,6 +42,7 @@ const notes = require('./notes.js');
|
|
|
38
42
|
const whisper = require('./notes-whisper.js');
|
|
39
43
|
const notesRepo = require('./notes-repo.js');
|
|
40
44
|
const notesAudio = require('./notes-audio.js');
|
|
45
|
+
const notesLinks = require('./notes-links.js');
|
|
41
46
|
const jevNotes = require('./jev-notes.js');
|
|
42
47
|
const tsSettings = require('./typesafe-settings.js');
|
|
43
48
|
|
|
@@ -47,7 +52,7 @@ const CHECKED = new Set(['decision', 'action', 'statement']);
|
|
|
47
52
|
const prefixes = ['/api/notes'];
|
|
48
53
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
49
54
|
'/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
50
|
-
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete']);
|
|
55
|
+
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link']);
|
|
51
56
|
|
|
52
57
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
53
58
|
const chains = new Map(); // note id → the promise of its enrichment so far
|
|
@@ -144,6 +149,19 @@ async function handle(req, res, url, ctx) {
|
|
|
144
149
|
notesAudio.serve(req, res, dir, nid);
|
|
145
150
|
return true;
|
|
146
151
|
}
|
|
152
|
+
if (p === '/api/notes/initiatives') {
|
|
153
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
154
|
+
const wsId = url.searchParams.get('ws');
|
|
155
|
+
const w = wsId ? ctx.getWs(wsId) : null;
|
|
156
|
+
if (!w || w.id !== wsId) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
157
|
+
const open = notesLinks.initiativesOf(w.root);
|
|
158
|
+
const nid = url.searchParams.get('id');
|
|
159
|
+
const r = nid ? notes.read(dir, nid) : null;
|
|
160
|
+
if (r && !r.ok) { send(ctx, res, r); return true; }
|
|
161
|
+
const s = r ? notesLinks.suggest(r.note, open) : { pick: null, ranked: [] };
|
|
162
|
+
ctx.json(res, { ok: true, open: open.map((i) => ({ id: i.id, title: i.title })), pick: s.pick });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
147
165
|
if (p === '/api/notes/engine') {
|
|
148
166
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
149
167
|
const wsId = url.searchParams.get('ws');
|
|
@@ -160,7 +178,19 @@ async function handle(req, res, url, ctx) {
|
|
|
160
178
|
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
161
179
|
const id = body && typeof body.id === 'string' ? body.id : '';
|
|
162
180
|
const done = (r) => { send(ctx, res, r); return true; };
|
|
163
|
-
if (p === '/api/notes/new')
|
|
181
|
+
if (p === '/api/notes/new') {
|
|
182
|
+
// one calendar event keeps one note: Take notes on an event that already has one opens it
|
|
183
|
+
const m = body.meeting;
|
|
184
|
+
if (m && m.kind === 'event') {
|
|
185
|
+
const hit = notes.findByEvent(dir, m);
|
|
186
|
+
if (hit) return done({ ok: true, note: hit, reused: true });
|
|
187
|
+
}
|
|
188
|
+
const r = notes.create(dir, { title: body.title, meeting: m, initiative: body.initiative });
|
|
189
|
+
if (r.ok) r.reused = false;
|
|
190
|
+
return done(r);
|
|
191
|
+
}
|
|
192
|
+
// from_event: the initiative is the calendar event's own, and never replaces the PM's choice
|
|
193
|
+
if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, by: body.from_event === true ? 'event' : 'pm' }));
|
|
164
194
|
if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
|
|
165
195
|
if (p === '/api/notes/save') return done(notes.save(dir, { id, title: body.title, body: body.body }));
|
|
166
196
|
const wsOf = () => {
|
|
@@ -238,7 +268,18 @@ async function handle(req, res, url, ctx) {
|
|
|
238
268
|
if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
239
269
|
return done(r);
|
|
240
270
|
}
|
|
241
|
-
if (p === '/api/notes/summarize')
|
|
271
|
+
if (p === '/api/notes/summarize') {
|
|
272
|
+
const r = notes.localSummary(dir, { id });
|
|
273
|
+
if (!r.ok) return done(r);
|
|
274
|
+
const ws = wsOf();
|
|
275
|
+
const cur = notes.read(dir, id);
|
|
276
|
+
if (ws.ok && ws.w && cur.ok && !cur.note.initiative) {
|
|
277
|
+
const s = notesLinks.suggest(cur.note, notesLinks.initiativesOf(ws.w.root));
|
|
278
|
+
if (s.pick) { const l = notes.link(dir, { id, initiative: { id: s.pick.id, ws: ws.w.id, title: s.pick.title, shared: s.pick.shared }, by: 'local' }); if (l.ok) r.initiative = l.note.initiative; }
|
|
279
|
+
}
|
|
280
|
+
if (r.initiative === undefined) r.initiative = cur.ok ? cur.note.initiative || null : null;
|
|
281
|
+
return done(r);
|
|
282
|
+
}
|
|
242
283
|
if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
|
|
243
284
|
// summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
|
|
244
285
|
let w = null;
|
package/workspace/package.json
CHANGED
|
@@ -5940,6 +5940,93 @@
|
|
|
5940
5940
|
}
|
|
5941
5941
|
}
|
|
5942
5942
|
|
|
5943
|
+
/* meeting-notes Run 4: the note's meeting and initiative, and its notes on the calendar and the initiative */
|
|
5944
|
+
.nt-headwrap {
|
|
5945
|
+
flex: 1 1 280px;
|
|
5946
|
+
min-width: 0;
|
|
5947
|
+
display: flex;
|
|
5948
|
+
flex-direction: column;
|
|
5949
|
+
gap: 8px;
|
|
5950
|
+
}
|
|
5951
|
+
.nt-links {
|
|
5952
|
+
display: flex;
|
|
5953
|
+
flex-wrap: wrap;
|
|
5954
|
+
gap: 4px 24px;
|
|
5955
|
+
font-size: 14px;
|
|
5956
|
+
}
|
|
5957
|
+
.nt-link {
|
|
5958
|
+
display: flex;
|
|
5959
|
+
flex-wrap: wrap;
|
|
5960
|
+
align-items: center;
|
|
5961
|
+
gap: 4px 8px;
|
|
5962
|
+
min-width: 0;
|
|
5963
|
+
}
|
|
5964
|
+
.nt-link__k {
|
|
5965
|
+
font-size: 12px;
|
|
5966
|
+
color: var(--muted-foreground);
|
|
5967
|
+
}
|
|
5968
|
+
.nt-picks,
|
|
5969
|
+
.nt-picks [role="radiogroup"] {
|
|
5970
|
+
display: flex;
|
|
5971
|
+
flex-direction: column;
|
|
5972
|
+
gap: 8px;
|
|
5973
|
+
}
|
|
5974
|
+
.nt-pick {
|
|
5975
|
+
display: flex;
|
|
5976
|
+
flex-direction: column;
|
|
5977
|
+
align-items: flex-start;
|
|
5978
|
+
gap: 2px;
|
|
5979
|
+
padding: 8px 12px;
|
|
5980
|
+
border: 1px solid var(--border);
|
|
5981
|
+
border-radius: 0;
|
|
5982
|
+
background: var(--background);
|
|
5983
|
+
color: var(--foreground);
|
|
5984
|
+
font: inherit;
|
|
5985
|
+
text-align: left;
|
|
5986
|
+
cursor: pointer;
|
|
5987
|
+
}
|
|
5988
|
+
.nt-pick {
|
|
5989
|
+
height: auto;
|
|
5990
|
+
justify-content: flex-start;
|
|
5991
|
+
}
|
|
5992
|
+
.nt-pick__txt {
|
|
5993
|
+
display: flex;
|
|
5994
|
+
flex-direction: column;
|
|
5995
|
+
align-items: flex-start;
|
|
5996
|
+
gap: 2px;
|
|
5997
|
+
white-space: normal;
|
|
5998
|
+
}
|
|
5999
|
+
.nt-pick[aria-checked="true"] {
|
|
6000
|
+
border-color: var(--foreground);
|
|
6001
|
+
background: var(--accent);
|
|
6002
|
+
}
|
|
6003
|
+
.nt-pick:focus-visible {
|
|
6004
|
+
outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
|
|
6005
|
+
outline-offset: 2px;
|
|
6006
|
+
}
|
|
6007
|
+
.nt-lsum {
|
|
6008
|
+
margin: 4px 0;
|
|
6009
|
+
padding-left: 18px;
|
|
6010
|
+
}
|
|
6011
|
+
.nt-lrows {
|
|
6012
|
+
display: flex;
|
|
6013
|
+
flex-direction: column;
|
|
6014
|
+
}
|
|
6015
|
+
.nt-lrow {
|
|
6016
|
+
display: flex;
|
|
6017
|
+
align-items: flex-start;
|
|
6018
|
+
justify-content: space-between;
|
|
6019
|
+
gap: 12px;
|
|
6020
|
+
padding: 12px 0;
|
|
6021
|
+
border-bottom: 1px solid var(--border);
|
|
6022
|
+
}
|
|
6023
|
+
.nt-lrow__main {
|
|
6024
|
+
min-width: 0;
|
|
6025
|
+
}
|
|
6026
|
+
.nt-lrow__meta {
|
|
6027
|
+
font-size: 12px;
|
|
6028
|
+
color: var(--muted-foreground);
|
|
6029
|
+
}
|
|
5943
6030
|
/* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
|
|
5944
6031
|
/* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
|
|
5945
6032
|
.lg-stack {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* the graph is doc-graph.js, initiative docs-library-graph), History,
|
|
8
8
|
* Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
|
|
9
9
|
* People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
|
|
10
|
+
* Notes (notes-links.js, initiative meeting-notes Run 4: the meeting notes linked to it, each with its summary)
|
|
10
11
|
* ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
|
|
11
12
|
* Nothing on this sheet says "plan". Everything it shows is read from GET /api/initiatives/<id>; every write goes through
|
|
12
13
|
* initiatives-api.js. The Breakdown tab mounts with a tab id of the form `initiative:<slug>`, so it never meets a panel-4
|
|
@@ -22,6 +23,7 @@ import { icon, ensureSprite } from './icons.js';
|
|
|
22
23
|
import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
|
|
23
24
|
import { mountJira, unmountJira } from './sheet-jira.js';
|
|
24
25
|
import { initiativePeople } from './people-link.js';
|
|
26
|
+
import { notesOfInitiative, noteRows, onNotes, refreshNotes } from './notes-links.js';
|
|
25
27
|
import { openDoc } from './panel-initiatives.js';
|
|
26
28
|
import { getLibrary, initiativeGraph } from './doc-graph.js';
|
|
27
29
|
import { openNewSession } from './panel-newsess.js';
|
|
@@ -83,7 +85,7 @@ export function openSheet(repo, slug) {
|
|
|
83
85
|
const s = sheet({
|
|
84
86
|
mode: 'auto', title: slug, sub: '', body: [crumb, stepperHost, waitingHost, tabsHost, panel],
|
|
85
87
|
attrs: { class: 'in-sheet', 'data-in-sheet': 'initiative', 'data-in-id': slug },
|
|
86
|
-
onClose: () => { closed = true; unmountBreakdown(bdHost); unmountJira(panel); if (current === s) current = null; },
|
|
88
|
+
onClose: () => { closed = true; unmountBreakdown(bdHost); unmountJira(panel); offNotes(); if (current === s) current = null; },
|
|
87
89
|
});
|
|
88
90
|
current = s;
|
|
89
91
|
|
|
@@ -244,10 +246,14 @@ export function openSheet(repo, slug) {
|
|
|
244
246
|
);
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
const notesHere = () => notesOfInitiative(slug).filter((n) => !n.initiative.ws || !ws || n.initiative.ws === ws);
|
|
250
|
+
const offNotes = onNotes(() => { if (!closed && tab === 'notes') drawPanel(); });
|
|
251
|
+
refreshNotes();
|
|
252
|
+
|
|
247
253
|
function drawTabs() {
|
|
248
254
|
tabsHost.replaceChildren(tabs({
|
|
249
255
|
variant: 'segmented', full: true, ariaLabel: COPY.sheet.tabs, value: tab,
|
|
250
|
-
items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }, { id: 'people', label: COPY.sheet.people }],
|
|
256
|
+
items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }, { id: 'people', label: COPY.sheet.people }, { id: 'notes', label: COPY.sheet.notes }],
|
|
251
257
|
onChange: (id) => {
|
|
252
258
|
if (id === tab) return;
|
|
253
259
|
if (tab === 'breakdown') unmountBreakdown(bdHost);
|
|
@@ -270,6 +276,8 @@ export function openSheet(repo, slug) {
|
|
|
270
276
|
}
|
|
271
277
|
if (tab === 'jira') { mountJira(panel, { ws, slug }); return; }
|
|
272
278
|
if (tab === 'people') { panel.replaceChildren(initiativePeople(ws, slug, { onOpen: () => s.close() })); return; }
|
|
279
|
+
// meeting-notes Run 4: the meeting notes linked to this initiative, newest first, each with its summary
|
|
280
|
+
if (tab === 'notes') { panel.replaceChildren(el('div', { 'data-in-notes': '' }, noteRows(notesHere(), { onOpen: () => s.close() }))); return; }
|
|
273
281
|
if (tab === 'files') {
|
|
274
282
|
const files = data.files || [];
|
|
275
283
|
// Grouped by where the tie came from (scan-consistency R3, scan-documents S3): the record's own paths, the documents
|
|
@@ -135,6 +135,7 @@ export const COPY = {
|
|
|
135
135
|
history: 'History',
|
|
136
136
|
jira: 'Jira tickets',
|
|
137
137
|
people: 'People',
|
|
138
|
+
notes: 'Notes',
|
|
138
139
|
waitingYou: (act) => `Waiting on you: ${act}`,
|
|
139
140
|
waitingAgent: (act) => `Waiting on agent: ${act}`,
|
|
140
141
|
nothingOwed: 'Nothing is owed.',
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* pm/notes-links.js — a meeting note where its meeting and its initiative are shown (meeting-notes Run 4).
|
|
2
|
+
*
|
|
3
|
+
* notesFor(e) → the note rows linked to a calendar event (same id, or same start minute and title)
|
|
4
|
+
* notesOfInitiative(id) → the note rows linked to an initiative, newest first
|
|
5
|
+
* notesBlock(e, { ws, repo, initiative }) → the "Meeting notes" block of an event's detail: the summary and Open notes,
|
|
6
|
+
* or Take notes; Take notes on an event that already has a note opens that note
|
|
7
|
+
* noteRows(rows, { onOpen }) → the rows of an initiative's Notes tab; onOpen runs before the note opens
|
|
8
|
+
* openNote(id) → opens the Notes tab on that note
|
|
9
|
+
* onNotes(fn) · refreshNotes() → redraw when the list is read
|
|
10
|
+
*
|
|
11
|
+
* One read feeds it: GET /api/notes (a row carries the meeting, the initiative and up to three summary lines, never the body;
|
|
12
|
+
* a private note carries its title only). Take notes writes POST /api/notes/new { meeting, initiative }.
|
|
13
|
+
*/
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { button } from './primitives.js';
|
|
16
|
+
import { pmGet, pmPost } from './http.js';
|
|
17
|
+
|
|
18
|
+
const READ_MS = 15000; // the list is read again at most this often while a calendar or an initiative is drawn
|
|
19
|
+
let rows = null; let readAt = 0; let reading = null; let failed = false;
|
|
20
|
+
const subs = new Set();
|
|
21
|
+
|
|
22
|
+
export function onNotes(fn) { subs.add(fn); return () => subs.delete(fn); }
|
|
23
|
+
export function refreshNotes() {
|
|
24
|
+
if (reading) return reading;
|
|
25
|
+
reading = pmGet('/api/notes').then((r) => {
|
|
26
|
+
reading = null; readAt = Date.now();
|
|
27
|
+
if (r && r.ok) { const sig = JSON.stringify(r.notes); const was = JSON.stringify(rows); rows = r.notes; failed = false; if (sig !== was) for (const f of subs) f(); }
|
|
28
|
+
else failed = true;
|
|
29
|
+
});
|
|
30
|
+
return reading;
|
|
31
|
+
}
|
|
32
|
+
function list() {
|
|
33
|
+
if (!reading && Date.now() - readAt > READ_MS) refreshNotes();
|
|
34
|
+
return rows || [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
|
|
38
|
+
const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
39
|
+
/** Whether a note's meeting is this event. The same rule as lib/notes.js sameEvent. Pure, for the suite. */
|
|
40
|
+
export function sameEvent(meeting, e) {
|
|
41
|
+
if (!meeting || meeting.kind !== 'event' || !e) return false;
|
|
42
|
+
if (meeting.event_id && e.id && meeting.event_id === e.id) return true;
|
|
43
|
+
const a = minuteOf(meeting.start); const b = minuteOf(e.start);
|
|
44
|
+
return a !== null && a === b && normTitle(meeting.title) === normTitle(e.title);
|
|
45
|
+
}
|
|
46
|
+
export const notesFor = (e) => list().filter((n) => sameEvent(n.meeting, e));
|
|
47
|
+
export const notesOfInitiative = (id) => list().filter((n) => n.initiative && n.initiative.id === id);
|
|
48
|
+
export const notesKnown = () => rows !== null || failed;
|
|
49
|
+
|
|
50
|
+
/** The meeting link a calendar event gives a note. */
|
|
51
|
+
export function meetingOf(e) {
|
|
52
|
+
return { kind: 'event', event_id: /^[A-Za-z0-9_-]{1,40}$/.test(String(e.id || '')) ? String(e.id) : null, src: e.src === 'memory' ? 'memory' : 'brief',
|
|
53
|
+
start: e.start, end: e.end || null, title: e.title };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function openNote(id) {
|
|
57
|
+
const [v, c] = await Promise.all([import('./view-notes.js'), import('./panel-cockpit.js')]);
|
|
58
|
+
v.openNote(id);
|
|
59
|
+
c.openView('notes');
|
|
60
|
+
}
|
|
61
|
+
async function takeNotes(e, { ws, repo, initiative } = {}, btn) {
|
|
62
|
+
if (btn) btn.disabled = true;
|
|
63
|
+
const ini = initiative && initiative.id ? { id: initiative.id, ws: ws || null, repo: repo || null, title: initiative.title || null } : undefined;
|
|
64
|
+
const r = await pmPost('/api/notes/new', { meeting: meetingOf(e), ...(ini ? { initiative: ini } : {}) });
|
|
65
|
+
if (btn) btn.disabled = false;
|
|
66
|
+
if (!r || !r.ok) { const { toast } = await import('./primitives.js'); toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
|
|
67
|
+
refreshNotes();
|
|
68
|
+
openNote(r.note.id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
|
|
72
|
+
const clock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|
|
73
|
+
|
|
74
|
+
/** The Meeting notes block of an event's detail. */
|
|
75
|
+
export function notesBlock(e, opts = {}) {
|
|
76
|
+
const mine = notesFor(e);
|
|
77
|
+
const ended = !!e.ended;
|
|
78
|
+
if (mine.length) {
|
|
79
|
+
const n = mine[0];
|
|
80
|
+
const lines = n.summary_lines || [];
|
|
81
|
+
return el('div', { class: 'td-day__notes', 'data-notes-block': 'linked', 'data-notes-id': n.id },
|
|
82
|
+
el('h4', null, 'Meeting notes'),
|
|
83
|
+
n.private ? el('p', { class: 'td-day__note' }, 'This note is private.')
|
|
84
|
+
: lines.length ? el('ul', { class: 'nt-lsum' }, ...lines.map((l) => el('li', null, l)))
|
|
85
|
+
: el('p', { class: 'td-day__note' }, n.segments ? 'No summary yet. Open the note and press Summarize.' : 'No summary yet.'),
|
|
86
|
+
el('div', { class: 'td-day__acts' }, button({ label: 'Open notes', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => openNote(n.id) })));
|
|
87
|
+
}
|
|
88
|
+
if (!notesKnown()) return null;
|
|
89
|
+
const take = button({ label: 'Take notes', variant: ended ? undefined : 'primary', size: 'sm', attrs: { 'data-notes-take': e.id || '' }, onClick: () => takeNotes(e, opts, take) });
|
|
90
|
+
return el('div', { class: 'td-day__notes', 'data-notes-block': 'none' },
|
|
91
|
+
el('h4', null, 'Meeting notes'),
|
|
92
|
+
el('p', { class: 'td-day__note' }, ended ? 'No notes were taken in PMOS for this meeting.' : 'Take notes here when the meeting starts. The note is linked to this event' + (opts.initiative ? ' and its initiative.' : '.')),
|
|
93
|
+
el('div', { class: 'td-day__acts' }, take));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The rows of an initiative's Notes tab. */
|
|
97
|
+
export function noteRows(found, { onOpen } = {}) {
|
|
98
|
+
if (!found.length) return el('p', { class: 'td-day__note', 'data-notes-empty': '' }, 'No meeting notes are linked to this initiative yet. Link one from the note\'s Initiative line.');
|
|
99
|
+
const sorted = found.slice().sort((a, b) => String((b.meeting && b.meeting.start) || b.created_at).localeCompare(String((a.meeting && a.meeting.start) || a.created_at)));
|
|
100
|
+
return el('div', { class: 'nt-lrows' }, ...sorted.map((n) => {
|
|
101
|
+
const at = n.meeting && n.meeting.kind === 'event' ? n.meeting.start : n.created_at;
|
|
102
|
+
const where = n.meeting && n.meeting.kind === 'event' ? 'calendar' : 'not on the calendar';
|
|
103
|
+
const lines = n.summary_lines || [];
|
|
104
|
+
return el('div', { class: 'nt-lrow', 'data-notes-row': n.id },
|
|
105
|
+
el('div', { class: 'nt-lrow__main' }, el('b', null, n.title || 'Untitled note'),
|
|
106
|
+
el('div', { class: 'nt-lrow__meta' }, dayOf(at) + ', ' + clock(at) + ' · ' + where + (n.private ? ' · private' : '')),
|
|
107
|
+
lines.length ? el('ul', { class: 'nt-lsum' }, ...lines.map((l) => el('li', null, l))) : el('p', { class: 'td-day__note' }, 'No summary yet.')),
|
|
108
|
+
button({ label: 'Open', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => { if (onOpen) onOpen(); openNote(n.id); } }));
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* picks the week. The lens switch says which answer the cells show; the agenda under the month always shows them all.
|
|
13
13
|
* The calendar memory changes only through the morning-brief session's brief. The one write here is the PM's key-meeting
|
|
14
14
|
* mark (POST /api/proactive/agenda/mark): this time or every time, key or not key, over what the calendar says.
|
|
15
|
+
* A meeting's detail shows its meeting notes, and an event row a notes chip, from pm/notes-links.js (meeting-notes Run 4);
|
|
16
|
+
* Take notes writes there.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import { peopleLinks } from './people-link.js';
|
|
@@ -19,6 +21,7 @@ import { el } from './dom.js';
|
|
|
19
21
|
import { button, iconButton, badge, tabs, toast } from './primitives.js';
|
|
20
22
|
import { pmGet, pmPost } from './http.js';
|
|
21
23
|
import { COPY } from './today-copy.js';
|
|
24
|
+
import { notesBlock, notesFor } from './notes-links.js';
|
|
22
25
|
|
|
23
26
|
const A = COPY.day.ag;
|
|
24
27
|
const DAY0 = 9 * 60;
|
|
@@ -203,7 +206,8 @@ function eventRow(h, e) {
|
|
|
203
206
|
const chips = el('span', { class: 'td-day__chips' },
|
|
204
207
|
e.imp ? badge({ text: A.star, tone: 'outline', attrs: { title: A.means } }) : null,
|
|
205
208
|
e.prep ? el('span', { class: 'td-ag__ready', 'data-ag-ready': e.ready || 'ok' }, A.readyBadge(e.prep_done || 0, e.prep.length)) : null,
|
|
206
|
-
e.
|
|
209
|
+
notesFor(e).length ? badge({ text: 'notes', tone: 'outline', attrs: { 'data-ag-notes': e.id, title: 'Meeting notes are linked to this event' } })
|
|
210
|
+
: e.ended && !e.notes && (e.imp || e.kind === 'one') ? el('span', { class: 'td-ag__nonotes' }, A.noNotes(1)) : null);
|
|
207
211
|
return h.rowBtn(type, e.id, { lead: e.time, title: e.title, meta: A.kindWord[e.kind] + (e.attendees ? ' · ' + A.people(e.attendees) : ''), trail: chips },
|
|
208
212
|
{ 'data-past': e.ended ? 'true' : null, 'data-ag-event': e.id });
|
|
209
213
|
}
|
|
@@ -327,5 +331,6 @@ export function meetingDetail(ctx, id, h) {
|
|
|
327
331
|
e.ended ? el('div', { class: 'td-day__phase', 'data-ag': 'notes' }, el('h4', null, A.afterNotes),
|
|
328
332
|
e.notes ? el('p', null, e.notes) : el('p', { class: 'td-day__note' }, A.noNotesYet),
|
|
329
333
|
e.open && e.open.length ? [el('h4', null, A.openLines), el('ul', { class: 'td-day__chk' }, ...e.open.map((x) => el('li', null, x)))] : null) : null,
|
|
334
|
+
notesBlock(e, { ws: ctx && ctx.ws }),
|
|
330
335
|
];
|
|
331
336
|
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* - on the left, the calendar (with a line at the current time), the jobs (needs you, agent working, done), the goals
|
|
11
11
|
* and the mentions with the watched channels, projects and spaces;
|
|
12
12
|
* - on the right, the detail of what is selected: a meeting, a job, a goal, a mention, a step, the sources or a place.
|
|
13
|
+
* A meeting's detail ends with its meeting notes: the summary and Open notes, or Take notes (pm/notes-links.js,
|
|
14
|
+
* meeting-notes Run 4).
|
|
13
15
|
* The presses go to the routes that own them: the goals route (add, done, drop, reopen, remove), /goals/plan (Prepare my
|
|
14
16
|
* day), /day/decide (approve, revise, run again), /day/check (Check now) and the settings route (sources, levels, the
|
|
15
17
|
* daytime check, websites). Nothing here sends anything outward: the agent sessions do that, after the PM approves.
|
|
@@ -30,6 +32,7 @@ import { COPY } from './today-copy.js';
|
|
|
30
32
|
import { goalRows, goalAdd, goalDetail, addGoal, goalsSig } from './today-goals.js';
|
|
31
33
|
import { peopleLinks } from './people-link.js';
|
|
32
34
|
import { agendaPanel, meetingDetail, memoryBlock, refreshAgenda, agendaSig, onAgenda } from './today-agenda.js';
|
|
35
|
+
import { notesBlock, onNotes } from './notes-links.js';
|
|
33
36
|
|
|
34
37
|
const D = COPY.day;
|
|
35
38
|
const SLOW_MS = 30000;
|
|
@@ -57,6 +60,7 @@ const ui = {
|
|
|
57
60
|
revising: new Set(), notes: new Map(), siteDraft: '', placeDraft: {}, busy: new Set(), scroll: false,
|
|
58
61
|
};
|
|
59
62
|
let rerender = () => {};
|
|
63
|
+
let notesSub = null; // the redraw when the meeting notes list is read (pm/notes-links.js)
|
|
60
64
|
let timer = null;
|
|
61
65
|
|
|
62
66
|
/* ── the read ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -566,6 +570,7 @@ function eventDetail(ctx, e) {
|
|
|
566
570
|
memoryBlock(e.id),
|
|
567
571
|
!e.ended ? el('div', { class: 'td-day__acts' }, e.prep ? pill(D.needPrep, 'warn') : steps.before && steps.before.length ? pill(D.nothingToPrep, 'ok') : null) : null,
|
|
568
572
|
after,
|
|
573
|
+
notesBlock(e, { ws: ctx.ws, initiative: ini }),
|
|
569
574
|
!e.ended ? [list(D.phases.before, (steps.before || []).map(stepItem)), list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock] : null,
|
|
570
575
|
Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null,
|
|
571
576
|
];
|
|
@@ -856,6 +861,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
|
|
|
856
861
|
// cause a whole-page render under whatever the PM is doing elsewhere
|
|
857
862
|
if (setState) rerender = () => { if (document.querySelector('[data-today="day"]')) setState({}); };
|
|
858
863
|
onAgenda(() => rerender());
|
|
864
|
+
if (!notesSub) notesSub = onNotes(() => rerender());
|
|
859
865
|
ensure(ws);
|
|
860
866
|
const c = cache.get(ws) || {};
|
|
861
867
|
const m = model(c.data, { here: here || null, all: !!all });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to
|
|
1
|
+
/* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 4).
|
|
2
2
|
*
|
|
3
3
|
* The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
|
|
4
4
|
* header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
|
|
@@ -24,10 +24,15 @@
|
|
|
24
24
|
* the transcript line, its item and the note line written at that moment are highlighted, and a time in the transcript
|
|
25
25
|
* jumps there. Your notes are shown read-only while the meeting plays, so their lines can be highlighted.
|
|
26
26
|
*
|
|
27
|
+
* MEETING AND INITIATIVE (Run 4). Under the title, the note says which meeting it is (a calendar event, or not on the
|
|
28
|
+
* calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
|
|
29
|
+
* or chosen by the PM. Change opens a sheet for each. A note taken from an event's detail (pm/notes-links.js) opens here.
|
|
30
|
+
*
|
|
27
31
|
* Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
|
|
28
32
|
* GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
|
|
33
|
+
* · /api/notes/initiatives?ws=…&id=… · /api/proactive/agenda?month=…
|
|
29
34
|
* POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
|
|
30
|
-
* · summarize · summarize-agent · delete · line · recording-delete
|
|
35
|
+
* · summarize · summarize-agent · delete · line · recording-delete · link
|
|
31
36
|
*/
|
|
32
37
|
import { el } from './dom.js';
|
|
33
38
|
import { askConfirm, askText } from './dialogs.js';
|
|
@@ -58,6 +63,7 @@ let pollTimer = null; let agentTimer = null;
|
|
|
58
63
|
let busy = '';
|
|
59
64
|
let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
|
|
60
65
|
let people = null; // { ws, names } — the repository's people, offered in the line editor
|
|
66
|
+
let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
|
|
61
67
|
const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
|
|
62
68
|
|
|
63
69
|
const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|
|
@@ -199,7 +205,7 @@ async function loadList(ctx) {
|
|
|
199
205
|
if (!currentId && list.notes.length) currentId = list.notes[0].id;
|
|
200
206
|
ctx.setState({});
|
|
201
207
|
}
|
|
202
|
-
const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes]);
|
|
208
|
+
const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
|
|
203
209
|
async function loadNote(ctx, id) {
|
|
204
210
|
const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
|
|
205
211
|
if (r && r.ok) {
|
|
@@ -312,9 +318,10 @@ async function setPrivate(ctx, n, priv) {
|
|
|
312
318
|
async function summarize(ctx, n, quiet = false) {
|
|
313
319
|
await flushSave(ctx);
|
|
314
320
|
busy = 'summary'; ctx.setState({});
|
|
315
|
-
const
|
|
321
|
+
const ws = n.ws || wsOf(ctx);
|
|
322
|
+
const r = await pmPost('/api/notes/summarize', { id: n.id, ...(ws ? { ws } : {}) });
|
|
316
323
|
busy = '';
|
|
317
|
-
if (r && r.ok) { n.summary = r.summary; banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
|
|
324
|
+
if (r && r.ok) { n.summary = r.summary; if (r.initiative !== undefined) { n.initiative = r.initiative; const c = cache.get(n.id); if (c) c.initiative = r.initiative; } banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
|
|
318
325
|
ctx.setState({});
|
|
319
326
|
}
|
|
320
327
|
async function summarizeAgent(ctx, n) {
|
|
@@ -762,6 +769,105 @@ function editorFor(ctx, n) {
|
|
|
762
769
|
title.addEventListener('blur', () => flushSave(ctx));
|
|
763
770
|
return editor;
|
|
764
771
|
}
|
|
772
|
+
/* ── the meeting and the initiative of a note (Run 4) ───────────────────────────────────────────── */
|
|
773
|
+
const BY_WORDS = { event: 'from the calendar event', local: 'suggested from what was said', pm: 'chosen by you' };
|
|
774
|
+
const ymOfIso = (iso) => { const d = new Date(iso); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); };
|
|
775
|
+
const dateOfIso = (iso) => { const d = new Date(iso); return ymOfIso(iso) + '-' + String(d.getDate()).padStart(2, '0'); };
|
|
776
|
+
/** Open the Notes tab on this note at its next draw (pm/notes-links.js openNote). */
|
|
777
|
+
export function openNote(id) { pendingOpen = id; }
|
|
778
|
+
async function link(ctx, n, patch, said) {
|
|
779
|
+
const r = await pmPost('/api/notes/link', { id: n.id, ...patch });
|
|
780
|
+
if (r && r.ok) {
|
|
781
|
+
for (const x of new Set([n, cache.get(n.id)])) if (x) { x.meeting = r.note.meeting; x.initiative = r.note.initiative; x.title = r.note.title; }
|
|
782
|
+
if (editor && editor.id === n.id && !editor.title.value.trim()) editor.title.value = r.note.title || '';
|
|
783
|
+
touchRow(n); banner = { tone: 'ok', text: said(r.note) };
|
|
784
|
+
import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {});
|
|
785
|
+
} else banner = { tone: 'bad', text: (r && r.error) || 'the link was not changed' };
|
|
786
|
+
ctx.setState({});
|
|
787
|
+
}
|
|
788
|
+
/** The events of the note's day and the day before, from the Agenda's read. */
|
|
789
|
+
async function eventsNear(n) {
|
|
790
|
+
const at = n.created_at || new Date().toISOString();
|
|
791
|
+
const day = dateOfIso(at); const before = dateOfIso(new Date(Date.parse(at) - 86400000).toISOString());
|
|
792
|
+
const months = [...new Set([ymOfIso(at), before.slice(0, 7)])];
|
|
793
|
+
const reads = await Promise.all(months.map((m) => pmGet('/api/proactive/agenda?month=' + m)));
|
|
794
|
+
const out = []; const seen = new Set();
|
|
795
|
+
for (const r of reads) {
|
|
796
|
+
if (!r || !r.days) continue;
|
|
797
|
+
for (const d of [before, day]) for (const e of ((r.days[d] && r.days[d].events) || [])) { const k = e.start + '|' + e.title; if (!seen.has(k)) { seen.add(k); out.push({ ...e, date: d }); } }
|
|
798
|
+
}
|
|
799
|
+
return out.sort((a, b) => String(a.start).localeCompare(String(b.start)));
|
|
800
|
+
}
|
|
801
|
+
async function meetingSheet(ctx, n) {
|
|
802
|
+
const box = el('div', { class: 'nt-picks', role: 'radiogroup', 'aria-label': 'Meeting' }, el('p', { class: 'nt-hint' }, 'reading your calendar …'));
|
|
803
|
+
let pick = n.meeting && n.meeting.kind === 'adhoc' ? 'adhoc' : null;
|
|
804
|
+
let evs = [];
|
|
805
|
+
const opt = (key, title, sub, on) => button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': on ? 'true' : 'false', 'data-nt-meeting-pick': key },
|
|
806
|
+
onClick: (ev) => { pick = key; for (const b of box.querySelectorAll('[data-nt-meeting-pick]')) b.setAttribute('aria-checked', b === ev.currentTarget ? 'true' : 'false'); } });
|
|
807
|
+
const h = sheet({ title: 'Which meeting is this?', sub: 'The day of the note and the day before, from your calendar.', body: [box], attrs: { 'data-nt-meeting-sheet': '' },
|
|
808
|
+
actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Link', variant: 'primary', attrs: { 'data-nt-meeting-save': '' }, onClick: async () => {
|
|
809
|
+
if (!pick) { h.close(); return; }
|
|
810
|
+
h.close();
|
|
811
|
+
if (pick === 'adhoc') return link(ctx, n, { meeting: { kind: 'adhoc' } }, () => 'Marked as a meeting that is not on the calendar.');
|
|
812
|
+
const e = evs[Number(pick)];
|
|
813
|
+
const { meetingOf } = await import('./notes-links.js');
|
|
814
|
+
return link(ctx, n, { meeting: meetingOf(e) }, () => 'Linked to ' + e.title + '. The note shows on the calendar.');
|
|
815
|
+
} })] });
|
|
816
|
+
evs = await eventsNear(n);
|
|
817
|
+
if (!h.open) return;
|
|
818
|
+
const { notesFor } = await import('./notes-links.js');
|
|
819
|
+
box.replaceChildren(...evs.map((e, i) => {
|
|
820
|
+
const on = !!(n.meeting && n.meeting.kind === 'event' && (n.meeting.event_id === e.id || (Date.parse(n.meeting.start) === Date.parse(e.start) && n.meeting.title === e.title)));
|
|
821
|
+
if (on) pick = String(i);
|
|
822
|
+
const other = notesFor(e).some((x) => x.id !== n.id);
|
|
823
|
+
return opt(String(i), e.title, dayOf(e.start) + ', ' + e.time + (other ? ' · has a note' : ''), on);
|
|
824
|
+
}), evs.length ? null : el('p', { class: 'nt-hint' }, 'No calendar events on these days.'),
|
|
825
|
+
opt('adhoc', 'Not on the calendar', 'a hallway chat, a call', pick === 'adhoc'));
|
|
826
|
+
}
|
|
827
|
+
async function initiativeSheet(ctx, n) {
|
|
828
|
+
const ws = n.ws || wsOf(ctx);
|
|
829
|
+
const box = el('div', { class: 'nt-picks' }, el('p', { class: 'nt-hint' }, 'reading the initiatives …'));
|
|
830
|
+
let pick = n.initiative ? n.initiative.id : '';
|
|
831
|
+
let open = [];
|
|
832
|
+
const h = sheet({ title: 'Which initiative is this note about?', sub: 'The note shows on the initiative\'s page.', body: [box], attrs: { 'data-nt-ini-sheet': '' },
|
|
833
|
+
actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', attrs: { 'data-nt-ini-save': '' }, onClick: () => {
|
|
834
|
+
h.close();
|
|
835
|
+
if (pick === (n.initiative ? n.initiative.id : '') && (!n.initiative || n.initiative.by === 'pm')) return;
|
|
836
|
+
if (!pick) return link(ctx, n, { initiative: null }, () => 'Not linked to an initiative.');
|
|
837
|
+
const i = open.find((x) => x.id === pick) || { id: pick, title: pick };
|
|
838
|
+
return link(ctx, n, { initiative: { id: i.id, ws, repo: (ctx.state && ctx.state.product) || ctx.repo || null, title: i.title } }, () => 'Now on ' + i.title + '. The note shows on its page.');
|
|
839
|
+
} })] });
|
|
840
|
+
if (!ws) { box.replaceChildren(el('p', { class: 'nt-hint' }, 'Open a repository first: its initiatives are the ones a note can belong to.')); return; }
|
|
841
|
+
const r = await pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id));
|
|
842
|
+
if (!h.open) return;
|
|
843
|
+
if (!r || !r.ok) { box.replaceChildren(el('p', { class: 'nt-hint', role: 'alert' }, (r && r.error) || 'The initiatives could not be read.')); return; }
|
|
844
|
+
open = r.open || [];
|
|
845
|
+
const radios = [];
|
|
846
|
+
const choose = (v) => { pick = v; for (const b of radios) b.setAttribute('aria-checked', b.dataset.ntIniPick === v ? 'true' : 'false'); if (sel.control.value !== v) sel.control.value = open.some((x) => x.id === v) ? v : ''; };
|
|
847
|
+
const opt = (v, title, sub) => { const b = button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': pick === v ? 'true' : 'false', 'data-nt-ini-pick': v }, onClick: () => choose(v) }); radios.push(b); return b; };
|
|
848
|
+
const sel = field({ label: 'Or choose any open initiative', kind: 'select', value: open.some((x) => x.id === pick) ? pick : '', options: [{ value: '', label: 'Choose …' }, ...open.map((x) => ({ value: x.id, label: x.title }))], attrs: { 'data-nt-ini-all': '' } });
|
|
849
|
+
sel.control.addEventListener('change', () => choose(sel.control.value));
|
|
850
|
+
const sugg = r.pick ? [el('h3', { class: 'nt-sec__h' }, 'Suggested from what was said'), opt(r.pick.id, r.pick.title, r.pick.shared + ' words in common')] : [];
|
|
851
|
+
const current = n.initiative && (!r.pick || r.pick.id !== n.initiative.id) ? [opt(n.initiative.id, n.initiative.title || n.initiative.id, BY_WORDS[n.initiative.by] || '')] : [];
|
|
852
|
+
box.replaceChildren(el('div', { role: 'radiogroup', 'aria-label': 'Initiative' }, ...sugg, ...current, opt('', 'None', 'not about an initiative')), open.length ? sel : el('p', { class: 'nt-hint' }, 'This repository has no open initiatives.'));
|
|
853
|
+
}
|
|
854
|
+
async function openInitiativeOf(ctx, ini) {
|
|
855
|
+
const reg = await import('./registry.js');
|
|
856
|
+
reg.openInitiative(ini.repo || (ctx.state && ctx.state.product) || ctx.repo || null, ini.id);
|
|
857
|
+
}
|
|
858
|
+
function linksLine(ctx, n) {
|
|
859
|
+
const m = n.meeting; const ini = n.initiative;
|
|
860
|
+
const meet = m && m.kind === 'event'
|
|
861
|
+
? [el('b', null, m.title), el('span', { class: 'nt-hint' }, dayOf(m.start) + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : ''))]
|
|
862
|
+
: m && m.kind === 'adhoc' ? [el('b', null, 'Not on the calendar'), el('span', { class: 'nt-hint' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at))]
|
|
863
|
+
: [el('b', null, 'None')];
|
|
864
|
+
return el('div', { class: 'nt-links', 'data-nt-part': 'links' },
|
|
865
|
+
el('div', { class: 'nt-link', 'data-nt-link': 'meeting' }, el('span', { class: 'nt-link__k' }, 'Meeting'), ...meet,
|
|
866
|
+
button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } })),
|
|
867
|
+
el('div', { class: 'nt-link', 'data-nt-link': 'initiative', 'data-nt-ini-by': ini ? ini.by : '' }, el('span', { class: 'nt-link__k' }, 'Initiative'),
|
|
868
|
+
ini ? [button({ label: ini.title || ini.id, variant: 'ghost', size: 'sm', onClick: () => openInitiativeOf(ctx, ini), attrs: { 'data-nt-ini-open': ini.id } }), el('span', { class: 'nt-hint' }, BY_WORDS[ini.by] || '')] : el('b', null, 'None'),
|
|
869
|
+
button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } })));
|
|
870
|
+
}
|
|
765
871
|
function listening(n) {
|
|
766
872
|
if (audio && audio.id === n.id) return { kind: 'local', state: audio.state, sources: audio.sources, started: audio.started };
|
|
767
873
|
if (speech && speech.id === n.id && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking')) return { kind: 'browser', state: speech.state, sources: ['mic'], started: speech.started };
|
|
@@ -790,7 +896,7 @@ function header(ctx, n, ed) {
|
|
|
790
896
|
}
|
|
791
897
|
acts.push(button({ label: 'All notes', variant: 'ghost', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
|
|
792
898
|
acts.push(iconButton({ icon: 'settings', ariaLabel: 'Settings', onClick: () => openSettings(ctx, n), attrs: { 'data-nt-settings': '' } }));
|
|
793
|
-
return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, left, el('div', { class: 'nt-acts' }, acts));
|
|
899
|
+
return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, el('div', { class: 'nt-headwrap' }, left, linksLine(ctx, n)), el('div', { class: 'nt-acts' }, acts));
|
|
794
900
|
}
|
|
795
901
|
function listPane(ctx) {
|
|
796
902
|
const rows = (list && list.notes) || [];
|
|
@@ -900,6 +1006,7 @@ export function renderBoard(inner, ctx) {
|
|
|
900
1006
|
ensureComponentsCss();
|
|
901
1007
|
if (FIXTURE) { inner.appendChild(el('p', { class: 'nt-hint' }, 'The fixture keeps no notes.')); return; }
|
|
902
1008
|
if (!list) loadList(ctx);
|
|
1009
|
+
if (pendingOpen) { const id = pendingOpen; pendingOpen = null; if (id !== currentId) { selectNote(ctx, id); loadList(ctx); } }
|
|
903
1010
|
if (!engine || engine.ws !== wsOf(ctx)) { if (!engine || !engine.loading || engine.ws !== wsOf(ctx)) loadEngine(ctx); }
|
|
904
1011
|
|
|
905
1012
|
const focus = typeof document !== 'undefined' && editor && (document.activeElement === editor.body ? 'body' : document.activeElement === editor.title ? 'title' : null);
|