@harborppm/cli 0.79.0 → 0.81.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 +494 -54
- package/lib/cli.mjs +51 -8
- package/lib/format.mjs +24 -0
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -5913,7 +5913,7 @@ const STATUS_ORDER = [
|
|
|
5913
5913
|
"review",
|
|
5914
5914
|
"done"
|
|
5915
5915
|
];
|
|
5916
|
-
const PRIORITIES$
|
|
5916
|
+
const PRIORITIES$2 = [
|
|
5917
5917
|
"low",
|
|
5918
5918
|
"medium",
|
|
5919
5919
|
"high",
|
|
@@ -5938,7 +5938,7 @@ function isLate(task, today) {
|
|
|
5938
5938
|
function myOpenTasks(data, personId, today) {
|
|
5939
5939
|
const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
5940
5940
|
const rank = (t) => isLate(t, today) ? 0 : t.dueDate ? 1 : 2;
|
|
5941
|
-
return mine.sort((a, b) => rank(a) - rank(b) || (a.dueDate ?? "").localeCompare(b.dueDate ?? "") || PRIORITIES$
|
|
5941
|
+
return mine.sort((a, b) => rank(a) - rank(b) || (a.dueDate ?? "").localeCompare(b.dueDate ?? "") || PRIORITIES$2.indexOf(b.priority) - PRIORITIES$2.indexOf(a.priority));
|
|
5942
5942
|
}
|
|
5943
5943
|
/** Others' open tasks that wait on one of this person's. */
|
|
5944
5944
|
function waitingOnMe(data, personId) {
|
|
@@ -9975,6 +9975,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
9975
9975
|
detail: `${r.kind === "issue" ? "Issue" : "Risk"}${where ? ` in ${where}` : ""} · its review date has passed`,
|
|
9976
9976
|
at: r.dueDate,
|
|
9977
9977
|
href: `/risks/${r.id}`,
|
|
9978
|
+
raidId: r.id,
|
|
9978
9979
|
personId: null
|
|
9979
9980
|
});
|
|
9980
9981
|
}
|
|
@@ -10064,6 +10065,7 @@ function InboxView({ active, width, height, go }) {
|
|
|
10064
10065
|
const open = (p) => {
|
|
10065
10066
|
store.getState().readPing(p.id);
|
|
10066
10067
|
if (p.taskId) go.openTask(p.taskId);
|
|
10068
|
+
else if (p.raidId) go.openPanel("raid", p.raidId);
|
|
10067
10069
|
else if (p.programId) go.openProgram(p.programId);
|
|
10068
10070
|
};
|
|
10069
10071
|
useInput((input, key) => {
|
|
@@ -13235,6 +13237,7 @@ const PAGES = {
|
|
|
13235
13237
|
projects: "/projects",
|
|
13236
13238
|
risks: "/risks",
|
|
13237
13239
|
goals: "/goals",
|
|
13240
|
+
reports: "/reports",
|
|
13238
13241
|
calendar: "/calendar",
|
|
13239
13242
|
people: "/people"
|
|
13240
13243
|
};
|
|
@@ -13759,6 +13762,269 @@ function keyResultText(kr, work) {
|
|
|
13759
13762
|
}
|
|
13760
13763
|
const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
|
|
13761
13764
|
|
|
13765
|
+
// src/lib/reports.ts
|
|
13766
|
+
init_date_fns();
|
|
13767
|
+
init_date_text();
|
|
13768
|
+
init_indexes();
|
|
13769
|
+
init_swatches();
|
|
13770
|
+
/** Weeks a "by week" chart shows: back from this week, or ahead for due dates. */
|
|
13771
|
+
const WEEKS_BACK = 12;
|
|
13772
|
+
const WEEKS_AHEAD = 8;
|
|
13773
|
+
/** Bars past this many are summed into "Everything else". */
|
|
13774
|
+
const MAX_BARS = 12;
|
|
13775
|
+
const GROUPS = [
|
|
13776
|
+
"status",
|
|
13777
|
+
"owner",
|
|
13778
|
+
"priority",
|
|
13779
|
+
"project",
|
|
13780
|
+
"program",
|
|
13781
|
+
"week"
|
|
13782
|
+
];
|
|
13783
|
+
const MEASURE_LABEL = {
|
|
13784
|
+
open: "Open tasks",
|
|
13785
|
+
done: "Finished tasks",
|
|
13786
|
+
all: "All tasks",
|
|
13787
|
+
overdue: "Overdue tasks",
|
|
13788
|
+
hours: "Hours logged"
|
|
13789
|
+
};
|
|
13790
|
+
const GROUP_LABEL = {
|
|
13791
|
+
status: "Status",
|
|
13792
|
+
owner: "Owner",
|
|
13793
|
+
priority: "Priority",
|
|
13794
|
+
project: "Project",
|
|
13795
|
+
program: "Program",
|
|
13796
|
+
week: "Week"
|
|
13797
|
+
};
|
|
13798
|
+
/** The splits a measure can take: hours have no status or priority of their own. */
|
|
13799
|
+
function groupsFor(measure) {
|
|
13800
|
+
return measure === "hours" ? [
|
|
13801
|
+
"owner",
|
|
13802
|
+
"project",
|
|
13803
|
+
"program",
|
|
13804
|
+
"week"
|
|
13805
|
+
] : GROUPS;
|
|
13806
|
+
}
|
|
13807
|
+
const STATUS_FILL = {
|
|
13808
|
+
backlog: "var(--color-swatch-slate)",
|
|
13809
|
+
todo: "var(--color-swatch-sand)",
|
|
13810
|
+
doing: "var(--color-swatch-sea)",
|
|
13811
|
+
review: "var(--color-swatch-rust)",
|
|
13812
|
+
done: "var(--color-swatch-moss)"
|
|
13813
|
+
};
|
|
13814
|
+
const PRIORITY_FILL = {
|
|
13815
|
+
low: "var(--color-swatch-slate)",
|
|
13816
|
+
medium: "var(--color-swatch-sea)",
|
|
13817
|
+
high: "var(--color-swatch-sand)",
|
|
13818
|
+
urgent: "var(--color-swatch-coral)"
|
|
13819
|
+
};
|
|
13820
|
+
const PRIORITIES$1 = [
|
|
13821
|
+
"urgent",
|
|
13822
|
+
"high",
|
|
13823
|
+
"medium",
|
|
13824
|
+
"low"
|
|
13825
|
+
];
|
|
13826
|
+
/** Colours handed out in turn where a slice has none of its own. */
|
|
13827
|
+
const CYCLE = [
|
|
13828
|
+
"sea",
|
|
13829
|
+
"sand",
|
|
13830
|
+
"moss",
|
|
13831
|
+
"rust",
|
|
13832
|
+
"slate",
|
|
13833
|
+
"pine",
|
|
13834
|
+
"clay",
|
|
13835
|
+
"sage"
|
|
13836
|
+
];
|
|
13837
|
+
const swatchFill = (s) => `var(--color-swatch-${s})`;
|
|
13838
|
+
/** The Monday on or before a day or moment (ISO, read in local time), or "" when it isn't a date. */
|
|
13839
|
+
function mondayOf(day) {
|
|
13840
|
+
const d = parseIso(day.length > 10 ? day : `${day}T12:00:00`);
|
|
13841
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
13842
|
+
const back = (d.getDay() + 6) % 7;
|
|
13843
|
+
return format(addDays(d, -back), "yyyy-MM-dd");
|
|
13844
|
+
}
|
|
13845
|
+
/** The projects a chart reads: everything, or what sits under its scope. */
|
|
13846
|
+
function scopeOf(data, scopeId) {
|
|
13847
|
+
if (!scopeId) return {
|
|
13848
|
+
projectIds: null,
|
|
13849
|
+
name: "Whole harbor",
|
|
13850
|
+
found: true
|
|
13851
|
+
};
|
|
13852
|
+
const project = byId(data.projects).get(scopeId);
|
|
13853
|
+
if (project) return {
|
|
13854
|
+
projectIds: /* @__PURE__ */ new Set([project.id]),
|
|
13855
|
+
name: project.name,
|
|
13856
|
+
found: true
|
|
13857
|
+
};
|
|
13858
|
+
const program = byId(data.programs).get(scopeId);
|
|
13859
|
+
if (program) {
|
|
13860
|
+
const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
|
|
13861
|
+
return {
|
|
13862
|
+
projectIds: new Set(ids),
|
|
13863
|
+
name: program.name,
|
|
13864
|
+
found: true
|
|
13865
|
+
};
|
|
13866
|
+
}
|
|
13867
|
+
const portfolio = byId(data.portfolios).get(scopeId);
|
|
13868
|
+
if (portfolio) {
|
|
13869
|
+
const programIds = new Set(data.programs.filter((p) => p.portfolioId === portfolio.id).map((p) => p.id));
|
|
13870
|
+
const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
|
|
13871
|
+
return {
|
|
13872
|
+
projectIds: new Set(ids),
|
|
13873
|
+
name: portfolio.name,
|
|
13874
|
+
found: true
|
|
13875
|
+
};
|
|
13876
|
+
}
|
|
13877
|
+
return {
|
|
13878
|
+
projectIds: /* @__PURE__ */ new Set(),
|
|
13879
|
+
name: "Deleted",
|
|
13880
|
+
found: false
|
|
13881
|
+
};
|
|
13882
|
+
}
|
|
13883
|
+
/**
|
|
13884
|
+
* What a chart shows today: its slices in a fixed order (statuses and
|
|
13885
|
+
* priorities in board order, weeks in time order, the rest largest first,
|
|
13886
|
+
* past MAX_BARS summed into "Everything else"), with their total.
|
|
13887
|
+
*/
|
|
13888
|
+
function chartData(chart, data, today) {
|
|
13889
|
+
const scope = scopeOf(data, chart.scopeId);
|
|
13890
|
+
const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
|
|
13891
|
+
const unit = chart.measure === "hours" ? "hours" : "tasks";
|
|
13892
|
+
const groupBy = groupsFor(chart.measure).includes(chart.groupBy) ? chart.groupBy : "owner";
|
|
13893
|
+
const items = [];
|
|
13894
|
+
for (const task of data.tasks) {
|
|
13895
|
+
if (!inScope(task)) continue;
|
|
13896
|
+
const done = task.status === "done";
|
|
13897
|
+
switch (chart.measure) {
|
|
13898
|
+
case "open":
|
|
13899
|
+
if (!done) items.push({
|
|
13900
|
+
task,
|
|
13901
|
+
amount: 1,
|
|
13902
|
+
day: task.dueDate
|
|
13903
|
+
});
|
|
13904
|
+
break;
|
|
13905
|
+
case "done":
|
|
13906
|
+
if (done) items.push({
|
|
13907
|
+
task,
|
|
13908
|
+
amount: 1,
|
|
13909
|
+
day: task.completedAt
|
|
13910
|
+
});
|
|
13911
|
+
break;
|
|
13912
|
+
case "all":
|
|
13913
|
+
items.push({
|
|
13914
|
+
task,
|
|
13915
|
+
amount: 1,
|
|
13916
|
+
day: task.createdAt
|
|
13917
|
+
});
|
|
13918
|
+
break;
|
|
13919
|
+
case "overdue":
|
|
13920
|
+
if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) items.push({
|
|
13921
|
+
task,
|
|
13922
|
+
amount: 1,
|
|
13923
|
+
day: task.dueDate
|
|
13924
|
+
});
|
|
13925
|
+
break;
|
|
13926
|
+
case "hours": for (const entry of task.time ?? []) items.push({
|
|
13927
|
+
task,
|
|
13928
|
+
amount: entry.minutes / 60,
|
|
13929
|
+
day: entry.on,
|
|
13930
|
+
personId: entry.personId
|
|
13931
|
+
});
|
|
13932
|
+
}
|
|
13933
|
+
}
|
|
13934
|
+
const projects = byId(data.projects);
|
|
13935
|
+
const programs = byId(data.programs);
|
|
13936
|
+
const people = byId(data.people);
|
|
13937
|
+
const sums = /* @__PURE__ */ new Map();
|
|
13938
|
+
const add = (key, amount) => sums.set(key, (sums.get(key) ?? 0) + amount);
|
|
13939
|
+
let weeks = [];
|
|
13940
|
+
if (groupBy === "week") {
|
|
13941
|
+
const thisWeek = mondayOf(today);
|
|
13942
|
+
const ahead = chart.measure === "open";
|
|
13943
|
+
const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
|
|
13944
|
+
const count = ahead ? 8 : 12;
|
|
13945
|
+
const start = parseIso(`${first}T12:00:00`);
|
|
13946
|
+
weeks = Array.from({ length: count }, (_, i) => format(addDays(start, 7 * i), "yyyy-MM-dd"));
|
|
13947
|
+
const known = new Set(weeks);
|
|
13948
|
+
for (const item of items) {
|
|
13949
|
+
if (!item.day) continue;
|
|
13950
|
+
const week = mondayOf(item.day);
|
|
13951
|
+
if (known.has(week)) add(week, item.amount);
|
|
13952
|
+
else if (ahead && week < first) add(first, item.amount);
|
|
13953
|
+
}
|
|
13954
|
+
} else for (const item of items) {
|
|
13955
|
+
const t = item.task;
|
|
13956
|
+
add(groupBy === "status" ? t.status : groupBy === "priority" ? t.priority : groupBy === "project" ? t.projectId : groupBy === "program" ? projects.get(t.projectId)?.programId ?? "" : (chart.measure === "hours" ? item.personId : t.assigneeId) ?? "", item.amount);
|
|
13957
|
+
}
|
|
13958
|
+
const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
|
|
13959
|
+
let slices;
|
|
13960
|
+
if (groupBy === "week") slices = weeks.map((w) => ({
|
|
13961
|
+
key: w,
|
|
13962
|
+
label: format(parseIso(`${w}T12:00:00`), "MMM d"),
|
|
13963
|
+
value: round(sums.get(w) ?? 0),
|
|
13964
|
+
fill: "var(--color-primary)"
|
|
13965
|
+
}));
|
|
13966
|
+
else if (groupBy === "status") slices = TASK_COLUMNS.filter((s) => sums.has(s)).map((s) => ({
|
|
13967
|
+
key: s,
|
|
13968
|
+
label: TASK_STATUS_LABEL[s],
|
|
13969
|
+
value: round(sums.get(s)),
|
|
13970
|
+
fill: STATUS_FILL[s]
|
|
13971
|
+
}));
|
|
13972
|
+
else if (groupBy === "priority") slices = PRIORITIES$1.filter((p) => sums.has(p)).map((p) => ({
|
|
13973
|
+
key: p,
|
|
13974
|
+
label: PRIORITY_LABEL[p],
|
|
13975
|
+
value: round(sums.get(p)),
|
|
13976
|
+
fill: PRIORITY_FILL[p]
|
|
13977
|
+
}));
|
|
13978
|
+
else {
|
|
13979
|
+
const named = [...sums.entries()].map(([key, value], i) => {
|
|
13980
|
+
if (groupBy === "owner") {
|
|
13981
|
+
const person = key ? people.get(key) : void 0;
|
|
13982
|
+
return {
|
|
13983
|
+
key: key || "nobody",
|
|
13984
|
+
label: key ? person?.name ?? "Former member" : "Nobody",
|
|
13985
|
+
value: round(value),
|
|
13986
|
+
fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
|
|
13987
|
+
};
|
|
13988
|
+
}
|
|
13989
|
+
const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
|
|
13990
|
+
return {
|
|
13991
|
+
key: key || "none",
|
|
13992
|
+
label: thing?.name ?? "Deleted",
|
|
13993
|
+
value: round(value),
|
|
13994
|
+
fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
|
|
13995
|
+
};
|
|
13996
|
+
});
|
|
13997
|
+
named.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
|
|
13998
|
+
if (named.length > 12) {
|
|
13999
|
+
const rest = named.slice(11);
|
|
14000
|
+
slices = [...named.slice(0, 11), {
|
|
14001
|
+
key: "rest",
|
|
14002
|
+
label: `Everything else (${rest.length})`,
|
|
14003
|
+
value: round(rest.reduce((n, s) => n + s.value, 0)),
|
|
14004
|
+
fill: "var(--color-swatch-slate)"
|
|
14005
|
+
}];
|
|
14006
|
+
} else slices = named;
|
|
14007
|
+
}
|
|
14008
|
+
const total = round(slices.reduce((n, s) => n + s.value, 0));
|
|
14009
|
+
return {
|
|
14010
|
+
slices,
|
|
14011
|
+
total,
|
|
14012
|
+
unit,
|
|
14013
|
+
scopeName: scope.name,
|
|
14014
|
+
scopeFound: scope.found
|
|
14015
|
+
};
|
|
14016
|
+
}
|
|
14017
|
+
/** "12 tasks", "1 task", "7.5 hours". */
|
|
14018
|
+
function amountText(value, unit) {
|
|
14019
|
+
if (unit === "hours") return `${value} ${value === 1 ? "hour" : "hours"}`;
|
|
14020
|
+
return `${value} ${value === 1 ? "task" : "tasks"}`;
|
|
14021
|
+
}
|
|
14022
|
+
/** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
|
|
14023
|
+
function chartText(title, result) {
|
|
14024
|
+
if (result.slices.length === 0) return `${title}: nothing yet`;
|
|
14025
|
+
return `${title}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
|
|
14026
|
+
}
|
|
14027
|
+
|
|
13762
14028
|
// node_modules/zod/v4/core/util.js
|
|
13763
14029
|
function getEnumValues(entries) {
|
|
13764
14030
|
const numericValues = Object.values(entries).filter((v) => typeof v === "number");
|
|
@@ -18956,7 +19222,7 @@ init_blockers();
|
|
|
18956
19222
|
init_format();
|
|
18957
19223
|
init_indexes();
|
|
18958
19224
|
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
18959
|
-
const days$
|
|
19225
|
+
const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
|
|
18960
19226
|
const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
|
|
18961
19227
|
const open = (t) => t.status !== "done";
|
|
18962
19228
|
/** True when `a` finishes later than `b`, or as late behind a longer line. */
|
|
@@ -19023,7 +19289,7 @@ function linksOf(line, walk, target, today) {
|
|
|
19023
19289
|
task,
|
|
19024
19290
|
finish,
|
|
19025
19291
|
late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
|
|
19026
|
-
slack: against ? days$
|
|
19292
|
+
slack: against ? days$2(finish, against) : null
|
|
19027
19293
|
};
|
|
19028
19294
|
});
|
|
19029
19295
|
}
|
|
@@ -19035,7 +19301,7 @@ function chainOf(line, walk, target, today) {
|
|
|
19035
19301
|
links,
|
|
19036
19302
|
finish,
|
|
19037
19303
|
target,
|
|
19038
|
-
over: target ? days$
|
|
19304
|
+
over: target ? days$2(target, finish) : null,
|
|
19039
19305
|
undated: 0
|
|
19040
19306
|
};
|
|
19041
19307
|
}
|
|
@@ -19116,17 +19382,17 @@ function milestonePole(milestone, data, now) {
|
|
|
19116
19382
|
const others = runs.slice(1).map((r) => ({
|
|
19117
19383
|
project: r.project,
|
|
19118
19384
|
finish: r.finish,
|
|
19119
|
-
slack: days$
|
|
19385
|
+
slack: days$2(r.finish, due)
|
|
19120
19386
|
}));
|
|
19121
19387
|
return {
|
|
19122
19388
|
kind: "chain",
|
|
19123
19389
|
links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
|
|
19124
19390
|
...l,
|
|
19125
|
-
slack: days$
|
|
19391
|
+
slack: days$2(l.finish, due)
|
|
19126
19392
|
} : l) : [],
|
|
19127
19393
|
finish: first.finish,
|
|
19128
19394
|
target: due,
|
|
19129
|
-
over: days$
|
|
19395
|
+
over: days$2(due, first.finish),
|
|
19130
19396
|
via: {
|
|
19131
19397
|
project: first.project,
|
|
19132
19398
|
finish: first.finish
|
|
@@ -19209,7 +19475,8 @@ function pickData(s) {
|
|
|
19209
19475
|
spendEntries: s.spendEntries ?? NONE,
|
|
19210
19476
|
reflections: s.reflections ?? NONE,
|
|
19211
19477
|
priorityRules: s.priorityRules ?? null,
|
|
19212
|
-
workCalendar: s.workCalendar ?? null
|
|
19478
|
+
workCalendar: s.workCalendar ?? null,
|
|
19479
|
+
dashboards: s.dashboards ?? NONE
|
|
19213
19480
|
};
|
|
19214
19481
|
}
|
|
19215
19482
|
/** The document as sent to the server: normalized and without inline file data. */
|
|
@@ -21435,6 +21702,67 @@ const HARBOR_TOOLS = [
|
|
|
21435
21702
|
})) };
|
|
21436
21703
|
}
|
|
21437
21704
|
}),
|
|
21705
|
+
tool({
|
|
21706
|
+
name: "list_dashboards",
|
|
21707
|
+
title: "Dashboards",
|
|
21708
|
+
description: "The harbor's dashboards from the Reports page, each chart with what it counts (open, finished, overdue or all tasks, or hours logged), how it's split (status, owner, priority, project, program or week), the part of the harbor it reads, and its numbers as of today, with a one-line text of each." + DATA_NOTE,
|
|
21709
|
+
input: object({
|
|
21710
|
+
harbor: harborArg,
|
|
21711
|
+
dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
|
|
21712
|
+
}),
|
|
21713
|
+
run: (ctx, input) => {
|
|
21714
|
+
const { data } = ctx;
|
|
21715
|
+
const today = todayISO(ctx.now);
|
|
21716
|
+
const all = data.dashboards ?? [];
|
|
21717
|
+
return { dashboards: (input.dashboard ? [findOne(all, input.dashboard, "dashboard")] : all).map((d) => ({
|
|
21718
|
+
id: d.id,
|
|
21719
|
+
name: d.name,
|
|
21720
|
+
charts: d.charts.map((c) => {
|
|
21721
|
+
const result = chartData(c, data, today);
|
|
21722
|
+
return {
|
|
21723
|
+
id: c.id,
|
|
21724
|
+
title: c.title,
|
|
21725
|
+
counts: MEASURE_LABEL[c.measure],
|
|
21726
|
+
splitBy: GROUP_LABEL[c.groupBy],
|
|
21727
|
+
view: c.view,
|
|
21728
|
+
from: result.scopeName,
|
|
21729
|
+
total: amountText(result.total, result.unit),
|
|
21730
|
+
values: result.slices.map((s) => ({
|
|
21731
|
+
label: s.label,
|
|
21732
|
+
value: s.value
|
|
21733
|
+
})),
|
|
21734
|
+
text: chartText(c.title, result)
|
|
21735
|
+
};
|
|
21736
|
+
})
|
|
21737
|
+
})) };
|
|
21738
|
+
}
|
|
21739
|
+
}),
|
|
21740
|
+
tool({
|
|
21741
|
+
name: "list_task_templates",
|
|
21742
|
+
title: "Task templates",
|
|
21743
|
+
description: "A project's task templates (or every project's), to start new tasks from with create_task's template: each one's name, the title and notes it gives, its priority, owner, checklist and how many days after it's made the task is due." + DATA_NOTE,
|
|
21744
|
+
input: object({
|
|
21745
|
+
harbor: harborArg,
|
|
21746
|
+
project: string().max(200).optional().describe("Project id or name. Default: all.")
|
|
21747
|
+
}),
|
|
21748
|
+
run: (ctx, input) => {
|
|
21749
|
+
const { data } = ctx;
|
|
21750
|
+
const projects = input.project ? [findOne(data.projects, input.project, "project")] : data.projects.filter((p) => p.taskTemplates?.length);
|
|
21751
|
+
const name = (id) => id ? data.people.find((p) => p.id === id)?.name ?? null : null;
|
|
21752
|
+
return { templates: projects.flatMap((p) => (p.taskTemplates ?? []).map((t) => ({
|
|
21753
|
+
id: t.id,
|
|
21754
|
+
name: t.name,
|
|
21755
|
+
project: p.name,
|
|
21756
|
+
title: t.title,
|
|
21757
|
+
notes: t.description || void 0,
|
|
21758
|
+
priority: t.priority,
|
|
21759
|
+
owner: name(t.assigneeId),
|
|
21760
|
+
alsoOn: (t.followerIds ?? []).map(name).filter(Boolean),
|
|
21761
|
+
checklist: t.checklist,
|
|
21762
|
+
dueInDays: t.dueInDays
|
|
21763
|
+
}))) };
|
|
21764
|
+
}
|
|
21765
|
+
}),
|
|
21438
21766
|
tool({
|
|
21439
21767
|
name: "long_pole",
|
|
21440
21768
|
title: "What's holding this up",
|
|
@@ -22246,7 +22574,8 @@ var init_undo = __esmMin((() => {
|
|
|
22246
22574
|
"attachments",
|
|
22247
22575
|
"checkIns",
|
|
22248
22576
|
"spendEntries",
|
|
22249
|
-
"reflections"
|
|
22577
|
+
"reflections",
|
|
22578
|
+
"dashboards"
|
|
22250
22579
|
];
|
|
22251
22580
|
FOLD_MS = 5e3;
|
|
22252
22581
|
LEAD = [
|
|
@@ -22613,6 +22942,9 @@ function hierarchySlice({ get, write }) {
|
|
|
22613
22942
|
...patch
|
|
22614
22943
|
} : p) });
|
|
22615
22944
|
},
|
|
22945
|
+
setDashboards: (dashboards) => {
|
|
22946
|
+
write({ dashboards });
|
|
22947
|
+
},
|
|
22616
22948
|
updateProgram: (id, patch) => {
|
|
22617
22949
|
write({ programs: get().programs.map((p) => p.id === id ? {
|
|
22618
22950
|
...p,
|
|
@@ -22942,6 +23274,16 @@ var init_github_refs = __esmMin((() => {
|
|
|
22942
23274
|
}));
|
|
22943
23275
|
|
|
22944
23276
|
// src/lib/automations.ts
|
|
23277
|
+
/** Whether a task meets the rule's "only if" line. */
|
|
23278
|
+
function meetsCondition(rule, task) {
|
|
23279
|
+
const c = rule.onlyIf;
|
|
23280
|
+
if (!c) return true;
|
|
23281
|
+
if (c.priority && task.priority !== c.priority) return false;
|
|
23282
|
+
if (c.assignee) {
|
|
23283
|
+
if (c.assignee === "nobody" ? task.assigneeId != null : task.assigneeId !== c.assignee) return false;
|
|
23284
|
+
}
|
|
23285
|
+
return true;
|
|
23286
|
+
}
|
|
22945
23287
|
/**
|
|
22946
23288
|
* Checked once, against the (before, after) pair of a single user-driven
|
|
22947
23289
|
* mutation — never re-checked against an automation's own effect. That's the
|
|
@@ -22959,6 +23301,64 @@ function matches(rule, prev, next, created) {
|
|
|
22959
23301
|
default: return false;
|
|
22960
23302
|
}
|
|
22961
23303
|
}
|
|
23304
|
+
/** One rule's actions on one task, in order. */
|
|
23305
|
+
function runActions(actions, start, ctx) {
|
|
23306
|
+
let task = start;
|
|
23307
|
+
const comments = [];
|
|
23308
|
+
for (const action of actions) switch (action.type) {
|
|
23309
|
+
case "set_status": {
|
|
23310
|
+
if (!action.status) break;
|
|
23311
|
+
const wasDone = task.status === "done";
|
|
23312
|
+
const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
|
|
23313
|
+
task = {
|
|
23314
|
+
...task,
|
|
23315
|
+
status: action.status,
|
|
23316
|
+
completedAt
|
|
23317
|
+
};
|
|
23318
|
+
break;
|
|
23319
|
+
}
|
|
23320
|
+
case "set_priority":
|
|
23321
|
+
if (action.priority) task = {
|
|
23322
|
+
...task,
|
|
23323
|
+
priority: action.priority
|
|
23324
|
+
};
|
|
23325
|
+
break;
|
|
23326
|
+
case "set_assignee":
|
|
23327
|
+
task = {
|
|
23328
|
+
...task,
|
|
23329
|
+
assigneeId: action.assigneeId ?? null
|
|
23330
|
+
};
|
|
23331
|
+
break;
|
|
23332
|
+
case "set_due": {
|
|
23333
|
+
const n = Math.min(365, Math.max(0, Math.round(action.inDays ?? 0)));
|
|
23334
|
+
task = {
|
|
23335
|
+
...task,
|
|
23336
|
+
dueDate: dayAfter(ctx.today, n)
|
|
23337
|
+
};
|
|
23338
|
+
break;
|
|
23339
|
+
}
|
|
23340
|
+
case "add_follower": {
|
|
23341
|
+
const id = action.personId;
|
|
23342
|
+
if (!id || id === task.assigneeId || task.followerIds?.includes(id)) break;
|
|
23343
|
+
task = {
|
|
23344
|
+
...task,
|
|
23345
|
+
followerIds: [...task.followerIds ?? [], id]
|
|
23346
|
+
};
|
|
23347
|
+
break;
|
|
23348
|
+
}
|
|
23349
|
+
case "add_comment": if (action.commentBody?.trim()) comments.push({
|
|
23350
|
+
taskId: task.id,
|
|
23351
|
+
authorId: ctx.authorId,
|
|
23352
|
+
body: action.commentBody.trim(),
|
|
23353
|
+
createdAt: ctx.now,
|
|
23354
|
+
mentionedIds: []
|
|
23355
|
+
});
|
|
23356
|
+
}
|
|
23357
|
+
return {
|
|
23358
|
+
task,
|
|
23359
|
+
comments
|
|
23360
|
+
};
|
|
23361
|
+
}
|
|
22962
23362
|
/**
|
|
22963
23363
|
* Applies every enabled rule on `project` whose trigger matches this one
|
|
22964
23364
|
* mutation (`prev` -> `next`, or `prev` undefined for a brand-new task) and
|
|
@@ -22969,49 +23369,32 @@ function matches(rule, prev, next, created) {
|
|
|
22969
23369
|
function applyAutomations(project, prev, next, opts) {
|
|
22970
23370
|
const rules = project?.automations ?? [];
|
|
22971
23371
|
const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
23372
|
+
const today = opts.now ? opts.now.slice(0, 10) : todayISO();
|
|
23373
|
+
const ctx = {
|
|
23374
|
+
authorId: opts.currentUserId,
|
|
23375
|
+
now,
|
|
23376
|
+
today
|
|
23377
|
+
};
|
|
22972
23378
|
let task = next;
|
|
22973
23379
|
const comments = [];
|
|
22974
23380
|
for (const rule of rules) {
|
|
22975
|
-
if (!matches(rule, prev, next, opts.created)) continue;
|
|
22976
|
-
|
|
22977
|
-
|
|
22978
|
-
|
|
22979
|
-
const wasDone = task.status === "done";
|
|
22980
|
-
const completedAt = action.status === "done" ? task.completedAt ?? now : wasDone ? null : task.completedAt;
|
|
22981
|
-
task = {
|
|
22982
|
-
...task,
|
|
22983
|
-
status: action.status,
|
|
22984
|
-
completedAt
|
|
22985
|
-
};
|
|
22986
|
-
break;
|
|
22987
|
-
}
|
|
22988
|
-
case "set_priority":
|
|
22989
|
-
if (action.priority) task = {
|
|
22990
|
-
...task,
|
|
22991
|
-
priority: action.priority
|
|
22992
|
-
};
|
|
22993
|
-
break;
|
|
22994
|
-
case "set_assignee":
|
|
22995
|
-
task = {
|
|
22996
|
-
...task,
|
|
22997
|
-
assigneeId: action.assigneeId ?? null
|
|
22998
|
-
};
|
|
22999
|
-
break;
|
|
23000
|
-
case "add_comment": if (action.commentBody?.trim()) comments.push({
|
|
23001
|
-
taskId: task.id,
|
|
23002
|
-
authorId: opts.currentUserId,
|
|
23003
|
-
body: action.commentBody.trim(),
|
|
23004
|
-
createdAt: now,
|
|
23005
|
-
mentionedIds: []
|
|
23006
|
-
});
|
|
23007
|
-
}
|
|
23381
|
+
if (!matches(rule, prev, next, opts.created) || !meetsCondition(rule, next)) continue;
|
|
23382
|
+
const ran = runActions(rule.actions, task, ctx);
|
|
23383
|
+
task = ran.task;
|
|
23384
|
+
comments.push(...ran.comments);
|
|
23008
23385
|
}
|
|
23009
23386
|
return {
|
|
23010
23387
|
task,
|
|
23011
23388
|
comments
|
|
23012
23389
|
};
|
|
23013
23390
|
}
|
|
23014
|
-
var
|
|
23391
|
+
var dayAfter;
|
|
23392
|
+
var init_automations = __esmMin((() => {
|
|
23393
|
+
init_date_fns();
|
|
23394
|
+
init_date_text();
|
|
23395
|
+
init_format();
|
|
23396
|
+
dayAfter = (day, n) => format(addDays(parseIso(day), n), "yyyy-MM-dd");
|
|
23397
|
+
}));
|
|
23015
23398
|
|
|
23016
23399
|
// src/lib/focus.ts
|
|
23017
23400
|
function focusIdsOf(data, personId, date = todayISO()) {
|
|
@@ -24499,7 +24882,7 @@ function pickCollections(doc, keys) {
|
|
|
24499
24882
|
function emptyRows() {
|
|
24500
24883
|
return Object.fromEntries(ROW_COLLECTIONS.map((k) => [k, []]));
|
|
24501
24884
|
}
|
|
24502
|
-
/** A collection as rows; `spendEntries`, `reflections`, `priorityRules` and `
|
|
24885
|
+
/** A collection as rows; `spendEntries`, `reflections`, `priorityRules`, `workCalendar` and `dashboards` may be missing from older documents. */
|
|
24503
24886
|
function rowsOf(doc, key) {
|
|
24504
24887
|
if (isKeyed(key)) return KEYED[key].rows(doc[key]);
|
|
24505
24888
|
return doc[key] ?? [];
|
|
@@ -24584,7 +24967,8 @@ var init_changes = __esmMin((() => {
|
|
|
24584
24967
|
"spendEntries",
|
|
24585
24968
|
"reflections",
|
|
24586
24969
|
"priorityRules",
|
|
24587
|
-
"workCalendar"
|
|
24970
|
+
"workCalendar",
|
|
24971
|
+
"dashboards"
|
|
24588
24972
|
];
|
|
24589
24973
|
ROW_COLLECTIONS = [
|
|
24590
24974
|
"tasks",
|
|
@@ -24605,7 +24989,8 @@ var init_changes = __esmMin((() => {
|
|
|
24605
24989
|
"ropes",
|
|
24606
24990
|
"readPingIds",
|
|
24607
24991
|
"priorityRules",
|
|
24608
|
-
"workCalendar"
|
|
24992
|
+
"workCalendar",
|
|
24993
|
+
"dashboards"
|
|
24609
24994
|
];
|
|
24610
24995
|
KEYED = {
|
|
24611
24996
|
boardConfigs: {
|
|
@@ -28404,7 +28789,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
28404
28789
|
if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
|
|
28405
28790
|
label: "Any priority",
|
|
28406
28791
|
value: "any"
|
|
28407
|
-
}, ...PRIORITIES$
|
|
28792
|
+
}, ...PRIORITIES$2.map((p) => ({
|
|
28408
28793
|
label: PRIORITY_LABEL[p],
|
|
28409
28794
|
value: p
|
|
28410
28795
|
}))], (priority) => setFilter((f) => ({
|
|
@@ -29024,13 +29409,18 @@ const TRIGGER_LABEL = {
|
|
|
29024
29409
|
task_created: "A task is created",
|
|
29025
29410
|
status_changed_to: "Status is set to…",
|
|
29026
29411
|
priority_changed_to: "Priority is set to…",
|
|
29027
|
-
assigned: "A task is assigned to anyone"
|
|
29412
|
+
assigned: "A task is assigned to anyone",
|
|
29413
|
+
due_soon: "A task is due soon",
|
|
29414
|
+
overdue: "A task becomes overdue",
|
|
29415
|
+
untouched: "A task sits untouched"
|
|
29028
29416
|
};
|
|
29029
29417
|
const ACTION_LABEL = {
|
|
29030
29418
|
set_status: "Set status to…",
|
|
29031
29419
|
set_priority: "Set priority to…",
|
|
29032
29420
|
set_assignee: "Assign to…",
|
|
29033
|
-
add_comment: "Post a comment"
|
|
29421
|
+
add_comment: "Post a comment",
|
|
29422
|
+
set_due: "Move the due date",
|
|
29423
|
+
add_follower: "Add a follower"
|
|
29034
29424
|
};
|
|
29035
29425
|
function describeAction(a, peopleName) {
|
|
29036
29426
|
switch (a.type) {
|
|
@@ -29038,18 +29428,36 @@ function describeAction(a, peopleName) {
|
|
|
29038
29428
|
case "set_priority": return `set priority to ${a.priority ? PRIORITY_LABEL[a.priority] : "…"}`;
|
|
29039
29429
|
case "set_assignee": return `assign to ${peopleName(a.assigneeId)}`;
|
|
29040
29430
|
case "add_comment": return `post “${a.commentBody ?? ""}”`;
|
|
29431
|
+
case "set_due": {
|
|
29432
|
+
const n = a.inDays ?? 0;
|
|
29433
|
+
return n === 0 ? "set the due date to that day" : `set the due date ${n} ${n === 1 ? "day" : "days"} out`;
|
|
29434
|
+
}
|
|
29435
|
+
case "add_follower": return `add ${peopleName(a.personId)} as a follower`;
|
|
29041
29436
|
}
|
|
29042
29437
|
}
|
|
29438
|
+
const days$1 = (n) => `${n} ${n === 1 ? "day" : "days"}`;
|
|
29043
29439
|
function describeTrigger(rule) {
|
|
29044
29440
|
const t = rule.trigger;
|
|
29045
29441
|
if (t.type === "status_changed_to") return `When status is set to ${t.status ? TASK_STATUS_LABEL[t.status] : "…"}`;
|
|
29046
29442
|
if (t.type === "priority_changed_to") return `When priority is set to ${t.priority ? PRIORITY_LABEL[t.priority] : "…"}`;
|
|
29443
|
+
if (t.type === "due_soon") return `When a task is due within ${days$1(t.days ?? 2)}`;
|
|
29444
|
+
if (t.type === "untouched") return `When nobody touches a task for ${days$1(t.days ?? 7)}`;
|
|
29047
29445
|
return `When ${TRIGGER_LABEL[t.type].toLowerCase()}`;
|
|
29048
29446
|
}
|
|
29447
|
+
/** The "only if" line: "only High priority, owned by Ana", or "" when there's none. */
|
|
29448
|
+
function describeCondition(rule, peopleName) {
|
|
29449
|
+
const c = rule.onlyIf;
|
|
29450
|
+
if (!c) return "";
|
|
29451
|
+
const parts = [];
|
|
29452
|
+
if (c.priority) parts.push(`${PRIORITY_LABEL[c.priority]} priority`);
|
|
29453
|
+
if (c.assignee) parts.push(c.assignee === "nobody" ? "with no owner" : `owned by ${peopleName(c.assignee)}`);
|
|
29454
|
+
return parts.length ? `only ${parts.join(", ")}` : "";
|
|
29455
|
+
}
|
|
29049
29456
|
/** "When status is set to Done: set priority to Low, post “Shipped”". */
|
|
29050
29457
|
function describeRule(rule, peopleName) {
|
|
29051
29458
|
const did = rule.actions.map((a) => describeAction(a, peopleName)).join(", ");
|
|
29052
|
-
|
|
29459
|
+
const only = describeCondition(rule, peopleName);
|
|
29460
|
+
return `${describeTrigger(rule)}${only ? ` (${only})` : ""}: ${did || "no actions"}`;
|
|
29053
29461
|
}
|
|
29054
29462
|
function defaultAction(type) {
|
|
29055
29463
|
if (type === "set_status") return {
|
|
@@ -29064,6 +29472,14 @@ function defaultAction(type) {
|
|
|
29064
29472
|
type,
|
|
29065
29473
|
assigneeId: null
|
|
29066
29474
|
};
|
|
29475
|
+
if (type === "set_due") return {
|
|
29476
|
+
type,
|
|
29477
|
+
inDays: 3
|
|
29478
|
+
};
|
|
29479
|
+
if (type === "add_follower") return {
|
|
29480
|
+
type,
|
|
29481
|
+
personId: null
|
|
29482
|
+
};
|
|
29067
29483
|
return {
|
|
29068
29484
|
type,
|
|
29069
29485
|
commentBody: ""
|
|
@@ -29103,6 +29519,7 @@ function readFieldValue(def, text) {
|
|
|
29103
29519
|
* starts from, each column (name, shown, cap, place), lanes and what cards
|
|
29104
29520
|
* show, the project's own fields and its automations. b on a project opens it.
|
|
29105
29521
|
*/
|
|
29522
|
+
init_automations();
|
|
29106
29523
|
init_board();
|
|
29107
29524
|
init_swatches();
|
|
29108
29525
|
const LANES = [
|
|
@@ -29137,7 +29554,7 @@ const statusChoices = () => TASK_COLUMNS.map((s) => ({
|
|
|
29137
29554
|
label: TASK_STATUS_LABEL[s],
|
|
29138
29555
|
value: s
|
|
29139
29556
|
}));
|
|
29140
|
-
const priorityChoices = () => PRIORITIES$
|
|
29557
|
+
const priorityChoices = () => PRIORITIES$2.map((p) => ({
|
|
29141
29558
|
label: PRIORITY_LABEL[p],
|
|
29142
29559
|
value: p
|
|
29143
29560
|
}));
|
|
@@ -29391,6 +29808,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29391
29808
|
type,
|
|
29392
29809
|
priority: p
|
|
29393
29810
|
}));
|
|
29811
|
+
if (type === "due_soon" || type === "untouched") return go.ask(type === "due_soon" ? "Due within how many days?" : "Untouched for how many days?", (text) => {
|
|
29812
|
+
const n = Math.round(Number(text.trim()));
|
|
29813
|
+
if (!Number.isFinite(n) || n < 1) return;
|
|
29814
|
+
askAction({
|
|
29815
|
+
type,
|
|
29816
|
+
days: Math.min(60, n)
|
|
29817
|
+
});
|
|
29818
|
+
});
|
|
29394
29819
|
askAction({ type });
|
|
29395
29820
|
}
|
|
29396
29821
|
function askAction(trigger) {
|
|
@@ -29424,6 +29849,21 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29424
29849
|
...defaultAction(type),
|
|
29425
29850
|
assigneeId: pid
|
|
29426
29851
|
}));
|
|
29852
|
+
if (type === "set_due") return go.ask("Set the due date how many days out?", (text) => {
|
|
29853
|
+
const n = Math.round(Number(text.trim()));
|
|
29854
|
+
if (Number.isFinite(n) && n >= 0) add({
|
|
29855
|
+
type,
|
|
29856
|
+
inDays: Math.min(365, n)
|
|
29857
|
+
});
|
|
29858
|
+
});
|
|
29859
|
+
if (type === "add_follower") return go.pick("Add as a follower", people.map((p) => ({
|
|
29860
|
+
label: p.name,
|
|
29861
|
+
value: p.id,
|
|
29862
|
+
hint: p.role
|
|
29863
|
+
})), (pid) => add({
|
|
29864
|
+
type,
|
|
29865
|
+
personId: pid
|
|
29866
|
+
}));
|
|
29427
29867
|
go.ask("Comment to post", (text) => text.trim() && add({
|
|
29428
29868
|
type,
|
|
29429
29869
|
commentBody: text.trim()
|
|
@@ -29822,11 +30262,11 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
29822
30262
|
});
|
|
29823
30263
|
} else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
|
|
29824
30264
|
title: "Priority",
|
|
29825
|
-
choices: PRIORITIES$
|
|
30265
|
+
choices: PRIORITIES$2.map((p) => ({
|
|
29826
30266
|
label: PRIORITY_LABEL[p],
|
|
29827
30267
|
value: p
|
|
29828
30268
|
})),
|
|
29829
|
-
initial: PRIORITIES$
|
|
30269
|
+
initial: PRIORITIES$2.indexOf(task.priority),
|
|
29830
30270
|
onCancel: done,
|
|
29831
30271
|
onPick: (p) => {
|
|
29832
30272
|
store.getState().updateTask(task.id, { priority: p });
|
package/lib/cli.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
followedText,
|
|
41
41
|
soundingsText,
|
|
42
42
|
goalsText,
|
|
43
|
+
templatesText,
|
|
43
44
|
thanksText,
|
|
44
45
|
ashoreText,
|
|
45
46
|
voyageText,
|
|
@@ -166,7 +167,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
166
167
|
[--also "NAME, NAME"] [--due DATE|pilot]
|
|
167
168
|
[--priority low|medium|high|urgent]
|
|
168
169
|
[--status S] [--comment TEXT] [--over-limit] [--dry-run]
|
|
169
|
-
[--literal]
|
|
170
|
+
[--literal] [--template NAME]
|
|
170
171
|
reads plain words in TITLE as the
|
|
171
172
|
website does: @name, by friday,
|
|
172
173
|
!high, #project (--literal: keep
|
|
@@ -263,6 +264,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
263
264
|
the ones waiting on your answer
|
|
264
265
|
harbor goals [PORTFOLIO] each portfolio's goals, how far
|
|
265
266
|
along and whether on track
|
|
267
|
+
harbor templates [PROJECT] the task templates new tasks
|
|
268
|
+
start from (harbor add --template)
|
|
269
|
+
harbor template TASK [--name NAME] save a task as a template in its
|
|
270
|
+
project
|
|
266
271
|
harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
|
|
267
272
|
public holidays and time away
|
|
268
273
|
coming up, and whose holidays
|
|
@@ -768,6 +773,11 @@ const DETAIL = {
|
|
|
768
773
|
" ten lines with --comment add the same comment to all ten. More than 100",
|
|
769
774
|
" lines is harbor import's job.",
|
|
770
775
|
"",
|
|
776
|
+
" --template NAME starts from one of the project's task templates (save one",
|
|
777
|
+
" with Save as template on a task, or harbor template TASK): its title,",
|
|
778
|
+
" notes, priority, owner, others on it, checklist and due offset fill what",
|
|
779
|
+
" the flags don't. Leave out TITLE to take the template's.",
|
|
780
|
+
"",
|
|
771
781
|
" --due pilot takes the earliest day the owner's queue suggests: their open",
|
|
772
782
|
" work due first, their weekly pace, time away, what it waits on and how",
|
|
773
783
|
" long their similar tasks took. It needs an owner (--assignee none is",
|
|
@@ -1026,6 +1036,16 @@ const DETAIL = {
|
|
|
1026
1036
|
" these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
|
|
1027
1037
|
" NZ US (national holidays only).",
|
|
1028
1038
|
],
|
|
1039
|
+
templates: [
|
|
1040
|
+
" harbor templates [PROJECT] reads: each project's task templates",
|
|
1041
|
+
" (all projects when left out)",
|
|
1042
|
+
" harbor template TASK [--name NAME] writes: saves the task as a template",
|
|
1043
|
+
" in its project, named after it unless",
|
|
1044
|
+
" --name says otherwise",
|
|
1045
|
+
"",
|
|
1046
|
+
" harbor add --project P --template NAME starts a task from one: its title,",
|
|
1047
|
+
" notes, priority, owner, others on it, checklist and due offset.",
|
|
1048
|
+
],
|
|
1029
1049
|
goals: [
|
|
1030
1050
|
" harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
|
|
1031
1051
|
" left out), off track first, with how far",
|
|
@@ -2478,12 +2498,15 @@ export async function run(argv, io = {}) {
|
|
|
2478
2498
|
const { positionals, values, harbor } = parse(
|
|
2479
2499
|
spec(
|
|
2480
2500
|
["over-limit", "dry-run", "literal"],
|
|
2481
|
-
["project", "assignee", "also", "due", "priority", "status", "comment"],
|
|
2501
|
+
["project", "assignee", "also", "due", "priority", "status", "comment", "template"],
|
|
2482
2502
|
),
|
|
2483
2503
|
);
|
|
2504
|
+
// --template with no title: the template's own title.
|
|
2505
|
+
const fromTemplate = values.template !== undefined && positionals.length === 0;
|
|
2484
2506
|
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
2485
2507
|
const piped =
|
|
2486
|
-
|
|
2508
|
+
!fromTemplate &&
|
|
2509
|
+
(positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-"));
|
|
2487
2510
|
const stdin = io.stdin ?? process.stdin;
|
|
2488
2511
|
const titles =
|
|
2489
2512
|
piped && (io.stdin || !process.stdin.isTTY)
|
|
@@ -2491,7 +2514,9 @@ export async function run(argv, io = {}) {
|
|
|
2491
2514
|
.split(/\r?\n/)
|
|
2492
2515
|
.map((l) => l.trim())
|
|
2493
2516
|
.filter(Boolean)
|
|
2494
|
-
:
|
|
2517
|
+
: fromTemplate
|
|
2518
|
+
? [""]
|
|
2519
|
+
: [text(positionals, "the task")];
|
|
2495
2520
|
if (titles.length === 0)
|
|
2496
2521
|
throw new HarborError("Say the task, or give one title a line on stdin.");
|
|
2497
2522
|
if (titles.length > 100)
|
|
@@ -2500,7 +2525,7 @@ export async function run(argv, io = {}) {
|
|
|
2500
2525
|
);
|
|
2501
2526
|
// Plain words, as on the website: @person, by Friday, !high, #Project.
|
|
2502
2527
|
// --literal keeps every title exactly as typed.
|
|
2503
|
-
const reads = values.literal
|
|
2528
|
+
const reads = values.literal || fromTemplate
|
|
2504
2529
|
? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
|
|
2505
2530
|
: await readTitles(client, harbor, titles, values);
|
|
2506
2531
|
// No --project and no #Project: the harbor's one open project, if that's all there is.
|
|
@@ -2569,9 +2594,10 @@ export async function run(argv, io = {}) {
|
|
|
2569
2594
|
.call("create_task", {
|
|
2570
2595
|
harbor,
|
|
2571
2596
|
project: projectOf(words),
|
|
2572
|
-
title: words.title,
|
|
2573
|
-
|
|
2574
|
-
|
|
2597
|
+
title: words.title || undefined,
|
|
2598
|
+
template: values.template,
|
|
2599
|
+
// Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
|
|
2600
|
+
assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
|
|
2575
2601
|
also: values.also === undefined ? undefined : alsoList(values.also),
|
|
2576
2602
|
due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
|
|
2577
2603
|
suggestedDue: duePilot(values.due),
|
|
@@ -3190,6 +3216,23 @@ export async function run(argv, io = {}) {
|
|
|
3190
3216
|
soundingsText(r),
|
|
3191
3217
|
);
|
|
3192
3218
|
}
|
|
3219
|
+
case "templates": {
|
|
3220
|
+
const { positionals, harbor } = parse(spec());
|
|
3221
|
+
const project = positionals.join(" ").trim();
|
|
3222
|
+
return call("list_task_templates", { harbor, ...(project ? { project } : {}) }, (r) =>
|
|
3223
|
+
templatesText(r),
|
|
3224
|
+
);
|
|
3225
|
+
}
|
|
3226
|
+
case "template": {
|
|
3227
|
+
const { positionals, values, harbor } = parse(spec([], ["name"]));
|
|
3228
|
+
const task = text(positionals, "the task");
|
|
3229
|
+
return call(
|
|
3230
|
+
"save_task_template",
|
|
3231
|
+
{ harbor, task, ...(values.name ? { name: values.name } : {}) },
|
|
3232
|
+
(r) =>
|
|
3233
|
+
`Saved “${r.template.name}” as a template in ${r.template.project}. harbor add --project "${r.template.project}" --template "${r.template.name}" starts a task from it.`,
|
|
3234
|
+
);
|
|
3235
|
+
}
|
|
3193
3236
|
case "goals": {
|
|
3194
3237
|
const { positionals, harbor } = parse(spec());
|
|
3195
3238
|
const portfolio = positionals.join(" ").trim();
|
package/lib/format.mjs
CHANGED
|
@@ -1231,6 +1231,30 @@ export function catchupText(r) {
|
|
|
1231
1231
|
}
|
|
1232
1232
|
|
|
1233
1233
|
/** list_goals as text: each portfolio's goals, what needs attention first, with their key results. */
|
|
1234
|
+
/** harbor templates: each project's task templates, with what a new task takes from them. */
|
|
1235
|
+
export function templatesText(r) {
|
|
1236
|
+
const list = r.templates ?? [];
|
|
1237
|
+
if (!list.length)
|
|
1238
|
+
return "No task templates yet. harbor template TASK saves one, or Save as template on a task on the website.";
|
|
1239
|
+
const lines = [];
|
|
1240
|
+
let project = null;
|
|
1241
|
+
for (const t of list) {
|
|
1242
|
+
if (t.project !== project) {
|
|
1243
|
+
if (project !== null) lines.push("");
|
|
1244
|
+
lines.push(t.project);
|
|
1245
|
+
project = t.project;
|
|
1246
|
+
}
|
|
1247
|
+
const bits = [];
|
|
1248
|
+
if (t.priority && t.priority !== "medium") bits.push(`${t.priority} priority`);
|
|
1249
|
+
if (t.owner) bits.push(t.owner);
|
|
1250
|
+
if (t.dueInDays != null)
|
|
1251
|
+
bits.push(t.dueInDays === 0 ? "due the day it's made" : `due in ${t.dueInDays} ${t.dueInDays === 1 ? "day" : "days"}`);
|
|
1252
|
+
if (t.checklist?.length) bits.push(`${t.checklist.length} checklist ${t.checklist.length === 1 ? "item" : "items"}`);
|
|
1253
|
+
lines.push(` ${t.name}${t.title !== t.name ? `: ${t.title}` : ""}${bits.length ? ` (${bits.join(", ")})` : ""}`);
|
|
1254
|
+
}
|
|
1255
|
+
return lines.join("\n");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1234
1258
|
export function goalsText(r) {
|
|
1235
1259
|
const order = ["off-track", "at-risk", "not-started", "on-track", "achieved"];
|
|
1236
1260
|
const withGoals = (r.portfolios ?? []).filter((p) => p.goals?.length);
|