@harborppm/cli 0.32.0 → 0.33.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
@@ -129,8 +129,12 @@ 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
131
  harbor limit PROJECT [COLUMN N|none]
132
+ harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
133
+ harbor handback PERSON
132
134
  ```
133
135
 
136
+ `harbor handover Priya` lists everything Priya carries (open tasks, sign-offs waiting on her, risks and issues she owns, projects and programs she leads) and who should take each, from who has room this week. Nothing moves until you add `--confirm`. `--until DATE` is for time away: Priya shows as away on People, and `harbor handback Priya` gives back whatever is still with whoever took it. `--note` goes with each task and risk as a comment.
137
+
134
138
  `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.
135
139
 
136
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.
package/dist/app.mjs CHANGED
@@ -795,6 +795,9 @@ function money(n) {
795
795
  function shortDate(value) {
796
796
  return format(parseDate$1(value), "MMM d");
797
797
  }
798
+ function longDate(value) {
799
+ return format(parseDate$1(value), "EEE, MMM d");
800
+ }
798
801
  function isOverdue(value, done = false) {
799
802
  if (!value || done) return false;
800
803
  try {
@@ -10408,6 +10411,38 @@ function hierarchySlice({ get, write }) {
10408
10411
  get().log(`added the sample project`, `/projects/${id}`);
10409
10412
  return id;
10410
10413
  },
10414
+ addShelfPlan: (rows) => {
10415
+ const state = get();
10416
+ const starter = starterProgram(state);
10417
+ const program = starter ? {
10418
+ ...starter,
10419
+ ...rows.program,
10420
+ id: starter.id,
10421
+ portfolioId: starter.portfolioId,
10422
+ health: starter.health
10423
+ } : rows.program;
10424
+ const milestones = rows.milestones.map((m) => ({
10425
+ ...m,
10426
+ programId: program.id
10427
+ }));
10428
+ const project = {
10429
+ ...rows.project,
10430
+ programId: program.id
10431
+ };
10432
+ write({
10433
+ programs: starter ? state.programs.map((g) => g.id === starter.id ? program : g) : [...state.programs, program],
10434
+ milestones: [...state.milestones, ...milestones],
10435
+ projects: [...state.projects, project],
10436
+ tasks: [...state.tasks, ...rows.tasks],
10437
+ raidItems: [...state.raidItems, ...rows.raidItems],
10438
+ boardConfigs: {
10439
+ ...state.boardConfigs ?? {},
10440
+ [project.id]: defaultBoardConfig()
10441
+ }
10442
+ });
10443
+ get().log(`opened the ${rows.program.name} plan`, `/programs/${program.id}`);
10444
+ return program.id;
10445
+ },
10411
10446
  updatePortfolio: (id, patch) => {
10412
10447
  write({ portfolios: get().portfolios.map((p) => p.id === id ? {
10413
10448
  ...p,
@@ -11367,6 +11402,42 @@ function milestonesRaidSlice({ set, get, write }) {
11367
11402
  };
11368
11403
  }
11369
11404
 
11405
+ //#endregion
11406
+ //#region src/lib/handover.ts
11407
+ /** The line that travels with each handed item: who it came from, to whom, until when, and the note. */
11408
+ function handoverLine(opts) {
11409
+ const until = opts.until && opts.untilText ? `, until ${opts.untilText}` : "";
11410
+ const note = opts.note.trim();
11411
+ return `Handed over from ${opts.fromName} to ${opts.toName}${until}.${note ? ` ${note}` : ""}`;
11412
+ }
11413
+ /** What can go back after time away: moves whose item is still with the person who took it. */
11414
+ function returnable(data, person) {
11415
+ const moves = person.away?.moves ?? [];
11416
+ const tasks = byId(data.tasks);
11417
+ const milestones = byId(data.milestones);
11418
+ const raid = byId(data.raidItems);
11419
+ const projects = byId(data.projects);
11420
+ const programs = byId(data.programs);
11421
+ return moves.filter((m) => {
11422
+ switch (m.kind) {
11423
+ case "task": {
11424
+ const t = tasks.get(m.id);
11425
+ return Boolean(t && t.assigneeId === m.toId && t.status !== "done");
11426
+ }
11427
+ case "signoff": {
11428
+ const ms = milestones.get(m.id);
11429
+ return Boolean(ms && ms.approverId === m.toId && !ms.done);
11430
+ }
11431
+ case "raid": {
11432
+ const r = raid.get(m.id);
11433
+ return Boolean(r && r.ownerId === m.toId && isActive(r));
11434
+ }
11435
+ case "project": return projects.get(m.id)?.ownerId === m.toId;
11436
+ case "program": return programs.get(m.id)?.ownerId === m.toId;
11437
+ }
11438
+ });
11439
+ }
11440
+
11370
11441
  //#endregion
11371
11442
  //#region src/lib/allocation.ts
11372
11443
  /** Allocations with `programId` set to `percent`; 0 or null removes the line. */
@@ -11390,6 +11461,57 @@ function peopleSpendSlice({ set, get, write }) {
11390
11461
  allocations: withAllocation(p.allocations, programId, percent)
11391
11462
  } : p) });
11392
11463
  },
11464
+ handOver: (fromId, moves, opts) => {
11465
+ const from = get().people.find((p) => p.id === fromId);
11466
+ if (!from) return 0;
11467
+ const until = opts.until || null;
11468
+ const applied = applyMoves(get(), moves, (m) => ({
11469
+ fromName: from.name,
11470
+ toName: get().people.find((p) => p.id === m.toId)?.name ?? "someone",
11471
+ until,
11472
+ untilText: until ? longDate(until) : null,
11473
+ note: opts.note ?? ""
11474
+ }));
11475
+ write({
11476
+ ...applied.patch,
11477
+ people: get().people.map((p) => p.id === fromId ? {
11478
+ ...p,
11479
+ away: until ? {
11480
+ until,
11481
+ note: opts.note?.trim() || null,
11482
+ since: stamp(),
11483
+ moves: applied.moved
11484
+ } : null
11485
+ } : p)
11486
+ });
11487
+ get().log(until ? `handed over ${from.name}’s work until ${longDate(until)}` : `handed over ${from.name}’s work`, "/people");
11488
+ return applied.moved.length;
11489
+ },
11490
+ handBack: (personId) => {
11491
+ const person = get().people.find((p) => p.id === personId);
11492
+ if (!person?.away) return 0;
11493
+ const back = returnable(get(), person).map((m) => ({
11494
+ ...m,
11495
+ toId: personId,
11496
+ fromId: m.toId
11497
+ }));
11498
+ const applied = applyMoves(get(), back, (m) => ({
11499
+ fromName: get().people.find((p) => p.id === m.fromId)?.name ?? "someone",
11500
+ toName: person.name,
11501
+ until: null,
11502
+ untilText: null,
11503
+ note: "Back from time away."
11504
+ }));
11505
+ write({
11506
+ ...applied.patch,
11507
+ people: get().people.map((p) => p.id === personId ? {
11508
+ ...p,
11509
+ away: null
11510
+ } : p)
11511
+ });
11512
+ get().log(`handed ${person.name}’s work back`, "/people");
11513
+ return applied.moved.length;
11514
+ },
11393
11515
  updatePerson: (id, patch) => {
11394
11516
  write({ people: get().people.map((p) => p.id === id ? {
11395
11517
  ...p,
@@ -11484,6 +11606,95 @@ function peopleSpendSlice({ set, get, write }) {
11484
11606
  }
11485
11607
  };
11486
11608
  }
11609
+ /**
11610
+ * One handover's changes as a single write: each item to its new person, the
11611
+ * line that travels with it (a comment on a task, an update on a RAID item),
11612
+ * and the moves that actually applied. Done or missing items are skipped.
11613
+ */
11614
+ function applyMoves(s, moves, lineFor) {
11615
+ const by = s.currentUserId;
11616
+ const at = stamp();
11617
+ const want = (kind) => new Map(moves.filter((m) => m.kind === kind).map((m) => [m.id, m]));
11618
+ const tasksTo = want("task");
11619
+ const signoffsTo = want("signoff");
11620
+ const raidTo = want("raid");
11621
+ const projectsTo = want("project");
11622
+ const programsTo = want("program");
11623
+ const moved = [];
11624
+ const done = (m) => moved.push({
11625
+ kind: m.kind,
11626
+ id: m.id,
11627
+ toId: m.toId
11628
+ });
11629
+ const comments = [];
11630
+ const patch = {};
11631
+ if (tasksTo.size) patch.tasks = s.tasks.map((t) => {
11632
+ const m = tasksTo.get(t.id);
11633
+ if (!m || t.status === "done" || t.assigneeId === m.toId) return t;
11634
+ done(m);
11635
+ comments.push({
11636
+ id: uid("c"),
11637
+ taskId: t.id,
11638
+ authorId: by,
11639
+ body: handoverLine(lineFor(m)),
11640
+ createdAt: at,
11641
+ mentionedIds: []
11642
+ });
11643
+ return stampAssignment(t, {
11644
+ ...t,
11645
+ assigneeId: m.toId
11646
+ }, by, at);
11647
+ });
11648
+ if (comments.length) patch.comments = [...s.comments, ...comments];
11649
+ if (signoffsTo.size) patch.milestones = s.milestones.map((ms) => {
11650
+ const m = signoffsTo.get(ms.id);
11651
+ if (!m || ms.done || ms.approverId === m.toId) return ms;
11652
+ done(m);
11653
+ return {
11654
+ ...ms,
11655
+ approverId: m.toId
11656
+ };
11657
+ });
11658
+ if (raidTo.size) patch.raidItems = s.raidItems.map((r) => {
11659
+ const m = raidTo.get(r.id);
11660
+ if (!m || r.ownerId === m.toId) return r;
11661
+ done(m);
11662
+ const update = {
11663
+ id: uid("ru"),
11664
+ authorId: by,
11665
+ body: handoverLine(lineFor(m)),
11666
+ createdAt: at
11667
+ };
11668
+ return {
11669
+ ...r,
11670
+ ownerId: m.toId,
11671
+ updates: [...r.updates, update],
11672
+ updatedAt: at
11673
+ };
11674
+ });
11675
+ if (projectsTo.size) patch.projects = s.projects.map((p) => {
11676
+ const m = projectsTo.get(p.id);
11677
+ if (!m || p.ownerId === m.toId) return p;
11678
+ done(m);
11679
+ return {
11680
+ ...p,
11681
+ ownerId: m.toId
11682
+ };
11683
+ });
11684
+ if (programsTo.size) patch.programs = s.programs.map((p) => {
11685
+ const m = programsTo.get(p.id);
11686
+ if (!m || p.ownerId === m.toId) return p;
11687
+ done(m);
11688
+ return {
11689
+ ...p,
11690
+ ownerId: m.toId
11691
+ };
11692
+ });
11693
+ return {
11694
+ patch,
11695
+ moved
11696
+ };
11697
+ }
11487
11698
 
11488
11699
  //#endregion
11489
11700
  //#region src/tui/engine/store.ts
@@ -11765,6 +11976,7 @@ const FIELD = {
11765
11976
  role: "role",
11766
11977
  weeklyCapacity: "weekly capacity",
11767
11978
  allocations: "time split",
11979
+ away: "time away",
11768
11980
  location: "location",
11769
11981
  note: "note"
11770
11982
  };
package/lib/cli.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  myWork,
28
28
  premortemText,
29
29
  voyageText,
30
+ handoverText,
30
31
  raidList,
31
32
  programTree,
32
33
  projectList,
@@ -116,6 +117,10 @@ export const HELP = `harbor: Harbor from the terminal
116
117
  answer or read it
117
118
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
118
119
  your line in it
120
+ harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
121
+ [--keep ID,ID] [--confirm]
122
+ hand someone's work to the crew
123
+ harbor handback PERSON give it back after time away
119
124
 
120
125
  Every command takes --json, and --harbor NAME when you're in more than one
121
126
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
@@ -284,6 +289,10 @@ const FLAG = {
284
289
  column: "the column",
285
290
  limit: "the limit",
286
291
  at: "--at",
292
+ until: "--until",
293
+ to: "--to",
294
+ keep: "--keep",
295
+ person: "the person",
287
296
  };
288
297
 
289
298
  /** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
@@ -377,6 +386,21 @@ const DETAIL = {
377
386
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
378
387
  " name, give the key.",
379
388
  ],
389
+ handover: [
390
+ " harbor handover PERSON reads what would move, and to whom:",
391
+ " their open tasks, sign-offs, risks",
392
+ " and issues, and what they lead",
393
+ " harbor handover PERSON --confirm writes: hands it over as shown",
394
+ "",
395
+ " --to NAME all of it to one person, instead of who has room",
396
+ " --until DATE time away: they show as away, and harbor handback PERSON",
397
+ " (or People on the website) gives back what's still with",
398
+ " whoever took it",
399
+ ' --note "TEXT" goes with each task and risk as a comment',
400
+ " --keep ID,ID leave these where they are (ids or keys from the list)",
401
+ "",
402
+ ' PERSON is a name, an id, or "me".',
403
+ ],
380
404
  voyage: [
381
405
  " harbor voyage PROJECT reads a Done project's voyage log",
382
406
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
@@ -1340,6 +1364,52 @@ export async function run(argv, io = {}) {
1340
1364
  }
1341
1365
  return call("get_voyage_log", { harbor, project }, voyageText);
1342
1366
  }
1367
+ case "handover": {
1368
+ const { positionals, values, harbor } = parse(
1369
+ spec(["confirm"], ["to", "until", "note", "keep"]),
1370
+ );
1371
+ const person = positionals.join(" ").trim();
1372
+ if (!person) throw new HarborError('Say whose work: harbor handover "NAME" (or me).');
1373
+ if (!values.confirm) {
1374
+ return call("handover_plan", { harbor, person, to: values.to }, (r) => handoverText(r));
1375
+ }
1376
+ return call(
1377
+ "hand_over",
1378
+ {
1379
+ harbor,
1380
+ person,
1381
+ to: values.to,
1382
+ until: dueDate(values.until),
1383
+ note: values.note,
1384
+ keep: values.keep
1385
+ ? values.keep
1386
+ .split(",")
1387
+ .map((k) => k.trim())
1388
+ .filter(Boolean)
1389
+ : undefined,
1390
+ },
1391
+ (r) => {
1392
+ const h = r.handedOver;
1393
+ const names = [...new Set(h.items.map((i) => i.to))];
1394
+ const who =
1395
+ names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
1396
+ const moved = `Handed ${h.person}'s work over: ${h.moved} ${h.moved === 1 ? "thing" : "things"}${who ? ` to ${who}` : ""}.`;
1397
+ return h.awayUntil
1398
+ ? `${moved} ${h.person} shows as away until ${h.awayUntil}; harbor handback "${h.person}" gives it back.`
1399
+ : moved;
1400
+ },
1401
+ );
1402
+ }
1403
+ case "handback": {
1404
+ const { positionals, harbor } = parse(spec());
1405
+ const person = positionals.join(" ").trim();
1406
+ if (!person) throw new HarborError('Say who is back: harbor handback "NAME".');
1407
+ return call("hand_back", { harbor, person }, (r) =>
1408
+ r.handedBack.moved
1409
+ ? `Handed ${r.handedBack.moved} back to ${r.handedBack.person}.`
1410
+ : `${r.handedBack.person} is back. Nothing was still waiting to go back.`,
1411
+ );
1412
+ }
1343
1413
  case "risk":
1344
1414
  case "issue":
1345
1415
  case "decision": {
package/lib/format.mjs CHANGED
@@ -369,3 +369,30 @@ export function hillList(r) {
369
369
  }
370
370
  return blocks.join("\n\n");
371
371
  }
372
+
373
+ /** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
374
+ export function handoverText(r, { confirmHint = true } = {}) {
375
+ const items = r.items ?? [];
376
+ if (items.length === 0) return `${r.person} has nothing open to hand over.`;
377
+ const lines = [
378
+ `Hand over ${r.person}'s work${r.awayUntil ? ` (away until ${r.awayUntil})` : ""}`,
379
+ ];
380
+ let group = null;
381
+ for (const i of items) {
382
+ if (i.group !== group) {
383
+ group = i.group;
384
+ lines.push("", `${group}:`);
385
+ }
386
+ lines.push(
387
+ ` ${i.what}${i.due && i.kind !== "project" && i.kind !== "program" ? ` (${i.due})` : ""} → ${i.to ?? "nobody yet"}`,
388
+ );
389
+ }
390
+ const room = (r.takers ?? []).filter((t) => t.note).map((t) => `${t.who} ${t.note}`);
391
+ if (room.length) lines.push("", `This week: ${room.join(", ")}.`);
392
+ if (confirmHint)
393
+ lines.push(
394
+ "",
395
+ 'Nothing has moved yet. Add --confirm to hand it over, --until DATE for time away, --note "…" to send a note with it, --to NAME to give it all to one person, or --keep ID,ID to leave some where it is.',
396
+ );
397
+ return lines.join("\n");
398
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",