@harborppm/cli 0.64.0 → 0.65.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 +6 -1
- package/dist/app.mjs +227 -163
- package/lib/cli.mjs +39 -5
- package/lib/format.mjs +96 -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]
|
package/dist/app.mjs
CHANGED
|
@@ -19110,6 +19110,169 @@ function pilotReasons(p, you = false) {
|
|
|
19110
19110
|
return out;
|
|
19111
19111
|
}
|
|
19112
19112
|
|
|
19113
|
+
//#endregion
|
|
19114
|
+
//#region src/lib/seen-mark.ts
|
|
19115
|
+
/** Days without opening Harbor before the catch-up shows. */
|
|
19116
|
+
const UNSEEN_DAYS = 3;
|
|
19117
|
+
/** Calendar days from one ISO day (or moment) to another, by their dates alone. */
|
|
19118
|
+
function dayGap(from, to) {
|
|
19119
|
+
const utc = (iso) => {
|
|
19120
|
+
const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
|
|
19121
|
+
return Date.UTC(y, m - 1, d);
|
|
19122
|
+
};
|
|
19123
|
+
return Math.round((utc(to) - utc(from)) / 864e5);
|
|
19124
|
+
}
|
|
19125
|
+
/**
|
|
19126
|
+
* Whether to catch someone up, and from when: back from time away they were
|
|
19127
|
+
* marked for (a handover with a return date that has come, until this device
|
|
19128
|
+
* has caught them up once since), or `UNSEEN_DAYS` or more since this device
|
|
19129
|
+
* last saw them. Null otherwise, and the first time a device sees them:
|
|
19130
|
+
* there's nothing to compare with.
|
|
19131
|
+
*/
|
|
19132
|
+
function welcomeWhen(person, lastSeen, today) {
|
|
19133
|
+
const away = person?.away;
|
|
19134
|
+
if (away?.since && away.until && away.until.slice(0, 10) <= today) {
|
|
19135
|
+
if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
|
|
19136
|
+
since: away.since,
|
|
19137
|
+
why: "away",
|
|
19138
|
+
days: dayGap(away.since, today)
|
|
19139
|
+
};
|
|
19140
|
+
}
|
|
19141
|
+
if (!lastSeen) return null;
|
|
19142
|
+
const days = dayGap(lastSeen, today);
|
|
19143
|
+
return days >= 3 ? {
|
|
19144
|
+
since: lastSeen,
|
|
19145
|
+
why: "unseen",
|
|
19146
|
+
days
|
|
19147
|
+
} : null;
|
|
19148
|
+
}
|
|
19149
|
+
|
|
19150
|
+
//#endregion
|
|
19151
|
+
//#region src/lib/welcome-back.ts
|
|
19152
|
+
init_date_text();
|
|
19153
|
+
init_indexes();
|
|
19154
|
+
init_task_keys();
|
|
19155
|
+
const day = (iso) => parseIso(iso.slice(0, 10));
|
|
19156
|
+
const daysBetween = dayGap;
|
|
19157
|
+
/** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
|
|
19158
|
+
function projectsOf(data, personId) {
|
|
19159
|
+
const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
|
|
19160
|
+
return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
|
|
19161
|
+
}
|
|
19162
|
+
const first = (p) => p?.name.split(" ")[0] ?? null;
|
|
19163
|
+
/** "due Thu, Oct 23", "3 days late", "due today". */
|
|
19164
|
+
function dueText(task, today) {
|
|
19165
|
+
if (!task.dueDate) return "no due date";
|
|
19166
|
+
const d = daysBetween(today, task.dueDate);
|
|
19167
|
+
if (d === 0) return "due today";
|
|
19168
|
+
if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
|
|
19169
|
+
if (d === 1) return "due tomorrow";
|
|
19170
|
+
return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
|
|
19171
|
+
}
|
|
19172
|
+
/** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
|
|
19173
|
+
function welcomeBack(data, personId, when, today) {
|
|
19174
|
+
const since = when.since;
|
|
19175
|
+
const people = byId(data.people);
|
|
19176
|
+
const projects = projectsOf(data, personId);
|
|
19177
|
+
const projectIds = new Set(projects.map((p) => p.id));
|
|
19178
|
+
const programIds = new Set(projects.map((p) => p.programId));
|
|
19179
|
+
const projectById = byId(data.projects);
|
|
19180
|
+
const label = (t) => {
|
|
19181
|
+
const p = projectById.get(t.projectId);
|
|
19182
|
+
const key = p ? taskKey(t, p) : null;
|
|
19183
|
+
return key ? `${key} ${t.title}` : t.title;
|
|
19184
|
+
};
|
|
19185
|
+
const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
19186
|
+
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) => ({
|
|
19187
|
+
task,
|
|
19188
|
+
by: people.get(task.assignedBy) ?? null
|
|
19189
|
+
}));
|
|
19190
|
+
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) => ({
|
|
19191
|
+
item,
|
|
19192
|
+
by: item.decidedById ? people.get(item.decidedById) ?? null : null
|
|
19193
|
+
}));
|
|
19194
|
+
const moved = [];
|
|
19195
|
+
for (const project of projects) {
|
|
19196
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
19197
|
+
const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
|
|
19198
|
+
const added = tasks.filter((t) => t.createdAt >= since).length;
|
|
19199
|
+
const history = project.healthHistory ?? [];
|
|
19200
|
+
const before = [...history].reverse().find((h) => h.at < since)?.health;
|
|
19201
|
+
const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
|
|
19202
|
+
from: before,
|
|
19203
|
+
to: project.health
|
|
19204
|
+
} : null;
|
|
19205
|
+
if (done || added || health) moved.push({
|
|
19206
|
+
project,
|
|
19207
|
+
done,
|
|
19208
|
+
added,
|
|
19209
|
+
health
|
|
19210
|
+
});
|
|
19211
|
+
}
|
|
19212
|
+
moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
|
|
19213
|
+
const look = [];
|
|
19214
|
+
const seen = /* @__PURE__ */ new Set();
|
|
19215
|
+
const push = (item) => {
|
|
19216
|
+
const id = item.taskId ?? item.raidId ?? item.text;
|
|
19217
|
+
if (look.length >= 3 || seen.has(id)) return;
|
|
19218
|
+
seen.add(id);
|
|
19219
|
+
look.push(item);
|
|
19220
|
+
};
|
|
19221
|
+
for (const h of handed) {
|
|
19222
|
+
const by = first(h.by);
|
|
19223
|
+
push({
|
|
19224
|
+
kind: "handed",
|
|
19225
|
+
taskId: h.task.id,
|
|
19226
|
+
text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
|
|
19227
|
+
});
|
|
19228
|
+
}
|
|
19229
|
+
const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
|
|
19230
|
+
for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
|
|
19231
|
+
kind: "late",
|
|
19232
|
+
taskId: t.id,
|
|
19233
|
+
text: `${label(t)}: ${dueText(t, today)}`
|
|
19234
|
+
});
|
|
19235
|
+
for (const t of dated) {
|
|
19236
|
+
const d = daysBetween(today, t.dueDate);
|
|
19237
|
+
if (d >= 0 && d <= 3) push({
|
|
19238
|
+
kind: "due",
|
|
19239
|
+
taskId: t.id,
|
|
19240
|
+
text: `${label(t)}: ${dueText(t, today)}`
|
|
19241
|
+
});
|
|
19242
|
+
}
|
|
19243
|
+
for (const d of decisions) {
|
|
19244
|
+
const by = first(d.by);
|
|
19245
|
+
push({
|
|
19246
|
+
kind: "decision",
|
|
19247
|
+
raidId: d.item.id,
|
|
19248
|
+
text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
|
|
19249
|
+
});
|
|
19250
|
+
}
|
|
19251
|
+
return {
|
|
19252
|
+
when,
|
|
19253
|
+
handed,
|
|
19254
|
+
decisions,
|
|
19255
|
+
moved: moved.slice(0, 6),
|
|
19256
|
+
lookFirst: look,
|
|
19257
|
+
quiet: !handed.length && !decisions.length && !moved.length && !look.length
|
|
19258
|
+
};
|
|
19259
|
+
}
|
|
19260
|
+
/** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
|
|
19261
|
+
function welcomeTitle(w, name, today) {
|
|
19262
|
+
const hi = name ? `Welcome back, ${name}` : "Welcome back";
|
|
19263
|
+
if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
|
|
19264
|
+
return `${hi} · ${w.when.days} days since you last looked`;
|
|
19265
|
+
}
|
|
19266
|
+
/** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
|
|
19267
|
+
function movedText(m, healthLabel) {
|
|
19268
|
+
const bits = [
|
|
19269
|
+
m.done ? `${m.done} done` : null,
|
|
19270
|
+
m.added ? `${m.added} added` : null,
|
|
19271
|
+
m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
|
|
19272
|
+
].filter(Boolean);
|
|
19273
|
+
return `${m.project.name}: ${bits.join(", ")}`;
|
|
19274
|
+
}
|
|
19275
|
+
|
|
19113
19276
|
//#endregion
|
|
19114
19277
|
//#region src/lib/agents.ts
|
|
19115
19278
|
const isAgent = (p) => p?.kind === "agent";
|
|
@@ -19383,6 +19546,7 @@ init_search();
|
|
|
19383
19546
|
init_derived_health();
|
|
19384
19547
|
init_format();
|
|
19385
19548
|
init_date_text();
|
|
19549
|
+
init_date_fns();
|
|
19386
19550
|
init_shipping_forecast();
|
|
19387
19551
|
init_handover();
|
|
19388
19552
|
init_work();
|
|
@@ -19999,7 +20163,9 @@ const HARBOR_TOOLS = [
|
|
|
19999
20163
|
} : {},
|
|
20000
20164
|
starts: dayOf(p.startDate),
|
|
20001
20165
|
ends: dayOf(p.endDate),
|
|
20166
|
+
...p.baselineStart ? { baselineStarts: dayOf(p.baselineStart) } : {},
|
|
20002
20167
|
...p.baselineEnd ? { baselineEnds: dayOf(p.baselineEnd) } : {},
|
|
20168
|
+
...p.baselineAt ? { baselineSetAt: p.baselineAt } : {},
|
|
20003
20169
|
budget: p.budget,
|
|
20004
20170
|
spent: p.spent,
|
|
20005
20171
|
tasks: byStatus,
|
|
@@ -20201,6 +20367,67 @@ const HARBOR_TOOLS = [
|
|
|
20201
20367
|
return weekOut(ctx.data, mine.id, weekOf(input.week ?? todayISO(ctx.now)));
|
|
20202
20368
|
}
|
|
20203
20369
|
}),
|
|
20370
|
+
tool({
|
|
20371
|
+
name: "catch_up",
|
|
20372
|
+
title: "While you were away",
|
|
20373
|
+
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,
|
|
20374
|
+
input: object({
|
|
20375
|
+
harbor: harborArg,
|
|
20376
|
+
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.")
|
|
20377
|
+
}),
|
|
20378
|
+
run: (ctx, input) => {
|
|
20379
|
+
const { data } = ctx;
|
|
20380
|
+
const mine = me(ctx);
|
|
20381
|
+
if (!mine) return {
|
|
20382
|
+
you: null,
|
|
20383
|
+
note: "You aren't one of this harbor's people yet, so there's nothing to catch up on."
|
|
20384
|
+
};
|
|
20385
|
+
const today = todayFor(mine, ctx.now, todayISO(ctx.now));
|
|
20386
|
+
const since = input.since ?? subDays(ctx.now, 7).toISOString();
|
|
20387
|
+
const away = welcomeWhen(mine, input.since ?? null, today);
|
|
20388
|
+
const when = away?.why === "away" ? away : {
|
|
20389
|
+
since,
|
|
20390
|
+
why: "unseen",
|
|
20391
|
+
days: dayGap(since, today)
|
|
20392
|
+
};
|
|
20393
|
+
const w = welcomeBack(data, mine.id, when, today);
|
|
20394
|
+
const projects = byId(data.projects);
|
|
20395
|
+
const keyOf = (t) => taskKey(t, projects.get(t.projectId));
|
|
20396
|
+
return {
|
|
20397
|
+
you: mine.name,
|
|
20398
|
+
from: when.why === "away" ? "time away" : input.since ? "when you last looked" : "the last 7 days",
|
|
20399
|
+
since: dayOf(when.since),
|
|
20400
|
+
days: when.days,
|
|
20401
|
+
quiet: w.quiet,
|
|
20402
|
+
lookFirst: w.lookFirst.map((l) => ({
|
|
20403
|
+
kind: l.kind,
|
|
20404
|
+
text: l.text,
|
|
20405
|
+
...l.taskId ? { taskId: l.taskId } : {},
|
|
20406
|
+
...l.raidId ? { raidId: l.raidId } : {}
|
|
20407
|
+
})),
|
|
20408
|
+
handed: w.handed.map(({ task, by }) => ({
|
|
20409
|
+
id: task.id,
|
|
20410
|
+
...keyOf(task) ? { key: keyOf(task) } : {},
|
|
20411
|
+
title: task.title,
|
|
20412
|
+
due: dayOf(task.dueDate),
|
|
20413
|
+
by: by?.name ?? null
|
|
20414
|
+
})),
|
|
20415
|
+
decisions: w.decisions.map(({ item, by }) => ({
|
|
20416
|
+
id: item.id,
|
|
20417
|
+
title: item.title,
|
|
20418
|
+
decidedAt: dayOf(item.decidedAt),
|
|
20419
|
+
by: by?.name ?? null
|
|
20420
|
+
})),
|
|
20421
|
+
moved: w.moved.map((m) => ({
|
|
20422
|
+
project: m.project.name,
|
|
20423
|
+
...m.project.key ? { key: m.project.key } : {},
|
|
20424
|
+
done: m.done,
|
|
20425
|
+
added: m.added,
|
|
20426
|
+
health: m.health
|
|
20427
|
+
}))
|
|
20428
|
+
};
|
|
20429
|
+
}
|
|
20430
|
+
}),
|
|
20204
20431
|
tool({
|
|
20205
20432
|
name: "weekly_brief",
|
|
20206
20433
|
title: "Weekly brief",
|
|
@@ -26622,169 +26849,6 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
|
|
|
26622
26849
|
});
|
|
26623
26850
|
}
|
|
26624
26851
|
|
|
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
26852
|
//#endregion
|
|
26789
26853
|
//#region src/tui/views/welcome.tsx
|
|
26790
26854
|
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
|
|
@@ -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
|
|
|
@@ -644,7 +658,7 @@ export function barnaclesText(r) {
|
|
|
644
658
|
const from =
|
|
645
659
|
r.measuredFrom === "when the baseline was set"
|
|
646
660
|
? `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.`;
|
|
661
|
+
: `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
662
|
return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
|
|
649
663
|
}
|
|
650
664
|
|
|
@@ -1110,3 +1124,84 @@ export function followedText(r) {
|
|
|
1110
1124
|
const f = r.followed;
|
|
1111
1125
|
return `${f.person} is a second pair of hands on ${f.what}: they follow it now, and it stays with whoever has it.`;
|
|
1112
1126
|
}
|
|
1127
|
+
|
|
1128
|
+
/** A project's baseline from get_project, as harbor baseline reads it (or would set it, with --dry-run). */
|
|
1129
|
+
export function baselineData(p, { dryRun = false, reset = false } = {}) {
|
|
1130
|
+
const has = Boolean(p.baselineEnds);
|
|
1131
|
+
return {
|
|
1132
|
+
project: p.name,
|
|
1133
|
+
...(p.key ? { key: p.key } : {}),
|
|
1134
|
+
baselineStart: p.baselineStarts ?? null,
|
|
1135
|
+
baselineEnd: p.baselineEnds ?? null,
|
|
1136
|
+
setAt: p.baselineSetAt ?? null,
|
|
1137
|
+
starts: p.starts ?? null,
|
|
1138
|
+
ends: p.ends ?? null,
|
|
1139
|
+
...(dryRun
|
|
1140
|
+
? {
|
|
1141
|
+
dryRun: true,
|
|
1142
|
+
wouldSet: !has || reset ? { baselineStart: p.starts ?? null, baselineEnd: p.ends ?? null } : null,
|
|
1143
|
+
}
|
|
1144
|
+
: {}),
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const span = (from, to) => (from ? `${monthDay(from)} – ${monthDay(to)}` : `ending ${monthDay(to)}`);
|
|
1149
|
+
|
|
1150
|
+
export function baselineText(b) {
|
|
1151
|
+
const name = b.project;
|
|
1152
|
+
const now = b.starts && b.ends ? span(b.starts, b.ends) : null;
|
|
1153
|
+
const had = b.baselineEnd
|
|
1154
|
+
? `${b.setAt ? `Baseline set ${monthDay(b.setAt)}` : "Baseline"}: ${span(b.baselineStart, b.baselineEnd)}.`
|
|
1155
|
+
: null;
|
|
1156
|
+
if (b.dryRun) {
|
|
1157
|
+
if (!b.wouldSet) {
|
|
1158
|
+
return `${name} already has a baseline (${span(b.baselineStart, b.baselineEnd)}); --reset would replace it${now ? ` with ${now}` : ""}. Nothing changed.`;
|
|
1159
|
+
}
|
|
1160
|
+
if (!now) return `${name} has no start and end dates yet, so there's nothing to set as its baseline.`;
|
|
1161
|
+
return `Would set ${name}'s baseline: ${now}${had ? `, replacing ${span(b.baselineStart, b.baselineEnd)}` : ""}. Nothing changed; leave out --dry-run to set it.`;
|
|
1162
|
+
}
|
|
1163
|
+
if (!had) {
|
|
1164
|
+
return now
|
|
1165
|
+
? `${name}: no baseline yet. harbor baseline "${name}" --set commits its dates now (${now}).`
|
|
1166
|
+
: `${name}: no baseline yet, and no start and end dates to set one from.`;
|
|
1167
|
+
}
|
|
1168
|
+
const moved = now && (b.starts !== b.baselineStart || b.ends !== b.baselineEnd) && b.baselineStart;
|
|
1169
|
+
return [`${name}: ${had}`, ...(moved ? [`Its dates now: ${now}. --reset makes those the baseline.`] : [])].join("\n");
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
/** catch_up as the terminal shows it: the three to look at first, the calls made without you, what moved. */
|
|
1174
|
+
export function catchupText(r) {
|
|
1175
|
+
if (!r.you) return r.note ?? "Nothing to catch up on.";
|
|
1176
|
+
const first = r.you.split(" ")[0];
|
|
1177
|
+
const head =
|
|
1178
|
+
r.from === "time away"
|
|
1179
|
+
? `While you were away, ${first} · since ${monthDay(r.since)}`
|
|
1180
|
+
: r.from === "when you last looked"
|
|
1181
|
+
? `Since you last looked, ${first} · ${monthDay(r.since)}, ${r.days} day${r.days === 1 ? "" : "s"} ago`
|
|
1182
|
+
: `The last 7 days, ${first} · since ${monthDay(r.since)}`;
|
|
1183
|
+
if (r.quiet) return `${head}\n\nNothing moved on your projects since then.`;
|
|
1184
|
+
const out = [head];
|
|
1185
|
+
if (r.lookFirst.length) {
|
|
1186
|
+
out.push("", "Look at first:");
|
|
1187
|
+
for (const l of r.lookFirst) out.push(` ${l.text}`);
|
|
1188
|
+
}
|
|
1189
|
+
if (r.decisions.length) {
|
|
1190
|
+
out.push("", "Decided without you:");
|
|
1191
|
+
for (const d of r.decisions) out.push(` ${d.title}${d.by ? ` · ${d.by.split(" ")[0]}` : ""} (${monthDay(d.decidedAt)})`);
|
|
1192
|
+
}
|
|
1193
|
+
if (r.moved.length) {
|
|
1194
|
+
out.push("", "What moved:");
|
|
1195
|
+
for (const m of r.moved) {
|
|
1196
|
+
const bits = [
|
|
1197
|
+
m.done ? `${m.done} done` : null,
|
|
1198
|
+
m.added ? `${m.added} added` : null,
|
|
1199
|
+
m.health ? `now ${HEALTH[m.health.to] ?? m.health.to} (was ${HEALTH[m.health.from] ?? m.health.from})` : null,
|
|
1200
|
+
].filter(Boolean);
|
|
1201
|
+
out.push(` ${m.project}: ${bits.join(", ")}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const more = r.handed.length > r.lookFirst.filter((l) => l.kind === "handed").length;
|
|
1205
|
+
if (more) out.push("", `${r.handed.length} handed to you in all; harbor catchup --json lists them.`);
|
|
1206
|
+
return out.join("\n");
|
|
1207
|
+
}
|