@harborppm/cli 0.43.0 → 0.44.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 +11 -3
- package/dist/app.mjs +2225 -1846
- package/lib/cli.mjs +186 -1
- package/lib/format.mjs +70 -0
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Built from src/tui by cli/build.mjs. Edit the source, not this file.
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
|
-
import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
|
|
3
|
+
import { Box, Text, render, useApp, useInput, useStdout, useWindowSize } from "ink";
|
|
4
4
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
@@ -2082,6 +2082,36 @@ function sortRaid(items) {
|
|
|
2082
2082
|
return b.updatedAt.localeCompare(a.updatedAt);
|
|
2083
2083
|
});
|
|
2084
2084
|
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Live risks rated on both scales, placed on the 5×5 grid: rows run from
|
|
2087
|
+
* impact 5 down to 1, columns from probability 1 up to 5, so the top-right
|
|
2088
|
+
* corner is the worst place to be.
|
|
2089
|
+
*/
|
|
2090
|
+
function heatGrid(items) {
|
|
2091
|
+
const rows = [];
|
|
2092
|
+
for (let impact = 5; impact >= 1; impact -= 1) {
|
|
2093
|
+
const row = [];
|
|
2094
|
+
for (let probability = 1; probability <= 5; probability += 1) row.push({
|
|
2095
|
+
probability,
|
|
2096
|
+
impact,
|
|
2097
|
+
band: scoreBand(probability * impact),
|
|
2098
|
+
items: []
|
|
2099
|
+
});
|
|
2100
|
+
rows.push(row);
|
|
2101
|
+
}
|
|
2102
|
+
for (const item of items) {
|
|
2103
|
+
if (item.kind !== "risk" || !isActive(item)) continue;
|
|
2104
|
+
const p = clampScale(item.probability);
|
|
2105
|
+
const i = clampScale(item.impact);
|
|
2106
|
+
if (p == null || i == null) continue;
|
|
2107
|
+
rows[5 - i][p - 1].items.push(item);
|
|
2108
|
+
}
|
|
2109
|
+
return rows;
|
|
2110
|
+
}
|
|
2111
|
+
/** True when a risk sits in the given cell of the grid. */
|
|
2112
|
+
function inHeatCell(item, probability, impact) {
|
|
2113
|
+
return item.kind === "risk" && item.probability === probability && item.impact === impact;
|
|
2114
|
+
}
|
|
2085
2115
|
var KIND_LABEL, STATUS_LABEL$1, STATUSES, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY;
|
|
2086
2116
|
var init_raid = __esmMin((() => {
|
|
2087
2117
|
KIND_LABEL = {
|
|
@@ -5401,6 +5431,173 @@ function renderBriefText(brief, data, workspace = "Harbor", all = false) {
|
|
|
5401
5431
|
return composeBriefBodyFrom(brief, data, workspace, all);
|
|
5402
5432
|
}
|
|
5403
5433
|
|
|
5434
|
+
//#endregion
|
|
5435
|
+
//#region src/lib/shipping-forecast.ts
|
|
5436
|
+
init_date_fns();
|
|
5437
|
+
init_derived_health();
|
|
5438
|
+
init_raid();
|
|
5439
|
+
init_date_text();
|
|
5440
|
+
init_format();
|
|
5441
|
+
init_indexes();
|
|
5442
|
+
/** Areas read out by name; any more are summed up in one line. */
|
|
5443
|
+
const FORECAST_AREAS = 10;
|
|
5444
|
+
const WORDS = [
|
|
5445
|
+
"no",
|
|
5446
|
+
"one",
|
|
5447
|
+
"two",
|
|
5448
|
+
"three",
|
|
5449
|
+
"four",
|
|
5450
|
+
"five",
|
|
5451
|
+
"six",
|
|
5452
|
+
"seven",
|
|
5453
|
+
"eight",
|
|
5454
|
+
"nine",
|
|
5455
|
+
"ten",
|
|
5456
|
+
"eleven",
|
|
5457
|
+
"twelve"
|
|
5458
|
+
];
|
|
5459
|
+
/** Small numbers as words, which a voice reads better and the forecast always uses. */
|
|
5460
|
+
function spoken(n) {
|
|
5461
|
+
return n >= 0 && n < WORDS.length ? WORDS[n] : String(n);
|
|
5462
|
+
}
|
|
5463
|
+
function count$1(n, one, many = `${one}s`) {
|
|
5464
|
+
return `${spoken(n)} ${n === 1 ? one : many}`;
|
|
5465
|
+
}
|
|
5466
|
+
const VISIBILITY = {
|
|
5467
|
+
"on-track": "good",
|
|
5468
|
+
"at-risk": "moderate",
|
|
5469
|
+
"off-track": "poor"
|
|
5470
|
+
};
|
|
5471
|
+
const ORDER = {
|
|
5472
|
+
"off-track": 0,
|
|
5473
|
+
"at-risk": 1,
|
|
5474
|
+
"on-track": 2
|
|
5475
|
+
};
|
|
5476
|
+
/** The time in words, to the nearest quarter hour: "nine", "half past nine", "quarter to ten". */
|
|
5477
|
+
function issuedAt(now) {
|
|
5478
|
+
const quarter = Math.round(now.getMinutes() / 15);
|
|
5479
|
+
const hour = (h) => {
|
|
5480
|
+
const twelve = (h % 24 + 24) % 12;
|
|
5481
|
+
if (twelve === 0) return h % 24 === 0 ? "midnight" : "noon";
|
|
5482
|
+
return spoken(twelve);
|
|
5483
|
+
};
|
|
5484
|
+
const h = now.getHours();
|
|
5485
|
+
if (quarter === 0) return hour(h);
|
|
5486
|
+
if (quarter === 1) return `quarter past ${hour(h)}`;
|
|
5487
|
+
if (quarter === 2) return `half past ${hour(h)}`;
|
|
5488
|
+
if (quarter === 3) return `quarter to ${hour(h + 1)}`;
|
|
5489
|
+
return hour(h + 1);
|
|
5490
|
+
}
|
|
5491
|
+
/** The wind for so many late tasks. */
|
|
5492
|
+
function windFor(late) {
|
|
5493
|
+
const force = Math.min(12, 3 + late);
|
|
5494
|
+
if (late === 0) return {
|
|
5495
|
+
force,
|
|
5496
|
+
words: "Variable 3 or less"
|
|
5497
|
+
};
|
|
5498
|
+
if (force >= 10) return {
|
|
5499
|
+
force,
|
|
5500
|
+
words: `Storm ${force}`
|
|
5501
|
+
};
|
|
5502
|
+
if (force >= 8) return {
|
|
5503
|
+
force,
|
|
5504
|
+
words: `Gale ${force}`
|
|
5505
|
+
};
|
|
5506
|
+
return {
|
|
5507
|
+
force,
|
|
5508
|
+
words: `Westerly ${force}`
|
|
5509
|
+
};
|
|
5510
|
+
}
|
|
5511
|
+
function capital(s) {
|
|
5512
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
5513
|
+
}
|
|
5514
|
+
function list$1(names) {
|
|
5515
|
+
if (names.length <= 1) return names.join("");
|
|
5516
|
+
return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
5517
|
+
}
|
|
5518
|
+
/**
|
|
5519
|
+
* The forecast for a harbor, or one portfolio of it, at `now`. Reads the
|
|
5520
|
+
* document only; nothing here changes it.
|
|
5521
|
+
*/
|
|
5522
|
+
function shippingForecast(data, opts) {
|
|
5523
|
+
const { now } = opts;
|
|
5524
|
+
const today = todayISO(now);
|
|
5525
|
+
const programs = data.programs.filter((p) => !opts.portfolioId || p.portfolioId === opts.portfolioId);
|
|
5526
|
+
const areas = programs.filter((p) => ORDER[p.health] != null).sort((a, b) => (ORDER[a.health] ?? 9) - (ORDER[b.health] ?? 9) || a.endDate.localeCompare(b.endDate)).map((program) => area(data, program, now, today));
|
|
5527
|
+
const opening = `And now the harbor forecast for ${opts.harborName?.trim() || "Harbor"}, issued at ${issuedAt(now)} on ${format(now, "EEEE d MMMM")}.`;
|
|
5528
|
+
const gales = areas.filter((a) => a.health === "off-track").map((a) => a.name);
|
|
5529
|
+
const warnings = gales.length ? `There are warnings of gales in ${list$1(gales)}.` : null;
|
|
5530
|
+
const projectIds = new Set(data.projects.filter((p) => programs.some((g) => g.id === p.programId)).map((p) => p.id));
|
|
5531
|
+
const tasks = data.tasks.filter((t) => projectIds.has(t.projectId));
|
|
5532
|
+
const weekAgo = todayISO(subDays(now, 6));
|
|
5533
|
+
const finished = tasks.filter((t) => t.status === "done" && t.completedAt && t.completedAt.slice(0, 10) >= weekAgo).length;
|
|
5534
|
+
const late = lateTasksOf(tasks, now).length;
|
|
5535
|
+
const synopsis = `General synopsis: ${count$1(finished, "task")} finished this week, ${count$1(late, "late", "late")}` + (late === 0 ? ", seas calm." : ".");
|
|
5536
|
+
const coda = [];
|
|
5537
|
+
const read = areas.slice(0, 10);
|
|
5538
|
+
const rest = areas.slice(10);
|
|
5539
|
+
if (rest.length) {
|
|
5540
|
+
const fair = rest.every((a) => a.health === "on-track");
|
|
5541
|
+
coda.push(`${capital(count$1(rest.length, "more area"))}, ${fair ? "fair, visibility good" : "mixed"}.`);
|
|
5542
|
+
}
|
|
5543
|
+
const port = programs.filter((p) => p.health === "done").map((p) => p.name);
|
|
5544
|
+
if (port.length) coda.push(`In port: ${list$1(port)}.`);
|
|
5545
|
+
const anchor = programs.filter((p) => p.health === "paused").map((p) => p.name);
|
|
5546
|
+
if (anchor.length) coda.push(`At anchor: ${list$1(anchor)}.`);
|
|
5547
|
+
const closing = areas.length || coda.length ? "That ends the harbor forecast." : "No programs at sea. That ends the harbor forecast.";
|
|
5548
|
+
return {
|
|
5549
|
+
opening,
|
|
5550
|
+
warnings,
|
|
5551
|
+
synopsis,
|
|
5552
|
+
areas: read,
|
|
5553
|
+
coda,
|
|
5554
|
+
closing,
|
|
5555
|
+
script: [
|
|
5556
|
+
opening,
|
|
5557
|
+
warnings,
|
|
5558
|
+
synopsis,
|
|
5559
|
+
...read.map((a) => `${a.name}: ${a.text}`),
|
|
5560
|
+
...coda,
|
|
5561
|
+
closing
|
|
5562
|
+
].filter(Boolean).join("\n")
|
|
5563
|
+
};
|
|
5564
|
+
}
|
|
5565
|
+
function area(data, program, now, today) {
|
|
5566
|
+
const projects = data.projects.filter((p) => p.programId === program.id);
|
|
5567
|
+
const projectIds = new Set(projects.map((p) => p.id));
|
|
5568
|
+
const late = projects.flatMap((p) => lateTasksOf([...tasksOf(data.tasks, p.id)], now));
|
|
5569
|
+
const wind = windFor(late.length);
|
|
5570
|
+
const parts = [wind.words];
|
|
5571
|
+
if (late.length) {
|
|
5572
|
+
const worst = Math.max(...late.map((t) => daysOverdue(t.dueDate, now)));
|
|
5573
|
+
if (late.length === 1) parts.push(`one task ${count$1(worst, "day")} late`);
|
|
5574
|
+
else parts.push(`${count$1(late.length, "task")} late, the worst by ${count$1(worst, "day")}`);
|
|
5575
|
+
}
|
|
5576
|
+
const open = data.milestones.filter((m) => m.programId === program.id && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
|
|
5577
|
+
const overdue = open.find((m) => m.dueDate < today);
|
|
5578
|
+
const next = open.find((m) => m.dueDate >= today);
|
|
5579
|
+
if (overdue) parts.push(`${overdue.name} overdue`);
|
|
5580
|
+
else if (next) {
|
|
5581
|
+
const days = differenceInCalendarDays(parseIso(next.dueDate), parseIso(today));
|
|
5582
|
+
if (days <= 14) {
|
|
5583
|
+
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${count$1(days, "day")}`;
|
|
5584
|
+
parts.push(`${next.name} ${when}`);
|
|
5585
|
+
}
|
|
5586
|
+
}
|
|
5587
|
+
const gates = open.filter((m) => m.approverId && m.approvalStatus === "pending").length;
|
|
5588
|
+
if (gates) parts.push(`${count$1(gates, "gate")} awaiting sign-off`);
|
|
5589
|
+
const risks = data.raidItems.filter((r) => r.kind === "risk" && isActive(r) && isHighRisk(r) && (r.programId === program.id || r.projectId != null && projectIds.has(r.projectId))).length;
|
|
5590
|
+
if (risks) parts.push(`${count$1(risks, "high risk")}`);
|
|
5591
|
+
const visibility = VISIBILITY[program.health] ?? "good";
|
|
5592
|
+
return {
|
|
5593
|
+
programId: program.id,
|
|
5594
|
+
name: program.name,
|
|
5595
|
+
text: `${parts.join(", ")}. Visibility ${visibility}.`,
|
|
5596
|
+
health: program.health,
|
|
5597
|
+
force: wind.force
|
|
5598
|
+
};
|
|
5599
|
+
}
|
|
5600
|
+
|
|
5404
5601
|
//#endregion
|
|
5405
5602
|
//#region src/tui/views/letter.tsx
|
|
5406
5603
|
/**
|
|
@@ -5457,13 +5654,24 @@ function Letter({ title, text, hints, width, height, active, keys = {}, onClose
|
|
|
5457
5654
|
|
|
5458
5655
|
//#endregion
|
|
5459
5656
|
//#region src/tui/views/brief.tsx
|
|
5657
|
+
init_swatches();
|
|
5658
|
+
/**
|
|
5659
|
+
* The terminal's copy of what it's given (OSC 52): most terminals put it on
|
|
5660
|
+
* the clipboard, over SSH too; tmux needs set-clipboard on.
|
|
5661
|
+
*/
|
|
5662
|
+
function copySequence(text) {
|
|
5663
|
+
return `\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`;
|
|
5664
|
+
}
|
|
5460
5665
|
/**
|
|
5461
|
-
* The weekly brief, the same letter the website writes:
|
|
5462
|
-
*
|
|
5463
|
-
*
|
|
5666
|
+
* The weekly brief, the same letter the website writes: how many programs
|
|
5667
|
+
* sit at each health, notes that opened, what's off track, risks to watch,
|
|
5668
|
+
* milestones, late work, what got done and what the crew said. ↑↓ read on,
|
|
5669
|
+
* p narrows it to one portfolio, f swaps in the shipping forecast and y
|
|
5670
|
+
* copies what's shown.
|
|
5464
5671
|
*/
|
|
5465
5672
|
function BriefView({ active, width, height, go }) {
|
|
5466
5673
|
const { today } = useDeps();
|
|
5674
|
+
const { stdout } = useStdout();
|
|
5467
5675
|
const harborName = useHarbor((s) => s.harborName);
|
|
5468
5676
|
const portfolios = useHarbor((s) => s.portfolios);
|
|
5469
5677
|
const programs = useHarbor((s) => s.programs);
|
|
@@ -5474,8 +5682,11 @@ function BriefView({ active, width, height, go }) {
|
|
|
5474
5682
|
const people = useHarbor((s) => s.people);
|
|
5475
5683
|
const checkIns = useHarbor((s) => s.checkIns);
|
|
5476
5684
|
const constraints = useHarbor((s) => s.constraints);
|
|
5685
|
+
const reflections = useHarbor((s) => s.reflections);
|
|
5477
5686
|
const [portfolio, setPortfolio] = useState(null);
|
|
5687
|
+
const [forecast, setForecast] = useState(false);
|
|
5478
5688
|
const day = today();
|
|
5689
|
+
const name = harborName?.trim() || "Harbor";
|
|
5479
5690
|
const brief = useMemo(() => {
|
|
5480
5691
|
const data = {
|
|
5481
5692
|
portfolios,
|
|
@@ -5486,15 +5697,25 @@ function BriefView({ active, width, height, go }) {
|
|
|
5486
5697
|
raidItems,
|
|
5487
5698
|
people,
|
|
5488
5699
|
checkIns,
|
|
5489
|
-
constraints
|
|
5700
|
+
constraints,
|
|
5701
|
+
reflections
|
|
5490
5702
|
};
|
|
5491
5703
|
const b = briefOf(data, {
|
|
5492
5704
|
portfolioId: portfolio,
|
|
5493
5705
|
now: /* @__PURE__ */ new Date(`${day}T12:00:00`)
|
|
5494
5706
|
});
|
|
5707
|
+
const text = renderBriefText(b, data, name);
|
|
5708
|
+
const scoped = programs.filter((p) => !portfolio || p.portfolioId === portfolio);
|
|
5709
|
+
const counts = [
|
|
5710
|
+
"on-track",
|
|
5711
|
+
"at-risk",
|
|
5712
|
+
"off-track"
|
|
5713
|
+
].map((h) => `${scoped.filter((p) => p.health === h).length} ${HEALTH_LABEL$1[h].toLowerCase()}`).join(" · ");
|
|
5714
|
+
const body = text.split("\n").slice(2).join("\n");
|
|
5495
5715
|
return {
|
|
5496
5716
|
scope: b.scopeLabel,
|
|
5497
|
-
text
|
|
5717
|
+
text,
|
|
5718
|
+
shown: `${counts}\n\n${body}`
|
|
5498
5719
|
};
|
|
5499
5720
|
}, [
|
|
5500
5721
|
portfolios,
|
|
@@ -5506,24 +5727,53 @@ function BriefView({ active, width, height, go }) {
|
|
|
5506
5727
|
people,
|
|
5507
5728
|
checkIns,
|
|
5508
5729
|
constraints,
|
|
5730
|
+
reflections,
|
|
5509
5731
|
portfolio,
|
|
5510
5732
|
day,
|
|
5511
|
-
|
|
5733
|
+
name
|
|
5734
|
+
]);
|
|
5735
|
+
const script = useMemo(() => forecast ? shippingForecast({
|
|
5736
|
+
programs,
|
|
5737
|
+
projects,
|
|
5738
|
+
tasks,
|
|
5739
|
+
milestones,
|
|
5740
|
+
raidItems
|
|
5741
|
+
}, {
|
|
5742
|
+
now: /* @__PURE__ */ new Date(),
|
|
5743
|
+
harborName: name,
|
|
5744
|
+
portfolioId: portfolio
|
|
5745
|
+
}).script : "", [
|
|
5746
|
+
forecast,
|
|
5747
|
+
programs,
|
|
5748
|
+
projects,
|
|
5749
|
+
tasks,
|
|
5750
|
+
milestones,
|
|
5751
|
+
raidItems,
|
|
5752
|
+
name,
|
|
5753
|
+
portfolio
|
|
5512
5754
|
]);
|
|
5755
|
+
const copy = () => {
|
|
5756
|
+
stdout.write(copySequence(forecast ? script : brief.text));
|
|
5757
|
+
go.notify(`Copied the ${forecast ? "forecast" : "brief"}, if this terminal lets apps copy. harbor brief prints it too.`);
|
|
5758
|
+
};
|
|
5513
5759
|
return /* @__PURE__ */ jsx(Letter, {
|
|
5514
|
-
title:
|
|
5515
|
-
text: brief.
|
|
5516
|
-
hints:
|
|
5760
|
+
title: `${forecast ? "Shipping forecast" : "Brief"} · ${brief.scope}`,
|
|
5761
|
+
text: forecast ? script : brief.shown,
|
|
5762
|
+
hints: `p portfolio · f ${forecast ? "brief" : "forecast"} · y copy`,
|
|
5517
5763
|
width,
|
|
5518
5764
|
height,
|
|
5519
5765
|
active,
|
|
5520
|
-
keys: {
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5766
|
+
keys: {
|
|
5767
|
+
p: () => go.pick("Brief for which portfolio?", [{
|
|
5768
|
+
label: "All portfolios",
|
|
5769
|
+
value: null
|
|
5770
|
+
}, ...portfolios.map((p) => ({
|
|
5771
|
+
label: p.name,
|
|
5772
|
+
value: p.id
|
|
5773
|
+
}))], (v) => setPortfolio(v ?? null)),
|
|
5774
|
+
f: () => setForecast((v) => !v),
|
|
5775
|
+
y: copy
|
|
5776
|
+
}
|
|
5527
5777
|
});
|
|
5528
5778
|
}
|
|
5529
5779
|
|
|
@@ -6487,6 +6737,26 @@ var init_load = __esmMin((() => {
|
|
|
6487
6737
|
};
|
|
6488
6738
|
}));
|
|
6489
6739
|
|
|
6740
|
+
//#endregion
|
|
6741
|
+
//#region src/lib/review.ts
|
|
6742
|
+
init_load();
|
|
6743
|
+
init_indexes();
|
|
6744
|
+
init_derived_health();
|
|
6745
|
+
/** A program is someone's when they lead it, lead one of its projects or hold a task in one. */
|
|
6746
|
+
function programTouches(program, data, personId) {
|
|
6747
|
+
if (program.ownerId === personId) return true;
|
|
6748
|
+
return data.projects.some((p) => p.programId === program.id && (p.ownerId === personId || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId)));
|
|
6749
|
+
}
|
|
6750
|
+
/**
|
|
6751
|
+
* The review's programs: worst health first, then soonest end, narrowed to
|
|
6752
|
+
* one person's and one portfolio's when asked. Done ones are left in; the
|
|
6753
|
+
* website, the terminal and the agents' tool each tuck them away themselves.
|
|
6754
|
+
*/
|
|
6755
|
+
function reviewPrograms(data, opts = {}) {
|
|
6756
|
+
const { ownerId, portfolioId } = opts;
|
|
6757
|
+
return data.programs.filter((p) => (!portfolioId || p.portfolioId === portfolioId) && (!ownerId || programTouches(p, data, ownerId))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate));
|
|
6758
|
+
}
|
|
6759
|
+
|
|
6490
6760
|
//#endregion
|
|
6491
6761
|
//#region src/lib/priority.ts
|
|
6492
6762
|
/** Rules with anything missing or out of range brought back to the defaults. */
|
|
@@ -6523,2007 +6793,2116 @@ var init_priority = __esmMin((() => {
|
|
|
6523
6793
|
}));
|
|
6524
6794
|
|
|
6525
6795
|
//#endregion
|
|
6526
|
-
//#region src/tui/views/
|
|
6527
|
-
init_indexes();
|
|
6528
|
-
init_load();
|
|
6529
|
-
init_raid();
|
|
6530
|
-
init_derived_health();
|
|
6796
|
+
//#region src/tui/views/heat.tsx
|
|
6531
6797
|
init_priority();
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6798
|
+
init_raid();
|
|
6799
|
+
const CELL = 7;
|
|
6800
|
+
const SIDE = 9;
|
|
6801
|
+
const SHORT = [
|
|
6802
|
+
"",
|
|
6803
|
+
"Rare",
|
|
6804
|
+
"Unlikely",
|
|
6805
|
+
"Possible",
|
|
6806
|
+
"Likely",
|
|
6807
|
+
"Almost"
|
|
6808
|
+
];
|
|
6809
|
+
/**
|
|
6810
|
+
* The website's 5×5 risk heat map in text: impact up the side, likelihood
|
|
6811
|
+
* along the bottom, how many live risks sit in each cell, coloured by how
|
|
6812
|
+
* bad that corner is. The cell narrowed to is shown reversed.
|
|
6813
|
+
*/
|
|
6814
|
+
function HeatMap({ items, selected }) {
|
|
6815
|
+
const grid = heatGrid(items);
|
|
6545
6816
|
return /* @__PURE__ */ jsxs(Box, {
|
|
6546
6817
|
flexDirection: "column",
|
|
6547
|
-
width,
|
|
6548
|
-
height,
|
|
6549
|
-
paddingX: 1,
|
|
6550
6818
|
children: [
|
|
6551
|
-
/* @__PURE__ */ jsx(
|
|
6552
|
-
|
|
6553
|
-
children:
|
|
6819
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6820
|
+
color: theme.muted,
|
|
6821
|
+
children: "Impact ↓ · likelihood →"
|
|
6554
6822
|
}),
|
|
6555
|
-
|
|
6823
|
+
grid.map((row) => /* @__PURE__ */ jsxs(Text, {
|
|
6824
|
+
wrap: "truncate-end",
|
|
6825
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
6826
|
+
color: theme.muted,
|
|
6827
|
+
children: IMPACT_LABEL[row[0].impact].padEnd(SIDE)
|
|
6828
|
+
}), row.map((cell) => {
|
|
6829
|
+
const on = selected?.probability === cell.probability && selected.impact === cell.impact;
|
|
6830
|
+
const n = cell.items.length;
|
|
6831
|
+
const text = (n ? String(n) : "·").padStart(Math.ceil(CELL / 2)).padEnd(CELL);
|
|
6832
|
+
return /* @__PURE__ */ jsx(Text, {
|
|
6833
|
+
inverse: on,
|
|
6834
|
+
bold: n > 0,
|
|
6835
|
+
color: n === 0 ? theme.muted : cell.band === "high" ? theme.bad : cell.band === "medium" ? theme.warn : theme.good,
|
|
6836
|
+
children: text
|
|
6837
|
+
}, cell.probability);
|
|
6838
|
+
})]
|
|
6839
|
+
}, row[0].impact)),
|
|
6840
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
6556
6841
|
color: theme.muted,
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
view.items.map((r, i) => {
|
|
6560
|
-
const on = active && view.start + i === at;
|
|
6561
|
-
return /* @__PURE__ */ jsxs(Text, {
|
|
6562
|
-
color: on ? theme.selected : r.color,
|
|
6563
|
-
bold: on || r.bold,
|
|
6564
|
-
wrap: "truncate-end",
|
|
6565
|
-
children: [
|
|
6566
|
-
on ? "› " : " ",
|
|
6567
|
-
" ".repeat(r.depth),
|
|
6568
|
-
r.health ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(HealthMark, { health: r.health }), " "] }) : null,
|
|
6569
|
-
r.title,
|
|
6570
|
-
r.detail ? /* @__PURE__ */ jsxs(Text, {
|
|
6571
|
-
color: theme.muted,
|
|
6572
|
-
children: [" ", r.detail]
|
|
6573
|
-
}) : null
|
|
6574
|
-
]
|
|
6575
|
-
}, r.id);
|
|
6842
|
+
wrap: "truncate-end",
|
|
6843
|
+
children: [" ".repeat(SIDE), SHORT.slice(1).map((l) => l.slice(0, 6).padEnd(CELL)).join("")]
|
|
6576
6844
|
})
|
|
6577
6845
|
]
|
|
6578
6846
|
});
|
|
6579
6847
|
}
|
|
6580
|
-
/**
|
|
6581
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
const
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
title: portfolio.name,
|
|
6603
|
-
bold: true,
|
|
6604
|
-
health: portfolio.health
|
|
6605
|
-
});
|
|
6606
|
-
for (const program of programs.filter((g) => g.portfolioId === portfolio.id)) {
|
|
6607
|
-
const { open, done } = splitDone(projects.filter((p) => p.programId === program.id));
|
|
6608
|
-
if (program.health === "done" && open.length === 0) {
|
|
6609
|
-
hidden += 1 + done.length;
|
|
6610
|
-
if (!showDone) continue;
|
|
6611
|
-
} else hidden += done.length;
|
|
6612
|
-
out.push({
|
|
6613
|
-
id: program.id,
|
|
6614
|
-
kind: "program",
|
|
6615
|
-
depth: 1,
|
|
6616
|
-
title: program.name,
|
|
6617
|
-
health: program.health
|
|
6618
|
-
});
|
|
6619
|
-
for (const project of showDone ? [...open, ...done] : open) {
|
|
6620
|
-
shown++;
|
|
6621
|
-
const { done: finished, total } = projectSummary(data, project);
|
|
6622
|
-
const late = tasksOf(tasks, project.id).filter((t) => isLate(t, today())).length;
|
|
6623
|
-
out.push({
|
|
6624
|
-
id: project.id,
|
|
6625
|
-
kind: "project",
|
|
6626
|
-
depth: 2,
|
|
6627
|
-
title: `${project.name}${project.key ? ` ${project.key}` : ""}`,
|
|
6628
|
-
health: project.health,
|
|
6629
|
-
detail: `${finished}/${total} done${late ? ` · ${late} late` : ""} · ends ${shortDay(project.endDate, today())}`
|
|
6630
|
-
});
|
|
6631
|
-
}
|
|
6632
|
-
}
|
|
6633
|
-
}
|
|
6634
|
-
return {
|
|
6635
|
-
rows: out,
|
|
6636
|
-
shown,
|
|
6637
|
-
hidden
|
|
6638
|
-
};
|
|
6639
|
-
}, [
|
|
6640
|
-
portfolios,
|
|
6641
|
-
programs,
|
|
6642
|
-
projects,
|
|
6643
|
-
tasks,
|
|
6644
|
-
today,
|
|
6645
|
-
showDone
|
|
6646
|
-
]);
|
|
6647
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
6648
|
-
title: "Portfolios, programs and projects",
|
|
6649
|
-
right: `${count(shown, "project")}${hidden ? ` · ${hidden} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new project · e edit`,
|
|
6650
|
-
rows,
|
|
6651
|
-
empty: "No projects yet. n adds one.",
|
|
6652
|
-
active,
|
|
6653
|
-
width,
|
|
6654
|
-
height,
|
|
6655
|
-
onOpen: (r) => r.kind === "project" ? go.openProject(r.id) : r.kind === "program" ? go.openProgram(r.id) : go.openPortfolio(r.id),
|
|
6656
|
-
keys: {
|
|
6657
|
-
n: (r) => {
|
|
6658
|
-
const programId = r?.kind === "program" ? r.id : r?.kind === "project" ? projects.find((p) => p.id === r.id)?.programId : void 0;
|
|
6659
|
-
go.create("project", programId ? { programId } : {});
|
|
6660
|
-
},
|
|
6661
|
-
e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id),
|
|
6662
|
-
D: () => hidden > 0 && setShowDone((v) => !v)
|
|
6663
|
-
}
|
|
6664
|
-
});
|
|
6848
|
+
/** "likely and major impact", as the website says what a cell narrows to. */
|
|
6849
|
+
function cellWords(cell) {
|
|
6850
|
+
return `${PROBABILITY_LABEL[cell.probability].toLowerCase()}, ${IMPACT_LABEL[cell.impact].toLowerCase()} impact`;
|
|
6851
|
+
}
|
|
6852
|
+
|
|
6853
|
+
//#endregion
|
|
6854
|
+
//#region src/lib/deps.ts
|
|
6855
|
+
init_blockers();
|
|
6856
|
+
/** True when making `id` wait on `candidate` would close a loop (or is `id` itself). */
|
|
6857
|
+
function wouldCycle(id, candidate, items) {
|
|
6858
|
+
const byId = new Map(items.map((n) => [n.id, n]));
|
|
6859
|
+
const stack = [candidate];
|
|
6860
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6861
|
+
while (stack.length) {
|
|
6862
|
+
const cursor = stack.pop();
|
|
6863
|
+
if (cursor === id) return true;
|
|
6864
|
+
if (seen.has(cursor)) continue;
|
|
6865
|
+
seen.add(cursor);
|
|
6866
|
+
const node = byId.get(cursor);
|
|
6867
|
+
if (node) stack.push(...blockerIds(node));
|
|
6868
|
+
}
|
|
6869
|
+
return false;
|
|
6665
6870
|
}
|
|
6666
6871
|
/**
|
|
6667
|
-
*
|
|
6668
|
-
*
|
|
6669
|
-
*
|
|
6670
|
-
* writes the next move; done programs wait behind D.
|
|
6872
|
+
* Everything that waits on `id`, directly or through others: the items that
|
|
6873
|
+
* would close a loop if `id` waited on them. One walk backwards from `id`,
|
|
6874
|
+
* instead of a walk forwards from every candidate.
|
|
6671
6875
|
*/
|
|
6672
|
-
function
|
|
6673
|
-
const
|
|
6674
|
-
const
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
const
|
|
6680
|
-
const
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
}), [projects, tasks]);
|
|
6689
|
-
const picked = useMemo(() => programs.filter((p) => (!portfolio || p.portfolioId === portfolio) && (!owner || programTouches(p, data, owner))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate)), [
|
|
6690
|
-
programs,
|
|
6691
|
-
portfolio,
|
|
6692
|
-
owner,
|
|
6693
|
-
data
|
|
6694
|
-
]);
|
|
6695
|
-
const split = useMemo(() => splitDone(picked), [picked]);
|
|
6696
|
-
const listed = useMemo(() => showDone ? [...split.open, ...split.done] : split.open, [split, showDone]);
|
|
6697
|
-
const rows = useMemo(() => {
|
|
6698
|
-
const now = /* @__PURE__ */ new Date(`${day}T12:00:00`);
|
|
6699
|
-
const rules = normalizeRules(priorityRules);
|
|
6700
|
-
const maxBudget = Math.max(1, ...programs.map((p) => p.budget));
|
|
6701
|
-
return listed.map((program) => {
|
|
6702
|
-
const late = programLateCount(program.id, data, now);
|
|
6703
|
-
const lead = people.find((p) => p.id === program.ownerId);
|
|
6704
|
-
return {
|
|
6705
|
-
id: program.id,
|
|
6706
|
-
kind: "program",
|
|
6707
|
-
depth: 0,
|
|
6708
|
-
title: program.name,
|
|
6709
|
-
health: program.health,
|
|
6710
|
-
detail: [
|
|
6711
|
-
lead?.name.split(" ")[0],
|
|
6712
|
-
program.endDate ? `ends ${shortDay(program.endDate, day)}` : null,
|
|
6713
|
-
late ? `${late} late` : null,
|
|
6714
|
-
isRated(program) ? quadrantOf(program, maxBudget, rules) : null,
|
|
6715
|
-
program.nextMove ? `next: ${program.nextMove}` : "no next move"
|
|
6716
|
-
].filter(Boolean).join(" · ")
|
|
6717
|
-
};
|
|
6718
|
-
});
|
|
6719
|
-
}, [
|
|
6720
|
-
listed,
|
|
6721
|
-
programs,
|
|
6722
|
-
people,
|
|
6723
|
-
data,
|
|
6724
|
-
day,
|
|
6725
|
-
priorityRules
|
|
6726
|
-
]);
|
|
6727
|
-
const done = split.done.length;
|
|
6728
|
-
const whose = owner === me ? "My programs" : owner ? `${people.find((p) => p.id === owner)?.name ?? "Someone"}'s programs` : "Programs";
|
|
6729
|
-
const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
|
|
6730
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
6731
|
-
title: `${whose}${where}`,
|
|
6732
|
-
right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · m next move · o p narrow`,
|
|
6733
|
-
rows,
|
|
6734
|
-
empty: done ? "Every program here is done. D shows them." : owner || portfolio ? "Nothing here. o and p change what's shown." : "No programs yet. n adds one.",
|
|
6735
|
-
active,
|
|
6736
|
-
width,
|
|
6737
|
-
height,
|
|
6738
|
-
onOpen: (r) => go.openProgram(r.id),
|
|
6739
|
-
keys: {
|
|
6740
|
-
n: (r) => {
|
|
6741
|
-
const portfolioId = r ? programs.find((p) => p.id === r.id)?.portfolioId : void 0;
|
|
6742
|
-
go.create("program", portfolioId ? { portfolioId } : {});
|
|
6743
|
-
},
|
|
6744
|
-
e: (r) => r && go.openPanel("program", r.id),
|
|
6745
|
-
m: (r) => {
|
|
6746
|
-
const program = r && programs.find((p) => p.id === r.id);
|
|
6747
|
-
if (!program) return;
|
|
6748
|
-
go.ask(`Next move for ${program.name}`, (text) => store.getState().setNextMove(program.id, text.trim()), program.nextMove ?? "");
|
|
6749
|
-
},
|
|
6750
|
-
o: () => go.pick("Whose programs?", [
|
|
6751
|
-
{
|
|
6752
|
-
label: "Everyone's",
|
|
6753
|
-
value: null
|
|
6754
|
-
},
|
|
6755
|
-
{
|
|
6756
|
-
label: "Mine",
|
|
6757
|
-
value: me
|
|
6758
|
-
},
|
|
6759
|
-
...people.filter((p) => p.id !== me).map((p) => ({
|
|
6760
|
-
label: p.name,
|
|
6761
|
-
value: p.id,
|
|
6762
|
-
hint: p.role
|
|
6763
|
-
}))
|
|
6764
|
-
], (v) => setOwner(v ?? null)),
|
|
6765
|
-
p: () => go.pick("In which portfolio?", [{
|
|
6766
|
-
label: "Every portfolio",
|
|
6767
|
-
value: null
|
|
6768
|
-
}, ...portfolios.map((p) => ({
|
|
6769
|
-
label: p.name,
|
|
6770
|
-
value: p.id
|
|
6771
|
-
}))], (v) => setPortfolio(v ?? null)),
|
|
6772
|
-
D: () => done > 0 && setShowDone((v) => !v)
|
|
6773
|
-
}
|
|
6774
|
-
});
|
|
6775
|
-
}
|
|
6776
|
-
/** A program is someone's when they lead it, lead one of its projects or hold a task in one. */
|
|
6777
|
-
function programTouches(program, data, personId) {
|
|
6778
|
-
if (program.ownerId === personId) return true;
|
|
6779
|
-
return data.projects.some((p) => p.programId === program.id && (p.ownerId === personId || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId)));
|
|
6780
|
-
}
|
|
6781
|
-
/**
|
|
6782
|
-
* The register, worst first: open items unless a shows closed ones too. As
|
|
6783
|
-
* on the website it narrows by kind (t), owner (o) and portfolio (p).
|
|
6784
|
-
*/
|
|
6785
|
-
function RisksView({ active, width, height, go }) {
|
|
6786
|
-
const raidItems = useHarbor((s) => s.raidItems);
|
|
6787
|
-
const projects = useHarbor((s) => s.projects);
|
|
6788
|
-
const programs = useHarbor((s) => s.programs);
|
|
6789
|
-
const portfolios = useHarbor((s) => s.portfolios);
|
|
6790
|
-
const people = useHarbor((s) => s.people);
|
|
6791
|
-
const [all, setAll] = useState(false);
|
|
6792
|
-
const [kind, setKind] = useState(null);
|
|
6793
|
-
const [owner, setOwner] = useState(null);
|
|
6794
|
-
const [portfolio, setPortfolio] = useState(null);
|
|
6795
|
-
const inPortfolio = useMemo(() => portfolio ? new Set(portfolioRaidItems({
|
|
6796
|
-
raidItems,
|
|
6797
|
-
projects,
|
|
6798
|
-
programs
|
|
6799
|
-
}, portfolio).map((r) => r.id)) : null, [
|
|
6800
|
-
portfolio,
|
|
6801
|
-
raidItems,
|
|
6802
|
-
projects,
|
|
6803
|
-
programs
|
|
6804
|
-
]);
|
|
6805
|
-
const rows = useMemo(() => sortRaid(raidItems.filter((r) => (all || isActive(r)) && (!kind || r.kind === kind) && (!owner || r.ownerId === owner) && (!inPortfolio || inPortfolio.has(r.id)))).map((r) => {
|
|
6806
|
-
const score = scoreOf(r);
|
|
6807
|
-
const band = score != null ? scoreBand(score) : null;
|
|
6808
|
-
return {
|
|
6809
|
-
id: r.id,
|
|
6810
|
-
kind: "raid",
|
|
6811
|
-
depth: 0,
|
|
6812
|
-
title: `${KIND_LABEL[r.kind]}: ${r.title}`,
|
|
6813
|
-
health: band === "high" ? "off-track" : band === "medium" ? "at-risk" : void 0,
|
|
6814
|
-
color: isActive(r) ? void 0 : theme.muted,
|
|
6815
|
-
detail: [
|
|
6816
|
-
STATUS_LABEL$1[r.status],
|
|
6817
|
-
score != null ? `score ${score}` : null,
|
|
6818
|
-
people.find((p) => p.id === r.ownerId)?.name,
|
|
6819
|
-
projects.find((p) => p.id === r.projectId)?.name
|
|
6820
|
-
].filter(Boolean).join(" · ")
|
|
6821
|
-
};
|
|
6822
|
-
}), [
|
|
6823
|
-
raidItems,
|
|
6824
|
-
projects,
|
|
6825
|
-
people,
|
|
6826
|
-
all,
|
|
6827
|
-
kind,
|
|
6828
|
-
owner,
|
|
6829
|
-
inPortfolio
|
|
6830
|
-
]);
|
|
6831
|
-
const what = kind ? `${KIND_LABEL[kind].toLowerCase()}s` : "risks, issues and decisions";
|
|
6832
|
-
const whose = owner ? `${people.find((p) => p.id === owner)?.name ?? "Someone"}'s ` : "";
|
|
6833
|
-
const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
|
|
6834
|
-
const phrase = `${whose}${all ? "" : "open "}${what}${where}`;
|
|
6835
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
6836
|
-
title: phrase.charAt(0).toUpperCase() + phrase.slice(1),
|
|
6837
|
-
right: `${rows.length} · t o p a narrow`,
|
|
6838
|
-
rows,
|
|
6839
|
-
empty: kind || owner || portfolio ? "Nothing matches. t, o and p change what's shown." : "Nothing open in the register. n logs one.",
|
|
6840
|
-
active,
|
|
6841
|
-
width,
|
|
6842
|
-
height,
|
|
6843
|
-
onOpen: (r) => go.openPanel("raid", r.id),
|
|
6844
|
-
keys: {
|
|
6845
|
-
n: () => go.create("raid", {}),
|
|
6846
|
-
a: () => setAll((v) => !v),
|
|
6847
|
-
t: () => setKind((k) => KINDS[(KINDS.indexOf(k) + 1) % KINDS.length] ?? null),
|
|
6848
|
-
o: () => go.pick("Whose?", [{
|
|
6849
|
-
label: "Anyone's",
|
|
6850
|
-
value: null
|
|
6851
|
-
}, ...people.map((p) => ({
|
|
6852
|
-
label: p.name,
|
|
6853
|
-
value: p.id,
|
|
6854
|
-
hint: p.role
|
|
6855
|
-
}))], (v) => setOwner(v ?? null)),
|
|
6856
|
-
p: () => go.pick("In which portfolio?", [{
|
|
6857
|
-
label: "Every portfolio",
|
|
6858
|
-
value: null
|
|
6859
|
-
}, ...portfolios.map((p) => ({
|
|
6860
|
-
label: p.name,
|
|
6861
|
-
value: p.id
|
|
6862
|
-
}))], (v) => setPortfolio(v ?? null))
|
|
6863
|
-
}
|
|
6864
|
-
});
|
|
6876
|
+
function waitingOnChain(items, id) {
|
|
6877
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
6878
|
+
for (const n of items) for (const b of blockerIds(n)) {
|
|
6879
|
+
const list = waiters.get(b);
|
|
6880
|
+
if (list) list.push(n.id);
|
|
6881
|
+
else waiters.set(b, [n.id]);
|
|
6882
|
+
}
|
|
6883
|
+
const out = /* @__PURE__ */ new Set();
|
|
6884
|
+
const stack = [...waiters.get(id) ?? []];
|
|
6885
|
+
while (stack.length) {
|
|
6886
|
+
const cursor = stack.pop();
|
|
6887
|
+
if (out.has(cursor)) continue;
|
|
6888
|
+
out.add(cursor);
|
|
6889
|
+
stack.push(...waiters.get(cursor) ?? []);
|
|
6890
|
+
}
|
|
6891
|
+
return out;
|
|
6865
6892
|
}
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
"decision"
|
|
6871
|
-
];
|
|
6872
|
-
/** Who's here: open and late work, and how full each of the next weeks is against their capacity. */
|
|
6873
|
-
function PeopleView({ active, width, height, go }) {
|
|
6874
|
-
const people = useHarbor((s) => s.people);
|
|
6875
|
-
const tasks = useHarbor((s) => s.tasks);
|
|
6876
|
-
const { today } = useDeps();
|
|
6877
|
-
const rows = useMemo(() => {
|
|
6878
|
-
const now = /* @__PURE__ */ new Date(`${today()}T12:00:00`);
|
|
6879
|
-
const nameWidth = Math.max(0, ...people.map((p) => p.name.length));
|
|
6880
|
-
return people.map((p) => {
|
|
6881
|
-
const open = tasks.filter((t) => t.assigneeId === p.id && t.status !== "done");
|
|
6882
|
-
const load = weeklyLoad(tasks, p.id, now, 4);
|
|
6883
|
-
const cap = p.weeklyCapacity ?? 5;
|
|
6884
|
-
const weeks = load.weeks.map((w) => (w.due === 0 ? "·" : w.due > cap ? `${w.due}!` : String(w.due)).padStart(3)).join("");
|
|
6885
|
-
const late = load.overdue ? `${load.overdue} late` : "";
|
|
6886
|
-
return {
|
|
6887
|
-
id: p.id,
|
|
6888
|
-
kind: "person",
|
|
6889
|
-
depth: 0,
|
|
6890
|
-
title: p.name.padEnd(nameWidth),
|
|
6891
|
-
detail: `${String(open.length).padStart(3)} open ${late.padEnd(7)} weeks${weeks} of ${cap} · ${p.role}`
|
|
6892
|
-
};
|
|
6893
|
-
});
|
|
6894
|
-
}, [
|
|
6895
|
-
people,
|
|
6896
|
-
tasks,
|
|
6897
|
-
today
|
|
6898
|
-
]);
|
|
6899
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
6900
|
-
title: "People",
|
|
6901
|
-
right: `${people.length === 1 ? "1 person" : `${people.length} people`} · Enter to change`,
|
|
6902
|
-
rows,
|
|
6903
|
-
empty: "Nobody here yet. Invite people from the website.",
|
|
6904
|
-
active,
|
|
6905
|
-
width,
|
|
6906
|
-
height,
|
|
6907
|
-
onOpen: (r) => go.openPanel("person", r.id)
|
|
6908
|
-
});
|
|
6893
|
+
/** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
|
|
6894
|
+
function blockerOptions(items, id) {
|
|
6895
|
+
const loops = waitingOnChain(items, id);
|
|
6896
|
+
return items.filter((n) => n.id !== id && !loops.has(n.id));
|
|
6909
6897
|
}
|
|
6910
6898
|
|
|
6911
6899
|
//#endregion
|
|
6912
|
-
//#region
|
|
6900
|
+
//#region src/lib/status-report.ts
|
|
6901
|
+
init_derived_health();
|
|
6902
|
+
init_format();
|
|
6903
|
+
init_raid();
|
|
6904
|
+
init_swatches();
|
|
6913
6905
|
/**
|
|
6914
|
-
*
|
|
6915
|
-
*
|
|
6916
|
-
*
|
|
6917
|
-
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
6918
|
-
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
6919
|
-
*
|
|
6920
|
-
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
6921
|
-
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
6922
|
-
*/
|
|
6923
|
-
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
6924
|
-
const LOGO = [
|
|
6925
|
-
"...TTTTTTTTTTTTTTTTTT...",
|
|
6926
|
-
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
6927
|
-
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
6928
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
6929
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
6930
|
-
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
6931
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
6932
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
6933
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
6934
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
6935
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
6936
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
6937
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
6938
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
6939
|
-
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
6940
|
-
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
6941
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
6942
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
6943
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
6944
|
-
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
6945
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
6946
|
-
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
6947
|
-
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
6948
|
-
"...TTTTTTTTTTTTTTTTTT..."
|
|
6949
|
-
];
|
|
6950
|
-
/** 16 by 16, for the keys overlay. */
|
|
6951
|
-
const LOGO_SMALL = [
|
|
6952
|
-
"..TTTTTTTTTTTT..",
|
|
6953
|
-
".TTTTTTTTTTTTTT.",
|
|
6954
|
-
"TTTTWWTTTTWWTTTT",
|
|
6955
|
-
"TTTTWWTTTTWWTTTT",
|
|
6956
|
-
"TPPTWWTTTTWWTTTT",
|
|
6957
|
-
"TCCTWWTTTTWWTTTT",
|
|
6958
|
-
"TCCTWWTTTTWWTTTT",
|
|
6959
|
-
"TTTTWWWWWWWWTTTT",
|
|
6960
|
-
"TTTTWWWWWWWWTTTT",
|
|
6961
|
-
"TTTTWWTTTTWWTPPT",
|
|
6962
|
-
"TTTTWWTPPTWWTCCT",
|
|
6963
|
-
"TTTTWWTCCTWWTCCT",
|
|
6964
|
-
"TTTTWWTCCTWWTTTT",
|
|
6965
|
-
"TTTTWWTTTTWWTTTT",
|
|
6966
|
-
".TTTTTTTTTTTTTT.",
|
|
6967
|
-
"..TTTTTTTTTTTT.."
|
|
6968
|
-
];
|
|
6969
|
-
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
6970
|
-
const LOGO_COLOURS = {
|
|
6971
|
-
T: "#0f6e64",
|
|
6972
|
-
W: "#ffffff",
|
|
6973
|
-
C: "#c44a2c"
|
|
6974
|
-
};
|
|
6975
|
-
/**
|
|
6976
|
-
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
6977
|
-
* One stretch of a terminal row in the same colours.
|
|
6978
|
-
*/
|
|
6979
|
-
/**
|
|
6980
|
-
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
6981
|
-
* half block's colour, the bottom one its background.
|
|
6982
|
-
* @param {string[]} pixels
|
|
6983
|
-
* @returns {Run[][]}
|
|
6906
|
+
* A program's status report as Markdown: what a lead pastes into a Friday
|
|
6907
|
+
* update or an exec thread. Everything in it is already on the program page;
|
|
6908
|
+
* this is the one-page version.
|
|
6984
6909
|
*/
|
|
6985
|
-
function
|
|
6986
|
-
|
|
6987
|
-
const
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
} :
|
|
7011
|
-
|
|
7012
|
-
fg: b
|
|
7013
|
-
} : {
|
|
7014
|
-
text: "▀",
|
|
7015
|
-
fg: t,
|
|
7016
|
-
bg: b
|
|
7017
|
-
};
|
|
7018
|
-
const last = runs.at(-1);
|
|
7019
|
-
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
7020
|
-
else runs.push(cell);
|
|
6910
|
+
function composeStatusReport(data, program, now = /* @__PURE__ */ new Date()) {
|
|
6911
|
+
const projects = projectsInProgram(data, program.id);
|
|
6912
|
+
const tasks = tasksInProgramOf(data, program.id);
|
|
6913
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
6914
|
+
const late = lateTasksOf(tasks, now);
|
|
6915
|
+
const milestones = data.milestones.filter((m) => m.programId === program.id);
|
|
6916
|
+
const lateMilestones = lateMilestonesOf(milestones, now);
|
|
6917
|
+
const upcoming = milestones.filter((m) => !m.done && !lateMilestones.includes(m)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, 3);
|
|
6918
|
+
const spent = programSpent(data, program.id);
|
|
6919
|
+
const allocated = programBudgetAllocated(data, program.id);
|
|
6920
|
+
const forecast = forecastRollup(projects, program.budget, now);
|
|
6921
|
+
const owner = data.people.find((p) => p.id === program.ownerId)?.name ?? "Unassigned";
|
|
6922
|
+
const live = sortRaid(programRaidItems(data, program.id).filter((r) => r.kind !== "decision" && isActive(r)));
|
|
6923
|
+
const lines = [];
|
|
6924
|
+
lines.push(`# ${program.name} — status ${shortDate(todayISO(now))}`);
|
|
6925
|
+
lines.push("");
|
|
6926
|
+
lines.push(`**Health:** ${HEALTH_LABEL$1[program.health]}${program.healthNote ? ` — ${program.healthNote}` : ""}`);
|
|
6927
|
+
lines.push(`**Owner:** ${owner} · **Dates:** ${shortDate(program.startDate)} – ${shortDate(program.endDate)}`);
|
|
6928
|
+
lines.push(`**Progress:** ${done} of ${tasks.length} tasks done${late.length ? `, ${late.length} late` : ""}`);
|
|
6929
|
+
if (program.nextMove) lines.push(`**Next move:** ${program.nextMove}`);
|
|
6930
|
+
lines.push("");
|
|
6931
|
+
lines.push("## Budget");
|
|
6932
|
+
if (program.budget > 0 || spent > 0) {
|
|
6933
|
+
lines.push(`- ${money(spent)} spent of ${money(program.budget)}${allocated > program.budget ? ` (projects allocate ${money(allocated)})` : ""}`);
|
|
6934
|
+
if (forecast) {
|
|
6935
|
+
const verdict = forecast.tone === "over" ? `over by ${money(-forecast.variance)}` : forecast.tone === "tight" ? "within 5% of budget" : `under by ${money(forecast.variance)}`;
|
|
6936
|
+
lines.push(`- Forecast at completion ${money(forecast.eac)}, ${verdict}`);
|
|
7021
6937
|
}
|
|
7022
|
-
|
|
6938
|
+
} else lines.push("- No budget set");
|
|
6939
|
+
lines.push("");
|
|
6940
|
+
lines.push("## Milestones");
|
|
6941
|
+
if (milestones.length === 0) lines.push("- None yet");
|
|
6942
|
+
for (const m of lateMilestones) lines.push(`- **Late:** ${m.name}, due ${shortDate(m.dueDate)}`);
|
|
6943
|
+
for (const m of upcoming) {
|
|
6944
|
+
const risk = milestoneRisks(m, data)[0];
|
|
6945
|
+
const note = !risk ? "" : risk.reason === "ends-after" ? ` — **at risk:** waits on ${risk.project.name}, which ends ${shortDate(risk.project.endDate)}` : ` — **at risk:** waits on ${risk.project.name}, which is off track`;
|
|
6946
|
+
lines.push(`- ${m.name}, due ${shortDate(m.dueDate)}${note}`);
|
|
7023
6947
|
}
|
|
7024
|
-
|
|
6948
|
+
const doneCount = milestones.filter((m) => m.done).length;
|
|
6949
|
+
if (doneCount) lines.push(`- ${doneCount} of ${milestones.length} reached`);
|
|
6950
|
+
lines.push("");
|
|
6951
|
+
lines.push("## Projects");
|
|
6952
|
+
if (projects.length === 0) lines.push("- None yet");
|
|
6953
|
+
for (const p of projects) {
|
|
6954
|
+
const own = tasks.filter((t) => t.projectId === p.id);
|
|
6955
|
+
const ownDone = own.filter((t) => t.status === "done").length;
|
|
6956
|
+
lines.push(`- ${p.name}: ${HEALTH_LABEL$1[p.health]}, ${ownDone}/${own.length} done, ${shortDate(p.startDate)} – ${shortDate(p.endDate)}`);
|
|
6957
|
+
}
|
|
6958
|
+
lines.push("");
|
|
6959
|
+
lines.push("## Risks and issues");
|
|
6960
|
+
if (live.length === 0) lines.push("- Nothing open");
|
|
6961
|
+
for (const r of live.slice(0, 8)) {
|
|
6962
|
+
const score = scoreOf(r);
|
|
6963
|
+
const band = score != null ? ` (${scoreBand(score)}, ${score})` : "";
|
|
6964
|
+
const who = data.people.find((p) => p.id === r.ownerId)?.name;
|
|
6965
|
+
lines.push(`- ${r.kind === "risk" ? "Risk" : "Issue"}: ${r.title}${band}${who ? ` — ${who}` : ""}`);
|
|
6966
|
+
}
|
|
6967
|
+
lines.push("");
|
|
6968
|
+
return lines.join("\n");
|
|
7025
6969
|
}
|
|
7026
6970
|
|
|
7027
6971
|
//#endregion
|
|
7028
|
-
//#region src/tui/
|
|
7029
|
-
/**
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
6972
|
+
//#region src/tui/views/panels.tsx
|
|
6973
|
+
/**
|
|
6974
|
+
* The panels that change one thing in place: a program, a portfolio, a
|
|
6975
|
+
* project's details, a milestone, a risk (issue, decision), a person. Each
|
|
6976
|
+
* is a DetailPanel of its fields; deleting asks first and u undoes it.
|
|
6977
|
+
*/
|
|
6978
|
+
init_blockers();
|
|
6979
|
+
init_baseline();
|
|
6980
|
+
init_derived_health();
|
|
6981
|
+
init_date_text();
|
|
6982
|
+
init_format();
|
|
6983
|
+
init_swatches();
|
|
6984
|
+
init_raid();
|
|
6985
|
+
/** The people of this harbor to pick from, with "Nobody" first when that's allowed. */
|
|
6986
|
+
function peopleChoices(people, allowNone) {
|
|
6987
|
+
const list = people.map((p) => ({
|
|
6988
|
+
label: p.name,
|
|
6989
|
+
value: p.id,
|
|
6990
|
+
hint: p.role
|
|
6991
|
+
}));
|
|
6992
|
+
return allowNone ? [{
|
|
6993
|
+
label: "Nobody yet",
|
|
6994
|
+
value: null
|
|
6995
|
+
}, ...list] : list;
|
|
7040
6996
|
}
|
|
7041
|
-
/**
|
|
7042
|
-
function
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
7052
|
-
children: [
|
|
7053
|
-
mark ? /* @__PURE__ */ jsx(Box, {
|
|
7054
|
-
marginBottom: 1,
|
|
7055
|
-
children: /* @__PURE__ */ jsx(Logo, {})
|
|
7056
|
-
}) : null,
|
|
7057
|
-
/* @__PURE__ */ jsx(Text, {
|
|
7058
|
-
bold: true,
|
|
7059
|
-
color: theme.accent,
|
|
7060
|
-
children: "Harbor"
|
|
7061
|
-
}),
|
|
7062
|
-
/* @__PURE__ */ jsx(Text, {
|
|
7063
|
-
color: theme.muted,
|
|
7064
|
-
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
7065
|
-
})
|
|
7066
|
-
]
|
|
7067
|
-
});
|
|
6997
|
+
/** The healths to pick from by hand, after "worked out", which clears an override. */
|
|
6998
|
+
function healthChoices() {
|
|
6999
|
+
return [{
|
|
7000
|
+
label: `Worked out from what's under it`,
|
|
7001
|
+
value: "derived"
|
|
7002
|
+
}, ...HEALTHS.map((h) => ({
|
|
7003
|
+
label: HEALTH_LABEL[h],
|
|
7004
|
+
value: h,
|
|
7005
|
+
color: HEALTH_COLOR[h]
|
|
7006
|
+
}))];
|
|
7068
7007
|
}
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
|
|
7078
|
-
|
|
7079
|
-
|
|
7080
|
-
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
if (key.downArrow) return setAt((i) => step(i, 1, hits.length));
|
|
7088
|
-
if (key.return) {
|
|
7089
|
-
const hit = hits[Math.min(at, hits.length - 1)];
|
|
7090
|
-
if (hit) onOpen(hit.item.id);
|
|
7091
|
-
return;
|
|
7092
|
-
}
|
|
7093
|
-
if (key.backspace || key.delete) {
|
|
7094
|
-
setAt(0);
|
|
7095
|
-
return setQuery((q) => q.slice(0, -1));
|
|
7096
|
-
}
|
|
7097
|
-
if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
|
|
7098
|
-
if (input) {
|
|
7099
|
-
setAt(0);
|
|
7100
|
-
setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
|
|
7008
|
+
/** A health picked from healthChoices: "worked out" clears it, any other asks why (the note shows with it). */
|
|
7009
|
+
function healthPicked(value, go, set, clear) {
|
|
7010
|
+
if (value === "derived") return clear();
|
|
7011
|
+
if (value === "done") return go.ask(`What shipped? (optional, shows with the health)`, (note) => set("done", note.trim()));
|
|
7012
|
+
go.ask(`Why is it ${HEALTH_LABEL[value].toLowerCase()}? (shows with the health)`, (note) => set(value, note.trim()));
|
|
7013
|
+
}
|
|
7014
|
+
/** Health with its override: picking one asks why (the note shows with it); "worked out" clears it. */
|
|
7015
|
+
function healthField(kind, item, go, set, clear, finished = false) {
|
|
7016
|
+
return {
|
|
7017
|
+
label: "Health",
|
|
7018
|
+
value: `${HEALTH_LABEL[item.health]}${overridesTrack(item) ? ` (set by hand: ${item.healthNote || "no note"})` : item.healthNote && item.healthOverride ? ` (${item.healthNote})` : ""}${finished ? " · every task done, mark it Done?" : ""}`,
|
|
7019
|
+
color: HEALTH_COLOR[item.health],
|
|
7020
|
+
edit: {
|
|
7021
|
+
kind: "pick",
|
|
7022
|
+
title: `Health of this ${kind}`,
|
|
7023
|
+
choices: healthChoices(),
|
|
7024
|
+
initial: item.healthOverride ? HEALTHS.indexOf(item.healthOverride) + 1 : 0,
|
|
7025
|
+
onPick: (value) => healthPicked(value, go, set, clear)
|
|
7101
7026
|
}
|
|
7102
|
-
}
|
|
7103
|
-
return /* @__PURE__ */ jsxs(Box, {
|
|
7104
|
-
flexDirection: "column",
|
|
7105
|
-
width,
|
|
7106
|
-
borderStyle: "round",
|
|
7107
|
-
borderColor: theme.border,
|
|
7108
|
-
paddingX: 1,
|
|
7109
|
-
children: [
|
|
7110
|
-
/* @__PURE__ */ jsxs(Text, { children: [
|
|
7111
|
-
/* @__PURE__ */ jsxs(Text, {
|
|
7112
|
-
color: theme.accent,
|
|
7113
|
-
bold: true,
|
|
7114
|
-
children: ["/", " "]
|
|
7115
|
-
}),
|
|
7116
|
-
query || /* @__PURE__ */ jsx(Text, {
|
|
7117
|
-
color: theme.muted,
|
|
7118
|
-
children: "a key like RR-42, or words from a title"
|
|
7119
|
-
}),
|
|
7120
|
-
/* @__PURE__ */ jsx(Text, {
|
|
7121
|
-
inverse: true,
|
|
7122
|
-
children: " "
|
|
7123
|
-
})
|
|
7124
|
-
] }),
|
|
7125
|
-
hits.length === 0 && query ? /* @__PURE__ */ jsx(Text, {
|
|
7126
|
-
color: theme.muted,
|
|
7127
|
-
children: "Nothing matches."
|
|
7128
|
-
}) : null,
|
|
7129
|
-
hits.map(({ item }, i) => {
|
|
7130
|
-
const on = i === Math.min(at, hits.length - 1);
|
|
7131
|
-
const project = projects.find((p) => p.id === item.projectId);
|
|
7132
|
-
return /* @__PURE__ */ jsxs(Text, {
|
|
7133
|
-
color: on ? theme.selected : void 0,
|
|
7134
|
-
bold: on,
|
|
7135
|
-
wrap: "truncate-end",
|
|
7136
|
-
children: [
|
|
7137
|
-
on ? "› " : " ",
|
|
7138
|
-
/* @__PURE__ */ jsx(Text, {
|
|
7139
|
-
color: theme.muted,
|
|
7140
|
-
children: keyOf$1({ projects }, item).padEnd(7)
|
|
7141
|
-
}),
|
|
7142
|
-
item.title,
|
|
7143
|
-
/* @__PURE__ */ jsxs(Text, {
|
|
7144
|
-
color: theme.muted,
|
|
7145
|
-
children: [
|
|
7146
|
-
" ",
|
|
7147
|
-
"· ",
|
|
7148
|
-
STATUS_LABEL[item.status],
|
|
7149
|
-
project ? ` · ${project.name}` : ""
|
|
7150
|
-
]
|
|
7151
|
-
})
|
|
7152
|
-
]
|
|
7153
|
-
}, item.id);
|
|
7154
|
-
}),
|
|
7155
|
-
/* @__PURE__ */ jsx(Text, {
|
|
7156
|
-
color: theme.muted,
|
|
7157
|
-
children: "↑↓ choose · Enter open · Esc close"
|
|
7158
|
-
})
|
|
7159
|
-
]
|
|
7160
|
-
});
|
|
7027
|
+
};
|
|
7161
7028
|
}
|
|
7162
|
-
/**
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
const
|
|
7168
|
-
const
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
placeholder: "What needs doing?",
|
|
7187
|
-
hint: `Enter adds it (yours, ${STATUS_LABEL[status ?? "todo"].toLowerCase()}) · Esc leaves it`,
|
|
7188
|
-
onCancel: () => onDone(null),
|
|
7189
|
-
onSubmit: (title) => {
|
|
7190
|
-
if (!title.trim()) return onDone(null);
|
|
7191
|
-
const full = roomFor(store, chosen.id, status ?? "todo");
|
|
7192
|
-
if (full) return onFull(full);
|
|
7193
|
-
onDone(store.getState().addTask({
|
|
7194
|
-
projectId: chosen.id,
|
|
7195
|
-
title: title.trim(),
|
|
7196
|
-
description: "",
|
|
7197
|
-
status: status ?? "todo",
|
|
7198
|
-
priority: "medium",
|
|
7199
|
-
assigneeId: me || null,
|
|
7200
|
-
dueDate: null
|
|
7201
|
-
}));
|
|
7029
|
+
/**
|
|
7030
|
+
* What a thing waits on, picked from `options`: Enter on one adds it or
|
|
7031
|
+
* takes it off, and one that already waits on this (a loop) is refused.
|
|
7032
|
+
*/
|
|
7033
|
+
function waitsField(label, self, options, go, save, loops = true) {
|
|
7034
|
+
const on = blockerIds(self);
|
|
7035
|
+
const names = options.filter((o) => on.includes(o.id)).map((o) => o.name);
|
|
7036
|
+
const choices = options.filter((o) => o.id !== self.id).map((o) => ({
|
|
7037
|
+
label: `${on.includes(o.id) ? "✓" : " "} ${o.name}`,
|
|
7038
|
+
value: o.id,
|
|
7039
|
+
hint: on.includes(o.id) ? "Enter stops waiting" : void 0
|
|
7040
|
+
}));
|
|
7041
|
+
return {
|
|
7042
|
+
label,
|
|
7043
|
+
value: names.length ? names.join(", ") : "Nothing",
|
|
7044
|
+
edit: choices.length ? {
|
|
7045
|
+
kind: "pick",
|
|
7046
|
+
title: `${label}: Enter adds or takes off one`,
|
|
7047
|
+
choices,
|
|
7048
|
+
onPick: (v) => {
|
|
7049
|
+
const picked = v;
|
|
7050
|
+
if (on.includes(picked)) return save(blockersPatch(on.filter((x) => x !== picked)));
|
|
7051
|
+
if (loops && wouldCycle(self.id, picked, options)) return go.notify("That one already waits on this, so it would go round in a loop.");
|
|
7052
|
+
save(blockersPatch([...on, picked]));
|
|
7202
7053
|
}
|
|
7203
|
-
}
|
|
7204
|
-
}
|
|
7054
|
+
} : void 0
|
|
7055
|
+
};
|
|
7205
7056
|
}
|
|
7206
|
-
/**
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
})
|
|
7221
|
-
|
|
7057
|
+
/** The lines HealthMoves takes. */
|
|
7058
|
+
const movesRows = (history, today) => {
|
|
7059
|
+
const n = healthMoves(history, today).length;
|
|
7060
|
+
return n ? n + 2 : 0;
|
|
7061
|
+
};
|
|
7062
|
+
/** The last few moves of a thing's health, under its fields. */
|
|
7063
|
+
function HealthMoves({ history, today }) {
|
|
7064
|
+
const moves = healthMoves(history, today);
|
|
7065
|
+
if (!moves.length) return null;
|
|
7066
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
7067
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
7068
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
7069
|
+
color: theme.muted,
|
|
7070
|
+
children: [" ", "Health history"]
|
|
7071
|
+
}),
|
|
7072
|
+
moves.map((m, i) => /* @__PURE__ */ jsxs(Text, {
|
|
7073
|
+
wrap: "truncate-end",
|
|
7074
|
+
children: [" ", m]
|
|
7075
|
+
}, i))
|
|
7076
|
+
] });
|
|
7222
7077
|
}
|
|
7223
|
-
const
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
/**
|
|
7243
|
-
* The mark sits beside the keys only when both fit: the longest line, a gap,
|
|
7244
|
-
* the 16-pixel mark, and the box's border and padding. Narrower, the lines
|
|
7245
|
-
* would wrap and split the mark.
|
|
7246
|
-
*/
|
|
7247
|
-
const HELP_WITH_MARK = 13 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
|
|
7248
|
-
function Help({ width, onClose }) {
|
|
7249
|
-
useInput((input, key) => {
|
|
7250
|
-
if (key.escape || key.return || input === "?" || input === "q") onClose();
|
|
7078
|
+
const CONSTRAINT_KINDS = Object.keys(CONSTRAINT_KIND_LABEL);
|
|
7079
|
+
function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
7080
|
+
const { store, today } = useDeps();
|
|
7081
|
+
const program = useHarbor((s) => s.programs.find((p) => p.id === id));
|
|
7082
|
+
const people = useHarbor((s) => s.people);
|
|
7083
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
7084
|
+
const programs = useHarbor((s) => s.programs);
|
|
7085
|
+
const projects = useHarbor((s) => s.projects);
|
|
7086
|
+
const constraint = useHarbor((s) => s.constraints.find((c) => c.programId === id && c.status === "active"));
|
|
7087
|
+
if (!program) return null;
|
|
7088
|
+
const s = store.getState();
|
|
7089
|
+
const inProgram = projects.filter((p) => p.programId === id);
|
|
7090
|
+
const baselined = inProgram.filter(hasBaseline);
|
|
7091
|
+
const holding = (patch) => store.getState().setConstraint(id, {
|
|
7092
|
+
title: constraint?.title ?? "",
|
|
7093
|
+
kind: constraint?.kind ?? "resource",
|
|
7094
|
+
ownerId: constraint?.ownerId ?? null,
|
|
7095
|
+
note: constraint?.note ?? "",
|
|
7096
|
+
...patch
|
|
7251
7097
|
});
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
|
|
7329
|
-
|
|
7330
|
-
|
|
7331
|
-
|
|
7332
|
-
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7098
|
+
const fields = [
|
|
7099
|
+
{
|
|
7100
|
+
label: "Name",
|
|
7101
|
+
value: program.name,
|
|
7102
|
+
edit: {
|
|
7103
|
+
kind: "text",
|
|
7104
|
+
initial: program.name,
|
|
7105
|
+
onSave: (v) => v.trim() && s.updateProgram(id, { name: v.trim() })
|
|
7106
|
+
}
|
|
7107
|
+
},
|
|
7108
|
+
healthField("program", program, go, (h, n) => store.getState().setHealthOverride("program", id, h, n), () => store.getState().clearHealthOverride("program", id)),
|
|
7109
|
+
{
|
|
7110
|
+
label: "Next move",
|
|
7111
|
+
value: program.nextMove ?? "",
|
|
7112
|
+
edit: {
|
|
7113
|
+
kind: "text",
|
|
7114
|
+
initial: program.nextMove ?? "",
|
|
7115
|
+
placeholder: "What happens next, in a line",
|
|
7116
|
+
onSave: (v) => store.getState().setNextMove(id, v.trim())
|
|
7117
|
+
}
|
|
7118
|
+
},
|
|
7119
|
+
{
|
|
7120
|
+
label: "Lead",
|
|
7121
|
+
value: people.find((p) => p.id === program.ownerId)?.name ?? "",
|
|
7122
|
+
edit: {
|
|
7123
|
+
kind: "pick",
|
|
7124
|
+
choices: peopleChoices(people, false),
|
|
7125
|
+
initial: people.findIndex((p) => p.id === program.ownerId),
|
|
7126
|
+
onPick: (v) => store.getState().updateProgram(id, { ownerId: v })
|
|
7127
|
+
}
|
|
7128
|
+
},
|
|
7129
|
+
{
|
|
7130
|
+
label: "Starts",
|
|
7131
|
+
value: shortDay(program.startDate, today()),
|
|
7132
|
+
edit: {
|
|
7133
|
+
kind: "date",
|
|
7134
|
+
initial: program.startDate,
|
|
7135
|
+
onSave: (v) => v && store.getState().updateProgram(id, { startDate: v })
|
|
7136
|
+
}
|
|
7137
|
+
},
|
|
7138
|
+
{
|
|
7139
|
+
label: "Ends",
|
|
7140
|
+
value: shortDay(program.endDate, today()),
|
|
7141
|
+
edit: {
|
|
7142
|
+
kind: "date",
|
|
7143
|
+
initial: program.endDate,
|
|
7144
|
+
onSave: (v) => v && store.getState().updateProgram(id, { endDate: v })
|
|
7145
|
+
}
|
|
7146
|
+
},
|
|
7147
|
+
{
|
|
7148
|
+
label: "Portfolio",
|
|
7149
|
+
value: portfolios.find((p) => p.id === program.portfolioId)?.name ?? "",
|
|
7150
|
+
edit: {
|
|
7151
|
+
kind: "pick",
|
|
7152
|
+
choices: portfolios.map((p) => ({
|
|
7153
|
+
label: p.name,
|
|
7154
|
+
value: p.id
|
|
7155
|
+
})),
|
|
7156
|
+
initial: portfolios.findIndex((p) => p.id === program.portfolioId),
|
|
7157
|
+
onPick: (v) => store.getState().updateProgram(id, { portfolioId: v })
|
|
7158
|
+
}
|
|
7159
|
+
},
|
|
7160
|
+
{
|
|
7161
|
+
label: "Budget",
|
|
7162
|
+
value: money(program.budget),
|
|
7163
|
+
edit: {
|
|
7164
|
+
kind: "number",
|
|
7165
|
+
initial: program.budget,
|
|
7166
|
+
min: 0,
|
|
7167
|
+
onSave: (v) => store.getState().updateProgram(id, { budget: v ?? 0 })
|
|
7168
|
+
}
|
|
7169
|
+
},
|
|
7170
|
+
{
|
|
7171
|
+
label: "Impact",
|
|
7172
|
+
value: `${program.impact ?? 5} of 10${program.impact == null ? " (not set)" : ""}`,
|
|
7173
|
+
edit: {
|
|
7174
|
+
kind: "number",
|
|
7175
|
+
initial: program.impact ?? 5,
|
|
7176
|
+
min: 1,
|
|
7177
|
+
max: 10,
|
|
7178
|
+
onSave: (v) => v != null && store.getState().updateProgram(id, { impact: Math.round(v) })
|
|
7179
|
+
}
|
|
7180
|
+
},
|
|
7181
|
+
{
|
|
7182
|
+
label: "Holding it back",
|
|
7183
|
+
value: constraint ? `${constraint.title} (${CONSTRAINT_KIND_LABEL[constraint.kind].toLowerCase()})` : "Nothing named",
|
|
7184
|
+
edit: {
|
|
7185
|
+
kind: "text",
|
|
7186
|
+
initial: constraint?.title ?? "",
|
|
7187
|
+
placeholder: "The one thing that sets the pace; empty when it's cleared",
|
|
7188
|
+
onSave: (v) => v.trim() ? holding({ title: v.trim() }) : constraint && store.getState().resolveConstraint(constraint.id)
|
|
7189
|
+
}
|
|
7190
|
+
},
|
|
7191
|
+
...constraint ? [{
|
|
7192
|
+
label: "What kind",
|
|
7193
|
+
value: CONSTRAINT_KIND_LABEL[constraint.kind],
|
|
7194
|
+
edit: {
|
|
7195
|
+
kind: "pick",
|
|
7196
|
+
choices: CONSTRAINT_KINDS.map((k) => ({
|
|
7197
|
+
label: CONSTRAINT_KIND_LABEL[k],
|
|
7198
|
+
value: k,
|
|
7199
|
+
hint: CONSTRAINT_KIND_HINT[k]
|
|
7200
|
+
})),
|
|
7201
|
+
initial: CONSTRAINT_KINDS.indexOf(constraint.kind),
|
|
7202
|
+
onPick: (v) => holding({ kind: v })
|
|
7203
|
+
}
|
|
7204
|
+
}, {
|
|
7205
|
+
label: "Who clears it",
|
|
7206
|
+
value: people.find((p) => p.id === constraint.ownerId)?.name ?? "Nobody yet",
|
|
7207
|
+
edit: {
|
|
7208
|
+
kind: "pick",
|
|
7209
|
+
choices: peopleChoices(people, true),
|
|
7210
|
+
initial: Math.max(0, people.findIndex((p) => p.id === constraint.ownerId) + 1),
|
|
7211
|
+
onPick: (v) => holding({ ownerId: v })
|
|
7212
|
+
}
|
|
7213
|
+
}] : [],
|
|
7214
|
+
waitsField("Waits on", program, programs, go, (patch) => store.getState().updateProgram(id, patch)),
|
|
7215
|
+
{
|
|
7216
|
+
label: "Baseline",
|
|
7217
|
+
value: !inProgram.length ? "No projects yet" : baselined.length === inProgram.length ? "Set on every project" : baselined.length ? `Set on ${baselined.length} of ${inProgram.length} projects` : "Not set",
|
|
7218
|
+
edit: inProgram.length ? {
|
|
7219
|
+
kind: "pick",
|
|
7220
|
+
title: "The plan to measure slips against",
|
|
7221
|
+
choices: [...baselined.length < inProgram.length ? [{
|
|
7222
|
+
label: "Set it where it's missing",
|
|
7223
|
+
value: "set",
|
|
7224
|
+
hint: "today's dates become the plan"
|
|
7225
|
+
}] : [], ...baselined.length ? [{
|
|
7226
|
+
label: "Reset every project to today's dates",
|
|
7227
|
+
value: "reset",
|
|
7228
|
+
hint: "slips start from zero"
|
|
7229
|
+
}] : []],
|
|
7230
|
+
onPick: (v) => store.getState().snapshotBaseline(id, v === "reset")
|
|
7231
|
+
} : void 0
|
|
7232
|
+
},
|
|
7233
|
+
{
|
|
7234
|
+
label: "About",
|
|
7235
|
+
value: program.description.replace(/\s+/g, " "),
|
|
7236
|
+
edit: {
|
|
7237
|
+
kind: "text",
|
|
7238
|
+
initial: program.description,
|
|
7239
|
+
onSave: (v) => store.getState().updateProgram(id, { description: v })
|
|
7240
|
+
}
|
|
7351
7241
|
}
|
|
7352
|
-
|
|
7353
|
-
return /* @__PURE__ */
|
|
7354
|
-
|
|
7242
|
+
];
|
|
7243
|
+
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
7244
|
+
title: /* @__PURE__ */ jsx(Text, {
|
|
7245
|
+
bold: true,
|
|
7246
|
+
children: program.name
|
|
7247
|
+
}),
|
|
7248
|
+
subtitle: "Program",
|
|
7249
|
+
fields,
|
|
7355
7250
|
width,
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
hits.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
7376
|
-
color: theme.muted,
|
|
7377
|
-
children: "Nothing matches."
|
|
7378
|
-
}) : null,
|
|
7379
|
-
hits.map((c, i) => {
|
|
7380
|
-
const on = i === Math.min(at, hits.length - 1);
|
|
7381
|
-
return /* @__PURE__ */ jsxs(Text, {
|
|
7382
|
-
color: on ? theme.selected : void 0,
|
|
7383
|
-
bold: on,
|
|
7384
|
-
wrap: "truncate-end",
|
|
7385
|
-
children: [
|
|
7386
|
-
on ? "› " : " ",
|
|
7387
|
-
c.label,
|
|
7388
|
-
c.hint ? /* @__PURE__ */ jsxs(Text, {
|
|
7389
|
-
color: theme.muted,
|
|
7390
|
-
children: [" · ", c.hint]
|
|
7391
|
-
}) : null
|
|
7392
|
-
]
|
|
7393
|
-
}, `${c.label}-${i}`);
|
|
7394
|
-
}),
|
|
7395
|
-
/* @__PURE__ */ jsx(Text, {
|
|
7396
|
-
color: theme.muted,
|
|
7397
|
-
children: "↑↓ choose · Enter go · Esc close"
|
|
7398
|
-
})
|
|
7399
|
-
]
|
|
7251
|
+
height,
|
|
7252
|
+
hints: "r status report · d delete",
|
|
7253
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
7254
|
+
history: program.healthHistory,
|
|
7255
|
+
today: today()
|
|
7256
|
+
}),
|
|
7257
|
+
belowRows: movesRows(program.healthHistory, today()),
|
|
7258
|
+
keys: {
|
|
7259
|
+
r: () => go.read(`Status report · ${program.name}`, markdownToPlain(composeStatusReport(store.getState(), program))),
|
|
7260
|
+
d: () => {
|
|
7261
|
+
const what = deleteSummary(store.getState(), "program", id);
|
|
7262
|
+
go.confirm(`Delete the program “${program.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
7263
|
+
store.getState().deleteEntity("program", id);
|
|
7264
|
+
onClose();
|
|
7265
|
+
}, "Delete");
|
|
7266
|
+
}
|
|
7267
|
+
},
|
|
7268
|
+
active,
|
|
7269
|
+
onClose
|
|
7400
7270
|
});
|
|
7401
7271
|
}
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
const
|
|
7409
|
-
const
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
}
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
lines.push(`**Owner:** ${owner} · **Dates:** ${shortDate(program.startDate)} – ${shortDate(program.endDate)}`);
|
|
7478
|
-
lines.push(`**Progress:** ${done} of ${tasks.length} tasks done${late.length ? `, ${late.length} late` : ""}`);
|
|
7479
|
-
if (program.nextMove) lines.push(`**Next move:** ${program.nextMove}`);
|
|
7480
|
-
lines.push("");
|
|
7481
|
-
lines.push("## Budget");
|
|
7482
|
-
if (program.budget > 0 || spent > 0) {
|
|
7483
|
-
lines.push(`- ${money(spent)} spent of ${money(program.budget)}${allocated > program.budget ? ` (projects allocate ${money(allocated)})` : ""}`);
|
|
7484
|
-
if (forecast) {
|
|
7485
|
-
const verdict = forecast.tone === "over" ? `over by ${money(-forecast.variance)}` : forecast.tone === "tight" ? "within 5% of budget" : `under by ${money(forecast.variance)}`;
|
|
7486
|
-
lines.push(`- Forecast at completion ${money(forecast.eac)}, ${verdict}`);
|
|
7272
|
+
function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
7273
|
+
const { store, today } = useDeps();
|
|
7274
|
+
const stored = useHarbor((s) => s.priorityRules);
|
|
7275
|
+
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
|
|
7276
|
+
const people = useHarbor((s) => s.people);
|
|
7277
|
+
if (!portfolio) return null;
|
|
7278
|
+
const rules = normalizeRules(stored);
|
|
7279
|
+
const fields = [
|
|
7280
|
+
{
|
|
7281
|
+
label: "Name",
|
|
7282
|
+
value: portfolio.name,
|
|
7283
|
+
edit: {
|
|
7284
|
+
kind: "text",
|
|
7285
|
+
initial: portfolio.name,
|
|
7286
|
+
onSave: (v) => v.trim() && store.getState().updatePortfolio(id, { name: v.trim() })
|
|
7287
|
+
}
|
|
7288
|
+
},
|
|
7289
|
+
healthField("portfolio", portfolio, go, (h, n) => store.getState().setHealthOverride("portfolio", id, h, n), () => store.getState().clearHealthOverride("portfolio", id)),
|
|
7290
|
+
{
|
|
7291
|
+
label: "Owner",
|
|
7292
|
+
value: people.find((p) => p.id === portfolio.ownerId)?.name ?? "",
|
|
7293
|
+
edit: {
|
|
7294
|
+
kind: "pick",
|
|
7295
|
+
choices: peopleChoices(people, false),
|
|
7296
|
+
initial: people.findIndex((p) => p.id === portfolio.ownerId),
|
|
7297
|
+
onPick: (v) => store.getState().updatePortfolio(id, { ownerId: v })
|
|
7298
|
+
}
|
|
7299
|
+
},
|
|
7300
|
+
{
|
|
7301
|
+
label: "Theme",
|
|
7302
|
+
value: portfolio.theme ?? "",
|
|
7303
|
+
edit: {
|
|
7304
|
+
kind: "text",
|
|
7305
|
+
initial: portfolio.theme ?? "",
|
|
7306
|
+
placeholder: "What this portfolio is for, in a line",
|
|
7307
|
+
onSave: (v) => store.getState().updatePortfolio(id, { theme: v.trim() || null })
|
|
7308
|
+
}
|
|
7309
|
+
},
|
|
7310
|
+
{
|
|
7311
|
+
label: "Budget",
|
|
7312
|
+
value: money(portfolio.budget),
|
|
7313
|
+
edit: {
|
|
7314
|
+
kind: "number",
|
|
7315
|
+
initial: portfolio.budget,
|
|
7316
|
+
min: 0,
|
|
7317
|
+
onSave: (v) => store.getState().updatePortfolio(id, { budget: v ?? 0 })
|
|
7318
|
+
}
|
|
7319
|
+
},
|
|
7320
|
+
{
|
|
7321
|
+
label: "High impact from",
|
|
7322
|
+
value: `${rules.impactThreshold} of 10${stored ? "" : " (the usual)"}`,
|
|
7323
|
+
edit: {
|
|
7324
|
+
kind: "number",
|
|
7325
|
+
initial: rules.impactThreshold,
|
|
7326
|
+
min: 1,
|
|
7327
|
+
max: 10,
|
|
7328
|
+
onSave: (v) => store.getState().setPriorityRules({
|
|
7329
|
+
...rules,
|
|
7330
|
+
impactThreshold: v ?? DEFAULT_RULES.impactThreshold
|
|
7331
|
+
})
|
|
7332
|
+
}
|
|
7333
|
+
},
|
|
7334
|
+
{
|
|
7335
|
+
label: "High budget from",
|
|
7336
|
+
value: `${Math.round(rules.budgetShare * 100)}% of the largest program's${stored ? "" : " (the usual)"}`,
|
|
7337
|
+
edit: {
|
|
7338
|
+
kind: "number",
|
|
7339
|
+
initial: Math.round(rules.budgetShare * 100),
|
|
7340
|
+
min: 5,
|
|
7341
|
+
max: 100,
|
|
7342
|
+
onSave: (v) => store.getState().setPriorityRules({
|
|
7343
|
+
...rules,
|
|
7344
|
+
budgetShare: (v ?? DEFAULT_RULES.budgetShare * 100) / 100
|
|
7345
|
+
})
|
|
7346
|
+
}
|
|
7487
7347
|
}
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
const who = data.people.find((p) => p.id === r.ownerId)?.name;
|
|
7515
|
-
lines.push(`- ${r.kind === "risk" ? "Risk" : "Issue"}: ${r.title}${band}${who ? ` — ${who}` : ""}`);
|
|
7516
|
-
}
|
|
7517
|
-
lines.push("");
|
|
7518
|
-
return lines.join("\n");
|
|
7519
|
-
}
|
|
7520
|
-
|
|
7521
|
-
//#endregion
|
|
7522
|
-
//#region src/tui/views/panels.tsx
|
|
7523
|
-
/**
|
|
7524
|
-
* The panels that change one thing in place: a program, a portfolio, a
|
|
7525
|
-
* project's details, a milestone, a risk (issue, decision), a person. Each
|
|
7526
|
-
* is a DetailPanel of its fields; deleting asks first and u undoes it.
|
|
7527
|
-
*/
|
|
7528
|
-
init_blockers();
|
|
7529
|
-
init_baseline();
|
|
7530
|
-
init_derived_health();
|
|
7531
|
-
init_date_text();
|
|
7532
|
-
init_format();
|
|
7533
|
-
init_priority();
|
|
7534
|
-
init_swatches();
|
|
7535
|
-
init_raid();
|
|
7536
|
-
/** The people of this harbor to pick from, with "Nobody" first when that's allowed. */
|
|
7537
|
-
function peopleChoices(people, allowNone) {
|
|
7538
|
-
const list = people.map((p) => ({
|
|
7539
|
-
label: p.name,
|
|
7540
|
-
value: p.id,
|
|
7541
|
-
hint: p.role
|
|
7542
|
-
}));
|
|
7543
|
-
return allowNone ? [{
|
|
7544
|
-
label: "Nobody yet",
|
|
7545
|
-
value: null
|
|
7546
|
-
}, ...list] : list;
|
|
7348
|
+
];
|
|
7349
|
+
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
7350
|
+
title: /* @__PURE__ */ jsx(Text, {
|
|
7351
|
+
bold: true,
|
|
7352
|
+
children: portfolio.name
|
|
7353
|
+
}),
|
|
7354
|
+
subtitle: "Portfolio",
|
|
7355
|
+
fields,
|
|
7356
|
+
width,
|
|
7357
|
+
height,
|
|
7358
|
+
hints: "d delete",
|
|
7359
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
7360
|
+
history: portfolio.healthHistory,
|
|
7361
|
+
today: today()
|
|
7362
|
+
}),
|
|
7363
|
+
belowRows: movesRows(portfolio.healthHistory, today()),
|
|
7364
|
+
keys: { d: () => {
|
|
7365
|
+
const what = deleteSummary(store.getState(), "portfolio", id);
|
|
7366
|
+
go.confirm(`Delete the portfolio “${portfolio.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
7367
|
+
store.getState().deleteEntity("portfolio", id);
|
|
7368
|
+
onClose();
|
|
7369
|
+
}, "Delete");
|
|
7370
|
+
} },
|
|
7371
|
+
active,
|
|
7372
|
+
onClose
|
|
7373
|
+
});
|
|
7547
7374
|
}
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
const
|
|
7551
|
-
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
7558
|
-
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
|
|
7562
|
-
|
|
7563
|
-
|
|
7564
|
-
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
|
|
7569
|
-
|
|
7570
|
-
|
|
7375
|
+
function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
7376
|
+
const { store, today } = useDeps();
|
|
7377
|
+
const project = useHarbor((s) => s.projects.find((p) => p.id === id));
|
|
7378
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
7379
|
+
const people = useHarbor((s) => s.people);
|
|
7380
|
+
const programs = useHarbor((s) => s.programs);
|
|
7381
|
+
const projects = useHarbor((s) => s.projects);
|
|
7382
|
+
if (!project) return null;
|
|
7383
|
+
const up = (patch) => store.getState().updateProject(id, patch);
|
|
7384
|
+
const planned = plannedByMonth(project);
|
|
7385
|
+
const months = monthsBetween(project.startDate, project.endDate);
|
|
7386
|
+
const monthName = (key) => format(parseIso(`${key}-01`), "MMM yyyy");
|
|
7387
|
+
const waits = blockerIds(project);
|
|
7388
|
+
const slip = slipLabel(project);
|
|
7389
|
+
const fields = [
|
|
7390
|
+
{
|
|
7391
|
+
label: "Name",
|
|
7392
|
+
value: project.name,
|
|
7393
|
+
edit: {
|
|
7394
|
+
kind: "text",
|
|
7395
|
+
initial: project.name,
|
|
7396
|
+
onSave: (v) => v.trim() && up({ name: v.trim() })
|
|
7397
|
+
}
|
|
7398
|
+
},
|
|
7399
|
+
healthField("project", project, go, (h, n) => store.getState().setHealthOverride("project", id, h, n), () => store.getState().clearHealthOverride("project", id), suggestsDone(project, tasks)),
|
|
7400
|
+
{
|
|
7401
|
+
label: "Lead",
|
|
7402
|
+
value: people.find((p) => p.id === project.ownerId)?.name ?? "",
|
|
7403
|
+
edit: {
|
|
7404
|
+
kind: "pick",
|
|
7405
|
+
choices: peopleChoices(people, false),
|
|
7406
|
+
initial: people.findIndex((p) => p.id === project.ownerId),
|
|
7407
|
+
onPick: (v) => up({ ownerId: v })
|
|
7408
|
+
}
|
|
7409
|
+
},
|
|
7410
|
+
{
|
|
7411
|
+
label: "Starts",
|
|
7412
|
+
value: shortDay(project.startDate, today()),
|
|
7413
|
+
edit: {
|
|
7414
|
+
kind: "date",
|
|
7415
|
+
initial: project.startDate,
|
|
7416
|
+
onSave: (v) => v && up({ startDate: v })
|
|
7417
|
+
}
|
|
7418
|
+
},
|
|
7419
|
+
{
|
|
7420
|
+
label: "Ends",
|
|
7421
|
+
value: shortDay(project.endDate, today()),
|
|
7422
|
+
edit: {
|
|
7423
|
+
kind: "date",
|
|
7424
|
+
initial: project.endDate,
|
|
7425
|
+
onSave: (v) => v && up({ endDate: v })
|
|
7426
|
+
}
|
|
7427
|
+
},
|
|
7428
|
+
{
|
|
7429
|
+
label: "Budget",
|
|
7430
|
+
value: money(project.budget),
|
|
7431
|
+
edit: {
|
|
7432
|
+
kind: "number",
|
|
7433
|
+
initial: project.budget,
|
|
7434
|
+
min: 0,
|
|
7435
|
+
onSave: (v) => up({ budget: v ?? 0 })
|
|
7436
|
+
}
|
|
7437
|
+
},
|
|
7438
|
+
{
|
|
7439
|
+
label: "Spent",
|
|
7440
|
+
value: money(project.spent),
|
|
7441
|
+
edit: {
|
|
7442
|
+
kind: "number",
|
|
7443
|
+
initial: project.spent,
|
|
7444
|
+
min: 0,
|
|
7445
|
+
onSave: (v) => store.getState().setSpentTotal(id, v ?? 0)
|
|
7446
|
+
}
|
|
7447
|
+
},
|
|
7448
|
+
{
|
|
7449
|
+
label: "Program",
|
|
7450
|
+
value: programs.find((p) => p.id === project.programId)?.name ?? "",
|
|
7451
|
+
edit: {
|
|
7452
|
+
kind: "pick",
|
|
7453
|
+
choices: programs.map((p) => ({
|
|
7454
|
+
label: p.name,
|
|
7455
|
+
value: p.id
|
|
7456
|
+
})),
|
|
7457
|
+
initial: programs.findIndex((p) => p.id === project.programId),
|
|
7458
|
+
onPick: (v) => store.getState().moveProjects([id], v)
|
|
7459
|
+
}
|
|
7460
|
+
},
|
|
7461
|
+
{
|
|
7462
|
+
label: "Plan by month",
|
|
7463
|
+
value: hasOwnPhasing(project) ? "Set by hand" : "Spread evenly",
|
|
7464
|
+
edit: months.length ? {
|
|
7465
|
+
kind: "pick",
|
|
7466
|
+
title: "Pick a month to change what it plans to spend",
|
|
7467
|
+
choices: [...months.map((k) => ({
|
|
7468
|
+
label: `${monthName(k)} ${money(planned[k] ?? 0)}`,
|
|
7469
|
+
value: k
|
|
7470
|
+
})), ...hasOwnPhasing(project) ? [{
|
|
7471
|
+
label: "Back to an even spread",
|
|
7472
|
+
value: null
|
|
7473
|
+
}] : []],
|
|
7474
|
+
onPick: (v) => v == null ? up({ phasing: null }) : go.ask(`Planned for ${monthName(v)}`, (text) => {
|
|
7475
|
+
const n = Number(text.replace(/[$,\s]/g, ""));
|
|
7476
|
+
if (!Number.isFinite(n) || n < 0) return go.notify(`“${text}” isn't an amount.`);
|
|
7477
|
+
up({ phasing: withMonth(project, v, n) });
|
|
7478
|
+
}, String(planned[v] ?? 0))
|
|
7479
|
+
} : void 0
|
|
7480
|
+
},
|
|
7481
|
+
waitsField("Waits on", project, projects, go, (patch) => up({
|
|
7482
|
+
...patch,
|
|
7483
|
+
lagDays: patch.blockedByIds.length ? project.lagDays : null
|
|
7484
|
+
})),
|
|
7485
|
+
...waits.length ? [{
|
|
7486
|
+
label: "Lag days",
|
|
7487
|
+
value: project.lagDays ? `${project.lagDays} after they finish` : "None",
|
|
7488
|
+
edit: {
|
|
7489
|
+
kind: "number",
|
|
7490
|
+
initial: project.lagDays ?? null,
|
|
7491
|
+
min: 0,
|
|
7492
|
+
onSave: (v) => up({ lagDays: v ? Math.round(v) : null })
|
|
7571
7493
|
}
|
|
7572
|
-
}
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
7583
|
-
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
value: names.length ? names.join(", ") : "Nothing",
|
|
7590
|
-
edit: choices.length ? {
|
|
7591
|
-
kind: "pick",
|
|
7592
|
-
title: `${label}: Enter adds or takes off one`,
|
|
7593
|
-
choices,
|
|
7594
|
-
onPick: (v) => {
|
|
7595
|
-
const picked = v;
|
|
7596
|
-
if (on.includes(picked)) return save(blockersPatch(on.filter((x) => x !== picked)));
|
|
7597
|
-
if (loops && wouldCycle(self.id, picked, options)) return go.notify("That one already waits on this, so it would go round in a loop.");
|
|
7598
|
-
save(blockersPatch([...on, picked]));
|
|
7494
|
+
}] : [],
|
|
7495
|
+
{
|
|
7496
|
+
label: "Baseline",
|
|
7497
|
+
value: hasBaseline(project) ? `${shortDay(project.baselineStart, today())} to ${shortDay(project.baselineEnd, today())}${slip ? `, ${slip}` : ""}` : "Not set (set it on the program)",
|
|
7498
|
+
color: slip?.startsWith("slipped") ? theme.bad : void 0
|
|
7499
|
+
},
|
|
7500
|
+
{
|
|
7501
|
+
label: "Key",
|
|
7502
|
+
value: project.key ?? ""
|
|
7503
|
+
},
|
|
7504
|
+
{
|
|
7505
|
+
label: "About",
|
|
7506
|
+
value: project.description.replace(/\s+/g, " "),
|
|
7507
|
+
edit: {
|
|
7508
|
+
kind: "text",
|
|
7509
|
+
initial: project.description,
|
|
7510
|
+
onSave: (v) => up({ description: v })
|
|
7599
7511
|
}
|
|
7600
|
-
}
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
return n ? n + 2 : 0;
|
|
7607
|
-
};
|
|
7608
|
-
/** The last few moves of a thing's health, under its fields. */
|
|
7609
|
-
function HealthMoves({ history, today }) {
|
|
7610
|
-
const moves = healthMoves(history, today);
|
|
7611
|
-
if (!moves.length) return null;
|
|
7612
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
7613
|
-
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
7614
|
-
/* @__PURE__ */ jsxs(Text, {
|
|
7615
|
-
color: theme.muted,
|
|
7616
|
-
children: [" ", "Health history"]
|
|
7512
|
+
}
|
|
7513
|
+
];
|
|
7514
|
+
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
7515
|
+
title: /* @__PURE__ */ jsx(Text, {
|
|
7516
|
+
bold: true,
|
|
7517
|
+
children: project.name
|
|
7617
7518
|
}),
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7519
|
+
subtitle: "Project details",
|
|
7520
|
+
fields,
|
|
7521
|
+
width,
|
|
7522
|
+
height,
|
|
7523
|
+
hints: "d delete",
|
|
7524
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
7525
|
+
history: project.healthHistory,
|
|
7526
|
+
today: today()
|
|
7527
|
+
}),
|
|
7528
|
+
belowRows: movesRows(project.healthHistory, today()),
|
|
7529
|
+
keys: { d: () => {
|
|
7530
|
+
const what = deleteSummary(store.getState(), "project", id);
|
|
7531
|
+
go.confirm(`Delete the project “${project.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
7532
|
+
store.getState().deleteEntity("project", id);
|
|
7533
|
+
onClose();
|
|
7534
|
+
}, "Delete");
|
|
7535
|
+
} },
|
|
7536
|
+
active,
|
|
7537
|
+
onClose
|
|
7538
|
+
});
|
|
7623
7539
|
}
|
|
7624
|
-
const
|
|
7625
|
-
|
|
7540
|
+
const SIGN_OFF = {
|
|
7541
|
+
pending: "Waiting for sign-off",
|
|
7542
|
+
approved: "Approved",
|
|
7543
|
+
changes_requested: "Changes requested"
|
|
7544
|
+
};
|
|
7545
|
+
function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
7626
7546
|
const { store, today } = useDeps();
|
|
7627
|
-
const
|
|
7547
|
+
const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
|
|
7628
7548
|
const people = useHarbor((s) => s.people);
|
|
7629
|
-
const portfolios = useHarbor((s) => s.portfolios);
|
|
7630
|
-
const programs = useHarbor((s) => s.programs);
|
|
7631
7549
|
const projects = useHarbor((s) => s.projects);
|
|
7632
|
-
const
|
|
7633
|
-
if (!
|
|
7634
|
-
const
|
|
7635
|
-
const
|
|
7636
|
-
const
|
|
7637
|
-
const
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
note: constraint?.note ?? "",
|
|
7642
|
-
...patch
|
|
7643
|
-
});
|
|
7550
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
7551
|
+
if (!milestone) return null;
|
|
7552
|
+
const up = (patch) => store.getState().updateMilestone(id, patch);
|
|
7553
|
+
const late = !milestone.done && milestone.dueDate < today();
|
|
7554
|
+
const gated = Boolean(milestone.approverId);
|
|
7555
|
+
const approver = people.find((p) => p.id === milestone.approverId);
|
|
7556
|
+
const status = milestone.approvalStatus ?? "pending";
|
|
7557
|
+
const inProgram = projects.filter((p) => p.programId === milestone.programId);
|
|
7558
|
+
const risks = milestoneRisks(milestone, store.getState());
|
|
7644
7559
|
const fields = [
|
|
7645
7560
|
{
|
|
7646
7561
|
label: "Name",
|
|
7647
|
-
value:
|
|
7562
|
+
value: milestone.name,
|
|
7563
|
+
edit: {
|
|
7564
|
+
kind: "text",
|
|
7565
|
+
initial: milestone.name,
|
|
7566
|
+
onSave: (v) => v.trim() && up({ name: v.trim() })
|
|
7567
|
+
}
|
|
7568
|
+
},
|
|
7569
|
+
{
|
|
7570
|
+
label: "Due",
|
|
7571
|
+
value: `${shortDay(milestone.dueDate, today())}${late ? " (late)" : ""}`,
|
|
7572
|
+
color: late ? theme.bad : void 0,
|
|
7573
|
+
edit: {
|
|
7574
|
+
kind: "date",
|
|
7575
|
+
initial: milestone.dueDate,
|
|
7576
|
+
onSave: (v) => v && up({ dueDate: v })
|
|
7577
|
+
}
|
|
7578
|
+
},
|
|
7579
|
+
{
|
|
7580
|
+
label: "Done",
|
|
7581
|
+
value: milestone.done ? "Yes" : gated ? "Not yet (the sign-off ticks it)" : "Not yet",
|
|
7582
|
+
edit: gated ? void 0 : {
|
|
7583
|
+
kind: "pick",
|
|
7584
|
+
choices: [{
|
|
7585
|
+
label: "Not yet",
|
|
7586
|
+
value: false
|
|
7587
|
+
}, {
|
|
7588
|
+
label: "Yes",
|
|
7589
|
+
value: true
|
|
7590
|
+
}],
|
|
7591
|
+
initial: milestone.done ? 1 : 0,
|
|
7592
|
+
onPick: (v) => v !== milestone.done && store.getState().toggleMilestone(id)
|
|
7593
|
+
}
|
|
7594
|
+
},
|
|
7595
|
+
{
|
|
7596
|
+
label: "Signs off",
|
|
7597
|
+
value: approver?.name ?? "Nobody (no sign-off)",
|
|
7598
|
+
edit: {
|
|
7599
|
+
kind: "pick",
|
|
7600
|
+
title: "Who signs it off? Nobody else can tick it done.",
|
|
7601
|
+
choices: peopleChoices(people, true),
|
|
7602
|
+
initial: Math.max(0, people.findIndex((p) => p.id === milestone.approverId) + 1),
|
|
7603
|
+
onPick: (v) => v ? v !== milestone.approverId && up({
|
|
7604
|
+
approverId: v,
|
|
7605
|
+
approvalStatus: "pending",
|
|
7606
|
+
done: false
|
|
7607
|
+
}) : up({
|
|
7608
|
+
approverId: null,
|
|
7609
|
+
approvalStatus: null,
|
|
7610
|
+
approvalNote: null
|
|
7611
|
+
})
|
|
7612
|
+
}
|
|
7613
|
+
},
|
|
7614
|
+
...gated ? [{
|
|
7615
|
+
label: "Sign-off",
|
|
7616
|
+
value: `${SIGN_OFF[status]}${status === "changes_requested" && milestone.approvalNote ? `: ${milestone.approvalNote}` : ""}`,
|
|
7617
|
+
color: status === "approved" ? theme.good : status === "pending" ? void 0 : theme.bad,
|
|
7618
|
+
edit: milestone.approverId === me && status !== "approved" ? {
|
|
7619
|
+
kind: "pick",
|
|
7620
|
+
title: "Your sign-off",
|
|
7621
|
+
choices: [{
|
|
7622
|
+
label: "Approve",
|
|
7623
|
+
value: "approve",
|
|
7624
|
+
hint: "ticks it done"
|
|
7625
|
+
}, {
|
|
7626
|
+
label: "Request changes",
|
|
7627
|
+
value: "changes"
|
|
7628
|
+
}],
|
|
7629
|
+
onPick: (v) => v === "approve" ? up({
|
|
7630
|
+
approvalStatus: "approved",
|
|
7631
|
+
done: true
|
|
7632
|
+
}) : go.ask("What needs changing?", (note) => up({
|
|
7633
|
+
approvalStatus: "changes_requested",
|
|
7634
|
+
approvalNote: note.trim() || null,
|
|
7635
|
+
done: false
|
|
7636
|
+
}))
|
|
7637
|
+
} : void 0
|
|
7638
|
+
}] : [],
|
|
7639
|
+
waitsField("Waits on", milestone, inProgram, go, (patch) => up(patch), false)
|
|
7640
|
+
];
|
|
7641
|
+
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
7642
|
+
title: /* @__PURE__ */ jsx(Text, {
|
|
7643
|
+
bold: true,
|
|
7644
|
+
children: milestone.name
|
|
7645
|
+
}),
|
|
7646
|
+
subtitle: "Milestone",
|
|
7647
|
+
fields,
|
|
7648
|
+
width,
|
|
7649
|
+
height,
|
|
7650
|
+
hints: gated ? "d delete" : "space done · d delete",
|
|
7651
|
+
keys: {
|
|
7652
|
+
" ": () => gated ? go.notify(`${approver?.name ?? "The approver"} ticks this off by signing it off.`) : store.getState().toggleMilestone(id),
|
|
7653
|
+
d: () => go.confirm(`Delete the milestone “${milestone.name}”? u undoes it.`, () => {
|
|
7654
|
+
store.getState().deleteMilestone(id);
|
|
7655
|
+
onClose();
|
|
7656
|
+
}, "Delete")
|
|
7657
|
+
},
|
|
7658
|
+
belowRows: risks.length ? risks.length + 1 : 0,
|
|
7659
|
+
below: risks.length ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: " " }), risks.map((r) => /* @__PURE__ */ jsxs(Text, {
|
|
7660
|
+
color: theme.bad,
|
|
7661
|
+
wrap: "truncate-end",
|
|
7662
|
+
children: [
|
|
7663
|
+
" ",
|
|
7664
|
+
"At risk:",
|
|
7665
|
+
" ",
|
|
7666
|
+
r.reason === "ends-after" ? `${r.project.name} ends ${shortDay(r.project.endDate, today())}` : `${r.project.name} is off track`
|
|
7667
|
+
]
|
|
7668
|
+
}, r.project.id))] }) : null,
|
|
7669
|
+
active,
|
|
7670
|
+
onClose
|
|
7671
|
+
});
|
|
7672
|
+
}
|
|
7673
|
+
function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
7674
|
+
const { store, today } = useDeps();
|
|
7675
|
+
const item = useHarbor((s) => s.raidItems.find((r) => r.id === id));
|
|
7676
|
+
const people = useHarbor((s) => s.people);
|
|
7677
|
+
const projects = useHarbor((s) => s.projects);
|
|
7678
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
7679
|
+
if (!item) return null;
|
|
7680
|
+
const up = (patch) => store.getState().updateRaidItem(id, patch);
|
|
7681
|
+
const scale = (labels) => [
|
|
7682
|
+
1,
|
|
7683
|
+
2,
|
|
7684
|
+
3,
|
|
7685
|
+
4,
|
|
7686
|
+
5
|
|
7687
|
+
].map((n) => ({
|
|
7688
|
+
label: `${n} ${labels[n]}`,
|
|
7689
|
+
value: n
|
|
7690
|
+
}));
|
|
7691
|
+
const score = scoreOf(item);
|
|
7692
|
+
const copy = FIELD_COPY[item.kind];
|
|
7693
|
+
const person = (pid) => people.find((p) => p.id === pid)?.name;
|
|
7694
|
+
const text = (key) => {
|
|
7695
|
+
const label = copy[key];
|
|
7696
|
+
if (!label) return [];
|
|
7697
|
+
const value = item[key] ?? "";
|
|
7698
|
+
return [{
|
|
7699
|
+
label,
|
|
7700
|
+
value: value.replace(/\s+/g, " "),
|
|
7648
7701
|
edit: {
|
|
7649
7702
|
kind: "text",
|
|
7650
|
-
initial:
|
|
7651
|
-
onSave: (v) =>
|
|
7703
|
+
initial: value,
|
|
7704
|
+
onSave: (v) => up({ [key]: v })
|
|
7652
7705
|
}
|
|
7653
|
-
}
|
|
7654
|
-
|
|
7706
|
+
}];
|
|
7707
|
+
};
|
|
7708
|
+
const fields = [
|
|
7655
7709
|
{
|
|
7656
|
-
label: "
|
|
7657
|
-
value:
|
|
7710
|
+
label: "Title",
|
|
7711
|
+
value: item.title,
|
|
7658
7712
|
edit: {
|
|
7659
7713
|
kind: "text",
|
|
7660
|
-
initial:
|
|
7661
|
-
|
|
7662
|
-
onSave: (v) => store.getState().setNextMove(id, v.trim())
|
|
7714
|
+
initial: item.title,
|
|
7715
|
+
onSave: (v) => v.trim() && up({ title: v.trim() })
|
|
7663
7716
|
}
|
|
7664
7717
|
},
|
|
7665
7718
|
{
|
|
7666
|
-
label: "
|
|
7667
|
-
value:
|
|
7719
|
+
label: "Kind",
|
|
7720
|
+
value: KIND_LABEL[item.kind],
|
|
7668
7721
|
edit: {
|
|
7669
7722
|
kind: "pick",
|
|
7670
|
-
choices:
|
|
7671
|
-
|
|
7672
|
-
|
|
7723
|
+
choices: [
|
|
7724
|
+
"risk",
|
|
7725
|
+
"issue",
|
|
7726
|
+
"decision"
|
|
7727
|
+
].map((k) => ({
|
|
7728
|
+
label: KIND_LABEL[k],
|
|
7729
|
+
value: k
|
|
7730
|
+
})),
|
|
7731
|
+
initial: [
|
|
7732
|
+
"risk",
|
|
7733
|
+
"issue",
|
|
7734
|
+
"decision"
|
|
7735
|
+
].indexOf(item.kind),
|
|
7736
|
+
onPick: (v) => up({ kind: v })
|
|
7673
7737
|
}
|
|
7674
7738
|
},
|
|
7675
7739
|
{
|
|
7676
|
-
label: "
|
|
7677
|
-
value:
|
|
7740
|
+
label: "Status",
|
|
7741
|
+
value: STATUS_LABEL$1[item.status],
|
|
7678
7742
|
edit: {
|
|
7679
|
-
kind: "
|
|
7680
|
-
|
|
7681
|
-
|
|
7743
|
+
kind: "pick",
|
|
7744
|
+
choices: STATUSES[item.kind].map((s) => ({
|
|
7745
|
+
label: STATUS_LABEL$1[s],
|
|
7746
|
+
value: s
|
|
7747
|
+
})),
|
|
7748
|
+
initial: STATUSES[item.kind].indexOf(item.status),
|
|
7749
|
+
onPick: (v) => up(v === "decided" ? {
|
|
7750
|
+
status: v,
|
|
7751
|
+
decidedAt: item.decidedAt ?? today(),
|
|
7752
|
+
decidedById: item.decidedById ?? me
|
|
7753
|
+
} : { status: v })
|
|
7682
7754
|
}
|
|
7683
7755
|
},
|
|
7684
7756
|
{
|
|
7685
|
-
label: "
|
|
7686
|
-
value:
|
|
7757
|
+
label: "Owner",
|
|
7758
|
+
value: person(item.ownerId) ?? "Nobody yet",
|
|
7687
7759
|
edit: {
|
|
7688
|
-
kind: "
|
|
7689
|
-
|
|
7690
|
-
|
|
7760
|
+
kind: "pick",
|
|
7761
|
+
choices: peopleChoices(people, true),
|
|
7762
|
+
initial: Math.max(0, people.findIndex((p) => p.id === item.ownerId) + 1),
|
|
7763
|
+
onPick: (v) => up({ ownerId: v })
|
|
7691
7764
|
}
|
|
7692
7765
|
},
|
|
7693
7766
|
{
|
|
7694
|
-
label: "
|
|
7695
|
-
value:
|
|
7767
|
+
label: "Project",
|
|
7768
|
+
value: projects.find((p) => p.id === item.projectId)?.name ?? "None in particular",
|
|
7696
7769
|
edit: {
|
|
7697
7770
|
kind: "pick",
|
|
7698
|
-
choices:
|
|
7771
|
+
choices: [{
|
|
7772
|
+
label: "None in particular",
|
|
7773
|
+
value: null
|
|
7774
|
+
}, ...projects.map((p) => ({
|
|
7699
7775
|
label: p.name,
|
|
7700
7776
|
value: p.id
|
|
7701
|
-
})),
|
|
7702
|
-
initial:
|
|
7703
|
-
onPick: (v) =>
|
|
7777
|
+
}))],
|
|
7778
|
+
initial: Math.max(0, projects.findIndex((p) => p.id === item.projectId) + 1),
|
|
7779
|
+
onPick: (v) => up({
|
|
7780
|
+
projectId: v,
|
|
7781
|
+
programId: projects.find((p) => p.id === v)?.programId ?? null
|
|
7782
|
+
})
|
|
7704
7783
|
}
|
|
7705
7784
|
},
|
|
7706
|
-
{
|
|
7707
|
-
label: "
|
|
7708
|
-
value:
|
|
7785
|
+
...item.kind === "risk" ? [{
|
|
7786
|
+
label: "Likelihood",
|
|
7787
|
+
value: item.probability ? `${item.probability} ${PROBABILITY_LABEL[item.probability]}` : "",
|
|
7709
7788
|
edit: {
|
|
7710
|
-
kind: "
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7789
|
+
kind: "pick",
|
|
7790
|
+
choices: scale(PROBABILITY_LABEL),
|
|
7791
|
+
initial: (item.probability ?? 3) - 1,
|
|
7792
|
+
onPick: (v) => up({ probability: v })
|
|
7714
7793
|
}
|
|
7715
|
-
},
|
|
7716
|
-
{
|
|
7794
|
+
}] : [],
|
|
7795
|
+
...item.kind !== "decision" ? [{
|
|
7717
7796
|
label: "Impact",
|
|
7718
|
-
value: `${
|
|
7719
|
-
edit: {
|
|
7720
|
-
kind: "number",
|
|
7721
|
-
initial: program.impact ?? 5,
|
|
7722
|
-
min: 1,
|
|
7723
|
-
max: 10,
|
|
7724
|
-
onSave: (v) => v != null && store.getState().updateProgram(id, { impact: Math.round(v) })
|
|
7725
|
-
}
|
|
7726
|
-
},
|
|
7727
|
-
{
|
|
7728
|
-
label: "Holding it back",
|
|
7729
|
-
value: constraint ? `${constraint.title} (${CONSTRAINT_KIND_LABEL[constraint.kind].toLowerCase()})` : "Nothing named",
|
|
7797
|
+
value: item.impact ? `${item.impact} ${IMPACT_LABEL[item.impact]}` : "",
|
|
7730
7798
|
edit: {
|
|
7731
|
-
kind: "
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7799
|
+
kind: "pick",
|
|
7800
|
+
choices: scale(IMPACT_LABEL),
|
|
7801
|
+
initial: (item.impact ?? 3) - 1,
|
|
7802
|
+
onPick: (v) => up({ impact: v })
|
|
7735
7803
|
}
|
|
7736
|
-
},
|
|
7737
|
-
...
|
|
7738
|
-
label: "
|
|
7739
|
-
value:
|
|
7804
|
+
}] : [],
|
|
7805
|
+
...item.kind === "risk" ? [{
|
|
7806
|
+
label: "Score",
|
|
7807
|
+
value: score != null ? String(score) : ""
|
|
7808
|
+
}] : [],
|
|
7809
|
+
...item.kind === "decision" ? [{
|
|
7810
|
+
label: "Decided by",
|
|
7811
|
+
value: person(item.decidedById) ?? "Not yet",
|
|
7740
7812
|
edit: {
|
|
7741
7813
|
kind: "pick",
|
|
7742
|
-
choices:
|
|
7743
|
-
label:
|
|
7744
|
-
value:
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7814
|
+
choices: [{
|
|
7815
|
+
label: "Not yet",
|
|
7816
|
+
value: null
|
|
7817
|
+
}, ...people.map((p) => ({
|
|
7818
|
+
label: p.name,
|
|
7819
|
+
value: p.id
|
|
7820
|
+
}))],
|
|
7821
|
+
initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
|
|
7822
|
+
onPick: (v) => up({ decidedById: v })
|
|
7749
7823
|
}
|
|
7750
7824
|
}, {
|
|
7751
|
-
label: "
|
|
7752
|
-
value:
|
|
7825
|
+
label: "Decided on",
|
|
7826
|
+
value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
|
|
7753
7827
|
edit: {
|
|
7754
|
-
kind: "
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
onPick: (v) => holding({ ownerId: v })
|
|
7828
|
+
kind: "date",
|
|
7829
|
+
initial: item.decidedAt ?? null,
|
|
7830
|
+
onSave: (v) => up({ decidedAt: v })
|
|
7758
7831
|
}
|
|
7759
7832
|
}] : [],
|
|
7760
|
-
waitsField("Waits on", program, programs, go, (patch) => store.getState().updateProgram(id, patch)),
|
|
7761
7833
|
{
|
|
7762
|
-
label:
|
|
7763
|
-
value:
|
|
7764
|
-
edit:
|
|
7765
|
-
kind: "
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
value: "set",
|
|
7770
|
-
hint: "today's dates become the plan"
|
|
7771
|
-
}] : [], ...baselined.length ? [{
|
|
7772
|
-
label: "Reset every project to today's dates",
|
|
7773
|
-
value: "reset",
|
|
7774
|
-
hint: "slips start from zero"
|
|
7775
|
-
}] : []],
|
|
7776
|
-
onPick: (v) => store.getState().snapshotBaseline(id, v === "reset")
|
|
7777
|
-
} : void 0
|
|
7834
|
+
label: copy.dueDate,
|
|
7835
|
+
value: shortDay(item.dueDate, today()),
|
|
7836
|
+
edit: {
|
|
7837
|
+
kind: "date",
|
|
7838
|
+
initial: item.dueDate,
|
|
7839
|
+
onSave: (v) => up({ dueDate: v })
|
|
7840
|
+
}
|
|
7778
7841
|
},
|
|
7842
|
+
...text("description"),
|
|
7843
|
+
...text("mitigation"),
|
|
7844
|
+
...text("contingency"),
|
|
7845
|
+
...text("resolution"),
|
|
7846
|
+
...text("rationale"),
|
|
7779
7847
|
{
|
|
7780
|
-
label: "
|
|
7781
|
-
value:
|
|
7848
|
+
label: "Notes",
|
|
7849
|
+
value: (item.notes ?? "").replace(/\s+/g, " "),
|
|
7782
7850
|
edit: {
|
|
7783
7851
|
kind: "text",
|
|
7784
|
-
initial:
|
|
7785
|
-
onSave: (v) =>
|
|
7852
|
+
initial: item.notes ?? "",
|
|
7853
|
+
onSave: (v) => up({ notes: v })
|
|
7786
7854
|
}
|
|
7787
7855
|
}
|
|
7788
7856
|
];
|
|
7857
|
+
const updates = item.updates.slice(-5);
|
|
7858
|
+
const earlier = item.updates.length - updates.length;
|
|
7789
7859
|
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
7790
|
-
title: /* @__PURE__ */
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7860
|
+
title: /* @__PURE__ */ jsxs(Text, { children: [
|
|
7861
|
+
/* @__PURE__ */ jsx(Text, {
|
|
7862
|
+
color: theme.coral,
|
|
7863
|
+
children: KIND_LABEL[item.kind]
|
|
7864
|
+
}),
|
|
7865
|
+
" ",
|
|
7866
|
+
/* @__PURE__ */ jsx(Text, {
|
|
7867
|
+
bold: true,
|
|
7868
|
+
children: item.title
|
|
7869
|
+
})
|
|
7870
|
+
] }),
|
|
7795
7871
|
fields,
|
|
7796
7872
|
width,
|
|
7797
7873
|
height,
|
|
7798
|
-
hints: "
|
|
7799
|
-
below: /* @__PURE__ */
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
}),
|
|
7803
|
-
|
|
7874
|
+
hints: "c add an update · d delete",
|
|
7875
|
+
below: updates.length ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Text, {
|
|
7876
|
+
color: theme.muted,
|
|
7877
|
+
children: ["Updates", earlier ? ` · the last ${updates.length} of ${item.updates.length}` : ""]
|
|
7878
|
+
}), updates.map((u) => /* @__PURE__ */ jsxs(Text, {
|
|
7879
|
+
wrap: "truncate-end",
|
|
7880
|
+
children: [
|
|
7881
|
+
/* @__PURE__ */ jsx(Text, {
|
|
7882
|
+
bold: true,
|
|
7883
|
+
children: person(u.authorId) ?? "Someone"
|
|
7884
|
+
}),
|
|
7885
|
+
" ",
|
|
7886
|
+
/* @__PURE__ */ jsx(Text, {
|
|
7887
|
+
color: theme.muted,
|
|
7888
|
+
children: shortDay(u.createdAt.slice(0, 10), today())
|
|
7889
|
+
}),
|
|
7890
|
+
" ",
|
|
7891
|
+
u.body.replace(/\s+/g, " ")
|
|
7892
|
+
]
|
|
7893
|
+
}, u.id))] }) : void 0,
|
|
7804
7894
|
keys: {
|
|
7805
|
-
|
|
7806
|
-
d: () => {
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
onClose();
|
|
7811
|
-
}, "Delete");
|
|
7812
|
-
}
|
|
7895
|
+
c: () => go.ask("Update (shows in its history)", (text) => text.trim() && store.getState().addRaidUpdate(id, text.trim())),
|
|
7896
|
+
d: () => go.confirm(`Delete “${item.title}” from the register? u undoes it.`, () => {
|
|
7897
|
+
store.getState().deleteRaidItem(id);
|
|
7898
|
+
onClose();
|
|
7899
|
+
}, "Delete")
|
|
7813
7900
|
},
|
|
7814
7901
|
active,
|
|
7815
7902
|
onClose
|
|
7816
7903
|
});
|
|
7817
7904
|
}
|
|
7818
|
-
function
|
|
7819
|
-
const { store
|
|
7820
|
-
const
|
|
7821
|
-
const
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
const
|
|
7905
|
+
function PersonPanel({ id, width, height, go, active, onClose }) {
|
|
7906
|
+
const { store } = useDeps();
|
|
7907
|
+
const person = useHarbor((s) => s.people.find((p) => p.id === id));
|
|
7908
|
+
const programs = useHarbor((s) => s.programs);
|
|
7909
|
+
if (!person) return null;
|
|
7910
|
+
const up = (patch) => store.getState().updatePerson(id, patch);
|
|
7911
|
+
const allocations = person.allocations ?? [];
|
|
7825
7912
|
const fields = [
|
|
7826
7913
|
{
|
|
7827
7914
|
label: "Name",
|
|
7828
|
-
value:
|
|
7915
|
+
value: person.name,
|
|
7829
7916
|
edit: {
|
|
7830
7917
|
kind: "text",
|
|
7831
|
-
initial:
|
|
7832
|
-
onSave: (v) => v.trim() &&
|
|
7833
|
-
}
|
|
7834
|
-
},
|
|
7835
|
-
healthField("portfolio", portfolio, go, (h, n) => store.getState().setHealthOverride("portfolio", id, h, n), () => store.getState().clearHealthOverride("portfolio", id)),
|
|
7836
|
-
{
|
|
7837
|
-
label: "Owner",
|
|
7838
|
-
value: people.find((p) => p.id === portfolio.ownerId)?.name ?? "",
|
|
7839
|
-
edit: {
|
|
7840
|
-
kind: "pick",
|
|
7841
|
-
choices: peopleChoices(people, false),
|
|
7842
|
-
initial: people.findIndex((p) => p.id === portfolio.ownerId),
|
|
7843
|
-
onPick: (v) => store.getState().updatePortfolio(id, { ownerId: v })
|
|
7918
|
+
initial: person.name,
|
|
7919
|
+
onSave: (v) => v.trim() && up({ name: v.trim() })
|
|
7844
7920
|
}
|
|
7845
7921
|
},
|
|
7846
7922
|
{
|
|
7847
|
-
label: "
|
|
7848
|
-
value:
|
|
7923
|
+
label: "Role",
|
|
7924
|
+
value: person.role,
|
|
7849
7925
|
edit: {
|
|
7850
7926
|
kind: "text",
|
|
7851
|
-
initial:
|
|
7852
|
-
|
|
7853
|
-
onSave: (v) => store.getState().updatePortfolio(id, { theme: v.trim() || null })
|
|
7927
|
+
initial: person.role,
|
|
7928
|
+
onSave: (v) => up({ role: v.trim() })
|
|
7854
7929
|
}
|
|
7855
7930
|
},
|
|
7856
7931
|
{
|
|
7857
|
-
label: "
|
|
7858
|
-
value:
|
|
7932
|
+
label: "Tasks a week",
|
|
7933
|
+
value: person.weeklyCapacity != null ? String(person.weeklyCapacity) : "5 (the usual)",
|
|
7859
7934
|
edit: {
|
|
7860
7935
|
kind: "number",
|
|
7861
|
-
initial:
|
|
7936
|
+
initial: person.weeklyCapacity ?? null,
|
|
7862
7937
|
min: 0,
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
},
|
|
7866
|
-
{
|
|
7867
|
-
label: "High impact from",
|
|
7868
|
-
value: `${rules.impactThreshold} of 10${stored ? "" : " (the usual)"}`,
|
|
7869
|
-
edit: {
|
|
7870
|
-
kind: "number",
|
|
7871
|
-
initial: rules.impactThreshold,
|
|
7872
|
-
min: 1,
|
|
7873
|
-
max: 10,
|
|
7874
|
-
onSave: (v) => store.getState().setPriorityRules({
|
|
7875
|
-
...rules,
|
|
7876
|
-
impactThreshold: v ?? DEFAULT_RULES.impactThreshold
|
|
7877
|
-
})
|
|
7938
|
+
max: 100,
|
|
7939
|
+
onSave: (v) => up({ weeklyCapacity: v })
|
|
7878
7940
|
}
|
|
7879
7941
|
},
|
|
7880
7942
|
{
|
|
7881
|
-
label: "
|
|
7882
|
-
value:
|
|
7943
|
+
label: "Location",
|
|
7944
|
+
value: person.location,
|
|
7883
7945
|
edit: {
|
|
7884
|
-
kind: "
|
|
7885
|
-
initial:
|
|
7886
|
-
|
|
7887
|
-
max: 100,
|
|
7888
|
-
onSave: (v) => store.getState().setPriorityRules({
|
|
7889
|
-
...rules,
|
|
7890
|
-
budgetShare: (v ?? DEFAULT_RULES.budgetShare * 100) / 100
|
|
7891
|
-
})
|
|
7946
|
+
kind: "text",
|
|
7947
|
+
initial: person.location,
|
|
7948
|
+
onSave: (v) => up({ location: v.trim() })
|
|
7892
7949
|
}
|
|
7893
7950
|
}
|
|
7894
7951
|
];
|
|
7895
7952
|
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
7896
7953
|
title: /* @__PURE__ */ jsx(Text, {
|
|
7897
7954
|
bold: true,
|
|
7898
|
-
children:
|
|
7955
|
+
children: person.name
|
|
7899
7956
|
}),
|
|
7900
|
-
subtitle: "
|
|
7957
|
+
subtitle: person.accountId ? "Signs in to Harbor" : "Doesn't sign in (named on tasks only)",
|
|
7901
7958
|
fields,
|
|
7902
7959
|
width,
|
|
7903
7960
|
height,
|
|
7904
|
-
hints: "
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
}),
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7961
|
+
hints: "a promise time to a program",
|
|
7962
|
+
keys: { a: () => go.pick("Promise time to which program?", programs.map((p) => ({
|
|
7963
|
+
label: p.name,
|
|
7964
|
+
value: p.id
|
|
7965
|
+
})), (programId) => go.ask("What share of their time, in percent? (0 takes it away)", (text) => {
|
|
7966
|
+
const n = Number(text.replace("%", "").trim());
|
|
7967
|
+
if (!Number.isFinite(n) || n < 0 || n > 100) return go.notify("A percent from 0 to 100.");
|
|
7968
|
+
store.getState().setAllocation(id, programId, n || null);
|
|
7969
|
+
})) },
|
|
7970
|
+
below: allocations.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
7971
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
7972
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
7973
|
+
color: theme.muted,
|
|
7974
|
+
children: [" ", "Time promised"]
|
|
7975
|
+
}),
|
|
7976
|
+
allocations.map((a) => /* @__PURE__ */ jsxs(Text, {
|
|
7977
|
+
wrap: "truncate-end",
|
|
7978
|
+
children: [
|
|
7979
|
+
" ",
|
|
7980
|
+
programs.find((p) => p.id === a.programId)?.name ?? "A program",
|
|
7981
|
+
" ",
|
|
7982
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
7983
|
+
color: theme.muted,
|
|
7984
|
+
children: [a.percent, "%"]
|
|
7985
|
+
})
|
|
7986
|
+
]
|
|
7987
|
+
}, a.programId))
|
|
7988
|
+
] }) : null,
|
|
7917
7989
|
active,
|
|
7918
7990
|
onClose
|
|
7919
7991
|
});
|
|
7920
7992
|
}
|
|
7921
|
-
|
|
7993
|
+
|
|
7994
|
+
//#endregion
|
|
7995
|
+
//#region src/tui/views/lists.tsx
|
|
7996
|
+
init_indexes();
|
|
7997
|
+
init_raid();
|
|
7998
|
+
init_derived_health();
|
|
7999
|
+
init_priority();
|
|
8000
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
8001
|
+
function RowList({ title, right, rows, empty, active, width, height, onOpen, keys = {}, above, aboveRows = 0 }) {
|
|
8002
|
+
const [at, setAt] = useState(0);
|
|
8003
|
+
const row = rows[Math.min(at, rows.length - 1)];
|
|
8004
|
+
useInput((input, key) => {
|
|
8005
|
+
if (key.upArrow || input === "k") setAt((i) => step(i, -1, rows.length));
|
|
8006
|
+
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, rows.length));
|
|
8007
|
+
else if (key.pageDown) setAt((i) => step(i, 10, rows.length));
|
|
8008
|
+
else if (key.pageUp) setAt((i) => step(i, -10, rows.length));
|
|
8009
|
+
else if (key.return && row) onOpen(row);
|
|
8010
|
+
else keys[input]?.(row);
|
|
8011
|
+
}, { isActive: active });
|
|
8012
|
+
const view = windowOf(rows, at, Math.max(1, height - 2 - (above ? aboveRows : 0)));
|
|
8013
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
8014
|
+
flexDirection: "column",
|
|
8015
|
+
width,
|
|
8016
|
+
height,
|
|
8017
|
+
paddingX: 1,
|
|
8018
|
+
children: [
|
|
8019
|
+
/* @__PURE__ */ jsx(Heading, {
|
|
8020
|
+
right,
|
|
8021
|
+
children: title
|
|
8022
|
+
}),
|
|
8023
|
+
above,
|
|
8024
|
+
rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
8025
|
+
color: theme.muted,
|
|
8026
|
+
children: empty
|
|
8027
|
+
}) : null,
|
|
8028
|
+
view.items.map((r, i) => {
|
|
8029
|
+
const on = active && view.start + i === at;
|
|
8030
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
8031
|
+
color: on ? theme.selected : r.color,
|
|
8032
|
+
bold: on || r.bold,
|
|
8033
|
+
wrap: "truncate-end",
|
|
8034
|
+
children: [
|
|
8035
|
+
on ? "› " : " ",
|
|
8036
|
+
" ".repeat(r.depth),
|
|
8037
|
+
r.health ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(HealthMark, { health: r.health }), " "] }) : null,
|
|
8038
|
+
r.title,
|
|
8039
|
+
r.detail ? /* @__PURE__ */ jsxs(Text, {
|
|
8040
|
+
color: theme.muted,
|
|
8041
|
+
children: [" ", r.detail]
|
|
8042
|
+
}) : null
|
|
8043
|
+
]
|
|
8044
|
+
}, r.id);
|
|
8045
|
+
})
|
|
8046
|
+
]
|
|
8047
|
+
});
|
|
8048
|
+
}
|
|
8049
|
+
/** Every project, under its program and portfolio, with health and progress. Done
|
|
8050
|
+
* projects and programs stay out of the way until D shows them. */
|
|
8051
|
+
function ProjectsView({ active, width, height, go }) {
|
|
8052
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
8053
|
+
const programs = useHarbor((s) => s.programs);
|
|
8054
|
+
const projects = useHarbor((s) => s.projects);
|
|
8055
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
8056
|
+
const { today } = useDeps();
|
|
8057
|
+
const [showDone, setShowDone] = useState(false);
|
|
8058
|
+
const { rows, shown, hidden } = useMemo(() => {
|
|
8059
|
+
const data = {
|
|
8060
|
+
tasks,
|
|
8061
|
+
projects
|
|
8062
|
+
};
|
|
8063
|
+
const out = [];
|
|
8064
|
+
let shown = 0;
|
|
8065
|
+
let hidden = 0;
|
|
8066
|
+
for (const portfolio of portfolios) {
|
|
8067
|
+
out.push({
|
|
8068
|
+
id: portfolio.id,
|
|
8069
|
+
kind: "portfolio",
|
|
8070
|
+
depth: 0,
|
|
8071
|
+
title: portfolio.name,
|
|
8072
|
+
bold: true,
|
|
8073
|
+
health: portfolio.health
|
|
8074
|
+
});
|
|
8075
|
+
for (const program of programs.filter((g) => g.portfolioId === portfolio.id)) {
|
|
8076
|
+
const { open, done } = splitDone(projects.filter((p) => p.programId === program.id));
|
|
8077
|
+
if (program.health === "done" && open.length === 0) {
|
|
8078
|
+
hidden += 1 + done.length;
|
|
8079
|
+
if (!showDone) continue;
|
|
8080
|
+
} else hidden += done.length;
|
|
8081
|
+
out.push({
|
|
8082
|
+
id: program.id,
|
|
8083
|
+
kind: "program",
|
|
8084
|
+
depth: 1,
|
|
8085
|
+
title: program.name,
|
|
8086
|
+
health: program.health
|
|
8087
|
+
});
|
|
8088
|
+
for (const project of showDone ? [...open, ...done] : open) {
|
|
8089
|
+
shown++;
|
|
8090
|
+
const { done: finished, total } = projectSummary(data, project);
|
|
8091
|
+
const late = tasksOf(tasks, project.id).filter((t) => isLate(t, today())).length;
|
|
8092
|
+
out.push({
|
|
8093
|
+
id: project.id,
|
|
8094
|
+
kind: "project",
|
|
8095
|
+
depth: 2,
|
|
8096
|
+
title: `${project.name}${project.key ? ` ${project.key}` : ""}`,
|
|
8097
|
+
health: project.health,
|
|
8098
|
+
detail: `${finished}/${total} done${late ? ` · ${late} late` : ""} · ends ${shortDay(project.endDate, today())}`
|
|
8099
|
+
});
|
|
8100
|
+
}
|
|
8101
|
+
}
|
|
8102
|
+
}
|
|
8103
|
+
return {
|
|
8104
|
+
rows: out,
|
|
8105
|
+
shown,
|
|
8106
|
+
hidden
|
|
8107
|
+
};
|
|
8108
|
+
}, [
|
|
8109
|
+
portfolios,
|
|
8110
|
+
programs,
|
|
8111
|
+
projects,
|
|
8112
|
+
tasks,
|
|
8113
|
+
today,
|
|
8114
|
+
showDone
|
|
8115
|
+
]);
|
|
8116
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
8117
|
+
title: "Portfolios, programs and projects",
|
|
8118
|
+
right: `${count(shown, "project")}${hidden ? ` · ${hidden} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new project · e edit`,
|
|
8119
|
+
rows,
|
|
8120
|
+
empty: "No projects yet. n adds one.",
|
|
8121
|
+
active,
|
|
8122
|
+
width,
|
|
8123
|
+
height,
|
|
8124
|
+
onOpen: (r) => r.kind === "project" ? go.openProject(r.id) : r.kind === "program" ? go.openProgram(r.id) : go.openPortfolio(r.id),
|
|
8125
|
+
keys: {
|
|
8126
|
+
n: (r) => {
|
|
8127
|
+
const programId = r?.kind === "program" ? r.id : r?.kind === "project" ? projects.find((p) => p.id === r.id)?.programId : void 0;
|
|
8128
|
+
go.create("project", programId ? { programId } : {});
|
|
8129
|
+
},
|
|
8130
|
+
e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id),
|
|
8131
|
+
D: () => hidden > 0 && setShowDone((v) => !v)
|
|
8132
|
+
}
|
|
8133
|
+
});
|
|
8134
|
+
}
|
|
8135
|
+
/**
|
|
8136
|
+
* The review, as on the website: programs worst first, then by end date,
|
|
8137
|
+
* each with its lead, how much is late, where it sits on the priority matrix
|
|
8138
|
+
* and the next move. o narrows to someone's programs, p to a portfolio, m
|
|
8139
|
+
* writes the next move, h sets the health by hand (with why) or hands it
|
|
8140
|
+
* back to the late work; done programs wait behind D.
|
|
8141
|
+
*/
|
|
8142
|
+
function ProgramsView({ active, width, height, go }) {
|
|
7922
8143
|
const { store, today } = useDeps();
|
|
7923
|
-
const project = useHarbor((s) => s.projects.find((p) => p.id === id));
|
|
7924
|
-
const tasks = useHarbor((s) => s.tasks);
|
|
7925
|
-
const people = useHarbor((s) => s.people);
|
|
7926
8144
|
const programs = useHarbor((s) => s.programs);
|
|
8145
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
7927
8146
|
const projects = useHarbor((s) => s.projects);
|
|
7928
|
-
|
|
7929
|
-
const
|
|
7930
|
-
const
|
|
7931
|
-
const
|
|
7932
|
-
const
|
|
7933
|
-
const
|
|
7934
|
-
const
|
|
7935
|
-
const
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
label: "Back to an even spread",
|
|
8147
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
8148
|
+
const people = useHarbor((s) => s.people);
|
|
8149
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
8150
|
+
const priorityRules = useHarbor((s) => s.priorityRules);
|
|
8151
|
+
const [showDone, setShowDone] = useState(false);
|
|
8152
|
+
const [owner, setOwner] = useState(null);
|
|
8153
|
+
const [portfolio, setPortfolio] = useState(null);
|
|
8154
|
+
const day = today();
|
|
8155
|
+
const data = useMemo(() => ({
|
|
8156
|
+
projects,
|
|
8157
|
+
tasks
|
|
8158
|
+
}), [projects, tasks]);
|
|
8159
|
+
const picked = useMemo(() => reviewPrograms({
|
|
8160
|
+
programs,
|
|
8161
|
+
projects,
|
|
8162
|
+
tasks
|
|
8163
|
+
}, {
|
|
8164
|
+
ownerId: owner,
|
|
8165
|
+
portfolioId: portfolio
|
|
8166
|
+
}), [
|
|
8167
|
+
programs,
|
|
8168
|
+
projects,
|
|
8169
|
+
tasks,
|
|
8170
|
+
portfolio,
|
|
8171
|
+
owner
|
|
8172
|
+
]);
|
|
8173
|
+
const split = useMemo(() => splitDone(picked), [picked]);
|
|
8174
|
+
const listed = useMemo(() => showDone ? [...split.open, ...split.done] : split.open, [split, showDone]);
|
|
8175
|
+
const rows = useMemo(() => {
|
|
8176
|
+
const now = /* @__PURE__ */ new Date(`${day}T12:00:00`);
|
|
8177
|
+
const rules = normalizeRules(priorityRules);
|
|
8178
|
+
const maxBudget = Math.max(1, ...programs.map((p) => p.budget));
|
|
8179
|
+
return listed.map((program) => {
|
|
8180
|
+
const late = programLateCount(program.id, data, now);
|
|
8181
|
+
const lead = people.find((p) => p.id === program.ownerId);
|
|
8182
|
+
return {
|
|
8183
|
+
id: program.id,
|
|
8184
|
+
kind: "program",
|
|
8185
|
+
depth: 0,
|
|
8186
|
+
title: program.name,
|
|
8187
|
+
health: program.health,
|
|
8188
|
+
detail: [
|
|
8189
|
+
lead?.name.split(" ")[0],
|
|
8190
|
+
program.endDate ? `ends ${shortDay(program.endDate, day)}` : null,
|
|
8191
|
+
late ? `${late} late` : null,
|
|
8192
|
+
isRated(program) ? quadrantOf(program, maxBudget, rules) : null,
|
|
8193
|
+
program.nextMove ? `next: ${program.nextMove}` : "no next move"
|
|
8194
|
+
].filter(Boolean).join(" · ")
|
|
8195
|
+
};
|
|
8196
|
+
});
|
|
8197
|
+
}, [
|
|
8198
|
+
listed,
|
|
8199
|
+
programs,
|
|
8200
|
+
people,
|
|
8201
|
+
data,
|
|
8202
|
+
day,
|
|
8203
|
+
priorityRules
|
|
8204
|
+
]);
|
|
8205
|
+
const done = split.done.length;
|
|
8206
|
+
const whose = owner === me ? "My programs" : owner ? `${people.find((p) => p.id === owner)?.name ?? "Someone"}'s programs` : "Programs";
|
|
8207
|
+
const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
|
|
8208
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
8209
|
+
title: `${whose}${where}`,
|
|
8210
|
+
right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · m next move · h health · o p narrow`,
|
|
8211
|
+
rows,
|
|
8212
|
+
empty: done ? "Every program here is done. D shows them." : owner || portfolio ? "Nothing here. o and p change what's shown." : "No programs yet. n adds one.",
|
|
8213
|
+
active,
|
|
8214
|
+
width,
|
|
8215
|
+
height,
|
|
8216
|
+
onOpen: (r) => go.openProgram(r.id),
|
|
8217
|
+
keys: {
|
|
8218
|
+
n: (r) => {
|
|
8219
|
+
const portfolioId = r ? programs.find((p) => p.id === r.id)?.portfolioId : void 0;
|
|
8220
|
+
go.create("program", portfolioId ? { portfolioId } : {});
|
|
8221
|
+
},
|
|
8222
|
+
e: (r) => r && go.openPanel("program", r.id),
|
|
8223
|
+
m: (r) => {
|
|
8224
|
+
const program = r && programs.find((p) => p.id === r.id);
|
|
8225
|
+
if (!program) return;
|
|
8226
|
+
go.ask(`Next move for ${program.name}`, (text) => store.getState().setNextMove(program.id, text.trim()), program.nextMove ?? "");
|
|
8227
|
+
},
|
|
8228
|
+
h: (r) => {
|
|
8229
|
+
const program = r && programs.find((p) => p.id === r.id);
|
|
8230
|
+
if (!program) return;
|
|
8231
|
+
go.pick(`Health of ${program.name}`, healthChoices(), (v) => healthPicked(v, go, (health, note) => store.getState().setHealthOverride("program", program.id, health, note), () => store.getState().clearHealthOverride("program", program.id)));
|
|
8232
|
+
},
|
|
8233
|
+
o: () => go.pick("Whose programs?", [
|
|
8234
|
+
{
|
|
8235
|
+
label: "Everyone's",
|
|
8018
8236
|
value: null
|
|
8019
|
-
}
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8237
|
+
},
|
|
8238
|
+
{
|
|
8239
|
+
label: "Mine",
|
|
8240
|
+
value: me
|
|
8241
|
+
},
|
|
8242
|
+
...people.filter((p) => p.id !== me).map((p) => ({
|
|
8243
|
+
label: p.name,
|
|
8244
|
+
value: p.id,
|
|
8245
|
+
hint: p.role
|
|
8246
|
+
}))
|
|
8247
|
+
], (v) => setOwner(v ?? null)),
|
|
8248
|
+
p: () => go.pick("In which portfolio?", [{
|
|
8249
|
+
label: "Every portfolio",
|
|
8250
|
+
value: null
|
|
8251
|
+
}, ...portfolios.map((p) => ({
|
|
8252
|
+
label: p.name,
|
|
8253
|
+
value: p.id
|
|
8254
|
+
}))], (v) => setPortfolio(v ?? null)),
|
|
8255
|
+
D: () => done > 0 && setShowDone((v) => !v)
|
|
8256
|
+
}
|
|
8257
|
+
});
|
|
8258
|
+
}
|
|
8259
|
+
/**
|
|
8260
|
+
* The register, worst first: open items unless a shows closed ones too. As
|
|
8261
|
+
* on the website it narrows by kind (t), owner (o) and portfolio (p); h shows
|
|
8262
|
+
* the heat map over it and c narrows to one of its cells.
|
|
8263
|
+
*/
|
|
8264
|
+
function RisksView({ active, width, height, go }) {
|
|
8265
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
8266
|
+
const projects = useHarbor((s) => s.projects);
|
|
8267
|
+
const programs = useHarbor((s) => s.programs);
|
|
8268
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
8269
|
+
const people = useHarbor((s) => s.people);
|
|
8270
|
+
const [all, setAll] = useState(false);
|
|
8271
|
+
const [kind, setKind] = useState(null);
|
|
8272
|
+
const [owner, setOwner] = useState(null);
|
|
8273
|
+
const [portfolio, setPortfolio] = useState(null);
|
|
8274
|
+
const [heat, setHeat] = useState(false);
|
|
8275
|
+
const [cell, setCell] = useState(null);
|
|
8276
|
+
const inPortfolio = useMemo(() => portfolio ? new Set(portfolioRaidItems({
|
|
8277
|
+
raidItems,
|
|
8278
|
+
projects,
|
|
8279
|
+
programs
|
|
8280
|
+
}, portfolio).map((r) => r.id)) : null, [
|
|
8281
|
+
portfolio,
|
|
8282
|
+
raidItems,
|
|
8283
|
+
projects,
|
|
8284
|
+
programs
|
|
8285
|
+
]);
|
|
8286
|
+
const inScope = useMemo(() => raidItems.filter((r) => (!owner || r.ownerId === owner) && (!inPortfolio || inPortfolio.has(r.id))), [
|
|
8287
|
+
raidItems,
|
|
8288
|
+
owner,
|
|
8289
|
+
inPortfolio
|
|
8290
|
+
]);
|
|
8291
|
+
const rows = useMemo(() => sortRaid(inScope.filter((r) => (all || isActive(r)) && (!kind || r.kind === kind) && (!cell || inHeatCell(r, cell.probability, cell.impact)))).map((r) => {
|
|
8292
|
+
const score = scoreOf(r);
|
|
8293
|
+
const band = score != null ? scoreBand(score) : null;
|
|
8294
|
+
return {
|
|
8295
|
+
id: r.id,
|
|
8296
|
+
kind: "raid",
|
|
8297
|
+
depth: 0,
|
|
8298
|
+
title: `${KIND_LABEL[r.kind]}: ${r.title}`,
|
|
8299
|
+
health: band === "high" ? "off-track" : band === "medium" ? "at-risk" : void 0,
|
|
8300
|
+
color: isActive(r) ? void 0 : theme.muted,
|
|
8301
|
+
detail: [
|
|
8302
|
+
STATUS_LABEL$1[r.status],
|
|
8303
|
+
score != null ? `score ${score}` : null,
|
|
8304
|
+
people.find((p) => p.id === r.ownerId)?.name,
|
|
8305
|
+
projects.find((p) => p.id === r.projectId)?.name
|
|
8306
|
+
].filter(Boolean).join(" · ")
|
|
8307
|
+
};
|
|
8308
|
+
}), [
|
|
8309
|
+
inScope,
|
|
8310
|
+
projects,
|
|
8311
|
+
people,
|
|
8312
|
+
all,
|
|
8313
|
+
kind,
|
|
8314
|
+
cell
|
|
8315
|
+
]);
|
|
8316
|
+
const cells = useMemo(() => heatGrid(inScope).flat().filter((c) => c.items.length > 0).sort((a, b) => b.probability * b.impact - a.probability * a.impact), [inScope]);
|
|
8317
|
+
const showHeat = heat && (!kind || kind === "risk") && height >= 16;
|
|
8318
|
+
const what = cell ? "risks" : kind ? `${KIND_LABEL[kind].toLowerCase()}s` : "risks, issues and decisions";
|
|
8319
|
+
const whose = owner ? `${people.find((p) => p.id === owner)?.name ?? "Someone"}'s ` : "";
|
|
8320
|
+
const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
|
|
8321
|
+
const rated = cell ? ` rated ${cellWords(cell)}` : "";
|
|
8322
|
+
const phrase = `${whose}${all || cell ? "" : "open "}${what}${where}${rated}`;
|
|
8323
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
8324
|
+
title: phrase.charAt(0).toUpperCase() + phrase.slice(1),
|
|
8325
|
+
right: `${rows.length} · t o p a narrow · h heat map${cells.length ? " · c cell" : ""}`,
|
|
8326
|
+
above: showHeat ? /* @__PURE__ */ jsx(HeatMap, {
|
|
8327
|
+
items: inScope,
|
|
8328
|
+
selected: cell
|
|
8329
|
+
}) : void 0,
|
|
8330
|
+
aboveRows: 7,
|
|
8331
|
+
rows,
|
|
8332
|
+
empty: kind || owner || portfolio || cell ? "Nothing matches. t, o, p and c change what's shown." : "Nothing open in the register. n logs one.",
|
|
8333
|
+
active,
|
|
8067
8334
|
width,
|
|
8068
8335
|
height,
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8336
|
+
onOpen: (r) => go.openPanel("raid", r.id),
|
|
8337
|
+
keys: {
|
|
8338
|
+
n: () => go.create("raid", {}),
|
|
8339
|
+
a: () => setAll((v) => !v),
|
|
8340
|
+
t: () => setKind((k) => KINDS[(KINDS.indexOf(k) + 1) % KINDS.length] ?? null),
|
|
8341
|
+
h: () => setHeat((v) => !v),
|
|
8342
|
+
c: () => cells.length || cell ? go.pick("Which cell of the heat map?", [{
|
|
8343
|
+
label: "Every risk",
|
|
8344
|
+
value: null
|
|
8345
|
+
}, ...cells.map((c) => ({
|
|
8346
|
+
label: `${PROBABILITY_LABEL[c.probability]} × ${IMPACT_LABEL[c.impact]}`,
|
|
8347
|
+
value: {
|
|
8348
|
+
probability: c.probability,
|
|
8349
|
+
impact: c.impact
|
|
8350
|
+
},
|
|
8351
|
+
hint: `${count(c.items.length, "risk")} · score ${c.probability * c.impact}`,
|
|
8352
|
+
color: c.band === "high" ? theme.bad : c.band === "medium" ? theme.warn : theme.good
|
|
8353
|
+
}))], (v) => setCell(v ?? null)) : go.notify("No live risk is rated on both scales yet."),
|
|
8354
|
+
o: () => go.pick("Whose?", [{
|
|
8355
|
+
label: "Anyone's",
|
|
8356
|
+
value: null
|
|
8357
|
+
}, ...people.map((p) => ({
|
|
8358
|
+
label: p.name,
|
|
8359
|
+
value: p.id,
|
|
8360
|
+
hint: p.role
|
|
8361
|
+
}))], (v) => setOwner(v ?? null)),
|
|
8362
|
+
p: () => go.pick("In which portfolio?", [{
|
|
8363
|
+
label: "Every portfolio",
|
|
8364
|
+
value: null
|
|
8365
|
+
}, ...portfolios.map((p) => ({
|
|
8366
|
+
label: p.name,
|
|
8367
|
+
value: p.id
|
|
8368
|
+
}))], (v) => setPortfolio(v ?? null))
|
|
8369
|
+
}
|
|
8370
|
+
});
|
|
8371
|
+
}
|
|
8372
|
+
const KINDS = [
|
|
8373
|
+
null,
|
|
8374
|
+
"risk",
|
|
8375
|
+
"issue",
|
|
8376
|
+
"decision"
|
|
8377
|
+
];
|
|
8378
|
+
/** Who's here: open and late work, and how full each of the next weeks is against their capacity. */
|
|
8379
|
+
function PeopleView({ active, width, height, go }) {
|
|
8380
|
+
const people = useHarbor((s) => s.people);
|
|
8381
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
8382
|
+
const { today } = useDeps();
|
|
8383
|
+
const rows = useMemo(() => {
|
|
8384
|
+
const now = /* @__PURE__ */ new Date(`${today()}T12:00:00`);
|
|
8385
|
+
const nameWidth = Math.max(0, ...people.map((p) => p.name.length));
|
|
8386
|
+
return people.map((p) => {
|
|
8387
|
+
const open = tasks.filter((t) => t.assigneeId === p.id && t.status !== "done");
|
|
8388
|
+
const load = weeklyLoad(tasks, p.id, now, 4);
|
|
8389
|
+
const cap = p.weeklyCapacity ?? 5;
|
|
8390
|
+
const weeks = load.weeks.map((w) => (w.due === 0 ? "·" : w.due > cap ? `${w.due}!` : String(w.due)).padStart(3)).join("");
|
|
8391
|
+
const late = load.overdue ? `${load.overdue} late` : "";
|
|
8392
|
+
return {
|
|
8393
|
+
id: p.id,
|
|
8394
|
+
kind: "person",
|
|
8395
|
+
depth: 0,
|
|
8396
|
+
title: p.name.padEnd(nameWidth),
|
|
8397
|
+
detail: `${String(open.length).padStart(3)} open ${late.padEnd(7)} weeks${weeks} of ${cap} · ${p.role}`
|
|
8398
|
+
};
|
|
8399
|
+
});
|
|
8400
|
+
}, [
|
|
8401
|
+
people,
|
|
8402
|
+
tasks,
|
|
8403
|
+
today
|
|
8404
|
+
]);
|
|
8405
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
8406
|
+
title: "People",
|
|
8407
|
+
right: `${people.length === 1 ? "1 person" : `${people.length} people`} · Enter to change`,
|
|
8408
|
+
rows,
|
|
8409
|
+
empty: "Nobody here yet. Invite people from the website.",
|
|
8082
8410
|
active,
|
|
8083
|
-
|
|
8411
|
+
width,
|
|
8412
|
+
height,
|
|
8413
|
+
onOpen: (r) => go.openPanel("person", r.id)
|
|
8414
|
+
});
|
|
8415
|
+
}
|
|
8416
|
+
|
|
8417
|
+
//#endregion
|
|
8418
|
+
//#region cli/lib/logo.mjs
|
|
8419
|
+
/**
|
|
8420
|
+
* Harbor's mark for the terminal: the favicon (public/favicon.svg) as
|
|
8421
|
+
* pixels, a teal tile with the white H and three coral boats. Each
|
|
8422
|
+
* terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
|
|
8423
|
+
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
8424
|
+
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
8425
|
+
*
|
|
8426
|
+
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
8427
|
+
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
8428
|
+
*/
|
|
8429
|
+
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
8430
|
+
const LOGO = [
|
|
8431
|
+
"...TTTTTTTTTTTTTTTTTT...",
|
|
8432
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
8433
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
8434
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
8435
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
8436
|
+
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
8437
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
8438
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
8439
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
8440
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
8441
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
8442
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
8443
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
8444
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
8445
|
+
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
8446
|
+
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
8447
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
8448
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
8449
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
8450
|
+
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
8451
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
8452
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
8453
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
8454
|
+
"...TTTTTTTTTTTTTTTTTT..."
|
|
8455
|
+
];
|
|
8456
|
+
/** 16 by 16, for the keys overlay. */
|
|
8457
|
+
const LOGO_SMALL = [
|
|
8458
|
+
"..TTTTTTTTTTTT..",
|
|
8459
|
+
".TTTTTTTTTTTTTT.",
|
|
8460
|
+
"TTTTWWTTTTWWTTTT",
|
|
8461
|
+
"TTTTWWTTTTWWTTTT",
|
|
8462
|
+
"TPPTWWTTTTWWTTTT",
|
|
8463
|
+
"TCCTWWTTTTWWTTTT",
|
|
8464
|
+
"TCCTWWTTTTWWTTTT",
|
|
8465
|
+
"TTTTWWWWWWWWTTTT",
|
|
8466
|
+
"TTTTWWWWWWWWTTTT",
|
|
8467
|
+
"TTTTWWTTTTWWTPPT",
|
|
8468
|
+
"TTTTWWTPPTWWTCCT",
|
|
8469
|
+
"TTTTWWTCCTWWTCCT",
|
|
8470
|
+
"TTTTWWTCCTWWTTTT",
|
|
8471
|
+
"TTTTWWTTTTWWTTTT",
|
|
8472
|
+
".TTTTTTTTTTTTTT.",
|
|
8473
|
+
"..TTTTTTTTTTTT.."
|
|
8474
|
+
];
|
|
8475
|
+
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
8476
|
+
const LOGO_COLOURS = {
|
|
8477
|
+
T: "#0f6e64",
|
|
8478
|
+
W: "#ffffff",
|
|
8479
|
+
C: "#c44a2c"
|
|
8480
|
+
};
|
|
8481
|
+
/**
|
|
8482
|
+
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
8483
|
+
* One stretch of a terminal row in the same colours.
|
|
8484
|
+
*/
|
|
8485
|
+
/**
|
|
8486
|
+
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
8487
|
+
* half block's colour, the bottom one its background.
|
|
8488
|
+
* @param {string[]} pixels
|
|
8489
|
+
* @returns {Run[][]}
|
|
8490
|
+
*/
|
|
8491
|
+
function logoRows(pixels = LOGO) {
|
|
8492
|
+
/** @type {Run[][]} */
|
|
8493
|
+
const rows = [];
|
|
8494
|
+
for (let y = 0; y < pixels.length; y += 2) {
|
|
8495
|
+
/** @type {Run[]} */
|
|
8496
|
+
const runs = [];
|
|
8497
|
+
const top = pixels[y] ?? "";
|
|
8498
|
+
const bottom = pixels[y + 1] ?? "";
|
|
8499
|
+
for (let x = 0; x < top.length; x++) {
|
|
8500
|
+
const t = LOGO_COLOURS[top[x]];
|
|
8501
|
+
const b = LOGO_COLOURS[bottom[x]];
|
|
8502
|
+
const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
|
|
8503
|
+
const left = bowRow ? bowRow[x - 1] !== "P" : false;
|
|
8504
|
+
/** @type {Run} */
|
|
8505
|
+
const cell = top[x] === "P" ? {
|
|
8506
|
+
text: left ? "▟" : "▙",
|
|
8507
|
+
fg: LOGO_COLOURS.C,
|
|
8508
|
+
bg: LOGO_COLOURS.T
|
|
8509
|
+
} : bottom[x] === "P" ? {
|
|
8510
|
+
text: left ? "▗" : "▖",
|
|
8511
|
+
fg: LOGO_COLOURS.C,
|
|
8512
|
+
bg: t
|
|
8513
|
+
} : !t && !b ? { text: " " } : t === b || !b ? {
|
|
8514
|
+
text: t === b ? "█" : "▀",
|
|
8515
|
+
fg: t
|
|
8516
|
+
} : !t ? {
|
|
8517
|
+
text: "▄",
|
|
8518
|
+
fg: b
|
|
8519
|
+
} : {
|
|
8520
|
+
text: "▀",
|
|
8521
|
+
fg: t,
|
|
8522
|
+
bg: b
|
|
8523
|
+
};
|
|
8524
|
+
const last = runs.at(-1);
|
|
8525
|
+
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
8526
|
+
else runs.push(cell);
|
|
8527
|
+
}
|
|
8528
|
+
rows.push(runs);
|
|
8529
|
+
}
|
|
8530
|
+
return rows;
|
|
8531
|
+
}
|
|
8532
|
+
|
|
8533
|
+
//#endregion
|
|
8534
|
+
//#region src/tui/logo.tsx
|
|
8535
|
+
/** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
|
|
8536
|
+
function Logo({ pixels = LOGO }) {
|
|
8537
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
8538
|
+
flexDirection: "column",
|
|
8539
|
+
flexShrink: 0,
|
|
8540
|
+
children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
|
|
8541
|
+
color: r.fg,
|
|
8542
|
+
backgroundColor: r.bg,
|
|
8543
|
+
children: r.text
|
|
8544
|
+
}, i)) }, y))
|
|
8084
8545
|
});
|
|
8085
8546
|
}
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8547
|
+
/** What shows while the harbor loads: the mark, centred, and which harbor. */
|
|
8548
|
+
function Splash({ opening }) {
|
|
8549
|
+
const { columns, rows } = useWindowSize();
|
|
8550
|
+
const mark = rows >= 18 && columns >= 30;
|
|
8551
|
+
const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
|
|
8552
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
8553
|
+
width: columns,
|
|
8554
|
+
height: rows,
|
|
8555
|
+
flexDirection: "column",
|
|
8556
|
+
alignItems: "center",
|
|
8557
|
+
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
8558
|
+
children: [
|
|
8559
|
+
mark ? /* @__PURE__ */ jsx(Box, {
|
|
8560
|
+
marginBottom: 1,
|
|
8561
|
+
children: /* @__PURE__ */ jsx(Logo, {})
|
|
8562
|
+
}) : null,
|
|
8563
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8564
|
+
bold: true,
|
|
8565
|
+
color: theme.accent,
|
|
8566
|
+
children: "Harbor"
|
|
8567
|
+
}),
|
|
8568
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8569
|
+
color: theme.muted,
|
|
8570
|
+
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
8571
|
+
})
|
|
8572
|
+
]
|
|
8573
|
+
});
|
|
8574
|
+
}
|
|
8575
|
+
|
|
8576
|
+
//#endregion
|
|
8577
|
+
//#region src/tui/views/overlays.tsx
|
|
8578
|
+
init_search();
|
|
8579
|
+
/** Find a task by key or words; Enter opens it. */
|
|
8580
|
+
function SearchBox({ width, onOpen, onClose }) {
|
|
8581
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
8095
8582
|
const projects = useHarbor((s) => s.projects);
|
|
8096
|
-
const
|
|
8097
|
-
|
|
8098
|
-
const
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
},
|
|
8125
|
-
{
|
|
8126
|
-
label: "Done",
|
|
8127
|
-
value: milestone.done ? "Yes" : gated ? "Not yet (the sign-off ticks it)" : "Not yet",
|
|
8128
|
-
edit: gated ? void 0 : {
|
|
8129
|
-
kind: "pick",
|
|
8130
|
-
choices: [{
|
|
8131
|
-
label: "Not yet",
|
|
8132
|
-
value: false
|
|
8133
|
-
}, {
|
|
8134
|
-
label: "Yes",
|
|
8135
|
-
value: true
|
|
8136
|
-
}],
|
|
8137
|
-
initial: milestone.done ? 1 : 0,
|
|
8138
|
-
onPick: (v) => v !== milestone.done && store.getState().toggleMilestone(id)
|
|
8139
|
-
}
|
|
8140
|
-
},
|
|
8141
|
-
{
|
|
8142
|
-
label: "Signs off",
|
|
8143
|
-
value: approver?.name ?? "Nobody (no sign-off)",
|
|
8144
|
-
edit: {
|
|
8145
|
-
kind: "pick",
|
|
8146
|
-
title: "Who signs it off? Nobody else can tick it done.",
|
|
8147
|
-
choices: peopleChoices(people, true),
|
|
8148
|
-
initial: Math.max(0, people.findIndex((p) => p.id === milestone.approverId) + 1),
|
|
8149
|
-
onPick: (v) => v ? v !== milestone.approverId && up({
|
|
8150
|
-
approverId: v,
|
|
8151
|
-
approvalStatus: "pending",
|
|
8152
|
-
done: false
|
|
8153
|
-
}) : up({
|
|
8154
|
-
approverId: null,
|
|
8155
|
-
approvalStatus: null,
|
|
8156
|
-
approvalNote: null
|
|
8157
|
-
})
|
|
8158
|
-
}
|
|
8159
|
-
},
|
|
8160
|
-
...gated ? [{
|
|
8161
|
-
label: "Sign-off",
|
|
8162
|
-
value: `${SIGN_OFF[status]}${status === "changes_requested" && milestone.approvalNote ? `: ${milestone.approvalNote}` : ""}`,
|
|
8163
|
-
color: status === "approved" ? theme.good : status === "pending" ? void 0 : theme.bad,
|
|
8164
|
-
edit: milestone.approverId === me && status !== "approved" ? {
|
|
8165
|
-
kind: "pick",
|
|
8166
|
-
title: "Your sign-off",
|
|
8167
|
-
choices: [{
|
|
8168
|
-
label: "Approve",
|
|
8169
|
-
value: "approve",
|
|
8170
|
-
hint: "ticks it done"
|
|
8171
|
-
}, {
|
|
8172
|
-
label: "Request changes",
|
|
8173
|
-
value: "changes"
|
|
8174
|
-
}],
|
|
8175
|
-
onPick: (v) => v === "approve" ? up({
|
|
8176
|
-
approvalStatus: "approved",
|
|
8177
|
-
done: true
|
|
8178
|
-
}) : go.ask("What needs changing?", (note) => up({
|
|
8179
|
-
approvalStatus: "changes_requested",
|
|
8180
|
-
approvalNote: note.trim() || null,
|
|
8181
|
-
done: false
|
|
8182
|
-
}))
|
|
8183
|
-
} : void 0
|
|
8184
|
-
}] : [],
|
|
8185
|
-
waitsField("Waits on", milestone, inProgram, go, (patch) => up(patch), false)
|
|
8186
|
-
];
|
|
8187
|
-
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
8188
|
-
title: /* @__PURE__ */ jsx(Text, {
|
|
8189
|
-
bold: true,
|
|
8190
|
-
children: milestone.name
|
|
8191
|
-
}),
|
|
8192
|
-
subtitle: "Milestone",
|
|
8193
|
-
fields,
|
|
8583
|
+
const [query, setQuery] = useState("");
|
|
8584
|
+
const [at, setAt] = useState(0);
|
|
8585
|
+
const hits = useMemo(() => searchTasks(tasks, projects, query.trim(), 12), [
|
|
8586
|
+
tasks,
|
|
8587
|
+
projects,
|
|
8588
|
+
query
|
|
8589
|
+
]);
|
|
8590
|
+
useInput((input, key) => {
|
|
8591
|
+
if (key.escape) return onClose();
|
|
8592
|
+
if (key.upArrow) return setAt((i) => step(i, -1, hits.length));
|
|
8593
|
+
if (key.downArrow) return setAt((i) => step(i, 1, hits.length));
|
|
8594
|
+
if (key.return) {
|
|
8595
|
+
const hit = hits[Math.min(at, hits.length - 1)];
|
|
8596
|
+
if (hit) onOpen(hit.item.id);
|
|
8597
|
+
return;
|
|
8598
|
+
}
|
|
8599
|
+
if (key.backspace || key.delete) {
|
|
8600
|
+
setAt(0);
|
|
8601
|
+
return setQuery((q) => q.slice(0, -1));
|
|
8602
|
+
}
|
|
8603
|
+
if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
|
|
8604
|
+
if (input) {
|
|
8605
|
+
setAt(0);
|
|
8606
|
+
setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
|
|
8607
|
+
}
|
|
8608
|
+
});
|
|
8609
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
8610
|
+
flexDirection: "column",
|
|
8194
8611
|
width,
|
|
8195
|
-
|
|
8196
|
-
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
]
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8612
|
+
borderStyle: "round",
|
|
8613
|
+
borderColor: theme.border,
|
|
8614
|
+
paddingX: 1,
|
|
8615
|
+
children: [
|
|
8616
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
8617
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
8618
|
+
color: theme.accent,
|
|
8619
|
+
bold: true,
|
|
8620
|
+
children: ["/", " "]
|
|
8621
|
+
}),
|
|
8622
|
+
query || /* @__PURE__ */ jsx(Text, {
|
|
8623
|
+
color: theme.muted,
|
|
8624
|
+
children: "a key like RR-42, or words from a title"
|
|
8625
|
+
}),
|
|
8626
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8627
|
+
inverse: true,
|
|
8628
|
+
children: " "
|
|
8629
|
+
})
|
|
8630
|
+
] }),
|
|
8631
|
+
hits.length === 0 && query ? /* @__PURE__ */ jsx(Text, {
|
|
8632
|
+
color: theme.muted,
|
|
8633
|
+
children: "Nothing matches."
|
|
8634
|
+
}) : null,
|
|
8635
|
+
hits.map(({ item }, i) => {
|
|
8636
|
+
const on = i === Math.min(at, hits.length - 1);
|
|
8637
|
+
const project = projects.find((p) => p.id === item.projectId);
|
|
8638
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
8639
|
+
color: on ? theme.selected : void 0,
|
|
8640
|
+
bold: on,
|
|
8641
|
+
wrap: "truncate-end",
|
|
8642
|
+
children: [
|
|
8643
|
+
on ? "› " : " ",
|
|
8644
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8645
|
+
color: theme.muted,
|
|
8646
|
+
children: keyOf$1({ projects }, item).padEnd(7)
|
|
8647
|
+
}),
|
|
8648
|
+
item.title,
|
|
8649
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
8650
|
+
color: theme.muted,
|
|
8651
|
+
children: [
|
|
8652
|
+
" ",
|
|
8653
|
+
"· ",
|
|
8654
|
+
STATUS_LABEL[item.status],
|
|
8655
|
+
project ? ` · ${project.name}` : ""
|
|
8656
|
+
]
|
|
8657
|
+
})
|
|
8658
|
+
]
|
|
8659
|
+
}, item.id);
|
|
8660
|
+
}),
|
|
8661
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8662
|
+
color: theme.muted,
|
|
8663
|
+
children: "↑↓ choose · Enter open · Esc close"
|
|
8664
|
+
})
|
|
8665
|
+
]
|
|
8217
8666
|
});
|
|
8218
8667
|
}
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
const
|
|
8222
|
-
const people = useHarbor((s) => s.people);
|
|
8668
|
+
/** Add a task: which project (when it isn't known), then its title. */
|
|
8669
|
+
function NewTask({ projectId, status, width, onDone, onFull }) {
|
|
8670
|
+
const { store } = useDeps();
|
|
8223
8671
|
const projects = useHarbor((s) => s.projects);
|
|
8224
8672
|
const me = useHarbor((s) => s.currentUserId);
|
|
8225
|
-
|
|
8226
|
-
const
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
initial: item.title,
|
|
8261
|
-
onSave: (v) => v.trim() && up({ title: v.trim() })
|
|
8262
|
-
}
|
|
8263
|
-
},
|
|
8264
|
-
{
|
|
8265
|
-
label: "Kind",
|
|
8266
|
-
value: KIND_LABEL[item.kind],
|
|
8267
|
-
edit: {
|
|
8268
|
-
kind: "pick",
|
|
8269
|
-
choices: [
|
|
8270
|
-
"risk",
|
|
8271
|
-
"issue",
|
|
8272
|
-
"decision"
|
|
8273
|
-
].map((k) => ({
|
|
8274
|
-
label: KIND_LABEL[k],
|
|
8275
|
-
value: k
|
|
8276
|
-
})),
|
|
8277
|
-
initial: [
|
|
8278
|
-
"risk",
|
|
8279
|
-
"issue",
|
|
8280
|
-
"decision"
|
|
8281
|
-
].indexOf(item.kind),
|
|
8282
|
-
onPick: (v) => up({ kind: v })
|
|
8283
|
-
}
|
|
8284
|
-
},
|
|
8285
|
-
{
|
|
8286
|
-
label: "Status",
|
|
8287
|
-
value: STATUS_LABEL$1[item.status],
|
|
8288
|
-
edit: {
|
|
8289
|
-
kind: "pick",
|
|
8290
|
-
choices: STATUSES[item.kind].map((s) => ({
|
|
8291
|
-
label: STATUS_LABEL$1[s],
|
|
8292
|
-
value: s
|
|
8293
|
-
})),
|
|
8294
|
-
initial: STATUSES[item.kind].indexOf(item.status),
|
|
8295
|
-
onPick: (v) => up(v === "decided" ? {
|
|
8296
|
-
status: v,
|
|
8297
|
-
decidedAt: item.decidedAt ?? today(),
|
|
8298
|
-
decidedById: item.decidedById ?? me
|
|
8299
|
-
} : { status: v })
|
|
8300
|
-
}
|
|
8301
|
-
},
|
|
8302
|
-
{
|
|
8303
|
-
label: "Owner",
|
|
8304
|
-
value: person(item.ownerId) ?? "Nobody yet",
|
|
8305
|
-
edit: {
|
|
8306
|
-
kind: "pick",
|
|
8307
|
-
choices: peopleChoices(people, true),
|
|
8308
|
-
initial: Math.max(0, people.findIndex((p) => p.id === item.ownerId) + 1),
|
|
8309
|
-
onPick: (v) => up({ ownerId: v })
|
|
8310
|
-
}
|
|
8311
|
-
},
|
|
8312
|
-
{
|
|
8313
|
-
label: "Project",
|
|
8314
|
-
value: projects.find((p) => p.id === item.projectId)?.name ?? "None in particular",
|
|
8315
|
-
edit: {
|
|
8316
|
-
kind: "pick",
|
|
8317
|
-
choices: [{
|
|
8318
|
-
label: "None in particular",
|
|
8319
|
-
value: null
|
|
8320
|
-
}, ...projects.map((p) => ({
|
|
8321
|
-
label: p.name,
|
|
8322
|
-
value: p.id
|
|
8323
|
-
}))],
|
|
8324
|
-
initial: Math.max(0, projects.findIndex((p) => p.id === item.projectId) + 1),
|
|
8325
|
-
onPick: (v) => up({
|
|
8326
|
-
projectId: v,
|
|
8327
|
-
programId: projects.find((p) => p.id === v)?.programId ?? null
|
|
8328
|
-
})
|
|
8329
|
-
}
|
|
8330
|
-
},
|
|
8331
|
-
...item.kind === "risk" ? [{
|
|
8332
|
-
label: "Likelihood",
|
|
8333
|
-
value: item.probability ? `${item.probability} ${PROBABILITY_LABEL[item.probability]}` : "",
|
|
8334
|
-
edit: {
|
|
8335
|
-
kind: "pick",
|
|
8336
|
-
choices: scale(PROBABILITY_LABEL),
|
|
8337
|
-
initial: (item.probability ?? 3) - 1,
|
|
8338
|
-
onPick: (v) => up({ probability: v })
|
|
8339
|
-
}
|
|
8340
|
-
}] : [],
|
|
8341
|
-
...item.kind !== "decision" ? [{
|
|
8342
|
-
label: "Impact",
|
|
8343
|
-
value: item.impact ? `${item.impact} ${IMPACT_LABEL[item.impact]}` : "",
|
|
8344
|
-
edit: {
|
|
8345
|
-
kind: "pick",
|
|
8346
|
-
choices: scale(IMPACT_LABEL),
|
|
8347
|
-
initial: (item.impact ?? 3) - 1,
|
|
8348
|
-
onPick: (v) => up({ impact: v })
|
|
8349
|
-
}
|
|
8350
|
-
}] : [],
|
|
8351
|
-
...item.kind === "risk" ? [{
|
|
8352
|
-
label: "Score",
|
|
8353
|
-
value: score != null ? String(score) : ""
|
|
8354
|
-
}] : [],
|
|
8355
|
-
...item.kind === "decision" ? [{
|
|
8356
|
-
label: "Decided by",
|
|
8357
|
-
value: person(item.decidedById) ?? "Not yet",
|
|
8358
|
-
edit: {
|
|
8359
|
-
kind: "pick",
|
|
8360
|
-
choices: [{
|
|
8361
|
-
label: "Not yet",
|
|
8362
|
-
value: null
|
|
8363
|
-
}, ...people.map((p) => ({
|
|
8364
|
-
label: p.name,
|
|
8365
|
-
value: p.id
|
|
8366
|
-
}))],
|
|
8367
|
-
initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
|
|
8368
|
-
onPick: (v) => up({ decidedById: v })
|
|
8369
|
-
}
|
|
8370
|
-
}, {
|
|
8371
|
-
label: "Decided on",
|
|
8372
|
-
value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
|
|
8373
|
-
edit: {
|
|
8374
|
-
kind: "date",
|
|
8375
|
-
initial: item.decidedAt ?? null,
|
|
8376
|
-
onSave: (v) => up({ decidedAt: v })
|
|
8377
|
-
}
|
|
8378
|
-
}] : [],
|
|
8379
|
-
{
|
|
8380
|
-
label: copy.dueDate,
|
|
8381
|
-
value: shortDay(item.dueDate, today()),
|
|
8382
|
-
edit: {
|
|
8383
|
-
kind: "date",
|
|
8384
|
-
initial: item.dueDate,
|
|
8385
|
-
onSave: (v) => up({ dueDate: v })
|
|
8673
|
+
const [project, setProject] = useState(projectId);
|
|
8674
|
+
const chosen = projects.find((p) => p.id === project);
|
|
8675
|
+
if (!chosen) return /* @__PURE__ */ jsx(Box, {
|
|
8676
|
+
width,
|
|
8677
|
+
children: /* @__PURE__ */ jsx(Picker, {
|
|
8678
|
+
title: projects.length ? "New task in which project?" : "No projects yet. Esc, then 3 for Projects and n to start one.",
|
|
8679
|
+
choices: projects.map((p) => ({
|
|
8680
|
+
label: p.name,
|
|
8681
|
+
value: p.id,
|
|
8682
|
+
hint: p.key
|
|
8683
|
+
})),
|
|
8684
|
+
onCancel: () => onDone(null),
|
|
8685
|
+
onPick: (id) => setProject(id)
|
|
8686
|
+
})
|
|
8687
|
+
});
|
|
8688
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
8689
|
+
width,
|
|
8690
|
+
children: /* @__PURE__ */ jsx(TextField, {
|
|
8691
|
+
label: `New task in ${chosen.name}${status ? ` · ${STATUS_LABEL[status]}` : ""}`,
|
|
8692
|
+
placeholder: "What needs doing?",
|
|
8693
|
+
hint: `Enter adds it (yours, ${STATUS_LABEL[status ?? "todo"].toLowerCase()}) · Esc leaves it`,
|
|
8694
|
+
onCancel: () => onDone(null),
|
|
8695
|
+
onSubmit: (title) => {
|
|
8696
|
+
if (!title.trim()) return onDone(null);
|
|
8697
|
+
const full = roomFor(store, chosen.id, status ?? "todo");
|
|
8698
|
+
if (full) return onFull(full);
|
|
8699
|
+
onDone(store.getState().addTask({
|
|
8700
|
+
projectId: chosen.id,
|
|
8701
|
+
title: title.trim(),
|
|
8702
|
+
description: "",
|
|
8703
|
+
status: status ?? "todo",
|
|
8704
|
+
priority: "medium",
|
|
8705
|
+
assigneeId: me || null,
|
|
8706
|
+
dueDate: null
|
|
8707
|
+
}));
|
|
8386
8708
|
}
|
|
8387
|
-
}
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
}),
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
8709
|
+
})
|
|
8710
|
+
});
|
|
8711
|
+
}
|
|
8712
|
+
/** Switch to another harbor this account belongs to. */
|
|
8713
|
+
function HarborSwitch({ harbors, current, width, onPick, onClose }) {
|
|
8714
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
8715
|
+
width,
|
|
8716
|
+
children: /* @__PURE__ */ jsx(Picker, {
|
|
8717
|
+
title: "Harbors",
|
|
8718
|
+
choices: harbors.map((h) => ({
|
|
8719
|
+
label: h.name,
|
|
8720
|
+
value: h.id,
|
|
8721
|
+
hint: `${h.role}${h.id === current ? " · open" : ""}`
|
|
8722
|
+
})),
|
|
8723
|
+
initial: Math.max(0, harbors.findIndex((h) => h.id === current)),
|
|
8724
|
+
onCancel: onClose,
|
|
8725
|
+
onPick
|
|
8726
|
+
})
|
|
8727
|
+
});
|
|
8728
|
+
}
|
|
8729
|
+
const HELP = [
|
|
8730
|
+
["0–9 / Tab", "the screens / between the sidebar and a screen"],
|
|
8731
|
+
[":", "anything: a screen, something new, a name"],
|
|
8732
|
+
["/ / H", "find a task by key or words / switch harbor"],
|
|
8733
|
+
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
8734
|
+
["Enter / Esc", "open / back"],
|
|
8735
|
+
["n", "new: a task, or what the screen lists"],
|
|
8736
|
+
["e / b", "change what's open / a project's board setup"],
|
|
8737
|
+
["[ ] / { }", "move a card a column / a project along its hill"],
|
|
8738
|
+
["x / d", "mark done / delete (asks first)"],
|
|
8739
|
+
["u / r", "undo your last change / retry saving"],
|
|
8740
|
+
["v", "switch layout: board, list, budget, by project"],
|
|
8741
|
+
["m / R", "new milestone on a program / log a risk"],
|
|
8742
|
+
["D", "show or hide Done projects and programs"],
|
|
8743
|
+
["c / a / C", "on a task: comment / add a step / change yours"],
|
|
8744
|
+
["f / c", "on Today: focus on a task / check in"],
|
|
8745
|
+
["f / p", "on a board: filter by owner / priority"],
|
|
8746
|
+
["q", "quit (saves first)"]
|
|
8747
|
+
];
|
|
8748
|
+
/**
|
|
8749
|
+
* The mark sits beside the keys only when both fit: the longest line, a gap,
|
|
8750
|
+
* the 16-pixel mark, and the box's border and padding. Narrower, the lines
|
|
8751
|
+
* would wrap and split the mark.
|
|
8752
|
+
*/
|
|
8753
|
+
const HELP_WITH_MARK = 13 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
|
|
8754
|
+
function Help({ width, onClose }) {
|
|
8755
|
+
useInput((input, key) => {
|
|
8756
|
+
if (key.escape || key.return || input === "?" || input === "q") onClose();
|
|
8757
|
+
});
|
|
8758
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
8408
8759
|
width,
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8415
|
-
wrap: "truncate-end",
|
|
8760
|
+
borderStyle: "round",
|
|
8761
|
+
borderColor: theme.border,
|
|
8762
|
+
paddingX: 1,
|
|
8763
|
+
children: [/* @__PURE__ */ jsxs(Box, {
|
|
8764
|
+
flexDirection: "column",
|
|
8765
|
+
flexGrow: 1,
|
|
8416
8766
|
children: [
|
|
8417
8767
|
/* @__PURE__ */ jsx(Text, {
|
|
8418
8768
|
bold: true,
|
|
8419
|
-
|
|
8769
|
+
color: theme.accent,
|
|
8770
|
+
children: "Keys"
|
|
8420
8771
|
}),
|
|
8421
|
-
|
|
8772
|
+
HELP.map(([k, what]) => /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsx(Text, {
|
|
8773
|
+
bold: true,
|
|
8774
|
+
children: k.padEnd(13)
|
|
8775
|
+
}), what] }, k)),
|
|
8422
8776
|
/* @__PURE__ */ jsx(Text, {
|
|
8423
8777
|
color: theme.muted,
|
|
8424
|
-
children:
|
|
8778
|
+
children: "Everything saves as you go and shows on the website."
|
|
8779
|
+
})
|
|
8780
|
+
]
|
|
8781
|
+
}), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
|
|
8782
|
+
flexDirection: "column",
|
|
8783
|
+
alignItems: "center",
|
|
8784
|
+
marginLeft: 2,
|
|
8785
|
+
marginTop: 1,
|
|
8786
|
+
children: [
|
|
8787
|
+
/* @__PURE__ */ jsx(Logo, { pixels: LOGO_SMALL }),
|
|
8788
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8789
|
+
bold: true,
|
|
8790
|
+
color: theme.accent,
|
|
8791
|
+
children: "Harbor"
|
|
8425
8792
|
}),
|
|
8426
|
-
|
|
8427
|
-
|
|
8793
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8794
|
+
color: theme.muted,
|
|
8795
|
+
children: "harborppm.com"
|
|
8796
|
+
})
|
|
8428
8797
|
]
|
|
8429
|
-
}
|
|
8430
|
-
keys: {
|
|
8431
|
-
c: () => go.ask("Update (shows in its history)", (text) => text.trim() && store.getState().addRaidUpdate(id, text.trim())),
|
|
8432
|
-
d: () => go.confirm(`Delete “${item.title}” from the register? u undoes it.`, () => {
|
|
8433
|
-
store.getState().deleteRaidItem(id);
|
|
8434
|
-
onClose();
|
|
8435
|
-
}, "Delete")
|
|
8436
|
-
},
|
|
8437
|
-
active,
|
|
8438
|
-
onClose
|
|
8798
|
+
}) : null]
|
|
8439
8799
|
});
|
|
8440
8800
|
}
|
|
8441
|
-
|
|
8442
|
-
|
|
8443
|
-
|
|
8801
|
+
|
|
8802
|
+
//#endregion
|
|
8803
|
+
//#region src/tui/views/palette.tsx
|
|
8804
|
+
/**
|
|
8805
|
+
* `:` — everything in one list: screens, making things, every project and
|
|
8806
|
+
* program by name, other harbors. Type to narrow, Enter to run.
|
|
8807
|
+
*/
|
|
8808
|
+
function Palette({ width, base, open, onClose }) {
|
|
8809
|
+
const projects = useHarbor((s) => s.projects);
|
|
8444
8810
|
const programs = useHarbor((s) => s.programs);
|
|
8445
|
-
|
|
8446
|
-
const
|
|
8447
|
-
const
|
|
8448
|
-
const
|
|
8449
|
-
|
|
8450
|
-
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
}
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
|
|
8474
|
-
|
|
8475
|
-
|
|
8476
|
-
|
|
8477
|
-
|
|
8478
|
-
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
kind: "text",
|
|
8483
|
-
initial: person.location,
|
|
8484
|
-
onSave: (v) => up({ location: v.trim() })
|
|
8485
|
-
}
|
|
8811
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
8812
|
+
const [query, setQuery] = useState("");
|
|
8813
|
+
const [at, setAt] = useState(0);
|
|
8814
|
+
const all = useMemo(() => [
|
|
8815
|
+
...base,
|
|
8816
|
+
...projects.map((p) => ({
|
|
8817
|
+
label: `Open ${p.name}`,
|
|
8818
|
+
hint: `project ${p.key ?? ""}`,
|
|
8819
|
+
run: () => open("project", p.id)
|
|
8820
|
+
})),
|
|
8821
|
+
...programs.map((p) => ({
|
|
8822
|
+
label: `Open ${p.name}`,
|
|
8823
|
+
hint: "program",
|
|
8824
|
+
run: () => open("program", p.id)
|
|
8825
|
+
})),
|
|
8826
|
+
...portfolios.map((p) => ({
|
|
8827
|
+
label: `Open ${p.name}`,
|
|
8828
|
+
hint: "portfolio",
|
|
8829
|
+
run: () => open("portfolio", p.id)
|
|
8830
|
+
}))
|
|
8831
|
+
], [
|
|
8832
|
+
base,
|
|
8833
|
+
projects,
|
|
8834
|
+
programs,
|
|
8835
|
+
portfolios,
|
|
8836
|
+
open
|
|
8837
|
+
]);
|
|
8838
|
+
const hits = useMemo(() => matchCommands(all, query, 12), [all, query]);
|
|
8839
|
+
useInput((input, key) => {
|
|
8840
|
+
if (key.escape) return onClose();
|
|
8841
|
+
if (key.upArrow) return setAt((i) => step(i, -1, hits.length));
|
|
8842
|
+
if (key.downArrow) return setAt((i) => step(i, 1, hits.length));
|
|
8843
|
+
if (key.return) {
|
|
8844
|
+
const hit = hits[Math.min(at, hits.length - 1)];
|
|
8845
|
+
onClose();
|
|
8846
|
+
if (hit) hit.run();
|
|
8847
|
+
return;
|
|
8486
8848
|
}
|
|
8487
|
-
|
|
8488
|
-
|
|
8489
|
-
|
|
8490
|
-
|
|
8491
|
-
|
|
8492
|
-
|
|
8493
|
-
|
|
8494
|
-
|
|
8849
|
+
if (key.backspace || key.delete) {
|
|
8850
|
+
setAt(0);
|
|
8851
|
+
return setQuery((q) => q.slice(0, -1));
|
|
8852
|
+
}
|
|
8853
|
+
if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
|
|
8854
|
+
if (input) {
|
|
8855
|
+
setAt(0);
|
|
8856
|
+
setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
|
|
8857
|
+
}
|
|
8858
|
+
});
|
|
8859
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
8860
|
+
flexDirection: "column",
|
|
8495
8861
|
width,
|
|
8496
|
-
|
|
8497
|
-
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
|
|
8502
|
-
|
|
8503
|
-
|
|
8504
|
-
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
|
|
8862
|
+
borderStyle: "round",
|
|
8863
|
+
borderColor: theme.border,
|
|
8864
|
+
paddingX: 1,
|
|
8865
|
+
children: [
|
|
8866
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
8867
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
8868
|
+
color: theme.accent,
|
|
8869
|
+
bold: true,
|
|
8870
|
+
children: [":", " "]
|
|
8871
|
+
}),
|
|
8872
|
+
query || /* @__PURE__ */ jsx(Text, {
|
|
8873
|
+
color: theme.muted,
|
|
8874
|
+
children: "a screen, “new …”, or a project, program or portfolio by name"
|
|
8875
|
+
}),
|
|
8876
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8877
|
+
inverse: true,
|
|
8878
|
+
children: " "
|
|
8879
|
+
})
|
|
8880
|
+
] }),
|
|
8881
|
+
hits.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
8509
8882
|
color: theme.muted,
|
|
8510
|
-
children:
|
|
8883
|
+
children: "Nothing matches."
|
|
8884
|
+
}) : null,
|
|
8885
|
+
hits.map((c, i) => {
|
|
8886
|
+
const on = i === Math.min(at, hits.length - 1);
|
|
8887
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
8888
|
+
color: on ? theme.selected : void 0,
|
|
8889
|
+
bold: on,
|
|
8890
|
+
wrap: "truncate-end",
|
|
8891
|
+
children: [
|
|
8892
|
+
on ? "› " : " ",
|
|
8893
|
+
c.label,
|
|
8894
|
+
c.hint ? /* @__PURE__ */ jsxs(Text, {
|
|
8895
|
+
color: theme.muted,
|
|
8896
|
+
children: [" · ", c.hint]
|
|
8897
|
+
}) : null
|
|
8898
|
+
]
|
|
8899
|
+
}, `${c.label}-${i}`);
|
|
8511
8900
|
}),
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
children:
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
" ",
|
|
8518
|
-
/* @__PURE__ */ jsxs(Text, {
|
|
8519
|
-
color: theme.muted,
|
|
8520
|
-
children: [a.percent, "%"]
|
|
8521
|
-
})
|
|
8522
|
-
]
|
|
8523
|
-
}, a.programId))
|
|
8524
|
-
] }) : null,
|
|
8525
|
-
active,
|
|
8526
|
-
onClose
|
|
8901
|
+
/* @__PURE__ */ jsx(Text, {
|
|
8902
|
+
color: theme.muted,
|
|
8903
|
+
children: "↑↓ choose · Enter go · Esc close"
|
|
8904
|
+
})
|
|
8905
|
+
]
|
|
8527
8906
|
});
|
|
8528
8907
|
}
|
|
8529
8908
|
|