@harborppm/cli 0.84.0 → 0.85.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 +274 -52
- package/lib/cli.mjs +33 -1
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -19695,6 +19695,85 @@ function exportName(harborName, now) {
|
|
|
19695
19695
|
return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
|
|
19696
19696
|
}
|
|
19697
19697
|
|
|
19698
|
+
// src/lib/custom-field-text.ts
|
|
19699
|
+
init_date_text();
|
|
19700
|
+
const FIELD_TYPE_LABEL = {
|
|
19701
|
+
text: "Text",
|
|
19702
|
+
number: "Number",
|
|
19703
|
+
select: "Dropdown",
|
|
19704
|
+
multi: "Tags",
|
|
19705
|
+
checkbox: "Checkbox",
|
|
19706
|
+
date: "Date",
|
|
19707
|
+
person: "Person",
|
|
19708
|
+
url: "Link"
|
|
19709
|
+
};
|
|
19710
|
+
const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
|
|
19711
|
+
/** A dropdown or tags field: one with a list of options. */
|
|
19712
|
+
function hasOptions(type) {
|
|
19713
|
+
return type === "select" || type === "multi";
|
|
19714
|
+
}
|
|
19715
|
+
/** A dropdown's options from "S, M, L". */
|
|
19716
|
+
function optionsOf(value) {
|
|
19717
|
+
return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
|
|
19718
|
+
}
|
|
19719
|
+
/** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
|
|
19720
|
+
function safeLink(value) {
|
|
19721
|
+
if (typeof value !== "string") return null;
|
|
19722
|
+
try {
|
|
19723
|
+
const u = new URL(value.trim());
|
|
19724
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
|
|
19725
|
+
} catch {
|
|
19726
|
+
return null;
|
|
19727
|
+
}
|
|
19728
|
+
}
|
|
19729
|
+
/** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
|
|
19730
|
+
function readLink(text) {
|
|
19731
|
+
const t = text.trim();
|
|
19732
|
+
if (!t || /\s/.test(t)) return null;
|
|
19733
|
+
const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
|
|
19734
|
+
return href && /\./.test(new URL(href).hostname) ? href : null;
|
|
19735
|
+
}
|
|
19736
|
+
/** "example.com/docs/plan" from a link: what a short chip says. */
|
|
19737
|
+
function linkLabel(href) {
|
|
19738
|
+
try {
|
|
19739
|
+
const u = new URL(href);
|
|
19740
|
+
const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
|
|
19741
|
+
const text = u.host.replace(/^www\./, "") + path;
|
|
19742
|
+
return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
19743
|
+
} catch {
|
|
19744
|
+
return href;
|
|
19745
|
+
}
|
|
19746
|
+
}
|
|
19747
|
+
/** A tags field's value as a list, whatever was stored. */
|
|
19748
|
+
function tagsOf(value) {
|
|
19749
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
|
|
19750
|
+
return typeof value === "string" && value ? [value] : [];
|
|
19751
|
+
}
|
|
19752
|
+
/** Whether a task carries a value for the field. */
|
|
19753
|
+
function hasFieldValue(def, value) {
|
|
19754
|
+
if (value == null || value === "" || value === false) return false;
|
|
19755
|
+
if (def.type === "multi") return tagsOf(value).length > 0;
|
|
19756
|
+
return true;
|
|
19757
|
+
}
|
|
19758
|
+
/** A task's value for a field as one line: "" when it has none. */
|
|
19759
|
+
function fieldValueText(def, value, nameOf) {
|
|
19760
|
+
if (!hasFieldValue(def, value)) return "";
|
|
19761
|
+
switch (def.type) {
|
|
19762
|
+
case "checkbox": return value ? "Yes" : "";
|
|
19763
|
+
case "multi": return tagsOf(value).join(", ");
|
|
19764
|
+
case "date": {
|
|
19765
|
+
const d = parseIso(String(value));
|
|
19766
|
+
return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
|
|
19767
|
+
}
|
|
19768
|
+
case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
|
|
19769
|
+
case "url": {
|
|
19770
|
+
const href = safeLink(value);
|
|
19771
|
+
return href ? linkLabel(href) : "";
|
|
19772
|
+
}
|
|
19773
|
+
default: return Array.isArray(value) ? value.join(", ") : String(value);
|
|
19774
|
+
}
|
|
19775
|
+
}
|
|
19776
|
+
|
|
19698
19777
|
// src/lib/exports.ts
|
|
19699
19778
|
/**
|
|
19700
19779
|
* The spreadsheets Harbor hands out: a project's tasks, the risk register and
|
|
@@ -19723,6 +19802,8 @@ function tasksCsv(data, projectId) {
|
|
|
19723
19802
|
const here = byId(tasks);
|
|
19724
19803
|
const people = byId(data.people);
|
|
19725
19804
|
const name = (t) => taskKey(t, project) ?? t.title;
|
|
19805
|
+
const fields = project?.customFields ?? [];
|
|
19806
|
+
const nameOf = (id) => people.get(id)?.name;
|
|
19726
19807
|
return toCsv([
|
|
19727
19808
|
"Key",
|
|
19728
19809
|
"Title",
|
|
@@ -19732,7 +19813,8 @@ function tasksCsv(data, projectId) {
|
|
|
19732
19813
|
"Due",
|
|
19733
19814
|
"Done",
|
|
19734
19815
|
"Blocked by",
|
|
19735
|
-
"Description"
|
|
19816
|
+
"Description",
|
|
19817
|
+
...fields.map((f) => f.name)
|
|
19736
19818
|
], tasks.map((t) => [
|
|
19737
19819
|
taskKey(t, project) ?? "",
|
|
19738
19820
|
t.title,
|
|
@@ -19742,7 +19824,8 @@ function tasksCsv(data, projectId) {
|
|
|
19742
19824
|
t.dueDate ?? "",
|
|
19743
19825
|
t.status === "done" ? "yes" : "no",
|
|
19744
19826
|
blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
|
|
19745
|
-
t.description
|
|
19827
|
+
t.description,
|
|
19828
|
+
...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
|
|
19746
19829
|
]));
|
|
19747
19830
|
}
|
|
19748
19831
|
/** Where a risk, issue or decision lives, by name; null for the whole harbor. */
|
|
@@ -20631,6 +20714,26 @@ function soundingOut(data, t, meId, now) {
|
|
|
20631
20714
|
};
|
|
20632
20715
|
}
|
|
20633
20716
|
const dayOf = (iso) => iso ? iso.slice(0, 10) : null;
|
|
20717
|
+
/** A project's own fields, as set_field takes them (custom-field-text.ts). */
|
|
20718
|
+
function fieldDefsOut(p) {
|
|
20719
|
+
return (p.customFields ?? []).map((f) => ({
|
|
20720
|
+
name: f.name,
|
|
20721
|
+
type: FIELD_TYPE_LABEL[f.type].toLowerCase(),
|
|
20722
|
+
...hasOptions(f.type) ? { options: f.options ?? [] } : {}
|
|
20723
|
+
}));
|
|
20724
|
+
}
|
|
20725
|
+
/** A task's values for its project's own fields, by field name: { Size: "L" }. */
|
|
20726
|
+
function fieldValuesOut(data, p, t) {
|
|
20727
|
+
if (!p?.customFields?.length || !t.customFieldValues) return null;
|
|
20728
|
+
const people = byId(data.people);
|
|
20729
|
+
const out = {};
|
|
20730
|
+
for (const f of p.customFields) {
|
|
20731
|
+
const v = t.customFieldValues[f.id];
|
|
20732
|
+
if (!hasFieldValue(f, v)) continue;
|
|
20733
|
+
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);
|
|
20734
|
+
}
|
|
20735
|
+
return Object.keys(out).length ? out : null;
|
|
20736
|
+
}
|
|
20634
20737
|
/** A task's approval (src/lib/task-approval.ts), as an agent reads it. */
|
|
20635
20738
|
function approvalOut(data, a) {
|
|
20636
20739
|
const people = byId(data.people);
|
|
@@ -20993,7 +21096,7 @@ const HARBOR_TOOLS = [
|
|
|
20993
21096
|
tool({
|
|
20994
21097
|
name: "get_project",
|
|
20995
21098
|
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,
|
|
21099
|
+
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
21100
|
input: object({
|
|
20998
21101
|
harbor: harborArg,
|
|
20999
21102
|
project: string().min(1).max(200).describe("Project id or name.")
|
|
@@ -21054,6 +21157,7 @@ const HARBOR_TOOLS = [
|
|
|
21054
21157
|
waitsOn,
|
|
21055
21158
|
milestones,
|
|
21056
21159
|
openRaid: raid,
|
|
21160
|
+
...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
|
|
21057
21161
|
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
|
|
21058
21162
|
...charterOf(p, tasks),
|
|
21059
21163
|
...notesOf(ctx, p),
|
|
@@ -21115,7 +21219,7 @@ const HARBOR_TOOLS = [
|
|
|
21115
21219
|
tool({
|
|
21116
21220
|
name: "get_task",
|
|
21117
21221
|
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,
|
|
21222
|
+
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
21223
|
input: object({
|
|
21120
21224
|
harbor: harborArg,
|
|
21121
21225
|
task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
|
|
@@ -21169,6 +21273,7 @@ const HARBOR_TOOLS = [
|
|
|
21169
21273
|
waitedOnBy: waiters.map(brief),
|
|
21170
21274
|
...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
|
|
21171
21275
|
...t.approval ? { approval: approvalOut(data, t.approval) } : {},
|
|
21276
|
+
...((v) => v ? { fields: v } : {})(fieldValuesOut(data, project, t)),
|
|
21172
21277
|
...pilot ? { pilotDue: {
|
|
21173
21278
|
earliest: pilot.earliest,
|
|
21174
21279
|
fits: pilot.fits,
|
|
@@ -24251,6 +24356,7 @@ var init_raid_history = __esmMin((() => {}));
|
|
|
24251
24356
|
function milestonesRaidSlice({ set, get, write }) {
|
|
24252
24357
|
return {
|
|
24253
24358
|
toggleMilestone: (id) => {
|
|
24359
|
+
if (get().milestones.find((m) => m.id === id)?.approverId) return;
|
|
24254
24360
|
write({ milestones: get().milestones.map((m) => m.id === id ? {
|
|
24255
24361
|
...m,
|
|
24256
24362
|
done: !m.done
|
|
@@ -26371,7 +26477,7 @@ function fieldsOf(data, task) {
|
|
|
26371
26477
|
};
|
|
26372
26478
|
}
|
|
26373
26479
|
/** The person a file's owner names: their whole name, else a first name or initials only they have. */
|
|
26374
|
-
function personNamed$
|
|
26480
|
+
function personNamed$2(people, name) {
|
|
26375
26481
|
const want = name.trim().toLowerCase();
|
|
26376
26482
|
if (!want) return null;
|
|
26377
26483
|
const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
|
|
@@ -26413,7 +26519,7 @@ function taskPatch(data, fields, say, label) {
|
|
|
26413
26519
|
if (fields.repeat !== void 0) patch.repeat = fields.repeat;
|
|
26414
26520
|
if (fields.notes !== void 0) patch.description = fields.notes;
|
|
26415
26521
|
if (fields.owner !== void 0) {
|
|
26416
|
-
const person = fields.owner ? personNamed$
|
|
26522
|
+
const person = fields.owner ? personNamed$2(data.people, fields.owner) : null;
|
|
26417
26523
|
if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
|
|
26418
26524
|
patch.assigneeId = person?.id ?? null;
|
|
26419
26525
|
}
|
|
@@ -26574,7 +26680,7 @@ function applyFolder(store, projectId, files, base, opts) {
|
|
|
26574
26680
|
let fresh = 0;
|
|
26575
26681
|
if (opts.fileWins) for (const text of files.values()) {
|
|
26576
26682
|
const owner = parseTask(text).fields?.owner;
|
|
26577
|
-
if (owner && !personNamed$
|
|
26683
|
+
if (owner && !personNamed$2(get().people, owner)) {
|
|
26578
26684
|
const { id: _id, ...person } = localPerson(owner, "");
|
|
26579
26685
|
get().addPerson(person);
|
|
26580
26686
|
}
|
|
@@ -26836,7 +26942,7 @@ async function moveLocalTasks(folder, store, projectId, say) {
|
|
|
26836
26942
|
};
|
|
26837
26943
|
for (const t of local.tasks) {
|
|
26838
26944
|
const owner = t.assigneeId ? local.people.find((p) => p.id === t.assigneeId)?.name : null;
|
|
26839
|
-
const person = owner ? personNamed$
|
|
26945
|
+
const person = owner ? personNamed$2(get().people, owner) : null;
|
|
26840
26946
|
if (owner && !person) say(`${t.title}: nobody called "${owner}" is in the harbor, so it has no owner.`);
|
|
26841
26947
|
const id = get().addTask({
|
|
26842
26948
|
projectId,
|
|
@@ -27062,7 +27168,7 @@ function taskIn(data, project, key) {
|
|
|
27062
27168
|
if (!task || task.projectId !== project.id) throw new HarborError(`No task ${key} in ${project.name}. Use its key, like ${project.key ?? "RR"}-4.`);
|
|
27063
27169
|
return task;
|
|
27064
27170
|
}
|
|
27065
|
-
const personNamed = (data, name) => {
|
|
27171
|
+
const personNamed$1 = (data, name) => {
|
|
27066
27172
|
try {
|
|
27067
27173
|
return findOne(data.people, name, "person");
|
|
27068
27174
|
} catch (e) {
|
|
@@ -27103,7 +27209,7 @@ function changesFrom(data, project, flags, now) {
|
|
|
27103
27209
|
for (const [key, who] of pairs(flags.give, "give", "KEY=NAME (RL-4=Sam, RL-5=none)")) add({
|
|
27104
27210
|
kind: "assign",
|
|
27105
27211
|
taskId: taskIn(data, project, key).id,
|
|
27106
|
-
assigneeId: who === "none" ? null : personNamed(data, who).id
|
|
27212
|
+
assigneeId: who === "none" ? null : personNamed$1(data, who).id
|
|
27107
27213
|
});
|
|
27108
27214
|
if (flags.end) add({
|
|
27109
27215
|
kind: "end",
|
|
@@ -27115,13 +27221,13 @@ function changesFrom(data, project, flags, now) {
|
|
|
27115
27221
|
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
27222
|
add({
|
|
27117
27223
|
kind: "borrow",
|
|
27118
|
-
personId: personNamed(data, name.trim()).id,
|
|
27224
|
+
personId: personNamed$1(data, name.trim()).id,
|
|
27119
27225
|
days
|
|
27120
27226
|
});
|
|
27121
27227
|
}
|
|
27122
27228
|
for (const name of list(flags.release)) add({
|
|
27123
27229
|
kind: "release",
|
|
27124
|
-
personId: personNamed(data, name).id
|
|
27230
|
+
personId: personNamed$1(data, name).id
|
|
27125
27231
|
});
|
|
27126
27232
|
return changes;
|
|
27127
27233
|
}
|
|
@@ -28305,8 +28411,12 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
28305
28411
|
} else if (key.upArrow || input === "k") setMAt((i) => step$1(i, -1, marks.length));
|
|
28306
28412
|
else if (key.downArrow || input === "j") setMAt((i) => step$1(i, 1, marks.length));
|
|
28307
28413
|
else if (key.return && mark) go.openPanel("milestone", mark.id);
|
|
28308
|
-
else if (input === " " && mark)
|
|
28309
|
-
|
|
28414
|
+
else if (input === " " && mark) {
|
|
28415
|
+
if (mark.approverId) {
|
|
28416
|
+
const approver = people.find((p) => p.id === mark.approverId);
|
|
28417
|
+
go.notify(`${approver?.name ?? "The approver"} ticks this off by signing it off.`);
|
|
28418
|
+
} else store.getState().toggleMilestone(mark.id);
|
|
28419
|
+
} else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
|
|
28310
28420
|
}, { isActive: active });
|
|
28311
28421
|
if (!program) return /* @__PURE__ */ jsx(Text, {
|
|
28312
28422
|
color: theme.muted,
|
|
@@ -29660,33 +29770,6 @@ function defaultAction(type) {
|
|
|
29660
29770
|
};
|
|
29661
29771
|
}
|
|
29662
29772
|
|
|
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
29773
|
// src/tui/views/setup.tsx
|
|
29691
29774
|
/**
|
|
29692
29775
|
* A project's board setup, as the website's board settings have it: where it
|
|
@@ -29743,7 +29826,8 @@ function columnValue(c) {
|
|
|
29743
29826
|
}
|
|
29744
29827
|
function fieldValue(f) {
|
|
29745
29828
|
const kind = FIELD_TYPE_LABEL[f.type];
|
|
29746
|
-
|
|
29829
|
+
const head = hasOptions(f.type) && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
|
|
29830
|
+
return f.onCards ? `${head} · on cards` : head;
|
|
29747
29831
|
}
|
|
29748
29832
|
function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
29749
29833
|
const { store } = useDeps();
|
|
@@ -29892,10 +29976,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29892
29976
|
label: "Rename it",
|
|
29893
29977
|
value: "rename"
|
|
29894
29978
|
},
|
|
29895
|
-
...f.type
|
|
29979
|
+
...hasOptions(f.type) ? [{
|
|
29896
29980
|
label: "Change its options",
|
|
29897
29981
|
value: "options"
|
|
29898
29982
|
}] : [],
|
|
29983
|
+
{
|
|
29984
|
+
label: f.onCards ? "Stop showing it on cards" : "Show it on board cards",
|
|
29985
|
+
value: "cards"
|
|
29986
|
+
},
|
|
29899
29987
|
{
|
|
29900
29988
|
label: "Take it out",
|
|
29901
29989
|
value: "remove"
|
|
@@ -29904,6 +29992,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29904
29992
|
onPick: (what) => {
|
|
29905
29993
|
const s = store.getState();
|
|
29906
29994
|
if (what === "rename") return go.ask("Field name", (text) => text.trim() && s.updateCustomField(id, f.id, { name: text.trim() }), f.name);
|
|
29995
|
+
if (what === "cards") return s.updateCustomField(id, f.id, { onCards: f.onCards ? void 0 : true });
|
|
29907
29996
|
if (what === "options") return go.ask("Options, split by commas", (text) => s.updateCustomField(id, f.id, { options: optionsOf(text) }), (f.options ?? []).join(", "));
|
|
29908
29997
|
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
29998
|
}
|
|
@@ -29924,7 +30013,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29924
30013
|
value: t
|
|
29925
30014
|
})), (t) => {
|
|
29926
30015
|
const type = t;
|
|
29927
|
-
if (type
|
|
30016
|
+
if (!hasOptions(type)) return void store.getState().addCustomField(id, {
|
|
29928
30017
|
name: trimmed,
|
|
29929
30018
|
type
|
|
29930
30019
|
});
|
|
@@ -30058,6 +30147,113 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
30058
30147
|
});
|
|
30059
30148
|
}
|
|
30060
30149
|
|
|
30150
|
+
// src/lib/date-picker.ts
|
|
30151
|
+
/**
|
|
30152
|
+
* The date picker's rules: the days a month's grid shows (always six weeks,
|
|
30153
|
+
* so the calendar keeps one height and stops jumping as you flip months),
|
|
30154
|
+
* the years its year list offers, and what a typed date means. Pure.
|
|
30155
|
+
*/
|
|
30156
|
+
/**
|
|
30157
|
+
* The day a typed date names, as an ISO day, or null when it names none.
|
|
30158
|
+
* Reads what the task composer reads ("Mar 14", "14 March 2027", "3/14",
|
|
30159
|
+
* "2027-03-14", "next friday", "in 2 weeks", "tomorrow"), plus a year after
|
|
30160
|
+
* a slash date in full ("3/14/2027"), and full stops or dashes for slashes.
|
|
30161
|
+
*/
|
|
30162
|
+
function typedDate(text, today) {
|
|
30163
|
+
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}`);
|
|
30164
|
+
if (!t) return null;
|
|
30165
|
+
return dayFromWords(t, today);
|
|
30166
|
+
}
|
|
30167
|
+
|
|
30168
|
+
// src/lib/custom-field-read.ts
|
|
30169
|
+
/**
|
|
30170
|
+
* What someone typed for one of a project's own fields, as the value kept on
|
|
30171
|
+
* the task: in the terminal, the CLI and AI apps (the website's drawer has a
|
|
30172
|
+
* control per type). Pure.
|
|
30173
|
+
*/
|
|
30174
|
+
const YES = /* @__PURE__ */ new Set([
|
|
30175
|
+
"yes",
|
|
30176
|
+
"y",
|
|
30177
|
+
"true",
|
|
30178
|
+
"on",
|
|
30179
|
+
"1",
|
|
30180
|
+
"x",
|
|
30181
|
+
"✓"
|
|
30182
|
+
]);
|
|
30183
|
+
const NO = /* @__PURE__ */ new Set([
|
|
30184
|
+
"no",
|
|
30185
|
+
"n",
|
|
30186
|
+
"false",
|
|
30187
|
+
"off",
|
|
30188
|
+
"0"
|
|
30189
|
+
]);
|
|
30190
|
+
function option(def, text) {
|
|
30191
|
+
const t = text.trim().toLowerCase();
|
|
30192
|
+
return (def.options ?? []).find((o) => o.toLowerCase() === t);
|
|
30193
|
+
}
|
|
30194
|
+
/** The person a name names: their whole name, first name, or id; one match only. */
|
|
30195
|
+
function personNamed(people, text) {
|
|
30196
|
+
const t = text.trim().replace(/^@/, "").toLowerCase();
|
|
30197
|
+
if (!t) return void 0;
|
|
30198
|
+
const exact = people.filter((p) => p.id === t || p.name.toLowerCase() === t);
|
|
30199
|
+
if (exact.length === 1) return exact[0];
|
|
30200
|
+
const first = people.filter((p) => p.name.toLowerCase().split(/\s+/)[0] === t);
|
|
30201
|
+
if (first.length === 1) return first[0];
|
|
30202
|
+
return first.length > 1 ? "many" : void 0;
|
|
30203
|
+
}
|
|
30204
|
+
/**
|
|
30205
|
+
* A typed value for a field: an empty string clears it. Says what's wrong
|
|
30206
|
+
* when it can't be read (an option the field doesn't have, a name that
|
|
30207
|
+
* matches nobody, a day it can't read).
|
|
30208
|
+
*/
|
|
30209
|
+
function readFieldValue(def, text, ctx) {
|
|
30210
|
+
const t = text.trim();
|
|
30211
|
+
if (!t) return { value: def.type === "multi" ? [] : null };
|
|
30212
|
+
switch (def.type) {
|
|
30213
|
+
case "number": {
|
|
30214
|
+
const n = Number(t.replace(/,/g, ""));
|
|
30215
|
+
return Number.isFinite(n) ? { value: n } : { error: `“${t}” isn't a number.` };
|
|
30216
|
+
}
|
|
30217
|
+
case "checkbox": {
|
|
30218
|
+
const l = t.toLowerCase();
|
|
30219
|
+
if (YES.has(l)) return { value: true };
|
|
30220
|
+
if (NO.has(l)) return { value: false };
|
|
30221
|
+
return { error: "Say yes or no." };
|
|
30222
|
+
}
|
|
30223
|
+
case "select": {
|
|
30224
|
+
const o = option(def, t);
|
|
30225
|
+
return o ? { value: o } : { error: offer(def, t) };
|
|
30226
|
+
}
|
|
30227
|
+
case "multi": {
|
|
30228
|
+
const picked = [];
|
|
30229
|
+
for (const part of t.split(",").map((p) => p.trim()).filter(Boolean)) {
|
|
30230
|
+
const o = option(def, part);
|
|
30231
|
+
if (!o) return { error: offer(def, part) };
|
|
30232
|
+
if (!picked.includes(o)) picked.push(o);
|
|
30233
|
+
}
|
|
30234
|
+
return { value: picked };
|
|
30235
|
+
}
|
|
30236
|
+
case "date": {
|
|
30237
|
+
const day = typedDate(t, ctx.today);
|
|
30238
|
+
return day ? { value: day } : { error: `Couldn't read “${t}” as a day.` };
|
|
30239
|
+
}
|
|
30240
|
+
case "person": {
|
|
30241
|
+
const p = personNamed(ctx.people, t);
|
|
30242
|
+
if (p === "many") return { error: `More than one person is called “${t}”; use the full name.` };
|
|
30243
|
+
return p ? { value: p.id } : { error: `Nobody here is called “${t}”.` };
|
|
30244
|
+
}
|
|
30245
|
+
case "url": {
|
|
30246
|
+
const href = readLink(t);
|
|
30247
|
+
return href ? { value: href } : { error: `“${t}” isn't a web address.` };
|
|
30248
|
+
}
|
|
30249
|
+
default: return { value: t.slice(0, 500) };
|
|
30250
|
+
}
|
|
30251
|
+
}
|
|
30252
|
+
function offer(def, t) {
|
|
30253
|
+
const opts = def.options ?? [];
|
|
30254
|
+
return opts.length ? `“${t}” isn't one of ${def.name}'s options: ${opts.join(", ")}.` : `${def.name} has no options yet.`;
|
|
30255
|
+
}
|
|
30256
|
+
|
|
30061
30257
|
// src/tui/views/sounding.ts
|
|
30062
30258
|
init_soundings();
|
|
30063
30259
|
const nameOf = (people, id) => people.find((p) => p.id === id)?.name ?? "someone";
|
|
@@ -30310,6 +30506,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30310
30506
|
const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
|
|
30311
30507
|
const blockers = blockersOf(task, tasks);
|
|
30312
30508
|
const done = () => setEditing(null);
|
|
30509
|
+
const nameOf = (id) => people.find((p) => p.id === id)?.name;
|
|
30313
30510
|
const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
|
|
30314
30511
|
function editCustom(f) {
|
|
30315
30512
|
if (!task) return;
|
|
@@ -30322,6 +30519,13 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30322
30519
|
label: o,
|
|
30323
30520
|
value: o
|
|
30324
30521
|
}))], (v) => setCustom(f.id, v));
|
|
30522
|
+
if (f.type === "person") return go.pick(f.name, [{
|
|
30523
|
+
label: "Nobody",
|
|
30524
|
+
value: null
|
|
30525
|
+
}, ...people.filter((p) => p.kind !== "agent").map((p) => ({
|
|
30526
|
+
label: p.name,
|
|
30527
|
+
value: p.id
|
|
30528
|
+
}))], (v) => setCustom(f.id, v));
|
|
30325
30529
|
setEditing({
|
|
30326
30530
|
kind: "custom",
|
|
30327
30531
|
fieldId: f.id
|
|
@@ -30409,7 +30613,9 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30409
30613
|
const f = own.find((x) => x.id === editing.fieldId);
|
|
30410
30614
|
if (f) editor = /* @__PURE__ */ jsx(CustomField, {
|
|
30411
30615
|
def: f,
|
|
30412
|
-
initial:
|
|
30616
|
+
initial: typedText(f, task.customFieldValues?.[f.id]),
|
|
30617
|
+
today: day,
|
|
30618
|
+
people,
|
|
30413
30619
|
onCancel: done,
|
|
30414
30620
|
onSubmit: (v) => {
|
|
30415
30621
|
setCustom(f.id, v);
|
|
@@ -30605,7 +30811,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30605
30811
|
color: theme.muted,
|
|
30606
30812
|
children: f.name.slice(0, 8).padEnd(9)
|
|
30607
30813
|
}),
|
|
30608
|
-
fieldValueText(f, task.customFieldValues?.[f.id]) || "—"
|
|
30814
|
+
fieldValueText(f, task.customFieldValues?.[f.id], nameOf) || "—"
|
|
30609
30815
|
]
|
|
30610
30816
|
}, f.id);
|
|
30611
30817
|
}),
|
|
@@ -30696,18 +30902,34 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30696
30902
|
]
|
|
30697
30903
|
});
|
|
30698
30904
|
}
|
|
30699
|
-
/**
|
|
30700
|
-
function
|
|
30905
|
+
/** What a field's value reads as when it's typed again: the day or link as kept. */
|
|
30906
|
+
function typedText(def, value) {
|
|
30907
|
+
if (value == null || value === false) return "";
|
|
30908
|
+
if (def.type === "multi") return tagsOf(value).join(", ");
|
|
30909
|
+
if (def.type === "checkbox") return "yes";
|
|
30910
|
+
return String(value);
|
|
30911
|
+
}
|
|
30912
|
+
const FIELD_HINT = {
|
|
30913
|
+
number: "A number, or empty for none",
|
|
30914
|
+
date: "A day (2026-12-01, friday, in 2 weeks), or empty for none",
|
|
30915
|
+
url: "A web address, or empty for none"
|
|
30916
|
+
};
|
|
30917
|
+
/** A typed field of the project's own; a value that can't be read says why. */
|
|
30918
|
+
function CustomField({ def, initial, today, people, onSubmit, onCancel }) {
|
|
30701
30919
|
const [error, setError] = useState(null);
|
|
30920
|
+
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
30921
|
return /* @__PURE__ */ jsx(TextField, {
|
|
30703
30922
|
label: def.name,
|
|
30704
30923
|
initial,
|
|
30705
|
-
hint: error ??
|
|
30924
|
+
hint: error ?? hint,
|
|
30706
30925
|
onCancel,
|
|
30707
30926
|
onSubmit: (text) => {
|
|
30708
|
-
const
|
|
30709
|
-
|
|
30710
|
-
|
|
30927
|
+
const read = readFieldValue(def, text, {
|
|
30928
|
+
today,
|
|
30929
|
+
people
|
|
30930
|
+
});
|
|
30931
|
+
if ("error" in read) return setError(read.error);
|
|
30932
|
+
onSubmit(read.value);
|
|
30711
30933
|
}
|
|
30712
30934
|
});
|
|
30713
30935
|
}
|
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);
|
|
@@ -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());
|