@harborppm/cli 0.20.1 → 0.21.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/dist/app.mjs +310 -263
- package/lib/cli.mjs +15 -7
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -4230,6 +4230,262 @@ function Palette({ width, base, open, onClose }) {
|
|
|
4230
4230
|
});
|
|
4231
4231
|
}
|
|
4232
4232
|
|
|
4233
|
+
//#endregion
|
|
4234
|
+
//#region src/lib/health-history.ts
|
|
4235
|
+
/** The history with one more line when `health` differs from the last recorded one. Same array otherwise. */
|
|
4236
|
+
function recordHealth(history, health, at, override, note) {
|
|
4237
|
+
const last = history?.[history.length - 1];
|
|
4238
|
+
if (last && last.health === health && last.override === override && (last.note ?? null) === (note ?? null)) return history;
|
|
4239
|
+
return [...history ?? [], {
|
|
4240
|
+
at,
|
|
4241
|
+
health,
|
|
4242
|
+
override,
|
|
4243
|
+
note: note ?? null
|
|
4244
|
+
}].slice(-30);
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
//#endregion
|
|
4248
|
+
//#region src/lib/derived-health.ts
|
|
4249
|
+
const iso$1 = (d) => {
|
|
4250
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
4251
|
+
};
|
|
4252
|
+
function parseDate(value) {
|
|
4253
|
+
return parseIso(value.length === 10 ? `${value}T12:00:00` : value);
|
|
4254
|
+
}
|
|
4255
|
+
function isBlocked(task, tasks) {
|
|
4256
|
+
return blockersIn(task, tasks).some((b) => b.status !== "done");
|
|
4257
|
+
}
|
|
4258
|
+
/** A project with no tasks yet, or any task still open, hasn't cleared. */
|
|
4259
|
+
function projectHasOpenWork(data, projectId) {
|
|
4260
|
+
const tasks = tasksOf(data.tasks, projectId);
|
|
4261
|
+
return tasks.length === 0 || tasks.some((t) => t.status !== "done");
|
|
4262
|
+
}
|
|
4263
|
+
/** Every task finished (and at least one): the project reads as ready to be marked Done. */
|
|
4264
|
+
function allTasksDone(allTasks, projectId) {
|
|
4265
|
+
const tasks = tasksOf(allTasks, projectId);
|
|
4266
|
+
return tasks.length > 0 && tasks.every((t) => t.status === "done");
|
|
4267
|
+
}
|
|
4268
|
+
/** A project whose work is all done but isn't marked Done yet: offer to finish it. */
|
|
4269
|
+
function suggestsDone(project, tasks) {
|
|
4270
|
+
return project.health !== "done" && allTasksDone(tasks, project.id);
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* A hand-set health that overrules the rules: On track, At risk or Off track.
|
|
4274
|
+
* Paused and Done are states someone puts a project in, so they don't read
|
|
4275
|
+
* as an override.
|
|
4276
|
+
*/
|
|
4277
|
+
function overridesTrack(item) {
|
|
4278
|
+
const h = item.healthOverride;
|
|
4279
|
+
return h === "on-track" || h === "at-risk" || h === "off-track";
|
|
4280
|
+
}
|
|
4281
|
+
/** Every project this one waits on that still exists. */
|
|
4282
|
+
function projectBlockersOf(data, project) {
|
|
4283
|
+
return blockersIn(project, data.projects);
|
|
4284
|
+
}
|
|
4285
|
+
/** The blockers with open work left: the ones actually holding it up. */
|
|
4286
|
+
function projectOpenBlockers(data, project) {
|
|
4287
|
+
return projectBlockersOf(data, project).filter((b) => projectHasOpenWork(data, b.id));
|
|
4288
|
+
}
|
|
4289
|
+
function projectIsBlocked(data, project) {
|
|
4290
|
+
return projectOpenBlockers(data, project).length > 0;
|
|
4291
|
+
}
|
|
4292
|
+
/** A program with no projects yet, or any project still open, hasn't cleared. */
|
|
4293
|
+
function programHasOpenWork(data, programId) {
|
|
4294
|
+
const projects = data.projects.filter((p) => p.programId === programId);
|
|
4295
|
+
return projects.length === 0 || projects.some((p) => projectHasOpenWork(data, p.id));
|
|
4296
|
+
}
|
|
4297
|
+
function programBlockersOf(data, program) {
|
|
4298
|
+
return blockersIn(program, data.programs);
|
|
4299
|
+
}
|
|
4300
|
+
function programOpenBlockers(data, program) {
|
|
4301
|
+
return programBlockersOf(data, program).filter((b) => programHasOpenWork(data, b.id));
|
|
4302
|
+
}
|
|
4303
|
+
function programIsBlocked(data, program) {
|
|
4304
|
+
return programOpenBlockers(data, program).length > 0;
|
|
4305
|
+
}
|
|
4306
|
+
let overdueDay = "";
|
|
4307
|
+
const overdueByDue = /* @__PURE__ */ new Map();
|
|
4308
|
+
function daysOverdue(dueDate, now) {
|
|
4309
|
+
if (!dueDate) return 0;
|
|
4310
|
+
const today = iso$1(now);
|
|
4311
|
+
if (today !== overdueDay) {
|
|
4312
|
+
overdueDay = today;
|
|
4313
|
+
overdueByDue.clear();
|
|
4314
|
+
}
|
|
4315
|
+
const hit = overdueByDue.get(dueDate);
|
|
4316
|
+
if (hit !== void 0) return hit;
|
|
4317
|
+
let days = 0;
|
|
4318
|
+
try {
|
|
4319
|
+
const n = differenceInCalendarDays(startOfDay(now), startOfDay(parseDate(dueDate)));
|
|
4320
|
+
days = n > 0 ? n : 0;
|
|
4321
|
+
} catch {
|
|
4322
|
+
days = 0;
|
|
4323
|
+
}
|
|
4324
|
+
overdueByDue.set(dueDate, days);
|
|
4325
|
+
return days;
|
|
4326
|
+
}
|
|
4327
|
+
let untilDay = "";
|
|
4328
|
+
const untilByDate = /* @__PURE__ */ new Map();
|
|
4329
|
+
function daysUntil(date, now) {
|
|
4330
|
+
if (!date) return null;
|
|
4331
|
+
const today = iso$1(now);
|
|
4332
|
+
if (today !== untilDay) {
|
|
4333
|
+
untilDay = today;
|
|
4334
|
+
untilByDate.clear();
|
|
4335
|
+
}
|
|
4336
|
+
const hit = untilByDate.get(date);
|
|
4337
|
+
if (hit !== void 0) return hit;
|
|
4338
|
+
let days;
|
|
4339
|
+
try {
|
|
4340
|
+
days = differenceInCalendarDays(startOfDay(parseDate(date)), startOfDay(now));
|
|
4341
|
+
} catch {
|
|
4342
|
+
days = null;
|
|
4343
|
+
}
|
|
4344
|
+
untilByDate.set(date, days);
|
|
4345
|
+
return days;
|
|
4346
|
+
}
|
|
4347
|
+
function incomplete(task) {
|
|
4348
|
+
return task.status !== "done";
|
|
4349
|
+
}
|
|
4350
|
+
function wipLimitOf(status, stored, uncapped) {
|
|
4351
|
+
if (stored != null) return stored;
|
|
4352
|
+
if (uncapped === true) return null;
|
|
4353
|
+
if (status === "doing") return 3;
|
|
4354
|
+
if (status === "review") return 2;
|
|
4355
|
+
return null;
|
|
4356
|
+
}
|
|
4357
|
+
function wipOverflowing(data, projectId) {
|
|
4358
|
+
const cols = data.boardConfigs?.[projectId]?.columns;
|
|
4359
|
+
if (cols?.length) return cols.some((c) => {
|
|
4360
|
+
if (c.visible === false) return false;
|
|
4361
|
+
const limit = wipLimitOf(c.status, c.wipLimit, c.uncapped);
|
|
4362
|
+
if (limit == null) return false;
|
|
4363
|
+
return tasksOf(data.tasks, projectId).filter((t) => t.status === c.status).length > limit;
|
|
4364
|
+
});
|
|
4365
|
+
const mine = tasksOf(data.tasks, projectId);
|
|
4366
|
+
const doing = mine.filter((t) => t.status === "doing").length;
|
|
4367
|
+
const review = mine.filter((t) => t.status === "review").length;
|
|
4368
|
+
return doing > 3 || review > 2;
|
|
4369
|
+
}
|
|
4370
|
+
function deriveProjectHealth(project, data, now) {
|
|
4371
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
4372
|
+
const open = tasks.filter(incomplete);
|
|
4373
|
+
const maxOverdue = open.map((t) => daysOverdue(t.dueDate, now)).reduce((a, b) => Math.max(a, b), 0);
|
|
4374
|
+
const until = daysUntil(project.endDate, now);
|
|
4375
|
+
const unfinished = open.length > 0 || tasks.length === 0 && until != null && until < 0;
|
|
4376
|
+
if (maxOverdue > 3 || until != null && until < 0 && unfinished) return "off-track";
|
|
4377
|
+
const blockers = open.some((t) => isBlocked(t, data.tasks)) || projectIsBlocked(data, project);
|
|
4378
|
+
const wip = wipOverflowing(data, project.id);
|
|
4379
|
+
if (maxOverdue >= 1 || until != null && until >= 0 && until <= 7 && (blockers || wip)) return "at-risk";
|
|
4380
|
+
return "on-track";
|
|
4381
|
+
}
|
|
4382
|
+
function worstTrack(healths) {
|
|
4383
|
+
if (healths.some((h) => h === "off-track")) return "off-track";
|
|
4384
|
+
if (healths.some((h) => h === "at-risk")) return "at-risk";
|
|
4385
|
+
return "on-track";
|
|
4386
|
+
}
|
|
4387
|
+
/** The override wins when set; Paused and Done are overrides too, and stay until reset. */
|
|
4388
|
+
function effectiveProjectHealth(project, data, now) {
|
|
4389
|
+
return project.healthOverride ?? deriveProjectHealth(project, data, now);
|
|
4390
|
+
}
|
|
4391
|
+
/** Milestones past their date and still open. */
|
|
4392
|
+
function lateMilestonesOf(milestones, now) {
|
|
4393
|
+
const today = iso$1(now);
|
|
4394
|
+
return milestones.filter((m) => !m.done && m.dueDate < today);
|
|
4395
|
+
}
|
|
4396
|
+
/**
|
|
4397
|
+
* The projects an open milestone waits on that put its date in doubt: one
|
|
4398
|
+
* scheduled to end after the milestone is due, or one off track with work
|
|
4399
|
+
* still open. Finished projects and a reached milestone carry no risk.
|
|
4400
|
+
*/
|
|
4401
|
+
function milestoneRisks(milestone, data) {
|
|
4402
|
+
if (milestone.done) return [];
|
|
4403
|
+
return blockersIn(milestone, data.projects).flatMap((project) => {
|
|
4404
|
+
if (!projectHasOpenWork(data, project.id)) return [];
|
|
4405
|
+
if (project.endDate > milestone.dueDate) return [{
|
|
4406
|
+
project,
|
|
4407
|
+
reason: "ends-after"
|
|
4408
|
+
}];
|
|
4409
|
+
if (project.health === "off-track") return [{
|
|
4410
|
+
project,
|
|
4411
|
+
reason: "off-track"
|
|
4412
|
+
}];
|
|
4413
|
+
return [];
|
|
4414
|
+
});
|
|
4415
|
+
}
|
|
4416
|
+
/**
|
|
4417
|
+
* `projectHealth` defaults to working each project out afresh; stampHealth
|
|
4418
|
+
* passes the health it has just stamped, so no project is derived twice.
|
|
4419
|
+
*/
|
|
4420
|
+
function deriveProgramHealth(program, data, now, projectHealth = (p) => effectiveProjectHealth(p, data, now)) {
|
|
4421
|
+
const kids = data.projects.filter((p) => p.programId === program.id);
|
|
4422
|
+
const blocked = programIsBlocked(data, program) ? "at-risk" : "on-track";
|
|
4423
|
+
const ownMilestones = (data.milestones ?? []).filter((m) => m.programId === program.id);
|
|
4424
|
+
const lateMilestone = lateMilestonesOf(ownMilestones, now).length > 0 || ownMilestones.some((m) => milestoneRisks(m, data).length > 0) ? "at-risk" : "on-track";
|
|
4425
|
+
if (kids.length === 0) return worstTrack([blocked, lateMilestone]);
|
|
4426
|
+
return worstTrack([
|
|
4427
|
+
...kids.map(projectHealth),
|
|
4428
|
+
blocked,
|
|
4429
|
+
lateMilestone
|
|
4430
|
+
]);
|
|
4431
|
+
}
|
|
4432
|
+
function effectiveProgramHealth(program, data, now, projectHealth) {
|
|
4433
|
+
return program.healthOverride ?? deriveProgramHealth(program, data, now, projectHealth);
|
|
4434
|
+
}
|
|
4435
|
+
/** A paused or done program neither helps nor hurts the portfolio. */
|
|
4436
|
+
function derivePortfolioHealth(portfolioId, data, now, programHealth = (p) => effectiveProgramHealth(p, data, now)) {
|
|
4437
|
+
const kids = data.programs.filter((p) => p.portfolioId === portfolioId);
|
|
4438
|
+
if (kids.length === 0) return "on-track";
|
|
4439
|
+
return worstTrack(kids.map(programHealth));
|
|
4440
|
+
}
|
|
4441
|
+
function effectivePortfolioHealth(portfolio, data, now, programHealth) {
|
|
4442
|
+
return portfolio.healthOverride ?? derivePortfolioHealth(portfolio.id, data, now, programHealth);
|
|
4443
|
+
}
|
|
4444
|
+
/**
|
|
4445
|
+
* Re-derive every project, program and portfolio health.
|
|
4446
|
+
*
|
|
4447
|
+
* Reference-stable: an entity whose health did not change is returned as the
|
|
4448
|
+
* same object, and a collection with no changed entity as the same array, so
|
|
4449
|
+
* selectors that pick `projects`, `programs` or `portfolios` only re-render
|
|
4450
|
+
* when a health really moved.
|
|
4451
|
+
*/
|
|
4452
|
+
function stampHealth(data, now = clockNow()) {
|
|
4453
|
+
const at = now.toISOString();
|
|
4454
|
+
const projects = restamp(data.projects, (p) => effectiveProjectHealth(p, data, now), at);
|
|
4455
|
+
const mid = projects === data.projects ? data : {
|
|
4456
|
+
...data,
|
|
4457
|
+
projects
|
|
4458
|
+
};
|
|
4459
|
+
const programs = restamp(data.programs, (p) => effectiveProgramHealth(p, mid, now, (project) => project.health), at);
|
|
4460
|
+
const withPrograms = programs === mid.programs ? mid : {
|
|
4461
|
+
...mid,
|
|
4462
|
+
programs
|
|
4463
|
+
};
|
|
4464
|
+
const portfolios = restamp(data.portfolios, (p) => effectivePortfolioHealth(p, withPrograms, now, (program) => program.health), at);
|
|
4465
|
+
if (projects === data.projects && programs === data.programs && portfolios === data.portfolios) return data;
|
|
4466
|
+
return {
|
|
4467
|
+
...data,
|
|
4468
|
+
projects,
|
|
4469
|
+
programs,
|
|
4470
|
+
portfolios
|
|
4471
|
+
};
|
|
4472
|
+
}
|
|
4473
|
+
/** Every row whose health moved gets the new value and a line of history. */
|
|
4474
|
+
function restamp(rows, healthOf, at) {
|
|
4475
|
+
let changed = false;
|
|
4476
|
+
const next = rows.map((row) => {
|
|
4477
|
+
const health = healthOf(row);
|
|
4478
|
+
if (health === row.health) return row;
|
|
4479
|
+
changed = true;
|
|
4480
|
+
return {
|
|
4481
|
+
...row,
|
|
4482
|
+
health,
|
|
4483
|
+
healthHistory: recordHealth(row.healthHistory, health, at, Boolean(row.healthOverride), row.healthOverride ? row.healthNote : null)
|
|
4484
|
+
};
|
|
4485
|
+
});
|
|
4486
|
+
return changed ? next : rows;
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4233
4489
|
//#endregion
|
|
4234
4490
|
//#region src/tui/views/panels.tsx
|
|
4235
4491
|
/**
|
|
@@ -4250,7 +4506,7 @@ function peopleChoices(people, allowNone) {
|
|
|
4250
4506
|
}, ...list] : list;
|
|
4251
4507
|
}
|
|
4252
4508
|
/** Health with its override: picking one asks why (the note shows with it); "worked out" clears it. */
|
|
4253
|
-
function healthField(kind, item, go, set, clear) {
|
|
4509
|
+
function healthField(kind, item, go, set, clear, finished = false) {
|
|
4254
4510
|
const choices = [{
|
|
4255
4511
|
label: `Worked out from what's under it`,
|
|
4256
4512
|
value: "derived"
|
|
@@ -4261,7 +4517,7 @@ function healthField(kind, item, go, set, clear) {
|
|
|
4261
4517
|
}))];
|
|
4262
4518
|
return {
|
|
4263
4519
|
label: "Health",
|
|
4264
|
-
value: `${HEALTH_LABEL[item.health]}${item
|
|
4520
|
+
value: `${HEALTH_LABEL[item.health]}${overridesTrack(item) ? ` (set by hand: ${item.healthNote || "no note"})` : item.healthNote && item.healthOverride ? ` (${item.healthNote})` : ""}${finished ? " · every task done, mark it Done?" : ""}`,
|
|
4265
4521
|
color: HEALTH_COLOR[item.health],
|
|
4266
4522
|
edit: {
|
|
4267
4523
|
kind: "pick",
|
|
@@ -4270,6 +4526,7 @@ function healthField(kind, item, go, set, clear) {
|
|
|
4270
4526
|
initial: item.healthOverride ? HEALTHS.indexOf(item.healthOverride) + 1 : 0,
|
|
4271
4527
|
onPick: (value) => {
|
|
4272
4528
|
if (value === "derived") return clear();
|
|
4529
|
+
if (value === "done") return go.ask(`What shipped? (optional, shows with the health)`, (note) => set("done", note.trim()));
|
|
4273
4530
|
go.ask(`Why is it ${HEALTH_LABEL[value].toLowerCase()}? (shows with the health)`, (note) => set(value, note.trim()));
|
|
4274
4531
|
}
|
|
4275
4532
|
}
|
|
@@ -4446,6 +4703,7 @@ function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
|
4446
4703
|
function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
4447
4704
|
const { store, today } = useDeps();
|
|
4448
4705
|
const project = useHarbor((s) => s.projects.find((p) => p.id === id));
|
|
4706
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
4449
4707
|
const people = useHarbor((s) => s.people);
|
|
4450
4708
|
const programs = useHarbor((s) => s.programs);
|
|
4451
4709
|
if (!project) return null;
|
|
@@ -4460,7 +4718,7 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
4460
4718
|
onSave: (v) => v.trim() && up({ name: v.trim() })
|
|
4461
4719
|
}
|
|
4462
4720
|
},
|
|
4463
|
-
healthField("project", project, go, (h, n) => store.getState().setHealthOverride("project", id, h, n), () => store.getState().clearHealthOverride("project", id)),
|
|
4721
|
+
healthField("project", project, go, (h, n) => store.getState().setHealthOverride("project", id, h, n), () => store.getState().clearHealthOverride("project", id), suggestsDone(project, tasks)),
|
|
4464
4722
|
{
|
|
4465
4723
|
label: "Lead",
|
|
4466
4724
|
value: people.find((p) => p.id === project.ownerId)?.name ?? "",
|
|
@@ -5248,7 +5506,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
5248
5506
|
health: program.health,
|
|
5249
5507
|
label: true
|
|
5250
5508
|
}),
|
|
5251
|
-
program
|
|
5509
|
+
overridesTrack(program) ? /* @__PURE__ */ jsxs(Text, {
|
|
5252
5510
|
color: theme.muted,
|
|
5253
5511
|
children: [
|
|
5254
5512
|
" (by hand: ",
|
|
@@ -5463,7 +5721,7 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
5463
5721
|
health: portfolio.health,
|
|
5464
5722
|
label: true
|
|
5465
5723
|
}),
|
|
5466
|
-
portfolio
|
|
5724
|
+
overridesTrack(portfolio) ? /* @__PURE__ */ jsxs(Text, {
|
|
5467
5725
|
color: theme.muted,
|
|
5468
5726
|
children: [
|
|
5469
5727
|
" (by hand: ",
|
|
@@ -5854,7 +6112,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5854
6112
|
color: on ? theme.accent : void 0,
|
|
5855
6113
|
wrap: "truncate-end",
|
|
5856
6114
|
children: [
|
|
5857
|
-
c.label
|
|
6115
|
+
c.label,
|
|
5858
6116
|
" ",
|
|
5859
6117
|
/* @__PURE__ */ jsxs(Text, {
|
|
5860
6118
|
color: over ? theme.bad : theme.muted,
|
|
@@ -6195,15 +6453,15 @@ const REPEAT_LABEL = {
|
|
|
6195
6453
|
biweekly: "Every two weeks",
|
|
6196
6454
|
monthly: "Every month"
|
|
6197
6455
|
};
|
|
6198
|
-
const iso
|
|
6456
|
+
const iso = (d) => {
|
|
6199
6457
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
6200
6458
|
};
|
|
6201
6459
|
/** The due date one interval after `from`. */
|
|
6202
6460
|
function nextDueDate(from, repeat) {
|
|
6203
6461
|
const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
|
|
6204
|
-
if (repeat === "weekly") return iso
|
|
6205
|
-
if (repeat === "biweekly") return iso
|
|
6206
|
-
return iso
|
|
6462
|
+
if (repeat === "weekly") return iso(addDays(d, 7));
|
|
6463
|
+
if (repeat === "biweekly") return iso(addDays(d, 14));
|
|
6464
|
+
return iso(addMonths(d, 1));
|
|
6207
6465
|
}
|
|
6208
6466
|
/**
|
|
6209
6467
|
* The next occurrence of a repeating task once this one is done: same
|
|
@@ -7454,244 +7712,6 @@ function App({ ui }) {
|
|
|
7454
7712
|
});
|
|
7455
7713
|
}
|
|
7456
7714
|
|
|
7457
|
-
//#endregion
|
|
7458
|
-
//#region src/lib/health-history.ts
|
|
7459
|
-
/** The history with one more line when `health` differs from the last recorded one. Same array otherwise. */
|
|
7460
|
-
function recordHealth(history, health, at, override, note) {
|
|
7461
|
-
const last = history?.[history.length - 1];
|
|
7462
|
-
if (last && last.health === health && last.override === override && (last.note ?? null) === (note ?? null)) return history;
|
|
7463
|
-
return [...history ?? [], {
|
|
7464
|
-
at,
|
|
7465
|
-
health,
|
|
7466
|
-
override,
|
|
7467
|
-
note: note ?? null
|
|
7468
|
-
}].slice(-30);
|
|
7469
|
-
}
|
|
7470
|
-
|
|
7471
|
-
//#endregion
|
|
7472
|
-
//#region src/lib/derived-health.ts
|
|
7473
|
-
const iso = (d) => {
|
|
7474
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
7475
|
-
};
|
|
7476
|
-
function parseDate(value) {
|
|
7477
|
-
return parseIso(value.length === 10 ? `${value}T12:00:00` : value);
|
|
7478
|
-
}
|
|
7479
|
-
function isBlocked(task, tasks) {
|
|
7480
|
-
return blockersIn(task, tasks).some((b) => b.status !== "done");
|
|
7481
|
-
}
|
|
7482
|
-
/** A project with no tasks yet, or any task still open, hasn't cleared. */
|
|
7483
|
-
function projectHasOpenWork(data, projectId) {
|
|
7484
|
-
const tasks = tasksOf(data.tasks, projectId);
|
|
7485
|
-
return tasks.length === 0 || tasks.some((t) => t.status !== "done");
|
|
7486
|
-
}
|
|
7487
|
-
/** Every project this one waits on that still exists. */
|
|
7488
|
-
function projectBlockersOf(data, project) {
|
|
7489
|
-
return blockersIn(project, data.projects);
|
|
7490
|
-
}
|
|
7491
|
-
/** The blockers with open work left: the ones actually holding it up. */
|
|
7492
|
-
function projectOpenBlockers(data, project) {
|
|
7493
|
-
return projectBlockersOf(data, project).filter((b) => projectHasOpenWork(data, b.id));
|
|
7494
|
-
}
|
|
7495
|
-
function projectIsBlocked(data, project) {
|
|
7496
|
-
return projectOpenBlockers(data, project).length > 0;
|
|
7497
|
-
}
|
|
7498
|
-
/** A program with no projects yet, or any project still open, hasn't cleared. */
|
|
7499
|
-
function programHasOpenWork(data, programId) {
|
|
7500
|
-
const projects = data.projects.filter((p) => p.programId === programId);
|
|
7501
|
-
return projects.length === 0 || projects.some((p) => projectHasOpenWork(data, p.id));
|
|
7502
|
-
}
|
|
7503
|
-
function programBlockersOf(data, program) {
|
|
7504
|
-
return blockersIn(program, data.programs);
|
|
7505
|
-
}
|
|
7506
|
-
function programOpenBlockers(data, program) {
|
|
7507
|
-
return programBlockersOf(data, program).filter((b) => programHasOpenWork(data, b.id));
|
|
7508
|
-
}
|
|
7509
|
-
function programIsBlocked(data, program) {
|
|
7510
|
-
return programOpenBlockers(data, program).length > 0;
|
|
7511
|
-
}
|
|
7512
|
-
let overdueDay = "";
|
|
7513
|
-
const overdueByDue = /* @__PURE__ */ new Map();
|
|
7514
|
-
function daysOverdue(dueDate, now) {
|
|
7515
|
-
if (!dueDate) return 0;
|
|
7516
|
-
const today = iso(now);
|
|
7517
|
-
if (today !== overdueDay) {
|
|
7518
|
-
overdueDay = today;
|
|
7519
|
-
overdueByDue.clear();
|
|
7520
|
-
}
|
|
7521
|
-
const hit = overdueByDue.get(dueDate);
|
|
7522
|
-
if (hit !== void 0) return hit;
|
|
7523
|
-
let days = 0;
|
|
7524
|
-
try {
|
|
7525
|
-
const n = differenceInCalendarDays(startOfDay(now), startOfDay(parseDate(dueDate)));
|
|
7526
|
-
days = n > 0 ? n : 0;
|
|
7527
|
-
} catch {
|
|
7528
|
-
days = 0;
|
|
7529
|
-
}
|
|
7530
|
-
overdueByDue.set(dueDate, days);
|
|
7531
|
-
return days;
|
|
7532
|
-
}
|
|
7533
|
-
let untilDay = "";
|
|
7534
|
-
const untilByDate = /* @__PURE__ */ new Map();
|
|
7535
|
-
function daysUntil(date, now) {
|
|
7536
|
-
if (!date) return null;
|
|
7537
|
-
const today = iso(now);
|
|
7538
|
-
if (today !== untilDay) {
|
|
7539
|
-
untilDay = today;
|
|
7540
|
-
untilByDate.clear();
|
|
7541
|
-
}
|
|
7542
|
-
const hit = untilByDate.get(date);
|
|
7543
|
-
if (hit !== void 0) return hit;
|
|
7544
|
-
let days;
|
|
7545
|
-
try {
|
|
7546
|
-
days = differenceInCalendarDays(startOfDay(parseDate(date)), startOfDay(now));
|
|
7547
|
-
} catch {
|
|
7548
|
-
days = null;
|
|
7549
|
-
}
|
|
7550
|
-
untilByDate.set(date, days);
|
|
7551
|
-
return days;
|
|
7552
|
-
}
|
|
7553
|
-
function incomplete(task) {
|
|
7554
|
-
return task.status !== "done";
|
|
7555
|
-
}
|
|
7556
|
-
function wipLimitOf(status, stored, uncapped) {
|
|
7557
|
-
if (stored != null) return stored;
|
|
7558
|
-
if (uncapped === true) return null;
|
|
7559
|
-
if (status === "doing") return 3;
|
|
7560
|
-
if (status === "review") return 2;
|
|
7561
|
-
return null;
|
|
7562
|
-
}
|
|
7563
|
-
function wipOverflowing(data, projectId) {
|
|
7564
|
-
const cols = data.boardConfigs?.[projectId]?.columns;
|
|
7565
|
-
if (cols?.length) return cols.some((c) => {
|
|
7566
|
-
if (c.visible === false) return false;
|
|
7567
|
-
const limit = wipLimitOf(c.status, c.wipLimit, c.uncapped);
|
|
7568
|
-
if (limit == null) return false;
|
|
7569
|
-
return tasksOf(data.tasks, projectId).filter((t) => t.status === c.status).length > limit;
|
|
7570
|
-
});
|
|
7571
|
-
const mine = tasksOf(data.tasks, projectId);
|
|
7572
|
-
const doing = mine.filter((t) => t.status === "doing").length;
|
|
7573
|
-
const review = mine.filter((t) => t.status === "review").length;
|
|
7574
|
-
return doing > 3 || review > 2;
|
|
7575
|
-
}
|
|
7576
|
-
function deriveProjectHealth(project, data, now) {
|
|
7577
|
-
const tasks = tasksOf(data.tasks, project.id);
|
|
7578
|
-
const open = tasks.filter(incomplete);
|
|
7579
|
-
const maxOverdue = open.map((t) => daysOverdue(t.dueDate, now)).reduce((a, b) => Math.max(a, b), 0);
|
|
7580
|
-
const until = daysUntil(project.endDate, now);
|
|
7581
|
-
const unfinished = open.length > 0 || tasks.length === 0 && until != null && until < 0;
|
|
7582
|
-
if (maxOverdue > 3 || until != null && until < 0 && unfinished) return "off-track";
|
|
7583
|
-
const blockers = open.some((t) => isBlocked(t, data.tasks)) || projectIsBlocked(data, project);
|
|
7584
|
-
const wip = wipOverflowing(data, project.id);
|
|
7585
|
-
if (maxOverdue >= 1 || until != null && until >= 0 && until <= 7 && (blockers || wip)) return "at-risk";
|
|
7586
|
-
return "on-track";
|
|
7587
|
-
}
|
|
7588
|
-
function worstTrack(healths) {
|
|
7589
|
-
if (healths.some((h) => h === "off-track")) return "off-track";
|
|
7590
|
-
if (healths.some((h) => h === "at-risk")) return "at-risk";
|
|
7591
|
-
return "on-track";
|
|
7592
|
-
}
|
|
7593
|
-
/** The override wins when set; Paused and Done are overrides too, and stay until reset. */
|
|
7594
|
-
function effectiveProjectHealth(project, data, now) {
|
|
7595
|
-
return project.healthOverride ?? deriveProjectHealth(project, data, now);
|
|
7596
|
-
}
|
|
7597
|
-
/** Milestones past their date and still open. */
|
|
7598
|
-
function lateMilestonesOf(milestones, now) {
|
|
7599
|
-
const today = iso(now);
|
|
7600
|
-
return milestones.filter((m) => !m.done && m.dueDate < today);
|
|
7601
|
-
}
|
|
7602
|
-
/**
|
|
7603
|
-
* The projects an open milestone waits on that put its date in doubt: one
|
|
7604
|
-
* scheduled to end after the milestone is due, or one off track with work
|
|
7605
|
-
* still open. Finished projects and a reached milestone carry no risk.
|
|
7606
|
-
*/
|
|
7607
|
-
function milestoneRisks(milestone, data) {
|
|
7608
|
-
if (milestone.done) return [];
|
|
7609
|
-
return blockersIn(milestone, data.projects).flatMap((project) => {
|
|
7610
|
-
if (!projectHasOpenWork(data, project.id)) return [];
|
|
7611
|
-
if (project.endDate > milestone.dueDate) return [{
|
|
7612
|
-
project,
|
|
7613
|
-
reason: "ends-after"
|
|
7614
|
-
}];
|
|
7615
|
-
if (project.health === "off-track") return [{
|
|
7616
|
-
project,
|
|
7617
|
-
reason: "off-track"
|
|
7618
|
-
}];
|
|
7619
|
-
return [];
|
|
7620
|
-
});
|
|
7621
|
-
}
|
|
7622
|
-
/**
|
|
7623
|
-
* `projectHealth` defaults to working each project out afresh; stampHealth
|
|
7624
|
-
* passes the health it has just stamped, so no project is derived twice.
|
|
7625
|
-
*/
|
|
7626
|
-
function deriveProgramHealth(program, data, now, projectHealth = (p) => effectiveProjectHealth(p, data, now)) {
|
|
7627
|
-
const kids = data.projects.filter((p) => p.programId === program.id);
|
|
7628
|
-
const blocked = programIsBlocked(data, program) ? "at-risk" : "on-track";
|
|
7629
|
-
const ownMilestones = (data.milestones ?? []).filter((m) => m.programId === program.id);
|
|
7630
|
-
const lateMilestone = lateMilestonesOf(ownMilestones, now).length > 0 || ownMilestones.some((m) => milestoneRisks(m, data).length > 0) ? "at-risk" : "on-track";
|
|
7631
|
-
if (kids.length === 0) return worstTrack([blocked, lateMilestone]);
|
|
7632
|
-
return worstTrack([
|
|
7633
|
-
...kids.map(projectHealth),
|
|
7634
|
-
blocked,
|
|
7635
|
-
lateMilestone
|
|
7636
|
-
]);
|
|
7637
|
-
}
|
|
7638
|
-
function effectiveProgramHealth(program, data, now, projectHealth) {
|
|
7639
|
-
return program.healthOverride ?? deriveProgramHealth(program, data, now, projectHealth);
|
|
7640
|
-
}
|
|
7641
|
-
/** A paused or done program neither helps nor hurts the portfolio. */
|
|
7642
|
-
function derivePortfolioHealth(portfolioId, data, now, programHealth = (p) => effectiveProgramHealth(p, data, now)) {
|
|
7643
|
-
const kids = data.programs.filter((p) => p.portfolioId === portfolioId);
|
|
7644
|
-
if (kids.length === 0) return "on-track";
|
|
7645
|
-
return worstTrack(kids.map(programHealth));
|
|
7646
|
-
}
|
|
7647
|
-
function effectivePortfolioHealth(portfolio, data, now, programHealth) {
|
|
7648
|
-
return portfolio.healthOverride ?? derivePortfolioHealth(portfolio.id, data, now, programHealth);
|
|
7649
|
-
}
|
|
7650
|
-
/**
|
|
7651
|
-
* Re-derive every project, program and portfolio health.
|
|
7652
|
-
*
|
|
7653
|
-
* Reference-stable: an entity whose health did not change is returned as the
|
|
7654
|
-
* same object, and a collection with no changed entity as the same array, so
|
|
7655
|
-
* selectors that pick `projects`, `programs` or `portfolios` only re-render
|
|
7656
|
-
* when a health really moved.
|
|
7657
|
-
*/
|
|
7658
|
-
function stampHealth(data, now = clockNow()) {
|
|
7659
|
-
const at = now.toISOString();
|
|
7660
|
-
const projects = restamp(data.projects, (p) => effectiveProjectHealth(p, data, now), at);
|
|
7661
|
-
const mid = projects === data.projects ? data : {
|
|
7662
|
-
...data,
|
|
7663
|
-
projects
|
|
7664
|
-
};
|
|
7665
|
-
const programs = restamp(data.programs, (p) => effectiveProgramHealth(p, mid, now, (project) => project.health), at);
|
|
7666
|
-
const withPrograms = programs === mid.programs ? mid : {
|
|
7667
|
-
...mid,
|
|
7668
|
-
programs
|
|
7669
|
-
};
|
|
7670
|
-
const portfolios = restamp(data.portfolios, (p) => effectivePortfolioHealth(p, withPrograms, now, (program) => program.health), at);
|
|
7671
|
-
if (projects === data.projects && programs === data.programs && portfolios === data.portfolios) return data;
|
|
7672
|
-
return {
|
|
7673
|
-
...data,
|
|
7674
|
-
projects,
|
|
7675
|
-
programs,
|
|
7676
|
-
portfolios
|
|
7677
|
-
};
|
|
7678
|
-
}
|
|
7679
|
-
/** Every row whose health moved gets the new value and a line of history. */
|
|
7680
|
-
function restamp(rows, healthOf, at) {
|
|
7681
|
-
let changed = false;
|
|
7682
|
-
const next = rows.map((row) => {
|
|
7683
|
-
const health = healthOf(row);
|
|
7684
|
-
if (health === row.health) return row;
|
|
7685
|
-
changed = true;
|
|
7686
|
-
return {
|
|
7687
|
-
...row,
|
|
7688
|
-
health,
|
|
7689
|
-
healthHistory: recordHealth(row.healthHistory, health, at, Boolean(row.healthOverride), row.healthOverride ? row.healthNote : null)
|
|
7690
|
-
};
|
|
7691
|
-
});
|
|
7692
|
-
return changed ? next : rows;
|
|
7693
|
-
}
|
|
7694
|
-
|
|
7695
7715
|
//#endregion
|
|
7696
7716
|
//#region src/lib/payload.ts
|
|
7697
7717
|
const NONE = [];
|
|
@@ -8392,6 +8412,36 @@ function sampleProject(opts) {
|
|
|
8392
8412
|
};
|
|
8393
8413
|
}
|
|
8394
8414
|
|
|
8415
|
+
//#endregion
|
|
8416
|
+
//#region src/lib/empty.ts
|
|
8417
|
+
/**
|
|
8418
|
+
* A name worth offering in the harbor-name field: "Sam's harbor", or "" when
|
|
8419
|
+
* nobody is known yet, so the field stays empty and shows its placeholder.
|
|
8420
|
+
*/
|
|
8421
|
+
function harborNameFor(displayName, email) {
|
|
8422
|
+
const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
|
|
8423
|
+
return first ? `${first}'s harbor` : "";
|
|
8424
|
+
}
|
|
8425
|
+
/** What a new harbor is called until its owner names it: "Sam's harbor", else "My harbor". */
|
|
8426
|
+
function suggestedHarborName(displayName, email) {
|
|
8427
|
+
return harborNameFor(displayName, email) || "My harbor";
|
|
8428
|
+
}
|
|
8429
|
+
/** What a new harbor calls its first program until someone names it. */
|
|
8430
|
+
const STARTER_PROGRAM = "The work";
|
|
8431
|
+
|
|
8432
|
+
//#endregion
|
|
8433
|
+
//#region src/lib/first-week.ts
|
|
8434
|
+
/**
|
|
8435
|
+
* The few things a new harbor needs in its first week, in the order they
|
|
8436
|
+
* matter: a real name on the account, a name for the program launch made up,
|
|
8437
|
+
* a first project, and someone else aboard. Today lists the ones not done.
|
|
8438
|
+
* Someone who joined is asked only for their name.
|
|
8439
|
+
*/
|
|
8440
|
+
/** The program launch named for you, when nobody has renamed it yet. */
|
|
8441
|
+
function starterProgram(data) {
|
|
8442
|
+
return data.programs.find((p) => p.name.trim() === "The work") ?? null;
|
|
8443
|
+
}
|
|
8444
|
+
|
|
8395
8445
|
//#endregion
|
|
8396
8446
|
//#region src/lib/slices/hierarchy.ts
|
|
8397
8447
|
/** Portfolios, programs and projects: create, edit, custom fields, automations, health overrides, boards and cascading delete. */
|
|
@@ -8409,6 +8459,18 @@ function hierarchySlice({ get, write }) {
|
|
|
8409
8459
|
return id;
|
|
8410
8460
|
},
|
|
8411
8461
|
addProgram: (p) => {
|
|
8462
|
+
const starter = p.name.trim() === "The work" ? null : starterProgram(get());
|
|
8463
|
+
if (starter) {
|
|
8464
|
+
const row = {
|
|
8465
|
+
...starter,
|
|
8466
|
+
...p,
|
|
8467
|
+
id: starter.id,
|
|
8468
|
+
health: starter.health
|
|
8469
|
+
};
|
|
8470
|
+
write({ programs: get().programs.map((g) => g.id === starter.id ? row : g) });
|
|
8471
|
+
get().log(`opened program ${row.name}`, `/programs/${starter.id}`);
|
|
8472
|
+
return starter.id;
|
|
8473
|
+
}
|
|
8412
8474
|
const id = uid("pg");
|
|
8413
8475
|
const row = {
|
|
8414
8476
|
health: "on-track",
|
|
@@ -10373,21 +10435,6 @@ function createUiStore() {
|
|
|
10373
10435
|
}));
|
|
10374
10436
|
}
|
|
10375
10437
|
|
|
10376
|
-
//#endregion
|
|
10377
|
-
//#region src/lib/empty.ts
|
|
10378
|
-
/**
|
|
10379
|
-
* A name worth offering in the harbor-name field: "Sam's harbor", or "" when
|
|
10380
|
-
* nobody is known yet, so the field stays empty and shows its placeholder.
|
|
10381
|
-
*/
|
|
10382
|
-
function harborNameFor(displayName, email) {
|
|
10383
|
-
const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
|
|
10384
|
-
return first ? `${first}'s harbor` : "";
|
|
10385
|
-
}
|
|
10386
|
-
/** What a new harbor is called until its owner names it: "Sam's harbor", else "My harbor". */
|
|
10387
|
-
function suggestedHarborName(displayName, email) {
|
|
10388
|
-
return harborNameFor(displayName, email) || "My harbor";
|
|
10389
|
-
}
|
|
10390
|
-
|
|
10391
10438
|
//#endregion
|
|
10392
10439
|
//#region src/tui/first-run.ts
|
|
10393
10440
|
/**
|
package/lib/cli.mjs
CHANGED
|
@@ -77,7 +77,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
77
77
|
[--ends DATE]
|
|
78
78
|
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
79
79
|
[--ends DATE] [--budget N]
|
|
80
|
-
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
80
|
+
harbor add "TITLE" --project P [--assignee me|none|NAME] [--due DATE]
|
|
81
81
|
[--priority low|medium|high|urgent] [--status S]
|
|
82
82
|
[--over-limit]
|
|
83
83
|
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
@@ -490,7 +490,7 @@ export async function run(argv, io = {}) {
|
|
|
490
490
|
if (!hasFullAccess(creds)) {
|
|
491
491
|
out(
|
|
492
492
|
creds
|
|
493
|
-
? "
|
|
493
|
+
? "This sign-in can only read, and the full-screen app needs full access, as on the website. Signing in again with full access; the commands will have it too (harbor login --read-only goes back to reading only)…"
|
|
494
494
|
: "Signing in first…",
|
|
495
495
|
);
|
|
496
496
|
const code = await run(
|
|
@@ -934,11 +934,15 @@ export async function run(argv, io = {}) {
|
|
|
934
934
|
return 0;
|
|
935
935
|
}
|
|
936
936
|
const as = r.role === "viewer" ? "someone who can view (not change)" : "crew who can edit";
|
|
937
|
+
const again = r.again
|
|
938
|
+
? `${r.email} was already invited; the earlier link has stopped working and this one replaces it.\n`
|
|
939
|
+
: "";
|
|
937
940
|
out(
|
|
938
941
|
plain(
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
+
again +
|
|
943
|
+
(r.sent
|
|
944
|
+
? `Invited ${r.email} to ${r.harbor} as ${as}. Harbor emailed them the invite; they join on the website or with harbor login.`
|
|
945
|
+
: `Made an invite for ${r.email} to ${r.harbor} as ${as}, but Harbor didn't email it (it sends invites once your own email address is confirmed). Send them this link yourself:\n\n ${r.link}`),
|
|
942
946
|
),
|
|
943
947
|
);
|
|
944
948
|
return 0;
|
|
@@ -966,7 +970,7 @@ export async function run(argv, io = {}) {
|
|
|
966
970
|
}
|
|
967
971
|
out(
|
|
968
972
|
plain(
|
|
969
|
-
`${values.new ? "A new calendar link" : "Your calendar link"} for ${what}${values.new ? " (the old one has stopped working)" : ""}:\n\n ${r.url}\n\nAdd it to Google, Apple or Outlook calendar as a calendar from a URL. Anyone with the link can see these dates; harbor calendar --new swaps it for a fresh one.`,
|
|
973
|
+
`${values.new ? "A new calendar link" : r.fresh ? "A new calendar link (none was on)" : "Your calendar link"} for ${what}${values.new ? " (the old one has stopped working)" : ""}:\n\n ${r.url}\n\nAdd it to Google, Apple or Outlook calendar as a calendar from a URL. Anyone with the link can see these dates; harbor calendar --new swaps it for a fresh one.`,
|
|
970
974
|
),
|
|
971
975
|
);
|
|
972
976
|
return 0;
|
|
@@ -982,7 +986,8 @@ export async function run(argv, io = {}) {
|
|
|
982
986
|
harbor,
|
|
983
987
|
project: values.project,
|
|
984
988
|
title: text(positionals, "the task"),
|
|
985
|
-
|
|
989
|
+
// Yours unless you say otherwise, as with n in the app.
|
|
990
|
+
assignee: values.assignee ?? "me",
|
|
986
991
|
due: dueDate(values.due),
|
|
987
992
|
priority: values.priority,
|
|
988
993
|
status: values.status,
|
|
@@ -1009,6 +1014,9 @@ export async function run(argv, io = {}) {
|
|
|
1009
1014
|
[
|
|
1010
1015
|
`${r.updated.title}: ${{ doing: "started", review: "in review", done: "done" }[status]}.`,
|
|
1011
1016
|
r.queuedNext ? `Next one: ${taskLine(r.queuedNext, opts)}` : "",
|
|
1017
|
+
r.projectFinished
|
|
1018
|
+
? `That was the last open task in ${r.projectFinished}. Mark the project Done in the app (harbor) or on the website.`
|
|
1019
|
+
: "",
|
|
1012
1020
|
]
|
|
1013
1021
|
.filter(Boolean)
|
|
1014
1022
|
.join("\n"),
|