@harborppm/cli 0.78.0 → 0.80.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 +507 -54
- package/lib/cli.mjs +69 -11
- 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) {
|
|
@@ -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;
|
|
@@ -13220,6 +13235,7 @@ const PAGES = {
|
|
|
13220
13235
|
projects: "/projects",
|
|
13221
13236
|
risks: "/risks",
|
|
13222
13237
|
goals: "/goals",
|
|
13238
|
+
reports: "/reports",
|
|
13223
13239
|
calendar: "/calendar",
|
|
13224
13240
|
people: "/people"
|
|
13225
13241
|
};
|
|
@@ -13744,6 +13760,269 @@ function keyResultText(kr, work) {
|
|
|
13744
13760
|
}
|
|
13745
13761
|
const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
|
|
13746
13762
|
|
|
13763
|
+
// src/lib/reports.ts
|
|
13764
|
+
init_date_fns();
|
|
13765
|
+
init_date_text();
|
|
13766
|
+
init_indexes();
|
|
13767
|
+
init_swatches();
|
|
13768
|
+
/** Weeks a "by week" chart shows: back from this week, or ahead for due dates. */
|
|
13769
|
+
const WEEKS_BACK = 12;
|
|
13770
|
+
const WEEKS_AHEAD = 8;
|
|
13771
|
+
/** Bars past this many are summed into "Everything else". */
|
|
13772
|
+
const MAX_BARS = 12;
|
|
13773
|
+
const GROUPS = [
|
|
13774
|
+
"status",
|
|
13775
|
+
"owner",
|
|
13776
|
+
"priority",
|
|
13777
|
+
"project",
|
|
13778
|
+
"program",
|
|
13779
|
+
"week"
|
|
13780
|
+
];
|
|
13781
|
+
const MEASURE_LABEL = {
|
|
13782
|
+
open: "Open tasks",
|
|
13783
|
+
done: "Finished tasks",
|
|
13784
|
+
all: "All tasks",
|
|
13785
|
+
overdue: "Overdue tasks",
|
|
13786
|
+
hours: "Hours logged"
|
|
13787
|
+
};
|
|
13788
|
+
const GROUP_LABEL = {
|
|
13789
|
+
status: "Status",
|
|
13790
|
+
owner: "Owner",
|
|
13791
|
+
priority: "Priority",
|
|
13792
|
+
project: "Project",
|
|
13793
|
+
program: "Program",
|
|
13794
|
+
week: "Week"
|
|
13795
|
+
};
|
|
13796
|
+
/** The splits a measure can take: hours have no status or priority of their own. */
|
|
13797
|
+
function groupsFor(measure) {
|
|
13798
|
+
return measure === "hours" ? [
|
|
13799
|
+
"owner",
|
|
13800
|
+
"project",
|
|
13801
|
+
"program",
|
|
13802
|
+
"week"
|
|
13803
|
+
] : GROUPS;
|
|
13804
|
+
}
|
|
13805
|
+
const STATUS_FILL = {
|
|
13806
|
+
backlog: "var(--color-swatch-slate)",
|
|
13807
|
+
todo: "var(--color-swatch-sand)",
|
|
13808
|
+
doing: "var(--color-swatch-sea)",
|
|
13809
|
+
review: "var(--color-swatch-rust)",
|
|
13810
|
+
done: "var(--color-swatch-moss)"
|
|
13811
|
+
};
|
|
13812
|
+
const PRIORITY_FILL = {
|
|
13813
|
+
low: "var(--color-swatch-slate)",
|
|
13814
|
+
medium: "var(--color-swatch-sea)",
|
|
13815
|
+
high: "var(--color-swatch-sand)",
|
|
13816
|
+
urgent: "var(--color-swatch-coral)"
|
|
13817
|
+
};
|
|
13818
|
+
const PRIORITIES$1 = [
|
|
13819
|
+
"urgent",
|
|
13820
|
+
"high",
|
|
13821
|
+
"medium",
|
|
13822
|
+
"low"
|
|
13823
|
+
];
|
|
13824
|
+
/** Colours handed out in turn where a slice has none of its own. */
|
|
13825
|
+
const CYCLE = [
|
|
13826
|
+
"sea",
|
|
13827
|
+
"sand",
|
|
13828
|
+
"moss",
|
|
13829
|
+
"rust",
|
|
13830
|
+
"slate",
|
|
13831
|
+
"pine",
|
|
13832
|
+
"clay",
|
|
13833
|
+
"sage"
|
|
13834
|
+
];
|
|
13835
|
+
const swatchFill = (s) => `var(--color-swatch-${s})`;
|
|
13836
|
+
/** The Monday on or before a day or moment (ISO, read in local time), or "" when it isn't a date. */
|
|
13837
|
+
function mondayOf(day) {
|
|
13838
|
+
const d = parseIso(day.length > 10 ? day : `${day}T12:00:00`);
|
|
13839
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
13840
|
+
const back = (d.getDay() + 6) % 7;
|
|
13841
|
+
return format(addDays(d, -back), "yyyy-MM-dd");
|
|
13842
|
+
}
|
|
13843
|
+
/** The projects a chart reads: everything, or what sits under its scope. */
|
|
13844
|
+
function scopeOf(data, scopeId) {
|
|
13845
|
+
if (!scopeId) return {
|
|
13846
|
+
projectIds: null,
|
|
13847
|
+
name: "Whole harbor",
|
|
13848
|
+
found: true
|
|
13849
|
+
};
|
|
13850
|
+
const project = byId(data.projects).get(scopeId);
|
|
13851
|
+
if (project) return {
|
|
13852
|
+
projectIds: /* @__PURE__ */ new Set([project.id]),
|
|
13853
|
+
name: project.name,
|
|
13854
|
+
found: true
|
|
13855
|
+
};
|
|
13856
|
+
const program = byId(data.programs).get(scopeId);
|
|
13857
|
+
if (program) {
|
|
13858
|
+
const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
|
|
13859
|
+
return {
|
|
13860
|
+
projectIds: new Set(ids),
|
|
13861
|
+
name: program.name,
|
|
13862
|
+
found: true
|
|
13863
|
+
};
|
|
13864
|
+
}
|
|
13865
|
+
const portfolio = byId(data.portfolios).get(scopeId);
|
|
13866
|
+
if (portfolio) {
|
|
13867
|
+
const programIds = new Set(data.programs.filter((p) => p.portfolioId === portfolio.id).map((p) => p.id));
|
|
13868
|
+
const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
|
|
13869
|
+
return {
|
|
13870
|
+
projectIds: new Set(ids),
|
|
13871
|
+
name: portfolio.name,
|
|
13872
|
+
found: true
|
|
13873
|
+
};
|
|
13874
|
+
}
|
|
13875
|
+
return {
|
|
13876
|
+
projectIds: /* @__PURE__ */ new Set(),
|
|
13877
|
+
name: "Deleted",
|
|
13878
|
+
found: false
|
|
13879
|
+
};
|
|
13880
|
+
}
|
|
13881
|
+
/**
|
|
13882
|
+
* What a chart shows today: its slices in a fixed order (statuses and
|
|
13883
|
+
* priorities in board order, weeks in time order, the rest largest first,
|
|
13884
|
+
* past MAX_BARS summed into "Everything else"), with their total.
|
|
13885
|
+
*/
|
|
13886
|
+
function chartData(chart, data, today) {
|
|
13887
|
+
const scope = scopeOf(data, chart.scopeId);
|
|
13888
|
+
const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
|
|
13889
|
+
const unit = chart.measure === "hours" ? "hours" : "tasks";
|
|
13890
|
+
const groupBy = groupsFor(chart.measure).includes(chart.groupBy) ? chart.groupBy : "owner";
|
|
13891
|
+
const items = [];
|
|
13892
|
+
for (const task of data.tasks) {
|
|
13893
|
+
if (!inScope(task)) continue;
|
|
13894
|
+
const done = task.status === "done";
|
|
13895
|
+
switch (chart.measure) {
|
|
13896
|
+
case "open":
|
|
13897
|
+
if (!done) items.push({
|
|
13898
|
+
task,
|
|
13899
|
+
amount: 1,
|
|
13900
|
+
day: task.dueDate
|
|
13901
|
+
});
|
|
13902
|
+
break;
|
|
13903
|
+
case "done":
|
|
13904
|
+
if (done) items.push({
|
|
13905
|
+
task,
|
|
13906
|
+
amount: 1,
|
|
13907
|
+
day: task.completedAt
|
|
13908
|
+
});
|
|
13909
|
+
break;
|
|
13910
|
+
case "all":
|
|
13911
|
+
items.push({
|
|
13912
|
+
task,
|
|
13913
|
+
amount: 1,
|
|
13914
|
+
day: task.createdAt
|
|
13915
|
+
});
|
|
13916
|
+
break;
|
|
13917
|
+
case "overdue":
|
|
13918
|
+
if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) items.push({
|
|
13919
|
+
task,
|
|
13920
|
+
amount: 1,
|
|
13921
|
+
day: task.dueDate
|
|
13922
|
+
});
|
|
13923
|
+
break;
|
|
13924
|
+
case "hours": for (const entry of task.time ?? []) items.push({
|
|
13925
|
+
task,
|
|
13926
|
+
amount: entry.minutes / 60,
|
|
13927
|
+
day: entry.on,
|
|
13928
|
+
personId: entry.personId
|
|
13929
|
+
});
|
|
13930
|
+
}
|
|
13931
|
+
}
|
|
13932
|
+
const projects = byId(data.projects);
|
|
13933
|
+
const programs = byId(data.programs);
|
|
13934
|
+
const people = byId(data.people);
|
|
13935
|
+
const sums = /* @__PURE__ */ new Map();
|
|
13936
|
+
const add = (key, amount) => sums.set(key, (sums.get(key) ?? 0) + amount);
|
|
13937
|
+
let weeks = [];
|
|
13938
|
+
if (groupBy === "week") {
|
|
13939
|
+
const thisWeek = mondayOf(today);
|
|
13940
|
+
const ahead = chart.measure === "open";
|
|
13941
|
+
const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
|
|
13942
|
+
const count = ahead ? 8 : 12;
|
|
13943
|
+
const start = parseIso(`${first}T12:00:00`);
|
|
13944
|
+
weeks = Array.from({ length: count }, (_, i) => format(addDays(start, 7 * i), "yyyy-MM-dd"));
|
|
13945
|
+
const known = new Set(weeks);
|
|
13946
|
+
for (const item of items) {
|
|
13947
|
+
if (!item.day) continue;
|
|
13948
|
+
const week = mondayOf(item.day);
|
|
13949
|
+
if (known.has(week)) add(week, item.amount);
|
|
13950
|
+
else if (ahead && week < first) add(first, item.amount);
|
|
13951
|
+
}
|
|
13952
|
+
} else for (const item of items) {
|
|
13953
|
+
const t = item.task;
|
|
13954
|
+
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);
|
|
13955
|
+
}
|
|
13956
|
+
const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
|
|
13957
|
+
let slices;
|
|
13958
|
+
if (groupBy === "week") slices = weeks.map((w) => ({
|
|
13959
|
+
key: w,
|
|
13960
|
+
label: format(parseIso(`${w}T12:00:00`), "MMM d"),
|
|
13961
|
+
value: round(sums.get(w) ?? 0),
|
|
13962
|
+
fill: "var(--color-primary)"
|
|
13963
|
+
}));
|
|
13964
|
+
else if (groupBy === "status") slices = TASK_COLUMNS.filter((s) => sums.has(s)).map((s) => ({
|
|
13965
|
+
key: s,
|
|
13966
|
+
label: TASK_STATUS_LABEL[s],
|
|
13967
|
+
value: round(sums.get(s)),
|
|
13968
|
+
fill: STATUS_FILL[s]
|
|
13969
|
+
}));
|
|
13970
|
+
else if (groupBy === "priority") slices = PRIORITIES$1.filter((p) => sums.has(p)).map((p) => ({
|
|
13971
|
+
key: p,
|
|
13972
|
+
label: PRIORITY_LABEL[p],
|
|
13973
|
+
value: round(sums.get(p)),
|
|
13974
|
+
fill: PRIORITY_FILL[p]
|
|
13975
|
+
}));
|
|
13976
|
+
else {
|
|
13977
|
+
const named = [...sums.entries()].map(([key, value], i) => {
|
|
13978
|
+
if (groupBy === "owner") {
|
|
13979
|
+
const person = key ? people.get(key) : void 0;
|
|
13980
|
+
return {
|
|
13981
|
+
key: key || "nobody",
|
|
13982
|
+
label: key ? person?.name ?? "Former member" : "Nobody",
|
|
13983
|
+
value: round(value),
|
|
13984
|
+
fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
|
|
13985
|
+
};
|
|
13986
|
+
}
|
|
13987
|
+
const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
|
|
13988
|
+
return {
|
|
13989
|
+
key: key || "none",
|
|
13990
|
+
label: thing?.name ?? "Deleted",
|
|
13991
|
+
value: round(value),
|
|
13992
|
+
fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
|
|
13993
|
+
};
|
|
13994
|
+
});
|
|
13995
|
+
named.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
|
|
13996
|
+
if (named.length > 12) {
|
|
13997
|
+
const rest = named.slice(11);
|
|
13998
|
+
slices = [...named.slice(0, 11), {
|
|
13999
|
+
key: "rest",
|
|
14000
|
+
label: `Everything else (${rest.length})`,
|
|
14001
|
+
value: round(rest.reduce((n, s) => n + s.value, 0)),
|
|
14002
|
+
fill: "var(--color-swatch-slate)"
|
|
14003
|
+
}];
|
|
14004
|
+
} else slices = named;
|
|
14005
|
+
}
|
|
14006
|
+
const total = round(slices.reduce((n, s) => n + s.value, 0));
|
|
14007
|
+
return {
|
|
14008
|
+
slices,
|
|
14009
|
+
total,
|
|
14010
|
+
unit,
|
|
14011
|
+
scopeName: scope.name,
|
|
14012
|
+
scopeFound: scope.found
|
|
14013
|
+
};
|
|
14014
|
+
}
|
|
14015
|
+
/** "12 tasks", "1 task", "7.5 hours". */
|
|
14016
|
+
function amountText(value, unit) {
|
|
14017
|
+
if (unit === "hours") return `${value} ${value === 1 ? "hour" : "hours"}`;
|
|
14018
|
+
return `${value} ${value === 1 ? "task" : "tasks"}`;
|
|
14019
|
+
}
|
|
14020
|
+
/** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
|
|
14021
|
+
function chartText(title, result) {
|
|
14022
|
+
if (result.slices.length === 0) return `${title}: nothing yet`;
|
|
14023
|
+
return `${title}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
|
|
14024
|
+
}
|
|
14025
|
+
|
|
13747
14026
|
// node_modules/zod/v4/core/util.js
|
|
13748
14027
|
function getEnumValues(entries) {
|
|
13749
14028
|
const numericValues = Object.values(entries).filter((v) => typeof v === "number");
|
|
@@ -18941,7 +19220,7 @@ init_blockers();
|
|
|
18941
19220
|
init_format();
|
|
18942
19221
|
init_indexes();
|
|
18943
19222
|
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
18944
|
-
const days$
|
|
19223
|
+
const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
|
|
18945
19224
|
const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
|
|
18946
19225
|
const open = (t) => t.status !== "done";
|
|
18947
19226
|
/** True when `a` finishes later than `b`, or as late behind a longer line. */
|
|
@@ -19008,7 +19287,7 @@ function linksOf(line, walk, target, today) {
|
|
|
19008
19287
|
task,
|
|
19009
19288
|
finish,
|
|
19010
19289
|
late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
|
|
19011
|
-
slack: against ? days$
|
|
19290
|
+
slack: against ? days$2(finish, against) : null
|
|
19012
19291
|
};
|
|
19013
19292
|
});
|
|
19014
19293
|
}
|
|
@@ -19020,7 +19299,7 @@ function chainOf(line, walk, target, today) {
|
|
|
19020
19299
|
links,
|
|
19021
19300
|
finish,
|
|
19022
19301
|
target,
|
|
19023
|
-
over: target ? days$
|
|
19302
|
+
over: target ? days$2(target, finish) : null,
|
|
19024
19303
|
undated: 0
|
|
19025
19304
|
};
|
|
19026
19305
|
}
|
|
@@ -19101,17 +19380,17 @@ function milestonePole(milestone, data, now) {
|
|
|
19101
19380
|
const others = runs.slice(1).map((r) => ({
|
|
19102
19381
|
project: r.project,
|
|
19103
19382
|
finish: r.finish,
|
|
19104
|
-
slack: days$
|
|
19383
|
+
slack: days$2(r.finish, due)
|
|
19105
19384
|
}));
|
|
19106
19385
|
return {
|
|
19107
19386
|
kind: "chain",
|
|
19108
19387
|
links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
|
|
19109
19388
|
...l,
|
|
19110
|
-
slack: days$
|
|
19389
|
+
slack: days$2(l.finish, due)
|
|
19111
19390
|
} : l) : [],
|
|
19112
19391
|
finish: first.finish,
|
|
19113
19392
|
target: due,
|
|
19114
|
-
over: days$
|
|
19393
|
+
over: days$2(due, first.finish),
|
|
19115
19394
|
via: {
|
|
19116
19395
|
project: first.project,
|
|
19117
19396
|
finish: first.finish
|
|
@@ -19194,7 +19473,8 @@ function pickData(s) {
|
|
|
19194
19473
|
spendEntries: s.spendEntries ?? NONE,
|
|
19195
19474
|
reflections: s.reflections ?? NONE,
|
|
19196
19475
|
priorityRules: s.priorityRules ?? null,
|
|
19197
|
-
workCalendar: s.workCalendar ?? null
|
|
19476
|
+
workCalendar: s.workCalendar ?? null,
|
|
19477
|
+
dashboards: s.dashboards ?? NONE
|
|
19198
19478
|
};
|
|
19199
19479
|
}
|
|
19200
19480
|
/** The document as sent to the server: normalized and without inline file data. */
|
|
@@ -21420,6 +21700,67 @@ const HARBOR_TOOLS = [
|
|
|
21420
21700
|
})) };
|
|
21421
21701
|
}
|
|
21422
21702
|
}),
|
|
21703
|
+
tool({
|
|
21704
|
+
name: "list_dashboards",
|
|
21705
|
+
title: "Dashboards",
|
|
21706
|
+
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,
|
|
21707
|
+
input: object({
|
|
21708
|
+
harbor: harborArg,
|
|
21709
|
+
dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
|
|
21710
|
+
}),
|
|
21711
|
+
run: (ctx, input) => {
|
|
21712
|
+
const { data } = ctx;
|
|
21713
|
+
const today = todayISO(ctx.now);
|
|
21714
|
+
const all = data.dashboards ?? [];
|
|
21715
|
+
return { dashboards: (input.dashboard ? [findOne(all, input.dashboard, "dashboard")] : all).map((d) => ({
|
|
21716
|
+
id: d.id,
|
|
21717
|
+
name: d.name,
|
|
21718
|
+
charts: d.charts.map((c) => {
|
|
21719
|
+
const result = chartData(c, data, today);
|
|
21720
|
+
return {
|
|
21721
|
+
id: c.id,
|
|
21722
|
+
title: c.title,
|
|
21723
|
+
counts: MEASURE_LABEL[c.measure],
|
|
21724
|
+
splitBy: GROUP_LABEL[c.groupBy],
|
|
21725
|
+
view: c.view,
|
|
21726
|
+
from: result.scopeName,
|
|
21727
|
+
total: amountText(result.total, result.unit),
|
|
21728
|
+
values: result.slices.map((s) => ({
|
|
21729
|
+
label: s.label,
|
|
21730
|
+
value: s.value
|
|
21731
|
+
})),
|
|
21732
|
+
text: chartText(c.title, result)
|
|
21733
|
+
};
|
|
21734
|
+
})
|
|
21735
|
+
})) };
|
|
21736
|
+
}
|
|
21737
|
+
}),
|
|
21738
|
+
tool({
|
|
21739
|
+
name: "list_task_templates",
|
|
21740
|
+
title: "Task templates",
|
|
21741
|
+
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,
|
|
21742
|
+
input: object({
|
|
21743
|
+
harbor: harborArg,
|
|
21744
|
+
project: string().max(200).optional().describe("Project id or name. Default: all.")
|
|
21745
|
+
}),
|
|
21746
|
+
run: (ctx, input) => {
|
|
21747
|
+
const { data } = ctx;
|
|
21748
|
+
const projects = input.project ? [findOne(data.projects, input.project, "project")] : data.projects.filter((p) => p.taskTemplates?.length);
|
|
21749
|
+
const name = (id) => id ? data.people.find((p) => p.id === id)?.name ?? null : null;
|
|
21750
|
+
return { templates: projects.flatMap((p) => (p.taskTemplates ?? []).map((t) => ({
|
|
21751
|
+
id: t.id,
|
|
21752
|
+
name: t.name,
|
|
21753
|
+
project: p.name,
|
|
21754
|
+
title: t.title,
|
|
21755
|
+
notes: t.description || void 0,
|
|
21756
|
+
priority: t.priority,
|
|
21757
|
+
owner: name(t.assigneeId),
|
|
21758
|
+
alsoOn: (t.followerIds ?? []).map(name).filter(Boolean),
|
|
21759
|
+
checklist: t.checklist,
|
|
21760
|
+
dueInDays: t.dueInDays
|
|
21761
|
+
}))) };
|
|
21762
|
+
}
|
|
21763
|
+
}),
|
|
21423
21764
|
tool({
|
|
21424
21765
|
name: "long_pole",
|
|
21425
21766
|
title: "What's holding this up",
|
|
@@ -22231,7 +22572,8 @@ var init_undo = __esmMin((() => {
|
|
|
22231
22572
|
"attachments",
|
|
22232
22573
|
"checkIns",
|
|
22233
22574
|
"spendEntries",
|
|
22234
|
-
"reflections"
|
|
22575
|
+
"reflections",
|
|
22576
|
+
"dashboards"
|
|
22235
22577
|
];
|
|
22236
22578
|
FOLD_MS = 5e3;
|
|
22237
22579
|
LEAD = [
|
|
@@ -22598,6 +22940,9 @@ function hierarchySlice({ get, write }) {
|
|
|
22598
22940
|
...patch
|
|
22599
22941
|
} : p) });
|
|
22600
22942
|
},
|
|
22943
|
+
setDashboards: (dashboards) => {
|
|
22944
|
+
write({ dashboards });
|
|
22945
|
+
},
|
|
22601
22946
|
updateProgram: (id, patch) => {
|
|
22602
22947
|
write({ programs: get().programs.map((p) => p.id === id ? {
|
|
22603
22948
|
...p,
|
|
@@ -22927,6 +23272,16 @@ var init_github_refs = __esmMin((() => {
|
|
|
22927
23272
|
}));
|
|
22928
23273
|
|
|
22929
23274
|
// src/lib/automations.ts
|
|
23275
|
+
/** Whether a task meets the rule's "only if" line. */
|
|
23276
|
+
function meetsCondition(rule, task) {
|
|
23277
|
+
const c = rule.onlyIf;
|
|
23278
|
+
if (!c) return true;
|
|
23279
|
+
if (c.priority && task.priority !== c.priority) return false;
|
|
23280
|
+
if (c.assignee) {
|
|
23281
|
+
if (c.assignee === "nobody" ? task.assigneeId != null : task.assigneeId !== c.assignee) return false;
|
|
23282
|
+
}
|
|
23283
|
+
return true;
|
|
23284
|
+
}
|
|
22930
23285
|
/**
|
|
22931
23286
|
* Checked once, against the (before, after) pair of a single user-driven
|
|
22932
23287
|
* mutation — never re-checked against an automation's own effect. That's the
|
|
@@ -22944,6 +23299,64 @@ function matches(rule, prev, next, created) {
|
|
|
22944
23299
|
default: return false;
|
|
22945
23300
|
}
|
|
22946
23301
|
}
|
|
23302
|
+
/** One rule's actions on one task, in order. */
|
|
23303
|
+
function runActions(actions, start, ctx) {
|
|
23304
|
+
let task = start;
|
|
23305
|
+
const comments = [];
|
|
23306
|
+
for (const action of actions) switch (action.type) {
|
|
23307
|
+
case "set_status": {
|
|
23308
|
+
if (!action.status) break;
|
|
23309
|
+
const wasDone = task.status === "done";
|
|
23310
|
+
const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
|
|
23311
|
+
task = {
|
|
23312
|
+
...task,
|
|
23313
|
+
status: action.status,
|
|
23314
|
+
completedAt
|
|
23315
|
+
};
|
|
23316
|
+
break;
|
|
23317
|
+
}
|
|
23318
|
+
case "set_priority":
|
|
23319
|
+
if (action.priority) task = {
|
|
23320
|
+
...task,
|
|
23321
|
+
priority: action.priority
|
|
23322
|
+
};
|
|
23323
|
+
break;
|
|
23324
|
+
case "set_assignee":
|
|
23325
|
+
task = {
|
|
23326
|
+
...task,
|
|
23327
|
+
assigneeId: action.assigneeId ?? null
|
|
23328
|
+
};
|
|
23329
|
+
break;
|
|
23330
|
+
case "set_due": {
|
|
23331
|
+
const n = Math.min(365, Math.max(0, Math.round(action.inDays ?? 0)));
|
|
23332
|
+
task = {
|
|
23333
|
+
...task,
|
|
23334
|
+
dueDate: dayAfter(ctx.today, n)
|
|
23335
|
+
};
|
|
23336
|
+
break;
|
|
23337
|
+
}
|
|
23338
|
+
case "add_follower": {
|
|
23339
|
+
const id = action.personId;
|
|
23340
|
+
if (!id || id === task.assigneeId || task.followerIds?.includes(id)) break;
|
|
23341
|
+
task = {
|
|
23342
|
+
...task,
|
|
23343
|
+
followerIds: [...task.followerIds ?? [], id]
|
|
23344
|
+
};
|
|
23345
|
+
break;
|
|
23346
|
+
}
|
|
23347
|
+
case "add_comment": if (action.commentBody?.trim()) comments.push({
|
|
23348
|
+
taskId: task.id,
|
|
23349
|
+
authorId: ctx.authorId,
|
|
23350
|
+
body: action.commentBody.trim(),
|
|
23351
|
+
createdAt: ctx.now,
|
|
23352
|
+
mentionedIds: []
|
|
23353
|
+
});
|
|
23354
|
+
}
|
|
23355
|
+
return {
|
|
23356
|
+
task,
|
|
23357
|
+
comments
|
|
23358
|
+
};
|
|
23359
|
+
}
|
|
22947
23360
|
/**
|
|
22948
23361
|
* Applies every enabled rule on `project` whose trigger matches this one
|
|
22949
23362
|
* mutation (`prev` -> `next`, or `prev` undefined for a brand-new task) and
|
|
@@ -22954,49 +23367,32 @@ function matches(rule, prev, next, created) {
|
|
|
22954
23367
|
function applyAutomations(project, prev, next, opts) {
|
|
22955
23368
|
const rules = project?.automations ?? [];
|
|
22956
23369
|
const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
23370
|
+
const today = opts.now ? opts.now.slice(0, 10) : todayISO();
|
|
23371
|
+
const ctx = {
|
|
23372
|
+
authorId: opts.currentUserId,
|
|
23373
|
+
now,
|
|
23374
|
+
today
|
|
23375
|
+
};
|
|
22957
23376
|
let task = next;
|
|
22958
23377
|
const comments = [];
|
|
22959
23378
|
for (const rule of rules) {
|
|
22960
|
-
if (!matches(rule, prev, next, opts.created)) continue;
|
|
22961
|
-
|
|
22962
|
-
|
|
22963
|
-
|
|
22964
|
-
const wasDone = task.status === "done";
|
|
22965
|
-
const completedAt = action.status === "done" ? task.completedAt ?? now : wasDone ? null : task.completedAt;
|
|
22966
|
-
task = {
|
|
22967
|
-
...task,
|
|
22968
|
-
status: action.status,
|
|
22969
|
-
completedAt
|
|
22970
|
-
};
|
|
22971
|
-
break;
|
|
22972
|
-
}
|
|
22973
|
-
case "set_priority":
|
|
22974
|
-
if (action.priority) task = {
|
|
22975
|
-
...task,
|
|
22976
|
-
priority: action.priority
|
|
22977
|
-
};
|
|
22978
|
-
break;
|
|
22979
|
-
case "set_assignee":
|
|
22980
|
-
task = {
|
|
22981
|
-
...task,
|
|
22982
|
-
assigneeId: action.assigneeId ?? null
|
|
22983
|
-
};
|
|
22984
|
-
break;
|
|
22985
|
-
case "add_comment": if (action.commentBody?.trim()) comments.push({
|
|
22986
|
-
taskId: task.id,
|
|
22987
|
-
authorId: opts.currentUserId,
|
|
22988
|
-
body: action.commentBody.trim(),
|
|
22989
|
-
createdAt: now,
|
|
22990
|
-
mentionedIds: []
|
|
22991
|
-
});
|
|
22992
|
-
}
|
|
23379
|
+
if (!matches(rule, prev, next, opts.created) || !meetsCondition(rule, next)) continue;
|
|
23380
|
+
const ran = runActions(rule.actions, task, ctx);
|
|
23381
|
+
task = ran.task;
|
|
23382
|
+
comments.push(...ran.comments);
|
|
22993
23383
|
}
|
|
22994
23384
|
return {
|
|
22995
23385
|
task,
|
|
22996
23386
|
comments
|
|
22997
23387
|
};
|
|
22998
23388
|
}
|
|
22999
|
-
var
|
|
23389
|
+
var dayAfter;
|
|
23390
|
+
var init_automations = __esmMin((() => {
|
|
23391
|
+
init_date_fns();
|
|
23392
|
+
init_date_text();
|
|
23393
|
+
init_format();
|
|
23394
|
+
dayAfter = (day, n) => format(addDays(parseIso(day), n), "yyyy-MM-dd");
|
|
23395
|
+
}));
|
|
23000
23396
|
|
|
23001
23397
|
// src/lib/focus.ts
|
|
23002
23398
|
function focusIdsOf(data, personId, date = todayISO()) {
|
|
@@ -24484,7 +24880,7 @@ function pickCollections(doc, keys) {
|
|
|
24484
24880
|
function emptyRows() {
|
|
24485
24881
|
return Object.fromEntries(ROW_COLLECTIONS.map((k) => [k, []]));
|
|
24486
24882
|
}
|
|
24487
|
-
/** A collection as rows; `spendEntries`, `reflections`, `priorityRules` and `
|
|
24883
|
+
/** A collection as rows; `spendEntries`, `reflections`, `priorityRules`, `workCalendar` and `dashboards` may be missing from older documents. */
|
|
24488
24884
|
function rowsOf(doc, key) {
|
|
24489
24885
|
if (isKeyed(key)) return KEYED[key].rows(doc[key]);
|
|
24490
24886
|
return doc[key] ?? [];
|
|
@@ -24569,7 +24965,8 @@ var init_changes = __esmMin((() => {
|
|
|
24569
24965
|
"spendEntries",
|
|
24570
24966
|
"reflections",
|
|
24571
24967
|
"priorityRules",
|
|
24572
|
-
"workCalendar"
|
|
24968
|
+
"workCalendar",
|
|
24969
|
+
"dashboards"
|
|
24573
24970
|
];
|
|
24574
24971
|
ROW_COLLECTIONS = [
|
|
24575
24972
|
"tasks",
|
|
@@ -24590,7 +24987,8 @@ var init_changes = __esmMin((() => {
|
|
|
24590
24987
|
"ropes",
|
|
24591
24988
|
"readPingIds",
|
|
24592
24989
|
"priorityRules",
|
|
24593
|
-
"workCalendar"
|
|
24990
|
+
"workCalendar",
|
|
24991
|
+
"dashboards"
|
|
24594
24992
|
];
|
|
24595
24993
|
KEYED = {
|
|
24596
24994
|
boardConfigs: {
|
|
@@ -28389,7 +28787,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
28389
28787
|
if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
|
|
28390
28788
|
label: "Any priority",
|
|
28391
28789
|
value: "any"
|
|
28392
|
-
}, ...PRIORITIES$
|
|
28790
|
+
}, ...PRIORITIES$2.map((p) => ({
|
|
28393
28791
|
label: PRIORITY_LABEL[p],
|
|
28394
28792
|
value: p
|
|
28395
28793
|
}))], (priority) => setFilter((f) => ({
|
|
@@ -29009,13 +29407,18 @@ const TRIGGER_LABEL = {
|
|
|
29009
29407
|
task_created: "A task is created",
|
|
29010
29408
|
status_changed_to: "Status is set to…",
|
|
29011
29409
|
priority_changed_to: "Priority is set to…",
|
|
29012
|
-
assigned: "A task is assigned to anyone"
|
|
29410
|
+
assigned: "A task is assigned to anyone",
|
|
29411
|
+
due_soon: "A task is due soon",
|
|
29412
|
+
overdue: "A task becomes overdue",
|
|
29413
|
+
untouched: "A task sits untouched"
|
|
29013
29414
|
};
|
|
29014
29415
|
const ACTION_LABEL = {
|
|
29015
29416
|
set_status: "Set status to…",
|
|
29016
29417
|
set_priority: "Set priority to…",
|
|
29017
29418
|
set_assignee: "Assign to…",
|
|
29018
|
-
add_comment: "Post a comment"
|
|
29419
|
+
add_comment: "Post a comment",
|
|
29420
|
+
set_due: "Move the due date",
|
|
29421
|
+
add_follower: "Add a follower"
|
|
29019
29422
|
};
|
|
29020
29423
|
function describeAction(a, peopleName) {
|
|
29021
29424
|
switch (a.type) {
|
|
@@ -29023,18 +29426,36 @@ function describeAction(a, peopleName) {
|
|
|
29023
29426
|
case "set_priority": return `set priority to ${a.priority ? PRIORITY_LABEL[a.priority] : "…"}`;
|
|
29024
29427
|
case "set_assignee": return `assign to ${peopleName(a.assigneeId)}`;
|
|
29025
29428
|
case "add_comment": return `post “${a.commentBody ?? ""}”`;
|
|
29429
|
+
case "set_due": {
|
|
29430
|
+
const n = a.inDays ?? 0;
|
|
29431
|
+
return n === 0 ? "set the due date to that day" : `set the due date ${n} ${n === 1 ? "day" : "days"} out`;
|
|
29432
|
+
}
|
|
29433
|
+
case "add_follower": return `add ${peopleName(a.personId)} as a follower`;
|
|
29026
29434
|
}
|
|
29027
29435
|
}
|
|
29436
|
+
const days$1 = (n) => `${n} ${n === 1 ? "day" : "days"}`;
|
|
29028
29437
|
function describeTrigger(rule) {
|
|
29029
29438
|
const t = rule.trigger;
|
|
29030
29439
|
if (t.type === "status_changed_to") return `When status is set to ${t.status ? TASK_STATUS_LABEL[t.status] : "…"}`;
|
|
29031
29440
|
if (t.type === "priority_changed_to") return `When priority is set to ${t.priority ? PRIORITY_LABEL[t.priority] : "…"}`;
|
|
29441
|
+
if (t.type === "due_soon") return `When a task is due within ${days$1(t.days ?? 2)}`;
|
|
29442
|
+
if (t.type === "untouched") return `When nobody touches a task for ${days$1(t.days ?? 7)}`;
|
|
29032
29443
|
return `When ${TRIGGER_LABEL[t.type].toLowerCase()}`;
|
|
29033
29444
|
}
|
|
29445
|
+
/** The "only if" line: "only High priority, owned by Ana", or "" when there's none. */
|
|
29446
|
+
function describeCondition(rule, peopleName) {
|
|
29447
|
+
const c = rule.onlyIf;
|
|
29448
|
+
if (!c) return "";
|
|
29449
|
+
const parts = [];
|
|
29450
|
+
if (c.priority) parts.push(`${PRIORITY_LABEL[c.priority]} priority`);
|
|
29451
|
+
if (c.assignee) parts.push(c.assignee === "nobody" ? "with no owner" : `owned by ${peopleName(c.assignee)}`);
|
|
29452
|
+
return parts.length ? `only ${parts.join(", ")}` : "";
|
|
29453
|
+
}
|
|
29034
29454
|
/** "When status is set to Done: set priority to Low, post “Shipped”". */
|
|
29035
29455
|
function describeRule(rule, peopleName) {
|
|
29036
29456
|
const did = rule.actions.map((a) => describeAction(a, peopleName)).join(", ");
|
|
29037
|
-
|
|
29457
|
+
const only = describeCondition(rule, peopleName);
|
|
29458
|
+
return `${describeTrigger(rule)}${only ? ` (${only})` : ""}: ${did || "no actions"}`;
|
|
29038
29459
|
}
|
|
29039
29460
|
function defaultAction(type) {
|
|
29040
29461
|
if (type === "set_status") return {
|
|
@@ -29049,6 +29470,14 @@ function defaultAction(type) {
|
|
|
29049
29470
|
type,
|
|
29050
29471
|
assigneeId: null
|
|
29051
29472
|
};
|
|
29473
|
+
if (type === "set_due") return {
|
|
29474
|
+
type,
|
|
29475
|
+
inDays: 3
|
|
29476
|
+
};
|
|
29477
|
+
if (type === "add_follower") return {
|
|
29478
|
+
type,
|
|
29479
|
+
personId: null
|
|
29480
|
+
};
|
|
29052
29481
|
return {
|
|
29053
29482
|
type,
|
|
29054
29483
|
commentBody: ""
|
|
@@ -29088,6 +29517,7 @@ function readFieldValue(def, text) {
|
|
|
29088
29517
|
* starts from, each column (name, shown, cap, place), lanes and what cards
|
|
29089
29518
|
* show, the project's own fields and its automations. b on a project opens it.
|
|
29090
29519
|
*/
|
|
29520
|
+
init_automations();
|
|
29091
29521
|
init_board();
|
|
29092
29522
|
init_swatches();
|
|
29093
29523
|
const LANES = [
|
|
@@ -29122,7 +29552,7 @@ const statusChoices = () => TASK_COLUMNS.map((s) => ({
|
|
|
29122
29552
|
label: TASK_STATUS_LABEL[s],
|
|
29123
29553
|
value: s
|
|
29124
29554
|
}));
|
|
29125
|
-
const priorityChoices = () => PRIORITIES$
|
|
29555
|
+
const priorityChoices = () => PRIORITIES$2.map((p) => ({
|
|
29126
29556
|
label: PRIORITY_LABEL[p],
|
|
29127
29557
|
value: p
|
|
29128
29558
|
}));
|
|
@@ -29376,6 +29806,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29376
29806
|
type,
|
|
29377
29807
|
priority: p
|
|
29378
29808
|
}));
|
|
29809
|
+
if (type === "due_soon" || type === "untouched") return go.ask(type === "due_soon" ? "Due within how many days?" : "Untouched for how many days?", (text) => {
|
|
29810
|
+
const n = Math.round(Number(text.trim()));
|
|
29811
|
+
if (!Number.isFinite(n) || n < 1) return;
|
|
29812
|
+
askAction({
|
|
29813
|
+
type,
|
|
29814
|
+
days: Math.min(60, n)
|
|
29815
|
+
});
|
|
29816
|
+
});
|
|
29379
29817
|
askAction({ type });
|
|
29380
29818
|
}
|
|
29381
29819
|
function askAction(trigger) {
|
|
@@ -29409,6 +29847,21 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29409
29847
|
...defaultAction(type),
|
|
29410
29848
|
assigneeId: pid
|
|
29411
29849
|
}));
|
|
29850
|
+
if (type === "set_due") return go.ask("Set the due date how many days out?", (text) => {
|
|
29851
|
+
const n = Math.round(Number(text.trim()));
|
|
29852
|
+
if (Number.isFinite(n) && n >= 0) add({
|
|
29853
|
+
type,
|
|
29854
|
+
inDays: Math.min(365, n)
|
|
29855
|
+
});
|
|
29856
|
+
});
|
|
29857
|
+
if (type === "add_follower") return go.pick("Add as a follower", people.map((p) => ({
|
|
29858
|
+
label: p.name,
|
|
29859
|
+
value: p.id,
|
|
29860
|
+
hint: p.role
|
|
29861
|
+
})), (pid) => add({
|
|
29862
|
+
type,
|
|
29863
|
+
personId: pid
|
|
29864
|
+
}));
|
|
29412
29865
|
go.ask("Comment to post", (text) => text.trim() && add({
|
|
29413
29866
|
type,
|
|
29414
29867
|
commentBody: text.trim()
|
|
@@ -29807,11 +30260,11 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
29807
30260
|
});
|
|
29808
30261
|
} else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
|
|
29809
30262
|
title: "Priority",
|
|
29810
|
-
choices: PRIORITIES$
|
|
30263
|
+
choices: PRIORITIES$2.map((p) => ({
|
|
29811
30264
|
label: PRIORITY_LABEL[p],
|
|
29812
30265
|
value: p
|
|
29813
30266
|
})),
|
|
29814
|
-
initial: PRIORITIES$
|
|
30267
|
+
initial: PRIORITIES$2.indexOf(task.priority),
|
|
29815
30268
|
onCancel: done,
|
|
29816
30269
|
onPick: (p) => {
|
|
29817
30270
|
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
|
|
@@ -557,17 +562,30 @@ async function readTitles(client, harbor, titles, values, today = todayLocal())
|
|
|
557
562
|
});
|
|
558
563
|
}
|
|
559
564
|
|
|
560
|
-
/**
|
|
565
|
+
/** "Fri 2026-11-13": a day with its weekday, so "next friday" shows which Friday it became. */
|
|
566
|
+
export function dayWithWeekday(iso) {
|
|
567
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso;
|
|
568
|
+
const day = WEEKDAYS[new Date(`${iso}T12:00:00`).getDay()];
|
|
569
|
+
return day ? `${day.charAt(0).toUpperCase()}${day.slice(1, 3)} ${iso}` : iso;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** What a title's words said, in a line: "Quinn (@Quinn), due Fri 2026-11-06 (by Friday)". */
|
|
561
573
|
function wordsLine(r) {
|
|
562
574
|
const bits = [
|
|
563
575
|
r.owner && `${r.owner.value} (${r.owner.text})`,
|
|
564
|
-
r.due && `due ${r.due.value} (${r.due.text})`,
|
|
576
|
+
r.due && `due ${dayWithWeekday(r.due.value)} (${r.due.text})`,
|
|
565
577
|
r.priority && `${r.priority.value} priority (${r.priority.text})`,
|
|
566
578
|
r.project && `in ${r.project.value} (${r.project.text})`,
|
|
567
579
|
].filter(Boolean);
|
|
568
580
|
return bits.length ? `Read from your words: ${bits.join(", ")}.` : null;
|
|
569
581
|
}
|
|
570
582
|
|
|
583
|
+
/** --due given in words rather than as a date or +N, when it reads as a day. */
|
|
584
|
+
function dueWords(v) {
|
|
585
|
+
if (typeof v !== "string" || /^(\d{4}-\d{2}-\d{2}|\+\d{1,3})$/.test(v) || PILOT.has(v.toLowerCase())) return false;
|
|
586
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(dueDate(v));
|
|
587
|
+
}
|
|
588
|
+
|
|
571
589
|
/** --due pilot: the date the owner's queue suggests, set by Harbor. */
|
|
572
590
|
const PILOT = new Set(["pilot", "suggested"]);
|
|
573
591
|
const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
|
|
@@ -755,6 +773,11 @@ const DETAIL = {
|
|
|
755
773
|
" ten lines with --comment add the same comment to all ten. More than 100",
|
|
756
774
|
" lines is harbor import's job.",
|
|
757
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
|
+
"",
|
|
758
781
|
" --due pilot takes the earliest day the owner's queue suggests: their open",
|
|
759
782
|
" work due first, their weekly pace, time away, what it waits on and how",
|
|
760
783
|
" long their similar tasks took. It needs an owner (--assignee none is",
|
|
@@ -1013,6 +1036,16 @@ const DETAIL = {
|
|
|
1013
1036
|
" these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
|
|
1014
1037
|
" NZ US (national holidays only).",
|
|
1015
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
|
+
],
|
|
1016
1049
|
goals: [
|
|
1017
1050
|
" harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
|
|
1018
1051
|
" left out), off track first, with how far",
|
|
@@ -2465,12 +2498,15 @@ export async function run(argv, io = {}) {
|
|
|
2465
2498
|
const { positionals, values, harbor } = parse(
|
|
2466
2499
|
spec(
|
|
2467
2500
|
["over-limit", "dry-run", "literal"],
|
|
2468
|
-
["project", "assignee", "also", "due", "priority", "status", "comment"],
|
|
2501
|
+
["project", "assignee", "also", "due", "priority", "status", "comment", "template"],
|
|
2469
2502
|
),
|
|
2470
2503
|
);
|
|
2504
|
+
// --template with no title: the template's own title.
|
|
2505
|
+
const fromTemplate = values.template !== undefined && positionals.length === 0;
|
|
2471
2506
|
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
2472
2507
|
const piped =
|
|
2473
|
-
|
|
2508
|
+
!fromTemplate &&
|
|
2509
|
+
(positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-"));
|
|
2474
2510
|
const stdin = io.stdin ?? process.stdin;
|
|
2475
2511
|
const titles =
|
|
2476
2512
|
piped && (io.stdin || !process.stdin.isTTY)
|
|
@@ -2478,7 +2514,9 @@ export async function run(argv, io = {}) {
|
|
|
2478
2514
|
.split(/\r?\n/)
|
|
2479
2515
|
.map((l) => l.trim())
|
|
2480
2516
|
.filter(Boolean)
|
|
2481
|
-
:
|
|
2517
|
+
: fromTemplate
|
|
2518
|
+
? [""]
|
|
2519
|
+
: [text(positionals, "the task")];
|
|
2482
2520
|
if (titles.length === 0)
|
|
2483
2521
|
throw new HarborError("Say the task, or give one title a line on stdin.");
|
|
2484
2522
|
if (titles.length > 100)
|
|
@@ -2487,7 +2525,7 @@ export async function run(argv, io = {}) {
|
|
|
2487
2525
|
);
|
|
2488
2526
|
// Plain words, as on the website: @person, by Friday, !high, #Project.
|
|
2489
2527
|
// --literal keeps every title exactly as typed.
|
|
2490
|
-
const reads = values.literal
|
|
2528
|
+
const reads = values.literal || fromTemplate
|
|
2491
2529
|
? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
|
|
2492
2530
|
: await readTitles(client, harbor, titles, values);
|
|
2493
2531
|
// No --project and no #Project: the harbor's one open project, if that's all there is.
|
|
@@ -2537,7 +2575,7 @@ export async function run(argv, io = {}) {
|
|
|
2537
2575
|
[
|
|
2538
2576
|
`Would add ${n} to ${where.join(" and ")}:`,
|
|
2539
2577
|
...reads.flatMap((r, i) => [
|
|
2540
|
-
` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
|
|
2578
|
+
` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueWords(values.due) ? dayWithWeekday(dueDate(values.due)) : dueDate(values.due)})` : ""}`,
|
|
2541
2579
|
...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
|
|
2542
2580
|
]),
|
|
2543
2581
|
...(pilot
|
|
@@ -2556,9 +2594,10 @@ export async function run(argv, io = {}) {
|
|
|
2556
2594
|
.call("create_task", {
|
|
2557
2595
|
harbor,
|
|
2558
2596
|
project: projectOf(words),
|
|
2559
|
-
title: words.title,
|
|
2560
|
-
|
|
2561
|
-
|
|
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"),
|
|
2562
2601
|
also: values.also === undefined ? undefined : alsoList(values.also),
|
|
2563
2602
|
due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
|
|
2564
2603
|
suggestedDue: duePilot(values.due),
|
|
@@ -2598,6 +2637,8 @@ export async function run(argv, io = {}) {
|
|
|
2598
2637
|
`Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
|
|
2599
2638
|
// What the title's words said, so nothing is read without being seen.
|
|
2600
2639
|
...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
|
|
2640
|
+
// --due in words ("next friday"): the day it became, weekday and all.
|
|
2641
|
+
...(dueWords(values.due) ? [`--due ${values.due}: ${dayWithWeekday(dueDate(values.due))}.`] : []),
|
|
2601
2642
|
// Pilot speaks up only when a date was given that the queue says won't hold.
|
|
2602
2643
|
...(values.due ? pilotLines(r, r.created) : []),
|
|
2603
2644
|
].join("\n"),
|
|
@@ -3175,6 +3216,23 @@ export async function run(argv, io = {}) {
|
|
|
3175
3216
|
soundingsText(r),
|
|
3176
3217
|
);
|
|
3177
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
|
+
}
|
|
3178
3236
|
case "goals": {
|
|
3179
3237
|
const { positionals, harbor } = parse(spec());
|
|
3180
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);
|