create-pmos 0.116.0 → 0.118.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/notes.md +43 -3
- package/workspace/guide/today.md +22 -13
- package/workspace/lib/brief.js +2 -2
- package/workspace/lib/day-marks.js +29 -9
- package/workspace/lib/goals.js +68 -10
- package/workspace/lib/notes.js +139 -17
- package/workspace/lib/routes-notes.js +68 -11
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +289 -91
- package/workspace/public/pm/dest-notes.js +136 -29
- package/workspace/public/pm/note-dock.js +204 -164
- package/workspace/public/pm/notes-context.js +60 -0
- package/workspace/public/pm/notes-links.js +32 -6
- package/workspace/public/pm/primitives.js +1 -1
- package/workspace/public/pm/today-agenda.js +27 -53
- package/workspace/public/pm/today-copy.js +34 -15
- package/workspace/public/pm/today-day.js +98 -76
- package/workspace/public/pm/today-goals.js +159 -15
- package/workspace/public/pm/today-jobs.js +84 -0
- package/workspace/public/pm/today-mentions.js +50 -0
- package/workspace/public/pm/topbar.js +71 -32
- package/workspace/public/pm/view-notes.js +142 -73
- package/workspace/server.js +10 -8
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
- package/workspace/test-lists/today-goals-jobs.json +5 -0
- package/workspace/test-lists/today-mentions-cards.json +5 -0
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* then Queue it (the shipped background prepare: one approval is one prompt hash)
|
|
8
8
|
* addGoal(ctx, text, ini) → POST the add; true when the goal is on the list. While Today shows one repository, the
|
|
9
9
|
* goal belongs to it (repo); the goal's detail can change that.
|
|
10
|
+
* goalJobsBlock(ctx, g, n) → a goal's done when and jobs, with the add and link presses: the goal's detail and a meeting's
|
|
11
|
+
* detail draw the same block (initiative today-goals-jobs, PRD G3, A4)
|
|
12
|
+
* jobsWithoutGoal(ctx) → the end of the Goals section: every job of the day with no goal, each with the link control (G5)
|
|
10
13
|
* goalsSig() → a string that changes whenever what this module draws changes on its own
|
|
11
14
|
*
|
|
12
15
|
* `ctx` is the day section's (today-day.js): { ws, m (the day model), select(type, id), reload(), rerender(), cur(type, id) }.
|
|
@@ -17,10 +20,16 @@ import { el } from './dom.js';
|
|
|
17
20
|
import { button, field, badge, toast, row, meter } from './primitives.js';
|
|
18
21
|
import { pmPost } from './http.js';
|
|
19
22
|
import { COPY } from './today-copy.js';
|
|
23
|
+
import { statusPill, whoActs, mark as whoMark, tagged, originOf, goalPicker, linkField } from './today-jobs.js';
|
|
20
24
|
|
|
21
25
|
const G = COPY.goals;
|
|
22
26
|
const D = COPY.day;
|
|
27
|
+
const A = COPY.act;
|
|
28
|
+
const MAX_JOBS = 12; // the jobs the PM types on one goal (lib/goals.js)
|
|
23
29
|
let draft = '';
|
|
30
|
+
const ctxDraft = { done_when: '', anchor: '', due: '' }; // the "Add context" fields, kept while the section is redrawn
|
|
31
|
+
let ctxOpen = false;
|
|
32
|
+
const jobDrafts = new Map(); // goal id -> the text typed in its "Add a job" field
|
|
24
33
|
let lastAdded = null; // { key, at }: the text of the last goal sent, so a double commit adds it once
|
|
25
34
|
const prepared = new Map(); // step ref -> { key, branch, item_id, prompt_sha, initiative_id, ws } (a plan shown, not yet queued)
|
|
26
35
|
|
|
@@ -31,10 +40,12 @@ async function press(ctx, body) {
|
|
|
31
40
|
return true;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
/** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it.
|
|
35
|
-
|
|
43
|
+
/** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it. `extra` holds the
|
|
44
|
+
* optional context: done_when, anchor, due and event_id (the meeting it was made from). */
|
|
45
|
+
export function addGoal(ctx, text, ini, extra) {
|
|
36
46
|
const body = { action: 'add', text: String(text || '').trim().slice(0, 140) };
|
|
37
47
|
if (ini) body.initiative_id = ini;
|
|
48
|
+
for (const k of ['done_when', 'anchor', 'due', 'event_id']) if (extra && typeof extra[k] === 'string' && extra[k].trim()) body[k] = extra[k].trim();
|
|
38
49
|
// one day for every repository: a goal added while Today shows one repository belongs to it; otherwise to none
|
|
39
50
|
if (ctx.m && ctx.m.narrow && ctx.m.here) body.repo = ctx.m.here;
|
|
40
51
|
return press(ctx, body);
|
|
@@ -42,21 +53,28 @@ export function addGoal(ctx, text, ini) {
|
|
|
42
53
|
|
|
43
54
|
/** The jobs of a goal: the day model's jobs that name it. */
|
|
44
55
|
const jobsOf = (m, id) => m.jobs.filter((j) => j.goal === id && !j.worth);
|
|
56
|
+
const typedOf = (g) => (Array.isArray(g.jobs) ? g.jobs : []);
|
|
57
|
+
/** "N of M jobs done": the jobs made from the brief and the ones the PM typed. Information only: it never closes a goal (G2). */
|
|
58
|
+
function tally(m, g) {
|
|
59
|
+
const js = jobsOf(m, g.id);
|
|
60
|
+
const typed = typedOf(g);
|
|
61
|
+
return { js, typed, total: js.length + typed.length, done: js.filter((j) => j.group === 'done').length + typed.filter((x) => x.done).length, need: js.filter((j) => j.group === 'needs').length };
|
|
62
|
+
}
|
|
45
63
|
|
|
46
64
|
export function goalRows(ctx) {
|
|
47
65
|
const { m } = ctx;
|
|
48
66
|
if (!m.goals.length) return null;
|
|
49
67
|
return el('ul', { class: 'td-day__rows', 'data-day-list': 'goals' }, ...m.goals.map((g) => {
|
|
50
|
-
const
|
|
51
|
-
const done = js.filter((j) => j.group === 'done').length;
|
|
52
|
-
const need = js.filter((j) => j.group === 'needs').length;
|
|
68
|
+
const t = tally(m, g);
|
|
53
69
|
const closed = g.status !== 'open';
|
|
54
|
-
const sub = closed ? (G.coverage[g.coverage] || g.status) :
|
|
70
|
+
const sub = closed ? (G.coverage[g.coverage] || g.status) : t.total ? D.goalJobs(t.done, t.total) + (t.need ? ' · ' + D.goalNeed(t.need) : '') : (G.coverage[g.coverage] || D.goalNone);
|
|
55
71
|
const cur = ctx.cur('goal', g.id);
|
|
72
|
+
// the optional context (G1): when it is due, what it serves, and the prompt to say what done looks like
|
|
73
|
+
const meta = [sub, g.due ? G.dueMeta(g.due) : null, g.anchor ? G.anchorMeta(g.anchor) : null, !closed && !g.done_when ? G.addDoneWhen : null];
|
|
56
74
|
return el('li', null, row({
|
|
57
|
-
title: g.text, meta
|
|
75
|
+
title: g.text, meta, done: closed, onOpen: () => ctx.select('goal', g.id),
|
|
58
76
|
lead: [el('span', { class: 'td-day__lead' }, String(m.goalNo.get(g.id)))],
|
|
59
|
-
trail: [el('span', { class: 'td-day__chips' }, g.repo ? badge({ text: m.repoName(g.repo), tone: 'outline', attrs: { 'data-day-repo': g.repo } }) : null), meter({ done:
|
|
77
|
+
trail: [el('span', { class: 'td-day__chips' }, g.repo ? badge({ text: m.repoName(g.repo), tone: 'outline', attrs: { 'data-day-repo': g.repo } }) : null), meter({ done: t.total ? t.done : g.status === 'done' ? 1 : 0, total: t.total || 1, label: '' })],
|
|
60
78
|
attrs: { class: 'td-day__r', 'data-day-sel': 'goal:' + g.id, 'data-goal': g.id, 'data-goal-coverage': g.coverage, 'data-cur': cur },
|
|
61
79
|
openAttrs: { 'aria-current': cur },
|
|
62
80
|
}));
|
|
@@ -77,13 +95,32 @@ export function goalAdd(ctx) {
|
|
|
77
95
|
lastAdded = { key, at: Date.now() };
|
|
78
96
|
// clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
|
|
79
97
|
draft = '';
|
|
80
|
-
|
|
81
|
-
|
|
98
|
+
const extra = { ...ctxDraft };
|
|
99
|
+
clearContext();
|
|
100
|
+
if (await addGoal(ctx, v, null, extra)) { if (f.control.value.trim() === String(v).trim()) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; } ctx.rerender(); }
|
|
101
|
+
else { lastAdded = null; draft = v; Object.assign(ctxDraft, extra); ctxOpen = true; ctx.rerender(); }
|
|
82
102
|
} });
|
|
83
103
|
// a rebuild keeps what is typed. The field is made empty and filled after, so Enter still commits the restored text.
|
|
84
104
|
f.control.value = draft;
|
|
85
105
|
f.control.addEventListener('input', () => { draft = f.control.value; });
|
|
86
|
-
return el('div', { class: 'td-day__goaladd' }, f);
|
|
106
|
+
return el('div', { class: 'td-day__goaladd' }, f, contextFold());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function clearContext() { ctxDraft.done_when = ''; ctxDraft.anchor = ''; ctxDraft.due = ''; ctxOpen = false; }
|
|
110
|
+
|
|
111
|
+
/** "Add context" (G6): done when, anchor and due. The jobs carry who does the work and what comes after, so no field asks. */
|
|
112
|
+
function contextFold() {
|
|
113
|
+
const one = (key, label, hint, max) => {
|
|
114
|
+
const f = field({ label, value: '', placeholder: hint, attrs: { maxlength: String(max), 'data-goals': 'ctx-' + key, 'data-goal-ctx-new': key }, onCommit: (v) => { ctxDraft[key] = v; } });
|
|
115
|
+
f.control.value = ctxDraft[key];
|
|
116
|
+
f.control.addEventListener('input', () => { ctxDraft[key] = f.control.value; });
|
|
117
|
+
return f;
|
|
118
|
+
};
|
|
119
|
+
const d = el('details', { class: 'td-day__fold td-goal__ctxfold', open: ctxOpen ? true : null, 'data-goals': 'context' },
|
|
120
|
+
el('summary', null, G.addContext),
|
|
121
|
+
el('div', { class: 'td-goal__ctxfields' }, one('done_when', G.doneWhen, G.doneWhenHint, 200), one('anchor', G.anchor, G.anchorHint, 80), one('due', G.due, G.dueHint, 40)));
|
|
122
|
+
d.addEventListener('toggle', () => { ctxOpen = d.open; });
|
|
123
|
+
return d;
|
|
87
124
|
}
|
|
88
125
|
|
|
89
126
|
async function prepareStep(ctx, st) {
|
|
@@ -117,6 +154,113 @@ function stepNode(ctx, st) {
|
|
|
117
154
|
acts);
|
|
118
155
|
}
|
|
119
156
|
|
|
157
|
+
const srcOf = (k) => D.srcName[k] || String(k || '');
|
|
158
|
+
|
|
159
|
+
/** A job made from the brief, as a line of a goal (G3): a check for the PM's own job that has no task yet, the title, who
|
|
160
|
+
* acts, where it came from (when not from the goal), and its status. The press on the title opens the job. */
|
|
161
|
+
function jobLine(ctx, j) {
|
|
162
|
+
const who = whoActs(j);
|
|
163
|
+
const origin = originOf(j);
|
|
164
|
+
const cur = ctx.cur('job', j.id);
|
|
165
|
+
const checkable = j.status === 'you' && !j.task;
|
|
166
|
+
const check = checkable ? field({ kind: 'check', label: '', value: j.group === 'done', attrs: { 'aria-label': `${A.done}: ${j.title}`, 'data-goal-job-check': j.id },
|
|
167
|
+
onCommit: (v) => ctx.mark(j.id, { action: v ? 'done' : 'reopen' }, v ? A.did.done(j.title) : A.did.reopen) }) : null;
|
|
168
|
+
return el('li', null, row({
|
|
169
|
+
title: j.title, meta: [who.text, origin !== 'goal' ? D.origin[origin] : null, srcOf(j.source)], done: j.group === 'done',
|
|
170
|
+
lead: [check, whoMark(who.who)].filter(Boolean), trail: [statusPill(j)], onOpen: () => ctx.select('job', j.id),
|
|
171
|
+
attrs: { class: 'td-day__r', 'data-day-sel': 'job:' + j.id, 'data-goal-job': j.id, 'data-who': who.who, 'data-cur': cur }, openAttrs: { 'aria-current': cur },
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A job the PM typed on the goal (G3): the PM's own, with no agent and no session. Check, uncheck and remove are the PM's presses. */
|
|
176
|
+
function typedLine(ctx, g, j) {
|
|
177
|
+
const who = whoActs({ typed: true, done: j.done });
|
|
178
|
+
const open = g.status === 'open';
|
|
179
|
+
const check = field({ kind: 'check', label: '', value: !!j.done, attrs: { 'aria-label': `${A.done}: ${j.text}`, 'data-goal-typed-check': j.id, disabled: open ? null : true },
|
|
180
|
+
onCommit: (v) => press(ctx, { action: v ? 'job-done' : 'job-reopen', id: g.id, job: j.id }) });
|
|
181
|
+
return el('li', null, row({
|
|
182
|
+
title: j.text, meta: [who.text, D.origin.you], done: !!j.done, lead: [check, whoMark('pm')],
|
|
183
|
+
trail: open ? [button({ label: tagged(G.removeJob, 'you'), size: 'sm', variant: 'ghost', attrs: { 'data-goal-typed-remove': j.id, 'aria-label': `${G.removeJob}: ${j.text}` },
|
|
184
|
+
onClick: () => press(ctx, { action: 'job-remove', id: g.id, job: j.id }) })] : [],
|
|
185
|
+
attrs: { class: 'td-day__r', 'data-goal-typed': j.id, 'data-who': 'pm' },
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The "Add a job" field (G3, L2): typed text becomes a job the PM owns. Enter adds it. */
|
|
190
|
+
function jobAdd(ctx, g) {
|
|
191
|
+
if (typedOf(g).length >= MAX_JOBS) return el('p', { class: 'td-day__note' }, G.jobFull(MAX_JOBS));
|
|
192
|
+
const f = field({ label: G.addJob, value: '', placeholder: G.addJobHint, hideLabel: true, attrs: { 'data-goals': 'job-input', 'data-goal-job-input': g.id, maxlength: '140' },
|
|
193
|
+
onCommit: async (v) => {
|
|
194
|
+
const text = String(v).trim();
|
|
195
|
+
if (!text) { jobDrafts.set(g.id, ''); return; }
|
|
196
|
+
const key = g.id + '|' + text.toLowerCase();
|
|
197
|
+
if (lastAdded && lastAdded.key === key && Date.now() - lastAdded.at < 10000) { jobDrafts.set(g.id, ''); f.control.value = ''; ctx.rerender(); return; }
|
|
198
|
+
lastAdded = { key, at: Date.now() };
|
|
199
|
+
jobDrafts.set(g.id, '');
|
|
200
|
+
if (await press(ctx, { action: 'job-add', id: g.id, text })) { if (f.control.value.trim() === text) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; } ctx.rerender(); }
|
|
201
|
+
else { lastAdded = null; jobDrafts.set(g.id, text); ctx.rerender(); }
|
|
202
|
+
} });
|
|
203
|
+
f.control.value = jobDrafts.get(g.id) || '';
|
|
204
|
+
f.control.addEventListener('input', () => { jobDrafts.set(g.id, f.control.value); });
|
|
205
|
+
return f;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Link a job of the day that has no goal to this goal (G3, G4): the picker offers only jobs with no goal. */
|
|
209
|
+
function jobLink(ctx, g) {
|
|
210
|
+
const free = ctx.m.jobs.filter((j) => !j.goal);
|
|
211
|
+
if (!free.length) return null;
|
|
212
|
+
return linkField(free, (j) => ctx.mark(j.linkId, { action: 'link', goal: g.id }, D.linked(g.text)), { label: G.linkJob, prompt: G.linkJobPick, attrs: { 'data-goal-link': g.id } });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The jobs of a goal and what finished looks like. `n` is the goal's number. The goal's detail and a meeting's detail draw it. */
|
|
216
|
+
export function goalJobsBlock(ctx, g, n) {
|
|
217
|
+
const t = tally(ctx.m, g);
|
|
218
|
+
const open = g.status === 'open';
|
|
219
|
+
return el('div', { class: 'td-day__rel td-goal__jobs', 'data-goals': 'jobs', 'data-goal-jobs': g.id },
|
|
220
|
+
el('h4', null, n ? G.forGoal(n, g.text) : G.jobsTitle),
|
|
221
|
+
g.done_when ? el('p', { class: 'td-goal__donewhen', 'data-goal-donewhen': g.id }, el('b', null, G.doneWhen + ': '), g.done_when) : null,
|
|
222
|
+
t.total ? el('p', { class: 'td-day__note', 'data-goal-tally': g.id }, D.goalJobs(t.done, t.total)) : null,
|
|
223
|
+
t.total ? el('ul', { class: 'td-day__rows', 'data-goal-job-list': g.id }, ...t.js.map((j) => jobLine(ctx, j)), ...t.typed.map((j) => typedLine(ctx, g, j)))
|
|
224
|
+
: el('p', { class: 'td-day__note' }, G.noJobs),
|
|
225
|
+
open ? jobAdd(ctx, g) : null,
|
|
226
|
+
open ? jobLink(ctx, g) : null);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The context of a goal (G1): done when, anchor and due. The PM edits it while the goal is open; an empty value clears it. */
|
|
230
|
+
function contextFields(ctx, g) {
|
|
231
|
+
if (g.status !== 'open') {
|
|
232
|
+
const bits = [[G.doneWhen, g.done_when], [G.anchor, g.anchor], [G.due, g.due]].filter(([, v]) => v);
|
|
233
|
+
return bits.length ? el('dl', { class: 'td-day__ctx' }, ...bits.flatMap(([k, v]) => [el('dt', null, k), el('dd', null, v)])) : null;
|
|
234
|
+
}
|
|
235
|
+
const one = (key, label, hint, max) => {
|
|
236
|
+
const f = field({ label, value: g[key] || '', placeholder: hint, attrs: { maxlength: String(max), 'data-goals': 'ctx-' + key, 'data-goal-ctx': key },
|
|
237
|
+
onCommit: (v) => press(ctx, { action: 'context', id: g.id, [key]: String(v).trim() }) });
|
|
238
|
+
return f;
|
|
239
|
+
};
|
|
240
|
+
return el('div', { class: 'td-goal__ctxfields', 'data-goals': 'context-fields' }, one('done_when', G.doneWhen, G.doneWhenHint, 200), one('anchor', G.anchor, G.anchorHint, 80), one('due', G.due, G.dueHint, 40));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The end of the Goals section (G5): every job of the day with no goal, each with a picker of today's open goals. */
|
|
244
|
+
export function jobsWithoutGoal(ctx) {
|
|
245
|
+
const { m } = ctx;
|
|
246
|
+
const free = m.jobs.filter((j) => !j.goal);
|
|
247
|
+
if (!m.jobs.length) return null;
|
|
248
|
+
const hasGoals = m.allGoals.some((g) => g.status === 'open');
|
|
249
|
+
return el('div', { class: 'td-day__rel td-goal__nogoal', 'data-goals': 'no-goal' },
|
|
250
|
+
el('h4', null, G.noGoalTitle, free.length ? el('span', null, String(free.length)) : null),
|
|
251
|
+
free.length ? el('ul', { class: 'td-day__rows', 'data-day-list': 'no-goal' }, ...free.map((j) => {
|
|
252
|
+
const who = whoActs(j);
|
|
253
|
+
const pick = hasGoals ? goalPicker(m, null, (gid) => {
|
|
254
|
+
const g = gid ? m.allGoals.find((x) => x.id === gid) : null;
|
|
255
|
+
if (g) ctx.mark(j.linkId, { action: 'link', goal: g.id }, D.linked(g.text));
|
|
256
|
+
}, { label: G.linkLabel(j.title), none: G.linkTo, attrs: { 'data-nogoal-link': j.id } }) : null;
|
|
257
|
+
return el('li', null, row({
|
|
258
|
+
title: j.title, meta: [who.text, srcOf(j.source)], lead: [whoMark(who.who)], trail: pick ? [el('span', { class: 'td-goal__pick' }, pick)] : [], onOpen: () => ctx.select('job', j.id),
|
|
259
|
+
attrs: { class: 'td-day__r', 'data-nogoal-job': j.id, 'data-who': who.who },
|
|
260
|
+
}));
|
|
261
|
+
})) : el('p', { class: 'td-day__note' }, G.noGoalNone));
|
|
262
|
+
}
|
|
263
|
+
|
|
120
264
|
/** The detail of one goal. `row(type, id, label, lead, trail)` draws a selectable row (today-day.js). */
|
|
121
265
|
export function goalDetail(ctx, id, row) {
|
|
122
266
|
const { m } = ctx;
|
|
@@ -124,8 +268,7 @@ export function goalDetail(ctx, id, row) {
|
|
|
124
268
|
if (i < 0) return null;
|
|
125
269
|
const g = m.allGoals[i];
|
|
126
270
|
const closed = g.status !== 'open';
|
|
127
|
-
const
|
|
128
|
-
const meets = m.events.filter((e) => e.goal === g.id);
|
|
271
|
+
const meets = m.events.filter((e) => e.gid === g.id);
|
|
129
272
|
const ctrls = closed
|
|
130
273
|
? [button({ label: G.reopen, size: 'sm', attrs: { 'data-goal-act': 'reopen' }, onClick: () => press(ctx, { action: 'reopen', id: g.id }) })]
|
|
131
274
|
: [button({ label: G.done, size: 'sm', attrs: { 'data-goal-act': 'done' }, onClick: () => press(ctx, { action: 'done', id: g.id }) }),
|
|
@@ -137,12 +280,13 @@ export function goalDetail(ctx, id, row) {
|
|
|
137
280
|
el('div', { class: 'td-day__dhead' }, el('span', { class: 'td-day__kind' }, D.kind.goal(i + 1)), el('h3', null, g.text)),
|
|
138
281
|
el('div', { class: 'td-day__acts' }, badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'outline' }),
|
|
139
282
|
g.initiative_id ? el('span', { class: 'td-day__mono' }, g.initiative_id) : null),
|
|
283
|
+
contextFields(ctx, g),
|
|
284
|
+
closed ? null : el('p', { class: 'td-day__note', 'data-goals': 'close-note' }, G.closeNote),
|
|
140
285
|
ctx.repoPicker && m.repos.length ? el('div', { class: 'td-day__ch', 'data-goals': 'repo' }, el('span', null, D.repo.label),
|
|
141
286
|
ctx.repoPicker(m, g.repo, (r) => press(ctx, { action: 'tag', id: g.id, repo: r }), { 'data-goal-repo': g.id })) : null,
|
|
142
287
|
ctx.repoPicker && m.repos.length ? el('p', { class: 'td-day__note' }, D.repo.goalHint) : null,
|
|
143
|
-
js.length ? el('p', null, D.goalJobs(js.filter((j) => j.group === 'done').length, js.length)) : el('p', { class: 'td-day__note' }, G.noStep),
|
|
144
288
|
meets.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.meetings), el('ul', { class: 'td-day__rows' }, ...meets.map((e) => row('event', e.id, e.title, e.time)))) : null,
|
|
145
|
-
|
|
289
|
+
goalJobsBlock(ctx, g, null),
|
|
146
290
|
!closed && steps.length ? el('div', { class: 'td-day__rel', 'data-goals': 'steps' }, el('h4', null, D.stepsTitle), el('ul', { class: 'td-goal__steps' }, ...steps.map((st) => stepNode(ctx, st)))) : null,
|
|
147
291
|
el('div', { class: 'td-day__acts' }, ...ctrls),
|
|
148
292
|
];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* pm/today-jobs.js — what a job says about itself on Today (initiative today-goals-jobs, PRD J1 to J4).
|
|
2
|
+
*
|
|
3
|
+
* statusPill(j) → the status badge: To do, Needs you, Running, Done or Blocked
|
|
4
|
+
* whoActs(j) → { who: 'pm'|'agent', text }: who acts on the job's next press
|
|
5
|
+
* mark(who) → the square beside it: open for the PM, filled for the agent
|
|
6
|
+
* tag(who) → the small YOU or AGENT tag a press carries
|
|
7
|
+
* originOf(j) → 'goal'|'found'|'mention'|'you': where the job came from, derived from the data
|
|
8
|
+
* goalPicker(m, cur, ...) → a select: No goal, or one of today's open goals
|
|
9
|
+
* linkField(...) → a select of the jobs of the day that have no goal
|
|
10
|
+
*
|
|
11
|
+
* Pure drawing: no request is sent here. The presses are the day section's (today-day.js) and the goals module's.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { badge, field } from './primitives.js';
|
|
16
|
+
import { COPY } from './today-copy.js';
|
|
17
|
+
|
|
18
|
+
const D = COPY.day;
|
|
19
|
+
const TONE = { '': 'muted', ok: 'outline', info: 'outline', warn: 'warning', purple: 'warning', bad: 'destructive' };
|
|
20
|
+
|
|
21
|
+
/** The status of a job as a badge. The raw status stays in data-day-status for the suites. */
|
|
22
|
+
export function statusPill(j) {
|
|
23
|
+
const s = D.status2;
|
|
24
|
+
const map = {
|
|
25
|
+
notStarted: [s.notStarted, ''], waiting: [s.waiting, ''], working: [s.working, 'info'], sending: [s.sending, 'info'],
|
|
26
|
+
review: [j.kind === 'auto' ? s.reviewAuto : s.review, 'purple'], sent: [s.sent, 'ok'], done: [s.done, 'ok'],
|
|
27
|
+
failed: [s.failed, 'bad'], you: [s.you, 'warn'],
|
|
28
|
+
};
|
|
29
|
+
const [t, tone] = map[j.status] || [j.status, ''];
|
|
30
|
+
return badge({ text: t, tone: TONE[tone] || 'muted', attrs: { 'data-day-status': j.status } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Who acts on the next press. A draft to approve, a failed job and the PM's own job wait on the PM. */
|
|
34
|
+
export function whoActs(j) {
|
|
35
|
+
const W = D.who2;
|
|
36
|
+
if (j.typed) return j.done ? { who: 'pm', text: W.youDone } : { who: 'pm', text: W.you };
|
|
37
|
+
if (j.status === 'you') return j.group === 'done' ? { who: 'pm', text: W.youDone } : { who: 'pm', text: W.you };
|
|
38
|
+
if (j.status === 'working' || j.status === 'sending') return { who: 'agent', text: W.working };
|
|
39
|
+
if (j.status === 'done' || j.status === 'sent') return { who: 'agent', text: W.finished };
|
|
40
|
+
if (j.status === 'notStarted' || j.status === 'waiting') return { who: 'agent', text: W.queued };
|
|
41
|
+
return { who: 'pm', text: W.you }; // review and failed
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The square: filled for the agent, open for the PM. It is a picture; the words beside it say the same. */
|
|
45
|
+
export function mark(who) {
|
|
46
|
+
return el('span', { class: 'td-day__sq', 'data-who': who, 'aria-hidden': 'true' });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The small tag on a press: YOU when the PM does it, AGENT when the press starts the agent. */
|
|
50
|
+
export function tag(who) {
|
|
51
|
+
return el('span', { class: 'td-day__tag', 'data-who': who, 'data-day': 'tag' }, who === 'agent' ? D.tag.agent : D.tag.you);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A button label with its tag: [text, tag]. */
|
|
55
|
+
export const tagged = (text, who) => [text, ' ', tag(who)];
|
|
56
|
+
|
|
57
|
+
const MEETING_SOURCES = ['calendar', 'wispr'];
|
|
58
|
+
/** Where a job came from, from the data and never from a field the PM edits. */
|
|
59
|
+
export function originOf(j) {
|
|
60
|
+
if (j.typed) return 'you';
|
|
61
|
+
if (j.item && j.item.mention) return 'mention';
|
|
62
|
+
if (j.item && (MEETING_SOURCES.includes(j.item.source) || j.item.verb === 'prepare')) return 'found';
|
|
63
|
+
if (!j.item) return 'you'; // a briefed task with no item: the PM gave it to the agent by hand
|
|
64
|
+
return j.goal ? 'goal' : 'found';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A select of today's open goals with No goal first. `onPick(goalId|null)`. */
|
|
68
|
+
export function goalPicker(m, cur, onPick, { label, none = D.goalCtl.none, attrs } = {}) {
|
|
69
|
+
const open = m.allGoals.filter((g) => g.status === 'open');
|
|
70
|
+
const f = field({ label: label || D.goalCtl.label, kind: 'select', value: cur || '', hideLabel: true,
|
|
71
|
+
options: [{ value: '', label: none }, ...open.map((g) => ({ value: g.id, label: `${m.goalNo.get(g.id)} · ${g.text}` })),
|
|
72
|
+
// a goal that is closed stays shown while the job is under it, so the picker never hides what is stored
|
|
73
|
+
...(cur && !open.some((g) => g.id === cur) ? [{ value: cur, label: (m.allGoals.find((g) => g.id === cur) || { text: cur }).text }] : [])],
|
|
74
|
+
attrs, onCommit: (v) => onPick(v || null) });
|
|
75
|
+
return f;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A select of jobs with no goal: the first option is the prompt. `onPick(job)`. */
|
|
79
|
+
export function linkField(jobs, onPick, { label, prompt, attrs } = {}) {
|
|
80
|
+
const f = field({ label, kind: 'select', value: '', hideLabel: true, attrs,
|
|
81
|
+
options: [{ value: '', label: prompt }, ...jobs.map((j) => ({ value: j.linkId, label: j.title }))],
|
|
82
|
+
onCommit: (v) => { const j = jobs.find((x) => x.linkId === v); if (j) onPick(j); } });
|
|
83
|
+
return f;
|
|
84
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* pm/today-mentions.js — a mention in the look of its source (initiative today-goals-jobs, PRD M1 to M3).
|
|
2
|
+
*
|
|
3
|
+
* hasCard(source) → true for Slack, Confluence and Figma: the sources that draw as a card
|
|
4
|
+
* mentionCard(j) → the card of a mention job: the source line, the author, the time, the text, and, only when the
|
|
5
|
+
* brief item carries them, the quoted passage (Confluence) or the frame (Figma)
|
|
6
|
+
*
|
|
7
|
+
* Pure drawing from the brief item's own fields (who, about, ask, where, quote, thread). A field the item lacks is left
|
|
8
|
+
* out, never made up. No press lives here: the card sits inside the row's open button or in the detail, and the card
|
|
9
|
+
* links to the conversation. Nothing here posts to, resolves in or reacts in a service.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { el } from './dom.js';
|
|
13
|
+
import { icon } from './icons.js';
|
|
14
|
+
import { COPY } from './today-copy.js';
|
|
15
|
+
|
|
16
|
+
const D = COPY.day;
|
|
17
|
+
const ICON = { slack: 'message-square', confluence: 'file-text', figma: 'diamond' };
|
|
18
|
+
|
|
19
|
+
export const hasCard = (source) => Object.prototype.hasOwnProperty.call(ICON, source);
|
|
20
|
+
|
|
21
|
+
const initials = (who) => String(who || '').replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('');
|
|
22
|
+
|
|
23
|
+
/** The time of the author's last message in the thread the item carries; none when it carries none. */
|
|
24
|
+
function timeOf(it) {
|
|
25
|
+
const mine = (Array.isArray(it.thread) ? it.thread : []).filter((m) => m && m.at && (!it.who || m.who === it.who));
|
|
26
|
+
return mine.length ? mine[mine.length - 1].at : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The card of one mention. `name` is the display name of the source (Slack, Confluence, Figma). */
|
|
30
|
+
export function mentionCard(j, name) {
|
|
31
|
+
const it = j.item || {};
|
|
32
|
+
const svc = j.source;
|
|
33
|
+
const ini = initials(it.who);
|
|
34
|
+
const words = [it.about, it.ask].filter(Boolean);
|
|
35
|
+
const when = timeOf(it);
|
|
36
|
+
const quote = svc === 'confluence' && typeof it.quote === 'string' && it.quote.trim() ? it.quote.trim() : null;
|
|
37
|
+
const frame = svc === 'figma'
|
|
38
|
+
? el('span', { class: 'td-day__mc-frame', 'data-mc': 'frame', 'aria-hidden': 'true' }, icon('circle-dot')) : null;
|
|
39
|
+
const avatar = svc !== 'figma' && ini ? el('span', { class: 'td-day__av', 'data-mc': 'avatar', 'aria-hidden': 'true' }, ini) : null;
|
|
40
|
+
return el('span', { class: 'td-day__mc', 'data-mention-card': svc },
|
|
41
|
+
el('span', { class: 'td-day__mc-head', 'data-mc': 'source' }, icon(ICON[svc]), D.mc.via(name, it.where)),
|
|
42
|
+
quote ? el('span', { class: 'td-day__mc-quote', 'data-mc': 'quote' }, quote) : null,
|
|
43
|
+
el('span', { class: 'td-day__mc-main' },
|
|
44
|
+
frame || avatar,
|
|
45
|
+
el('span', { class: 'td-day__mc-col' },
|
|
46
|
+
it.who || when ? el('span', { class: 'td-day__mc-line' },
|
|
47
|
+
it.who ? el('strong', { class: 'td-day__mc-name', 'data-mc': 'who' }, it.who) : null,
|
|
48
|
+
when ? el('span', { class: 'td-day__mc-time', 'data-mc': 'time' }, when) : null) : null,
|
|
49
|
+
...words.map((w) => el('span', { class: 'td-day__mc-text', 'data-mc': 'text' }, w)))));
|
|
50
|
+
}
|
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { el, setChildren } from './dom.js';
|
|
22
|
-
import { button, needsDot, needsCount } from './primitives.js';
|
|
22
|
+
import { button, iconButton, menu, needsDot, needsCount } from './primitives.js';
|
|
23
23
|
import { COPY } from './frame-copy.js';
|
|
24
24
|
import * as dock from './note-dock.js';
|
|
25
|
-
import { iconButton } from './primitives.js';
|
|
26
25
|
import { el as h } from './dom.js';
|
|
26
|
+
import { upcomingToday, upcomingWithoutNote } from './notes-links.js';
|
|
27
|
+
import { contextOf, peopleOf } from './notes-context.js';
|
|
27
28
|
|
|
28
29
|
/** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
|
|
29
30
|
* PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"). People joined after Docs when the
|
|
@@ -57,8 +58,9 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
|
|
|
57
58
|
const dot = needsDot();
|
|
58
59
|
const links = {};
|
|
59
60
|
for (const id of [...BAR_DESTS, 'settings']) {
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// icon only (meeting-notes Run 13): the place's name is its tooltip and its aria-label
|
|
62
|
+
links[id] = iconButton({
|
|
63
|
+
icon: NAV_ICON[id], ariaLabel: COPY.nav[id],
|
|
62
64
|
attrs: { class: 'fr-link', 'data-fr-dest': id, 'aria-current': 'false' },
|
|
63
65
|
onClick: () => go && go(id),
|
|
64
66
|
});
|
|
@@ -80,42 +82,80 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
|
|
|
80
82
|
}
|
|
81
83
|
const nav = el('nav', { class: 'fr-nav', 'aria-label': COPY.nav.label }, BAR_DESTS.map((id) => links[id]));
|
|
82
84
|
|
|
83
|
-
const search =
|
|
84
|
-
|
|
85
|
+
const search = iconButton({
|
|
86
|
+
icon: 'search', ariaLabel: COPY.search + ' (' + COPY.searchKey + ')',
|
|
85
87
|
attrs: { class: 'fr-search', 'data-fr': 'search', 'aria-keyshortcuts': 'Meta+K Control+K' },
|
|
86
88
|
onClick: () => openPalette && openPalette(),
|
|
87
89
|
});
|
|
88
|
-
search.appendChild(el('kbd', { class: 'fr-kbd' }, COPY.searchKey));
|
|
89
90
|
|
|
90
|
-
/* The Record control (meeting-notes Run
|
|
91
|
-
*
|
|
91
|
+
/* The Record control (meeting-notes Run 13, prototype/meeting-notes/record-control-mockup.html). Two groups, always in the same
|
|
92
|
+
* place. Left, only while a meeting is recording or paused: the chip that names it, then Highlight, Pause and Stop (recording)
|
|
93
|
+
* or Resume and Stop (paused). Nothing to resume: no left group. Right, always: New (an untitled note) and an arrow that lists
|
|
94
|
+
* today's meetings that have not started; both are unavailable while a meeting is recording. The highlight's pop-up hangs
|
|
95
|
+
* under the control. */
|
|
92
96
|
const recSlot = el('div', { class: 'fr-rec', 'data-fr': 'record' });
|
|
93
97
|
let recKey = '';
|
|
94
98
|
let recTick = null;
|
|
95
|
-
const
|
|
99
|
+
const left = (until) => Math.max(1, Math.ceil((until - Date.now()) / 60000));
|
|
100
|
+
const chipText = (st) => (st.mode === 'recording' ? (st.started ? fmtClock(Date.now() - st.started) : '') : st.until ? 'paused · ' + left(st.until) + ' min left' : 'paused');
|
|
101
|
+
const tip = (b, text) => { b.title = text; b.setAttribute('aria-label', text); return b; };
|
|
102
|
+
const off = (b, why) => { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-rec__off'); tip(b, why); return b; };
|
|
103
|
+
const chipOf = (st) => {
|
|
104
|
+
const clock = h('span', { class: 'fr-rec__clock', 'data-fr-rec-clock': '' }, chipText(st));
|
|
105
|
+
const dotEl = h('span', { class: 'fr-rec-dot' + (st.mode === 'paused' ? ' fr-rec-dot--paused' : ''), 'aria-hidden': 'true' });
|
|
106
|
+
const b = button({ label: h('span', { class: 'fr-rec__txt' }, dotEl, h('b', { class: 'fr-rec__title' }, st.title), clock), variant: 'ghost',
|
|
107
|
+
attrs: { class: 'fr-rec__seg fr-rec__chip', 'data-fr-rec': st.mode, 'aria-label': (st.mode === 'recording' ? 'Recording ' : 'Paused: ') + st.title + '. Open the note.' }, onClick: () => dock.openDock(st.id) });
|
|
108
|
+
return b;
|
|
109
|
+
};
|
|
96
110
|
function drawRecord() {
|
|
97
111
|
const st = dock.recordState();
|
|
98
|
-
const
|
|
99
|
-
const key = st.mode
|
|
112
|
+
const hl = st.mode === 'recording' ? dock.highlightBusy() : null;
|
|
113
|
+
const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : ''].join(':');
|
|
100
114
|
if (key === recKey) return;
|
|
101
115
|
recKey = key;
|
|
102
116
|
clearInterval(recTick); recTick = null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
const kids = [];
|
|
118
|
+
const recording = st.mode === 'recording';
|
|
119
|
+
if (st.mode !== 'idle') {
|
|
120
|
+
const chip = chipOf(st);
|
|
121
|
+
const group = [chip];
|
|
122
|
+
if (recording) {
|
|
123
|
+
const hlb = iconButton({ icon: 'highlighter', ariaLabel: hl ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next',
|
|
124
|
+
attrs: { class: 'fr-rec__seg' + (hl ? ' fr-rec__seg--busy' : ''), 'data-fr-rec-highlight': hl ? hl.state : '' }, onClick: () => dock.highlightNow() });
|
|
125
|
+
group.push(hlb, iconButton({ icon: 'pause', ariaLabel: 'Pause (you can resume within 1 hour)', attrs: { class: 'fr-rec__seg', 'data-fr-rec-pause': '' }, onClick: () => dock.pauseNow() }));
|
|
126
|
+
} else {
|
|
127
|
+
group.push(iconButton({ icon: 'play', ariaLabel: 'Resume ' + st.title, attrs: { class: 'fr-rec__seg fr-rec__resume', 'data-fr-rec-resume': '' }, onClick: () => dock.resumeNow() }));
|
|
128
|
+
}
|
|
129
|
+
group.push(iconButton({ icon: 'square', ariaLabel: 'Stop for good (cannot be resumed)', attrs: { class: 'fr-rec__seg fr-rec__stop', 'data-fr-rec-stop': '' }, onClick: () => dock.stopNow() }));
|
|
130
|
+
kids.push(h('div', { class: 'fr-rec__group', role: 'group', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + st.title }, group));
|
|
131
|
+
const clockNode = chip.querySelector('[data-fr-rec-clock]');
|
|
132
|
+
recTick = setInterval(() => { const s2 = dock.recordState(); if (clockNode) clockNode.textContent = chipText(s2); }, 1000);
|
|
110
133
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
134
|
+
const why = 'Pause or stop the meeting being recorded to start a new one';
|
|
135
|
+
const mic = iconButton({ icon: 'mic', ariaLabel: 'Record an untitled note', attrs: { class: 'fr-rec__seg fr-rec__new', 'data-fr-rec-new': '' }, onClick: (e) => { if (recording) return; dock.recordNew(e && e.currentTarget); } });
|
|
136
|
+
const arrow = iconButton({ icon: 'chevron-down', ariaLabel: 'Record for an upcoming meeting', size: 'sm', attrs: { class: 'fr-rec__seg fr-rec__upcoming', 'data-fr-rec-upcoming': '' },
|
|
137
|
+
onClick: (e) => { if (!recording) openUpcoming(e && e.currentTarget); } });
|
|
138
|
+
if (recording) { off(mic, why); off(arrow, why); }
|
|
139
|
+
kids.push(h('div', { class: 'fr-rec__group fr-rec__group--new', role: 'group', 'aria-label': 'New recording' }, mic, arrow));
|
|
140
|
+
const panel = recording ? dock.highlightPanel() : null;
|
|
141
|
+
if (panel) kids.push(panel);
|
|
142
|
+
setChildren(recSlot, kids);
|
|
143
|
+
}
|
|
144
|
+
/** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
|
|
145
|
+
async function openUpcoming(anchor) {
|
|
146
|
+
const evs = upcomingWithoutNote(await upcomingToday());
|
|
147
|
+
const fmt = (e, i) => {
|
|
148
|
+
const c = contextOf(e) || {};
|
|
149
|
+
const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
|
|
150
|
+
const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
|
|
151
|
+
const who = peopleOf(c); const extra = c.attendees ? ' (' + c.attendees + ' attendees)' : '';
|
|
152
|
+
const bits = [i === 0 ? 'Next' : null, inText, who.length ? who.slice(0, 2).join(', ') + (who.length > 2 ? ' +' + (who.length - 2) : '') : c.attendees ? c.attendees + ' attendees' : null, c.agenda && c.agenda.length ? c.agenda.length + ' agenda item' + (c.agenda.length === 1 ? '' : 's') : null].filter(Boolean);
|
|
153
|
+
void extra;
|
|
154
|
+
return { label: e.title, hint: e.time || '', detail: bits.join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
|
|
155
|
+
};
|
|
156
|
+
const items = [{ label: 'Untitled note', icon: 'plus', detail: 'not linked to a meeting', attrs: { 'data-fr-rec-untitled': '' }, onSelect: () => dock.recordNew() }, { sep: true },
|
|
157
|
+
...(evs.length ? evs.map(fmt) : [{ label: 'No more meetings today', attrs: { 'data-fr-rec-none': '' }, onSelect: () => {} }])];
|
|
158
|
+
menu({ anchor, items, ariaLabel: 'Record a new meeting' });
|
|
119
159
|
}
|
|
120
160
|
|
|
121
161
|
let syncBtn = null;
|
|
@@ -130,11 +170,10 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
|
|
|
130
170
|
return b;
|
|
131
171
|
}
|
|
132
172
|
function syncButton(label, busy) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
173
|
+
const b = iconButton({ icon: 'refresh-cw', ariaLabel: busy ? label : COPY.sync.title || label,
|
|
174
|
+
attrs: { class: 'fr-sync', 'data-fr': 'sync', 'data-fr-sync-label': label, 'aria-busy': busy ? 'true' : 'false' }, onClick: () => { if (!busy && sync) sync(); } });
|
|
175
|
+
if (busy) { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-sync--busy'); }
|
|
176
|
+
return b;
|
|
138
177
|
}
|
|
139
178
|
|
|
140
179
|
function update(m) {
|