@harborppm/cli 0.34.0 → 0.35.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 CHANGED
@@ -128,6 +128,7 @@ harbor brief [--portfolio P] [--say] the weekly brief, ready to send
128
128
  harbor report PROGRAM a program's status report
129
129
  harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
130
130
  harbor voyage PROJECT ["YOUR LINE"]
131
+ harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
131
132
  harbor limit PROJECT [COLUMN N|none]
132
133
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
133
134
  harbor handback PERSON
@@ -137,7 +138,7 @@ harbor handback PERSON
137
138
 
138
139
  `harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
139
140
 
140
- `harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours.
141
+ `harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief.
141
142
 
142
143
  `harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux).
143
144
 
package/dist/app.mjs CHANGED
@@ -3524,6 +3524,123 @@ function programLateCount(programId, data, now) {
3524
3524
  return late;
3525
3525
  }
3526
3526
 
3527
+ //#endregion
3528
+ //#region src/lib/bottle-rules.ts
3529
+ /**
3530
+ * What a note for later needs before it's sealed (the rest is in bottle.ts).
3531
+ * Apart so the store's action carries only this into every page's first load.
3532
+ */
3533
+ /** Longest note kept. */
3534
+ const BOTTLE_MAX = 1e3;
3535
+ /**
3536
+ * Why a new note can't be sealed, or null when it can: nothing written, a
3537
+ * date that isn't after today (it would open at once), or a milestone that
3538
+ * isn't this project's program's or is already reached.
3539
+ */
3540
+ function bottleProblem(body, opens, ctx) {
3541
+ if (!body.trim()) return "Write the note first.";
3542
+ if (opens.on === "date") {
3543
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(opens.date)) return "Pick the day it opens.";
3544
+ if (opens.date <= todayISO(ctx.now)) return "Pick a day after today.";
3545
+ }
3546
+ if (opens.on === "milestone") {
3547
+ const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
3548
+ if (!m || m.programId !== ctx.programId) return "Pick one of this program's milestones.";
3549
+ if (m.done) return `${m.name} is already reached.`;
3550
+ }
3551
+ return null;
3552
+ }
3553
+
3554
+ //#endregion
3555
+ //#region src/lib/bottle.ts
3556
+ /**
3557
+ * Message in a bottle: a note someone leaves on a project now that opens
3558
+ * later, on a date, when one of the program's milestones is reached, or if
3559
+ * the project turns Off track. Until then the crew sees only that a sealed
3560
+ * note is there, who left it and what opens it; the writer can read their
3561
+ * own. Once open it sits at the top of the project and in the brief.
3562
+ *
3563
+ * Notes are `reflections` of kind "bottle". Whether one is open is worked
3564
+ * out from the project as it stands (nothing stamps it), so every screen,
3565
+ * the CLI and the agents agree. Like the pre-mortem's wait, the seal is a
3566
+ * courtesy the screens keep, not a secret from the server.
3567
+ */
3568
+ /** An opened note stays at the top of its project this many days, then folds away. */
3569
+ const BOTTLE_FRESH_DAYS = 14;
3570
+ const isBottle = (r) => r.kind === "bottle";
3571
+ /**
3572
+ * Whether a note is open, and since when. A date opens it that morning; a
3573
+ * milestone when it's marked done (dated by its due date, or today if that's
3574
+ * still ahead); Off track the first time the project went Off track after
3575
+ * the note was left. A milestone since deleted opens it, so nothing is
3576
+ * sealed for good.
3577
+ */
3578
+ function bottleState(note, ctx) {
3579
+ const today = todayISO(ctx.now);
3580
+ const opens = note.opens;
3581
+ if (!opens) return {
3582
+ open: true,
3583
+ openedOn: note.createdAt.slice(0, 10)
3584
+ };
3585
+ if (opens.on === "date") return today >= opens.date ? {
3586
+ open: true,
3587
+ openedOn: opens.date
3588
+ } : sealed;
3589
+ if (opens.on === "milestone") {
3590
+ const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
3591
+ if (!m) return {
3592
+ open: true,
3593
+ openedOn: today
3594
+ };
3595
+ if (!m.done) return sealed;
3596
+ return {
3597
+ open: true,
3598
+ openedOn: m.dueDate && m.dueDate < today ? m.dueDate : today
3599
+ };
3600
+ }
3601
+ const flip = (ctx.project.healthHistory ?? []).find((h) => h.health === "off-track" && h.at >= note.createdAt);
3602
+ if (flip) return {
3603
+ open: true,
3604
+ openedOn: flip.at.slice(0, 10)
3605
+ };
3606
+ if (ctx.project.health === "off-track") return {
3607
+ open: true,
3608
+ openedOn: today
3609
+ };
3610
+ return sealed;
3611
+ }
3612
+ const sealed = {
3613
+ open: false,
3614
+ openedOn: null
3615
+ };
3616
+ /** Opened in the last `days` days, so it still belongs at the top and in the brief. */
3617
+ function isFresh(state, now, days = 14) {
3618
+ if (!state.open || !state.openedOn) return false;
3619
+ return differenceInCalendarDays(now, parseIso(state.openedOn)) < days;
3620
+ }
3621
+ /** Notes opened in the last fortnight across these projects, newest first: the brief's. */
3622
+ function freshlyOpened(data, projects, now) {
3623
+ const notes = (data.reflections ?? []).filter(isBottle);
3624
+ if (notes.length === 0) return [];
3625
+ const byProject = new Map(projects.map((p) => [p.id, p]));
3626
+ const out = [];
3627
+ for (const note of notes) {
3628
+ const project = byProject.get(note.projectId);
3629
+ if (!project) continue;
3630
+ const state = bottleState(note, {
3631
+ project,
3632
+ milestones: data.milestones,
3633
+ now
3634
+ });
3635
+ if (isFresh(state, now)) out.push({
3636
+ note,
3637
+ project,
3638
+ state
3639
+ });
3640
+ }
3641
+ return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
3642
+ }
3643
+
3527
3644
  //#endregion
