@harborppm/cli 0.22.0 → 0.24.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
@@ -92,6 +92,8 @@ harbor harbors the harbors you belong to
92
92
  harbor use HARBOR|none the harbor to work in when you're in several
93
93
  harbor programs programs and their projects
94
94
  harbor projects [words] [--program P] projects with health and open work
95
+ harbor forecast [PROJECT] [--program P] when projects will likely finish, from
96
+ how fast their crews finish tasks
95
97
  harbor mine what's on you, late first
96
98
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
97
99
  harbor show TASK one task in full, with its web link
package/dist/app.mjs CHANGED
@@ -1591,6 +1591,132 @@ function forecastAtCompletion(item, now) {
1591
1591
  };
1592
1592
  }
1593
1593
 
1594
+ //#endregion
1595
+ //#region src/lib/tide.ts
1596
+ /**
1597
+ * The tide forecast: when a project will likely finish, from how fast its
1598
+ * crew has actually closed tasks. Each of a thousand runs replays the coming
1599
+ * weeks by drawing at random from the project's recent weekly counts, until
1600
+ * the open tasks run out; the spread of finish days is the forecast. No
1601
+ * estimates or story points: only tasks and when they were finished.
1602
+ *
1603
+ * Seeded from the project and the day, so the same project reads the same
1604
+ * on the server and the client and doesn't jitter between renders.
1605
+ */
1606
+ /** How many weeks back the crew's pace is read from. */
1607
+ const TIDE_WEEKS = 6;
1608
+ const TRIALS = 1e3;
1609
+ /** A run that hasn't finished in two years is counted as not finishing. */
1610
+ const MAX_WEEKS = 104;
1611
+ /** Fewer finished tasks than this and the forecast says it's rough. */
1612
+ const ROUGH_BELOW = 5;
1613
+ const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
1614
+ /** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
1615
+ function weeklyFinished(tasks, startDate, now, weeks = 6) {
1616
+ const today = noon(todayISO(now));
1617
+ const age = Math.floor(differenceInCalendarDays(today, noon(startDate)) / 7) + 1;
1618
+ const span = Math.max(1, Math.min(weeks, age));
1619
+ const counts = new Array(span).fill(0);
1620
+ for (const t of tasks) {
1621
+ if (t.status !== "done") continue;
1622
+ const at = t.completedAt ? noon(t.completedAt) : today;
1623
+ const back = Math.max(0, differenceInCalendarDays(today, at));
1624
+ const week = Math.floor(back / 7);
1625
+ if (week < span) counts[span - 1 - week] += 1;
1626
+ }
1627
+ return counts;
1628
+ }
1629
+ function hash(text) {
1630
+ let h = 2166136261;
1631
+ for (let i = 0; i < text.length; i++) {
1632
+ h ^= text.charCodeAt(i);
1633
+ h = Math.imul(h, 16777619);
1634
+ }
1635
+ return h >>> 0;
1636
+ }
1637
+ /** mulberry32: small, fast, and the same everywhere for the same seed. */
1638
+ function random(seed) {
1639
+ let a = seed;
1640
+ return () => {
1641
+ a = a + 1831565813 | 0;
1642
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
1643
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
1644
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1645
+ };
1646
+ }
1647
+ /** Days from today to finishing, one per run; Infinity for a run that never does. */
1648
+ function simulate(remaining, weekly, seed) {
1649
+ const next = random(seed);
1650
+ const out = [];
1651
+ for (let run = 0; run < TRIALS; run++) {
1652
+ let left = remaining;
1653
+ let days = Infinity;
1654
+ for (let week = 0; week < MAX_WEEKS; week++) {
1655
+ const closed = weekly[Math.floor(next() * weekly.length)];
1656
+ if (closed >= left) {
1657
+ const landed = Array.from({ length: closed }, () => 1 + Math.floor(next() * 7));
1658
+ landed.sort((a, b) => a - b);
1659
+ days = week * 7 + landed[left - 1];
1660
+ break;
1661
+ }
1662
+ left -= closed;
1663
+ }
1664
+ out.push(days);
1665
+ }
1666
+ return out.sort((a, b) => a - b);
1667
+ }
1668
+ function tideForecast(project, tasks, now) {
1669
+ const remaining = tasks.filter((t) => t.status !== "done").length;
1670
+ if (remaining === 0) return { kind: "done" };
1671
+ if (project.health === "paused") return {
1672
+ kind: "paused",
1673
+ remaining
1674
+ };
1675
+ const weekly = weeklyFinished(tasks, project.startDate, now);
1676
+ const finished = weekly.reduce((a, b) => a + b, 0);
1677
+ if (finished === 0) return {
1678
+ kind: "no-pace",
1679
+ remaining
1680
+ };
1681
+ const today = todayISO(now);
1682
+ const runs = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`));
1683
+ const base = noon(today);
1684
+ const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
1685
+ const at = (share) => runs[Math.min(runs.length - 1, Math.ceil(share * runs.length) - 1)];
1686
+ const toEnd = differenceInCalendarDays(noon(project.endDate), base);
1687
+ const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
1688
+ const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
1689
+ const curve = [];
1690
+ const step = Math.max(1, Math.ceil(last / 60));
1691
+ let i = 0;
1692
+ for (let d = 0; d <= last; d += step) {
1693
+ while (i < runs.length && runs[i] <= d) i++;
1694
+ curve.push({
1695
+ date: dayOf(d),
1696
+ chance: i / runs.length
1697
+ });
1698
+ }
1699
+ return {
1700
+ kind: "forecast",
1701
+ remaining,
1702
+ finished,
1703
+ weeks: weekly.length,
1704
+ perWeek: Math.round(finished / weekly.length * 10) / 10,
1705
+ likely: dayOf(at(.5)),
1706
+ veryLikely: dayOf(at(.85)),
1707
+ almostCertain: dayOf(at(.95)),
1708
+ chanceByEnd: Math.round(byEnd * 100) / 100,
1709
+ curve,
1710
+ rough: finished < ROUGH_BELOW
1711
+ };
1712
+ }
1713
+ /** "15%", with "under 5%" and "over 95%" at the ends, where a thousand runs can't be that sure. */
1714
+ function chanceText(chance) {
1715
+ if (chance < .05) return "under 5%";
1716
+ if (chance > .95) return "over 95%";
1717
+ return `${Math.round(chance * 20) * 5}%`;
1718
+ }
1719
+
1594
1720
  //#endregion
1595
1721
  //#region src/lib/flow.ts
1596
1722
  const WINDOW_DAYS = 30;
@@ -2338,6 +2464,29 @@ function filterTasks(tasks, f) {
2338
2464
  return tasks.filter((t) => (f.who === "any" || (f.who === "none" ? t.assigneeId === null : t.assigneeId === f.who)) && (f.priority === "any" || t.priority === f.priority));
2339
2465
  }
2340
2466
  /**
2467
+ * When the project will likely finish at its crew's recent pace, in one
2468
+ * line under its name, and whether that misses the end date. Empty when
2469
+ * there's no forecast (all done, paused, or nothing finished lately).
2470
+ */
2471
+ function tideLine(data, project, today) {
2472
+ const tide = tideForecast(project, tasksOf(data.tasks, project.id), parseIso(`${today}T12:00:00`));
2473
+ if (tide.kind === "no-pace") return {
2474
+ text: "No forecast yet: nothing finished in the last few weeks",
2475
+ late: false
2476
+ };
2477
+ if (tide.kind !== "forecast") return null;
2478
+ if (!tide.likely) return {
2479
+ text: "Not done in the next two years at this pace",
2480
+ late: true
2481
+ };
2482
+ const very = tide.veryLikely ? `, very likely by ${shortDay(tide.veryLikely, today)}` : "";
2483
+ const end = project.endDate >= today ? ` · ${chanceText(tide.chanceByEnd)} by its end, ${shortDay(project.endDate, today)}` : "";
2484
+ return {
2485
+ text: `Probably done ${shortDay(tide.likely, today)}${very}${end}${tide.rough ? " · rough" : ""}`,
2486
+ late: tide.chanceByEnd < .5
2487
+ };
2488
+ }
2489
+ /**
2341
2490
  * Bands of lines, each line owned by one item (a program or a project):
2342
2491
  * the first line to show so the selected band fits, scrolling as little
2343
2492
  * as it can from the top.
@@ -7086,6 +7235,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7086
7235
  })
7087
7236
  });
7088
7237
  const { done, total } = projectSummary(data, project);
7238
+ const tide = tideLine(data, project, day);
7089
7239
  const filtered = [filter.who === "none" ? "nobody's" : filter.who !== "any" ? `${people.find((p) => p.id === filter.who)?.name ?? "someone"}'s` : null, filter.priority !== "any" ? PRIORITY_LABEL[filter.priority].toLowerCase() : null].filter(Boolean);
7090
7240
  const lead = people.find((p) => p.id === project.ownerId);
7091
7241
  const body = height - 3;
@@ -7150,8 +7300,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7150
7300
  })]
