@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 +2 -0
- package/dist/app.mjs +153 -2
- package/lib/cli.mjs +9 -0
- package/lib/format.mjs +49 -0
- package/package.json +1 -1
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
|
-
|
|
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) {
|