@harborppm/cli 0.11.2 → 0.12.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 +8 -4
- package/dist/app.mjs +418 -81
- package/lib/cli.mjs +60 -7
- package/lib/format.mjs +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,16 +26,16 @@ harbor # name your harbor and its first project, and you're in
|
|
|
26
26
|
|
|
27
27
|
`harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
|
|
28
28
|
|
|
29
|
-
1. **Today**: what's on you, late first; who's waiting on you; your risks and issues.
|
|
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
|
-
3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
|
|
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
32
|
4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health.
|
|
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.
|
|
36
36
|
8. **History**: who changed what, for a portfolio, program or project (`s` picks which).
|
|
37
37
|
|
|
38
|
-
A task changes in place too: title, status, owner, due date (`2026-10-14`, `today`, `+3`), priority, project, notes, checklist steps, comments.
|
|
38
|
+
A task changes in place too: title, status, owner, due date (`2026-10-14`, `today`, `+3`), priority, project, what it waits on (type a key or words), how it repeats, notes, checklist steps, comments (`C` changes or takes out one of yours).
|
|
39
39
|
|
|
40
40
|
Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes back, `:` finds anything (a screen, "new project", a project or program by name), `/` finds a task by its key or words, `n` makes something new, `e` changes what's open, `[` `]` move a card a column, `x` marks it done, `d` deletes (it asks first), `u` undoes your last change, `H` switches harbor, `?` lists them all.
|
|
41
41
|
|
|
@@ -94,13 +94,17 @@ harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
|
94
94
|
[--priority low|medium|high|urgent] [--status S] [--over-limit]
|
|
95
95
|
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
96
96
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
97
|
-
[--priority P] [--status S] [--
|
|
97
|
+
[--priority P] [--status S] [--notes TEXT]
|
|
98
|
+
[--repeat weekly|biweekly|monthly|never]
|
|
99
|
+
[--waits-on TASK,TASK|none] [--confirm] [--over-limit]
|
|
98
100
|
harbor comment TASK "TEXT"
|
|
99
101
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
100
102
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
101
103
|
[--owner NAME] [--due DATE]
|
|
102
104
|
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
103
105
|
[--due DATE]
|
|
106
|
+
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
107
|
+
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
104
108
|
```
|
|
105
109
|
|
|
106
110
|
Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
|
package/dist/app.mjs
CHANGED
|
@@ -2088,6 +2088,14 @@ function forecastLine(f) {
|
|
|
2088
2088
|
const side = f.variance >= 0 ? "under" : "over";
|
|
2089
2089
|
return `At this rate it ends at ${money(Math.round(f.eac))} (${money(gap)} ${side} budget)`;
|
|
2090
2090
|
}
|
|
2091
|
+
const NO_FILTER = {
|
|
2092
|
+
who: "any",
|
|
2093
|
+
priority: "any"
|
|
2094
|
+
};
|
|
2095
|
+
function filterTasks(tasks, f) {
|
|
2096
|
+
if (f.who === "any" && f.priority === "any") return tasks;
|
|
2097
|
+
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
|
+
}
|
|
2091
2099
|
|
|
2092
2100
|
//#endregion
|
|
2093
2101
|
//#region src/tui/theme.ts
|
|
@@ -3908,11 +3916,9 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
|
|
|
3908
3916
|
});
|
|
3909
3917
|
}
|
|
3910
3918
|
const HELP = [
|
|
3911
|
-
["1–8", "the screens
|
|
3919
|
+
["1–8 / Tab", "the screens / between the sidebar and a screen"],
|
|
3912
3920
|
[":", "anything: a screen, something new, a name"],
|
|
3913
|
-
["/", "find a task by key or words"],
|
|
3914
|
-
["H", "switch harbor"],
|
|
3915
|
-
["Tab", "between the sidebar and the screen"],
|
|
3921
|
+
["/ / H", "find a task by key or words / switch harbor"],
|
|
3916
3922
|
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
3917
3923
|
["Enter / Esc", "open / back"],
|
|
3918
3924
|
["n", "new: a task, or what the screen lists"],
|
|
@@ -3923,7 +3929,9 @@ const HELP = [
|
|
|
3923
3929
|
["v", "on a project: board, list, budget"],
|
|
3924
3930
|
["m / R", "new milestone on a program / log a risk"],
|
|
3925
3931
|
["D", "show or hide Done projects and programs"],
|
|
3926
|
-
["c / a", "on a task: comment / add a
|
|
3932
|
+
["c / a / C", "on a task: comment / add a step / change yours"],
|
|
3933
|
+
["f / c", "on Today: focus on a task / check in"],
|
|
3934
|
+
["f / p", "on a board: filter by owner / priority"],
|
|
3927
3935
|
["q", "quit (saves first)"]
|
|
3928
3936
|
];
|
|
3929
3937
|
/**
|
|
@@ -4132,7 +4140,7 @@ function healthField(kind, item, go, set, clear) {
|
|
|
4132
4140
|
}
|
|
4133
4141
|
};
|
|
4134
4142
|
}
|
|
4135
|
-
function ProgramPanel({ id, width, height, go, onClose }) {
|
|
4143
|
+
function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
4136
4144
|
const { store, today } = useDeps();
|
|
4137
4145
|
const program = useHarbor((s) => s.programs.find((p) => p.id === id));
|
|
4138
4146
|
const people = useHarbor((s) => s.people);
|
|
@@ -4228,10 +4236,11 @@ function ProgramPanel({ id, width, height, go, onClose }) {
|
|
|
4228
4236
|
onClose();
|
|
4229
4237
|
}, "Delete");
|
|
4230
4238
|
} },
|
|
4239
|
+
active,
|
|
4231
4240
|
onClose
|
|
4232
4241
|
});
|
|
4233
4242
|
}
|
|
4234
|
-
function PortfolioPanel({ id, width, height, go, onClose }) {
|
|
4243
|
+
function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
4235
4244
|
const { store } = useDeps();
|
|
4236
4245
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
|
|
4237
4246
|
const people = useHarbor((s) => s.people);
|
|
@@ -4295,10 +4304,11 @@ function PortfolioPanel({ id, width, height, go, onClose }) {
|
|
|
4295
4304
|
onClose();
|
|
4296
4305
|
}, "Delete");
|
|
4297
4306
|
} },
|
|
4307
|
+
active,
|
|
4298
4308
|
onClose
|
|
4299
4309
|
});
|
|
4300
4310
|
}
|
|
4301
|
-
function ProjectPanel({ id, width, height, go, onClose }) {
|
|
4311
|
+
function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
4302
4312
|
const { store, today } = useDeps();
|
|
4303
4313
|
const project = useHarbor((s) => s.projects.find((p) => p.id === id));
|
|
4304
4314
|
const people = useHarbor((s) => s.people);
|
|
@@ -4408,10 +4418,11 @@ function ProjectPanel({ id, width, height, go, onClose }) {
|
|
|
4408
4418
|
onClose();
|
|
4409
4419
|
}, "Delete");
|
|
4410
4420
|
} },
|
|
4421
|
+
active,
|
|
4411
4422
|
onClose
|
|
4412
4423
|
});
|
|
4413
4424
|
}
|
|
4414
|
-
function MilestonePanel({ id, width, height, go, onClose }) {
|
|
4425
|
+
function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
4415
4426
|
const { store, today } = useDeps();
|
|
4416
4427
|
const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
|
|
4417
4428
|
const people = useHarbor((s) => s.people);
|
|
@@ -4482,10 +4493,11 @@ function MilestonePanel({ id, width, height, go, onClose }) {
|
|
|
4482
4493
|
onClose();
|
|
4483
4494
|
}, "Delete")
|
|
4484
4495
|
},
|
|
4496
|
+
active,
|
|
4485
4497
|
onClose
|
|
4486
4498
|
});
|
|
4487
4499
|
}
|
|
4488
|
-
function RaidPanel({ id, width, height, go, onClose }) {
|
|
4500
|
+
function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
4489
4501
|
const { store, today } = useDeps();
|
|
4490
4502
|
const item = useHarbor((s) => s.raidItems.find((r) => r.id === id));
|
|
4491
4503
|
const people = useHarbor((s) => s.people);
|
|
@@ -4632,10 +4644,11 @@ function RaidPanel({ id, width, height, go, onClose }) {
|
|
|
4632
4644
|
onClose();
|
|
4633
4645
|
}, "Delete")
|
|
4634
4646
|
},
|
|
4647
|
+
active,
|
|
4635
4648
|
onClose
|
|
4636
4649
|
});
|
|
4637
4650
|
}
|
|
4638
|
-
function PersonPanel({ id, width, height, go, onClose }) {
|
|
4651
|
+
function PersonPanel({ id, width, height, go, active, onClose }) {
|
|
4639
4652
|
const { store } = useDeps();
|
|
4640
4653
|
const person = useHarbor((s) => s.people.find((p) => p.id === id));
|
|
4641
4654
|
const programs = useHarbor((s) => s.programs);
|
|
@@ -4719,6 +4732,7 @@ function PersonPanel({ id, width, height, go, onClose }) {
|
|
|
4719
4732
|
]
|
|
4720
4733
|
}, a.programId))
|
|
4721
4734
|
] }) : null,
|
|
4735
|
+
active,
|
|
4722
4736
|
onClose
|
|
4723
4737
|
});
|
|
4724
4738
|
}
|
|
@@ -5036,8 +5050,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5036
5050
|
people,
|
|
5037
5051
|
boardConfigs
|
|
5038
5052
|
]);
|
|
5039
|
-
const
|
|
5040
|
-
const
|
|
5053
|
+
const [filter, setFilter] = useState(NO_FILTER);
|
|
5054
|
+
const columns = useMemo(() => boardOf(data, projectId).map((c) => ({
|
|
5055
|
+
...c,
|
|
5056
|
+
all: c.tasks.length,
|
|
5057
|
+
tasks: filterTasks(c.tasks, filter)
|
|
5058
|
+
})), [
|
|
5059
|
+
data,
|
|
5060
|
+
projectId,
|
|
5061
|
+
filter
|
|
5062
|
+
]);
|
|
5063
|
+
const list = useMemo(() => filterTasks(projectTasks(data, projectId), filter), [
|
|
5064
|
+
data,
|
|
5065
|
+
projectId,
|
|
5066
|
+
filter
|
|
5067
|
+
]);
|
|
5041
5068
|
const [mode, setMode] = useState("board");
|
|
5042
5069
|
const [spendAt, setSpendAt] = useState(0);
|
|
5043
5070
|
const spendEntries = useHarbor((s) => s.spendEntries) ?? [];
|
|
@@ -5069,6 +5096,38 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5069
5096
|
if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
|
|
5070
5097
|
if (input === "e") return go.openPanel("project", projectId);
|
|
5071
5098
|
if (input === "R") return go.create("raid", { projectId });
|
|
5099
|
+
if (input === "f" && mode !== "budget") return go.pick("Show cards assigned to", [
|
|
5100
|
+
{
|
|
5101
|
+
label: "Anyone",
|
|
5102
|
+
value: "any"
|
|
5103
|
+
},
|
|
5104
|
+
{
|
|
5105
|
+
label: "Me",
|
|
5106
|
+
value: store.getState().currentUserId
|
|
5107
|
+
},
|
|
5108
|
+
{
|
|
5109
|
+
label: "Nobody yet",
|
|
5110
|
+
value: "none"
|
|
5111
|
+
},
|
|
5112
|
+
...people.map((p) => ({
|
|
5113
|
+
label: p.name,
|
|
5114
|
+
value: p.id,
|
|
5115
|
+
hint: p.role
|
|
5116
|
+
}))
|
|
5117
|
+
], (who) => setFilter((f) => ({
|
|
5118
|
+
...f,
|
|
5119
|
+
who
|
|
5120
|
+
})));
|
|
5121
|
+
if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
|
|
5122
|
+
label: "Any priority",
|
|
5123
|
+
value: "any"
|
|
5124
|
+
}, ...PRIORITIES.map((p) => ({
|
|
5125
|
+
label: PRIORITY_LABEL[p],
|
|
5126
|
+
value: p
|
|
5127
|
+
}))], (priority) => setFilter((f) => ({
|
|
5128
|
+
...f,
|
|
5129
|
+
priority
|
|
5130
|
+
})));
|
|
5072
5131
|
if (mode === "budget") {
|
|
5073
5132
|
const entries = project ? budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date()).entries : [];
|
|
5074
5133
|
const entry = entries[Math.min(spendAt, entries.length - 1)];
|
|
@@ -5108,6 +5167,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5108
5167
|
})
|
|
5109
5168
|
});
|
|
5110
5169
|
const { done, total } = projectSummary(data, project);
|
|
5170
|
+
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);
|
|
5111
5171
|
const lead = people.find((p) => p.id === project.ownerId);
|
|
5112
5172
|
const body = height - 3;
|
|
5113
5173
|
const fit = Math.max(1, Math.floor((width - 2) / 14));
|
|
@@ -5156,7 +5216,16 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5156
5216
|
lead ? ` · ${lead.name}` : "",
|
|
5157
5217
|
" · ",
|
|
5158
5218
|
mode,
|
|
5159
|
-
"
|
|
5219
|
+
filtered.length && mode !== "budget" ? /* @__PURE__ */ jsxs(Text, {
|
|
5220
|
+
color: theme.warn,
|
|
5221
|
+
children: [
|
|
5222
|
+
" · only ",
|
|
5223
|
+
filtered.join(", "),
|
|
5224
|
+
" cards"
|
|
5225
|
+
]
|
|
5226
|
+
}) : null,
|
|
5227
|
+
" ",
|
|
5228
|
+
"· v next · f p filter · e details"
|
|
5160
5229
|
]
|
|
5161
5230
|
})
|
|
5162
5231
|
})]
|
|
@@ -5180,7 +5249,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5180
5249
|
const on = active && ci === col;
|
|
5181
5250
|
const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
|
|
5182
5251
|
const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
|
|
5183
|
-
const over = c.limit != null && c.
|
|
5252
|
+
const over = c.limit != null && c.all > c.limit;
|
|
5184
5253
|
return /* @__PURE__ */ jsxs(Box, {
|
|
5185
5254
|
flexDirection: "column",
|
|
5186
5255
|
width: w,
|
|
@@ -5205,7 +5274,11 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5205
5274
|
" ",
|
|
5206
5275
|
/* @__PURE__ */ jsxs(Text, {
|
|
5207
5276
|
color: over ? theme.bad : theme.muted,
|
|
5208
|
-
children: [
|
|
5277
|
+
children: [
|
|
5278
|
+
c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
|
|
5279
|
+
c.all,
|
|
5280
|
+
c.limit != null ? `/${c.limit}` : ""
|
|
5281
|
+
]
|
|
5209
5282
|
})
|
|
5210
5283
|
]
|
|
5211
5284
|
})
|
|
@@ -5497,6 +5570,78 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
5497
5570
|
});
|
|
5498
5571
|
}
|
|
5499
5572
|
|
|
5573
|
+
//#endregion
|
|
5574
|
+
//#region src/lib/deps.ts
|
|
5575
|
+
/**
|
|
5576
|
+
* Everything that waits on `id`, directly or through others: the items that
|
|
5577
|
+
* would close a loop if `id` waited on them. One walk backwards from `id`,
|
|
5578
|
+
* instead of a walk forwards from every candidate.
|
|
5579
|
+
*/
|
|
5580
|
+
function waitingOnChain(items, id) {
|
|
5581
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
5582
|
+
for (const n of items) for (const b of blockerIds(n)) {
|
|
5583
|
+
const list = waiters.get(b);
|
|
5584
|
+
if (list) list.push(n.id);
|
|
5585
|
+
else waiters.set(b, [n.id]);
|
|
5586
|
+
}
|
|
5587
|
+
const out = /* @__PURE__ */ new Set();
|
|
5588
|
+
const stack = [...waiters.get(id) ?? []];
|
|
5589
|
+
while (stack.length) {
|
|
5590
|
+
const cursor = stack.pop();
|
|
5591
|
+
if (out.has(cursor)) continue;
|
|
5592
|
+
out.add(cursor);
|
|
5593
|
+
stack.push(...waiters.get(cursor) ?? []);
|
|
5594
|
+
}
|
|
5595
|
+
return out;
|
|
5596
|
+
}
|
|
5597
|
+
/** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
|
|
5598
|
+
function blockerOptions(items, id) {
|
|
5599
|
+
const loops = waitingOnChain(items, id);
|
|
5600
|
+
return items.filter((n) => n.id !== id && !loops.has(n.id));
|
|
5601
|
+
}
|
|
5602
|
+
|
|
5603
|
+
//#endregion
|
|
5604
|
+
//#region src/lib/recurrence.ts
|
|
5605
|
+
const REPEAT_LABEL = {
|
|
5606
|
+
weekly: "Every week",
|
|
5607
|
+
biweekly: "Every two weeks",
|
|
5608
|
+
monthly: "Every month"
|
|
5609
|
+
};
|
|
5610
|
+
const iso$1 = (d) => {
|
|
5611
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
5612
|
+
};
|
|
5613
|
+
/** The due date one interval after `from`. */
|
|
5614
|
+
function nextDueDate(from, repeat) {
|
|
5615
|
+
const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
|
|
5616
|
+
if (repeat === "weekly") return iso$1(addDays(d, 7));
|
|
5617
|
+
if (repeat === "biweekly") return iso$1(addDays(d, 14));
|
|
5618
|
+
return iso$1(addMonths(d, 1));
|
|
5619
|
+
}
|
|
5620
|
+
/**
|
|
5621
|
+
* The next occurrence of a repeating task once this one is done: same
|
|
5622
|
+
* title, owner, priority and checklist (unticked), due one interval after
|
|
5623
|
+
* the old due date (or after today when it had none), back in To do.
|
|
5624
|
+
* Null when the task does not repeat.
|
|
5625
|
+
*/
|
|
5626
|
+
function nextOccurrence(task, id, today, createdAt) {
|
|
5627
|
+
if (!task.repeat) return null;
|
|
5628
|
+
const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
|
|
5629
|
+
return {
|
|
5630
|
+
...task,
|
|
5631
|
+
id,
|
|
5632
|
+
status: "todo",
|
|
5633
|
+
dueDate: nextDueDate(anchor, task.repeat),
|
|
5634
|
+
createdAt,
|
|
5635
|
+
completedAt: null,
|
|
5636
|
+
blockedById: null,
|
|
5637
|
+
blockedByIds: [],
|
|
5638
|
+
checklist: (task.checklist ?? []).map((c) => ({
|
|
5639
|
+
...c,
|
|
5640
|
+
done: false
|
|
5641
|
+
}))
|
|
5642
|
+
};
|
|
5643
|
+
}
|
|
5644
|
+
|
|
5500
5645
|
//#endregion
|
|
5501
5646
|
//#region src/tui/views/task.tsx
|
|
5502
5647
|
const FIELDS = [
|
|
@@ -5524,19 +5669,34 @@ const FIELDS = [
|
|
|
5524
5669
|
field: "project",
|
|
5525
5670
|
label: "Project"
|
|
5526
5671
|
},
|
|
5672
|
+
{
|
|
5673
|
+
field: "waits",
|
|
5674
|
+
label: "Waits on"
|
|
5675
|
+
},
|
|
5676
|
+
{
|
|
5677
|
+
field: "repeat",
|
|
5678
|
+
label: "Repeats"
|
|
5679
|
+
},
|
|
5527
5680
|
{
|
|
5528
5681
|
field: "notes",
|
|
5529
5682
|
label: "Notes"
|
|
5530
5683
|
}
|
|
5531
5684
|
];
|
|
5685
|
+
const REPEATS = [
|
|
5686
|
+
null,
|
|
5687
|
+
"weekly",
|
|
5688
|
+
"biweekly",
|
|
5689
|
+
"monthly"
|
|
5690
|
+
];
|
|
5532
5691
|
/** Everything about one task, every field editable: ↑↓ to a field, Enter to change it. */
|
|
5533
|
-
function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
5692
|
+
function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
5534
5693
|
const { store, today } = useDeps();
|
|
5535
5694
|
const tasks = useHarbor((s) => s.tasks);
|
|
5536
5695
|
const projects = useHarbor((s) => s.projects);
|
|
5537
5696
|
const people = useHarbor((s) => s.people);
|
|
5538
5697
|
const comments = useHarbor((s) => s.comments);
|
|
5539
5698
|
const viewOnly = useHarbor((s) => s.myRole === "viewer" || s.keepPaused);
|
|
5699
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
5540
5700
|
const task = tasks.find((t) => t.id === taskId);
|
|
5541
5701
|
const [at, setAt] = useState(0);
|
|
5542
5702
|
const [editing, setEditing] = useState(null);
|
|
@@ -5549,6 +5709,10 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5549
5709
|
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows));
|
|
5550
5710
|
if (input === "c") return setEditing({ kind: "comment" });
|
|
5551
5711
|
if (input === "a") return setEditing({ kind: "step" });
|
|
5712
|
+
if (input === "C") {
|
|
5713
|
+
if (comments.some((c) => c.taskId === task.id && c.authorId === me)) return setEditing({ kind: "mine" });
|
|
5714
|
+
return go.notify("You haven't commented on this task.");
|
|
5715
|
+
}
|
|
5552
5716
|
if (input === "x") return moveTask(store, go, task, "done");
|
|
5553
5717
|
if (input === "d") return go.confirm(`Delete ${keyOf({ projects }, task)} “${task.title}”? u undoes it.`, () => {
|
|
5554
5718
|
store.getState().deleteTask(task.id);
|
|
@@ -5561,12 +5725,14 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5561
5725
|
kind: "field",
|
|
5562
5726
|
field: FIELDS[at].field
|
|
5563
5727
|
});
|
|
5564
|
-
}, { isActive: editing === null });
|
|
5728
|
+
}, { isActive: active && editing === null });
|
|
5565
5729
|
if (!task) return null;
|
|
5566
5730
|
const project = projects.find((p) => p.id === task.projectId);
|
|
5567
5731
|
const owner = people.find((p) => p.id === task.assigneeId);
|
|
5568
5732
|
const waits = openBlockerNames(task, tasks);
|
|
5569
5733
|
const thread = comments.filter((c) => c.taskId === task.id).slice(-4);
|
|
5734
|
+
const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
|
|
5735
|
+
const blockers = blockersOf(task, tasks);
|
|
5570
5736
|
const done = () => setEditing(null);
|
|
5571
5737
|
const value = {
|
|
5572
5738
|
title: task.title,
|
|
@@ -5575,6 +5741,8 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5575
5741
|
due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
|
|
5576
5742
|
priority: PRIORITY_LABEL[task.priority],
|
|
5577
5743
|
project: project ? `${project.name}${project.key ? ` (${project.key})` : ""}` : "",
|
|
5744
|
+
waits: blockers.length ? blockers.map((b) => `${keyOf({ projects }, b)}${b.status === "done" ? " (done)" : ""}`).join(", ") : "Nothing",
|
|
5745
|
+
repeat: task.repeat ? REPEAT_LABEL[task.repeat] : "Never",
|
|
5578
5746
|
notes: task.description.replace(/\s+/g, " ").trim() || "None"
|
|
5579
5747
|
};
|
|
5580
5748
|
let editor = null;
|
|
@@ -5595,7 +5763,44 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5595
5763
|
done();
|
|
5596
5764
|
}
|
|
5597
5765
|
});
|
|
5598
|
-
else if (editing?.kind === "
|
|
5766
|
+
else if (editing?.kind === "mine") editor = /* @__PURE__ */ jsx(Picker, {
|
|
5767
|
+
title: "One of your comments: Enter to change it or take it out",
|
|
5768
|
+
choices: mine.map((c) => ({
|
|
5769
|
+
label: c.body.replace(/\s+/g, " "),
|
|
5770
|
+
value: c.id
|
|
5771
|
+
})),
|
|
5772
|
+
initial: mine.length - 1,
|
|
5773
|
+
onCancel: done,
|
|
5774
|
+
onPick: (id) => {
|
|
5775
|
+
const c = mine.find((x) => x.id === id);
|
|
5776
|
+
setEditing(null);
|
|
5777
|
+
go.pick("This comment", [{
|
|
5778
|
+
label: "Change the words",
|
|
5779
|
+
value: "edit"
|
|
5780
|
+
}, {
|
|
5781
|
+
label: "Take it out",
|
|
5782
|
+
value: "delete"
|
|
5783
|
+
}], (what) => {
|
|
5784
|
+
if (what === "edit") setEditing({
|
|
5785
|
+
kind: "reword",
|
|
5786
|
+
id: c.id
|
|
5787
|
+
});
|
|
5788
|
+
else go.confirm("Take out your comment? u undoes it.", () => store.getState().deleteComment(c.id), "Take it out");
|
|
5789
|
+
});
|
|
5790
|
+
}
|
|
5791
|
+
});
|
|
5792
|
+
else if (editing?.kind === "reword") {
|
|
5793
|
+
const c = comments.find((x) => x.id === editing.id);
|
|
5794
|
+
editor = /* @__PURE__ */ jsx(TextField, {
|
|
5795
|
+
label: "Your comment",
|
|
5796
|
+
initial: c?.body ?? "",
|
|
5797
|
+
onCancel: done,
|
|
5798
|
+
onSubmit: (text) => {
|
|
5799
|
+
if (c && text.trim() && text.trim() !== c.body) store.getState().updateComment(c.id, text.trim());
|
|
5800
|
+
done();
|
|
5801
|
+
}
|
|
5802
|
+
});
|
|
5803
|
+
} else if (editing?.kind === "field") {
|
|
5599
5804
|
const f = editing.field;
|
|
5600
5805
|
if (f === "title" || f === "notes") editor = /* @__PURE__ */ jsx(TextField, {
|
|
5601
5806
|
label: f === "title" ? "Title" : "Notes",
|
|
@@ -5663,7 +5868,24 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5663
5868
|
done();
|
|
5664
5869
|
}
|
|
5665
5870
|
});
|
|
5666
|
-
} else if (f === "
|
|
5871
|
+
} else if (f === "repeat") editor = /* @__PURE__ */ jsx(Picker, {
|
|
5872
|
+
title: "Repeats (finishing it queues the next one)",
|
|
5873
|
+
choices: REPEATS.map((r) => ({
|
|
5874
|
+
label: r ? REPEAT_LABEL[r] : "Never",
|
|
5875
|
+
value: r
|
|
5876
|
+
})),
|
|
5877
|
+
initial: REPEATS.indexOf(task.repeat ?? null),
|
|
5878
|
+
onCancel: done,
|
|
5879
|
+
onPick: (r) => {
|
|
5880
|
+
store.getState().updateTask(task.id, { repeat: r });
|
|
5881
|
+
done();
|
|
5882
|
+
}
|
|
5883
|
+
});
|
|
5884
|
+
else if (f === "waits") editor = /* @__PURE__ */ jsx(WaitsEditor, {
|
|
5885
|
+
task,
|
|
5886
|
+
onDone: done
|
|
5887
|
+
});
|
|
5888
|
+
else if (f === "project") {
|
|
5667
5889
|
const choices = projects.map((p) => ({
|
|
5668
5890
|
label: p.name,
|
|
5669
5891
|
value: p.id,
|
|
@@ -5796,7 +6018,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5796
6018
|
editor ?? /* @__PURE__ */ jsx(Text, {
|
|
5797
6019
|
color: theme.muted,
|
|
5798
6020
|
wrap: "truncate-end",
|
|
5799
|
-
children: "↑↓ field · Enter change · c comment · a add step · space tick · x done · d delete · Esc close"
|
|
6021
|
+
children: "↑↓ field · Enter change · c comment · C yours · a add step · space tick · x done · d delete · Esc close"
|
|
5800
6022
|
})
|
|
5801
6023
|
]
|
|
5802
6024
|
});
|
|
@@ -5819,16 +6041,140 @@ function DueField({ initial, today, onSubmit, onCancel }) {
|
|
|
5819
6041
|
})
|
|
5820
6042
|
});
|
|
5821
6043
|
}
|
|
6044
|
+
/**
|
|
6045
|
+
* What a task waits on. Empty, it lists the current ones (Enter stops
|
|
6046
|
+
* waiting on one); typing finds others by key or words (Enter adds it).
|
|
6047
|
+
* Tasks already waiting on this one aren't offered, so nothing loops.
|
|
6048
|
+
*/
|
|
6049
|
+
function WaitsEditor({ task, onDone }) {
|
|
6050
|
+
const { store } = useDeps();
|
|
6051
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
6052
|
+
const projects = useHarbor((s) => s.projects);
|
|
6053
|
+
const [query, setQuery] = useState("");
|
|
6054
|
+
const [at, setAt] = useState(0);
|
|
6055
|
+
const current = blockersOf(task, tasks);
|
|
6056
|
+
const hits = useMemo(() => {
|
|
6057
|
+
if (!query.trim()) return [];
|
|
6058
|
+
const taken = new Set(current.map((b) => b.id));
|
|
6059
|
+
const open = blockerOptions(tasks, task.id).filter((t) => !taken.has(t.id));
|
|
6060
|
+
return searchTasks(open, projects, query.trim(), 8).map((h) => h.item);
|
|
6061
|
+
}, [
|
|
6062
|
+
query,
|
|
6063
|
+
tasks,
|
|
6064
|
+
projects,
|
|
6065
|
+
task.id,
|
|
6066
|
+
current
|
|
6067
|
+
]);
|
|
6068
|
+
const list = query.trim() ? hits : current;
|
|
6069
|
+
const set = (ids) => store.getState().updateTask(task.id, blockersPatch(ids));
|
|
6070
|
+
useInput((input, key) => {
|
|
6071
|
+
if (key.escape) return onDone();
|
|
6072
|
+
if (key.upArrow) return setAt((i) => step(i, -1, list.length));
|
|
6073
|
+
if (key.downArrow) return setAt((i) => step(i, 1, list.length));
|
|
6074
|
+
if (key.return) {
|
|
6075
|
+
const pick = list[Math.min(at, list.length - 1)];
|
|
6076
|
+
if (!pick) return onDone();
|
|
6077
|
+
if (query.trim()) {
|
|
6078
|
+
set([...current.map((b) => b.id), pick.id]);
|
|
6079
|
+
setQuery("");
|
|
6080
|
+
} else set(current.filter((b) => b.id !== pick.id).map((b) => b.id));
|
|
6081
|
+
return setAt(0);
|
|
6082
|
+
}
|
|
6083
|
+
if (key.backspace || key.delete) {
|
|
6084
|
+
setAt(0);
|
|
6085
|
+
return setQuery((q) => q.slice(0, -1));
|
|
6086
|
+
}
|
|
6087
|
+
if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
|
|
6088
|
+
if (input) {
|
|
6089
|
+
setAt(0);
|
|
6090
|
+
setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
|
|
6091
|
+
}
|
|
6092
|
+
});
|
|
6093
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
6094
|
+
flexDirection: "column",
|
|
6095
|
+
borderStyle: "round",
|
|
6096
|
+
borderColor: theme.border,
|
|
6097
|
+
paddingX: 1,
|
|
6098
|
+
children: [
|
|
6099
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6100
|
+
bold: true,
|
|
6101
|
+
children: "Waits on"
|
|
6102
|
+
}),
|
|
6103
|
+
/* @__PURE__ */ jsxs(Text, { children: [query || /* @__PURE__ */ jsx(Text, {
|
|
6104
|
+
color: theme.muted,
|
|
6105
|
+
children: "type a key like RR-42, or words, to add one"
|
|
6106
|
+
}), /* @__PURE__ */ jsx(Text, {
|
|
6107
|
+
inverse: true,
|
|
6108
|
+
children: " "
|
|
6109
|
+
})] }),
|
|
6110
|
+
list.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
6111
|
+
color: theme.muted,
|
|
6112
|
+
children: query.trim() ? "Nothing matches." : "Nothing yet."
|
|
6113
|
+
}) : null,
|
|
6114
|
+
list.map((t, i) => {
|
|
6115
|
+
const on = i === Math.min(at, list.length - 1);
|
|
6116
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
6117
|
+
color: on ? theme.selected : void 0,
|
|
6118
|
+
bold: on,
|
|
6119
|
+
wrap: "truncate-end",
|
|
6120
|
+
children: [
|
|
6121
|
+
on ? "› " : " ",
|
|
6122
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6123
|
+
color: theme.muted,
|
|
6124
|
+
children: keyOf({ projects }, t).padEnd(7)
|
|
6125
|
+
}),
|
|
6126
|
+
t.title,
|
|
6127
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
6128
|
+
color: theme.muted,
|
|
6129
|
+
children: [" · ", STATUS_LABEL[t.status]]
|
|
6130
|
+
})
|
|
6131
|
+
]
|
|
6132
|
+
}, t.id);
|
|
6133
|
+
}),
|
|
6134
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
6135
|
+
color: theme.muted,
|
|
6136
|
+
children: [query.trim() ? "Enter adds it" : "Enter stops waiting on it", " · Esc done"]
|
|
6137
|
+
})
|
|
6138
|
+
]
|
|
6139
|
+
});
|
|
6140
|
+
}
|
|
6141
|
+
|
|
6142
|
+
//#endregion
|
|
6143
|
+
//#region src/lib/focus.ts
|
|
6144
|
+
const FOCUS_CAP = 3;
|
|
6145
|
+
const CHECK_IN_QUESTION = "On the blocker, or on something else?";
|
|
6146
|
+
function focusIdsOf(data, personId, date = todayISO()) {
|
|
6147
|
+
return (data.ropes ?? []).find((r) => r.personId === personId && r.date === date)?.taskIds ?? [];
|
|
6148
|
+
}
|
|
6149
|
+
function upsertFocus(ropes, personId, date, taskIds) {
|
|
6150
|
+
const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
|
|
6151
|
+
if (taskIds.length === 0) return rest;
|
|
6152
|
+
return [...rest, {
|
|
6153
|
+
personId,
|
|
6154
|
+
date,
|
|
6155
|
+
taskIds: taskIds.slice(0, 3)
|
|
6156
|
+
}];
|
|
6157
|
+
}
|
|
6158
|
+
function myCheckIn(data, personId, date = todayISO()) {
|
|
6159
|
+
return (data.checkIns ?? []).find((c) => c.personId === personId && c.date === date);
|
|
6160
|
+
}
|
|
5822
6161
|
|
|
5823
6162
|
//#endregion
|
|
5824
6163
|
//#region src/tui/views/today.tsx
|
|
5825
|
-
/**
|
|
6164
|
+
/**
|
|
6165
|
+
* What's on you: today's focus and check-in, your open tasks, late first;
|
|
6166
|
+
* who waits on you; your risks and issues.
|
|
6167
|
+
*/
|
|
5826
6168
|
function TodayView({ active, width, height, go }) {
|
|
5827
6169
|
const { store, today } = useDeps();
|
|
5828
6170
|
const tasks = useHarbor((s) => s.tasks);
|
|
5829
6171
|
const projects = useHarbor((s) => s.projects);
|
|
5830
6172
|
const raidItems = useHarbor((s) => s.raidItems);
|
|
5831
6173
|
const me = useHarbor((s) => s.currentUserId);
|
|
6174
|
+
const ropes = useHarbor((s) => s.ropes);
|
|
6175
|
+
const checkIns = useHarbor((s) => s.checkIns);
|
|
6176
|
+
const focus = focusIdsOf({ ropes }, me).map((id) => tasks.find((t) => t.id === id)).filter((t) => t !== void 0 && t.status !== "done");
|
|
6177
|
+
const said = myCheckIn({ checkIns }, me);
|
|
5832
6178
|
const day = today();
|
|
5833
6179
|
const data = useMemo(() => ({
|
|
5834
6180
|
tasks,
|
|
@@ -5857,8 +6203,24 @@ function TodayView({ active, width, height, go }) {
|
|
|
5857
6203
|
else if (key.return && selected) go.openTask(selected.id);
|
|
5858
6204
|
else if (input === "x" && selected) moveTask(store, go, selected, "done");
|
|
5859
6205
|
else if (input === "n") go.newTask(selected?.projectId ?? null);
|
|
6206
|
+
else if (input === "f" && selected) {
|
|
6207
|
+
if (store.getState().toggleFocus(selected.id) === "full") go.notify(`Today's focus holds ${3}. f on one of them takes it off first.`);
|
|
6208
|
+
} else if (input === "c") go.ask("Check in: what are you on today? (one sentence)", (text) => {
|
|
6209
|
+
const note = text.trim().slice(0, 280);
|
|
6210
|
+
if (!note) return;
|
|
6211
|
+
go.pick(CHECK_IN_QUESTION, [{
|
|
6212
|
+
label: "On the blocker",
|
|
6213
|
+
value: true
|
|
6214
|
+
}, {
|
|
6215
|
+
label: "Other work",
|
|
6216
|
+
value: false
|
|
6217
|
+
}], (onDrum) => {
|
|
6218
|
+
store.getState().submitCheckIn(note, onDrum === true);
|
|
6219
|
+
go.notify("Checked in for today.");
|
|
6220
|
+
});
|
|
6221
|
+
}, said?.body);
|
|
5860
6222
|
}, { isActive: active });
|
|
5861
|
-
const extra = (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
|
|
6223
|
+
const extra = (focus.length ? focus.length + 2 : 0) + 1 + (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
|
|
5862
6224
|
const rows = Math.max(3, height - 2 - extra);
|
|
5863
6225
|
const view = windowOf(mine, at, rows);
|
|
5864
6226
|
return /* @__PURE__ */ jsxs(Box, {
|
|
@@ -5867,8 +6229,32 @@ function TodayView({ active, width, height, go }) {
|
|
|
5867
6229
|
height,
|
|
5868
6230
|
paddingX: 1,
|
|
5869
6231
|
children: [
|
|
6232
|
+
focus.length > 0 && /* @__PURE__ */ jsxs(Box, {
|
|
6233
|
+
flexDirection: "column",
|
|
6234
|
+
marginBottom: 1,
|
|
6235
|
+
children: [/* @__PURE__ */ jsx(Heading, {
|
|
6236
|
+
right: `${focus.length} of ${3} · f takes one off`,
|
|
6237
|
+
children: "Today's focus"
|
|
6238
|
+
}), focus.map((t) => /* @__PURE__ */ jsxs(Text, {
|
|
6239
|
+
wrap: "truncate-end",
|
|
6240
|
+
children: [
|
|
6241
|
+
" ",
|
|
6242
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6243
|
+
color: theme.accent,
|
|
6244
|
+
children: keyOf({ projects }, t)
|
|
6245
|
+
}),
|
|
6246
|
+
" ",
|
|
6247
|
+
t.title
|
|
6248
|
+
]
|
|
6249
|
+
}, t.id))]
|
|
6250
|
+
}),
|
|
6251
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6252
|
+
color: theme.muted,
|
|
6253
|
+
wrap: "truncate-end",
|
|
6254
|
+
children: said ? `Your check-in: ${said.body}${said.onDrum ? " (on the blocker)" : ""} · c changes it` : "No check-in today · c checks in"
|
|
6255
|
+
}),
|
|
5870
6256
|
/* @__PURE__ */ jsx(Heading, {
|
|
5871
|
-
right: `${mine.length} open${late ? ` · ${late} late` : ""}`,
|
|
6257
|
+
right: `${mine.length} open${late ? ` · ${late} late` : ""} · f focus`,
|
|
5872
6258
|
children: "On you"
|
|
5873
6259
|
}),
|
|
5874
6260
|
mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
@@ -6288,6 +6674,7 @@ function App({ ui }) {
|
|
|
6288
6674
|
width: mainWidth,
|
|
6289
6675
|
height: bodyHeight,
|
|
6290
6676
|
go,
|
|
6677
|
+
active: !covered,
|
|
6291
6678
|
onClose: () => setPanel(null)
|
|
6292
6679
|
};
|
|
6293
6680
|
let panelNode = null;
|
|
@@ -6327,6 +6714,7 @@ function App({ ui }) {
|
|
|
6327
6714
|
["x", "done"],
|
|
6328
6715
|
["d", "delete"],
|
|
6329
6716
|
["v", "board/list/budget"],
|
|
6717
|
+
["f p", "filter"],
|
|
6330
6718
|
["e", "details"],
|
|
6331
6719
|
["Esc", "back"]
|
|
6332
6720
|
] : page ? [
|
|
@@ -6453,9 +6841,8 @@ function App({ ui }) {
|
|
|
6453
6841
|
display: overlayNode || panelNode ? "none" : "flex",
|
|
6454
6842
|
children: screenNode
|
|
6455
6843
|
}),
|
|
6456
|
-
panelNode
|
|
6457
|
-
|
|
6458
|
-
display: "none",
|
|
6844
|
+
panelNode ? /* @__PURE__ */ jsx(Box, {
|
|
6845
|
+
display: overlayNode ? "none" : "flex",
|
|
6459
6846
|
children: panelNode
|
|
6460
6847
|
}) : null
|
|
6461
6848
|
]
|
|
@@ -6493,7 +6880,7 @@ function recordHealth(history, health, at, override, note) {
|
|
|
6493
6880
|
|
|
6494
6881
|
//#endregion
|
|
6495
6882
|
//#region src/lib/derived-health.ts
|
|
6496
|
-
const iso
|
|
6883
|
+
const iso = (d) => {
|
|
6497
6884
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
6498
6885
|
};
|
|
6499
6886
|
function parseDate(value) {
|
|
@@ -6536,7 +6923,7 @@ let overdueDay = "";
|
|
|
6536
6923
|
const overdueByDue = /* @__PURE__ */ new Map();
|
|
6537
6924
|
function daysOverdue(dueDate, now) {
|
|
6538
6925
|
if (!dueDate) return 0;
|
|
6539
|
-
const today = iso
|
|
6926
|
+
const today = iso(now);
|
|
6540
6927
|
if (today !== overdueDay) {
|
|
6541
6928
|
overdueDay = today;
|
|
6542
6929
|
overdueByDue.clear();
|
|
@@ -6557,7 +6944,7 @@ let untilDay = "";
|
|
|
6557
6944
|
const untilByDate = /* @__PURE__ */ new Map();
|
|
6558
6945
|
function daysUntil(date, now) {
|
|
6559
6946
|
if (!date) return null;
|
|
6560
|
-
const today = iso
|
|
6947
|
+
const today = iso(now);
|
|
6561
6948
|
if (today !== untilDay) {
|
|
6562
6949
|
untilDay = today;
|
|
6563
6950
|
untilByDate.clear();
|
|
@@ -6618,7 +7005,7 @@ function effectiveProjectHealth(project, data, now) {
|
|
|
6618
7005
|
}
|
|
6619
7006
|
/** Milestones past their date and still open. */
|
|
6620
7007
|
function lateMilestonesOf(milestones, now) {
|
|
6621
|
-
const today = iso
|
|
7008
|
+
const today = iso(now);
|
|
6622
7009
|
return milestones.filter((m) => !m.done && m.dueDate < today);
|
|
6623
7010
|
}
|
|
6624
7011
|
/**
|
|
@@ -7841,56 +8228,6 @@ function applyAutomations(project, prev, next, opts) {
|
|
|
7841
8228
|
};
|
|
7842
8229
|
}
|
|
7843
8230
|
|
|
7844
|
-
//#endregion
|
|
7845
|
-
//#region src/lib/focus.ts
|
|
7846
|
-
const FOCUS_CAP = 3;
|
|
7847
|
-
function upsertFocus(ropes, personId, date, taskIds) {
|
|
7848
|
-
const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
|
|
7849
|
-
if (taskIds.length === 0) return rest;
|
|
7850
|
-
return [...rest, {
|
|
7851
|
-
personId,
|
|
7852
|
-
date,
|
|
7853
|
-
taskIds: taskIds.slice(0, 3)
|
|
7854
|
-
}];
|
|
7855
|
-
}
|
|
7856
|
-
|
|
7857
|
-
//#endregion
|
|
7858
|
-
//#region src/lib/recurrence.ts
|
|
7859
|
-
const iso = (d) => {
|
|
7860
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
7861
|
-
};
|
|
7862
|
-
/** The due date one interval after `from`. */
|
|
7863
|
-
function nextDueDate(from, repeat) {
|
|
7864
|
-
const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
|
|
7865
|
-
if (repeat === "weekly") return iso(addDays(d, 7));
|
|
7866
|
-
if (repeat === "biweekly") return iso(addDays(d, 14));
|
|
7867
|
-
return iso(addMonths(d, 1));
|
|
7868
|
-
}
|
|
7869
|
-
/**
|
|
7870
|
-
* The next occurrence of a repeating task once this one is done: same
|
|
7871
|
-
* title, owner, priority and checklist (unticked), due one interval after
|
|
7872
|
-
* the old due date (or after today when it had none), back in To do.
|
|
7873
|
-
* Null when the task does not repeat.
|
|
7874
|
-
*/
|
|
7875
|
-
function nextOccurrence(task, id, today, createdAt) {
|
|
7876
|
-
if (!task.repeat) return null;
|
|
7877
|
-
const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
|
|
7878
|
-
return {
|
|
7879
|
-
...task,
|
|
7880
|
-
id,
|
|
7881
|
-
status: "todo",
|
|
7882
|
-
dueDate: nextDueDate(anchor, task.repeat),
|
|
7883
|
-
createdAt,
|
|
7884
|
-
completedAt: null,
|
|
7885
|
-
blockedById: null,
|
|
7886
|
-
blockedByIds: [],
|
|
7887
|
-
checklist: (task.checklist ?? []).map((c) => ({
|
|
7888
|
-
...c,
|
|
7889
|
-
done: false
|
|
7890
|
-
}))
|
|
7891
|
-
};
|
|
7892
|
-
}
|
|
7893
|
-
|
|
7894
8231
|
//#endregion
|
|
7895
8232
|
//#region src/lib/assignment.ts
|
|
7896
8233
|
/**
|
package/lib/cli.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import {
|
|
20
20
|
harbors,
|
|
21
21
|
myWork,
|
|
22
|
+
raidList,
|
|
22
23
|
programTree,
|
|
23
24
|
projectList,
|
|
24
25
|
taskLine,
|
|
@@ -59,13 +60,18 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
59
60
|
[--over-limit]
|
|
60
61
|
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
61
62
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
62
|
-
[--priority P] [--status S] [--
|
|
63
|
+
[--priority P] [--status S] [--notes TEXT]
|
|
64
|
+
[--repeat weekly|biweekly|monthly|never]
|
|
65
|
+
[--waits-on TASK,TASK|none] [--confirm] [--over-limit]
|
|
63
66
|
harbor comment TASK "TEXT"
|
|
64
67
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
65
68
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
66
69
|
[--owner NAME] [--due DATE]
|
|
67
70
|
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
68
71
|
[--due DATE]
|
|
72
|
+
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
73
|
+
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
74
|
+
the register, worst first
|
|
69
75
|
|
|
70
76
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
71
77
|
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
@@ -144,6 +150,7 @@ const level = (v, name) => {
|
|
|
144
150
|
return n;
|
|
145
151
|
};
|
|
146
152
|
const nullable = (v) => (v === "none" ? null : v);
|
|
153
|
+
const REPEAT_WORD = { weekly: "every week", biweekly: "every two weeks", monthly: "every month" };
|
|
147
154
|
|
|
148
155
|
/** --due as people say it: today, tomorrow, +3, or a date. Anything else goes to Harbor to judge. */
|
|
149
156
|
export function dueDate(v, today = new Date()) {
|
|
@@ -176,6 +183,9 @@ const FLAG = {
|
|
|
176
183
|
query: "the words",
|
|
177
184
|
task: "the task",
|
|
178
185
|
note: "the check-in",
|
|
186
|
+
waitsOn: "--waits-on",
|
|
187
|
+
repeat: "--repeat",
|
|
188
|
+
description: "--notes",
|
|
179
189
|
body: "the comment",
|
|
180
190
|
};
|
|
181
191
|
|
|
@@ -213,7 +223,7 @@ export function hint(message) {
|
|
|
213
223
|
.replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
|
|
214
224
|
.replace(
|
|
215
225
|
/^Nothing to change: pass a field to update\.$/,
|
|
216
|
-
"Nothing to change. Say what with --title, --assignee, --due, --priority or --
|
|
226
|
+
"Nothing to change. Say what with --title, --assignee, --due, --priority, --status, --notes, --repeat or --waits-on.",
|
|
217
227
|
)
|
|
218
228
|
.replace(
|
|
219
229
|
/^This connection was allowed to read only\..*$/s,
|
|
@@ -782,8 +792,12 @@ export async function run(argv, io = {}) {
|
|
|
782
792
|
}
|
|
783
793
|
case "edit": {
|
|
784
794
|
const { positionals, values, harbor } = parse(
|
|
785
|
-
spec(
|
|
795
|
+
spec(
|
|
796
|
+
["confirm", "over-limit"],
|
|
797
|
+
["title", "assignee", "due", "priority", "status", "notes", "repeat", "waits-on"],
|
|
798
|
+
),
|
|
786
799
|
);
|
|
800
|
+
const waits = values["waits-on"];
|
|
787
801
|
return call(
|
|
788
802
|
"update_task",
|
|
789
803
|
{
|
|
@@ -794,10 +808,28 @@ export async function run(argv, io = {}) {
|
|
|
794
808
|
due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
|
|
795
809
|
priority: values.priority,
|
|
796
810
|
status: values.status,
|
|
811
|
+
description: values.notes,
|
|
812
|
+
repeat: values.repeat,
|
|
813
|
+
waitsOn:
|
|
814
|
+
waits === undefined
|
|
815
|
+
? undefined
|
|
816
|
+
: waits === "none"
|
|
817
|
+
? []
|
|
818
|
+
: waits
|
|
819
|
+
.split(",")
|
|
820
|
+
.map((x) => x.trim())
|
|
821
|
+
.filter(Boolean),
|
|
797
822
|
confirmed: values.confirm || undefined,
|
|
798
823
|
overLimit: values["over-limit"] || undefined,
|
|
799
824
|
},
|
|
800
|
-
(r) =>
|
|
825
|
+
(r) =>
|
|
826
|
+
[
|
|
827
|
+
`Updated: ${taskLine(r.updated, opts)}`,
|
|
828
|
+
r.updated.waitsOn ? `Waits on ${r.updated.waitsOn.join(", ")}.` : null,
|
|
829
|
+
r.updated.repeat ? `Repeats ${REPEAT_WORD[r.updated.repeat]}.` : null,
|
|
830
|
+
]
|
|
831
|
+
.filter(Boolean)
|
|
832
|
+
.join("\n"),
|
|
801
833
|
);
|
|
802
834
|
}
|
|
803
835
|
case "comment": {
|
|
@@ -823,13 +855,34 @@ export async function run(argv, io = {}) {
|
|
|
823
855
|
() => "Checked in for today.",
|
|
824
856
|
);
|
|
825
857
|
}
|
|
858
|
+
case "risks": {
|
|
859
|
+
const { positionals, values, harbor } = parse(spec(["closed"], ["kind", "project"]));
|
|
860
|
+
const kind = values.kind
|
|
861
|
+
?.split(",")
|
|
862
|
+
.map((x) => x.trim().replace(/s$/, ""))
|
|
863
|
+
.filter(Boolean);
|
|
864
|
+
const bad = kind?.find((k) => !["risk", "issue", "decision"].includes(k));
|
|
865
|
+
if (bad) throw new HarborError(`--kind is risk, issue or decision (not "${bad}").`);
|
|
866
|
+
return call(
|
|
867
|
+
"list_risks",
|
|
868
|
+
{
|
|
869
|
+
harbor,
|
|
870
|
+
kind,
|
|
871
|
+
project: values.project,
|
|
872
|
+
includeClosed: Boolean(values.closed),
|
|
873
|
+
limit: 50,
|
|
874
|
+
},
|
|
875
|
+
(r) => raidList(r, positionals.join(" ")),
|
|
876
|
+
);
|
|
877
|
+
}
|
|
826
878
|
case "risk":
|
|
827
|
-
case "issue":
|
|
879
|
+
case "issue":
|
|
880
|
+
case "decision": {
|
|
828
881
|
const { positionals, values, harbor } = parse(
|
|
829
882
|
spec(
|
|
830
883
|
[],
|
|
831
|
-
["project", "program", "owner", "
|
|
832
|
-
command === "risk" ? ["probability"] : [],
|
|
884
|
+
["project", "program", "owner", "due"].concat(
|
|
885
|
+
command === "risk" ? ["impact", "probability"] : command === "issue" ? ["impact"] : [],
|
|
833
886
|
),
|
|
834
887
|
),
|
|
835
888
|
);
|
package/lib/format.mjs
CHANGED
|
@@ -158,3 +158,28 @@ export function todayLocal(d = new Date()) {
|
|
|
158
158
|
const p = (n) => String(n).padStart(2, "0");
|
|
159
159
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/** The register, worst first: kind, title, then status, scores, owner and where it sits. */
|
|
163
|
+
export function raidList(r, words = "") {
|
|
164
|
+
const want = words.trim().toLowerCase();
|
|
165
|
+
const items = (r.items ?? []).filter(
|
|
166
|
+
(x) => !want || `${x.title} ${x.description ?? ""}`.toLowerCase().includes(want),
|
|
167
|
+
);
|
|
168
|
+
if (!items.length) return want ? "Nothing in the register matches." : "The register is clear.";
|
|
169
|
+
const lines = items.map((x) => {
|
|
170
|
+
const bits = [
|
|
171
|
+
x.status?.toLowerCase(),
|
|
172
|
+
x.probability != null ? `likelihood ${x.probability}` : null,
|
|
173
|
+
x.impact != null ? `impact ${x.impact}` : null,
|
|
174
|
+
x.owner,
|
|
175
|
+
x.project ?? x.program ?? x.portfolio ?? null,
|
|
176
|
+
x.due ? `${x.kind === "Decision" ? "decide by" : "review by"} ${x.due}` : null,
|
|
177
|
+
].filter(Boolean);
|
|
178
|
+
return `${x.kind.toLowerCase().padEnd(8)} ${x.title} (${bits.join(" · ")})`;
|
|
179
|
+
});
|
|
180
|
+
const more =
|
|
181
|
+
!want && r.total > items.length
|
|
182
|
+
? `\n…and ${r.total - items.length} more. Narrow it with --kind or --project.`
|
|
183
|
+
: "";
|
|
184
|
+
return lines.join("\n") + more;
|
|
185
|
+
}
|