7151
7301
  }),
7152
7302
  /* @__PURE__ */ jsx(Text, {
7153
- color: theme.muted,
7154
- children: " "
7303
+ color: tide?.late ? theme.warn : theme.muted,
7304
+ wrap: "truncate-end",
7305
+ children: tide ? tide.text : " "
7155
7306
  }),
7156
7307
  mode === "budget" ? /* @__PURE__ */ jsx(BudgetPane, {
7157
7308
  project,
package/lib/cli.mjs CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  writeSettings,
18
18
  } from "./config.mjs";
19
19
  import {
20
+ forecastList,
21
+ forecastOne,
20
22
  STATUS,
21
23
  harbors,
22
24
  limitsText,
@@ -61,6 +63,7 @@ export const HELP = `harbor: Harbor from the terminal
61
63
  harbor projects [words] [--program P] [--done]
62
64
  projects with health and open work
63
65
  (--json adds each board's limits)
66
+ harbor forecast [PROJECT] [--program P] when projects will likely finish
64
67
  harbor mine what's on you, late first
65
68
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
66
69
  [--late] [--status S,S]
@@ -825,6 +828,12 @@ export async function run(argv, io = {}) {
825
828
  `Opened the project ${r.created.name} in ${r.created.program} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}). Add work with harbor add "TITLE" --project "${r.created.name}".`,
826
829
  );
827
830
  }
831
+ case "forecast": {
832
+ const { positionals, values, harbor } = parse(spec([], ["program"]));
833
+ const project = positionals.join(" ").trim();
834
+ if (project) return call("get_project", { harbor, project }, forecastOne);
835
+ return call("list_projects", { harbor, program: values.program, limit: 100 }, forecastList);
836
+ }
828
837
  case "mine": {
829
838
  const { positionals, harbor } = parse(spec());
830
839
  none(positionals);
package/lib/format.mjs CHANGED
@@ -199,6 +199,55 @@ export function raidList(r, words = "") {
199
199
  return lines.join("\n") + more;
200
200
  }
201
201
 
202
+ /** "over 95%", in steps of five, as the website says it (src/lib/tide.ts chanceText). */
203
+ function chance(c) {
204
+ if (c < 0.05) return "under 5%";
205
+ if (c > 0.95) return "over 95%";
206
+ return `${Math.round(c * 20) * 5}%`;
207
+ }
208
+
209
+ function finishRange(f) {
210
+ if (!f.likely) return "not in the next two years at this pace";
211
+ return `probably ${f.likely}${f.veryLikely ? `, very likely by ${f.veryLikely}` : ""}`;
212
+ }
213
+
214
+ /** When each open project will likely finish at its crew's recent pace. */
215
+ export function forecastList(r) {
216
+ const projects = (r.projects ?? []).filter((p) => p.health !== "done");
217
+ if (!projects.length) return "No open projects match.";
218
+ const width = Math.min(32, Math.max(...projects.map((p) => p.name.length)));
219
+ return projects
220
+ .map((p) => {
221
+ const f = p.finish ?? {};
222
+ const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
223
+ if (f.none) return `${name} no forecast: ${f.none.replace(/^\w/, (c) => c.toLowerCase())}`;
224
+ const end = p.ends ? ` · ${chance(f.chanceByEnd)} by its end, ${p.ends}` : "";
225
+ return `${name} ${finishRange(f)}${end}${f.rough ? " · rough" : ""}`;
226
+ })
227
+ .join("\n");
228
+ }
229
+
230
+ /** One project's forecast, in a few sentences. */
231
+ export function forecastOne(p) {
232
+ const f = p.finish ?? {};
233
+ if (f.none) return `${p.name}: ${f.none}`;
234
+ const range = finishRange(f);
235
+ const lines = [
236
+ f.likely
237
+ ? `${p.name} will ${range.replace(/^probably/, "probably finish")}${f.almostCertain ? `, almost certainly by ${f.almostCertain}` : ""}.`
238
+ : `${p.name} won't finish in the next two years at this pace.`,
239
+ ];
240
+ if (p.ends) {
241
+ const c = chance(f.chanceByEnd);
242
+ lines.push(`${c[0].toUpperCase()}${c.slice(1)} chance of making its end date, ${p.ends}.`);
243
+ }
244
+ lines.push(
245
+ `${f.openTasks} task${f.openTasks === 1 ? "" : "s"} open; the crew finishes about ${f.finishedPerWeek} a week (${f.basedOn}).`,
246
+ );
247
+ if (f.rough) lines.push("Only a few tasks have finished, so treat this as rough.");
248
+ return lines.join("\n");
249
+ }
250
+
202
251
  /** A project's pre-mortem, as get_premortem answers it. */
203
252
  export function premortemText(r) {
204
253
  if (!r.running) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.22.0",
3
+ "version": "0.24.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",