@harborppm/cli 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/app.mjs +230 -164
- package/lib/cli.mjs +40 -6
- package/lib/format.mjs +103 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,7 +117,8 @@ Task titles, names and notes can come from an intake form, a GitHub pull request
|
|
|
117
117
|
|
|
118
118
|
```
|
|
119
119
|
harbor harbors the harbors you belong to
|
|
120
|
-
harbor whoami who you're signed in as, and
|
|
120
|
+
harbor whoami who you're signed in as, where, and which
|
|
121
|
+
build of the site answers
|
|
121
122
|
harbor use HARBOR|none the harbor to work in when you're in several
|
|
122
123
|
harbor programs programs and their projects
|
|
123
124
|
harbor projects [words] [--program P] projects with health and open work
|
|
@@ -131,10 +132,14 @@ harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of ta
|
|
|
131
132
|
waits that sets its date, slack per link
|
|
132
133
|
harbor barnacles PROJECT work added since the plan was set: by
|
|
133
134
|
week, by who, and the slip it explains
|
|
135
|
+
harbor baseline PROJECT [--set|--reset] reads a project's baseline; --set commits
|
|
136
|
+
its dates now (--dry-run says what)
|
|
134
137
|
harbor hill [--program P] where each project is on its hill
|
|
135
138
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
|
|
136
139
|
past 50 is getting it done
|
|
137
140
|
harbor mine what's on you, late first
|
|
141
|
+
harbor catchup [--since DATE] while you were away: handed to you,
|
|
142
|
+
decided without you, what moved
|
|
138
143
|
harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
|
|
139
144
|
who's working now; set your (or NAME's) time zone and hours
|
|
140
145
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S] [--all]
|
|
@@ -184,7 +189,7 @@ harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
|
|
|
184
189
|
the review: programs worst first, with next moves
|
|
185
190
|
(reads only; harbor submit moves a task to In review)
|
|
186
191
|
harbor thanks [TASK "LINE"] [--to NAME] a one-line thanks for a task (--milestone, --decision);
|
|
187
|
-
on its own, the thanks you were given
|
|
192
|
+
on its own, the thanks you were given in the last 30 days
|
|
188
193
|
harbor bridge run the review as a meeting: one project a screen,
|
|
189
194
|
what changed since the last one, log decisions,
|
|
190
195
|
actions and risks live, post the summary to chat
|
package/dist/app.mjs
CHANGED
|
@@ -3285,6 +3285,7 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
|
|
|
3285
3285
|
return {
|
|
3286
3286
|
since,
|
|
3287
3287
|
from,
|
|
3288
|
+
settling: from === "start" && since.slice(0, 10) > today,
|
|
3288
3289
|
planned,
|
|
3289
3290
|
added: added.length,
|
|
3290
3291
|
share: planned > 0 ? added.length / planned : null,
|
|
@@ -19110,6 +19111,169 @@ function pilotReasons(p, you = false) {
|
|
|
19110
19111
|
return out;
|
|
19111
19112
|
}
|
|
19112
19113
|
|
|
19114
|
+
//#endregion
|
|
19115
|
+
//#region src/lib/seen-mark.ts
|
|
19116
|
+
/** Days without opening Harbor before the catch-up shows. */
|
|
19117
|
+
const UNSEEN_DAYS = 3;
|
|
19118
|
+
/** Calendar days from one ISO day (or moment) to another, by their dates alone. */
|
|
19119
|
+
function dayGap(from, to) {
|
|
19120
|
+
const utc = (iso) => {
|
|
19121
|
+
const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
|
|
19122
|
+
return Date.UTC(y, m - 1, d);
|
|
19123
|
+
};
|
|
19124
|
+
return Math.round((utc(to) - utc(from)) / 864e5);
|
|
19125
|
+
}
|
|
19126
|
+
/**
|
|
19127
|
+
* Whether to catch someone up, and from when: back from time away they were
|
|
19128
|
+
* marked for (a handover with a return date that has come, until this device
|
|
19129
|
+
* has caught them up once since), or `UNSEEN_DAYS` or more since this device
|
|
19130
|
+
* last saw them. Null otherwise, and the first time a device sees them:
|
|
19131
|
+
* there's nothing to compare with.
|
|
19132
|
+
*/
|
|
19133
|
+
function welcomeWhen(person, lastSeen, today) {
|
|
19134
|
+
const away = person?.away;
|
|
19135
|
+
if (away?.since && away.until && away.until.slice(0, 10) <= today) {
|
|
19136
|
+
if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
|
|
19137
|
+
since: away.since,
|
|
19138
|
+
why: "away",
|
|
19139
|
+
days: dayGap(away.since, today)
|
|
19140
|
+
};
|
|
19141
|
+
}
|
|
19142
|
+
if (!lastSeen) return null;
|
|
19143
|
+
const days = dayGap(lastSeen, today);
|
|
19144
|
+
return days >= 3 ? {
|
|
19145
|
+
since: lastSeen,
|
|
19146
|
+
why: "unseen",
|
|
19147
|
+
days
|
|
19148
|
+
} : null;
|
|
19149
|
+
}
|
|
19150
|
+
|
|
19151
|
+
//#endregion
|
|
19152
|
+
//#region src/lib/welcome-back.ts
|
|
19153
|
+
init_date_text();
|
|
19154
|
+
init_indexes();
|
|
19155
|
+
init_task_keys();
|
|
19156
|
+
const day = (iso) => parseIso(iso.slice(0, 10));
|
|
19157
|
+
const daysBetween = dayGap;
|
|
19158
|
+
/** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
|
|
19159
|
+
function projectsOf(data, personId) {
|
|
19160
|
+
const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
|
|
19161
|
+
return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
|
|
19162
|
+
}
|
|
19163
|
+
const first = (p) => p?.name.split(" ")[0] ?? null;
|
|
19164
|
+
/** "due Thu, Oct 23", "3 days late", "due today". */
|
|
19165
|
+
function dueText(task, today) {
|
|
19166
|
+
if (!task.dueDate) return "no due date";
|
|
19167
|
+
const d = daysBetween(today, task.dueDate);
|
|
19168
|
+
if (d === 0) return "due today";
|
|
19169
|
+
if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
|
|
19170
|
+
if (d === 1) return "due tomorrow";
|
|
19171
|
+
return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
|
|
19172
|
+
}
|
|
19173
|
+
/** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
|
|
19174
|
+
function welcomeBack(data, personId, when, today) {
|
|
19175
|
+
const since = when.since;
|
|
19176
|
+
const people = byId(data.people);
|
|
19177
|
+
const projects = projectsOf(data, personId);
|
|
19178
|
+
const projectIds = new Set(projects.map((p) => p.id));
|
|
19179
|
+
const programIds = new Set(projects.map((p) => p.programId));
|
|
19180
|
+
const projectById = byId(data.projects);
|
|
19181
|
+
const label = (t) => {
|
|
19182
|
+
const p = projectById.get(t.projectId);
|
|
19183
|
+
const key = p ? taskKey(t, p) : null;
|
|
19184
|
+
return key ? `${key} ${t.title}` : t.title;
|
|
19185
|
+
};
|
|
19186
|
+
const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
19187
|
+
const handed = mine.filter((t) => t.assignedAt && t.assignedAt >= since && t.assignedBy && t.assignedBy !== personId).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).map((task) => ({
|
|
19188
|
+
task,
|
|
19189
|
+
by: people.get(task.assignedBy) ?? null
|
|
19190
|
+
}));
|
|
19191
|
+
const decisions = data.raidItems.filter((r) => r.kind === "decision" && r.status !== "proposed" && Boolean(r.decidedAt) && r.decidedAt >= since && r.decidedById !== personId && (r.projectId ? projectIds.has(r.projectId) : Boolean(r.programId && programIds.has(r.programId)))).sort((a, b) => b.decidedAt.localeCompare(a.decidedAt)).map((item) => ({
|
|
19192
|
+
item,
|
|
19193
|
+
by: item.decidedById ? people.get(item.decidedById) ?? null : null
|
|
19194
|
+
}));
|
|
19195
|
+
const moved = [];
|
|
19196
|
+
for (const project of projects) {
|
|
19197
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
19198
|
+
const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
|
|
19199
|
+
const added = tasks.filter((t) => t.createdAt >= since).length;
|
|
19200
|
+
const history = project.healthHistory ?? [];
|
|
19201
|
+
const before = [...history].reverse().find((h) => h.at < since)?.health;
|
|
19202
|
+
const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
|
|
19203
|
+
from: before,
|
|
19204
|
+
to: project.health
|
|
19205
|
+
} : null;
|
|
19206
|
+
if (done || added || health) moved.push({
|
|
19207
|
+
project,
|
|
19208
|
+
done,
|
|
19209
|
+
added,
|
|
19210
|
+
health
|
|
19211
|
+
});
|
|
19212
|
+
}
|
|
19213
|
+
moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
|
|
19214
|
+
const look = [];
|
|
19215
|
+
const seen = /* @__PURE__ */ new Set();
|
|
19216
|
+
const push = (item) => {
|
|
19217
|
+
const id = item.taskId ?? item.raidId ?? item.text;
|
|
19218
|
+
if (look.length >= 3 || seen.has(id)) return;
|
|
19219
|
+
seen.add(id);
|
|
19220
|
+
look.push(item);
|
|
19221
|
+
};
|
|
19222
|
+
for (const h of handed) {
|
|
19223
|
+
const by = first(h.by);
|
|
19224
|
+
push({
|
|
19225
|
+
kind: "handed",
|
|
19226
|
+
taskId: h.task.id,
|
|
19227
|
+
text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
|
|
19228
|
+
});
|
|
19229
|
+
}
|
|
19230
|
+
const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
|
|
19231
|
+
for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
|
|
19232
|
+
kind: "late",
|
|
19233
|
+
taskId: t.id,
|
|
19234
|
+
text: `${label(t)}: ${dueText(t, today)}`
|
|
19235
|
+
});
|
|
19236
|
+
for (const t of dated) {
|
|
19237
|
+
const d = daysBetween(today, t.dueDate);
|
|
19238
|
+
if (d >= 0 && d <= 3) push({
|
|
19239
|
+
kind: "due",
|
|
19240
|
+
taskId: t.id,
|
|
19241
|
+
text: `${label(t)}: ${dueText(t, today)}`
|
|
19242
|
+
});
|
|
19243
|
+
}
|
|
19244
|
+
for (const d of decisions) {
|
|
19245
|
+
const by = first(d.by);
|
|
19246
|
+
push({
|
|
19247
|
+
kind: "decision",
|
|
19248
|
+
raidId: d.item.id,
|
|
19249
|
+
text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
|
|
19250
|
+
});
|
|
19251
|
+
}
|
|
19252
|
+
return {
|
|
19253
|
+
when,
|
|
19254
|
+
handed,
|
|
19255
|
+
decisions,
|
|
19256
|
+
moved: moved.slice(0, 6),
|
|
19257
|
+
lookFirst: look,
|
|
19258
|
+
quiet: !handed.length && !decisions.length && !moved.length && !look.length
|
|
19259
|
+
};
|
|
19260
|
+
}
|
|
19261
|
+
/** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
|
|
19262
|
+
function welcomeTitle(w, name, today) {
|
|
19263
|
+
const hi = name ? `Welcome back, ${name}` : "Welcome back";
|
|
19264
|
+
if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
|
|
19265
|
+
return `${hi} · ${w.when.days} days since you last looked`;
|
|
19266
|
+
}
|
|
19267
|
+
/** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
|
|
19268
|
+
function movedText(m, healthLabel) {
|
|
19269
|
+
const bits = [
|
|
19270
|
+
m.done ? `${m.done} done` : null,
|
|
19271
|
+
m.added ? `${m.added} added` : null,
|
|
19272
|
+
m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
|
|
19273
|
+
].filter(Boolean);
|
|
19274
|
+
return `${m.project.name}: ${bits.join(", ")}`;
|
|
19275
|
+
}
|
|
19276
|
+
|
|
19113
19277
|
//#endregion
|
|
19114
19278
|
//#region src/lib/agents.ts
|
|
19115
19279
|
const isAgent = (p) => p?.kind === "agent";
|
|
@@ -19383,6 +19547,7 @@ init_search();
|
|
|
19383
19547
|
init_derived_health();
|
|
19384
19548
|
init_format();
|
|
19385
19549
|
init_date_text();
|
|
19550
|
+
init_date_fns();
|
|
19386
19551
|
init_shipping_forecast();
|
|
19387
19552
|
init_handover();
|
|
19388
19553
|
init_work();
|
|
@@ -19417,6 +19582,7 @@ function barnaclesData(b) {
|
|
|
19417
19582
|
return {
|
|
19418
19583
|
measuredFrom: b.from === "baseline" ? "when the baseline was set" : "a week after its first task was made",
|
|
19419
19584
|
since: b.since,
|
|
19585
|
+
settling: b.settling,
|
|
19420
19586
|
planned: b.planned,
|
|
19421
19587
|
added: b.added,
|
|
19422
19588
|
addedPercent: b.share == null ? null : Math.round(b.share * 100),
|
|
@@ -19999,7 +20165,9 @@ const HARBOR_TOOLS = [
|
|
|
19999
20165
|
} : {},
|
|
20000
20166
|
starts: dayOf(p.startDate),
|
|
20001
20167
|
ends: dayOf(p.endDate),
|
|
20168
|
+
...p.baselineStart ? { baselineStarts: dayOf(p.baselineStart) } : {},
|
|
20002
20169
|
...p.baselineEnd ? { baselineEnds: dayOf(p.baselineEnd) } : {},
|
|
20170
|
+
...p.baselineAt ? { baselineSetAt: p.baselineAt } : {},
|
|
20003
20171
|
budget: p.budget,
|
|
20004
20172
|
spent: p.spent,
|
|
20005
20173
|
tasks: byStatus,
|
|
@@ -20201,6 +20369,67 @@ const HARBOR_TOOLS = [
|
|
|
20201
20369
|
return weekOut(ctx.data, mine.id, weekOf(input.week ?? todayISO(ctx.now)));
|
|
20202
20370
|
}
|
|
20203
20371
|
}),
|
|
20372
|
+
tool({
|
|
20373
|
+
name: "catch_up",
|
|
20374
|
+
title: "While you were away",
|
|
20375
|
+
description: "The catch-up the website and the terminal app open on after time away or a few days unseen (while you were away): tasks handed to the person since then and by whom, decisions made on their projects without them, what moved on their projects (done, added, health changes), and the three to look at first. It counts from the start of time away they were marked for once its return date has come, else from since (when they last looked; any ISO date or moment), else the last 7 days. Reading it doesn't clear the catch-up anywhere." + DATA_NOTE,
|
|
20376
|
+
input: object({
|
|
20377
|
+
harbor: harborArg,
|
|
20378
|
+
since: string().regex(/^\d{4}-\d{2}-\d{2}/, "a date as YYYY-MM-DD, or an ISO moment").max(40).optional().describe("When the person last looked, YYYY-MM-DD or an ISO moment.")
|
|
20379
|
+
}),
|
|
20380
|
+
run: (ctx, input) => {
|
|
20381
|
+
const { data } = ctx;
|
|
20382
|
+
const mine = me(ctx);
|
|
20383
|
+
if (!mine) return {
|
|
20384
|
+
you: null,
|
|
20385
|
+
note: "You aren't one of this harbor's people yet, so there's nothing to catch up on."
|
|
20386
|
+
};
|
|
20387
|
+
const today = todayFor(mine, ctx.now, todayISO(ctx.now));
|
|
20388
|
+
const since = input.since ?? subDays(ctx.now, 7).toISOString();
|
|
20389
|
+
const away = welcomeWhen(mine, input.since ?? null, today);
|
|
20390
|
+
const when = away?.why === "away" ? away : {
|
|
20391
|
+
since,
|
|
20392
|
+
why: "unseen",
|
|
20393
|
+
days: dayGap(since, today)
|
|
20394
|
+
};
|
|
20395
|
+
const w = welcomeBack(data, mine.id, when, today);
|
|
20396
|
+
const projects = byId(data.projects);
|
|
20397
|
+
const keyOf = (t) => taskKey(t, projects.get(t.projectId));
|
|
20398
|
+
return {
|
|
20399
|
+
you: mine.name,
|
|
20400
|
+
from: when.why === "away" ? "time away" : input.since ? "when you last looked" : "the last 7 days",
|
|
20401
|
+
since: dayOf(when.since),
|
|
20402
|
+
days: when.days,
|
|
20403
|
+
quiet: w.quiet,
|
|
20404
|
+
lookFirst: w.lookFirst.map((l) => ({
|
|
20405
|
+
kind: l.kind,
|
|
20406
|
+
text: l.text,
|
|
20407
|
+
...l.taskId ? { taskId: l.taskId } : {},
|
|
20408
|
+
...l.raidId ? { raidId: l.raidId } : {}
|
|
20409
|
+
})),
|
|
20410
|
+
handed: w.handed.map(({ task, by }) => ({
|
|
20411
|
+
id: task.id,
|
|
20412
|
+
...keyOf(task) ? { key: keyOf(task) } : {},
|
|
20413
|
+
title: task.title,
|
|
20414
|
+
due: dayOf(task.dueDate),
|
|
20415
|
+
by: by?.name ?? null
|
|
20416
|
+
})),
|
|
20417
|
+
decisions: w.decisions.map(({ item, by }) => ({
|
|
20418
|
+
id: item.id,
|
|
20419
|
+
title: item.title,
|
|
20420
|
+
decidedAt: dayOf(item.decidedAt),
|
|
20421
|
+
by: by?.name ?? null
|
|
20422
|
+
})),
|
|
20423
|
+
moved: w.moved.map((m) => ({
|
|
20424
|
+
project: m.project.name,
|
|
20425
|
+
...m.project.key ? { key: m.project.key } : {},
|
|
20426
|
+
done: m.done,
|
|
20427
|
+
added: m.added,
|
|
20428
|
+
health: m.health
|
|
20429
|
+
}))
|
|
20430
|
+
};
|
|
20431
|
+
}
|
|
20432
|
+
}),
|
|
20204
20433
|
tool({
|
|
20205
20434
|
name: "weekly_brief",
|
|
20206
20435
|
title: "Weekly brief",
|
|
@@ -20723,7 +20952,7 @@ const HARBOR_TOOLS = [
|
|
|
20723
20952
|
tool({
|
|
20724
20953
|
name: "scope_growth",
|
|
20725
20954
|
title: "Barnacles: work added since the plan was set",
|
|
20726
|
-
description: "How much a project's plan has grown since it was set (barnacles): tasks made after the baseline was set (or, without a baseline time, after the project's first week of tasks), as a count and a percent of what was planned, by week (weekOf is a Monday) and by who added them (who is null for tasks made before Harbor kept track, or by a form). daysOfWork puts them at the crew's pace (tasks finished a week over the last six); slipDays is how late the project runs against its planned end (the likely finish at that pace, or the end date's move from the baseline), and daysLine says how much of it the added work explains." + DATA_NOTE,
|
|
20955
|
+
description: "How much a project's plan has grown since it was set (barnacles): tasks made after the baseline was set (or, without a baseline time, after the project's first week of tasks), as a count and a percent of what was planned, by week (weekOf is a Monday) and by who added them (who is null for tasks made before Harbor kept track, or by a form). daysOfWork puts them at the crew's pace (tasks finished a week over the last six); slipDays is how late the project runs against its planned end (the likely finish at that pace, or the end date's move from the baseline), and daysLine says how much of it the added work explains. settling is true while a project without a baseline is still in its first week (since is in the future): nothing counts as added yet, and setting a baseline counts from now." + DATA_NOTE,
|
|
20727
20956
|
input: object({
|
|
20728
20957
|
harbor: harborArg,
|
|
20729
20958
|
project: string().min(1).max(200).describe("Project id, name or key.")
|
|
@@ -26622,169 +26851,6 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
|
|
|
26622
26851
|
});
|
|
26623
26852
|
}
|
|
26624
26853
|
|
|
26625
|
-
//#endregion
|
|
26626
|
-
//#region src/lib/seen-mark.ts
|
|
26627
|
-
/** Days without opening Harbor before the catch-up shows. */
|
|
26628
|
-
const UNSEEN_DAYS = 3;
|
|
26629
|
-
/** Calendar days from one ISO day (or moment) to another, by their dates alone. */
|
|
26630
|
-
function dayGap(from, to) {
|
|
26631
|
-
const utc = (iso) => {
|
|
26632
|
-
const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
|
|
26633
|
-
return Date.UTC(y, m - 1, d);
|
|
26634
|
-
};
|
|
26635
|
-
return Math.round((utc(to) - utc(from)) / 864e5);
|
|
26636
|
-
}
|
|
26637
|
-
/**
|
|
26638
|
-
* Whether to catch someone up, and from when: back from time away they were
|
|
26639
|
-
* marked for (a handover with a return date that has come, until this device
|
|
26640
|
-
* has caught them up once since), or `UNSEEN_DAYS` or more since this device
|
|
26641
|
-
* last saw them. Null otherwise, and the first time a device sees them:
|
|
26642
|
-
* there's nothing to compare with.
|
|
26643
|
-
*/
|
|
26644
|
-
function welcomeWhen(person, lastSeen, today) {
|
|
26645
|
-
const away = person?.away;
|
|
26646
|
-
if (away?.since && away.until && away.until.slice(0, 10) <= today) {
|
|
26647
|
-
if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
|
|
26648
|
-
since: away.since,
|
|
26649
|
-
why: "away",
|
|
26650
|
-
days: dayGap(away.since, today)
|
|
26651
|
-
};
|
|
26652
|
-
}
|
|
26653
|
-
if (!lastSeen) return null;
|
|
26654
|
-
const days = dayGap(lastSeen, today);
|
|
26655
|
-
return days >= 3 ? {
|
|
26656
|
-
since: lastSeen,
|
|
26657
|
-
why: "unseen",
|
|
26658
|
-
days
|
|
26659
|
-
} : null;
|
|
26660
|
-
}
|
|
26661
|
-
|
|
26662
|
-
//#endregion
|
|
26663
|
-
//#region src/lib/welcome-back.ts
|
|
26664
|
-
init_date_text();
|
|
26665
|
-
init_indexes();
|
|
26666
|
-
init_task_keys();
|
|
26667
|
-
const day = (iso) => parseIso(iso.slice(0, 10));
|
|
26668
|
-
const daysBetween = dayGap;
|
|
26669
|
-
/** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
|
|
26670
|
-
function projectsOf(data, personId) {
|
|
26671
|
-
const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
|
|
26672
|
-
return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
|
|
26673
|
-
}
|
|
26674
|
-
const first = (p) => p?.name.split(" ")[0] ?? null;
|
|
26675
|
-
/** "due Thu, Oct 23", "3 days late", "due today". */
|
|
26676
|
-
function dueText(task, today) {
|
|
26677
|
-
if (!task.dueDate) return "no due date";
|
|
26678
|
-
const d = daysBetween(today, task.dueDate);
|
|
26679
|
-
if (d === 0) return "due today";
|
|
26680
|
-
if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
|
|
26681
|
-
if (d === 1) return "due tomorrow";
|
|
26682
|
-
return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
|
|
26683
|
-
}
|
|
26684
|
-
/** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
|
|
26685
|
-
function welcomeBack(data, personId, when, today) {
|
|
26686
|
-
const since = when.since;
|
|
26687
|
-
const people = byId(data.people);
|
|
26688
|
-
const projects = projectsOf(data, personId);
|
|
26689
|
-
const projectIds = new Set(projects.map((p) => p.id));
|
|
26690
|
-
const programIds = new Set(projects.map((p) => p.programId));
|
|
26691
|
-
const projectById = byId(data.projects);
|
|
26692
|
-
const label = (t) => {
|
|
26693
|
-
const p = projectById.get(t.projectId);
|
|
26694
|
-
const key = p ? taskKey(t, p) : null;
|
|
26695
|
-
return key ? `${key} ${t.title}` : t.title;
|
|
26696
|
-
};
|
|
26697
|
-
const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
26698
|
-
const handed = mine.filter((t) => t.assignedAt && t.assignedAt >= since && t.assignedBy && t.assignedBy !== personId).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).map((task) => ({
|
|
26699
|
-
task,
|
|
26700
|
-
by: people.get(task.assignedBy) ?? null
|
|
26701
|
-
}));
|
|
26702
|
-
const decisions = data.raidItems.filter((r) => r.kind === "decision" && r.status !== "proposed" && Boolean(r.decidedAt) && r.decidedAt >= since && r.decidedById !== personId && (r.projectId ? projectIds.has(r.projectId) : Boolean(r.programId && programIds.has(r.programId)))).sort((a, b) => b.decidedAt.localeCompare(a.decidedAt)).map((item) => ({
|
|
26703
|
-
item,
|
|
26704
|
-
by: item.decidedById ? people.get(item.decidedById) ?? null : null
|
|
26705
|
-
}));
|
|
26706
|
-
const moved = [];
|
|
26707
|
-
for (const project of projects) {
|
|
26708
|
-
const tasks = tasksOf(data.tasks, project.id);
|
|
26709
|
-
const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
|
|
26710
|
-
const added = tasks.filter((t) => t.createdAt >= since).length;
|
|
26711
|
-
const history = project.healthHistory ?? [];
|
|
26712
|
-
const before = [...history].reverse().find((h) => h.at < since)?.health;
|
|
26713
|
-
const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
|
|
26714
|
-
from: before,
|
|
26715
|
-
to: project.health
|
|
26716
|
-
} : null;
|
|
26717
|
-
if (done || added || health) moved.push({
|
|
26718
|
-
project,
|
|
26719
|
-
done,
|
|
26720
|
-
added,
|
|
26721
|
-
health
|
|
26722
|
-
});
|
|
26723
|
-
}
|
|
26724
|
-
moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
|
|
26725
|
-
const look = [];
|
|
26726
|
-
const seen = /* @__PURE__ */ new Set();
|
|
26727
|
-
const push = (item) => {
|
|
26728
|
-
const id = item.taskId ?? item.raidId ?? item.text;
|
|
26729
|
-
if (look.length >= 3 || seen.has(id)) return;
|
|
26730
|
-
seen.add(id);
|
|
26731
|
-
look.push(item);
|
|
26732
|
-
};
|
|
26733
|
-
for (const h of handed) {
|
|
26734
|
-
const by = first(h.by);
|
|
26735
|
-
push({
|
|
26736
|
-
kind: "handed",
|
|
26737
|
-
taskId: h.task.id,
|
|
26738
|
-
text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
|
|
26739
|
-
});
|
|
26740
|
-
}
|
|
26741
|
-
const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
|
|
26742
|
-
for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
|
|
26743
|
-
kind: "late",
|
|
26744
|
-
taskId: t.id,
|
|
26745
|
-
text: `${label(t)}: ${dueText(t, today)}`
|
|
26746
|
-
});
|
|
26747
|
-
for (const t of dated) {
|
|
26748
|
-
const d = daysBetween(today, t.dueDate);
|
|
26749
|
-
if (d >= 0 && d <= 3) push({
|
|
26750
|
-
kind: "due",
|
|
26751
|
-
taskId: t.id,
|
|
26752
|
-
text: `${label(t)}: ${dueText(t, today)}`
|
|
26753
|
-
});
|
|
26754
|
-
}
|
|
26755
|
-
for (const d of decisions) {
|
|
26756
|
-
const by = first(d.by);
|
|
26757
|
-
push({
|
|
26758
|
-
kind: "decision",
|
|
26759
|
-
raidId: d.item.id,
|
|
26760
|
-
text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
|
|
26761
|
-
});
|
|
26762
|
-
}
|
|
26763
|
-
return {
|
|
26764
|
-
when,
|
|
26765
|
-
handed,
|
|
26766
|
-
decisions,
|
|
26767
|
-
moved: moved.slice(0, 6),
|
|
26768
|
-
lookFirst: look,
|
|
26769
|
-
quiet: !handed.length && !decisions.length && !moved.length && !look.length
|
|
26770
|
-
};
|
|
26771
|
-
}
|
|
26772
|
-
/** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
|
|
26773
|
-
function welcomeTitle(w, name, today) {
|
|
26774
|
-
const hi = name ? `Welcome back, ${name}` : "Welcome back";
|
|
26775
|
-
if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
|
|
26776
|
-
return `${hi} · ${w.when.days} days since you last looked`;
|
|
26777
|
-
}
|
|
26778
|
-
/** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
|
|
26779
|
-
function movedText(m, healthLabel) {
|
|
26780
|
-
const bits = [
|
|
26781
|
-
m.done ? `${m.done} done` : null,
|
|
26782
|
-
m.added ? `${m.added} added` : null,
|
|
26783
|
-
m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
|
|
26784
|
-
].filter(Boolean);
|
|
26785
|
-
return `${m.project.name}: ${bits.join(", ")}`;
|
|
26786
|
-
}
|
|
26787
|
-
|
|
26788
26854
|
//#endregion
|
|
26789
26855
|
//#region src/tui/views/welcome.tsx
|
|
26790
26856
|
init_swatches();
|
package/lib/cli.mjs
CHANGED
|
@@ -45,6 +45,9 @@ import {
|
|
|
45
45
|
whyText,
|
|
46
46
|
bridgeText,
|
|
47
47
|
barnaclesText,
|
|
48
|
+
baselineData,
|
|
49
|
+
baselineText,
|
|
50
|
+
catchupText,
|
|
48
51
|
handoverText,
|
|
49
52
|
fogText,
|
|
50
53
|
agentWorkText,
|
|
@@ -103,13 +106,15 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
103
106
|
harbor barnacles PROJECT work added since the plan was set:
|
|
104
107
|
by week, by who, and the days of
|
|
105
108
|
slip it explains
|
|
106
|
-
harbor baseline PROJECT
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
harbor baseline PROJECT reads a project's baseline
|
|
110
|
+
harbor baseline PROJECT --set|--reset sets it: its dates now, and
|
|
111
|
+
[--dry-run] growth counted from now
|
|
109
112
|
harbor hill [--program P] where each project is on its hill
|
|
110
113
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
|
|
111
114
|
out, past 50 is getting it done
|
|
112
115
|
harbor mine what's on you, late first
|
|
116
|
+
harbor catchup [--since DATE] while you were away: handed to
|
|
117
|
+
you, decided, what moved
|
|
113
118
|
harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
|
|
114
119
|
who's working now, or set your
|
|
115
120
|
(or NAME's) time zone and hours
|
|
@@ -200,7 +205,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
200
205
|
the review: programs worst first,
|
|
201
206
|
each with its next move (reads
|
|
202
207
|
only; harbor submit moves a task)
|
|
203
|
-
harbor thanks [--all] [--given] thanks
|
|
208
|
+
harbor thanks [--all] [--given] your thanks, last 30 days
|
|
204
209
|
harbor thanks TASK "LINE" [--to NAME] a one-line thanks for a task
|
|
205
210
|
harbor thanks --milestone NAME|--decision TITLE "LINE" [--to NAME]
|
|
206
211
|
the same for a milestone or a
|
|
@@ -972,14 +977,27 @@ const DETAIL = {
|
|
|
972
977
|
" don't keep hours.",
|
|
973
978
|
],
|
|
974
979
|
baseline: [
|
|
975
|
-
" harbor baseline PROJECT
|
|
980
|
+
" harbor baseline PROJECT reads its baseline and when it was set",
|
|
981
|
+
" harbor baseline PROJECT --set writes: commits its start and end",
|
|
976
982
|
" dates as its baseline",
|
|
977
983
|
" harbor baseline PROJECT --reset writes: replaces one already set",
|
|
984
|
+
" harbor baseline PROJECT --set --dry-run",
|
|
985
|
+
" reads: says what it would set",
|
|
978
986
|
"",
|
|
979
987
|
" The timeline then shows slips against those dates, and harbor barnacles",
|
|
980
988
|
" counts tasks added from now on as growth. On the website a program's",
|
|
981
989
|
" Set baseline does the same for all its projects.",
|
|
982
990
|
],
|
|
991
|
+
catchup: [
|
|
992
|
+
" harbor catchup reads the catch-up the app opens on",
|
|
993
|
+
" harbor catchup --since DATE reads it from a day (YYYY-MM-DD)",
|
|
994
|
+
"",
|
|
995
|
+
" What was handed to you and by whom, the calls made on your projects",
|
|
996
|
+
" without you, what moved on them, and the three to look at first. It",
|
|
997
|
+
" counts from your time away once you're back, else from when the app on",
|
|
998
|
+
" this machine last saw you, else the last 7 days. Reading it here",
|
|
999
|
+
" doesn't clear the app's or the website's catch-up.",
|
|
1000
|
+
],
|
|
983
1001
|
barnacles: [
|
|
984
1002
|
" harbor barnacles PROJECT reads how much the plan has grown",
|
|
985
1003
|
"",
|
|
@@ -2873,6 +2891,15 @@ export async function run(argv, io = {}) {
|
|
|
2873
2891
|
if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
|
|
2874
2892
|
return call("long_pole", { harbor, of }, whyText);
|
|
2875
2893
|
}
|
|
2894
|
+
case "catchup": {
|
|
2895
|
+
const { positionals, values, harbor } = parse(spec([], ["since"]));
|
|
2896
|
+
none(positionals);
|
|
2897
|
+
if (values.since && !/^\d{4}-\d{2}-\d{2}$/.test(values.since))
|
|
2898
|
+
throw new HarborError("--since takes a date like 2026-10-14.");
|
|
2899
|
+
// When the app on this machine last saw you in this harbor (it keeps the mark by id).
|
|
2900
|
+
const seen = values.since ?? (harbor ? (await readSettings(env)).seen?.[harbor] : null);
|
|
2901
|
+
return call("catch_up", { harbor, ...(seen ? { since: seen } : {}) }, catchupText);
|
|
2902
|
+
}
|
|
2876
2903
|
case "barnacles": {
|
|
2877
2904
|
const { positionals, harbor } = parse(spec());
|
|
2878
2905
|
const project = positionals.join(" ").trim();
|
|
@@ -2880,9 +2907,16 @@ export async function run(argv, io = {}) {
|
|
|
2880
2907
|
return call("scope_growth", { harbor, project }, barnaclesText);
|
|
2881
2908
|
}
|
|
2882
2909
|
case "baseline": {
|
|
2883
|
-
const { positionals, values, harbor } = parse(spec(["reset"]));
|
|
2910
|
+
const { positionals, values, harbor } = parse(spec(["set", "reset", "dry-run"]));
|
|
2884
2911
|
const project = positionals.join(" ").trim();
|
|
2885
2912
|
if (!project) throw new HarborError("Say which project: harbor baseline PROJECT.");
|
|
2913
|
+
const dryRun = Boolean(values["dry-run"]);
|
|
2914
|
+
// Reading is the default: only --set or --reset writes.
|
|
2915
|
+
if (dryRun || !(values.set || values.reset)) {
|
|
2916
|
+
return call("get_project", { harbor, project }, baselineText, (r) =>
|
|
2917
|
+
baselineData(r, { dryRun, reset: Boolean(values.reset) }),
|
|
2918
|
+
);
|
|
2919
|
+
}
|
|
2886
2920
|
return call(
|
|
2887
2921
|
"set_baseline",
|
|
2888
2922
|
{ harbor, project, ...(values.reset ? { reset: true } : {}) },
|
package/lib/format.mjs
CHANGED
|
@@ -205,9 +205,21 @@ export function whoami(r, creds) {
|
|
|
205
205
|
else if (list.length)
|
|
206
206
|
lines.push("No harbor set: say --harbor NAME, or set one with harbor use NAME.");
|
|
207
207
|
else lines.push("You aren't in any harbor yet. Run harbor to make one.");
|
|
208
|
+
// Shown from whoamiData (build), or straight from list_harbors (server).
|
|
209
|
+
lines.push(serverLine(r.build ?? (typeof r.server === "object" ? r.server : null)));
|
|
208
210
|
return lines.join("\n");
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
/**
|
|
214
|
+
* "Server: built Oct 22 03:14 UTC from 5e1c6d4." A site from before the stamp
|
|
215
|
+
* says nothing about itself, which means it's older than this CLI's whoami.
|
|
216
|
+
*/
|
|
217
|
+
export function serverLine(server) {
|
|
218
|
+
if (!server?.builtAt) return "Server: an older build that doesn't say which; some commands may not be there yet.";
|
|
219
|
+
const at = `${monthDay(server.builtAt)} ${server.builtAt.slice(11, 16)} UTC`;
|
|
220
|
+
return `Server: built ${at}${server.commit ? ` from ${server.commit.slice(0, 7)}` : ""}.`;
|
|
221
|
+
}
|
|
222
|
+
|
|
211
223
|
/** whoami --json: the account and its harbors, with where it signed in and the harbor commands use. */
|
|
212
224
|
export function whoamiData(r, creds) {
|
|
213
225
|
const list = r.harbors ?? [];
|
|
@@ -224,6 +236,8 @@ export function whoamiData(r, creds) {
|
|
|
224
236
|
server: creds.server,
|
|
225
237
|
readOnly: !creds.scope?.split(" ").includes("harbor:write"),
|
|
226
238
|
harbor: using,
|
|
239
|
+
// The site's build (commit and when), or null from a site too old to say.
|
|
240
|
+
build: r.server ?? null,
|
|
227
241
|
};
|
|
228
242
|
}
|
|
229
243
|
|
|
@@ -629,6 +643,13 @@ export function whyText(r) {
|
|
|
629
643
|
/** A project's barnacles, as scope_growth answers it: the growth, its cost in days, by week and by who. */
|
|
630
644
|
export function barnaclesText(r) {
|
|
631
645
|
const head = [`Barnacles · ${r.project}`];
|
|
646
|
+
if (r.settling) {
|
|
647
|
+
head.push(
|
|
648
|
+
`The plan is still being written: ${r.planned} task${r.planned === 1 ? "" : "s"} so far, and nothing counts as added until ${monthDay(r.since)}, a week after its first task.`,
|
|
649
|
+
`harbor baseline "${r.key ?? r.project}" --set counts from now instead.`,
|
|
650
|
+
);
|
|
651
|
+
return head.join("\n");
|
|
652
|
+
}
|
|
632
653
|
if (!r.added) {
|
|
633
654
|
head.push(`Nothing added since the plan was set (${r.since.slice(0, 10)}): ${r.planned} task${r.planned === 1 ? "" : "s"}, as planned.`);
|
|
634
655
|
return head.join("\n");
|
|
@@ -644,7 +665,7 @@ export function barnaclesText(r) {
|
|
|
644
665
|
const from =
|
|
645
666
|
r.measuredFrom === "when the baseline was set"
|
|
646
667
|
? `Counted from when the baseline was set, ${r.since.slice(0, 10)}.`
|
|
647
|
-
: `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" counts from then instead.`;
|
|
668
|
+
: `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" --set counts from then instead.`;
|
|
648
669
|
return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
|
|
649
670
|
}
|
|
650
671
|
|
|
@@ -1110,3 +1131,84 @@ export function followedText(r) {
|
|
|
1110
1131
|
const f = r.followed;
|
|
1111
1132
|
return `${f.person} is a second pair of hands on ${f.what}: they follow it now, and it stays with whoever has it.`;
|
|
1112
1133
|
}
|
|
1134
|
+
|
|
1135
|
+
/** A project's baseline from get_project, as harbor baseline reads it (or would set it, with --dry-run). */
|
|
1136
|
+
export function baselineData(p, { dryRun = false, reset = false } = {}) {
|
|
1137
|
+
const has = Boolean(p.baselineEnds);
|
|
1138
|
+
return {
|
|
1139
|
+
project: p.name,
|
|
1140
|
+
...(p.key ? { key: p.key } : {}),
|
|
1141
|
+
baselineStart: p.baselineStarts ?? null,
|
|
1142
|
+
baselineEnd: p.baselineEnds ?? null,
|
|
1143
|
+
setAt: p.baselineSetAt ?? null,
|
|
1144
|
+
starts: p.starts ?? null,
|
|
1145
|
+
ends: p.ends ?? null,
|
|
1146
|
+
...(dryRun
|
|
1147
|
+
? {
|
|
1148
|
+
dryRun: true,
|
|
1149
|
+
wouldSet: !has || reset ? { baselineStart: p.starts ?? null, baselineEnd: p.ends ?? null } : null,
|
|
1150
|
+
}
|
|
1151
|
+
: {}),
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const span = (from, to) => (from ? `${monthDay(from)} – ${monthDay(to)}` : `ending ${monthDay(to)}`);
|
|
1156
|
+
|
|
1157
|
+
export function baselineText(b) {
|
|
1158
|
+
const name = b.project;
|
|
1159
|
+
const now = b.starts && b.ends ? span(b.starts, b.ends) : null;
|
|
1160
|
+
const had = b.baselineEnd
|
|
1161
|
+
? `${b.setAt ? `Baseline set ${monthDay(b.setAt)}` : "Baseline"}: ${span(b.baselineStart, b.baselineEnd)}.`
|
|
1162
|
+
: null;
|
|
1163
|
+
if (b.dryRun) {
|
|
1164
|
+
if (!b.wouldSet) {
|
|
1165
|
+
return `${name} already has a baseline (${span(b.baselineStart, b.baselineEnd)}); --reset would replace it${now ? ` with ${now}` : ""}. Nothing changed.`;
|
|
1166
|
+
}
|
|
1167
|
+
if (!now) return `${name} has no start and end dates yet, so there's nothing to set as its baseline.`;
|
|
1168
|
+
return `Would set ${name}'s baseline: ${now}${had ? `, replacing ${span(b.baselineStart, b.baselineEnd)}` : ""}. Nothing changed; leave out --dry-run to set it.`;
|
|
1169
|
+
}
|
|
1170
|
+
if (!had) {
|
|
1171
|
+
return now
|
|
1172
|
+
? `${name}: no baseline yet. harbor baseline "${name}" --set commits its dates now (${now}).`
|
|
1173
|
+
: `${name}: no baseline yet, and no start and end dates to set one from.`;
|
|
1174
|
+
}
|
|
1175
|
+
const moved = now && (b.starts !== b.baselineStart || b.ends !== b.baselineEnd) && b.baselineStart;
|
|
1176
|
+
return [`${name}: ${had}`, ...(moved ? [`Its dates now: ${now}. --reset makes those the baseline.`] : [])].join("\n");
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
/** catch_up as the terminal shows it: the three to look at first, the calls made without you, what moved. */
|
|
1181
|
+
export function catchupText(r) {
|
|
1182
|
+
if (!r.you) return r.note ?? "Nothing to catch up on.";
|
|
1183
|
+
const first = r.you.split(" ")[0];
|
|
1184
|
+
const head =
|
|
1185
|
+
r.from === "time away"
|
|
1186
|
+
? `While you were away, ${first} · since ${monthDay(r.since)}`
|
|
1187
|
+
: r.from === "when you last looked"
|
|
1188
|
+
? `Since you last looked, ${first} · ${monthDay(r.since)}, ${r.days} day${r.days === 1 ? "" : "s"} ago`
|
|
1189
|
+
: `The last 7 days, ${first} · since ${monthDay(r.since)}`;
|
|
1190
|
+
if (r.quiet) return `${head}\n\nNothing moved on your projects since then.`;
|
|
1191
|
+
const out = [head];
|
|
1192
|
+
if (r.lookFirst.length) {
|
|
1193
|
+
out.push("", "Look at first:");
|
|
1194
|
+
for (const l of r.lookFirst) out.push(` ${l.text}`);
|
|
1195
|
+
}
|
|
1196
|
+
if (r.decisions.length) {
|
|
1197
|
+
out.push("", "Decided without you:");
|
|
1198
|
+
for (const d of r.decisions) out.push(` ${d.title}${d.by ? ` · ${d.by.split(" ")[0]}` : ""} (${monthDay(d.decidedAt)})`);
|
|
1199
|
+
}
|
|
1200
|
+
if (r.moved.length) {
|
|
1201
|
+
out.push("", "What moved:");
|
|
1202
|
+
for (const m of r.moved) {
|
|
1203
|
+
const bits = [
|
|
1204
|
+
m.done ? `${m.done} done` : null,
|
|
1205
|
+
m.added ? `${m.added} added` : null,
|
|
1206
|
+
m.health ? `now ${HEALTH[m.health.to] ?? m.health.to} (was ${HEALTH[m.health.from] ?? m.health.from})` : null,
|
|
1207
|
+
].filter(Boolean);
|
|
1208
|
+
out.push(` ${m.key ? `${m.key} ` : ""}${m.project}: ${bits.join(", ")}`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const more = r.handed.length > r.lookFirst.filter((l) => l.kind === "handed").length;
|
|
1212
|
+
if (more) out.push("", `${r.handed.length} handed to you in all; harbor catchup --json lists them.`);
|
|
1213
|
+
return out.join("\n");
|
|
1214
|
+
}
|