@harborppm/cli 0.90.0 → 0.92.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/README.md CHANGED
@@ -166,7 +166,8 @@ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
166
166
  they really finished in that project
167
167
  harbor add "TITLE" --project P [--assignee me|NAME] [--also "NAME, NAME"] [--due DATE|pilot]
168
168
  [--priority low|medium|high|urgent] [--status S] [--comment TEXT]
169
- [--over-limit] [--literal] no TITLE: one title a line from stdin
169
+ [--starts DATE] [--over-limit] [--literal]
170
+ no TITLE: one title a line from stdin
170
171
  --due pilot takes the earliest day the owner's queue
171
172
  suggests (their open work due first, weekly pace, time
172
173
  away, what it waits on, how long their similar tasks
@@ -174,7 +175,7 @@ harbor add "TITLE" --project P [--assignee me|NAME] [--also "NAME, NAME"] [--due
174
175
  their next working day. A date that won't hold says so
175
176
  harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
176
177
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--also "NAME, NAME"|none]
177
- [--due DATE|pilot|none]
178
+ [--due DATE|pilot|none] [--starts DATE|none]
178
179
  [--priority P] [--status S] [--notes TEXT]
179
180
  [--repeat weekly|biweekly|monthly|never]
180
181
  [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
package/dist/app.mjs CHANGED
@@ -3670,8 +3670,8 @@ init_date_text();
3670
3670
  init_format();
3671
3671
  init_indexes();
3672
3672
  init_premortem();
3673
- const DAY = (iso) => parseIso(iso.slice(0, 10));
3674
- const span$1 = (a, b) => differenceInCalendarDays(DAY(b), DAY(a));
3673
+ const DAY$1 = (iso) => parseIso(iso.slice(0, 10));
3674
+ const span$1 = (a, b) => differenceInCalendarDays(DAY$1(b), DAY$1(a));
3675
3675
  /** Projects marked Done that have tasks to copy, most recently finished first. */
3676
3676
  function finishedProjects(data) {
3677
3677
  return data.projects.filter((p) => p.health === "done" && tasksOf(data.tasks, p.id).length > 0).map((p) => ({
@@ -3762,7 +3762,7 @@ function reckoningLine(r) {
3762
3762
  }
3763
3763
  /** The new project's end date, as long after its start as the old one really ran. */
3764
3764
  function reckonedEnd(r, startDate) {
3765
- return todayISO(addDays(DAY(startDate), r.took));
3765
+ return todayISO(addDays(DAY$1(startDate), r.took));
3766
3766
  }
3767
3767
  /** Puts the reckoning's tasks and risks into the new project, dated from its start. */
3768
3768
  function applyReckoning(actions, projectId, r, startDate) {
@@ -3773,7 +3773,7 @@ function applyReckoning(actions, projectId, r, startDate) {
3773
3773
  status: "todo",
3774
3774
  priority: t.priority,
3775
3775
  assigneeId: null,
3776
- dueDate: todayISO(addDays(DAY(startDate), t.dueIn))
3776
+ dueDate: todayISO(addDays(DAY$1(startDate), t.dueIn))
3777
3777
  }, {
3778
3778
  allowOverCap: true,
3779
3779
  allowBlocked: true
@@ -9604,12 +9604,14 @@ const NOUN$1 = {
9604
9604
  milestone: "milestone",
9605
9605
  risk: "risk",
9606
9606
  issue: "issue",
9607
- decision: "decision"
9607
+ decision: "decision",
9608
+ hold: ""
9608
9609
  };
9609
9610
  const CREATED = {
9610
9611
  risk: "raised risk",
9611
9612
  issue: "logged issue",
9612
- decision: "recorded decision"
9613
+ decision: "recorded decision",
9614
+ hold: "named what's holding it back:"
9613
9615
  };
9614
9616
  const whole = new Intl.NumberFormat("en-US", {
9615
9617
  style: "currency",
@@ -9667,23 +9669,31 @@ function describeEvent(e, names) {
9667
9669
  detail: null
9668
9670
  };
9669
9671
  if (e.kind === "deleted") return {
9670
- action: `deleted ${noun}`,
9672
+ action: `deleted ${noun}`.trim(),
9671
9673
  detail: null
9672
9674
  };
9673
9675
  const { from, to } = e;
9674
9676
  switch (e.field) {
9675
9677
  case "name":
9676
9678
  case "title": return {
9677
- action: `renamed ${noun}`,
9679
+ action: `renamed ${noun}`.trim(),
9678
9680
  detail: from ? `from “${from}”` : null
9679
9681
  };
9680
- case "status": return e.entity === "task" ? {
9681
- action: "moved",
9682
- detail: `from ${statusLabel(e.entity, from)} to ${statusLabel(e.entity, to)}`
9683
- } : {
9684
- action: "set",
9685
- detail: `to ${statusLabel(e.entity, to)}`
9686
- };
9682
+ case "status":
9683
+ if (e.entity === "hold") return to === "resolved" ? {
9684
+ action: "cleared",
9685
+ detail: "from what's holding it back"
9686
+ } : {
9687
+ action: "brought back",
9688
+ detail: "as what's holding it back"
9689
+ };
9690
+ return e.entity === "task" ? {
9691
+ action: "moved",
9692
+ detail: `from ${statusLabel(e.entity, from)} to ${statusLabel(e.entity, to)}`
9693
+ } : {
9694
+ action: "set",
9695
+ detail: `to ${statusLabel(e.entity, to)}`
9696
+ };
9687
9697
  case "assigneeId": return to ? {
9688
9698
  action: "assigned",
9689
9699
  detail: `to ${nameOf$3(names, to)}`
@@ -11070,6 +11080,46 @@ function blockerOptions(items, id) {
11070
11080
  return items.filter((n) => n.id !== id && !loops.has(n.id));
11071
11081
  }
11072
11082
 
11083
+ // src/lib/constraint-history.ts
11084
+ const RETIRED = "retired the constraint on ";
11085
+ function clearedConstraints(data, programId) {
11086
+ const rows = (data.constraints ?? []).filter((c) => c.programId === programId && c.status === "resolved");
11087
+ const unstamped = rows.filter((c) => !c.resolvedAt);
11088
+ let guess;
11089
+ if (unstamped.length === 1) guess = (data.activity ?? []).find((a) => a.text.startsWith(RETIRED) && a.href === `/programs/${programId}`);
11090
+ const out = rows.map((c, i) => {
11091
+ if (c.resolvedAt) return {
11092
+ constraint: c,
11093
+ at: c.resolvedAt,
11094
+ byId: c.resolvedById ?? null,
11095
+ order: i
11096
+ };
11097
+ if (guess) return {
11098
+ constraint: c,
11099
+ at: guess.at,
11100
+ byId: guess.personId || null,
11101
+ order: i
11102
+ };
11103
+ return {
11104
+ constraint: c,
11105
+ at: null,
11106
+ byId: null,
11107
+ order: i
11108
+ };
11109
+ });
11110
+ out.sort((a, b) => {
11111
+ if (a.at && b.at) return a.at < b.at ? 1 : a.at > b.at ? -1 : b.order - a.order;
11112
+ if (a.at) return -1;
11113
+ if (b.at) return 1;
11114
+ return b.order - a.order;
11115
+ });
11116
+ return out.map(({ constraint, at, byId }) => ({
11117
+ constraint,
11118
+ at,
11119
+ byId
11120
+ }));
11121
+ }
11122
+
11073
11123
  // src/lib/status-report.ts
11074
11124
  init_derived_health();
11075
11125
  init_flow();
@@ -11426,7 +11476,19 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
11426
11476
  const programs = useHarbor((s) => s.programs);
11427
11477
  const projects = useHarbor((s) => s.projects);
11428
11478
  const constraint = useHarbor((s) => s.constraints.find((c) => c.programId === id && c.status === "active"));
11479
+ const constraints = useHarbor((s) => s.constraints);
11480
+ const activity = useHarbor((s) => s.activity);
11429
11481
  if (!program) return null;
11482
+ const cleared = clearedConstraints({
11483
+ constraints,
11484
+ activity
11485
+ }, id);
11486
+ const clearedHint = (at, byId) => {
11487
+ const when = at ? parseIso(at) : null;
11488
+ const by = byId ? people.find((p) => p.id === byId)?.name : void 0;
11489
+ const ago = when && !Number.isNaN(when.getTime()) ? `cleared ${timeAgo(when)}` : "cleared, date not kept";
11490
+ return by ? `${ago} by ${by}` : ago;
11491
+ };
11430
11492
  const s = store.getState();
11431
11493
  const inProgram = projects.filter((p) => p.programId === id);
11432
11494
  const baselined = inProgram.filter(hasBaseline);
@@ -11553,6 +11615,22 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
11553
11615
  onPick: (v) => holding({ ownerId: v })
11554
11616
  }
11555
11617
  }] : [],
11618
+ ...cleared.length ? [{
11619
+ label: "Cleared before",
11620
+ value: `${cleared[0].constraint.title} (${clearedHint(cleared[0].at, cleared[0].byId)})${cleared.length > 1 ? ` and ${cleared.length - 1} more` : ""}`,
11621
+ color: theme.muted,
11622
+ edit: constraint ? void 0 : {
11623
+ kind: "pick",
11624
+ title: "Bring one back as what's holding it back",
11625
+ choices: cleared.map((c) => ({
11626
+ label: c.constraint.title,
11627
+ value: c.constraint.id,
11628
+ hint: clearedHint(c.at, c.byId)
11629
+ })),
11630
+ initial: 0,
11631
+ onPick: (v) => store.getState().reopenConstraint(v)
11632
+ }
11633
+ }] : [],
11556
11634
  waitsField("Waits on", program, programs, go, (patch) => store.getState().updateProgram(id, patch)),
11557
11635
  {
11558
11636
  label: "Baseline",
@@ -13990,6 +14068,33 @@ function linkedProjects(kr, projects) {
13990
14068
  if (ids.size === 0) return [];
13991
14069
  return projects.filter((p) => ids.has(p.id) || ids.has(p.programId));
13992
14070
  }
14071
+ /**
14072
+ * The tasks a work key result counts: every task in the projects it names
14073
+ * (directly or through their program), and each task it names on its own,
14074
+ * each counted once however many ways it's linked.
14075
+ */
14076
+ function workCount(kr, work) {
14077
+ let done = 0;
14078
+ let all = 0;
14079
+ const seen = /* @__PURE__ */ new Set();
14080
+ const count = (t) => {
14081
+ if (seen.has(t.id)) return;
14082
+ seen.add(t.id);
14083
+ all++;
14084
+ if (t.status === "done") done++;
14085
+ };
14086
+ const projects = linkedProjects(kr, work.projects);
14087
+ for (const p of projects) for (const t of tasksOf(work.tasks, p.id)) count(t);
14088
+ const tasks = byId(work.tasks);
14089
+ for (const id of kr.linkIds ?? []) {
14090
+ const t = tasks.get(id);
14091
+ if (t) count(t);
14092
+ }
14093
+ return {
14094
+ done,
14095
+ all
14096
+ };
14097
+ }
13993
14098
  /** How far along one key result is, 0 to 1. */
13994
14099
  function keyResultProgress(kr, work) {
13995
14100
  if (kr.kind === "number") {
@@ -13998,12 +14103,7 @@ function keyResultProgress(kr, work) {
13998
14103
  if (span === 0) return (kr.current ?? start) >= (kr.target ?? 0) ? 1 : 0;
13999
14104
  return clamp(((kr.current ?? start) - start) / span);
14000
14105
  }
14001
- let done = 0;
14002
- let all = 0;
14003
- for (const p of linkedProjects(kr, work.projects)) for (const t of tasksOf(work.tasks, p.id)) {
14004
- all++;
14005
- if (t.status === "done") done++;
14006
- }
14106
+ const { done, all } = workCount(kr, work);
14007
14107
  return all === 0 ? 0 : done / all;
14008
14108
  }
14009
14109
  /** How far along a goal is: the average of its key results, 0 to 1. */
@@ -14012,10 +14112,12 @@ function goalProgress(goal, work) {
14012
14112
  if (krs.length === 0) return 0;
14013
14113
  return krs.reduce((sum, kr) => sum + keyResultProgress(kr, work), 0) / krs.length;
14014
14114
  }
14015
- /** How much of the goal's time has gone by, 0 to 1, from when it was set to its due day. */
14115
+ /** The day a goal's clock starts: its start day, or the day it was set. */
14116
+ const goalStart = (goal) => goal.start || goal.createdAt.slice(0, 10);
14117
+ /** How much of the goal's time has gone by, 0 to 1, from its start day to its due day. */
14016
14118
  function timeGone(goal, today) {
14017
14119
  if (!goal.due) return null;
14018
- const start = Date.parse(`${goal.createdAt.slice(0, 10)}T00:00:00Z`);
14120
+ const start = Date.parse(`${goalStart(goal)}T00:00:00Z`);
14019
14121
  const end = Date.parse(`${goal.due}T00:00:00Z`);
14020
14122
  const now = Date.parse(`${today}T00:00:00Z`);
14021
14123
  if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
@@ -14033,6 +14135,7 @@ function goalStatus(progress, goal, today) {
14033
14135
  const gone = timeGone(goal, today);
14034
14136
  if (gone == null) return progress > 0 ? "on-track" : "not-started";
14035
14137
  if (goal.due && today > goal.due) return "off-track";
14138
+ if (today < goalStart(goal)) return "not-started";
14036
14139
  if (progress === 0 && gone < .1) return "not-started";
14037
14140
  const behind = gone - progress;
14038
14141
  if (behind <= .1) return "on-track";
@@ -14045,18 +14148,94 @@ function keyResultText(kr, work) {
14045
14148
  const unit = kr.unit ? ` ${kr.unit}` : "";
14046
14149
  return `${fmt(kr.current ?? kr.start ?? 0)} of ${fmt(kr.target ?? 0)}${unit}`;
14047
14150
  }
14048
- let done = 0;
14049
- let all = 0;
14050
- for (const p of linkedProjects(kr, work.projects)) for (const t of tasksOf(work.tasks, p.id)) {
14051
- all++;
14052
- if (t.status === "done") done++;
14053
- }
14151
+ if (!kr.linkIds?.length) return "Nothing linked yet";
14152
+ const { done, all } = workCount(kr, work);
14054
14153
  return all === 0 ? "No tasks yet" : `${done} of ${all} tasks done`;
14055
14154
  }
14056
14155
  const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
14057
14156
 
14157
+ // src/lib/custom-field-text.ts
14158
+ init_date_text();
14159
+ const FIELD_TYPE_LABEL = {
14160
+ text: "Text",
14161
+ number: "Number",
14162
+ select: "Dropdown",
14163
+ multi: "Tags",
14164
+ checkbox: "Checkbox",
14165
+ date: "Date",
14166
+ person: "Person",
14167
+ url: "Link"
14168
+ };
14169
+ const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
14170
+ /** A dropdown or tags field: one with a list of options. */
14171
+ function hasOptions(type) {
14172
+ return type === "select" || type === "multi";
14173
+ }
14174
+ /** A dropdown's options from "S, M, L". */
14175
+ function optionsOf(value) {
14176
+ return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
14177
+ }
14178
+ /** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
14179
+ function safeLink(value) {
14180
+ if (typeof value !== "string") return null;
14181
+ try {
14182
+ const u = new URL(value.trim());
14183
+ return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
14184
+ } catch {
14185
+ return null;
14186
+ }
14187
+ }
14188
+ /** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
14189
+ function readLink(text) {
14190
+ const t = text.trim();
14191
+ if (!t || /\s/.test(t)) return null;
14192
+ const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
14193
+ return href && /\./.test(new URL(href).hostname) ? href : null;
14194
+ }
14195
+ /** "example.com/docs/plan" from a link: what a short chip says. */
14196
+ function linkLabel(href) {
14197
+ try {
14198
+ const u = new URL(href);
14199
+ const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
14200
+ const text = u.host.replace(/^www\./, "") + path;
14201
+ return text.length > 40 ? `${text.slice(0, 39)}…` : text;
14202
+ } catch {
14203
+ return href;
14204
+ }
14205
+ }
14206
+ /** A tags field's value as a list, whatever was stored. */
14207
+ function tagsOf(value) {
14208
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
14209
+ return typeof value === "string" && value ? [value] : [];
14210
+ }
14211
+ /** Whether a task carries a value for the field. */
14212
+ function hasFieldValue(def, value) {
14213
+ if (value == null || value === "" || value === false) return false;
14214
+ if (def.type === "multi") return tagsOf(value).length > 0;
14215
+ return true;
14216
+ }
14217
+ /** A task's value for a field as one line: "" when it has none. */
14218
+ function fieldValueText(def, value, nameOf) {
14219
+ if (!hasFieldValue(def, value)) return "";
14220
+ switch (def.type) {
14221
+ case "checkbox": return value ? "Yes" : "";
14222
+ case "multi": return tagsOf(value).join(", ");
14223
+ case "date": {
14224
+ const d = parseIso(String(value));
14225
+ return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
14226
+ }
14227
+ case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
14228
+ case "url": {
14229
+ const href = safeLink(value);
14230
+ return href ? linkLabel(href) : "";
14231
+ }
14232
+ default: return Array.isArray(value) ? value.join(", ") : String(value);
14233
+ }
14234
+ }
14235
+
14058
14236
  // src/lib/reports.ts
14059
14237
  init_date_fns();
14238
+ init_blockers();
14060
14239
  init_date_text();
14061
14240
  init_indexes();
14062
14241
  init_swatches();
@@ -14065,20 +14244,29 @@ const WEEKS_BACK = 12;
14065
14244
  const WEEKS_AHEAD = 8;
14066
14245
  /** Bars past this many are summed into "Everything else". */
14067
14246
  const MAX_BARS = 12;
14068
- const GROUPS = [
14069
- "status",
14070
- "owner",
14071
- "priority",
14072
- "project",
14073
- "program",
14074
- "week"
14247
+ const MEASURES = [
14248
+ "open",
14249
+ "done",
14250
+ "all",
14251
+ "overdue",
14252
+ "blocked",
14253
+ "approval",
14254
+ "hours",
14255
+ "risks",
14256
+ "issues",
14257
+ "decisions"
14075
14258
  ];
14076
14259
  const MEASURE_LABEL = {
14077
14260
  open: "Open tasks",
14078
14261
  done: "Finished tasks",
14079
14262
  all: "All tasks",
14080
14263
  overdue: "Overdue tasks",
14081
- hours: "Hours logged"
14264
+ blocked: "Blocked tasks",
14265
+ approval: "Tasks waiting for approval",
14266
+ hours: "Hours logged",
14267
+ risks: "Open risks",
14268
+ issues: "Open issues",
14269
+ decisions: "Decisions to make"
14082
14270
  };
14083
14271
  const GROUP_LABEL = {
14084
14272
  status: "Status",
@@ -14086,16 +14274,72 @@ const GROUP_LABEL = {
14086
14274
  priority: "Priority",
14087
14275
  project: "Project",
14088
14276
  program: "Program",
14089
- week: "Week"
14277
+ week: "Week",
14278
+ severity: "Severity",
14279
+ field: "A project field"
14090
14280
  };
14091
- /** The splits a measure can take: hours have no status or priority of their own. */
14281
+ /** Still open: raid.ts's `isActive`, kept here so a chart doesn't load the register's code. */
14282
+ const ACTIVE_RAID = /* @__PURE__ */ new Set([
14283
+ "open",
14284
+ "watching",
14285
+ "mitigated",
14286
+ "proposed"
14287
+ ]);
14288
+ const RAID_KIND = {
14289
+ risks: "risk",
14290
+ issues: "issue",
14291
+ decisions: "decision"
14292
+ };
14293
+ /** True for the measures that count risks, issues or decisions rather than tasks. */
14294
+ const isRaidMeasure = (m) => m in RAID_KIND;
14295
+ /** The splits a measure can take: hours have no status or priority of their own, risks no project fields. */
14092
14296
  function groupsFor(measure) {
14093
- return measure === "hours" ? [
14297
+ if (measure === "hours") return [
14298
+ "owner",
14299
+ "project",
14300
+ "program",
14301
+ "week",
14302
+ "field"
14303
+ ];
14304
+ if (measure === "decisions") return [
14094
14305
  "owner",
14095
14306
  "project",
14096
14307
  "program",
14097
14308
  "week"
14098
- ] : GROUPS;
14309
+ ];
14310
+ if (isRaidMeasure(measure)) return [
14311
+ "severity",
14312
+ "owner",
14313
+ "project",
14314
+ "program",
14315
+ "week"
14316
+ ];
14317
+ return [
14318
+ "status",
14319
+ "owner",
14320
+ "priority",
14321
+ "project",
14322
+ "program",
14323
+ "week",
14324
+ "field"
14325
+ ];
14326
+ }
14327
+ /** What a chart can be kept to. Hours read the task they were logged on, so they take its priority and status. */
14328
+ function filtersFor(measure) {
14329
+ if (measure === "decisions") return ["owner"];
14330
+ if (isRaidMeasure(measure)) return ["severity", "owner"];
14331
+ return [
14332
+ "priority",
14333
+ "owner",
14334
+ "status",
14335
+ "field"
14336
+ ];
14337
+ }
14338
+ /** "Owner" means who logged the hours, and who's asked to approve; the editor says so. */
14339
+ function groupLabel(measure, group) {
14340
+ if (group === "owner" && measure === "hours") return "Who logged it";
14341
+ if (group === "owner" && measure === "approval") return "Approver";
14342
+ return GROUP_LABEL[group];
14099
14343
  }
14100
14344
  const STATUS_FILL = {
14101
14345
  backlog: "var(--color-swatch-slate)",
@@ -14116,6 +14360,21 @@ const PRIORITIES$1 = [
14116
14360
  "medium",
14117
14361
  "low"
14118
14362
  ];
14363
+ const SEVERITIES = [
14364
+ "high",
14365
+ "medium",
14366
+ "low"
14367
+ ];
14368
+ const SEVERITY_LABEL = {
14369
+ high: "High",
14370
+ medium: "Medium",
14371
+ low: "Low"
14372
+ };
14373
+ const SEVERITY_FILL = {
14374
+ high: "var(--color-swatch-coral)",
14375
+ medium: "var(--color-swatch-sand)",
14376
+ low: "var(--color-swatch-slate)"
14377
+ };
14119
14378
  /** Colours handed out in turn where a slice has none of its own. */
14120
14379
  const CYCLE = [
14121
14380
  "sea",
@@ -14137,14 +14396,21 @@ function mondayOf(day) {
14137
14396
  }
14138
14397
  /** The projects a chart reads: everything, or what sits under its scope. */
14139
14398
  function scopeOf(data, scopeId) {
14399
+ const none = {
14400
+ programIds: null,
14401
+ portfolioId: null
14402
+ };
14140
14403
  if (!scopeId) return {
14141
14404
  projectIds: null,
14405
+ ...none,
14142
14406
  name: "Whole harbor",
14143
14407
  found: true
14144
14408
  };
14145
14409
  const project = byId(data.projects).get(scopeId);
14146
14410
  if (project) return {
14147
14411
  projectIds: /* @__PURE__ */ new Set([project.id]),
14412
+ programIds: /* @__PURE__ */ new Set(),
14413
+ portfolioId: null,
14148
14414
  name: project.name,
14149
14415
  found: true
14150
14416
  };
@@ -14153,6 +14419,8 @@ function scopeOf(data, scopeId) {
14153
14419
  const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
14154
14420
  return {
14155
14421
  projectIds: new Set(ids),
14422
+ programIds: /* @__PURE__ */ new Set([program.id]),
14423
+ portfolioId: null,
14156
14424
  name: program.name,
14157
14425
  found: true
14158
14426
  };
@@ -14163,60 +14431,243 @@ function scopeOf(data, scopeId) {
14163
14431
  const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
14164
14432
  return {
14165
14433
  projectIds: new Set(ids),
14434
+ programIds,
14435
+ portfolioId: portfolio.id,
14166
14436
  name: portfolio.name,
14167
14437
  found: true
14168
14438
  };
14169
14439
  }
14170
14440
  return {
14171
14441
  projectIds: /* @__PURE__ */ new Set(),
14442
+ programIds: /* @__PURE__ */ new Set(),
14443
+ portfolioId: null,
14172
14444
  name: "Deleted",
14173
14445
  found: false
14174
14446
  };
14175
14447
  }
14448
+ /** A risk, issue or decision belongs to a scope when it was raised in it or anywhere under it. */
14449
+ function raidInScope(item, scope) {
14450
+ if (!scope.projectIds) return true;
14451
+ if (item.projectId) return scope.projectIds.has(item.projectId);
14452
+ if (item.programId) return scope.programIds?.has(item.programId) ?? false;
14453
+ return !!item.portfolioId && item.portfolioId === scope.portfolioId;
14454
+ }
14455
+ const isGroupable = (def) => def.type === "select" || def.type === "multi" || def.type === "person" || def.type === "checkbox";
14456
+ function matchesFilter(def, value, want) {
14457
+ if (!want) return !hasFieldValue(def, value);
14458
+ if (def.type === "checkbox") return value === true;
14459
+ if (def.type === "multi") return tagsOf(value).includes(want);
14460
+ return value === want;
14461
+ }
14462
+ const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
14176
14463
  /**
14177
- * What a chart shows today: its slices in a fixed order (statuses and
14178
- * priorities in board order, weeks in time order, the rest largest first,
14179
- * past MAX_BARS summed into "Everything else"), with their total.
14464
+ * Each project's field of this name, where a chart can split or filter by it:
14465
+ * the kind of field that has a short list of values (choice, tags, person,
14466
+ * checkbox), and the same kind as the first project that has one, so "Team"
14467
+ * as a choice in one project and a person in another doesn't mix the two.
14468
+ */
14469
+ function fieldDefs(projects, name) {
14470
+ const defs = /* @__PURE__ */ new Map();
14471
+ if (!name?.trim()) return defs;
14472
+ let type = null;
14473
+ for (const p of projects) {
14474
+ const def = (p.customFields ?? []).find((d) => sameName(d.name, name) && isGroupable(d));
14475
+ if (!def) continue;
14476
+ type ??= def.type;
14477
+ if (def.type === type) defs.set(p.id, def);
14478
+ }
14479
+ return defs;
14480
+ }
14481
+ /** The fields a chart over this scope can split or filter by, by name, in the projects' own order. */
14482
+ function chartFields(data, scopeId) {
14483
+ const scope = scopeOf(data, scopeId);
14484
+ const out = [];
14485
+ for (const p of data.projects) {
14486
+ if (scope.projectIds && !scope.projectIds.has(p.id)) continue;
14487
+ for (const def of p.customFields ?? []) {
14488
+ if (!isGroupable(def) || out.some((f) => sameName(f.name, def.name))) continue;
14489
+ out.push({
14490
+ name: def.name.trim(),
14491
+ def
14492
+ });
14493
+ }
14494
+ }
14495
+ return out;
14496
+ }
14497
+ /** The values a filter can keep to, with "" meaning none ("Nobody", "No Size"). */
14498
+ function filterValues(measure, filter, data, scopeId) {
14499
+ switch (filter.by) {
14500
+ case "priority": return PRIORITIES$1.map((p) => ({
14501
+ value: p,
14502
+ label: PRIORITY_LABEL[p]
14503
+ }));
14504
+ case "status": return TASK_COLUMNS.map((s) => ({
14505
+ value: s,
14506
+ label: TASK_STATUS_LABEL[s]
14507
+ }));
14508
+ case "severity": return SEVERITIES.map((s) => ({
14509
+ value: s,
14510
+ label: SEVERITY_LABEL[s]
14511
+ }));
14512
+ case "owner": return [...data.people.map((p) => ({
14513
+ value: p.id,
14514
+ label: p.name
14515
+ })), {
14516
+ value: "",
14517
+ label: measure === "approval" ? "No approver" : "Nobody"
14518
+ }];
14519
+ case "field": {
14520
+ const found = chartFields(data, scopeId).find((f) => sameName(f.name, filter.field ?? ""));
14521
+ if (!found) return [];
14522
+ const def = {
14523
+ ...found.def,
14524
+ name: found.name
14525
+ };
14526
+ if (def.type === "select" || def.type === "multi") {
14527
+ const scope = scopeOf(data, scopeId);
14528
+ const inScope = data.projects.filter((p) => !scope.projectIds || scope.projectIds.has(p.id));
14529
+ const options = /* @__PURE__ */ new Set();
14530
+ for (const d of fieldDefs(inScope, def.name).values()) for (const o of d.options ?? []) options.add(o);
14531
+ return [...[...options].map((o) => ({
14532
+ value: o,
14533
+ label: o
14534
+ })), {
14535
+ value: "",
14536
+ label: `No ${def.name}`
14537
+ }];
14538
+ }
14539
+ return def.type === "checkbox" ? [{
14540
+ value: "yes",
14541
+ label: def.name
14542
+ }, {
14543
+ value: "",
14544
+ label: `Not ${def.name}`
14545
+ }] : [...data.people.map((p) => ({
14546
+ value: p.id,
14547
+ label: p.name
14548
+ })), {
14549
+ value: "",
14550
+ label: `No ${def.name}`
14551
+ }];
14552
+ }
14553
+ }
14554
+ }
14555
+ /** "Only urgent", "Only Ana's", "Only Size: L", "Only no owner". */
14556
+ function filterText(measure, filter, data, scopeId) {
14557
+ const personField = filter.by === "field" && chartFields(data, scopeId).find((f) => sameName(f.name, filter.field ?? ""))?.def.type === "person";
14558
+ const label = filterValues(measure, filter, data, scopeId).find((c) => c.value === filter.value)?.label ?? (filter.by === "owner" ? "Former member" : personField ? "Someone no longer here" : filter.value);
14559
+ if (filter.by === "field") {
14560
+ const name = filter.field?.trim() ?? "";
14561
+ if (!filter.value) return `Only ${label.charAt(0).toLowerCase()}${label.slice(1)}`;
14562
+ return sameName(label, name) ? `Only ${label}` : `Only ${name}: ${label}`;
14563
+ }
14564
+ if (filter.by === "owner") {
14565
+ if (!filter.value) return measure === "approval" ? "Only with no approver" : "Only with no owner";
14566
+ if (measure === "hours") return `Only logged by ${label}`;
14567
+ if (measure === "approval") return `Only for ${label} to approve`;
14568
+ return `Only ${label}'s`;
14569
+ }
14570
+ if (filter.by === "severity") return `Only ${label.toLowerCase()} severity`;
14571
+ return `Only ${label.toLowerCase()}`;
14572
+ }
14573
+ /**
14574
+ * What a chart shows today: its slices in a fixed order (statuses,
14575
+ * priorities and severities in their own order, weeks in time order, the
14576
+ * rest largest first, past MAX_BARS summed into "Everything else"), with
14577
+ * their total.
14180
14578
  */
14181
14579
  function chartData(chart, data, today) {
14580
+ const measure = MEASURES.includes(chart.measure) ? chart.measure : "open";
14182
14581
  const scope = scopeOf(data, chart.scopeId);
14183
14582
  const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
14184
- const unit = chart.measure === "hours" ? "hours" : "tasks";
14185
- const groupBy = groupsFor(chart.measure).includes(chart.groupBy) ? chart.groupBy : "owner";
14583
+ const kind = RAID_KIND[measure];
14584
+ const unit = kind ? measure : measure === "hours" ? "hours" : "tasks";
14585
+ const groupBy = groupsFor(measure).includes(chart.groupBy) ? chart.groupBy : "owner";
14586
+ const filter = chart.filter && filtersFor(measure).includes(chart.filter.by) ? chart.filter : null;
14587
+ const projectsInScope = scope.projectIds ? data.projects.filter((p) => scope.projectIds.has(p.id)) : data.projects;
14588
+ const splitDefs = groupBy === "field" ? fieldDefs(projectsInScope, chart.field) : null;
14589
+ const filterDefs = filter?.by === "field" ? fieldDefs(projectsInScope, filter.field) : null;
14590
+ const tasksById = measure === "blocked" ? byId(data.tasks) : null;
14591
+ const keep = (item) => {
14592
+ if (!filter) return true;
14593
+ const t = item.task;
14594
+ switch (filter.by) {
14595
+ case "owner": return (item.personId ?? "") === filter.value;
14596
+ case "priority": return !!t && t.priority === filter.value;
14597
+ case "status": return !!t && t.status === filter.value;
14598
+ case "severity": return !!item.raid && item.raid.severity === filter.value;
14599
+ case "field": {
14600
+ if (!t) return false;
14601
+ const def = filterDefs.get(t.projectId);
14602
+ if (!def) return filter.value === "";
14603
+ return matchesFilter(def, t.customFieldValues?.[def.id], filter.value);
14604
+ }
14605
+ }
14606
+ };
14186
14607
  const items = [];
14187
- for (const task of data.tasks) {
14608
+ const push = (item) => {
14609
+ if (keep(item)) items.push(item);
14610
+ };
14611
+ if (kind) for (const r of data.raidItems ?? []) {
14612
+ if (r.kind !== kind || !ACTIVE_RAID.has(r.status) || !raidInScope(r, scope)) continue;
14613
+ push({
14614
+ raid: r,
14615
+ amount: 1,
14616
+ day: r.dueDate,
14617
+ personId: r.ownerId
14618
+ });
14619
+ }
14620
+ else for (const task of data.tasks) {
14188
14621
  if (!inScope(task)) continue;
14189
14622
  const done = task.status === "done";
14190
- switch (chart.measure) {
14623
+ const mine = {
14624
+ task,
14625
+ amount: 1,
14626
+ personId: task.assigneeId
14627
+ };
14628
+ switch (measure) {
14191
14629
  case "open":
14192
- if (!done) items.push({
14193
- task,
14194
- amount: 1,
14630
+ if (!done) push({
14631
+ ...mine,
14195
14632
  day: task.dueDate
14196
14633
  });
14197
14634
  break;
14198
14635
  case "done":
14199
- if (done) items.push({
14200
- task,
14201
- amount: 1,
14636
+ if (done) push({
14637
+ ...mine,
14202
14638
  day: task.completedAt
14203
14639
  });
14204
14640
  break;
14205
14641
  case "all":
14206
- items.push({
14207
- task,
14208
- amount: 1,
14642
+ push({
14643
+ ...mine,
14209
14644
  day: task.createdAt
14210
14645
  });
14211
14646
  break;
14212
14647
  case "overdue":
14213
- if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) items.push({
14648
+ if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) push({
14649
+ ...mine,
14650
+ day: task.dueDate
14651
+ });
14652
+ break;
14653
+ case "blocked":
14654
+ if (!done && blockerIds(task).some((id) => {
14655
+ const b = tasksById.get(id);
14656
+ return !!b && b.status !== "done";
14657
+ })) push({
14658
+ ...mine,
14659
+ day: task.dueDate
14660
+ });
14661
+ break;
14662
+ case "approval":
14663
+ if (task.approval?.status === "pending") push({
14214
14664
  task,
14215
14665
  amount: 1,
14216
- day: task.dueDate
14666
+ day: task.approval.askedAt,
14667
+ personId: task.approval.approverId
14217
14668
  });
14218
14669
  break;
14219
- case "hours": for (const entry of task.time ?? []) items.push({
14670
+ case "hours": for (const entry of task.time ?? []) push({
14220
14671
  task,
14221
14672
  amount: entry.minutes / 60,
14222
14673
  day: entry.on,
@@ -14229,10 +14680,14 @@ function chartData(chart, data, today) {
14229
14680
  const people = byId(data.people);
14230
14681
  const sums = /* @__PURE__ */ new Map();
14231
14682
  const add = (key, amount) => sums.set(key, (sums.get(key) ?? 0) + amount);
14683
+ /** Field values in the order the field lists them, for a field split. */
14684
+ const fieldLabels = /* @__PURE__ */ new Map();
14685
+ let fieldNone = "";
14686
+ let overlaps = false;
14232
14687
  let weeks = [];
14233
14688
  if (groupBy === "week") {
14234
14689
  const thisWeek = mondayOf(today);
14235
- const ahead = chart.measure === "open";
14690
+ const ahead = measure !== "done" && measure !== "all" && measure !== "overdue" && measure !== "approval" && measure !== "hours";
14236
14691
  const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
14237
14692
  const count = ahead ? 8 : 12;
14238
14693
  const start = parseIso(`${first}T12:00:00`);
@@ -14244,9 +14699,36 @@ function chartData(chart, data, today) {
14244
14699
  if (known.has(week)) add(week, item.amount);
14245
14700
  else if (ahead && week < first) add(first, item.amount);
14246
14701
  }
14702
+ } else if (groupBy === "field") {
14703
+ const name = chart.field?.trim() || "field";
14704
+ const first = splitDefs.values().next().value;
14705
+ const shown = first?.name.trim() || name;
14706
+ fieldNone = first?.type === "checkbox" ? `Not ${shown}` : `No ${shown}`;
14707
+ for (const item of items) {
14708
+ const t = item.task;
14709
+ const def = splitDefs.get(t.projectId);
14710
+ const v = def ? t.customFieldValues?.[def.id] : void 0;
14711
+ if (!def || !hasFieldValue(def, v)) add("", item.amount);
14712
+ else if (def.type === "multi") {
14713
+ const tags = [...new Set(tagsOf(v))];
14714
+ if (tags.length > 1) overlaps = true;
14715
+ for (const tag of tags) {
14716
+ fieldLabels.set(tag, tag);
14717
+ add(tag, item.amount);
14718
+ }
14719
+ } else if (def.type === "checkbox") {
14720
+ fieldLabels.set("yes", def.name.trim());
14721
+ add("yes", item.amount);
14722
+ } else {
14723
+ const key = String(v);
14724
+ fieldLabels.set(key, fieldValueText(def, v, (id) => people.get(id)?.name) || key);
14725
+ add(key, item.amount);
14726
+ }
14727
+ }
14247
14728
  } else for (const item of items) {
14248
14729
  const t = item.task;
14249
- add(groupBy === "status" ? t.status : groupBy === "priority" ? t.priority : groupBy === "project" ? t.projectId : groupBy === "program" ? projects.get(t.projectId)?.programId ?? "" : (chart.measure === "hours" ? item.personId : t.assigneeId) ?? "", item.amount);
14730
+ const r = item.raid;
14731
+ add(groupBy === "status" ? t?.status ?? "" : groupBy === "priority" ? t?.priority ?? "" : groupBy === "severity" ? r?.severity ?? "" : groupBy === "project" ? (t ? t.projectId : r?.projectId) ?? "" : groupBy === "program" ? t ? projects.get(t.projectId)?.programId ?? "" : r?.programId ?? (r?.projectId ? projects.get(r.projectId)?.programId : null) ?? "" : item.personId ?? "", item.amount);
14250
14732
  }
14251
14733
  const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
14252
14734
  let slices;
@@ -14268,20 +14750,38 @@ function chartData(chart, data, today) {
14268
14750
  value: round(sums.get(p)),
14269
14751
  fill: PRIORITY_FILL[p]
14270
14752
  }));
14753
+ else if (groupBy === "severity") slices = SEVERITIES.filter((s) => sums.has(s)).map((s) => ({
14754
+ key: s,
14755
+ label: SEVERITY_LABEL[s],
14756
+ value: round(sums.get(s)),
14757
+ fill: SEVERITY_FILL[s]
14758
+ }));
14271
14759
  else {
14272
14760
  const named = [...sums.entries()].map(([key, value], i) => {
14761
+ if (groupBy === "field") return {
14762
+ key: key || "none",
14763
+ label: key ? fieldLabels.get(key) ?? key : fieldNone,
14764
+ value: round(value),
14765
+ fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
14766
+ };
14273
14767
  if (groupBy === "owner") {
14274
14768
  const person = key ? people.get(key) : void 0;
14275
14769
  return {
14276
14770
  key: key || "nobody",
14277
- label: key ? person?.name ?? "Former member" : "Nobody",
14771
+ label: key ? person?.name ?? "Former member" : measure === "approval" ? "No approver" : "Nobody",
14278
14772
  value: round(value),
14279
14773
  fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
14280
14774
  };
14281
14775
  }
14776
+ if (!key) return {
14777
+ key: "none",
14778
+ label: groupBy === "project" ? "Not on a project" : "Not in a program",
14779
+ value: round(value),
14780
+ fill: "var(--color-swatch-slate)"
14781
+ };
14282
14782
  const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
14283
14783
  return {
14284
- key: key || "none",
14784
+ key,
14285
14785
  label: thing?.name ?? "Deleted",
14286
14786
  value: round(value),
14287
14787
  fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
@@ -14298,24 +14798,37 @@ function chartData(chart, data, today) {
14298
14798
  }];
14299
14799
  } else slices = named;
14300
14800
  }
14301
- const total = round(slices.reduce((n, s) => n + s.value, 0));
14801
+ const total = overlaps ? round(items.reduce((n, i) => n + i.amount, 0)) : round(slices.reduce((n, s) => n + s.value, 0));
14302
14802
  return {
14303
14803
  slices,
14304
14804
  total,
14305
14805
  unit,
14306
14806
  scopeName: scope.name,
14307
- scopeFound: scope.found
14807
+ scopeFound: scope.found,
14808
+ filterText: filter ? filterText(measure, filter, data, chart.scopeId) : null,
14809
+ overlaps
14308
14810
  };
14309
14811
  }
14310
- /** "12 tasks", "1 task", "7.5 hours". */
14812
+ const UNIT_WORDS = {
14813
+ tasks: ["task", "tasks"],
14814
+ hours: ["hour", "hours"],
14815
+ risks: ["risk", "risks"],
14816
+ issues: ["issue", "issues"],
14817
+ decisions: ["decision", "decisions"]
14818
+ };
14819
+ /** "task" or "tasks", "risk" or "risks". */
14820
+ function unitWord(value, unit) {
14821
+ return UNIT_WORDS[unit][value === 1 ? 0 : 1];
14822
+ }
14823
+ /** "12 tasks", "1 task", "7.5 hours", "3 risks". */
14311
14824
  function amountText(value, unit) {
14312
- if (unit === "hours") return `${value} ${value === 1 ? "hour" : "hours"}`;
14313
- return `${value} ${value === 1 ? "task" : "tasks"}`;
14825
+ return `${value} ${unitWord(value, unit)}`;
14314
14826
  }
14315
14827
  /** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
14316
14828
  function chartText(title, result) {
14317
- if (result.slices.length === 0) return `${title}: nothing yet`;
14318
- return `${title}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
14829
+ const named = result.filterText ? `${title} (${result.filterText.toLowerCase()})` : title;
14830
+ if (result.slices.length === 0) return `${named}: nothing yet`;
14831
+ return `${named}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
14319
14832
  }
14320
14833
 
14321
14834
  // node_modules/zod/v4/core/util.js
@@ -19897,85 +20410,6 @@ function exportName(harborName, now) {
19897
20410
  return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
19898
20411
  }
19899
20412
 
19900
- // src/lib/custom-field-text.ts
19901
- init_date_text();
19902
- const FIELD_TYPE_LABEL = {
19903
- text: "Text",
19904
- number: "Number",
19905
- select: "Dropdown",
19906
- multi: "Tags",
19907
- checkbox: "Checkbox",
19908
- date: "Date",
19909
- person: "Person",
19910
- url: "Link"
19911
- };
19912
- const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
19913
- /** A dropdown or tags field: one with a list of options. */
19914
- function hasOptions(type) {
19915
- return type === "select" || type === "multi";
19916
- }
19917
- /** A dropdown's options from "S, M, L". */
19918
- function optionsOf(value) {
19919
- return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
19920
- }
19921
- /** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
19922
- function safeLink(value) {
19923
- if (typeof value !== "string") return null;
19924
- try {
19925
- const u = new URL(value.trim());
19926
- return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
19927
- } catch {
19928
- return null;
19929
- }
19930
- }
19931
- /** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
19932
- function readLink(text) {
19933
- const t = text.trim();
19934
- if (!t || /\s/.test(t)) return null;
19935
- const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
19936
- return href && /\./.test(new URL(href).hostname) ? href : null;
19937
- }
19938
- /** "example.com/docs/plan" from a link: what a short chip says. */
19939
- function linkLabel(href) {
19940
- try {
19941
- const u = new URL(href);
19942
- const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
19943
- const text = u.host.replace(/^www\./, "") + path;
19944
- return text.length > 40 ? `${text.slice(0, 39)}…` : text;
19945
- } catch {
19946
- return href;
19947
- }
19948
- }
19949
- /** A tags field's value as a list, whatever was stored. */
19950
- function tagsOf(value) {
19951
- if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
19952
- return typeof value === "string" && value ? [value] : [];
19953
- }
19954
- /** Whether a task carries a value for the field. */
19955
- function hasFieldValue(def, value) {
19956
- if (value == null || value === "" || value === false) return false;
19957
- if (def.type === "multi") return tagsOf(value).length > 0;
19958
- return true;
19959
- }
19960
- /** A task's value for a field as one line: "" when it has none. */
19961
- function fieldValueText(def, value, nameOf) {
19962
- if (!hasFieldValue(def, value)) return "";
19963
- switch (def.type) {
19964
- case "checkbox": return value ? "Yes" : "";
19965
- case "multi": return tagsOf(value).join(", ");
19966
- case "date": {
19967
- const d = parseIso(String(value));
19968
- return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
19969
- }
19970
- case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
19971
- case "url": {
19972
- const href = safeLink(value);
19973
- return href ? linkLabel(href) : "";
19974
- }
19975
- default: return Array.isArray(value) ? value.join(", ") : String(value);
19976
- }
19977
- }
19978
-
19979
20413
  // src/lib/exports.ts
19980
20414
  init_subtasks();
19981
20415
  init_derived_health();
@@ -20013,6 +20447,7 @@ function tasksCsv(data, projectId) {
20013
20447
  "Blocked by",
20014
20448
  "Description",
20015
20449
  "Subtask of",
20450
+ "Starts",
20016
20451
  ...fields.map((f) => f.name)
20017
20452
  ], tasks.map((t) => [
20018
20453
  taskKey(t, project) ?? "",
@@ -20025,6 +20460,7 @@ function tasksCsv(data, projectId) {
20025
20460
  blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
20026
20461
  t.description,
20027
20462
  ((p) => p ? name(p) : "")(parentOf(t, data.tasks)),
20463
+ t.startDate ?? "",
20028
20464
  ...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
20029
20465
  ]));
20030
20466
  }
@@ -21449,6 +21885,7 @@ const HARBOR_TOOLS = [
21449
21885
  position: boardPositions(data.tasks).get(t.id) ?? null,
21450
21886
  priority: t.priority,
21451
21887
  due: dayOf(t.dueDate),
21888
+ ...t.startDate ? { starts: dayOf(t.startDate) } : {},
21452
21889
  assignee: nameOf$2(people, t.assigneeId),
21453
21890
  ...alsoOnOf(t, people),
21454
21891
  project: project?.name ?? null,
@@ -22105,6 +22542,7 @@ const HARBOR_TOOLS = [
22105
22542
  const today = todayISO(ctx.now);
22106
22543
  const people = byId(data.people);
22107
22544
  const named = byId([...data.programs, ...data.projects]);
22545
+ const tasksById = byId(data.tasks);
22108
22546
  return { portfolios: (input.portfolio ? [findOne(data.portfolios, input.portfolio, "portfolio")] : data.portfolios).map((pf) => ({
22109
22547
  id: pf.id,
22110
22548
  name: pf.name,
@@ -22115,6 +22553,7 @@ const HARBOR_TOOLS = [
22115
22553
  id: g.id,
22116
22554
  title: g.title,
22117
22555
  owner: g.ownerId ? people.get(g.ownerId)?.name ?? null : null,
22556
+ ...g.start ? { start: g.start } : {},
22118
22557
  due: g.due,
22119
22558
  ...g.why ? { why: g.why } : {},
22120
22559
  progress: Math.round(progress * 100),
@@ -22131,7 +22570,7 @@ const HARBOR_TOOLS = [
22131
22570
  target: kr.target,
22132
22571
  current: kr.current ?? kr.start ?? 0
22133
22572
  } : {
22134
- counts: (kr.linkIds ?? []).map((id) => named.get(id)?.name ?? id),
22573
+ counts: (kr.linkIds ?? []).map((id) => named.get(id)?.name ?? tasksById.get(id)?.title ?? id),
22135
22574
  projects: linkedProjects(kr, data.projects).length
22136
22575
  }
22137
22576
  }))
@@ -22143,7 +22582,7 @@ const HARBOR_TOOLS = [
22143
22582
  tool({
22144
22583
  name: "list_dashboards",
22145
22584
  title: "Dashboards",
22146
- description: "The harbor's dashboards from the Reports page, each chart with what it counts (open, finished, overdue or all tasks, or hours logged), how it's split (status, owner, priority, project, program or week), the part of the harbor it reads, and its numbers as of today, with a one-line text of each." + DATA_NOTE,
22585
+ description: "The harbor's dashboards from the Reports page, each chart with what it counts (open, finished, overdue, blocked, waiting-for-approval or all tasks, hours logged, open risks, open issues or decisions to make), how it's split (status, owner, priority, project, program, week, severity or one of the projects' own fields by name), what it's kept to (only: one priority, owner, status, severity or field value; absent when it counts everything), the part of the harbor it reads, and its numbers as of today, with a one-line text of each. Subtasks count as tasks of their own; with a tags field one task can sit under two values, so the values can add up to more than the total." + DATA_NOTE,
22147
22586
  input: object({
22148
22587
  harbor: harborArg,
22149
22588
  dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
@@ -22161,9 +22600,10 @@ const HARBOR_TOOLS = [
22161
22600
  id: c.id,
22162
22601
  title: c.title,
22163
22602
  counts: MEASURE_LABEL[c.measure],
22164
- splitBy: GROUP_LABEL[c.groupBy],
22603
+ splitBy: c.groupBy === "field" ? c.field ?? "" : groupLabel(c.measure, c.groupBy),
22165
22604
  view: c.view,
22166
22605
  from: result.scopeName,
22606
+ ...result.filterText ? { only: result.filterText } : {},
22167
22607
  total: amountText(result.total, result.unit),
22168
22608
  values: result.slices.map((s) => ({
22169
22609
  label: s.label,
@@ -23935,11 +24375,13 @@ function nextOccurrence(task, id, today, createdAt, siblings = []) {
23935
24375
  if (!task.repeat) return null;
23936
24376
  const dueDate = task.dueDate ? nextSeriesDate(task.dueDate, task.repeat, today) : nextDueDate(today, task.repeat);
23937
24377
  if (siblings.some((t) => t.status !== "done" && t.repeat === task.repeat && t.title === task.title && (t.dueDate ?? "") >= dueDate)) return null;
24378
+ const lead = task.startDate && task.dueDate ? differenceInCalendarDays(/* @__PURE__ */ new Date(`${task.dueDate.slice(0, 10)}T12:00:00`), /* @__PURE__ */ new Date(`${task.startDate.slice(0, 10)}T12:00:00`)) : null;
23938
24379
  return {
23939
24380
  ...task,
23940
24381
  id,
23941
24382
  status: "todo",
23942
24383
  dueDate,
24384
+ ...task.startDate ? { startDate: lead != null && lead >= 0 ? format(addDays(/* @__PURE__ */ new Date(`${dueDate}T12:00:00`), -lead), "yyyy-MM-dd") : null } : {},
23943
24385
  createdAt,
23944
24386
  completedAt: null,
23945
24387
  blockedById: null,
@@ -23953,6 +24395,7 @@ function nextOccurrence(task, id, today, createdAt, siblings = []) {
23953
24395
  var REPEAT_LABEL, iso, step;
23954
24396
  var init_recurrence = __esmMin((() => {
23955
24397
  init_date_fns();
24398
+ init_date_text();
23956
24399
  REPEAT_LABEL = {
23957
24400
  weekly: "Every week",
23958
24401
  biweekly: "Every two weeks",
@@ -24018,6 +24461,18 @@ var init_bulk = __esmMin((() => {
24018
24461
  plural$2 = (n) => `${n} ${n === 1 ? "task" : "tasks"}`;
24019
24462
  }));
24020
24463
 
24464
+ // src/lib/task-start.ts
24465
+ function fitStart(start, due) {
24466
+ const s = start ? start.slice(0, 10) : "";
24467
+ if (!DAY.test(s)) return null;
24468
+ const d = due ? due.slice(0, 10) : "";
24469
+ return DAY.test(d) && s > d ? d : s;
24470
+ }
24471
+ var DAY;
24472
+ var init_task_start = __esmMin((() => {
24473
+ DAY = /^\d{4}-\d{2}-\d{2}$/;
24474
+ }));
24475
+
24021
24476
  // src/lib/slices/tasks.ts
24022
24477
  function tasksSlice({ set, get, write }) {
24023
24478
  return {
@@ -24054,6 +24509,7 @@ function tasksSlice({ set, get, write }) {
24054
24509
  completedAt: t.status === "done" ? t.completedAt ?? stamp() : null,
24055
24510
  checklist: t.checklist ?? [],
24056
24511
  ..."parentTaskId" in t ? { parentTaskId } : {},
24512
+ ...t.startDate ? { startDate: fitStart(t.startDate, t.dueDate) } : {},
24057
24513
  ...blockersPatch(blockerIds({
24058
24514
  id,
24059
24515
  blockedById: t.blockedById,
@@ -24119,6 +24575,7 @@ function tasksSlice({ set, get, write }) {
24119
24575
  ...patch,
24120
24576
  touchedAt: stamp()
24121
24577
  };
24578
+ if (next.startDate) next.startDate = fitStart(next.startDate, next.dueDate);
24122
24579
  if (next.assigneeId && next.followerIds?.includes(next.assigneeId)) next.followerIds = alsoOnIds(next);
24123
24580
  if (patch.status && patch.status !== row.status) {
24124
24581
  if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
@@ -24583,6 +25040,7 @@ var init_tasks = __esmMin((() => {
24583
25040
  init_bulk();
24584
25041
  init_task_people();
24585
25042
  init_subtasks();
25043
+ init_task_start();
24586
25044
  }));
24587
25045
 
24588
25046
  // src/lib/raid-history.ts
@@ -24608,7 +25066,7 @@ function recordScoreChange(item, patch, at) {
24608
25066
  var init_raid_history = __esmMin((() => {}));
24609
25067
 
24610
25068
  // src/lib/slices/milestones-raid.ts
24611
- function milestonesRaidSlice({ set, get, write }) {
25069
+ function milestonesRaidSlice({ get, write }) {
24612
25070
  return {
24613
25071
  toggleMilestone: (id) => {
24614
25072
  if (get().milestones.find((m) => m.id === id)?.approverId) return;
@@ -24640,15 +25098,16 @@ function milestonesRaidSlice({ set, get, write }) {
24640
25098
  write({ milestones: [...get().milestones, m] });
24641
25099
  },
24642
25100
  setConstraint: (programId, patch) => {
25101
+ const program = get().programs.find((p) => p.id === programId);
25102
+ const logNamed = () => get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
24643
25103
  const existing = get().constraints.find((c) => c.programId === programId && c.status === "active");
24644
25104
  if (existing) {
24645
- set({ constraints: get().constraints.map((c) => c.id === existing.id ? {
25105
+ write({ constraints: get().constraints.map((c) => c.id === existing.id ? {
24646
25106
  ...c,
24647
25107
  ...patch,
24648
25108
  status: "active"
24649
25109
  } : c) });
24650
- const program = get().programs.find((p) => p.id === programId);
24651
- get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
25110
+ logNamed();
24652
25111
  return existing.id;
24653
25112
  }
24654
25113
  const id = uid("k");
@@ -24656,29 +25115,48 @@ function milestonesRaidSlice({ set, get, write }) {
24656
25115
  ...patch,
24657
25116
  id,
24658
25117
  programId,
24659
- status: "active"
25118
+ status: "active",
25119
+ createdAt: stamp(),
25120
+ createdById: get().currentUserId || null
24660
25121
  };
24661
- set({ constraints: [...get().constraints, row] });
24662
- const program = get().programs.find((p) => p.id === programId);
24663
- get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
25122
+ write({ constraints: [...get().constraints, row] });
25123
+ logNamed();
24664
25124
  return id;
24665
25125
  },
24666
25126
  updateConstraint: (id, patch) => {
24667
- set({ constraints: get().constraints.map((c) => c.id === id ? {
25127
+ write({ constraints: get().constraints.map((c) => c.id === id ? {
24668
25128
  ...c,
24669
25129
  ...patch
24670
25130
  } : c) });
24671
25131
  },
24672
25132
  resolveConstraint: (id) => {
24673
25133
  const row = get().constraints.find((c) => c.id === id);
24674
- set({ constraints: get().constraints.map((c) => c.id === id ? {
25134
+ if (!row || row.status === "resolved") return;
25135
+ write({ constraints: get().constraints.map((c) => c.id === id ? {
24675
25136
  ...c,
24676
- status: "resolved"
25137
+ status: "resolved",
25138
+ resolvedAt: stamp(),
25139
+ resolvedById: get().currentUserId || null
24677
25140
  } : c) });
24678
- if (row) {
24679
- const program = get().programs.find((p) => p.id === row.programId);
24680
- get().log(`retired the constraint on ${program?.name ?? "a program"}`, `/programs/${row.programId}`);
24681
- }
25141
+ const program = get().programs.find((p) => p.id === row.programId);
25142
+ get().log(`retired the constraint on ${program?.name ?? "a program"}`, `/programs/${row.programId}`);
25143
+ },
25144
+ reopenConstraint: (id) => {
25145
+ const row = get().constraints.find((c) => c.id === id);
25146
+ if (!row) return false;
25147
+ if (row.status === "active") return true;
25148
+ if (get().constraints.some((c) => c.programId === row.programId && c.status === "active")) return false;
25149
+ write({ constraints: get().constraints.map((c) => {
25150
+ if (c.id !== id) return c;
25151
+ const { resolvedAt: _at, resolvedById: _by, ...rest } = c;
25152
+ return {
25153
+ ...rest,
25154
+ status: "active"
25155
+ };
25156
+ }) });
25157
+ const program = get().programs.find((p) => p.id === row.programId);
25158
+ get().log(`brought back the constraint on ${program?.name ?? "a program"}`, `/programs/${row.programId}`);
25159
+ return true;
24682
25160
  },
24683
25161
  addRaidItem: (r) => {
24684
25162
  const id = uid("raid");
package/lib/cli.mjs CHANGED
@@ -165,7 +165,7 @@ export const HELP = `harbor: Harbor from the terminal
165
165
  --from: plan it from a finished
166
166
  project as that one really ran
167
167
  harbor add "TITLE" [--project P] [--assignee me|none|NAME]
168
- [--also "NAME, NAME"] [--due DATE|pilot]
168
+ [--also "NAME, NAME"] [--due DATE|pilot] [--starts DATE]
169
169
  [--priority low|medium|high|urgent]
170
170
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
171
171
  [--literal] [--template NAME] [--parent TASK]
@@ -181,6 +181,7 @@ export const HELP = `harbor: Harbor from the terminal
181
181
  or Done
182
182
  harbor edit TASK [--title T] [--assignee me|none|NAME]
183
183
  [--also "NAME, NAME"|none] [--due DATE|pilot|none]
184
+ [--starts DATE|none]
184
185
  [--priority P] [--status S] [--notes TEXT]
185
186
  [--repeat weekly|biweekly|monthly|never]
186
187
  [--waits-on TASK,TASK|none] [--parent TASK|none]
@@ -705,7 +706,7 @@ export function hint(message, project) {
705
706
  )
706
707
  .replace(
707
708
  /^Nothing to change: pass a field to update\.$/,
708
- "Nothing to change. Say what with --title, --assignee, --also, --due, --priority, --status, --notes, --repeat, --waits-on or --parent.",
709
+ "Nothing to change. Say what with --title, --assignee, --also, --due, --starts, --priority, --status, --notes, --repeat, --waits-on or --parent.",
709
710
  )
710
711
  .replace(
711
712
  /^This connection was allowed to read only\..*$/s,
@@ -790,6 +791,10 @@ const DETAIL = {
790
791
  " own owner and date, shown in the parent's details rather than on the",
791
792
  " board. It goes in the parent's project; subtasks go one level deep.",
792
793
  "",
794
+ " --starts DATE gives it a start day as well as a due day, so the project's",
795
+ " timeline draws it as a bar from one to the other; a start after the due",
796
+ " day comes to the due day. harbor edit TASK --starts DATE|none moves it.",
797
+ "",
793
798
  " --due pilot takes the earliest day the owner's queue suggests: their open",
794
799
  " work due first, their weekly pace, time away, what it waits on and how",
795
800
  " long their similar tasks took. It needs an owner (--assignee none is",
@@ -2293,6 +2298,7 @@ export async function run(argv, io = {}) {
2293
2298
  ),
2294
2299
  ];
2295
2300
  if (t.alsoOn?.length) lines.push(`Also on it: ${t.alsoOn.join(", ")}.`);
2301
+ if (t.starts) lines.push(`Starts ${t.starts}${t.due ? `, due ${t.due}` : ""}.`);
2296
2302
  if (t.subtaskOf) lines.push(`Subtask of ${t.subtaskOf.key ?? t.subtaskOf.id} ${t.subtaskOf.title}.`);
2297
2303
  if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
2298
2304
  for (const [name, v] of Object.entries(t.fields ?? {}))
@@ -2546,7 +2552,7 @@ export async function run(argv, io = {}) {
2546
2552
  const { positionals, values, harbor } = parse(
2547
2553
  spec(
2548
2554
  ["over-limit", "dry-run", "literal"],
2549
- ["project", "assignee", "also", "due", "priority", "status", "comment", "template", "parent"],
2555
+ ["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent"],
2550
2556
  ),
2551
2557
  );
2552
2558
  // A subtask goes in its parent's project.
@@ -2652,6 +2658,7 @@ export async function run(argv, io = {}) {
2652
2658
  also: values.also === undefined ? undefined : alsoList(values.also),
2653
2659
  due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2654
2660
  suggestedDue: duePilot(values.due),
2661
+ starts: dueDate(values.starts),
2655
2662
  priority: values.priority ?? words.priority?.value,
2656
2663
  status: values.status === undefined ? undefined : statusWord(values.status),
2657
2664
  parent: values.parent,
@@ -2775,6 +2782,7 @@ export async function run(argv, io = {}) {
2775
2782
  "assignee",
2776
2783
  "also",
2777
2784
  "due",
2785
+ "starts",
2778
2786
  "priority",
2779
2787
  "status",
2780
2788
  "notes",
@@ -2798,6 +2806,7 @@ export async function run(argv, io = {}) {
2798
2806
  ? undefined
2799
2807
  : nullable(dueDate(values.due)),
2800
2808
  suggestedDue: duePilot(values.due),
2809
+ starts: values.starts === undefined ? undefined : nullable(dueDate(values.starts)),
2801
2810
  priority: values.priority,
2802
2811
  status: values.status === undefined ? undefined : statusWord(values.status),
2803
2812
  description: values.notes,
@@ -2818,6 +2827,11 @@ export async function run(argv, io = {}) {
2818
2827
  (r) =>
2819
2828
  [
2820
2829
  `Updated: ${taskLine(r.updated, opts)}`,
2830
+ values.starts !== undefined
2831
+ ? r.updated.starts
2832
+ ? `Starts ${r.updated.starts}${r.updated.due ? `, due ${r.updated.due}` : ""}.`
2833
+ : "No start day now."
2834
+ : null,
2821
2835
  values.parent !== undefined
2822
2836
  ? r.updated.subtaskOf
2823
2837
  ? `A subtask of ${r.updated.subtaskOf}.`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.90.0",
3
+ "version": "0.92.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",