create-pmos 0.228.0 → 0.230.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/install.ps1 +41 -4
- package/install.sh +26 -4
- 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/notes.md +1 -1
- package/workspace/lib/calendar-events.js +17 -17
- package/workspace/lib/routes-calendar-views.js +11 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +162 -0
- package/workspace/public/pm/view-notes.js +106 -28
package/install.ps1
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
# 1. Node.js latest LTS from nodejs.org, into %LOCALAPPDATA%\PMOS\node
|
|
7
7
|
# 2. git the official Portable Git, into %LOCALAPPDATA%\PMOS\git (Claude Code needs its bash)
|
|
8
8
|
# 3. Claude Code its own installer, claude.ai/install.ps1
|
|
9
|
-
# 4. the PMOS
|
|
10
|
-
# 5. the PMOS
|
|
9
|
+
# 4. the newest PMOS release that npm can download (a release a few minutes old may not download yet)
|
|
10
|
+
# 5. the PMOS kit in %USERPROFILE%\PMOS (npx create-pmos)
|
|
11
|
+
# 6. the PMOS app (npx create-pmos workspace), which opens in the browser
|
|
11
12
|
# Every step checks first and skips what is already there, so running the line again is safe.
|
|
12
13
|
#
|
|
13
14
|
# Settings (environment variables):
|
|
@@ -23,6 +24,7 @@ $ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is many times slo
|
|
|
23
24
|
$PmosHome = Join-Path $env:LOCALAPPDATA 'PMOS'
|
|
24
25
|
$PmosDir = if ($env:PMOS_DIR) { $env:PMOS_DIR } else { Join-Path $env:USERPROFILE 'PMOS' }
|
|
25
26
|
$PmosPkg = if ($env:PMOS_PKG) { $env:PMOS_PKG } else { 'create-pmos@latest' }
|
|
27
|
+
$PmosPkgGiven = [bool]$env:PMOS_PKG
|
|
26
28
|
$NodeMin = 18
|
|
27
29
|
|
|
28
30
|
function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
|
@@ -147,7 +149,42 @@ if ($env:PMOS_SKIP_CLAUDE -eq '1') {
|
|
|
147
149
|
else { Fail 'Claude Code did not install.' 'run: irm https://claude.ai/install.ps1 | iex then paste the line again' }
|
|
148
150
|
}
|
|
149
151
|
|
|
150
|
-
# --- 4. The PMOS
|
|
152
|
+
# --- 4. The PMOS release ---
|
|
153
|
+
# For a few minutes after a release, npm can name it as the newest while its download servers do not
|
|
154
|
+
# have it yet ("No matching version found"). Ask for the newest release first and step back to the ones
|
|
155
|
+
# before it until one downloads, then use that same release for the kit and for the app.
|
|
156
|
+
function Get-PmosRelease($spec) {
|
|
157
|
+
# npm writes its warnings to stderr; with 'Stop', Windows PowerShell would turn them into a thrown error.
|
|
158
|
+
$old = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
|
159
|
+
try { $out = (& npx.cmd -y -p $spec create-pmos --version 2>$null) -join "`n"; $code = $LASTEXITCODE }
|
|
160
|
+
catch { $out = ''; $code = 1 }
|
|
161
|
+
finally { $ErrorActionPreference = $old }
|
|
162
|
+
if ($code -eq 0 -and $out -match '(?m)^create-pmos (\d+\.\d+\.\d+)') { return $Matches[1] }
|
|
163
|
+
return $null
|
|
164
|
+
}
|
|
165
|
+
if (-not $PmosPkgGiven) {
|
|
166
|
+
Say 'Finding the newest PMOS release'
|
|
167
|
+
# The three newest published releases, newest first (node reads npm's JSON; it is installed by now).
|
|
168
|
+
$older = ''
|
|
169
|
+
$old = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
|
170
|
+
try {
|
|
171
|
+
$json = (& npm.cmd view create-pmos versions --json 2>$null) -join "`n"
|
|
172
|
+
$older = ($json | & node -e "let s='';process.stdin.on('data',(d)=>{s+=d}).on('end',()=>{try{console.log([].concat(JSON.parse(s.replace(/^\uFEFF/,''))).slice(-3).reverse().join(' '))}catch{}})") -join ' '
|
|
173
|
+
} catch { $older = '' }
|
|
174
|
+
finally { $ErrorActionPreference = $old }
|
|
175
|
+
$tries = @('latest') + @($older -split ' ' | Where-Object { $_ })
|
|
176
|
+
$picked = $null
|
|
177
|
+
foreach ($v in $tries) {
|
|
178
|
+
$picked = Get-PmosRelease "create-pmos@$v"
|
|
179
|
+
if ($picked) { break }
|
|
180
|
+
Write-Host " create-pmos@$v does not download yet; trying the release before it"
|
|
181
|
+
}
|
|
182
|
+
if (-not $picked) { Fail 'Could not download PMOS from npm.' 'check the internet connection, then paste the line again' }
|
|
183
|
+
$PmosPkg = "create-pmos@$picked"
|
|
184
|
+
Ok "PMOS $picked"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# --- 5. The PMOS folder and kit ---
|
|
151
188
|
Say "Setting up the PMOS folder: $PmosDir"
|
|
152
189
|
New-Item -ItemType Directory -Force -Path $PmosDir | Out-Null
|
|
153
190
|
if (-not (Test-Path (Join-Path $PmosDir '.git'))) {
|
|
@@ -162,7 +199,7 @@ if (Test-Path (Join-Path $PmosDir 'pmos\AGENTS.md')) {
|
|
|
162
199
|
Ok 'PMOS kit added'
|
|
163
200
|
}
|
|
164
201
|
|
|
165
|
-
# ---
|
|
202
|
+
# --- 6. The PMOS app ---
|
|
166
203
|
Say 'Starting the PMOS app'
|
|
167
204
|
& npx.cmd -y -p $PmosPkg create-pmos workspace $PmosDir
|
|
168
205
|
if ($LASTEXITCODE -ne 0) { Fail 'The PMOS app did not start.' 'run: pmos doctor and send what it prints to your PMOS contact' }
|
package/install.sh
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
# 1. Node.js (latest LTS from nodejs.org, into ~/.pmos/node; no Homebrew, no admin)
|
|
8
8
|
# 2. git (macOS: Apple's Command Line Tools, one system dialog; Linux: the package manager)
|
|
9
9
|
# 3. Claude Code (its own installer, claude.ai/install.sh)
|
|
10
|
-
# 4. the PMOS
|
|
11
|
-
# 5. the PMOS
|
|
10
|
+
# 4. the newest PMOS release that npm can download (a release a few minutes old may not download yet)
|
|
11
|
+
# 5. the PMOS kit in ~/PMOS (npx create-pmos)
|
|
12
|
+
# 6. the PMOS app (npx create-pmos workspace), which opens in the browser
|
|
12
13
|
# Every step checks first and skips what is already there, so running the line again is safe.
|
|
13
14
|
#
|
|
14
15
|
# Settings (environment variables):
|
|
@@ -20,6 +21,7 @@ set -euo pipefail
|
|
|
20
21
|
|
|
21
22
|
PMOS_HOME="${PMOS_HOME:-$HOME/.pmos}"
|
|
22
23
|
PMOS_DIR="${PMOS_DIR:-$HOME/PMOS}"
|
|
24
|
+
PMOS_PKG_GIVEN="${PMOS_PKG:-}"
|
|
23
25
|
PMOS_PKG="${PMOS_PKG:-create-pmos@latest}"
|
|
24
26
|
NODE_MIN=18
|
|
25
27
|
MARK="# added by PMOS setup"
|
|
@@ -161,7 +163,27 @@ else
|
|
|
161
163
|
have claude && ok "Claude Code installed" || fail "Claude Code installed, but the claude command is not found." "open a new Terminal window and paste the line again"
|
|
162
164
|
fi
|
|
163
165
|
|
|
164
|
-
# ── 4. The PMOS
|
|
166
|
+
# ── 4. The PMOS release ─────────────────────────────────────────────────────────────────────
|
|
167
|
+
# For a few minutes after a release, npm can name it as the newest while its download servers do not
|
|
168
|
+
# have it yet ("No matching version found"). Ask for the newest release first and step back to the ones
|
|
169
|
+
# before it until one downloads, then use that same release for the kit and for the app.
|
|
170
|
+
if [ -z "$PMOS_PKG_GIVEN" ]; then
|
|
171
|
+
say "Finding the newest PMOS release"
|
|
172
|
+
older="$(npm view create-pmos versions --json 2>/dev/null \
|
|
173
|
+
| node -e 'let s="";process.stdin.on("data",(d)=>{s+=d}).on("end",()=>{try{console.log([].concat(JSON.parse(s)).slice(-3).reverse().join(" "))}catch{}})' || true)"
|
|
174
|
+
picked=""
|
|
175
|
+
for v in latest $older; do
|
|
176
|
+
out="$(npx -y -p "create-pmos@$v" create-pmos --version 2>/dev/null || true)"
|
|
177
|
+
rel="$(printf '%s\n' "$out" | sed -n '/^create-pmos [0-9]/{s/^create-pmos //p;q;}')"
|
|
178
|
+
if [ -n "$rel" ]; then picked="$rel"; break; fi
|
|
179
|
+
echo " create-pmos@$v does not download yet; trying the release before it"
|
|
180
|
+
done
|
|
181
|
+
[ -n "$picked" ] || fail "Could not download PMOS from npm." "check the internet connection, then paste the line again"
|
|
182
|
+
PMOS_PKG="create-pmos@$picked"
|
|
183
|
+
ok "PMOS $picked"
|
|
184
|
+
fi
|
|
185
|
+
|
|
186
|
+
# ── 5. The PMOS folder and kit ──────────────────────────────────────────────────────────────
|
|
165
187
|
say "Setting up the PMOS folder: $PMOS_DIR"
|
|
166
188
|
mkdir -p "$PMOS_DIR"
|
|
167
189
|
if [ ! -d "$PMOS_DIR/.git" ] && ! git -C "$PMOS_DIR" rev-parse --git-dir >/dev/null 2>&1; then
|
|
@@ -175,7 +197,7 @@ else
|
|
|
175
197
|
ok "PMOS kit added"
|
|
176
198
|
fi
|
|
177
199
|
|
|
178
|
-
# ──
|
|
200
|
+
# ── 6. The PMOS app ─────────────────────────────────────────────────────────────────────────
|
|
179
201
|
say "Starting the PMOS app"
|
|
180
202
|
npx -y -p "$PMOS_PKG" create-pmos workspace "$PMOS_DIR" || fail "The PMOS app did not start." "run: pmos doctor, and send what it prints to your PMOS contact"
|
|
181
203
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.230.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-14T07:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T07:57:03Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-14T07:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T07:57:03Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-14T07:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T07:57:03Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -265,7 +265,7 @@ The task appears in your My Work view and on the initiative's board when the not
|
|
|
265
265
|
|
|
266
266
|
Under the title, a note shows two lines: **Meeting** and **Initiative**.
|
|
267
267
|
|
|
268
|
-
- **Meeting** is the calendar event the note was taken in, or **Not on the calendar** for a hallway chat or a call. Press **Change** to select an event
|
|
268
|
+
- **Meeting** is the calendar event the note was taken in, or **Not on the calendar** for a hallway chat or a call. Press **Change** to select an event. The picker opens on the note's day, with the meeting that was on while you recorded at the top as **Best match**. Search by title or person to find any meeting on your calendar, or use the week strip and **Jump to** to open another day. **Show earlier days** adds three more days. **Not on the calendar** is at the bottom.
|
|
269
269
|
- **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to open the list. The list shows the suggestion first, then the initiatives that are active now, then **None**. Initiatives planned for Next and Later are in the menu under them, each marked with its horizon. When more than one repository is open, a **Repository** menu at the top of the list changes which repository's initiatives you see. If the repository is still being read, the list says so and fills in by itself. Press the initiative's name to open it.
|
|
270
270
|
|
|
271
271
|
When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
|
|
@@ -72,24 +72,24 @@ function readAll(runtimeDir, { now = Date.now() } = {}) {
|
|
|
72
72
|
return out.sort((a, b) => (Date.parse(a.at) || 0) - (Date.parse(b.at) || 0));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
out.push({ id: String(e.id || ''), start: localIso(s), end: localIso(f), title: String(e.title), time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
|
87
|
-
minutes: Math.round((f - s) / 60000), ...(people.length ? { with: people.slice(0, 6).join(', ') } : {}),
|
|
88
|
-
src: e.src || 'import', ...(e.initiative ? { initiative: e.initiative } : {}) });
|
|
89
|
-
}
|
|
90
|
-
return out.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
|
75
|
+
/** An event in the shape the Record menu and a note's meeting link use: { id, date, start, end, title, time, minutes, with, src, ... },
|
|
76
|
+
* or null for an event with no title or start. `date` is the local day of the start. Pure. */
|
|
77
|
+
function menuShape(e) {
|
|
78
|
+
const s = Date.parse(e && e.at);
|
|
79
|
+
if (!e || !e.title || Number.isNaN(s)) return null;
|
|
80
|
+
const f = e.end && !Number.isNaN(Date.parse(e.end)) ? Date.parse(e.end) : s + 30 * 60000;
|
|
81
|
+
const people = [].concat(e.attendees || []).map((p) => (p && typeof p === 'object' ? p.name || '' : String(p || ''))).filter(Boolean);
|
|
82
|
+
const d = new Date(s);
|
|
83
|
+
return { id: String(e.id || ''), date: localDate(s), start: localIso(s), end: localIso(f), title: String(e.title), time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
|
84
|
+
minutes: Math.round((f - s) / 60000), ...(people.length ? { with: people.slice(0, 6).join(', ') } : {}),
|
|
85
|
+
src: e.src || 'import', ...(e.initiative ? { initiative: e.initiative } : {}), ...(e.series ? { series: e.series } : {}) };
|
|
91
86
|
}
|
|
87
|
+
/** Every event in menuShape, oldest first (GET /api/calendar-events, a note's meeting picker). Pure. */
|
|
88
|
+
const listOf = (events) => (events || []).map(menuShape).filter(Boolean).sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
|
89
|
+
/** One day's meetings as the Record menu reads them (GET /api/calendar-today?date=): every event readAll joins, kept when its start
|
|
90
|
+
* falls on the local day `day` (YYYY-MM-DD), in menuShape. Pure. */
|
|
91
|
+
const eventsOn = (events, day) => listOf(events).filter((e) => e.date === day);
|
|
92
92
|
/** Today's meetings (the Record menu): eventsOn the local day of `now`. */
|
|
93
93
|
const todayOf = (events, now = Date.now()) => eventsOn(events, localDate(now));
|
|
94
94
|
|
|
95
|
-
module.exports = { readAll, asEvent, localIso, todayOf, eventsOn };
|
|
95
|
+
module.exports = { readAll, asEvent, localIso, todayOf, eventsOn, listOf, menuShape };
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* &only=gantt → { ok, view, range, gantt, no_plan, strategy, today } the rows with a plan date or a Jira read, and how many have neither
|
|
8
8
|
* GET /api/calendar-today[?date=YYYY-MM-DD] → { ok, date, events } one day's meetings (default today) for the Record menu and a
|
|
9
9
|
* note's meeting picker (calendar-events.js eventsOn)
|
|
10
|
+
* GET /api/calendar-events → { ok, today, events } every meeting the Calendar holds, oldest first, for a
|
|
11
|
+
* note's meeting picker (calendar-events.js listOf)
|
|
10
12
|
* POST /api/calendar-links → { ok, link } what a meeting is about (lib/calendar-links.js, below)
|
|
11
13
|
* tidy, tidy_total, tidy_note } (lib/calendar-tidy.js: suggestions in words, no write)
|
|
12
14
|
*
|
|
@@ -223,13 +225,21 @@ async function postLink(req, res, ctx) {
|
|
|
223
225
|
|
|
224
226
|
module.exports = {
|
|
225
227
|
readers,
|
|
226
|
-
prefixes: ['/api/calendar-views', '/api/calendar-links', '/api/calendar-today'],
|
|
228
|
+
prefixes: ['/api/calendar-views', '/api/calendar-links', '/api/calendar-today', '/api/calendar-events'],
|
|
227
229
|
handlePure: handle,
|
|
228
230
|
handle: async (req, res, url, ctx) => {
|
|
229
231
|
if (url.pathname === '/api/calendar-links') {
|
|
230
232
|
if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
|
|
231
233
|
return postLink(req, res, ctx);
|
|
232
234
|
}
|
|
235
|
+
if (url.pathname === '/api/calendar-events') {
|
|
236
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
237
|
+
const cal = require('./calendar-events.js');
|
|
238
|
+
let events = [];
|
|
239
|
+
try { events = cal.listOf(cal.readAll(ctx.runtimeDir, { now: Date.now() })); } catch { events = []; }
|
|
240
|
+
ctx.json(res, { ok: true, today: today(), events });
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
233
243
|
if (url.pathname === '/api/calendar-today') {
|
|
234
244
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
235
245
|
const cal = require('./calendar-events.js');
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.230.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.230.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -7104,6 +7104,168 @@
|
|
|
7104
7104
|
outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
|
|
7105
7105
|
outline-offset: 2px;
|
|
7106
7106
|
}
|
|
7107
|
+
/* the note's meeting picker (view-notes.js meetingSheet): search, a week strip and a date jump over every calendar meeting */
|
|
7108
|
+
.nt-mp-tools {
|
|
7109
|
+
position: sticky;
|
|
7110
|
+
top: -16px;
|
|
7111
|
+
z-index: 1;
|
|
7112
|
+
display: flex;
|
|
7113
|
+
flex-direction: column;
|
|
7114
|
+
gap: 8px;
|
|
7115
|
+
margin-top: -16px;
|
|
7116
|
+
padding: 16px 0 12px;
|
|
7117
|
+
background: var(--card);
|
|
7118
|
+
border-bottom: 1px solid var(--border);
|
|
7119
|
+
}
|
|
7120
|
+
.nt-mp-q {
|
|
7121
|
+
height: 36px;
|
|
7122
|
+
padding: 0 12px;
|
|
7123
|
+
border: 1px solid var(--border);
|
|
7124
|
+
border-radius: 8px;
|
|
7125
|
+
background: var(--background);
|
|
7126
|
+
color: var(--foreground);
|
|
7127
|
+
font: 14px var(--sans);
|
|
7128
|
+
}
|
|
7129
|
+
.nt-mp-q:focus-visible {
|
|
7130
|
+
outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
|
|
7131
|
+
outline-offset: 1px;
|
|
7132
|
+
}
|
|
7133
|
+
.nt-mp-days[hidden] {
|
|
7134
|
+
display: none;
|
|
7135
|
+
}
|
|
7136
|
+
.nt-mp-days {
|
|
7137
|
+
display: flex;
|
|
7138
|
+
flex-direction: column;
|
|
7139
|
+
gap: 8px;
|
|
7140
|
+
}
|
|
7141
|
+
.nt-mp-week {
|
|
7142
|
+
display: flex;
|
|
7143
|
+
gap: 4px;
|
|
7144
|
+
align-items: stretch;
|
|
7145
|
+
}
|
|
7146
|
+
.nt-mp-strip {
|
|
7147
|
+
display: grid;
|
|
7148
|
+
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
7149
|
+
gap: 4px;
|
|
7150
|
+
flex: 1;
|
|
7151
|
+
min-width: 0;
|
|
7152
|
+
}
|
|
7153
|
+
.nt-mp-nav {
|
|
7154
|
+
width: 28px;
|
|
7155
|
+
border: 1px solid var(--border);
|
|
7156
|
+
border-radius: 6px;
|
|
7157
|
+
background: var(--background);
|
|
7158
|
+
color: var(--muted-foreground);
|
|
7159
|
+
font: 14px var(--mono);
|
|
7160
|
+
cursor: pointer;
|
|
7161
|
+
}
|
|
7162
|
+
.nt-mp-day {
|
|
7163
|
+
display: grid;
|
|
7164
|
+
justify-items: center;
|
|
7165
|
+
padding: 4px 0;
|
|
7166
|
+
border: 1px solid transparent;
|
|
7167
|
+
border-radius: 6px;
|
|
7168
|
+
background: transparent;
|
|
7169
|
+
color: var(--foreground);
|
|
7170
|
+
font: 12px var(--mono);
|
|
7171
|
+
cursor: pointer;
|
|
7172
|
+
}
|
|
7173
|
+
.nt-mp-day[aria-current="true"] {
|
|
7174
|
+
border-color: var(--foreground);
|
|
7175
|
+
background: var(--accent);
|
|
7176
|
+
}
|
|
7177
|
+
.nt-mp-day__w,
|
|
7178
|
+
.nt-mp-day__n {
|
|
7179
|
+
font-size: 11px;
|
|
7180
|
+
color: var(--muted-foreground);
|
|
7181
|
+
}
|
|
7182
|
+
.nt-mp-day__w {
|
|
7183
|
+
text-transform: uppercase;
|
|
7184
|
+
letter-spacing: 0.06em;
|
|
7185
|
+
}
|
|
7186
|
+
.nt-mp-day__d {
|
|
7187
|
+
font-size: 14px;
|
|
7188
|
+
font-weight: 500;
|
|
7189
|
+
}
|
|
7190
|
+
.nt-mp-day--note .nt-mp-day__d {
|
|
7191
|
+
text-decoration: underline 2px var(--foreground);
|
|
7192
|
+
text-underline-offset: 3px;
|
|
7193
|
+
}
|
|
7194
|
+
.nt-mp-jumprow {
|
|
7195
|
+
display: flex;
|
|
7196
|
+
flex-wrap: wrap;
|
|
7197
|
+
gap: 8px;
|
|
7198
|
+
align-items: center;
|
|
7199
|
+
justify-content: space-between;
|
|
7200
|
+
font: 12px var(--mono);
|
|
7201
|
+
color: var(--muted-foreground);
|
|
7202
|
+
}
|
|
7203
|
+
.nt-mp-jump {
|
|
7204
|
+
padding: 2px 6px;
|
|
7205
|
+
border: 1px solid var(--border);
|
|
7206
|
+
border-radius: 6px;
|
|
7207
|
+
background: var(--background);
|
|
7208
|
+
color: var(--foreground);
|
|
7209
|
+
font: 12px var(--mono);
|
|
7210
|
+
}
|
|
7211
|
+
.nt-mp-lnk {
|
|
7212
|
+
align-self: flex-start;
|
|
7213
|
+
padding: 0;
|
|
7214
|
+
border: 0;
|
|
7215
|
+
background: transparent;
|
|
7216
|
+
color: var(--foreground);
|
|
7217
|
+
font: 12px var(--mono);
|
|
7218
|
+
text-decoration: underline;
|
|
7219
|
+
text-underline-offset: 2px;
|
|
7220
|
+
cursor: pointer;
|
|
7221
|
+
}
|
|
7222
|
+
.nt-mp-lnk[hidden] {
|
|
7223
|
+
display: none;
|
|
7224
|
+
}
|
|
7225
|
+
.nt-mp-group {
|
|
7226
|
+
display: flex;
|
|
7227
|
+
flex-direction: column;
|
|
7228
|
+
gap: 8px;
|
|
7229
|
+
}
|
|
7230
|
+
.nt-mp-gh {
|
|
7231
|
+
font: 500 12px var(--mono);
|
|
7232
|
+
color: var(--muted-foreground);
|
|
7233
|
+
text-transform: uppercase;
|
|
7234
|
+
letter-spacing: 0.06em;
|
|
7235
|
+
}
|
|
7236
|
+
.nt-mp-tags {
|
|
7237
|
+
display: flex;
|
|
7238
|
+
flex-wrap: wrap;
|
|
7239
|
+
gap: 4px;
|
|
7240
|
+
}
|
|
7241
|
+
.nt-mp-tag {
|
|
7242
|
+
padding: 1px 6px;
|
|
7243
|
+
border-radius: 6px;
|
|
7244
|
+
background: var(--muted);
|
|
7245
|
+
color: var(--muted-foreground);
|
|
7246
|
+
font: 500 11px var(--mono);
|
|
7247
|
+
white-space: nowrap;
|
|
7248
|
+
}
|
|
7249
|
+
.nt-mp-tag--best {
|
|
7250
|
+
border: 1px solid var(--good);
|
|
7251
|
+
background: transparent;
|
|
7252
|
+
color: var(--good);
|
|
7253
|
+
}
|
|
7254
|
+
.nt-mp .nt-pick {
|
|
7255
|
+
width: 100%;
|
|
7256
|
+
}
|
|
7257
|
+
.nt-mp-nav,
|
|
7258
|
+
.nt-mp-day,
|
|
7259
|
+
.nt-mp-lnk {
|
|
7260
|
+
height: auto;
|
|
7261
|
+
min-height: 0;
|
|
7262
|
+
}
|
|
7263
|
+
.nt-mp-nav {
|
|
7264
|
+
padding: 0;
|
|
7265
|
+
}
|
|
7266
|
+
.nt-mp-day {
|
|
7267
|
+
gap: 0;
|
|
7268
|
+
}
|
|
7107
7269
|
.nt-lsum {
|
|
7108
7270
|
margin: 4px 0;
|
|
7109
7271
|
padding-left: 18px;
|
|
@@ -1191,41 +1191,119 @@ async function link(ctx, n, patch, said) {
|
|
|
1191
1191
|
} else banner = { tone: 'bad', text: (r && r.error) || 'the link was not changed' };
|
|
1192
1192
|
ctx.setState({});
|
|
1193
1193
|
}
|
|
1194
|
-
|
|
1195
|
-
*
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1194
|
+
/* ── the meeting picker: every meeting the Calendar holds (GET /api/calendar-events, lib/calendar-events.js readAll), opened on the
|
|
1195
|
+
* note's day, with search, a week strip and a date jump (planning/prd/note-meeting-search-prd.md) ── */
|
|
1196
|
+
const MP_WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
1197
|
+
const mpAdd = (day, n) => { const d = new Date(day + 'T12:00:00'); d.setDate(d.getDate() + n); return dateOfIso(d.toISOString()); };
|
|
1198
|
+
const mpDate = (day) => new Date(day + 'T12:00:00');
|
|
1199
|
+
const MP_MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
1200
|
+
/** A primitives.js button drawn as the picker's own control `cls` (day chip, week arrow, text link). */
|
|
1201
|
+
function mpBtn(cls, attrs, kids, onClick) {
|
|
1202
|
+
const b = button({ variant: 'ghost', onClick, attrs: { ...attrs, class: cls } });
|
|
1203
|
+
b.replaceChildren(...[].concat(kids));
|
|
1204
|
+
return b;
|
|
1205
|
+
}
|
|
1206
|
+
const mpLabel = (day) => { const d = mpDate(day); return MP_WD[d.getDay()] + ' ' + d.getDate() + ' ' + MP_MON[d.getMonth()]; };
|
|
1207
|
+
/** The meeting that was on while the note was recorded: the event that overlaps [start, start + length] the most, or null. */
|
|
1208
|
+
export function bestMatchOf(events, n) {
|
|
1209
|
+
const s = Date.parse(n.recorded_from || n.created_at || '');
|
|
1210
|
+
if (!Number.isFinite(s)) return null;
|
|
1211
|
+
const len = Math.max(60000, Number(n.meeting_ms) || Math.max(0, ...(n.transcript || []).map((x) => Number(x.t) || 0)));
|
|
1212
|
+
let best = null; let most = 0;
|
|
1213
|
+
for (const e of events) {
|
|
1214
|
+
const a = Date.parse(e.start); const z = Date.parse(e.end) || a + (e.minutes || 30) * 60000;
|
|
1215
|
+
const o = Math.min(z, s + len) - Math.max(a, s);
|
|
1216
|
+
if (o > most) { most = o; best = e; }
|
|
1217
|
+
}
|
|
1218
|
+
return best;
|
|
1219
|
+
}
|
|
1220
|
+
/** The events of a search, newest first: every word in the title or the people. */
|
|
1221
|
+
export function searchEvents(events, q) {
|
|
1222
|
+
const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
1223
|
+
if (!words.length) return [];
|
|
1224
|
+
return events.filter((e) => { const t = (e.title + ' ' + (e.with || '')).toLowerCase(); return words.every((w) => t.includes(w)); })
|
|
1225
|
+
.sort((a, b) => Date.parse(b.start) - Date.parse(a.start));
|
|
1203
1226
|
}
|
|
1204
1227
|
async function meetingSheet(ctx, n) {
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
let
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1228
|
+
const noteDay = dateOfIso(n.recorded_from || n.created_at || new Date().toISOString());
|
|
1229
|
+
const S = { day: noteDay, weekEnd: noteDay, span: 3, q: '', pick: n.meeting && n.meeting.kind === 'adhoc' ? 'adhoc' : null };
|
|
1230
|
+
let all = []; let best = null; let notesFor = () => [];
|
|
1231
|
+
const list = el('div', { class: 'nt-picks', role: 'radiogroup', 'aria-label': 'Meeting', 'data-nt-meeting-list': '' }, el('p', { class: 'nt-hint' }, 'reading your calendar …'));
|
|
1232
|
+
const q = el('input', { type: 'search', class: 'nt-mp-q', placeholder: 'Search meetings by title or person', 'aria-label': 'Search meetings', autocomplete: 'off', 'data-nt-meeting-q': '',
|
|
1233
|
+
onInput: () => { S.q = q.value; draw(); } });
|
|
1234
|
+
const strip = el('div', { class: 'nt-mp-strip' });
|
|
1235
|
+
const jump = el('input', { type: 'date', class: 'nt-mp-jump', 'aria-label': 'Jump to a date', 'data-nt-meeting-jump': '',
|
|
1236
|
+
onChange: () => { if (/^\d{4}-\d{2}-\d{2}$/.test(jump.value)) { S.day = jump.value; S.weekEnd = jump.value; S.span = 3; draw(); } } });
|
|
1237
|
+
const back = mpBtn('nt-mp-lnk', { 'data-nt-meeting-back': '' }, 'Back to the note\'s day', () => { S.day = noteDay; S.weekEnd = noteDay; S.span = 3; draw(); });
|
|
1238
|
+
const week = (k) => { S.weekEnd = mpAdd(S.weekEnd, 7 * k); S.day = S.weekEnd; S.span = 3; draw(); };
|
|
1239
|
+
const prev = mpBtn('nt-mp-nav', { 'aria-label': 'Earlier week', 'data-nt-meeting-week': 'prev' }, '‹', () => week(-1));
|
|
1240
|
+
const next = mpBtn('nt-mp-nav', { 'aria-label': 'Later week', 'data-nt-meeting-week': 'next' }, '›', () => week(1));
|
|
1241
|
+
const days = el('div', { class: 'nt-mp-days' }, el('div', { class: 'nt-mp-week' }, prev, strip, next),
|
|
1242
|
+
el('div', { class: 'nt-mp-jumprow' }, el('label', { class: 'nt-mp-jumplabel' }, 'Jump to ', jump), back));
|
|
1243
|
+
const tools = el('div', { class: 'nt-mp-tools' }, q, days);
|
|
1244
|
+
const keyOf = (e) => e.start + '|' + e.title;
|
|
1245
|
+
const isOn = (e) => !!(n.meeting && n.meeting.kind === 'event' && (n.meeting.event_id === e.id || (Date.parse(n.meeting.start) === Date.parse(e.start) && n.meeting.title === e.title)));
|
|
1246
|
+
const row = (e) => {
|
|
1247
|
+
const other = notesFor(e).some((x) => x.id !== n.id);
|
|
1248
|
+
const b = button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, e.title),
|
|
1249
|
+
el('span', { class: 'nt-hint' }, [e.time + '–' + (e.end ? e.end.slice(11, 16) : ''), e.with].filter(Boolean).join(' · ')),
|
|
1250
|
+
(best === e || other || e.series) ? el('span', { class: 'nt-mp-tags' }, best === e ? el('span', { class: 'nt-mp-tag nt-mp-tag--best' }, 'Best match · on while you recorded') : null,
|
|
1251
|
+
other ? el('span', { class: 'nt-mp-tag' }, 'has a note') : null, e.series ? el('span', { class: 'nt-mp-tag' }, 'series') : null) : null),
|
|
1252
|
+
variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': S.pick === keyOf(e) ? 'true' : 'false', 'data-nt-meeting-pick': keyOf(e) },
|
|
1253
|
+
onClick: () => { S.pick = keyOf(e); draw(); } });
|
|
1254
|
+
return b;
|
|
1255
|
+
};
|
|
1256
|
+
const group = (day, evs) => el('div', { class: 'nt-mp-group', 'data-nt-meeting-day-group': day },
|
|
1257
|
+
el('div', { class: 'nt-mp-gh' }, mpLabel(day) + (day === noteDay ? ' · the note\'s day' : '')),
|
|
1258
|
+
...(evs.length ? evs.map(row) : [el('p', { class: 'nt-hint' }, 'No meetings.')]));
|
|
1259
|
+
const ofDay = (day) => all.filter((e) => e.date === day).sort((a, b) => (b === best) - (a === best) || Date.parse(a.start) - Date.parse(b.start));
|
|
1260
|
+
function draw() {
|
|
1261
|
+
const term = S.q.trim();
|
|
1262
|
+
days.hidden = !!term;
|
|
1263
|
+
const out = [];
|
|
1264
|
+
if (term) {
|
|
1265
|
+
const hits = searchEvents(all, term);
|
|
1266
|
+
out.push(el('div', { class: 'nt-mp-gh' }, hits.length + (hits.length === 1 ? ' meeting' : ' meetings') + ' on your calendar, newest first'));
|
|
1267
|
+
if (!hits.length) out.push(el('p', { class: 'nt-hint' }, 'No meeting matches "' + term + '". Try a person\'s name, or pick Not on the calendar.'));
|
|
1268
|
+
for (const d of [...new Set(hits.map((e) => e.date))]) out.push(group(d, hits.filter((e) => e.date === d).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))));
|
|
1269
|
+
} else {
|
|
1270
|
+
strip.replaceChildren(...[6, 5, 4, 3, 2, 1, 0].map((k) => {
|
|
1271
|
+
const d = mpAdd(S.weekEnd, -k); const c = all.filter((e) => e.date === d).length;
|
|
1272
|
+
return mpBtn('nt-mp-day' + (d === noteDay ? ' nt-mp-day--note' : ''), { 'aria-current': d === S.day ? 'true' : 'false', 'data-nt-meeting-day': d,
|
|
1273
|
+
'aria-label': mpLabel(d) + ', ' + (c || 'no') + (c === 1 ? ' meeting' : ' meetings') },
|
|
1274
|
+
[el('span', { class: 'nt-mp-day__w' }, MP_WD[mpDate(d).getDay()]), el('span', { class: 'nt-mp-day__d' }, String(mpDate(d).getDate())), el('span', { class: 'nt-mp-day__n' }, c ? String(c) : '·')],
|
|
1275
|
+
() => { S.day = d; S.span = 3; draw(); });
|
|
1276
|
+
}));
|
|
1277
|
+
jump.value = S.day; back.hidden = S.day === noteDay;
|
|
1278
|
+
for (let k = 0; k < S.span; k++) { const d = mpAdd(S.day, -k); const evs = ofDay(d); if (evs.length || k === 0) out.push(group(d, evs)); }
|
|
1279
|
+
out.push(mpBtn('nt-mp-lnk', { 'data-nt-meeting-more': '' }, 'Show earlier days (from ' + mpLabel(mpAdd(S.day, -S.span)) + ')', () => { S.span += 3; draw(); }));
|
|
1280
|
+
}
|
|
1281
|
+
out.push(button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, 'Not on the calendar'), el('span', { class: 'nt-hint' }, 'a hallway chat, a call')), variant: 'ghost',
|
|
1282
|
+
attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': S.pick === 'adhoc' ? 'true' : 'false', 'data-nt-meeting-pick': 'adhoc' }, onClick: () => { S.pick = 'adhoc'; draw(); } }));
|
|
1283
|
+
list.replaceChildren(...out);
|
|
1284
|
+
const chosen = all.find((e) => keyOf(e) === S.pick);
|
|
1285
|
+
save.textContent = chosen ? 'Link to ' + mpLabel(chosen.date).replace(/^\S+\s/, '') + ', ' + chosen.time : 'Link';
|
|
1286
|
+
}
|
|
1287
|
+
const save = el('span', null, 'Link');
|
|
1288
|
+
const h = sheet({ title: 'Which meeting is this?', sub: 'Closest to the note first. Search or go back to any day on your calendar.', body: [tools, list], attrs: { 'data-nt-meeting-sheet': '', class: 'nt-mp' },
|
|
1289
|
+
actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: save, variant: 'primary', attrs: { 'data-nt-meeting-save': '' }, onClick: async () => {
|
|
1290
|
+
if (!S.pick) { h.close(); return; }
|
|
1213
1291
|
h.close();
|
|
1214
|
-
if (pick === 'adhoc') return link(ctx, n, { meeting: { kind: 'adhoc' } }, () => 'Marked as a meeting that is not on the calendar.');
|
|
1215
|
-
const e =
|
|
1292
|
+
if (S.pick === 'adhoc') return link(ctx, n, { meeting: { kind: 'adhoc' } }, () => 'Marked as a meeting that is not on the calendar.');
|
|
1293
|
+
const e = all.find((x) => keyOf(x) === S.pick);
|
|
1294
|
+
if (!e) return;
|
|
1216
1295
|
const { meetingOf } = await import('./notes-links.js');
|
|
1217
1296
|
return link(ctx, n, { meeting: meetingOf(e) }, () => 'Linked to ' + e.title + '. The note shows on the calendar.');
|
|
1218
1297
|
} })] });
|
|
1219
|
-
|
|
1298
|
+
const [r, links] = await Promise.all([pmGet('/api/calendar-events'), import('./notes-links.js')]);
|
|
1220
1299
|
if (!h.open) return;
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
opt('adhoc', 'Not on the calendar', 'a hallway chat, a call', pick === 'adhoc'));
|
|
1300
|
+
notesFor = links.notesFor;
|
|
1301
|
+
all = (r && r.events) || [];
|
|
1302
|
+
best = bestMatchOf(all.filter((e) => e.date === noteDay || e.date === mpAdd(noteDay, -1)), n);
|
|
1303
|
+
const on = all.find(isOn);
|
|
1304
|
+
if (on) { S.pick = keyOf(on); S.day = on.date; S.weekEnd = on.date > noteDay ? on.date : noteDay; }
|
|
1305
|
+
else if (!S.pick && best) S.pick = keyOf(best);
|
|
1306
|
+
draw();
|
|
1229
1307
|
}
|
|
1230
1308
|
const HORIZON_WORDS = { now: 'Now', next: 'Next', later: 'Later' };
|
|
1231
1309
|
/** The repositories the notes can be about, one row per repository (its main tree), from the workspace server. */
|