@harborppm/cli 0.22.0 → 0.28.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 +5 -0
- package/dist/app.mjs +222 -4
- package/lib/cli.mjs +42 -0
- package/lib/format.mjs +83 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,11 @@ 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
|
|
97
|
+
harbor hill [--program P] where each project is on its hill
|
|
98
|
+
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
|
|
99
|
+
past 50 is getting it done
|
|
95
100
|
harbor mine what's on you, late first
|
|
96
101
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
97
102
|
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.
|
|
@@ -3913,6 +4062,48 @@ function CalendarView({ active, width, height, go }) {
|
|
|
3913
4062
|
});
|
|
3914
4063
|
}
|
|
3915
4064
|
|
|
4065
|
+
//#endregion
|
|
4066
|
+
//#region src/lib/hill.ts
|
|
4067
|
+
/**
|
|
4068
|
+
* Hill charts, after Basecamp's Shape Up: each project is a dot on a hill.
|
|
4069
|
+
* Climbing the left side means still figuring out how to do it; rolling down
|
|
4070
|
+
* the right means the unknowns are solved and it's just the work. Owners
|
|
4071
|
+
* place their own dot, and a dot nobody has moved in two weeks fades, so a
|
|
4072
|
+
* stuck project shows without anyone saying so.
|
|
4073
|
+
*/
|
|
4074
|
+
/** Where the top of the hill is, on the 0–100 scale. */
|
|
4075
|
+
const HILL_TOP = 50;
|
|
4076
|
+
/** Days without a move before a dot reads as still. */
|
|
4077
|
+
const STILL_DAYS = 14;
|
|
4078
|
+
const HILL_PHASE_LABEL = {
|
|
4079
|
+
figuring: "Figuring it out",
|
|
4080
|
+
doing: "Getting it done"
|
|
4081
|
+
};
|
|
4082
|
+
/** A position kept on the scale, in whole steps. */
|
|
4083
|
+
function clampHill(at) {
|
|
4084
|
+
if (!Number.isFinite(at)) return 0;
|
|
4085
|
+
return Math.max(0, Math.min(100, Math.round(at)));
|
|
4086
|
+
}
|
|
4087
|
+
function hillPhase(at) {
|
|
4088
|
+
return clampHill(at) < 50 ? "figuring" : "doing";
|
|
4089
|
+
}
|
|
4090
|
+
/** Days since the dot last moved, or null when it's never been placed. */
|
|
4091
|
+
function stillFor(project, today) {
|
|
4092
|
+
if (!project.hillMovedAt) return null;
|
|
4093
|
+
return Math.max(0, differenceInCalendarDays(/* @__PURE__ */ new Date(`${today}T12:00:00`), /* @__PURE__ */ new Date(`${project.hillMovedAt.slice(0, 10)}T12:00:00`)));
|
|
4094
|
+
}
|
|
4095
|
+
function isStill(project, today) {
|
|
4096
|
+
const days = stillFor(project, today);
|
|
4097
|
+
return days != null && days >= 14;
|
|
4098
|
+
}
|
|
4099
|
+
/** "Figuring it out, 40 of 100", or "Not on the hill yet". */
|
|
4100
|
+
function hillText(project) {
|
|
4101
|
+
if (project.hillAt == null) return "Not on the hill yet";
|
|
4102
|
+
const at = clampHill(project.hillAt);
|
|
4103
|
+
if (at === 100) return "Over the hill: done";
|
|
4104
|
+
return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
|
|
4105
|
+
}
|
|
4106
|
+
|
|
3916
4107
|
//#endregion
|
|
3917
4108
|
//#region src/lib/history-copy.ts
|
|
3918
4109
|
/**
|
|
@@ -4082,6 +4273,10 @@ function describeEvent(e, names) {
|
|
|
4082
4273
|
action: "cleared the health override on",
|
|
4083
4274
|
detail: null
|
|
4084
4275
|
};
|
|
4276
|
+
case "hillAt": return {
|
|
4277
|
+
action: "moved",
|
|
4278
|
+
detail: `on the hill to ${hillText({ hillAt: to == null ? null : Number(to) }).toLowerCase()}`
|
|
4279
|
+
};
|
|
4085
4280
|
case "programId": return {
|
|
4086
4281
|
action: "moved",
|
|
4087
4282
|
detail: `to ${nameOf$1(names, to, "another program")}`
|
|
@@ -5326,7 +5521,7 @@ const HELP = [
|
|
|
5326
5521
|
["Enter / Esc", "open / back"],
|
|
5327
5522
|
["n", "new: a task, or what the screen lists"],
|
|
5328
5523
|
["e", "change what's open (health, lead, dates)"],
|
|
5329
|
-
["[ ]", "move a card
|
|
5524
|
+
["[ ] / { }", "move a card a column / a project along its hill"],
|
|
5330
5525
|
["x / d", "mark done / delete (asks first)"],
|
|
5331
5526
|
["u / r", "undo your last change / retry saving"],
|
|
5332
5527
|
["v", "switch layout: board, list, budget, by project"],
|
|
@@ -7056,6 +7251,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7056
7251
|
else if (input === "d" && entry) go.confirm(`Take out ${money(entry.amount)}${entry.note ? ` for “${entry.note}”` : ""}? u undoes it.`, () => store.getState().removeSpend(entry.id), "Take it out");
|
|
7057
7252
|
return;
|
|
7058
7253
|
}
|
|
7254
|
+
if ((input === "{" || input === "}") && project) {
|
|
7255
|
+
const at = project.hillAt ?? (input === "}" ? -5 : 0);
|
|
7256
|
+
return store.getState().moveOnHill(projectId, at + (input === "}" ? 5 : -5));
|
|
7257
|
+
}
|
|
7059
7258
|
if (input === "[") return moveSelected(-1);
|
|
7060
7259
|
if (input === "]") return moveSelected(1);
|
|
7061
7260
|
if (key.return && selected) return go.openTask(selected.id);
|
|
@@ -7086,6 +7285,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7086
7285
|
})
|
|
7087
7286
|
});
|
|
7088
7287
|
const { done, total } = projectSummary(data, project);
|
|
7288
|
+
const tide = tideLine(data, project, day);
|
|
7089
7289
|
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
7290
|
const lead = people.find((p) => p.id === project.ownerId);
|
|
7091
7291
|
const body = height - 3;
|
|
@@ -7149,9 +7349,19 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7149
7349
|
})
|
|
7150
7350
|
})]
|
|
7151
7351
|
}),
|
|
7152
|
-
/* @__PURE__ */
|
|
7153
|
-
|
|
7154
|
-
children:
|
|
7352
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
7353
|
+
wrap: "truncate-end",
|
|
7354
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
7355
|
+
color: tide?.late ? theme.warn : theme.muted,
|
|
7356
|
+
children: tide ? `${tide.text} · ` : ""
|
|
7357
|
+
}), /* @__PURE__ */ jsxs(Text, {
|
|
7358
|
+
color: isStill(project, day) ? theme.warn : theme.muted,
|
|
7359
|
+
children: [
|
|
7360
|
+
project.hillAt == null ? "Not on the hill · } puts it on" : `Hill: ${hillText(project).toLowerCase()}`,
|
|
7361
|
+
isStill(project, day) ? `, still ${stillFor(project, day)} days` : "",
|
|
7362
|
+
project.hillAt == null ? "" : " · { } move it"
|
|
7363
|
+
]
|
|
7364
|
+
})]
|
|
7155
7365
|
}),
|
|
7156
7366
|
mode === "budget" ? /* @__PURE__ */ jsx(BudgetPane, {
|
|
7157
7367
|
project,
|
|
@@ -9623,6 +9833,14 @@ function hierarchySlice({ get, write }) {
|
|
|
9623
9833
|
...patch
|
|
9624
9834
|
} : p) });
|
|
9625
9835
|
},
|
|
9836
|
+
moveOnHill: (id, at) => {
|
|
9837
|
+
const hillAt = clampHill(at);
|
|
9838
|
+
write({ projects: get().projects.map((p) => p.id === id ? {
|
|
9839
|
+
...p,
|
|
9840
|
+
hillAt,
|
|
9841
|
+
hillMovedAt: stamp()
|
|
9842
|
+
} : p) });
|
|
9843
|
+
},
|
|
9626
9844
|
addCustomField: (projectId, def) => {
|
|
9627
9845
|
const id = uid("cf");
|
|
9628
9846
|
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
package/lib/cli.mjs
CHANGED
|
@@ -17,9 +17,12 @@ 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,
|
|
25
|
+
hillList,
|
|
23
26
|
myWork,
|
|
24
27
|
premortemText,
|
|
25
28
|
voyageText,
|
|
@@ -61,6 +64,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
61
64
|
harbor projects [words] [--program P] [--done]
|
|
62
65
|
projects with health and open work
|
|
63
66
|
(--json adds each board's limits)
|
|
67
|
+
harbor forecast [PROJECT] [--program P] when projects will likely finish
|
|
68
|
+
harbor hill [--program P] where each project is on its hill
|
|
69
|
+
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
|
|
70
|
+
out, past 50 is getting it done
|
|
64
71
|
harbor mine what's on you, late first
|
|
65
72
|
harbor tasks [words] [--project P] [--assignee me|none|NAME]
|
|
66
73
|
[--late] [--status S,S]
|
|
@@ -220,6 +227,7 @@ const FLAG = {
|
|
|
220
227
|
body: "the comment",
|
|
221
228
|
column: "the column",
|
|
222
229
|
limit: "the limit",
|
|
230
|
+
at: "--at",
|
|
223
231
|
};
|
|
224
232
|
|
|
225
233
|
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
@@ -825,6 +833,33 @@ export async function run(argv, io = {}) {
|
|
|
825
833
|
`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
834
|
);
|
|
827
835
|
}
|
|
836
|
+
case "forecast": {
|
|
837
|
+
const { positionals, values, harbor } = parse(spec([], ["program"]));
|
|
838
|
+
const project = positionals.join(" ").trim();
|
|
839
|
+
if (project) return call("get_project", { harbor, project }, forecastOne);
|
|
840
|
+
return call("list_projects", { harbor, program: values.program, limit: 100 }, forecastList);
|
|
841
|
+
}
|
|
842
|
+
case "hill": {
|
|
843
|
+
const { positionals, values, harbor } = parse(spec([], ["program", "at"]));
|
|
844
|
+
const project = positionals.join(" ").trim();
|
|
845
|
+
if (!project) {
|
|
846
|
+
if (values.at !== undefined)
|
|
847
|
+
throw new HarborError("Say which project: harbor hill PROJECT --at N.");
|
|
848
|
+
return call("list_projects", { harbor, program: values.program, limit: 100 }, hillList);
|
|
849
|
+
}
|
|
850
|
+
if (values.at === undefined) {
|
|
851
|
+
throw new HarborError("Say where with --at: 0 to 100, or start, top, doing or done.");
|
|
852
|
+
}
|
|
853
|
+
const at = readHillWord(values.at);
|
|
854
|
+
if (at === null)
|
|
855
|
+
throw new HarborError("--at is 0 to 100, or start, figuring, top, doing or done.");
|
|
856
|
+
return call(
|
|
857
|
+
"move_on_hill",
|
|
858
|
+
{ harbor, project, at },
|
|
859
|
+
(r) =>
|
|
860
|
+
`Moved ${r.moved.project} on the hill: ${r.moved.where.replace(/^\w/, (c) => c.toLowerCase())}.`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
828
863
|
case "mine": {
|
|
829
864
|
const { positionals, harbor } = parse(spec());
|
|
830
865
|
none(positionals);
|
|
@@ -1214,3 +1249,10 @@ export async function run(argv, io = {}) {
|
|
|
1214
1249
|
throw new HarborError(`No command "${command}". Run harbor help.`);
|
|
1215
1250
|
}
|
|
1216
1251
|
}
|
|
1252
|
+
|
|
1253
|
+
/** --at for harbor hill: a number 0–100 or a word (src/lib/hill.ts readHill says the same). */
|
|
1254
|
+
function readHillWord(text) {
|
|
1255
|
+
const t = String(text).trim().toLowerCase();
|
|
1256
|
+
if (/^\d{1,3}$/.test(t)) return Number(t) <= 100 ? Number(t) : null;
|
|
1257
|
+
return { start: 0, figuring: 25, top: 50, doing: 75, done: 100 }[t] ?? null;
|
|
1258
|
+
}
|
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) {
|
|
@@ -238,3 +287,37 @@ export function voyageText(r) {
|
|
|
238
287
|
"\n",
|
|
239
288
|
);
|
|
240
289
|
}
|
|
290
|
+
|
|
291
|
+
/** A position on a 21-wide track, the top of the hill marked in the middle. */
|
|
292
|
+
function hillTrack(at) {
|
|
293
|
+
const cells = Array.from({ length: 21 }, (_, i) => (i === 10 ? "┼" : "─"));
|
|
294
|
+
cells[Math.round(at / 5)] = "●";
|
|
295
|
+
return cells.join("");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Each open project's place on its program's hill, by program, unplaced ones named after. */
|
|
299
|
+
export function hillList(r) {
|
|
300
|
+
const projects = (r.projects ?? []).filter((p) => p.health !== "done");
|
|
301
|
+
if (!projects.length) return "No open projects match.";
|
|
302
|
+
const byProgram = new Map();
|
|
303
|
+
for (const p of projects) {
|
|
304
|
+
const key = p.program ?? "No program";
|
|
305
|
+
if (!byProgram.has(key)) byProgram.set(key, []);
|
|
306
|
+
byProgram.get(key).push(p);
|
|
307
|
+
}
|
|
308
|
+
const width = Math.min(28, Math.max(...projects.map((p) => p.name.length)));
|
|
309
|
+
const blocks = [];
|
|
310
|
+
for (const [program, list] of byProgram) {
|
|
311
|
+
const placed = list.filter((p) => p.hill).sort((a, b) => a.hill.at - b.hill.at);
|
|
312
|
+
const unplaced = list.filter((p) => !p.hill);
|
|
313
|
+
const lines = [program, ` ${"".padEnd(width)} figuring it out ┼ getting it done`];
|
|
314
|
+
for (const p of placed) {
|
|
315
|
+
const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
|
|
316
|
+
const still = p.hill.daysSinceMoved >= 14 ? ` still ${p.hill.daysSinceMoved} days` : "";
|
|
317
|
+
lines.push(` ${name} ${hillTrack(p.hill.at)} ${String(p.hill.at).padStart(3)}${still}`);
|
|
318
|
+
}
|
|
319
|
+
if (unplaced.length) lines.push(` Not on the hill: ${unplaced.map((p) => p.name).join(", ")}`);
|
|
320
|
+
blocks.push(lines.join("\n"));
|
|
321
|
+
}
|
|
322
|
+
return blocks.join("\n\n");
|
|
323
|
+
}
|