create-pmos 0.509.0 → 0.511.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 +247 -134
- 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/board-programs-test.js +3 -0
- package/workspace/briefing-premium-board-test.js +6 -7
- package/workspace/changelog.json +352 -909
- package/workspace/ev-everywhere-test.js +4 -4
- package/workspace/guide/home.md +2 -1
- package/workspace/guide/initiative-boards.md +3 -1
- package/workspace/guide/programs.md +2 -1
- package/workspace/guide/settings.md +9 -5
- package/workspace/lib/board-premium.js +3 -2
- package/workspace/lib/kit-update.js +19 -4
- package/workspace/lib/routes-boards.js +15 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/panels-test.js +68 -1
- package/workspace/public/pm/bd-cols.js +2 -1
- package/workspace/public/pm/bd-deliveries.js +1 -1
- package/workspace/public/pm/bd-prog.js +10 -5
- package/workspace/public/pm/bd-span.js +15 -10
- package/workspace/public/pm/cozy-board.css +8 -2
- package/workspace/public/pm/cozy-ev.css +24 -10
- package/workspace/public/pm/cozy-live-pill.css +9 -0
- package/workspace/public/pm/cozy-live-pill.js +50 -3
- package/workspace/public/pm/cozy-panels.css +9 -0
- package/workspace/public/pm/cozy-panels.js +99 -16
- package/workspace/public/pm/cozy-settings.js +34 -15
- package/workspace/public/pm/cozy-viewer.js +15 -3
- package/workspace/public/pm/home-chat.js +4 -0
- package/workspace/public/pm/page-chat.css +9 -0
- package/workspace/public/pm/page-chat.js +90 -0
- package/workspace/public/pm/premium-board.js +9 -0
- package/workspace/public/pm/readonly.js +3 -1
- package/workspace/public/pm/upd-weeks.js +27 -9
- package/workspace/public/premium-board.html +2 -2
- package/workspace/test-lists/closed-not-late.json +5 -0
|
@@ -150,20 +150,20 @@ const put = (root, rel, text) => { fs.mkdirSync(path.dirname(path.join(root, rel
|
|
|
150
150
|
const hits = { check: 0, update: 0, upgrade: 0 };
|
|
151
151
|
const day = (n) => new Date(Date.now() - n * 864e5).toISOString();
|
|
152
152
|
await pg.route('**/api/kit/status*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, app: { version: '0.442.0' }, kit: { present: true, version: '0.442.0' }, latest: '0.451.0', appBehind: true, kitBehind: true, checkDaily: true,
|
|
153
|
-
changes: [{ v: '0.451.0', at: day(0), prs: [{ n: 1098, t: 'Claude settings', s: 'Settings has a Claude section.', k: '
|
|
153
|
+
changes: [{ v: '0.451.0', at: day(0), prs: [{ n: 1098, t: 'Claude settings', s: 'Settings has a Claude section.', k: 'imp', h: 'Claude in Settings', d: 'Claude had no settings of its own. Now Settings has a Claude section.', top: 'Claude has its own place in Settings.' }, { n: 1100, t: 'Hide linked worktrees', s: 'They no longer show as repositories.', k: 'fix' }] }, { v: '0.441.0', at: day(8), prs: [{ n: 1078, t: 'Ready-made dashboards', s: 'Every program has one.', k: 'new' }] }] }) }));
|
|
154
154
|
await pg.route('**/api/kit/check', (r) => { hits.check++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, latest: '0.451.0', appBehind: true, kitBehind: true, latestChanges: [] }) }); });
|
|
155
155
|
await pg.route('**/api/kit/update', (r) => { hits.update++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true }) }); });
|
|
156
156
|
await pg.route('**/api/kit/upgrade-app', (r) => { hits.upgrade++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: false, error: 'stubbed: not installing' }) }); });
|
|
157
157
|
await pg.keyboard.press('Escape'); await until(() => pg.evaluate(() => !document.querySelector('#evSettings:not([hidden])')));
|
|
158
158
|
await menu(pg); await pg.click('#czMenu [data-cz-settings]'); await pg.waitForSelector('#evSettings:not([hidden])'); // reopen: the routes above answer the status read
|
|
159
159
|
await pg.click('#evSettings [data-ev-sec="updates"]');
|
|
160
|
-
const wk = await until(() => pg.evaluate(() => { const d = document.querySelector('#evSettings'); const h = d.querySelector('.ev-wkh'); return h ? { head: h.textContent,
|
|
160
|
+
const wk = await until(() => pg.evaluate(() => { const d = document.querySelector('#evSettings'); const h = d.querySelector('.ev-wkh'); return h ? { head: h.textContent, sum: [...d.querySelectorAll('.ev-wk > .ev-sum p')].map((x) => x.textContent), kit: (d.querySelector('.ev-wk .ev-rv') || {}).textContent || '', dots: [...d.querySelectorAll('.ev-wk > .ev-rel .ev-dot')].map((x) => x.title), t: d.querySelector('.ev-wk').textContent, arc: !!d.querySelector('[data-ev-arc][aria-expanded="false"]'), rows: d.querySelectorAll('[data-ev-wk]').length } : null; }), 20000);
|
|
161
161
|
const first = await (async () => { await sleep(800); return hits.check; })();
|
|
162
162
|
await pg.click('#evSettings [data-ev-sec="general"]'); await pg.click('#evSettings [data-ev-sec="updates"]'); await sleep(500);
|
|
163
163
|
ok('W2 opening Updates asks for the latest kit by itself (one read for each workspace), and only once while Settings stays open', first >= 1 && hits.check === first, JSON.stringify(hits));
|
|
164
|
-
ok('W2
|
|
164
|
+
ok('W2 design E: this week opens with its summary line, the newest kit at version level, each change a story with a New or Fixed dot, no PR numbers, the earlier week folded in a closed Archive', !!wk && /^This week/.test(wk.head) && /2 changes/.test(wk.head) && wk.sum.join() === 'Claude has its own place in Settings.' && /^0\.451\.0latest/.test(wk.kit) && wk.dots.join() === 'New,Fixed' && /Claude in Settings/.test(wk.t) && /Now Settings has a Claude section/.test(wk.t) && !/#1100|#1098/.test(wk.t) && wk.arc && wk.rows === 0, JSON.stringify(wk));
|
|
165
165
|
await pg.click('#evSettings [data-ev-arc]'); await pg.click('#evSettings [data-ev-wk]');
|
|
166
|
-
ok('W2 the Archive opens to the earlier weeks, and a week opens on press', !!(await until(() => pg.evaluate(() => /Ready-made dashboards
|
|
166
|
+
ok('W2 the Archive opens to the earlier weeks, and a week opens on press', !!(await until(() => pg.evaluate(() => /Ready-made dashboards/.test(document.querySelector('#evSettings').textContent)))));
|
|
167
167
|
await snap(pg, 'updates-1440-light');
|
|
168
168
|
await pg.click('#evSettings [data-ev-upd="update"]');
|
|
169
169
|
await until(() => hits.upgrade > 0, 8000);
|
package/workspace/guide/home.md
CHANGED
|
@@ -160,7 +160,8 @@ Some features are still not designed. Home keeps each one usable as a plain card
|
|
|
160
160
|
|
|
161
161
|
## The other pages
|
|
162
162
|
|
|
163
|
-
- **Board.** New initiative takes links and files, starts in Triage and asks only the questions that the material does not answer. **Later** keeps the questions as the next step. The Board also has List, By stage and a span picker. Documents are in each initiative's Documents fold and in Search. On a phone, **Metrics** and **Cancelled and archived** are in every view.
|
|
163
|
+
- **Board.** New initiative takes links and files, starts in Triage and asks only the questions that the material does not answer. **Later** keeps the questions as the next step. The Board also has List, By stage and a span picker. Documents are in each initiative's Documents fold and in Search. On a phone, **Metrics** and **Cancelled and archived** are in every view. On a computer, press **Discuss in chat** on an initiative's or a program's panel to open the chat about it in a panel on the Board. It is the same conversation as on Home. At 1600 px and wider the chat opens beside the panel; on a laptop it slides over it.
|
|
164
|
+
- **Dock a panel left or right (Board and Intelligence).** Drag a panel's header to the left or the right half of the window, and drop it when that half turns blue. With the keyboard, press **Arrange** in the panel's header, then **Dock on the left** or **Dock on the right**. Each kind of panel stays on its side after a reload. The divider resizes the panel on either side. The Live pill's list has a grab bar on top: drag it to the left or the right side, or press it to move the list to the other side.
|
|
164
165
|
- **Meetings.** Replay with screen captures and kept points, Focus, a presenter view, Add material, the After view, a Day, Week and Month calendar, and Notes.
|
|
165
166
|
- **Reports.** The Brief, the report states, a document reader and a live page reader.
|
|
166
167
|
- **Insights.** A goal's detail with its chart and readings, targets on each tile, Library flows and a Portfolio view.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Initiatives on the console: Portfolio, boards and workflows"
|
|
4
4
|
description: "How to use the Initiatives tab of the console page (/): the Portfolio of boards, a board and its nine tabs, an initiative with its own workflow and stage records, and the Build and deliver setting."
|
|
5
|
-
timestamp: 2026-11-
|
|
5
|
+
timestamp: 2026-11-03
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Initiatives on the console: Portfolio, boards and workflows
|
|
@@ -58,6 +58,8 @@ Each initiative has a **stage** and a **status**. The stage is the step in its o
|
|
|
58
58
|
| Done | It reached the end of its workflow, or it was accepted or closed. |
|
|
59
59
|
| Cancelled | You stopped it. It is counted as cancelled, not done. |
|
|
60
60
|
|
|
61
|
+
An initiative in the last stage of its workflow (for example **Closed**, or a stage in the Done category) is never Late or At risk. On the Timeline its bar ends at its own end date. It has no line to Today, no late dot and no late milestone.
|
|
62
|
+
|
|
61
63
|
A cancelled initiative is hidden from every view. The list footer says how many are hidden, for example "1 cancelled hidden". Press it to see them, or pick Cancelled in the Status filter.
|
|
62
64
|
|
|
63
65
|
Press **…** on a row to open its menu. It is the initiative's short menu: Split, Merge into, Program, Copy, The record and **Delete** (see [Home](home.md#an-initiative)). Press the stage name on the initiative's stage card for the stage picker:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Programs on the Board: All products, groupings and the program panel"
|
|
4
4
|
description: "A program groups initiatives across products. How it shows on the Board (stage track, Kanban, timeline and List), the All products mode, the By product grouping, the program panel and the side panel width."
|
|
5
|
-
timestamp: 2026-11-
|
|
5
|
+
timestamp: 2026-11-03
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Programs on the Board
|
|
@@ -18,6 +18,7 @@ A cancelled or archived initiative leaves its program's views: the band, the pro
|
|
|
18
18
|
- The product picker is the same chip and list as on Home (see [Home](home.md)). It shows the tiles of the ticked products, or **All**. When the list closes, the Board shows the ticked products, merged as on All products. The pick is the saved setting that Home and Insights share, so a reload keeps it, and a product that you untick here goes from Home too.
|
|
19
19
|
- An address with `?ws=`, `?board=`, `?all=1` or `?pick=` is a one-off view: the Board shows what the address names and does not change the saved pick. A change in the list saves the pick and opens the Board without those parts.
|
|
20
20
|
- A program comes first. Its band shows the stage spread of its initiatives, the next milestone and how many initiatives are ready for it. Under the band, the initiatives sit under a small heading for each product. Then each product has its own group.
|
|
21
|
+
- A product's group head works like a program's: press the product's tile or name to open its page in Home's Viewer. Press the chevron to fold the group. This works in every view: the stage track, Kanban, the timeline and List. On a shared board, the product's name is plain text.
|
|
21
22
|
- To group by product only, open **Filter** and choose **By product**. A program member then shows a small layers icon and stays under its product in every view, the timeline included. A chip shows while By product is on.
|
|
22
23
|
- **Kanban** shows programs as swimlanes, then products. Press a column header to fold the column to a strip with its count. An empty column starts folded. The Board remembers each fold.
|
|
23
24
|
- The **timeline** draws a program row that brackets its initiatives' bars, on every span. Program milestones are diamonds. Milestones that are close together merge into one diamond with a count.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Settings in console3: one home for each setting"
|
|
4
4
|
description: "How to open Settings in console3, what each section holds, what the scope tags and the environment notes mean, and where the settings are kept."
|
|
5
|
-
timestamp: 2026-11-
|
|
5
|
+
timestamp: 2026-11-03
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Settings in console3: one home for each setting
|
|
@@ -67,12 +67,16 @@ Open **Updates**. PMOS asks the npm registry for the latest version at once, and
|
|
|
67
67
|
|
|
68
68
|
### What's new
|
|
69
69
|
|
|
70
|
-
Under the update card, **Updates** shows what changed in the published kits. **This week** is open: it is the one you need. A week runs Monday to Sunday.
|
|
70
|
+
Under the update card, **Updates** shows what changed in the published kits. **This week** is open: it is the one you need. A week runs Monday to Sunday. The heading gives the dates and the number of changes. When there is nothing for this week yet, the newest week with changes is the open one.
|
|
71
71
|
|
|
72
|
-
-
|
|
73
|
-
-
|
|
72
|
+
- **Summary.** Up to three lines on top say the most important changes of the week. Claude writes them when it makes the change.
|
|
73
|
+
- **The newest kit** comes next, with its version and **latest** when it is the newest published kit.
|
|
74
|
+
- **Earlier this week** lists the other changes of the week as one list, the most important ones first. The list shows seven changes; press **N more** to show the rest.
|
|
75
|
+
- Each change is a short story: a plain name, then what was missing and what is different now. Claude writes it for you. A blue dot marks **New** and an amber dot marks **Fixed**. A change with no dot is **Improved**. The most important changes always show as **New**.
|
|
76
|
+
- There are no pull request numbers and no kit ranges. Work that you cannot see in PMOS (notes, tests, run logs) is left out.
|
|
77
|
+
- **Archive** holds every earlier week, last week included. Press **Archive** to list them, then press a week to read it.
|
|
74
78
|
- A week shows **Ready to install** when one of its kits is newer than the version you run.
|
|
75
|
-
- A
|
|
79
|
+
- A change published before Claude wrote stories shows the pull request's title and its sentence instead.
|
|
76
80
|
- **What's new** is not a section of its own any more. The button on the update card in **General** opens **Updates**.
|
|
77
81
|
|
|
78
82
|
## Claude: the profile and the API key
|
|
@@ -275,10 +275,11 @@ function rowOf(r, wf, today) {
|
|
|
275
275
|
['Progress', pctOf(r.progress) != null ? pctOf(r.progress) + '%' : ''], ['Starts', r.start ? shortDate(r.start) : ''], ['Ends', r.end ? shortDate(r.end) : ''],
|
|
276
276
|
['Last changed', r.updated ? shortDate(String(r.updated).slice(0, 10)) : ''], ['Possible duplicate of', r.dupOf], ['Warnings', (r.warnings || []).map((w) => (typeof w === 'string' ? w : w && (w.text || w.message || w.id)) || '').filter(Boolean).join('; ')],
|
|
277
277
|
].filter((x) => x[1]);
|
|
278
|
-
|
|
278
|
+
// a closed initiative (its workflow's terminal stage, lib/routes-boards.js closedOf) is never over: no line to Today, no days over (batch 26 item 5)
|
|
279
|
+
const over = r.end && today && days(r.end, today) > 0 && r.status !== 'done' && !r.closed;
|
|
279
280
|
return {
|
|
280
281
|
key: 'board:' + r.id, id: r.id, title: clip(r.title, 140), sub: [r.owner || 'No owner', r.lane].filter(Boolean).join(' · '),
|
|
281
|
-
status: r.status || null, statusLabel: r.status && r.status !== 'on track' ? (STATUS_LABEL[r.status] || r.status) : null, bad, late: r.status === 'late',
|
|
282
|
+
status: r.status || null, statusLabel: r.status && r.status !== 'on track' ? (STATUS_LABEL[r.status] || r.status) : null, bad, late: r.status === 'late', closed: !!r.closed,
|
|
282
283
|
stage: r.stage || null, cat: r.cat || null, stageNote: r.stageSource === 'derived' ? 'derived' : null,
|
|
283
284
|
seg: names.map((n, j) => (j < cur ? 'p' : j === cur ? 'n' : '')), progress: pctOf(r.progress),
|
|
284
285
|
finish: r.end ? shortDate(r.end) : 'Not planned', over: !!over, start: r.start || null, end: r.end || null,
|
|
@@ -122,12 +122,27 @@ const npxArgs = (src, version, rest) => (src && src.kind === 'local'
|
|
|
122
122
|
? ['-y', `--package=${src.path}`, '--', PKG_NAME, ...rest]
|
|
123
123
|
: ['-y', `${PKG_NAME}@${version}`, ...rest]);
|
|
124
124
|
|
|
125
|
-
/** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary
|
|
126
|
-
* kit-changelog format 2, a
|
|
125
|
+
/** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary, from
|
|
126
|
+
* kit-changelog format 2 a title, and from format 3 a kind (k: new, imp, fix or x), which decides New, Improved or Fixed.
|
|
127
|
+
* Kits published before kit-changelog dropped "[skip publish]" kept it in the text and lost the PR number to an inline
|
|
128
|
+
* "(#123)"; both are repaired here, so every line ends in one muted number. From format 4 a PR may carry Claude's story for the
|
|
129
|
+
* PM (h: a plain title, d: what was missing and what is different now, top: a summary line that marks a highlight). */
|
|
130
|
+
const KINDS = new Set(['new', 'imp', 'fix', 'x']);
|
|
131
|
+
const SKIP_TAG = /\s*\[(?:skip|no)[ -][a-z]+\]/gi;
|
|
132
|
+
const PR_REF = /\s*\(#(\d+)\)/;
|
|
133
|
+
const WORK_TAG = /\s*\((?:batch \d+|canvas |D\d+\b)[^()]*\)\s*$/i; // "(batch 25 item 11)", "(canvas TplA)", "(D105)": how it was made, not what it does
|
|
134
|
+
const tidy = (t) => t.replace(SKIP_TAG, '').replace(new RegExp(PR_REF.source, 'g'), '').replace(/\s+/g, ' ').trim().replace(WORK_TAG, '').trim();
|
|
135
|
+
function cleanPr(p) {
|
|
136
|
+
const t = typeof p.t === 'string' ? p.t : '', ref = PR_REF.exec(t) || PR_REF.exec(p.s);
|
|
137
|
+
const n = Number.isInteger(p.n) ? p.n : ref ? Number(ref[1]) : null;
|
|
138
|
+
const tt = tidy(t).slice(0, 200), s = tidy(p.s).slice(0, 400);
|
|
139
|
+
const str = (x, max) => (typeof x === 'string' ? x.replace(/\s+/g, ' ').trim().slice(0, max) : '');
|
|
140
|
+
const h = str(p.h, 120), d = h ? str(p.d, 400) : '', top = h ? str(p.top, 200) : '';
|
|
141
|
+
return { n, ...(tt ? { t: tt } : {}), s: s || tt, ...(KINDS.has(p.k) ? { k: p.k } : {}), ...(h ? { h } : {}), ...(d ? { d } : {}), ...(top ? { top } : {}) };
|
|
142
|
+
}
|
|
127
143
|
function cleanEntry(e) {
|
|
128
144
|
if (!e || !validVersion(e.v)) return null;
|
|
129
|
-
const prs = (Array.isArray(e.prs) ? e.prs : []).filter((p) => p && typeof p.s === 'string' && p.s.trim())
|
|
130
|
-
.map((p) => ({ n: Number.isInteger(p.n) ? p.n : null, ...(typeof p.t === 'string' && p.t.trim() ? { t: p.t.trim().slice(0, 200) } : {}), s: p.s.trim().slice(0, 400) }));
|
|
145
|
+
const prs = (Array.isArray(e.prs) ? e.prs : []).filter((p) => p && typeof p.s === 'string' && p.s.trim()).map(cleanPr).filter((p) => p.s);
|
|
131
146
|
return { v: e.v, at: typeof e.at === 'string' && !Number.isNaN(Date.parse(e.at)) ? e.at : null, prs };
|
|
132
147
|
}
|
|
133
148
|
const cleanList = (list) => (Array.isArray(list) ? list.map(cleanEntry).filter(Boolean) : []);
|
|
@@ -477,6 +477,16 @@ const gapFields = (spans) => ({ gaps: holdSpans.planGaps(spans, 14), stageSpans:
|
|
|
477
477
|
const minDay = (a, b) => (!a ? b || null : !b ? a : a < b ? a : b);
|
|
478
478
|
const maxDay = (a, b) => (!a ? b || null : !b ? a : a > b ? a : b);
|
|
479
479
|
|
|
480
|
+
/** batch 26 item 5: whether an initiative stands in its workflow's terminal stage, read from the workflow, never from a list of names. On a
|
|
481
|
+
* D99 workflow: the stage is in the Done category, or it is the workflow's last stage. On a board's own column workflow: its last column. */
|
|
482
|
+
function closedOf(row, wx) {
|
|
483
|
+
if (wx.own) {
|
|
484
|
+
const st = wx.stages.find((x) => x.key === wx.stage);
|
|
485
|
+
return !!st && (st.cat === 'done' || (st.of > 1 && st.n === st.of));
|
|
486
|
+
}
|
|
487
|
+
const wf = Array.isArray(row.workflow) ? row.workflow : [];
|
|
488
|
+
return wf.length > 1 && wf.findIndex((x) => x.name === row.stage) === wf.length - 1;
|
|
489
|
+
}
|
|
480
490
|
function boardRow(board, base, folded, lib) {
|
|
481
491
|
const s = B.stageOf(board, base.id, base.reading, folded);
|
|
482
492
|
const ts = taskSpanOf(folded, base.id); // the bar covers the breakdown items and the tasks
|
|
@@ -496,6 +506,10 @@ function boardRow(board, base, folded, lib) {
|
|
|
496
506
|
const wx = workflowRowOf(board, base, folded, lib || libOf(folded), s.stage);
|
|
497
507
|
Object.assign(row, wfRowFields(wx));
|
|
498
508
|
if (wx.own) { row.stage = (wx.stages.find((x) => x.key === wx.stage) || {}).name || row.stage; row.cat = null; row.stageSource = 'move'; }
|
|
509
|
+
// batch 26 item 5: an initiative in its workflow's terminal stage is finished, so it is never late or at risk. Every surface (the Board's
|
|
510
|
+
// tone, dot, late line and edge tabs, Home, Kanban, the program rollups) reads the status this leaves and row.closed.
|
|
511
|
+
row.closed = closedOf(row, wx);
|
|
512
|
+
if (row.closed && (row.status === 'late' || row.status === 'at risk')) row.status = 'on track';
|
|
499
513
|
const w = [];
|
|
500
514
|
if (s.source === 'move') {
|
|
501
515
|
const last = [...folded.moves].reverse().find((m) => m.board === board.id && m.id === base.id);
|
|
@@ -1822,4 +1836,4 @@ function meetingBoards(ctx) {
|
|
|
1822
1836
|
return { list, detail };
|
|
1823
1837
|
}
|
|
1824
1838
|
|
|
1825
|
-
module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, startOnDefault, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
|
|
1839
|
+
module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, startOnDefault, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, closedOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.511.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.511.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
package/workspace/panels-test.js
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* over at 1440), the active edge, a plain click replaces the Viewer used last P5 a drop on a Viewer replaces it P6 the divider to the
|
|
21
21
|
* left edge: full screen; Split brings the page back; to the right edge: the Viewer closes P7 a reload reopens nothing; the share holds;
|
|
22
22
|
* Home resets it P8 1920: a drop at the far edge opens beside, three panels P9 Intelligence: split, ⌘-click P10 a phone as today
|
|
23
|
+
* P11 dock a Viewer on the left by its header, the divider on that side, kept after a reload, ⧉'s Dock on the right by the keyboard
|
|
24
|
+
* P12 Discuss in chat opens Home's chat in a panel on the Board (slides over at 1440, beside at 1920), with the same conversation key
|
|
25
|
+
* P13 the Live pill's list docks on the right by its grab bar, and a press moves it back (batch 26 item 4)
|
|
23
26
|
* Screenshots go to PMOS_SHOTS when it is set. Every wait polls the page. */
|
|
24
27
|
const fs = require('fs');
|
|
25
28
|
const path = require('path');
|
|
@@ -278,7 +281,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
278
281
|
await Bd.page.click('#czViewer [data-cz-vw="place"]');
|
|
279
282
|
await until(() => Bd.page.$('.cz-pn-menu'));
|
|
280
283
|
const m3 = await Bd.page.evaluate(() => [...document.querySelectorAll('.cz-pn-menu button')].map((b) => b.getAttribute('aria-label') + (b.getAttribute('aria-checked') === 'true' ? '*' : '')).join('|'));
|
|
281
|
-
ok('P3 ⧉ opens Full screen, Split, Slide over; Split is checked', m3 === 'Full screen|Split*|Slide over', m3);
|
|
284
|
+
ok('P3 ⧉ opens Full screen, Split, Slide over, then Dock on the left and Dock on the right (right checked); Split is checked', m3 === 'Full screen|Split*|Slide over|Dock on the left|Dock on the right*', m3);
|
|
282
285
|
await Bd.page.click('.cz-pn-menu [data-cz-pn-place="over"]');
|
|
283
286
|
const b3 = await until(() => pl(Bd.page).then((l) => (P(l, 'v0').mode === 'over' ? l : null)), 3000);
|
|
284
287
|
ok('P3 Slide over: the Viewer floats 560 px at the right with a grab bar; the page is whole again, with no divider', !!b3 && P(b3, 'v0').box.w === 560 && P(b3, 'page').box.w > 1300 && b3.divs.length === 0 && !b3.narrow
|
|
@@ -347,6 +350,70 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
347
350
|
await shot(Bw.page, 'p8-board-three-1920');
|
|
348
351
|
ok('0 no page error on the Board at 1920', Bw.errors.length === 0, Bw.errors.join(' | '));
|
|
349
352
|
await Bw.close();
|
|
353
|
+
/* ── batch 26 item 4: dock a panel on the left or the right; the chat as a panel on the Board ── */
|
|
354
|
+
const ctxD = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
355
|
+
const threads = [];
|
|
356
|
+
await ctxD.route(/\/api\/briefing-premium\/thread\?/, (r) => { threads.push(new URL(r.request().url()).searchParams.get('key')); return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, session: null, history: { messages: [] } }) }); });
|
|
357
|
+
const Dd = await open({ ctx: ctxD, at: '/premium-board.html', ready: boardReady });
|
|
358
|
+
const KD = await keys(Dd.page);
|
|
359
|
+
await Dd.page.click(nm(KD[0]));
|
|
360
|
+
await until(() => pl(Dd.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown && l.divs.length === 1 ? l : null)));
|
|
361
|
+
let z11 = null;
|
|
362
|
+
await drag(Dd.page, '#czViewer .cz-vw-tile', 200, 400, { hold: async () => { z11 = await Dd.page.evaluate(() => { const z = document.querySelector('.cz-pn-drop:not([hidden])'); if (!z) return null; const r = z.getBoundingClientRect(); return { side: z.dataset.side, l: Math.round(r.left), w: Math.round(r.width) }; }); await shot(Dd.page, 'p11-dock-drag-1440'); } });
|
|
363
|
+
const d11 = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.r < P(l, 'page').box.l ? l : null)), 3000);
|
|
364
|
+
const side11 = await Dd.page.evaluate(() => { try { return JSON.parse(localStorage.getItem('cz-pn-side')); } catch { return null; } });
|
|
365
|
+
ok('P11 a drag of the Viewer\'s header over the left half turns that half blue', !!z11 && z11.side === 'left' && z11.l <= 20 && z11.w > 600, JSON.stringify(z11));
|
|
366
|
+
ok('P11 the drop docks it on the left: the Viewer, the divider, then the page; the title is not renamed', !!d11 && d11.divs.length === 1 && P(d11, 'v0').box.r <= d11.divs[0].l + 12 && d11.divs[0].r <= P(d11, 'page').box.l + 12 && !(await Dd.page.$('#czViewer [data-cz-vw-ren]')), JSON.stringify(d11 || await pl(Dd.page)));
|
|
367
|
+
ok('P11 the side is kept for its kind (cz-pn-side)', !!side11 && Object.values(side11).includes('left'), JSON.stringify(side11));
|
|
368
|
+
await shot(Dd.page, 'p11-docked-left-1440');
|
|
369
|
+
{ const d = await divAt(Dd.page); const w0 = P(await pl(Dd.page), 'v0').box.w; await drag(Dd.page, d, d.x + 80, d.y); const w1 = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.w > w0 + 40 ? P(l, 'v0').box.w : null)), 3000); ok('P11 the divider resizes it on the left: a drag to the right widens the Viewer', !!w1, JSON.stringify([w0, w1])); }
|
|
370
|
+
await Dd.page.reload();
|
|
371
|
+
await until(() => Dd.page.evaluate(boardReady), 20000);
|
|
372
|
+
await Dd.page.click(nm(KD[1]));
|
|
373
|
+
const d11b = await until(() => pl(Dd.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown && l.divs.length === 1 ? l : null)), 4000);
|
|
374
|
+
ok('P11 after a reload an initiative opens on the left again', !!d11b && P(d11b, 'v0').box.r < P(d11b, 'page').box.l, JSON.stringify(d11b));
|
|
375
|
+
await Dd.page.click('#czViewer [data-cz-vw="place"]');
|
|
376
|
+
await until(() => Dd.page.$('.cz-pn-menu'));
|
|
377
|
+
await Dd.page.focus('.cz-pn-menu [data-cz-pn-dock="right"]'); await Dd.page.keyboard.press('Enter');
|
|
378
|
+
const d11c = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.l > P(l, 'page').box.r ? l : null)), 3000);
|
|
379
|
+
ok('P11 ⧉ › Dock on the right (an icon with its aria-label, by the keyboard) docks it back on the right', !!d11c && !(await Dd.page.evaluate(() => localStorage.getItem('cz-pn-side'))), JSON.stringify(d11c));
|
|
380
|
+
// the chat
|
|
381
|
+
const dh = await Dd.page.evaluate(() => [...document.querySelectorAll('#czViewer .cz-vw-hd [data-cz-vw]')].map((b) => b.dataset.czVw));
|
|
382
|
+
ok('P12 the initiative panel has Discuss in chat', dh.includes('discuss'), JSON.stringify(dh));
|
|
383
|
+
const url0 = Dd.page.url();
|
|
384
|
+
await Dd.page.click('#czViewer [data-cz-vw="discuss"]');
|
|
385
|
+
const c12 = await until(() => Dd.page.evaluate(() => { const v = [...document.querySelectorAll('.cz-vw')].find((x) => !x.hidden && x.querySelector('.pc-chat')); if (!v) return null; const r = v.getBoundingClientRect(); return { id: v.dataset.czPanel, w: Math.round(r.width), log: !!v.querySelector('.hm-log'), box: !!v.querySelector('.hm-foot textarea'), title: v.querySelector('.cz-vw-title').textContent }; }), 6000);
|
|
386
|
+
const l12 = await pl(Dd.page);
|
|
387
|
+
ok('P12 Discuss in chat opens the chat in a panel on the Board (no page change): the log and the box', !!c12 && c12.log && c12.box && Dd.page.url() === url0, JSON.stringify([c12, Dd.page.url()]));
|
|
388
|
+
ok('P12 at 1440 the chat slides over; the initiative split under it stays', !!c12 && P(l12, c12.id).mode === 'over' && P(l12, 'v0').mode === 'split', JSON.stringify(l12));
|
|
389
|
+
const iniId = await Dd.page.evaluate(() => window.czViewer.current() && window.czViewer.current().id);
|
|
390
|
+
ok('P12 it is Home\'s conversation about the initiative (key init:<id>)', threads.some((k) => /^init:/.test(k || '')), JSON.stringify([threads, iniId]));
|
|
391
|
+
await shot(Dd.page, 'p12-chat-over-1440');
|
|
392
|
+
ok('0 no page error while docking and with the chat on the Board', Dd.errors.length === 0, Dd.errors.join(' | '));
|
|
393
|
+
// the Live pill's list: its grab bar docks it on the right; a press moves it back
|
|
394
|
+
await Dd.page.evaluate(() => window.czPanels.putAway());
|
|
395
|
+
await Dd.page.click('#czLive');
|
|
396
|
+
await until(() => Dd.page.$('#czLiveList:not([hidden]) .lv-side'), 3000);
|
|
397
|
+
const g13 = await Dd.page.evaluate(() => { const r = document.querySelector('#czLiveList .lv-side').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, lab: document.querySelector('#czLiveList .lv-side').getAttribute('aria-label') }; });
|
|
398
|
+
await drag(Dd.page, g13, 1300, g13.y);
|
|
399
|
+
const t13 = await until(() => Dd.page.evaluate(() => { const t = document.getElementById('czLiveList'); const r = t.getBoundingClientRect(); return t.classList.contains('lv-r') ? { r: Math.round(r.right), lab: t.querySelector('.lv-side').getAttribute('aria-label'), kept: localStorage.getItem('cz-live-side') } : null; }), 3000);
|
|
400
|
+
ok('P13 the Live pill\'s list docks on the right by its grab bar (an icon with an aria-label), and the side is kept', !!t13 && Math.abs(t13.r - (1440 - 16)) <= 2 && t13.kept === 'right' && g13.lab === 'Move this list to the right side', JSON.stringify([g13, t13]));
|
|
401
|
+
await shot(Dd.page, 'p13-live-right-1440');
|
|
402
|
+
await Dd.page.focus('#czLiveList .lv-side'); await Dd.page.keyboard.press('Enter');
|
|
403
|
+
ok('P13 Enter on the grab bar moves it back to the left', !!(await until(() => Dd.page.evaluate(() => !document.getElementById('czLiveList').classList.contains('lv-r') && !localStorage.getItem('cz-live-side')), 2000)));
|
|
404
|
+
await Dd.close();
|
|
405
|
+
// 1920: the chat opens beside the initiative panel
|
|
406
|
+
const ctxW = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
|
407
|
+
await ctxW.route(/\/api\/briefing-premium\/thread\?/, (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, session: null, history: { messages: [] } }) }));
|
|
408
|
+
const Dw = await open({ ctx: ctxW, at: '/premium-board.html', ready: boardReady });
|
|
409
|
+
await Dw.page.click(nm(KD[0]));
|
|
410
|
+
await until(() => pl(Dw.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown ? l : null)));
|
|
411
|
+
await Dw.page.click('#czViewer [data-cz-vw="discuss"]');
|
|
412
|
+
const c12w = await until(() => pl(Dw.page).then((l) => (P(l, 'v1') && P(l, 'v1').box.shown && l.divs.length === 2 ? l : null)), 6000);
|
|
413
|
+
ok('P12 at 1920 the chat opens beside the initiative panel: the page, the panel, the chat, two dividers', !!c12w && P(c12w, 'page').box.r < P(c12w, 'v0').box.l && P(c12w, 'v0').box.r < P(c12w, 'v1').box.l && P(c12w, 'v1').mode === 'split', JSON.stringify(c12w || await pl(Dw.page)));
|
|
414
|
+
await shot(Dw.page, 'p12-chat-beside-1920');
|
|
415
|
+
ok('0 no page error with the chat at 1920', Dw.errors.length === 0, Dw.errors.join(' | '));
|
|
416
|
+
await Dw.close();
|
|
350
417
|
// Intelligence: a goal row opens a split; ⌘-click opens beside
|
|
351
418
|
const In = await open({ at: '/premium-intelligence.html', ready: () => !!window.czPanels && document.querySelectorAll('#goalTbl .ins-row[data-goal]').length > 1 });
|
|
352
419
|
ok('P9 Intelligence draws its goal rows and window.czPanels', !!In.up);
|
|
@@ -70,7 +70,8 @@ export function nextMs(r, now = new Date()) {
|
|
|
70
70
|
if (!x && es && !(r.milestones || []).some((m) => day(m.date)) && day(r.end) && k < 8 && !(r.track && r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)) x = { name: es, d: day(r.end) };
|
|
71
71
|
if (!x) return null;
|
|
72
72
|
const over = Math.round((t - x.d) / 864e5);
|
|
73
|
-
|
|
73
|
+
// a closed initiative (batch 26 item 5) is never late: its next milestone keeps its date and drops the days late
|
|
74
|
+
return r.closed ? { ...x, late: false, over: 0 } : { ...x, late: over > 0, over: Math.max(0, over) };
|
|
74
75
|
}
|
|
75
76
|
const STATUS = { late: 'Late', risk: 'At risk', q: 'On hold' };
|
|
76
77
|
const nextText = (r) => { const t = r.track || {}; if (typeof t.next === 'string') return t.next; if (t.next && t.next.text) return t.next.text; const l = (t.line || [])[0]; return l ? l.text || '' : ''; };
|
|
@@ -76,7 +76,7 @@ export function entriesOf(o) {
|
|
|
76
76
|
const slip = r.plan && r.plan.slip > 0 ? r.plan.slip : 0;
|
|
77
77
|
const atEnd = (d) => !!(e && +d === +e);
|
|
78
78
|
const one = (id, name, d, done, isEnd) => {
|
|
79
|
-
const late = !done && !h && (d < now || (isEnd && r.over && now > e));
|
|
79
|
+
const late = !done && !h && !r.closed && (d < now || (isEnd && r.over && now > e)); // never on a closed initiative (batch 26 item 5)
|
|
80
80
|
const risk = !done && !late && !h && isEnd && (r.status === 'at risk' || slip > 0);
|
|
81
81
|
const planned = isEnd && slip ? new Date(d.getFullYear(), d.getMonth(), d.getDate() - slip) : null;
|
|
82
82
|
out.push({ ...base, id, name, date: d, done: !!done, late, risk, fc: false, planned, slip: isEnd ? slip : 0 });
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* readyHtml(m, whole) a milestone's readiness as small diamonds, one per initiative it waits on
|
|
11
11
|
* mergeBoards(list) All products: one Board model from every product's model, each row keeping its own product
|
|
12
12
|
* bandHtml(g, st, o) the stage track's program row: tile, title, products, your count, its spread, its next milestones
|
|
13
|
-
* prodHtml(g, o) · subHtml(name) a product group's head row (tile
|
|
14
|
-
* prodCell(g, dots) the head's fold
|
|
13
|
+
* prodHtml(g, o) · subHtml(name) a product group's head row (tile and name open the product, goal dots, chevron folds) and a program's product sub-row
|
|
14
|
+
* prodCell(g, dots) the head's open (tile, name) and fold buttons alone (the timeline's product lane, batch 11 item 17); dots replace the goal dots
|
|
15
15
|
* goalsFrom(fn) · goalsHtml(name) the goal dots of a product head, from Home's source (pm/home-list.js goalsOf); fn(name) gives its rows
|
|
16
16
|
*
|
|
17
17
|
* A program has no stage of its own (PM, 2026-10-23): the band shows only what its initiatives say. With one product picked, the program shows
|
|
@@ -173,9 +173,14 @@ export function prodHtml(g, cls = 'bt-prd') {
|
|
|
173
173
|
/** The fold button of a product head alone: the timeline's product lane (batch 11 item 17, PlmA1Board) puts it in the name cell, its track beside it. */
|
|
174
174
|
export function prodCell(g, dots) {
|
|
175
175
|
// the calm timeline (batch 13 item 4) passes its own dots: none while open, one per initiative off track while folded
|
|
176
|
+
// the tile and name open the product's page in the Viewer (batch 26 item 3, as Home's header and a program's title do); the rest folds
|
|
176
177
|
const open = !folded(g.key), gl = dots !== undefined ? dots : open ? goalsHtml(g.name) : '';
|
|
177
|
-
return `<button type="button" class="bd-
|
|
178
|
+
return `<span class="bd-prdb">${prodOpen(g, 'bd-prdo')}<button type="button" class="bd-prdf" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${esc(g.name)}, ${g.rows.length} ${plural(g.rows.length, 'initiative', 'initiatives')}. ${open ? 'Fold' : 'Show'}" title="${open ? 'Fold' : 'Show'}">${gl}${open || dots !== undefined ? '' : `<span class="n">${g.rows.length}</span>`}${SV(open ? CHEV_D : CHEV_R)}</button></span>`;
|
|
178
179
|
}
|
|
180
|
+
/** A product's workspace, from its rows (All products: each row keeps its own); '' when the rows carry none (the page uses its own board's). */
|
|
181
|
+
export const prodWs = (g) => ((g.rows || []).find((r) => r && r.ws) || {}).ws || '';
|
|
182
|
+
/** A product head's tile and name as one button: it opens that product's page in the Viewer (pm/premium-board.js data-prodopen). */
|
|
183
|
+
export const prodOpen = (g, cls) => `<button type="button" class="${cls}" data-prodopen="${esc(g.name)}" data-prodws="${esc(prodWs(g))}" aria-label="Open ${esc(g.name)}, a product">${tileHtml(g.name)}<span>${esc(g.name)}</span></button>`;
|
|
179
184
|
/** The goal dots of a product's head (PgBoard .gd), from the same source as Home's product header (pm/home-list.js goalsOf): one per KR or
|
|
180
185
|
* budget its open initiatives anchor to, green on track, amber at risk. With no goal anchored, nothing is drawn. The page sets the rows. */
|
|
181
186
|
let ROWS_OF = null;
|
|
@@ -191,11 +196,11 @@ export const subHtml = (name, cls = 'bt-psb') => `<div class="${cls}" role="row"
|
|
|
191
196
|
export const memberIcon = (programs, id) => { const p = (programs || []).find((x) => x.id === id); return p ? `<span class="bd-pgi" role="img" aria-label="In ${esc(p.title)}" title="In ${esc(p.title)}">${SV(PROG)}</span>` : ''; };
|
|
192
197
|
|
|
193
198
|
/** A Kanban swimlane's head (PgKanban .lh): a program's tile, title, your count and its next milestones merged into one counted diamond with
|
|
194
|
-
* the readiness of the first; a product's tile and name. Both fold with the chevron; the
|
|
199
|
+
* the readiness of the first; a product's tile and name. Both fold with the chevron; the title opens the program or the product. */
|
|
195
200
|
export function laneHtml(g, st) {
|
|
196
201
|
const open = !folded(g.key);
|
|
197
202
|
const fb = (label) => `<button type="button" class="bd-ib sm bd-fold" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${open ? 'Fold' : 'Show'} ${esc(label)}" title="${open ? 'Fold' : 'Show'}">${SV(open ? CHEV_D : CHEV_R)}</button>`;
|
|
198
|
-
if (g.kind !== 'program') return
|
|
203
|
+
if (g.kind !== 'program') return `${prodOpen(g, 'bd-lho')}<span class="bd-sp"></span>${open ? goalsHtml(g.name) : ''}${fb(g.name)}`;
|
|
199
204
|
const p = g.program, c = nextCluster(st);
|
|
200
205
|
const ms = c ? `<span class="bd-pm2${c.late ? ' late' : ''}" title="${esc(c.items.map((m) => m.name + (m.day ? ' ' + m.day : '')).join(' · '))}"><i class="bd-dm"></i><b>${esc(c.label)}</b>${esc(c.range)}${readyHtml(c.first, whole(st))}</span>` : '';
|
|
201
206
|
return `<button type="button" class="bd-lho" data-prog="${esc(p.id)}" aria-haspopup="dialog" aria-label="${esc(p.title)}, a program. Open the program">${progTile()}<span>${esc(p.title)}</span></button>${yoursHtml(st)}<span class="bd-sp"></span>${ms}${fb(p.title)}`;
|
|
@@ -103,9 +103,10 @@ export function windowOf(span, now, phone, sprint, dates) {
|
|
|
103
103
|
const pctOf = (W) => (d) => Math.max(0, Math.min(100, ((d - W.from) / (W.to - W.from)) * 100));
|
|
104
104
|
const P = (x) => x.toFixed(2) + '%';
|
|
105
105
|
const doneOf = (r) => { const p = r.track && r.track.prog; return p ? Math.max(0, Math.min(100, (p.done / Math.max(1, p.total)) * 100)) : 0; };
|
|
106
|
-
/** A milestone task past its due date and not done (feedback batch 11 item 3): its ◇ turns orange, and so does the row's dot.
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
/** A milestone task past its due date and not done (feedback batch 11 item 3): its ◇ turns orange, and so does the row's dot. Never on a closed
|
|
107
|
+
* initiative (its workflow's terminal stage, r.closed from lib/routes-boards.js closedOf; batch 26 item 5): a closed initiative is not late. */
|
|
108
|
+
const msLate = (m, now, r) => !(r && r.closed) && !m.done && day(m.date) && day(m.date) < new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
109
|
+
export const anyMsLate = (r, now) => !!(now && (r.milestones || []).some((m) => msLate(m, now, r)));
|
|
109
110
|
/** The forecast range a row draws (FcBoard): { tone: 'risk' | 'miss' | 'held', low, high } past its promised date, else null. */
|
|
110
111
|
export function fcRange(r) {
|
|
111
112
|
const F = r && r.forecast, e = r && day(r.end);
|
|
@@ -119,8 +120,9 @@ const toneOf = (r, now) => (r.late || anyMsLate(r, now) ? 'late' : r.status ===
|
|
|
119
120
|
export const offTone = (r, now) => { const t = toneOf(r, now); return t === 'late' || t === 'risk' ? t : r.status === 'on hold' ? 'q' : null; };
|
|
120
121
|
const dotHtml = (r, now) => { const t = offTone(r, now); return t ? `<i class="cz-dot ${t}${t === 'q' ? '' : ' m-warn'}" aria-hidden="true"></i>` : ''; };
|
|
121
122
|
const dated = (r) => day(r.start) || day(r.end) || (r.track && day(r.track.forecast)) || (r.draft && day(r.draft.start));
|
|
122
|
-
/** Done work leaves no edge tab (TsCGrill 5): the initiative is
|
|
123
|
-
|
|
123
|
+
/** Done work leaves no edge tab (TsCGrill 5): the initiative is closed (its workflow's terminal stage, batch 26 item 5), past Deliver, or every
|
|
124
|
+
* part it counts is done. */
|
|
125
|
+
const isDone = (r) => !!r.closed || !!(r.track && (r.track.k >= 6 || (r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)));
|
|
124
126
|
/** A row's holds as spans on the bar: [{ a, b, kind, h, i }]. kind: past (resumed), open (the return date ahead), over (the return date passed:
|
|
125
127
|
* dotted on to today), none (no return date: dotted to today). Only the ones that touch the plan (XtiAHard). */
|
|
126
128
|
export function holdSpans(r, now) {
|
|
@@ -231,7 +233,7 @@ function qBar(r, X, now, phone, fmt, pxPct, ms = {}) {
|
|
|
231
233
|
if (clash) hover.push(clash);
|
|
232
234
|
const sw = slipWords(r, e); if (sw) hover.push(sw);
|
|
233
235
|
// the committed end is the ◇ (PgTimeline); a milestone task in the stage it stands for IS that ◇ (say it once, batch 11 item 3): its name and its done
|
|
234
|
-
const isLate = !!(over || clash || r.late || (et && msLate(et, now)));
|
|
236
|
+
const isLate = !!(over || clash || r.late || (et && msLate(et, now, r)));
|
|
235
237
|
// the ◇ and its label carry the milestone's own date; the initiative's end, when it differs, is the hover (batch 14 item 1)
|
|
236
238
|
const ed = et ? day(et.date) : e; if (+ed !== +e) hover.push('Initiative ends ' + dy(e, now));
|
|
237
239
|
const name = landsOf(r, et), eTitle = name + ' · ' + md(ed) + (et && et.stage ? ' · ◆ ' + et.stage : '') + (et && et.done ? ' · done' : isLate ? ' · late' : '');
|
|
@@ -316,6 +318,9 @@ function gapsHtml(r, X, now, phone, fmt, pxPct, s, e, tone, note = '', lands = n
|
|
|
316
318
|
if (x && x.kind === 'none') faded = true;
|
|
317
319
|
}
|
|
318
320
|
if (at < e) pieces.push([at, e, faded]);
|
|
321
|
+
// a plan of one day (its only dated work starts and ends the same day) still draws its bar: before, it drew no piece, and a late one showed
|
|
322
|
+
// only its line to Today (batch 26 item 5, CRA to Vite)
|
|
323
|
+
if (!pieces.length && +s === +e) pieces.push([s, e, false]);
|
|
319
324
|
const days = (p) => Math.max(0, (p[1] - p[0]) / 86400000), total = pieces.reduce((n, p) => n + days(p), 0);
|
|
320
325
|
let left = (doneOf(r) / 100) * total;
|
|
321
326
|
// now is blue (batch 19 item 5, StsA8Board): from where the done part ends to the end of this week, while the work is under way
|
|
@@ -327,7 +332,7 @@ function gapsHtml(r, X, now, phone, fmt, pxPct, s, e, tone, note = '', lands = n
|
|
|
327
332
|
let html = pieces.map((p, i) => {
|
|
328
333
|
const d = days(p), f = d ? Math.max(0, Math.min(1, left / d)) : 0; left -= d;
|
|
329
334
|
const xl = i > 0 || p[0] > s ? ' xl' : '', xr = i < pieces.length - 1 || p[1] < e ? ' xr' : '';
|
|
330
|
-
return `<span class="tg-bar${xl}${xr}${p[2] ? ' fd' : ''}" data-key="${esc(r.key)}" style="left:${P(X(p[0]))};width:${P(Math.max(0.15, X(p[1]) - X(p[0])))}" title="${esc('PMOS plan ' + md(s) + ' to ' + md(e) + hold)}"><b style="width:${(f * 100).toFixed(1)}%"></b>${nowIn(p)}</span>`;
|
|
335
|
+
return `<span class="tg-bar${xl}${xr}${p[2] ? ' fd' : ''}" data-key="${esc(r.key)}" style="left:${P(X(p[0]))};width:${P(Math.max(+p[1] === +p[0] && pxPct ? 20 / pxPct : 0.15, X(p[1]) - X(p[0])))}" title="${esc('PMOS plan ' + md(s) + ' to ' + md(e) + hold)}"><b style="width:${(f * 100).toFixed(1)}%"></b>${nowIn(p)}</span>`;
|
|
331
336
|
}).join('');
|
|
332
337
|
// each plain gap: the hairline, one press (a 24 px area) with its words; a phone draws the line alone and its row sheet says it
|
|
333
338
|
plain.forEach(([a, b], gi) => {
|
|
@@ -382,7 +387,7 @@ function taskMarks(r, X, now, phone, pxPct, ms) {
|
|
|
382
387
|
const M = (r.milestones || []).filter((m) => day(m.date));
|
|
383
388
|
if (!M.length) return { html: '', end: null };
|
|
384
389
|
const end = endTask(r, M, ms.reach || 0), show = new Set(shownMs(r, ms.mode || 'pinned').map((m) => m.id));
|
|
385
|
-
const L = M.filter((m) => (!end || m.id !== end.id) && show.has(m.id)).map((m) => ({ ...m, late: msLate(m, now), stage: msStage(r, m) }))
|
|
390
|
+
const L = M.filter((m) => (!end || m.id !== end.id) && show.has(m.id)).map((m) => ({ ...m, late: msLate(m, now, r), stage: msStage(r, m) }))
|
|
386
391
|
.filter((m) => { const x = X(day(m.date)); return x > 0 && x < 100; });
|
|
387
392
|
const C = mergeClose(L, Math.max(1, ms.reach || 7));
|
|
388
393
|
const html = C.map((c) => {
|
|
@@ -405,9 +410,9 @@ export function laneMilestones(rows, now, reach = 0) {
|
|
|
405
410
|
const out = [];
|
|
406
411
|
for (const r of rows || []) {
|
|
407
412
|
const M = (r.milestones || []).filter((m) => day(m.date)), s = day(r.start), e = day(r.end), et = endTask(r, M, reach);
|
|
408
|
-
for (const m of M) if (!et || m.id !== et.id) out.push({ id: m.id, name: m.name, date: m.date, done: !!m.done, late: msLate(m, now), row: r });
|
|
413
|
+
for (const m of M) if (!et || m.id !== et.id) out.push({ id: m.id, name: m.name, date: m.date, done: !!m.done, late: msLate(m, now, r), row: r });
|
|
409
414
|
if (!s || !e || (!et && !endStage(r))) continue;
|
|
410
|
-
const ob = openBack(r, now), late = (r.over && now > e) || (ob && ob > e) || (et && msLate(et, now));
|
|
415
|
+
const ob = openBack(r, now), late = (r.over && now > e) || (ob && ob > e) || (et && msLate(et, now, r));
|
|
411
416
|
out.push({ id: et ? et.id : '', name: et ? et.name : endStage(r), date: et ? String(et.date).slice(0, 10) : iso(e), done: !!(et && et.done), late: !!late, row: r });
|
|
412
417
|
}
|
|
413
418
|
return out.sort((a, b) => day(a.date) - day(b.date));
|
|
@@ -1085,7 +1085,7 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
|
|
|
1085
1085
|
.bd-rdy i { display: block; width: 6px; height: 6px; border-radius: 1px; transform: rotate(45deg); background: var(--bd-plan); }
|
|
1086
1086
|
.bd-rdy i.on { background: var(--bd-prog); }
|
|
1087
1087
|
.bd-fold { color: var(--cz-ink3); }
|
|
1088
|
-
.bd-fold:focus-visible, .bt-pgo:focus-visible, .bd-
|
|
1088
|
+
.bd-fold:focus-visible, .bt-pgo:focus-visible, .bd-prdo:focus-visible, .bd-prdf:focus-visible, .bd-lho:focus-visible, .tg-pgo:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 10px; }
|
|
1089
1089
|
/* the grouping control in the toolbar (PgBoardProduct: "By product" on in violet) */
|
|
1090
1090
|
.bd-grpw2 { position: relative; display: inline-flex; align-items: center; }
|
|
1091
1091
|
.bd-grpw2[hidden] { display: none; }
|
|
@@ -1119,7 +1119,13 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
|
|
|
1119
1119
|
.bt-psb > span, .tg-psb > span { display: inline-flex; align-items: center; gap: 8px; }
|
|
1120
1120
|
.bt-psb::before, .tg-psb::before { content: ""; position: absolute; left: 26px; top: 0; bottom: 0; width: 2px; background: var(--bd-progln); }
|
|
1121
1121
|
.bt-prd, .tg-prd { display: flex; align-items: center; padding: 8px 14px; background: var(--bd-row); border-top: 1px solid var(--cz-hair); }
|
|
1122
|
-
.bd-prdb { all: unset; box-sizing: border-box;
|
|
1122
|
+
.bd-prdb { all: unset; box-sizing: border-box; flex: 1; display: flex; align-items: center; gap: 10px; font-size: var(--fs-3); font-weight: 800; color: var(--cz-ink); min-height: 28px; }
|
|
1123
|
+
/* the tile and name open the product's page (batch 26 item 3, as Home's header); the rest of the head, its dots, count and chevron, folds it */
|
|
1124
|
+
.bd-prdo, .bd-prdf { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 10px; min-height: 28px; }
|
|
1125
|
+
.bd-prdo { flex: 0 1 auto; min-width: 0; }
|
|
1126
|
+
.bd-prdo > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1127
|
+
.bd-prdf { flex: 1; align-self: stretch; justify-content: flex-end; }
|
|
1128
|
+
@media (hover: hover) { .bd-prdo:hover > span:last-child, .bd-lh.pd .bd-lho:hover > span:last-child { color: var(--cz-blueink); } }
|
|
1123
1129
|
.bd-prdb svg { width: 15px; height: 15px; stroke-width: 2; color: var(--cz-ink3); margin-left: auto; }
|
|
1124
1130
|
/* a product head's goal dots (PgBoard .gd): Home's source and look (cozy-today.css .hv-gd) */
|
|
1125
1131
|
.bd-prdb .n { font-size: var(--fs-2); font-weight: 700; color: var(--cz-ink3); margin-left: auto; }
|
|
@@ -222,23 +222,37 @@
|
|
|
222
222
|
.ev-st .ev-sw[aria-checked="true"] { background: var(--cz-bluebg); color: var(--cz-blueink); }
|
|
223
223
|
.ev-st .ev-add { display: flex; gap: 8px; margin-top: 8px; }
|
|
224
224
|
.ev-st [role="status"] { min-height: 1.2em; margin: 0; font-size: var(--fs-2); color: var(--cz-ink3); }
|
|
225
|
-
/* Updates › what's new:
|
|
225
|
+
/* Updates › what's new (design E, UpE): summary lines, the newest kit at version level, "Earlier this week" as one flat list of
|
|
226
|
+
stories, the earlier weeks folded into an Archive. A blue dot is New, an amber dot is Fixed, no dot is Improved. */
|
|
226
227
|
.ev-st .ev-wk { display: flex; flex-direction: column; gap: 14px; margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--cz-hair); }
|
|
227
228
|
.ev-st .ev-wkh { display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; }
|
|
228
|
-
.ev-st .ev-wkh b { font-size: var(--fs-
|
|
229
|
-
.ev-st .ev-
|
|
230
|
-
.ev-st .ev-
|
|
231
|
-
.ev-st .ev-
|
|
232
|
-
.ev-st .ev-
|
|
233
|
-
.ev-st .ev-
|
|
234
|
-
.ev-st .ev-
|
|
235
|
-
.ev-st .ev-
|
|
229
|
+
.ev-st .ev-wkh b { font-size: var(--fs-5); font-weight: 800; }
|
|
230
|
+
.ev-st .ev-sum { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 16px; background: var(--cz-bluebg); }
|
|
231
|
+
.ev-st .ev-sum p { display: flex; gap: 10px; margin: 0; font-size: var(--fs-4); line-height: 1.5; color: var(--cz-ink); max-width: 72ch; }
|
|
232
|
+
.ev-st .ev-sum svg { flex: none; margin-top: 5px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; color: var(--cz-blueink); }
|
|
233
|
+
.ev-st .ev-leg { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: var(--fs-2); color: var(--cz-ink3); }
|
|
234
|
+
.ev-st .ev-leg span { display: inline-flex; align-items: center; gap: 6px; }
|
|
235
|
+
.ev-st .ev-rel { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 0 14px; }
|
|
236
|
+
.ev-st .ev-rv { display: flex; flex-direction: column; padding-top: 6px; color: var(--cz-blueink); font-variant-numeric: tabular-nums; }
|
|
237
|
+
.ev-st .ev-rv b { font-size: var(--fs-4); font-weight: 800; }
|
|
238
|
+
.ev-st .ev-rv span { font-weight: 600; }
|
|
239
|
+
.ev-st .ev-sl { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
|
240
|
+
.ev-st .ev-sy { display: flex; gap: 10px; padding: 6px 0; font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink2); max-width: 72ch; overflow-wrap: anywhere; }
|
|
241
|
+
.ev-st .ev-sy b { color: var(--cz-ink); font-weight: 700; }
|
|
242
|
+
.ev-st .ev-sx { flex: 1; min-width: 0; }
|
|
243
|
+
.ev-st .ev-dc { width: 7px; flex: none; display: inline-flex; justify-content: center; padding-top: 8px; }
|
|
244
|
+
.ev-st .ev-grp { display: flex; flex-direction: column; border-top: 1px solid var(--cz-hair); }
|
|
245
|
+
.ev-st .ev-gh { display: flex; align-items: center; gap: 10px; min-height: 44px; padding-top: 6px; font-size: var(--fs-4); }
|
|
246
|
+
.ev-st .ev-gh b { font-weight: 800; }
|
|
247
|
+
.ev-st .ev-more { align-self: flex-start; min-height: 44px; padding: 0 0 0 17px; border: 0; background: none; font: inherit; font-size: var(--fs-2); color: var(--cz-ink3); cursor: pointer; }
|
|
248
|
+
.ev-st .ev-more:hover { color: var(--cz-ink); }
|
|
249
|
+
.ev-st .ev-wkr > .ev-sum { margin-bottom: 4px; }
|
|
250
|
+
@media (max-width: 560px) { .ev-st .ev-rel { grid-template-columns: minmax(0, 1fr); } .ev-st .ev-rv { flex-direction: row; gap: 8px; align-items: baseline; padding-top: 0; } }
|
|
236
251
|
.ev-st .ev-arc { display: flex; flex-direction: column; border-top: 1px solid var(--cz-hair); }
|
|
237
252
|
.ev-st .ev-arcb, .ev-st .ev-wkt { display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; width: 100%; min-height: 44px; padding: 10px 0; border: 0; background: none; font: inherit; color: var(--cz-ink); text-align: left; cursor: pointer; }
|
|
238
253
|
.ev-st .ev-arcb svg, .ev-st .ev-wkt svg { margin-left: auto; align-self: center; fill: none; stroke: currentColor; stroke-width: 2; color: var(--cz-ink3); transition: transform .15s; }
|
|
239
254
|
.ev-st .ev-arcb[aria-expanded="true"] svg, .ev-st .ev-wkt[aria-expanded="true"] svg { transform: rotate(180deg); }
|
|
240
255
|
.ev-st .ev-wkr { display: flex; flex-direction: column; gap: 12px; padding-bottom: 12px; border-top: 1px solid var(--cz-hair); }
|
|
241
|
-
.ev-st .ev-wkr .ev-wkm { padding: 0 0 4px; }
|
|
242
256
|
.ev-st .ev-wkt .ev-tl { margin-right: 8px; }
|
|
243
257
|
@media (prefers-reduced-motion: reduce) { .ev-st .ev-arcb svg, .ev-st .ev-wkt svg { transition: none; } }
|
|
244
258
|
@media (max-width: 760px) {
|
|
@@ -56,11 +56,20 @@
|
|
|
56
56
|
|
|
57
57
|
/* the list opens above the pill */
|
|
58
58
|
@media (min-width: 761px) { .lv-tray { bottom: 120px; } }
|
|
59
|
+
/* its grab bar (batch 26 item 4): drag it to the left or the right side; a press moves it to the other side. A phone's sheet has none. */
|
|
60
|
+
.lv-rows { display: flex; flex-direction: column; }
|
|
61
|
+
.lv-side { align-self: center; width: 64px; height: 16px; margin: -2px 0 0; padding: 0; border: 0; border-radius: 8px; background: none; display: grid; place-items: center; cursor: grab; touch-action: none; }
|
|
62
|
+
.lv-side i { display: block; width: 36px; height: 4px; border-radius: 2px; background: color-mix(in srgb, var(--cz-ink3) 40%, transparent); }
|
|
63
|
+
.lv-side:hover i { background: var(--cz-ink3); }
|
|
64
|
+
.lv-side:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 1px; }
|
|
65
|
+
.lv-tray.lv-moving { transition: none; cursor: grabbing; opacity: .92; }
|
|
66
|
+
@media (min-width: 761px) { .lv-tray.lv-r { position: fixed; left: auto; right: 16px; } }
|
|
59
67
|
/* a phone: the menu button is bottom right, so the pill stacks above it there, and the list is a short sheet */
|
|
60
68
|
@media (max-width: 760px) {
|
|
61
69
|
.cz-live { left: auto; right: 6px; }
|
|
62
70
|
.lv-scrim { position: fixed; inset: 0; background: rgba(42,46,53,.18); z-index: 0; }
|
|
63
71
|
.lv-tray.sheet { position: fixed; left: 0; right: 0; bottom: 0; width: auto; padding: 18px 8px 28px; border-radius: 26px 26px 0 0; border: 0; }
|
|
72
|
+
.lv-side { display: none; }
|
|
64
73
|
.lv-tray.sheet::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 5px; margin-left: -20px; border-radius: 3px; background: var(--cz-well2); }
|
|
65
74
|
}
|
|
66
75
|
/* the phone keyboard is up: no pill and no menu button over the chat box */
|