@harborppm/cli 0.12.0 → 0.14.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/README.md +11 -1
- package/bin/harbor.mjs +5 -3
- package/dist/app.mjs +640 -100
- package/lib/cli.mjs +71 -0
- package/lib/prompt.mjs +140 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ harbor # name your harbor and its first project, and you're in
|
|
|
29
29
|
1. **Today**: today's focus (`f` adds or takes off the task you're on, up to three) and your check-in (`c`); what's on you, late first; who's waiting on you; your risks and issues.
|
|
30
30
|
2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
|
|
31
31
|
3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side; `f` and `p` show only someone's cards or one priority), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
|
|
32
|
-
4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health.
|
|
32
|
+
4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health; `v` reads it project by project, each with its bar across the months, open tasks by status and worst risks. A portfolio opens as a board (programs, with risks, next milestones and money beside them); `v` reads it program by program.
|
|
33
33
|
5. **Risks**: the register, worst first. Enter changes one (status, owner, likelihood, impact, mitigation); `n` logs a risk, issue or decision.
|
|
34
34
|
6. **People**: open and late work and the next four weeks against each person's capacity; Enter changes their capacity, role, or the time they're promised to a program.
|
|
35
35
|
7. **Roadmap**: a portfolio's programs and projects across the months, in their health's colour, ◆ for milestones.
|
|
@@ -59,6 +59,16 @@ Your harbor is the same one at [harborppm.com](https://harborppm.com/today?utm_s
|
|
|
59
59
|
|
|
60
60
|
The website has the commands too: turn on **Command line at the bottom** in your menu, press `/`, and type `mine`, `add "Call the mill" #ridge @sam --due fri` or `done RR-12`.
|
|
61
61
|
|
|
62
|
+
## In your shell prompt
|
|
63
|
+
|
|
64
|
+
`harbor prompt` prints a few characters for your prompt: `⚓ 2 today · 1 late`, with late work in coral, and nothing at all when you're clear. It never waits on the network: it shows the counts it kept and fetches new ones in the background when they're more than five minutes old, or after you change a task. `harbor prompt --init zsh` (or `bash`, `fish`, `starship`) prints the lines to add to your shell's setup:
|
|
65
|
+
|
|
66
|
+
```zsh
|
|
67
|
+
# ~/.zshrc
|
|
68
|
+
setopt prompt_subst
|
|
69
|
+
RPROMPT='$(harbor prompt --shell zsh)'
|
|
70
|
+
```
|
|
71
|
+
|
|
62
72
|
## Staying up to date
|
|
63
73
|
|
|
64
74
|
npm doesn't update a global install by itself, so once a day `harbor` asks npm whether a newer version is out, alongside whatever command you ran, and says so in one line when there is (`npm install -g @harborppm/cli@latest` updates it). It says nothing in scripts, pipes or CI; set `HARBOR_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to turn it off.
|
package/bin/harbor.mjs
CHANGED
|
@@ -6,9 +6,11 @@ import { updateNotice } from "../lib/update.mjs";
|
|
|
6
6
|
|
|
7
7
|
const argv = process.argv.slice(2);
|
|
8
8
|
// Asked alongside the command, so it costs nothing extra (see update.mjs).
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// Not for the shell prompt, which draws on every Enter and must never wait.
|
|
10
|
+
const notice =
|
|
11
|
+
argv.includes("--json") || argv[0] === "prompt"
|
|
12
|
+
? Promise.resolve(null)
|
|
13
|
+
: updateNotice({ version: VERSION }).catch(() => null);
|
|
12
14
|
const tell = async () => {
|
|
13
15
|
const line = await notice;
|
|
14
16
|
if (line) process.stderr.write(`\n${line}\n`);
|
package/dist/app.mjs
CHANGED
|
@@ -1485,6 +1485,97 @@ function forecastAtCompletion(item, now) {
|
|
|
1485
1485
|
};
|
|
1486
1486
|
}
|
|
1487
1487
|
|
|
1488
|
+
//#endregion
|
|
1489
|
+
//#region src/lib/flow.ts
|
|
1490
|
+
function tasksInProgramOf(data, programId) {
|
|
1491
|
+
const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
|
|
1492
|
+
return data.tasks.filter((t) => ids.has(t.projectId));
|
|
1493
|
+
}
|
|
1494
|
+
function constraintOf(data, programId) {
|
|
1495
|
+
const rows = data.constraints ?? [];
|
|
1496
|
+
return rows.find((c) => c.programId === programId && c.status === "active") ?? rows.find((c) => c.programId === programId);
|
|
1497
|
+
}
|
|
1498
|
+
function activeConstraints(data) {
|
|
1499
|
+
return (data.constraints ?? []).filter((c) => c.status === "active");
|
|
1500
|
+
}
|
|
1501
|
+
function constraintsOwnedBy(data, personId) {
|
|
1502
|
+
return activeConstraints(data).filter((c) => c.ownerId === personId);
|
|
1503
|
+
}
|
|
1504
|
+
function constraintIdle(data, constraint) {
|
|
1505
|
+
if (constraint.status !== "active" || !constraint.ownerId) return false;
|
|
1506
|
+
const tasks = tasksInProgramOf(data, constraint.programId);
|
|
1507
|
+
if (!tasks.some((t) => t.status !== "done")) return false;
|
|
1508
|
+
if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
|
|
1509
|
+
const here = new Set(tasks.map((t) => t.id));
|
|
1510
|
+
return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region src/lib/spend.ts
|
|
1515
|
+
function spendEntriesOf(data, projectId) {
|
|
1516
|
+
return (data.spendEntries ?? []).filter((e) => e.projectId === projectId).sort((a, b) => a.date.localeCompare(b.date));
|
|
1517
|
+
}
|
|
1518
|
+
function spentFromEntries(entries) {
|
|
1519
|
+
return entries.reduce((sum, e) => sum + e.amount, 0);
|
|
1520
|
+
}
|
|
1521
|
+
/** The entry that makes the ledger agree with a total typed by hand. */
|
|
1522
|
+
function adjustmentEntry(projectId, oldSpent, newSpent, date, id) {
|
|
1523
|
+
const delta = Math.round((newSpent - oldSpent) * 100) / 100;
|
|
1524
|
+
if (delta === 0) return null;
|
|
1525
|
+
return {
|
|
1526
|
+
id,
|
|
1527
|
+
projectId,
|
|
1528
|
+
amount: delta,
|
|
1529
|
+
date,
|
|
1530
|
+
note: "Adjusted the total by hand"
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
//#endregion
|
|
1535
|
+
//#region src/lib/selectors.ts
|
|
1536
|
+
function programsIn(data, portfolioId) {
|
|
1537
|
+
return data.programs.filter((p) => p.portfolioId === portfolioId);
|
|
1538
|
+
}
|
|
1539
|
+
function projectsInProgram(data, programId) {
|
|
1540
|
+
return data.projects.filter((p) => p.programId === programId);
|
|
1541
|
+
}
|
|
1542
|
+
/** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
|
|
1543
|
+
function splitDone(items) {
|
|
1544
|
+
const open = [];
|
|
1545
|
+
const done = [];
|
|
1546
|
+
for (const item of items) (item.health === "done" ? done : open).push(item);
|
|
1547
|
+
return {
|
|
1548
|
+
open,
|
|
1549
|
+
done
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
function projectsInPortfolio(data, portfolioId) {
|
|
1553
|
+
const ids = new Set(programsIn(data, portfolioId).map((p) => p.id));
|
|
1554
|
+
return data.projects.filter((p) => ids.has(p.programId));
|
|
1555
|
+
}
|
|
1556
|
+
function sum(values) {
|
|
1557
|
+
return values.reduce((a, b) => a + b, 0);
|
|
1558
|
+
}
|
|
1559
|
+
/** Projects are the leaf: their own `spent` field, no rollup needed. */
|
|
1560
|
+
function programSpent(data, programId) {
|
|
1561
|
+
return sum(projectsInProgram(data, programId).map((p) => p.spent));
|
|
1562
|
+
}
|
|
1563
|
+
/** Sum of a program's own projects' budgets — compare against the program's stated budget to see over/under-allocation. */
|
|
1564
|
+
function programBudgetAllocated(data, programId) {
|
|
1565
|
+
return sum(projectsInProgram(data, programId).map((p) => p.budget));
|
|
1566
|
+
}
|
|
1567
|
+
/** This program's own items plus everything raised in its projects. */
|
|
1568
|
+
function programRaidItems(data, programId) {
|
|
1569
|
+
const projectIds = new Set(projectsInProgram(data, programId).map((p) => p.id));
|
|
1570
|
+
return data.raidItems.filter((r) => r.programId === programId || r.projectId != null && projectIds.has(r.projectId));
|
|
1571
|
+
}
|
|
1572
|
+
/** This portfolio's own items plus everything raised in its programs and their projects. */
|
|
1573
|
+
function portfolioRaidItems(data, portfolioId) {
|
|
1574
|
+
const programIds = new Set(programsIn(data, portfolioId).map((p) => p.id));
|
|
1575
|
+
const projectIds = new Set(projectsInPortfolio(data, portfolioId).map((p) => p.id));
|
|
1576
|
+
return data.raidItems.filter((r) => r.portfolioId === portfolioId || r.programId != null && programIds.has(r.programId) || r.projectId != null && projectIds.has(r.projectId));
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1488
1579
|
//#endregion
|
|
1489
1580
|
//#region src/lib/baseline.ts
|
|
1490
1581
|
const day$2 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
@@ -1823,27 +1914,6 @@ function searchTasks(tasks, projects, query, limit = 12) {
|
|
|
1823
1914
|
}, limit);
|
|
1824
1915
|
}
|
|
1825
1916
|
|
|
1826
|
-
//#endregion
|
|
1827
|
-
//#region src/lib/spend.ts
|
|
1828
|
-
function spendEntriesOf(data, projectId) {
|
|
1829
|
-
return (data.spendEntries ?? []).filter((e) => e.projectId === projectId).sort((a, b) => a.date.localeCompare(b.date));
|
|
1830
|
-
}
|
|
1831
|
-
function spentFromEntries(entries) {
|
|
1832
|
-
return entries.reduce((sum, e) => sum + e.amount, 0);
|
|
1833
|
-
}
|
|
1834
|
-
/** The entry that makes the ledger agree with a total typed by hand. */
|
|
1835
|
-
function adjustmentEntry(projectId, oldSpent, newSpent, date, id) {
|
|
1836
|
-
const delta = Math.round((newSpent - oldSpent) * 100) / 100;
|
|
1837
|
-
if (delta === 0) return null;
|
|
1838
|
-
return {
|
|
1839
|
-
id,
|
|
1840
|
-
projectId,
|
|
1841
|
-
amount: delta,
|
|
1842
|
-
date,
|
|
1843
|
-
note: "Adjusted the total by hand"
|
|
1844
|
-
};
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
1917
|
//#endregion
|
|
1848
1918
|
//#region src/lib/work.ts
|
|
1849
1919
|
/** Every task this one waits on that still exists, done or not. */
|
|
@@ -2096,6 +2166,74 @@ function filterTasks(tasks, f) {
|
|
|
2096
2166
|
if (f.who === "any" && f.priority === "any") return tasks;
|
|
2097
2167
|
return tasks.filter((t) => (f.who === "any" || (f.who === "none" ? t.assigneeId === null : t.assigneeId === f.who)) && (f.priority === "any" || t.priority === f.priority));
|
|
2098
2168
|
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Bands of lines, each line owned by one item (a program or a project):
|
|
2171
|
+
* the first line to show so the selected band fits, scrolling as little
|
|
2172
|
+
* as it can from the top.
|
|
2173
|
+
*/
|
|
2174
|
+
function bandStart(owners, selected, rows) {
|
|
2175
|
+
const first = owners.indexOf(selected);
|
|
2176
|
+
if (first < 0) return 0;
|
|
2177
|
+
const last = owners.lastIndexOf(selected);
|
|
2178
|
+
return Math.max(0, Math.min(first, last - rows + 1));
|
|
2179
|
+
}
|
|
2180
|
+
/** The next milestones still to reach, soonest first. */
|
|
2181
|
+
function nextMarks(data, programId, n) {
|
|
2182
|
+
return data.milestones.filter((m) => m.programId === programId && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, n);
|
|
2183
|
+
}
|
|
2184
|
+
/** Open risks and issues, worst first. */
|
|
2185
|
+
function worst(items, n) {
|
|
2186
|
+
return sortRaid(items.filter(isActive)).slice(0, n);
|
|
2187
|
+
}
|
|
2188
|
+
function projectRow(data, project, today) {
|
|
2189
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
2190
|
+
const open = {};
|
|
2191
|
+
let done = 0;
|
|
2192
|
+
let late = 0;
|
|
2193
|
+
for (const t of tasks) {
|
|
2194
|
+
if (t.status === "done") done++;
|
|
2195
|
+
else open[t.status] = (open[t.status] ?? 0) + 1;
|
|
2196
|
+
if (isLate(t, today)) late++;
|
|
2197
|
+
}
|
|
2198
|
+
return {
|
|
2199
|
+
project,
|
|
2200
|
+
progress: tasks.length ? Math.round(done / tasks.length * 100) : 0,
|
|
2201
|
+
late,
|
|
2202
|
+
open
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
function activeConstraint(data, programId) {
|
|
2206
|
+
const c = constraintOf(data, programId);
|
|
2207
|
+
return c?.status === "active" ? c.title : null;
|
|
2208
|
+
}
|
|
2209
|
+
/** A portfolio read program by program, as the website's By program layout. */
|
|
2210
|
+
function programBands(data, portfolioId, today, showDone) {
|
|
2211
|
+
const split = splitDone(data.programs.filter((p) => p.portfolioId === portfolioId));
|
|
2212
|
+
return (showDone ? [...split.open, ...split.done] : split.open).map((program) => {
|
|
2213
|
+
const mine = splitDone(data.projects.filter((p) => p.programId === program.id));
|
|
2214
|
+
const rows = (showDone ? [...mine.open, ...mine.done] : mine.open).map((p) => projectRow(data, p, today));
|
|
2215
|
+
const all = [...mine.open, ...mine.done].map((p) => tasksOf(data.tasks, p.id));
|
|
2216
|
+
const total = all.reduce((n, t) => n + t.length, 0);
|
|
2217
|
+
const done = all.reduce((n, t) => n + t.filter((x) => x.status === "done").length, 0);
|
|
2218
|
+
return {
|
|
2219
|
+
program,
|
|
2220
|
+
progress: total ? Math.round(done / total * 100) : 0,
|
|
2221
|
+
constraint: activeConstraint(data, program.id),
|
|
2222
|
+
projects: rows,
|
|
2223
|
+
doneProjects: showDone ? 0 : mine.done.length,
|
|
2224
|
+
milestones: nextMarks(data, program.id, 2),
|
|
2225
|
+
risks: worst(programRaidItems(data, program.id), 2)
|
|
2226
|
+
};
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
/** A program read project by project, as the website's By project layout. */
|
|
2230
|
+
function projectBands(data, programId, today, showDone) {
|
|
2231
|
+
const split = splitDone(data.projects.filter((p) => p.programId === programId));
|
|
2232
|
+
return (showDone ? [...split.open, ...split.done] : split.open).map((p) => ({
|
|
2233
|
+
...projectRow(data, p, today),
|
|
2234
|
+
risks: worst(data.raidItems.filter((r) => r.projectId === p.id), 2)
|
|
2235
|
+
}));
|
|
2236
|
+
}
|
|
2099
2237
|
|
|
2100
2238
|
//#endregion
|
|
2101
2239
|
//#region src/tui/theme.ts
|
|
@@ -2981,27 +3119,6 @@ function HistoryView({ active, width, height, go }) {
|
|
|
2981
3119
|
});
|
|
2982
3120
|
}
|
|
2983
3121
|
|
|
2984
|
-
//#endregion
|
|
2985
|
-
//#region src/lib/flow.ts
|
|
2986
|
-
function tasksInProgramOf(data, programId) {
|
|
2987
|
-
const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
|
|
2988
|
-
return data.tasks.filter((t) => ids.has(t.projectId));
|
|
2989
|
-
}
|
|
2990
|
-
function activeConstraints(data) {
|
|
2991
|
-
return (data.constraints ?? []).filter((c) => c.status === "active");
|
|
2992
|
-
}
|
|
2993
|
-
function constraintsOwnedBy(data, personId) {
|
|
2994
|
-
return activeConstraints(data).filter((c) => c.ownerId === personId);
|
|
2995
|
-
}
|
|
2996
|
-
function constraintIdle(data, constraint) {
|
|
2997
|
-
if (constraint.status !== "active" || !constraint.ownerId) return false;
|
|
2998
|
-
const tasks = tasksInProgramOf(data, constraint.programId);
|
|
2999
|
-
if (!tasks.some((t) => t.status !== "done")) return false;
|
|
3000
|
-
if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
|
|
3001
|
-
const here = new Set(tasks.map((t) => t.id));
|
|
3002
|
-
return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
|
|
3003
|
-
}
|
|
3004
|
-
|
|
3005
3122
|
//#endregion
|
|
3006
3123
|
//#region src/lib/inbox.ts
|
|
3007
3124
|
const KIND_RANK = {
|
|
@@ -3293,19 +3410,6 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
|
|
|
3293
3410
|
};
|
|
3294
3411
|
}
|
|
3295
3412
|
|
|
3296
|
-
//#endregion
|
|
3297
|
-
//#region src/lib/selectors.ts
|
|
3298
|
-
/** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
|
|
3299
|
-
function splitDone(items) {
|
|
3300
|
-
const open = [];
|
|
3301
|
-
const done = [];
|
|
3302
|
-
for (const item of items) (item.health === "done" ? done : open).push(item);
|
|
3303
|
-
return {
|
|
3304
|
-
open,
|
|
3305
|
-
done
|
|
3306
|
-
};
|
|
3307
|
-
}
|
|
3308
|
-
|
|
3309
3413
|
//#endregion
|
|
3310
3414
|
//#region src/tui/views/lists.tsx
|
|
3311
3415
|
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
@@ -4737,12 +4841,293 @@ function PersonPanel({ id, width, height, go, active, onClose }) {
|
|
|
4737
4841
|
});
|
|
4738
4842
|
}
|
|
4739
4843
|
|
|
4844
|
+
//#endregion
|
|
4845
|
+
//#region src/tui/views/bands.tsx
|
|
4846
|
+
/**
|
|
4847
|
+
* The website's band layouts in the terminal: a portfolio read program by
|
|
4848
|
+
* program, a program read project by project. Each band is a few lines; the
|
|
4849
|
+
* one you're on stays in view.
|
|
4850
|
+
*/
|
|
4851
|
+
const NAME$1 = 24;
|
|
4852
|
+
function Lines({ lines, selected, rows }) {
|
|
4853
|
+
const start = bandStart(lines.map((l) => l.owner), selected, rows);
|
|
4854
|
+
const shown = lines.slice(start, start + rows);
|
|
4855
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
4856
|
+
flexDirection: "column",
|
|
4857
|
+
height: rows,
|
|
4858
|
+
children: shown.map((l, i) => /* @__PURE__ */ jsx(Box, { children: l.node }, start + i))
|
|
4859
|
+
});
|
|
4860
|
+
}
|
|
4861
|
+
function fit(text, n) {
|
|
4862
|
+
return text.length > n ? `${text.slice(0, n - 1)}…` : text.padEnd(n);
|
|
4863
|
+
}
|
|
4864
|
+
function riskLine(r, today) {
|
|
4865
|
+
const score = scoreOf(r);
|
|
4866
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
4867
|
+
wrap: "truncate-end",
|
|
4868
|
+
children: [
|
|
4869
|
+
" ",
|
|
4870
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4871
|
+
color: r.kind === "issue" ? theme.bad : r.kind === "risk" ? theme.warn : theme.muted,
|
|
4872
|
+
children: KIND_LABEL[r.kind].toLowerCase()
|
|
4873
|
+
}),
|
|
4874
|
+
" ",
|
|
4875
|
+
r.title,
|
|
4876
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
4877
|
+
color: theme.muted,
|
|
4878
|
+
children: [score != null ? ` · score ${score}` : "", r.dueDate ? ` · review ${shortDay(r.dueDate, today)}` : ""]
|
|
4879
|
+
})
|
|
4880
|
+
]
|
|
4881
|
+
});
|
|
4882
|
+
}
|
|
4883
|
+
function projectLine(row, w, bar, today, indent = 4) {
|
|
4884
|
+
const p = row.project;
|
|
4885
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
4886
|
+
wrap: "truncate-end",
|
|
4887
|
+
children: [
|
|
4888
|
+
" ".repeat(indent),
|
|
4889
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4890
|
+
color: HEALTH_COLOR[p.health],
|
|
4891
|
+
children: "●"
|
|
4892
|
+
}),
|
|
4893
|
+
" ",
|
|
4894
|
+
fit(p.name, NAME$1 - indent),
|
|
4895
|
+
" ",
|
|
4896
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4897
|
+
color: HEALTH_COLOR[p.health],
|
|
4898
|
+
children: roadmapCells(p.startDate, p.endDate, [], w, bar)
|
|
4899
|
+
}),
|
|
4900
|
+
" ",
|
|
4901
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
4902
|
+
color: theme.muted,
|
|
4903
|
+
children: [String(row.progress).padStart(3), "%"]
|
|
4904
|
+
}),
|
|
4905
|
+
row.late ? /* @__PURE__ */ jsxs(Text, {
|
|
4906
|
+
color: theme.bad,
|
|
4907
|
+
children: [
|
|
4908
|
+
" ",
|
|
4909
|
+
row.late,
|
|
4910
|
+
" late"
|
|
4911
|
+
]
|
|
4912
|
+
}) : null
|
|
4913
|
+
]
|
|
4914
|
+
});
|
|
4915
|
+
}
|
|
4916
|
+
/** The months over the bars, lined up with them. */
|
|
4917
|
+
function Axis({ w, bar }) {
|
|
4918
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
4919
|
+
color: theme.muted,
|
|
4920
|
+
wrap: "truncate-end",
|
|
4921
|
+
children: [" ".repeat(27), roadmapHeader(w, bar)]
|
|
4922
|
+
});
|
|
4923
|
+
}
|
|
4924
|
+
/** A portfolio, program by program: each with its projects' bars, next milestones and worst risks. */
|
|
4925
|
+
function ProgramBands({ bands, at, active, width, height, w, today }) {
|
|
4926
|
+
const bar = Math.max(10, width - NAME$1 - 19);
|
|
4927
|
+
const lines = [];
|
|
4928
|
+
bands.forEach((b, i) => {
|
|
4929
|
+
const on = active && i === at;
|
|
4930
|
+
const p = b.program;
|
|
4931
|
+
lines.push({
|
|
4932
|
+
owner: i,
|
|
4933
|
+
node: /* @__PURE__ */ jsxs(Text, {
|
|
4934
|
+
wrap: "truncate-end",
|
|
4935
|
+
color: on ? theme.selected : void 0,
|
|
4936
|
+
bold: on,
|
|
4937
|
+
children: [
|
|
4938
|
+
on ? "› " : " ",
|
|
4939
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4940
|
+
color: HEALTH_COLOR[p.health],
|
|
4941
|
+
children: "●"
|
|
4942
|
+
}),
|
|
4943
|
+
" ",
|
|
4944
|
+
p.name,
|
|
4945
|
+
" ",
|
|
4946
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
4947
|
+
color: theme.muted,
|
|
4948
|
+
bold: false,
|
|
4949
|
+
children: [
|
|
4950
|
+
HEALTH_LABEL[p.health],
|
|
4951
|
+
" · ",
|
|
4952
|
+
b.progress,
|
|
4953
|
+
"% done",
|
|
4954
|
+
p.nextMove ? ` · next: ${p.nextMove}` : ""
|
|
4955
|
+
]
|
|
4956
|
+
})
|
|
4957
|
+
]
|
|
4958
|
+
})
|
|
4959
|
+
});
|
|
4960
|
+
if (b.constraint) lines.push({
|
|
4961
|
+
owner: i,
|
|
4962
|
+
node: /* @__PURE__ */ jsxs(Text, {
|
|
4963
|
+
color: theme.warn,
|
|
4964
|
+
wrap: "truncate-end",
|
|
4965
|
+
children: [
|
|
4966
|
+
" ",
|
|
4967
|
+
"Held back by ",
|
|
4968
|
+
b.constraint
|
|
4969
|
+
]
|
|
4970
|
+
})
|
|
4971
|
+
});
|
|
4972
|
+
for (const row of b.projects) lines.push({
|
|
4973
|
+
owner: i,
|
|
4974
|
+
node: projectLine(row, w, bar, today)
|
|
4975
|
+
});
|
|
4976
|
+
if (b.projects.length === 0) lines.push({
|
|
4977
|
+
owner: i,
|
|
4978
|
+
node: /* @__PURE__ */ jsxs(Text, {
|
|
4979
|
+
color: theme.muted,
|
|
4980
|
+
children: [" ", b.doneProjects ? "Every project here is done." : "No projects yet."]
|
|
4981
|
+
})
|
|
4982
|
+
});
|
|
4983
|
+
else if (b.doneProjects) lines.push({
|
|
4984
|
+
owner: i,
|
|
4985
|
+
node: /* @__PURE__ */ jsxs(Text, {
|
|
4986
|
+
color: theme.muted,
|
|
4987
|
+
children: [
|
|
4988
|
+
" ",
|
|
4989
|
+
b.doneProjects,
|
|
4990
|
+
" done hidden (D)"
|
|
4991
|
+
]
|
|
4992
|
+
})
|
|
4993
|
+
});
|
|
4994
|
+
for (const m of b.milestones) lines.push({
|
|
4995
|
+
owner: i,
|
|
4996
|
+
node: /* @__PURE__ */ jsxs(Text, {
|
|
4997
|
+
wrap: "truncate-end",
|
|
4998
|
+
children: [
|
|
4999
|
+
" ",
|
|
5000
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5001
|
+
color: m.dueDate < today ? theme.bad : theme.accent,
|
|
5002
|
+
children: "◆"
|
|
5003
|
+
}),
|
|
5004
|
+
" ",
|
|
5005
|
+
m.name,
|
|
5006
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5007
|
+
color: m.dueDate < today ? theme.bad : theme.muted,
|
|
5008
|
+
children: [
|
|
5009
|
+
" ",
|
|
5010
|
+
"· ",
|
|
5011
|
+
m.dueDate < today ? "late, " : "",
|
|
5012
|
+
shortDay(m.dueDate, today)
|
|
5013
|
+
]
|
|
5014
|
+
})
|
|
5015
|
+
]
|
|
5016
|
+
})
|
|
5017
|
+
});
|
|
5018
|
+
for (const r of b.risks) lines.push({
|
|
5019
|
+
owner: i,
|
|
5020
|
+
node: riskLine(r, today)
|
|
5021
|
+
});
|
|
5022
|
+
lines.push({
|
|
5023
|
+
owner: i,
|
|
5024
|
+
node: /* @__PURE__ */ jsx(Text, { children: " " })
|
|
5025
|
+
});
|
|
5026
|
+
});
|
|
5027
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
5028
|
+
flexDirection: "column",
|
|
5029
|
+
height,
|
|
5030
|
+
children: [/* @__PURE__ */ jsx(Axis, {
|
|
5031
|
+
w,
|
|
5032
|
+
bar
|
|
5033
|
+
}), /* @__PURE__ */ jsx(Lines, {
|
|
5034
|
+
lines,
|
|
5035
|
+
selected: at,
|
|
5036
|
+
rows: Math.max(1, height - 1)
|
|
5037
|
+
})]
|
|
5038
|
+
});
|
|
5039
|
+
}
|
|
5040
|
+
/** A program, project by project: each with its bar, its open tasks by status and its worst risks. */
|
|
5041
|
+
function ProjectBands({ bands, at, active, width, height, w, today }) {
|
|
5042
|
+
const bar = Math.max(10, width - NAME$1 - 14);
|
|
5043
|
+
const lines = [];
|
|
5044
|
+
bands.forEach((b, i) => {
|
|
5045
|
+
const on = active && i === at;
|
|
5046
|
+
const p = b.project;
|
|
5047
|
+
lines.push({
|
|
5048
|
+
owner: i,
|
|
5049
|
+
node: /* @__PURE__ */ jsxs(Text, {
|
|
5050
|
+
wrap: "truncate-end",
|
|
5051
|
+
color: on ? theme.selected : void 0,
|
|
5052
|
+
bold: on,
|
|
5053
|
+
children: [
|
|
5054
|
+
on ? "› " : " ",
|
|
5055
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5056
|
+
color: HEALTH_COLOR[p.health],
|
|
5057
|
+
children: "●"
|
|
5058
|
+
}),
|
|
5059
|
+
" ",
|
|
5060
|
+
fit(p.name, 22),
|
|
5061
|
+
" ",
|
|
5062
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5063
|
+
color: HEALTH_COLOR[p.health],
|
|
5064
|
+
bold: false,
|
|
5065
|
+
children: roadmapCells(p.startDate, p.endDate, [], w, bar)
|
|
5066
|
+
}),
|
|
5067
|
+
" ",
|
|
5068
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5069
|
+
color: theme.muted,
|
|
5070
|
+
bold: false,
|
|
5071
|
+
children: [String(b.progress).padStart(3), "%"]
|
|
5072
|
+
})
|
|
5073
|
+
]
|
|
5074
|
+
})
|
|
5075
|
+
});
|
|
5076
|
+
const open = STATUS_ORDER.filter((s) => s !== "done" && b.open[s]).map((s) => `${STATUS_LABEL[s].toLowerCase()} ${b.open[s]}`);
|
|
5077
|
+
lines.push({
|
|
5078
|
+
owner: i,
|
|
5079
|
+
node: /* @__PURE__ */ jsxs(Text, {
|
|
5080
|
+
wrap: "truncate-end",
|
|
5081
|
+
color: theme.muted,
|
|
5082
|
+
children: [
|
|
5083
|
+
" ",
|
|
5084
|
+
HEALTH_LABEL[p.health],
|
|
5085
|
+
" · ",
|
|
5086
|
+
open.length ? open.join(" · ") : "nothing open",
|
|
5087
|
+
b.late ? /* @__PURE__ */ jsxs(Text, {
|
|
5088
|
+
color: theme.bad,
|
|
5089
|
+
children: [
|
|
5090
|
+
" · ",
|
|
5091
|
+
b.late,
|
|
5092
|
+
" late"
|
|
5093
|
+
]
|
|
5094
|
+
}) : null,
|
|
5095
|
+
" · ends",
|
|
5096
|
+
" ",
|
|
5097
|
+
shortDay(p.endDate, today)
|
|
5098
|
+
]
|
|
5099
|
+
})
|
|
5100
|
+
});
|
|
5101
|
+
for (const r of b.risks) lines.push({
|
|
5102
|
+
owner: i,
|
|
5103
|
+
node: riskLine(r, today)
|
|
5104
|
+
});
|
|
5105
|
+
lines.push({
|
|
5106
|
+
owner: i,
|
|
5107
|
+
node: /* @__PURE__ */ jsx(Text, { children: " " })
|
|
5108
|
+
});
|
|
5109
|
+
});
|
|
5110
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
5111
|
+
flexDirection: "column",
|
|
5112
|
+
height,
|
|
5113
|
+
children: [/* @__PURE__ */ jsx(Axis, {
|
|
5114
|
+
w,
|
|
5115
|
+
bar
|
|
5116
|
+
}), /* @__PURE__ */ jsx(Lines, {
|
|
5117
|
+
lines,
|
|
5118
|
+
selected: at,
|
|
5119
|
+
rows: Math.max(1, height - 1)
|
|
5120
|
+
})]
|
|
5121
|
+
});
|
|
5122
|
+
}
|
|
5123
|
+
|
|
4740
5124
|
//#endregion
|
|
4741
5125
|
//#region src/tui/views/program.tsx
|
|
4742
5126
|
/**
|
|
4743
5127
|
* One program: its health (and why, when set by hand), next move, its
|
|
4744
5128
|
* projects and its milestones. ←→ between the two lists. Projects set to
|
|
4745
|
-
* Done stay out of the list until D shows them.
|
|
5129
|
+
* Done stay out of the list until D shows them. v reads it project by
|
|
5130
|
+
* project instead, as the website's By project layout.
|
|
4746
5131
|
*/
|
|
4747
5132
|
function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
4748
5133
|
const { store, today } = useDeps();
|
|
@@ -4751,6 +5136,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4751
5136
|
const tasks = useHarbor((s) => s.tasks);
|
|
4752
5137
|
const milestones = useHarbor((s) => s.milestones);
|
|
4753
5138
|
const people = useHarbor((s) => s.people);
|
|
5139
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
4754
5140
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === program?.portfolioId));
|
|
4755
5141
|
const [showDone, setShowDone] = useState(false);
|
|
4756
5142
|
const split = useMemo(() => splitDone(projects.filter((p) => p.programId === programId)), [projects, programId]);
|
|
@@ -4761,14 +5147,19 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4761
5147
|
today
|
|
4762
5148
|
]);
|
|
4763
5149
|
const [side, setSide] = useState("projects");
|
|
5150
|
+
const [layout, setLayout] = useState("board");
|
|
4764
5151
|
const [pAt, setPAt] = useState(0);
|
|
4765
5152
|
const [mAt, setMAt] = useState(0);
|
|
4766
5153
|
const project = mine[Math.min(pAt, mine.length - 1)];
|
|
4767
5154
|
const mark = marks[Math.min(mAt, marks.length - 1)]?.milestone;
|
|
4768
5155
|
useInput((input, key) => {
|
|
4769
5156
|
if (key.escape || key.backspace) return onBack();
|
|
4770
|
-
if (
|
|
4771
|
-
|
|
5157
|
+
if (input === "v") {
|
|
5158
|
+
setSide("projects");
|
|
5159
|
+
return setLayout((l) => l === "board" ? "bands" : "board");
|
|
5160
|
+
}
|
|
5161
|
+
if (layout === "board" && (key.leftArrow || input === "h")) return setSide("projects");
|
|
5162
|
+
if (layout === "board" && (key.rightArrow || input === "l")) return setSide("milestones");
|
|
4772
5163
|
if (input === "e") return go.openPanel("program", programId);
|
|
4773
5164
|
if (input === "n") return go.create("project", { programId });
|
|
4774
5165
|
if (input === "m") return go.create("milestone", { programId });
|
|
@@ -4798,6 +5189,11 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4798
5189
|
tasks,
|
|
4799
5190
|
projects
|
|
4800
5191
|
};
|
|
5192
|
+
const bands = layout === "bands" ? projectBands({
|
|
5193
|
+
tasks,
|
|
5194
|
+
projects,
|
|
5195
|
+
raidItems
|
|
5196
|
+
}, programId, today(), showDone) : [];
|
|
4801
5197
|
return /* @__PURE__ */ jsxs(Box, {
|
|
4802
5198
|
flexDirection: "column",
|
|
4803
5199
|
width,
|
|
@@ -4845,7 +5241,21 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4845
5241
|
children: ["Next move: ", program.nextMove || "none written yet (e to write one)"]
|
|
4846
5242
|
}),
|
|
4847
5243
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
4848
|
-
/* @__PURE__ */ jsxs(
|
|
5244
|
+
layout === "bands" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Heading, {
|
|
5245
|
+
right: `${mine.length}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""} · v board`,
|
|
5246
|
+
children: "By project"
|
|
5247
|
+
}), mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5248
|
+
color: theme.muted,
|
|
5249
|
+
children: split.done.length > 0 ? "Every project here is done. D shows them." : "None yet. n adds one."
|
|
5250
|
+
}) : /* @__PURE__ */ jsx(ProjectBands, {
|
|
5251
|
+
bands,
|
|
5252
|
+
at: Math.min(pAt, bands.length - 1),
|
|
5253
|
+
active,
|
|
5254
|
+
width: width - 2,
|
|
5255
|
+
height: height - 4,
|
|
5256
|
+
w: roadmapWindow("half", today()),
|
|
5257
|
+
today: today()
|
|
5258
|
+
})] }) : /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
|
|
4849
5259
|
flexDirection: "column",
|
|
4850
5260
|
width: half,
|
|
4851
5261
|
marginRight: 1,
|
|
@@ -4930,20 +5340,54 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4930
5340
|
]
|
|
4931
5341
|
});
|
|
4932
5342
|
}
|
|
4933
|
-
/**
|
|
5343
|
+
/**
|
|
5344
|
+
* One portfolio, as the website's board: its programs, with its worst
|
|
5345
|
+
* risks, next milestones and money beside them when there's room. v reads
|
|
5346
|
+
* it program by program instead (By program). Done programs wait for D.
|
|
5347
|
+
*/
|
|
4934
5348
|
function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
5349
|
+
const { today } = useDeps();
|
|
4935
5350
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === portfolioId));
|
|
4936
5351
|
const programs = useHarbor((s) => s.programs);
|
|
4937
5352
|
const projects = useHarbor((s) => s.projects);
|
|
5353
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
5354
|
+
const milestones = useHarbor((s) => s.milestones);
|
|
5355
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
5356
|
+
const constraints = useHarbor((s) => s.constraints);
|
|
4938
5357
|
const [showDone, setShowDone] = useState(false);
|
|
5358
|
+
const [layout, setLayout] = useState("board");
|
|
4939
5359
|
const split = useMemo(() => splitDone(programs.filter((p) => p.portfolioId === portfolioId)), [programs, portfolioId]);
|
|
4940
5360
|
const list = showDone ? [...split.open, ...split.done] : split.open;
|
|
4941
5361
|
const [at, setAt] = useState(0);
|
|
4942
5362
|
const program = list[Math.min(at, list.length - 1)];
|
|
5363
|
+
const day = today();
|
|
5364
|
+
const data = useMemo(() => ({
|
|
5365
|
+
programs,
|
|
5366
|
+
projects,
|
|
5367
|
+
tasks,
|
|
5368
|
+
milestones,
|
|
5369
|
+
raidItems,
|
|
5370
|
+
constraints
|
|
5371
|
+
}), [
|
|
5372
|
+
programs,
|
|
5373
|
+
projects,
|
|
5374
|
+
tasks,
|
|
5375
|
+
milestones,
|
|
5376
|
+
raidItems,
|
|
5377
|
+
constraints
|
|
5378
|
+
]);
|
|
5379
|
+
const bands = useMemo(() => layout === "bands" ? programBands(data, portfolioId, day, showDone) : [], [
|
|
5380
|
+
layout,
|
|
5381
|
+
data,
|
|
5382
|
+
portfolioId,
|
|
5383
|
+
day,
|
|
5384
|
+
showDone
|
|
5385
|
+
]);
|
|
4943
5386
|
useInput((input, key) => {
|
|
4944
5387
|
if (key.escape || key.backspace) return onBack();
|
|
4945
5388
|
if (input === "e") return go.openPanel("portfolio", portfolioId);
|
|
4946
5389
|
if (input === "n") return go.create("program", { portfolioId });
|
|
5390
|
+
if (input === "v") return setLayout((l) => l === "board" ? "bands" : "board");
|
|
4947
5391
|
if (input === "D" && split.done.length > 0) return setShowDone((v) => !v);
|
|
4948
5392
|
if (key.upArrow || input === "k") setAt((i) => step(i, -1, list.length));
|
|
4949
5393
|
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, list.length));
|
|
@@ -4953,6 +5397,15 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
4953
5397
|
color: theme.muted,
|
|
4954
5398
|
children: " That portfolio isn’t in this harbor any more. Esc goes back."
|
|
4955
5399
|
});
|
|
5400
|
+
const side = layout === "board" && width >= 96 ? Math.floor((width - 3) * .4) : 0;
|
|
5401
|
+
const main = width - 2 - (side ? side + 1 : 0);
|
|
5402
|
+
const body = height - 4;
|
|
5403
|
+
const risks = sortRaid(portfolioRaidItems(data, portfolioId).filter(isActive));
|
|
5404
|
+
const ids = new Set(list.map((p) => p.id));
|
|
5405
|
+
const next = milestones.filter((m) => ids.has(m.programId) && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
|
|
5406
|
+
const budget = list.reduce((n, p) => n + programBudgetAllocated(data, p.id), 0);
|
|
5407
|
+
const spent = list.reduce((n, p) => n + programSpent(data, p.id), 0);
|
|
5408
|
+
const listRows = Math.max(1, body - 1);
|
|
4956
5409
|
return /* @__PURE__ */ jsxs(Box, {
|
|
4957
5410
|
flexDirection: "column",
|
|
4958
5411
|
width,
|
|
@@ -4981,50 +5434,136 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
4981
5434
|
}) : null
|
|
4982
5435
|
]
|
|
4983
5436
|
}),
|
|
4984
|
-
/* @__PURE__ */
|
|
5437
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
4985
5438
|
color: theme.muted,
|
|
4986
5439
|
wrap: "truncate-end",
|
|
4987
|
-
children:
|
|
5440
|
+
children: [
|
|
5441
|
+
portfolio.theme || "Portfolio",
|
|
5442
|
+
" · ",
|
|
5443
|
+
layout === "board" ? "board" : "by program",
|
|
5444
|
+
" · v switches"
|
|
5445
|
+
]
|
|
4988
5446
|
}),
|
|
4989
5447
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
4990
|
-
/* @__PURE__ */
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5448
|
+
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
|
|
5449
|
+
flexDirection: "column",
|
|
5450
|
+
width: main,
|
|
5451
|
+
children: [/* @__PURE__ */ jsx(Heading, {
|
|
5452
|
+
right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
|
|
5453
|
+
children: layout === "board" ? "Programs" : "By program"
|
|
5454
|
+
}), list.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5455
|
+
color: theme.muted,
|
|
5456
|
+
children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
|
|
5457
|
+
}) : layout === "bands" ? /* @__PURE__ */ jsx(ProgramBands, {
|
|
5458
|
+
bands,
|
|
5459
|
+
at: Math.min(at, bands.length - 1),
|
|
5460
|
+
active,
|
|
5461
|
+
width: main,
|
|
5462
|
+
height: listRows,
|
|
5463
|
+
w: roadmapWindow("half", day),
|
|
5464
|
+
today: day
|
|
5465
|
+
}) : windowOf(list, at, listRows).items.map((p) => {
|
|
5466
|
+
const on = active && p.id === program?.id;
|
|
5467
|
+
const count = projects.filter((x) => x.programId === p.id).length;
|
|
5468
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
5469
|
+
color: on ? theme.selected : void 0,
|
|
5470
|
+
bold: on,
|
|
5471
|
+
wrap: "truncate-end",
|
|
5472
|
+
children: [
|
|
5473
|
+
on ? "› " : " ",
|
|
5474
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5475
|
+
color: HEALTH_COLOR[p.health],
|
|
5476
|
+
children: "●"
|
|
5477
|
+
}),
|
|
5478
|
+
" ",
|
|
5479
|
+
p.name,
|
|
5480
|
+
" ",
|
|
5481
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5482
|
+
color: theme.muted,
|
|
5483
|
+
children: [
|
|
5484
|
+
HEALTH_LABEL[p.health],
|
|
5485
|
+
" · ",
|
|
5486
|
+
count,
|
|
5487
|
+
" project",
|
|
5488
|
+
count === 1 ? "" : "s",
|
|
5489
|
+
p.nextMove ? ` · next: ${p.nextMove}` : ""
|
|
5490
|
+
]
|
|
5491
|
+
})
|
|
5492
|
+
]
|
|
5493
|
+
}, p.id);
|
|
5494
|
+
})]
|
|
5495
|
+
}), side ? /* @__PURE__ */ jsxs(Box, {
|
|
5496
|
+
flexDirection: "column",
|
|
5497
|
+
width: side,
|
|
5498
|
+
marginLeft: 1,
|
|
5499
|
+
children: [
|
|
5500
|
+
/* @__PURE__ */ jsx(Heading, {
|
|
5501
|
+
right: `${risks.length} open`,
|
|
5502
|
+
children: "Risks & issues"
|
|
5503
|
+
}),
|
|
5504
|
+
risks.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5505
|
+
color: theme.muted,
|
|
5506
|
+
children: "Nothing open."
|
|
5507
|
+
}) : null,
|
|
5508
|
+
risks.slice(0, 5).map((r) => /* @__PURE__ */ jsxs(Text, {
|
|
5509
|
+
wrap: "truncate-end",
|
|
5510
|
+
children: [
|
|
5511
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5512
|
+
color: r.kind === "issue" ? theme.bad : r.kind === "risk" ? theme.warn : theme.muted,
|
|
5513
|
+
children: r.kind
|
|
5514
|
+
}),
|
|
5515
|
+
" ",
|
|
5516
|
+
r.title
|
|
5517
|
+
]
|
|
5518
|
+
}, r.id)),
|
|
5519
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
5520
|
+
/* @__PURE__ */ jsx(Heading, { children: "Next milestones" }),
|
|
5521
|
+
next.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5522
|
+
color: theme.muted,
|
|
5523
|
+
children: "None to come."
|
|
5524
|
+
}) : null,
|
|
5525
|
+
next.slice(0, 4).map((m) => /* @__PURE__ */ jsxs(Text, {
|
|
5526
|
+
wrap: "truncate-end",
|
|
5527
|
+
children: [
|
|
5528
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5529
|
+
color: m.dueDate < day ? theme.bad : theme.accent,
|
|
5530
|
+
children: "◆"
|
|
5531
|
+
}),
|
|
5532
|
+
" ",
|
|
5533
|
+
m.name,
|
|
5534
|
+
" ",
|
|
5535
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5536
|
+
color: m.dueDate < day ? theme.bad : theme.muted,
|
|
5537
|
+
children: shortDay(m.dueDate, day)
|
|
5538
|
+
})
|
|
5539
|
+
]
|
|
5540
|
+
}, m.id)),
|
|
5541
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
5542
|
+
/* @__PURE__ */ jsx(Heading, { children: "Money" }),
|
|
5543
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5544
|
+
wrap: "truncate-end",
|
|
5545
|
+
children: [
|
|
5546
|
+
money(spent),
|
|
5547
|
+
" ",
|
|
5548
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5549
|
+
color: theme.muted,
|
|
5550
|
+
children: "spent of"
|
|
5551
|
+
}),
|
|
5552
|
+
" ",
|
|
5553
|
+
money(budget),
|
|
5554
|
+
budget > 0 ? /* @__PURE__ */ jsxs(Text, {
|
|
5555
|
+
color: spent > budget ? theme.bad : theme.muted,
|
|
5556
|
+
children: [
|
|
5557
|
+
" ",
|
|
5558
|
+
"(",
|
|
5559
|
+
Math.round(spent / budget * 100),
|
|
5560
|
+
"%)"
|
|
5561
|
+
]
|
|
5562
|
+
}) : null
|
|
5563
|
+
]
|
|
5564
|
+
})
|
|
5565
|
+
]
|
|
5566
|
+
}) : null] })
|
|
5028
5567
|
]
|
|
5029
5568
|
});
|
|
5030
5569
|
}
|
|
@@ -6722,6 +7261,7 @@ function App({ ui }) {
|
|
|
6722
7261
|
["Enter", "open"],
|
|
6723
7262
|
["e", "edit"],
|
|
6724
7263
|
["n", "new"],
|
|
7264
|
+
["v", "layout"],
|
|
6725
7265
|
...page.kind === "program" ? [["m", "milestone"], ["←→", "lists"]] : [],
|
|
6726
7266
|
["Esc", "back"]
|
|
6727
7267
|
] : [
|
package/lib/cli.mjs
CHANGED
|
@@ -29,6 +29,18 @@ import {
|
|
|
29
29
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
30
30
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
31
31
|
import { jsonText, plain } from "./plain.mjs";
|
|
32
|
+
import {
|
|
33
|
+
FETCH_LIMIT,
|
|
34
|
+
SHELLS,
|
|
35
|
+
initSnippet,
|
|
36
|
+
isFresh,
|
|
37
|
+
promptCounts,
|
|
38
|
+
readPromptCache,
|
|
39
|
+
refreshInBackground,
|
|
40
|
+
segment,
|
|
41
|
+
stalePromptCache,
|
|
42
|
+
writePromptCache,
|
|
43
|
+
} from "./prompt.mjs";
|
|
32
44
|
|
|
33
45
|
export const HELP = `harbor: Harbor from the terminal
|
|
34
46
|
|
|
@@ -50,6 +62,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
50
62
|
harbor show TASK one task (TASK: a key like RR-42)
|
|
51
63
|
harbor open [TASK|PROJECT] open it on the website (or Today)
|
|
52
64
|
harbor invite EMAIL [--viewer] invite someone to your harbor
|
|
65
|
+
harbor prompt [--shell SHELL] [--refresh] due today and late, for your
|
|
66
|
+
shell prompt (never waits)
|
|
67
|
+
harbor prompt --init SHELL how to add it: zsh, bash, fish
|
|
68
|
+
or starship
|
|
53
69
|
|
|
54
70
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
55
71
|
[--ends DATE]
|
|
@@ -497,6 +513,59 @@ export async function run(argv, io = {}) {
|
|
|
497
513
|
return 0;
|
|
498
514
|
}
|
|
499
515
|
|
|
516
|
+
if (command === "prompt") {
|
|
517
|
+
const { values, positionals } = parseArgs(rest, {
|
|
518
|
+
flags: ["refresh", "json"],
|
|
519
|
+
options: ["shell", "init", "harbor"],
|
|
520
|
+
});
|
|
521
|
+
none(positionals);
|
|
522
|
+
if (values.init !== undefined) {
|
|
523
|
+
const snippet = initSnippet(values.init);
|
|
524
|
+
if (!snippet) throw new HarborError(`Pick ${SHELLS.join(", ")} (not "${values.init}").`);
|
|
525
|
+
out(
|
|
526
|
+
`${snippet}\n\nYour prompt shows what's due today and late once Harbor has answered,\na moment after the next prompt. harbor prompt --refresh checks now.`,
|
|
527
|
+
);
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
const shell = values.shell;
|
|
531
|
+
if (shell !== undefined && !SHELLS.includes(shell))
|
|
532
|
+
throw new HarborError(`--shell is ${SHELLS.join(", ")} (not "${shell}").`);
|
|
533
|
+
const creds = await readCredentials(env);
|
|
534
|
+
const harbor = values.harbor ?? creds?.harbor ?? null;
|
|
535
|
+
const today = todayLocal(io.now ? new Date(io.now()) : new Date());
|
|
536
|
+
const show = (counts, at) =>
|
|
537
|
+
out(
|
|
538
|
+
values.json
|
|
539
|
+
? jsonText({ ...(counts ?? { today: null, late: null }), at })
|
|
540
|
+
: segment(counts ?? {}, shell),
|
|
541
|
+
);
|
|
542
|
+
if (!creds) {
|
|
543
|
+
if (values.refresh) throw new HarborError("Not signed in. Run harbor login.");
|
|
544
|
+
show(null, null);
|
|
545
|
+
return 0;
|
|
546
|
+
}
|
|
547
|
+
if (values.refresh) {
|
|
548
|
+
const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
|
|
549
|
+
const work = await client.call("my_work", {
|
|
550
|
+
harbor: harbor ?? undefined,
|
|
551
|
+
limit: FETCH_LIMIT,
|
|
552
|
+
});
|
|
553
|
+
const counts = promptCounts(work, today);
|
|
554
|
+
const at = io.now?.() ?? Date.now();
|
|
555
|
+
await writePromptCache({ at, day: today, harbor, counts }, env);
|
|
556
|
+
show(counts, at);
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
// The prompt itself: what's kept, at once, and fresh counts fetched for next time.
|
|
560
|
+
const cache = await readPromptCache(env);
|
|
561
|
+
const now = io.now?.() ?? Date.now();
|
|
562
|
+
if (!isFresh(cache, { now, today, harbor }))
|
|
563
|
+
await (io.refreshPrompt ?? refreshInBackground)(env, now);
|
|
564
|
+
const usable = cache && cache.day === today && (cache.harbor ?? null) === harbor;
|
|
565
|
+
show(usable ? cache.counts : null, usable ? cache.at : null);
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
500
569
|
// A mistyped command says so, signed in or not.
|
|
501
570
|
if (commandHelp(command) === null) {
|
|
502
571
|
throw new HarborError(`No command "${command}". Run harbor help.`);
|
|
@@ -516,6 +585,8 @@ export async function run(argv, io = {}) {
|
|
|
516
585
|
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
517
586
|
throw err;
|
|
518
587
|
});
|
|
588
|
+
// The prompt's counts are behind now; its next draw fetches new ones.
|
|
589
|
+
if (name === "create_task" || name === "update_task") await stalePromptCache(env);
|
|
519
590
|
out(asJson ? jsonText(result) : plain(show(result)));
|
|
520
591
|
return 0;
|
|
521
592
|
};
|
package/lib/prompt.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `harbor prompt`: a few characters for a shell prompt saying what's due
|
|
3
|
+
* today and what's late. A prompt draws on every Enter, so this never waits
|
|
4
|
+
* on the network and never prints an error: it reads counts kept beside the
|
|
5
|
+
* sign-in, and when they're older than a few minutes it starts
|
|
6
|
+
* `harbor prompt --refresh` in the background to fetch new ones for the next
|
|
7
|
+
* prompt. With nothing due and nothing late it prints nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { configDir } from "./config.mjs";
|
|
14
|
+
|
|
15
|
+
export const FRESH_MS = 5 * 60 * 1000;
|
|
16
|
+
/** A refresh started this recently is still running (or failed): don't start another. */
|
|
17
|
+
const TRYING_MS = 60 * 1000;
|
|
18
|
+
export const SHELLS = ["zsh", "bash", "fish", "starship"];
|
|
19
|
+
/** As many tasks as my_work gives; late and due-today ones come first, so they fit. */
|
|
20
|
+
export const FETCH_LIMIT = 50;
|
|
21
|
+
|
|
22
|
+
const cacheFile = (env) => join(configDir(env), "prompt.json");
|
|
23
|
+
const tryingFile = (env) => join(configDir(env), "prompt.trying");
|
|
24
|
+
|
|
25
|
+
/** Due today and late among your open tasks, by this machine's date. */
|
|
26
|
+
export function promptCounts(work, today) {
|
|
27
|
+
const tasks = work?.tasks ?? [];
|
|
28
|
+
return {
|
|
29
|
+
today: tasks.filter((t) => t.due === today).length,
|
|
30
|
+
late: tasks.filter((t) => t.due && t.due < today).length,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 256-colour codes near Harbor's teal, amber and coral.
|
|
35
|
+
const COLOUR = { mark: 30, today: 179, late: 167 };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The segment as `shell` wants it: zsh and bash need colour codes marked as
|
|
39
|
+
* taking no room, or the cursor lands in the wrong place; fish takes them
|
|
40
|
+
* as they are; Starship colours its own modules, so it gets plain text, as
|
|
41
|
+
* does no shell at all.
|
|
42
|
+
*/
|
|
43
|
+
export function segment({ today, late }, shell) {
|
|
44
|
+
if (!today && !late) return "";
|
|
45
|
+
const parts = [];
|
|
46
|
+
if (today) parts.push(["today", `${today} today`]);
|
|
47
|
+
if (late) parts.push(["late", `${late} late`]);
|
|
48
|
+
if (!shell || shell === "starship") return `⚓ ${parts.map(([, s]) => s).join(" · ")}`;
|
|
49
|
+
const hide =
|
|
50
|
+
shell === "zsh" ? (s) => `%{${s}%}` : shell === "bash" ? (s) => `\x01${s}\x02` : (s) => s;
|
|
51
|
+
const paint = (kind, s) => `${hide(`\x1b[38;5;${COLOUR[kind]}m`)}${s}${hide("\x1b[0m")}`;
|
|
52
|
+
return [paint("mark", "⚓"), ...parts.map(([kind, s]) => paint(kind, s))].join(" ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What to put in the shell's startup file. */
|
|
56
|
+
export function initSnippet(shell) {
|
|
57
|
+
switch (shell) {
|
|
58
|
+
case "zsh":
|
|
59
|
+
return [
|
|
60
|
+
"# Add to ~/.zshrc",
|
|
61
|
+
"setopt prompt_subst",
|
|
62
|
+
"RPROMPT='$(harbor prompt --shell zsh)'",
|
|
63
|
+
].join("\n");
|
|
64
|
+
case "bash":
|
|
65
|
+
return ["# Add to ~/.bashrc", "PS1='$(harbor prompt --shell bash) '\"$PS1\""].join("\n");
|
|
66
|
+
case "fish":
|
|
67
|
+
return [
|
|
68
|
+
"# Add to ~/.config/fish/config.fish",
|
|
69
|
+
"function fish_right_prompt",
|
|
70
|
+
" harbor prompt --shell fish",
|
|
71
|
+
"end",
|
|
72
|
+
].join("\n");
|
|
73
|
+
case "starship":
|
|
74
|
+
return [
|
|
75
|
+
"# Add to ~/.config/starship.toml",
|
|
76
|
+
"[custom.harbor]",
|
|
77
|
+
'command = "harbor prompt --shell starship"',
|
|
78
|
+
'when = "true"',
|
|
79
|
+
'style = "bold #c44a2c"',
|
|
80
|
+
'format = "[$output]($style) "',
|
|
81
|
+
].join("\n");
|
|
82
|
+
default:
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The kept counts, or null when there are none. */
|
|
88
|
+
export async function readPromptCache(env = process.env) {
|
|
89
|
+
try {
|
|
90
|
+
const c = JSON.parse(await readFile(cacheFile(env), "utf8"));
|
|
91
|
+
return typeof c?.at === "number" && c.counts ? c : null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function writePromptCache(cache, env = process.env) {
|
|
98
|
+
await mkdir(configDir(env), { recursive: true, mode: 0o700 });
|
|
99
|
+
const temp = `${cacheFile(env)}.${process.pid}.tmp`;
|
|
100
|
+
await writeFile(temp, JSON.stringify(cache));
|
|
101
|
+
await rename(temp, cacheFile(env));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Counts saved for one harbor and one day only: a new day, or `harbor use`
|
|
106
|
+
* another harbor, makes them stale even if they were fetched a minute ago.
|
|
107
|
+
*/
|
|
108
|
+
export function isFresh(cache, { now, today, harbor }) {
|
|
109
|
+
return Boolean(
|
|
110
|
+
cache &&
|
|
111
|
+
now - cache.at < FRESH_MS &&
|
|
112
|
+
cache.day === today &&
|
|
113
|
+
(cache.harbor ?? null) === (harbor ?? null),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Marks the kept counts stale, after a command changed tasks. */
|
|
118
|
+
export async function stalePromptCache(env = process.env) {
|
|
119
|
+
const cache = await readPromptCache(env);
|
|
120
|
+
if (cache) await writePromptCache({ ...cache, at: 0 }, env).catch(() => {});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Starts `harbor prompt --refresh` on its own, unless one started a moment ago. */
|
|
124
|
+
export async function refreshInBackground(env = process.env, now = Date.now()) {
|
|
125
|
+
try {
|
|
126
|
+
const tried = await stat(tryingFile(env)).catch(() => null);
|
|
127
|
+
if (tried && now - tried.mtimeMs < TRYING_MS) return false;
|
|
128
|
+
await mkdir(configDir(env), { recursive: true, mode: 0o700 });
|
|
129
|
+
await writeFile(tryingFile(env), "");
|
|
130
|
+
const bin = fileURLToPath(new URL("../bin/harbor.mjs", import.meta.url));
|
|
131
|
+
spawn(process.execPath, [bin, "prompt", "--refresh"], {
|
|
132
|
+
stdio: "ignore",
|
|
133
|
+
detached: true,
|
|
134
|
+
env: { ...env, HARBOR_NO_UPDATE_CHECK: "1" },
|
|
135
|
+
}).unref();
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|