create-pmos 0.52.0 → 0.53.1
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 +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +24 -63
- package/workspace/bin/pmos.js +19 -3
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +28 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +102 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +75 -0
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +210 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +380 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +69 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +9 -6
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +93 -1
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +578 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +40 -19
- package/workspace/public/pm/app.js +330 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3135 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +182 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +175 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +99 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +200 -111
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +126 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2877 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +446 -40
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +21 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/* pm/initiatives-timeline.js — Initiatives › Timeline (pm-os-absorb Run 4, slice 4C; PRD R20 A).
|
|
2
|
+
*
|
|
3
|
+
* Months across, one row per initiative, grouped by horizon (Now, Next, Later). A bar is drawn by the Timeline bar
|
|
4
|
+
* primitive (primitives.js timelineBar, slice 4B), split by stage, with a diamond for each milestone, and one line marks
|
|
5
|
+
* today. An initiative with no dated item sits below the chart in a "Not dated" section, grouped by horizon the same way.
|
|
6
|
+
*
|
|
7
|
+
* - THE ROWS COME FROM `GET /api/timeline?ws=` (lib/routes-timeline.js): the dates are the items' own start and due.
|
|
8
|
+
* Nothing is derived here, and no number is written to a record by this page.
|
|
9
|
+
* - A ROW REORDERS BY ITS GRIP, inside its horizon and nowhere else: a `placed` line through POST /api/placements
|
|
10
|
+
* (Run 2, slice 2C), `after` = the row it now follows, or null for the top. One line is enough while every row above
|
|
11
|
+
* the new place has a placement line of its own. A row that only reads Later by default (`placed: false`) has none,
|
|
12
|
+
* so a line that names it as `after` cannot find it: the move then writes a line for each row from the first row
|
|
13
|
+
* it changes to the last row that has no line (`moveEvents`), in one request, and the page says so. The same move is on the keyboard
|
|
14
|
+
* (Alt+ArrowUp/Down on the title) and in the row's ⋯ menu (To top, Up, Down). Moving to another horizon is the
|
|
15
|
+
* Roadmap's act, where the PM's press for Now is asked for.
|
|
16
|
+
* - A BAR NEVER DRAGS. The drag handle is the grip only: a drag that starts on a bar, or anywhere else on a row, writes
|
|
17
|
+
* nothing. Dates change in the Breakdown, on the item.
|
|
18
|
+
* - ▸ shows the stage rows of an initiative: the stage, its done and total items, and its own bar.
|
|
19
|
+
* - At 390 px the chart scrolls sideways inside itself and the page never scrolls sideways. The name of a row stays at
|
|
20
|
+
* the left edge while its months scroll under it. The chart opens with today in view.
|
|
21
|
+
* - THE VIEW WATCHES its own GET with sig-watch.js watchSig (seams §4.9), because /api/sessions `ledgers` covers
|
|
22
|
+
* plan-items only. Each read is compared with the sig of the answer on screen, so a write that lands between the
|
|
23
|
+
* first read and the first tick is caught, and a changed answer is drawn from the read that found it (no second
|
|
24
|
+
* GET). It never rebuilds while a drag is live.
|
|
25
|
+
* - TODAY and THE RANGE come from the route (the server's local date; at most 24 months). A date outside the range is
|
|
26
|
+
* cut off, and a note says so.
|
|
27
|
+
*
|
|
28
|
+
* Registers itself as the Initiatives view `timeline`; slice 2D's View control shows it when it is registered. The mount
|
|
29
|
+
* ctx is the frozen initiativesViewCtx: { repo, ws, rows, sig, openInitiative, setState }. `rows` and `sig` are the
|
|
30
|
+
* Roadmap's, so this view reads neither of them: it asks for its own rows.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { registerInitiativesView } from './registry.js';
|
|
34
|
+
import { el, setChildren } from './dom.js';
|
|
35
|
+
import { FIXTURE } from './state.js';
|
|
36
|
+
import { pmGet, pmPost } from './http.js';
|
|
37
|
+
import { watchSig } from './sig-watch.js';
|
|
38
|
+
import { SEL, button, iconButton, row, menu, empty, toast, timelineBar, dragHandle, ensureComponentsCss, layer } from './primitives.js';
|
|
39
|
+
import { ensureSprite } from './icons.js';
|
|
40
|
+
import { loadSortable, baseOptions, dragSet } from './bd-drag.js';
|
|
41
|
+
import { COPY, monthLabel, spanText } from './initiatives-timeline-copy.js';
|
|
42
|
+
|
|
43
|
+
const HORIZONS = ['now', 'next', 'later'];
|
|
44
|
+
const DAY = 86400000;
|
|
45
|
+
const day = (ymd) => Date.parse(ymd + 'T00:00:00Z');
|
|
46
|
+
|
|
47
|
+
const views = new WeakMap(); // host -> view state
|
|
48
|
+
|
|
49
|
+
/* ── the chart's axis ────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
50
|
+
|
|
51
|
+
/** The months of a range: [{ start: 'YYYY-MM-01', days }], so a month is as wide as it has days. */
|
|
52
|
+
function monthsOf(range) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let y = Number(range.start.slice(0, 4)); let m = Number(range.start.slice(5, 7));
|
|
55
|
+
const endY = Number(range.end.slice(0, 4)); const endM = Number(range.end.slice(5, 7));
|
|
56
|
+
while (y < endY || (y === endY && m <= endM)) {
|
|
57
|
+
out.push({ start: `${y}-${String(m).padStart(2, '0')}-01`, days: new Date(Date.UTC(y, m, 0)).getUTCDate() });
|
|
58
|
+
m += 1; if (m > 12) { m = 1; y += 1; }
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function todayPercent(range, d) {
|
|
64
|
+
const total = day(range.end) + DAY - day(range.start);
|
|
65
|
+
const at = day(String(d.today || d.now).slice(0, 10)) - day(range.start);
|
|
66
|
+
return Math.max(0, Math.min(100, (at / total) * 100));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A segment cut to the range, or null when it lies wholly outside it. */
|
|
70
|
+
function clip(range, g) {
|
|
71
|
+
if (!range || !g.start) return null;
|
|
72
|
+
if (g.due < range.start || g.start > range.end) return null;
|
|
73
|
+
return { stage: g.stage, start: g.start < range.start ? range.start : g.start, due: g.due > range.end ? range.end : g.due };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ── one row ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
77
|
+
|
|
78
|
+
const listOf = (V, r) => V.data.horizons[r.horizon].filter((x) => x.dated === r.dated);
|
|
79
|
+
|
|
80
|
+
/* A part of the meta line (lane, count, dates) never breaks inside itself: its spaces are no-break spaces. (The row's
|
|
81
|
+
* meta is a primitive's, so the view does not restyle it, R28-9.) */
|
|
82
|
+
const unbroken = (t) => String(t).replace(/ /g, '\u00a0');
|
|
83
|
+
function metaOf(r) {
|
|
84
|
+
return [r.lane, COPY.progress(r.progress.done, r.progress.total), r.span ? spanText(r.span) : null].filter(Boolean).map(unbroken);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function nameCell(V, r) {
|
|
88
|
+
const open = V.open.has(r.id);
|
|
89
|
+
const lead = [dragHandle({ label: COPY.aria.reorder(r.title) })];
|
|
90
|
+
if (!r.stages.length) lead.push(el('span', { class: 'tl-init-lead-gap', 'aria-hidden': 'true' })); // keeps the title where ▸ would leave it
|
|
91
|
+
if (r.stages.length) {
|
|
92
|
+
lead.push(iconButton({
|
|
93
|
+
icon: open ? 'chevron-down' : 'chevron-right', size: 'sm', expanded: open,
|
|
94
|
+
ariaLabel: open ? COPY.aria.hide(r.title) : COPY.aria.show(r.title),
|
|
95
|
+
onClick: (e) => { e.stopPropagation(); if (V.open.has(r.id)) V.open.delete(r.id); else V.open.add(r.id); render(V); },
|
|
96
|
+
attrs: { 'data-tl-toggle': r.id },
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
const more = iconButton({
|
|
100
|
+
icon: 'ellipsis', size: 'sm', ariaLabel: COPY.aria.more(r.title),
|
|
101
|
+
onClick: (e) => { e.stopPropagation(); openMenu(V, e.currentTarget, r); },
|
|
102
|
+
attrs: { 'aria-haspopup': 'menu', 'data-tl-more': r.id },
|
|
103
|
+
});
|
|
104
|
+
return row({
|
|
105
|
+
title: r.title, meta: metaOf(r), lead, trail: [more],
|
|
106
|
+
onOpen: () => open_(V, r.id), attrs: { class: 'tl-init-name' }, openAttrs: { 'data-tl-open': r.id },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function trackCell(V, r) {
|
|
111
|
+
const bar = timelineBar({
|
|
112
|
+
label: COPY.aria.bar(r.title, r.span),
|
|
113
|
+
segments: r.stages.map((s) => clip(V.data.range, { stage: s.id, start: s.start, due: s.due })).filter(Boolean),
|
|
114
|
+
milestones: r.milestones.filter((m) => m >= V.data.range.start && m <= V.data.range.end), range: V.data.range,
|
|
115
|
+
});
|
|
116
|
+
return el('div', { class: 'tl-init-track', 'data-tl-track': r.id }, bar);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stageRow(V, r, s) {
|
|
120
|
+
const seg = clip(V.data.range, { stage: s.id, start: s.start, due: s.due });
|
|
121
|
+
const track = seg
|
|
122
|
+
? el('div', { class: 'tl-init-track' }, timelineBar({
|
|
123
|
+
label: COPY.aria.stageBar(r.title, s.label, { start: s.start, due: s.due }),
|
|
124
|
+
segments: [seg], milestones: [], range: V.data.range,
|
|
125
|
+
}))
|
|
126
|
+
: (V.data.range && r.dated ? el('div', { class: 'tl-init-track' }, el('span', { class: 'tl-init-sub__none' }, COPY.noDatedItems)) : null);
|
|
127
|
+
return el('div', { class: 'tl-init-row tl-init-row--stage', 'data-tl-stage': s.id },
|
|
128
|
+
el('div', { class: 'tl-init-name tl-init-sub' }, COPY.stageLine(s.label, s.done, s.total)), track);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function itemEl(V, r) {
|
|
132
|
+
const kids = [el('div', { class: 'tl-init-row' }, nameCell(V, r), r.dated && V.data.range ? trackCell(V, r) : null)];
|
|
133
|
+
if (V.open.has(r.id)) for (const s of r.stages) kids.push(stageRow(V, r, s));
|
|
134
|
+
return el('div', {
|
|
135
|
+
class: 'tl-init-item', role: 'listitem', 'data-tl-row': r.id, 'data-id': r.id, 'data-horizon': r.horizon, 'data-dated': r.dated ? '1' : '0',
|
|
136
|
+
}, kids);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function listEl(V, h, rows, dated) {
|
|
140
|
+
return el('div', { class: 'tl-init-list', role: 'list', 'data-tl-list': h, 'data-dated': dated ? '1' : '0', 'aria-label': COPY.aria.group(COPY.horizon[h]) },
|
|
141
|
+
rows.map((r) => itemEl(V, r)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ── the chart and the undated section ───────────────────────────────────────────────────────────────────────────── */
|
|
145
|
+
|
|
146
|
+
function chartEl(V, groups) {
|
|
147
|
+
const d = V.data;
|
|
148
|
+
const months = monthsOf(d.range);
|
|
149
|
+
const chart = el('div', { class: 'tl-init-chart', 'data-tl-chart': '', role: 'region', tabindex: '0', 'aria-label': COPY.aria.chart });
|
|
150
|
+
chart.style.setProperty('--tl-init-months', String(months.length));
|
|
151
|
+
chart.style.setProperty('--tl-init-cols', months.map((m) => m.days + 'fr').join(' '));
|
|
152
|
+
chart.style.setProperty('--tl-init-today', todayPercent(d.range, d).toFixed(2) + '%');
|
|
153
|
+
const head = el('div', { class: 'tl-init-head' }, el('div', { class: 'tl-init-corner' }),
|
|
154
|
+
el('div', { class: 'tl-init-months', 'data-tl-months': '' }, months.map((m, i) => el('div', { class: 'tl-init-month' }, monthLabel(m.start, i === 0)))));
|
|
155
|
+
const body = [head];
|
|
156
|
+
for (const g of groups) {
|
|
157
|
+
body.push(el('div', { class: 'tl-init-group', 'data-tl-group': g.h }, el('div', { class: 'tl-init-gname' }, COPY.horizon[g.h]), el('div', null)));
|
|
158
|
+
body.push(listEl(V, g.h, g.rows, true));
|
|
159
|
+
}
|
|
160
|
+
body.push(el('div', { class: 'tl-init-plot', 'aria-hidden': 'true' },
|
|
161
|
+
el('div', { class: 'tl-init-today', 'data-tl-today': '' }, el('span', { class: 'tl-init-today__label' }, COPY.today))));
|
|
162
|
+
chart.appendChild(el('div', { class: 'tl-init-inner' }, body));
|
|
163
|
+
return chart;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function undatedEl(V, groups) {
|
|
167
|
+
const kids = [el('h3', { class: 'tl-init-undated__title' }, COPY.undatedHead), el('p', { class: 'tl-init-undated__note' }, COPY.undatedNote)];
|
|
168
|
+
for (const g of groups) {
|
|
169
|
+
kids.push(el('div', { class: 'tl-init-gname tl-init-gname--undated', 'data-tl-group': g.h }, COPY.horizon[g.h]));
|
|
170
|
+
kids.push(listEl(V, g.h, g.rows, false));
|
|
171
|
+
}
|
|
172
|
+
return el('section', { class: 'tl-init-undated', 'data-tl-undated': '' }, kids);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function bodyOf(V) {
|
|
176
|
+
if (!V.ctx.ws) return [empty({ text: COPY.readFailed(COPY.why.noWs) })];
|
|
177
|
+
if (!V.data) {
|
|
178
|
+
if (V.error) return [empty({ text: COPY.readFailed(V.error), attrs: { role: 'alert', 'data-tl-error': '' } }), button({ label: COPY.retry, onClick: () => load(V), attrs: { 'data-tl-retry': '' } })];
|
|
179
|
+
return [empty({ text: COPY.reading })];
|
|
180
|
+
}
|
|
181
|
+
const d = V.data;
|
|
182
|
+
const groups = (dated) => HORIZONS.map((h) => ({ h, rows: d.horizons[h].filter((r) => r.dated === dated) })).filter((g) => g.rows.length);
|
|
183
|
+
const datedGroups = groups(true);
|
|
184
|
+
const undatedGroups = groups(false);
|
|
185
|
+
if (!datedGroups.length && !undatedGroups.length) return [empty({ text: COPY.none, attrs: { 'data-tl-none': '' } })];
|
|
186
|
+
const out = [];
|
|
187
|
+
if (V.error) out.push(empty({ text: COPY.readFailed(V.error), attrs: { role: 'alert', 'data-tl-error': '' } }));
|
|
188
|
+
if (datedGroups.length && d.range) {
|
|
189
|
+
out.push(chartEl(V, datedGroups));
|
|
190
|
+
if (d.range.clipped) out.push(el('p', { class: 'tl-init-note', 'data-tl-clipped': '' }, COPY.clipped(monthLabel(d.range.start, true), monthLabel(d.range.end, true))));
|
|
191
|
+
}
|
|
192
|
+
else out.push(empty({ text: COPY.noDates, attrs: { 'data-tl-nodates': '' } }));
|
|
193
|
+
if (undatedGroups.length) out.push(undatedEl(V, undatedGroups));
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── render, drag, reorder ───────────────────────────────────────────────────────────────────────────────────────── */
|
|
198
|
+
|
|
199
|
+
function render(V) {
|
|
200
|
+
if (V.disposed) return;
|
|
201
|
+
V.drags.destroy();
|
|
202
|
+
const old = V.root.querySelector('.tl-init-chart');
|
|
203
|
+
const left = old ? old.scrollLeft : null;
|
|
204
|
+
setChildren(V.root, bodyOf(V));
|
|
205
|
+
const chart = V.root.querySelector('.tl-init-chart');
|
|
206
|
+
if (chart) {
|
|
207
|
+
if (left !== null) chart.scrollLeft = left;
|
|
208
|
+
else if (!V.scrolled) { V.scrolled = true; showToday(chart); }
|
|
209
|
+
}
|
|
210
|
+
if (V.focusId) {
|
|
211
|
+
const b = V.root.querySelector(`[data-tl-open="${CSS.escape(V.focusId)}"]`);
|
|
212
|
+
V.focusId = null;
|
|
213
|
+
if (b) b.focus();
|
|
214
|
+
}
|
|
215
|
+
bindDrag(V);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A chart wider than its box opens with today about a third of the way in, not on its first month. */
|
|
219
|
+
function showToday(chart) {
|
|
220
|
+
if (chart.scrollWidth <= chart.clientWidth) return;
|
|
221
|
+
const plot = chart.querySelector('.tl-init-plot');
|
|
222
|
+
const today = chart.querySelector('.tl-init-today');
|
|
223
|
+
if (!plot || !today) return;
|
|
224
|
+
chart.scrollLeft = Math.max(0, plot.offsetLeft + today.offsetLeft - Math.round(chart.clientWidth / 3));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function bindDrag(V) {
|
|
228
|
+
loadSortable().then((Sortable) => {
|
|
229
|
+
if (!Sortable || V.disposed || V.drags.size || !V.data) return;
|
|
230
|
+
for (const list of V.root.querySelectorAll('.tl-init-list')) {
|
|
231
|
+
V.drags.add(new Sortable(list, baseOptions({
|
|
232
|
+
handle: SEL.drag, draggable: '.tl-init-item',
|
|
233
|
+
ghostClass: 'tl-init-ghost', chosenClass: 'tl-init-chosen', dragClass: 'tl-init-dragging', fallbackClass: 'tl-init-fallback',
|
|
234
|
+
onStart: () => { V.dragging = true; },
|
|
235
|
+
onEnd: (evt) => { V.dragging = false; drop(V, list, evt); },
|
|
236
|
+
})));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function drop(V, list, evt) {
|
|
242
|
+
const dirty = V.dirty; V.dirty = false;
|
|
243
|
+
const item = evt.item;
|
|
244
|
+
const id = item && item.getAttribute('data-id');
|
|
245
|
+
const r = id && V.data && V.data.horizons[item.getAttribute('data-horizon')].find((x) => x.id === id);
|
|
246
|
+
if (!r || evt.oldIndex === evt.newIndex) { if (dirty) render(V); return; }
|
|
247
|
+
const next = [...list.querySelectorAll(':scope > .tl-init-item')].map((n) => n.getAttribute('data-id'));
|
|
248
|
+
place(V, r, moveEvents(listOf(V, r), next, id));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The `placed` events that make the list `list` (rows of one horizon and one group, in the order shown) read as `next`
|
|
253
|
+
* (the same ids with `moved` in its new place). The ledger puts a row after the row `after` names, and a row it holds no
|
|
254
|
+
* line for is not found: such a row (`placed: false`) reads after every row that has a line, in id order. So:
|
|
255
|
+
* - one event, `moved` after the row above it, when that row has a line (or there is none: the top) — the rows below
|
|
256
|
+
* keep the order they read in;
|
|
257
|
+
* - else one event for each row from the first row the move changes (or the first row with no line, if that is
|
|
258
|
+
* earlier) to the last row that must have a line. The rows after that point have no line and are already in id order.
|
|
259
|
+
*/
|
|
260
|
+
export function moveEvents(list, next, moved) {
|
|
261
|
+
const horizon = list[0].horizon;
|
|
262
|
+
const cur = list.map((x) => x.id);
|
|
263
|
+
const has = new Map(list.map((x) => [x.id, !!x.placed]));
|
|
264
|
+
const line = (k) => ({ ev: 'placed', initiative: next[k], horizon, after: k ? next[k - 1] : null });
|
|
265
|
+
const to = next.indexOf(moved);
|
|
266
|
+
if (!to || has.get(next[to - 1])) return [line(to)];
|
|
267
|
+
const a = Math.min(next.findIndex((id, k) => id !== cur[k]), next.findIndex((id) => !has.get(id)));
|
|
268
|
+
const order = cur.filter((id) => !has.get(id));
|
|
269
|
+
let b = next.length;
|
|
270
|
+
let at = order.length;
|
|
271
|
+
while (b > a) { // the tail of rows with no line that keep their id order
|
|
272
|
+
const k = order.indexOf(next[b - 1]);
|
|
273
|
+
if (k < 0 || k >= at) break;
|
|
274
|
+
at = k; b -= 1;
|
|
275
|
+
}
|
|
276
|
+
if (a < 0 || a >= b) return [line(to)];
|
|
277
|
+
const out = [];
|
|
278
|
+
for (let k = a; k < b; k++) out.push(line(k));
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Write the `placed` events of one move. The page then reads the rows again. */
|
|
283
|
+
async function place(V, r, events) {
|
|
284
|
+
if (V.busy) return;
|
|
285
|
+
V.busy = true;
|
|
286
|
+
V.focusId = r.id;
|
|
287
|
+
let res;
|
|
288
|
+
try { res = await pmPost('/api/placements', { ws: V.ctx.ws, events }); }
|
|
289
|
+
catch (e) { res = { ok: false, error: String((e && e.message) || e) }; }
|
|
290
|
+
V.busy = false;
|
|
291
|
+
if (V.disposed) return;
|
|
292
|
+
if (res && res.ok) {
|
|
293
|
+
if (events.length > 1) toast({ text: COPY.alsoPlaced(r.title, events.length - 1, COPY.horizon[r.horizon]) });
|
|
294
|
+
await load(V);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
toast({ text: COPY.moveFailed(r.title, (res && res.error) || COPY.why.noAnswer), tone: 'error' });
|
|
298
|
+
render(V);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Move `r` by `dir` places inside its own list (-1 up, 1 down), or to the top (dir 'top'). */
|
|
302
|
+
function reorder(V, r, dir) {
|
|
303
|
+
const list = listOf(V, r);
|
|
304
|
+
const i = list.findIndex((x) => x.id === r.id);
|
|
305
|
+
const j = dir === 'top' ? 0 : i + dir;
|
|
306
|
+
if (i < 0 || j < 0 || j >= list.length || j === i) return;
|
|
307
|
+
const next = list.map((x) => x.id);
|
|
308
|
+
next.splice(i, 1); next.splice(j, 0, r.id);
|
|
309
|
+
place(V, r, moveEvents(list, next, r.id));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function openMenu(V, anchor, r) {
|
|
313
|
+
const list = listOf(V, r);
|
|
314
|
+
const i = list.findIndex((x) => x.id === r.id);
|
|
315
|
+
const items = [{ label: COPY.menu.open, onSelect: () => open_(V, r.id) }];
|
|
316
|
+
if (i > 0) items.push({ label: COPY.menu.top, onSelect: () => reorder(V, r, 'top') }, { label: COPY.menu.up, onSelect: () => reorder(V, r, -1) });
|
|
317
|
+
if (i >= 0 && i < list.length - 1) items.push({ label: COPY.menu.down, onSelect: () => reorder(V, r, 1) });
|
|
318
|
+
menu({ anchor, items, ariaLabel: COPY.aria.more(r.title) });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const open_ = (V, id) => { if (typeof V.ctx.openInitiative === 'function') V.ctx.openInitiative(V.ctx.repo, id); };
|
|
322
|
+
|
|
323
|
+
function onKey(V, e) {
|
|
324
|
+
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
|
|
325
|
+
const item = e.target && e.target.closest ? e.target.closest('.tl-init-item') : null;
|
|
326
|
+
if (!item) return;
|
|
327
|
+
const r = V.data && V.data.horizons[item.getAttribute('data-horizon')].find((x) => x.id === item.getAttribute('data-id'));
|
|
328
|
+
if (!r) return;
|
|
329
|
+
e.preventDefault(); e.stopPropagation();
|
|
330
|
+
reorder(V, r, e.key === 'ArrowUp' ? -1 : 1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* ── load, watch, mount ──────────────────────────────────────────────────────────────────────────────────────────── */
|
|
334
|
+
|
|
335
|
+
const urlOf = (ws) => '/api/timeline?ws=' + encodeURIComponent(ws);
|
|
336
|
+
|
|
337
|
+
/** Draw an answer of the route. During a drag it only marks the page dirty: the rows must not move under the pointer. */
|
|
338
|
+
function apply(V, j) {
|
|
339
|
+
V.data = j; V.error = null;
|
|
340
|
+
if (V.dragging) V.dirty = true; else render(V);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function load(V) {
|
|
344
|
+
if (!V.ctx.ws) { render(V); return; }
|
|
345
|
+
const seq = ++V.seq;
|
|
346
|
+
const j = await pmGet(urlOf(V.ctx.ws));
|
|
347
|
+
if (V.disposed || seq !== V.seq) return;
|
|
348
|
+
if (!j || j.ok === false) {
|
|
349
|
+
V.error = (j && j.error) || COPY.why.noAnswer;
|
|
350
|
+
if (!V.dragging) render(V);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
apply(V, j);
|
|
354
|
+
if (!V.stop) V.stop = watch(V);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The watcher's first read is the answer already on screen (no GET); every later read is compared with the sig drawn. */
|
|
358
|
+
function watch(V) {
|
|
359
|
+
let first = true;
|
|
360
|
+
return watchSig(async () => {
|
|
361
|
+
if (first) { first = false; return JSON.stringify(V.data.sig); }
|
|
362
|
+
const seq = V.seq; // a load that starts meanwhile draws its own answer, and this one is dropped
|
|
363
|
+
const k = await pmGet(urlOf(V.ctx.ws));
|
|
364
|
+
if (!k || k.ok === false || !k.sig) return null;
|
|
365
|
+
const sig = JSON.stringify(k.sig);
|
|
366
|
+
if (!V.disposed && seq === V.seq && sig !== JSON.stringify(V.data.sig)) apply(V, k);
|
|
367
|
+
return sig;
|
|
368
|
+
}, () => {}, { everyMs: 10000 });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function create(host, ctx) {
|
|
372
|
+
const V = {
|
|
373
|
+
host, ctx, ws: ctx.ws, root: el('div', { class: 'tl-init-root', 'data-tl-init': '' }), data: null, error: null, open: new Set(),
|
|
374
|
+
drags: dragSet(), dragging: false, dirty: false, busy: false, disposed: false, seq: 0, scrolled: false, stop: null, focusId: null,
|
|
375
|
+
};
|
|
376
|
+
V.root.addEventListener('keydown', (e) => onKey(V, e));
|
|
377
|
+
return V;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function dispose(host) {
|
|
381
|
+
const V = views.get(host);
|
|
382
|
+
if (!V) return;
|
|
383
|
+
views.delete(host);
|
|
384
|
+
V.disposed = true;
|
|
385
|
+
if (V.stop) V.stop();
|
|
386
|
+
V.drags.destroy();
|
|
387
|
+
if (V.root.parentNode === host) V.root.remove();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function mount(host, ctx) {
|
|
391
|
+
if (FIXTURE) return;
|
|
392
|
+
let V = views.get(host);
|
|
393
|
+
if (V && V.ws !== ctx.ws) { dispose(host); V = null; }
|
|
394
|
+
if (!V) {
|
|
395
|
+
ensureComponentsCss(); layer(); ensureSprite();
|
|
396
|
+
V = create(host, ctx);
|
|
397
|
+
views.set(host, V);
|
|
398
|
+
setChildren(host, [V.root]);
|
|
399
|
+
render(V);
|
|
400
|
+
load(V);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
V.ctx = ctx;
|
|
404
|
+
if (host.firstChild !== V.root || host.childNodes.length !== 1) setChildren(host, [V.root]);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
registerInitiativesView('timeline', { label: COPY.view, mount, dispose });
|
|
408
|
+
|
|
409
|
+
/* for the browser suites: the state of the view mounted in a host, read-only */
|
|
410
|
+
export function _viewOf(host) { return views.get(host) || null; }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/* pm/initiatives-ui.js — the small pieces the Initiatives modules share (pm-os-absorb Run 2, slice 2D).
|
|
2
|
+
*
|
|
3
|
+
* healthSignal(health) the health reason as text plus a 6px dot, or null when on track (R8: on track shows nothing)
|
|
4
|
+
* horizonLabel(h) "Now", "Next" or "Later"
|
|
5
|
+
* progressText(row) "7 of 12" or "no breakdown", from the server's own label
|
|
6
|
+
* confirmSheet({…}) a sheet that asks for one press (a PM-only act): an optional choice, a reason, Cancel, Confirm
|
|
7
|
+
* checkBySheet({…}) a sheet with a date and Save or Clear
|
|
8
|
+
* say(out, ok) a toast for the answer of a write: the success text, or the server's refusal
|
|
9
|
+
* No native dialog is used anywhere here. Every control is a primitive (primitives.js).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { el } from './dom.js';
|
|
13
|
+
import { button, field, signal, sheet, tabs, toast } from './primitives.js';
|
|
14
|
+
import { COPY } from './initiatives-copy.js';
|
|
15
|
+
|
|
16
|
+
export const horizonLabel = (h) => COPY.horizons[h] || h;
|
|
17
|
+
export const progressText = (row) => (row && row.progress && row.progress.label) || '';
|
|
18
|
+
|
|
19
|
+
/** The most severe reason of a health answer, as a signal. `on` shows nothing. */
|
|
20
|
+
export function healthSignal(health) {
|
|
21
|
+
if (!health || health.level === 'on' || !Array.isArray(health.reasons) || !health.reasons.length) return null;
|
|
22
|
+
const top = health.reasons[0];
|
|
23
|
+
const tone = health.level === 'off' ? 'destructive' : health.level === 'at-risk' ? 'warning' : 'muted';
|
|
24
|
+
return signal({ text: top.text || COPY.healthUnknown, tone, title: top.source || null, attrs: { 'data-health': health.level } });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A toast for the answer of a write. A refusal shows the server's own words. */
|
|
28
|
+
export function say(out, okText) {
|
|
29
|
+
if (out && out.ok) { if (okText) toast({ text: okText, timeout: 4000 }); return true; }
|
|
30
|
+
toast({ text: COPY.refused((out && out.error) || 'The server did not answer.'), tone: 'error', timeout: 8000 });
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A sheet that asks for one press.
|
|
36
|
+
* confirmSheet({ title, body, confirmLabel, reason: boolean, choice?: { label, items: [{ id, label }], value }, onConfirm(reason, choice) → Promise<{ ok, error? }> })
|
|
37
|
+
* `choice` is a row of radio buttons inside the sheet (the bulk lifecycle's state), so the choice and the confirm are one sheet and
|
|
38
|
+
* the default needs no press. A refusal stays in the sheet, in words, and the buttons come back; success closes it. Returns the sheet handle.
|
|
39
|
+
*/
|
|
40
|
+
export function confirmSheet({ title, body, confirmLabel, reason, choice, onConfirm }) {
|
|
41
|
+
let rf = null;
|
|
42
|
+
let picked = choice ? choice.value : null;
|
|
43
|
+
const err = el('p', { class: 'in-error', role: 'alert', 'data-in-error': '' });
|
|
44
|
+
err.hidden = true;
|
|
45
|
+
const note = el('p', { class: 'in-note' }, body || '');
|
|
46
|
+
const kids = [note];
|
|
47
|
+
if (choice) {
|
|
48
|
+
const holder = el('div', { 'data-in-choice': '' });
|
|
49
|
+
const draw = (focus) => {
|
|
50
|
+
holder.replaceChildren(tabs({ variant: 'segmented', role: 'radiogroup', items: choice.items, value: picked, ariaLabel: choice.label, full: true, onChange: (id) => { picked = id; draw(true); } }));
|
|
51
|
+
if (focus) { const b = holder.querySelector('[data-tab="' + picked + '"]'); if (b) b.focus(); }
|
|
52
|
+
};
|
|
53
|
+
draw(false);
|
|
54
|
+
kids.push(el('p', { class: 'in-note' }, choice.label), holder);
|
|
55
|
+
}
|
|
56
|
+
if (reason) { rf = field({ label: COPY.reason, kind: 'text', attrs: { 'data-in-reason': '' } }); kids.push(rf); }
|
|
57
|
+
kids.push(err);
|
|
58
|
+
let busy = false;
|
|
59
|
+
const go = button({
|
|
60
|
+
label: confirmLabel || COPY.confirm, variant: 'primary', attrs: { 'data-in-confirm': '' },
|
|
61
|
+
onClick: async () => {
|
|
62
|
+
if (busy) return;
|
|
63
|
+
busy = true; go.disabled = true; err.hidden = true;
|
|
64
|
+
let out;
|
|
65
|
+
try { out = await onConfirm(rf ? rf.control.value.trim() : '', picked); } catch (e) { out = { ok: false, error: String((e && e.message) || e) }; }
|
|
66
|
+
busy = false; go.disabled = false;
|
|
67
|
+
if (out && out.ok) { s.close(); return; }
|
|
68
|
+
err.textContent = COPY.refused((out && out.error) || 'The server did not answer.');
|
|
69
|
+
err.hidden = false;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const s = sheet({
|
|
73
|
+
mode: 'auto', title, body: kids, attrs: { 'data-in-sheet': 'confirm' },
|
|
74
|
+
actions: [button({ label: COPY.cancel, variant: 'outline', onClick: () => s.close(), attrs: { 'data-in-cancel': '' } }), go],
|
|
75
|
+
});
|
|
76
|
+
return s;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A sheet with one date. `onSave(date | null)` answers `{ ok, error? }`; Clear saves null. */
|
|
80
|
+
export function checkBySheet({ title, value, onSave }) {
|
|
81
|
+
const f = field({ label: COPY.sheet.checkByLabel, kind: 'date', value: value || '', attrs: { 'data-in-date': '' } });
|
|
82
|
+
const err = el('p', { class: 'in-error', role: 'alert', 'data-in-error': '' });
|
|
83
|
+
err.hidden = true;
|
|
84
|
+
const run = async (date) => {
|
|
85
|
+
let out;
|
|
86
|
+
try { out = await onSave(date); } catch (e) { out = { ok: false, error: String((e && e.message) || e) }; }
|
|
87
|
+
if (out && out.ok) { s.close(); return; }
|
|
88
|
+
err.textContent = COPY.refused((out && out.error) || 'The server did not answer.');
|
|
89
|
+
err.hidden = false;
|
|
90
|
+
};
|
|
91
|
+
const s = sheet({
|
|
92
|
+
mode: 'auto', title, body: [f, err], attrs: { 'data-in-sheet': 'check-by' },
|
|
93
|
+
actions: [
|
|
94
|
+
button({ label: COPY.sheet.clear, variant: 'outline', onClick: () => run(null), attrs: { 'data-in-clear': '' } }),
|
|
95
|
+
button({ label: COPY.sheet.save, variant: 'primary', onClick: () => run(f.control.value || null), attrs: { 'data-in-save': '' } }),
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let rightDown = false; // the right button is down: the menu waits for its release (see onContextMenu)
|
|
102
|
+
let afterUp = [];
|
|
103
|
+
if (typeof document !== 'undefined') {
|
|
104
|
+
document.addEventListener('pointerdown', (e) => { if (e.button === 2) rightDown = true; }, true);
|
|
105
|
+
const up = () => { rightDown = false; const run = afterUp; afterUp = []; for (const f of run) setTimeout(f, 0); };
|
|
106
|
+
document.addEventListener('pointerup', up, true);
|
|
107
|
+
document.addEventListener('pointercancel', up, true);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The context menu of a card or a row on a desk: a right click, or the menu key on a focused one. `find(target)` answers
|
|
112
|
+
* `{ anchor, id }` for the node under the pointer, or null. The menu is a popover that closes on a pointer-up outside it, so a
|
|
113
|
+
* right click that opens it between the button's down and up would close it at once: the open waits for the up.
|
|
114
|
+
*/
|
|
115
|
+
export function onContextMenu(root, find, open) {
|
|
116
|
+
root.addEventListener('contextmenu', (e) => {
|
|
117
|
+
const hit = find(e.target);
|
|
118
|
+
if (!hit) return;
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
if (!rightDown) { open(hit.anchor, hit.id); return; }
|
|
121
|
+
afterUp.push(() => open(hit.anchor, hit.id));
|
|
122
|
+
});
|
|
123
|
+
}
|