3528
3645
  //#region src/lib/brief.ts
3529
3646
  const BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
@@ -3618,6 +3735,14 @@ function composeBriefBody(opts) {
3618
3735
  const lateMilestones = milestones.filter((m) => m.daysLate > 0);
3619
3736
  const quiet = opts.offTrack.length === 0 && opts.atRisk.length === 0 && opts.late.length === 0 && watch.length === 0 && lateMilestones.length === 0;
3620
3737
  const lines = [`Harbor brief — ${opts.workspace} — ${date}`, ""];
3738
+ if (opts.opened?.length) {
3739
+ lines.push("Notes that opened");
3740
+ for (const n of opts.opened) {
3741
+ const body = n.body.replace(/\s+/g, " ").trim();
3742
+ lines.push(`• ${n.project}${n.who ? `, from ${n.who}` : ""}: “${body}”`);
3743
+ }
3744
+ lines.push("");
3745
+ }
3621
3746
  const done = () => {
3622
3747
  if (!opts.shipped?.length) return;
3623
3748
  lines.push("Done this week");
@@ -3861,6 +3986,7 @@ function briefOf(data, opts = {}) {
3861
3986
  finished,
3862
3987
  said: saidThisWeek(data, now, portfolioId ? new Set(tasks.flatMap((t) => t.assigneeId ? [t.assigneeId] : [])) : null),
3863
3988
  late: lateLinesOf(data, tasks, now),
3989
+ opened: freshlyOpened(data, projects, now),
3864
3990
  talk,
3865
3991
  milestonesSoon,
3866
3992
  milestonesLate,
@@ -3906,6 +4032,11 @@ function composeBriefBodyFrom(brief, data, workspace) {
3906
4032
  program: data.programs.find((p) => p.id === m.programId)?.name ?? null,
3907
4033
  dueDate: m.dueDate,
3908
4034
  daysLate: -daysUntil(m.dueDate, brief.asOf)
4035
+ })),
4036
+ opened: brief.opened.map((b) => ({
4037
+ project: b.project.name,
4038
+ who: data.people.find((p) => p.id === b.note.authorId)?.name ?? null,
4039
+ body: b.note.body
3909
4040
  }))
3910
4041
  });
3911
4042
  }
@@ -7757,6 +7888,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7757
7888
  const projects = useHarbor((s) => s.projects);
7758
7889
  const people = useHarbor((s) => s.people);
7759
7890
  const boardConfigs = useHarbor((s) => s.boardConfigs);
7891
+ const reflections = useHarbor((s) => s.reflections);
7892
+ const milestones = useHarbor((s) => s.milestones);
7760
7893
  const project = projects.find((p) => p.id === projectId);
