@harborppm/cli 0.69.0 → 0.71.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
@@ -6749,7 +7011,7 @@ function programIsBlocked(data, program) {
6749
7011
  }
6750
7012
  function daysOverdue(dueDate, now) {
6751
7013
  if (!dueDate) return 0;
6752
- const today = iso$3(now);
7014
+ const today = iso$2(now);
6753
7015
  if (today !== overdueDay) {
6754
7016
  overdueDay = today;
6755
7017
  overdueByDue.clear();
@@ -6768,7 +7030,7 @@ function daysOverdue(dueDate, now) {
6768
7030
  }
6769
7031
  function daysUntil$1(date, now) {
6770
7032
  if (!date) return null;
6771
- const today = iso$3(now);
7033
+ const today = iso$2(now);
6772
7034
  if (today !== untilDay) {
6773
7035
  untilDay = today;
6774
7036
  untilByDate.clear();
@@ -6833,7 +7095,7 @@ function effectiveProjectHealth(project, data, now) {
6833
7095
  }
6834
7096
  /** Milestones past their date and still open. */
6835
7097
  function lateMilestonesOf(milestones, now) {
6836
- const today = iso$3(now);
7098
+ const today = iso$2(now);
6837
7099
  return milestones.filter((m) => !m.done && m.dueDate < today);
6838
7100
  }
6839
7101
  /**
@@ -6938,7 +7200,7 @@ function programLateCount(programId, data, now) {
6938
7200
  for (const p of data.projects) if (p.programId === programId) late += lateTasksOf([...tasksOf(data.tasks, p.id)], now).length;
6939
7201
  return late;
6940
7202
  }
6941
- var iso$3, overdueDay, overdueByDue, untilDay, untilByDate;
7203
+ var iso$2, overdueDay, overdueByDue, untilDay, untilByDate;
6942
7204
  var init_derived_health = __esmMin((() => {
6943
7205
  init_date_fns();
6944
7206
  init_date_text();
@@ -6946,7 +7208,7 @@ var init_derived_health = __esmMin((() => {
6946
7208
  init_indexes();
6947
7209
  init_health_history();
6948
7210
  init_clock();
6949
- iso$3 = (d) => {
7211
+ iso$2 = (d) => {
6950
7212
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
6951
7213
  };
6952
7214
  overdueDay = "";
@@ -7417,7 +7679,7 @@ function briefOf(data, opts = {}) {
7417
7679
  const portfolioId = opts.portfolioId ?? null;
7418
7680
  const programs = portfolioId ? data.programs.filter((p) => p.portfolioId === portfolioId) : data.programs;
7419
7681
  const programIds = new Set(programs.map((p) => p.id));
7420
- const projects = data.projects.filter((p) => programIds.has(p.programId));
7682
+ const projects = ownProjects(data.projects.filter((p) => programIds.has(p.programId)));
7421
7683
  const projectIds = new Set(projects.map((p) => p.id));
7422
7684
  const tasks = data.tasks.filter((t) => projectIds.has(t.projectId));
7423
7685
  const risks = data.raidItems.filter((r) => r.kind === "risk" && r.programId != null && programIds.has(r.programId));
@@ -7631,6 +7893,7 @@ var init_brief = __esmMin((() => {
7631
7893
  init_decisions();
7632
7894
  init_barnacles();
7633
7895
  init_baseline();
7896
+ init_first_voyage();
7634
7897
  BRIEF_BARNACLES_SHOWN = 5;
7635
7898
  BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
7636
7899
  BRIEF_LATE_SHOWN = 8;
@@ -7995,6 +8258,7 @@ init_raid();
7995
8258
  init_swatches();
7996
8259
  init_task_keys();
7997
8260
  init_format();
8261
+ init_first_voyage();
7998
8262
  /** How many items of one kind a slide lists before "and N more". */
7999
8263
  const LIST_CAP = 4;
8000
8264
  /** When a program's changes are counted from: its last meeting, else a week back. */
@@ -8105,7 +8369,7 @@ function bridgeSlides(doc, programs, now) {
8105
8369
  if (program.health === "done") continue;
8106
8370
  const since = bridgeSince(program, now);
8107
8371
  const milestone = doc.milestones.filter((m) => m.programId === program.id && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate))[0] ?? null;
8108
- 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));
8109
8373
  for (const project of projects) {
8110
8374
  const changes = projectChanges(doc, project, since, now);
8111
8375
  if (changes.length === 0) {
@@ -9752,13 +10016,13 @@ function InboxView({ active, width, height, go }) {
9752
10016
  /** Open tasks for one person, bucketed by the week they are due, plus what is already late or has no date. */
9753
10017
  function weeklyLoad(tasks, personId, now, weeks = 6, calendar) {
9754
10018
  const mine = tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
9755
- const today = iso$2(now);
10019
+ const today = iso$1(now);
9756
10020
  const origin = startOfWeek(now, { weekStartsOn: 1 });
9757
10021
  const buckets = Array.from({ length: weeks }, (_, i) => {
9758
- const start = iso$2(addDays(origin, i * 7));
10022
+ const start = iso$1(addDays(origin, i * 7));
9759
10023
  const off = [];
9760
10024
  if (calendar?.active) for (let d = 0; d < 5; d++) {
9761
- const date = iso$2(addDays(origin, i * 7 + d));
10025
+ const date = iso$1(addDays(origin, i * 7 + d));
9762
10026
  const why = calendar.offOn(personId, date);
9763
10027
  if (why) off.push({
9764
10028
  date,
@@ -9793,58 +10057,14 @@ function weeklyLoad(tasks, personId, now, weeks = 6, calendar) {
9793
10057
  weeks: buckets
9794
10058
  };
9795
10059
  }
9796
- var iso$2;
10060
+ var iso$1;
9797
10061
  var init_load = __esmMin((() => {
9798
10062
  init_date_fns();
9799
- iso$2 = (d) => {
10063
+ iso$1 = (d) => {
9800
10064
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
9801
10065
  };
9802
10066
  }));
9803
10067
 
9804
- //#endregion
9805
- //#region src/lib/first-voyage.ts
9806
- function isVoyageProject(projectId) {
9807
- return projectId === VOYAGE_PROJECT_ID;
9808
- }
9809
- var VOYAGE_PROJECT_ID, STEPS, voyageTaskId, SEEDED;
9810
- var init_first_voyage = __esmMin((() => {
9811
- VOYAGE_PROJECT_ID = "voyage";
9812
- STEPS = [
9813
- {
9814
- step: "task",
9815
- title: "Add a task of your own",
9816
- 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."
9817
- },
9818
- {
9819
- step: "move",
9820
- title: "Move a task to In progress",
9821
- 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."
9822
- },
9823
- {
9824
- step: "crew",
9825
- title: "Bring someone aboard, or tick this to skip",
9826
- 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."
9827
- },
9828
- {
9829
- step: "terminal",
9830
- title: "Try Harbor in your terminal",
9831
- 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."
9832
- },
9833
- {
9834
- step: "agent",
9835
- title: "Ask an AI app to add a task for you",
9836
- 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."
9837
- },
9838
- {
9839
- step: "wrap",
9840
- title: "Mark this project Done, or delete it",
9841
- description: "That's the voyage. Set its health to Done to put it away, or delete it from Details. Your own work stays."
9842
- }
9843
- ];
9844
- voyageTaskId = (step) => `voyage-${step}`;
9845
- SEEDED = new Set(STEPS.map((s) => voyageTaskId(s.step)));
9846
- }));
9847
-
9848
10068
  //#endregion
9849
10069
  //#region src/lib/handover.ts
9850
10070
  /** Everything a person carries that someone else would have to pick up, grouped in KIND_ORDER. */
@@ -18883,7 +19103,7 @@ function reviewCsv(data, programs, now) {
18883
19103
  //#endregion
18884
19104
  //#region src/lib/crew-clocks.ts
18885
19105
  /** Working hours when only a time zone is set. */
18886
- const DEFAULT_HOURS$1 = {
19106
+ const DEFAULT_HOURS = {
18887
19107
  start: "09:00",
18888
19108
  end: "17:00"
18889
19109
  };
@@ -18957,8 +19177,8 @@ function clockOf(person, at) {
18957
19177
  const zone = person?.timeZone;
18958
19178
  if (!isZone(zone)) return null;
18959
19179
  const wall = wallIn(zone, at);
18960
- const start = minutesOf(person?.workStart) ?? minutesOf(DEFAULT_HOURS$1.start);
18961
- 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);
18962
19182
  if (end <= start) end = Math.min(1440, start + 480);
18963
19183
  const weekday = wall.weekday >= 1 && wall.weekday <= 5;
18964
19184
  const working = weekday && wall.minutes >= start && wall.minutes < end;
@@ -19440,150 +19660,12 @@ var init_fog = __esmMin((() => {
19440
19660
  fileCache = /* @__PURE__ */ new WeakMap();
19441
19661
  }));
19442
19662
 
19443
- //#endregion
19444
- //#region src/lib/passage.ts
19445
- /** Hours as a person would say them: "2h", "1.5h", "30m". */
19446
- function hoursText(h) {
19447
- if (h > 0 && h < 1) return `${Math.round(h * 60)}m`;
19448
- return `${Math.round(h * 10) / 10}h`;
19449
- }
19450
- /** Hours kept to the half hour, within what one day can take. */
19451
- function clampHours(h) {
19452
- if (!Number.isFinite(h)) return 2;
19453
- return Math.min(12, Math.max(MIN_HOURS, Math.round(h * 2) / 2));
19454
- }
19455
- function dayHoursOf(person) {
19456
- const h = person?.dayHours;
19457
- return typeof h === "number" && h > 0 ? Math.min(16, h) : 7;
19458
- }
19459
- /** The Monday of the week a day falls in (a Saturday or Sunday looks ahead to the next week). */
19460
- function weekOf(day = todayISO()) {
19461
- const d = at(day);
19462
- const wd = d.getDay();
19463
- const back = wd === 0 ? -1 : wd === 6 ? -2 : wd - 1;
19464
- return iso$1(addDays(d, -back));
19465
- }
19466
- /** Monday to Friday of the week starting `monday`. */
19467
- function weekDays(monday) {
19468
- const d = at(monday);
19469
- return [
19470
- 0,
19471
- 1,
19472
- 2,
19473
- 3,
19474
- 4
19475
- ].map((i) => iso$1(addDays(d, i)));
19476
- }
19477
- function shiftWeek(monday, weeks) {
19478
- return iso$1(addDays(at(monday), weeks * 7));
19479
- }
19480
- /** One person's placements, every week. */
19481
- function placementsOf(data, personId) {
19482
- return (data.reflections ?? []).filter((r) => isPassage(r) && r.authorId === personId);
19483
- }
19484
- /** The week as its owner plans it. Placements whose task is gone are left out. */
19485
- function weekPlan(data, personId, monday) {
19486
- const tasks = byId(data.tasks);
19487
- const dates = weekDays(monday);
19488
- const dayHours = dayHoursOf(data.people.find((p) => p.id === personId));
19489
- const inWeek = placementsOf(data, personId).filter((r) => r.date && dates.includes(r.date) && r.taskId && tasks.has(r.taskId));
19490
- const days = dates.map((date) => {
19491
- const items = inWeek.filter((r) => r.date === date).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((r) => ({
19492
- id: r.id,
19493
- task: tasks.get(r.taskId),
19494
- date,
19495
- hours: clampHours(r.hours ?? 2)
19496
- }));
19497
- const hours = items.reduce((sum, i) => sum + i.hours, 0);
19498
- return {
19499
- date,
19500
- items,
19501
- hours,
19502
- over: hours > dayHours
19503
- };
19504
- });
19505
- const placed = new Set(inWeek.map((r) => r.taskId));
19506
- const open = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done" && !placed.has(t.id));
19507
- const unplanned = open.filter((t) => t.status !== "review").sort(byDue);
19508
- const inReview = open.filter((t) => t.status === "review").sort(byDue);
19509
- return {
19510
- monday,
19511
- days,
19512
- dayHours,
19513
- planned: days.reduce((sum, d) => sum + d.hours, 0),
19514
- capacity: dayHours * 5,
19515
- unplanned,
19516
- inReview
19517
- };
19518
- }
19519
- /** Planned against done for a week: what was on the plan, what got finished, and what came in besides. */
19520
- function weekReview(data, personId, monday) {
19521
- const plan = weekPlan(data, personId, monday);
19522
- const first = plan.days[0].date;
19523
- const sunday = iso$1(addDays(at(monday), 6));
19524
- const doneInWeek = (t) => t.status === "done" && Boolean(t.completedAt) && todayISO(new Date(t.completedAt)) >= first && todayISO(new Date(t.completedAt)) <= sunday;
19525
- const hoursBy = /* @__PURE__ */ new Map();
19526
- for (const d of plan.days) for (const i of d.items) {
19527
- const had = hoursBy.get(i.task.id);
19528
- hoursBy.set(i.task.id, {
19529
- task: i.task,
19530
- hours: (had?.hours ?? 0) + i.hours
19531
- });
19532
- }
19533
- const planned = [...hoursBy.values()].map(({ task, hours }) => ({
19534
- task,
19535
- hours,
19536
- done: task.status === "done" && (!task.completedAt || todayISO(new Date(task.completedAt)) <= sunday)
19537
- }));
19538
- const alsoDone = data.tasks.filter((t) => t.assigneeId === personId && doneInWeek(t) && !hoursBy.has(t.id)).sort((a, b) => (a.completedAt ?? "").localeCompare(b.completedAt ?? ""));
19539
- return {
19540
- monday,
19541
- planned,
19542
- doneCount: planned.filter((p) => p.done).length,
19543
- alsoDone,
19544
- plannedHours: plan.planned,
19545
- doneHours: planned.filter((p) => p.done).reduce((sum, p) => sum + p.hours, 0)
19546
- };
19547
- }
19548
- /** The one line the Friday view leads with. */
19549
- function reviewText(r) {
19550
- if (r.planned.length === 0) return r.alsoDone.length ? `Nothing was planned, and you finished ${r.alsoDone.length}.` : "Nothing was planned this week.";
19551
- const also = r.alsoDone.length ? `, and ${r.alsoDone.length} more besides` : "";
19552
- return `You finished ${r.doneCount} of the ${r.planned.length} you planned${also}.`;
19553
- }
19554
- /** Placements of this person's from before the weeks kept, which a new plan clears. */
19555
- function stalePlacements(data, personId, today = todayISO()) {
19556
- const cutoff = shiftWeek(weekOf(today), -12);
19557
- return new Set(placementsOf(data, personId).filter((r) => (r.date ?? "") < cutoff).map((r) => r.id));
19558
- }
19559
- var DAY_HOURS, DEFAULT_HOURS, MIN_HOURS, MAX_HOURS, isPassage, iso$1, at, byDue, DAY_WORDS;
19560
- var init_passage = __esmMin((() => {
19561
- init_date_fns();
19562
- init_date_text();
19563
- init_format();
19564
- init_indexes();
19565
- DAY_HOURS = 7;
19566
- DEFAULT_HOURS = 2;
19567
- MIN_HOURS = .5;
19568
- MAX_HOURS = 12;
19569
- isPassage = (r) => r.kind === "passage";
19570
- iso$1 = (d) => todayISO(d);
19571
- at = (day) => parseIso(`${day}T12:00:00`);
19572
- byDue = (a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999");
19573
- DAY_WORDS = [
19574
- "mon",
19575
- "tue",
19576
- "wed",
19577
- "thu",
19578
- "fri"
19579
- ];
19580
- }));
19581
-
19582
19663
  //#endregion
19583
19664
  //#region src/lib/mcp/tools.ts
19584
19665
  init_thanks();
19585
19666
  init_task_keys();
19586
19667
  init_brief();
19668
+ init_first_voyage();
19587
19669
  init_indexes();
19588
19670
  init_tide();
19589
19671
  init_barnacles();
@@ -19685,6 +19767,7 @@ const STATUSES = [
19685
19767
  * Given the spend ledger, also what each week late costs (src/lib/slip-cost.ts).
19686
19768
  */
19687
19769
  function finishForecast(project, tasks, now, spend, data) {
19770
+ if (isVoyageProject(project.id)) return { none: VOYAGE_NO_FORECAST };
19688
19771
  const tide = tideForecast(project, tasks, now, data ? tideOptions(data) : {});
19689
19772
  if (tide.kind !== "forecast") return { none: tideQuiet(tide) };
19690
19773
  const lastDue = dueAfterFinish(tide);
@@ -29731,6 +29814,189 @@ function TodayView({ active, width, height, go }) {
29731
29814
  });
29732
29815
  }
29733
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
+
29734
30000
  //#endregion
29735
30001
  //#region src/tui/app.tsx
29736
30002
  /**
@@ -29784,10 +30050,14 @@ const NAV = [
29784
30050
  {
29785
30051
  screen: "crew",
29786
30052
  label: "Crew"
30053
+ },
30054
+ {
30055
+ screen: "week",
30056
+ label: "Your week"
29787
30057
  }
29788
30058
  ];
29789
- /** The key that opens a sidebar screen: 1 to 9, 0 for the tenth, C for the crew. */
29790
- 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";
29791
30061
  /** The sidebar's width: its longest line ("9 Calendar", ": Anything") with room either side. */
29792
30062
  const NAV_WIDTH = 15;
29793
30063
  /** Every open program, in the review's order: what a meeting started from the palette covers. */
@@ -30008,6 +30278,7 @@ function App({ ui, startIn }) {
30008
30278
  if (input === "?") return setOverlay({ kind: "help" });
30009
30279
  if (input === "H") return setOverlay({ kind: "harbors" });
30010
30280
  if (input === "C") return goScreen("crew");
30281
+ if (input === "W") return goScreen("week");
30011
30282
  if (input === "r" && page?.kind !== "bridge") {
30012
30283
  sync.retryNow();
30013
30284
  return notify("Saving now.");
@@ -30301,6 +30572,7 @@ function App({ ui, startIn }) {
30301
30572
  else if (screen === "calendar") screenNode = /* @__PURE__ */ jsx(CalendarView, { ...viewProps });
30302
30573
  else if (screen === "brief") screenNode = /* @__PURE__ */ jsx(BriefView, { ...viewProps });
30303
30574
  else if (screen === "crew") screenNode = /* @__PURE__ */ jsx(CrewView, { ...viewProps });
30575
+ else if (screen === "week") screenNode = /* @__PURE__ */ jsx(WeekView, { ...viewProps });
30304
30576
  else screenNode = /* @__PURE__ */ jsx(HistoryView, { ...viewProps });
30305
30577
  return /* @__PURE__ */ jsxs(Box, {
30306
30578
  flexDirection: "column",
package/lib/cli.mjs CHANGED
@@ -156,7 +156,7 @@ export const HELP = `harbor: Harbor from the terminal
156
156
  [--ends DATE] [--budget N] [--from "DONE PROJECT"]
157
157
  --from: plan it from a finished
158
158
  project as that one really ran
159
- harbor add "TITLE" --project P [--assignee me|none|NAME]
159
+ harbor add "TITLE" [--project P] [--assignee me|none|NAME]
160
160
  [--due DATE|pilot] [--priority low|medium|high|urgent]
161
161
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
162
162
  with no TITLE: one title a line
@@ -284,6 +284,10 @@ export const HELP = `harbor: Harbor from the terminal
284
284
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands
285
285
  never delete; the full-screen app can, as the website can, and asks first,
286
286
  and so can harbor fog --sweep's x (drop it), one task at a time.
287
+ Practice: a new harbor starts with First voyage, a project of practice
288
+ tasks that tick themselves off as you try things; the brief, harbor week,
289
+ bridge and forecast leave it out. To look around a made-up harbor instead,
290
+ open harborppm.com/demo or run ssh try.harborppm.com.
287
291
  `;
288
292
 
289
293
  /**
@@ -442,6 +446,32 @@ const REPEAT_WORD = { weekly: "every week", biweekly: "every two weeks", monthly
442
446
 
443
447
  const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
444
448
 
449
+ /** The one open project a task can go in (First voyage aside), or why --project is needed. */
450
+ async function onlyProject(client, harbor) {
451
+ const r = await client.call("list_projects", {
452
+ harbor,
453
+ health: ["on-track", "at-risk", "off-track", "paused"],
454
+ limit: 5,
455
+ });
456
+ const open = (r.projects ?? []).filter((p) => p.id !== "voyage");
457
+ if (open.length === 1) return open[0].name;
458
+ if (open.length === 0)
459
+ throw new HarborError('This harbor has no open project yet; harbor project "NAME" --program P makes one.');
460
+ const names = open.slice(0, 3).map((p) => p.name);
461
+ const more = open.length > 3 || r.total > 5 ? ", …" : "";
462
+ throw new HarborError(`Say which project: --project NAME (${names.join(", ")}${more}).`);
463
+ }
464
+
465
+ const WEEKDAY_NAME = WEEKDAYS.map((d) => d[0].toUpperCase() + d.slice(1));
466
+
467
+ /** The Monday of a day's week; Saturday and Sunday look ahead to the next, as Harbor's weeks do. */
468
+ function mondayOf(iso) {
469
+ const d = new Date(`${iso}T12:00:00`);
470
+ const wd = d.getDay();
471
+ const back = wd === 0 ? -1 : wd === 6 ? -2 : wd - 1;
472
+ return todayLocal(new Date(d.getFullYear(), d.getMonth(), d.getDate() - back));
473
+ }
474
+
445
475
  /** "fri", "Friday": how many days until that weekday comes round (today counts). */
446
476
  function daysToWeekday(v, today) {
447
477
  const w = v.toLowerCase();
@@ -621,6 +651,9 @@ const DETAIL = {
621
651
  " harbor add --project P --dry-run reads: says what it would add, and",
622
652
  " adds nothing",
623
653
  "",
654
+ " --project can be left out when the harbor has one open project (First",
655
+ " voyage aside); the task goes there.",
656
+ "",
624
657
  " --assignee me|none|NAME, --due DATE|pilot, --priority low|medium|high|urgent,",
625
658
  " --status S, --comment TEXT and --over-limit apply to every task added, so",
626
659
  " ten lines with --comment add the same comment to all ten. More than 100",
@@ -645,7 +678,10 @@ const DETAIL = {
645
678
  " harbor week --day-hours N writes: the hours you work a day",
646
679
  "",
647
680
  " Only you see your week. Tasks in review are left out of what's not on a",
648
- " day yet, since they wait on someone else.",
681
+ " day yet, since they wait on someone else. A weekday already gone this",
682
+ " week means next week's (mon on a Friday is the coming Monday); add",
683
+ " --week today to plan this week's. First voyage's practice tasks are left",
684
+ " out.",
649
685
  ],
650
686
  init: [
651
687
  " harbor init [PROJECT] [--hook] writes: .harbor/ at the top of this",
@@ -1809,7 +1845,7 @@ export async function run(argv, io = {}) {
1809
1845
  const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
1810
1846
  // A terminal's lines fit its width; piped output stays whole for scripts.
1811
1847
  const opts = {
1812
- today: todayLocal(),
1848
+ today: todayLocal(io.now ? new Date(io.now()) : new Date()),
1813
1849
  width: process.stdout.isTTY ? process.stdout.columns : undefined,
1814
1850
  };
1815
1851
 
@@ -2298,7 +2334,8 @@ export async function run(argv, io = {}) {
2298
2334
  ["project", "assignee", "due", "priority", "status", "comment"],
2299
2335
  ),
2300
2336
  );
2301
- if (!values.project) throw new HarborError("Say which project: --project NAME.");
2337
+ // No --project: the harbor's one open project, if that's all there is.
2338
+ if (!values.project) values.project = await onlyProject(client, harbor);
2302
2339
  // Several at once: one title a line on stdin, with no title (or -) after add.
2303
2340
  const piped =
2304
2341
  positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
@@ -2335,7 +2372,11 @@ export async function run(argv, io = {}) {
2335
2372
  dryRun: true,
2336
2373
  project: values.project,
2337
2374
  titles,
2338
- ...(pilot ? { due: pilot.map((d) => d.earliest) } : {}),
2375
+ ...(pilot
2376
+ ? { due: pilot.map((d) => d.earliest) }
2377
+ : values.due
2378
+ ? { due: titles.map(() => dueDate(values.due)) }
2379
+ : {}),
2339
2380
  }),
2340
2381
  );
2341
2382
  else
@@ -2343,7 +2384,10 @@ export async function run(argv, io = {}) {
2343
2384
  plain(
2344
2385
  [
2345
2386
  `Would add ${n} to ${values.project}:`,
2346
- ...titles.map((t, i) => ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : ""}`),
2387
+ ...titles.map(
2388
+ (t, i) =>
2389
+ ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
2390
+ ),
2347
2391
  ...(pilot
2348
2392
  ? [
2349
2393
  `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
@@ -2536,11 +2580,14 @@ export async function run(argv, io = {}) {
2536
2580
  throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
2537
2581
  return n;
2538
2582
  };
2539
- // The day comes as --day; a bare second word still works, as it did first.
2583
+ // The day comes as --day; a bare second word (the first form) is refused, not guessed.
2540
2584
  const [task, bareDay, ...extra] = positionals;
2541
- if (extra.length || (bareDay && values.day))
2542
- throw new HarborError("Give a task key and a day: harbor week RR-42 --day wed.");
2543
- const day = values.day ?? bareDay;
2585
+ if (bareDay && !extra.length && !values.day)
2586
+ throw new HarborError(
2587
+ `Say the day with --day: harbor week ${task} --day ${bareDay.toLowerCase()}.`,
2588
+ );
2589
+ if (bareDay) throw new HarborError("Give a task key and a day: harbor week RR-42 --day wed.");
2590
+ const day = values.day;
2544
2591
  const dayHours = number(values["day-hours"], "day-hours");
2545
2592
  const show = (r) =>
2546
2593
  [r.done, weekText(r, { ...opts, review: Boolean(values.review) })]
@@ -2555,18 +2602,28 @@ export async function run(argv, io = {}) {
2555
2602
  throw new HarborError(
2556
2603
  `Say which day for ${task}: harbor week ${task} --day wed, or --off to take it off the week.`,
2557
2604
  );
2605
+ // A weekday already gone this week means the next one, as --due friday does.
2606
+ let planWeek = week;
2607
+ let ahead = "";
2608
+ if (day && !values.off && !values.week && !values.next && !values.last) {
2609
+ const next = dueDate(day, new Date(`${opts.today}T12:00:00`));
2610
+ if (next !== day && mondayOf(next) !== mondayOf(opts.today)) {
2611
+ planWeek = next;
2612
+ ahead = `${WEEKDAY_NAME[new Date(`${next}T12:00:00`).getDay()]} has gone this week, so that's next week's (${next}); add --week today for this week's.`;
2613
+ }
2614
+ }
2558
2615
  return call(
2559
2616
  "plan_week",
2560
2617
  {
2561
2618
  harbor,
2562
- week,
2619
+ week: planWeek,
2563
2620
  ...(task ? { task } : {}),
2564
2621
  ...(day ? { day } : {}),
2565
2622
  ...(values.hours ? { hours: number(values.hours, "hours") } : {}),
2566
2623
  ...(values.off ? { remove: true } : {}),
2567
2624
  ...(dayHours !== undefined ? { dayHours } : {}),
2568
2625
  },
2569
- show,
2626
+ (r) => [ahead, show(r)].filter(Boolean).join("\n\n"),
2570
2627
  );
2571
2628
  }
2572
2629
  case "comment": {
package/lib/format.mjs CHANGED
@@ -31,10 +31,13 @@ export function statusWord(typed) {
31
31
  * piped output keeps every title whole.
32
32
  */
33
33
  export function taskLine(t, { today, mine = false, width } = {}) {
34
- const late = t.due && today && t.due < today && t.status !== "done";
34
+ const open = t.status !== "done";
35
+ const late = t.due && today && t.due < today && open;
36
+ // Today and tomorrow in words, so what's close stands out from the dates.
37
+ const soon = open && today && t.due ? soonWord(t.due, today) : null;
35
38
  const bits = [
36
39
  STATUS[t.status] ?? t.status,
37
- t.due ? (late ? `late, due ${t.due}` : `due ${t.due}`) : null,
40
+ t.due ? (late ? `late, due ${t.due}` : soon ? `due ${soon}` : `due ${t.due}`) : null,
38
41
  t.priority && t.priority !== "medium" ? t.priority : null,
39
42
  mine ? null : (t.assignee ?? null),
40
43
  t.project ?? null,
@@ -45,6 +48,14 @@ export function taskLine(t, { today, mine = false, width } = {}) {
45
48
  return key + fitTitle(t.title, width ? width - 1 - key.length - tail.length : Infinity) + tail;
46
49
  }
47
50
 
51
+ /** "today" or "tomorrow" for a due date that close, else null. */
52
+ function soonWord(due, today) {
53
+ if (due === today) return "today";
54
+ const d = new Date(`${today}T12:00:00`);
55
+ d.setDate(d.getDate() + 1);
56
+ return due === todayLocal(d) ? "tomorrow" : null;
57
+ }
58
+
48
59
  /** The title cut to `room` characters with an ellipsis, but never below 20. */
49
60
  function fitTitle(title, room) {
50
61
  const chars = [...title];
@@ -756,7 +767,8 @@ export function importText(r) {
756
767
  if (r.alreadyThere) lines.push(`${n(r.alreadyThere, "task")} already there, left alone.`);
757
768
  if (r.leftOut) lines.push(`${n(r.leftOut, "row")} left out (canceled, won't do or duplicate).`);
758
769
  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}`);
770
+ for (const p of r.problems ?? [])
771
+ lines.push(`${p.card ? `Card “${p.card}”` : `Row ${p.row}`}: ${p.message}`);
760
772
  return lines.join("\n");
761
773
  }
762
774
 
@@ -1052,7 +1064,10 @@ export function weekText(w, { today, review = false, width } = {}) {
1052
1064
  }
1053
1065
  }
1054
1066
  if (w.unplannedCount) {
1055
- out.push("", `Not on a day yet: ${w.unplannedCount}. harbor week KEY DAY puts one there.`);
1067
+ out.push(
1068
+ "",
1069
+ `Not on a day yet: ${w.unplannedCount}. harbor week KEY --day DAY puts one there.`,
1070
+ );
1056
1071
  for (const t of w.unplanned.slice(0, 5))
1057
1072
  out.push(` ${taskLine(t, { today, mine: true, width: width ? width - 2 : undefined })}`);
1058
1073
  if (w.unplannedCount > 5)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.69.0",
3
+ "version": "0.71.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",