@harborppm/cli 0.91.0 → 0.93.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 +2 -1
- package/dist/app.mjs +752 -149
- package/lib/cli.mjs +35 -0
- package/lib/format.mjs +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,6 +213,7 @@ harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
|
213
213
|
add tasks from a CSV file
|
|
214
214
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
215
215
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
216
|
+
harbor messages PROJECT ["TEXT"] [--limit N]
|
|
216
217
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
217
218
|
harbor sound TASK [--answer DAYS|--ask NAMES|--settle DAYS] [--why TEXT]
|
|
218
219
|
harbor soundings [PROJECT]
|
|
@@ -258,7 +259,7 @@ A task has one owner (`--assignee`), who answers for it and whose load it counts
|
|
|
258
259
|
|
|
259
260
|
`harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
|
|
260
261
|
|
|
261
|
-
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK --answer 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first. `harbor goals` lists each portfolio's goals (set on the portfolio's page on the website), off track first, with each key result's progress. `harbor ashore` lists the public holidays and time away in the next eight weeks; `--country US,GB` sets whose holidays the harbor takes and `--person NAME --country DE` one person's own, which the forecast and the weekly load then count as days off.
|
|
262
|
+
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor messages PROJECT` reads a project's Messages tab, one conversation for the whole project, and `harbor messages PROJECT "TEXT"` writes to it; `@Name` sends it to that person's Inbox. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK --answer 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first. `harbor goals` lists each portfolio's goals (set on the portfolio's page on the website), off track first, with each key result's progress. `harbor ashore` lists the public holidays and time away in the next eight weeks; `--country US,GB` sets whose holidays the harbor takes and `--person NAME --country DE` one person's own, which the forecast and the weekly load then count as days off.
|
|
262
263
|
|
|
263
264
|
`harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux, which you may need to install). With `--json` it prints the forecast and stays quiet.
|
|
264
265
|
|
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":
|
|
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)}`
|
|
@@ -9993,7 +10003,83 @@ function HistoryView({ active, width, height, go }) {
|
|
|
9993
10003
|
});
|
|
9994
10004
|
}
|
|
9995
10005
|
|
|
10006
|
+
// src/lib/project-messages.ts
|
|
10007
|
+
/** This project's messages, oldest first. */
|
|
10008
|
+
function messagesOf(reflections, projectId) {
|
|
10009
|
+
return (reflections ?? []).filter((r) => r.projectId === projectId && isMessage(r)).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
10010
|
+
}
|
|
10011
|
+
/** Why a message can't be posted, or null. */
|
|
10012
|
+
function messageProblem(body) {
|
|
10013
|
+
const t = body.trim();
|
|
10014
|
+
if (!t) return "Write something first.";
|
|
10015
|
+
if (t.length > 4e3) return `Keep it under ${MESSAGE_MAX.toLocaleString("en")} characters.`;
|
|
10016
|
+
return null;
|
|
10017
|
+
}
|
|
10018
|
+
/** Who a message @mentions, leaving out its writer. */
|
|
10019
|
+
function messageMentions(body, people, authorId) {
|
|
10020
|
+
return parseMentions(body, people).filter((id) => id !== authorId);
|
|
10021
|
+
}
|
|
10022
|
+
/** Whether someone may change or delete a message: its writer, or the harbor's owner (delete only). */
|
|
10023
|
+
function mayChangeMessage(message, personId, role, action) {
|
|
10024
|
+
if (message.authorId === personId) return true;
|
|
10025
|
+
return action === "delete" && role === "owner";
|
|
10026
|
+
}
|
|
10027
|
+
/**
|
|
10028
|
+
* What a person's Inbox says about project messages since `since`: each
|
|
10029
|
+
* message that @mentions them, and for each project they've written in, the
|
|
10030
|
+
* messages others posted after their last one (a mention among them is
|
|
10031
|
+
* already its own line). Projects that are finished are left out.
|
|
10032
|
+
*/
|
|
10033
|
+
function messageLines(data, personId, since) {
|
|
10034
|
+
const done = new Set(data.projects.filter((p) => p.health === "done").map((p) => p.id));
|
|
10035
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
10036
|
+
for (const r of data.reflections ?? []) {
|
|
10037
|
+
if (!isMessage(r) || done.has(r.projectId)) continue;
|
|
10038
|
+
const list = byProject.get(r.projectId);
|
|
10039
|
+
if (list) list.push(r);
|
|
10040
|
+
else byProject.set(r.projectId, [r]);
|
|
10041
|
+
}
|
|
10042
|
+
const out = [];
|
|
10043
|
+
for (const list of byProject.values()) {
|
|
10044
|
+
list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
10045
|
+
let joined = false;
|
|
10046
|
+
let replies = [];
|
|
10047
|
+
for (const m of list) {
|
|
10048
|
+
if (m.authorId === personId) {
|
|
10049
|
+
joined = true;
|
|
10050
|
+
replies = [];
|
|
10051
|
+
continue;
|
|
10052
|
+
}
|
|
10053
|
+
if (m.createdAt < since) continue;
|
|
10054
|
+
if ((m.mentionedIds ?? []).includes(personId)) {
|
|
10055
|
+
out.push({
|
|
10056
|
+
kind: "mention",
|
|
10057
|
+
message: m,
|
|
10058
|
+
authorIds: [m.authorId]
|
|
10059
|
+
});
|
|
10060
|
+
continue;
|
|
10061
|
+
}
|
|
10062
|
+
if (joined) replies.push(m);
|
|
10063
|
+
}
|
|
10064
|
+
const latest = replies.at(-1);
|
|
10065
|
+
if (latest) out.push({
|
|
10066
|
+
kind: "reply",
|
|
10067
|
+
message: latest,
|
|
10068
|
+
authorIds: [...new Set(replies.map((m) => m.authorId))]
|
|
10069
|
+
});
|
|
10070
|
+
}
|
|
10071
|
+
return out;
|
|
10072
|
+
}
|
|
10073
|
+
var MESSAGE_MAX, isMessage, messageText;
|
|
10074
|
+
var init_project_messages = __esmMin((() => {
|
|
10075
|
+
init_work();
|
|
10076
|
+
MESSAGE_MAX = 4e3;
|
|
10077
|
+
isMessage = (r) => r.kind === "message";
|
|
10078
|
+
messageText = (body) => body.trim().slice(0, MESSAGE_MAX);
|
|
10079
|
+
}));
|
|
10080
|
+
|
|
9996
10081
|
// src/lib/read-marks.ts
|
|
10082
|
+
init_project_messages();
|
|
9997
10083
|
/**
|
|
9998
10084
|
* Inbox read marks. A harbor keeps one list of them (`readPingIds`), shared
|
|
9999
10085
|
* by everyone in it, so each mark names whose it is: reading a mention that
|
|
@@ -10303,6 +10389,25 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
10303
10389
|
href: `/projects/${task.projectId}`
|
|
10304
10390
|
});
|
|
10305
10391
|
}
|
|
10392
|
+
for (const line of messageLines(data, personId, since)) {
|
|
10393
|
+
const project = data.projects.find((p) => p.id === line.message.projectId);
|
|
10394
|
+
if (!project) continue;
|
|
10395
|
+
const names = line.authorIds.map((id) => {
|
|
10396
|
+
const who = data.people.find((p) => p.id === id);
|
|
10397
|
+
return who ? firstName$3(who.name) : "Someone";
|
|
10398
|
+
});
|
|
10399
|
+
const snippet = excerpt(line.message.body);
|
|
10400
|
+
const where = `in ${project.name}’s messages`;
|
|
10401
|
+
pings.push({
|
|
10402
|
+
id: `message-${line.kind}:${line.message.id}`,
|
|
10403
|
+
kind: line.kind,
|
|
10404
|
+
title: line.kind === "mention" ? `${names[0]} mentioned you` : `${listNames(names)} wrote back`,
|
|
10405
|
+
detail: snippet ? `${where} — ${snippet}` : where,
|
|
10406
|
+
at: line.message.createdAt,
|
|
10407
|
+
personId: line.message.authorId,
|
|
10408
|
+
href: `/projects/${project.id}?view=messages`
|
|
10409
|
+
});
|
|
10410
|
+
}
|
|
10306
10411
|
for (const task of data.tasks) {
|
|
10307
10412
|
if (task.assignedBy !== personId || !task.assigneeId || task.assigneeId === personId) continue;
|
|
10308
10413
|
if (task.status !== "done" || !task.completedAt || task.completedAt < since) continue;
|
|
@@ -11070,6 +11175,46 @@ function blockerOptions(items, id) {
|
|
|
11070
11175
|
return items.filter((n) => n.id !== id && !loops.has(n.id));
|
|
11071
11176
|
}
|
|
11072
11177
|
|
|
11178
|
+
// src/lib/constraint-history.ts
|
|
11179
|
+
const RETIRED = "retired the constraint on ";
|
|
11180
|
+
function clearedConstraints(data, programId) {
|
|
11181
|
+
const rows = (data.constraints ?? []).filter((c) => c.programId === programId && c.status === "resolved");
|
|
11182
|
+
const unstamped = rows.filter((c) => !c.resolvedAt);
|
|
11183
|
+
let guess;
|
|
11184
|
+
if (unstamped.length === 1) guess = (data.activity ?? []).find((a) => a.text.startsWith(RETIRED) && a.href === `/programs/${programId}`);
|
|
11185
|
+
const out = rows.map((c, i) => {
|
|
11186
|
+
if (c.resolvedAt) return {
|
|
11187
|
+
constraint: c,
|
|
11188
|
+
at: c.resolvedAt,
|
|
11189
|
+
byId: c.resolvedById ?? null,
|
|
11190
|
+
order: i
|
|
11191
|
+
};
|
|
11192
|
+
if (guess) return {
|
|
11193
|
+
constraint: c,
|
|
11194
|
+
at: guess.at,
|
|
11195
|
+
byId: guess.personId || null,
|
|
11196
|
+
order: i
|
|
11197
|
+
};
|
|
11198
|
+
return {
|
|
11199
|
+
constraint: c,
|
|
11200
|
+
at: null,
|
|
11201
|
+
byId: null,
|
|
11202
|
+
order: i
|
|
11203
|
+
};
|
|
11204
|
+
});
|
|
11205
|
+
out.sort((a, b) => {
|
|
11206
|
+
if (a.at && b.at) return a.at < b.at ? 1 : a.at > b.at ? -1 : b.order - a.order;
|
|
11207
|
+
if (a.at) return -1;
|
|
11208
|
+
if (b.at) return 1;
|
|
11209
|
+
return b.order - a.order;
|
|
11210
|
+
});
|
|
11211
|
+
return out.map(({ constraint, at, byId }) => ({
|
|
11212
|
+
constraint,
|
|
11213
|
+
at,
|
|
11214
|
+
byId
|
|
11215
|
+
}));
|
|
11216
|
+
}
|
|
11217
|
+
|
|
11073
11218
|
// src/lib/status-report.ts
|
|
11074
11219
|
init_derived_health();
|
|
11075
11220
|
init_flow();
|
|
@@ -11426,7 +11571,19 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
|
11426
11571
|
const programs = useHarbor((s) => s.programs);
|
|
11427
11572
|
const projects = useHarbor((s) => s.projects);
|
|
11428
11573
|
const constraint = useHarbor((s) => s.constraints.find((c) => c.programId === id && c.status === "active"));
|
|
11574
|
+
const constraints = useHarbor((s) => s.constraints);
|
|
11575
|
+
const activity = useHarbor((s) => s.activity);
|
|
11429
11576
|
if (!program) return null;
|
|
11577
|
+
const cleared = clearedConstraints({
|
|
11578
|
+
constraints,
|
|
11579
|
+
activity
|
|
11580
|
+
}, id);
|
|
11581
|
+
const clearedHint = (at, byId) => {
|
|
11582
|
+
const when = at ? parseIso(at) : null;
|
|
11583
|
+
const by = byId ? people.find((p) => p.id === byId)?.name : void 0;
|
|
11584
|
+
const ago = when && !Number.isNaN(when.getTime()) ? `cleared ${timeAgo(when)}` : "cleared, date not kept";
|
|
11585
|
+
return by ? `${ago} by ${by}` : ago;
|
|
11586
|
+
};
|
|
11430
11587
|
const s = store.getState();
|
|
11431
11588
|
const inProgram = projects.filter((p) => p.programId === id);
|
|
11432
11589
|
const baselined = inProgram.filter(hasBaseline);
|
|
@@ -11553,6 +11710,22 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
|
11553
11710
|
onPick: (v) => holding({ ownerId: v })
|
|
11554
11711
|
}
|
|
11555
11712
|
}] : [],
|
|
11713
|
+
...cleared.length ? [{
|
|
11714
|
+
label: "Cleared before",
|
|
11715
|
+
value: `${cleared[0].constraint.title} (${clearedHint(cleared[0].at, cleared[0].byId)})${cleared.length > 1 ? ` and ${cleared.length - 1} more` : ""}`,
|
|
11716
|
+
color: theme.muted,
|
|
11717
|
+
edit: constraint ? void 0 : {
|
|
11718
|
+
kind: "pick",
|
|
11719
|
+
title: "Bring one back as what's holding it back",
|
|
11720
|
+
choices: cleared.map((c) => ({
|
|
11721
|
+
label: c.constraint.title,
|
|
11722
|
+
value: c.constraint.id,
|
|
11723
|
+
hint: clearedHint(c.at, c.byId)
|
|
11724
|
+
})),
|
|
11725
|
+
initial: 0,
|
|
11726
|
+
onPick: (v) => store.getState().reopenConstraint(v)
|
|
11727
|
+
}
|
|
11728
|
+
}] : [],
|
|
11556
11729
|
waitsField("Waits on", program, programs, go, (patch) => store.getState().updateProgram(id, patch)),
|
|
11557
11730
|
{
|
|
11558
11731
|
label: "Baseline",
|
|
@@ -14076,8 +14249,88 @@ function keyResultText(kr, work) {
|
|
|
14076
14249
|
}
|
|
14077
14250
|
const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
|
|
14078
14251
|
|
|
14252
|
+
// src/lib/custom-field-text.ts
|
|
14253
|
+
init_date_text();
|
|
14254
|
+
const FIELD_TYPE_LABEL = {
|
|
14255
|
+
text: "Text",
|
|
14256
|
+
number: "Number",
|
|
14257
|
+
select: "Dropdown",
|
|
14258
|
+
multi: "Tags",
|
|
14259
|
+
checkbox: "Checkbox",
|
|
14260
|
+
date: "Date",
|
|
14261
|
+
person: "Person",
|
|
14262
|
+
url: "Link"
|
|
14263
|
+
};
|
|
14264
|
+
const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
|
|
14265
|
+
/** A dropdown or tags field: one with a list of options. */
|
|
14266
|
+
function hasOptions(type) {
|
|
14267
|
+
return type === "select" || type === "multi";
|
|
14268
|
+
}
|
|
14269
|
+
/** A dropdown's options from "S, M, L". */
|
|
14270
|
+
function optionsOf(value) {
|
|
14271
|
+
return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
|
|
14272
|
+
}
|
|
14273
|
+
/** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
|
|
14274
|
+
function safeLink(value) {
|
|
14275
|
+
if (typeof value !== "string") return null;
|
|
14276
|
+
try {
|
|
14277
|
+
const u = new URL(value.trim());
|
|
14278
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
|
|
14279
|
+
} catch {
|
|
14280
|
+
return null;
|
|
14281
|
+
}
|
|
14282
|
+
}
|
|
14283
|
+
/** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
|
|
14284
|
+
function readLink(text) {
|
|
14285
|
+
const t = text.trim();
|
|
14286
|
+
if (!t || /\s/.test(t)) return null;
|
|
14287
|
+
const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
|
|
14288
|
+
return href && /\./.test(new URL(href).hostname) ? href : null;
|
|
14289
|
+
}
|
|
14290
|
+
/** "example.com/docs/plan" from a link: what a short chip says. */
|
|
14291
|
+
function linkLabel(href) {
|
|
14292
|
+
try {
|
|
14293
|
+
const u = new URL(href);
|
|
14294
|
+
const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
|
|
14295
|
+
const text = u.host.replace(/^www\./, "") + path;
|
|
14296
|
+
return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
14297
|
+
} catch {
|
|
14298
|
+
return href;
|
|
14299
|
+
}
|
|
14300
|
+
}
|
|
14301
|
+
/** A tags field's value as a list, whatever was stored. */
|
|
14302
|
+
function tagsOf(value) {
|
|
14303
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
|
|
14304
|
+
return typeof value === "string" && value ? [value] : [];
|
|
14305
|
+
}
|
|
14306
|
+
/** Whether a task carries a value for the field. */
|
|
14307
|
+
function hasFieldValue(def, value) {
|
|
14308
|
+
if (value == null || value === "" || value === false) return false;
|
|
14309
|
+
if (def.type === "multi") return tagsOf(value).length > 0;
|
|
14310
|
+
return true;
|
|
14311
|
+
}
|
|
14312
|
+
/** A task's value for a field as one line: "" when it has none. */
|
|
14313
|
+
function fieldValueText(def, value, nameOf) {
|
|
14314
|
+
if (!hasFieldValue(def, value)) return "";
|
|
14315
|
+
switch (def.type) {
|
|
14316
|
+
case "checkbox": return value ? "Yes" : "";
|
|
14317
|
+
case "multi": return tagsOf(value).join(", ");
|
|
14318
|
+
case "date": {
|
|
14319
|
+
const d = parseIso(String(value));
|
|
14320
|
+
return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
|
|
14321
|
+
}
|
|
14322
|
+
case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
|
|
14323
|
+
case "url": {
|
|
14324
|
+
const href = safeLink(value);
|
|
14325
|
+
return href ? linkLabel(href) : "";
|
|
14326
|
+
}
|
|
14327
|
+
default: return Array.isArray(value) ? value.join(", ") : String(value);
|
|
14328
|
+
}
|
|
14329
|
+
}
|
|
14330
|
+
|
|
14079
14331
|
// src/lib/reports.ts
|
|
14080
14332
|
init_date_fns();
|
|
14333
|
+
init_blockers();
|
|
14081
14334
|
init_date_text();
|
|
14082
14335
|
init_indexes();
|
|
14083
14336
|
init_swatches();
|
|
@@ -14086,20 +14339,29 @@ const WEEKS_BACK = 12;
|
|
|
14086
14339
|
const WEEKS_AHEAD = 8;
|
|
14087
14340
|
/** Bars past this many are summed into "Everything else". */
|
|
14088
14341
|
const MAX_BARS = 12;
|
|
14089
|
-
const
|
|
14090
|
-
"
|
|
14091
|
-
"
|
|
14092
|
-
"
|
|
14093
|
-
"
|
|
14094
|
-
"
|
|
14095
|
-
"
|
|
14342
|
+
const MEASURES = [
|
|
14343
|
+
"open",
|
|
14344
|
+
"done",
|
|
14345
|
+
"all",
|
|
14346
|
+
"overdue",
|
|
14347
|
+
"blocked",
|
|
14348
|
+
"approval",
|
|
14349
|
+
"hours",
|
|
14350
|
+
"risks",
|
|
14351
|
+
"issues",
|
|
14352
|
+
"decisions"
|
|
14096
14353
|
];
|
|
14097
14354
|
const MEASURE_LABEL = {
|
|
14098
14355
|
open: "Open tasks",
|
|
14099
14356
|
done: "Finished tasks",
|
|
14100
14357
|
all: "All tasks",
|
|
14101
14358
|
overdue: "Overdue tasks",
|
|
14102
|
-
|
|
14359
|
+
blocked: "Blocked tasks",
|
|
14360
|
+
approval: "Tasks waiting for approval",
|
|
14361
|
+
hours: "Hours logged",
|
|
14362
|
+
risks: "Open risks",
|
|
14363
|
+
issues: "Open issues",
|
|
14364
|
+
decisions: "Decisions to make"
|
|
14103
14365
|
};
|
|
14104
14366
|
const GROUP_LABEL = {
|
|
14105
14367
|
status: "Status",
|
|
@@ -14107,16 +14369,72 @@ const GROUP_LABEL = {
|
|
|
14107
14369
|
priority: "Priority",
|
|
14108
14370
|
project: "Project",
|
|
14109
14371
|
program: "Program",
|
|
14110
|
-
week: "Week"
|
|
14372
|
+
week: "Week",
|
|
14373
|
+
severity: "Severity",
|
|
14374
|
+
field: "A project field"
|
|
14375
|
+
};
|
|
14376
|
+
/** Still open: raid.ts's `isActive`, kept here so a chart doesn't load the register's code. */
|
|
14377
|
+
const ACTIVE_RAID = /* @__PURE__ */ new Set([
|
|
14378
|
+
"open",
|
|
14379
|
+
"watching",
|
|
14380
|
+
"mitigated",
|
|
14381
|
+
"proposed"
|
|
14382
|
+
]);
|
|
14383
|
+
const RAID_KIND = {
|
|
14384
|
+
risks: "risk",
|
|
14385
|
+
issues: "issue",
|
|
14386
|
+
decisions: "decision"
|
|
14111
14387
|
};
|
|
14112
|
-
/**
|
|
14388
|
+
/** True for the measures that count risks, issues or decisions rather than tasks. */
|
|
14389
|
+
const isRaidMeasure = (m) => m in RAID_KIND;
|
|
14390
|
+
/** The splits a measure can take: hours have no status or priority of their own, risks no project fields. */
|
|
14113
14391
|
function groupsFor(measure) {
|
|
14114
|
-
|
|
14392
|
+
if (measure === "hours") return [
|
|
14393
|
+
"owner",
|
|
14394
|
+
"project",
|
|
14395
|
+
"program",
|
|
14396
|
+
"week",
|
|
14397
|
+
"field"
|
|
14398
|
+
];
|
|
14399
|
+
if (measure === "decisions") return [
|
|
14400
|
+
"owner",
|
|
14401
|
+
"project",
|
|
14402
|
+
"program",
|
|
14403
|
+
"week"
|
|
14404
|
+
];
|
|
14405
|
+
if (isRaidMeasure(measure)) return [
|
|
14406
|
+
"severity",
|
|
14115
14407
|
"owner",
|
|
14116
14408
|
"project",
|
|
14117
14409
|
"program",
|
|
14118
14410
|
"week"
|
|
14119
|
-
]
|
|
14411
|
+
];
|
|
14412
|
+
return [
|
|
14413
|
+
"status",
|
|
14414
|
+
"owner",
|
|
14415
|
+
"priority",
|
|
14416
|
+
"project",
|
|
14417
|
+
"program",
|
|
14418
|
+
"week",
|
|
14419
|
+
"field"
|
|
14420
|
+
];
|
|
14421
|
+
}
|
|
14422
|
+
/** What a chart can be kept to. Hours read the task they were logged on, so they take its priority and status. */
|
|
14423
|
+
function filtersFor(measure) {
|
|
14424
|
+
if (measure === "decisions") return ["owner"];
|
|
14425
|
+
if (isRaidMeasure(measure)) return ["severity", "owner"];
|
|
14426
|
+
return [
|
|
14427
|
+
"priority",
|
|
14428
|
+
"owner",
|
|
14429
|
+
"status",
|
|
14430
|
+
"field"
|
|
14431
|
+
];
|
|
14432
|
+
}
|
|
14433
|
+
/** "Owner" means who logged the hours, and who's asked to approve; the editor says so. */
|
|
14434
|
+
function groupLabel(measure, group) {
|
|
14435
|
+
if (group === "owner" && measure === "hours") return "Who logged it";
|
|
14436
|
+
if (group === "owner" && measure === "approval") return "Approver";
|
|
14437
|
+
return GROUP_LABEL[group];
|
|
14120
14438
|
}
|
|
14121
14439
|
const STATUS_FILL = {
|
|
14122
14440
|
backlog: "var(--color-swatch-slate)",
|
|
@@ -14137,6 +14455,21 @@ const PRIORITIES$1 = [
|
|
|
14137
14455
|
"medium",
|
|
14138
14456
|
"low"
|
|
14139
14457
|
];
|
|
14458
|
+
const SEVERITIES = [
|
|
14459
|
+
"high",
|
|
14460
|
+
"medium",
|
|
14461
|
+
"low"
|
|
14462
|
+
];
|
|
14463
|
+
const SEVERITY_LABEL = {
|
|
14464
|
+
high: "High",
|
|
14465
|
+
medium: "Medium",
|
|
14466
|
+
low: "Low"
|
|
14467
|
+
};
|
|
14468
|
+
const SEVERITY_FILL = {
|
|
14469
|
+
high: "var(--color-swatch-coral)",
|
|
14470
|
+
medium: "var(--color-swatch-sand)",
|
|
14471
|
+
low: "var(--color-swatch-slate)"
|
|
14472
|
+
};
|
|
14140
14473
|
/** Colours handed out in turn where a slice has none of its own. */
|
|
14141
14474
|
const CYCLE = [
|
|
14142
14475
|
"sea",
|
|
@@ -14158,14 +14491,21 @@ function mondayOf(day) {
|
|
|
14158
14491
|
}
|
|
14159
14492
|
/** The projects a chart reads: everything, or what sits under its scope. */
|
|
14160
14493
|
function scopeOf(data, scopeId) {
|
|
14494
|
+
const none = {
|
|
14495
|
+
programIds: null,
|
|
14496
|
+
portfolioId: null
|
|
14497
|
+
};
|
|
14161
14498
|
if (!scopeId) return {
|
|
14162
14499
|
projectIds: null,
|
|
14500
|
+
...none,
|
|
14163
14501
|
name: "Whole harbor",
|
|
14164
14502
|
found: true
|
|
14165
14503
|
};
|
|
14166
14504
|
const project = byId(data.projects).get(scopeId);
|
|
14167
14505
|
if (project) return {
|
|
14168
14506
|
projectIds: /* @__PURE__ */ new Set([project.id]),
|
|
14507
|
+
programIds: /* @__PURE__ */ new Set(),
|
|
14508
|
+
portfolioId: null,
|
|
14169
14509
|
name: project.name,
|
|
14170
14510
|
found: true
|
|
14171
14511
|
};
|
|
@@ -14174,6 +14514,8 @@ function scopeOf(data, scopeId) {
|
|
|
14174
14514
|
const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
|
|
14175
14515
|
return {
|
|
14176
14516
|
projectIds: new Set(ids),
|
|
14517
|
+
programIds: /* @__PURE__ */ new Set([program.id]),
|
|
14518
|
+
portfolioId: null,
|
|
14177
14519
|
name: program.name,
|
|
14178
14520
|
found: true
|
|
14179
14521
|
};
|
|
@@ -14184,60 +14526,243 @@ function scopeOf(data, scopeId) {
|
|
|
14184
14526
|
const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
|
|
14185
14527
|
return {
|
|
14186
14528
|
projectIds: new Set(ids),
|
|
14529
|
+
programIds,
|
|
14530
|
+
portfolioId: portfolio.id,
|
|
14187
14531
|
name: portfolio.name,
|
|
14188
14532
|
found: true
|
|
14189
14533
|
};
|
|
14190
14534
|
}
|
|
14191
14535
|
return {
|
|
14192
14536
|
projectIds: /* @__PURE__ */ new Set(),
|
|
14537
|
+
programIds: /* @__PURE__ */ new Set(),
|
|
14538
|
+
portfolioId: null,
|
|
14193
14539
|
name: "Deleted",
|
|
14194
14540
|
found: false
|
|
14195
14541
|
};
|
|
14196
14542
|
}
|
|
14543
|
+
/** A risk, issue or decision belongs to a scope when it was raised in it or anywhere under it. */
|
|
14544
|
+
function raidInScope(item, scope) {
|
|
14545
|
+
if (!scope.projectIds) return true;
|
|
14546
|
+
if (item.projectId) return scope.projectIds.has(item.projectId);
|
|
14547
|
+
if (item.programId) return scope.programIds?.has(item.programId) ?? false;
|
|
14548
|
+
return !!item.portfolioId && item.portfolioId === scope.portfolioId;
|
|
14549
|
+
}
|
|
14550
|
+
const isGroupable = (def) => def.type === "select" || def.type === "multi" || def.type === "person" || def.type === "checkbox";
|
|
14551
|
+
function matchesFilter(def, value, want) {
|
|
14552
|
+
if (!want) return !hasFieldValue(def, value);
|
|
14553
|
+
if (def.type === "checkbox") return value === true;
|
|
14554
|
+
if (def.type === "multi") return tagsOf(value).includes(want);
|
|
14555
|
+
return value === want;
|
|
14556
|
+
}
|
|
14557
|
+
const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
14558
|
+
/**
|
|
14559
|
+
* Each project's field of this name, where a chart can split or filter by it:
|
|
14560
|
+
* the kind of field that has a short list of values (choice, tags, person,
|
|
14561
|
+
* checkbox), and the same kind as the first project that has one, so "Team"
|
|
14562
|
+
* as a choice in one project and a person in another doesn't mix the two.
|
|
14563
|
+
*/
|
|
14564
|
+
function fieldDefs(projects, name) {
|
|
14565
|
+
const defs = /* @__PURE__ */ new Map();
|
|
14566
|
+
if (!name?.trim()) return defs;
|
|
14567
|
+
let type = null;
|
|
14568
|
+
for (const p of projects) {
|
|
14569
|
+
const def = (p.customFields ?? []).find((d) => sameName(d.name, name) && isGroupable(d));
|
|
14570
|
+
if (!def) continue;
|
|
14571
|
+
type ??= def.type;
|
|
14572
|
+
if (def.type === type) defs.set(p.id, def);
|
|
14573
|
+
}
|
|
14574
|
+
return defs;
|
|
14575
|
+
}
|
|
14576
|
+
/** The fields a chart over this scope can split or filter by, by name, in the projects' own order. */
|
|
14577
|
+
function chartFields(data, scopeId) {
|
|
14578
|
+
const scope = scopeOf(data, scopeId);
|
|
14579
|
+
const out = [];
|
|
14580
|
+
for (const p of data.projects) {
|
|
14581
|
+
if (scope.projectIds && !scope.projectIds.has(p.id)) continue;
|
|
14582
|
+
for (const def of p.customFields ?? []) {
|
|
14583
|
+
if (!isGroupable(def) || out.some((f) => sameName(f.name, def.name))) continue;
|
|
14584
|
+
out.push({
|
|
14585
|
+
name: def.name.trim(),
|
|
14586
|
+
def
|
|
14587
|
+
});
|
|
14588
|
+
}
|
|
14589
|
+
}
|
|
14590
|
+
return out;
|
|
14591
|
+
}
|
|
14592
|
+
/** The values a filter can keep to, with "" meaning none ("Nobody", "No Size"). */
|
|
14593
|
+
function filterValues(measure, filter, data, scopeId) {
|
|
14594
|
+
switch (filter.by) {
|
|
14595
|
+
case "priority": return PRIORITIES$1.map((p) => ({
|
|
14596
|
+
value: p,
|
|
14597
|
+
label: PRIORITY_LABEL[p]
|
|
14598
|
+
}));
|
|
14599
|
+
case "status": return TASK_COLUMNS.map((s) => ({
|
|
14600
|
+
value: s,
|
|
14601
|
+
label: TASK_STATUS_LABEL[s]
|
|
14602
|
+
}));
|
|
14603
|
+
case "severity": return SEVERITIES.map((s) => ({
|
|
14604
|
+
value: s,
|
|
14605
|
+
label: SEVERITY_LABEL[s]
|
|
14606
|
+
}));
|
|
14607
|
+
case "owner": return [...data.people.map((p) => ({
|
|
14608
|
+
value: p.id,
|
|
14609
|
+
label: p.name
|
|
14610
|
+
})), {
|
|
14611
|
+
value: "",
|
|
14612
|
+
label: measure === "approval" ? "No approver" : "Nobody"
|
|
14613
|
+
}];
|
|
14614
|
+
case "field": {
|
|
14615
|
+
const found = chartFields(data, scopeId).find((f) => sameName(f.name, filter.field ?? ""));
|
|
14616
|
+
if (!found) return [];
|
|
14617
|
+
const def = {
|
|
14618
|
+
...found.def,
|
|
14619
|
+
name: found.name
|
|
14620
|
+
};
|
|
14621
|
+
if (def.type === "select" || def.type === "multi") {
|
|
14622
|
+
const scope = scopeOf(data, scopeId);
|
|
14623
|
+
const inScope = data.projects.filter((p) => !scope.projectIds || scope.projectIds.has(p.id));
|
|
14624
|
+
const options = /* @__PURE__ */ new Set();
|
|
14625
|
+
for (const d of fieldDefs(inScope, def.name).values()) for (const o of d.options ?? []) options.add(o);
|
|
14626
|
+
return [...[...options].map((o) => ({
|
|
14627
|
+
value: o,
|
|
14628
|
+
label: o
|
|
14629
|
+
})), {
|
|
14630
|
+
value: "",
|
|
14631
|
+
label: `No ${def.name}`
|
|
14632
|
+
}];
|
|
14633
|
+
}
|
|
14634
|
+
return def.type === "checkbox" ? [{
|
|
14635
|
+
value: "yes",
|
|
14636
|
+
label: def.name
|
|
14637
|
+
}, {
|
|
14638
|
+
value: "",
|
|
14639
|
+
label: `Not ${def.name}`
|
|
14640
|
+
}] : [...data.people.map((p) => ({
|
|
14641
|
+
value: p.id,
|
|
14642
|
+
label: p.name
|
|
14643
|
+
})), {
|
|
14644
|
+
value: "",
|
|
14645
|
+
label: `No ${def.name}`
|
|
14646
|
+
}];
|
|
14647
|
+
}
|
|
14648
|
+
}
|
|
14649
|
+
}
|
|
14650
|
+
/** "Only urgent", "Only Ana's", "Only Size: L", "Only no owner". */
|
|
14651
|
+
function filterText(measure, filter, data, scopeId) {
|
|
14652
|
+
const personField = filter.by === "field" && chartFields(data, scopeId).find((f) => sameName(f.name, filter.field ?? ""))?.def.type === "person";
|
|
14653
|
+
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);
|
|
14654
|
+
if (filter.by === "field") {
|
|
14655
|
+
const name = filter.field?.trim() ?? "";
|
|
14656
|
+
if (!filter.value) return `Only ${label.charAt(0).toLowerCase()}${label.slice(1)}`;
|
|
14657
|
+
return sameName(label, name) ? `Only ${label}` : `Only ${name}: ${label}`;
|
|
14658
|
+
}
|
|
14659
|
+
if (filter.by === "owner") {
|
|
14660
|
+
if (!filter.value) return measure === "approval" ? "Only with no approver" : "Only with no owner";
|
|
14661
|
+
if (measure === "hours") return `Only logged by ${label}`;
|
|
14662
|
+
if (measure === "approval") return `Only for ${label} to approve`;
|
|
14663
|
+
return `Only ${label}'s`;
|
|
14664
|
+
}
|
|
14665
|
+
if (filter.by === "severity") return `Only ${label.toLowerCase()} severity`;
|
|
14666
|
+
return `Only ${label.toLowerCase()}`;
|
|
14667
|
+
}
|
|
14197
14668
|
/**
|
|
14198
|
-
* What a chart shows today: its slices in a fixed order (statuses
|
|
14199
|
-
* priorities in
|
|
14200
|
-
* past MAX_BARS summed into "Everything else"), with
|
|
14669
|
+
* What a chart shows today: its slices in a fixed order (statuses,
|
|
14670
|
+
* priorities and severities in their own order, weeks in time order, the
|
|
14671
|
+
* rest largest first, past MAX_BARS summed into "Everything else"), with
|
|
14672
|
+
* their total.
|
|
14201
14673
|
*/
|
|
14202
14674
|
function chartData(chart, data, today) {
|
|
14675
|
+
const measure = MEASURES.includes(chart.measure) ? chart.measure : "open";
|
|
14203
14676
|
const scope = scopeOf(data, chart.scopeId);
|
|
14204
14677
|
const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
|
|
14205
|
-
const
|
|
14206
|
-
const
|
|
14678
|
+
const kind = RAID_KIND[measure];
|
|
14679
|
+
const unit = kind ? measure : measure === "hours" ? "hours" : "tasks";
|
|
14680
|
+
const groupBy = groupsFor(measure).includes(chart.groupBy) ? chart.groupBy : "owner";
|
|
14681
|
+
const filter = chart.filter && filtersFor(measure).includes(chart.filter.by) ? chart.filter : null;
|
|
14682
|
+
const projectsInScope = scope.projectIds ? data.projects.filter((p) => scope.projectIds.has(p.id)) : data.projects;
|
|
14683
|
+
const splitDefs = groupBy === "field" ? fieldDefs(projectsInScope, chart.field) : null;
|
|
14684
|
+
const filterDefs = filter?.by === "field" ? fieldDefs(projectsInScope, filter.field) : null;
|
|
14685
|
+
const tasksById = measure === "blocked" ? byId(data.tasks) : null;
|
|
14686
|
+
const keep = (item) => {
|
|
14687
|
+
if (!filter) return true;
|
|
14688
|
+
const t = item.task;
|
|
14689
|
+
switch (filter.by) {
|
|
14690
|
+
case "owner": return (item.personId ?? "") === filter.value;
|
|
14691
|
+
case "priority": return !!t && t.priority === filter.value;
|
|
14692
|
+
case "status": return !!t && t.status === filter.value;
|
|
14693
|
+
case "severity": return !!item.raid && item.raid.severity === filter.value;
|
|
14694
|
+
case "field": {
|
|
14695
|
+
if (!t) return false;
|
|
14696
|
+
const def = filterDefs.get(t.projectId);
|
|
14697
|
+
if (!def) return filter.value === "";
|
|
14698
|
+
return matchesFilter(def, t.customFieldValues?.[def.id], filter.value);
|
|
14699
|
+
}
|
|
14700
|
+
}
|
|
14701
|
+
};
|
|
14207
14702
|
const items = [];
|
|
14208
|
-
|
|
14703
|
+
const push = (item) => {
|
|
14704
|
+
if (keep(item)) items.push(item);
|
|
14705
|
+
};
|
|
14706
|
+
if (kind) for (const r of data.raidItems ?? []) {
|
|
14707
|
+
if (r.kind !== kind || !ACTIVE_RAID.has(r.status) || !raidInScope(r, scope)) continue;
|
|
14708
|
+
push({
|
|
14709
|
+
raid: r,
|
|
14710
|
+
amount: 1,
|
|
14711
|
+
day: r.dueDate,
|
|
14712
|
+
personId: r.ownerId
|
|
14713
|
+
});
|
|
14714
|
+
}
|
|
14715
|
+
else for (const task of data.tasks) {
|
|
14209
14716
|
if (!inScope(task)) continue;
|
|
14210
14717
|
const done = task.status === "done";
|
|
14211
|
-
|
|
14718
|
+
const mine = {
|
|
14719
|
+
task,
|
|
14720
|
+
amount: 1,
|
|
14721
|
+
personId: task.assigneeId
|
|
14722
|
+
};
|
|
14723
|
+
switch (measure) {
|
|
14212
14724
|
case "open":
|
|
14213
|
-
if (!done)
|
|
14214
|
-
|
|
14215
|
-
amount: 1,
|
|
14725
|
+
if (!done) push({
|
|
14726
|
+
...mine,
|
|
14216
14727
|
day: task.dueDate
|
|
14217
14728
|
});
|
|
14218
14729
|
break;
|
|
14219
14730
|
case "done":
|
|
14220
|
-
if (done)
|
|
14221
|
-
|
|
14222
|
-
amount: 1,
|
|
14731
|
+
if (done) push({
|
|
14732
|
+
...mine,
|
|
14223
14733
|
day: task.completedAt
|
|
14224
14734
|
});
|
|
14225
14735
|
break;
|
|
14226
14736
|
case "all":
|
|
14227
|
-
|
|
14228
|
-
|
|
14229
|
-
amount: 1,
|
|
14737
|
+
push({
|
|
14738
|
+
...mine,
|
|
14230
14739
|
day: task.createdAt
|
|
14231
14740
|
});
|
|
14232
14741
|
break;
|
|
14233
14742
|
case "overdue":
|
|
14234
|
-
if (!done && task.dueDate && task.dueDate.slice(0, 10) < today)
|
|
14743
|
+
if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) push({
|
|
14744
|
+
...mine,
|
|
14745
|
+
day: task.dueDate
|
|
14746
|
+
});
|
|
14747
|
+
break;
|
|
14748
|
+
case "blocked":
|
|
14749
|
+
if (!done && blockerIds(task).some((id) => {
|
|
14750
|
+
const b = tasksById.get(id);
|
|
14751
|
+
return !!b && b.status !== "done";
|
|
14752
|
+
})) push({
|
|
14753
|
+
...mine,
|
|
14754
|
+
day: task.dueDate
|
|
14755
|
+
});
|
|
14756
|
+
break;
|
|
14757
|
+
case "approval":
|
|
14758
|
+
if (task.approval?.status === "pending") push({
|
|
14235
14759
|
task,
|
|
14236
14760
|
amount: 1,
|
|
14237
|
-
day: task.
|
|
14761
|
+
day: task.approval.askedAt,
|
|
14762
|
+
personId: task.approval.approverId
|
|
14238
14763
|
});
|
|
14239
14764
|
break;
|
|
14240
|
-
case "hours": for (const entry of task.time ?? [])
|
|
14765
|
+
case "hours": for (const entry of task.time ?? []) push({
|
|
14241
14766
|
task,
|
|
14242
14767
|
amount: entry.minutes / 60,
|
|
14243
14768
|
day: entry.on,
|
|
@@ -14250,10 +14775,14 @@ function chartData(chart, data, today) {
|
|
|
14250
14775
|
const people = byId(data.people);
|
|
14251
14776
|
const sums = /* @__PURE__ */ new Map();
|
|
14252
14777
|
const add = (key, amount) => sums.set(key, (sums.get(key) ?? 0) + amount);
|
|
14778
|
+
/** Field values in the order the field lists them, for a field split. */
|
|
14779
|
+
const fieldLabels = /* @__PURE__ */ new Map();
|
|
14780
|
+
let fieldNone = "";
|
|
14781
|
+
let overlaps = false;
|
|
14253
14782
|
let weeks = [];
|
|
14254
14783
|
if (groupBy === "week") {
|
|
14255
14784
|
const thisWeek = mondayOf(today);
|
|
14256
|
-
const ahead =
|
|
14785
|
+
const ahead = measure !== "done" && measure !== "all" && measure !== "overdue" && measure !== "approval" && measure !== "hours";
|
|
14257
14786
|
const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
|
|
14258
14787
|
const count = ahead ? 8 : 12;
|
|
14259
14788
|
const start = parseIso(`${first}T12:00:00`);
|
|
@@ -14265,9 +14794,36 @@ function chartData(chart, data, today) {
|
|
|
14265
14794
|
if (known.has(week)) add(week, item.amount);
|
|
14266
14795
|
else if (ahead && week < first) add(first, item.amount);
|
|
14267
14796
|
}
|
|
14797
|
+
} else if (groupBy === "field") {
|
|
14798
|
+
const name = chart.field?.trim() || "field";
|
|
14799
|
+
const first = splitDefs.values().next().value;
|
|
14800
|
+
const shown = first?.name.trim() || name;
|
|
14801
|
+
fieldNone = first?.type === "checkbox" ? `Not ${shown}` : `No ${shown}`;
|
|
14802
|
+
for (const item of items) {
|
|
14803
|
+
const t = item.task;
|
|
14804
|
+
const def = splitDefs.get(t.projectId);
|
|
14805
|
+
const v = def ? t.customFieldValues?.[def.id] : void 0;
|
|
14806
|
+
if (!def || !hasFieldValue(def, v)) add("", item.amount);
|
|
14807
|
+
else if (def.type === "multi") {
|
|
14808
|
+
const tags = [...new Set(tagsOf(v))];
|
|
14809
|
+
if (tags.length > 1) overlaps = true;
|
|
14810
|
+
for (const tag of tags) {
|
|
14811
|
+
fieldLabels.set(tag, tag);
|
|
14812
|
+
add(tag, item.amount);
|
|
14813
|
+
}
|
|
14814
|
+
} else if (def.type === "checkbox") {
|
|
14815
|
+
fieldLabels.set("yes", def.name.trim());
|
|
14816
|
+
add("yes", item.amount);
|
|
14817
|
+
} else {
|
|
14818
|
+
const key = String(v);
|
|
14819
|
+
fieldLabels.set(key, fieldValueText(def, v, (id) => people.get(id)?.name) || key);
|
|
14820
|
+
add(key, item.amount);
|
|
14821
|
+
}
|
|
14822
|
+
}
|
|
14268
14823
|
} else for (const item of items) {
|
|
14269
14824
|
const t = item.task;
|
|
14270
|
-
|
|
14825
|
+
const r = item.raid;
|
|
14826
|
+
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
14827
|
}
|
|
14272
14828
|
const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
|
|
14273
14829
|
let slices;
|
|
@@ -14289,20 +14845,38 @@ function chartData(chart, data, today) {
|
|
|
14289
14845
|
value: round(sums.get(p)),
|
|
14290
14846
|
fill: PRIORITY_FILL[p]
|
|
14291
14847
|
}));
|
|
14848
|
+
else if (groupBy === "severity") slices = SEVERITIES.filter((s) => sums.has(s)).map((s) => ({
|
|
14849
|
+
key: s,
|
|
14850
|
+
label: SEVERITY_LABEL[s],
|
|
14851
|
+
value: round(sums.get(s)),
|
|
14852
|
+
fill: SEVERITY_FILL[s]
|
|
14853
|
+
}));
|
|
14292
14854
|
else {
|
|
14293
14855
|
const named = [...sums.entries()].map(([key, value], i) => {
|
|
14856
|
+
if (groupBy === "field") return {
|
|
14857
|
+
key: key || "none",
|
|
14858
|
+
label: key ? fieldLabels.get(key) ?? key : fieldNone,
|
|
14859
|
+
value: round(value),
|
|
14860
|
+
fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
|
|
14861
|
+
};
|
|
14294
14862
|
if (groupBy === "owner") {
|
|
14295
14863
|
const person = key ? people.get(key) : void 0;
|
|
14296
14864
|
return {
|
|
14297
14865
|
key: key || "nobody",
|
|
14298
|
-
label: key ? person?.name ?? "Former member" : "Nobody",
|
|
14866
|
+
label: key ? person?.name ?? "Former member" : measure === "approval" ? "No approver" : "Nobody",
|
|
14299
14867
|
value: round(value),
|
|
14300
14868
|
fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
|
|
14301
14869
|
};
|
|
14302
14870
|
}
|
|
14871
|
+
if (!key) return {
|
|
14872
|
+
key: "none",
|
|
14873
|
+
label: groupBy === "project" ? "Not on a project" : "Not in a program",
|
|
14874
|
+
value: round(value),
|
|
14875
|
+
fill: "var(--color-swatch-slate)"
|
|
14876
|
+
};
|
|
14303
14877
|
const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
|
|
14304
14878
|
return {
|
|
14305
|
-
key
|
|
14879
|
+
key,
|
|
14306
14880
|
label: thing?.name ?? "Deleted",
|
|
14307
14881
|
value: round(value),
|
|
14308
14882
|
fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
|
|
@@ -14319,24 +14893,37 @@ function chartData(chart, data, today) {
|
|
|
14319
14893
|
}];
|
|
14320
14894
|
} else slices = named;
|
|
14321
14895
|
}
|
|
14322
|
-
const total = round(slices.reduce((n, s) => n + s.value, 0));
|
|
14896
|
+
const total = overlaps ? round(items.reduce((n, i) => n + i.amount, 0)) : round(slices.reduce((n, s) => n + s.value, 0));
|
|
14323
14897
|
return {
|
|
14324
14898
|
slices,
|
|
14325
14899
|
total,
|
|
14326
14900
|
unit,
|
|
14327
14901
|
scopeName: scope.name,
|
|
14328
|
-
scopeFound: scope.found
|
|
14902
|
+
scopeFound: scope.found,
|
|
14903
|
+
filterText: filter ? filterText(measure, filter, data, chart.scopeId) : null,
|
|
14904
|
+
overlaps
|
|
14329
14905
|
};
|
|
14330
14906
|
}
|
|
14331
|
-
|
|
14907
|
+
const UNIT_WORDS = {
|
|
14908
|
+
tasks: ["task", "tasks"],
|
|
14909
|
+
hours: ["hour", "hours"],
|
|
14910
|
+
risks: ["risk", "risks"],
|
|
14911
|
+
issues: ["issue", "issues"],
|
|
14912
|
+
decisions: ["decision", "decisions"]
|
|
14913
|
+
};
|
|
14914
|
+
/** "task" or "tasks", "risk" or "risks". */
|
|
14915
|
+
function unitWord(value, unit) {
|
|
14916
|
+
return UNIT_WORDS[unit][value === 1 ? 0 : 1];
|
|
14917
|
+
}
|
|
14918
|
+
/** "12 tasks", "1 task", "7.5 hours", "3 risks". */
|
|
14332
14919
|
function amountText(value, unit) {
|
|
14333
|
-
|
|
14334
|
-
return `${value} ${value === 1 ? "task" : "tasks"}`;
|
|
14920
|
+
return `${value} ${unitWord(value, unit)}`;
|
|
14335
14921
|
}
|
|
14336
14922
|
/** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
|
|
14337
14923
|
function chartText(title, result) {
|
|
14338
|
-
|
|
14339
|
-
|
|
14924
|
+
const named = result.filterText ? `${title} (${result.filterText.toLowerCase()})` : title;
|
|
14925
|
+
if (result.slices.length === 0) return `${named}: nothing yet`;
|
|
14926
|
+
return `${named}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
|
|
14340
14927
|
}
|
|
14341
14928
|
|
|
14342
14929
|
// node_modules/zod/v4/core/util.js
|
|
@@ -19918,85 +20505,6 @@ function exportName(harborName, now) {
|
|
|
19918
20505
|
return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
|
|
19919
20506
|
}
|
|
19920
20507
|
|
|
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
20508
|
// src/lib/exports.ts
|
|
20001
20509
|
init_subtasks();
|
|
20002
20510
|
init_derived_health();
|
|
@@ -20658,6 +21166,7 @@ function approvalsWaitingOn(tasks, personId) {
|
|
|
20658
21166
|
}
|
|
20659
21167
|
|
|
20660
21168
|
// src/lib/mcp/tools.ts
|
|
21169
|
+
init_project_messages();
|
|
20661
21170
|
init_subtasks();
|
|
20662
21171
|
init_thanks();
|
|
20663
21172
|
init_task_keys();
|
|
@@ -22053,6 +22562,36 @@ const HARBOR_TOOLS = [
|
|
|
22053
22562
|
};
|
|
22054
22563
|
}
|
|
22055
22564
|
}),
|
|
22565
|
+
tool({
|
|
22566
|
+
name: "list_messages",
|
|
22567
|
+
title: "A project's messages",
|
|
22568
|
+
description: "The conversation on a project's Messages tab, for what isn't about one task: each message with who wrote it, when, whether it was edited, and who it @mentions, oldest first (the newest `limit`, default 50; more says how many earlier ones were left out). post_message writes one." + DATA_NOTE,
|
|
22569
|
+
input: object({
|
|
22570
|
+
harbor: harborArg,
|
|
22571
|
+
project: string().min(1).max(200).describe("Project id or name."),
|
|
22572
|
+
limit: number().int().min(1).max(500).optional()
|
|
22573
|
+
}),
|
|
22574
|
+
run: (ctx, input) => {
|
|
22575
|
+
const { data } = ctx;
|
|
22576
|
+
const p = findOne(data.projects, input.project, "project");
|
|
22577
|
+
const people = byId(data.people);
|
|
22578
|
+
const all = messagesOf(data.reflections, p.id);
|
|
22579
|
+
const limit = input.limit ?? 50;
|
|
22580
|
+
const shown = all.slice(-limit);
|
|
22581
|
+
return {
|
|
22582
|
+
project: p.name,
|
|
22583
|
+
messages: shown.map((m) => ({
|
|
22584
|
+
id: m.id,
|
|
22585
|
+
from: nameOf$2(people, m.authorId),
|
|
22586
|
+
at: m.createdAt,
|
|
22587
|
+
...m.editedAt ? { edited: m.editedAt } : {},
|
|
22588
|
+
body: m.body,
|
|
22589
|
+
...(m.mentionedIds ?? []).length ? { mentions: (m.mentionedIds ?? []).map((id) => nameOf$2(people, id)) } : {}
|
|
22590
|
+
})),
|
|
22591
|
+
...all.length > shown.length ? { more: all.length - shown.length } : {}
|
|
22592
|
+
};
|
|
22593
|
+
}
|
|
22594
|
+
}),
|
|
22056
22595
|
tool({
|
|
22057
22596
|
name: "list_soundings",
|
|
22058
22597
|
title: "Soundings",
|
|
@@ -22169,7 +22708,7 @@ const HARBOR_TOOLS = [
|
|
|
22169
22708
|
tool({
|
|
22170
22709
|
name: "list_dashboards",
|
|
22171
22710
|
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
|
|
22711
|
+
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
22712
|
input: object({
|
|
22174
22713
|
harbor: harborArg,
|
|
22175
22714
|
dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
|
|
@@ -22187,9 +22726,10 @@ const HARBOR_TOOLS = [
|
|
|
22187
22726
|
id: c.id,
|
|
22188
22727
|
title: c.title,
|
|
22189
22728
|
counts: MEASURE_LABEL[c.measure],
|
|
22190
|
-
splitBy:
|
|
22729
|
+
splitBy: c.groupBy === "field" ? c.field ?? "" : groupLabel(c.measure, c.groupBy),
|
|
22191
22730
|
view: c.view,
|
|
22192
22731
|
from: result.scopeName,
|
|
22732
|
+
...result.filterText ? { only: result.filterText } : {},
|
|
22193
22733
|
total: amountText(result.total, result.unit),
|
|
22194
22734
|
values: result.slices.map((s) => ({
|
|
22195
22735
|
label: s.label,
|
|
@@ -24652,7 +25192,7 @@ function recordScoreChange(item, patch, at) {
|
|
|
24652
25192
|
var init_raid_history = __esmMin((() => {}));
|
|
24653
25193
|
|
|
24654
25194
|
// src/lib/slices/milestones-raid.ts
|
|
24655
|
-
function milestonesRaidSlice({
|
|
25195
|
+
function milestonesRaidSlice({ get, write }) {
|
|
24656
25196
|
return {
|
|
24657
25197
|
toggleMilestone: (id) => {
|
|
24658
25198
|
if (get().milestones.find((m) => m.id === id)?.approverId) return;
|
|
@@ -24684,15 +25224,16 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
24684
25224
|
write({ milestones: [...get().milestones, m] });
|
|
24685
25225
|
},
|
|
24686
25226
|
setConstraint: (programId, patch) => {
|
|
25227
|
+
const program = get().programs.find((p) => p.id === programId);
|
|
25228
|
+
const logNamed = () => get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
|
|
24687
25229
|
const existing = get().constraints.find((c) => c.programId === programId && c.status === "active");
|
|
24688
25230
|
if (existing) {
|
|
24689
|
-
|
|
25231
|
+
write({ constraints: get().constraints.map((c) => c.id === existing.id ? {
|
|
24690
25232
|
...c,
|
|
24691
25233
|
...patch,
|
|
24692
25234
|
status: "active"
|
|
24693
25235
|
} : c) });
|
|
24694
|
-
|
|
24695
|
-
get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
|
|
25236
|
+
logNamed();
|
|
24696
25237
|
return existing.id;
|
|
24697
25238
|
}
|
|
24698
25239
|
const id = uid("k");
|
|
@@ -24700,29 +25241,48 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
24700
25241
|
...patch,
|
|
24701
25242
|
id,
|
|
24702
25243
|
programId,
|
|
24703
|
-
status: "active"
|
|
25244
|
+
status: "active",
|
|
25245
|
+
createdAt: stamp(),
|
|
25246
|
+
createdById: get().currentUserId || null
|
|
24704
25247
|
};
|
|
24705
|
-
|
|
24706
|
-
|
|
24707
|
-
get().log(`named the constraint on ${program?.name ?? "a program"}`, `/programs/${programId}`);
|
|
25248
|
+
write({ constraints: [...get().constraints, row] });
|
|
25249
|
+
logNamed();
|
|
24708
25250
|
return id;
|
|
24709
25251
|
},
|
|
24710
25252
|
updateConstraint: (id, patch) => {
|
|
24711
|
-
|
|
25253
|
+
write({ constraints: get().constraints.map((c) => c.id === id ? {
|
|
24712
25254
|
...c,
|
|
24713
25255
|
...patch
|
|
24714
25256
|
} : c) });
|
|
24715
25257
|
},
|
|
24716
25258
|
resolveConstraint: (id) => {
|
|
24717
25259
|
const row = get().constraints.find((c) => c.id === id);
|
|
24718
|
-
|
|
25260
|
+
if (!row || row.status === "resolved") return;
|
|
25261
|
+
write({ constraints: get().constraints.map((c) => c.id === id ? {
|
|
24719
25262
|
...c,
|
|
24720
|
-
status: "resolved"
|
|
25263
|
+
status: "resolved",
|
|
25264
|
+
resolvedAt: stamp(),
|
|
25265
|
+
resolvedById: get().currentUserId || null
|
|
24721
25266
|
} : c) });
|
|
24722
|
-
|
|
24723
|
-
|
|
24724
|
-
|
|
24725
|
-
|
|
25267
|
+
const program = get().programs.find((p) => p.id === row.programId);
|
|
25268
|
+
get().log(`retired the constraint on ${program?.name ?? "a program"}`, `/programs/${row.programId}`);
|
|
25269
|
+
},
|
|
25270
|
+
reopenConstraint: (id) => {
|
|
25271
|
+
const row = get().constraints.find((c) => c.id === id);
|
|
25272
|
+
if (!row) return false;
|
|
25273
|
+
if (row.status === "active") return true;
|
|
25274
|
+
if (get().constraints.some((c) => c.programId === row.programId && c.status === "active")) return false;
|
|
25275
|
+
write({ constraints: get().constraints.map((c) => {
|
|
25276
|
+
if (c.id !== id) return c;
|
|
25277
|
+
const { resolvedAt: _at, resolvedById: _by, ...rest } = c;
|
|
25278
|
+
return {
|
|
25279
|
+
...rest,
|
|
25280
|
+
status: "active"
|
|
25281
|
+
};
|
|
25282
|
+
}) });
|
|
25283
|
+
const program = get().programs.find((p) => p.id === row.programId);
|
|
25284
|
+
get().log(`brought back the constraint on ${program?.name ?? "a program"}`, `/programs/${row.programId}`);
|
|
25285
|
+
return true;
|
|
24726
25286
|
},
|
|
24727
25287
|
addRaidItem: (r) => {
|
|
24728
25288
|
const id = uid("raid");
|
|
@@ -24938,6 +25498,48 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
24938
25498
|
write({ reflections: [...s.reflections ?? [], row] });
|
|
24939
25499
|
return row.id;
|
|
24940
25500
|
},
|
|
25501
|
+
postMessage: (projectId, body) => {
|
|
25502
|
+
const s = get();
|
|
25503
|
+
if (messageProblem(body)) return null;
|
|
25504
|
+
const project = s.projects.find((p) => p.id === projectId);
|
|
25505
|
+
if (!project) return null;
|
|
25506
|
+
const me = s.currentUserId;
|
|
25507
|
+
const text = messageText(body);
|
|
25508
|
+
const row = {
|
|
25509
|
+
id: uid("msg"),
|
|
25510
|
+
projectId,
|
|
25511
|
+
kind: "message",
|
|
25512
|
+
authorId: me,
|
|
25513
|
+
body: text,
|
|
25514
|
+
createdAt: stamp(),
|
|
25515
|
+
mentionedIds: messageMentions(text, s.people, me)
|
|
25516
|
+
};
|
|
25517
|
+
write({ reflections: [...s.reflections ?? [], row] });
|
|
25518
|
+
get().log(`wrote in ${project.name}’s messages`, `/projects/${projectId}?view=messages`);
|
|
25519
|
+
return row.id;
|
|
25520
|
+
},
|
|
25521
|
+
editMessage: (id, body) => {
|
|
25522
|
+
const s = get();
|
|
25523
|
+
const all = s.reflections ?? [];
|
|
25524
|
+
const row = all.find((r) => r.id === id && isMessage(r));
|
|
25525
|
+
if (!row || !mayChangeMessage(row, s.currentUserId, s.myRole, "edit")) return;
|
|
25526
|
+
if (messageProblem(body)) return;
|
|
25527
|
+
const text = messageText(body);
|
|
25528
|
+
if (text === row.body) return;
|
|
25529
|
+
write({ reflections: all.map((r) => r.id === id ? {
|
|
25530
|
+
...r,
|
|
25531
|
+
body: text,
|
|
25532
|
+
mentionedIds: messageMentions(text, s.people, r.authorId),
|
|
25533
|
+
editedAt: stamp()
|
|
25534
|
+
} : r) });
|
|
25535
|
+
},
|
|
25536
|
+
deleteMessage: (id) => {
|
|
25537
|
+
const s = get();
|
|
25538
|
+
const all = s.reflections ?? [];
|
|
25539
|
+
const row = all.find((r) => r.id === id && isMessage(r));
|
|
25540
|
+
if (!row || !mayChangeMessage(row, s.currentUserId, s.myRole, "delete")) return;
|
|
25541
|
+
write({ reflections: all.filter((r) => r.id !== id) });
|
|
25542
|
+
},
|
|
24941
25543
|
writeVoyageLine: (projectId, body) => {
|
|
24942
25544
|
const text = body.trim().slice(0, 600);
|
|
24943
25545
|
const me = get().currentUserId;
|
|
@@ -25031,6 +25633,7 @@ var init_milestones_raid = __esmMin((() => {
|
|
|
25031
25633
|
init_raid_history();
|
|
25032
25634
|
init_passage();
|
|
25033
25635
|
init_format();
|
|
25636
|
+
init_project_messages();
|
|
25034
25637
|
init_utils();
|
|
25035
25638
|
init_shared();
|
|
25036
25639
|
}));
|
package/lib/cli.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
myWork,
|
|
34
34
|
premortemText,
|
|
35
35
|
notesText,
|
|
36
|
+
messagesText,
|
|
36
37
|
charterText,
|
|
37
38
|
daysText,
|
|
38
39
|
soundingText,
|
|
@@ -251,6 +252,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
251
252
|
answer or read it
|
|
252
253
|
harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
|
|
253
254
|
your line in it
|
|
255
|
+
harbor messages PROJECT ["TEXT"] [--limit N]
|
|
256
|
+
a project's messages, or write
|
|
257
|
+
one (@Name tells them)
|
|
254
258
|
harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
|
|
255
259
|
[--calls NAME]
|
|
256
260
|
a project's five-line charter
|
|
@@ -1270,6 +1274,14 @@ const DETAIL = {
|
|
|
1270
1274
|
" it explains. The weekly brief names projects that grew a lot since",
|
|
1271
1275
|
" their baseline.",
|
|
1272
1276
|
],
|
|
1277
|
+
messages: [
|
|
1278
|
+
" harbor messages PROJECT reads a project's Messages tab",
|
|
1279
|
+
' harbor messages PROJECT "TEXT" writes: posts to everyone on it',
|
|
1280
|
+
"",
|
|
1281
|
+
" One conversation for the whole project, for what isn't about one task;",
|
|
1282
|
+
" harbor comment TASK is for that. @Name sends it to that person's Inbox.",
|
|
1283
|
+
" Reading shows the newest 50, oldest first; --limit N shows up to 500.",
|
|
1284
|
+
],
|
|
1273
1285
|
voyage: [
|
|
1274
1286
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
1275
1287
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
@@ -3458,6 +3470,29 @@ export async function run(argv, io = {}) {
|
|
|
3458
3470
|
}
|
|
3459
3471
|
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
3460
3472
|
}
|
|
3473
|
+
case "messages": {
|
|
3474
|
+
const { positionals, values, harbor } = parse(spec([], ["limit"]));
|
|
3475
|
+
const [project, ...words] = positionals;
|
|
3476
|
+
if (!project)
|
|
3477
|
+
throw new HarborError(
|
|
3478
|
+
'Give a project, and a message if you\'re writing one: harbor messages "NAME" "TEXT".',
|
|
3479
|
+
);
|
|
3480
|
+
const body = words.join(" ").trim();
|
|
3481
|
+
if (body) {
|
|
3482
|
+
if (values.limit)
|
|
3483
|
+
throw new HarborError("--limit is for reading; leave it off when you write.");
|
|
3484
|
+
return call(
|
|
3485
|
+
"post_message",
|
|
3486
|
+
{ harbor, project, body },
|
|
3487
|
+
(r) =>
|
|
3488
|
+
`Posted in ${r.posted.project}'s messages${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
|
|
3489
|
+
);
|
|
3490
|
+
}
|
|
3491
|
+
const limit = values.limit ? Number(values.limit) : undefined;
|
|
3492
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500))
|
|
3493
|
+
throw new HarborError("--limit is a whole number from 1 to 500.");
|
|
3494
|
+
return call("list_messages", { harbor, project, ...(limit ? { limit } : {}) }, messagesText);
|
|
3495
|
+
}
|
|
3461
3496
|
case "watch": {
|
|
3462
3497
|
const { positionals, values, harbor } = parse(spec(["bell", "once"], ["project", "since"]));
|
|
3463
3498
|
none(positionals);
|
package/lib/format.mjs
CHANGED
|
@@ -1295,3 +1295,22 @@ export function goalsText(r) {
|
|
|
1295
1295
|
}
|
|
1296
1296
|
return lines.join("\n");
|
|
1297
1297
|
}
|
|
1298
|
+
|
|
1299
|
+
/** A project's Messages tab, as list_messages answers it: oldest first, one block a message. */
|
|
1300
|
+
export function messagesText(r) {
|
|
1301
|
+
const list = r.messages ?? [];
|
|
1302
|
+
if (!list.length)
|
|
1303
|
+
return `No messages on ${r.project} yet. harbor messages "${r.project}" "TEXT" writes the first.`;
|
|
1304
|
+
const head = r.more
|
|
1305
|
+
? [
|
|
1306
|
+
`${r.more} earlier ${r.more === 1 ? "message" : "messages"} left out (--limit N shows more).`,
|
|
1307
|
+
]
|
|
1308
|
+
: [];
|
|
1309
|
+
return [
|
|
1310
|
+
...head,
|
|
1311
|
+
...list.map(
|
|
1312
|
+
(m) =>
|
|
1313
|
+
`${m.from ?? "Someone"} · ${m.at.slice(0, 16).replace("T", " ")}${m.edited ? " (edited)" : ""}\n ${m.body.replace(/\n/g, "\n ")}`,
|
|
1314
|
+
),
|
|
1315
|
+
].join("\n\n");
|
|
1316
|
+
}
|