7761
7894
  const data = useMemo(() => ({
7762
7895
  tasks,
@@ -7893,7 +8026,12 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7893
8026
  const tide = tideLine(data, project, day);
7894
8027
  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);
7895
8028
  const lead = people.find((p) => p.id === project.ownerId);
7896
- const body = height - 3;
8029
+ const opened = freshlyOpened({
8030
+ reflections,
8031
+ milestones
8032
+ }, [project], /* @__PURE__ */ new Date(`${day}T12:00:00`))[0];
8033
+ const openedBy = opened ? people.find((p) => p.id === opened.note.authorId)?.name : null;
8034
+ const body = height - 3 - (opened ? 1 : 0);
7897
8035
  const fit = Math.max(1, Math.floor((width - 2) / 14));
7898
8036
  const firstColumn = Math.min(Math.max(0, col - Math.floor(fit / 2)), Math.max(0, columns.length - fit));
7899
8037
  const shownColumns = columns.slice(firstColumn, firstColumn + fit);
@@ -7968,6 +8106,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7968
8106
  ]
7969
8107
  })]
7970
8108
  }),
8109
+ opened && /* @__PURE__ */ jsxs(Text, {
8110
+ wrap: "truncate-end",
8111
+ color: theme.warn,
8112
+ children: [
8113
+ "A note opened",
8114
+ openedBy ? `, from ${openedBy}` : "",
8115
+ ":",
8116
+ " ",
8117
+ /* @__PURE__ */ jsxs(Text, { children: [
8118
+ "“",
8119
+ opened.note.body.replace(/\s+/g, " "),
8120
+ "”"
8121
+ ] })
8122
+ ]
8123
+ }),
7971
8124
  mode === "budget" ? /* @__PURE__ */ jsx(BudgetPane, {
7972
8125
  project,
7973
8126
  spendEntries,
@@ -11322,13 +11475,36 @@ function milestonesRaidSlice({ set, get, write }) {
11322
11475
  return row.id;
11323
11476
  },
11324
11477
  editReflection: (id, body) => {
11325
- const text = body.trim().slice(0, 600);
11326
- if (!text) return;
11327
11478
  const me = get().currentUserId;
11328
- write({ reflections: (get().reflections ?? []).map((r) => r.id === id && r.authorId === me ? {
11329
- ...r,
11330
- body: text
11331
- } : r) });
11479
+ write({ reflections: (get().reflections ?? []).map((r) => {
11480
+ if (r.id !== id || r.authorId !== me) return r;
11481
+ const text = body.trim().slice(0, r.kind === "bottle" ? BOTTLE_MAX : 600);
11482
+ return text ? {
11483
+ ...r,
11484
+ body: text
11485
+ } : r;
11486
+ }) });
11487
+ },
11488
+ leaveBottle: (projectId, body, opens) => {
11489
+ const s = get();
11490
+ const project = s.projects.find((p) => p.id === projectId);
11491
+ if (!project) return null;
11492
+ if (bottleProblem(body, opens, {
11493
+ programId: project.programId,
11494
+ milestones: s.milestones,
11495
+ now: clockNow()
11496
+ })) return null;
11497
+ const row = {
11498
+ id: uid("rf"),
11499
+ projectId,
11500
+ kind: "bottle",
11501
+ authorId: s.currentUserId,
11502
+ body: body.trim().slice(0, BOTTLE_MAX),
11503
+ createdAt: stamp(),
11504
+ opens
11505
+ };
11506
+ write({ reflections: [...s.reflections ?? [], row] });
11507
+ return row.id;
11332
11508
  },
11333
11509
  deleteReflection: (id) => {
11334
11510
  const me = get().currentUserId;
package/lib/cli.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  hillList,
27
27
  myWork,
28
28
  premortemText,
29
+ notesText,
29
30
  voyageText,
30
31
  handoverText,
31
32
  raidList,
@@ -117,6 +118,9 @@ export const HELP = `harbor: Harbor from the terminal
117
118
  answer or read it
118
119
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
119
120
  your line in it
121
+ harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
122
+ a note for later: sealed until
123
+ its day, milestone, or Off track
120
124
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
121
125
  [--keep ID,ID] [--confirm]
122
126
  hand someone's work to the crew
@@ -289,6 +293,8 @@ const FLAG = {
289
293
  column: "the column",
290
294
  limit: "the limit",
291
295
  at: "--at",
296
+ opensOn: "--opens",
297
+ milestone: "--milestone",
292
298
  until: "--until",
293
299
  to: "--to",
294
300
  keep: "--keep",
@@ -335,6 +341,10 @@ export function hint(message, project) {
335
341
  (_, name) =>
336
342
  `${name} has no pre-mortem running. Start one with harbor premortem "${project ?? name}" --start.`,
337
343
  )
344
+ .replace(
345
+ /^Say what opens the note: exactly one of opensOn, milestone or ifOffTrack\.$/,
346
+ "Say what opens it: --opens DATE, --milestone NAME or --off-track (one of them).",
347
+ )
338
348
  .replace(
339
349
  /^Nothing to change: pass a field to update\.$/,
340
350
  "Nothing to change. Say what with --title, --assignee, --due, --priority, --status, --notes, --repeat or --waits-on.",
@@ -396,6 +406,19 @@ const DETAIL = {
396
406
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
397
407
  " name, give the key.",
398
408
  ],
409
+ note: [
410
+ " harbor note PROJECT reads its notes for later: who left",
411
+ " each, what opens it, and the opened ones",
412
+ ' harbor note PROJECT "NOTE" --opens DATE',
413
+ " writes: seals a note that opens that day",
414
+ ' harbor note PROJECT "NOTE" --milestone NAME',
415
+ " writes: opens when that milestone is reached",
416
+ ' harbor note PROJECT "NOTE" --off-track',
417
+ " writes: opens if the project turns Off track",
418
+ "",
419
+ " Until it opens, the crew sees only that a note is there and who left it.",
420
+ " DATE: YYYY-MM-DD, tomorrow, or +N for N days from today.",
421
+ ],
399
422
  handover: [
400
423
  " harbor handover PERSON reads what would move, and to whom:",
401
424
  " their open tasks, sign-offs, risks",
@@ -1362,6 +1385,32 @@ export async function run(argv, io = {}) {
1362
1385
  limitsText(r.set.project, r.limits, r.tasks, r.hiddenColumns),
1363
1386
  );
1364
1387
  }
1388
+ case "note": {
1389
+ const { positionals, values, harbor } = parse(spec(["off-track"], ["opens", "milestone"]));
1390
+ const [project, ...words] = positionals;
1391
+ if (!project)
1392
+ throw new HarborError(
1393
+ 'Give a project, then the note: harbor note "NAME" "NOTE" --opens +30.',
1394
+ );
1395
+ const note = words.join(" ").trim();
1396
+ if (!note) {
1397
+ if (values.opens || values.milestone || values["off-track"])
1398
+ throw new HarborError("Write the note too, in quotes after the project.");
1399
+ return call("get_project", { harbor, project }, (r) => notesText(r.name, r.notesForLater));
1400
+ }
1401
+ return call(
1402
+ "leave_note",
1403
+ {
1404
+ harbor,
1405
+ project,
1406
+ note,
1407
+ ...(values.opens ? { opensOn: dueDate(values.opens) } : {}),
1408
+ ...(values.milestone ? { milestone: values.milestone } : {}),
1409
+ ...(values["off-track"] ? { ifOffTrack: true } : {}),
1410
+ },
1411
+ (r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
1412
+ );
1413
+ }
1365
1414
  case "voyage": {
1366
1415
  const { positionals, harbor } = parse(spec());
1367
1416
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -370,6 +370,35 @@ export function hillList(r) {
370
370
  return blocks.join("\n\n");
371
371
  }
372
372
 
373
+ /** A project's notes for later, as get_project lists them. */
374
+ export function notesText(project, notes) {
375
+ if (!notes?.length)
376
+ return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
377
+ const open = notes.filter((n) => n.open);
378
+ const sealed = notes.filter((n) => !n.open);
379
+ const lines = [`Notes for later · ${project}`];
380
+ for (const n of open) {
381
+ const when = n.openedOn ? `Opened ${n.openedOn}` : "Opened";
382
+ lines.push("", ` ${when} · left by ${n.yours ? "you" : (n.from ?? "someone")} on ${n.left}`);
383
+ lines.push(
384
+ ...String(n.note ?? "")
385
+ .split("\n")
386
+ .map((l) => ` ${l}`),
387
+ );
388
+ }
389
+ if (sealed.length) lines.push("");
390
+ for (const n of sealed) {
391
+ lines.push(` Sealed · ${n.yours ? "yours" : `from ${n.from ?? "someone"}`} · ${n.opens}`);
392
+ if (n.yours && n.note)
393
+ lines.push(
394
+ ...String(n.note)
395
+ .split("\n")
396
+ .map((l) => ` ${l}`),
397
+ );
398
+ }
399
+ return lines.join("\n");
400
+ }
401
+
373
402
  /** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
374
403
  export function handoverText(r, { confirmHint = true } = {}) {
375
404
  const items = r.items ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.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",