@harborppm/cli 0.83.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 +468 -148
- package/lib/cli.mjs +33 -1
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -3085,7 +3085,36 @@ var init_barnacles = __esmMin((() => {
|
|
|
3085
3085
|
plural$7 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3086
3086
|
}));
|
|
3087
3087
|
|
|
3088
|
+
// src/lib/local-day.ts
|
|
3089
|
+
init_indexes();
|
|
3090
|
+
init_premortem();
|
|
3091
|
+
init_barnacles();
|
|
3092
|
+
init_format();
|
|
3093
|
+
/**
|
|
3094
|
+
* The day a saved moment fell on where this person is: "2026-11-05T01:30:00Z"
|
|
3095
|
+
* is still the 4th in New York. A plain day ("2026-11-05") is itself.
|
|
3096
|
+
*/
|
|
3097
|
+
function localDay(stamp) {
|
|
3098
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(stamp)) return stamp;
|
|
3099
|
+
const d = new Date(stamp);
|
|
3100
|
+
return Number.isNaN(d.getTime()) ? stamp.slice(0, 10) : todayISO(d);
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3088
3103
|
// src/lib/task-keys.ts
|
|
3104
|
+
/**
|
|
3105
|
+
* Short keys for tasks: a project's key and a task's number within it,
|
|
3106
|
+
* "RR-42". They're what someone types in a branch name, a pull request or a
|
|
3107
|
+
* commit, so once given they don't change, except that a task moved to
|
|
3108
|
+
* another project takes that project's next number.
|
|
3109
|
+
*
|
|
3110
|
+
* The server settles keys on every write (a save, an agent's change, an
|
|
3111
|
+
* intake form, the nightly sweep) and on first load: projects without a key
|
|
3112
|
+
* get one, tasks without a number get the next ones in the order they were
|
|
3113
|
+
* made, and when two tabs gave out the same number at once the task made
|
|
3114
|
+
* first keeps it. In the browser, `write()` numbers only the tasks it adds or
|
|
3115
|
+
* moves (`numberNewTasks`), so a tab never invents numbers for old tasks that
|
|
3116
|
+
* the server would number differently.
|
|
3117
|
+
*/
|
|
3089
3118
|
/** "Ridge Runner" → "RR", "Website" → "WEB", "Q4 launch plan" → "QLP". */
|
|
3090
3119
|
function baseKey(name) {
|
|
3091
3120
|
const words = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9 ]+/g, " ").split(/\s+/).filter((w) => /[A-Za-z]/.test(w));
|
|
@@ -3248,7 +3277,6 @@ function findByKey(data, text) {
|
|
|
3248
3277
|
}
|
|
3249
3278
|
var MAX_KEY, KEY, made, kept, KEY_TEXT;
|
|
3250
3279
|
var init_task_keys = __esmMin((() => {
|
|
3251
|
-
init_indexes();
|
|
3252
3280
|
MAX_KEY = 5;
|
|
3253
3281
|
KEY = /^[A-Z][A-Z0-9]{0,9}$/;
|
|
3254
3282
|
made = (a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
@@ -3365,11 +3393,9 @@ var init_charter_lines = __esmMin((() => {
|
|
|
3365
3393
|
}));
|
|
3366
3394
|
|
|
3367
3395
|
// src/lib/charter.ts
|
|
3368
|
-
init_barnacles();
|
|
3369
3396
|
init_thanks();
|
|
3370
3397
|
init_date_fns();
|
|
3371
3398
|
init_date_text();
|
|
3372
|
-
init_premortem();
|
|
3373
3399
|
init_charter_lines();
|
|
3374
3400
|
const CHARTER_LABEL = {
|
|
3375
3401
|
why: "Why",
|
|
@@ -3487,7 +3513,7 @@ init_date_fns();
|
|
|
3487
3513
|
init_blockers();
|
|
3488
3514
|
init_date_text();
|
|
3489
3515
|
init_premortem();
|
|
3490
|
-
const day$8 = (iso) => parseIso(iso
|
|
3516
|
+
const day$8 = (iso) => parseIso(localDay(iso));
|
|
3491
3517
|
const between = (a, b) => differenceInCalendarDays(day$8(b), day$8(a));
|
|
3492
3518
|
/** When the project was marked Done: its last move to Done in its health history. */
|
|
3493
3519
|
function finishedOn(project, tasks) {
|
|
@@ -3509,7 +3535,7 @@ function voyageLog(data, projectId) {
|
|
|
3509
3535
|
return {
|
|
3510
3536
|
task: t,
|
|
3511
3537
|
daysLate: between(t.dueDate, t.completedAt),
|
|
3512
|
-
waitedOn: blockerIds(t).map((id) => byId.get(id)).filter((b) => Boolean(b?.completedAt && b.completedAt
|
|
3538
|
+
waitedOn: blockerIds(t).map((id) => byId.get(id)).filter((b) => Boolean(b?.completedAt && localDay(b.completedAt) > t.dueDate.slice(0, 10))).sort((a, b) => b.completedAt.localeCompare(a.completedAt))[0] ?? null
|
|
3513
3539
|
};
|
|
3514
3540
|
}).filter((s) => s.daysLate > 0).sort((a, b) => b.daysLate - a.daysLate).slice(0, 3);
|
|
3515
3541
|
const raid = data.raidItems.filter((r) => r.projectId === projectId);
|
|
@@ -5626,6 +5652,7 @@ const ATTENTION = ["at-risk", "off-track"];
|
|
|
5626
5652
|
function matches$1(row, f) {
|
|
5627
5653
|
if (f.health === "attention" && !ATTENTION.includes(row.health)) return false;
|
|
5628
5654
|
if (f.ownerId && row.ownerId !== f.ownerId) return false;
|
|
5655
|
+
if (f.hideDone && row.health === "done") return false;
|
|
5629
5656
|
return true;
|
|
5630
5657
|
}
|
|
5631
5658
|
function milestoneOf(m, today) {
|
|
@@ -5642,7 +5669,8 @@ function milestoneOf(m, today) {
|
|
|
5642
5669
|
/**
|
|
5643
5670
|
* Programs by start date, each followed by its projects by start date unless
|
|
5644
5671
|
* collapsed. A filter keeps a program when it or any of its projects match,
|
|
5645
|
-
* and keeps the projects that match.
|
|
5672
|
+
* and keeps the projects that match. So with done work hidden, a Done
|
|
5673
|
+
* program still heads any of its projects that aren't done.
|
|
5646
5674
|
*/
|
|
5647
5675
|
function roadmapRows(data, portfolioId, filter, collapsed, today) {
|
|
5648
5676
|
const programs = data.programs.filter((p) => p.portfolioId === portfolioId).sort((a, b) => a.startDate.localeCompare(b.startDate) || byName(a.name, b.name));
|
|
@@ -5666,7 +5694,7 @@ function roadmapRows(data, portfolioId, filter, collapsed, today) {
|
|
|
5666
5694
|
baselineEnd: null,
|
|
5667
5695
|
slip: null,
|
|
5668
5696
|
waitsOn: blockerIds(program).filter((id) => programIds.has(id)),
|
|
5669
|
-
milestones: data.milestones.filter((m) => m.programId === program.id).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf(m, today)),
|
|
5697
|
+
milestones: data.milestones.filter((m) => m.programId === program.id && !(filter.hideDone && m.done)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf(m, today)),
|
|
5670
5698
|
projectCount: projects.length
|
|
5671
5699
|
});
|
|
5672
5700
|
if (collapsed.has(program.id)) continue;
|
|
@@ -5689,6 +5717,18 @@ function roadmapRows(data, portfolioId, filter, collapsed, today) {
|
|
|
5689
5717
|
}
|
|
5690
5718
|
return out;
|
|
5691
5719
|
}
|
|
5720
|
+
/** How many programs and projects on this roadmap are Done: what hiding done work tucks away. */
|
|
5721
|
+
function roadmapDoneCount(data, portfolioId) {
|
|
5722
|
+
const programIds = /* @__PURE__ */ new Set();
|
|
5723
|
+
let count = 0;
|
|
5724
|
+
for (const p of data.programs) {
|
|
5725
|
+
if (p.portfolioId !== portfolioId) continue;
|
|
5726
|
+
programIds.add(p.id);
|
|
5727
|
+
if (p.health === "done") count++;
|
|
5728
|
+
}
|
|
5729
|
+
for (const p of data.projects) if (programIds.has(p.programId) && p.health === "done") count++;
|
|
5730
|
+
return count;
|
|
5731
|
+
}
|
|
5692
5732
|
|
|
5693
5733
|
// src/lib/search.ts
|
|
5694
5734
|
function tokensOf(query) {
|
|
@@ -8952,7 +8992,7 @@ function TaskLine({ task, projects, people, today, selected, picked = false, wid
|
|
|
8952
8992
|
});
|
|
8953
8993
|
}
|
|
8954
8994
|
/** Up/down within a list of `length`, wrapping neither way. */
|
|
8955
|
-
function step(at, delta, length) {
|
|
8995
|
+
function step$1(at, delta, length) {
|
|
8956
8996
|
if (length === 0) return 0;
|
|
8957
8997
|
return Math.max(0, Math.min(length - 1, at + delta));
|
|
8958
8998
|
}
|
|
@@ -9009,8 +9049,8 @@ function CalendarView({ active, width, height, go }) {
|
|
|
9009
9049
|
useInput((input, key) => {
|
|
9010
9050
|
if (inList) {
|
|
9011
9051
|
if (key.escape) setInList(false);
|
|
9012
|
-
else if (key.upArrow || input === "k") setAt((i) => step(i, -1, agenda.length));
|
|
9013
|
-
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, agenda.length));
|
|
9052
|
+
else if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, agenda.length));
|
|
9053
|
+
else if (key.downArrow || input === "j") setAt((i) => step$1(i, 1, agenda.length));
|
|
9014
9054
|
else if (key.return && selected) go.openTask(selected.id);
|
|
9015
9055
|
else if (input === "x" && selected) moveTask(store, go, selected, "done");
|
|
9016
9056
|
return;
|
|
@@ -9699,12 +9739,12 @@ function HistoryView({ active, width, height, go }) {
|
|
|
9699
9739
|
});
|
|
9700
9740
|
};
|
|
9701
9741
|
useInput((input, key) => {
|
|
9702
|
-
if (key.upArrow || input === "k") setAt((i) => step(i, -1, rows.length));
|
|
9742
|
+
if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, rows.length));
|
|
9703
9743
|
else if (key.downArrow || input === "j") {
|
|
9704
9744
|
if (at >= rows.length - 2) loadMore();
|
|
9705
|
-
setAt((i) => step(i, 1, rows.length));
|
|
9706
|
-
} else if (key.pageDown) setAt((i) => step(i, 10, rows.length));
|
|
9707
|
-
else if (key.pageUp) setAt((i) => step(i, -10, rows.length));
|
|
9745
|
+
setAt((i) => step$1(i, 1, rows.length));
|
|
9746
|
+
} else if (key.pageDown) setAt((i) => step$1(i, 10, rows.length));
|
|
9747
|
+
else if (key.pageUp) setAt((i) => step$1(i, -10, rows.length));
|
|
9708
9748
|
else if (input === "s") go.pick("History of", [
|
|
9709
9749
|
...portfolios.map((p) => ({
|
|
9710
9750
|
label: p.name,
|
|
@@ -9824,6 +9864,36 @@ function HistoryView({ active, width, height, go }) {
|
|
|
9824
9864
|
});
|
|
9825
9865
|
}
|
|
9826
9866
|
|
|
9867
|
+
// src/lib/read-marks.ts
|
|
9868
|
+
/**
|
|
9869
|
+
* Inbox read marks. A harbor keeps one list of them (`readPingIds`), shared
|
|
9870
|
+
* by everyone in it, so each mark names whose it is: reading a mention that
|
|
9871
|
+
* names Sam and Aya clears it from Sam's Inbox only. Marks written before
|
|
9872
|
+
* they were personal (the bare line id) still count as read. Pure; the Inbox
|
|
9873
|
+
* makes the marks it hands the store, so none of this is in the first load.
|
|
9874
|
+
*/
|
|
9875
|
+
/** FNV-1a over the text, as eight hex digits. */
|
|
9876
|
+
function hash8(text) {
|
|
9877
|
+
let h = 2166136261;
|
|
9878
|
+
for (let i = 0; i < text.length; i++) {
|
|
9879
|
+
h ^= text.charCodeAt(i);
|
|
9880
|
+
h = Math.imul(h, 16777619);
|
|
9881
|
+
}
|
|
9882
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
9883
|
+
}
|
|
9884
|
+
/** A saved row id holds 64 characters. */
|
|
9885
|
+
const MAX_MARK = 64;
|
|
9886
|
+
/** The mark that says this person has read this Inbox line. */
|
|
9887
|
+
function readMark(personId, pingId) {
|
|
9888
|
+
const mark = `r${hash8(personId)}:${pingId}`;
|
|
9889
|
+
return mark.length <= MAX_MARK ? mark : `r${hash8(personId)}:#${hash8(pingId)}`;
|
|
9890
|
+
}
|
|
9891
|
+
/** Whether a person has read a line, given the harbor's marks. */
|
|
9892
|
+
function readBy(marks, personId) {
|
|
9893
|
+
const set = marks instanceof Set ? marks : new Set(marks ?? []);
|
|
9894
|
+
return (pingId) => set.has(pingId) || set.has(readMark(personId, pingId));
|
|
9895
|
+
}
|
|
9896
|
+
|
|
9827
9897
|
// src/lib/inbox.ts
|
|
9828
9898
|
init_date_fns();
|
|
9829
9899
|
init_indexes();
|
|
@@ -10091,22 +10161,22 @@ function InboxView({ active, width, height, go }) {
|
|
|
10091
10161
|
me,
|
|
10092
10162
|
store
|
|
10093
10163
|
]);
|
|
10094
|
-
const read =
|
|
10095
|
-
const unread = pings.filter((p) => !read
|
|
10164
|
+
const read = readBy(readIds, me);
|
|
10165
|
+
const unread = pings.filter((p) => !read(p.id)).length;
|
|
10096
10166
|
const [at, setAt] = useState(0);
|
|
10097
10167
|
const ping = pings[Math.min(at, pings.length - 1)];
|
|
10098
10168
|
const open = (p) => {
|
|
10099
|
-
store.getState().readPing(p.id);
|
|
10169
|
+
store.getState().readPing(readMark(me, p.id));
|
|
10100
10170
|
if (p.taskId) go.openTask(p.taskId);
|
|
10101
10171
|
else if (p.raidId) go.openPanel("raid", p.raidId);
|
|
10102
10172
|
else if (p.programId) go.openProgram(p.programId);
|
|
10103
10173
|
};
|
|
10104
10174
|
useInput((input, key) => {
|
|
10105
|
-
if (key.upArrow || input === "k") setAt((i) => step(i, -1, pings.length));
|
|
10106
|
-
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, pings.length));
|
|
10175
|
+
if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, pings.length));
|
|
10176
|
+
else if (key.downArrow || input === "j") setAt((i) => step$1(i, 1, pings.length));
|
|
10107
10177
|
else if (key.return && ping) open(ping);
|
|
10108
|
-
else if (input === " " && ping) store.getState().readPing(ping.id);
|
|
10109
|
-
else if (input === "a")
|
|
10178
|
+
else if (input === " " && ping) store.getState().readPing(readMark(me, ping.id));
|
|
10179
|
+
else if (input === "a") store.getState().catchUp(pings.map((p) => readMark(me, p.id)));
|
|
10110
10180
|
}, { isActive: active });
|
|
10111
10181
|
const view = windowOf(pings, at, Math.max(1, height - 2));
|
|
10112
10182
|
return /* @__PURE__ */ jsxs(Box, {
|
|
@@ -10125,7 +10195,7 @@ function InboxView({ active, width, height, go }) {
|
|
|
10125
10195
|
}) : null,
|
|
10126
10196
|
view.items.map((p, i) => {
|
|
10127
10197
|
const on = active && view.start + i === at;
|
|
10128
|
-
const fresh = !read
|
|
10198
|
+
const fresh = !read(p.id);
|
|
10129
10199
|
return /* @__PURE__ */ jsxs(Text, {
|
|
10130
10200
|
color: on ? theme.selected : void 0,
|
|
10131
10201
|
bold: on || fresh,
|
|
@@ -12108,10 +12178,10 @@ function RowList({ title, right, rows, empty, active, width, height, onOpen, key
|
|
|
12108
12178
|
const [at, setAt] = useState(0);
|
|
12109
12179
|
const row = rows[Math.min(at, rows.length - 1)];
|
|
12110
12180
|
useInput((input, key) => {
|
|
12111
|
-
if (key.upArrow || input === "k") setAt((i) => step(i, -1, rows.length));
|
|
12112
|
-
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, rows.length));
|
|
12113
|
-
else if (key.pageDown) setAt((i) => step(i, 10, rows.length));
|
|
12114
|
-
else if (key.pageUp) setAt((i) => step(i, -10, rows.length));
|
|
12181
|
+
if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, rows.length));
|
|
12182
|
+
else if (key.downArrow || input === "j") setAt((i) => step$1(i, 1, rows.length));
|
|
12183
|
+
else if (key.pageDown) setAt((i) => step$1(i, 10, rows.length));
|
|
12184
|
+
else if (key.pageUp) setAt((i) => step$1(i, -10, rows.length));
|
|
12115
12185
|
else if (key.return && row) onOpen(row);
|
|
12116
12186
|
else keys[input]?.(row);
|
|
12117
12187
|
}, { isActive: active });
|
|
@@ -12741,8 +12811,8 @@ function SearchBox({ width, onOpen, onClose }) {
|
|
|
12741
12811
|
]);
|
|
12742
12812
|
useInput((input, key) => {
|
|
12743
12813
|
if (key.escape) return onClose();
|
|
12744
|
-
if (key.upArrow) return setAt((i) => step(i, -1, hits.length));
|
|
12745
|
-
if (key.downArrow) return setAt((i) => step(i, 1, hits.length));
|
|
12814
|
+
if (key.upArrow) return setAt((i) => step$1(i, -1, hits.length));
|
|
12815
|
+
if (key.downArrow) return setAt((i) => step$1(i, 1, hits.length));
|
|
12746
12816
|
if (key.return) {
|
|
12747
12817
|
const hit = hits[Math.min(at, hits.length - 1)];
|
|
12748
12818
|
if (hit) onOpen(hit.item.id);
|
|
@@ -13000,8 +13070,8 @@ function Palette({ width, base, open, onClose }) {
|
|
|
13000
13070
|
const hits = useMemo(() => matchCommands(all, query, 12), [all, query]);
|
|
13001
13071
|
useInput((input, key) => {
|
|
13002
13072
|
if (key.escape) return onClose();
|
|
13003
|
-
if (key.upArrow) return setAt((i) => step(i, -1, hits.length));
|
|
13004
|
-
if (key.downArrow) return setAt((i) => step(i, 1, hits.length));
|
|
13073
|
+
if (key.upArrow) return setAt((i) => step$1(i, -1, hits.length));
|
|
13074
|
+
if (key.downArrow) return setAt((i) => step$1(i, 1, hits.length));
|
|
13005
13075
|
if (key.return) {
|
|
13006
13076
|
const hit = hits[Math.min(at, hits.length - 1)];
|
|
13007
13077
|
onClose();
|
|
@@ -13108,8 +13178,8 @@ function PremortemPanel({ id, width, height, go, active, onClose }) {
|
|
|
13108
13178
|
useInput((input, key) => {
|
|
13109
13179
|
if (key.escape) return onClose();
|
|
13110
13180
|
if (!project || !pm) return;
|
|
13111
|
-
if (key.upArrow || input === "k") return setAt((i) => step(i, -1, rows.length));
|
|
13112
|
-
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows.length));
|
|
13181
|
+
if (key.upArrow || input === "k") return setAt((i) => step$1(i, -1, rows.length));
|
|
13182
|
+
if (key.downArrow || input === "j") return setAt((i) => step$1(i, 1, rows.length));
|
|
13113
13183
|
if (key.return && row?.risk) return go.openPanel("raid", row.risk.id);
|
|
13114
13184
|
if (viewOnly) return;
|
|
13115
13185
|
if (pm.phase === "none") {
|
|
@@ -19625,6 +19695,85 @@ function exportName(harborName, now) {
|
|
|
19625
19695
|
return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
|
|
19626
19696
|
}
|
|
19627
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
|
+
|
|
19628
19777
|
// src/lib/exports.ts
|
|
19629
19778
|
/**
|
|
19630
19779
|
* The spreadsheets Harbor hands out: a project's tasks, the risk register and
|
|
@@ -19653,6 +19802,8 @@ function tasksCsv(data, projectId) {
|
|
|
19653
19802
|
const here = byId(tasks);
|
|
19654
19803
|
const people = byId(data.people);
|
|
19655
19804
|
const name = (t) => taskKey(t, project) ?? t.title;
|
|
19805
|
+
const fields = project?.customFields ?? [];
|
|
19806
|
+
const nameOf = (id) => people.get(id)?.name;
|
|
19656
19807
|
return toCsv([
|
|
19657
19808
|
"Key",
|
|
19658
19809
|
"Title",
|
|
@@ -19662,7 +19813,8 @@ function tasksCsv(data, projectId) {
|
|
|
19662
19813
|
"Due",
|
|
19663
19814
|
"Done",
|
|
19664
19815
|
"Blocked by",
|
|
19665
|
-
"Description"
|
|
19816
|
+
"Description",
|
|
19817
|
+
...fields.map((f) => f.name)
|
|
19666
19818
|
], tasks.map((t) => [
|
|
19667
19819
|
taskKey(t, project) ?? "",
|
|
19668
19820
|
t.title,
|
|
@@ -19672,7 +19824,8 @@ function tasksCsv(data, projectId) {
|
|
|
19672
19824
|
t.dueDate ?? "",
|
|
19673
19825
|
t.status === "done" ? "yes" : "no",
|
|
19674
19826
|
blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
|
|
19675
|
-
t.description
|
|
19827
|
+
t.description,
|
|
19828
|
+
...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
|
|
19676
19829
|
]));
|
|
19677
19830
|
}
|
|
19678
19831
|
/** Where a risk, issue or decision lives, by name; null for the whole harbor. */
|
|
@@ -20561,6 +20714,26 @@ function soundingOut(data, t, meId, now) {
|
|
|
20561
20714
|
};
|
|
20562
20715
|
}
|
|
20563
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
|
+
}
|
|
20564
20737
|
/** A task's approval (src/lib/task-approval.ts), as an agent reads it. */
|
|
20565
20738
|
function approvalOut(data, a) {
|
|
20566
20739
|
const people = byId(data.people);
|
|
@@ -20923,7 +21096,7 @@ const HARBOR_TOOLS = [
|
|
|
20923
21096
|
tool({
|
|
20924
21097
|
name: "get_project",
|
|
20925
21098
|
title: "Project details",
|
|
20926
|
-
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,
|
|
20927
21100
|
input: object({
|
|
20928
21101
|
harbor: harborArg,
|
|
20929
21102
|
project: string().min(1).max(200).describe("Project id or name.")
|
|
@@ -20984,6 +21157,7 @@ const HARBOR_TOOLS = [
|
|
|
20984
21157
|
waitsOn,
|
|
20985
21158
|
milestones,
|
|
20986
21159
|
openRaid: raid,
|
|
21160
|
+
...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
|
|
20987
21161
|
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
|
|
20988
21162
|
...charterOf(p, tasks),
|
|
20989
21163
|
...notesOf(ctx, p),
|
|
@@ -21045,7 +21219,7 @@ const HARBOR_TOOLS = [
|
|
|
21045
21219
|
tool({
|
|
21046
21220
|
name: "get_task",
|
|
21047
21221
|
title: "Task details",
|
|
21048
|
-
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,
|
|
21049
21223
|
input: object({
|
|
21050
21224
|
harbor: harborArg,
|
|
21051
21225
|
task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
|
|
@@ -21099,6 +21273,7 @@ const HARBOR_TOOLS = [
|
|
|
21099
21273
|
waitedOnBy: waiters.map(brief),
|
|
21100
21274
|
...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
|
|
21101
21275
|
...t.approval ? { approval: approvalOut(data, t.approval) } : {},
|
|
21276
|
+
...((v) => v ? { fields: v } : {})(fieldValuesOut(data, project, t)),
|
|
21102
21277
|
...pilot ? { pilotDue: {
|
|
21103
21278
|
earliest: pilot.earliest,
|
|
21104
21279
|
fits: pilot.fits,
|
|
@@ -23485,25 +23660,39 @@ var init_focus = __esmMin((() => {
|
|
|
23485
23660
|
// src/lib/recurrence.ts
|
|
23486
23661
|
/** The due date one interval after `from`. */
|
|
23487
23662
|
function nextDueDate(from, repeat) {
|
|
23488
|
-
|
|
23489
|
-
|
|
23490
|
-
|
|
23491
|
-
|
|
23663
|
+
return iso(step(/* @__PURE__ */ new Date(`${from}T12:00:00`), repeat, 1));
|
|
23664
|
+
}
|
|
23665
|
+
/**
|
|
23666
|
+
* The next date in a series that started on `due`, at least one interval
|
|
23667
|
+
* on and no earlier than `today`. A weekly Monday meeting ticked off late on
|
|
23668
|
+
* Wednesday comes back the next Monday, so the series keeps its day; a
|
|
23669
|
+
* monthly one on the 31st keeps the 31st where the month has one.
|
|
23670
|
+
*/
|
|
23671
|
+
function nextSeriesDate(due, repeat, today) {
|
|
23672
|
+
const start = /* @__PURE__ */ new Date(`${due}T12:00:00`);
|
|
23673
|
+
let n = 1;
|
|
23674
|
+
let next = iso(step(start, repeat, n));
|
|
23675
|
+
while (next < today && n < 2e3) next = iso(step(start, repeat, ++n));
|
|
23676
|
+
return next;
|
|
23492
23677
|
}
|
|
23493
23678
|
/**
|
|
23494
23679
|
* The next occurrence of a repeating task once this one is done: same
|
|
23495
|
-
* title, owner, priority and checklist (unticked),
|
|
23496
|
-
* the
|
|
23497
|
-
*
|
|
23680
|
+
* title, owner, priority and checklist (unticked), back in To do. It is due
|
|
23681
|
+
* on the series' next date that isn't already past (see `nextSeriesDate`),
|
|
23682
|
+
* or one interval after today when the task had no due date. Null when the
|
|
23683
|
+
* task does not repeat, or when `siblings` (the project's tasks) already
|
|
23684
|
+
* hold an open copy of the series due on or after that date, so finishing,
|
|
23685
|
+
* reopening and finishing again doesn't queue a second one.
|
|
23498
23686
|
*/
|
|
23499
|
-
function nextOccurrence(task, id, today, createdAt) {
|
|
23687
|
+
function nextOccurrence(task, id, today, createdAt, siblings = []) {
|
|
23500
23688
|
if (!task.repeat) return null;
|
|
23501
|
-
const
|
|
23689
|
+
const dueDate = task.dueDate ? nextSeriesDate(task.dueDate, task.repeat, today) : nextDueDate(today, task.repeat);
|
|
23690
|
+
if (siblings.some((t) => t.status !== "done" && t.repeat === task.repeat && t.title === task.title && (t.dueDate ?? "") >= dueDate)) return null;
|
|
23502
23691
|
return {
|
|
23503
23692
|
...task,
|
|
23504
23693
|
id,
|
|
23505
23694
|
status: "todo",
|
|
23506
|
-
dueDate
|
|
23695
|
+
dueDate,
|
|
23507
23696
|
createdAt,
|
|
23508
23697
|
completedAt: null,
|
|
23509
23698
|
blockedById: null,
|
|
@@ -23514,7 +23703,7 @@ function nextOccurrence(task, id, today, createdAt) {
|
|
|
23514
23703
|
}))
|
|
23515
23704
|
};
|
|
23516
23705
|
}
|
|
23517
|
-
var REPEAT_LABEL, iso;
|
|
23706
|
+
var REPEAT_LABEL, iso, step;
|
|
23518
23707
|
var init_recurrence = __esmMin((() => {
|
|
23519
23708
|
init_date_fns();
|
|
23520
23709
|
REPEAT_LABEL = {
|
|
@@ -23525,6 +23714,7 @@ var init_recurrence = __esmMin((() => {
|
|
|
23525
23714
|
iso = (d) => {
|
|
23526
23715
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
23527
23716
|
};
|
|
23717
|
+
step = (d, repeat, n) => repeat === "weekly" ? addDays(d, 7 * n) : repeat === "biweekly" ? addDays(d, 14 * n) : addMonths$1(d, n);
|
|
23528
23718
|
}));
|
|
23529
23719
|
|
|
23530
23720
|
// src/lib/assignment.ts
|
|
@@ -23700,7 +23890,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
23700
23890
|
return stampAssignment(row, finalTask, get().currentUserId, stamp());
|
|
23701
23891
|
});
|
|
23702
23892
|
const finished = task && patch.status === "done" && task.status !== "done" ? tasks.find((t) => t.id === id) : void 0;
|
|
23703
|
-
const follow = finished ? nextOccurrence(finished, uid("t"), todayISO(), stamp()) : null;
|
|
23893
|
+
const follow = finished ? nextOccurrence(finished, uid("t"), todayISO(), stamp(), tasksOf(tasks, finished.projectId)) : null;
|
|
23704
23894
|
write({
|
|
23705
23895
|
tasks: follow ? [...tasks, follow] : tasks,
|
|
23706
23896
|
comments: newComments.length ? [...get().comments, ...newComments] : get().comments,
|
|
@@ -23732,7 +23922,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
23732
23922
|
created: false,
|
|
23733
23923
|
currentUserId: get().currentUserId
|
|
23734
23924
|
});
|
|
23735
|
-
const follow = status === "done" ? nextOccurrence(finalRow, uid("t"), todayISO(), stamp()) : null;
|
|
23925
|
+
const follow = status === "done" ? nextOccurrence(finalRow, uid("t"), todayISO(), stamp(), tasksOf(get().tasks, task.projectId)) : null;
|
|
23736
23926
|
const moved = get().tasks.map((row) => row.id === id ? finalRow : row);
|
|
23737
23927
|
write({
|
|
23738
23928
|
tasks: follow ? [...moved, follow] : moved,
|
|
@@ -23937,15 +24127,15 @@ function tasksSlice({ set, get, write }) {
|
|
|
23937
24127
|
github: withRef(t.github, ref)
|
|
23938
24128
|
} : t) });
|
|
23939
24129
|
},
|
|
23940
|
-
readPing: (
|
|
24130
|
+
readPing: (mark) => {
|
|
23941
24131
|
const ids = get().readPingIds ?? [];
|
|
23942
|
-
if (ids.includes(
|
|
23943
|
-
set({ readPingIds: [...ids,
|
|
24132
|
+
if (ids.includes(mark)) return;
|
|
24133
|
+
set({ readPingIds: [...ids, mark] });
|
|
23944
24134
|
},
|
|
23945
|
-
catchUp: (
|
|
23946
|
-
if (
|
|
24135
|
+
catchUp: (marks) => {
|
|
24136
|
+
if (marks.length === 0) return;
|
|
23947
24137
|
const have = new Set(get().readPingIds ?? []);
|
|
23948
|
-
for (const
|
|
24138
|
+
for (const mark of marks) have.add(mark);
|
|
23949
24139
|
set({ readPingIds: [...have] });
|
|
23950
24140
|
},
|
|
23951
24141
|
tickVoyage: (ids) => {
|
|
@@ -24014,7 +24204,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
24014
24204
|
})));
|
|
24015
24205
|
const final = stampAssignment(row, automated, me, at);
|
|
24016
24206
|
if (final.status === "done" && row.status !== "done") {
|
|
24017
|
-
const follow = nextOccurrence(final, uid("t"), todayISO(), at);
|
|
24207
|
+
const follow = nextOccurrence(final, uid("t"), todayISO(), at, [...tasksOf(s.tasks, row.projectId), ...follows]);
|
|
24018
24208
|
if (follow) follows.push(follow);
|
|
24019
24209
|
}
|
|
24020
24210
|
return final;
|
|
@@ -24033,7 +24223,8 @@ function tasksSlice({ set, get, write }) {
|
|
|
24033
24223
|
toggleFocus: (taskId) => {
|
|
24034
24224
|
const personId = get().currentUserId;
|
|
24035
24225
|
const date = todayISO();
|
|
24036
|
-
const
|
|
24226
|
+
const live = byId(get().tasks);
|
|
24227
|
+
const ids = ((get().ropes ?? []).find((r) => r.personId === personId && r.date === date)?.taskIds ?? []).filter((id) => live.has(id));
|
|
24037
24228
|
if (ids.includes(taskId)) {
|
|
24038
24229
|
set({ ropes: upsertFocus(get().ropes, personId, date, ids.filter((id) => id !== taskId)) });
|
|
24039
24230
|
return "removed";
|
|
@@ -24133,6 +24324,7 @@ var init_tasks = __esmMin((() => {
|
|
|
24133
24324
|
init_work();
|
|
24134
24325
|
init_shared();
|
|
24135
24326
|
init_recurrence();
|
|
24327
|
+
init_indexes();
|
|
24136
24328
|
init_assignment();
|
|
24137
24329
|
init_bulk();
|
|
24138
24330
|
init_task_people();
|
|
@@ -24164,6 +24356,7 @@ var init_raid_history = __esmMin((() => {}));
|
|
|
24164
24356
|
function milestonesRaidSlice({ set, get, write }) {
|
|
24165
24357
|
return {
|
|
24166
24358
|
toggleMilestone: (id) => {
|
|
24359
|
+
if (get().milestones.find((m) => m.id === id)?.approverId) return;
|
|
24167
24360
|
write({ milestones: get().milestones.map((m) => m.id === id ? {
|
|
24168
24361
|
...m,
|
|
24169
24362
|
done: !m.done
|
|
@@ -26284,7 +26477,7 @@ function fieldsOf(data, task) {
|
|
|
26284
26477
|
};
|
|
26285
26478
|
}
|
|
26286
26479
|
/** The person a file's owner names: their whole name, else a first name or initials only they have. */
|
|
26287
|
-
function personNamed$
|
|
26480
|
+
function personNamed$2(people, name) {
|
|
26288
26481
|
const want = name.trim().toLowerCase();
|
|
26289
26482
|
if (!want) return null;
|
|
26290
26483
|
const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
|
|
@@ -26326,7 +26519,7 @@ function taskPatch(data, fields, say, label) {
|
|
|
26326
26519
|
if (fields.repeat !== void 0) patch.repeat = fields.repeat;
|
|
26327
26520
|
if (fields.notes !== void 0) patch.description = fields.notes;
|
|
26328
26521
|
if (fields.owner !== void 0) {
|
|
26329
|
-
const person = fields.owner ? personNamed$
|
|
26522
|
+
const person = fields.owner ? personNamed$2(data.people, fields.owner) : null;
|
|
26330
26523
|
if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
|
|
26331
26524
|
patch.assigneeId = person?.id ?? null;
|
|
26332
26525
|
}
|
|
@@ -26487,7 +26680,7 @@ function applyFolder(store, projectId, files, base, opts) {
|
|
|
26487
26680
|
let fresh = 0;
|
|
26488
26681
|
if (opts.fileWins) for (const text of files.values()) {
|
|
26489
26682
|
const owner = parseTask(text).fields?.owner;
|
|
26490
|
-
if (owner && !personNamed$
|
|
26683
|
+
if (owner && !personNamed$2(get().people, owner)) {
|
|
26491
26684
|
const { id: _id, ...person } = localPerson(owner, "");
|
|
26492
26685
|
get().addPerson(person);
|
|
26493
26686
|
}
|
|
@@ -26749,7 +26942,7 @@ async function moveLocalTasks(folder, store, projectId, say) {
|
|
|
26749
26942
|
};
|
|
26750
26943
|
for (const t of local.tasks) {
|
|
26751
26944
|
const owner = t.assigneeId ? local.people.find((p) => p.id === t.assigneeId)?.name : null;
|
|
26752
|
-
const person = owner ? personNamed$
|
|
26945
|
+
const person = owner ? personNamed$2(get().people, owner) : null;
|
|
26753
26946
|
if (owner && !person) say(`${t.title}: nobody called "${owner}" is in the harbor, so it has no owner.`);
|
|
26754
26947
|
const id = get().addTask({
|
|
26755
26948
|
projectId,
|
|
@@ -26975,7 +27168,7 @@ function taskIn(data, project, key) {
|
|
|
26975
27168
|
if (!task || task.projectId !== project.id) throw new HarborError(`No task ${key} in ${project.name}. Use its key, like ${project.key ?? "RR"}-4.`);
|
|
26976
27169
|
return task;
|
|
26977
27170
|
}
|
|
26978
|
-
const personNamed = (data, name) => {
|
|
27171
|
+
const personNamed$1 = (data, name) => {
|
|
26979
27172
|
try {
|
|
26980
27173
|
return findOne(data.people, name, "person");
|
|
26981
27174
|
} catch (e) {
|
|
@@ -27016,7 +27209,7 @@ function changesFrom(data, project, flags, now) {
|
|
|
27016
27209
|
for (const [key, who] of pairs(flags.give, "give", "KEY=NAME (RL-4=Sam, RL-5=none)")) add({
|
|
27017
27210
|
kind: "assign",
|
|
27018
27211
|
taskId: taskIn(data, project, key).id,
|
|
27019
|
-
assigneeId: who === "none" ? null : personNamed(data, who).id
|
|
27212
|
+
assigneeId: who === "none" ? null : personNamed$1(data, who).id
|
|
27020
27213
|
});
|
|
27021
27214
|
if (flags.end) add({
|
|
27022
27215
|
kind: "end",
|
|
@@ -27028,13 +27221,13 @@ function changesFrom(data, project, flags, now) {
|
|
|
27028
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.");
|
|
27029
27222
|
add({
|
|
27030
27223
|
kind: "borrow",
|
|
27031
|
-
personId: personNamed(data, name.trim()).id,
|
|
27224
|
+
personId: personNamed$1(data, name.trim()).id,
|
|
27032
27225
|
days
|
|
27033
27226
|
});
|
|
27034
27227
|
}
|
|
27035
27228
|
for (const name of list(flags.release)) add({
|
|
27036
27229
|
kind: "release",
|
|
27037
|
-
personId: personNamed(data, name).id
|
|
27230
|
+
personId: personNamed$1(data, name).id
|
|
27038
27231
|
});
|
|
27039
27232
|
return changes;
|
|
27040
27233
|
}
|
|
@@ -27330,8 +27523,8 @@ function DockPanel({ id, width, height, go, active, onClose }) {
|
|
|
27330
27523
|
useInput((input, key) => {
|
|
27331
27524
|
if (key.escape) return onClose();
|
|
27332
27525
|
if (!project) return;
|
|
27333
|
-
if (key.upArrow || input === "k") return setAt((i) => step(i, -1, changes.length));
|
|
27334
|
-
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, changes.length));
|
|
27526
|
+
if (key.upArrow || input === "k") return setAt((i) => step$1(i, -1, changes.length));
|
|
27527
|
+
if (key.downArrow || input === "j") return setAt((i) => step$1(i, 1, changes.length));
|
|
27335
27528
|
if (input === "a" || input === "n") return newChange();
|
|
27336
27529
|
if (input === "d" && changes.length) {
|
|
27337
27530
|
const i = Math.min(at, changes.length - 1);
|
|
@@ -27509,8 +27702,8 @@ function LifeboatsPanel({ id, width, height, go, active, onClose }) {
|
|
|
27509
27702
|
};
|
|
27510
27703
|
useInput((input, key) => {
|
|
27511
27704
|
if (key.escape) return onClose();
|
|
27512
|
-
if (key.upArrow || input === "k") return setAt((i) => step(i, -1, lines.length));
|
|
27513
|
-
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, lines.length));
|
|
27705
|
+
if (key.upArrow || input === "k") return setAt((i) => step$1(i, -1, lines.length));
|
|
27706
|
+
if (key.downArrow || input === "j") return setAt((i) => step$1(i, 1, lines.length));
|
|
27514
27707
|
if (key.return && line) return choose(line);
|
|
27515
27708
|
}, { isActive: active !== false });
|
|
27516
27709
|
const rows = Math.max(1, Math.floor((height - 6 - notes.length) / 3));
|
|
@@ -27642,8 +27835,8 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
|
|
|
27642
27835
|
useInput((input, key) => {
|
|
27643
27836
|
if (key.escape) return onClose();
|
|
27644
27837
|
if (!answer) return;
|
|
27645
|
-
if (key.upArrow || input === "k") return setAt((i) => step(i, -1, SHORE_KINDS.length));
|
|
27646
|
-
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, SHORE_KINDS.length));
|
|
27838
|
+
if (key.upArrow || input === "k") return setAt((i) => step$1(i, -1, SHORE_KINDS.length));
|
|
27839
|
+
if (key.downArrow || input === "j") return setAt((i) => step$1(i, 1, SHORE_KINDS.length));
|
|
27647
27840
|
if (viewOnly) return;
|
|
27648
27841
|
if (input === "p") return go.ask("The channel's incoming-webhook address", (text) => {
|
|
27649
27842
|
const url = text.trim();
|
|
@@ -27802,8 +27995,8 @@ function WelcomeView({ when, active, width, height, go, onDone }) {
|
|
|
27802
27995
|
const look = w.lookFirst;
|
|
27803
27996
|
useInput((input, key) => {
|
|
27804
27997
|
if (key.escape || input === "g") return onDone();
|
|
27805
|
-
if (key.upArrow || input === "k") setAt((i) => step(i, -1, look.length));
|
|
27806
|
-
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, look.length));
|
|
27998
|
+
if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, look.length));
|
|
27999
|
+
else if (key.downArrow || input === "j") setAt((i) => step$1(i, 1, look.length));
|
|
27807
28000
|
else if (key.return) {
|
|
27808
28001
|
const l = look[at];
|
|
27809
28002
|
if (!l) return onDone();
|
|
@@ -28212,14 +28405,18 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
28212
28405
|
return setShowDone((v) => !v);
|
|
28213
28406
|
}
|
|
28214
28407
|
if (side === "projects") {
|
|
28215
|
-
if (key.upArrow || input === "k") setPAt((i) => step(i, -1, mine.length));
|
|
28216
|
-
else if (key.downArrow || input === "j") setPAt((i) => step(i, 1, mine.length));
|
|
28408
|
+
if (key.upArrow || input === "k") setPAt((i) => step$1(i, -1, mine.length));
|
|
28409
|
+
else if (key.downArrow || input === "j") setPAt((i) => step$1(i, 1, mine.length));
|
|
28217
28410
|
else if (key.return && project) go.openProject(project.id);
|
|
28218
|
-
} else if (key.upArrow || input === "k") setMAt((i) => step(i, -1, marks.length));
|
|
28219
|
-
else if (key.downArrow || input === "j") setMAt((i) => step(i, 1, marks.length));
|
|
28411
|
+
} else if (key.upArrow || input === "k") setMAt((i) => step$1(i, -1, marks.length));
|
|
28412
|
+
else if (key.downArrow || input === "j") setMAt((i) => step$1(i, 1, marks.length));
|
|
28220
28413
|
else if (key.return && mark) go.openPanel("milestone", mark.id);
|
|
28221
|
-
else if (input === " " && mark)
|
|
28222
|
-
|
|
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");
|
|
28223
28420
|
}, { isActive: active });
|
|
28224
28421
|
if (!program) return /* @__PURE__ */ jsx(Text, {
|
|
28225
28422
|
color: theme.muted,
|
|
@@ -28440,8 +28637,8 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
28440
28637
|
if (input === "n") return go.create("program", { portfolioId });
|
|
28441
28638
|
if (input === "v") return setLayout((l) => l === "board" ? "bands" : "board");
|
|
28442
28639
|
if (input === "D" && split.done.length > 0) return setShowDone((v) => !v);
|
|
28443
|
-
if (key.upArrow || input === "k") setAt((i) => step(i, -1, list.length));
|
|
28444
|
-
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, list.length));
|
|
28640
|
+
if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, list.length));
|
|
28641
|
+
else if (key.downArrow || input === "j") setAt((i) => step$1(i, 1, list.length));
|
|
28445
28642
|
else if (key.return && program) go.openProgram(program.id);
|
|
28446
28643
|
}, { isActive: active });
|
|
28447
28644
|
if (!portfolio) return /* @__PURE__ */ jsx(Text, {
|
|
@@ -28830,8 +29027,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
28830
29027
|
if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : m === "budget" ? "files" : "board");
|
|
28831
29028
|
if (mode === "files") {
|
|
28832
29029
|
const f = files[Math.min(fileAt, files.length - 1)];
|
|
28833
|
-
if (key.upArrow || input === "k") setFileAt((i) => step(i, -1, files.length));
|
|
28834
|
-
else if (key.downArrow || input === "j") setFileAt((i) => step(i, 1, files.length));
|
|
29030
|
+
if (key.upArrow || input === "k") setFileAt((i) => step$1(i, -1, files.length));
|
|
29031
|
+
else if (key.downArrow || input === "j") setFileAt((i) => step$1(i, 1, files.length));
|
|
28835
29032
|
else if (key.return && f) go.openTask(f.task.id);
|
|
28836
29033
|
return;
|
|
28837
29034
|
}
|
|
@@ -28875,8 +29072,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
28875
29072
|
if (mode === "budget") {
|
|
28876
29073
|
const entries = project ? budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date()).entries : [];
|
|
28877
29074
|
const entry = entries[Math.min(spendAt, entries.length - 1)];
|
|
28878
|
-
if (key.upArrow || input === "k") setSpendAt((i) => step(i, -1, entries.length));
|
|
28879
|
-
else if (key.downArrow || input === "j") setSpendAt((i) => step(i, 1, entries.length));
|
|
29075
|
+
if (key.upArrow || input === "k") setSpendAt((i) => step$1(i, -1, entries.length));
|
|
29076
|
+
else if (key.downArrow || input === "j") setSpendAt((i) => step$1(i, 1, entries.length));
|
|
28880
29077
|
else if (input === "a" || input === "n") go.create("spend", { projectId });
|
|
28881
29078
|
else if (input === "d" && entry) go.confirm(`Take out ${money(entry.amount)}${entry.note ? ` for “${entry.note}”` : ""}? u undoes it.`, () => store.getState().removeSpend(entry.id), "Take it out");
|
|
28882
29079
|
return;
|
|
@@ -28892,20 +29089,20 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
28892
29089
|
if (input === "x" && selected) return moveTask(store, go, selected, "done");
|
|
28893
29090
|
if (input === "d" && selected) return go.confirm(`Delete ${keyOf$1({ projects }, selected)} “${selected.title}”? u undoes it.`, () => store.getState().deleteTask(selected.id), "Delete");
|
|
28894
29091
|
if (mode === "board") {
|
|
28895
|
-
if (key.leftArrow || input === "h") setCol((c) => step(c, -1, columns.length));
|
|
28896
|
-
else if (key.rightArrow || input === "l") setCol((c) => step(c, 1, columns.length));
|
|
29092
|
+
if (key.leftArrow || input === "h") setCol((c) => step$1(c, -1, columns.length));
|
|
29093
|
+
else if (key.rightArrow || input === "l") setCol((c) => step$1(c, 1, columns.length));
|
|
28897
29094
|
else if (key.upArrow || input === "k") setRowByCol((r) => ({
|
|
28898
29095
|
...r,
|
|
28899
|
-
[col]: step(row, -1, column?.tasks.length ?? 0)
|
|
29096
|
+
[col]: step$1(row, -1, column?.tasks.length ?? 0)
|
|
28900
29097
|
}));
|
|
28901
29098
|
else if (key.downArrow || input === "j") setRowByCol((r) => ({
|
|
28902
29099
|
...r,
|
|
28903
|
-
[col]: step(row, 1, column?.tasks.length ?? 0)
|
|
29100
|
+
[col]: step$1(row, 1, column?.tasks.length ?? 0)
|
|
28904
29101
|
}));
|
|
28905
|
-
} else if (key.upArrow || input === "k") setListAt((i) => step(i, -1, list.length));
|
|
28906
|
-
else if (key.downArrow || input === "j") setListAt((i) => step(i, 1, list.length));
|
|
28907
|
-
else if (key.pageDown) setListAt((i) => step(i, 10, list.length));
|
|
28908
|
-
else if (key.pageUp) setListAt((i) => step(i, -10, list.length));
|
|
29102
|
+
} else if (key.upArrow || input === "k") setListAt((i) => step$1(i, -1, list.length));
|
|
29103
|
+
else if (key.downArrow || input === "j") setListAt((i) => step$1(i, 1, list.length));
|
|
29104
|
+
else if (key.pageDown) setListAt((i) => step$1(i, 10, list.length));
|
|
29105
|
+
else if (key.pageUp) setListAt((i) => step$1(i, -10, list.length));
|
|
28909
29106
|
}, { isActive: active });
|
|
28910
29107
|
if (!project) return /* @__PURE__ */ jsx(Box, {
|
|
28911
29108
|
paddingX: 1,
|
|
@@ -29376,7 +29573,8 @@ const NAME = 26;
|
|
|
29376
29573
|
/**
|
|
29377
29574
|
* A portfolio's programs and projects across the months, as on the
|
|
29378
29575
|
* website's roadmap: bars in each one's health, ◆ for milestones. [ ] for
|
|
29379
|
-
* another portfolio, z to zoom, ←→ to page through time
|
|
29576
|
+
* another portfolio, z to zoom, ←→ to page through time, D to hide or show
|
|
29577
|
+
* Done work (shown at first, as on the website).
|
|
29380
29578
|
*/
|
|
29381
29579
|
function RoadmapView({ active, width, height, go }) {
|
|
29382
29580
|
const { today } = useDeps();
|
|
@@ -29388,6 +29586,7 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
29388
29586
|
const [zoom, setZoom] = useState("half");
|
|
29389
29587
|
const [shift, setShift] = useState(0);
|
|
29390
29588
|
const [at, setAt] = useState(0);
|
|
29589
|
+
const [hideDone, setHideDone] = useState(false);
|
|
29391
29590
|
const portfolio = portfolios[Math.min(pf, portfolios.length - 1)];
|
|
29392
29591
|
const day = today();
|
|
29393
29592
|
const w = useMemo(() => roadmapWindow(zoom, day, shift), [
|
|
@@ -29401,14 +29600,20 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
29401
29600
|
milestones
|
|
29402
29601
|
}, portfolio.id, {
|
|
29403
29602
|
health: "all",
|
|
29404
|
-
ownerId: null
|
|
29603
|
+
ownerId: null,
|
|
29604
|
+
hideDone
|
|
29405
29605
|
}, /* @__PURE__ */ new Set(), day) : [], [
|
|
29406
29606
|
portfolio,
|
|
29407
29607
|
programs,
|
|
29408
29608
|
projects,
|
|
29409
29609
|
milestones,
|
|
29410
|
-
day
|
|
29610
|
+
day,
|
|
29611
|
+
hideDone
|
|
29411
29612
|
]);
|
|
29613
|
+
const doneCount = portfolio ? roadmapDoneCount({
|
|
29614
|
+
programs,
|
|
29615
|
+
projects
|
|
29616
|
+
}, portfolio.id) : 0;
|
|
29412
29617
|
const row = rows[Math.min(at, rows.length - 1)];
|
|
29413
29618
|
useInput((input, key) => {
|
|
29414
29619
|
if (input === "[") setPf((i) => (i - 1 + portfolios.length) % Math.max(1, portfolios.length));
|
|
@@ -29417,8 +29622,11 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
29417
29622
|
else if (key.leftArrow || input === "h") setShift((s) => s - 1);
|
|
29418
29623
|
else if (key.rightArrow || input === "l") setShift((s) => s + 1);
|
|
29419
29624
|
else if (input === "t") setShift(0);
|
|
29420
|
-
else if (
|
|
29421
|
-
|
|
29625
|
+
else if (input === "D" && (doneCount > 0 || hideDone)) {
|
|
29626
|
+
setHideDone((v) => !v);
|
|
29627
|
+
setAt(0);
|
|
29628
|
+
} else if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, rows.length));
|
|
29629
|
+
else if (key.downArrow || input === "j") setAt((i) => step$1(i, 1, rows.length));
|
|
29422
29630
|
else if (key.return && row) {
|
|
29423
29631
|
if (row.kind === "project") go.openProject(row.id);
|
|
29424
29632
|
else go.openProgram(row.id);
|
|
@@ -29434,7 +29642,7 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
29434
29642
|
paddingX: 1,
|
|
29435
29643
|
children: [
|
|
29436
29644
|
/* @__PURE__ */ jsxs(Heading, {
|
|
29437
|
-
right: `${label} · [ ] portfolio · z zoom · ←→ time · t today`,
|
|
29645
|
+
right: `${label}${doneCount ? ` · ${doneCount} done ${hideDone ? "hidden" : "shown"} (D)` : ""} · [ ] portfolio · z zoom · ←→ time · t today`,
|
|
29438
29646
|
children: ["Roadmap: ", portfolio?.name ?? "no portfolios yet"]
|
|
29439
29647
|
}),
|
|
29440
29648
|
/* @__PURE__ */ jsxs(Text, {
|
|
@@ -29443,7 +29651,7 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
29443
29651
|
}),
|
|
29444
29652
|
rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
29445
29653
|
color: theme.muted,
|
|
29446
|
-
children: "Nothing scheduled in this portfolio."
|
|
29654
|
+
children: hideDone && doneCount ? "Everything in this portfolio is done. D shows it." : "Nothing scheduled in this portfolio."
|
|
29447
29655
|
}) : null,
|
|
29448
29656
|
view.items.map((r, i) => {
|
|
29449
29657
|
const on = active && view.start + i === at;
|
|
@@ -29562,33 +29770,6 @@ function defaultAction(type) {
|
|
|
29562
29770
|
};
|
|
29563
29771
|
}
|
|
29564
29772
|
|
|
29565
|
-
// src/lib/custom-field-text.ts
|
|
29566
|
-
const FIELD_TYPE_LABEL = {
|
|
29567
|
-
text: "Text",
|
|
29568
|
-
number: "Number",
|
|
29569
|
-
select: "Dropdown",
|
|
29570
|
-
checkbox: "Checkbox"
|
|
29571
|
-
};
|
|
29572
|
-
const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
|
|
29573
|
-
/** A dropdown's options from "S, M, L". */
|
|
29574
|
-
function optionsOf(value) {
|
|
29575
|
-
return value.split(",").map((o) => o.trim()).filter(Boolean);
|
|
29576
|
-
}
|
|
29577
|
-
/** A task's value for a field as one line: "" when it has none. */
|
|
29578
|
-
function fieldValueText(def, value) {
|
|
29579
|
-
if (value == null || value === "") return "";
|
|
29580
|
-
if (def.type === "checkbox") return value ? "Yes" : "";
|
|
29581
|
-
return String(value);
|
|
29582
|
-
}
|
|
29583
|
-
/** What someone typed for a text or number field; undefined when a number can't be read. */
|
|
29584
|
-
function readFieldValue(def, text) {
|
|
29585
|
-
const t = text.trim();
|
|
29586
|
-
if (!t) return null;
|
|
29587
|
-
if (def.type !== "number") return t;
|
|
29588
|
-
const n = Number(t.replace(/,/g, ""));
|
|
29589
|
-
return Number.isFinite(n) ? n : void 0;
|
|
29590
|
-
}
|
|
29591
|
-
|
|
29592
29773
|
// src/tui/views/setup.tsx
|
|
29593
29774
|
/**
|
|
29594
29775
|
* A project's board setup, as the website's board settings have it: where it
|
|
@@ -29645,7 +29826,8 @@ function columnValue(c) {
|
|
|
29645
29826
|
}
|
|
29646
29827
|
function fieldValue(f) {
|
|
29647
29828
|
const kind = FIELD_TYPE_LABEL[f.type];
|
|
29648
|
-
|
|
29829
|
+
const head = hasOptions(f.type) && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
|
|
29830
|
+
return f.onCards ? `${head} · on cards` : head;
|
|
29649
29831
|
}
|
|
29650
29832
|
function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
29651
29833
|
const { store } = useDeps();
|
|
@@ -29794,10 +29976,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29794
29976
|
label: "Rename it",
|
|
29795
29977
|
value: "rename"
|
|
29796
29978
|
},
|
|
29797
|
-
...f.type
|
|
29979
|
+
...hasOptions(f.type) ? [{
|
|
29798
29980
|
label: "Change its options",
|
|
29799
29981
|
value: "options"
|
|
29800
29982
|
}] : [],
|
|
29983
|
+
{
|
|
29984
|
+
label: f.onCards ? "Stop showing it on cards" : "Show it on board cards",
|
|
29985
|
+
value: "cards"
|
|
29986
|
+
},
|
|
29801
29987
|
{
|
|
29802
29988
|
label: "Take it out",
|
|
29803
29989
|
value: "remove"
|
|
@@ -29806,6 +29992,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29806
29992
|
onPick: (what) => {
|
|
29807
29993
|
const s = store.getState();
|
|
29808
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 });
|
|
29809
29996
|
if (what === "options") return go.ask("Options, split by commas", (text) => s.updateCustomField(id, f.id, { options: optionsOf(text) }), (f.options ?? []).join(", "));
|
|
29810
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");
|
|
29811
29998
|
}
|
|
@@ -29826,7 +30013,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29826
30013
|
value: t
|
|
29827
30014
|
})), (t) => {
|
|
29828
30015
|
const type = t;
|
|
29829
|
-
if (type
|
|
30016
|
+
if (!hasOptions(type)) return void store.getState().addCustomField(id, {
|
|
29830
30017
|
name: trimmed,
|
|
29831
30018
|
type
|
|
29832
30019
|
});
|
|
@@ -29960,6 +30147,113 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
|
29960
30147
|
});
|
|
29961
30148
|
}
|
|
29962
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
|
+
|
|
29963
30257
|
// src/tui/views/sounding.ts
|
|
29964
30258
|
init_soundings();
|
|
29965
30259
|
const nameOf = (people, id) => people.find((p) => p.id === id)?.name ?? "someone";
|
|
@@ -30161,8 +30455,8 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30161
30455
|
useInput((input, key) => {
|
|
30162
30456
|
if (!task) return onClose();
|
|
30163
30457
|
if (key.escape) return onClose();
|
|
30164
|
-
if (key.upArrow || input === "k") return setAt((i) => step(i, -1, rows));
|
|
30165
|
-
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows));
|
|
30458
|
+
if (key.upArrow || input === "k") return setAt((i) => step$1(i, -1, rows));
|
|
30459
|
+
if (key.downArrow || input === "j") return setAt((i) => step$1(i, 1, rows));
|
|
30166
30460
|
if (input === "c") return setEditing({ kind: "comment" });
|
|
30167
30461
|
if (input === "a") return setEditing({ kind: "step" });
|
|
30168
30462
|
if (input === "C") {
|
|
@@ -30212,6 +30506,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30212
30506
|
const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
|
|
30213
30507
|
const blockers = blockersOf(task, tasks);
|
|
30214
30508
|
const done = () => setEditing(null);
|
|
30509
|
+
const nameOf = (id) => people.find((p) => p.id === id)?.name;
|
|
30215
30510
|
const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
|
|
30216
30511
|
function editCustom(f) {
|
|
30217
30512
|
if (!task) return;
|
|
@@ -30224,6 +30519,13 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30224
30519
|
label: o,
|
|
30225
30520
|
value: o
|
|
30226
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));
|
|
30227
30529
|
setEditing({
|
|
30228
30530
|
kind: "custom",
|
|
30229
30531
|
fieldId: f.id
|
|
@@ -30311,7 +30613,9 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30311
30613
|
const f = own.find((x) => x.id === editing.fieldId);
|
|
30312
30614
|
if (f) editor = /* @__PURE__ */ jsx(CustomField, {
|
|
30313
30615
|
def: f,
|
|
30314
|
-
initial:
|
|
30616
|
+
initial: typedText(f, task.customFieldValues?.[f.id]),
|
|
30617
|
+
today: day,
|
|
30618
|
+
people,
|
|
30315
30619
|
onCancel: done,
|
|
30316
30620
|
onSubmit: (v) => {
|
|
30317
30621
|
setCustom(f.id, v);
|
|
@@ -30507,7 +30811,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30507
30811
|
color: theme.muted,
|
|
30508
30812
|
children: f.name.slice(0, 8).padEnd(9)
|
|
30509
30813
|
}),
|
|
30510
|
-
fieldValueText(f, task.customFieldValues?.[f.id]) || "—"
|
|
30814
|
+
fieldValueText(f, task.customFieldValues?.[f.id], nameOf) || "—"
|
|
30511
30815
|
]
|
|
30512
30816
|
}, f.id);
|
|
30513
30817
|
}),
|
|
@@ -30598,18 +30902,34 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
30598
30902
|
]
|
|
30599
30903
|
});
|
|
30600
30904
|
}
|
|
30601
|
-
/**
|
|
30602
|
-
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 }) {
|
|
30603
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`;
|
|
30604
30921
|
return /* @__PURE__ */ jsx(TextField, {
|
|
30605
30922
|
label: def.name,
|
|
30606
30923
|
initial,
|
|
30607
|
-
hint: error ??
|
|
30924
|
+
hint: error ?? hint,
|
|
30608
30925
|
onCancel,
|
|
30609
30926
|
onSubmit: (text) => {
|
|
30610
|
-
const
|
|
30611
|
-
|
|
30612
|
-
|
|
30927
|
+
const read = readFieldValue(def, text, {
|
|
30928
|
+
today,
|
|
30929
|
+
people
|
|
30930
|
+
});
|
|
30931
|
+
if ("error" in read) return setError(read.error);
|
|
30932
|
+
onSubmit(read.value);
|
|
30613
30933
|
}
|
|
30614
30934
|
});
|
|
30615
30935
|
}
|
|
@@ -30659,8 +30979,8 @@ function WaitsEditor({ task, onDone }) {
|
|
|
30659
30979
|
const set = (ids) => store.getState().updateTask(task.id, blockersPatch(ids));
|
|
30660
30980
|
useInput((input, key) => {
|
|
30661
30981
|
if (key.escape) return onDone();
|
|
30662
|
-
if (key.upArrow) return setAt((i) => step(i, -1, list.length));
|
|
30663
|
-
if (key.downArrow) return setAt((i) => step(i, 1, list.length));
|
|
30982
|
+
if (key.upArrow) return setAt((i) => step$1(i, -1, list.length));
|
|
30983
|
+
if (key.downArrow) return setAt((i) => step$1(i, 1, list.length));
|
|
30664
30984
|
if (key.return) {
|
|
30665
30985
|
const pick = list[Math.min(at, list.length - 1)];
|
|
30666
30986
|
if (!pick) return onDone();
|
|
@@ -30769,10 +31089,10 @@ function TodayView({ active, width, height, go }) {
|
|
|
30769
31089
|
const selected = mine[Math.min(at, mine.length - 1)];
|
|
30770
31090
|
const late = mine.filter((t) => isLate(t, day)).length;
|
|
30771
31091
|
useInput((input, key) => {
|
|
30772
|
-
if (key.upArrow || input === "k") setAt((i) => step(i, -1, mine.length));
|
|
30773
|
-
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, mine.length));
|
|
30774
|
-
else if (key.pageDown) setAt((i) => step(i, 10, mine.length));
|
|
30775
|
-
else if (key.pageUp) setAt((i) => step(i, -10, mine.length));
|
|
31092
|
+
if (key.upArrow || input === "k") setAt((i) => step$1(i, -1, mine.length));
|
|
31093
|
+
else if (key.downArrow || input === "j") setAt((i) => step$1(i, 1, mine.length));
|
|
31094
|
+
else if (key.pageDown) setAt((i) => step$1(i, 10, mine.length));
|
|
31095
|
+
else if (key.pageUp) setAt((i) => step$1(i, -10, mine.length));
|
|
30776
31096
|
else if (key.return && selected) go.openTask(selected.id);
|
|
30777
31097
|
else if (input === "x" && selected) moveTask(store, go, selected, "done");
|
|
30778
31098
|
else if (input === "n" && projects.length === 0) go.create("project");
|
|
@@ -30967,8 +31287,8 @@ function WeekView({ active, width, height, go }) {
|
|
|
30967
31287
|
if (input === "t") return goWeek(0);
|
|
30968
31288
|
if (input === "v") return setReview((v) => !v);
|
|
30969
31289
|
if (review) return;
|
|
30970
|
-
if (key.upArrow || input === "k") setAt(step(sel, -1, picks.length));
|
|
30971
|
-
else if (key.downArrow || input === "j") setAt(step(sel, 1, picks.length));
|
|
31290
|
+
if (key.upArrow || input === "k") setAt(step$1(sel, -1, picks.length));
|
|
31291
|
+
else if (key.downArrow || input === "j") setAt(step$1(sel, 1, picks.length));
|
|
30972
31292
|
else if (key.return && task) go.openTask(task.id);
|
|
30973
31293
|
else if (input === "p" && line?.kind === "open") go.pick(`Plan “${line.task.title}” on`, dayChoices(), (d) => {
|
|
30974
31294
|
if (store.getState().planTask(line.task.id, d)) go.notify(`On ${dayName(d)}. h changes its hours.`);
|
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());
|