@harborppm/cli 0.68.0 → 0.70.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
@@ -4858,9 +4858,216 @@ var init_board = __esmMin((() => {
4858
4858
  ];
4859
4859
  }));
4860
4860
 
4861
+ //#endregion
4862
+ //#region src/lib/first-voyage.ts
4863
+ function isVoyageProject(projectId) {
4864
+ return projectId === VOYAGE_PROJECT_ID;
4865
+ }
4866
+ /** The harbor's projects without First voyage: the work people brought. */
4867
+ function ownProjects(projects) {
4868
+ return projects.filter((p) => !isVoyageProject(p.id));
4869
+ }
4870
+ var VOYAGE_PROJECT_ID, STEPS, voyageTaskId, VOYAGE_NO_FORECAST, SEEDED;
4871
+ var init_first_voyage = __esmMin((() => {
4872
+ VOYAGE_PROJECT_ID = "voyage";
4873
+ STEPS = [
4874
+ {
4875
+ step: "task",
4876
+ title: "Add a task of your own",
4877
+ description: "Press New, or the + at the top of a column on any board. Anything real will do. This ticks itself off when you add one."
4878
+ },
4879
+ {
4880
+ step: "move",
4881
+ title: "Move a task to In progress",
4882
+ description: "Open Board above and drag a card along, or change its status in the task's panel. This one counts too. It ticks itself off."
4883
+ },
4884
+ {
4885
+ step: "crew",
4886
+ title: "Bring someone aboard, or tick this to skip",
4887
+ description: "Invite from your menu or from People. Harbor is $49 a month for the whole crew, not per seat. Ticks itself off when someone's in People."
4888
+ },
4889
+ {
4890
+ step: "terminal",
4891
+ title: "Try Harbor in your terminal",
4892
+ description: "Run `npx @harborppm/cli mine` (Node 22 or later) and sign in when your browser asks. `harbor` on its own opens the whole harbor full screen. Ticks itself off once the terminal is connected; skip it if a terminal isn't your thing."
4893
+ },
4894
+ {
4895
+ step: "agent",
4896
+ title: "Ask an AI app to add a task for you",
4897
+ description: "Connect Claude, or any app that speaks MCP, at harborppm.com/ai, then ask it to add a task here. Harbor asks you first and History names the app. Ticks itself off once an app is connected."
4898
+ },
4899
+ {
4900
+ step: "wrap",
4901
+ title: "Mark this project Done, or delete it",
4902
+ description: "That's the voyage. Set its health to Done to put it away, or delete it from Details. Your own work stays."
4903
+ }
4904
+ ];
4905
+ voyageTaskId = (step) => `voyage-${step}`;
4906
+ VOYAGE_NO_FORECAST = "It's practice, not the crew's pace, so there's nothing to forecast.";
4907
+ SEEDED = new Set(STEPS.map((s) => voyageTaskId(s.step)));
4908
+ }));
4909
+
4910
+ //#endregion
4911
+ //#region src/lib/passage.ts
4912
+ /** Hours as a person would say them: "2h", "1.5h", "30m". */
4913
+ function hoursText(h) {
4914
+ if (h > 0 && h < 1) return `${Math.round(h * 60)}m`;
4915
+ return `${Math.round(h * 10) / 10}h`;
4916
+ }
4917
+ /** Hours kept to the half hour, within what one day can take. */
4918
+ function clampHours(h) {
4919
+ if (!Number.isFinite(h)) return 2;
4920
+ return Math.min(12, Math.max(MIN_HOURS, Math.round(h * 2) / 2));
4921
+ }
4922
+ function dayHoursOf(person) {
4923
+ const h = person?.dayHours;
4924
+ return typeof h === "number" && h > 0 ? Math.min(16, h) : 7;
4925
+ }
4926
+ /** The Monday of the week a day falls in (a Saturday or Sunday looks ahead to the next week). */
4927
+ function weekOf(day = todayISO()) {
4928
+ const d = at$1(day);
4929
+ const wd = d.getDay();
4930
+ const back = wd === 0 ? -1 : wd === 6 ? -2 : wd - 1;
4931
+ return iso$3(addDays(d, -back));
4932
+ }
4933
+ /** Monday to Friday of the week starting `monday`. */
4934
+ function weekDays(monday) {
4935
+ const d = at$1(monday);
4936
+ return [
4937
+ 0,
4938
+ 1,
4939
+ 2,
4940
+ 3,
4941
+ 4
4942
+ ].map((i) => iso$3(addDays(d, i)));
4943
+ }
4944
+ function shiftWeek(monday, weeks) {
4945
+ return iso$3(addDays(at$1(monday), weeks * 7));
4946
+ }
4947
+ /** Whether a week should read back how it went: from its Friday on. */
4948
+ function isReviewTime(monday, today = todayISO()) {
4949
+ return today >= weekDays(monday)[4];
4950
+ }
4951
+ /** One person's placements, every week. */
4952
+ function placementsOf(data, personId) {
4953
+ return (data.reflections ?? []).filter((r) => isPassage(r) && r.authorId === personId);
4954
+ }
4955
+ /** The week as its owner plans it. Placements whose task is gone are left out. */
4956
+ function weekPlan(data, personId, monday) {
4957
+ const tasks = byId(data.tasks);
4958
+ const dates = weekDays(monday);
4959
+ const dayHours = dayHoursOf(data.people.find((p) => p.id === personId));
4960
+ const inWeek = placementsOf(data, personId).filter((r) => r.date && dates.includes(r.date) && r.taskId && tasks.has(r.taskId));
4961
+ const days = dates.map((date) => {
4962
+ const items = inWeek.filter((r) => r.date === date).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((r) => ({
4963
+ id: r.id,
4964
+ task: tasks.get(r.taskId),
4965
+ date,
4966
+ hours: clampHours(r.hours ?? 2)
4967
+ }));
4968
+ const hours = items.reduce((sum, i) => sum + i.hours, 0);
4969
+ return {
4970
+ date,
4971
+ items,
4972
+ hours,
4973
+ over: hours > dayHours
4974
+ };
4975
+ });
4976
+ const placed = new Set(inWeek.map((r) => r.taskId));
4977
+ const open = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done" && !placed.has(t.id) && !isVoyageProject(t.projectId));
4978
+ const unplanned = open.filter((t) => t.status !== "review").sort(byDue);
4979
+ const inReview = open.filter((t) => t.status === "review").sort(byDue);
4980
+ return {
4981
+ monday,
4982
+ days,
4983
+ dayHours,
4984
+ planned: days.reduce((sum, d) => sum + d.hours, 0),
4985
+ capacity: dayHours * 5,
4986
+ unplanned,
4987
+ inReview
4988
+ };
4989
+ }
4990
+ /** Planned against done for a week: what was on the plan, what got finished, and what came in besides. */
4991
+ function weekReview(data, personId, monday) {
4992
+ const plan = weekPlan(data, personId, monday);
4993
+ const first = plan.days[0].date;
4994
+ const sunday = iso$3(addDays(at$1(monday), 6));
4995
+ const doneInWeek = (t) => t.status === "done" && Boolean(t.completedAt) && todayISO(new Date(t.completedAt)) >= first && todayISO(new Date(t.completedAt)) <= sunday;
4996
+ const hoursBy = /* @__PURE__ */ new Map();
4997
+ for (const d of plan.days) for (const i of d.items) {
4998
+ const had = hoursBy.get(i.task.id);
4999
+ hoursBy.set(i.task.id, {
5000
+ task: i.task,
5001
+ hours: (had?.hours ?? 0) + i.hours
5002
+ });
5003
+ }
5004
+ const planned = [...hoursBy.values()].map(({ task, hours }) => ({
5005
+ task,
5006
+ hours,
5007
+ done: task.status === "done" && (!task.completedAt || todayISO(new Date(task.completedAt)) <= sunday)
5008
+ }));
5009
+ const alsoDone = data.tasks.filter((t) => t.assigneeId === personId && doneInWeek(t) && !hoursBy.has(t.id) && !isVoyageProject(t.projectId)).sort((a, b) => (a.completedAt ?? "").localeCompare(b.completedAt ?? ""));
5010
+ return {
5011
+ monday,
5012
+ planned,
5013
+ doneCount: planned.filter((p) => p.done).length,
5014
+ alsoDone,
5015
+ plannedHours: plan.planned,
5016
+ doneHours: planned.filter((p) => p.done).reduce((sum, p) => sum + p.hours, 0)
5017
+ };
5018
+ }
5019
+ /** A line for the week's header: "32h of 35h planned", or how full a day is. */
5020
+ function plannedText(planned, capacity) {
5021
+ return `${hoursText(planned)} of ${hoursText(capacity)} planned`;
5022
+ }
5023
+ /** The one line the Friday view leads with. */
5024
+ function reviewText(r) {
5025
+ if (r.planned.length === 0) return r.alsoDone.length ? `Nothing was planned, and you finished ${r.alsoDone.length}.` : "Nothing was planned this week.";
5026
+ const also = r.alsoDone.length ? `, and ${r.alsoDone.length} more besides` : "";
5027
+ return `You finished ${r.doneCount} of the ${r.planned.length} you planned${also}.`;
5028
+ }
5029
+ /** Placements of this person's from before the weeks kept, which a new plan clears. */
5030
+ function stalePlacements(data, personId, today = todayISO()) {
5031
+ const cutoff = shiftWeek(weekOf(today), -12);
5032
+ return new Set(placementsOf(data, personId).filter((r) => (r.date ?? "") < cutoff).map((r) => r.id));
5033
+ }
5034
+ var DAY_HOURS, DEFAULT_HOURS$1, MIN_HOURS, MAX_HOURS, HOUR_CHOICES, isPassage, iso$3, at$1, byDue, DAY_WORDS;
5035
+ var init_passage = __esmMin((() => {
5036
+ init_date_fns();
5037
+ init_date_text();
5038
+ init_format();
5039
+ init_indexes();
5040
+ init_first_voyage();
5041
+ DAY_HOURS = 7;
5042
+ DEFAULT_HOURS$1 = 2;
5043
+ MIN_HOURS = .5;
5044
+ MAX_HOURS = 12;
5045
+ HOUR_CHOICES = [
5046
+ .5,
5047
+ 1,
5048
+ 2,
5049
+ 3,
5050
+ 4,
5051
+ 6,
5052
+ 8
5053
+ ];
5054
+ isPassage = (r) => r.kind === "passage";
5055
+ iso$3 = (d) => todayISO(d);
5056
+ at$1 = (day) => parseIso(`${day}T12:00:00`);
5057
+ byDue = (a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999");
5058
+ DAY_WORDS = [
5059
+ "mon",
5060
+ "tue",
5061
+ "wed",
5062
+ "thu",
5063
+ "fri"
5064
+ ];
5065
+ }));
5066
+
4861
5067
  //#endregion
4862
5068
  //#region src/lib/keep.ts
4863
5069
  init_board();
5070
+ init_passage();
4864
5071
  /** Whole days left in the trial, rounded up: 30 on the first day, 1 on the last. 0 once it's over. */
4865
5072
  function trialDaysLeft(trialEndsAt, now = /* @__PURE__ */ new Date()) {
4866
5073
  if (!trialEndsAt) return 0;
@@ -5295,7 +5502,7 @@ function roadmapWindow(zoom, today, shift = 0) {
5295
5502
  };
5296
5503
  }
5297
5504
  /** Where a day falls in the window, 0–1, or null outside it. */
5298
- function at$1(iso, w) {
5505
+ function at(iso, w) {
5299
5506
  if (!iso) return null;
5300
5507
  if (iso < w.start || iso >= w.end) return null;
5301
5508
  return differenceInCalendarDays(day$4(iso), day$4(w.start)) / w.days;
@@ -5582,6 +5789,7 @@ var init_work = __esmMin((() => {
5582
5789
  //#region src/tui/model.ts
5583
5790
  init_date_fns();
5584
5791
  init_date_text();
5792
+ init_first_voyage();
5585
5793
  init_indexes();
5586
5794
  init_tide();
5587
5795
  init_format();
@@ -5762,7 +5970,7 @@ function roadmapCells(start, end, milestones, w, width) {
5762
5970
  if (s.clippedEnd) cells[b - 1] = "›";
5763
5971
  }
5764
5972
  for (const m of milestones) {
5765
- const pos = at$1(m.due, w);
5973
+ const pos = at(m.due, w);
5766
5974
  if (pos == null) continue;
5767
5975
  cells[Math.min(width - 1, Math.floor(pos * width))] = m.done ? "◇" : "◆";
5768
5976
  }
@@ -5805,6 +6013,7 @@ function filterTasks(tasks, f) {
5805
6013
  * there's no forecast (all done, paused, or nothing finished lately).
5806
6014
  */
5807
6015
  function tideLine(data, project, today) {
6016
+ if (isVoyageProject(project.id)) return null;
5808
6017
  const tasks = tasksOf(data.tasks, project.id);
5809
6018
  const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`), tideOptions(data));
5810
6019
  if (tide.kind === "no-pace") return {
@@ -6218,6 +6427,59 @@ function slipLine(data, project, today) {
6218
6427
  if (!cost?.likely || cost.likely.daysLate <= 0) return null;
6219
6428
  return slipHeadline(cost);
6220
6429
  }
6430
+ /**
6431
+ * Your week as the website lays it out (passage.ts): each day with what's on
6432
+ * it against the hours you work, then what's yours still to place.
6433
+ */
6434
+ function weekLines(data, me, monday, today) {
6435
+ const plan = weekPlan(data, me, monday);
6436
+ const lines = [];
6437
+ for (const d of plan.days) {
6438
+ lines.push({
6439
+ kind: "day",
6440
+ date: d.date,
6441
+ label: format(parseIso(`${d.date}T12:00:00`), "EEE MMM d"),
6442
+ load: d.items.length ? `${hoursText(d.hours)} of ${hoursText(plan.dayHours)}${d.over ? ` · too full by ${hoursText(d.hours - plan.dayHours)}` : ""}` : "nothing planned",
6443
+ over: d.over,
6444
+ today: d.date === today
6445
+ });
6446
+ for (const placement of d.items) lines.push({
6447
+ kind: "placed",
6448
+ placement
6449
+ });
6450
+ }
6451
+ lines.push({
6452
+ kind: "head",
6453
+ text: `Yours to place · ${plan.unplanned.length}`
6454
+ });
6455
+ if (plan.unplanned.length === 0) lines.push({
6456
+ kind: "note",
6457
+ text: "Everything of yours is on a day."
6458
+ });
6459
+ for (const task of plan.unplanned) lines.push({
6460
+ kind: "open",
6461
+ task
6462
+ });
6463
+ if (plan.inReview.length) lines.push({
6464
+ kind: "note",
6465
+ text: `${plan.inReview.length} in review, waiting on someone else, left out.`
6466
+ });
6467
+ return {
6468
+ plan,
6469
+ lines
6470
+ };
6471
+ }
6472
+ /** How the week went, as lines to read: the website's Friday view. */
6473
+ function weekReviewLines(data, me, monday) {
6474
+ const r = weekReview(data, me, monday);
6475
+ const out = [reviewText(r)];
6476
+ if (r.planned.length) out.push(`${hoursText(r.doneHours)} of the ${hoursText(r.plannedHours)} you planned is done.`);
6477
+ if (r.planned.length) out.push("", "Planned");
6478
+ for (const p of r.planned) out.push(` ${p.done ? "✓" : "○"} ${keyOf$1(data, p.task).padEnd(7)}${p.task.title} ${hoursText(p.hours)}`);
6479
+ if (r.alsoDone.length) out.push("", "Also done");
6480
+ for (const t of r.alsoDone) out.push(` ✓ ${keyOf$1(data, t).padEnd(7)}${t.title}`);
6481
+ return out;
6482
+ }
6221
6483
 
6222
6484
  //#endregion
6223
6485
  //#region src/tui/theme.ts
@@ -6439,20 +6701,23 @@ function PriorityMark({ priority }) {
6439
6701
  children: priority === "urgent" ? "!!" : priority === "high" ? "!" : "↓"
6440
6702
  });
6441
6703
  }
6704
+ /** A heading keeps its own width, up to this much of the row; the hints beside it shorten first. */
6705
+ const HEADING_MAX = "70%";
6442
6706
  /** A section heading inside a screen. */
6443
6707
  function Heading({ children, right }) {
6444
6708
  return /* @__PURE__ */ jsxs(Box, {
6445
6709
  justifyContent: "space-between",
6446
6710
  columnGap: 2,
6447
6711
  children: [/* @__PURE__ */ jsx(Box, {
6448
- flexShrink: 1,
6712
+ flexShrink: 0,
6713
+ maxWidth: "70%",
6449
6714
  children: /* @__PURE__ */ jsx(Text, {
6450
6715
  bold: true,
6451
6716
  wrap: "truncate-end",
6452
6717
  children
6453
6718
  })
6454
6719
  }), right ? /* @__PURE__ */ jsx(Box, {
6455
- flexShrink: 2,
6720
+ flexShrink: 1,
6456
6721
  children: /* @__PURE__ */ jsx(Text, {
6457
6722
  color: theme.muted,
6458
6723
  wrap: "truncate-end",
@@ -6746,7 +7011,7 @@ function programIsBlocked(data, program) {
6746
7011
  }
6747
7012
  function daysOverdue(dueDate, now) {
6748
7013
  if (!dueDate) return 0;
6749
- const today = iso$3(now);
7014
+ const today = iso$2(now);
6750
7015
  if (today !== overdueDay) {
6751
7016
  overdueDay = today;
6752
7017
  overdueByDue.clear();
@@ -6765,7 +7030,7 @@ function daysOverdue(dueDate, now) {
6765
7030
  }
6766
7031
  function daysUntil$1(date, now) {
6767
7032
  if (!date) return null;
6768
- const today = iso$3(now);
7033
+ const today = iso$2(now);
6769
7034
  if (today !== untilDay) {
6770
7035
  untilDay = today;
6771
7036
  untilByDate.clear();
@@ -6830,7 +7095,7 @@ function effectiveProjectHealth(project, data, now) {
6830
7095
  }
6831
7096
  /** Milestones past their date and still open. */
6832
7097
  function lateMilestonesOf(milestones, now) {
6833
- const today = iso$3(now);
7098
+ const today = iso$2(now);
6834
7099
  return milestones.filter((m) => !m.done && m.dueDate < today);
6835
7100
  }
6836
7101
  /**
@@ -6935,7 +7200,7 @@ function programLateCount(programId, data, now) {
6935
7200
  for (const p of data.projects) if (p.programId === programId) late += lateTasksOf([...tasksOf(data.tasks, p.id)], now).length;
6936
7201
  return late;
6937
7202
  }
6938
- var iso$3, overdueDay, overdueByDue, untilDay, untilByDate;
7203
+ var iso$2, overdueDay, overdueByDue, untilDay, untilByDate;
6939
7204
  var init_derived_health = __esmMin((() => {
6940
7205
  init_date_fns();
6941
7206
  init_date_text();
@@ -6943,7 +7208,7 @@ var init_derived_health = __esmMin((() => {
6943
7208
  init_indexes();
6944
7209
  init_health_history();
6945
7210
  init_clock();
6946
- iso$3 = (d) => {
7211
+ iso$2 = (d) => {
6947
7212
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
6948
7213
  };
6949
7214
  overdueDay = "";
@@ -7414,7 +7679,7 @@ function briefOf(data, opts = {}) {
7414
7679
  const portfolioId = opts.portfolioId ?? null;
7415
7680
  const programs = portfolioId ? data.programs.filter((p) => p.portfolioId === portfolioId) : data.programs;
7416
7681
  const programIds = new Set(programs.map((p) => p.id));
7417
- const projects = data.projects.filter((p) => programIds.has(p.programId));
7682
+ const projects = ownProjects(data.projects.filter((p) => programIds.has(p.programId)));
7418
7683
  const projectIds = new Set(projects.map((p) => p.id));
7419
7684
  const tasks = data.tasks.filter((t) => projectIds.has(t.projectId));
7420
7685
  const risks = data.raidItems.filter((r) => r.kind === "risk" && r.programId != null && programIds.has(r.programId));
@@ -7628,6 +7893,7 @@ var init_brief = __esmMin((() => {
7628
7893
  init_decisions();
7629
7894
  init_barnacles();
7630
7895
  init_baseline();
7896
+ init_first_voyage();
7631
7897
  BRIEF_BARNACLES_SHOWN = 5;
7632
7898
  BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
7633
7899
  BRIEF_LATE_SHOWN = 8;
@@ -7992,6 +8258,7 @@ init_raid();
7992
8258
  init_swatches();
7993
8259
  init_task_keys();
7994
8260
  init_format();
8261
+ init_first_voyage();
7995
8262
  /** How many items of one kind a slide lists before "and N more". */
7996
8263
  const LIST_CAP = 4;
7997
8264
  /** When a program's changes are counted from: its last meeting, else a week back. */
@@ -8102,7 +8369,7 @@ function bridgeSlides(doc, programs, now) {
8102
8369
  if (program.health === "done") continue;
8103
8370
  const since = bridgeSince(program, now);
8104
8371
  const milestone = doc.milestones.filter((m) => m.programId === program.id && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate))[0] ?? null;
8105
- const projects = doc.projects.filter((p) => p.programId === program.id && p.health !== "done").sort((a, b) => rank[a.health] - rank[b.health] || a.endDate.localeCompare(b.endDate));
8372
+ const projects = doc.projects.filter((p) => p.programId === program.id && p.health !== "done" && !isVoyageProject(p.id)).sort((a, b) => rank[a.health] - rank[b.health] || a.endDate.localeCompare(b.endDate));
8106
8373
  for (const project of projects) {
8107
8374
  const changes = projectChanges(doc, project, since, now);
8108
8375
  if (changes.length === 0) {
@@ -9749,13 +10016,13 @@ function InboxView({ active, width, height, go }) {
9749
10016
  /** Open tasks for one person, bucketed by the week they are due, plus what is already late or has no date. */
9750
10017
  function weeklyLoad(tasks, personId, now, weeks = 6, calendar) {
9751
10018
  const mine = tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
9752
- const today = iso$2(now);
10019
+ const today = iso$1(now);
9753
10020
  const origin = startOfWeek(now, { weekStartsOn: 1 });
9754
10021
  const buckets = Array.from({ length: weeks }, (_, i) => {
9755
- const start = iso$2(addDays(origin, i * 7));
10022
+ const start = iso$1(addDays(origin, i * 7));
9756
10023
  const off = [];
9757
10024
  if (calendar?.active) for (let d = 0; d < 5; d++) {
9758
- const date = iso$2(addDays(origin, i * 7 + d));
10025
+ const date = iso$1(addDays(origin, i * 7 + d));
9759
10026
  const why = calendar.offOn(personId, date);
9760
10027
  if (why) off.push({
9761
10028
  date,
@@ -9790,58 +10057,14 @@ function weeklyLoad(tasks, personId, now, weeks = 6, calendar) {
9790
10057
  weeks: buckets
9791
10058
  };
9792
10059
  }
9793
- var iso$2;
10060
+ var iso$1;
9794
10061
  var init_load = __esmMin((() => {
9795
10062
  init_date_fns();
9796
- iso$2 = (d) => {
10063
+ iso$1 = (d) => {
9797
10064
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
9798
10065
  };
9799
10066
  }));
9800
10067
 
9801
- //#endregion
9802
- //#region src/lib/first-voyage.ts
9803
- function isVoyageProject(projectId) {
9804
- return projectId === VOYAGE_PROJECT_ID;
9805
- }
9806
- var VOYAGE_PROJECT_ID, STEPS, voyageTaskId, SEEDED;
9807
- var init_first_voyage = __esmMin((() => {
9808
- VOYAGE_PROJECT_ID = "voyage";
9809
- STEPS = [
9810
- {
9811
- step: "task",
9812
- title: "Add a task of your own",
9813
- description: "Press New, or the + at the top of a column on any board. Anything real will do. This ticks itself off when you add one."
9814
- },
9815
- {
9816
- step: "move",
9817
- title: "Move a task to In progress",
9818
- description: "Open Board above and drag a card along, or change its status in the task's panel. This one counts too. It ticks itself off."
9819
- },
9820
- {
9821
- step: "crew",
9822
- title: "Bring someone aboard, or tick this to skip",
9823
- description: "Invite from your menu or from People. Harbor is $49 a month for the whole crew, not per seat. Ticks itself off when someone's in People."
9824
- },
9825
- {
9826
- step: "terminal",
9827
- title: "Try Harbor in your terminal",
9828
- description: "Run `npx @harborppm/cli mine` (Node 22 or later) and sign in when your browser asks. `harbor` on its own opens the whole harbor full screen. Ticks itself off once the terminal is connected; skip it if a terminal isn't your thing."
9829
- },
9830
- {
9831
- step: "agent",
9832
- title: "Ask an AI app to add a task for you",
9833
- description: "Connect Claude, or any app that speaks MCP, at harborppm.com/ai, then ask it to add a task here. Harbor asks you first and History names the app. Ticks itself off once an app is connected."
9834
- },
9835
- {
9836
- step: "wrap",
9837
- title: "Mark this project Done, or delete it",
9838
- description: "That's the voyage. Set its health to Done to put it away, or delete it from Details. Your own work stays."
9839
- }
9840
- ];
9841
- voyageTaskId = (step) => `voyage-${step}`;
9842
- SEEDED = new Set(STEPS.map((s) => voyageTaskId(s.step)));
9843
- }));
9844
-
9845
10068
  //#endregion
9846
10069
  //#region src/lib/handover.ts
9847
10070
  /** Everything a person carries that someone else would have to pick up, grouped in KIND_ORDER. */
@@ -18880,7 +19103,7 @@ function reviewCsv(data, programs, now) {
18880
19103
  //#endregion
18881
19104
  //#region src/lib/crew-clocks.ts
18882
19105
  /** Working hours when only a time zone is set. */
18883
- const DEFAULT_HOURS$1 = {
19106
+ const DEFAULT_HOURS = {
18884
19107
  start: "09:00",
18885
19108
  end: "17:00"
18886
19109
  };
@@ -18954,8 +19177,8 @@ function clockOf(person, at) {
18954
19177
  const zone = person?.timeZone;
18955
19178
  if (!isZone(zone)) return null;
18956
19179
  const wall = wallIn(zone, at);
18957
- const start = minutesOf(person?.workStart) ?? minutesOf(DEFAULT_HOURS$1.start);
18958
- let end = minutesOf(person?.workEnd) ?? minutesOf(DEFAULT_HOURS$1.end);
19180
+ const start = minutesOf(person?.workStart) ?? minutesOf(DEFAULT_HOURS.start);
19181
+ let end = minutesOf(person?.workEnd) ?? minutesOf(DEFAULT_HOURS.end);
18959
19182
  if (end <= start) end = Math.min(1440, start + 480);
18960
19183
  const weekday = wall.weekday >= 1 && wall.weekday <= 5;
18961
19184
  const working = weekday && wall.minutes >= start && wall.minutes < end;
@@ -19437,150 +19660,12 @@ var init_fog = __esmMin((() => {
19437
19660
  fileCache = /* @__PURE__ */ new WeakMap();
19438
19661
  }));
19439
19662
 
19440
- //#endregion
19441
- //#region src/lib/passage.ts
19442
- /** Hours as a person would say them: "2h", "1.5h", "30m". */
19443
- function hoursText(h) {
19444
- if (h > 0 && h < 1) return `${Math.round(h * 60)}m`;
19445
- return `${Math.round(h * 10) / 10}h`;
19446
- }
19447
- /** Hours kept to the half hour, within what one day can take. */
19448
- function clampHours(h) {
19449
- if (!Number.isFinite(h)) return 2;
19450
- return Math.min(12, Math.max(MIN_HOURS, Math.round(h * 2) / 2));
19451
- }
19452
- function dayHoursOf(person) {
19453
- const h = person?.dayHours;
19454
- return typeof h === "number" && h > 0 ? Math.min(16, h) : 7;
19455
- }
19456
- /** The Monday of the week a day falls in (a Saturday or Sunday looks ahead to the next week). */
19457
- function weekOf(day = todayISO()) {
19458
- const d = at(day);
19459
- const wd = d.getDay();
19460
- const back = wd === 0 ? -1 : wd === 6 ? -2 : wd - 1;
19461
- return iso$1(addDays(d, -back));
19462
- }
19463
- /** Monday to Friday of the week starting `monday`. */
19464
- function weekDays(monday) {
19465
- const d = at(monday);
19466
- return [
19467
- 0,
19468
- 1,
19469
- 2,
19470
- 3,
19471
- 4
19472
- ].map((i) => iso$1(addDays(d, i)));
19473
- }
19474
- function shiftWeek(monday, weeks) {
19475
- return iso$1(addDays(at(monday), weeks * 7));
19476
- }
19477
- /** One person's placements, every week. */
19478
- function placementsOf(data, personId) {
19479
- return (data.reflections ?? []).filter((r) => isPassage(r) && r.authorId === personId);
19480
- }
19481
- /** The week as its owner plans it. Placements whose task is gone are left out. */
19482
- function weekPlan(data, personId, monday) {
19483
- const tasks = byId(data.tasks);
19484
- const dates = weekDays(monday);
19485
- const dayHours = dayHoursOf(data.people.find((p) => p.id === personId));
19486
- const inWeek = placementsOf(data, personId).filter((r) => r.date && dates.includes(r.date) && r.taskId && tasks.has(r.taskId));
19487
- const days = dates.map((date) => {
19488
- const items = inWeek.filter((r) => r.date === date).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((r) => ({
19489
- id: r.id,
19490
- task: tasks.get(r.taskId),
19491
- date,
19492
- hours: clampHours(r.hours ?? 2)
19493
- }));
19494
- const hours = items.reduce((sum, i) => sum + i.hours, 0);
19495
- return {
19496
- date,
19497
- items,
19498
- hours,
19499
- over: hours > dayHours
19500
- };
19501
- });
19502
- const placed = new Set(inWeek.map((r) => r.taskId));
19503
- const open = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done" && !placed.has(t.id));
19504
- const unplanned = open.filter((t) => t.status !== "review").sort(byDue);
19505
- const inReview = open.filter((t) => t.status === "review").sort(byDue);
19506
- return {
19507
- monday,
19508
- days,
19509
- dayHours,
19510
- planned: days.reduce((sum, d) => sum + d.hours, 0),
19511
- capacity: dayHours * 5,
19512
- unplanned,
19513
- inReview
19514
- };
19515
- }
19516
- /** Planned against done for a week: what was on the plan, what got finished, and what came in besides. */
19517
- function weekReview(data, personId, monday) {
19518
- const plan = weekPlan(data, personId, monday);
19519
- const first = plan.days[0].date;
19520
- const sunday = iso$1(addDays(at(monday), 6));
19521
- const doneInWeek = (t) => t.status === "done" && Boolean(t.completedAt) && todayISO(new Date(t.completedAt)) >= first && todayISO(new Date(t.completedAt)) <= sunday;
19522
- const hoursBy = /* @__PURE__ */ new Map();
19523
- for (const d of plan.days) for (const i of d.items) {
19524
- const had = hoursBy.get(i.task.id);
19525
- hoursBy.set(i.task.id, {
19526
- task: i.task,
19527
- hours: (had?.hours ?? 0) + i.hours
19528
- });
19529
- }
19530
- const planned = [...hoursBy.values()].map(({ task, hours }) => ({
19531
- task,
19532
- hours,
19533
- done: task.status === "done" && (!task.completedAt || todayISO(new Date(task.completedAt)) <= sunday)
19534
- }));
19535
- const alsoDone = data.tasks.filter((t) => t.assigneeId === personId && doneInWeek(t) && !hoursBy.has(t.id)).sort((a, b) => (a.completedAt ?? "").localeCompare(b.completedAt ?? ""));
19536
- return {
19537
- monday,
19538
- planned,
19539
- doneCount: planned.filter((p) => p.done).length,
19540
- alsoDone,
19541
- plannedHours: plan.planned,
19542
- doneHours: planned.filter((p) => p.done).reduce((sum, p) => sum + p.hours, 0)
19543
- };
19544
- }
19545
- /** The one line the Friday view leads with. */
19546
- function reviewText(r) {
19547
- if (r.planned.length === 0) return r.alsoDone.length ? `Nothing was planned, and you finished ${r.alsoDone.length}.` : "Nothing was planned this week.";
19548
- const also = r.alsoDone.length ? `, and ${r.alsoDone.length} more besides` : "";
19549
- return `You finished ${r.doneCount} of the ${r.planned.length} you planned${also}.`;
19550
- }
19551
- /** Placements of this person's from before the weeks kept, which a new plan clears. */
19552
- function stalePlacements(data, personId, today = todayISO()) {
19553
- const cutoff = shiftWeek(weekOf(today), -12);
19554
- return new Set(placementsOf(data, personId).filter((r) => (r.date ?? "") < cutoff).map((r) => r.id));
19555
- }
19556
- var DAY_HOURS, DEFAULT_HOURS, MIN_HOURS, MAX_HOURS, isPassage, iso$1, at, byDue, DAY_WORDS;
19557
- var init_passage = __esmMin((() => {
19558
- init_date_fns();
19559
- init_date_text();
19560
- init_format();
19561
- init_indexes();
19562
- DAY_HOURS = 7;
19563
- DEFAULT_HOURS = 2;
19564
- MIN_HOURS = .5;
19565
- MAX_HOURS = 12;
19566
- isPassage = (r) => r.kind === "passage";
19567
- iso$1 = (d) => todayISO(d);
19568
- at = (day) => parseIso(`${day}T12:00:00`);
19569
- byDue = (a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999");
19570
- DAY_WORDS = [
19571
- "mon",
19572
- "tue",
19573
- "wed",
19574
- "thu",
19575
- "fri"
19576
- ];
19577
- }));
19578
-
19579
19663
  //#endregion
19580
19664
  //#region src/lib/mcp/tools.ts
19581
19665
  init_thanks();
19582
19666
  init_task_keys();
19583
19667
  init_brief();
19668
+ init_first_voyage();
19584
19669
  init_indexes();
19585
19670
  init_tide();
19586
19671
  init_barnacles();
@@ -19682,6 +19767,7 @@ const STATUSES = [
19682
19767
  * Given the spend ledger, also what each week late costs (src/lib/slip-cost.ts).
19683
19768
  */
19684
19769
  function finishForecast(project, tasks, now, spend, data) {
19770
+ if (isVoyageProject(project.id)) return { none: VOYAGE_NO_FORECAST };
19685
19771
  const tide = tideForecast(project, tasks, now, data ? tideOptions(data) : {});
19686
19772
  if (tide.kind !== "forecast") return { none: tideQuiet(tide) };
19687
19773
  const lastDue = dueAfterFinish(tide);
@@ -27975,7 +28061,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
27975
28061
  justifyContent: "space-between",
27976
28062
  columnGap: 2,
27977
28063
  children: [/* @__PURE__ */ jsx(Box, {
27978
- flexShrink: 1,
28064
+ flexShrink: 0,
28065
+ maxWidth: "70%",
27979
28066
  children: /* @__PURE__ */ jsxs(Text, {
27980
28067
  wrap: "truncate-end",
27981
28068
  children: [
@@ -27996,7 +28083,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
27996
28083
  ]
27997
28084
  })
27998
28085
  }), /* @__PURE__ */ jsx(Box, {
27999
- flexShrink: 2,
28086
+ flexShrink: 1,
28000
28087
  children: /* @__PURE__ */ jsxs(Text, {
28001
28088
  color: theme.muted,
28002
28089
  wrap: "truncate-end",
@@ -28090,7 +28177,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
28090
28177
  const w = Math.floor((width - 2) / shownColumns.length);
28091
28178
  const on = active && ci === col;
28092
28179
  const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
28093
- const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
28180
+ const room = body - 3;
28181
+ const fits = Math.floor(room / 3) >= c.tasks.length;
28182
+ const view = windowOf(c.tasks, r, Math.max(1, Math.floor((fits ? room : room - 2) / 3)));
28094
28183
  const over = c.limit != null && c.all > c.limit;
28095
28184
  return /* @__PURE__ */ jsxs(Box, {
28096
28185
  flexDirection: "column",
@@ -29725,6 +29814,189 @@ function TodayView({ active, width, height, go }) {
29725
29814
  });
29726
29815
  }
29727
29816
 
29817
+ //#endregion
29818
+ //#region src/tui/views/week.tsx
29819
+ init_date_text();
29820
+ init_passage();
29821
+ const dayName = (date) => format(parseIso(`${date}T12:00:00`), "EEEE");
29822
+ /**
29823
+ * Your week, as on the website: your tasks laid on the five working days
29824
+ * against the hours you work, and from Friday how it went. Only you see it.
29825
+ * p puts a task on a day (or moves it), h sets its hours, d takes it off,
29826
+ * [ and ] step through the weeks, v swaps plan and how it went.
29827
+ */
29828
+ function WeekView({ active, width, height, go }) {
29829
+ const { store, today } = useDeps();
29830
+ const tasks = useHarbor((s) => s.tasks);
29831
+ const projects = useHarbor((s) => s.projects);
29832
+ const people = useHarbor((s) => s.people);
29833
+ const reflections = useHarbor((s) => s.reflections);
29834
+ const me = useHarbor((s) => s.currentUserId);
29835
+ const day = today();
29836
+ const [monday, setMonday] = useState(() => weekOf(day));
29837
+ const [review, setReview] = useState(() => isReviewTime(weekOf(day), day));
29838
+ const [at, setAt] = useState(0);
29839
+ const data = useMemo(() => ({
29840
+ tasks,
29841
+ projects,
29842
+ people,
29843
+ reflections
29844
+ }), [
29845
+ tasks,
29846
+ projects,
29847
+ people,
29848
+ reflections
29849
+ ]);
29850
+ const { plan, lines } = useMemo(() => weekLines(data, me, monday, day), [
29851
+ data,
29852
+ me,
29853
+ monday,
29854
+ day
29855
+ ]);
29856
+ const read = useMemo(() => weekReviewLines(data, me, monday), [
29857
+ data,
29858
+ me,
29859
+ monday
29860
+ ]);
29861
+ const picks = lines.flatMap((l, i) => l.kind === "placed" || l.kind === "open" ? [i] : []);
29862
+ const sel = Math.min(at, Math.max(0, picks.length - 1));
29863
+ const line = picks.length ? lines[picks[sel]] : void 0;
29864
+ const task = line?.kind === "placed" ? line.placement.task : line?.kind === "open" ? line.task : void 0;
29865
+ const dates = weekDays(monday);
29866
+ const dayChoices = (except) => dates.filter((d) => d !== except).map((d) => ({
29867
+ label: `${dayName(d)}${d === day ? " (today)" : ""}`,
29868
+ value: d
29869
+ }));
29870
+ const goWeek = (weeks) => {
29871
+ const next = weeks === 0 ? weekOf(day) : shiftWeek(monday, weeks);
29872
+ setMonday(next);
29873
+ setReview(isReviewTime(next, day) && next <= weekOf(day));
29874
+ setAt(0);
29875
+ };
29876
+ useInput((input, key) => {
29877
+ if (input === "[") return goWeek(-1);
29878
+ if (input === "]") return goWeek(1);
29879
+ if (input === "t") return goWeek(0);
29880
+ if (input === "v") return setReview((v) => !v);
29881
+ if (review) return;
29882
+ if (key.upArrow || input === "k") setAt(step(sel, -1, picks.length));
29883
+ else if (key.downArrow || input === "j") setAt(step(sel, 1, picks.length));
29884
+ else if (key.return && task) go.openTask(task.id);
29885
+ else if (input === "p" && line?.kind === "open") go.pick(`Plan “${line.task.title}” on`, dayChoices(), (d) => {
29886
+ if (store.getState().planTask(line.task.id, d)) go.notify(`On ${dayName(d)}. h changes its hours.`);
29887
+ });
29888
+ else if (input === "p" && line?.kind === "placed") go.pick(`Move “${line.placement.task.title}” to`, dayChoices(line.placement.date), (d) => store.getState().movePlacement(line.placement.id, d));
29889
+ else if (input === "h" && line?.kind === "placed") go.pick(`Hours for “${line.placement.task.title}” on ${dayName(line.placement.date)}`, HOUR_CHOICES.map((h) => ({
29890
+ label: hoursText(h),
29891
+ value: h
29892
+ })), (h) => store.getState().setPlacementHours(line.placement.id, h));
29893
+ else if ((input === "d" || key.delete) && line?.kind === "placed") {
29894
+ store.getState().unplan(line.placement.id);
29895
+ go.notify(`Took ${keyOf$1({ projects }, line.placement.task)} off ${dayName(line.placement.date)}.`);
29896
+ }
29897
+ }, { isActive: active });
29898
+ const title = `Your week · ${format(parseIso(`${dates[0]}T12:00:00`), "MMM d")} to ${format(parseIso(`${dates[4]}T12:00:00`), "MMM d")}`;
29899
+ const rows = Math.max(3, height - 4);
29900
+ if (review) return /* @__PURE__ */ jsxs(Box, {
29901
+ flexDirection: "column",
29902
+ width,
29903
+ height,
29904
+ paddingX: 1,
29905
+ children: [
29906
+ /* @__PURE__ */ jsx(Heading, { children: `${title} · How it went` }),
29907
+ read.slice(0, rows).map((l, i) => /* @__PURE__ */ jsx(Text, {
29908
+ wrap: "truncate-end",
29909
+ color: i === 0 ? void 0 : theme.muted,
29910
+ children: l || " "
29911
+ }, i)),
29912
+ /* @__PURE__ */ jsx(Box, {
29913
+ marginTop: 1,
29914
+ children: /* @__PURE__ */ jsx(Text, {
29915
+ color: theme.muted,
29916
+ wrap: "truncate-end",
29917
+ children: "Only you see this · v the plan · [ ] other weeks · t this week"
29918
+ })
29919
+ })
29920
+ ]
29921
+ });
29922
+ const view = windowOf(lines, picks[sel] ?? 0, rows);
29923
+ return /* @__PURE__ */ jsxs(Box, {
29924
+ flexDirection: "column",
29925
+ width,
29926
+ height,
29927
+ paddingX: 1,
29928
+ children: [
29929
+ /* @__PURE__ */ jsx(Heading, {
29930
+ right: plannedText(plan.planned, plan.capacity),
29931
+ children: title
29932
+ }),
29933
+ view.items.map((l, i) => {
29934
+ const selected = active && view.start + i === picks[sel];
29935
+ if (l.kind === "day") return /* @__PURE__ */ jsxs(Text, {
29936
+ wrap: "truncate-end",
29937
+ children: [/* @__PURE__ */ jsx(Text, {
29938
+ bold: true,
29939
+ children: l.label
29940
+ }), /* @__PURE__ */ jsxs(Text, {
29941
+ color: l.over ? theme.warn : theme.muted,
29942
+ children: [
29943
+ " ",
29944
+ l.load,
29945
+ l.today ? " · today" : ""
29946
+ ]
29947
+ })]
29948
+ }, l.date);
29949
+ if (l.kind === "placed") return /* @__PURE__ */ jsxs(Box, {
29950
+ width: width - 2,
29951
+ children: [/* @__PURE__ */ jsx(Box, {
29952
+ flexGrow: 1,
29953
+ flexShrink: 1,
29954
+ children: /* @__PURE__ */ jsx(TaskLine, {
29955
+ task: l.placement.task,
29956
+ projects,
29957
+ today: day,
29958
+ selected,
29959
+ width: width - 8,
29960
+ showProject: false
29961
+ })
29962
+ }), /* @__PURE__ */ jsxs(Text, {
29963
+ color: theme.muted,
29964
+ children: [" ", hoursText(l.placement.hours).padStart(4)]
29965
+ })]
29966
+ }, l.placement.id);
29967
+ if (l.kind === "open") return /* @__PURE__ */ jsx(TaskLine, {
29968
+ task: l.task,
29969
+ projects,
29970
+ today: day,
29971
+ selected,
29972
+ width: width - 2
29973
+ }, `open-${l.task.id}`);
29974
+ if (l.kind === "head") return /* @__PURE__ */ jsx(Box, {
29975
+ marginTop: view.start + i === 0 ? 0 : 1,
29976
+ children: /* @__PURE__ */ jsx(Text, {
29977
+ bold: true,
29978
+ wrap: "truncate-end",
29979
+ children: l.text
29980
+ })
29981
+ }, `head-${i}`);
29982
+ return /* @__PURE__ */ jsxs(Text, {
29983
+ color: theme.muted,
29984
+ wrap: "truncate-end",
29985
+ children: [" ", l.text]
29986
+ }, `note-${i}`);
29987
+ }),
29988
+ /* @__PURE__ */ jsx(Box, {
29989
+ marginTop: 1,
29990
+ children: /* @__PURE__ */ jsx(Text, {
29991
+ color: theme.muted,
29992
+ wrap: "truncate-end",
29993
+ children: "Only you see this · p plan · h hours · d off · v how it went · [ ] weeks"
29994
+ })
29995
+ })
29996
+ ]
29997
+ });
29998
+ }
29999
+
29728
30000
  //#endregion
29729
30001
  //#region src/tui/app.tsx
29730
30002
  /**
@@ -29778,10 +30050,14 @@ const NAV = [
29778
30050
  {
29779
30051
  screen: "crew",
29780
30052
  label: "Crew"
30053
+ },
30054
+ {
30055
+ screen: "week",
30056
+ label: "Your week"
29781
30057
  }
29782
30058
  ];
29783
- /** The key that opens a sidebar screen: 1 to 9, 0 for the tenth, C for the crew. */
29784
- const navKey = (i) => i < 10 ? String((i + 1) % 10) : "C";
30059
+ /** The key that opens a sidebar screen: 1 to 9, 0 for the tenth, C for the crew, W for your week. */
30060
+ const navKey = (i) => i < 10 ? String((i + 1) % 10) : i === 10 ? "C" : "W";
29785
30061
  /** The sidebar's width: its longest line ("9 Calendar", ": Anything") with room either side. */
29786
30062
  const NAV_WIDTH = 15;
29787
30063
  /** Every open program, in the review's order: what a meeting started from the palette covers. */
@@ -30002,6 +30278,7 @@ function App({ ui, startIn }) {
30002
30278
  if (input === "?") return setOverlay({ kind: "help" });
30003
30279
  if (input === "H") return setOverlay({ kind: "harbors" });
30004
30280
  if (input === "C") return goScreen("crew");
30281
+ if (input === "W") return goScreen("week");
30005
30282
  if (input === "r" && page?.kind !== "bridge") {
30006
30283
  sync.retryNow();
30007
30284
  return notify("Saving now.");
@@ -30295,6 +30572,7 @@ function App({ ui, startIn }) {
30295
30572
  else if (screen === "calendar") screenNode = /* @__PURE__ */ jsx(CalendarView, { ...viewProps });
30296
30573
  else if (screen === "brief") screenNode = /* @__PURE__ */ jsx(BriefView, { ...viewProps });
30297
30574
  else if (screen === "crew") screenNode = /* @__PURE__ */ jsx(CrewView, { ...viewProps });
30575
+ else if (screen === "week") screenNode = /* @__PURE__ */ jsx(WeekView, { ...viewProps });
30298
30576
  else screenNode = /* @__PURE__ */ jsx(HistoryView, { ...viewProps });
30299
30577
  return /* @__PURE__ */ jsxs(Box, {
30300
30578
  flexDirection: "column",
package/lib/cli.mjs CHANGED
@@ -440,11 +440,28 @@ const level = (v, name) => {
440
440
  const nullable = (v) => (v === "none" ? null : v);
441
441
  const REPEAT_WORD = { weekly: "every week", biweekly: "every two weeks", monthly: "every month" };
442
442
 
443
- /** --due as people say it: today, tomorrow, +3, or a date. Anything else goes to Harbor to judge. */
443
+ const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
444
+
445
+ /** "fri", "Friday": how many days until that weekday comes round (today counts). */
446
+ function daysToWeekday(v, today) {
447
+ const w = v.toLowerCase();
448
+ if (w.length < 3) return null;
449
+ const i = WEEKDAYS.findIndex((d) => d.startsWith(w));
450
+ return i < 0 ? null : (i - today.getDay() + 7) % 7;
451
+ }
452
+
453
+ /** --due as people say it: today, tomorrow, friday, +3, or a date. Anything else goes to Harbor to judge. */
444
454
  export function dueDate(v, today = new Date()) {
445
455
  if (v === undefined || v === null) return v;
446
456
  const words = { today: 0, tomorrow: 1 };
447
- const days = v in words ? words[v] : /^\+\d{1,3}$/.test(v) ? Number(v.slice(1)) : null;
457
+ const days =
458
+ v in words
459
+ ? words[v]
460
+ : /^\+\d{1,3}$/.test(v)
461
+ ? Number(v.slice(1))
462
+ : /^[a-z]+$/i.test(v)
463
+ ? daysToWeekday(v, today)
464
+ : null;
448
465
  if (days === null) return v;
449
466
  const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
450
467
  return todayLocal(d);
@@ -538,7 +555,7 @@ export function hint(message, project) {
538
555
  (_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
539
556
  )
540
557
  // The CLI reads today, tomorrow and +N before Harbor sees the date.
541
- .replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow or +N days");
558
+ .replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow, friday or +N days");
542
559
  if (!seen.has(flag)) seen.set(flag, said);
543
560
  }
544
561
  return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
@@ -1226,12 +1243,28 @@ function distance(a, b) {
1226
1243
  return d[a.length][b.length];
1227
1244
  }
1228
1245
 
1246
+ /** What someone who knows the website, or a feature's name, might type. */
1247
+ const OTHER_NAMES = {
1248
+ today: "harbor mine lists what's on you, late first; harbor week plans your days.",
1249
+ inbox: "harbor catchup says what was handed to you and what moved.",
1250
+ people: "harbor lifeboats, harbor clock and harbor ashore cover the crew; the app's People screen (harbor, then 6) shows everyone's load.",
1251
+ crew: "harbor invite EMAIL brings someone aboard; the app's Crew screen (harbor, then C) lists who's on it.",
1252
+ longpole: "harbor why PROJECT shows what's holding it up.",
1253
+ "long-pole": "harbor why PROJECT shows what's holding it up.",
1254
+ pilot: "harbor show TASK says the date its owner's queue suggests; --due pilot on add or edit takes it.",
1255
+ dock: "harbor try PROJECT is the dry dock.",
1256
+ "dry-dock": "harbor try PROJECT is the dry dock.",
1257
+ };
1258
+
1229
1259
  /** A mistyped command, with the one it was probably meant to be. */
1230
1260
  export function noCommand(name) {
1231
1261
  if (name.startsWith("-")) return `${name} goes after a command, like harbor mine ${name}.`;
1232
1262
  // A plural kind reads the register, not the command that writes one.
1233
1263
  if (["decisions", "issues"].includes(name))
1234
1264
  return `No command "${name}". harbor risks --kind ${name.slice(0, -1)} lists them.`;
1265
+ // Words for things the website has a page for, or that go by another name here.
1266
+ const other = OTHER_NAMES[name.toLowerCase()];
1267
+ if (other) return `No command "${name}". ${other}`;
1235
1268
  const near = COMMANDS.filter((c) => distance(name, c) <= (c.length > 4 ? 2 : 1));
1236
1269
  const guess = near.length === 1 ? ` Did you mean harbor ${near[0]}?` : "";
1237
1270
  return `No command "${name}".${guess} Run harbor help.`;
package/lib/format.mjs CHANGED
@@ -581,7 +581,7 @@ export function bridgeText(r) {
581
581
  );
582
582
  for (const p of r.projects ?? []) {
583
583
  const facts = [
584
- p.health,
584
+ p.health?.replace(/-/g, " "),
585
585
  p.lead,
586
586
  p.ends ? `ends ${p.ends}` : null,
587
587
  `${p.late} late of ${p.open} open`,
@@ -756,7 +756,8 @@ export function importText(r) {
756
756
  if (r.alreadyThere) lines.push(`${n(r.alreadyThere, "task")} already there, left alone.`);
757
757
  if (r.leftOut) lines.push(`${n(r.leftOut, "row")} left out (canceled, won't do or duplicate).`);
758
758
  if (r.waitsOnLinks) lines.push(`${n(r.waitsOnLinks, "link")} between tasks became "waits on".`);
759
- for (const p of r.problems ?? []) lines.push(`Row ${p.row}: ${p.message}`);
759
+ for (const p of r.problems ?? [])
760
+ lines.push(`${p.card ? `Card “${p.card}”` : `Row ${p.row}`}: ${p.message}`);
760
761
  return lines.join("\n");
761
762
  }
762
763
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.68.0",
3
+ "version": "0.70.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",