@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 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
- const notice = argv.includes("--json")
10
- ? Promise.resolve(null)
11
- : updateNotice({ version: VERSION }).catch(() => null);
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 (key.leftArrow || input === "h") return setSide("projects");
4771
- if (key.rightArrow || input === "l") return setSide("milestones");
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(Box, { children: [/* @__PURE__ */ jsxs(Box, {
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
- /** One portfolio: its health and its programs, each with theirs; Done ones wait for D. */
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__ */ jsx(Text, {
5437
+ /* @__PURE__ */ jsxs(Text, {
4985
5438
  color: theme.muted,
4986
5439
  wrap: "truncate-end",
4987
- children: portfolio.theme || "Portfolio"
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__ */ jsx(Heading, {
4991
- right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
4992
- children: "Programs"
4993
- }),
4994
- list.length === 0 ? /* @__PURE__ */ jsx(Text, {
4995
- color: theme.muted,
4996
- children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
4997
- }) : null,
4998
- windowOf(list, at, height - 5).items.map((p) => {
4999
- const on = active && p.id === program?.id;
5000
- const count = projects.filter((x) => x.programId === p.id).length;
5001
- return /* @__PURE__ */ jsxs(Text, {
5002
- color: on ? theme.selected : void 0,
5003
- bold: on,
5004
- wrap: "truncate-end",
5005
- children: [
5006
- on ? "› " : " ",
5007
- /* @__PURE__ */ jsx(Text, {
5008
- color: HEALTH_COLOR[p.health],
5009
- children: "●"
5010
- }),
5011
- " ",
5012
- p.name,
5013
- " ",
5014
- /* @__PURE__ */ jsxs(Text, {
5015
- color: theme.muted,
5016
- children: [
5017
- HEALTH_LABEL[p.health],
5018
- " · ",
5019
- count,
5020
- " project",
5021
- count === 1 ? "" : "s",
5022
- p.nextMove ? ` · next: ${p.nextMove}` : ""
5023
- ]
5024
- })
5025
- ]
5026
- }, p.id);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",