@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 +3 -2
- package/dist/app.mjs +646 -168
- package/lib/cli.mjs +17 -3
- package/package.json +1 -1
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]
|
|
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":
|
|
9681
|
-
|
|
9682
|
-
|
|
9683
|
-
|
|
9684
|
-
|
|
9685
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
14049
|
-
|
|
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
|
|
14069
|
-
"
|
|
14070
|
-
"
|
|
14071
|
-
"
|
|
14072
|
-
"
|
|
14073
|
-
"
|
|
14074
|
-
"
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
-
*
|
|
14178
|
-
*
|
|
14179
|
-
*
|
|
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
|
|
14185
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
14623
|
+
const mine = {
|
|
14624
|
+
task,
|
|
14625
|
+
amount: 1,
|
|
14626
|
+
personId: task.assigneeId
|
|
14627
|
+
};
|
|
14628
|
+
switch (measure) {
|
|
14191
14629
|
case "open":
|
|
14192
|
-
if (!done)
|
|
14193
|
-
|
|
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)
|
|
14200
|
-
|
|
14201
|
-
amount: 1,
|
|
14636
|
+
if (done) push({
|
|
14637
|
+
...mine,
|
|
14202
14638
|
day: task.completedAt
|
|
14203
14639
|
});
|
|
14204
14640
|
break;
|
|
14205
14641
|
case "all":
|
|
14206
|
-
|
|
14207
|
-
|
|
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)
|
|
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.
|
|
14666
|
+
day: task.approval.askedAt,
|
|
14667
|
+
personId: task.approval.approverId
|
|
14217
14668
|
});
|
|
14218
14669
|
break;
|
|
14219
|
-
case "hours": for (const entry of task.time ?? [])
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14318
|
-
|
|
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
|
|
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:
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24662
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24679
|
-
|
|
24680
|
-
|
|
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}.`
|