@harborppm/cli 0.77.0 → 0.79.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 +2 -1
- package/dist/app.mjs +145 -0
- package/lib/cli.mjs +33 -3
- package/lib/format.mjs +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -215,6 +215,7 @@ harbor voyage PROJECT ["YOUR LINE"]
|
|
|
215
215
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
216
216
|
harbor sound TASK [--answer DAYS|--ask NAMES|--settle DAYS] [--why TEXT]
|
|
217
217
|
harbor soundings [PROJECT]
|
|
218
|
+
harbor goals [PORTFOLIO] each portfolio's goals, off track first
|
|
218
219
|
harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
|
|
219
220
|
harbor limit PROJECT [COLUMN N|none]
|
|
220
221
|
harbor week [--next|--last|--week DATE] [--review]
|
|
@@ -256,7 +257,7 @@ A task has one owner (`--assignee`), who answers for it and whose load it counts
|
|
|
256
257
|
|
|
257
258
|
`harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
|
|
258
259
|
|
|
259
|
-
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK --answer 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first. `harbor ashore` lists the public holidays and time away in the next eight weeks; `--country US,GB` sets whose holidays the harbor takes and `--person NAME --country DE` one person's own, which the forecast and the weekly load then count as days off.
|
|
260
|
+
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK --answer 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first. `harbor goals` lists each portfolio's goals (set on the portfolio's page on the website), off track first, with each key result's progress. `harbor ashore` lists the public holidays and time away in the next eight weeks; `--country US,GB` sets whose holidays the harbor takes and `--person NAME --country DE` one person's own, which the forecast and the weekly load then count as days off.
|
|
260
261
|
|
|
261
262
|
`harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux, which you may need to install). With `--json` it prints the forecast and stays quiet.
|
|
262
263
|
|
package/dist/app.mjs
CHANGED
|
@@ -9834,6 +9834,7 @@ init_work();
|
|
|
9834
9834
|
init_clock();
|
|
9835
9835
|
init_soundings();
|
|
9836
9836
|
const KIND_RANK = {
|
|
9837
|
+
harbor: -1,
|
|
9837
9838
|
waiting: 0,
|
|
9838
9839
|
approval: 1,
|
|
9839
9840
|
review: 2,
|
|
@@ -9843,6 +9844,7 @@ const KIND_RANK = {
|
|
|
9843
9844
|
mention: 6
|
|
9844
9845
|
};
|
|
9845
9846
|
const PING_KIND_LABEL = {
|
|
9847
|
+
harbor: "Your harbor",
|
|
9846
9848
|
waiting: "Waiting on you",
|
|
9847
9849
|
approval: "Sign-offs",
|
|
9848
9850
|
review: "Reviews due",
|
|
@@ -9882,6 +9884,19 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
9882
9884
|
const pings = [];
|
|
9883
9885
|
const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
9884
9886
|
const since = subDays(now, ASSIGNED_DAYS).toISOString();
|
|
9887
|
+
const me = data.people.find((p) => p.id === personId);
|
|
9888
|
+
if (me?.harborHandedAt && me.harborHandedAt >= since && me.harborHandedById !== personId) {
|
|
9889
|
+
const by = data.people.find((p) => p.id === me.harborHandedById);
|
|
9890
|
+
pings.push({
|
|
9891
|
+
id: `harbor:${me.harborHandedAt}`,
|
|
9892
|
+
kind: "harbor",
|
|
9893
|
+
title: `${by ? firstName$3(by.name) : "The owner"} handed this harbor to you`,
|
|
9894
|
+
detail: "You own it now: invites, roles, billing, webhooks and the whole-harbor download. Settings has them all.",
|
|
9895
|
+
at: me.harborHandedAt,
|
|
9896
|
+
personId: by?.id ?? null,
|
|
9897
|
+
href: "/settings"
|
|
9898
|
+
});
|
|
9899
|
+
}
|
|
9885
9900
|
for (const task of mine) {
|
|
9886
9901
|
if (!task.assignedBy || task.assignedBy === personId || !task.assignedAt) continue;
|
|
9887
9902
|
if (task.assignedAt < since) continue;
|
|
@@ -13219,6 +13234,7 @@ const PAGES = {
|
|
|
13219
13234
|
programs: "/programs",
|
|
13220
13235
|
projects: "/projects",
|
|
13221
13236
|
risks: "/risks",
|
|
13237
|
+
goals: "/goals",
|
|
13222
13238
|
calendar: "/calendar",
|
|
13223
13239
|
people: "/people"
|
|
13224
13240
|
};
|
|
@@ -13662,6 +13678,87 @@ function entriesNewestFirst(task) {
|
|
|
13662
13678
|
return [...task.time ?? []].sort((a, b) => (b.on + b.at).localeCompare(a.on + a.at));
|
|
13663
13679
|
}
|
|
13664
13680
|
|
|
13681
|
+
// src/lib/goals.ts
|
|
13682
|
+
init_indexes();
|
|
13683
|
+
const GOAL_STATUS_LABEL = {
|
|
13684
|
+
achieved: "Achieved",
|
|
13685
|
+
"on-track": "On track",
|
|
13686
|
+
"at-risk": "At risk",
|
|
13687
|
+
"off-track": "Off track",
|
|
13688
|
+
"not-started": "Not started"
|
|
13689
|
+
};
|
|
13690
|
+
const clamp = (n) => Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
|
13691
|
+
/** The projects a work key result counts: those it names, and every project in a program it names. */
|
|
13692
|
+
function linkedProjects(kr, projects) {
|
|
13693
|
+
const ids = new Set(kr.linkIds ?? []);
|
|
13694
|
+
if (ids.size === 0) return [];
|
|
13695
|
+
return projects.filter((p) => ids.has(p.id) || ids.has(p.programId));
|
|
13696
|
+
}
|
|
13697
|
+
/** How far along one key result is, 0 to 1. */
|
|
13698
|
+
function keyResultProgress(kr, work) {
|
|
13699
|
+
if (kr.kind === "number") {
|
|
13700
|
+
const start = kr.start ?? 0;
|
|
13701
|
+
const span = (kr.target ?? 0) - start;
|
|
13702
|
+
if (span === 0) return (kr.current ?? start) >= (kr.target ?? 0) ? 1 : 0;
|
|
13703
|
+
return clamp(((kr.current ?? start) - start) / span);
|
|
13704
|
+
}
|
|
13705
|
+
let done = 0;
|
|
13706
|
+
let all = 0;
|
|
13707
|
+
for (const p of linkedProjects(kr, work.projects)) for (const t of tasksOf(work.tasks, p.id)) {
|
|
13708
|
+
all++;
|
|
13709
|
+
if (t.status === "done") done++;
|
|
13710
|
+
}
|
|
13711
|
+
return all === 0 ? 0 : done / all;
|
|
13712
|
+
}
|
|
13713
|
+
/** How far along a goal is: the average of its key results, 0 to 1. */
|
|
13714
|
+
function goalProgress(goal, work) {
|
|
13715
|
+
const krs = goal.keyResults ?? [];
|
|
13716
|
+
if (krs.length === 0) return 0;
|
|
13717
|
+
return krs.reduce((sum, kr) => sum + keyResultProgress(kr, work), 0) / krs.length;
|
|
13718
|
+
}
|
|
13719
|
+
/** How much of the goal's time has gone by, 0 to 1, from when it was set to its due day. */
|
|
13720
|
+
function timeGone(goal, today) {
|
|
13721
|
+
if (!goal.due) return null;
|
|
13722
|
+
const start = Date.parse(`${goal.createdAt.slice(0, 10)}T00:00:00Z`);
|
|
13723
|
+
const end = Date.parse(`${goal.due}T00:00:00Z`);
|
|
13724
|
+
const now = Date.parse(`${today}T00:00:00Z`);
|
|
13725
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
|
13726
|
+
if (end <= start) return now >= end ? 1 : 0;
|
|
13727
|
+
return clamp((now - start) / (end - start));
|
|
13728
|
+
}
|
|
13729
|
+
/**
|
|
13730
|
+
* Where a goal stands. Achieved at 100%. Otherwise, with a due day, progress
|
|
13731
|
+
* is held against the share of time gone by: within 10 points is on track,
|
|
13732
|
+
* within 25 at risk, further behind (or past due) off track. Without a due
|
|
13733
|
+
* day, any progress is on track.
|
|
13734
|
+
*/
|
|
13735
|
+
function goalStatus(progress, goal, today) {
|
|
13736
|
+
if (progress >= 1) return "achieved";
|
|
13737
|
+
const gone = timeGone(goal, today);
|
|
13738
|
+
if (gone == null) return progress > 0 ? "on-track" : "not-started";
|
|
13739
|
+
if (goal.due && today > goal.due) return "off-track";
|
|
13740
|
+
if (progress === 0 && gone < .1) return "not-started";
|
|
13741
|
+
const behind = gone - progress;
|
|
13742
|
+
if (behind <= .1) return "on-track";
|
|
13743
|
+
if (behind <= .25) return "at-risk";
|
|
13744
|
+
return "off-track";
|
|
13745
|
+
}
|
|
13746
|
+
/** "12 of 40 stores", "8 of 20 tasks done". */
|
|
13747
|
+
function keyResultText(kr, work) {
|
|
13748
|
+
if (kr.kind === "number") {
|
|
13749
|
+
const unit = kr.unit ? ` ${kr.unit}` : "";
|
|
13750
|
+
return `${fmt(kr.current ?? kr.start ?? 0)} of ${fmt(kr.target ?? 0)}${unit}`;
|
|
13751
|
+
}
|
|
13752
|
+
let done = 0;
|
|
13753
|
+
let all = 0;
|
|
13754
|
+
for (const p of linkedProjects(kr, work.projects)) for (const t of tasksOf(work.tasks, p.id)) {
|
|
13755
|
+
all++;
|
|
13756
|
+
if (t.status === "done") done++;
|
|
13757
|
+
}
|
|
13758
|
+
return all === 0 ? "No tasks yet" : `${done} of ${all} tasks done`;
|
|
13759
|
+
}
|
|
13760
|
+
const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
|
|
13761
|
+
|
|
13665
13762
|
// node_modules/zod/v4/core/util.js
|
|
13666
13763
|
function getEnumValues(entries) {
|
|
13667
13764
|
const numericValues = Object.values(entries).filter((v) => typeof v === "number");
|
|
@@ -21290,6 +21387,54 @@ const HARBOR_TOOLS = [
|
|
|
21290
21387
|
};
|
|
21291
21388
|
}
|
|
21292
21389
|
}),
|
|
21390
|
+
tool({
|
|
21391
|
+
name: "list_goals",
|
|
21392
|
+
title: "Goals",
|
|
21393
|
+
description: "Each portfolio's goals: what it should achieve by when, who owns it, and its key results (a number someone updates, or work in Harbor counted by finished tasks), with how far along each is and whether the goal is on track for its due day (progress held against the time gone by)." + DATA_NOTE,
|
|
21394
|
+
input: object({
|
|
21395
|
+
harbor: harborArg,
|
|
21396
|
+
portfolio: string().max(200).optional().describe("Portfolio id or name. Default: all.")
|
|
21397
|
+
}),
|
|
21398
|
+
run: (ctx, input) => {
|
|
21399
|
+
const { data } = ctx;
|
|
21400
|
+
const today = todayISO(ctx.now);
|
|
21401
|
+
const people = byId(data.people);
|
|
21402
|
+
const named = byId([...data.programs, ...data.projects]);
|
|
21403
|
+
return { portfolios: (input.portfolio ? [findOne(data.portfolios, input.portfolio, "portfolio")] : data.portfolios).map((pf) => ({
|
|
21404
|
+
id: pf.id,
|
|
21405
|
+
name: pf.name,
|
|
21406
|
+
goals: (pf.goals ?? []).map((g) => {
|
|
21407
|
+
const progress = goalProgress(g, data);
|
|
21408
|
+
const status = goalStatus(progress, g, today);
|
|
21409
|
+
return {
|
|
21410
|
+
id: g.id,
|
|
21411
|
+
title: g.title,
|
|
21412
|
+
owner: g.ownerId ? people.get(g.ownerId)?.name ?? null : null,
|
|
21413
|
+
due: g.due,
|
|
21414
|
+
...g.why ? { why: g.why } : {},
|
|
21415
|
+
progress: Math.round(progress * 100),
|
|
21416
|
+
status,
|
|
21417
|
+
statusText: GOAL_STATUS_LABEL[status],
|
|
21418
|
+
keyResults: g.keyResults.map((kr) => ({
|
|
21419
|
+
id: kr.id,
|
|
21420
|
+
title: kr.title,
|
|
21421
|
+
kind: kr.kind,
|
|
21422
|
+
text: keyResultText(kr, data),
|
|
21423
|
+
progress: Math.round(keyResultProgress(kr, data) * 100),
|
|
21424
|
+
...kr.kind === "number" ? {
|
|
21425
|
+
start: kr.start ?? 0,
|
|
21426
|
+
target: kr.target,
|
|
21427
|
+
current: kr.current ?? kr.start ?? 0
|
|
21428
|
+
} : {
|
|
21429
|
+
counts: (kr.linkIds ?? []).map((id) => named.get(id)?.name ?? id),
|
|
21430
|
+
projects: linkedProjects(kr, data.projects).length
|
|
21431
|
+
}
|
|
21432
|
+
}))
|
|
21433
|
+
};
|
|
21434
|
+
})
|
|
21435
|
+
})) };
|
|
21436
|
+
}
|
|
21437
|
+
}),
|
|
21293
21438
|
tool({
|
|
21294
21439
|
name: "long_pole",
|
|
21295
21440
|
title: "What's holding this up",
|
package/lib/cli.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
lifeboatsText,
|
|
40
40
|
followedText,
|
|
41
41
|
soundingsText,
|
|
42
|
+
goalsText,
|
|
42
43
|
thanksText,
|
|
43
44
|
ashoreText,
|
|
44
45
|
voyageText,
|
|
@@ -260,6 +261,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
260
261
|
your answer, or read the spread
|
|
261
262
|
harbor soundings [PROJECT] open tasks with a sounding, and
|
|
262
263
|
the ones waiting on your answer
|
|
264
|
+
harbor goals [PORTFOLIO] each portfolio's goals, how far
|
|
265
|
+
along and whether on track
|
|
263
266
|
harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
|
|
264
267
|
public holidays and time away
|
|
265
268
|
coming up, and whose holidays
|
|
@@ -554,17 +557,30 @@ async function readTitles(client, harbor, titles, values, today = todayLocal())
|
|
|
554
557
|
});
|
|
555
558
|
}
|
|
556
559
|
|
|
557
|
-
/**
|
|
560
|
+
/** "Fri 2026-11-13": a day with its weekday, so "next friday" shows which Friday it became. */
|
|
561
|
+
export function dayWithWeekday(iso) {
|
|
562
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso;
|
|
563
|
+
const day = WEEKDAYS[new Date(`${iso}T12:00:00`).getDay()];
|
|
564
|
+
return day ? `${day.charAt(0).toUpperCase()}${day.slice(1, 3)} ${iso}` : iso;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** What a title's words said, in a line: "Quinn (@Quinn), due Fri 2026-11-06 (by Friday)". */
|
|
558
568
|
function wordsLine(r) {
|
|
559
569
|
const bits = [
|
|
560
570
|
r.owner && `${r.owner.value} (${r.owner.text})`,
|
|
561
|
-
r.due && `due ${r.due.value} (${r.due.text})`,
|
|
571
|
+
r.due && `due ${dayWithWeekday(r.due.value)} (${r.due.text})`,
|
|
562
572
|
r.priority && `${r.priority.value} priority (${r.priority.text})`,
|
|
563
573
|
r.project && `in ${r.project.value} (${r.project.text})`,
|
|
564
574
|
].filter(Boolean);
|
|
565
575
|
return bits.length ? `Read from your words: ${bits.join(", ")}.` : null;
|
|
566
576
|
}
|
|
567
577
|
|
|
578
|
+
/** --due given in words rather than as a date or +N, when it reads as a day. */
|
|
579
|
+
function dueWords(v) {
|
|
580
|
+
if (typeof v !== "string" || /^(\d{4}-\d{2}-\d{2}|\+\d{1,3})$/.test(v) || PILOT.has(v.toLowerCase())) return false;
|
|
581
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(dueDate(v));
|
|
582
|
+
}
|
|
583
|
+
|
|
568
584
|
/** --due pilot: the date the owner's queue suggests, set by Harbor. */
|
|
569
585
|
const PILOT = new Set(["pilot", "suggested"]);
|
|
570
586
|
const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
|
|
@@ -1010,6 +1026,11 @@ const DETAIL = {
|
|
|
1010
1026
|
" these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
|
|
1011
1027
|
" NZ US (national holidays only).",
|
|
1012
1028
|
],
|
|
1029
|
+
goals: [
|
|
1030
|
+
" harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
|
|
1031
|
+
" left out), off track first, with how far",
|
|
1032
|
+
" along each key result is",
|
|
1033
|
+
],
|
|
1013
1034
|
soundings: [
|
|
1014
1035
|
" harbor soundings [PROJECT] reads: open tasks with a sounding",
|
|
1015
1036
|
" (all projects when left out), and the",
|
|
@@ -2529,7 +2550,7 @@ export async function run(argv, io = {}) {
|
|
|
2529
2550
|
[
|
|
2530
2551
|
`Would add ${n} to ${where.join(" and ")}:`,
|
|
2531
2552
|
...reads.flatMap((r, i) => [
|
|
2532
|
-
` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
|
|
2553
|
+
` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueWords(values.due) ? dayWithWeekday(dueDate(values.due)) : dueDate(values.due)})` : ""}`,
|
|
2533
2554
|
...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
|
|
2534
2555
|
]),
|
|
2535
2556
|
...(pilot
|
|
@@ -2590,6 +2611,8 @@ export async function run(argv, io = {}) {
|
|
|
2590
2611
|
`Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
|
|
2591
2612
|
// What the title's words said, so nothing is read without being seen.
|
|
2592
2613
|
...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
|
|
2614
|
+
// --due in words ("next friday"): the day it became, weekday and all.
|
|
2615
|
+
...(dueWords(values.due) ? [`--due ${values.due}: ${dayWithWeekday(dueDate(values.due))}.`] : []),
|
|
2593
2616
|
// Pilot speaks up only when a date was given that the queue says won't hold.
|
|
2594
2617
|
...(values.due ? pilotLines(r, r.created) : []),
|
|
2595
2618
|
].join("\n"),
|
|
@@ -3167,6 +3190,13 @@ export async function run(argv, io = {}) {
|
|
|
3167
3190
|
soundingsText(r),
|
|
3168
3191
|
);
|
|
3169
3192
|
}
|
|
3193
|
+
case "goals": {
|
|
3194
|
+
const { positionals, harbor } = parse(spec());
|
|
3195
|
+
const portfolio = positionals.join(" ").trim();
|
|
3196
|
+
return call("list_goals", { harbor, ...(portfolio ? { portfolio } : {}) }, (r) =>
|
|
3197
|
+
goalsText(r),
|
|
3198
|
+
);
|
|
3199
|
+
}
|
|
3170
3200
|
case "ashore": {
|
|
3171
3201
|
const { positionals, values, harbor } = parse(spec([], ["weeks", "country", "person"]));
|
|
3172
3202
|
none(positionals);
|
package/lib/format.mjs
CHANGED
|
@@ -1229,3 +1229,29 @@ export function catchupText(r) {
|
|
|
1229
1229
|
if (more) out.push("", `${r.handed.length} handed to you in all; harbor catchup --json lists them.`);
|
|
1230
1230
|
return out.join("\n");
|
|
1231
1231
|
}
|
|
1232
|
+
|
|
1233
|
+
/** list_goals as text: each portfolio's goals, what needs attention first, with their key results. */
|
|
1234
|
+
export function goalsText(r) {
|
|
1235
|
+
const order = ["off-track", "at-risk", "not-started", "on-track", "achieved"];
|
|
1236
|
+
const withGoals = (r.portfolios ?? []).filter((p) => p.goals?.length);
|
|
1237
|
+
if (!withGoals.length)
|
|
1238
|
+
return "No goals yet. Set one at the top of a portfolio's page on the website.";
|
|
1239
|
+
const lines = [];
|
|
1240
|
+
for (const p of withGoals) {
|
|
1241
|
+
if (lines.length) lines.push("");
|
|
1242
|
+
lines.push(p.name);
|
|
1243
|
+
const goals = [...p.goals].sort(
|
|
1244
|
+
(a, b) =>
|
|
1245
|
+
order.indexOf(a.status) - order.indexOf(b.status) ||
|
|
1246
|
+
(a.due ?? "9999").localeCompare(b.due ?? "9999"),
|
|
1247
|
+
);
|
|
1248
|
+
for (const g of goals) {
|
|
1249
|
+
const bits = [`${g.progress}%`, g.statusText.toLowerCase()];
|
|
1250
|
+
if (g.owner) bits.push(g.owner);
|
|
1251
|
+
if (g.due && g.status !== "achieved") bits.push(`by ${monthDay(g.due)}`);
|
|
1252
|
+
lines.push(` ${g.title} · ${bits.join(" · ")}`);
|
|
1253
|
+
for (const k of g.keyResults) lines.push(` ${k.title} · ${k.text}`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return lines.join("\n");
|
|
1257
|
+
}
|