create-pmos 0.129.0 → 0.131.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/c3-one-home-test.js +8 -3
- package/workspace/c3-work-test.js +200 -0
- package/workspace/fixtures/one-home-baseline.json +0 -91
- package/workspace/guide/briefing.md +60 -0
- package/workspace/guide/calendar-popup.md +46 -0
- package/workspace/guide/console-calendar.md +65 -0
- package/workspace/guide/index.md +4 -0
- package/workspace/guide/layout.md +1 -0
- package/workspace/guide/my-work.md +76 -1
- package/workspace/lib/calendar-layers.js +372 -0
- package/workspace/lib/pm-items.js +71 -6
- package/workspace/lib/report-needs.js +1 -1
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-brief-page.js +187 -0
- package/workspace/lib/routes-calendar-views.js +112 -3
- package/workspace/lib/routes-pm-items.js +123 -13
- package/workspace/lib/work-focus.js +106 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-brief-data.js +11 -0
- package/workspace/public/pm/c3-brief-needs.js +145 -0
- package/workspace/public/pm/c3-brief-pulse.js +70 -0
- package/workspace/public/pm/c3-brief-today.js +44 -0
- package/workspace/public/pm/c3-calendar.js +392 -0
- package/workspace/public/pm/c3-calpop.js +103 -0
- package/workspace/public/pm/c3-demo-brief-page.js +54 -0
- package/workspace/public/pm/c3-demo-cal4.js +114 -0
- package/workspace/public/pm/c3-demo-data.js +79 -77
- package/workspace/public/pm/c3-demo-misc2.js +4 -16
- package/workspace/public/pm/c3-demo-work-ix.js +19 -67
- package/workspace/public/pm/c3-demo.js +11 -4
- package/workspace/public/pm/c3-shell.js +27 -149
- package/workspace/public/pm/calendar-views.js +11 -3
- package/workspace/public/pm/components.css +1 -0
- package/workspace/public/pm/console3.css +198 -0
- package/workspace/public/pm/pm-work-view.js +364 -172
- package/workspace/public/pm/work-place.js +58 -0
- package/workspace/test-files.json +7 -1
- package/workspace/test-lists/calendar-layers.json +5 -0
- package/workspace/test-lists/calendar-time-page.json +5 -0
- package/workspace/test-lists/calpop.json +4 -0
- package/workspace/test-lists/console3-work-page.json +9 -0
- package/workspace/public/pm/c3-demo-work2.js +0 -13
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-brief-page.js — GET /api/brief-page[?ws=<id>&now=ISO] (console3 Briefing as the brief, PRD briefing-brief R2, R3, R8).
|
|
3
|
+
*
|
|
4
|
+
* → { ok, date, today: [meeting], needs: [row], decisions_error? }
|
|
5
|
+
*
|
|
6
|
+
* READ ONLY, and built only from what PMOS already holds. Nothing is invented.
|
|
7
|
+
* today one row per meeting of the local day, in time order, from the cached calendar events (routes-calendar-import.js readEvents,
|
|
8
|
+
* the same source as /api/calendar-views): { id, title, start, end, minutes, people, initiative, prepared, before, state }.
|
|
9
|
+
* prepared: [{ kind: 'prep'|'pm-note'|'note'|'last-note', text, id? }] what PMOS holds for the meeting (the agent's prep note,
|
|
10
|
+
* the PM's notes on the event, the note linked to it, the last note of an earlier occurrence). before: [{ id, title, due,
|
|
11
|
+
* done }] the PM's own open items that name the meeting (item.meeting_id or item.event_id equals the event id, or the title
|
|
12
|
+
* contains the meeting's title). state: 'past' | 'now' | 'next' | 'later'.
|
|
13
|
+
* needs decisions only (with ?decisions=1; the page takes them from GET /api/briefing instead) the PM can make (the signal engine's cards that carry options or are Critical), the PM's own items that are
|
|
14
|
+
* overdue or due today, and outside-PMOS items (kind 'offline') that await confirmation. Overdue rows come first, the most
|
|
15
|
+
* late first, then the items due today, then the offline ones, then the decisions.
|
|
16
|
+
* An item row: { key, kind: 'overdue'|'due-today'|'offline', id, headline, due, days_late, priority, person, offline, evidence }.
|
|
17
|
+
* evidence is { text, event_id } when the calendar holds a meeting with the person, else null: the page asks the PM to
|
|
18
|
+
* confirm it and never marks the item done itself.
|
|
19
|
+
* A decision row: { key, kind: 'decision', headline, tag, card }.
|
|
20
|
+
* Item fields read defensively (a missing field is absent): kind, defer, person, evidence, workPlacement(item, today) when lib/pm-items.js
|
|
21
|
+
* has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes. */
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const pmItems = require('./pm-items.js');
|
|
24
|
+
|
|
25
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
26
|
+
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
27
|
+
const DAY = 86400000;
|
|
28
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
29
|
+
const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
30
|
+
const hhmm = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
31
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
32
|
+
|
|
33
|
+
function daysBetween(fromIso, toIso) {
|
|
34
|
+
const a = Date.parse(fromIso + 'T00:00:00Z'); const b = Date.parse(toIso + 'T00:00:00Z');
|
|
35
|
+
return Math.round((b - a) / DAY);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function deferred(item, now, todayIso) {
|
|
39
|
+
const d = item.defer;
|
|
40
|
+
if (!d) return false;
|
|
41
|
+
const s = String(d);
|
|
42
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s > todayIso;
|
|
43
|
+
const t = Date.parse(s);
|
|
44
|
+
return Number.isFinite(t) && t > now.getTime();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The meetings of the local day. `notes` is an optional { findByEvent(ev), lastBefore(ev) } so the route can read the notes store. */
|
|
48
|
+
function todayAgenda(events, items, now, notes = {}) {
|
|
49
|
+
const todayIso = localDate(now);
|
|
50
|
+
const open = items.filter((i) => !i.done);
|
|
51
|
+
const rows = (events || []).filter((e) => e && e.at && !Number.isNaN(Date.parse(e.at)) && localDate(new Date(e.at)) === todayIso)
|
|
52
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || String(a.id).localeCompare(String(b.id)))
|
|
53
|
+
.map((e) => {
|
|
54
|
+
const s = new Date(e.at); const endMs = e.end && Date.parse(e.end) > Date.parse(e.at) ? Date.parse(e.end) : Date.parse(e.at);
|
|
55
|
+
const prepared = [];
|
|
56
|
+
if (e.agent_prep_notes && String(e.agent_prep_notes).trim()) prepared.push({ kind: 'prep', text: clip(e.agent_prep_notes, 200) });
|
|
57
|
+
const pm = Array.isArray(e.pm_notes) ? e.pm_notes : [];
|
|
58
|
+
if (pm.length) prepared.push({ kind: 'pm-note', text: clip(pm[pm.length - 1].text, 200) });
|
|
59
|
+
const linked = notes.findByEvent ? notes.findByEvent(e) : null;
|
|
60
|
+
if (linked) prepared.push({ kind: 'note', text: 'Notes started: ' + clip(linked.title, 80), id: linked.id });
|
|
61
|
+
const last = notes.lastBefore ? notes.lastBefore(e) : null;
|
|
62
|
+
if (last) prepared.push({ kind: 'last-note', text: 'Last notes: ' + clip(last.title, 80) + ' (' + String(last.updated_at || last.created_at || '').slice(0, 10) + ')', id: last.id });
|
|
63
|
+
const t = norm(e.title);
|
|
64
|
+
const before = open.filter((i) => (i.meeting_id && i.meeting_id === e.id) || (i.event_id && i.event_id === e.id) || (t.length >= 6 && norm(i.title).includes(t)))
|
|
65
|
+
.map((i) => ({ id: i.id, title: i.title, due: i.due || null, done: !!i.done }));
|
|
66
|
+
return { id: e.id, title: e.title, start: s.toISOString(), end: new Date(endMs).toISOString(), startLocal: hhmm(s), minutes: Math.max(0, Math.round((endMs - s.getTime()) / 60000)),
|
|
67
|
+
people: Array.isArray(e.attendees) ? e.attendees.slice(0, 12) : [], initiative: e.initiative || null, prepared, before, _s: s.getTime(), _e: endMs };
|
|
68
|
+
});
|
|
69
|
+
let nextDone = false;
|
|
70
|
+
for (const r of rows) {
|
|
71
|
+
const nowMs = now.getTime();
|
|
72
|
+
if (r._e <= nowMs && r._e > r._s) r.state = 'past';
|
|
73
|
+
else if (r._s <= nowMs && r._e > nowMs) r.state = 'now';
|
|
74
|
+
else if (r._e === r._s && r._s < nowMs) r.state = 'past';
|
|
75
|
+
else if (!nextDone) { r.state = 'next'; nextDone = true; } else r.state = 'later';
|
|
76
|
+
}
|
|
77
|
+
rows.forEach((r) => { delete r._s; delete r._e; });
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A calendar meeting that names the person, from today on: "a meeting with Ana is booked Tue 10:00". */
|
|
82
|
+
function evidenceFor(item, events, now) {
|
|
83
|
+
if (item.evidence) {
|
|
84
|
+
const t = Array.isArray(item.evidence) ? item.evidence[0] : item.evidence;
|
|
85
|
+
const text = typeof t === 'string' ? t : t && t.text;
|
|
86
|
+
if (text) return { text: clip(text, 160), event_id: (t && t.event_id) || null };
|
|
87
|
+
}
|
|
88
|
+
const names = [];
|
|
89
|
+
if (item.person) names.push(norm(item.person));
|
|
90
|
+
const title = norm(item.title);
|
|
91
|
+
const hits = (events || []).filter((e) => e && e.at && Date.parse(e.at) >= now.getTime() - 3600000 && Date.parse(e.at) <= now.getTime() + 21 * DAY)
|
|
92
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
93
|
+
for (const e of hits) {
|
|
94
|
+
const who = (Array.isArray(e.attendees) ? e.attendees : []).map(norm).filter(Boolean);
|
|
95
|
+
const hit = who.find((w) => names.some((n) => n && (w.includes(n) || n.includes(w))) || (w.split(' ')[0].length >= 3 && new RegExp('\\b' + w.split(' ')[0].replace(/[^a-z0-9]/g, '') + '\\b').test(title)));
|
|
96
|
+
if (hit) {
|
|
97
|
+
const d = new Date(e.at);
|
|
98
|
+
const who1 = hit.split(' ')[0]; const nice = who1.charAt(0).toUpperCase() + who1.slice(1);
|
|
99
|
+
return { text: 'a meeting with ' + nice + ' is booked ' + WD[d.getDay()] + ' ' + hhmm(d), event_id: e.id };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function needsOf(items, events, cards, now, deps = {}) {
|
|
106
|
+
const todayIso = localDate(now);
|
|
107
|
+
const todayDate = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
108
|
+
const work = typeof pmItems.workPlacement === 'function' ? pmItems.workPlacement : null;
|
|
109
|
+
const late = []; const dueToday = []; const offline = [];
|
|
110
|
+
for (const i of items) {
|
|
111
|
+
if (!i || i.done || deferred(i, now, todayIso)) continue;
|
|
112
|
+
const base = { id: i.id, headline: i.title, due: i.due || null, priority: i.priority || null, person: i.person || null };
|
|
113
|
+
if (i.kind === 'offline') {
|
|
114
|
+
const late0 = i.due && i.due < todayIso ? daysBetween(i.due, todayIso) : 0;
|
|
115
|
+
offline.push({ key: 'item:' + i.id, kind: 'offline', ...base, days_late: late0, offline: true, evidence: evidenceFor(i, events, now) });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!i.due || i.due > todayIso) continue;
|
|
119
|
+
if (work) { if (work(i, todayDate) !== 'today') continue; } else if (i.waiting_on || i.parked || i.list_override === 'later') continue;
|
|
120
|
+
if (i.due < todayIso) late.push({ key: 'item:' + i.id, kind: 'overdue', ...base, days_late: daysBetween(i.due, todayIso), offline: false, evidence: null });
|
|
121
|
+
else dueToday.push({ key: 'item:' + i.id, kind: 'due-today', ...base, days_late: 0, offline: false, evidence: null });
|
|
122
|
+
}
|
|
123
|
+
late.sort((a, b) => b.days_late - a.days_late || String(a.headline).localeCompare(String(b.headline)));
|
|
124
|
+
offline.sort((a, b) => b.days_late - a.days_late || String(a.headline).localeCompare(String(b.headline)));
|
|
125
|
+
const byTitle = (a, b) => String(a.headline).localeCompare(String(b.headline));
|
|
126
|
+
dueToday.sort(byTitle);
|
|
127
|
+
const decisions = (cards || []).filter((c) => c && (c.move || c.tag === 'Critical')).map((c) => ({ key: 'card:' + c.key, kind: 'decision', headline: c.headline, tag: c.tag, card: c }));
|
|
128
|
+
return [...late, ...dueToday, ...offline, ...decisions];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The pure builder. deps: { events, items (folded), cards, notes } so a test needs no repository. */
|
|
132
|
+
function build(query, deps = {}) {
|
|
133
|
+
const q = query || {};
|
|
134
|
+
let now = q.now ? new Date(q.now) : (deps.now ? new Date(deps.now) : new Date());
|
|
135
|
+
if (Number.isNaN(now.getTime())) now = new Date();
|
|
136
|
+
const events = deps.events || []; const items = deps.items || [];
|
|
137
|
+
const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps) };
|
|
138
|
+
if (deps.decisionsError) body.decisions_error = deps.decisionsError;
|
|
139
|
+
return { status: 200, body };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function notesAdapter(runtimeDir) {
|
|
143
|
+
let notes; try { notes = require('./notes.js'); } catch { return {}; }
|
|
144
|
+
const rows = () => { try { const l = notes.list(runtimeDir); return l && l.ok ? l.notes.filter((n) => !n.private) : []; } catch { return []; } };
|
|
145
|
+
return {
|
|
146
|
+
findByEvent: (e) => { try { const n = notes.findByEvent(runtimeDir, { event_id: e.id, start: e.at, title: e.title }); return n && !n.private ? { id: n.id, title: n.title || 'Untitled note' } : null; } catch { return null; } },
|
|
147
|
+
lastBefore: (e) => {
|
|
148
|
+
const t = norm(e.title);
|
|
149
|
+
const hits = rows().filter((n) => n.meeting && n.meeting.kind === 'event' && norm(n.meeting.title) === t && Date.parse(n.meeting.start) < Date.parse(e.at))
|
|
150
|
+
.sort((a, b) => String(b.meeting.start).localeCompare(String(a.meeting.start)));
|
|
151
|
+
return hits[0] || null;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
prefixes: ['/api/brief-page'],
|
|
158
|
+
build, todayAgenda, needsOf, evidenceFor, deferred,
|
|
159
|
+
handle: async (req, res, url, ctx) => {
|
|
160
|
+
if (url.pathname !== '/api/brief-page') return false;
|
|
161
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
162
|
+
const q = Object.fromEntries(url.searchParams.entries());
|
|
163
|
+
let now = q.now ? new Date(q.now) : new Date();
|
|
164
|
+
if (Number.isNaN(now.getTime())) now = new Date();
|
|
165
|
+
try {
|
|
166
|
+
const dir = ctx.runtimeDir;
|
|
167
|
+
const events = dir ? require('./routes-calendar-import.js').readEvents(dir) : [];
|
|
168
|
+
const items = dir ? pmItems.fold(pmItems.readLines(path.join(dir, 'pm-items', 'items.jsonl'))) : [];
|
|
169
|
+
// The decisions come from the signal engine, whose repository read is slow. The page already holds them (GET /api/briefing feeds its deck), so the
|
|
170
|
+
// read is made only when the caller asks (?decisions=1); the items and the agenda never wait for it.
|
|
171
|
+
let cards = []; let decisionsError = null;
|
|
172
|
+
if (q.decisions === '1') try {
|
|
173
|
+
const w = ctx.getWs && ctx.getWs(q.ws || null);
|
|
174
|
+
const root = (w && w.root) || path.resolve(__dirname, '..', '..');
|
|
175
|
+
const iso = localDate(now);
|
|
176
|
+
const facts = await require('./facts-async.js').readFactsAsync(root, { today: iso });
|
|
177
|
+
const sig = path.resolve(__dirname, '..', '..', 'scripts', 'signals');
|
|
178
|
+
cards = require(path.join(sig, 'index.js')).present(facts, { lens: 'Unblock', zoom: 'week', today: iso }).cards || [];
|
|
179
|
+
} catch (e) { decisionsError = 'the decisions could not be read: ' + (e && e.message ? e.message : String(e)); }
|
|
180
|
+
const out = build({ now: now.toISOString() }, { events, items, cards, decisionsError, notes: dir ? notesAdapter(dir) : {} });
|
|
181
|
+
ctx.json(res, out.body, out.status);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
ctx.json(res, { ok: false, error: 'the brief page could not be built: ' + (e && e.message ? e.message : String(e)) }, 500);
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/* routes-calendar-views.js — GET /api/calendar-views?view=day|week|month|quarter|year&date=YYYY-MM-DD&ws=<id> (console3 wave C2).
|
|
3
3
|
*
|
|
4
|
-
* → { ok, view, range, cells, unit, gantt, strategy, events_source
|
|
4
|
+
* → { ok, view, range, cells, unit, gantt, strategy, events_source, today,
|
|
5
|
+
* layers, layers_total, layers_note, free, context, context_total } (the last six: lib/calendar-layers.js)
|
|
5
6
|
*
|
|
6
7
|
* READ ONLY. Initiatives come from the signal engine's repo reader (scripts/signals/facts.js readRepoFacts), required inside
|
|
7
8
|
* the handler so a failure answers 500 { ok:false } and never reaches the server. The date and the plan come from the records:
|
|
@@ -13,10 +14,12 @@
|
|
|
13
14
|
* gets none and says so. */
|
|
14
15
|
const path = require('path');
|
|
15
16
|
const model = require('../../scripts/calendar-model.js');
|
|
17
|
+
const layersLib = require('./calendar-layers.js');
|
|
16
18
|
|
|
17
19
|
const today = () => { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; };
|
|
18
20
|
|
|
19
|
-
/** The pure handler. deps: { readFacts(
|
|
21
|
+
/** The pure handler. deps: { readFacts(), readEvents?(range), readKrs?(), today, nowMs?, readDated?(range), readItems?(),
|
|
22
|
+
* readContextSources?() } so tests need no repo. A dep that is missing gives its source as empty, said in layers_note. */
|
|
20
23
|
function handle(query, deps = {}) {
|
|
21
24
|
const view = (query && query.view) || 'month';
|
|
22
25
|
const date = (query && query.date) || (deps.today || today());
|
|
@@ -33,13 +36,118 @@ function handle(query, deps = {}) {
|
|
|
33
36
|
const nowDay = deps.today || today();
|
|
34
37
|
const gantt = model.ganttRows(inis, view, date, { today: nowDay });
|
|
35
38
|
const strategy = view === 'quarter' || view === 'year' ? model.strategyLens(inis, krs, nowDay) : null;
|
|
36
|
-
|
|
39
|
+
const dated = typeof deps.readDated === 'function' ? (deps.readDated(range) || {}) : {
|
|
40
|
+
unavailable: { plan_items: 'no reader is wired', releases: 'no reader is wired', reports: 'no reader is wired', own: 'no reader is wired' } };
|
|
41
|
+
// the initiatives come from the facts this handler already holds, so every caller gets their promised dates
|
|
42
|
+
if (!dated.initiatives && !(dated.unavailable && dated.unavailable.initiatives)) dated.initiatives = inis;
|
|
43
|
+
const items = typeof deps.readItems === 'function' ? (deps.readItems() || {}) : {};
|
|
44
|
+
const src = typeof deps.readContextSources === 'function' ? (deps.readContextSources() || {}) : {};
|
|
45
|
+
const nowMs = Number.isFinite(deps.nowMs) ? deps.nowMs : (deps.today ? undefined : Date.now());
|
|
46
|
+
const extra = layersLib.build({ range, today: nowDay, nowMs, events, dated, src, own: items.own, planItemsDue: items.planItems });
|
|
47
|
+
return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay, ...extra } };
|
|
37
48
|
} catch (e) {
|
|
38
49
|
return { status: 500, body: { ok: false, error: String(e && e.message || e) } };
|
|
39
50
|
}
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
/* ── the readers behind the layers, free time and context (each source on its own: one that cannot be read is named in
|
|
54
|
+
* layers_note and gives nothing; it never takes the answer down) ────────────────────────────────────────────────── */
|
|
55
|
+
const reason = (e) => String((e && e.message) || e).slice(0, 160);
|
|
56
|
+
|
|
57
|
+
function readers(root, runtimeDir) {
|
|
58
|
+
let plan = null;
|
|
59
|
+
const planFold = () => { if (!plan) plan = require('./plan-items.js').readFold(root); return plan; };
|
|
60
|
+
const planRows = () => Object.values(planFold().items).filter((p) => !(p.proposed && !p.kept));
|
|
61
|
+
let ownLines = null;
|
|
62
|
+
const ownRows = () => {
|
|
63
|
+
if (!ownLines) ownLines = require('./pm-items.js').readLines(path.join(runtimeDir, 'pm-items', 'items.jsonl'));
|
|
64
|
+
return require('./pm-items.js').fold(ownLines);
|
|
65
|
+
};
|
|
66
|
+
const planItem = (p) => ({ id: p.id, initiative: p.initiative, title: p.title, due: p.due, milestone: !!p.milestone, state: p.state });
|
|
67
|
+
return {
|
|
68
|
+
readDated(range) {
|
|
69
|
+
const unavailable = {};
|
|
70
|
+
const out = { unavailable };
|
|
71
|
+
try { out.planItems = planRows().map(planItem); } catch (e) { unavailable.plan_items = 'the plan ledger could not be read: ' + reason(e); }
|
|
72
|
+
try {
|
|
73
|
+
const R = require('../../scripts/releases.js');
|
|
74
|
+
const f = path.join(runtimeDir, 'releases', 'releases.jsonl');
|
|
75
|
+
let lines = []; try { lines = require('fs').readFileSync(f, 'utf8').split('\n').filter(Boolean); } catch { lines = []; }
|
|
76
|
+
out.releases = R.foldReleases(lines);
|
|
77
|
+
} catch (e) { unavailable.releases = 'the releases file could not be read: ' + reason(e); }
|
|
78
|
+
try {
|
|
79
|
+
const RN = require('./report-needs.js');
|
|
80
|
+
out.reports = RN.reportsOf(root).reports.map((r) => ({ cadence: r.cadence, slot: r.slot, issued: r.issued ? { at: r.issued.at } : null }));
|
|
81
|
+
out.reportSchedules = reportSchedules(RN, range);
|
|
82
|
+
} catch (e) { unavailable.reports = 'the report folders could not be read: ' + reason(e); }
|
|
83
|
+
try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done })); } catch (e) { unavailable.own = 'your items could not be read: ' + reason(e); }
|
|
84
|
+
return out;
|
|
85
|
+
},
|
|
86
|
+
readItems() {
|
|
87
|
+
const out = { own: [], planItems: [] };
|
|
88
|
+
try { out.planItems = planRows().map(planItem); } catch { /* the plan ledger is named in layers_note */ }
|
|
89
|
+
try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
|
|
90
|
+
return out;
|
|
91
|
+
},
|
|
92
|
+
readContextSources() {
|
|
93
|
+
const src = { notes: [], planItems: [], ownItems: [] };
|
|
94
|
+
try { src.planItems = planRows().map((p) => ({ id: p.id, initiative: p.initiative, title: p.title, createdAt: p.createdAt, doneAt: p.doneAt })); } catch { /* no plan ledger: no moved items */ }
|
|
95
|
+
try { src.ownItems = ownRows().map((o) => ({ id: o.id, title: o.title, initiative: o.initiative || null, created_at: o.created_at || null, done_at: o.done_at || null })); } catch { /* no own items */ }
|
|
96
|
+
try { src.notes = linkedNotes(runtimeDir); } catch { /* no notes: no decisions or actions */ }
|
|
97
|
+
return src;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The dates a report schedule fires on, from the range start, using the same slot math the scheduler runs on. */
|
|
103
|
+
function reportSchedules(RN, range) {
|
|
104
|
+
const logic = require('./schedule-logic.js');
|
|
105
|
+
const out = [];
|
|
106
|
+
const t0 = new Date();
|
|
107
|
+
const today = `${t0.getFullYear()}-${String(t0.getMonth() + 1).padStart(2, '0')}-${String(t0.getDate()).padStart(2, '0')}`;
|
|
108
|
+
const first = range && range.start > today ? range.start : today;
|
|
109
|
+
const from = new Date(Number(first.slice(0, 4)), Number(first.slice(5, 7)) - 1, Number(first.slice(8, 10)));
|
|
110
|
+
for (const s of RN.readSchedules()) {
|
|
111
|
+
if (!s || !s.enabled) continue;
|
|
112
|
+
const cadence = RN.cadenceOfSchedule(s);
|
|
113
|
+
if (!cadence) continue;
|
|
114
|
+
const dates = [];
|
|
115
|
+
let at = from;
|
|
116
|
+
for (let i = 0; i < 62; i++) {
|
|
117
|
+
const next = logic.nextRun(s, at);
|
|
118
|
+
if (!next) break;
|
|
119
|
+
const d = new Date(next);
|
|
120
|
+
const ds = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
121
|
+
if (range && ds > range.end) break;
|
|
122
|
+
dates.push(ds);
|
|
123
|
+
at = new Date(d.getTime() + 1000);
|
|
124
|
+
}
|
|
125
|
+
out.push({ id: String(s.id || s.name || cadence), name: s.name, cadence, dates: [...new Set(dates)] });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Notes linked to a calendar event, read straight from the notes folder (no import, no write). */
|
|
131
|
+
function linkedNotes(runtimeDir) {
|
|
132
|
+
const fs = require('fs');
|
|
133
|
+
const dir = path.join(runtimeDir, 'notes');
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
|
|
136
|
+
let n; try { n = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }
|
|
137
|
+
if (!n || !n.id || !n.meeting || n.meeting.kind !== 'event') continue;
|
|
138
|
+
const fin = n.final_notes && typeof n.final_notes === 'object' ? n.final_notes : null;
|
|
139
|
+
const sec = n.summary && n.summary.sections ? n.summary.sections : null;
|
|
140
|
+
const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
|
|
141
|
+
out.push({ id: n.id, title: n.title || '', private: !!n.private, initiative: n.initiative && n.initiative.id ? String(n.initiative.id) : null,
|
|
142
|
+
meeting: { event_id: n.meeting.event_id || null, start: n.meeting.start, title: n.meeting.title },
|
|
143
|
+
decisions: pick(fin && fin.decisions, sec && sec.decisions).map((x) => ({ text: x && x.text })),
|
|
144
|
+
actions: pick(fin && fin.actions, sec && sec.actions).map((x) => ({ text: x && x.text, owner: x && x.owner || null, due: x && x.due || null })) });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
42
149
|
module.exports = {
|
|
150
|
+
readers,
|
|
43
151
|
prefixes: ['/api/calendar-views'],
|
|
44
152
|
handlePure: handle,
|
|
45
153
|
handle: async (req, res, url, ctx) => {
|
|
@@ -54,6 +162,7 @@ module.exports = {
|
|
|
54
162
|
const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
|
|
55
163
|
readFacts: () => facts,
|
|
56
164
|
readEvents: () => require('./routes-calendar-import.js').readEvents(ctx.runtimeDir),
|
|
165
|
+
...readers(root, ctx.runtimeDir),
|
|
57
166
|
});
|
|
58
167
|
ctx.json(res, out.body, out.status);
|
|
59
168
|
return true;
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* routes-pm-items.js — the PM's own work items and jobs to be done (planning-flows run 1 and 2, UI slice
|
|
2
|
+
/* routes-pm-items.js — the PM's own work items and jobs to be done (planning-flows run 1 and 2, UI slice; the
|
|
3
|
+
* console3 Work page: lib/pm-items.js workPlacement, lib/work-focus.js).
|
|
3
4
|
*
|
|
4
|
-
* GET /api/pm-items → { ok, today, lists: { waiting, today, week, triage, later }, jobs, items
|
|
5
|
+
* GET /api/pm-items → { ok, today, lists: { waiting, today, week, triage, later }, jobs, items,
|
|
6
|
+
* work: { inbox, today, upcoming, waiting, someday } open items, placed once here
|
|
7
|
+
* review, review_days: 7 open items untouched 7 days, most stale first
|
|
8
|
+
* focus: { suggestions, accepted } at most 3 suggestions: { key, title, why, item_ids, job }
|
|
9
|
+
* counts: { inbox, today, upcoming, waiting, someday, review } }
|
|
5
10
|
* POST /api/pm-items/add { line } → { ok, item } one line: the parser (lib/pm-items.js) reads the parts
|
|
6
|
-
* POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job
|
|
11
|
+
* POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job,
|
|
12
|
+
* defer, flagged, resolves, kind, person, evidence, outcome
|
|
7
13
|
* POST /api/pm-items/remove { id } → { ok, undo_ms } deleted (created here) or dropped (imported)
|
|
8
14
|
* POST /api/pm-items/undo { id } → { ok, item } | 409 restores the last removal inside the 8 s window
|
|
9
|
-
* POST /api/pm-items/done { id }
|
|
15
|
+
* POST /api/pm-items/done { id, outcome? } → { ok } folds to item.done + done_at (+ outcome, one line); /reopen undoes it
|
|
10
16
|
* POST /api/pm-items/park { id } → { ok } parked: true, placed in Later; /unpark clears it
|
|
17
|
+
* POST /api/pm-items/flag { id, flagged } → { ok } flagged: boolean; a flagged item is in Today
|
|
18
|
+
* POST /api/pm-items/defer { id, until|null } → { ok } defer: not before that date (null clears); clears parked
|
|
19
|
+
* POST /api/pm-items/someday { id } → { ok } parked: true, placed in Someday
|
|
20
|
+
* POST /api/pm-items/waiting { id, on|null } → { ok } waiting_on and waiting_since (null clears); clears parked
|
|
21
|
+
* POST /api/pm-items/notyet { id, when } → { ok } when: later-today | tomorrow | next-week; adds 1 to not_yet_count;
|
|
22
|
+
* tomorrow and next-week set defer (next Monday), later-today sets nudge_at (now + 3 h)
|
|
23
|
+
* POST /api/pm-items/keep { id } → { ok } the Review pass kept it: event 'kept', touched_at moves
|
|
24
|
+
* POST /api/pm-items/focus/accept { key, title, why, item_ids, job? } → { ok } today's Focus (focus.jsonl)
|
|
25
|
+
* POST /api/pm-items/focus/dismiss { key } → { ok } not suggested again today
|
|
26
|
+
* POST /api/pm-items/focus/clear { key } → { ok } forget the accept or the dismissal
|
|
11
27
|
* POST /api/pm-items/move { id, list } → { ok } list_override, one of the five lists
|
|
12
28
|
* POST /api/pm-items/steps/add { id, text, who? } → { ok, step } outward steps are forced to agent-drafts
|
|
13
29
|
* POST /api/pm-items/steps/who { step_id, who } → { ok, who }
|
|
@@ -16,7 +32,7 @@
|
|
|
16
32
|
* POST /api/pm-jobs/edit { id, fields }→ { ok, job } fields: when, want, so
|
|
17
33
|
* POST /api/pm-jobs/delete { id, reassign_to? } → { ok, reassigned } items move to reassign_to, or to none
|
|
18
34
|
*
|
|
19
|
-
* STATE.
|
|
35
|
+
* STATE. Append-only files under <runtime>/pm-items/: items.jsonl, jobs.jsonl, steps.jsonl and focus.jsonl. Every write is one appended line
|
|
20
36
|
* through pm-items.appendEvent, the single writer, and the GET folds the whole file again. Nothing is read from Jira.
|
|
21
37
|
*
|
|
22
38
|
* WHO WRITES. Every POST passes pm-guard requirePagePress: the page only, with the Origin and nonce checks. An agent
|
|
@@ -28,9 +44,12 @@ const crypto = require('crypto');
|
|
|
28
44
|
const guard = require('./pm-guard.js');
|
|
29
45
|
const pmItems = require('./pm-items.js');
|
|
30
46
|
const jobs = require('./jobs-to-be-done.js');
|
|
47
|
+
const workFocus = require('./work-focus.js');
|
|
31
48
|
const steps = require('./item-steps.js');
|
|
32
49
|
|
|
33
|
-
const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job'];
|
|
50
|
+
const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job', 'defer', 'flagged', 'resolves', 'kind', 'person', 'evidence', 'outcome'];
|
|
51
|
+
const STRING_OR_NULL = ['resolves', 'person', 'evidence', 'outcome'];
|
|
52
|
+
const NOT_YET = ['later-today', 'tomorrow', 'next-week'];
|
|
34
53
|
const JOB_FIELDS = ['when', 'want', 'so'];
|
|
35
54
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
36
55
|
|
|
@@ -38,17 +57,40 @@ const dirOf = (ctx) => path.join(ctx.runtimeDir, 'pm-items');
|
|
|
38
57
|
const itemsFile = (ctx) => path.join(dirOf(ctx), 'items.jsonl');
|
|
39
58
|
const stepsFile = (ctx) => path.join(dirOf(ctx), 'steps.jsonl');
|
|
40
59
|
const jobsFile = (ctx) => path.join(dirOf(ctx), 'jobs.jsonl');
|
|
60
|
+
const focusFile = (ctx) => path.join(dirOf(ctx), 'focus.jsonl');
|
|
41
61
|
const newId = (prefix) => `${prefix}-${crypto.randomBytes(6).toString('hex')}`;
|
|
42
62
|
|
|
43
63
|
// The current view, folded from the files. `now` decides placement, so tests can pin the day.
|
|
44
|
-
|
|
64
|
+
// The titles of the team tasks (plan items) that own items advance, so the page can say "Advances: <title>". Best effort: no workspace, no map.
|
|
65
|
+
function resolvesTitles(items, ctx, url) {
|
|
66
|
+
const ids = [...new Set((items || []).map((i) => i.resolves).filter(Boolean))];
|
|
67
|
+
if (!ids.length || typeof ctx.getWs !== 'function') return {};
|
|
68
|
+
try {
|
|
69
|
+
const w = ctx.getWs(String(url.searchParams.get('ws') || ''));
|
|
70
|
+
if (!w || !w.root) return {};
|
|
71
|
+
const fold = require('./plan-items.js').readFold(w.root);
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const id of ids) if (fold.items[id] && fold.items[id].title) out[id] = fold.items[id].title;
|
|
74
|
+
return out;
|
|
75
|
+
} catch { return {}; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function view(itemLines, jobLines, now, stepLines = [], focusLines = []) {
|
|
45
79
|
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
46
80
|
const byItem = steps.foldSteps(stepLines).byItem;
|
|
47
81
|
const items = pmItems.fold(itemLines).map((i) => ({ ...i, steps: byItem[i.id] || [] }));
|
|
48
82
|
const currentJobs = jobs.foldJobs(jobLines).map((j) => ({ ...j, ...jobs.progress(j.id, items) }));
|
|
49
83
|
const lists = Object.fromEntries(pmItems.LISTS.map((l) => [l, []]));
|
|
50
84
|
for (const it of items) lists[pmItems.placement(it, today)].push(it);
|
|
51
|
-
|
|
85
|
+
const todayIso = today.toISOString().slice(0, 10);
|
|
86
|
+
const work = Object.fromEntries(pmItems.WORK_LISTS.map((l) => [l, []]));
|
|
87
|
+
for (const it of items) if (!it.done) work[pmItems.workPlacement(it, today)].push(it);
|
|
88
|
+
for (const l of pmItems.WORK_LISTS) work[l] = pmItems.sortWorkList(l, work[l], today);
|
|
89
|
+
const review = pmItems.reviewQueue(items, now);
|
|
90
|
+
const state = workFocus.foldFocus(focusLines, todayIso);
|
|
91
|
+
const focus = { suggestions: workFocus.focusSuggestions(work.today, currentJobs, state, todayIso), accepted: state.accepted };
|
|
92
|
+
const counts = { ...Object.fromEntries(pmItems.WORK_LISTS.map((l) => [l, work[l].length])), review: review.length };
|
|
93
|
+
return { ok: true, today: todayIso, lists, jobs: currentJobs, items, work, review, review_days: pmItems.REVIEW_DAYS, focus, counts };
|
|
52
94
|
}
|
|
53
95
|
|
|
54
96
|
// Each operation returns { status, body, events } and writes nothing itself; the caller appends `events`.
|
|
@@ -59,6 +101,12 @@ function checkFields(fields, allowed) {
|
|
|
59
101
|
}
|
|
60
102
|
if ('due' in fields && fields.due !== null && !DATE_RE.test(String(fields.due))) return 'due must be YYYY-MM-DD or null';
|
|
61
103
|
if ('priority' in fields && fields.priority !== null && !/^P[1-3]$/.test(String(fields.priority))) return 'priority must be P1, P2, P3 or null';
|
|
104
|
+
if ('defer' in fields && fields.defer !== null && !DATE_RE.test(String(fields.defer))) return 'defer must be YYYY-MM-DD or null';
|
|
105
|
+
if ('flagged' in fields && typeof fields.flagged !== 'boolean') return 'flagged must be true or false';
|
|
106
|
+
if ('kind' in fields && fields.kind !== null && fields.kind !== 'offline') return 'kind must be offline or null';
|
|
107
|
+
for (const k of STRING_OR_NULL) {
|
|
108
|
+
if (k in fields && fields[k] !== null && typeof fields[k] !== 'string') return `${k} must be a string or null`;
|
|
109
|
+
}
|
|
62
110
|
return null;
|
|
63
111
|
}
|
|
64
112
|
|
|
@@ -98,7 +146,11 @@ function flagItem(body, itemLines, events) {
|
|
|
98
146
|
if (!item) return noItem;
|
|
99
147
|
return { status: 200, body: { ok: true }, events: typeof events === 'function' ? events(item) : events };
|
|
100
148
|
}
|
|
101
|
-
|
|
149
|
+
function doneItem(b, l) {
|
|
150
|
+
if (b.outcome !== undefined && b.outcome !== null && typeof b.outcome !== 'string') return { status: 400, body: { ok: false, error: 'outcome must be a string' } };
|
|
151
|
+
const outcome = typeof b.outcome === 'string' && b.outcome.trim() ? { outcome: b.outcome.trim() } : {};
|
|
152
|
+
return flagItem(b, l, [{ event: 'done', id: b.id, ...outcome }]);
|
|
153
|
+
}
|
|
102
154
|
const reopenItem = (b, l) => flagItem(b, l, [{ event: 'reopened', id: b.id }]);
|
|
103
155
|
const parkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
|
|
104
156
|
const unparkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: false } }]);
|
|
@@ -107,6 +159,53 @@ function moveItem(b, l) {
|
|
|
107
159
|
return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { list_override: b.list, parked: false } }]);
|
|
108
160
|
}
|
|
109
161
|
|
|
162
|
+
// Work page routes. A legacy list_override would beat the new placement, so the routes that place an item clear it.
|
|
163
|
+
const bad = (error) => ({ status: 400, body: { ok: false, error } });
|
|
164
|
+
function flagRoute(b, l) {
|
|
165
|
+
if (typeof b.flagged !== 'boolean') return bad('flagged must be true or false');
|
|
166
|
+
return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { flagged: b.flagged } }]);
|
|
167
|
+
}
|
|
168
|
+
function deferItem(b, l) {
|
|
169
|
+
if (b.until !== null && !DATE_RE.test(String(b.until))) return bad('until must be YYYY-MM-DD or null');
|
|
170
|
+
return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { defer: b.until, parked: false, list_override: null } }]);
|
|
171
|
+
}
|
|
172
|
+
const somedayItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
|
|
173
|
+
function waitingItem(b, l, now) {
|
|
174
|
+
const on = typeof b.on === 'string' ? b.on.trim() : b.on;
|
|
175
|
+
if (on !== null && (typeof on !== 'string' || !on)) return bad('on must be a name or null');
|
|
176
|
+
return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { waiting_on: on, waiting_since: on ? now.toISOString() : null, parked: false, list_override: null } }]);
|
|
177
|
+
}
|
|
178
|
+
// "Not yet": tomorrow and next Monday are dates (defer); later today only counts and nudges 3 hours on.
|
|
179
|
+
// Dates follow the server's UTC day, as the placement does.
|
|
180
|
+
function notYetItem(b, l, now) {
|
|
181
|
+
if (!NOT_YET.includes(b.when)) return bad('when must be one of ' + NOT_YET.join(', '));
|
|
182
|
+
return flagItem(b, l, (item) => {
|
|
183
|
+
const fields = { not_yet_count: (item.not_yet_count || 0) + 1 };
|
|
184
|
+
const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
185
|
+
const plus = (n) => new Date(day.getTime() + n * 86400000).toISOString().slice(0, 10);
|
|
186
|
+
if (b.when === 'later-today') fields.nudge_at = new Date(now.getTime() + 3 * 3600000).toISOString();
|
|
187
|
+
else {
|
|
188
|
+
fields.defer = b.when === 'tomorrow' ? plus(1) : plus(((1 - day.getUTCDay() + 7) % 7) || 7);
|
|
189
|
+
fields.nudge_at = null;
|
|
190
|
+
fields.list_override = null;
|
|
191
|
+
}
|
|
192
|
+
return [{ event: 'edited', id: b.id, fields }];
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const keepItem = (b, l) => flagItem(b, l, [{ event: 'kept', id: b.id }]);
|
|
196
|
+
|
|
197
|
+
// Focus events go to focus.jsonl. The day comes from the server date, never from the page.
|
|
198
|
+
function focusEvent(kind, b, now) {
|
|
199
|
+
if (typeof b.key !== 'string' || !b.key) return bad('key is required');
|
|
200
|
+
const day = now.toISOString().slice(0, 10);
|
|
201
|
+
if (kind !== 'accepted') return { status: 200, body: { ok: true }, events: [{ event: kind, key: b.key, day }] };
|
|
202
|
+
if (typeof b.title !== 'string' || !b.title.trim()) return bad('title is required');
|
|
203
|
+
if (typeof b.why !== 'string') return bad('why must be a string');
|
|
204
|
+
if (!Array.isArray(b.item_ids) || b.item_ids.some((x) => typeof x !== 'string')) return bad('item_ids must be a list of item ids');
|
|
205
|
+
if (b.job !== undefined && b.job !== null && typeof b.job !== 'string') return bad('job must be a string or null');
|
|
206
|
+
return { status: 200, body: { ok: true }, events: [{ event: 'accepted', key: b.key, title: b.title.trim(), why: b.why, item_ids: b.item_ids, job: b.job || null, day }] };
|
|
207
|
+
}
|
|
208
|
+
|
|
110
209
|
function addStepRoute(b, itemLines) {
|
|
111
210
|
if (!findItem(itemLines, b.id)) return noItem;
|
|
112
211
|
const id = newId('step');
|
|
@@ -172,6 +271,15 @@ const ROUTES = {
|
|
|
172
271
|
'POST /api/pm-items/park': (b, ctx) => ({ ...parkItem(b, ctx.itemLines), file: 'items' }),
|
|
173
272
|
'POST /api/pm-items/unpark': (b, ctx) => ({ ...unparkItem(b, ctx.itemLines), file: 'items' }),
|
|
174
273
|
'POST /api/pm-items/move': (b, ctx) => ({ ...moveItem(b, ctx.itemLines), file: 'items' }),
|
|
274
|
+
'POST /api/pm-items/flag': (b, ctx) => ({ ...flagRoute(b, ctx.itemLines), file: 'items' }),
|
|
275
|
+
'POST /api/pm-items/defer': (b, ctx) => ({ ...deferItem(b, ctx.itemLines), file: 'items' }),
|
|
276
|
+
'POST /api/pm-items/someday': (b, ctx) => ({ ...somedayItem(b, ctx.itemLines), file: 'items' }),
|
|
277
|
+
'POST /api/pm-items/waiting': (b, ctx) => ({ ...waitingItem(b, ctx.itemLines, ctx.now || new Date()), file: 'items' }),
|
|
278
|
+
'POST /api/pm-items/notyet': (b, ctx) => ({ ...notYetItem(b, ctx.itemLines, ctx.now || new Date()), file: 'items' }),
|
|
279
|
+
'POST /api/pm-items/keep': (b, ctx) => ({ ...keepItem(b, ctx.itemLines), file: 'items' }),
|
|
280
|
+
'POST /api/pm-items/focus/accept': (b, ctx) => ({ ...focusEvent('accepted', b, ctx.now || new Date()), file: 'focus' }),
|
|
281
|
+
'POST /api/pm-items/focus/dismiss': (b, ctx) => ({ ...focusEvent('dismissed', b, ctx.now || new Date()), file: 'focus' }),
|
|
282
|
+
'POST /api/pm-items/focus/clear': (b, ctx) => ({ ...focusEvent('cleared', b, ctx.now || new Date()), file: 'focus' }),
|
|
175
283
|
'POST /api/pm-items/steps/add': (b, ctx) => ({ ...addStepRoute(b, ctx.itemLines), file: 'steps' }),
|
|
176
284
|
'POST /api/pm-items/steps/who': (b, ctx) => ({ ...stepWho(b, ctx.stepLines), file: 'steps' }),
|
|
177
285
|
'POST /api/pm-items/steps/plan': (b, ctx) => ({ ...planSteps(b, ctx.itemLines), file: 'steps' }),
|
|
@@ -181,17 +289,19 @@ const ROUTES = {
|
|
|
181
289
|
};
|
|
182
290
|
|
|
183
291
|
function prepare(ctx, now = new Date()) {
|
|
184
|
-
return { itemLines: pmItems.readLines(itemsFile(ctx)), jobLines: pmItems.readLines(jobsFile(ctx)), stepLines: pmItems.readLines(stepsFile(ctx)), now };
|
|
292
|
+
return { itemLines: pmItems.readLines(itemsFile(ctx)), jobLines: pmItems.readLines(jobsFile(ctx)), stepLines: pmItems.readLines(stepsFile(ctx)), focusLines: pmItems.readLines(focusFile(ctx)), now };
|
|
185
293
|
}
|
|
186
294
|
|
|
187
295
|
module.exports = {
|
|
188
296
|
prefixes: ['/api/pm-items', '/api/pm-jobs'],
|
|
189
|
-
view, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
|
|
297
|
+
view, resolvesTitles, flagRoute, deferItem, somedayItem, waitingItem, notYetItem, keepItem, focusEvent, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
|
|
190
298
|
handle: async (req, res, url, ctx) => {
|
|
191
299
|
const p = url.pathname;
|
|
192
300
|
if (p === '/api/pm-items' && req.method === 'GET') {
|
|
193
301
|
const c = prepare(ctx);
|
|
194
|
-
|
|
302
|
+
const v = view(c.itemLines, c.jobLines, c.now, c.stepLines, c.focusLines);
|
|
303
|
+
v.resolves_titles = resolvesTitles(v.items, ctx, url);
|
|
304
|
+
ctx.json(res, v);
|
|
195
305
|
return true;
|
|
196
306
|
}
|
|
197
307
|
const key = `${req.method} ${p}`;
|
|
@@ -207,7 +317,7 @@ module.exports = {
|
|
|
207
317
|
for (const ev of r.events) {
|
|
208
318
|
const which = ev._file || r.file;
|
|
209
319
|
const { _file, ...line } = ev;
|
|
210
|
-
pmItems.appendEvent(which === 'jobs' ? jobsFile(ctx) : which === 'steps' ? stepsFile(ctx) : itemsFile(ctx), line, c.now);
|
|
320
|
+
pmItems.appendEvent(which === 'jobs' ? jobsFile(ctx) : which === 'steps' ? stepsFile(ctx) : which === 'focus' ? focusFile(ctx) : itemsFile(ctx), line, c.now);
|
|
211
321
|
}
|
|
212
322
|
}
|
|
213
323
|
ctx.json(res, r.body, r.status);
|