@harborppm/cli 0.91.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.
Files changed (2) hide show
  1. package/dist/app.mjs +583 -149
  2. package/package.json +1 -1
package/dist/app.mjs CHANGED
@@ -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",
@@ -14076,8 +14154,88 @@ function keyResultText(kr, work) {
14076
14154
  }
14077
14155
  const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
14078
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
+
14079
14236
  // src/lib/reports.ts
14080
14237
  init_date_fns();
14238
+ init_blockers();
14081
14239
  init_date_text();
14082
14240
  init_indexes();
14083
14241
  init_swatches();
@@ -14086,20 +14244,29 @@ const WEEKS_BACK = 12;
14086
14244
  const WEEKS_AHEAD = 8;
14087
14245
  /** Bars past this many are summed into "Everything else". */
14088
14246
  const MAX_BARS = 12;
14089
- const GROUPS = [
14090
- "status",
14091
- "owner",
14092
- "priority",
14093
- "project",
14094
- "program",
14095
- "week"
14247
+ const MEASURES = [
14248
+ "open",
14249
+ "done",
14250
+ "all",
14251
+ "overdue",
14252
+ "blocked",
14253
+ "approval",
14254
+ "hours",
14255
+ "risks",
14256
+ "issues",
14257
+ "decisions"
14096
14258
  ];
14097
14259
  const MEASURE_LABEL = {
14098
14260
  open: "Open tasks",
14099
14261
  done: "Finished tasks",
14100
14262
  all: "All tasks",
14101
14263
  overdue: "Overdue tasks",
14102
- 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"
14103
14270
  };
14104
14271
  const GROUP_LABEL = {
14105
14272
  status: "Status",
@@ -14107,16 +14274,72 @@ const GROUP_LABEL = {
14107
14274
  priority: "Priority",
14108
14275
  project: "Project",
14109
14276
  program: "Program",
14110
- week: "Week"
14277
+ week: "Week",
14278
+ severity: "Severity",
14279
+ field: "A project field"
14280
+ };
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"
14111
14292
  };
14112
- /** The splits a measure can take: hours have no status or priority of their own. */
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. */
14113
14296
  function groupsFor(measure) {
14114
- return measure === "hours" ? [
14297
+ if (measure === "hours") return [
14298
+ "owner",
14299
+ "project",
14300
+ "program",
14301
+ "week",
14302
+ "field"
14303
+ ];
14304
+ if (measure === "decisions") return [
14305
+ "owner",
14306
+ "project",
14307
+ "program",
14308
+ "week"
14309
+ ];
14310
+ if (isRaidMeasure(measure)) return [
14311
+ "severity",
14115
14312
  "owner",
14116
14313
  "project",
14117
14314
  "program",
14118
14315
  "week"
14119
- ] : GROUPS;
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];
14120
14343
  }
14121
14344
  const STATUS_FILL = {
14122
14345
  backlog: "var(--color-swatch-slate)",
@@ -14137,6 +14360,21 @@ const PRIORITIES$1 = [
14137
14360
  "medium",
14138
14361
  "low"
14139
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
+ };
14140
14378
  /** Colours handed out in turn where a slice has none of its own. */
14141
14379
  const CYCLE = [
14142
14380
  "sea",
@@ -14158,14 +14396,21 @@ function mondayOf(day) {
14158
14396
  }
14159
14397
  /** The projects a chart reads: everything, or what sits under its scope. */
14160
14398
  function scopeOf(data, scopeId) {
14399
+ const none = {
14400
+ programIds: null,
14401
+ portfolioId: null
14402
+ };
14161
14403
  if (!scopeId) return {
14162
14404
  projectIds: null,
14405
+ ...none,
14163
14406
  name: "Whole harbor",
14164
14407
  found: true
14165
14408
  };
14166
14409
  const project = byId(data.projects).get(scopeId);
14167
14410
  if (project) return {
14168
14411
  projectIds: /* @__PURE__ */ new Set([project.id]),
14412
+ programIds: /* @__PURE__ */ new Set(),
14413
+ portfolioId: null,
14169
14414
  name: project.name,
14170
14415
  found: true
14171
14416
  };
@@ -14174,6 +14419,8 @@ function scopeOf(data, scopeId) {
14174
14419
  const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
14175
14420
  return {
14176
14421
  projectIds: new Set(ids),
14422
+ programIds: /* @__PURE__ */ new Set([program.id]),
14423
+ portfolioId: null,
14177
14424
  name: program.name,
14178
14425
  found: true
14179
14426
  };
@@ -14184,60 +14431,243 @@ function scopeOf(data, scopeId) {
14184
14431
  const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
14185
14432
  return {
14186
14433
  projectIds: new Set(ids),
14434
+ programIds,
14435
+ portfolioId: portfolio.id,
14187
14436
  name: portfolio.name,
14188
14437
  found: true
14189
14438
  };
14190
14439
  }
14191
14440
  return {
14192
14441
  projectIds: /* @__PURE__ */ new Set(),
14442
+ programIds: /* @__PURE__ */ new Set(),
14443
+ portfolioId: null,
14193
14444
  name: "Deleted",
14194
14445
  found: false
14195
14446
  };
14196
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();
14197
14463
  /**
14198
- * What a chart shows today: its slices in a fixed order (statuses and
14199
- * priorities in board order, weeks in time order, the rest largest first,
14200
- * 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.
14201
14578
  */
14202
14579
  function chartData(chart, data, today) {
14580
+ const measure = MEASURES.includes(chart.measure) ? chart.measure : "open";
14203
14581
  const scope = scopeOf(data, chart.scopeId);
14204
14582
  const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
14205
- const unit = chart.measure === "hours" ? "hours" : "tasks";
14206
- 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
+ };
14207
14607
  const items = [];
14208
- 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) {
14209
14621
  if (!inScope(task)) continue;
14210
14622
  const done = task.status === "done";
14211
- switch (chart.measure) {
14623
+ const mine = {
14624
+ task,
14625
+ amount: 1,
14626
+ personId: task.assigneeId
14627
+ };
14628
+ switch (measure) {
14212
14629
  case "open":
14213
- if (!done) items.push({
14214
- task,
14215
- amount: 1,
14630
+ if (!done) push({
14631
+ ...mine,
14216
14632
  day: task.dueDate
14217
14633
  });
14218
14634
  break;
14219
14635
  case "done":
14220
- if (done) items.push({
14221
- task,
14222
- amount: 1,
14636
+ if (done) push({
14637
+ ...mine,
14223
14638
  day: task.completedAt
14224
14639
  });
14225
14640
  break;
14226
14641
  case "all":
14227
- items.push({
14228
- task,
14229
- amount: 1,
14642
+ push({
14643
+ ...mine,
14230
14644
  day: task.createdAt
14231
14645
  });
14232
14646
  break;
14233
14647
  case "overdue":
14234
- 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({
14235
14664
  task,
14236
14665
  amount: 1,
14237
- day: task.dueDate
14666
+ day: task.approval.askedAt,
14667
+ personId: task.approval.approverId
14238
14668
  });
14239
14669
  break;
14240
- case "hours": for (const entry of task.time ?? []) items.push({
14670
+ case "hours": for (const entry of task.time ?? []) push({
14241
14671
  task,
14242
14672
  amount: entry.minutes / 60,
14243
14673
  day: entry.on,
@@ -14250,10 +14680,14 @@ function chartData(chart, data, today) {
14250
14680
  const people = byId(data.people);
14251
14681
  const sums = /* @__PURE__ */ new Map();
14252
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;
14253
14687
  let weeks = [];
14254
14688
  if (groupBy === "week") {
14255
14689
  const thisWeek = mondayOf(today);
14256
- const ahead = chart.measure === "open";
14690
+ const ahead = measure !== "done" && measure !== "all" && measure !== "overdue" && measure !== "approval" && measure !== "hours";
14257
14691
  const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
14258
14692
  const count = ahead ? 8 : 12;
14259
14693
  const start = parseIso(`${first}T12:00:00`);
@@ -14265,9 +14699,36 @@ function chartData(chart, data, today) {
14265
14699
  if (known.has(week)) add(week, item.amount);
14266
14700
  else if (ahead && week < first) add(first, item.amount);
14267
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
+ }
14268
14728
  } else for (const item of items) {
14269
14729
  const t = item.task;
14270
- 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);
14271
14732
  }
14272
14733
  const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
14273
14734
  let slices;
@@ -14289,20 +14750,38 @@ function chartData(chart, data, today) {
14289
14750
  value: round(sums.get(p)),
14290
14751
  fill: PRIORITY_FILL[p]
14291
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
+ }));
14292
14759
  else {
14293
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
+ };
14294
14767
  if (groupBy === "owner") {
14295
14768
  const person = key ? people.get(key) : void 0;
14296
14769
  return {
14297
14770
  key: key || "nobody",
14298
- label: key ? person?.name ?? "Former member" : "Nobody",
14771
+ label: key ? person?.name ?? "Former member" : measure === "approval" ? "No approver" : "Nobody",
14299
14772
  value: round(value),
14300
14773
  fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
14301
14774
  };
14302
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
+ };
14303
14782
  const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
14304
14783
  return {
14305
- key: key || "none",
14784
+ key,
14306
14785
  label: thing?.name ?? "Deleted",
14307
14786
  value: round(value),
14308
14787
  fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
@@ -14319,24 +14798,37 @@ function chartData(chart, data, today) {
14319
14798
  }];
14320
14799
  } else slices = named;
14321
14800
  }
14322
- 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));
14323
14802
  return {
14324
14803
  slices,
14325
14804
  total,
14326
14805
  unit,
14327
14806
  scopeName: scope.name,
14328
- scopeFound: scope.found
14807
+ scopeFound: scope.found,
14808
+ filterText: filter ? filterText(measure, filter, data, chart.scopeId) : null,
14809
+ overlaps
14329
14810
  };
14330
14811
  }
14331
- /** "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". */
14332
14824
  function amountText(value, unit) {
14333
- if (unit === "hours") return `${value} ${value === 1 ? "hour" : "hours"}`;
14334
- return `${value} ${value === 1 ? "task" : "tasks"}`;
14825
+ return `${value} ${unitWord(value, unit)}`;
14335
14826
  }
14336
14827
  /** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
14337
14828
  function chartText(title, result) {
14338
- if (result.slices.length === 0) return `${title}: nothing yet`;
14339
- 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)})`;
14340
14832
  }
14341
14833
 
14342
14834
  // node_modules/zod/v4/core/util.js
@@ -19918,85 +20410,6 @@ function exportName(harborName, now) {
19918
20410
  return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
19919
20411
  }
19920
20412
 
19921
- // src/lib/custom-field-text.ts
19922
- init_date_text();
19923
- const FIELD_TYPE_LABEL = {
19924
- text: "Text",
19925
- number: "Number",
19926
- select: "Dropdown",
19927
- multi: "Tags",
19928
- checkbox: "Checkbox",
19929
- date: "Date",
19930
- person: "Person",
19931
- url: "Link"
19932
- };
19933
- const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
19934
- /** A dropdown or tags field: one with a list of options. */
19935
- function hasOptions(type) {
19936
- return type === "select" || type === "multi";
19937
- }
19938
- /** A dropdown's options from "S, M, L". */
19939
- function optionsOf(value) {
19940
- return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
19941
- }
19942
- /** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
19943
- function safeLink(value) {
19944
- if (typeof value !== "string") return null;
19945
- try {
19946
- const u = new URL(value.trim());
19947
- return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
19948
- } catch {
19949
- return null;
19950
- }
19951
- }
19952
- /** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
19953
- function readLink(text) {
19954
- const t = text.trim();
19955
- if (!t || /\s/.test(t)) return null;
19956
- const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
19957
- return href && /\./.test(new URL(href).hostname) ? href : null;
19958
- }
19959
- /** "example.com/docs/plan" from a link: what a short chip says. */
19960
- function linkLabel(href) {
19961
- try {
19962
- const u = new URL(href);
19963
- const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
19964
- const text = u.host.replace(/^www\./, "") + path;
19965
- return text.length > 40 ? `${text.slice(0, 39)}…` : text;
19966
- } catch {
19967
- return href;
19968
- }
19969
- }
19970
- /** A tags field's value as a list, whatever was stored. */
19971
- function tagsOf(value) {
19972
- if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
19973
- return typeof value === "string" && value ? [value] : [];
19974
- }
19975
- /** Whether a task carries a value for the field. */
19976
- function hasFieldValue(def, value) {
19977
- if (value == null || value === "" || value === false) return false;
19978
- if (def.type === "multi") return tagsOf(value).length > 0;
19979
- return true;
19980
- }
19981
- /** A task's value for a field as one line: "" when it has none. */
19982
- function fieldValueText(def, value, nameOf) {
19983
- if (!hasFieldValue(def, value)) return "";
19984
- switch (def.type) {
19985
- case "checkbox": return value ? "Yes" : "";
19986
- case "multi": return tagsOf(value).join(", ");
19987
- case "date": {
19988
- const d = parseIso(String(value));
19989
- return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
19990
- }
19991
- case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
19992
- case "url": {
19993
- const href = safeLink(value);
19994
- return href ? linkLabel(href) : "";
19995
- }
19996
- default: return Array.isArray(value) ? value.join(", ") : String(value);
19997
- }
19998
- }
19999
-
20000
20413
  // src/lib/exports.ts
20001
20414
  init_subtasks();
20002
20415
  init_derived_health();
@@ -22169,7 +22582,7 @@ const HARBOR_TOOLS = [
22169
22582
  tool({
22170
22583
  name: "list_dashboards",
22171
22584
  title: "Dashboards",
22172
- 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,
22173
22586
  input: object({
22174
22587
  harbor: harborArg,
22175
22588
  dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
@@ -22187,9 +22600,10 @@ const HARBOR_TOOLS = [
22187
22600
  id: c.id,
22188
22601
  title: c.title,
22189
22602
  counts: MEASURE_LABEL[c.measure],
22190
- splitBy: GROUP_LABEL[c.groupBy],
22603
+ splitBy: c.groupBy === "field" ? c.field ?? "" : groupLabel(c.measure, c.groupBy),
22191
22604
  view: c.view,
22192
22605
  from: result.scopeName,
22606
+ ...result.filterText ? { only: result.filterText } : {},
22193
22607
  total: amountText(result.total, result.unit),
22194
22608
  values: result.slices.map((s) => ({
22195
22609
  label: s.label,
@@ -24652,7 +25066,7 @@ function recordScoreChange(item, patch, at) {
24652
25066
  var init_raid_history = __esmMin((() => {}));
24653
25067
 
24654
25068
  // src/lib/slices/milestones-raid.ts
24655
- function milestonesRaidSlice({ set, get, write }) {
25069
+ function milestonesRaidSlice({ get, write }) {
24656
25070
  return {
24657
25071
  toggleMilestone: (id) => {
24658
25072
  if (get().milestones.find((m) => m.id === id)?.approverId) return;
@@ -24684,15 +25098,16 @@ function milestonesRaidSlice({ set, get, write }) {
24684
25098
  write({ milestones: [...get().milestones, m] });
24685
25099
  },
24686
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}`);
24687
25103
  const existing = get().constraints.find((c) => c.programId === programId && c.status === "active");
24688
25104
  if (existing) {
24689
- set({ constraints: get().constraints.map((c) => c.id === existing.id ? {
25105
+ write({ constraints: get().constraints.map((c) => c.id === existing.id ? {
24690
25106
  ...c,
24691
25107
  ...patch,
24692
25108
  status: "active"
24693
25109
  } : c) });
24694
- const program = get().programs.find((p) => p.id === programId);
24695
- get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
25110
+ logNamed();
24696
25111
  return existing.id;
24697
25112
  }
24698
25113
  const id = uid("k");
@@ -24700,29 +25115,48 @@ function milestonesRaidSlice({ set, get, write }) {
24700
25115
  ...patch,
24701
25116
  id,
24702
25117
  programId,
24703
- status: "active"
25118
+ status: "active",
25119
+ createdAt: stamp(),
25120
+ createdById: get().currentUserId || null
24704
25121
  };
24705
- set({ constraints: [...get().constraints, row] });
24706
- const program = get().programs.find((p) => p.id === programId);
24707
- get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
25122
+ write({ constraints: [...get().constraints, row] });
25123
+ logNamed();
24708
25124
  return id;
24709
25125
  },
24710
25126
  updateConstraint: (id, patch) => {
24711
- set({ constraints: get().constraints.map((c) => c.id === id ? {
25127
+ write({ constraints: get().constraints.map((c) => c.id === id ? {
24712
25128
  ...c,
24713
25129
  ...patch
24714
25130
  } : c) });
24715
25131
  },
24716
25132
  resolveConstraint: (id) => {
24717
25133
  const row = get().constraints.find((c) => c.id === id);
24718
- 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 ? {
24719
25136
  ...c,
24720
- status: "resolved"
25137
+ status: "resolved",
25138
+ resolvedAt: stamp(),
25139
+ resolvedById: get().currentUserId || null
24721
25140
  } : c) });
24722
- if (row) {
24723
- const program = get().programs.find((p) => p.id === row.programId);
24724
- get().log(`retired the constraint on ${program?.name ?? "a program"}`, `/programs/${row.programId}`);
24725
- }
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;
24726
25160
  },
24727
25161
  addRaidItem: (r) => {
24728
25162
  const id = uid("raid");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.91.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",