@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 +485 -207
- package/lib/cli.mjs +36 -3
- package/lib/format.mjs +3 -2
- package/package.json +1 -1
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
|
|
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
|
|
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:
|
|
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:
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
10060
|
+
var iso$1;
|
|
9794
10061
|
var init_load = __esmMin((() => {
|
|
9795
10062
|
init_date_fns();
|
|
9796
|
-
iso$
|
|
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
|
|
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
|
|
18958
|
-
let end = minutesOf(person?.workEnd) ?? minutesOf(DEFAULT_HOURS
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 ?? [])
|
|
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
|
|