@harborppm/cli 0.84.0 → 0.86.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 +397 -91
- package/lib/cli.mjs +34 -2
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -5266,6 +5266,49 @@ function cascadeSummary(counts, kind) {
|
|
|
5266
5266
|
if (list.length === 1) return list[0];
|
|
5267
5267
|
return `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`;
|
|
5268
5268
|
}
|
|
5269
|
+
/** `data` with the cut links put back, wherever both ends exist again. */
|
|
5270
|
+
function relink(data, links) {
|
|
5271
|
+
const out = {};
|
|
5272
|
+
const exists = (key) => new Set(data[key].map((r) => r.id));
|
|
5273
|
+
const targetOf = {
|
|
5274
|
+
programs: "programs",
|
|
5275
|
+
projects: "projects",
|
|
5276
|
+
tasks: "tasks",
|
|
5277
|
+
milestones: "projects"
|
|
5278
|
+
};
|
|
5279
|
+
for (const key of [
|
|
5280
|
+
"programs",
|
|
5281
|
+
"projects",
|
|
5282
|
+
"tasks",
|
|
5283
|
+
"milestones"
|
|
5284
|
+
]) {
|
|
5285
|
+
const cut = links.blockers[key];
|
|
5286
|
+
if (!cut) continue;
|
|
5287
|
+
const there = exists(targetOf[key]);
|
|
5288
|
+
out[key] = data[key].map((row) => {
|
|
5289
|
+
const was = cut[row.id];
|
|
5290
|
+
if (!was) return row;
|
|
5291
|
+
const ids = [.../* @__PURE__ */ new Set([...was, ...blockerIds(row)])].filter((id) => there.has(id));
|
|
5292
|
+
return ids.length === blockerIds(row).length ? row : {
|
|
5293
|
+
...row,
|
|
5294
|
+
...blockersPatch(ids)
|
|
5295
|
+
};
|
|
5296
|
+
});
|
|
5297
|
+
}
|
|
5298
|
+
if (Object.keys(links.focus).length) {
|
|
5299
|
+
const tasks = exists("tasks");
|
|
5300
|
+
out.ropes = (data.ropes ?? []).map((r) => {
|
|
5301
|
+
const lost = links.focus[`${r.personId}|${r.date}`];
|
|
5302
|
+
if (!lost) return r;
|
|
5303
|
+
const back = lost.filter((id) => tasks.has(id) && !r.taskIds.includes(id));
|
|
5304
|
+
return back.length ? {
|
|
5305
|
+
...r,
|
|
5306
|
+
taskIds: [...r.taskIds, ...back]
|
|
5307
|
+
} : r;
|
|
5308
|
+
});
|
|
5309
|
+
}
|
|
5310
|
+
return out;
|
|
5311
|
+
}
|
|
5269
5312
|
var init_cascade = __esmMin((() => {
|
|
5270
5313
|
init_blockers();
|
|
5271
5314
|
}));
|
|
@@ -5731,15 +5774,19 @@ function roadmapDoneCount(data, portfolioId) {
|
|
|
5731
5774
|
}
|
|
5732
5775
|
|
|
5733
5776
|
// src/lib/search.ts
|
|
5777
|
+
/** Lower case without accents, so "cafe" finds "Café" and "Zoë" finds "Zoe". */
|
|
5778
|
+
function fold(text) {
|
|
5779
|
+
return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
5780
|
+
}
|
|
5734
5781
|
function tokensOf(query) {
|
|
5735
|
-
return query
|
|
5782
|
+
return fold(query).split(/\s+/).filter(Boolean);
|
|
5736
5783
|
}
|
|
5737
5784
|
/** Score a thing by its own name first, then by everything else it carries. */
|
|
5738
5785
|
function scoreMatch(query, name, ...secondary) {
|
|
5739
|
-
const q = query.trim()
|
|
5786
|
+
const q = fold(query.trim());
|
|
5740
5787
|
if (!q) return SCORE.anywhere;
|
|
5741
5788
|
const tokens = tokensOf(q);
|
|
5742
|
-
const n = name
|
|
5789
|
+
const n = fold(name);
|
|
5743
5790
|
if (n === q) return SCORE.exact;
|
|
5744
5791
|
if (n.startsWith(q)) return SCORE.prefix;
|
|
5745
5792
|
const words = n.split(/\s+/);
|
|
@@ -5747,7 +5794,7 @@ function scoreMatch(query, name, ...secondary) {
|
|
|
5747
5794
|
const bonus = tokens.every((t) => words.some((w) => w.startsWith(t))) ? 5 : 0;
|
|
5748
5795
|
return SCORE.name + bonus;
|
|
5749
5796
|
}
|
|
5750
|
-
const blob = [n, ...secondary.filter((s) => Boolean(s)).map(
|
|
5797
|
+
const blob = [n, ...secondary.filter((s) => Boolean(s)).map(fold)].join(" ");
|
|
5751
5798
|
return tokens.every((t) => blob.includes(t)) ? SCORE.anywhere : 0;
|
|
5752
5799
|
}
|
|
5753
5800
|
/** Keep what matches, best first. Ties keep the caller's order. */
|
|
@@ -19695,6 +19742,85 @@ function exportName(harborName, now) {
|
|
|
19695
19742
|
return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
|
|
19696
19743
|
}
|
|
19697
19744
|
|
|
19745
|
+
// src/lib/custom-field-text.ts
|
|
19746
|
+
init_date_text();
|
|
19747
|
+
const FIELD_TYPE_LABEL = {
|
|
19748
|
+
text: "Text",
|
|
19749
|
+
number: "Number",
|
|
19750
|
+
select: "Dropdown",
|
|
19751
|
+
multi: "Tags",
|
|
19752
|
+
checkbox: "Checkbox",
|
|
19753
|
+
date: "Date",
|
|
19754
|
+
person: "Person",
|
|
19755
|
+
url: "Link"
|
|
19756
|
+
};
|
|
19757
|
+
const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
|
|
19758
|
+
/** A dropdown or tags field: one with a list of options. */
|
|
19759
|
+
function hasOptions(type) {
|
|
19760
|
+
return type === "select" || type === "multi";
|
|
19761
|
+
}
|
|
19762
|
+
/** A dropdown's options from "S, M, L". */
|
|
19763
|
+
function optionsOf(value) {
|
|
19764
|
+
return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
|
|
19765
|
+
}
|
|
19766
|
+
/** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
|
|
19767
|
+
function safeLink(value) {
|
|
19768
|
+
if (typeof value !== "string") return null;
|
|
19769
|
+
try {
|
|
19770
|
+
const u = new URL(value.trim());
|
|
19771
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
|
|
19772
|
+
} catch {
|
|
19773
|
+
return null;
|
|
19774
|
+
}
|
|
19775
|
+
}
|
|
19776
|
+
/** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
|
|
19777
|
+
function readLink(text) {
|
|
19778
|
+
const t = text.trim();
|
|
19779
|
+
if (!t || /\s/.test(t)) return null;
|
|
19780
|
+
const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
|
|
19781
|
+
return href && /\./.test(new URL(href).hostname) ? href : null;
|
|
19782
|
+
}
|
|
19783
|
+
/** "example.com/docs/plan" from a link: what a short chip says. */
|
|
19784
|
+
function linkLabel(href) {
|
|
19785
|
+
try {
|
|
19786
|
+
const u = new URL(href);
|
|
19787
|
+
const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
|
|
19788
|
+
const text = u.host.replace(/^www\./, "") + path;
|
|
19789
|
+
return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
19790
|
+
} catch {
|
|
19791
|
+
return href;
|
|
19792
|
+
}
|
|
19793
|
+
}
|
|
19794
|
+
/** A tags field's value as a list, whatever was stored. */
|
|
19795
|
+
function tagsOf(value) {
|
|
19796
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
|
|
19797
|
+
return typeof value === "string" && value ? [value] : [];
|
|
19798
|
+
}
|
|
19799
|
+
/** Whether a task carries a value for the field. */
|
|
19800
|
+
function hasFieldValue(def, value) {
|
|
19801
|
+
if (value == null || value === "" || value === false) return false;
|
|
19802
|
+
if (def.type === "multi") return tagsOf(value).length > 0;
|
|
19803
|
+
return true;
|
|
19804
|
+
}
|
|
19805
|
+
/** A task's value for a field as one line: "" when it has none. */
|
|
19806
|
+
function fieldValueText(def, value, nameOf) {
|
|
19807
|
+
if (!hasFieldValue(def, value)) return "";
|
|
19808
|
+
switch (def.type) {
|
|
19809
|
+
case "checkbox": return value ? "Yes" : "";
|
|
19810
|
+
case "multi": return tagsOf(value).join(", ");
|
|
19811
|
+
case "date": {
|
|
19812
|
+
const d = parseIso(String(value));
|
|
19813
|
+
return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
|
|
19814
|
+
}
|
|
19815
|
+
case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
|
|
19816
|
+
case "url": {
|
|
19817
|
+
const href = safeLink(value);
|
|
19818
|
+
return href ? linkLabel(href) : "";
|
|
19819
|
+
}
|
|
19820
|
+
default: return Array.isArray(value) ? value.join(", ") : String(value);
|
|
19821
|
+
}
|
|
19822
|
+
}
|
|
19823
|
+
|
|
19698
19824
|
// src/lib/exports.ts
|
|
19699
19825
|
/**
|
|
19700
19826
|
* The spreadsheets Harbor hands out: a project's tasks, the risk register and
|
|
@@ -19723,6 +19849,8 @@ function tasksCsv(data, projectId) {
|
|
|
19723
19849
|
const here = byId(tasks);
|
|
19724
19850
|
const people = byId(data.people);
|
|
19725
19851
|
const name = (t) => taskKey(t, project) ?? t.title;
|
|
19852
|
+
const fields = project?.customFields ?? [];
|
|
19853
|
+
const nameOf = (id) => people.get(id)?.name;
|
|
19726
19854
|
return toCsv([
|
|
19727
19855
|
"Key",
|
|
19728
19856
|
"Title",
|
|
@@ -19732,7 +19860,8 @@ function tasksCsv(data, projectId) {
|
|
|
19732
19860
|
"Due",
|
|
19733
19861
|
"Done",
|
|
19734
19862
|
"Blocked by",
|
|
19735
|
-
"Description"
|
|
19863
|
+
"Description",
|
|
19864
|
+
...fields.map((f) => f.name)
|
|
19736
19865
|
], tasks.map((t) => [
|
|
19737
19866
|
taskKey(t, project) ?? "",
|
|
19738
19867
|
t.title,
|
|
@@ -19742,7 +19871,8 @@ function tasksCsv(data, projectId) {
|
|
|
19742
19871
|
t.dueDate ?? "",
|
|
19743
19872
|
t.status === "done" ? "yes" : "no",
|
|
19744
19873
|
blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
|
|
19745
|
-
t.description
|
|
19874
|
+
t.description,
|
|
19875
|
+
...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
|
|
19746
19876
|
]));
|
|
19747
19877
|
}
|
|
19748
19878
|
/** Where a risk, issue or decision lives, by name; null for the whole harbor. */
|
|
@@ -20631,6 +20761,26 @@ function soundingOut(data, t, meId, now) {
|
|
|
20631
20761
|
};
|
|
20632
20762
|
}
|
|
20633
20763
|
const dayOf = (iso) => iso ? iso.slice(0, 10) : null;
|
|
20764
|
+
/** A project's own fields, as set_field takes them (custom-field-text.ts). */
|
|
20765
|
+
function fieldDefsOut(p) {
|
|
20766
|
+
return (p.customFields ?? []).map((f) => ({
|
|
20767
|
+
name: f.name,
|
|
20768
|
+
type: FIELD_TYPE_LABEL[f.type].toLowerCase(),
|
|
20769
|
+
...hasOptions(f.type) ? { options: f.options ?? [] } : {}
|
|
20770
|
+
}));
|
|
20771
|
+
}
|
|
20772
|
+
/** A task's values for its project's own fields, by field name: { Size: "L" }. */
|
|
20773
|
+
function fieldValuesOut(data, p, t) {
|
|
20774
|
+
if (!p?.customFields?.length || !t.customFieldValues) return null;
|
|
20775
|
+
const people = byId(data.people);
|
|
20776
|
+
const out = {};
|
|
20777
|
+
for (const f of p.customFields) {
|
|
20778
|
+
const v = t.customFieldValues[f.id];
|
|
20779
|
+
if (!hasFieldValue(f, v)) continue;
|
|
20780
|
+
out[f.name] = f.type === "multi" ? tagsOf(v) : f.type === "person" ? nameOf$2(people, String(v)) ?? "someone no longer here" : f.type === "number" || f.type === "checkbox" ? v : f.type === "url" ? safeLink(v) ?? "" : String(v);
|
|
20781
|
+
}
|
|
20782
|
+
return Object.keys(out).length ? out : null;
|
|
20783
|
+
}
|
|
20634
20784
|
/** A task's approval (src/lib/task-approval.ts), as an agent reads it. */
|
|
20635
20785
|
function approvalOut(data, a) {
|
|
20636
20786
|
const people = byId(data.people);
|
|
@@ -20993,7 +21143,7 @@ const HARBOR_TOOLS = [
|
|
|
20993
21143
|
tool({
|
|
20994
21144
|
name: "get_project",
|
|
20995
21145
|
title: "Project details",
|
|
20996
|
-
description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), and its url on the website." + DATA_NOTE,
|
|
21146
|
+
description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
|
|
20997
21147
|
input: object({
|
|
20998
21148
|
harbor: harborArg,
|
|
20999
21149
|
project: string().min(1).max(200).describe("Project id or name.")
|
|
@@ -21054,6 +21204,7 @@ const HARBOR_TOOLS = [
|
|
|
21054
21204
|
waitsOn,
|
|
21055
21205
|
milestones,
|
|
21056
21206
|
openRaid: raid,
|
|
21207
|
+
...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
|
|
21057
21208
|
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
|
|
21058
21209
|
...charterOf(p, tasks),
|
|
21059
21210
|
...notesOf(ctx, p),
|
|
@@ -21098,12 +21249,13 @@ const HARBOR_TOOLS = [
|
|
|
21098
21249
|
if (input.query) {
|
|
21099
21250
|
const q = input.query;
|
|
21100
21251
|
const named = findByKey(data, q);
|
|
21252
|
+
const names = new Map(data.projects.map((p) => [p.id, p.name]));
|
|
21101
21253
|
list = list.map((t) => ({
|
|
21102
21254
|
t,
|
|
21103
|
-
s: scoreMatch(q, t.title, t.description)
|
|
21255
|
+
s: scoreMatch(q, t.title, t.description, names.get(t.projectId))
|
|
21104
21256
|
})).filter((h) => h.s > 0 && h.t !== named).sort((a, b) => b.s - a.s).map((h) => h.t);
|
|
21105
21257
|
if (named) list.unshift(named);
|
|
21106
|
-
if (!input.status?.length) doneMatches = data.tasks.filter((t) => t.status === "done" && t !== named && fits(t) && scoreMatch(q, t.title, t.description) > 0).length;
|
|
21258
|
+
if (!input.status?.length) doneMatches = data.tasks.filter((t) => t.status === "done" && t !== named && fits(t) && scoreMatch(q, t.title, t.description, names.get(t.projectId)) > 0).length;
|
|
21107
21259
|
} else list = [...list].sort((a, b) => a.status === "done" && b.status === "done" ? (b.completedAt ?? "").localeCompare(a.completedAt ?? "") : Number(a.status === "done") - Number(b.status === "done") || (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999"));
|
|
21108
21260
|
return {
|
|
21109
21261
|
...doneMatches ? { doneMatches } : {},
|
|
@@ -21115,7 +21267,7 @@ const HARBOR_TOOLS = [
|
|
|
21115
21267
|
tool({
|
|
21116
21268
|
name: "get_task",
|
|
21117
21269
|
title: "Task details",
|
|
21118
|
-
description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), and its url on the website." + DATA_NOTE,
|
|
21270
|
+
description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), and its url on the website." + DATA_NOTE,
|
|
21119
21271
|
input: object({
|
|
21120
21272
|
harbor: harborArg,
|
|
21121
21273
|
task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
|
|
@@ -21169,6 +21321,7 @@ const HARBOR_TOOLS = [
|
|
|
21169
21321
|
waitedOnBy: waiters.map(brief),
|
|
21170
21322
|
...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
|
|
21171
21323
|
...t.approval ? { approval: approvalOut(data, t.approval) } : {},
|
|
21324
|
+
...((v) => v ? { fields: v } : {})(fieldValuesOut(data, project, t)),
|
|
21172
21325
|
...pilot ? { pilotDue: {
|
|
21173
21326
|
earliest: pilot.earliest,
|
|
21174
21327
|
fits: pilot.fits,
|
|
@@ -22773,7 +22926,10 @@ function withStatus(task, status) {
|
|
|
22773
22926
|
touchedAt: stamp()
|
|
22774
22927
|
};
|
|
22775
22928
|
if (status === "done") next.completedAt = stamp();
|
|
22776
|
-
else if (task.status === "done")
|
|
22929
|
+
else if (task.status === "done") {
|
|
22930
|
+
next.completedAt = null;
|
|
22931
|
+
if (task.approval?.status === "approved") next.approval = null;
|
|
22932
|
+
}
|
|
22777
22933
|
return next;
|
|
22778
22934
|
}
|
|
22779
22935
|
var init_shared = __esmMin((() => {}));
|
|
@@ -22946,6 +23102,26 @@ var init_session = __esmMin((() => {
|
|
|
22946
23102
|
init_format();
|
|
22947
23103
|
}));
|
|
22948
23104
|
|
|
23105
|
+
// src/lib/assignment.ts
|
|
23106
|
+
/**
|
|
23107
|
+
* Record who set a task's assignee and when. Only a change of assignee
|
|
23108
|
+
* stamps; clearing the assignee clears the stamp.
|
|
23109
|
+
*/
|
|
23110
|
+
function stampAssignment(before, next, by, at) {
|
|
23111
|
+
if (before && before.assigneeId === next.assigneeId) return next;
|
|
23112
|
+
if (!next.assigneeId) return {
|
|
23113
|
+
...next,
|
|
23114
|
+
assignedBy: null,
|
|
23115
|
+
assignedAt: null
|
|
23116
|
+
};
|
|
23117
|
+
return {
|
|
23118
|
+
...next,
|
|
23119
|
+
assignedBy: by,
|
|
23120
|
+
assignedAt: at
|
|
23121
|
+
};
|
|
23122
|
+
}
|
|
23123
|
+
var init_assignment = __esmMin((() => {}));
|
|
23124
|
+
|
|
22949
23125
|
// src/lib/first-week.ts
|
|
22950
23126
|
/** The program launch named for you, when nobody has renamed it yet. */
|
|
22951
23127
|
function starterProgram(data) {
|
|
@@ -23272,13 +23448,13 @@ function hierarchySlice({ get, write }) {
|
|
|
23272
23448
|
const program = get().programs.find((p) => p.id === programId);
|
|
23273
23449
|
get().log(`moved ${ids.length} project${ids.length === 1 ? "" : "s"} to ${program?.name ?? "a program"}`, `/programs/${programId}`);
|
|
23274
23450
|
},
|
|
23275
|
-
restoreRows: (rows) => {
|
|
23451
|
+
restoreRows: (rows, links) => {
|
|
23276
23452
|
const state = get();
|
|
23277
23453
|
const back = (current, add) => {
|
|
23278
23454
|
const have = new Set((current ?? []).map((r) => r.id));
|
|
23279
23455
|
return [...current ?? [], ...(add ?? []).filter((r) => !have.has(r.id))];
|
|
23280
23456
|
};
|
|
23281
|
-
|
|
23457
|
+
const next = {
|
|
23282
23458
|
portfolios: back(state.portfolios, rows.portfolios),
|
|
23283
23459
|
programs: back(state.programs, rows.programs),
|
|
23284
23460
|
projects: back(state.projects, rows.projects),
|
|
@@ -23294,7 +23470,14 @@ function hierarchySlice({ get, write }) {
|
|
|
23294
23470
|
...rows.boardConfigs ?? {},
|
|
23295
23471
|
...state.boardConfigs ?? {}
|
|
23296
23472
|
}
|
|
23297
|
-
}
|
|
23473
|
+
};
|
|
23474
|
+
write(links ? {
|
|
23475
|
+
...next,
|
|
23476
|
+
...relink({
|
|
23477
|
+
...state,
|
|
23478
|
+
...next
|
|
23479
|
+
}, links)
|
|
23480
|
+
} : next);
|
|
23298
23481
|
const name = rows.portfolios?.[0]?.name ?? rows.programs?.[0]?.name ?? rows.projects?.[0]?.name;
|
|
23299
23482
|
if (name) get().log(`put back “${name}”`);
|
|
23300
23483
|
},
|
|
@@ -23305,10 +23488,13 @@ function hierarchySlice({ get, write }) {
|
|
|
23305
23488
|
const ids = have(current);
|
|
23306
23489
|
return [...current, ...(more ?? []).filter((r) => !ids.has(r.id))];
|
|
23307
23490
|
};
|
|
23491
|
+
const small = (rows.tasks?.length ?? 0) <= 20;
|
|
23492
|
+
const at = stamp();
|
|
23493
|
+
const incoming = small ? rows.tasks?.map((t) => stampAssignment(void 0, t, state.currentUserId, at)) : rows.tasks;
|
|
23308
23494
|
write({
|
|
23309
23495
|
programs: add(state.programs, rows.programs),
|
|
23310
23496
|
projects: add(state.projects, rows.projects),
|
|
23311
|
-
tasks: add(state.tasks,
|
|
23497
|
+
tasks: add(state.tasks, incoming),
|
|
23312
23498
|
people: add(state.people, rows.people),
|
|
23313
23499
|
raidItems: add(state.raidItems, rows.raidItems)
|
|
23314
23500
|
});
|
|
@@ -23379,6 +23565,7 @@ function hierarchySlice({ get, write }) {
|
|
|
23379
23565
|
}
|
|
23380
23566
|
};
|
|
23381
23567
|
}
|
|
23568
|
+
var SMALL_IMPORT;
|
|
23382
23569
|
var init_hierarchy = __esmMin((() => {
|
|
23383
23570
|
init_board();
|
|
23384
23571
|
init_cascade();
|
|
@@ -23388,11 +23575,13 @@ var init_hierarchy = __esmMin((() => {
|
|
|
23388
23575
|
init_health_history();
|
|
23389
23576
|
init_derived_health();
|
|
23390
23577
|
init_shared();
|
|
23578
|
+
init_assignment();
|
|
23391
23579
|
init_hill();
|
|
23392
23580
|
init_charter_lines();
|
|
23393
23581
|
init_format();
|
|
23394
23582
|
init_empty();
|
|
23395
23583
|
init_first_week();
|
|
23584
|
+
SMALL_IMPORT = 20;
|
|
23396
23585
|
}));
|
|
23397
23586
|
|
|
23398
23587
|
// src/lib/github-refs.ts
|
|
@@ -23441,6 +23630,16 @@ function runActions(actions, start, ctx) {
|
|
|
23441
23630
|
for (const action of actions) switch (action.type) {
|
|
23442
23631
|
case "set_status": {
|
|
23443
23632
|
if (!action.status) break;
|
|
23633
|
+
if (action.status === "done" && task.status !== "done" && ctx.waiting?.(task)) {
|
|
23634
|
+
comments.push({
|
|
23635
|
+
taskId: task.id,
|
|
23636
|
+
authorId: ctx.authorId,
|
|
23637
|
+
body: "Left open: this still waits on unfinished work, so the rule didn’t finish it.",
|
|
23638
|
+
createdAt: ctx.now,
|
|
23639
|
+
mentionedIds: []
|
|
23640
|
+
});
|
|
23641
|
+
break;
|
|
23642
|
+
}
|
|
23444
23643
|
const wasDone = task.status === "done";
|
|
23445
23644
|
const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
|
|
23446
23645
|
task = {
|
|
@@ -23506,7 +23705,8 @@ function applyAutomations(project, prev, next, opts) {
|
|
|
23506
23705
|
const ctx = {
|
|
23507
23706
|
authorId: opts.currentUserId,
|
|
23508
23707
|
now,
|
|
23509
|
-
today
|
|
23708
|
+
today,
|
|
23709
|
+
waiting: opts.waiting
|
|
23510
23710
|
};
|
|
23511
23711
|
let task = next;
|
|
23512
23712
|
const comments = [];
|
|
@@ -23612,26 +23812,6 @@ var init_recurrence = __esmMin((() => {
|
|
|
23612
23812
|
step = (d, repeat, n) => repeat === "weekly" ? addDays(d, 7 * n) : repeat === "biweekly" ? addDays(d, 14 * n) : addMonths$1(d, n);
|
|
23613
23813
|
}));
|
|
23614
23814
|
|
|
23615
|
-
// src/lib/assignment.ts
|
|
23616
|
-
/**
|
|
23617
|
-
* Record who set a task's assignee and when. Only a change of assignee
|
|
23618
|
-
* stamps; clearing the assignee clears the stamp.
|
|
23619
|
-
*/
|
|
23620
|
-
function stampAssignment(before, next, by, at) {
|
|
23621
|
-
if (before && before.assigneeId === next.assigneeId) return next;
|
|
23622
|
-
if (!next.assigneeId) return {
|
|
23623
|
-
...next,
|
|
23624
|
-
assignedBy: null,
|
|
23625
|
-
assignedAt: null
|
|
23626
|
-
};
|
|
23627
|
-
return {
|
|
23628
|
-
...next,
|
|
23629
|
-
assignedBy: by,
|
|
23630
|
-
assignedAt: at
|
|
23631
|
-
};
|
|
23632
|
-
}
|
|
23633
|
-
var init_assignment = __esmMin((() => {}));
|
|
23634
|
-
|
|
23635
23815
|
// src/lib/bulk.ts
|
|
23636
23816
|
/** A task's due date moved by `days`, or null when it has none. */
|
|
23637
23817
|
function shiftedDue(due, days) {
|
|
@@ -23728,7 +23908,8 @@ function tasksSlice({ set, get, write }) {
|
|
|
23728
23908
|
const project = get().projects.find((p) => p.id === t.projectId);
|
|
23729
23909
|
const { task: automated, comments: autoComments } = applyAutomations(project, void 0, row, {
|
|
23730
23910
|
created: true,
|
|
23731
|
-
currentUserId: get().currentUserId
|
|
23911
|
+
currentUserId: get().currentUserId,
|
|
23912
|
+
waiting: (t) => isBlocked$1(t, get().tasks)
|
|
23732
23913
|
});
|
|
23733
23914
|
const finalRow = stampAssignment(void 0, automated, get().currentUserId, stamp());
|
|
23734
23915
|
write({
|
|
@@ -23771,12 +23952,16 @@ function tasksSlice({ set, get, write }) {
|
|
|
23771
23952
|
if (next.assigneeId && next.followerIds?.includes(next.assigneeId)) next.followerIds = alsoOnIds(next);
|
|
23772
23953
|
if (patch.status && patch.status !== row.status) {
|
|
23773
23954
|
if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
|
|
23774
|
-
else if (row.status === "done" && patch.status !== "done")
|
|
23955
|
+
else if (row.status === "done" && patch.status !== "done") {
|
|
23956
|
+
next.completedAt = null;
|
|
23957
|
+
if (row.approval?.status === "approved" && !("approval" in patch)) next.approval = null;
|
|
23958
|
+
}
|
|
23775
23959
|
if (patch.status === "done" && isBlocked$1(row, get().tasks)) Object.assign(next, blockersPatch([]));
|
|
23776
23960
|
}
|
|
23777
23961
|
const { task: finalTask, comments: autoComments } = applyAutomations(project, row, next, {
|
|
23778
23962
|
created: false,
|
|
23779
|
-
currentUserId: get().currentUserId
|
|
23963
|
+
currentUserId: get().currentUserId,
|
|
23964
|
+
waiting: (t) => isBlocked$1(t, get().tasks)
|
|
23780
23965
|
});
|
|
23781
23966
|
newComments.push(...autoComments.map((c) => ({
|
|
23782
23967
|
...c,
|
|
@@ -23815,7 +24000,8 @@ function tasksSlice({ set, get, write }) {
|
|
|
23815
24000
|
const project = get().projects.find((p) => p.id === task.projectId);
|
|
23816
24001
|
const { task: finalRow, comments: autoComments } = applyAutomations(project, task, nextRow, {
|
|
23817
24002
|
created: false,
|
|
23818
|
-
currentUserId: get().currentUserId
|
|
24003
|
+
currentUserId: get().currentUserId,
|
|
24004
|
+
waiting: (t) => isBlocked$1(t, get().tasks)
|
|
23819
24005
|
});
|
|
23820
24006
|
const follow = status === "done" ? nextOccurrence(finalRow, uid("t"), todayISO(), stamp(), tasksOf(get().tasks, task.projectId)) : null;
|
|
23821
24007
|
const moved = get().tasks.map((row) => row.id === id ? finalRow : row);
|
|
@@ -23897,7 +24083,8 @@ function tasksSlice({ set, get, write }) {
|
|
|
23897
24083
|
},
|
|
23898
24084
|
updateComment: (id, body) => {
|
|
23899
24085
|
const comment = get().comments.find((c) => c.id === id);
|
|
23900
|
-
if (!comment
|
|
24086
|
+
if (!comment) return;
|
|
24087
|
+
if (comment.authorId !== get().currentUserId && get().myRole !== "owner") return;
|
|
23901
24088
|
const trimmed = body.trim();
|
|
23902
24089
|
if (!trimmed) return;
|
|
23903
24090
|
const mentionedIds = parseMentions(trimmed, get().people).filter((pid) => pid !== get().currentUserId);
|
|
@@ -23910,7 +24097,8 @@ function tasksSlice({ set, get, write }) {
|
|
|
23910
24097
|
},
|
|
23911
24098
|
deleteComment: (id) => {
|
|
23912
24099
|
const comment = get().comments.find((c) => c.id === id);
|
|
23913
|
-
if (!comment
|
|
24100
|
+
if (!comment) return;
|
|
24101
|
+
if (comment.authorId !== get().currentUserId && get().myRole !== "owner") return;
|
|
23914
24102
|
set({ comments: get().comments.filter((c) => c.id !== id) });
|
|
23915
24103
|
},
|
|
23916
24104
|
addChecklistItem: (taskId, title) => {
|
|
@@ -24091,7 +24279,8 @@ function tasksSlice({ set, get, write }) {
|
|
|
24091
24279
|
}
|
|
24092
24280
|
const { task: automated, comments } = applyAutomations(projects.get(row.projectId), row, next, {
|
|
24093
24281
|
created: false,
|
|
24094
|
-
currentUserId: me
|
|
24282
|
+
currentUserId: me,
|
|
24283
|
+
waiting: (t) => isBlocked$1(t, s.tasks)
|
|
24095
24284
|
});
|
|
24096
24285
|
newComments.push(...comments.map((c) => ({
|
|
24097
24286
|
...c,
|
|
@@ -24251,6 +24440,7 @@ var init_raid_history = __esmMin((() => {}));
|
|
|
24251
24440
|
function milestonesRaidSlice({ set, get, write }) {
|
|
24252
24441
|
return {
|
|
24253
24442
|
toggleMilestone: (id) => {
|
|
24443
|
+
if (get().milestones.find((m) => m.id === id)?.approverId) return;
|
|
24254
24444
|
write({ milestones: get().milestones.map((m) => m.id === id ? {
|
|
24255
24445
|
...m,
|
|
24256
24446
|
done: !m.done
|
|
@@ -26371,7 +26561,7 @@ function fieldsOf(data, task) {
|
|
|
26371
26561
|
};
|
|
26372
26562
|
}
|
|
26373
26563
|
/** The person a file's owner names: their whole name, else a first name or initials only they have. */
|
|
26374
|
-
function personNamed$
|
|
26564
|
+
function personNamed$2(people, name) {
|
|
26375
26565
|
const want = name.trim().toLowerCase();
|
|
26376
26566
|
if (!want) return null;
|
|
26377
26567
|
const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
|
|
@@ -26413,7 +26603,7 @@ function taskPatch(data, fields, say, label) {
|
|
|
26413
26603
|
if (fields.repeat !== void 0) patch.repeat = fields.repeat;
|
|
26414
26604
|
if (fields.notes !== void 0) patch.description = fields.notes;
|
|
26415
26605
|
if (fields.owner !== void 0) {
|
|
26416
|
-
const person = fields.owner ? personNamed$
|
|
26606
|
+
const person = fields.owner ? personNamed$2(data.people, fields.owner) : null;
|
|
26417
26607
|
if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
|
|
26418
26608
|
patch.assigneeId = person?.id ?? null;
|
|
26419
26609
|
}
|
|
@@ -26574,7 +26764,7 @@ function applyFolder(store, projectId, files, base, opts) {
|
|
|
26574
26764
|
let fresh = 0;
|
|
26575
26765
|
if (opts.fileWins) for (const text of files.values()) {
|
|
26576
26766
|
const owner = parseTask(text).fields?.owner;
|
|
26577
|
-
if (owner && !personNamed$
|
|
26767
|
+
if (owner && !personNamed$2(get().people, owner)) {
|
|
26578
26768
|
const { id: _id, ...person } = localPerson(owner, "");
|
|
26579
26769
|
get().addPerson(person);
|
|
26580
26770
|
}
|
|
@@ -26836,7 +27026,7 @@ async function moveLocalTasks(folder, store, projectId, say) {
|
|
|
26836
27026
|
};
|
|
26837
27027
|
for (const t of local.tasks) {
|
|
26838
27028
|
const owner = t.assigneeId ? local.people.find((p) => p.id === t.assigneeId)?.name : null;
|
|
26839
|
-
const person = owner ? personNamed$
|
|
27029
|
+
const person = owner ? personNamed$2(get().people, owner) : null;
|
|
26840
27030
|
if (owner && !person) say(`${t.title}: nobody called "${owner}" is in the harbor, so it has no owner.`);
|
|
26841
27031
|
const id = get().addTask({
|
|
26842
27032
|
projectId,
|
|
@@ -27062,7 +27252,7 @@ function taskIn(data, project, key) {
|
|
|
27062
27252
|
if (!task || task.projectId !== project.id) throw new HarborError(`No task ${key} in ${project.name}. Use its key, like ${project.key ?? "RR"}-4.`);
|
|
27063
27253
|
return task;
|
|
27064
27254
|
}
|
|
27065
|
-
const personNamed = (data, name) => {
|
|
27255
|
+
const personNamed$1 = (data, name) => {
|
|
27066
27256
|
try {
|
|
27067
27257
|
return findOne(data.people, name, "person");
|
|
27068
27258
|
} catch (e) {
|
|
@@ -27103,7 +27293,7 @@ function changesFrom(data, project, flags, now) {
|
|
|
27103
27293
|
for (const [key, who] of pairs(flags.give, "give", "KEY=NAME (RL-4=Sam, RL-5=none)")) add({
|
|
27104
27294
|
kind: "assign",
|
|
27105
27295
|
taskId: taskIn(data, project, key).id,
|
|
27106
|
-
assigneeId: who === "none" ? null : personNamed(data, who).id
|
|
27296
|
+
assigneeId: who === "none" ? null : personNamed$1(data, who).id
|
|
27107
27297
|
});
|
|
27108
27298
|
if (flags.end) add({
|
|
27109
27299
|
kind: "end",
|
|
@@ -27115,13 +27305,13 @@ function changesFrom(data, project, flags, now) {
|
|
|
27115
27305
|
if (!Number.isInteger(days) || days < 1 || days > 5) throw new HarborError("--borrow takes NAME or NAME=DAYS, days a week from 1 to 5.");
|
|
27116
27306
|
add({
|
|
27117
27307
|
kind: "borrow",
|
|
27118
|
-
personId: personNamed(data, name.trim()).id,
|
|
27308
|
+
personId: personNamed$1(data, name.trim()).id,
|
|
27119
27309
|
days
|
|
27120
27310
|
});
|
|
27121
27311
|
}
|
|
27122
27312
|
for (const name of list(flags.release)) add({
|
|
27123
27313
|
kind: "release",
|
|
27124
|
-
personId: personNamed(data, name).id
|
|
27314
|
+
personId: personNamed$1(data, name).id
|
|
27125
27315
|
});
|
|
27126
27316
|
return changes;
|
|
27127
27317
|
}
|
|
@@ -28305,8 +28495,12 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
28305
28495
|
} else if (key.upArrow || input === "k") setMAt((i) => step$1(i, -1, marks.length));
|
|
28306
28496
|
else if (key.downArrow || input === "j") setMAt((i) => step$1(i, 1, marks.length));
|
|
28307
28497
|
else if (key.return && mark) go.openPanel("milestone", mark.id);
|
|
28308
|
-
else if (input === " " && mark)
|
|
28309
|
-
|
|
28498
|
+
else if (input === " " && mark) {
|
|
28499
|
+
if (mark.approverId) {
|
|
28500
|
+
const approver = people.find((p) => p.id === mark.approverId);
|
|
28501
|
+
go.notify(`${approver?.name ?? "The approver"} ticks this off by signing it off.`);
|
|
28502
|
+
} else store.getState().toggleMilestone(mark.id);
|
|
28503
|
+
} else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
|
|
28310
28504
|
}, { isActive: active });
|
|
28311
28505
|
if (!program) return /* @__PURE__ */ jsx(Text, {
|
|
28312
28506
|
color: theme.muted,
|
|
@@ -29660,33 +29854,6 @@ function defaultAction(type) {
|
|
|
29660
29854
|
};
|
|
29661
29855
|
}
|
|
29662
29856
|
|
|
29663
|
-
// src/lib/custom-field-text.ts
|
|
29664
|
-
const FIELD_TYPE_LABEL = {
|
|
29665
|
-
text: "Text",
|
|
29666
|
-
number: "Number",
|
|
29667
|
-
select: "Dropdown",
|
|
29668
|
-
checkbox: "Checkbox"
|
|
29669
|
-
};
|
|
29670
|
-
const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
|
|
29671
|
-
/** A dropdown's options from "S, M, L". */
|
|
29672
|
-
function optionsOf(value) {
|
|
29673
|
-
return value.split(",").map((o) => o.trim()).filter(Boolean);
|
|
29674
|
-
}
|
|
29675
|
-
/** A task's value for a field as one line: "" when it has none. */
|
|
29676
|
-
function fieldValueText(def, value) {
|
|
29677
|
-
if (value == null || value === "") return "";
|
|
29678
|
-
if (def.type === "checkbox") return value ? "Yes" : "";
|
|
29679
|
-
return String(value);
|
|
29680
|
-
}
|
|
29681
|
-
/** What someone typed for a text or number field; undefined when a number can't be read. */
|
|
29682
|
-
function readFieldValue(def, text) {
|
|
29683
|
-
const t = text.trim();
|
|
29684
|
-
if (!t) return null;
|
|
29685
|
-
if (def.type !== "number") return t;
|
|
29686
|
-
const n = Number(t.replace(/,/g, ""));
|
|
29687
|
-
return Number.isFinite(n) ? n : void 0;
|
|
29688
|
-
}
|
|
29689
|
-
|
|
29690
29857
|
// src/tui/views/setup.tsx
|
|
29691
29858
|
/**
|
|
29692
29859
|
* A project's board setup, as the website's board settings have it: where it
|
|
@@ -29743,7 +29910,8 @@ function columnValue(c) {
|
|
|
29743
29910
|
}
|
|
29744
29911
|
function fieldValue(f) {
|
|
29745
29912
|
const kind = FIELD_TYPE_LABEL[f.type];
|
|
29746
|
-
|
|
29913
|
+
const head = hasOptions(f.type) && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
|
|
29914
|
+
return f.onCards ? `${head} · on cards` : head;
|
|
29747
29915
|
}
|
|
29748
29916
|
function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
29749
29917
|
const { store } = useDeps();
|
|
@@ -29892,10 +30060,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29892
30060
|
label: "Rename it",
|
|
29893
30061
|
value: "rename"
|
|
29894
30062
|
},
|
|
29895
|
-
...f.type
|
|
30063
|
+
...hasOptions(f.type) ? [{
|
|
29896
30064
|
label: "Change its options",
|
|
29897
30065
|
value: "options"
|
|
29898
30066
|
}] : [],
|
|
30067
|
+
{
|
|
30068
|
+
label: f.onCards ? "Stop showing it on cards" : "Show it on board cards",
|
|
30069
|
+
value: "cards"
|
|
30070
|
+
},
|
|
29899
30071
|
{
|
|
29900
30072
|
label: "Take it out",
|
|
29901
30073
|
value: "remove"
|
|
@@ -29904,6 +30076,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29904
30076
|
onPick: (what) => {
|
|
29905
30077
|
const s = store.getState();
|
|
29906
30078
|
if (what === "rename") return go.ask("Field name", (text) => text.trim() && s.updateCustomField(id, f.id, { name: text.trim() }), f.name);
|
|
30079
|
+
if (what === "cards") return s.updateCustomField(id, f.id, { onCards: f.onCards ? void 0 : true });
|
|
29907
30080
|
if (what === "options") return go.ask("Options, split by commas", (text) => s.updateCustomField(id, f.id, { options: optionsOf(text) }), (f.options ?? []).join(", "));
|
|
29908
30081
|
go.confirm(`Take out the field “${f.name}” and every task's value for it? u undoes it.`, () => s.removeCustomField(id, f.id), "Take it out");
|
|
29909
30082
|
}
|
|
@@ -29924,7 +30097,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29924
30097
|
value: t
|
|
29925
30098
|
})), (t) => {
|
|
29926
30099
|
const type = t;
|
|
29927
|
-
if (type
|
|
30100
|
+
if (!hasOptions(type)) return void store.getState().addCustomField(id, {
|
|
29928
30101
|
name: trimmed,
|
|
29929
30102
|
type
|
|
29930
30103
|
});
|
|
@@ -30058,6 +30231,113 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
30058
30231
|
});
|
|
30059
30232
|
}
|
|
30060
30233
|
|
|
30234
|
+
// src/lib/date-picker.ts
|
|
30235
|
+
/**
|
|
30236
|
+
* The date picker's rules: the days a month's grid shows (always six weeks,
|
|
30237
|
+
* so the calendar keeps one height and stops jumping as you flip months),
|
|
30238
|
+
* the years its year list offers, and what a typed date means. Pure.
|
|
30239
|
+
*/
|
|
30240
|
+
/**
|
|
30241
|
+
* The day a typed date names, as an ISO day, or null when it names none.
|
|
30242
|
+
* Reads what the task composer reads ("Mar 14", "14 March 2027", "3/14",
|
|
30243
|
+
* "2027-03-14", "next friday", "in 2 weeks", "tomorrow"), plus a year after
|
|
30244
|
+
* a slash date in full ("3/14/2027"), and full stops or dashes for slashes.
|
|
30245
|
+
*/
|
|
30246
|
+
function typedDate(text, today) {
|
|
30247
|
+
const t = text.trim().replace(/\s+/g, " ").replace(/^(\d{1,2})[.-](\d{1,2})(?:[.-](\d{2,4}))?$/, (_, m, d, y) => y ? `${m}/${d}/${y}` : `${m}/${d}`);
|
|
30248
|
+
if (!t) return null;
|
|
30249
|
+
return dayFromWords(t, today);
|
|
30250
|
+
}
|
|
30251
|
+
|
|
30252
|
+
// src/lib/custom-field-read.ts
|
|
30253
|
+
/**
|
|
30254
|
+
* What someone typed for one of a project's own fields, as the value kept on
|
|
30255
|
+
* the task: in the terminal, the CLI and AI apps (the website's drawer has a
|
|
30256
|
+
* control per type). Pure.
|
|
30257
|
+
*/
|
|
30258
|
+
const YES = /* @__PURE__ */ new Set([
|
|
30259
|
+
"yes",
|
|
30260
|
+
"y",
|
|
30261
|
+
"true",
|
|
30262
|
+
"on",
|
|
30263
|
+
"1",
|
|
30264
|
+
"x",
|
|
30265
|
+
"✓"
|
|
30266
|
+
]);
|
|
30267
|
+
const NO = /* @__PURE__ */ new Set([
|
|
30268
|
+
"no",
|
|
30269
|
+
"n",
|
|
30270
|
+
"false",
|
|
30271
|
+
"off",
|
|
30272
|
+
"0"
|
|
30273
|
+
]);
|
|
30274
|
+
function option(def, text) {
|
|
30275
|
+
const t = text.trim().toLowerCase();
|
|
30276
|
+
return (def.options ?? []).find((o) => o.toLowerCase() === t);
|
|
30277
|
+
}
|
|
30278
|
+
/** The person a name names: their whole name, first name, or id; one match only. */
|
|
30279
|
+
function personNamed(people, text) {
|
|
30280
|
+
const t = text.trim().replace(/^@/, "").toLowerCase();
|
|
30281
|
+
if (!t) return void 0;
|
|
30282
|
+
const exact = people.filter((p) => p.id === t || p.name.toLowerCase() === t);
|
|
30283
|
+
if (exact.length === 1) return exact[0];
|
|
30284
|
+
const first = people.filter((p) => p.name.toLowerCase().split(/\s+/)[0] === t);
|
|
30285
|
+
if (first.length === 1) return first[0];
|
|
30286
|
+
return first.length > 1 ? "many" : void 0;
|
|
30287
|
+
}
|
|
30288
|
+
/**
|
|
30289
|
+
* A typed value for a field: an empty string clears it. Says what's wrong
|
|
30290
|
+
* when it can't be read (an option the field doesn't have, a name that
|
|
30291
|
+
* matches nobody, a day it can't read).
|
|
30292
|
+
*/
|
|
30293
|
+
function readFieldValue(def, text, ctx) {
|
|
30294
|
+
const t = text.trim();
|
|
30295
|
+
if (!t) return { value: def.type === "multi" ? [] : null };
|
|
30296
|
+
switch (def.type) {
|
|
30297
|
+
case "number": {
|
|
30298
|
+
const n = Number(t.replace(/,/g, ""));
|
|
30299
|
+
return Number.isFinite(n) ? { value: n } : { error: `“${t}” isn't a number.` };
|
|
30300
|
+
}
|
|
30301
|
+
case "checkbox": {
|
|
30302
|
+
const l = t.toLowerCase();
|
|
30303
|
+
if (YES.has(l)) return { value: true };
|
|
30304
|
+
if (NO.has(l)) return { value: false };
|
|
30305
|
+
return { error: "Say yes or no." };
|
|
30306
|
+
}
|
|
30307
|
+
case "select": {
|
|
30308
|
+
const o = option(def, t);
|
|
30309
|
+
return o ? { value: o } : { error: offer(def, t) };
|
|
30310
|
+
}
|
|
30311
|
+
case "multi": {
|
|
30312
|
+
const picked = [];
|
|
30313
|
+
for (const part of t.split(",").map((p) => p.trim()).filter(Boolean)) {
|
|
30314
|
+
const o = option(def, part);
|
|
30315
|
+
if (!o) return { error: offer(def, part) };
|
|
30316
|
+
if (!picked.includes(o)) picked.push(o);
|
|
30317
|
+
}
|
|
30318
|
+
return { value: picked };
|
|
30319
|
+
}
|
|
30320
|
+
case "date": {
|
|
30321
|
+
const day = typedDate(t, ctx.today);
|
|
30322
|
+
return day ? { value: day } : { error: `Couldn't read “${t}” as a day.` };
|
|
30323
|
+
}
|
|
30324
|
+
case "person": {
|
|
30325
|
+
const p = personNamed(ctx.people, t);
|
|
30326
|
+
if (p === "many") return { error: `More than one person is called “${t}”; use the full name.` };
|
|
30327
|
+
return p ? { value: p.id } : { error: `Nobody here is called “${t}”.` };
|
|
30328
|
+
}
|
|
30329
|
+
case "url": {
|
|
30330
|
+
const href = readLink(t);
|
|
30331
|
+
return href ? { value: href } : { error: `“${t}” isn't a web address.` };
|
|
30332
|
+
}
|
|
30333
|
+
default: return { value: t.slice(0, 500) };
|
|
30334
|
+
}
|
|
30335
|
+
}
|
|
30336
|
+
function offer(def, t) {
|
|
30337
|
+
const opts = def.options ?? [];
|
|
30338
|
+
return opts.length ? `“${t}” isn't one of ${def.name}'s options: ${opts.join(", ")}.` : `${def.name} has no options yet.`;
|
|
30339
|
+
}
|
|
30340
|
+
|
|
30061
30341
|
// src/tui/views/sounding.ts
|
|
30062
30342
|
init_soundings();
|
|
30063
30343
|
const nameOf = (people, id) => people.find((p) => p.id === id)?.name ?? "someone";
|
|
@@ -30310,6 +30590,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30310
30590
|
const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
|
|
30311
30591
|
const blockers = blockersOf(task, tasks);
|
|
30312
30592
|
const done = () => setEditing(null);
|
|
30593
|
+
const nameOf = (id) => people.find((p) => p.id === id)?.name;
|
|
30313
30594
|
const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
|
|
30314
30595
|
function editCustom(f) {
|
|
30315
30596
|
if (!task) return;
|
|
@@ -30322,6 +30603,13 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30322
30603
|
label: o,
|
|
30323
30604
|
value: o
|
|
30324
30605
|
}))], (v) => setCustom(f.id, v));
|
|
30606
|
+
if (f.type === "person") return go.pick(f.name, [{
|
|
30607
|
+
label: "Nobody",
|
|
30608
|
+
value: null
|
|
30609
|
+
}, ...people.filter((p) => p.kind !== "agent").map((p) => ({
|
|
30610
|
+
label: p.name,
|
|
30611
|
+
value: p.id
|
|
30612
|
+
}))], (v) => setCustom(f.id, v));
|
|
30325
30613
|
setEditing({
|
|
30326
30614
|
kind: "custom",
|
|
30327
30615
|
fieldId: f.id
|
|
@@ -30409,7 +30697,9 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30409
30697
|
const f = own.find((x) => x.id === editing.fieldId);
|
|
30410
30698
|
if (f) editor = /* @__PURE__ */ jsx(CustomField, {
|
|
30411
30699
|
def: f,
|
|
30412
|
-
initial:
|
|
30700
|
+
initial: typedText(f, task.customFieldValues?.[f.id]),
|
|
30701
|
+
today: day,
|
|
30702
|
+
people,
|
|
30413
30703
|
onCancel: done,
|
|
30414
30704
|
onSubmit: (v) => {
|
|
30415
30705
|
setCustom(f.id, v);
|
|
@@ -30605,7 +30895,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30605
30895
|
color: theme.muted,
|
|
30606
30896
|
children: f.name.slice(0, 8).padEnd(9)
|
|
30607
30897
|
}),
|
|
30608
|
-
fieldValueText(f, task.customFieldValues?.[f.id]) || "—"
|
|
30898
|
+
fieldValueText(f, task.customFieldValues?.[f.id], nameOf) || "—"
|
|
30609
30899
|
]
|
|
30610
30900
|
}, f.id);
|
|
30611
30901
|
}),
|
|
@@ -30696,18 +30986,34 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30696
30986
|
]
|
|
30697
30987
|
});
|
|
30698
30988
|
}
|
|
30699
|
-
/**
|
|
30700
|
-
function
|
|
30989
|
+
/** What a field's value reads as when it's typed again: the day or link as kept. */
|
|
30990
|
+
function typedText(def, value) {
|
|
30991
|
+
if (value == null || value === false) return "";
|
|
30992
|
+
if (def.type === "multi") return tagsOf(value).join(", ");
|
|
30993
|
+
if (def.type === "checkbox") return "yes";
|
|
30994
|
+
return String(value);
|
|
30995
|
+
}
|
|
30996
|
+
const FIELD_HINT = {
|
|
30997
|
+
number: "A number, or empty for none",
|
|
30998
|
+
date: "A day (2026-12-01, friday, in 2 weeks), or empty for none",
|
|
30999
|
+
url: "A web address, or empty for none"
|
|
31000
|
+
};
|
|
31001
|
+
/** A typed field of the project's own; a value that can't be read says why. */
|
|
31002
|
+
function CustomField({ def, initial, today, people, onSubmit, onCancel }) {
|
|
30701
31003
|
const [error, setError] = useState(null);
|
|
31004
|
+
const hint = def.type === "multi" ? `Any of ${(def.options ?? []).join(", ") || "(no tags yet)"}, split by commas` : FIELD_HINT[def.type] ?? `${FIELD_TYPE_LABEL[def.type]}, or empty for none`;
|
|
30702
31005
|
return /* @__PURE__ */ jsx(TextField, {
|
|
30703
31006
|
label: def.name,
|
|
30704
31007
|
initial,
|
|
30705
|
-
hint: error ??
|
|
31008
|
+
hint: error ?? hint,
|
|
30706
31009
|
onCancel,
|
|
30707
31010
|
onSubmit: (text) => {
|
|
30708
|
-
const
|
|
30709
|
-
|
|
30710
|
-
|
|
31011
|
+
const read = readFieldValue(def, text, {
|
|
31012
|
+
today,
|
|
31013
|
+
people
|
|
31014
|
+
});
|
|
31015
|
+
if ("error" in read) return setError(read.error);
|
|
31016
|
+
onSubmit(read.value);
|
|
30711
31017
|
}
|
|
30712
31018
|
});
|
|
30713
31019
|
}
|
package/lib/cli.mjs
CHANGED
|
@@ -273,6 +273,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
273
273
|
approve a task (finishes it), send
|
|
274
274
|
it back, or ask someone to approve
|
|
275
275
|
harbor approvals tasks waiting on your approval
|
|
276
|
+
harbor field TASK NAME VALUE set one of the project's own
|
|
277
|
+
fields on a task ("" clears it)
|
|
276
278
|
harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
|
|
277
279
|
public holidays and time away
|
|
278
280
|
coming up, and whose holidays
|
|
@@ -1067,6 +1069,14 @@ const DETAIL = {
|
|
|
1067
1069
|
" oldest ask first",
|
|
1068
1070
|
" harbor approve TASK [--changes NOTE] writes: approve it or send it back",
|
|
1069
1071
|
],
|
|
1072
|
+
field: [
|
|
1073
|
+
" harbor field TASK NAME VALUE writes: sets one of the project's own",
|
|
1074
|
+
" fields on a task",
|
|
1075
|
+
"",
|
|
1076
|
+
" Type the value as you would say it: a dropdown's option, tags split by",
|
|
1077
|
+
' commas, a person\'s name ("me" for you), a day (2026-12-01, friday), a',
|
|
1078
|
+
' web address, or yes or no. "" clears it. harbor show TASK lists them.',
|
|
1079
|
+
],
|
|
1070
1080
|
goals: [
|
|
1071
1081
|
" harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
|
|
1072
1082
|
" left out), off track first, with how far",
|
|
@@ -2279,6 +2289,8 @@ export async function run(argv, io = {}) {
|
|
|
2279
2289
|
];
|
|
2280
2290
|
if (t.alsoOn?.length) lines.push(`Also on it: ${t.alsoOn.join(", ")}.`);
|
|
2281
2291
|
if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
|
|
2292
|
+
for (const [name, v] of Object.entries(t.fields ?? {}))
|
|
2293
|
+
lines.push(`${name}: ${Array.isArray(v) ? v.join(", ") : v === true ? "yes" : v}`);
|
|
2282
2294
|
if (t.pilotDue?.fits) lines.push(`${t.pilotDue.says}, so the due date holds.`);
|
|
2283
2295
|
else if (t.pilotDue) lines.push(...pilotLines({ pilot: t.pilotDue }, t));
|
|
2284
2296
|
if (t.description) lines.push("", t.description);
|
|
@@ -2725,7 +2737,7 @@ export async function run(argv, io = {}) {
|
|
|
2725
2737
|
},
|
|
2726
2738
|
(r) =>
|
|
2727
2739
|
[
|
|
2728
|
-
`${r.updated.title}: ${{ doing: "started", review: "in review", done: "done" }[status]}.`,
|
|
2740
|
+
`${r.updated.title}: ${r.reopened ? "reopened, " : ""}${{ doing: "started", review: "in review", done: "done" }[status]}.`,
|
|
2729
2741
|
r.queuedNext ? `Next one: ${taskLine(r.queuedNext, opts)}` : "",
|
|
2730
2742
|
r.projectFinished
|
|
2731
2743
|
? `That was the last open task in ${r.projectFinished}. Mark the project Done in the app (harbor) or on the website.`
|
|
@@ -3272,7 +3284,27 @@ export async function run(argv, io = {}) {
|
|
|
3272
3284
|
case "approvals": {
|
|
3273
3285
|
const { positionals, harbor } = parse(spec());
|
|
3274
3286
|
if (positionals.length) throw new HarborError(`Unexpected "${positionals.join(" ")}".`);
|
|
3275
|
-
return call(
|
|
3287
|
+
return call(
|
|
3288
|
+
"my_work",
|
|
3289
|
+
{ harbor, limit: 1 },
|
|
3290
|
+
(r) => approvalsText(r),
|
|
3291
|
+
(r) => ({ approvalsToGive: r.approvalsToGive ?? [] }),
|
|
3292
|
+
);
|
|
3293
|
+
}
|
|
3294
|
+
case "field": {
|
|
3295
|
+
const { positionals, harbor } = parse(spec());
|
|
3296
|
+
const [task, field, ...value] = positionals;
|
|
3297
|
+
if (!task || !field)
|
|
3298
|
+
throw new HarborError('Give a task, a field and a value, like harbor field RR-4 Size L (or "" to clear).');
|
|
3299
|
+
return call("set_field", { harbor, task, field, value: value.join(" ") }, (r) => {
|
|
3300
|
+
const name = r.set.key ?? r.set.title;
|
|
3301
|
+
const said = Object.entries(r.set.fields ?? {}).find(
|
|
3302
|
+
([n]) => n.toLowerCase() === field.trim().toLowerCase(),
|
|
3303
|
+
);
|
|
3304
|
+
if (!said) return `Cleared ${field} on ${name}.`;
|
|
3305
|
+
const v = said[1];
|
|
3306
|
+
return `${name}: ${said[0]} is ${Array.isArray(v) ? v.join(", ") : v === true ? "yes" : v}.`;
|
|
3307
|
+
});
|
|
3276
3308
|
}
|
|
3277
3309
|
case "templates": {
|
|
3278
3310
|
const { positionals, harbor } = parse(spec());
|