create-pmos 0.130.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 +4 -2
- package/workspace/c3-work-test.js +200 -0
- package/workspace/fixtures/one-home-baseline.json +0 -91
- package/workspace/guide/console-calendar.md +65 -0
- package/workspace/guide/index.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/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-calendar.js +392 -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 +10 -3
- package/workspace/public/pm/c3-shell.js +1 -1
- package/workspace/public/pm/calendar-views.js +10 -3
- package/workspace/public/pm/components.css +1 -0
- package/workspace/public/pm/console3.css +155 -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 +2 -0
- package/workspace/test-lists/calendar-layers.json +5 -0
- package/workspace/test-lists/calendar-time-page.json +5 -0
- package/workspace/test-lists/console3-work-page.json +9 -0
- package/workspace/public/pm/c3-demo-work2.js +0 -13
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* calendar-layers.js — the pure part of the console3 Calendar page's server answer (initiative calendar-time-page, R2 to R5, R8).
|
|
3
|
+
*
|
|
4
|
+
* layersFrom(dated, range, today) → { layers, layers_total, layers_note } dated items inside the range
|
|
5
|
+
* freeFrom(events, range, today, opts) → { min_minutes, day_start, day_end, days, count }
|
|
6
|
+
* contextFrom(events, range, src, opts) → { context, context_total } keyed by event id
|
|
7
|
+
* build({ range, today, nowMs, events, dated, src, view }) all three, for routes-calendar-views.js
|
|
8
|
+
*
|
|
9
|
+
* No I/O and no clock: `today` comes in as 'YYYY-MM-DD', `nowMs` as a number. Nothing is invented. A source with no reader
|
|
10
|
+
* is named in layers_note with the reason, and gives no item. Meetings are not layers; they stay in `cells`.
|
|
11
|
+
*
|
|
12
|
+
* INPUT SHAPES (plain data; the route reads them from the repository and the per-user store).
|
|
13
|
+
* dated = { initiatives:[{id,title,promised,done}], planItems:[{id,initiative,title,due,milestone,state}],
|
|
14
|
+
* releases:[{id,type,fix_version,deploy_at,release_at}], reports:[{cadence,slot,issued}],
|
|
15
|
+
* reportSchedules:[{id,name,cadence,dates:['YYYY-MM-DD']}], own:[{id,title,due,done}], unavailable:{ source: why } }
|
|
16
|
+
* src = { notes:[{id,title,private,meeting:{event_id,start,title},initiative,decisions:[{text}],actions:[{text,owner,due}]}],
|
|
17
|
+
* planItems:[{id,initiative,title,createdAt,doneAt}], ownItems:[{id,title,initiative,created_at,done_at}] }
|
|
18
|
+
* event = the imported event ({ id, at, end, title, initiative, pm_notes, agent_prep_notes, prep, prep_done, steps }).
|
|
19
|
+
*
|
|
20
|
+
* TONE. bad: past its date and not done. warn: due today up to 2 days ahead and not done. ok: done. Otherwise null.
|
|
21
|
+
* FREE TIME. Working days only (Monday to Friday), from DAY_START to DAY_END, a block of min_minutes or more (calendar-memory's
|
|
22
|
+
* dayLoad). Days before today are left out: free time that has passed cannot be used. An event with a date and no time (all day)
|
|
23
|
+
* does not block; an event with no end lasts 30 minutes. Quarter and Year answer days:[] and the count only. */
|
|
24
|
+
const memory = require('./calendar-memory.js');
|
|
25
|
+
|
|
26
|
+
const DAY_MS = 86400000;
|
|
27
|
+
const LAYER_CAP = 400;
|
|
28
|
+
const CONTEXT_CAP = 60;
|
|
29
|
+
const MIN_BLOCK = memory.FREE_BLOCK; // 90
|
|
30
|
+
const PER_STEP = memory.PER_STEP; // 30 minutes of free time for each prep step left
|
|
31
|
+
const PER_DAY = 60; // minutes of a whole working day before the meeting that count toward its prep
|
|
32
|
+
const DAY_START = 9 * 60;
|
|
33
|
+
const DAY_END = 18 * 60;
|
|
34
|
+
const WARN_DAYS = 2;
|
|
35
|
+
const SUGGEST_DAYS = 7;
|
|
36
|
+
const WINDOW_MS = 48 * 3600000;
|
|
37
|
+
const PER_KIND = { decision: 6, action: 6, moved: 8, prepared: 8, todo: 8 };
|
|
38
|
+
const LIGHT_VIEWS = ['quarter', 'year'];
|
|
39
|
+
|
|
40
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
41
|
+
const isDay = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
|
|
42
|
+
const day10 = (s) => String(s).slice(0, 10);
|
|
43
|
+
const hasTime = (s) => typeof s === 'string' && /T\d{2}:\d{2}/.test(s);
|
|
44
|
+
const ymdMs = (s) => Date.parse(day10(s) + 'T00:00:00Z');
|
|
45
|
+
const addDays = (s, n) => new Date(ymdMs(s) + n * DAY_MS).toISOString().slice(0, 10);
|
|
46
|
+
const diffDays = (a, b) => Math.round((ymdMs(a) - ymdMs(b)) / DAY_MS);
|
|
47
|
+
const dow = (s) => new Date(ymdMs(s)).getUTCDay(); // 0 Sunday
|
|
48
|
+
const isWorkday = (s) => dow(s) >= 1 && dow(s) <= 5;
|
|
49
|
+
const hhmm = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;
|
|
50
|
+
const localMs = (s) => { const [y, m, d] = day10(s).split('-').map(Number); return new Date(y, m - 1, d).getTime(); };
|
|
51
|
+
const localDay = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
|
|
52
|
+
const minuteOfDay = (ms) => { const d = new Date(ms); return d.getHours() * 60 + d.getMinutes(); };
|
|
53
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
54
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
55
|
+
const inRange = (d, range) => d >= range.start && d <= range.end;
|
|
56
|
+
const overlaps = (a, b, range) => a <= range.end && b >= range.start;
|
|
57
|
+
|
|
58
|
+
/* ── layers ────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
function toneOf(date, done, today) {
|
|
61
|
+
if (done) return 'ok';
|
|
62
|
+
if (date < today) return 'bad';
|
|
63
|
+
if (diffDays(date, today) <= WARN_DAYS) return 'warn';
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function layersFrom(dated, range, today) {
|
|
68
|
+
const d = dated || {};
|
|
69
|
+
const out = [];
|
|
70
|
+
const note = {};
|
|
71
|
+
const dateless = {}; // source -> items that carry a date at all (in range or not)
|
|
72
|
+
const inside = {}; // source -> items inside the range
|
|
73
|
+
const put = (source, x) => {
|
|
74
|
+
dateless[source] = (dateless[source] || 0) + 1;
|
|
75
|
+
const end = x.end && x.end > x.date ? x.end : null;
|
|
76
|
+
if (!overlaps(x.date, end || x.date, range)) return;
|
|
77
|
+
inside[source] = (inside[source] || 0) + 1;
|
|
78
|
+
const { end: _e, ...rest } = x;
|
|
79
|
+
out.push(end ? { ...rest, end } : rest);
|
|
80
|
+
};
|
|
81
|
+
const unavailable = d.unavailable && typeof d.unavailable === 'object' ? d.unavailable : {};
|
|
82
|
+
const why = (source, fallback) => { if (unavailable[source]) note[source] = String(unavailable[source]); else if (fallback) note[source] = fallback; };
|
|
83
|
+
|
|
84
|
+
// initiatives: the promised date
|
|
85
|
+
if (unavailable.initiatives) why('initiatives');
|
|
86
|
+
else {
|
|
87
|
+
let n = 0;
|
|
88
|
+
for (const i of d.initiatives || []) {
|
|
89
|
+
if (!i || !i.id || !isDay(i.promised)) continue;
|
|
90
|
+
n++;
|
|
91
|
+
const date = day10(i.promised);
|
|
92
|
+
put('initiatives', { id: `ini:${i.id}:promised`, date, kind: 'milestone', layer: 'milestones', title: str(i.title) || i.id, tone: toneOf(date, !!i.done, today), ref: { type: 'initiative', id: i.id }, initiative: i.id });
|
|
93
|
+
}
|
|
94
|
+
if (!n) why('initiatives', 'no initiative has a promised date');
|
|
95
|
+
}
|
|
96
|
+
// plan items: a milestone or a due date
|
|
97
|
+
if (unavailable.plan_items) why('plan_items');
|
|
98
|
+
else {
|
|
99
|
+
let n = 0;
|
|
100
|
+
for (const p of d.planItems || []) {
|
|
101
|
+
if (!p || !p.id || !isDay(p.due)) continue;
|
|
102
|
+
n++;
|
|
103
|
+
const date = day10(p.due);
|
|
104
|
+
put('plan_items', { id: `plan:${p.id}:${p.milestone ? 'milestone' : 'due'}`, date, kind: p.milestone ? 'milestone' : 'due', layer: 'milestones', title: str(p.title) || p.id,
|
|
105
|
+
tone: toneOf(date, p.state === 'done', today), ref: { type: 'plan-item', id: p.id }, ...(p.initiative ? { initiative: p.initiative } : {}) });
|
|
106
|
+
}
|
|
107
|
+
if (!n) why('plan_items', 'no plan item has a due date');
|
|
108
|
+
}
|
|
109
|
+
// releases
|
|
110
|
+
if (unavailable.releases) why('releases');
|
|
111
|
+
else {
|
|
112
|
+
let n = 0;
|
|
113
|
+
for (const r of d.releases || []) {
|
|
114
|
+
if (!r || !r.id) continue;
|
|
115
|
+
const dep = isDay(r.deploy_at) ? day10(r.deploy_at) : null; const rel = isDay(r.release_at) ? day10(r.release_at) : null;
|
|
116
|
+
const date = dep || rel; if (!date) continue;
|
|
117
|
+
n++;
|
|
118
|
+
const last = rel || dep;
|
|
119
|
+
put('releases', { id: `rel:${r.id}`, date, ...(last > date ? { end: last } : {}), kind: 'release', layer: 'releases', title: str(r.fix_version) || `Release ${str(r.type) || r.id}`,
|
|
120
|
+
tone: null, ref: { type: 'release', id: r.id } });
|
|
121
|
+
}
|
|
122
|
+
if (!n) why('releases', 'no release has a deploy or release date');
|
|
123
|
+
}
|
|
124
|
+
// reports: the issue date of a report file, and the next runs of a report schedule
|
|
125
|
+
if (unavailable.reports) why('reports');
|
|
126
|
+
else {
|
|
127
|
+
let n = 0;
|
|
128
|
+
for (const r of d.reports || []) {
|
|
129
|
+
if (!r || !r.cadence || !isDay(r.slot)) continue;
|
|
130
|
+
const date = r.issued && isDay(r.issued.at) ? day10(r.issued.at) : day10(r.slot);
|
|
131
|
+
n++;
|
|
132
|
+
put('reports', { id: `report:${r.cadence}:${day10(r.slot)}`, date, kind: 'report', layer: 'reports', title: `${r.cadence} report${r.issued ? '' : ' (not issued)'}`,
|
|
133
|
+
tone: r.issued ? 'ok' : null, ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
|
|
134
|
+
}
|
|
135
|
+
for (const s of d.reportSchedules || []) {
|
|
136
|
+
if (!s || !s.id) continue;
|
|
137
|
+
for (const dt of s.dates || []) {
|
|
138
|
+
if (!isDay(dt)) continue;
|
|
139
|
+
n++;
|
|
140
|
+
put('reports', { id: `report-sched:${s.id}:${day10(dt)}`, date: day10(dt), kind: 'report', layer: 'reports', title: `${str(s.name) || s.cadence || s.id} (scheduled)`, tone: null,
|
|
141
|
+
ref: { type: 'report', id: s.cadence || s.id } });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!n) why('reports', 'no report file and no report schedule gives an issue date');
|
|
145
|
+
}
|
|
146
|
+
// the PM's own items with a due date: markers only
|
|
147
|
+
if (unavailable.own) why('own');
|
|
148
|
+
else {
|
|
149
|
+
let n = 0;
|
|
150
|
+
for (const o of d.own || []) {
|
|
151
|
+
if (!o || !o.id || !isDay(o.due)) continue;
|
|
152
|
+
n++;
|
|
153
|
+
const date = day10(o.due);
|
|
154
|
+
put('own', { id: `own:${o.id}`, date, kind: 'own', layer: 'mine', title: str(o.title) || o.id, tone: toneOf(date, !!o.done, today), ref: { type: 'item', id: o.id } });
|
|
155
|
+
}
|
|
156
|
+
if (!n) why('own', 'none of your own items has a due date');
|
|
157
|
+
}
|
|
158
|
+
out.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.title < b.title ? -1 : a.title > b.title ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
159
|
+
const layers = out.slice(0, LAYER_CAP);
|
|
160
|
+
// a source that read fine but has nothing inside this range says so too, so an empty layer is never a silent one
|
|
161
|
+
for (const source of ['initiatives', 'plan_items', 'releases', 'reports', 'own']) {
|
|
162
|
+
if (note[source]) continue;
|
|
163
|
+
if (dateless[source] && !inside[source]) note[source] = 'nothing dated falls inside this range';
|
|
164
|
+
}
|
|
165
|
+
return { layers, layers_total: out.length, layers_note: note };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ── free time ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
169
|
+
|
|
170
|
+
/** An event as a span in epoch ms, or null for an all-day event or one with no usable start. */
|
|
171
|
+
function spanOf(e) {
|
|
172
|
+
if (!e || !hasTime(e.at || e.start)) return null;
|
|
173
|
+
const s = Date.parse(e.at || e.start);
|
|
174
|
+
if (!Number.isFinite(s)) return null;
|
|
175
|
+
let f = e.end ? Date.parse(e.end) : s + 30 * 60000;
|
|
176
|
+
if (!Number.isFinite(f) || f < s) f = s + 30 * 60000;
|
|
177
|
+
return { s, f };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Meetings that touch `date`, as minutes of that day. */
|
|
181
|
+
function minutesOn(spans, date) {
|
|
182
|
+
const a = localMs(date); const b = a + DAY_MS;
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const sp of spans) {
|
|
185
|
+
if (sp.f <= a || sp.s >= b) continue;
|
|
186
|
+
out.push({ from: sp.s <= a ? 0 : minuteOfDay(sp.s), to: sp.f >= b ? 1440 : minuteOfDay(sp.f) });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Free minutes inside working hours between two minutes of a day, given the merged busy intervals. */
|
|
192
|
+
function freeBetween(merged, from, until) {
|
|
193
|
+
const a = Math.max(DAY_START, from); const b = Math.min(DAY_END, until);
|
|
194
|
+
if (b <= a) return 0;
|
|
195
|
+
let busy = 0;
|
|
196
|
+
for (const [s, e] of merged) busy += Math.max(0, Math.min(e, b) - Math.max(s, a));
|
|
197
|
+
return b - a - busy;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The prep steps of an event: { steps:[text], done:n }. Reads prep / prep_done, else steps.before. */
|
|
201
|
+
function prepOf(e) {
|
|
202
|
+
if (!e) return { steps: [], done: 0 };
|
|
203
|
+
if (Array.isArray(e.prep) && e.prep.length) {
|
|
204
|
+
const steps = e.prep.map((x) => str(typeof x === 'string' ? x : x && (x.t || x.text))).filter(Boolean);
|
|
205
|
+
const done = Math.min(steps.length, Math.max(0, Number(e.prep_done) || 0));
|
|
206
|
+
return { steps, done, doneFlags: steps.map((_, i) => i < done) };
|
|
207
|
+
}
|
|
208
|
+
const before = e.steps && Array.isArray(e.steps.before) ? e.steps.before : [];
|
|
209
|
+
const steps = before.map((x) => str(x && (x.t || x.text))).filter(Boolean);
|
|
210
|
+
const flags = before.filter((x) => str(x && (x.t || x.text))).map((x) => !!x.done);
|
|
211
|
+
return { steps, done: flags.filter(Boolean).length, doneFlags: flags };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function nextWorkday(date) { let d = addDays(date, 1); while (!isWorkday(d)) d = addDays(d, 1); return d; }
|
|
215
|
+
|
|
216
|
+
function freeFrom(events, range, today, opts = {}) {
|
|
217
|
+
const view = range.view;
|
|
218
|
+
const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : localMs(today);
|
|
219
|
+
const spans = (events || []).map((e) => ({ e, ...(spanOf(e) || {}) })).filter((x) => x.s !== undefined);
|
|
220
|
+
const memo = new Map();
|
|
221
|
+
const loadOf = (date) => { if (!memo.has(date)) memo.set(date, memory.dayLoad(minutesOn(spans, date))); return memo.get(date); };
|
|
222
|
+
|
|
223
|
+
// prep at risk: a coming event with steps left and less free time before it than PER_STEP for each step
|
|
224
|
+
const risky = [];
|
|
225
|
+
for (const x of spans) {
|
|
226
|
+
const p = prepOf(x.e);
|
|
227
|
+
const left = p.steps.length - p.done;
|
|
228
|
+
if (!left || x.f <= nowMs) continue;
|
|
229
|
+
const date = localDay(x.s);
|
|
230
|
+
const startMin = minuteOfDay(x.s);
|
|
231
|
+
let free = freeBetween(loadOf(date).merged, date === today ? minuteOfDay(nowMs) : DAY_START, startMin);
|
|
232
|
+
for (let dd = today; dd < date; dd = addDays(dd, 1)) {
|
|
233
|
+
if (!isWorkday(dd)) continue;
|
|
234
|
+
free += Math.min(PER_DAY, freeBetween(loadOf(dd).merged, dd === today ? minuteOfDay(nowMs) : DAY_START, DAY_END));
|
|
235
|
+
}
|
|
236
|
+
if (free < left * PER_STEP) risky.push({ id: x.e.id, title: str(x.e.title) || 'a meeting', date, s: x.s, left });
|
|
237
|
+
}
|
|
238
|
+
risky.sort((a, b) => a.s - b.s);
|
|
239
|
+
|
|
240
|
+
// items due soon, most pressing first: overdue and nearest due date, then the higher priority, then the title
|
|
241
|
+
const prio = (p) => (/^P[1-3]$/.test(String(p || '')) ? Number(String(p)[1]) : 9);
|
|
242
|
+
const due = [];
|
|
243
|
+
for (const o of opts.own || []) if (o && o.id && !o.done && isDay(o.due)) due.push({ id: o.id, type: 'item', title: str(o.title) || o.id, due: day10(o.due), p: prio(o.priority) });
|
|
244
|
+
for (const p of opts.planItems || []) if (p && p.id && p.state !== 'done' && isDay(p.due)) due.push({ id: p.id, type: 'plan-item', title: str(p.title) || p.id, due: day10(p.due), p: 5 });
|
|
245
|
+
due.sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : a.p - b.p || (a.title < b.title ? -1 : a.title > b.title ? 1 : a.id < b.id ? -1 : 1)));
|
|
246
|
+
|
|
247
|
+
const usedEvent = new Set(); const usedItem = new Set();
|
|
248
|
+
const suggest = (date, block) => {
|
|
249
|
+
const next = nextWorkday(date);
|
|
250
|
+
const hit = risky.find((r) => !usedEvent.has(r.id) && (r.date === next || (r.date === date && r.s >= localMs(date) + block.from * 60000)));
|
|
251
|
+
if (hit) {
|
|
252
|
+
usedEvent.add(hit.id);
|
|
253
|
+
return { text: `Prepare for "${hit.title}": ${hit.left} ${hit.left === 1 ? 'step is' : 'steps are'} left and little free time remains before it.`, ref: { type: 'event', id: hit.id } };
|
|
254
|
+
}
|
|
255
|
+
const top = due.find((x) => !usedItem.has(x.id) && diffDays(x.due, date) <= SUGGEST_DAYS);
|
|
256
|
+
if (top) {
|
|
257
|
+
usedItem.add(top.id);
|
|
258
|
+
return { text: top.due < date ? `Work on "${top.title}". It was due ${top.due}.` : `Work on "${top.title}". It is due ${top.due}.`, ref: { type: top.type, id: top.id } };
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const days = []; let count = 0;
|
|
264
|
+
const light = LIGHT_VIEWS.includes(view);
|
|
265
|
+
for (let dd = range.start < today ? today : range.start; dd <= range.end; dd = addDays(dd, 1)) {
|
|
266
|
+
if (!isWorkday(dd)) continue;
|
|
267
|
+
const blocks = loadOf(dd).gaps.map(([a, b]) => ({ from: hhmm(a), to: hhmm(b), minutes: b - a, _a: a, _b: b }));
|
|
268
|
+
count += blocks.length;
|
|
269
|
+
if (light) continue;
|
|
270
|
+
const out = blocks.map((b) => ({ from: b.from, to: b.to, minutes: b.minutes, suggestion: suggest(dd, { from: b._a, to: b._b }) }));
|
|
271
|
+
if (out.length) days.push({ date: dd, blocks: out });
|
|
272
|
+
}
|
|
273
|
+
return { min_minutes: MIN_BLOCK, day_start: hhmm(DAY_START), day_end: hhmm(DAY_END), days, count };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ── context ───────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
277
|
+
|
|
278
|
+
function noteMatches(n, e) {
|
|
279
|
+
const m = n && n.meeting;
|
|
280
|
+
if (!m) return false;
|
|
281
|
+
if (m.event_id && e.id && String(m.event_id) === String(e.id)) return true;
|
|
282
|
+
const a = Date.parse(m.start); const b = Date.parse(e.at || e.start);
|
|
283
|
+
return Number.isFinite(a) && Number.isFinite(b) && Math.floor(a / 60000) === Math.floor(b / 60000) && norm(m.title) === norm(e.title);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function referencesEvent(text, e) {
|
|
287
|
+
const t = norm(text);
|
|
288
|
+
if (!t) return false;
|
|
289
|
+
if (e.id && t.includes(norm(e.id)) && norm(e.id).length >= 6) return true;
|
|
290
|
+
const title = norm(e.title);
|
|
291
|
+
return title.length >= 4 && t.includes(title);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function movedBy(e, eventEnd, src) {
|
|
295
|
+
const lo = eventEnd; const hi = eventEnd + WINDOW_MS;
|
|
296
|
+
const within = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) && t >= lo && t <= hi; };
|
|
297
|
+
const out = [];
|
|
298
|
+
const same = (ini, text) => (ini && e.initiative && ini === e.initiative) || referencesEvent(text, e);
|
|
299
|
+
for (const p of src.planItems || []) {
|
|
300
|
+
if (!p || !p.id || !same(p.initiative, p.title)) continue;
|
|
301
|
+
if (within(p.createdAt)) out.push({ kind: 'moved', text: `Created plan item: ${str(p.title) || p.id}`, ref: { type: 'plan-item', id: p.id }, at: p.createdAt });
|
|
302
|
+
if (within(p.doneAt)) out.push({ kind: 'moved', text: `Closed plan item: ${str(p.title) || p.id}`, ref: { type: 'plan-item', id: p.id }, at: p.doneAt });
|
|
303
|
+
}
|
|
304
|
+
for (const o of src.ownItems || []) {
|
|
305
|
+
if (!o || !o.id || !same(o.initiative, o.title)) continue;
|
|
306
|
+
if (within(o.created_at)) out.push({ kind: 'moved', text: `Created your item: ${str(o.title) || o.id}`, ref: { type: 'item', id: o.id }, at: o.created_at });
|
|
307
|
+
if (within(o.done_at)) out.push({ kind: 'moved', text: `Closed your item: ${str(o.title) || o.id}`, ref: { type: 'item', id: o.id }, at: o.done_at });
|
|
308
|
+
}
|
|
309
|
+
out.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.text < b.text ? -1 : 1));
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function capKinds(items) {
|
|
314
|
+
const n = {};
|
|
315
|
+
return items.filter((x) => { n[x.kind] = (n[x.kind] || 0) + 1; return n[x.kind] <= (PER_KIND[x.kind] || 8); });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function contextFrom(events, range, src, opts = {}) {
|
|
319
|
+
const s = src || {};
|
|
320
|
+
const today = opts.today;
|
|
321
|
+
const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : localMs(today);
|
|
322
|
+
const inside = [];
|
|
323
|
+
for (const e of events || []) {
|
|
324
|
+
const at = e && (e.at || e.start);
|
|
325
|
+
if (!e || !e.id || !isDay(at)) continue;
|
|
326
|
+
const a = day10(at); const b = e.end && isDay(e.end) ? day10(e.end) : a;
|
|
327
|
+
if (overlaps(a, b < a ? a : b, range)) inside.push(e);
|
|
328
|
+
}
|
|
329
|
+
const mid = (e) => (spanOf(e) ? spanOf(e).s : localMs(e.at || e.start));
|
|
330
|
+
inside.sort((x, y) => Math.abs(mid(x) - nowMs) - Math.abs(mid(y) - nowMs) || (String(x.id) < String(y.id) ? -1 : 1));
|
|
331
|
+
const context = {};
|
|
332
|
+
for (const e of inside.slice(0, CONTEXT_CAP)) {
|
|
333
|
+
const sp = spanOf(e);
|
|
334
|
+
const endMs = sp ? sp.f : localMs(e.end && isDay(e.end) ? e.end : e.at) + DAY_MS; // an all-day event ends at the end of its day
|
|
335
|
+
const past = endMs <= nowMs;
|
|
336
|
+
const notes = (s.notes || []).filter((n) => noteMatches(n, e));
|
|
337
|
+
const items = [];
|
|
338
|
+
if (past) {
|
|
339
|
+
for (const n of notes) {
|
|
340
|
+
if (n.private) continue; // a private note shows its title and nothing of its content
|
|
341
|
+
for (const d of n.decisions || []) if (str(d && d.text)) items.push({ kind: 'decision', text: str(d.text), ref: { type: 'note', id: n.id } });
|
|
342
|
+
for (const a of n.actions || []) {
|
|
343
|
+
if (!str(a && a.text)) continue;
|
|
344
|
+
const tail = [a.owner && `owner: ${a.owner}`, a.due && `due: ${a.due}`].filter(Boolean).join(', ');
|
|
345
|
+
items.push({ kind: 'action', text: str(a.text) + (tail ? ` (${tail})` : ''), ref: { type: 'note', id: n.id } });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const moved = movedBy({ ...e, initiative: e.initiative || (notes.find((n) => n.initiative) || {}).initiative || null }, endMs, s);
|
|
349
|
+
items.push(...moved);
|
|
350
|
+
} else {
|
|
351
|
+
const p = prepOf(e);
|
|
352
|
+
p.steps.forEach((t, i) => items.push({ kind: p.doneFlags && p.doneFlags[i] ? 'prepared' : 'todo', text: t, ref: { type: 'event', id: e.id } }));
|
|
353
|
+
if (str(e.agent_prep_notes)) items.push({ kind: 'prepared', text: 'Prep notes from your agent session are saved.', ref: { type: 'event', id: e.id } });
|
|
354
|
+
for (const m of e.pm_notes || []) if (str(m && m.text)) items.push({ kind: 'prepared', text: `Your note: ${str(m.text)}`, ref: { type: 'event', id: e.id }, ...(m.at ? { at: m.at } : {}) });
|
|
355
|
+
for (const n of notes) items.push({ kind: 'prepared', text: `A meeting note is linked: ${str(n.title) || n.id}`, ref: { type: 'note', id: n.id } });
|
|
356
|
+
}
|
|
357
|
+
const kept = capKinds(items);
|
|
358
|
+
context[e.id] = { phase: past ? 'past' : 'upcoming', held: kept.length > 0, items: kept };
|
|
359
|
+
}
|
|
360
|
+
return { context, context_total: inside.length };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* ── all three ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
364
|
+
|
|
365
|
+
function build({ range, today, nowMs, events, dated, src, own, planItemsDue }) {
|
|
366
|
+
const l = layersFrom(dated, range, today);
|
|
367
|
+
const free = freeFrom(events, range, today, { nowMs, own: own || (dated && dated.own) || [], planItems: planItemsDue || (dated && dated.planItems) || [] });
|
|
368
|
+
const c = contextFrom(events, range, src, { today, nowMs });
|
|
369
|
+
return { layers: l.layers, layers_total: l.layers_total, layers_note: l.layers_note, free, context: c.context, context_total: c.context_total };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
module.exports = { layersFrom, freeFrom, contextFrom, build, toneOf, prepOf, spanOf, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* and the fold of the whole file is the current list. Stage is never stored; a list is derived from the due
|
|
7
7
|
* date, priority and waiting-on fields by placement() below.
|
|
8
8
|
*
|
|
9
|
+
* The Work page (console3) reads a second placement, workPlacement(), into five views: inbox, today, upcoming,
|
|
10
|
+
* waiting, someday. placement() and its five lists stay for the old routes. A sixth view, Review, is a queue
|
|
11
|
+
* (reviewQueue) of items nobody has touched for a week. fold() stamps `touched_at` from the event's `at`.
|
|
12
|
+
*
|
|
9
13
|
* SINGLE WRITER: appendEvent() is the only function that writes the file. Callers pass the event; they never
|
|
10
14
|
* choose `at` or `by` themselves (attribution comes from the call path, as in plan-items.js).
|
|
11
15
|
*/
|
|
@@ -14,6 +18,11 @@ const path = require('path');
|
|
|
14
18
|
|
|
15
19
|
const UNDO_MS = 8000;
|
|
16
20
|
const LISTS = ['waiting', 'today', 'week', 'triage', 'later'];
|
|
21
|
+
const WORK_LISTS = ['inbox', 'today', 'upcoming', 'waiting', 'someday'];
|
|
22
|
+
const REVIEW_DAYS = 7;
|
|
23
|
+
const REVIEW_FROM = ['inbox', 'someday', 'waiting'];
|
|
24
|
+
// A legacy list_override (set by /move) maps onto the Work views.
|
|
25
|
+
const LEGACY_TO_WORK = { today: 'today', triage: 'inbox', waiting: 'waiting', week: 'upcoming', later: 'someday' };
|
|
17
26
|
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
18
27
|
|
|
19
28
|
const DUE_WORD = /\b(?:due|by)\s+(today|tomorrow|sun(?:day)?|mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:r(?:s(?:day)?)?)?|fri(?:day)?|sat(?:urday)?)\b/i;
|
|
@@ -66,15 +75,71 @@ function placement(item, today) {
|
|
|
66
75
|
return 'later';
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
// Place an item in one of the five Work views (console3 PRD R2). The one place this is decided; the client never
|
|
79
|
+
// copies an item between views. `today` is a Date. Rules, in order: a legacy override, parked is Someday, waiting
|
|
80
|
+
// on someone is Waiting, a defer date after today is Upcoming, flagged is Today, due today or earlier is Today,
|
|
81
|
+
// a later due date is Upcoming, no date is Inbox. One addition to R2, for R6 ("the item returns to Today on that
|
|
82
|
+
// date"): an undated item whose defer date has arrived is Today, not Inbox.
|
|
83
|
+
function workPlacement(item, today) {
|
|
84
|
+
if (item.list_override && LEGACY_TO_WORK[item.list_override]) return LEGACY_TO_WORK[item.list_override];
|
|
85
|
+
if (item.parked) return 'someday';
|
|
86
|
+
if (item.waiting_on) return 'waiting';
|
|
87
|
+
const t = iso(today);
|
|
88
|
+
if (item.defer && item.defer > t) return 'upcoming';
|
|
89
|
+
if (item.flagged) return 'today';
|
|
90
|
+
if (item.due && item.due <= t) return 'today';
|
|
91
|
+
if (item.due) return 'upcoming';
|
|
92
|
+
if (item.defer) return 'today';
|
|
93
|
+
return 'inbox';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PRIORITY_RANK = { P1: 1, P2: 2, P3: 3 };
|
|
97
|
+
const prio = (i) => PRIORITY_RANK[i.priority] || 9;
|
|
98
|
+
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
99
|
+
// The day an Upcoming item arrives: its defer date while that is still ahead, else its due date.
|
|
100
|
+
function arrivesOn(item, todayIso) { return item.defer && item.defer > todayIso ? item.defer : (item.due || ''); }
|
|
101
|
+
|
|
102
|
+
// Sort one Work view. today: overdue first (oldest first), then flagged, then by due date. upcoming: by arrival
|
|
103
|
+
// day. inbox and someday: newest first. waiting: the one waited on longest first.
|
|
104
|
+
function sortWorkList(name, items, today) {
|
|
105
|
+
const t = iso(today);
|
|
106
|
+
const byNew = (a, b) => cmpStr(b.created_at || '', a.created_at || '');
|
|
107
|
+
const out = [...items];
|
|
108
|
+
if (name === 'today') {
|
|
109
|
+
const group = (i) => (i.due && i.due < t ? 0 : i.flagged ? 1 : 2);
|
|
110
|
+
out.sort((a, b) => group(a) - group(b) || cmpStr(a.due || '9999', b.due || '9999') || prio(a) - prio(b) || byNew(a, b));
|
|
111
|
+
} else if (name === 'upcoming') {
|
|
112
|
+
out.sort((a, b) => cmpStr(arrivesOn(a, t) || '9999', arrivesOn(b, t) || '9999') || prio(a) - prio(b) || byNew(a, b));
|
|
113
|
+
} else if (name === 'waiting') {
|
|
114
|
+
out.sort((a, b) => cmpStr(a.waiting_since || a.created_at || '', b.waiting_since || b.created_at || ''));
|
|
115
|
+
} else out.sort(byNew);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The review queue: open items in Inbox, Someday or Waiting that nobody touched for `days` days, most stale first.
|
|
120
|
+
// `now` is a Date. An item with no readable touched_at is left out, never guessed.
|
|
121
|
+
function reviewQueue(items, now, days = REVIEW_DAYS) {
|
|
122
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
123
|
+
const cutoff = now.getTime() - days * 86400000;
|
|
124
|
+
const stamp = (i) => Date.parse(i.touched_at || i.created_at || '');
|
|
125
|
+
return items
|
|
126
|
+
.filter((i) => !i.done && REVIEW_FROM.includes(workPlacement(i, today)) && Number.isFinite(stamp(i)) && stamp(i) <= cutoff)
|
|
127
|
+
.sort((a, b) => stamp(a) - stamp(b));
|
|
128
|
+
}
|
|
129
|
+
|
|
69
130
|
// Fold the event lines into the current items, keyed by id. A dropped or deleted item is removed.
|
|
70
131
|
function fold(lines) {
|
|
71
132
|
const items = new Map();
|
|
72
133
|
for (const ev of lines) {
|
|
73
134
|
if (!ev || !ev.id) continue;
|
|
74
|
-
if (ev.event === 'created') items.set(ev.id, { ...ev.fields, id: ev.id, source: ev.source || 'local', created_at: ev.at });
|
|
75
|
-
else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields });
|
|
76
|
-
else if (ev.event === 'done' && items.has(ev.id))
|
|
77
|
-
|
|
135
|
+
if (ev.event === 'created') items.set(ev.id, { ...ev.fields, id: ev.id, source: ev.source || 'local', created_at: ev.at, touched_at: ev.at });
|
|
136
|
+
else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields, touched_at: ev.at });
|
|
137
|
+
else if (ev.event === 'done' && items.has(ev.id)) {
|
|
138
|
+
const outcome = typeof ev.outcome === 'string' && ev.outcome.trim() ? { outcome: ev.outcome.trim() } : {};
|
|
139
|
+
items.set(ev.id, { ...items.get(ev.id), done: true, done_at: ev.at, touched_at: ev.at, ...outcome });
|
|
140
|
+
}
|
|
141
|
+
else if (ev.event === 'reopened' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: false, done_at: null, touched_at: ev.at });
|
|
142
|
+
else if (ev.event === 'kept' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), touched_at: ev.at });
|
|
78
143
|
else if (ev.event === 'dropped' || ev.event === 'deleted') items.delete(ev.id);
|
|
79
144
|
}
|
|
80
145
|
return [...items.values()];
|
|
@@ -92,7 +157,7 @@ function removalEvent(item, now) {
|
|
|
92
157
|
function undoEvent(lines, id, now = new Date()) {
|
|
93
158
|
const removal = [...lines].reverse().find((ev) => ev && ev.id === id && (ev.event === 'deleted' || ev.event === 'dropped'));
|
|
94
159
|
if (!removal || !removal.snapshot || !canUndo(removal.at, now)) return null;
|
|
95
|
-
const { id: _id, source, created_at, ...fields } = removal.snapshot;
|
|
160
|
+
const { id: _id, source, created_at, touched_at, ...fields } = removal.snapshot;
|
|
96
161
|
return { event: 'created', id, fields, source: source || 'local', restored: true };
|
|
97
162
|
}
|
|
98
163
|
|
|
@@ -112,4 +177,4 @@ function readLines(file) {
|
|
|
112
177
|
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } });
|
|
113
178
|
}
|
|
114
179
|
|
|
115
|
-
module.exports = { UNDO_MS, LISTS, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
|
|
180
|
+
module.exports = { UNDO_MS, LISTS, WORK_LISTS, REVIEW_DAYS, workPlacement, sortWorkList, reviewQueue, arrivesOn, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
|
|
@@ -216,4 +216,4 @@ function sig(w) {
|
|
|
216
216
|
return [reportsOf(w.root).sig, schedulesStamp(), kill, hour].join(':');
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
|
|
219
|
+
module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, readSchedules, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
|
|
@@ -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;
|