@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.
Files changed (3) hide show
  1. package/dist/app.mjs +468 -148
  2. package/lib/cli.mjs +33 -1
  3. 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.slice(0, 10));
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.slice(0, 10) > t.dueDate.slice(0, 10))).sort((a, b) => b.completedAt.localeCompare(a.completedAt))[0] ?? null
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 = new Set(readIds ?? []);
10095
- const unread = pings.filter((p) => !read.has(p.id)).length;
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") for (const p of pings) store.getState().readPing(p.id);
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.has(p.id);
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
- const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
23489
- if (repeat === "weekly") return iso(addDays(d, 7));
23490
- if (repeat === "biweekly") return iso(addDays(d, 14));
23491
- return iso(addMonths$1(d, 1));
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), due one interval after
23496
- * the old due date (or after today when it had none), back in To do.
23497
- * Null when the task does not repeat.
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 anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
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: nextDueDate(anchor, task.repeat),
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: (id) => {
24130
+ readPing: (mark) => {
23941
24131
  const ids = get().readPingIds ?? [];
23942
- if (ids.includes(id)) return;
23943
- set({ readPingIds: [...ids, id] });
24132
+ if (ids.includes(mark)) return;
24133
+ set({ readPingIds: [...ids, mark] });
23944
24134
  },
23945
- catchUp: (ids) => {
23946
- if (ids.length === 0) return;
24135
+ catchUp: (marks) => {
24136
+ if (marks.length === 0) return;
23947
24137
  const have = new Set(get().readPingIds ?? []);
23948
- for (const id of ids) have.add(id);
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 ids = (get().ropes ?? []).find((r) => r.personId === personId && r.date === date)?.taskIds ?? [];
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$1(people, name) {
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$1(data.people, fields.owner) : null;
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$1(get().people, owner)) {
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$1(get().people, owner) : null;
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) store.getState().toggleMilestone(mark.id);
28222
- else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
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 (key.upArrow || input === "k") setAt((i) => step(i, -1, rows.length));
29421
- else if (key.downArrow || input === "j") setAt((i) => step(i, 1, rows.length));
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
- return f.type === "select" && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
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 === "select" ? [{
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 !== "select") return void store.getState().addCustomField(id, {
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: fieldValueText(f, task.customFieldValues?.[f.id]),
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
- /** A text or number field of the project's own; a number that can't be read says so. */
30602
- function CustomField({ def, initial, onSubmit, onCancel }) {
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 ?? (def.type === "number" ? "A number, or empty for none" : "Empty for none"),
30924
+ hint: error ?? hint,
30608
30925
  onCancel,
30609
30926
  onSubmit: (text) => {
30610
- const v = readFieldValue(def, text);
30611
- if (v === void 0) return setError(`“${text}” isn't a number.`);
30612
- onSubmit(v);
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("my_work", { harbor, limit: 1 }, (r) => approvalsText(r));
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());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.83.0",
3
+ "version": "0.85.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",