create-pmos 0.61.1 → 0.63.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/first-paint-audit.js +4 -2
- package/workspace/guide/index.md +6 -3
- package/workspace/guide/layout.md +5 -4
- package/workspace/guide/notes.md +72 -0
- package/workspace/guide/people.md +70 -0
- package/workspace/lib/calendar-memory.js +2 -1
- package/workspace/lib/jev-notes.js +162 -0
- package/workspace/lib/notes-repo.js +133 -0
- package/workspace/lib/notes-whisper.js +200 -0
- package/workspace/lib/notes.js +511 -0
- package/workspace/lib/people-cli.js +135 -0
- package/workspace/lib/people-store.js +123 -0
- package/workspace/lib/people.js +399 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +230 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -2
- package/workspace/public/pm/components.css +441 -0
- package/workspace/public/pm/dest-people.js +385 -0
- package/workspace/public/pm/dest-settings.js +44 -3
- package/workspace/public/pm/frame-copy.js +7 -0
- package/workspace/public/pm/initiative-sheet.js +5 -2
- package/workspace/public/pm/initiatives-copy.js +1 -0
- package/workspace/public/pm/item-sheet.js +18 -1
- package/workspace/public/pm/panel-cockpit.js +3 -0
- package/workspace/public/pm/people-link.js +106 -0
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/public/pm/today-agenda.js +3 -1
- package/workspace/public/pm/today-day.js +2 -0
- package/workspace/public/pm/topbar.js +4 -3
- package/workspace/public/pm/view-notes.js +723 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +30 -1
- package/workspace/skills/gather-requirements.skill +5 -3
- package/workspace/skills/jira-sync.skill +5 -4
- package/workspace/skills/live-notes.skill +3 -1
- package/workspace/skills/morning-brief.skill +5 -3
- package/workspace/test-lists/meeting-notes.json +15 -0
- package/workspace/test-lists/people-directory.json +6 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/* pm/dest-people.js — the People destination: the people the PM works with in a repository, with their interactions,
|
|
2
|
+
* tone, notes and shared work (initiative people-directory, PRD R5 to R11, proposed D90).
|
|
3
|
+
*
|
|
4
|
+
* A DESTINATION MODULE (registry.js): mountDestination(host, ctx) on every render with the same host, disposeDestination(host).
|
|
5
|
+
* It keeps ONE persistent root per host; the header (title and search) is built once, so typing never loses focus, and
|
|
6
|
+
* only the body is rebuilt when its own data changed.
|
|
7
|
+
*
|
|
8
|
+
* What it shows, for the repository the tab names (or the open one):
|
|
9
|
+
* - Waiting for you: new people agent sessions saw (Add to People · Ignore), possible duplicates (Merge · Keep
|
|
10
|
+
* separate), and how many tones wait for a confirm;
|
|
11
|
+
* - the list: search over names, handles and initiatives, All · Team · External, sort by latest, most or name;
|
|
12
|
+
* - the person's page: who, interactions with where each came from, tone (Confirm · Set another · Change), shared
|
|
13
|
+
* work (initiatives, items they own with Blocked shown, coming meetings), notes (Add · Remove), and identity and
|
|
14
|
+
* sources (handles, how many interactions each source gave, the file the person lives in).
|
|
15
|
+
* Every press is a page press to POST /api/people/decide. Nothing here reads a source: agent sessions record what they
|
|
16
|
+
* saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
|
|
17
|
+
* In fixture mode it reads nothing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { el } from './dom.js';
|
|
21
|
+
import { button, row, field, toast, empty, tabs, badge, menu, ensureComponentsCss } from './primitives.js';
|
|
22
|
+
import { ensureSprite } from './icons.js';
|
|
23
|
+
import { pmGet, pmPost } from './http.js';
|
|
24
|
+
import { wsOf } from './data.js';
|
|
25
|
+
import { registerCommand, openDestination } from './registry.js';
|
|
26
|
+
import { forgetPeople } from './people-link.js';
|
|
27
|
+
|
|
28
|
+
const TONES = ['warm', 'neutral', 'cautious', 'tense'];
|
|
29
|
+
const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira' };
|
|
30
|
+
const HANDLE = { mail: 'Mail', slack: 'Chat', jira: 'Jira', confluence: 'Confluence', speaker: 'Speaker', name: 'Name' };
|
|
31
|
+
|
|
32
|
+
export const COPY = Object.freeze({
|
|
33
|
+
title: 'People',
|
|
34
|
+
fixture: 'People shows the people of a repository in a real session.',
|
|
35
|
+
noRepo: 'Open a repository to see its people.',
|
|
36
|
+
loading: 'Reading…',
|
|
37
|
+
readError: 'People could not be read.',
|
|
38
|
+
find: 'Find a person',
|
|
39
|
+
waiting: 'Waiting for you',
|
|
40
|
+
newPerson: (n, src) => `${n} sighting${n === 1 ? '' : 's'} · ${src}`,
|
|
41
|
+
add: 'Add to People',
|
|
42
|
+
ignore: 'Ignore',
|
|
43
|
+
same: (a, b) => `${a} may be the same person as ${b}`,
|
|
44
|
+
merge: 'Merge',
|
|
45
|
+
keep: 'Keep separate',
|
|
46
|
+
tonesWait: (n) => `${n} tone${n === 1 ? '' : 's'} to confirm. Open the person to confirm it.`,
|
|
47
|
+
all: 'All', team: 'Team', external: 'External',
|
|
48
|
+
latest: 'Latest', most: 'Most', name: 'Name',
|
|
49
|
+
none: 'Nobody yet. Agent sessions that gather requirements, take live notes or sync Jira record the people they see. They wait here for you to add them.',
|
|
50
|
+
noMatch: (q) => `Nobody matches “${q}”.`,
|
|
51
|
+
pick: 'Choose a person to see their page.',
|
|
52
|
+
interactions: 'Interactions',
|
|
53
|
+
noInteractions: 'No interaction recorded yet.',
|
|
54
|
+
tone: 'Tone',
|
|
55
|
+
noTone: 'No interaction has a tone yet.',
|
|
56
|
+
proposed: (t) => `Proposed: ${t}, from the latest interactions`,
|
|
57
|
+
confirmed: (t) => `Confirmed: ${t}`,
|
|
58
|
+
confirm: 'Confirm',
|
|
59
|
+
setAnother: 'Set another',
|
|
60
|
+
change: 'Change',
|
|
61
|
+
shared: 'Shared work',
|
|
62
|
+
noShared: 'No shared initiative, item or meeting yet.',
|
|
63
|
+
owns: 'Owns',
|
|
64
|
+
blocked: 'Blocked',
|
|
65
|
+
meeting: 'Meeting',
|
|
66
|
+
notes: 'Notes',
|
|
67
|
+
noNotes: 'No note yet. Only you can add one.',
|
|
68
|
+
addNote: 'Add note',
|
|
69
|
+
notePlaceholder: 'Something to remember about working with this person',
|
|
70
|
+
remove: 'Remove',
|
|
71
|
+
identity: 'Identity and sources',
|
|
72
|
+
lives: (f) => `Lives in ${f}`,
|
|
73
|
+
added: (n) => `${n} added to People.`,
|
|
74
|
+
ignored: (n) => `${n} is ignored. Later sightings stay hidden.`,
|
|
75
|
+
merged: 'Merged.',
|
|
76
|
+
kept: 'Kept separate.',
|
|
77
|
+
noteAdded: 'Note added.',
|
|
78
|
+
noteRemoved: 'Note removed.',
|
|
79
|
+
toneSaved: 'Tone saved.',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
registerCommand({ id: 'people-open', label: 'People', hint: 'The people you work with in this repository', cli: 'pmos people list', run: () => openDestination('people') });
|
|
83
|
+
|
|
84
|
+
/* ── words ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
85
|
+
|
|
86
|
+
/** "Oct 1" for a YYYY-MM-DD date; "today" and "yesterday" for the two nearest. */
|
|
87
|
+
function day(d) {
|
|
88
|
+
if (!d) return '';
|
|
89
|
+
const t = new Date(d + 'T12:00:00');
|
|
90
|
+
if (Number.isNaN(t.getTime())) return d;
|
|
91
|
+
const today = new Date(); today.setHours(12, 0, 0, 0);
|
|
92
|
+
const n = Math.round((today - t) / 86400000);
|
|
93
|
+
if (n === 0) return 'today';
|
|
94
|
+
if (n === 1) return 'yesterday';
|
|
95
|
+
return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
96
|
+
}
|
|
97
|
+
const toneOf = (p) => p.tone.confirmed || p.tone.proposed || null;
|
|
98
|
+
const handleText = (h) => `${HANDLE[h.kind] || h.kind}: ${h.value}`;
|
|
99
|
+
const who = (p) => [p.role, p.org].filter(Boolean).join(' · ') || p.group;
|
|
100
|
+
|
|
101
|
+
/* ── mount ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
102
|
+
|
|
103
|
+
const insts = new WeakMap();
|
|
104
|
+
const REREAD_MS = 5000;
|
|
105
|
+
|
|
106
|
+
export function mountDestination(host, ctx) {
|
|
107
|
+
let inst = insts.get(host);
|
|
108
|
+
if (!inst) {
|
|
109
|
+
inst = { host, root: el('div', { class: 'rp-page rp-people', 'data-people': '' }), head: null, body: el('div', { class: 'rp-people-body' }),
|
|
110
|
+
ws: null, loaded: false, error: null, view: null, q: '', group: 'all', sort: 'latest', open: null, slug: null, busy: false,
|
|
111
|
+
seq: 0, last: '', readAt: 0 };
|
|
112
|
+
insts.set(host, inst);
|
|
113
|
+
// A redraw held back while a note was typed runs when the field is left.
|
|
114
|
+
const held = inst;
|
|
115
|
+
held.body.addEventListener('focusout', () => setTimeout(() => { if (!held.last && held.loaded) draw(held); }, 0));
|
|
116
|
+
}
|
|
117
|
+
ensureComponentsCss();
|
|
118
|
+
if (!inst.root.isConnected || inst.root.parentNode !== host) host.replaceChildren(inst.root);
|
|
119
|
+
if (ctx && ctx.FIXTURE) { inst.root.replaceChildren(empty({ text: COPY.fixture })); return; }
|
|
120
|
+
const repo = (ctx && ctx.tab && ctx.tab.repo) || (ctx && ctx.state && ctx.state.product) || null;
|
|
121
|
+
const ws = repo ? wsOf(repo) : null;
|
|
122
|
+
// A link from the Agenda, an initiative or an item names the person in the tab's slug.
|
|
123
|
+
const slug = (ctx && ctx.tab && ctx.tab.slug) || null;
|
|
124
|
+
if (slug !== inst.slug) { inst.slug = slug; if (slug) { inst.open = slug; inst.q = ''; if (inst.head) inst.head.find.control.resetCommitted(''); } }
|
|
125
|
+
if (ws !== inst.ws) {
|
|
126
|
+
Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '' });
|
|
127
|
+
if (!slug) inst.open = null;
|
|
128
|
+
if (ws) { ensureSprite(); refresh(inst); }
|
|
129
|
+
draw(inst);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (Date.now() - inst.readAt > REREAD_MS) refresh(inst);
|
|
133
|
+
draw(inst);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function disposeDestination(host) {
|
|
137
|
+
const inst = insts.get(host);
|
|
138
|
+
if (!inst) return;
|
|
139
|
+
inst.seq++;
|
|
140
|
+
inst.root.remove();
|
|
141
|
+
insts.delete(host);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function refresh(inst) {
|
|
145
|
+
const ws = inst.ws;
|
|
146
|
+
if (!ws) return;
|
|
147
|
+
const seq = ++inst.seq;
|
|
148
|
+
inst.readAt = Date.now();
|
|
149
|
+
const v = await pmGet('/api/people?ws=' + encodeURIComponent(ws));
|
|
150
|
+
if (seq !== inst.seq || ws !== inst.ws) return;
|
|
151
|
+
inst.loaded = true;
|
|
152
|
+
if (!v || v.ok === false) { inst.error = (v && v.error) || COPY.readError; inst.view = null; }
|
|
153
|
+
else { inst.error = null; inst.view = v; }
|
|
154
|
+
draw(inst);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ── presses ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
158
|
+
|
|
159
|
+
async function decide(inst, body, done) {
|
|
160
|
+
if (inst.busy) return null;
|
|
161
|
+
inst.busy = true;
|
|
162
|
+
const r = await pmPost('/api/people/decide', { ws: inst.ws, ...body });
|
|
163
|
+
inst.busy = false;
|
|
164
|
+
forgetPeople(inst.ws);
|
|
165
|
+
if (!r || !r.ok) { toast({ text: (r && r.error) || 'Nothing was saved.', tone: 'error' }); await refresh(inst); return null; }
|
|
166
|
+
if (done) toast({ text: done, timeout: 3000 });
|
|
167
|
+
await refresh(inst);
|
|
168
|
+
return r;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── the review list ─────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
172
|
+
|
|
173
|
+
function review(inst) {
|
|
174
|
+
const r = inst.view.review;
|
|
175
|
+
if (!r.count) return null;
|
|
176
|
+
const rows = [];
|
|
177
|
+
for (const f of r.new) {
|
|
178
|
+
const key = f.handle.kind + ':' + f.handle.value;
|
|
179
|
+
rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-new': key },
|
|
180
|
+
row({
|
|
181
|
+
title: f.name,
|
|
182
|
+
meta: [[f.role, f.org].filter(Boolean).join(' · ') || handleText(f.handle), COPY.newPerson(f.count, f.sources.map((s) => SOURCE[s] || s).join(', '))],
|
|
183
|
+
trail: [
|
|
184
|
+
button({ label: COPY.add, variant: 'primary', size: 'sm', attrs: { 'data-people-act': 'add', 'data-people-key': key },
|
|
185
|
+
onClick: async () => { const x = await decide(inst, { act: 'add', handle: f.handle, name: f.name }, COPY.added(f.name)); if (x && x.person) { inst.open = x.person; draw(inst); } } }),
|
|
186
|
+
button({ label: COPY.ignore, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'ignore', 'data-people-key': key },
|
|
187
|
+
onClick: () => decide(inst, { act: 'ignore', handle: f.handle }, COPY.ignored(f.name)) }),
|
|
188
|
+
],
|
|
189
|
+
attrs: { title: handleText(f.handle) },
|
|
190
|
+
})));
|
|
191
|
+
}
|
|
192
|
+
for (const m of r.merges) {
|
|
193
|
+
const key = m.handle.kind + ':' + m.handle.value;
|
|
194
|
+
rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-merge': key },
|
|
195
|
+
row({
|
|
196
|
+
title: COPY.same(m.handle.value, m.personName),
|
|
197
|
+
meta: [m.why],
|
|
198
|
+
trail: [
|
|
199
|
+
button({ label: COPY.merge, variant: 'outline', size: 'sm', attrs: { 'data-people-act': 'merge', 'data-people-key': key },
|
|
200
|
+
onClick: () => decide(inst, { act: 'merge', person: m.person, handle: m.handle }, COPY.merged) }),
|
|
201
|
+
button({ label: COPY.keep, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'keep-separate', 'data-people-key': key },
|
|
202
|
+
onClick: () => decide(inst, { act: 'keep-separate', a: m.a, b: m.b }, COPY.kept) }),
|
|
203
|
+
],
|
|
204
|
+
})));
|
|
205
|
+
}
|
|
206
|
+
if (r.tones.length) rows.push(el('p', { class: 'rp-people-hint', 'data-people-tones': String(r.tones.length) }, COPY.tonesWait(r.tones.length)));
|
|
207
|
+
return el('section', { class: 'rp-people-review', 'data-people-review': '' },
|
|
208
|
+
el('h3', { class: 'rp-people-sec__title' }, `${COPY.waiting} · ${r.count}`),
|
|
209
|
+
el('div', { role: 'list' }, rows));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ── the list ────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
213
|
+
|
|
214
|
+
function matches(p, q) {
|
|
215
|
+
if (!q) return true;
|
|
216
|
+
const hay = [p.name, p.role, p.org, ...p.handles.map((h) => h.value), ...p.shared.initiatives.map((g) => g.id)].join(' ').toLowerCase();
|
|
217
|
+
return hay.includes(q);
|
|
218
|
+
}
|
|
219
|
+
function shownPeople(inst) {
|
|
220
|
+
const q = inst.q.trim().toLowerCase();
|
|
221
|
+
let list = inst.view.people.filter((p) => matches(p, q));
|
|
222
|
+
if (inst.group === 'team') list = list.filter((p) => !p.external);
|
|
223
|
+
if (inst.group === 'external') list = list.filter((p) => p.external);
|
|
224
|
+
if (inst.sort === 'most') list = list.slice().sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
225
|
+
if (inst.sort === 'name') list = list.slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
226
|
+
return list;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function personLine(inst, p) {
|
|
230
|
+
const open = inst.open === p.id;
|
|
231
|
+
const t = toneOf(p);
|
|
232
|
+
return el('div', { class: 'rp-people-row' + (open ? ' rp-people-row--open' : ''), role: 'listitem', 'data-people-person': p.id },
|
|
233
|
+
row({
|
|
234
|
+
title: p.name,
|
|
235
|
+
meta: [who(p), p.last ? `${p.count} · ${day(p.last)}` : String(p.count)],
|
|
236
|
+
trail: t ? [badge({ text: p.tone.confirmed ? t : t + '?', tone: t === 'tense' ? 'destructive' : t === 'cautious' ? 'warning' : 'outline', attrs: { 'data-people-tone': t } })] : [],
|
|
237
|
+
onOpen: () => { inst.open = p.id; draw(inst); },
|
|
238
|
+
openLabel: `${p.name}, ${who(p)}`,
|
|
239
|
+
openAttrs: { 'aria-current': open ? 'true' : null, 'data-people-open': p.id },
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function listPane(inst) {
|
|
244
|
+
const v = inst.view;
|
|
245
|
+
const all = v.people.filter((p) => matches(p, inst.q.trim().toLowerCase()));
|
|
246
|
+
const controls = el('div', { class: 'rp-people-views' },
|
|
247
|
+
tabs({ variant: 'line', value: inst.group, ariaLabel: 'Group', attrs: { 'data-people-group': '' },
|
|
248
|
+
items: [{ id: 'all', label: COPY.all, count: all.length }, { id: 'team', label: COPY.team, count: all.filter((p) => !p.external).length },
|
|
249
|
+
{ id: 'external', label: COPY.external, count: all.filter((p) => p.external).length }],
|
|
250
|
+
onChange: (val) => { inst.group = val; draw(inst); } }),
|
|
251
|
+
tabs({ variant: 'segmented', value: inst.sort, ariaLabel: 'Sort', attrs: { 'data-people-sort': '' },
|
|
252
|
+
items: [{ id: 'latest', label: COPY.latest }, { id: 'most', label: COPY.most }, { id: 'name', label: COPY.name }],
|
|
253
|
+
onChange: (val) => { inst.sort = val; draw(inst); } }));
|
|
254
|
+
const list = shownPeople(inst);
|
|
255
|
+
let rows;
|
|
256
|
+
if (!v.people.length) rows = [el('p', { class: 'rp-section__empty' }, COPY.none)];
|
|
257
|
+
else if (!list.length) rows = [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
|
|
258
|
+
else rows = [el('div', { role: 'list', 'data-people-list': '' }, list.map((p) => personLine(inst, p)))];
|
|
259
|
+
return el('div', { class: 'rp-people-list' }, controls, ...rows);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ── the person's page ───────────────────────────────────────────────────────────────────────────────────────────── */
|
|
263
|
+
|
|
264
|
+
function section(title, kids, attrs) {
|
|
265
|
+
return el('section', { class: 'rp-people-sec', ...(attrs || {}) }, el('h4', { class: 'rp-people-sec__title' }, title), ...kids);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function interactionsSec(p) {
|
|
269
|
+
if (!p.interactions.length) return section(COPY.interactions, [el('p', { class: 'rp-people-hint' }, COPY.noInteractions)], { 'data-people-sec': 'interactions' });
|
|
270
|
+
return section(`${COPY.interactions} · ${p.count}`, [el('ol', { class: 'rp-people-ints' }, p.interactions.slice(0, 50).map((i) =>
|
|
271
|
+
el('li', { class: 'rp-people-int', 'data-people-int': i.date },
|
|
272
|
+
el('span', { class: 'rp-people-int__when' }, `${day(i.date)} · ${i.kind}`),
|
|
273
|
+
el('span', { class: 'rp-people-int__text' }, i.text),
|
|
274
|
+
el('span', { class: 'rp-people-int__src' }, [SOURCE[i.source] || i.source, i.pointer, i.tone].filter(Boolean).join(' · ')))))],
|
|
275
|
+
{ 'data-people-sec': 'interactions' });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toneSec(inst, p) {
|
|
279
|
+
const t = p.tone;
|
|
280
|
+
const mix = TONES.filter((x) => t.mix[x]).map((x) => `${t.mix[x]} ${x}`).join(' · ');
|
|
281
|
+
const kids = [];
|
|
282
|
+
if (!t.proposed && !t.confirmed) kids.push(el('p', { class: 'rp-people-hint' }, COPY.noTone));
|
|
283
|
+
if (t.confirmed) kids.push(el('p', { class: 'rp-people-tone', 'data-people-confirmed': t.confirmed }, COPY.confirmed(t.confirmed)));
|
|
284
|
+
else if (t.proposed) kids.push(el('p', { class: 'rp-people-tone', 'data-people-proposed': t.proposed }, COPY.proposed(t.proposed)));
|
|
285
|
+
if (mix) kids.push(el('p', { class: 'rp-people-hint', 'data-people-mix': '' }, mix));
|
|
286
|
+
const acts = [];
|
|
287
|
+
if (!t.confirmed && t.proposed) acts.push(button({ label: COPY.confirm, variant: 'primary', size: 'sm', attrs: { 'data-people-act': 'tone-confirm' },
|
|
288
|
+
onClick: () => decide(inst, { act: 'tone', person: p.id, tone: t.proposed }, COPY.toneSaved) }));
|
|
289
|
+
const another = button({ label: COPY.setAnother, variant: 'outline', size: 'sm', iconEnd: 'chevron-down', attrs: { 'data-people-act': 'tone-set', 'aria-haspopup': 'menu' },
|
|
290
|
+
onClick: () => menu({ anchor: another, ariaLabel: COPY.setAnother,
|
|
291
|
+
items: TONES.map((x) => ({ label: x, onSelect: () => decide(inst, { act: 'tone', person: p.id, tone: x }, COPY.toneSaved) })) }) });
|
|
292
|
+
acts.push(another);
|
|
293
|
+
if (t.confirmed) acts.push(button({ label: COPY.change, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'tone-clear' },
|
|
294
|
+
onClick: () => decide(inst, { act: 'tone', person: p.id, tone: null }, COPY.toneSaved) }));
|
|
295
|
+
kids.push(el('div', { class: 'rp-people-acts' }, acts));
|
|
296
|
+
return section(COPY.tone, kids, { 'data-people-sec': 'tone' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sharedSec(p) {
|
|
300
|
+
const s = p.shared;
|
|
301
|
+
const lines = [];
|
|
302
|
+
for (const g of s.initiatives) lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'initiative' }, `${g.id} · ${g.count} interaction${g.count === 1 ? '' : 's'}`));
|
|
303
|
+
for (const it of s.items) {
|
|
304
|
+
lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'item', 'data-people-item': it.id },
|
|
305
|
+
`${COPY.owns} ${it.title || it.id}${it.initiative ? ' · ' + it.initiative : ''} · ${it.state}`,
|
|
306
|
+
it.blocked ? badge({ text: COPY.blocked, tone: 'warning', attrs: { title: it.reason || COPY.blocked } }) : null));
|
|
307
|
+
}
|
|
308
|
+
for (const m of s.meetings) {
|
|
309
|
+
const when = new Date(m.start);
|
|
310
|
+
lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'meeting' },
|
|
311
|
+
`${COPY.meeting} · ${when.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} ${when.toTimeString().slice(0, 5)} · ${m.title}`));
|
|
312
|
+
}
|
|
313
|
+
return section(COPY.shared, lines.length ? [el('ul', { class: 'rp-people-list-plain' }, lines)] : [el('p', { class: 'rp-people-hint' }, COPY.noShared)], { 'data-people-sec': 'shared' });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function notesSec(inst, p) {
|
|
317
|
+
const list = p.notes.map((n) => el('li', { class: 'rp-people-note', 'data-people-note': n.id },
|
|
318
|
+
el('span', { class: 'rp-people-note__text' }, n.text),
|
|
319
|
+
button({ label: COPY.remove, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'note-remove', 'data-people-key': n.id },
|
|
320
|
+
onClick: () => decide(inst, { act: 'note-remove', person: p.id, id: n.id }, COPY.noteRemoved) })));
|
|
321
|
+
const f = field({ label: COPY.addNote, kind: 'textarea', hideLabel: true, placeholder: COPY.notePlaceholder, attrs: { 'data-people-field': 'note' } });
|
|
322
|
+
const save = async () => {
|
|
323
|
+
const text = f.control.value.trim();
|
|
324
|
+
if (!text) return;
|
|
325
|
+
f.control.blur();
|
|
326
|
+
const r = await decide(inst, { act: 'note', person: p.id, text }, COPY.noteAdded);
|
|
327
|
+
if (r) f.control.value = '';
|
|
328
|
+
};
|
|
329
|
+
return section(COPY.notes, [
|
|
330
|
+
list.length ? el('ul', { class: 'rp-people-list-plain' }, list) : el('p', { class: 'rp-people-hint' }, COPY.noNotes),
|
|
331
|
+
el('div', { class: 'rp-people-noteadd' }, f, el('div', { class: 'rp-people-acts' }, button({ label: COPY.addNote, variant: 'outline', size: 'sm', attrs: { 'data-people-act': 'note-add' }, onClick: save }))),
|
|
332
|
+
], { 'data-people-sec': 'notes' });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function identitySec(inst, p) {
|
|
336
|
+
const by = {};
|
|
337
|
+
for (const i of p.interactions) by[i.source] = (by[i.source] || 0) + 1;
|
|
338
|
+
const file = inst.view.file ? String(inst.view.file).split('/').slice(-3).join('/') : 'people.jsonl';
|
|
339
|
+
return section(COPY.identity, [
|
|
340
|
+
el('ul', { class: 'rp-people-list-plain' },
|
|
341
|
+
p.handles.map((h) => el('li', { class: 'rp-people-handle', 'data-people-handle': h.kind + ':' + h.value }, handleText(h))),
|
|
342
|
+
Object.entries(by).map(([s, n]) => el('li', { class: 'rp-people-hint' }, `${SOURCE[s] || s}: ${n} interaction${n === 1 ? '' : 's'}`))),
|
|
343
|
+
el('p', { class: 'rp-people-hint rp-people-file' }, COPY.lives(file)),
|
|
344
|
+
], { 'data-people-sec': 'identity' });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function personPane(inst) {
|
|
348
|
+
const p = inst.view.people.find((x) => x.id === inst.open);
|
|
349
|
+
if (!p) return el('div', { class: 'rp-people-page' }, el('p', { class: 'rp-section__empty' }, COPY.pick));
|
|
350
|
+
return el('article', { class: 'rp-people-page', 'data-people-page': p.id },
|
|
351
|
+
el('header', { class: 'rp-people-who' },
|
|
352
|
+
el('h3', { class: 'rp-people-who__name' }, p.name),
|
|
353
|
+
el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · '))),
|
|
354
|
+
interactionsSec(p), toneSec(inst, p), sharedSec(p), notesSec(inst, p), identitySec(inst, p));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
358
|
+
|
|
359
|
+
function header(inst) {
|
|
360
|
+
if (inst.head) return inst.head;
|
|
361
|
+
const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-people-find': '' } });
|
|
362
|
+
const q = find.control;
|
|
363
|
+
q.addEventListener('input', () => { inst.q = q.value; draw(inst); });
|
|
364
|
+
q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; draw(inst); } });
|
|
365
|
+
inst.head = el('div', { class: 'rp-people-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-people-find' }, find));
|
|
366
|
+
inst.head.find = find;
|
|
367
|
+
return inst.head;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function draw(inst) {
|
|
371
|
+
const root = inst.root;
|
|
372
|
+
if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
|
|
373
|
+
const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.q, inst.group, inst.sort, inst.open]);
|
|
374
|
+
if (fp === inst.last && root.childNodes.length && root.contains(inst.body)) return;
|
|
375
|
+
inst.last = fp;
|
|
376
|
+
const head = header(inst);
|
|
377
|
+
if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
|
|
378
|
+
if (!inst.loaded) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty' }, COPY.loading)); return; }
|
|
379
|
+
if (inst.error) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, inst.error)); return; }
|
|
380
|
+
if (!inst.open && inst.view.people.length) inst.open = inst.view.people[0].id;
|
|
381
|
+
// Focus stays in a note being typed: a redraw of the page would drop it, so it waits until the field is left.
|
|
382
|
+
const typing = document.activeElement && inst.body.contains(document.activeElement) && document.activeElement.matches('textarea');
|
|
383
|
+
if (typing) { inst.last = ''; return; }
|
|
384
|
+
inst.body.replaceChildren(...[review(inst), el('div', { class: 'rp-people-grid' }, listPane(inst), personPane(inst))].filter(Boolean));
|
|
385
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/* pm/dest-settings.js — the Settings destination, the frame's ⚙ (pm-os-absorb Run 3, slice 3A; PRD R9).
|
|
2
2
|
*
|
|
3
|
-
* Settings holds settings only, in
|
|
3
|
+
* Settings holds settings only, in eight sections and in this order:
|
|
4
4
|
* Repositories every registered repository as a row (open it to make it current; ⋯ for more), and Add repository,
|
|
5
5
|
* which draws the existing add-repository flow (view-addrepo.js) in place
|
|
6
6
|
* Profiles one row that opens the Claude profiles in the detail
|
|
7
7
|
* New sessions the default CLI and the default model the dock's + Session applies (dock.js)
|
|
8
8
|
* Validate consent the current repository's consent switch (GET and POST /api/validate/repo)
|
|
9
|
+
* People the current repository's switch for calendar attendee names (GET /api/people, POST /api/people/decide;
|
|
10
|
+
* people-directory, D90)
|
|
9
11
|
* Online the Online switch and its five settings (GET and POST /api/online; workspace-online-mode, D89)
|
|
10
12
|
* Theme Dark or Light
|
|
11
13
|
* Updates the workspace app's version and the current repository's kit version; Check for updates asks the
|
|
@@ -35,6 +37,7 @@ const roots = new WeakMap(); // host -> { root, sig }
|
|
|
35
37
|
const consentByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
|
|
36
38
|
const kitByWs = new Map(); // ws -> { st: GET /api/kit/status body | null, at, pending }
|
|
37
39
|
const upd = { phase: null, latest: null, compare: null, message: '', error: '', output: '' }; // the Updates section
|
|
40
|
+
const peopleByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
|
|
38
41
|
const online = { data: null, error: null, note: null, at: 0, pending: false }; // GET /api/online, and the last refusal
|
|
39
42
|
let addOpen = false;
|
|
40
43
|
let addMod = null;
|
|
@@ -62,6 +65,27 @@ async function setConsent(ws, on) {
|
|
|
62
65
|
setState({});
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
function readPeople(ws) {
|
|
69
|
+
if (!ws || FIXTURE) return;
|
|
70
|
+
const have = peopleByWs.get(ws);
|
|
71
|
+
if (have && (have.pending || Date.now() - have.at < 30000)) return;
|
|
72
|
+
const entry = { on: have ? have.on : null, error: null, at: Date.now(), pending: true };
|
|
73
|
+
peopleByWs.set(ws, entry);
|
|
74
|
+
pmGet('/api/people?ws=' + encodeURIComponent(ws)).then((j) => {
|
|
75
|
+
entry.pending = false; entry.at = Date.now();
|
|
76
|
+
if (j && j.ok !== false && j.settings) { entry.on = !!j.settings.calendar_names; entry.error = null; } else entry.error = (j && j.error) || P.peopleUnread;
|
|
77
|
+
setState({});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function setPeopleCalendar(ws, on) {
|
|
82
|
+
const j = await pmPost('/api/people/decide', { ws, act: 'setting', calendar_names: on });
|
|
83
|
+
peopleByWs.delete(ws);
|
|
84
|
+
if (!j || j.ok === false) peopleByWs.set(ws, { on: null, error: (j && j.error) || P.peopleUnread, at: Date.now() });
|
|
85
|
+
else readPeople(ws);
|
|
86
|
+
setState({});
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
function readOnline(force) {
|
|
66
90
|
if (FIXTURE || online.pending) return;
|
|
67
91
|
if (!force && Date.now() - online.at < 10000) return;
|
|
@@ -84,7 +108,7 @@ const section = (id, title, kids, action) => el('section', { class: 'fr-set__sec
|
|
|
84
108
|
el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, title), action || null),
|
|
85
109
|
kids);
|
|
86
110
|
|
|
87
|
-
/* ── the
|
|
111
|
+
/* ── the eight sections ────────────────────────────────────────────────────────────────────────────────────────── */
|
|
88
112
|
|
|
89
113
|
function reposSection(scope) {
|
|
90
114
|
const tuples = repoTuples();
|
|
@@ -194,6 +218,22 @@ function validateSection(scope) {
|
|
|
194
218
|
el('span', { 'data-fr': 'consent-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle, open)]);
|
|
195
219
|
}
|
|
196
220
|
|
|
221
|
+
function peopleSection(scope) {
|
|
222
|
+
const repo = scope === 'all' ? null : activeProduct();
|
|
223
|
+
const ws = wsOfRepo(repo);
|
|
224
|
+
if (!ws) return section('people', P.people, [empty({ text: P.peopleNoRepo })]);
|
|
225
|
+
readPeople(ws);
|
|
226
|
+
const c = peopleByWs.get(ws) || {};
|
|
227
|
+
const known = c.on === true || c.on === false;
|
|
228
|
+
const line = c.error ? c.error : !known ? '' : c.on ? P.peopleOn : P.peopleOff;
|
|
229
|
+
const toggle = known ? button({
|
|
230
|
+
label: c.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm',
|
|
231
|
+
attrs: { 'data-fr': 'people-calendar' }, onClick: () => setPeopleCalendar(ws, !c.on),
|
|
232
|
+
}) : null;
|
|
233
|
+
return section('people', P.people, [el('p', { class: 'fr-set__note' }, P.peopleNote),
|
|
234
|
+
el('div', { class: 'fr-set__line' }, el('span', { 'data-fr': 'people-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle)]);
|
|
235
|
+
}
|
|
236
|
+
|
|
197
237
|
function onlineSection() {
|
|
198
238
|
readOnline(false);
|
|
199
239
|
const d = online.data;
|
|
@@ -353,7 +393,7 @@ function signature(scope) {
|
|
|
353
393
|
let add = '';
|
|
354
394
|
try { add = JSON.stringify(addKeys); } catch { add = String(Date.now()); }
|
|
355
395
|
const kit = ws ? kitByWs.get(ws) : null;
|
|
356
|
-
return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note,
|
|
396
|
+
return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, peopleByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note,
|
|
357
397
|
kit ? kit.st : null, upd]) + add;
|
|
358
398
|
}
|
|
359
399
|
|
|
@@ -380,6 +420,7 @@ export function mountDestination(host, ctx) {
|
|
|
380
420
|
profilesSection(),
|
|
381
421
|
sessionsSection(),
|
|
382
422
|
validateSection(scope),
|
|
423
|
+
peopleSection(scope),
|
|
383
424
|
onlineSection(),
|
|
384
425
|
themeSection(),
|
|
385
426
|
updatesSection(scope),
|
|
@@ -27,6 +27,7 @@ export const COPY = {
|
|
|
27
27
|
board: 'Board',
|
|
28
28
|
reports: 'Reports',
|
|
29
29
|
docs: 'Docs',
|
|
30
|
+
people: 'People',
|
|
30
31
|
agent: 'Agent',
|
|
31
32
|
settings: 'Settings',
|
|
32
33
|
},
|
|
@@ -145,6 +146,12 @@ export const COPY = {
|
|
|
145
146
|
validateUnread: 'The consent of this repository did not load.',
|
|
146
147
|
validateNoRepo: 'Choose a repository to set its consent.',
|
|
147
148
|
validateOpen: 'Open Validate',
|
|
149
|
+
people: 'People',
|
|
150
|
+
peopleOn: 'Calendar attendee names are kept. Agent sessions record who attends each meeting.',
|
|
151
|
+
peopleOff: 'Calendar attendee names are not kept. Agent sessions record no one from the calendar.',
|
|
152
|
+
peopleNote: 'Keep attendee names from the calendar, for this repository only. Turning it off also hides the names recorded before.',
|
|
153
|
+
peopleUnread: 'The People setting of this repository did not load.',
|
|
154
|
+
peopleNoRepo: 'Choose a repository to set what People keeps.',
|
|
148
155
|
turnOn: 'Turn on',
|
|
149
156
|
turnOff: 'Turn off',
|
|
150
157
|
theme: 'Theme',
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* line 1 the title, the horizon, the health reason (only when not on track), and a meta line: lane · anchor · id
|
|
5
5
|
* line 2 the stage stepper, then "Waiting on you: approval [Open gate ▸]" or "Waiting on agent: build [Hand to agent ▸]"
|
|
6
6
|
* tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files, History,
|
|
7
|
-
* Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira)
|
|
7
|
+
* Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
|
|
8
|
+
* People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
|
|
8
9
|
* ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
|
|
9
10
|
* Nothing on this sheet says "plan". Everything it shows is read from GET /api/initiatives/<id>; every write goes through
|
|
10
11
|
* initiatives-api.js. The Breakdown tab mounts with a tab id of the form `initiative:<slug>`, so it never meets a panel-4
|
|
@@ -19,6 +20,7 @@ import { sheet, button, iconButton, tabs, row, badge, menu, toast, empty, field,
|
|
|
19
20
|
import { icon, ensureSprite } from './icons.js';
|
|
20
21
|
import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
|
|
21
22
|
import { mountJira, unmountJira } from './sheet-jira.js';
|
|
23
|
+
import { initiativePeople } from './people-link.js';
|
|
22
24
|
import { openDoc } from './panel-initiatives.js';
|
|
23
25
|
import { openNewSession } from './panel-newsess.js';
|
|
24
26
|
import { wsOf, readDoc, saveDoc } from './data.js';
|
|
@@ -241,7 +243,7 @@ export function openSheet(repo, slug) {
|
|
|
241
243
|
function drawTabs() {
|
|
242
244
|
tabsHost.replaceChildren(tabs({
|
|
243
245
|
variant: 'segmented', full: true, ariaLabel: COPY.sheet.tabs, value: tab,
|
|
244
|
-
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 }],
|
|
246
|
+
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 }],
|
|
245
247
|
onChange: (id) => {
|
|
246
248
|
if (id === tab) return;
|
|
247
249
|
if (tab === 'breakdown') unmountBreakdown(bdHost);
|
|
@@ -263,6 +265,7 @@ export function openSheet(repo, slug) {
|
|
|
263
265
|
return;
|
|
264
266
|
}
|
|
265
267
|
if (tab === 'jira') { mountJira(panel, { ws, slug }); return; }
|
|
268
|
+
if (tab === 'people') { panel.replaceChildren(initiativePeople(ws, slug, { onOpen: () => s.close() })); return; }
|
|
266
269
|
if (tab === 'files') {
|
|
267
270
|
const files = data.files || [];
|
|
268
271
|
// Grouped by where the tie came from (scan-consistency R3): the record's own paths, the documents the library's
|
|
@@ -127,6 +127,7 @@ export const COPY = {
|
|
|
127
127
|
files: 'Files',
|
|
128
128
|
history: 'History',
|
|
129
129
|
jira: 'Jira tickets',
|
|
130
|
+
people: 'People',
|
|
130
131
|
waitingYou: (act) => `Waiting on you: ${act}`,
|
|
131
132
|
waitingAgent: (act) => `Waiting on agent: ${act}`,
|
|
132
133
|
nothingOwed: 'Nothing is owed.',
|
|
@@ -17,6 +17,7 @@ import { icon } from './icons.js';
|
|
|
17
17
|
import { COPY, STATES, ago, dayOf, ownerLabel } from './breakdown-copy.js';
|
|
18
18
|
import { stageLabel } from './bd-model.js';
|
|
19
19
|
import { handoffBox } from './handoff-box.js';
|
|
20
|
+
import { peopleOf, namedIn, openPerson } from './people-link.js';
|
|
20
21
|
|
|
21
22
|
const ownerKind = (o) => (o === 'you' || o === 'agent' ? o : String(o || '').split(':')[0] || 'you');
|
|
22
23
|
const ownerName = (o) => { const s = String(o || ''); const i = s.indexOf(':'); return i > 0 ? s.slice(i + 1) : ''; };
|
|
@@ -113,6 +114,20 @@ export function openItemSheet(ctx, id, { handoff = false } = {}) {
|
|
|
113
114
|
});
|
|
114
115
|
owner.setAttribute('data-bd-field', 'owner');
|
|
115
116
|
const showOwnerName = (k) => { ownerNameField.hidden = !(k === 'person' || k === 'tracker'); };
|
|
117
|
+
// A person owner who is on People links to their page (people-directory R13). Nothing shows for anyone else.
|
|
118
|
+
const ownerPeople = el('span', { class: 'rp-people-links', 'data-bd-owner-people': '' });
|
|
119
|
+
let ownerShown = null;
|
|
120
|
+
const drawOwnerPeople = (o) => {
|
|
121
|
+
if (o === ownerShown) return;
|
|
122
|
+
ownerShown = o;
|
|
123
|
+
ownerPeople.replaceChildren();
|
|
124
|
+
if (ownerKind(o) !== 'person' || !ownerName(o)) return;
|
|
125
|
+
peopleOf(ctx.ws).then((v) => {
|
|
126
|
+
if (ownerShown !== o) return;
|
|
127
|
+
ownerPeople.replaceChildren(...namedIn(v, ownerName(o)).map((p) => button({ label: p.name, variant: 'ghost', size: 'sm', iconEnd: 'chevron-right',
|
|
128
|
+
attrs: { 'data-person-link': p.id, 'aria-label': `Open ${p.name} in People` }, onClick: () => { sh.close(); openPerson(p.id); } })));
|
|
129
|
+
});
|
|
130
|
+
};
|
|
116
131
|
const stage = field({
|
|
117
132
|
label: COPY.labels.stage, kind: 'select', value: it0.stage, attrs: { 'data-bd-input': 'stage' },
|
|
118
133
|
options: (L0.stages || []).map((s) => ({ value: s.id, label: s.label })),
|
|
@@ -211,12 +226,13 @@ export function openItemSheet(ctx, id, { handoff = false } = {}) {
|
|
|
211
226
|
const sh = sheet({
|
|
212
227
|
mode: 'auto', title: it0.title, sub: ctx.slug + ' · ' + stageLabel(L0, it0.stage),
|
|
213
228
|
attrs: { 'data-bd-sheet': id },
|
|
214
|
-
body: [stateSlot, noteSlot, title, el('div', { class: 'bd-grid2' }, el('div', { class: 'bd-group' }, owner, ownerNameField), stage),
|
|
229
|
+
body: [stateSlot, noteSlot, title, el('div', { class: 'bd-grid2' }, el('div', { class: 'bd-group' }, owner, ownerNameField, ownerPeople), stage),
|
|
215
230
|
afterSlot, el('div', { class: 'bd-grid2' }, start, due), el('div', { class: 'bd-checks' }, milestone, blocked), reason, note, handSlot, history],
|
|
216
231
|
actions: [withdraw, closeBtn],
|
|
217
232
|
onClose: () => { if (ctx.onClose) ctx.onClose(id); },
|
|
218
233
|
});
|
|
219
234
|
showOwnerName(ownerKind(it0.owner));
|
|
235
|
+
drawOwnerPeople(it0.owner);
|
|
220
236
|
|
|
221
237
|
const refresh = () => {
|
|
222
238
|
const it = item();
|
|
@@ -230,6 +246,7 @@ export function openItemSheet(ctx, id, { handoff = false } = {}) {
|
|
|
230
246
|
if (!focused(title.control)) title.control.resetCommitted(it.title);
|
|
231
247
|
if (!focused(owner.control)) { owner.control.value = ownerKind(it.owner); showOwnerName(ownerKind(it.owner)); }
|
|
232
248
|
if (!focused(ownerNameField.control) && ownerName(it.owner)) ownerNameField.control.resetCommitted(ownerName(it.owner));
|
|
249
|
+
drawOwnerPeople(it.owner);
|
|
233
250
|
if (!focused(stage.control)) stage.control.value = it.stage;
|
|
234
251
|
if (!afterSlot.contains(document.activeElement)) drawAfter(it);
|
|
235
252
|
if (!focused(start.control)) start.control.value = it.start || '';
|
|
@@ -44,6 +44,8 @@ export const VIEWS = {
|
|
|
44
44
|
addrepo: { label: 'add repository', mode: null, scoped: false },
|
|
45
45
|
// jev-live-notes Run 6: confirm each talking point as it arrives
|
|
46
46
|
livenotes: { label: 'live notes', mode: null, scoped: true, palette: true, hint: 'confirm talking points' },
|
|
47
|
+
// meeting-notes Run 1: typed notes, dictation, meeting transcripts and summaries; the PM's own, the same in every repository
|
|
48
|
+
notes: { label: 'notes', mode: null, scoped: false, palette: true, hint: 'type, dictate or record a meeting' },
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
/** The view a list mode opens as, or null when the mode is not a view. `product` is the list itself. */
|
|
@@ -93,6 +95,7 @@ function modes() {
|
|
|
93
95
|
* every `data-*` hook the suites use, and only adds the board's frame around it. A view with no board module draws as before. */
|
|
94
96
|
export const BOARD_MODULES = {
|
|
95
97
|
today: './view-today.js', validate: './view-validate.js', addrepo: './view-addrepo.js', livenotes: './view-livenotes.js',
|
|
98
|
+
notes: './view-notes.js',
|
|
96
99
|
};
|
|
97
100
|
const boardMods = {}; // view -> undefined (not tried) · null (loading or absent) · module
|
|
98
101
|
function boardOf(view) {
|