@harborppm/cli 0.11.1 → 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 +443 -90
- 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
|
|
@@ -3348,19 +3356,23 @@ function RowList({ title, right, rows, empty, active, width, height, onOpen, key
|
|
|
3348
3356
|
]
|
|
3349
3357
|
});
|
|
3350
3358
|
}
|
|
3351
|
-
/** Every project, under its program and portfolio, with health and progress.
|
|
3359
|
+
/** Every project, under its program and portfolio, with health and progress. Done
|
|
3360
|
+
* projects and programs stay out of the way until D shows them. */
|
|
3352
3361
|
function ProjectsView({ active, width, height, go }) {
|
|
3353
3362
|
const portfolios = useHarbor((s) => s.portfolios);
|
|
3354
3363
|
const programs = useHarbor((s) => s.programs);
|
|
3355
3364
|
const projects = useHarbor((s) => s.projects);
|
|
3356
3365
|
const tasks = useHarbor((s) => s.tasks);
|
|
3357
3366
|
const { today } = useDeps();
|
|
3358
|
-
const
|
|
3367
|
+
const [showDone, setShowDone] = useState(false);
|
|
3368
|
+
const { rows, shown, hidden } = useMemo(() => {
|
|
3359
3369
|
const data = {
|
|
3360
3370
|
tasks,
|
|
3361
3371
|
projects
|
|
3362
3372
|
};
|
|
3363
3373
|
const out = [];
|
|
3374
|
+
let shown = 0;
|
|
3375
|
+
let hidden = 0;
|
|
3364
3376
|
for (const portfolio of portfolios) {
|
|
3365
3377
|
out.push({
|
|
3366
3378
|
id: portfolio.id,
|
|
@@ -3371,6 +3383,11 @@ function ProjectsView({ active, width, height, go }) {
|
|
|
3371
3383
|
health: portfolio.health
|
|
3372
3384
|
});
|
|
3373
3385
|
for (const program of programs.filter((g) => g.portfolioId === portfolio.id)) {
|
|
3386
|
+
const { open, done } = splitDone(projects.filter((p) => p.programId === program.id));
|
|
3387
|
+
if (program.health === "done" && open.length === 0) {
|
|
3388
|
+
hidden += 1 + done.length;
|
|
3389
|
+
if (!showDone) continue;
|
|
3390
|
+
} else hidden += done.length;
|
|
3374
3391
|
out.push({
|
|
3375
3392
|
id: program.id,
|
|
3376
3393
|
kind: "program",
|
|
@@ -3378,8 +3395,9 @@ function ProjectsView({ active, width, height, go }) {
|
|
|
3378
3395
|
title: program.name,
|
|
3379
3396
|
health: program.health
|
|
3380
3397
|
});
|
|
3381
|
-
for (const project of
|
|
3382
|
-
|
|
3398
|
+
for (const project of showDone ? [...open, ...done] : open) {
|
|
3399
|
+
shown++;
|
|
3400
|
+
const { done: finished, total } = projectSummary(data, project);
|
|
3383
3401
|
const late = tasksOf(tasks, project.id).filter((t) => isLate(t, today())).length;
|
|
3384
3402
|
out.push({
|
|
3385
3403
|
id: project.id,
|
|
@@ -3387,22 +3405,27 @@ function ProjectsView({ active, width, height, go }) {
|
|
|
3387
3405
|
depth: 2,
|
|
3388
3406
|
title: `${project.name}${project.key ? ` ${project.key}` : ""}`,
|
|
3389
3407
|
health: project.health,
|
|
3390
|
-
detail: `${
|
|
3408
|
+
detail: `${finished}/${total} done${late ? ` · ${late} late` : ""} · ends ${shortDay(project.endDate, today())}`
|
|
3391
3409
|
});
|
|
3392
3410
|
}
|
|
3393
3411
|
}
|
|
3394
3412
|
}
|
|
3395
|
-
return
|
|
3413
|
+
return {
|
|
3414
|
+
rows: out,
|
|
3415
|
+
shown,
|
|
3416
|
+
hidden
|
|
3417
|
+
};
|
|
3396
3418
|
}, [
|
|
3397
3419
|
portfolios,
|
|
3398
3420
|
programs,
|
|
3399
3421
|
projects,
|
|
3400
3422
|
tasks,
|
|
3401
|
-
today
|
|
3423
|
+
today,
|
|
3424
|
+
showDone
|
|
3402
3425
|
]);
|
|
3403
3426
|
return /* @__PURE__ */ jsx(RowList, {
|
|
3404
3427
|
title: "Portfolios, programs and projects",
|
|
3405
|
-
right: `${count(
|
|
3428
|
+
right: `${count(shown, "project")}${hidden ? ` · ${hidden} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new project · e edit`,
|
|
3406
3429
|
rows,
|
|
3407
3430
|
empty: "No projects yet. n adds one.",
|
|
3408
3431
|
active,
|
|
@@ -3414,7 +3437,8 @@ function ProjectsView({ active, width, height, go }) {
|
|
|
3414
3437
|
const programId = r?.kind === "program" ? r.id : r?.kind === "project" ? projects.find((p) => p.id === r.id)?.programId : void 0;
|
|
3415
3438
|
go.create("project", programId ? { programId } : {});
|
|
3416
3439
|
},
|
|
3417
|
-
e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id)
|
|
3440
|
+
e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id),
|
|
3441
|
+
D: () => hidden > 0 && setShowDone((v) => !v)
|
|
3418
3442
|
}
|
|
3419
3443
|
});
|
|
3420
3444
|
}
|
|
@@ -3892,11 +3916,9 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
|
|
|
3892
3916
|
});
|
|
3893
3917
|
}
|
|
3894
3918
|
const HELP = [
|
|
3895
|
-
["1–8", "the screens
|
|
3919
|
+
["1–8 / Tab", "the screens / between the sidebar and a screen"],
|
|
3896
3920
|
[":", "anything: a screen, something new, a name"],
|
|
3897
|
-
["/", "find a task by key or words"],
|
|
3898
|
-
["H", "switch harbor"],
|
|
3899
|
-
["Tab", "between the sidebar and the screen"],
|
|
3921
|
+
["/ / H", "find a task by key or words / switch harbor"],
|
|
3900
3922
|
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
3901
3923
|
["Enter / Esc", "open / back"],
|
|
3902
3924
|
["n", "new: a task, or what the screen lists"],
|
|
@@ -3907,7 +3929,9 @@ const HELP = [
|
|
|
3907
3929
|
["v", "on a project: board, list, budget"],
|
|
3908
3930
|
["m / R", "new milestone on a program / log a risk"],
|
|
3909
3931
|
["D", "show or hide Done projects and programs"],
|
|
3910
|
-
["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"],
|
|
3911
3935
|
["q", "quit (saves first)"]
|
|
3912
3936
|
];
|
|
3913
3937
|
/**
|
|
@@ -4116,7 +4140,7 @@ function healthField(kind, item, go, set, clear) {
|
|
|
4116
4140
|
}
|
|
4117
4141
|
};
|
|
4118
4142
|
}
|
|
4119
|
-
function ProgramPanel({ id, width, height, go, onClose }) {
|
|
4143
|
+
function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
4120
4144
|
const { store, today } = useDeps();
|
|
4121
4145
|
const program = useHarbor((s) => s.programs.find((p) => p.id === id));
|
|
4122
4146
|
const people = useHarbor((s) => s.people);
|
|
@@ -4212,10 +4236,11 @@ function ProgramPanel({ id, width, height, go, onClose }) {
|
|
|
4212
4236
|
onClose();
|
|
4213
4237
|
}, "Delete");
|
|
4214
4238
|
} },
|
|
4239
|
+
active,
|
|
4215
4240
|
onClose
|
|
4216
4241
|
});
|
|
4217
4242
|
}
|
|
4218
|
-
function PortfolioPanel({ id, width, height, go, onClose }) {
|
|
4243
|
+
function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
4219
4244
|
const { store } = useDeps();
|
|
4220
4245
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
|
|
4221
4246
|
const people = useHarbor((s) => s.people);
|
|
@@ -4279,10 +4304,11 @@ function PortfolioPanel({ id, width, height, go, onClose }) {
|
|
|
4279
4304
|
onClose();
|
|
4280
4305
|
}, "Delete");
|
|
4281
4306
|
} },
|
|
4307
|
+
active,
|
|
4282
4308
|
onClose
|
|
4283
4309
|
});
|
|
4284
4310
|
}
|
|
4285
|
-
function ProjectPanel({ id, width, height, go, onClose }) {
|
|
4311
|
+
function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
4286
4312
|
const { store, today } = useDeps();
|
|
4287
4313
|
const project = useHarbor((s) => s.projects.find((p) => p.id === id));
|
|
4288
4314
|
const people = useHarbor((s) => s.people);
|
|
@@ -4392,10 +4418,11 @@ function ProjectPanel({ id, width, height, go, onClose }) {
|
|
|
4392
4418
|
onClose();
|
|
4393
4419
|
}, "Delete");
|
|
4394
4420
|
} },
|
|
4421
|
+
active,
|
|
4395
4422
|
onClose
|
|
4396
4423
|
});
|
|
4397
4424
|
}
|
|
4398
|
-
function MilestonePanel({ id, width, height, go, onClose }) {
|
|
4425
|
+
function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
4399
4426
|
const { store, today } = useDeps();
|
|
4400
4427
|
const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
|
|
4401
4428
|
const people = useHarbor((s) => s.people);
|
|
@@ -4466,10 +4493,11 @@ function MilestonePanel({ id, width, height, go, onClose }) {
|
|
|
4466
4493
|
onClose();
|
|
4467
4494
|
}, "Delete")
|
|
4468
4495
|
},
|
|
4496
|
+
active,
|
|
4469
4497
|
onClose
|
|
4470
4498
|
});
|
|
4471
4499
|
}
|
|
4472
|
-
function RaidPanel({ id, width, height, go, onClose }) {
|
|
4500
|
+
function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
4473
4501
|
const { store, today } = useDeps();
|
|
4474
4502
|
const item = useHarbor((s) => s.raidItems.find((r) => r.id === id));
|
|
4475
4503
|
const people = useHarbor((s) => s.people);
|
|
@@ -4616,10 +4644,11 @@ function RaidPanel({ id, width, height, go, onClose }) {
|
|
|
4616
4644
|
onClose();
|
|
4617
4645
|
}, "Delete")
|
|
4618
4646
|
},
|
|
4647
|
+
active,
|
|
4619
4648
|
onClose
|
|
4620
4649
|
});
|
|
4621
4650
|
}
|
|
4622
|
-
function PersonPanel({ id, width, height, go, onClose }) {
|
|
4651
|
+
function PersonPanel({ id, width, height, go, active, onClose }) {
|
|
4623
4652
|
const { store } = useDeps();
|
|
4624
4653
|
const person = useHarbor((s) => s.people.find((p) => p.id === id));
|
|
4625
4654
|
const programs = useHarbor((s) => s.programs);
|
|
@@ -4703,6 +4732,7 @@ function PersonPanel({ id, width, height, go, onClose }) {
|
|
|
4703
4732
|
]
|
|
4704
4733
|
}, a.programId))
|
|
4705
4734
|
] }) : null,
|
|
4735
|
+
active,
|
|
4706
4736
|
onClose
|
|
4707
4737
|
});
|
|
4708
4738
|
}
|
|
@@ -5020,8 +5050,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5020
5050
|
people,
|
|
5021
5051
|
boardConfigs
|
|
5022
5052
|
]);
|
|
5023
|
-
const
|
|
5024
|
-
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
|
+
]);
|
|
5025
5068
|
const [mode, setMode] = useState("board");
|
|
5026
5069
|
const [spendAt, setSpendAt] = useState(0);
|
|
5027
5070
|
const spendEntries = useHarbor((s) => s.spendEntries) ?? [];
|
|
@@ -5053,6 +5096,38 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5053
5096
|
if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
|
|
5054
5097
|
if (input === "e") return go.openPanel("project", projectId);
|
|
5055
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
|
+
})));
|
|
5056
5131
|
if (mode === "budget") {
|
|
5057
5132
|
const entries = project ? budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date()).entries : [];
|
|
5058
5133
|
const entry = entries[Math.min(spendAt, entries.length - 1)];
|
|
@@ -5092,6 +5167,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5092
5167
|
})
|
|
5093
5168
|
});
|
|
5094
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);
|
|
5095
5171
|
const lead = people.find((p) => p.id === project.ownerId);
|
|
5096
5172
|
const body = height - 3;
|
|
5097
5173
|
const fit = Math.max(1, Math.floor((width - 2) / 14));
|
|
@@ -5140,7 +5216,16 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5140
5216
|
lead ? ` · ${lead.name}` : "",
|
|
5141
5217
|
" · ",
|
|
5142
5218
|
mode,
|
|
5143
|
-
"
|
|
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"
|
|
5144
5229
|
]
|
|
5145
5230
|
})
|
|
5146
5231
|
})]
|
|
@@ -5164,7 +5249,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5164
5249
|
const on = active && ci === col;
|
|
5165
5250
|
const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
|
|
5166
5251
|
const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
|
|
5167
|
-
const over = c.limit != null && c.
|
|
5252
|
+
const over = c.limit != null && c.all > c.limit;
|
|
5168
5253
|
return /* @__PURE__ */ jsxs(Box, {
|
|
5169
5254
|
flexDirection: "column",
|
|
5170
5255
|
width: w,
|
|
@@ -5189,7 +5274,11 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5189
5274
|
" ",
|
|
5190
5275
|
/* @__PURE__ */ jsxs(Text, {
|
|
5191
5276
|
color: over ? theme.bad : theme.muted,
|
|
5192
|
-
children: [
|
|
5277
|
+
children: [
|
|
5278
|
+
c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
|
|
5279
|
+
c.all,
|
|
5280
|
+
c.limit != null ? `/${c.limit}` : ""
|
|
5281
|
+
]
|
|
5193
5282
|
})
|
|
5194
5283
|
]
|
|
5195
5284
|
})
|
|
@@ -5481,6 +5570,78 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
5481
5570
|
});
|
|
5482
5571
|
}
|
|
5483
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
|
+
|
|
5484
5645
|
//#endregion
|
|
5485
5646
|
//#region src/tui/views/task.tsx
|
|
5486
5647
|
const FIELDS = [
|
|
@@ -5508,19 +5669,34 @@ const FIELDS = [
|
|
|
5508
5669
|
field: "project",
|
|
5509
5670
|
label: "Project"
|
|
5510
5671
|
},
|
|
5672
|
+
{
|
|
5673
|
+
field: "waits",
|
|
5674
|
+
label: "Waits on"
|
|
5675
|
+
},
|
|
5676
|
+
{
|
|
5677
|
+
field: "repeat",
|
|
5678
|
+
label: "Repeats"
|
|
5679
|
+
},
|
|
5511
5680
|
{
|
|
5512
5681
|
field: "notes",
|
|
5513
5682
|
label: "Notes"
|
|
5514
5683
|
}
|
|
5515
5684
|
];
|
|
5685
|
+
const REPEATS = [
|
|
5686
|
+
null,
|
|
5687
|
+
"weekly",
|
|
5688
|
+
"biweekly",
|
|
5689
|
+
"monthly"
|
|
5690
|
+
];
|
|
5516
5691
|
/** Everything about one task, every field editable: ↑↓ to a field, Enter to change it. */
|
|
5517
|
-
function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
5692
|
+
function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
5518
5693
|
const { store, today } = useDeps();
|
|
5519
5694
|
const tasks = useHarbor((s) => s.tasks);
|
|
5520
5695
|
const projects = useHarbor((s) => s.projects);
|
|
5521
5696
|
const people = useHarbor((s) => s.people);
|
|
5522
5697
|
const comments = useHarbor((s) => s.comments);
|
|
5523
5698
|
const viewOnly = useHarbor((s) => s.myRole === "viewer" || s.keepPaused);
|
|
5699
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
5524
5700
|
const task = tasks.find((t) => t.id === taskId);
|
|
5525
5701
|
const [at, setAt] = useState(0);
|
|
5526
5702
|
const [editing, setEditing] = useState(null);
|
|
@@ -5533,6 +5709,10 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5533
5709
|
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows));
|
|
5534
5710
|
if (input === "c") return setEditing({ kind: "comment" });
|
|
5535
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
|
+
}
|
|
5536
5716
|
if (input === "x") return moveTask(store, go, task, "done");
|
|
5537
5717
|
if (input === "d") return go.confirm(`Delete ${keyOf({ projects }, task)} “${task.title}”? u undoes it.`, () => {
|
|
5538
5718
|
store.getState().deleteTask(task.id);
|
|
@@ -5545,12 +5725,14 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5545
5725
|
kind: "field",
|
|
5546
5726
|
field: FIELDS[at].field
|
|
5547
5727
|
});
|
|
5548
|
-
}, { isActive: editing === null });
|
|
5728
|
+
}, { isActive: active && editing === null });
|
|
5549
5729
|
if (!task) return null;
|
|
5550
5730
|
const project = projects.find((p) => p.id === task.projectId);
|
|
5551
5731
|
const owner = people.find((p) => p.id === task.assigneeId);
|
|
5552
5732
|
const waits = openBlockerNames(task, tasks);
|
|
5553
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);
|
|
5554
5736
|
const done = () => setEditing(null);
|
|
5555
5737
|
const value = {
|
|
5556
5738
|
title: task.title,
|
|
@@ -5559,6 +5741,8 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5559
5741
|
due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
|
|
5560
5742
|
priority: PRIORITY_LABEL[task.priority],
|
|
5561
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",
|
|
5562
5746
|
notes: task.description.replace(/\s+/g, " ").trim() || "None"
|
|
5563
5747
|
};
|
|
5564
5748
|
let editor = null;
|
|
@@ -5579,7 +5763,44 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5579
5763
|
done();
|
|
5580
5764
|
}
|
|
5581
5765
|
});
|
|
5582
|
-
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") {
|
|
5583
5804
|
const f = editing.field;
|
|
5584
5805
|
if (f === "title" || f === "notes") editor = /* @__PURE__ */ jsx(TextField, {
|
|
5585
5806
|
label: f === "title" ? "Title" : "Notes",
|
|
@@ -5647,7 +5868,24 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5647
5868
|
done();
|
|
5648
5869
|
}
|
|
5649
5870
|
});
|
|
5650
|
-
} 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") {
|
|
5651
5889
|
const choices = projects.map((p) => ({
|
|
5652
5890
|
label: p.name,
|
|
5653
5891
|
value: p.id,
|
|
@@ -5780,7 +6018,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5780
6018
|
editor ?? /* @__PURE__ */ jsx(Text, {
|
|
5781
6019
|
color: theme.muted,
|
|
5782
6020
|
wrap: "truncate-end",
|
|
5783
|
-
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"
|
|
5784
6022
|
})
|
|
5785
6023
|
]
|
|
5786
6024
|
});
|
|
@@ -5803,16 +6041,140 @@ function DueField({ initial, today, onSubmit, onCancel }) {
|
|
|
5803
6041
|
})
|
|
5804
6042
|
});
|
|
5805
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
|
+
}
|
|
5806
6161
|
|
|
5807
6162
|
//#endregion
|
|
5808
6163
|
//#region src/tui/views/today.tsx
|
|
5809
|
-
/**
|
|
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
|
+
*/
|
|
5810
6168
|
function TodayView({ active, width, height, go }) {
|
|
5811
6169
|
const { store, today } = useDeps();
|
|
5812
6170
|
const tasks = useHarbor((s) => s.tasks);
|
|
5813
6171
|
const projects = useHarbor((s) => s.projects);
|
|
5814
6172
|
const raidItems = useHarbor((s) => s.raidItems);
|
|
5815
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);
|
|
5816
6178
|
const day = today();
|
|
5817
6179
|
const data = useMemo(() => ({
|
|
5818
6180
|
tasks,
|
|
@@ -5841,8 +6203,24 @@ function TodayView({ active, width, height, go }) {
|
|
|
5841
6203
|
else if (key.return && selected) go.openTask(selected.id);
|
|
5842
6204
|
else if (input === "x" && selected) moveTask(store, go, selected, "done");
|
|
5843
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);
|
|
5844
6222
|
}, { isActive: active });
|
|
5845
|
-
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);
|
|
5846
6224
|
const rows = Math.max(3, height - 2 - extra);
|
|
5847
6225
|
const view = windowOf(mine, at, rows);
|
|
5848
6226
|
return /* @__PURE__ */ jsxs(Box, {
|
|
@@ -5851,8 +6229,32 @@ function TodayView({ active, width, height, go }) {
|
|
|
5851
6229
|
height,
|
|
5852
6230
|
paddingX: 1,
|
|
5853
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
|
+
}),
|
|
5854
6256
|
/* @__PURE__ */ jsx(Heading, {
|
|
5855
|
-
right: `${mine.length} open${late ? ` · ${late} late` : ""}`,
|
|
6257
|
+
right: `${mine.length} open${late ? ` · ${late} late` : ""} · f focus`,
|
|
5856
6258
|
children: "On you"
|
|
5857
6259
|
}),
|
|
5858
6260
|
mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
@@ -6272,6 +6674,7 @@ function App({ ui }) {
|
|
|
6272
6674
|
width: mainWidth,
|
|
6273
6675
|
height: bodyHeight,
|
|
6274
6676
|
go,
|
|
6677
|
+
active: !covered,
|
|
6275
6678
|
onClose: () => setPanel(null)
|
|
6276
6679
|
};
|
|
6277
6680
|
let panelNode = null;
|
|
@@ -6311,6 +6714,7 @@ function App({ ui }) {
|
|
|
6311
6714
|
["x", "done"],
|
|
6312
6715
|
["d", "delete"],
|
|
6313
6716
|
["v", "board/list/budget"],
|
|
6717
|
+
["f p", "filter"],
|
|
6314
6718
|
["e", "details"],
|
|
6315
6719
|
["Esc", "back"]
|
|
6316
6720
|
] : page ? [
|
|
@@ -6437,9 +6841,8 @@ function App({ ui }) {
|
|
|
6437
6841
|
display: overlayNode || panelNode ? "none" : "flex",
|
|
6438
6842
|
children: screenNode
|
|
6439
6843
|
}),
|
|
6440
|
-
panelNode
|
|
6441
|
-
|
|
6442
|
-
display: "none",
|
|
6844
|
+
panelNode ? /* @__PURE__ */ jsx(Box, {
|
|
6845
|
+
display: overlayNode ? "none" : "flex",
|
|
6443
6846
|
children: panelNode
|
|
6444
6847
|
}) : null
|
|
6445
6848
|
]
|
|
@@ -6477,7 +6880,7 @@ function recordHealth(history, health, at, override, note) {
|
|
|
6477
6880
|
|
|
6478
6881
|
//#endregion
|
|
6479
6882
|
//#region src/lib/derived-health.ts
|
|
6480
|
-
const iso
|
|
6883
|
+
const iso = (d) => {
|
|
6481
6884
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
6482
6885
|
};
|
|
6483
6886
|
function parseDate(value) {
|
|
@@ -6520,7 +6923,7 @@ let overdueDay = "";
|
|
|
6520
6923
|
const overdueByDue = /* @__PURE__ */ new Map();
|
|
6521
6924
|
function daysOverdue(dueDate, now) {
|
|
6522
6925
|
if (!dueDate) return 0;
|
|
6523
|
-
const today = iso
|
|
6926
|
+
const today = iso(now);
|
|
6524
6927
|
if (today !== overdueDay) {
|
|
6525
6928
|
overdueDay = today;
|
|
6526
6929
|
overdueByDue.clear();
|
|
@@ -6541,7 +6944,7 @@ let untilDay = "";
|
|
|
6541
6944
|
const untilByDate = /* @__PURE__ */ new Map();
|
|
6542
6945
|
function daysUntil(date, now) {
|
|
6543
6946
|
if (!date) return null;
|
|
6544
|
-
const today = iso
|
|
6947
|
+
const today = iso(now);
|
|
6545
6948
|
if (today !== untilDay) {
|
|
6546
6949
|
untilDay = today;
|
|
6547
6950
|
untilByDate.clear();
|
|
@@ -6602,7 +7005,7 @@ function effectiveProjectHealth(project, data, now) {
|
|
|
6602
7005
|
}
|
|
6603
7006
|
/** Milestones past their date and still open. */
|
|
6604
7007
|
function lateMilestonesOf(milestones, now) {
|
|
6605
|
-
const today = iso
|
|
7008
|
+
const today = iso(now);
|
|
6606
7009
|
return milestones.filter((m) => !m.done && m.dueDate < today);
|
|
6607
7010
|
}
|
|
6608
7011
|
/**
|
|
@@ -7825,56 +8228,6 @@ function applyAutomations(project, prev, next, opts) {
|
|
|
7825
8228
|
};
|
|
7826
8229
|
}
|
|
7827
8230
|
|
|
7828
|
-
//#endregion
|
|
7829
|
-
//#region src/lib/focus.ts
|
|
7830
|
-
const FOCUS_CAP = 3;
|
|
7831
|
-
function upsertFocus(ropes, personId, date, taskIds) {
|
|
7832
|
-
const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
|
|
7833
|
-
if (taskIds.length === 0) return rest;
|
|
7834
|
-
return [...rest, {
|
|
7835
|
-
personId,
|
|
7836
|
-
date,
|
|
7837
|
-
taskIds: taskIds.slice(0, 3)
|
|
7838
|
-
}];
|
|
7839
|
-
}
|
|
7840
|
-
|
|
7841
|
-
//#endregion
|
|
7842
|
-
//#region src/lib/recurrence.ts
|
|
7843
|
-
const iso = (d) => {
|
|
7844
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
7845
|
-
};
|
|
7846
|
-
/** The due date one interval after `from`. */
|
|
7847
|
-
function nextDueDate(from, repeat) {
|
|
7848
|
-
const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
|
|
7849
|
-
if (repeat === "weekly") return iso(addDays(d, 7));
|
|
7850
|
-
if (repeat === "biweekly") return iso(addDays(d, 14));
|
|
7851
|
-
return iso(addMonths(d, 1));
|
|
7852
|
-
}
|
|
7853
|
-
/**
|
|
7854
|
-
* The next occurrence of a repeating task once this one is done: same
|
|
7855
|
-
* title, owner, priority and checklist (unticked), due one interval after
|
|
7856
|
-
* the old due date (or after today when it had none), back in To do.
|
|
7857
|
-
* Null when the task does not repeat.
|
|
7858
|
-
*/
|
|
7859
|
-
function nextOccurrence(task, id, today, createdAt) {
|
|
7860
|
-
if (!task.repeat) return null;
|
|
7861
|
-
const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
|
|
7862
|
-
return {
|
|
7863
|
-
...task,
|
|
7864
|
-
id,
|
|
7865
|
-
status: "todo",
|
|
7866
|
-
dueDate: nextDueDate(anchor, task.repeat),
|
|
7867
|
-
createdAt,
|
|
7868
|
-
completedAt: null,
|
|
7869
|
-
blockedById: null,
|
|
7870
|
-
blockedByIds: [],
|
|
7871
|
-
checklist: (task.checklist ?? []).map((c) => ({
|
|
7872
|
-
...c,
|
|
7873
|
-
done: false
|
|
7874
|
-
}))
|
|
7875
|
-
};
|
|
7876
|
-
}
|
|
7877
|
-
|
|
7878
8231
|
//#endregion
|
|
7879
8232
|
//#region src/lib/assignment.ts
|
|
7880
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
|
+
}
|