@harborppm/cli 0.20.0 → 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 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.healthOverride ? ` (set by hand: ${item.healthNote || "no note"})` : ""}`,
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.healthOverride ? /* @__PURE__ */ jsxs(Text, {
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.healthOverride ? /* @__PURE__ */ jsxs(Text, {
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.toUpperCase(),
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$1 = (d) => {
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$1(addDays(d, 7));
6205
- if (repeat === "biweekly") return iso$1(addDays(d, 14));
6206
- return iso$1(addMonths(d, 1));
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
- ? "The full-screen app needs full access, as on the website. Signing in again…"
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
- r.sent
940
- ? `Invited ${r.email} to ${r.harbor} as ${as}. Harbor emailed them the invite; they join on the website or with harbor login.`
941
- : `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
+ 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
- assignee: values.assignee,
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"),
@@ -1137,7 +1145,9 @@ export async function run(argv, io = {}) {
1137
1145
  const [project, column, value, ...extra] = positionals;
1138
1146
  if (!project) throw new HarborError("Give a project, like harbor limit RR doing 5.");
1139
1147
  if (!column) {
1140
- return call("get_project", { harbor, project }, (r) => limitsText(r.name, r.limits));
1148
+ return call("get_project", { harbor, project }, (r) =>
1149
+ limitsText(r.name, r.limits, r.tasks),
1150
+ );
1141
1151
  }
1142
1152
  if (value === undefined || extra.length) {
1143
1153
  throw new HarborError(`Give one limit for ${column}: a number like 5, or none.`);
@@ -1147,7 +1157,7 @@ export async function run(argv, io = {}) {
1147
1157
  throw new HarborError("A limit is a whole number from 1 to 20, or none.");
1148
1158
  }
1149
1159
  return call("set_column_limit", { harbor, project, column, limit }, (r) =>
1150
- limitsText(r.set.project, r.limits),
1160
+ limitsText(r.set.project, r.limits, r.tasks),
1151
1161
  );
1152
1162
  }
1153
1163
  case "voyage": {
package/lib/format.mjs CHANGED
@@ -144,12 +144,16 @@ export function projectList(r, { done = false } = {}) {
144
144
  );
145
145
  }
146
146
 
147
- /** A project's column limits: "doing 5, review no limit". */
148
- export function limitsText(name, limits) {
149
- const bits = Object.entries(limits ?? {}).map(
150
- ([status, n]) => `${STATUS[status] ?? status} ${n == null ? "no limit" : n}`,
151
- );
152
- return bits.length ? `${name}: ${bits.join(", ")}.` : `${name}'s board has no column limits.`;
147
+ /** A project's column limits with how full each is: "doing 4 of 5; no limit on backlog, todo, done". */
148
+ export function limitsText(name, limits, tasks = {}) {
149
+ const entries = Object.entries(limits ?? {});
150
+ const capped = entries
151
+ .filter(([, n]) => n != null)
152
+ .map(([status, n]) => `${STATUS[status] ?? status} ${tasks[status] ?? 0} of ${n}`);
153
+ const open = entries.filter(([, n]) => n == null).map(([status]) => STATUS[status] ?? status);
154
+ if (!capped.length) return `${name}'s board has no column limits.`;
155
+ const rest = open.length ? `; no limit on ${open.join(", ")}` : "";
156
+ return `${name}: ${capped.join(", ")}${rest}.`;
153
157
  }
154
158
 
155
159
  export function harbors(r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",