@harborppm/cli 0.97.0 → 0.98.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
@@ -3376,7 +3376,7 @@ var init_thanks = __esmMin((() => {
3376
3376
  // src/lib/charter-lines.ts
3377
3377
  /** Nothing written on any of the five lines. */
3378
3378
  function charterEmpty(charter) {
3379
- return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
3379
+ return !charter || CHARTER_FIELDS.every((f) => !clean$1(charter[f]));
3380
3380
  }
3381
3381
  /**
3382
3382
  * The charter after a change to some of its lines: trimmed, and null once
@@ -3396,7 +3396,7 @@ function withCharter(current, patch, by, at) {
3396
3396
  }
3397
3397
  return charterEmpty(next) ? null : next;
3398
3398
  }
3399
- var CHARTER_FIELDS, clean;
3399
+ var CHARTER_FIELDS, clean$1;
3400
3400
  var init_charter_lines = __esmMin((() => {
3401
3401
  CHARTER_FIELDS = [
3402
3402
  "why",
@@ -3405,7 +3405,7 @@ var init_charter_lines = __esmMin((() => {
3405
3405
  "doneWhen",
3406
3406
  "callsBy"
3407
3407
  ];
3408
- clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
3408
+ clean$1 = (s) => (s ?? "").replace(/\s+/g, " ").trim();
3409
3409
  }));
3410
3410
 
3411
3411
  // src/lib/charter.ts
@@ -3434,7 +3434,7 @@ function charterLines(charter) {
3434
3434
  return CHARTER_FIELDS.map((field) => ({
3435
3435
  field,
3436
3436
  label: CHARTER_LABEL[field],
3437
- text: clean(charter[field])
3437
+ text: clean$1(charter[field])
3438
3438
  })).filter((l) => l.text);
3439
3439
  }
3440
3440
  function charterFilled(charter) {
@@ -3502,7 +3502,7 @@ function charterCheck(charter, tasks) {
3502
3502
  };
3503
3503
  });
3504
3504
  return {
3505
- doneWhen: clean(charter.doneWhen) || null,
3505
+ doneWhen: clean$1(charter.doneWhen) || null,
3506
3506
  delivered,
3507
3507
  crept: outOfScope(charter, tasks),
3508
3508
  outItems: charterItems(charter.outScope).length
@@ -3524,6 +3524,18 @@ function charterSentence(check) {
3524
3524
  return `Against the charter: ${bits.join("; ")}.`;
3525
3525
  }
3526
3526
 
3527
+ // src/lib/finished-on.ts
3528
+ /**
3529
+ * When the project was marked Done: its last move to Done in its health
3530
+ * history, else its last finished task, else its end date. Kept on its own so
3531
+ * the inbox can ask it without the voyage log's code.
3532
+ */
3533
+ function finishedOn(project, tasks) {
3534
+ const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
3535
+ if (marked) return marked.at.slice(0, 10);
3536
+ return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
3537
+ }
3538
+
3527
3539
  // src/lib/voyage-log.ts
3528
3540
  init_date_fns();
3529
3541
  init_blockers();
@@ -3531,12 +3543,6 @@ init_date_text();
3531
3543
  init_premortem();
3532
3544
  const day$8 = (iso) => parseIso(localDay(iso));
3533
3545
  const between = (a, b) => differenceInCalendarDays(day$8(b), day$8(a));
3534
- /** When the project was marked Done: its last move to Done in its health history. */
3535
- function finishedOn(project, tasks) {
3536
- const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
3537
- if (marked) return marked.at.slice(0, 10);
3538
- return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
3539
- }
3540
3546
  function voyageLog(data, projectId) {
3541
3547
  const project = data.projects.find((p) => p.id === projectId);
3542
3548
  if (!project) return null;
@@ -10560,6 +10566,126 @@ function HistoryView({ active, width, height, go }) {
10560
10566
  });
10561
10567
  }
10562
10568
 
10569
+ // src/lib/landfall-lines.ts
10570
+ function isLandfallDays(n) {
10571
+ return LANDFALL_DAYS.includes(n);
10572
+ }
10573
+ /** Why a landfall can't be saved as given, in words for the person; null when it can. */
10574
+ function landfallProblem(input) {
10575
+ const expect = clean(input.expect);
10576
+ if (!expect) return "Say what landfall should look like.";
10577
+ if (expect.length > 300) return `Keep it under ${300} characters.`;
10578
+ if (!isLandfallDays(input.afterDays)) return "Pick 30, 90 or 180 days.";
10579
+ if (clean(input.measure).length > 60) return `Name the number in under ${60} characters.`;
10580
+ return null;
10581
+ }
10582
+ /** Why an answer can't be saved as given; null when it can. */
10583
+ function landfallAnswerProblem(input) {
10584
+ if (!LANDFALL_RESULTS.includes(input.result)) return "Say yes, partly or no.";
10585
+ if (input.value != null && (typeof input.value !== "number" || !Number.isFinite(input.value))) return "The number has to be a number.";
10586
+ if (typeof input.value === "number" && Math.abs(input.value) > 0xe8d4a51000) return "That number is too big.";
10587
+ if (clean(input.note).length > 500) return `Keep the note under ${500} characters.`;
10588
+ return null;
10589
+ }
10590
+ /**
10591
+ * The landfall after someone sets or changes what it looks like. An answer
10592
+ * already given stays: changing the words after the fact doesn't unask it.
10593
+ * Throws on bad input, so callers check `landfallProblem` first.
10594
+ */
10595
+ function withLandfall(current, input, by, at) {
10596
+ const problem = landfallProblem(input);
10597
+ if (problem) throw new Error(problem);
10598
+ const measure = clean(input.measure);
10599
+ return {
10600
+ ...current ?? {},
10601
+ expect: clean(input.expect),
10602
+ afterDays: input.afterDays,
10603
+ measure: measure || null,
10604
+ setAt: current?.setAt ?? at,
10605
+ setById: current?.setById ?? by
10606
+ };
10607
+ }
10608
+ /** The landfall with an answer, or with the answer taken back (null) so it asks again. */
10609
+ function withLandfallAnswer(current, answer, by, at) {
10610
+ if (!answer) {
10611
+ const { result: _r, value: _v, note: _n, answeredAt: _a, answeredById: _b, ...rest } = current;
10612
+ return rest;
10613
+ }
10614
+ const problem = landfallAnswerProblem(answer);
10615
+ if (problem) throw new Error(problem);
10616
+ const note = clean(answer.note);
10617
+ return {
10618
+ ...current,
10619
+ result: answer.result,
10620
+ value: answer.value ?? null,
10621
+ note: note || null,
10622
+ answeredAt: at,
10623
+ answeredById: by
10624
+ };
10625
+ }
10626
+ var LANDFALL_DAYS, LANDFALL_EXPECT_MAX, LANDFALL_MEASURE_MAX, LANDFALL_NOTE_MAX, LANDFALL_RESULTS, clean;
10627
+ var init_landfall_lines = __esmMin((() => {
10628
+ LANDFALL_DAYS = [
10629
+ 30,
10630
+ 90,
10631
+ 180
10632
+ ];
10633
+ LANDFALL_EXPECT_MAX = 300;
10634
+ LANDFALL_MEASURE_MAX = 60;
10635
+ LANDFALL_NOTE_MAX = 500;
10636
+ LANDFALL_RESULTS = [
10637
+ "yes",
10638
+ "partly",
10639
+ "no"
10640
+ ];
10641
+ clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
10642
+ }));
10643
+
10644
+ // src/lib/landfall.ts
10645
+ init_date_fns();
10646
+ init_date_text();
10647
+ function plusDays(iso, days) {
10648
+ return addDays(parseIso(iso.slice(0, 10)), days).toISOString().slice(0, 10);
10649
+ }
10650
+ /** The day to look: that many days after the project was marked Done. */
10651
+ function landfallCheckOn(project, tasks) {
10652
+ if (!project.landfall) return null;
10653
+ return plusDays(finishedOn(project, tasks), project.landfall.afterDays);
10654
+ }
10655
+ function landfallState(project, tasks, today) {
10656
+ const lf = project.landfall;
10657
+ if (lf?.result) return "answered";
10658
+ if (project.health !== "done") return "sailing";
10659
+ if (!lf) return "unset";
10660
+ const on = landfallCheckOn(project, tasks);
10661
+ return on && on <= today ? "due" : "waiting";
10662
+ }
10663
+ /** Who is asked: the project's owner, else (their person gone) the program's. */
10664
+ function landfallAskee(data, project) {
10665
+ const here = (id) => id ? data.people.find((p) => p.id === id) : void 0;
10666
+ return here(project.ownerId) ?? here(data.programs.find((p) => p.id === project.programId)?.ownerId);
10667
+ }
10668
+ /** A project's landfall as agents and the CLI's --json read it; null while there's none. */
10669
+ function landfallJson(project, tasks, today, people) {
10670
+ const lf = project.landfall;
10671
+ if (!lf) return null;
10672
+ const answered = Boolean(lf.result);
10673
+ return {
10674
+ expect: lf.expect,
10675
+ afterDays: lf.afterDays,
10676
+ measure: lf.measure ?? null,
10677
+ state: landfallState(project, tasks, today),
10678
+ checkOn: project.health === "done" ? landfallCheckOn(project, tasks) : null,
10679
+ result: lf.result ?? null,
10680
+ ...answered ? {
10681
+ value: lf.value ?? null,
10682
+ note: lf.note ?? null,
10683
+ answeredOn: lf.answeredAt?.slice(0, 10) ?? null,
10684
+ answeredBy: people.find((p) => p.id === lf.answeredById)?.name ?? null
10685
+ } : {}
10686
+ };
10687
+ }
10688
+
10563
10689
  // src/lib/project-messages.ts
10564
10690
  /** This project's messages, oldest first. */
10565
10691
  function messagesOf(reflections, projectId) {
@@ -10682,6 +10808,7 @@ const KIND_RANK = {
10682
10808
  waiting: 0,
10683
10809
  approval: 1,
10684
10810
  review: 2,
10811
+ landfall: 2.5,
10685
10812
  sounding: 3,
10686
10813
  assigned: 4,
10687
10814
  idle: 5,
@@ -10694,6 +10821,7 @@ const PING_KIND_LABEL = {
10694
10821
  waiting: "Waiting on you",
10695
10822
  approval: "Sign-offs",
10696
10823
  review: "Reviews due",
10824
+ landfall: "Landfall",
10697
10825
  sounding: "Soundings",
10698
10826
  assigned: "Assigned to you",
10699
10827
  idle: "Quiet blockers",
@@ -10827,6 +10955,41 @@ function inboxOf(data, personId, now = clockNow()) {
10827
10955
  personId: null
10828
10956
  });
10829
10957
  }
10958
+ const askSince = todayISO(subDays(now, 14));
10959
+ for (const project of data.projects) {
10960
+ if (project.health !== "done" && !project.landfall?.result) continue;
10961
+ if (landfallAskee(data, project)?.id !== personId) continue;
10962
+ const tasks = tasksOf(data.tasks, project.id);
10963
+ const state = landfallState(project, tasks, today);
10964
+ if (state === "unset") {
10965
+ const finished = finishedOn(project, tasks);
10966
+ if (finished < askSince) continue;
10967
+ pings.push({
10968
+ id: `landfall-set:${project.id}:${finished}`,
10969
+ kind: "landfall",
10970
+ title: `${project.name} is done. What should landfall look like?`,
10971
+ detail: "Say what should be true in 30, 90 or 180 days, and Harbor asks you then.",
10972
+ at: finished,
10973
+ projectId: project.id,
10974
+ landfall: "set",
10975
+ personId: null,
10976
+ href: `/projects/${project.id}?view=log`
10977
+ });
10978
+ } else if (state === "due") {
10979
+ const on = landfallCheckOn(project, tasks);
10980
+ pings.push({
10981
+ id: `landfall:${project.id}:${on}`,
10982
+ kind: "landfall",
10983
+ title: `Did ${project.name} reach landfall?`,
10984
+ detail: `“${project.landfall.expect}”`,
10985
+ at: on,
10986
+ projectId: project.id,
10987
+ landfall: "check",
10988
+ personId: null,
10989
+ href: `/projects/${project.id}?view=log`
10990
+ });
10991
+ }
10992
+ }
10830
10993
  for (const constraint of constraintsOwnedBy(data, personId)) {
10831
10994
  if (!constraintIdle(data, constraint)) continue;
10832
10995
  const program = data.programs.find((p) => p.id === constraint.programId);
@@ -22828,7 +22991,7 @@ const HARBOR_TOOLS = [
22828
22991
  tool({
22829
22992
  name: "get_project",
22830
22993
  title: "Project details",
22831
- description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
22994
+ description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), its landfall (what should be true some days after it's Done, when Harbor asks, and the answer: yes, partly or no with a number; null until someone sets one; state is sailing while not Done, unset, waiting, due or answered), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
22832
22995
  input: object({
22833
22996
  harbor: harborArg,
22834
22997
  project: string().min(1).max(200).describe("Project id or name.")
@@ -22892,6 +23055,7 @@ const HARBOR_TOOLS = [
22892
23055
  ...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
22893
23056
  finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
22894
23057
  ...charterOf(p, tasks),
23058
+ landfall: landfallJson(p, tasks, today, data.people),
22895
23059
  ...notesOf(ctx, p),
22896
23060
  ...hillOf(p, today),
22897
23061
  ...webLink(ctx, { project: p.id })
@@ -25068,6 +25232,20 @@ function hierarchySlice({ get, write }) {
25068
25232
  charter: withCharter(p.charter, patch, by, stamp())
25069
25233
  } : p) });
25070
25234
  },
25235
+ setLandfall: (projectId, input) => {
25236
+ const by = get().currentUserId;
25237
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
25238
+ ...p,
25239
+ landfall: input ? withLandfall(p.landfall, input, by, stamp()) : null
25240
+ } : p) });
25241
+ },
25242
+ answerLandfall: (projectId, answer) => {
25243
+ const by = get().currentUserId;
25244
+ write({ projects: get().projects.map((p) => p.id === projectId && p.landfall ? {
25245
+ ...p,
25246
+ landfall: withLandfallAnswer(p.landfall, answer, by, stamp())
25247
+ } : p) });
25248
+ },
25071
25249
  addCustomField: (projectId, def) => {
25072
25250
  const id = uid("cf");
25073
25251
  write({ projects: get().projects.map((p) => p.id === projectId ? {
@@ -25429,6 +25607,7 @@ var init_hierarchy = __esmMin((() => {
25429
25607
  init_scuttle_rows();
25430
25608
  init_hill();
25431
25609
  init_charter_lines();
25610
+ init_landfall_lines();
25432
25611
  init_format();
25433
25612
  init_empty();
25434
25613
  init_first_week();
package/lib/cli.mjs CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  notesText,
36
36
  messagesText,
37
37
  charterText,
38
+ landfallText,
38
39
  daysText,
39
40
  soundingText,
40
41
  lifeboatsText,
@@ -263,6 +264,11 @@ export const HELP = `harbor: Harbor from the terminal
263
264
  a project's five-line charter
264
265
  (why, in, out, done when, who
265
266
  makes the calls): read or set
267
+ harbor landfall PROJECT [--expect T] [--after 30|90|180] [--measure T]
268
+ [--answer yes|partly|no] [--value N] [--note T]
269
+ did a Done project pay off? say
270
+ what should be true, and answer
271
+ on the day
266
272
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
267
273
  a note for later: sealed until
268
274
  its day, milestone, or Off track
@@ -1025,6 +1031,24 @@ const DETAIL = {
1025
1031
  " --no-people. A task already in its project is left alone: by its key",
1026
1032
  " (a Key column, as harbor export writes) or by title.",
1027
1033
  ],
1034
+ landfall: [
1035
+ " harbor landfall PROJECT reads its landfall: what should be",
1036
+ " true some days after it's Done,",
1037
+ " when Harbor asks, and the answer",
1038
+ " Say what landfall looks like:",
1039
+ ' --expect "TEXT" what should be true by then',
1040
+ " --after 30|90|180 days after the project is marked Done to look",
1041
+ " (90 unless you say)",
1042
+ ' --measure "TEXT" what the number counts, if there is one',
1043
+ " Answer it (on its day, or sooner):",
1044
+ " --answer yes|partly|no",
1045
+ " --value N the number on the day",
1046
+ ' --note "TEXT" what you found',
1047
+ " --clear takes the landfall off (with --answer, only the answer), so it",
1048
+ " asks again. The owner is asked in their Inbox on the day; the portfolio",
1049
+ " counts the voyages that paid off. PROJECT is a name, a key like RR, or",
1050
+ " an id.",
1051
+ ],
1028
1052
  charter: [
1029
1053
  " harbor charter PROJECT reads its charter, and any open task",
1030
1054
  " that names something it leaves out",
@@ -3286,6 +3310,70 @@ export async function run(argv, io = {}) {
3286
3310
  : `Cleared ${r.project}'s charter.`,
3287
3311
  );
3288
3312
  }
3313
+ case "landfall": {
3314
+ const { positionals, values, harbor } = parse(
3315
+ spec(["clear"], ["expect", "after", "measure", "answer", "value", "note"]),
3316
+ );
3317
+ const project = one(positionals, 'project: harbor landfall "NAME"');
3318
+ const answering =
3319
+ values.answer !== undefined || values.value !== undefined || values.note !== undefined;
3320
+ const setting =
3321
+ values.expect !== undefined || values.after !== undefined || values.measure !== undefined;
3322
+ if (answering && setting) {
3323
+ throw new HarborError("Set the landfall or answer it, one at a time.");
3324
+ }
3325
+ const show = (r) => landfallText(r.project ?? r.name, r.landfall);
3326
+ const json = (r) => ({ project: r.project ?? r.name, landfall: r.landfall ?? null });
3327
+ if (answering || (values.clear && values.answer !== undefined)) {
3328
+ if (values.clear) {
3329
+ return call("answer_landfall", { harbor, project, clear: true }, show, json);
3330
+ }
3331
+ const result = String(values.answer ?? "").toLowerCase();
3332
+ if (!["yes", "partly", "no"].includes(result)) {
3333
+ throw new HarborError("Say --answer yes, partly or no.");
3334
+ }
3335
+ let value;
3336
+ if (values.value !== undefined) {
3337
+ value = Number(String(values.value).replace(/,/g, ""));
3338
+ if (!Number.isFinite(value)) throw new HarborError("--value takes a number, like 120.");
3339
+ }
3340
+ return call(
3341
+ "answer_landfall",
3342
+ {
3343
+ harbor,
3344
+ project,
3345
+ result,
3346
+ ...(value !== undefined ? { value } : {}),
3347
+ ...(values.note !== undefined ? { note: values.note } : {}),
3348
+ },
3349
+ show,
3350
+ json,
3351
+ );
3352
+ }
3353
+ if (values.clear) return call("set_landfall", { harbor, project, clear: true }, show, json);
3354
+ if (setting) {
3355
+ let afterDays;
3356
+ if (values.after !== undefined) {
3357
+ afterDays = Number(String(values.after).replace(/\s*days?$/i, ""));
3358
+ if (![30, 90, 180].includes(afterDays)) {
3359
+ throw new HarborError("--after takes 30, 90 or 180 (days after the project is Done).");
3360
+ }
3361
+ }
3362
+ return call(
3363
+ "set_landfall",
3364
+ {
3365
+ harbor,
3366
+ project,
3367
+ ...(values.expect !== undefined ? { expect: values.expect } : {}),
3368
+ ...(afterDays !== undefined ? { afterDays } : {}),
3369
+ ...(values.measure !== undefined ? { measure: values.measure } : {}),
3370
+ },
3371
+ show,
3372
+ json,
3373
+ );
3374
+ }
3375
+ return call("get_project", { harbor, project }, show, json);
3376
+ }
3289
3377
  case "sound": {
3290
3378
  const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
3291
3379
  const [task, ...extra] = positionals;
package/lib/format.mjs CHANGED
@@ -815,6 +815,34 @@ export function charterText(r) {
815
815
  return lines.join("\n");
816
816
  }
817
817
 
818
+ const LANDFALL_RESULT = { yes: "Paid off", partly: "Partly", no: "Didn't" };
819
+
820
+ /** A project's landfall as get_project, set_landfall or answer_landfall gives it. */
821
+ export function landfallText(project, lf) {
822
+ if (!lf) {
823
+ return `${project} has no landfall yet. Say what should be true some days after it's Done:\n harbor landfall "${project}" --expect "…" --after 90 [--measure "weekly sign-ups"]`;
824
+ }
825
+ const lines = [`Landfall · ${project}`, ` Looks like ${lf.expect}`];
826
+ if (lf.measure) lines.push(` Counting ${lf.measure}`);
827
+ if (lf.result) {
828
+ const value =
829
+ lf.value != null ? ` · ${Number(lf.value).toLocaleString("en-US")}${lf.measure ? ` ${lf.measure}` : ""}` : "";
830
+ lines.push(` Answer ${LANDFALL_RESULT[lf.result] ?? lf.result}${value}`);
831
+ if (lf.note) lines.push(` ${lf.note}`);
832
+ if (lf.answeredOn)
833
+ lines.push(` said by ${lf.answeredBy ?? "someone"} on ${lf.answeredOn}`);
834
+ } else if (lf.state === "due") {
835
+ lines.push(
836
+ ` Due now its day was ${lf.checkOn}. Answer: harbor landfall "${project}" --answer yes|partly|no [--value N]`,
837
+ );
838
+ } else if (lf.state === "waiting") {
839
+ lines.push(` Check on ${lf.checkOn} (${lf.afterDays} days after Done)`);
840
+ } else {
841
+ lines.push(` Check ${lf.afterDays} days after the project is marked Done`);
842
+ }
843
+ return lines.join("\n");
844
+ }
845
+
818
846
  export function notesText(project, notes) {
819
847
  if (!notes?.length)
820
848
  return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.97.0",
3
+ "version": "0.98.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",