@harborppm/cli 0.97.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.mjs +191 -12
- package/lib/cli.mjs +88 -0
- package/lib/format.mjs +28 -0
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -3376,7 +3376,7 @@ var init_thanks = __esmMin((() => {
|
|
|
3376
3376
|
// src/lib/charter-lines.ts
|
|
3377
3377
|
/** Nothing written on any of the five lines. */
|
|
3378
3378
|
function charterEmpty(charter) {
|
|
3379
|
-
return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
|
|
3379
|
+
return !charter || CHARTER_FIELDS.every((f) => !clean$1(charter[f]));
|
|
3380
3380
|
}
|
|
3381
3381
|
/**
|
|
3382
3382
|
* The charter after a change to some of its lines: trimmed, and null once
|
|
@@ -3396,7 +3396,7 @@ function withCharter(current, patch, by, at) {
|
|
|
3396
3396
|
}
|
|
3397
3397
|
return charterEmpty(next) ? null : next;
|
|
3398
3398
|
}
|
|
3399
|
-
var CHARTER_FIELDS, clean;
|
|
3399
|
+
var CHARTER_FIELDS, clean$1;
|
|
3400
3400
|
var init_charter_lines = __esmMin((() => {
|
|
3401
3401
|
CHARTER_FIELDS = [
|
|
3402
3402
|
"why",
|
|
@@ -3405,7 +3405,7 @@ var init_charter_lines = __esmMin((() => {
|
|
|
3405
3405
|
"doneWhen",
|
|
3406
3406
|
"callsBy"
|
|
3407
3407
|
];
|
|
3408
|
-
clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
3408
|
+
clean$1 = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
3409
3409
|
}));
|
|
3410
3410
|
|
|
3411
3411
|
// src/lib/charter.ts
|
|
@@ -3434,7 +3434,7 @@ function charterLines(charter) {
|
|
|
3434
3434
|
return CHARTER_FIELDS.map((field) => ({
|
|
3435
3435
|
field,
|
|
3436
3436
|
label: CHARTER_LABEL[field],
|
|
3437
|
-
text: clean(charter[field])
|
|
3437
|
+
text: clean$1(charter[field])
|
|
3438
3438
|
})).filter((l) => l.text);
|
|
3439
3439
|
}
|
|
3440
3440
|
function charterFilled(charter) {
|
|
@@ -3502,7 +3502,7 @@ function charterCheck(charter, tasks) {
|
|
|
3502
3502
|
};
|
|
3503
3503
|
});
|
|
3504
3504
|
return {
|
|
3505
|
-
doneWhen: clean(charter.doneWhen) || null,
|
|
3505
|
+
doneWhen: clean$1(charter.doneWhen) || null,
|
|
3506
3506
|
delivered,
|
|
3507
3507
|
crept: outOfScope(charter, tasks),
|
|
3508
3508
|
outItems: charterItems(charter.outScope).length
|
|
@@ -3524,6 +3524,18 @@ function charterSentence(check) {
|
|
|
3524
3524
|
return `Against the charter: ${bits.join("; ")}.`;
|
|
3525
3525
|
}
|
|
3526
3526
|
|
|
3527
|
+
// src/lib/finished-on.ts
|
|
3528
|
+
/**
|
|
3529
|
+
* When the project was marked Done: its last move to Done in its health
|
|
3530
|
+
* history, else its last finished task, else its end date. Kept on its own so
|
|
3531
|
+
* the inbox can ask it without the voyage log's code.
|
|
3532
|
+
*/
|
|
3533
|
+
function finishedOn(project, tasks) {
|
|
3534
|
+
const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
|
|
3535
|
+
if (marked) return marked.at.slice(0, 10);
|
|
3536
|
+
return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3527
3539
|
// src/lib/voyage-log.ts
|
|
3528
3540
|
init_date_fns();
|
|
3529
3541
|
init_blockers();
|
|
@@ -3531,12 +3543,6 @@ init_date_text();
|
|
|
3531
3543
|
init_premortem();
|
|
3532
3544
|
const day$8 = (iso) => parseIso(localDay(iso));
|
|
3533
3545
|
const between = (a, b) => differenceInCalendarDays(day$8(b), day$8(a));
|
|
3534
|
-
/** When the project was marked Done: its last move to Done in its health history. */
|
|
3535
|
-
function finishedOn(project, tasks) {
|
|
3536
|
-
const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
|
|
3537
|
-
if (marked) return marked.at.slice(0, 10);
|
|
3538
|
-
return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
|
|
3539
|
-
}
|
|
3540
3546
|
function voyageLog(data, projectId) {
|
|
3541
3547
|
const project = data.projects.find((p) => p.id === projectId);
|
|
3542
3548
|
if (!project) return null;
|
|
@@ -10560,6 +10566,126 @@ function HistoryView({ active, width, height, go }) {
|
|
|
10560
10566
|
});
|
|
10561
10567
|
}
|
|
10562
10568
|
|
|
10569
|
+
// src/lib/landfall-lines.ts
|
|
10570
|
+
function isLandfallDays(n) {
|
|
10571
|
+
return LANDFALL_DAYS.includes(n);
|
|
10572
|
+
}
|
|
10573
|
+
/** Why a landfall can't be saved as given, in words for the person; null when it can. */
|
|
10574
|
+
function landfallProblem(input) {
|
|
10575
|
+
const expect = clean(input.expect);
|
|
10576
|
+
if (!expect) return "Say what landfall should look like.";
|
|
10577
|
+
if (expect.length > 300) return `Keep it under ${300} characters.`;
|
|
10578
|
+
if (!isLandfallDays(input.afterDays)) return "Pick 30, 90 or 180 days.";
|
|
10579
|
+
if (clean(input.measure).length > 60) return `Name the number in under ${60} characters.`;
|
|
10580
|
+
return null;
|
|
10581
|
+
}
|
|
10582
|
+
/** Why an answer can't be saved as given; null when it can. */
|
|
10583
|
+
function landfallAnswerProblem(input) {
|
|
10584
|
+
if (!LANDFALL_RESULTS.includes(input.result)) return "Say yes, partly or no.";
|
|
10585
|
+
if (input.value != null && (typeof input.value !== "number" || !Number.isFinite(input.value))) return "The number has to be a number.";
|
|
10586
|
+
if (typeof input.value === "number" && Math.abs(input.value) > 0xe8d4a51000) return "That number is too big.";
|
|
10587
|
+
if (clean(input.note).length > 500) return `Keep the note under ${500} characters.`;
|
|
10588
|
+
return null;
|
|
10589
|
+
}
|
|
10590
|
+
/**
|
|
10591
|
+
* The landfall after someone sets or changes what it looks like. An answer
|
|
10592
|
+
* already given stays: changing the words after the fact doesn't unask it.
|
|
10593
|
+
* Throws on bad input, so callers check `landfallProblem` first.
|
|
10594
|
+
*/
|
|
10595
|
+
function withLandfall(current, input, by, at) {
|
|
10596
|
+
const problem = landfallProblem(input);
|
|
10597
|
+
if (problem) throw new Error(problem);
|
|
10598
|
+
const measure = clean(input.measure);
|
|
10599
|
+
return {
|
|
10600
|
+
...current ?? {},
|
|
10601
|
+
expect: clean(input.expect),
|
|
10602
|
+
afterDays: input.afterDays,
|
|
10603
|
+
measure: measure || null,
|
|
10604
|
+
setAt: current?.setAt ?? at,
|
|
10605
|
+
setById: current?.setById ?? by
|
|
10606
|
+
};
|
|
10607
|
+
}
|
|
10608
|
+
/** The landfall with an answer, or with the answer taken back (null) so it asks again. */
|
|
10609
|
+
function withLandfallAnswer(current, answer, by, at) {
|
|
10610
|
+
if (!answer) {
|
|
10611
|
+
const { result: _r, value: _v, note: _n, answeredAt: _a, answeredById: _b, ...rest } = current;
|
|
10612
|
+
return rest;
|
|
10613
|
+
}
|
|
10614
|
+
const problem = landfallAnswerProblem(answer);
|
|
10615
|
+
if (problem) throw new Error(problem);
|
|
10616
|
+
const note = clean(answer.note);
|
|
10617
|
+
return {
|
|
10618
|
+
...current,
|
|
10619
|
+
result: answer.result,
|
|
10620
|
+
value: answer.value ?? null,
|
|
10621
|
+
note: note || null,
|
|
10622
|
+
answeredAt: at,
|
|
10623
|
+
answeredById: by
|
|
10624
|
+
};
|
|
10625
|
+
}
|
|
10626
|
+
var LANDFALL_DAYS, LANDFALL_EXPECT_MAX, LANDFALL_MEASURE_MAX, LANDFALL_NOTE_MAX, LANDFALL_RESULTS, clean;
|
|
10627
|
+
var init_landfall_lines = __esmMin((() => {
|
|
10628
|
+
LANDFALL_DAYS = [
|
|
10629
|
+
30,
|
|
10630
|
+
90,
|
|
10631
|
+
180
|
|
10632
|
+
];
|
|
10633
|
+
LANDFALL_EXPECT_MAX = 300;
|
|
10634
|
+
LANDFALL_MEASURE_MAX = 60;
|
|
10635
|
+
LANDFALL_NOTE_MAX = 500;
|
|
10636
|
+
LANDFALL_RESULTS = [
|
|
10637
|
+
"yes",
|
|
10638
|
+
"partly",
|
|
10639
|
+
"no"
|
|
10640
|
+
];
|
|
10641
|
+
clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
10642
|
+
}));
|
|
10643
|
+
|
|
10644
|
+
// src/lib/landfall.ts
|
|
10645
|
+
init_date_fns();
|
|
10646
|
+
init_date_text();
|
|
10647
|
+
function plusDays(iso, days) {
|
|
10648
|
+
return addDays(parseIso(iso.slice(0, 10)), days).toISOString().slice(0, 10);
|
|
10649
|
+
}
|
|
10650
|
+
/** The day to look: that many days after the project was marked Done. */
|
|
10651
|
+
function landfallCheckOn(project, tasks) {
|
|
10652
|
+
if (!project.landfall) return null;
|
|
10653
|
+
return plusDays(finishedOn(project, tasks), project.landfall.afterDays);
|
|
10654
|
+
}
|
|
10655
|
+
function landfallState(project, tasks, today) {
|
|
10656
|
+
const lf = project.landfall;
|
|
10657
|
+
if (lf?.result) return "answered";
|
|
10658
|
+
if (project.health !== "done") return "sailing";
|
|
10659
|
+
if (!lf) return "unset";
|
|
10660
|
+
const on = landfallCheckOn(project, tasks);
|
|
10661
|
+
return on && on <= today ? "due" : "waiting";
|
|
10662
|
+
}
|
|
10663
|
+
/** Who is asked: the project's owner, else (their person gone) the program's. */
|
|
10664
|
+
function landfallAskee(data, project) {
|
|
10665
|
+
const here = (id) => id ? data.people.find((p) => p.id === id) : void 0;
|
|
10666
|
+
return here(project.ownerId) ?? here(data.programs.find((p) => p.id === project.programId)?.ownerId);
|
|
10667
|
+
}
|
|
10668
|
+
/** A project's landfall as agents and the CLI's --json read it; null while there's none. */
|
|
10669
|
+
function landfallJson(project, tasks, today, people) {
|
|
10670
|
+
const lf = project.landfall;
|
|
10671
|
+
if (!lf) return null;
|
|
10672
|
+
const answered = Boolean(lf.result);
|
|
10673
|
+
return {
|
|
10674
|
+
expect: lf.expect,
|
|
10675
|
+
afterDays: lf.afterDays,
|
|
10676
|
+
measure: lf.measure ?? null,
|
|
10677
|
+
state: landfallState(project, tasks, today),
|
|
10678
|
+
checkOn: project.health === "done" ? landfallCheckOn(project, tasks) : null,
|
|
10679
|
+
result: lf.result ?? null,
|
|
10680
|
+
...answered ? {
|
|
10681
|
+
value: lf.value ?? null,
|
|
10682
|
+
note: lf.note ?? null,
|
|
10683
|
+
answeredOn: lf.answeredAt?.slice(0, 10) ?? null,
|
|
10684
|
+
answeredBy: people.find((p) => p.id === lf.answeredById)?.name ?? null
|
|
10685
|
+
} : {}
|
|
10686
|
+
};
|
|
10687
|
+
}
|
|
10688
|
+
|
|
10563
10689
|
// src/lib/project-messages.ts
|
|
10564
10690
|
/** This project's messages, oldest first. */
|
|
10565
10691
|
function messagesOf(reflections, projectId) {
|
|
@@ -10682,6 +10808,7 @@ const KIND_RANK = {
|
|
|
10682
10808
|
waiting: 0,
|
|
10683
10809
|
approval: 1,
|
|
10684
10810
|
review: 2,
|
|
10811
|
+
landfall: 2.5,
|
|
10685
10812
|
sounding: 3,
|
|
10686
10813
|
assigned: 4,
|
|
10687
10814
|
idle: 5,
|
|
@@ -10694,6 +10821,7 @@ const PING_KIND_LABEL = {
|
|
|
10694
10821
|
waiting: "Waiting on you",
|
|
10695
10822
|
approval: "Sign-offs",
|
|
10696
10823
|
review: "Reviews due",
|
|
10824
|
+
landfall: "Landfall",
|
|
10697
10825
|
sounding: "Soundings",
|
|
10698
10826
|
assigned: "Assigned to you",
|
|
10699
10827
|
idle: "Quiet blockers",
|
|
@@ -10827,6 +10955,41 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
10827
10955
|
personId: null
|
|
10828
10956
|
});
|
|
10829
10957
|
}
|
|
10958
|
+
const askSince = todayISO(subDays(now, 14));
|
|
10959
|
+
for (const project of data.projects) {
|
|
10960
|
+
if (project.health !== "done" && !project.landfall?.result) continue;
|
|
10961
|
+
if (landfallAskee(data, project)?.id !== personId) continue;
|
|
10962
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
10963
|
+
const state = landfallState(project, tasks, today);
|
|
10964
|
+
if (state === "unset") {
|
|
10965
|
+
const finished = finishedOn(project, tasks);
|
|
10966
|
+
if (finished < askSince) continue;
|
|
10967
|
+
pings.push({
|
|
10968
|
+
id: `landfall-set:${project.id}:${finished}`,
|
|
10969
|
+
kind: "landfall",
|
|
10970
|
+
title: `${project.name} is done. What should landfall look like?`,
|
|
10971
|
+
detail: "Say what should be true in 30, 90 or 180 days, and Harbor asks you then.",
|
|
10972
|
+
at: finished,
|
|
10973
|
+
projectId: project.id,
|
|
10974
|
+
landfall: "set",
|
|
10975
|
+
personId: null,
|
|
10976
|
+
href: `/projects/${project.id}?view=log`
|
|
10977
|
+
});
|
|
10978
|
+
} else if (state === "due") {
|
|
10979
|
+
const on = landfallCheckOn(project, tasks);
|
|
10980
|
+
pings.push({
|
|
10981
|
+
id: `landfall:${project.id}:${on}`,
|
|
10982
|
+
kind: "landfall",
|
|
10983
|
+
title: `Did ${project.name} reach landfall?`,
|
|
10984
|
+
detail: `“${project.landfall.expect}”`,
|
|
10985
|
+
at: on,
|
|
10986
|
+
projectId: project.id,
|
|
10987
|
+
landfall: "check",
|
|
10988
|
+
personId: null,
|
|
10989
|
+
href: `/projects/${project.id}?view=log`
|
|
10990
|
+
});
|
|
10991
|
+
}
|
|
10992
|
+
}
|
|
10830
10993
|
for (const constraint of constraintsOwnedBy(data, personId)) {
|
|
10831
10994
|
if (!constraintIdle(data, constraint)) continue;
|
|
10832
10995
|
const program = data.programs.find((p) => p.id === constraint.programId);
|
|
@@ -22828,7 +22991,7 @@ const HARBOR_TOOLS = [
|
|
|
22828
22991
|
tool({
|
|
22829
22992
|
name: "get_project",
|
|
22830
22993
|
title: "Project details",
|
|
22831
|
-
description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
|
|
22994
|
+
description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), its landfall (what should be true some days after it's Done, when Harbor asks, and the answer: yes, partly or no with a number; null until someone sets one; state is sailing while not Done, unset, waiting, due or answered), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
|
|
22832
22995
|
input: object({
|
|
22833
22996
|
harbor: harborArg,
|
|
22834
22997
|
project: string().min(1).max(200).describe("Project id or name.")
|
|
@@ -22892,6 +23055,7 @@ const HARBOR_TOOLS = [
|
|
|
22892
23055
|
...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
|
|
22893
23056
|
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
|
|
22894
23057
|
...charterOf(p, tasks),
|
|
23058
|
+
landfall: landfallJson(p, tasks, today, data.people),
|
|
22895
23059
|
...notesOf(ctx, p),
|
|
22896
23060
|
...hillOf(p, today),
|
|
22897
23061
|
...webLink(ctx, { project: p.id })
|
|
@@ -25068,6 +25232,20 @@ function hierarchySlice({ get, write }) {
|
|
|
25068
25232
|
charter: withCharter(p.charter, patch, by, stamp())
|
|
25069
25233
|
} : p) });
|
|
25070
25234
|
},
|
|
25235
|
+
setLandfall: (projectId, input) => {
|
|
25236
|
+
const by = get().currentUserId;
|
|
25237
|
+
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
25238
|
+
...p,
|
|
25239
|
+
landfall: input ? withLandfall(p.landfall, input, by, stamp()) : null
|
|
25240
|
+
} : p) });
|
|
25241
|
+
},
|
|
25242
|
+
answerLandfall: (projectId, answer) => {
|
|
25243
|
+
const by = get().currentUserId;
|
|
25244
|
+
write({ projects: get().projects.map((p) => p.id === projectId && p.landfall ? {
|
|
25245
|
+
...p,
|
|
25246
|
+
landfall: withLandfallAnswer(p.landfall, answer, by, stamp())
|
|
25247
|
+
} : p) });
|
|
25248
|
+
},
|
|
25071
25249
|
addCustomField: (projectId, def) => {
|
|
25072
25250
|
const id = uid("cf");
|
|
25073
25251
|
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
@@ -25429,6 +25607,7 @@ var init_hierarchy = __esmMin((() => {
|
|
|
25429
25607
|
init_scuttle_rows();
|
|
25430
25608
|
init_hill();
|
|
25431
25609
|
init_charter_lines();
|
|
25610
|
+
init_landfall_lines();
|
|
25432
25611
|
init_format();
|
|
25433
25612
|
init_empty();
|
|
25434
25613
|
init_first_week();
|
package/lib/cli.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
notesText,
|
|
36
36
|
messagesText,
|
|
37
37
|
charterText,
|
|
38
|
+
landfallText,
|
|
38
39
|
daysText,
|
|
39
40
|
soundingText,
|
|
40
41
|
lifeboatsText,
|
|
@@ -263,6 +264,11 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
263
264
|
a project's five-line charter
|
|
264
265
|
(why, in, out, done when, who
|
|
265
266
|
makes the calls): read or set
|
|
267
|
+
harbor landfall PROJECT [--expect T] [--after 30|90|180] [--measure T]
|
|
268
|
+
[--answer yes|partly|no] [--value N] [--note T]
|
|
269
|
+
did a Done project pay off? say
|
|
270
|
+
what should be true, and answer
|
|
271
|
+
on the day
|
|
266
272
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
267
273
|
a note for later: sealed until
|
|
268
274
|
its day, milestone, or Off track
|
|
@@ -1025,6 +1031,24 @@ const DETAIL = {
|
|
|
1025
1031
|
" --no-people. A task already in its project is left alone: by its key",
|
|
1026
1032
|
" (a Key column, as harbor export writes) or by title.",
|
|
1027
1033
|
],
|
|
1034
|
+
landfall: [
|
|
1035
|
+
" harbor landfall PROJECT reads its landfall: what should be",
|
|
1036
|
+
" true some days after it's Done,",
|
|
1037
|
+
" when Harbor asks, and the answer",
|
|
1038
|
+
" Say what landfall looks like:",
|
|
1039
|
+
' --expect "TEXT" what should be true by then',
|
|
1040
|
+
" --after 30|90|180 days after the project is marked Done to look",
|
|
1041
|
+
" (90 unless you say)",
|
|
1042
|
+
' --measure "TEXT" what the number counts, if there is one',
|
|
1043
|
+
" Answer it (on its day, or sooner):",
|
|
1044
|
+
" --answer yes|partly|no",
|
|
1045
|
+
" --value N the number on the day",
|
|
1046
|
+
' --note "TEXT" what you found',
|
|
1047
|
+
" --clear takes the landfall off (with --answer, only the answer), so it",
|
|
1048
|
+
" asks again. The owner is asked in their Inbox on the day; the portfolio",
|
|
1049
|
+
" counts the voyages that paid off. PROJECT is a name, a key like RR, or",
|
|
1050
|
+
" an id.",
|
|
1051
|
+
],
|
|
1028
1052
|
charter: [
|
|
1029
1053
|
" harbor charter PROJECT reads its charter, and any open task",
|
|
1030
1054
|
" that names something it leaves out",
|
|
@@ -3286,6 +3310,70 @@ export async function run(argv, io = {}) {
|
|
|
3286
3310
|
: `Cleared ${r.project}'s charter.`,
|
|
3287
3311
|
);
|
|
3288
3312
|
}
|
|
3313
|
+
case "landfall": {
|
|
3314
|
+
const { positionals, values, harbor } = parse(
|
|
3315
|
+
spec(["clear"], ["expect", "after", "measure", "answer", "value", "note"]),
|
|
3316
|
+
);
|
|
3317
|
+
const project = one(positionals, 'project: harbor landfall "NAME"');
|
|
3318
|
+
const answering =
|
|
3319
|
+
values.answer !== undefined || values.value !== undefined || values.note !== undefined;
|
|
3320
|
+
const setting =
|
|
3321
|
+
values.expect !== undefined || values.after !== undefined || values.measure !== undefined;
|
|
3322
|
+
if (answering && setting) {
|
|
3323
|
+
throw new HarborError("Set the landfall or answer it, one at a time.");
|
|
3324
|
+
}
|
|
3325
|
+
const show = (r) => landfallText(r.project ?? r.name, r.landfall);
|
|
3326
|
+
const json = (r) => ({ project: r.project ?? r.name, landfall: r.landfall ?? null });
|
|
3327
|
+
if (answering || (values.clear && values.answer !== undefined)) {
|
|
3328
|
+
if (values.clear) {
|
|
3329
|
+
return call("answer_landfall", { harbor, project, clear: true }, show, json);
|
|
3330
|
+
}
|
|
3331
|
+
const result = String(values.answer ?? "").toLowerCase();
|
|
3332
|
+
if (!["yes", "partly", "no"].includes(result)) {
|
|
3333
|
+
throw new HarborError("Say --answer yes, partly or no.");
|
|
3334
|
+
}
|
|
3335
|
+
let value;
|
|
3336
|
+
if (values.value !== undefined) {
|
|
3337
|
+
value = Number(String(values.value).replace(/,/g, ""));
|
|
3338
|
+
if (!Number.isFinite(value)) throw new HarborError("--value takes a number, like 120.");
|
|
3339
|
+
}
|
|
3340
|
+
return call(
|
|
3341
|
+
"answer_landfall",
|
|
3342
|
+
{
|
|
3343
|
+
harbor,
|
|
3344
|
+
project,
|
|
3345
|
+
result,
|
|
3346
|
+
...(value !== undefined ? { value } : {}),
|
|
3347
|
+
...(values.note !== undefined ? { note: values.note } : {}),
|
|
3348
|
+
},
|
|
3349
|
+
show,
|
|
3350
|
+
json,
|
|
3351
|
+
);
|
|
3352
|
+
}
|
|
3353
|
+
if (values.clear) return call("set_landfall", { harbor, project, clear: true }, show, json);
|
|
3354
|
+
if (setting) {
|
|
3355
|
+
let afterDays;
|
|
3356
|
+
if (values.after !== undefined) {
|
|
3357
|
+
afterDays = Number(String(values.after).replace(/\s*days?$/i, ""));
|
|
3358
|
+
if (![30, 90, 180].includes(afterDays)) {
|
|
3359
|
+
throw new HarborError("--after takes 30, 90 or 180 (days after the project is Done).");
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
return call(
|
|
3363
|
+
"set_landfall",
|
|
3364
|
+
{
|
|
3365
|
+
harbor,
|
|
3366
|
+
project,
|
|
3367
|
+
...(values.expect !== undefined ? { expect: values.expect } : {}),
|
|
3368
|
+
...(afterDays !== undefined ? { afterDays } : {}),
|
|
3369
|
+
...(values.measure !== undefined ? { measure: values.measure } : {}),
|
|
3370
|
+
},
|
|
3371
|
+
show,
|
|
3372
|
+
json,
|
|
3373
|
+
);
|
|
3374
|
+
}
|
|
3375
|
+
return call("get_project", { harbor, project }, show, json);
|
|
3376
|
+
}
|
|
3289
3377
|
case "sound": {
|
|
3290
3378
|
const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
|
|
3291
3379
|
const [task, ...extra] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -815,6 +815,34 @@ export function charterText(r) {
|
|
|
815
815
|
return lines.join("\n");
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
+
const LANDFALL_RESULT = { yes: "Paid off", partly: "Partly", no: "Didn't" };
|
|
819
|
+
|
|
820
|
+
/** A project's landfall as get_project, set_landfall or answer_landfall gives it. */
|
|
821
|
+
export function landfallText(project, lf) {
|
|
822
|
+
if (!lf) {
|
|
823
|
+
return `${project} has no landfall yet. Say what should be true some days after it's Done:\n harbor landfall "${project}" --expect "…" --after 90 [--measure "weekly sign-ups"]`;
|
|
824
|
+
}
|
|
825
|
+
const lines = [`Landfall · ${project}`, ` Looks like ${lf.expect}`];
|
|
826
|
+
if (lf.measure) lines.push(` Counting ${lf.measure}`);
|
|
827
|
+
if (lf.result) {
|
|
828
|
+
const value =
|
|
829
|
+
lf.value != null ? ` · ${Number(lf.value).toLocaleString("en-US")}${lf.measure ? ` ${lf.measure}` : ""}` : "";
|
|
830
|
+
lines.push(` Answer ${LANDFALL_RESULT[lf.result] ?? lf.result}${value}`);
|
|
831
|
+
if (lf.note) lines.push(` ${lf.note}`);
|
|
832
|
+
if (lf.answeredOn)
|
|
833
|
+
lines.push(` said by ${lf.answeredBy ?? "someone"} on ${lf.answeredOn}`);
|
|
834
|
+
} else if (lf.state === "due") {
|
|
835
|
+
lines.push(
|
|
836
|
+
` Due now its day was ${lf.checkOn}. Answer: harbor landfall "${project}" --answer yes|partly|no [--value N]`,
|
|
837
|
+
);
|
|
838
|
+
} else if (lf.state === "waiting") {
|
|
839
|
+
lines.push(` Check on ${lf.checkOn} (${lf.afterDays} days after Done)`);
|
|
840
|
+
} else {
|
|
841
|
+
lines.push(` Check ${lf.afterDays} days after the project is marked Done`);
|
|
842
|
+
}
|
|
843
|
+
return lines.join("\n");
|
|
844
|
+
}
|
|
845
|
+
|
|
818
846
|
export function notesText(project, notes) {
|
|
819
847
|
if (!notes?.length)
|
|
820
848
|
return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
|