create-pmos 0.55.4 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/docs.md +96 -0
- package/workspace/guide/index.md +3 -1
- package/workspace/guide/today.md +15 -1
- package/workspace/lib/doc-sync-store.js +322 -0
- package/workspace/lib/doc-sync.js +225 -0
- package/workspace/lib/docs-cli.js +221 -0
- package/workspace/lib/goals.js +44 -4
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/proactive.js +20 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -2
- package/workspace/public/pm/components.css +149 -0
- package/workspace/public/pm/dest-docs.js +405 -0
- package/workspace/public/pm/dest-today.js +4 -1
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/public/pm/today-copy.js +5 -0
- package/workspace/public/pm/today-day.js +57 -17
- package/workspace/public/pm/today-goals.js +13 -7
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +110 -2
- package/workspace/skills/doc-check.skill +40 -0
- package/workspace/skills/doc-sync.skill +34 -0
- package/workspace/test-lists/docs-three-way-sync.json +6 -0
|
@@ -2362,6 +2362,9 @@
|
|
|
2362
2362
|
.td-day__r[data-goal-tie="true"] { border-left: 3px solid var(--foreground); }
|
|
2363
2363
|
.td-day__lead { min-width: 44px; font: 12px var(--mono); color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
|
|
2364
2364
|
.td-day__chips { display: inline-flex; gap: 4px; }
|
|
2365
|
+
.td-day__chips { align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
|
2366
|
+
.td-day__repo { font: inherit; font-size: 12px; color: var(--foreground); background: var(--background); border: 1px solid var(--border); border-radius: 0; padding: 2px 6px; max-width: 160px; }
|
|
2367
|
+
.td-day__repo:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 2px; }
|
|
2365
2368
|
.td-day__nowrow { display: flex; align-items: center; gap: 8px; padding: 0 8px; font: 11px var(--mono); color: var(--destructive); }
|
|
2366
2369
|
.td-day__nowrow::after { content: ""; flex: 1; border-top: 2px solid var(--destructive); }
|
|
2367
2370
|
.td-day__gh { display: flex; align-items: center; gap: 6px; margin: 4px 8px 2px; font-size: 12px; font-weight: 600; color: var(--muted-foreground); }
|
|
@@ -4064,6 +4067,152 @@
|
|
|
4064
4067
|
}
|
|
4065
4068
|
}
|
|
4066
4069
|
|
|
4070
|
+
/* Docs (docs-three-way-sync): one line per copy — a status dot, the title over the copy's link, one sentence, one action. */
|
|
4071
|
+
.rp-docs {
|
|
4072
|
+
display: flex;
|
|
4073
|
+
flex-direction: column;
|
|
4074
|
+
gap: 16px;
|
|
4075
|
+
}
|
|
4076
|
+
.rp-docs-top {
|
|
4077
|
+
display: flex;
|
|
4078
|
+
flex-wrap: wrap;
|
|
4079
|
+
align-items: center;
|
|
4080
|
+
gap: 12px;
|
|
4081
|
+
}
|
|
4082
|
+
.rp-docs-top > .rp-head__title {
|
|
4083
|
+
margin-right: auto;
|
|
4084
|
+
}
|
|
4085
|
+
.rp-docs-find {
|
|
4086
|
+
flex: 1 1 260px;
|
|
4087
|
+
max-width: 420px;
|
|
4088
|
+
}
|
|
4089
|
+
.rp-docs-body {
|
|
4090
|
+
display: flex;
|
|
4091
|
+
flex-direction: column;
|
|
4092
|
+
gap: 16px;
|
|
4093
|
+
}
|
|
4094
|
+
.rp-docs-views {
|
|
4095
|
+
display: flex;
|
|
4096
|
+
flex-wrap: wrap;
|
|
4097
|
+
align-items: center;
|
|
4098
|
+
gap: 12px;
|
|
4099
|
+
border-bottom: 1px solid var(--border);
|
|
4100
|
+
}
|
|
4101
|
+
.rp-docs-when {
|
|
4102
|
+
display: flex;
|
|
4103
|
+
align-items: center;
|
|
4104
|
+
gap: 4px;
|
|
4105
|
+
margin-left: auto;
|
|
4106
|
+
font-size: 12px;
|
|
4107
|
+
color: var(--muted-foreground);
|
|
4108
|
+
}
|
|
4109
|
+
.rp-docs-group__title {
|
|
4110
|
+
margin: 0;
|
|
4111
|
+
padding: 8px 0;
|
|
4112
|
+
font-size: 12px;
|
|
4113
|
+
font-weight: 500;
|
|
4114
|
+
color: var(--muted-foreground);
|
|
4115
|
+
}
|
|
4116
|
+
.rp-docs-row {
|
|
4117
|
+
border-top: 1px solid var(--border);
|
|
4118
|
+
}
|
|
4119
|
+
.rp-docs-row:last-child {
|
|
4120
|
+
border-bottom: 1px solid var(--border);
|
|
4121
|
+
}
|
|
4122
|
+
.rp-docs-dot {
|
|
4123
|
+
display: inline-block;
|
|
4124
|
+
width: 8px;
|
|
4125
|
+
height: 8px;
|
|
4126
|
+
background: var(--muted-foreground);
|
|
4127
|
+
}
|
|
4128
|
+
.rp-docs-dot--ok {
|
|
4129
|
+
background: var(--muted-foreground);
|
|
4130
|
+
}
|
|
4131
|
+
.rp-docs-dot--warn {
|
|
4132
|
+
background: var(--warning);
|
|
4133
|
+
}
|
|
4134
|
+
.rp-docs-dot--bad {
|
|
4135
|
+
background: var(--destructive);
|
|
4136
|
+
}
|
|
4137
|
+
.rp-docs-dot--none {
|
|
4138
|
+
background: transparent;
|
|
4139
|
+
border: 1px solid var(--muted-foreground);
|
|
4140
|
+
}
|
|
4141
|
+
.rp-docs-open {
|
|
4142
|
+
font-size: 12px;
|
|
4143
|
+
color: var(--muted-foreground);
|
|
4144
|
+
white-space: nowrap;
|
|
4145
|
+
text-underline-offset: 2px;
|
|
4146
|
+
}
|
|
4147
|
+
.rp-docs-detail {
|
|
4148
|
+
display: grid;
|
|
4149
|
+
grid-template-columns: 1fr 1fr;
|
|
4150
|
+
gap: 12px;
|
|
4151
|
+
padding: 4px 0 16px 24px;
|
|
4152
|
+
}
|
|
4153
|
+
.rp-docs-side {
|
|
4154
|
+
display: flex;
|
|
4155
|
+
flex-direction: column;
|
|
4156
|
+
align-items: flex-start;
|
|
4157
|
+
gap: 4px;
|
|
4158
|
+
min-width: 0;
|
|
4159
|
+
padding: 12px;
|
|
4160
|
+
border: 1px solid var(--border);
|
|
4161
|
+
}
|
|
4162
|
+
.rp-docs-side--new {
|
|
4163
|
+
border-color: var(--foreground);
|
|
4164
|
+
}
|
|
4165
|
+
.rp-docs-side__label {
|
|
4166
|
+
font-size: 12px;
|
|
4167
|
+
color: var(--muted-foreground);
|
|
4168
|
+
}
|
|
4169
|
+
.rp-docs-side__where {
|
|
4170
|
+
max-width: 100%;
|
|
4171
|
+
font-family: var(--mono);
|
|
4172
|
+
font-size: 12px;
|
|
4173
|
+
overflow-wrap: anywhere;
|
|
4174
|
+
}
|
|
4175
|
+
.rp-docs-side__edit {
|
|
4176
|
+
margin-bottom: 8px;
|
|
4177
|
+
font-size: 14px;
|
|
4178
|
+
}
|
|
4179
|
+
.rp-docs-diff {
|
|
4180
|
+
grid-column: 1 / -1;
|
|
4181
|
+
margin: 0;
|
|
4182
|
+
padding: 8px 10px;
|
|
4183
|
+
border: 1px solid var(--border);
|
|
4184
|
+
font-family: var(--mono);
|
|
4185
|
+
font-size: 12px;
|
|
4186
|
+
overflow-wrap: anywhere;
|
|
4187
|
+
}
|
|
4188
|
+
.rp-docs-more {
|
|
4189
|
+
grid-column: 1 / -1;
|
|
4190
|
+
display: flex;
|
|
4191
|
+
flex-wrap: wrap;
|
|
4192
|
+
gap: 8px;
|
|
4193
|
+
}
|
|
4194
|
+
.rp-docs-add {
|
|
4195
|
+
display: flex;
|
|
4196
|
+
flex-wrap: wrap;
|
|
4197
|
+
align-items: center;
|
|
4198
|
+
gap: 8px;
|
|
4199
|
+
padding: 6px 0 12px 24px;
|
|
4200
|
+
}
|
|
4201
|
+
.rp-docs-add__input {
|
|
4202
|
+
flex: 1 1 240px;
|
|
4203
|
+
}
|
|
4204
|
+
.rp-docs-add__err {
|
|
4205
|
+
flex-basis: 100%;
|
|
4206
|
+
font-size: 12px;
|
|
4207
|
+
color: var(--destructive);
|
|
4208
|
+
}
|
|
4209
|
+
@media (max-width: 640px) {
|
|
4210
|
+
.rp-docs-detail {
|
|
4211
|
+
grid-template-columns: 1fr;
|
|
4212
|
+
padding-left: 0;
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4067
4216
|
/* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
|
|
4068
4217
|
/* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
|
|
4069
4218
|
.lg-stack {
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/* pm/dest-docs.js — the Docs destination: is each document the same as its Google Docs and Confluence copy, and use
|
|
2
|
+
* one version over the other on the PM's press (initiative docs-three-way-sync, PRD R-D1 to R-D6 and R-F1 to R-F4).
|
|
3
|
+
*
|
|
4
|
+
* A DESTINATION MODULE (registry.js): mountDestination(host, ctx) on every render with the same host, disposeDestination(host).
|
|
5
|
+
* It keeps ONE persistent root per host; the header (title and search) is built once, so typing never loses focus, and
|
|
6
|
+
* only the body is rebuilt when its own data changed.
|
|
7
|
+
*
|
|
8
|
+
* What it shows, for the repository the tab names (or the open one):
|
|
9
|
+
* - Docs, a search box, Needs you · All, and when the copies were last compared, with a control that compares again;
|
|
10
|
+
* - the copies grouped by initiative, ONE line per copy: a status dot, the title, a link that opens the copy, one
|
|
11
|
+
* sentence (which side is newer, and who edited it last) and at most one button (Use PMOS version, Use Confluence
|
|
12
|
+
* version, or Choose… when both changed);
|
|
13
|
+
* - a click on a line: both sides with where they live and their last edit, a Use … version button for each, the first
|
|
14
|
+
* difference the check saw, Compare again and Stop tracking this copy;
|
|
15
|
+
* - search: every markdown file that matches; a file without a copy offers Add copy, in place.
|
|
16
|
+
* The signal is decided on the server (lib/doc-sync.js) from what a check session read. Nothing here reads or writes
|
|
17
|
+
* Google Docs or Confluence. Use … version waits five seconds behind Undo, then asks the server to start ONE doc-sync
|
|
18
|
+
* session for that copy, which writes one side with the PM's own connectors (the PM's ruling, amended 2026-10-06).
|
|
19
|
+
* In fixture mode it reads nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { el } from './dom.js';
|
|
23
|
+
import { button, row, field, toast, empty, tabs, ensureComponentsCss } from './primitives.js';
|
|
24
|
+
import { ensureSprite } from './icons.js';
|
|
25
|
+
import { pmGet, pmPost } from './http.js';
|
|
26
|
+
import { wsOf } from './data.js';
|
|
27
|
+
import { registerCommand, openDestination } from './registry.js';
|
|
28
|
+
|
|
29
|
+
const LABEL = { google: 'Google Docs', confluence: 'Confluence' };
|
|
30
|
+
const UNDO_MS = 5000;
|
|
31
|
+
|
|
32
|
+
export const COPY = Object.freeze({
|
|
33
|
+
title: 'Docs',
|
|
34
|
+
fixture: 'Docs shows linked documents in a real session.',
|
|
35
|
+
noRepo: 'Open a repository to see its documents.',
|
|
36
|
+
loading: 'Reading…',
|
|
37
|
+
readError: 'The documents could not be read.',
|
|
38
|
+
find: 'Find a document',
|
|
39
|
+
needs: 'Needs you',
|
|
40
|
+
all: 'All',
|
|
41
|
+
compared: (iso) => 'Compared ' + ago(iso),
|
|
42
|
+
never: 'Not compared yet',
|
|
43
|
+
comparing: 'Comparing…',
|
|
44
|
+
compareAll: 'Compare every copy again',
|
|
45
|
+
none: 'No document has a copy yet. Find a document above and press Add copy.',
|
|
46
|
+
nothingNeeds: 'Nothing needs you. Every copy matches PMOS.',
|
|
47
|
+
noMatch: (q) => `No document matches “${q}”.`,
|
|
48
|
+
other: 'Other',
|
|
49
|
+
usePmos: 'Use PMOS version',
|
|
50
|
+
useCopy: (label) => `Use ${label} version`,
|
|
51
|
+
choose: 'Choose…',
|
|
52
|
+
noCopy: 'No copy',
|
|
53
|
+
addCopy: 'Add copy',
|
|
54
|
+
add: 'Add',
|
|
55
|
+
cancel: 'Cancel',
|
|
56
|
+
paste: 'Paste a Google Docs or Confluence link',
|
|
57
|
+
badPaste: 'Paste the address of a Google Doc or a Confluence page.',
|
|
58
|
+
added: 'Copy added. Compare to see which side is newer.',
|
|
59
|
+
compareAgain: 'Compare again',
|
|
60
|
+
stop: 'Stop tracking this copy',
|
|
61
|
+
stopped: (label) => `Stopped tracking the ${label} copy. Nothing was deleted.`,
|
|
62
|
+
undo: 'Undo',
|
|
63
|
+
pushing: (label) => `Updating ${label} with the PMOS version.`,
|
|
64
|
+
pulling: (label) => `Updating PMOS with the ${label} version.`,
|
|
65
|
+
cancelled: 'Nothing was changed.',
|
|
66
|
+
started: 'Compare started. It reads every copy and changes nothing.',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Docs is not in the top bar (the bar holds at most 7 controls); ⌘K and `g d` open it.
|
|
70
|
+
registerCommand({ id: 'docs-open', label: 'Docs', hint: 'Is each document the same as its Google Docs and Confluence copy', cli: 'pmos docs list', run: () => openDestination('docs') });
|
|
71
|
+
|
|
72
|
+
/* ── words ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
73
|
+
|
|
74
|
+
function ago(iso) {
|
|
75
|
+
if (!iso) return 'never';
|
|
76
|
+
const m = Math.round((Date.now() - Date.parse(iso)) / 60000);
|
|
77
|
+
if (m < 1) return 'just now';
|
|
78
|
+
if (m < 60) return `${m} min ago`;
|
|
79
|
+
if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
|
|
80
|
+
return `${Math.round(m / 1440)} days ago`;
|
|
81
|
+
}
|
|
82
|
+
/** "today 09:12", "yesterday 17:55", "Mon 14:05", "Sep 30". */
|
|
83
|
+
function when(iso) {
|
|
84
|
+
const t = new Date(iso);
|
|
85
|
+
if (Number.isNaN(t.getTime())) return '';
|
|
86
|
+
const hm = t.toTimeString().slice(0, 5);
|
|
87
|
+
const day = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
88
|
+
const days = Math.round((day(new Date()) - day(t)) / 86400000);
|
|
89
|
+
if (days === 0) return 'today ' + hm;
|
|
90
|
+
if (days === 1) return 'yesterday ' + hm;
|
|
91
|
+
if (days > 1 && days < 7) return t.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + hm;
|
|
92
|
+
return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
93
|
+
}
|
|
94
|
+
const edit = (by, at) => [by, at ? when(at) : null].filter(Boolean).join(', ');
|
|
95
|
+
const keyOf = (doc, link) => `${doc.path}|${link.kind}`;
|
|
96
|
+
/** Which kind a pasted address names. The server checks it again; this only picks the kind. */
|
|
97
|
+
function kindOf(target) {
|
|
98
|
+
const t = String(target || '');
|
|
99
|
+
if (/atlassian|confluence|\/wiki\/|pageId=/i.test(t)) return 'confluence';
|
|
100
|
+
if (/google|docs\.|drive\./i.test(t)) return 'google';
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One dot, one sentence and at most one button per copy. The newer side's last edit is the only detail on the line. */
|
|
105
|
+
function stateOf(inst, doc, link) {
|
|
106
|
+
const L = link.label || LABEL[link.kind];
|
|
107
|
+
const pend = inst.pending.get(keyOf(doc, link));
|
|
108
|
+
const syncing = link.syncing || (pend ? { use: pend.use } : null);
|
|
109
|
+
if (syncing) return { dot: 'none', strong: syncing.use === 'pmos' ? `Updating ${L}…` : `Updating PMOS from ${L}…` };
|
|
110
|
+
const by = (s) => (link.basis === 'date' ? s + ' · by date' : s);
|
|
111
|
+
let st;
|
|
112
|
+
switch (link.signal) {
|
|
113
|
+
case 'same': st = { dot: 'ok', rest: 'Same' }; break;
|
|
114
|
+
case 'ahead': st = { dot: 'warn', strong: 'PMOS is newer', rest: by(edit(doc.repo && doc.repo.modified_by, doc.repo && doc.repo.modified_at)), btn: 'pmos' }; break;
|
|
115
|
+
case 'behind': st = { dot: 'warn', strong: `${L} is newer`, rest: by(edit(link.remote_modified_by, link.remote_modified_at)), btn: 'copy' }; break;
|
|
116
|
+
case 'diverged': st = { dot: 'bad', strong: 'Edited on both sides', btn: 'choose' }; break;
|
|
117
|
+
case 'unchecked': st = { dot: 'none', rest: COPY.never }; break;
|
|
118
|
+
case 'unreadable': st = { dot: 'bad', strong: `Can't open in ${L}`, rest: String(link.words || '').replace(/^Could not check — /, '') }; break;
|
|
119
|
+
case 'missing-remote': st = { dot: 'bad', strong: `Not found in ${L}` }; break;
|
|
120
|
+
case 'missing-repo': st = { dot: 'bad', strong: 'Not in PMOS any more' }; break;
|
|
121
|
+
default: st = { dot: 'none', rest: link.words || '' };
|
|
122
|
+
}
|
|
123
|
+
if (link.sync_failed) return { ...st, dot: 'bad', strong: 'Sync failed', rest: link.sync_failed.reason };
|
|
124
|
+
return st;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── mount ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
128
|
+
|
|
129
|
+
const insts = new WeakMap();
|
|
130
|
+
const REREAD_MS = 5000;
|
|
131
|
+
|
|
132
|
+
export function mountDestination(host, ctx) {
|
|
133
|
+
let inst = insts.get(host);
|
|
134
|
+
if (!inst) {
|
|
135
|
+
inst = { host, root: el('div', { class: 'rp-page rp-docs', 'data-docs': '' }), head: null, body: el('div', { class: 'rp-docs-body' }),
|
|
136
|
+
ws: null, loaded: false, error: null, view: null, filter: 'needs', q: '', files: [], open: null, adding: null,
|
|
137
|
+
pending: new Map(), busy: false, seq: 0, fseq: 0, last: '', timer: null, ftimer: null };
|
|
138
|
+
insts.set(host, inst);
|
|
139
|
+
}
|
|
140
|
+
ensureComponentsCss();
|
|
141
|
+
if (!inst.root.isConnected || inst.root.parentNode !== host) host.replaceChildren(inst.root);
|
|
142
|
+
if (ctx && ctx.FIXTURE) { inst.root.replaceChildren(empty({ text: COPY.fixture })); return; }
|
|
143
|
+
const repo = (ctx && ctx.tab && ctx.tab.repo) || (ctx && ctx.state && ctx.state.product) || null;
|
|
144
|
+
const ws = repo ? wsOf(repo) : null;
|
|
145
|
+
if (ws !== inst.ws) {
|
|
146
|
+
stopPoll(inst);
|
|
147
|
+
Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [] });
|
|
148
|
+
if (ws) { ensureSprite(); refresh(inst); }
|
|
149
|
+
draw(inst);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Coming back to Docs reads again (at most every few seconds): a file edited since shows its new signal.
|
|
153
|
+
if (Date.now() - (inst.readAt || 0) > REREAD_MS) refresh(inst);
|
|
154
|
+
if (!inst.last) draw(inst);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function disposeDestination(host) {
|
|
158
|
+
const inst = insts.get(host);
|
|
159
|
+
if (!inst) return;
|
|
160
|
+
stopPoll(inst);
|
|
161
|
+
inst.seq++;
|
|
162
|
+
inst.root.remove();
|
|
163
|
+
insts.delete(host);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stopPoll(inst) { if (inst.timer) { clearInterval(inst.timer); inst.timer = null; } }
|
|
167
|
+
/** While a compare or a sync runs, read again every few seconds so its result lands without a reload; stop when it ends. */
|
|
168
|
+
function pollWhileRunning(inst) {
|
|
169
|
+
const v = inst.view;
|
|
170
|
+
const running = v && (v.open || v.session || v.docs.some((d) => d.links.some((l) => l.syncing)));
|
|
171
|
+
if (running && !inst.timer) inst.timer = setInterval(() => refresh(inst), 4000);
|
|
172
|
+
if (!running) stopPoll(inst);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function refresh(inst) {
|
|
176
|
+
const ws = inst.ws;
|
|
177
|
+
if (!ws) return;
|
|
178
|
+
const seq = ++inst.seq;
|
|
179
|
+
inst.readAt = Date.now();
|
|
180
|
+
const v = await pmGet('/api/docs?ws=' + encodeURIComponent(ws));
|
|
181
|
+
if (seq !== inst.seq || ws !== inst.ws) return;
|
|
182
|
+
inst.loaded = true;
|
|
183
|
+
if (!v || v.ok === false) { inst.error = (v && v.error) || COPY.readError; inst.view = null; }
|
|
184
|
+
else { inst.error = null; inst.view = v; }
|
|
185
|
+
pollWhileRunning(inst);
|
|
186
|
+
draw(inst);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The repository's files that match the search and have no copy yet: what Add copy is offered on. */
|
|
190
|
+
function searchFiles(inst) {
|
|
191
|
+
if (inst.ftimer) clearTimeout(inst.ftimer);
|
|
192
|
+
const q = inst.q.trim();
|
|
193
|
+
if (!q) { inst.files = []; return; }
|
|
194
|
+
inst.ftimer = setTimeout(async () => {
|
|
195
|
+
const seq = ++inst.fseq;
|
|
196
|
+
const r = await pmGet('/api/docs/files?ws=' + encodeURIComponent(inst.ws) + '&q=' + encodeURIComponent(q));
|
|
197
|
+
if (seq !== inst.fseq || q !== inst.q.trim()) return;
|
|
198
|
+
inst.files = (r && r.ok && r.files) || [];
|
|
199
|
+
draw(inst);
|
|
200
|
+
}, 200);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ── actions ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
204
|
+
|
|
205
|
+
async function compareAll(inst) {
|
|
206
|
+
inst.busy = true; draw(inst);
|
|
207
|
+
const r = await pmPost('/api/docs/check', { ws: inst.ws });
|
|
208
|
+
inst.busy = false;
|
|
209
|
+
if (r && r.ok) toast({ text: COPY.started, timeout: 4000 });
|
|
210
|
+
else toast({ text: (r && r.error) || 'The compare could not start.', tone: 'error' });
|
|
211
|
+
await refresh(inst);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Use one version over the other. Held for five seconds behind Undo; only then is the session asked for. */
|
|
215
|
+
function useVersion(inst, doc, link, use) {
|
|
216
|
+
const key = keyOf(doc, link);
|
|
217
|
+
if (inst.pending.has(key)) return;
|
|
218
|
+
const L = link.label || LABEL[link.kind];
|
|
219
|
+
const p = { use, timer: null, toast: null };
|
|
220
|
+
p.timer = setTimeout(async () => {
|
|
221
|
+
inst.pending.delete(key);
|
|
222
|
+
const r = await pmPost('/api/docs/sync', { ws: inst.ws, path: doc.path, kind: link.kind, use });
|
|
223
|
+
if (!r || !r.ok) toast({ text: (r && r.error) || 'The sync could not start.', tone: 'error' });
|
|
224
|
+
await refresh(inst);
|
|
225
|
+
}, UNDO_MS);
|
|
226
|
+
p.toast = toast({ text: use === 'pmos' ? COPY.pushing(L) : COPY.pulling(L), actionLabel: COPY.undo, timeout: UNDO_MS,
|
|
227
|
+
onAction: () => { clearTimeout(p.timer); inst.pending.delete(key); draw(inst); toast({ text: COPY.cancelled, timeout: 3000 }); } });
|
|
228
|
+
inst.pending.set(key, p);
|
|
229
|
+
inst.open = null;
|
|
230
|
+
draw(inst);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function stopTracking(inst, doc, link) {
|
|
234
|
+
const r = await pmPost('/api/docs/unlink', { ws: inst.ws, path: doc.path, kind: link.kind });
|
|
235
|
+
if (!r || !r.ok) { toast({ text: (r && r.error) || 'The copy is still tracked.', tone: 'error' }); return; }
|
|
236
|
+
inst.open = null;
|
|
237
|
+
toast({ text: COPY.stopped(link.label || LABEL[link.kind]), actionLabel: COPY.undo, timeout: 6000,
|
|
238
|
+
onAction: async () => { await pmPost('/api/docs/link', { ws: inst.ws, path: doc.path, kind: link.kind, target: link.target }); refresh(inst); } });
|
|
239
|
+
refresh(inst);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function addCopy(inst, path, target, err) {
|
|
243
|
+
const kind = kindOf(target);
|
|
244
|
+
if (!kind) { err.textContent = COPY.badPaste; return; }
|
|
245
|
+
const r = await pmPost('/api/docs/link', { ws: inst.ws, path, kind, target });
|
|
246
|
+
if (!r || !r.ok) { err.textContent = (r && r.error) || 'The copy was not added.'; return; }
|
|
247
|
+
inst.adding = null;
|
|
248
|
+
inst.files = inst.files.filter((f) => f.path !== path);
|
|
249
|
+
toast({ text: COPY.added, timeout: 4000 });
|
|
250
|
+
refresh(inst);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ── lines ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
254
|
+
|
|
255
|
+
function copyLine(inst, doc, link) {
|
|
256
|
+
const key = keyOf(doc, link);
|
|
257
|
+
const L = link.label || LABEL[link.kind];
|
|
258
|
+
const st = stateOf(inst, doc, link);
|
|
259
|
+
const open = inst.open === key;
|
|
260
|
+
const toggle = () => { inst.open = open ? null : key; draw(inst); };
|
|
261
|
+
let act = null;
|
|
262
|
+
if (st.btn === 'pmos' || st.btn === 'copy') {
|
|
263
|
+
act = button({ label: st.btn === 'pmos' ? COPY.usePmos : COPY.useCopy(L), variant: 'primary', size: 'sm',
|
|
264
|
+
attrs: { 'data-docs-act': 'use-' + st.btn, 'data-docs-key': key }, onClick: () => useVersion(inst, doc, link, st.btn) });
|
|
265
|
+
} else if (st.btn === 'choose') {
|
|
266
|
+
act = button({ label: COPY.choose, variant: 'outline', size: 'sm', attrs: { 'data-docs-act': 'choose', 'data-docs-key': key, 'aria-expanded': open ? 'true' : 'false' },
|
|
267
|
+
onClick: toggle });
|
|
268
|
+
}
|
|
269
|
+
const opener = link.url
|
|
270
|
+
? el('a', { class: 'rp-docs-open', href: link.url, target: '_blank', rel: 'noopener noreferrer', 'data-docs-open': link.kind }, L + ' ↗')
|
|
271
|
+
: el('span', { class: 'rp-docs-open' }, L);
|
|
272
|
+
const line = row({
|
|
273
|
+
title: doc.title,
|
|
274
|
+
meta: [st.strong, st.rest].filter(Boolean),
|
|
275
|
+
lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--' + st.dot, 'aria-hidden': 'true' })],
|
|
276
|
+
trail: [opener, act].filter(Boolean),
|
|
277
|
+
onOpen: toggle,
|
|
278
|
+
openLabel: `${doc.title}, ${L}: ${[st.strong, st.rest].filter(Boolean).join(', ')}`,
|
|
279
|
+
openAttrs: { 'aria-expanded': open ? 'true' : 'false', title: doc.path, 'data-docs-toggle': key },
|
|
280
|
+
attrs: { 'data-docs-line': key, 'data-docs-signal': link.signal, 'data-docs-kind': link.kind, 'data-docs-title': doc.title,
|
|
281
|
+
'data-docs-state': [st.strong, st.rest].filter(Boolean).join(' · ') },
|
|
282
|
+
});
|
|
283
|
+
const wrap = el('div', { class: 'rp-docs-row', role: 'listitem', 'data-docs-doc': doc.path }, line);
|
|
284
|
+
if (open) wrap.appendChild(details(inst, doc, link, st));
|
|
285
|
+
return wrap;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The details, on a click: where each side lives, its last edit, both choices, and the first difference the check saw. */
|
|
289
|
+
function details(inst, doc, link, st) {
|
|
290
|
+
const L = link.label || LABEL[link.kind];
|
|
291
|
+
const can = !st.strong || !/^Updating/.test(st.strong);
|
|
292
|
+
const offer = can && ['ahead', 'behind', 'diverged'].includes(link.signal);
|
|
293
|
+
const pNew = link.signal === 'ahead';
|
|
294
|
+
const cNew = link.signal === 'behind';
|
|
295
|
+
const side = (name, where, last, isNew, use) => el('div', { class: 'rp-docs-side' + (isNew ? ' rp-docs-side--new' : ''), 'data-docs-side': use },
|
|
296
|
+
el('span', { class: 'rp-docs-side__label' }, name + (isNew ? ' · newer' : '')),
|
|
297
|
+
el('span', { class: 'rp-docs-side__where' }, where),
|
|
298
|
+
el('span', { class: 'rp-docs-side__edit' }, last ? 'Last edit: ' + last : 'Last edit: read at the next compare'),
|
|
299
|
+
offer ? button({ label: use === 'pmos' ? COPY.usePmos : COPY.useCopy(L), variant: isNew ? 'primary' : 'outline', size: 'sm',
|
|
300
|
+
attrs: { 'data-docs-act': 'use-' + use, 'data-docs-key': keyOf(doc, link) }, onClick: () => useVersion(inst, doc, link, use) }) : null);
|
|
301
|
+
const diff = link.note && link.signal !== 'same' && link.signal !== 'unchecked'
|
|
302
|
+
? el('p', { class: 'rp-docs-diff', 'data-docs-diff': '' }, 'First difference: ' + link.note) : null;
|
|
303
|
+
return el('div', { class: 'rp-docs-detail', 'data-docs-detail': keyOf(doc, link) },
|
|
304
|
+
side('PMOS', doc.path, edit(doc.repo && doc.repo.modified_by, doc.repo && doc.repo.modified_at), pNew, 'pmos'),
|
|
305
|
+
side(L, link.target, edit(link.remote_modified_by, link.remote_modified_at), cNew, 'copy'),
|
|
306
|
+
diff,
|
|
307
|
+
el('div', { class: 'rp-docs-more' },
|
|
308
|
+
button({ label: COPY.compareAgain, variant: 'ghost', size: 'sm', disabled: !!(inst.view && (inst.view.open || inst.view.session)) || inst.busy,
|
|
309
|
+
attrs: { 'data-docs-act': 'compare-one' }, onClick: () => compareAll(inst) }),
|
|
310
|
+
button({ label: COPY.stop, variant: 'ghost', size: 'sm', attrs: { 'data-docs-act': 'stop', 'data-docs-key': keyOf(doc, link) },
|
|
311
|
+
onClick: () => stopTracking(inst, doc, link) })));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A file with no copy: "No copy" and Add copy, which opens a paste field in place. */
|
|
315
|
+
function fileLine(inst, f) {
|
|
316
|
+
const start = () => { inst.adding = f.path; draw(inst); };
|
|
317
|
+
const adding = inst.adding === f.path;
|
|
318
|
+
const line = row({
|
|
319
|
+
title: f.title,
|
|
320
|
+
meta: [COPY.noCopy],
|
|
321
|
+
lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--none', 'aria-hidden': 'true' })],
|
|
322
|
+
trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', attrs: { 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
|
|
323
|
+
attrs: { 'data-docs-file-line': f.path, title: f.path },
|
|
324
|
+
});
|
|
325
|
+
const wrap = el('div', { class: 'rp-docs-row', role: 'listitem', 'data-docs-file': f.path }, line);
|
|
326
|
+
if (!adding) return wrap;
|
|
327
|
+
const err = el('span', { class: 'rp-docs-add__err', role: 'alert' });
|
|
328
|
+
const inp = field({ label: `Copy of ${f.title}`, hideLabel: true, placeholder: COPY.paste, attrs: { 'data-docs-field': 'target' } });
|
|
329
|
+
inp.control.addEventListener('keydown', (e) => {
|
|
330
|
+
if (e.key === 'Enter') addCopy(inst, f.path, inp.control.value.trim(), err);
|
|
331
|
+
if (e.key === 'Escape') { inst.adding = null; draw(inst); }
|
|
332
|
+
});
|
|
333
|
+
setTimeout(() => inp.control.focus(), 0);
|
|
334
|
+
wrap.appendChild(el('div', { class: 'rp-docs-add' }, el('div', { class: 'rp-docs-add__input' }, inp),
|
|
335
|
+
button({ label: COPY.add, variant: 'primary', size: 'sm', attrs: { 'data-docs-act': 'add-save' }, onClick: () => addCopy(inst, f.path, inp.control.value.trim(), err) }),
|
|
336
|
+
button({ label: COPY.cancel, variant: 'ghost', size: 'sm', onClick: () => { inst.adding = null; draw(inst); } }),
|
|
337
|
+
err));
|
|
338
|
+
return wrap;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
342
|
+
|
|
343
|
+
function header(inst) {
|
|
344
|
+
if (inst.head) return inst.head;
|
|
345
|
+
const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-docs-find': '' } });
|
|
346
|
+
const q = find.control;
|
|
347
|
+
q.addEventListener('input', () => { inst.q = q.value; inst.open = null; inst.adding = null; searchFiles(inst); draw(inst); });
|
|
348
|
+
// The field restores its last committed text on Escape; here Escape clears the search instead.
|
|
349
|
+
q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; searchFiles(inst); draw(inst); } });
|
|
350
|
+
inst.head = el('div', { class: 'rp-docs-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-docs-find' }, find));
|
|
351
|
+
return inst.head;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function grouped(inst, docs) {
|
|
355
|
+
const by = new Map();
|
|
356
|
+
for (const d of docs) {
|
|
357
|
+
const k = d.initiative ? d.initiative.id : '';
|
|
358
|
+
if (!by.has(k)) by.set(k, { title: d.initiative ? d.initiative.title : COPY.other, docs: [] });
|
|
359
|
+
by.get(k).docs.push(d);
|
|
360
|
+
}
|
|
361
|
+
return [...by.entries()].sort((a, b) => (a[0] === '') - (b[0] === '')).map(([k, g]) =>
|
|
362
|
+
el('section', { class: 'rp-docs-group', 'data-docs-group': k || 'other' },
|
|
363
|
+
el('h3', { class: 'rp-docs-group__title' }, g.title),
|
|
364
|
+
el('div', { role: 'list' }, g.docs.flatMap((d) => d.links.map((l) => copyLine(inst, d, l))))));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function body(inst) {
|
|
368
|
+
const v = inst.view;
|
|
369
|
+
const q = inst.q.trim().toLowerCase();
|
|
370
|
+
if (q) {
|
|
371
|
+
const hits = v.docs.filter((d) => [d.title, d.path, d.initiative ? d.initiative.title : ''].join(' ').toLowerCase().includes(q));
|
|
372
|
+
const rows = [...hits.flatMap((d) => d.links.map((l) => copyLine(inst, d, l))), ...inst.files.map((f) => fileLine(inst, f))];
|
|
373
|
+
return rows.length ? [el('div', { role: 'list', class: 'rp-docs-group', 'data-docs-results': '' }, rows)] : [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
|
|
374
|
+
}
|
|
375
|
+
const all = v.docs.reduce((n, d) => n + d.links.length, 0);
|
|
376
|
+
const needs = v.docs.reduce((n, d) => n + d.links.filter((l) => l.signal !== 'same' || l.syncing).length, 0);
|
|
377
|
+
const running = !!(v.open || v.session) || inst.busy;
|
|
378
|
+
const out = [el('div', { class: 'rp-docs-views' },
|
|
379
|
+
tabs({ variant: 'line', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: 'all', label: COPY.all, count: all }],
|
|
380
|
+
value: inst.filter, ariaLabel: 'Filter', attrs: { 'data-docs-filter': '' }, onChange: (val) => { inst.filter = val; draw(inst); } }),
|
|
381
|
+
el('span', { class: 'rp-docs-when', 'data-docs-summary': '' },
|
|
382
|
+
running ? COPY.comparing : (v.last ? COPY.compared(v.last.finished_at) : COPY.never),
|
|
383
|
+
button({ label: '↻', variant: 'ghost', size: 'sm', disabled: running || !all,
|
|
384
|
+
attrs: { 'data-docs-act': 'check', 'aria-label': COPY.compareAll, title: COPY.compareAll }, onClick: () => compareAll(inst) })))];
|
|
385
|
+
if (!v.docs.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.none)); return out; }
|
|
386
|
+
const shown = inst.filter === 'needs'
|
|
387
|
+
? v.docs.map((d) => ({ ...d, links: d.links.filter((l) => l.signal !== 'same' || l.syncing || inst.pending.has(keyOf(d, l))) })).filter((d) => d.links.length)
|
|
388
|
+
: v.docs;
|
|
389
|
+
if (!shown.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.nothingNeeds)); return out; }
|
|
390
|
+
out.push(...grouped(inst, shown));
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function draw(inst) {
|
|
395
|
+
const root = inst.root;
|
|
396
|
+
if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
|
|
397
|
+
const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.filter, inst.busy, inst.q, inst.files, inst.open, inst.adding, [...inst.pending.keys()]]);
|
|
398
|
+
if (fp === inst.last && root.childNodes.length && root.contains(inst.body)) return;
|
|
399
|
+
inst.last = fp;
|
|
400
|
+
const head = header(inst);
|
|
401
|
+
if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
|
|
402
|
+
if (!inst.loaded) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty' }, COPY.loading)); return; }
|
|
403
|
+
if (inst.error) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, inst.error)); return; }
|
|
404
|
+
inst.body.replaceChildren(...body(inst));
|
|
405
|
+
}
|
|
@@ -125,7 +125,10 @@ function build(ctx0) {
|
|
|
125
125
|
// the open day takes the list into its left column ("Also waiting for you"), so Today shows one layout; a folded day
|
|
126
126
|
// leaves it below, as before
|
|
127
127
|
const owed = { list, more, count: n };
|
|
128
|
-
|
|
128
|
+
// one day for every repository: the day is the same everywhere; the Show switch narrows it to the repository Today
|
|
129
|
+
// was opened from (`here`) or shows all of it
|
|
130
|
+
const here = sc.repo ? wsOf(sc.repo) : null;
|
|
131
|
+
const day = daySection({ ws: dayWs, setState: ctx0.setState || setState, openIni: dayRepo ? (id) => openInitiative(dayRepo, id) : null, owed, here, all: sc.all });
|
|
129
132
|
const inDay = !!(day && day.contains(list));
|
|
130
133
|
return { children: inDay ? [head, day, health] : [head, day, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner]) + daySig(dayWs) + nodes.map((x) => x.outerHTML).join('') };
|
|
131
134
|
}
|
|
@@ -48,6 +48,8 @@ export const DESTINATIONS = Object.freeze([
|
|
|
48
48
|
Object.freeze({ id: 'initiatives', label: 'Initiatives', key: 'g i', module: 'dest-initiatives.js', run: 2 }),
|
|
49
49
|
Object.freeze({ id: 'board', label: 'Board', key: 'g b', module: 'dest-board.js', run: 4 }),
|
|
50
50
|
Object.freeze({ id: 'reports', label: 'Reports', key: 'g r', module: 'dest-reports.js', run: 5 }),
|
|
51
|
+
// Not a pm-os-absorb run (run: null): docs-three-way-sync added it — is each linked document the same as its copies.
|
|
52
|
+
Object.freeze({ id: 'docs', label: 'Docs', key: 'g d', module: 'dest-docs.js', run: null }),
|
|
51
53
|
Object.freeze({ id: 'settings', label: 'Settings', key: null, module: 'dest-settings.js', run: 3 }),
|
|
52
54
|
]);
|
|
53
55
|
export const EXTENSIONS = Object.freeze([
|
|
@@ -133,6 +133,11 @@ export const COPY = {
|
|
|
133
133
|
c.working ? `${c.working} working` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' · ') || 'No agent job yet.',
|
|
134
134
|
summaryAll: (n) => `All ${n} agent ${n === 1 ? 'job has' : 'jobs have'} a result.`,
|
|
135
135
|
waitingHint: 'Outcome checks, reports and sessions from your repositories, outside today\'s jobs.',
|
|
136
|
+
// one day for every repository: a goal or a place can carry a repository; the Show switch narrows the day to it
|
|
137
|
+
repo: { label: 'Repository', none: 'No repository',
|
|
138
|
+
goalHint: 'Its jobs show under this repository. With no repository, the goal is yours and shows under every repository.',
|
|
139
|
+
placeHint: 'Repository: its mentions and jobs show under that repository. With none, they show under every repository.',
|
|
140
|
+
only: (name) => `Showing ${name} and your items with no repository. The calendar always shows.` },
|
|
136
141
|
now: (at) => `now ${at}`,
|
|
137
142
|
noCalendar: 'No events read for today.',
|
|
138
143
|
noJobs: 'No jobs yet. Press Prepare my day. Each job shows here; select one to see what it needs.',
|