@harborppm/cli 0.21.0 → 0.22.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 +5 -2
- package/dist/app.mjs +2757 -1678
- package/lib/cli.mjs +6 -0
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -688,6 +688,30 @@ function startOfMonth(date, options) {
|
|
|
688
688
|
return _date;
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region node_modules/date-fns/isBefore.js
|
|
693
|
+
/**
|
|
694
|
+
* @name isBefore
|
|
695
|
+
* @category Common Helpers
|
|
696
|
+
* @summary Is the first date before the second one?
|
|
697
|
+
*
|
|
698
|
+
* @description
|
|
699
|
+
* Is the first date before the second one?
|
|
700
|
+
*
|
|
701
|
+
* @param date - The date that should be before the other one to return true
|
|
702
|
+
* @param dateToCompare - The date to compare with
|
|
703
|
+
*
|
|
704
|
+
* @returns The first date is before the second date
|
|
705
|
+
*
|
|
706
|
+
* @example
|
|
707
|
+
* // Is 10 July 1989 before 11 February 1987?
|
|
708
|
+
* const result = isBefore(new Date(1989, 6, 10), new Date(1987, 1, 11))
|
|
709
|
+
* //=> false
|
|
710
|
+
*/
|
|
711
|
+
function isBefore(date, dateToCompare) {
|
|
712
|
+
return +toDate(date) < +toDate(dateToCompare);
|
|
713
|
+
}
|
|
714
|
+
|
|
691
715
|
//#endregion
|
|
692
716
|
//#region node_modules/date-fns/subDays.js
|
|
693
717
|
/**
|
|
@@ -738,6 +762,16 @@ function money(n) {
|
|
|
738
762
|
function shortDate(value) {
|
|
739
763
|
return format(parseDate$1(value), "MMM d");
|
|
740
764
|
}
|
|
765
|
+
function isOverdue(value, done = false) {
|
|
766
|
+
if (!value || done) return false;
|
|
767
|
+
try {
|
|
768
|
+
const d = parseDate$1(value);
|
|
769
|
+
if (Number.isNaN(d.getTime())) return false;
|
|
770
|
+
return isBefore(d, startOfDay(clockNow()));
|
|
771
|
+
} catch {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
741
775
|
|
|
742
776
|
//#endregion
|
|
743
777
|
//#region src/lib/dates.ts
|
|
@@ -802,6 +836,26 @@ const IMPACT_LABEL = [
|
|
|
802
836
|
"Major",
|
|
803
837
|
"Severe"
|
|
804
838
|
];
|
|
839
|
+
/** What each kind calls its fields. Missing keys mean the kind doesn't have that field. */
|
|
840
|
+
const FIELD_COPY = {
|
|
841
|
+
risk: {
|
|
842
|
+
description: "What could happen",
|
|
843
|
+
mitigation: "How we reduce it",
|
|
844
|
+
contingency: "If it happens",
|
|
845
|
+
dueDate: "Review by"
|
|
846
|
+
},
|
|
847
|
+
issue: {
|
|
848
|
+
description: "What's going on",
|
|
849
|
+
resolution: "How it gets resolved",
|
|
850
|
+
dueDate: "Needs an answer by"
|
|
851
|
+
},
|
|
852
|
+
decision: {
|
|
853
|
+
description: "Context",
|
|
854
|
+
resolution: "The decision",
|
|
855
|
+
rationale: "Why",
|
|
856
|
+
dueDate: "Decide by"
|
|
857
|
+
}
|
|
858
|
+
};
|
|
805
859
|
function clampScale(n) {
|
|
806
860
|
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
|
807
861
|
return Math.min(5, Math.max(1, Math.round(n)));
|
|
@@ -917,6 +971,16 @@ const PRIORITY_LABEL = {
|
|
|
917
971
|
* (a Stepper shows them) and the store action the answers run, the same
|
|
918
972
|
* actions the website's own forms call.
|
|
919
973
|
*/
|
|
974
|
+
const scale = (labels) => [
|
|
975
|
+
1,
|
|
976
|
+
2,
|
|
977
|
+
3,
|
|
978
|
+
4,
|
|
979
|
+
5
|
|
980
|
+
].map((n) => ({
|
|
981
|
+
label: `${n} ${labels[n]}`,
|
|
982
|
+
value: n
|
|
983
|
+
}));
|
|
920
984
|
const plus = (today, days) => format(addDays(parseIso(today), days), "yyyy-MM-dd");
|
|
921
985
|
/** The colour a new project or program takes: the first one its neighbours don't use. */
|
|
922
986
|
function nextSwatch(used) {
|
|
@@ -1032,6 +1096,22 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
1032
1096
|
label: "Title",
|
|
1033
1097
|
kind: "text"
|
|
1034
1098
|
},
|
|
1099
|
+
{
|
|
1100
|
+
key: "probability",
|
|
1101
|
+
label: "Likelihood",
|
|
1102
|
+
kind: "pick",
|
|
1103
|
+
choices: scale(PROBABILITY_LABEL),
|
|
1104
|
+
initial: 2,
|
|
1105
|
+
when: (a) => a.kind === "risk"
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
key: "impact",
|
|
1109
|
+
label: "Impact",
|
|
1110
|
+
kind: "pick",
|
|
1111
|
+
choices: scale(IMPACT_LABEL),
|
|
1112
|
+
initial: 2,
|
|
1113
|
+
when: (a) => a.kind !== "decision"
|
|
1114
|
+
},
|
|
1035
1115
|
...ctx.projectId ? [] : [{
|
|
1036
1116
|
key: "projectId",
|
|
1037
1117
|
label: "Project",
|
|
@@ -1121,7 +1201,9 @@ function commitCreate(store, kind, v, ctx, today) {
|
|
|
1121
1201
|
title: str("title"),
|
|
1122
1202
|
projectId,
|
|
1123
1203
|
programId: project?.programId ?? null,
|
|
1124
|
-
ownerId: str("ownerId") || null
|
|
1204
|
+
ownerId: str("ownerId") || null,
|
|
1205
|
+
...typeof v.probability === "number" ? { probability: v.probability } : {},
|
|
1206
|
+
...typeof v.impact === "number" ? { impact: v.impact } : {}
|
|
1125
1207
|
});
|
|
1126
1208
|
}
|
|
1127
1209
|
case "spend":
|
|
@@ -1511,6 +1593,71 @@ function forecastAtCompletion(item, now) {
|
|
|
1511
1593
|
|
|
1512
1594
|
//#endregion
|
|
1513
1595
|
//#region src/lib/flow.ts
|
|
1596
|
+
const WINDOW_DAYS = 30;
|
|
1597
|
+
/** Below this many finished tasks, or a week of history, Flow guesses no wait. */
|
|
1598
|
+
const FLOW_MIN_FINISHED = 3;
|
|
1599
|
+
const FLOW_MIN_HISTORY_DAYS = 7;
|
|
1600
|
+
function parse(value) {
|
|
1601
|
+
return parseIso(value.length === 10 ? `${value}T12:00:00` : value);
|
|
1602
|
+
}
|
|
1603
|
+
function formatWait(days) {
|
|
1604
|
+
if (days < 1.5) return "about a day";
|
|
1605
|
+
if (days < 11) return `about ${Math.round(days)} days`;
|
|
1606
|
+
const weeks = Math.round(days / 7);
|
|
1607
|
+
if (weeks < 10) return `about ${weeks} week${weeks === 1 ? "" : "s"}`;
|
|
1608
|
+
const months = Math.max(1, Math.round(days / 30));
|
|
1609
|
+
return `about ${months} month${months === 1 ? "" : "s"}`;
|
|
1610
|
+
}
|
|
1611
|
+
function flowOf(tasks, now = clockNow(), windowDays = WINDOW_DAYS) {
|
|
1612
|
+
const wip = tasks.filter((t) => t.status === "doing" || t.status === "review").length;
|
|
1613
|
+
const start = subDays(now, windowDays);
|
|
1614
|
+
const finished = tasks.filter((t) => {
|
|
1615
|
+
if (!t.completedAt) return false;
|
|
1616
|
+
try {
|
|
1617
|
+
return parse(t.completedAt) >= start;
|
|
1618
|
+
} catch {
|
|
1619
|
+
return false;
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
const doneInWindow = finished.length;
|
|
1623
|
+
const throughputPerDay = doneInWindow / windowDays;
|
|
1624
|
+
const settled = doneInWindow >= 3 && historyDays(tasks, now) >= 7;
|
|
1625
|
+
const impliedDays = settled && throughputPerDay > 0 ? wip / throughputPerDay : null;
|
|
1626
|
+
const cycles = finished.map((t) => {
|
|
1627
|
+
if (!t.completedAt) return null;
|
|
1628
|
+
const n = differenceInCalendarDays(parse(t.completedAt), parse(t.createdAt));
|
|
1629
|
+
return n >= 0 ? n : null;
|
|
1630
|
+
}).filter((n) => n != null);
|
|
1631
|
+
const historicalCycleDays = settled && cycles.length > 0 ? Math.round(cycles.reduce((a, b) => a + b, 0) / cycles.length) : null;
|
|
1632
|
+
let sentence;
|
|
1633
|
+
if (wip === 0 && doneInWindow === 0) sentence = "Quiet. Nothing in play, nothing finished. Name a constraint — or enjoy it.";
|
|
1634
|
+
else if (wip === 0) sentence = `${doneInWindow} finished in ${windowDays} days and nothing in play. Spare capacity, or a starved constraint.`;
|
|
1635
|
+
else if (doneInWindow > 0 && !settled) sentence = `${wip} in play. ${doneInWindow} finished so far, too little to read a wait from yet.`;
|
|
1636
|
+
else if (impliedDays == null) sentence = `${wip} in play. Nothing finished in ${windowDays} days. The wait is unknown until something ships.`;
|
|
1637
|
+
else sentence = `${wip} in play. ${doneInWindow} finished in ${windowDays} days. At this load, ${formatWait(impliedDays)}.`;
|
|
1638
|
+
return {
|
|
1639
|
+
wip,
|
|
1640
|
+
doneInWindow,
|
|
1641
|
+
windowDays,
|
|
1642
|
+
throughputPerDay,
|
|
1643
|
+
impliedDays,
|
|
1644
|
+
historicalCycleDays,
|
|
1645
|
+
settled,
|
|
1646
|
+
sentence
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
/** Days from the oldest task (made or finished) to now. */
|
|
1650
|
+
function historyDays(tasks, now) {
|
|
1651
|
+
let oldest = null;
|
|
1652
|
+
for (const t of tasks) for (const v of [t.createdAt, t.completedAt]) {
|
|
1653
|
+
if (!v) continue;
|
|
1654
|
+
try {
|
|
1655
|
+
const d = parse(v);
|
|
1656
|
+
if (!Number.isNaN(d.getTime()) && (!oldest || d < oldest)) oldest = d;
|
|
1657
|
+
} catch {}
|
|
1658
|
+
}
|
|
1659
|
+
return oldest ? differenceInCalendarDays(now, oldest) : 0;
|
|
1660
|
+
}
|
|
1514
1661
|
function tasksInProgramOf(data, programId) {
|
|
1515
1662
|
const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
|
|
1516
1663
|
return data.tasks.filter((t) => ids.has(t.projectId));
|
|
@@ -2258,6 +2405,76 @@ function projectBands(data, programId, today, showDone) {
|
|
|
2258
2405
|
risks: worst(data.raidItems.filter((r) => r.projectId === p.id), 2)
|
|
2259
2406
|
}));
|
|
2260
2407
|
}
|
|
2408
|
+
/**
|
|
2409
|
+
* Text broken into lines no wider than `width`, at spaces where it can. A
|
|
2410
|
+
* bulleted line ("• …") carries on under its words, not under the bullet.
|
|
2411
|
+
*/
|
|
2412
|
+
function wrapLines(text, width) {
|
|
2413
|
+
const w = Math.max(10, width);
|
|
2414
|
+
const out = [];
|
|
2415
|
+
for (const line of text.split("\n")) {
|
|
2416
|
+
const hang = /^(•|-|\d+\.) /.exec(line)?.[0].replace(/./g, " ") ?? "";
|
|
2417
|
+
let rest = line;
|
|
2418
|
+
let first = true;
|
|
2419
|
+
do {
|
|
2420
|
+
const lead = first ? "" : hang;
|
|
2421
|
+
const room = w - lead.length;
|
|
2422
|
+
if (rest.length <= room) {
|
|
2423
|
+
out.push(lead + rest);
|
|
2424
|
+
break;
|
|
2425
|
+
}
|
|
2426
|
+
const cut = rest.lastIndexOf(" ", room);
|
|
2427
|
+
const at = cut > room / 3 ? cut : room;
|
|
2428
|
+
out.push(lead + rest.slice(0, at));
|
|
2429
|
+
rest = rest.slice(at).replace(/^ +/, "");
|
|
2430
|
+
first = false;
|
|
2431
|
+
} while (rest);
|
|
2432
|
+
}
|
|
2433
|
+
return out;
|
|
2434
|
+
}
|
|
2435
|
+
/** The first of the month a day falls in. */
|
|
2436
|
+
function monthOf(iso) {
|
|
2437
|
+
return `${iso.slice(0, 7)}-01`;
|
|
2438
|
+
}
|
|
2439
|
+
/** A month moved on (or back, with a negative step), as its first day. */
|
|
2440
|
+
function shiftMonth(month, step) {
|
|
2441
|
+
const [y, m] = month.split("-").map(Number);
|
|
2442
|
+
const n = y * 12 + (m - 1) + step;
|
|
2443
|
+
return `${Math.floor(n / 12)}-${String(n % 12 + 1).padStart(2, "0")}-01`;
|
|
2444
|
+
}
|
|
2445
|
+
/** The month as the website's calendar lays it out: whole weeks, Sunday first. */
|
|
2446
|
+
function monthWeeks(month) {
|
|
2447
|
+
const first = parseIso(month);
|
|
2448
|
+
const start = addDays(first, -first.getDay());
|
|
2449
|
+
const next = parseIso(shiftMonth(month, 1));
|
|
2450
|
+
const weeks = [];
|
|
2451
|
+
for (let d = start; d < next;) {
|
|
2452
|
+
const week = [];
|
|
2453
|
+
for (let i = 0; i < 7; i++, d = addDays(d, 1)) week.push(format(d, "yyyy-MM-dd"));
|
|
2454
|
+
weeks.push(week);
|
|
2455
|
+
}
|
|
2456
|
+
return weeks;
|
|
2457
|
+
}
|
|
2458
|
+
/** What's due on each day: tasks (done ones too, as on the website) and milestones. */
|
|
2459
|
+
function dueByDay(data) {
|
|
2460
|
+
const tasks = /* @__PURE__ */ new Map();
|
|
2461
|
+
for (const t of data.tasks) {
|
|
2462
|
+
if (!t.dueDate) continue;
|
|
2463
|
+
const list = tasks.get(t.dueDate) ?? [];
|
|
2464
|
+
list.push(t);
|
|
2465
|
+
tasks.set(t.dueDate, list);
|
|
2466
|
+
}
|
|
2467
|
+
const milestones = /* @__PURE__ */ new Map();
|
|
2468
|
+
for (const m of data.milestones) {
|
|
2469
|
+
const list = milestones.get(m.dueDate) ?? [];
|
|
2470
|
+
list.push(m);
|
|
2471
|
+
milestones.set(m.dueDate, list);
|
|
2472
|
+
}
|
|
2473
|
+
return {
|
|
2474
|
+
tasks,
|
|
2475
|
+
milestones
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2261
2478
|
|
|
2262
2479
|
//#endregion
|
|
2263
2480
|
//#region src/tui/theme.ts
|
|
@@ -2583,6 +2800,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2583
2800
|
if (run) run();
|
|
2584
2801
|
}, { isActive: active && editing === null });
|
|
2585
2802
|
const field = editing != null ? fields[editing] : null;
|
|
2803
|
+
const pad = Math.min(22, Math.max(12, ...fields.map((f) => f.label.length + 1)));
|
|
2586
2804
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2587
2805
|
flexDirection: "column",
|
|
2588
2806
|
width,
|
|
@@ -2611,7 +2829,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2611
2829
|
on ? "› " : " ",
|
|
2612
2830
|
/* @__PURE__ */ jsx(Text, {
|
|
2613
2831
|
color: theme.muted,
|
|
2614
|
-
children: f.label.padEnd(
|
|
2832
|
+
children: f.label.padEnd(pad)
|
|
2615
2833
|
}),
|
|
2616
2834
|
/* @__PURE__ */ jsx(Text, {
|
|
2617
2835
|
color: on ? void 0 : f.color,
|
|
@@ -2648,7 +2866,9 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
|
|
|
2648
2866
|
const [at, setAt] = useState(0);
|
|
2649
2867
|
const [values, setValues] = useState({});
|
|
2650
2868
|
const [error, setError] = useState(null);
|
|
2651
|
-
const
|
|
2869
|
+
const asked = (answers) => steps.filter((s) => !s.when || s.when(answers));
|
|
2870
|
+
const shown = asked(values);
|
|
2871
|
+
const step = shown[at];
|
|
2652
2872
|
const next = (value) => {
|
|
2653
2873
|
const all = {
|
|
2654
2874
|
...values,
|
|
@@ -2656,10 +2876,10 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
|
|
|
2656
2876
|
};
|
|
2657
2877
|
setValues(all);
|
|
2658
2878
|
setError(null);
|
|
2659
|
-
if (at + 1 <
|
|
2879
|
+
if (at + 1 < asked(all).length) setAt(at + 1);
|
|
2660
2880
|
else onDone(all);
|
|
2661
2881
|
};
|
|
2662
|
-
const label = `${title} · ${step.label} (${at + 1} of ${
|
|
2882
|
+
const label = `${title} · ${step.label} (${at + 1} of ${shown.length})`;
|
|
2663
2883
|
const done = Object.entries(values).map(([k, v]) => {
|
|
2664
2884
|
const s = steps.find((x) => x.key === k);
|
|
2665
2885
|
if (!s || v == null || v === "") return null;
|
|
@@ -2706,756 +2926,918 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
|
|
|
2706
2926
|
}
|
|
2707
2927
|
|
|
2708
2928
|
//#endregion
|
|
2709
|
-
//#region src/lib/history
|
|
2710
|
-
/**
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
milestone: "milestone",
|
|
2721
|
-
risk: "risk",
|
|
2722
|
-
issue: "issue",
|
|
2723
|
-
decision: "decision"
|
|
2724
|
-
};
|
|
2725
|
-
const CREATED = {
|
|
2726
|
-
risk: "raised risk",
|
|
2727
|
-
issue: "logged issue",
|
|
2728
|
-
decision: "recorded decision"
|
|
2729
|
-
};
|
|
2730
|
-
const whole = new Intl.NumberFormat("en-US", {
|
|
2731
|
-
style: "currency",
|
|
2732
|
-
currency: "USD",
|
|
2733
|
-
maximumFractionDigits: 0
|
|
2734
|
-
});
|
|
2735
|
-
function dollars(value) {
|
|
2736
|
-
const n = Number(value ?? 0);
|
|
2737
|
-
return whole.format(Number.isFinite(n) ? n : 0);
|
|
2929
|
+
//#region src/lib/health-history.ts
|
|
2930
|
+
/** The history with one more line when `health` differs from the last recorded one. Same array otherwise. */
|
|
2931
|
+
function recordHealth(history, health, at, override, note) {
|
|
2932
|
+
const last = history?.[history.length - 1];
|
|
2933
|
+
if (last && last.health === health && last.override === override && (last.note ?? null) === (note ?? null)) return history;
|
|
2934
|
+
return [...history ?? [], {
|
|
2935
|
+
at,
|
|
2936
|
+
health,
|
|
2937
|
+
override,
|
|
2938
|
+
note: note ?? null
|
|
2939
|
+
}].slice(-30);
|
|
2738
2940
|
}
|
|
2739
|
-
|
|
2740
|
-
|
|
2941
|
+
|
|
2942
|
+
//#endregion
|
|
2943
|
+
//#region src/lib/derived-health.ts
|
|
2944
|
+
const iso$2 = (d) => {
|
|
2945
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
2946
|
+
};
|
|
2947
|
+
function parseDate(value) {
|
|
2948
|
+
return parseIso(value.length === 10 ? `${value}T12:00:00` : value);
|
|
2741
2949
|
}
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
const a = Date.parse(`${from.slice(0, 10)}T12:00:00Z`);
|
|
2745
|
-
const b = Date.parse(`${to.slice(0, 10)}T12:00:00Z`);
|
|
2746
|
-
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
|
|
2747
|
-
return Math.round((b - a) / 864e5);
|
|
2950
|
+
function isBlocked(task, tasks) {
|
|
2951
|
+
return blockersIn(task, tasks).some((b) => b.status !== "done");
|
|
2748
2952
|
}
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
const
|
|
2752
|
-
|
|
2753
|
-
const n = Math.abs(d);
|
|
2754
|
-
return ` (${n} day${n === 1 ? "" : "s"} ${d > 0 ? "later" : "earlier"})`;
|
|
2953
|
+
/** A project with no tasks yet, or any task still open, hasn't cleared. */
|
|
2954
|
+
function projectHasOpenWork(data, projectId) {
|
|
2955
|
+
const tasks = tasksOf(data.tasks, projectId);
|
|
2956
|
+
return tasks.length === 0 || tasks.some((t) => t.status !== "done");
|
|
2755
2957
|
}
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2958
|
+
/** Every task finished (and at least one): the project reads as ready to be marked Done. */
|
|
2959
|
+
function allTasksDone(allTasks, projectId) {
|
|
2960
|
+
const tasks = tasksOf(allTasks, projectId);
|
|
2961
|
+
return tasks.length > 0 && tasks.every((t) => t.status === "done");
|
|
2759
2962
|
}
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
return STATUS_LABEL$1[value] ?? value;
|
|
2963
|
+
/** A project whose work is all done but isn't marked Done yet: offer to finish it. */
|
|
2964
|
+
function suggestsDone(project, tasks) {
|
|
2965
|
+
return project.health !== "done" && allTasksDone(tasks, project.id);
|
|
2764
2966
|
}
|
|
2765
2967
|
/**
|
|
2766
|
-
*
|
|
2767
|
-
*
|
|
2968
|
+
* A hand-set health that overrules the rules: On track, At risk or Off track.
|
|
2969
|
+
* Paused and Done are states someone puts a project in, so they don't read
|
|
2970
|
+
* as an override.
|
|
2768
2971
|
*/
|
|
2769
|
-
function
|
|
2770
|
-
const
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2972
|
+
function overridesTrack(item) {
|
|
2973
|
+
const h = item.healthOverride;
|
|
2974
|
+
return h === "on-track" || h === "at-risk" || h === "off-track";
|
|
2975
|
+
}
|
|
2976
|
+
/** Every project this one waits on that still exists. */
|
|
2977
|
+
function projectBlockersOf(data, project) {
|
|
2978
|
+
return blockersIn(project, data.projects);
|
|
2979
|
+
}
|
|
2980
|
+
/** The blockers with open work left: the ones actually holding it up. */
|
|
2981
|
+
function projectOpenBlockers(data, project) {
|
|
2982
|
+
return projectBlockersOf(data, project).filter((b) => projectHasOpenWork(data, b.id));
|
|
2983
|
+
}
|
|
2984
|
+
function projectIsBlocked(data, project) {
|
|
2985
|
+
return projectOpenBlockers(data, project).length > 0;
|
|
2986
|
+
}
|
|
2987
|
+
/** A program with no projects yet, or any project still open, hasn't cleared. */
|
|
2988
|
+
function programHasOpenWork(data, programId) {
|
|
2989
|
+
const projects = data.projects.filter((p) => p.programId === programId);
|
|
2990
|
+
return projects.length === 0 || projects.some((p) => projectHasOpenWork(data, p.id));
|
|
2991
|
+
}
|
|
2992
|
+
function programBlockersOf(data, program) {
|
|
2993
|
+
return blockersIn(program, data.programs);
|
|
2994
|
+
}
|
|
2995
|
+
function programOpenBlockers(data, program) {
|
|
2996
|
+
return programBlockersOf(data, program).filter((b) => programHasOpenWork(data, b.id));
|
|
2997
|
+
}
|
|
2998
|
+
function programIsBlocked(data, program) {
|
|
2999
|
+
return programOpenBlockers(data, program).length > 0;
|
|
3000
|
+
}
|
|
3001
|
+
let overdueDay = "";
|
|
3002
|
+
const overdueByDue = /* @__PURE__ */ new Map();
|
|
3003
|
+
function daysOverdue(dueDate, now) {
|
|
3004
|
+
if (!dueDate) return 0;
|
|
3005
|
+
const today = iso$2(now);
|
|
3006
|
+
if (today !== overdueDay) {
|
|
3007
|
+
overdueDay = today;
|
|
3008
|
+
overdueByDue.clear();
|
|
2780
3009
|
}
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
const { from, to } = e;
|
|
2790
|
-
switch (e.field) {
|
|
2791
|
-
case "name":
|
|
2792
|
-
case "title": return {
|
|
2793
|
-
action: `renamed ${noun}`,
|
|
2794
|
-
detail: from ? `from “${from}”` : null
|
|
2795
|
-
};
|
|
2796
|
-
case "status": return e.entity === "task" ? {
|
|
2797
|
-
action: "moved",
|
|
2798
|
-
detail: `from ${statusLabel(e.entity, from)} to ${statusLabel(e.entity, to)}`
|
|
2799
|
-
} : {
|
|
2800
|
-
action: "set",
|
|
2801
|
-
detail: `to ${statusLabel(e.entity, to)}`
|
|
2802
|
-
};
|
|
2803
|
-
case "assigneeId": return to ? {
|
|
2804
|
-
action: "assigned",
|
|
2805
|
-
detail: `to ${nameOf$1(names, to)}`
|
|
2806
|
-
} : {
|
|
2807
|
-
action: "unassigned",
|
|
2808
|
-
detail: from ? `from ${nameOf$1(names, from)}` : null
|
|
2809
|
-
};
|
|
2810
|
-
case "ownerId": return to ? {
|
|
2811
|
-
action: "handed",
|
|
2812
|
-
detail: `to ${nameOf$1(names, to)}`
|
|
2813
|
-
} : {
|
|
2814
|
-
action: "cleared the owner of",
|
|
2815
|
-
detail: null
|
|
2816
|
-
};
|
|
2817
|
-
case "dueDate": return {
|
|
2818
|
-
action: "moved the due date of",
|
|
2819
|
-
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
2820
|
-
};
|
|
2821
|
-
case "startDate": return {
|
|
2822
|
-
action: "moved the start of",
|
|
2823
|
-
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
2824
|
-
};
|
|
2825
|
-
case "endDate": return {
|
|
2826
|
-
action: "moved the end of",
|
|
2827
|
-
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
2828
|
-
};
|
|
2829
|
-
case "budget": return {
|
|
2830
|
-
action: "changed the budget of",
|
|
2831
|
-
detail: `from ${dollars(from)} to ${dollars(to)}`
|
|
2832
|
-
};
|
|
2833
|
-
case "spent": return {
|
|
2834
|
-
action: "moved spend on",
|
|
2835
|
-
detail: `from ${dollars(from)} to ${dollars(to)}`
|
|
2836
|
-
};
|
|
2837
|
-
case "priority": return {
|
|
2838
|
-
action: "set the priority of",
|
|
2839
|
-
detail: `to ${to ? PRIORITY_LABEL[to] ?? to : "none"}`
|
|
2840
|
-
};
|
|
2841
|
-
case "done": return to === "true" ? {
|
|
2842
|
-
action: "reached",
|
|
2843
|
-
detail: null
|
|
2844
|
-
} : {
|
|
2845
|
-
action: "reopened",
|
|
2846
|
-
detail: null
|
|
2847
|
-
};
|
|
2848
|
-
case "approvalStatus":
|
|
2849
|
-
if (to === "approved") return {
|
|
2850
|
-
action: "approved",
|
|
2851
|
-
detail: null
|
|
2852
|
-
};
|
|
2853
|
-
if (to === "changes_requested") return {
|
|
2854
|
-
action: "asked for changes to",
|
|
2855
|
-
detail: null
|
|
2856
|
-
};
|
|
2857
|
-
if (to === "pending") return {
|
|
2858
|
-
action: "asked for sign-off on",
|
|
2859
|
-
detail: null
|
|
2860
|
-
};
|
|
2861
|
-
return {
|
|
2862
|
-
action: "withdrew the sign-off on",
|
|
2863
|
-
detail: null
|
|
2864
|
-
};
|
|
2865
|
-
case "probability":
|
|
2866
|
-
case "impact": return {
|
|
2867
|
-
action: "rescored",
|
|
2868
|
-
detail: `${e.field} ${from ?? "unset"} → ${to ?? "unset"}`
|
|
2869
|
-
};
|
|
2870
|
-
case "healthOverride": return to ? {
|
|
2871
|
-
action: "marked",
|
|
2872
|
-
detail: `as ${HEALTH_LABEL$1[to] ?? to}${e.note ? `: “${e.note}”` : ""}`
|
|
2873
|
-
} : {
|
|
2874
|
-
action: "cleared the health override on",
|
|
2875
|
-
detail: null
|
|
2876
|
-
};
|
|
2877
|
-
case "programId": return {
|
|
2878
|
-
action: "moved",
|
|
2879
|
-
detail: `to ${nameOf$1(names, to, "another program")}`
|
|
2880
|
-
};
|
|
2881
|
-
case "portfolioId": return {
|
|
2882
|
-
action: "moved",
|
|
2883
|
-
detail: `to ${nameOf$1(names, to, "another portfolio")}`
|
|
2884
|
-
};
|
|
2885
|
-
case "blockedByIds": {
|
|
2886
|
-
const ids = to ? to.split(",") : [];
|
|
2887
|
-
return ids.length ? {
|
|
2888
|
-
action: "set",
|
|
2889
|
-
detail: `to wait on ${nameList(ids.map((id) => nameOf$1(names, id, "a deleted item")))}`
|
|
2890
|
-
} : {
|
|
2891
|
-
action: "cleared the blockers on",
|
|
2892
|
-
detail: null
|
|
2893
|
-
};
|
|
2894
|
-
}
|
|
2895
|
-
default: return {
|
|
2896
|
-
action: `changed ${e.field ?? "something on"}`,
|
|
2897
|
-
detail: null
|
|
2898
|
-
};
|
|
3010
|
+
const hit = overdueByDue.get(dueDate);
|
|
3011
|
+
if (hit !== void 0) return hit;
|
|
3012
|
+
let days = 0;
|
|
3013
|
+
try {
|
|
3014
|
+
const n = differenceInCalendarDays(startOfDay(now), startOfDay(parseDate(dueDate)));
|
|
3015
|
+
days = n > 0 ? n : 0;
|
|
3016
|
+
} catch {
|
|
3017
|
+
days = 0;
|
|
2899
3018
|
}
|
|
3019
|
+
overdueByDue.set(dueDate, days);
|
|
3020
|
+
return days;
|
|
2900
3021
|
}
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
3022
|
+
let untilDay = "";
|
|
3023
|
+
const untilByDate = /* @__PURE__ */ new Map();
|
|
3024
|
+
function daysUntil$1(date, now) {
|
|
3025
|
+
if (!date) return null;
|
|
3026
|
+
const today = iso$2(now);
|
|
3027
|
+
if (today !== untilDay) {
|
|
3028
|
+
untilDay = today;
|
|
3029
|
+
untilByDate.clear();
|
|
3030
|
+
}
|
|
3031
|
+
const hit = untilByDate.get(date);
|
|
3032
|
+
if (hit !== void 0) return hit;
|
|
3033
|
+
let days;
|
|
3034
|
+
try {
|
|
3035
|
+
days = differenceInCalendarDays(startOfDay(parseDate(date)), startOfDay(now));
|
|
3036
|
+
} catch {
|
|
3037
|
+
days = null;
|
|
3038
|
+
}
|
|
3039
|
+
untilByDate.set(date, days);
|
|
3040
|
+
return days;
|
|
2906
3041
|
}
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
task.status === "done" ? /* @__PURE__ */ jsx(Text, {
|
|
2928
|
-
strikethrough: true,
|
|
2929
|
-
children: task.title
|
|
2930
|
-
}) : task.title,
|
|
2931
|
-
" ",
|
|
2932
|
-
/* @__PURE__ */ jsx(PriorityMark, { priority: task.priority }),
|
|
2933
|
-
task.dueDate ? /* @__PURE__ */ jsxs(Text, {
|
|
2934
|
-
color: late ? theme.bad : theme.muted,
|
|
2935
|
-
children: [
|
|
2936
|
-
" ",
|
|
2937
|
-
late ? "late · " : "",
|
|
2938
|
-
shortDay(task.dueDate, today)
|
|
2939
|
-
]
|
|
2940
|
-
}) : null,
|
|
2941
|
-
blocked ? /* @__PURE__ */ jsx(Text, {
|
|
2942
|
-
color: theme.warn,
|
|
2943
|
-
children: " waits"
|
|
2944
|
-
}) : null,
|
|
2945
|
-
who ? /* @__PURE__ */ jsxs(Text, {
|
|
2946
|
-
color: theme.muted,
|
|
2947
|
-
children: [" · ", who.name]
|
|
2948
|
-
}) : null,
|
|
2949
|
-
project ? /* @__PURE__ */ jsxs(Text, {
|
|
2950
|
-
color: theme.muted,
|
|
2951
|
-
children: [" · ", project.name]
|
|
2952
|
-
}) : null
|
|
2953
|
-
]
|
|
2954
|
-
})
|
|
3042
|
+
function incomplete(task) {
|
|
3043
|
+
return task.status !== "done";
|
|
3044
|
+
}
|
|
3045
|
+
function lateTasksOf(tasks, now) {
|
|
3046
|
+
return tasks.filter((t) => incomplete(t) && daysOverdue(t.dueDate, now) >= 1);
|
|
3047
|
+
}
|
|
3048
|
+
function wipLimitOf(status, stored, uncapped) {
|
|
3049
|
+
if (stored != null) return stored;
|
|
3050
|
+
if (uncapped === true) return null;
|
|
3051
|
+
if (status === "doing") return 3;
|
|
3052
|
+
if (status === "review") return 2;
|
|
3053
|
+
return null;
|
|
3054
|
+
}
|
|
3055
|
+
function wipOverflowing(data, projectId) {
|
|
3056
|
+
const cols = data.boardConfigs?.[projectId]?.columns;
|
|
3057
|
+
if (cols?.length) return cols.some((c) => {
|
|
3058
|
+
if (c.visible === false) return false;
|
|
3059
|
+
const limit = wipLimitOf(c.status, c.wipLimit, c.uncapped);
|
|
3060
|
+
if (limit == null) return false;
|
|
3061
|
+
return tasksOf(data.tasks, projectId).filter((t) => t.status === c.status).length > limit;
|
|
2955
3062
|
});
|
|
3063
|
+
const mine = tasksOf(data.tasks, projectId);
|
|
3064
|
+
const doing = mine.filter((t) => t.status === "doing").length;
|
|
3065
|
+
const review = mine.filter((t) => t.status === "review").length;
|
|
3066
|
+
return doing > 3 || review > 2;
|
|
2956
3067
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
3068
|
+
function deriveProjectHealth(project, data, now) {
|
|
3069
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
3070
|
+
const open = tasks.filter(incomplete);
|
|
3071
|
+
const maxOverdue = open.map((t) => daysOverdue(t.dueDate, now)).reduce((a, b) => Math.max(a, b), 0);
|
|
3072
|
+
const until = daysUntil$1(project.endDate, now);
|
|
3073
|
+
const unfinished = open.length > 0 || tasks.length === 0 && until != null && until < 0;
|
|
3074
|
+
if (maxOverdue > 3 || until != null && until < 0 && unfinished) return "off-track";
|
|
3075
|
+
const blockers = open.some((t) => isBlocked(t, data.tasks)) || projectIsBlocked(data, project);
|
|
3076
|
+
const wip = wipOverflowing(data, project.id);
|
|
3077
|
+
if (maxOverdue >= 1 || until != null && until >= 0 && until <= 7 && (blockers || wip)) return "at-risk";
|
|
3078
|
+
return "on-track";
|
|
3079
|
+
}
|
|
3080
|
+
function worstTrack(healths) {
|
|
3081
|
+
if (healths.some((h) => h === "off-track")) return "off-track";
|
|
3082
|
+
if (healths.some((h) => h === "at-risk")) return "at-risk";
|
|
3083
|
+
return "on-track";
|
|
3084
|
+
}
|
|
3085
|
+
/** The override wins when set; Paused and Done are overrides too, and stay until reset. */
|
|
3086
|
+
function effectiveProjectHealth(project, data, now) {
|
|
3087
|
+
return project.healthOverride ?? deriveProjectHealth(project, data, now);
|
|
3088
|
+
}
|
|
3089
|
+
/** Milestones past their date and still open. */
|
|
3090
|
+
function lateMilestonesOf(milestones, now) {
|
|
3091
|
+
const today = iso$2(now);
|
|
3092
|
+
return milestones.filter((m) => !m.done && m.dueDate < today);
|
|
2961
3093
|
}
|
|
2962
|
-
|
|
2963
|
-
//#endregion
|
|
2964
|
-
//#region src/tui/views/history.tsx
|
|
2965
3094
|
/**
|
|
2966
|
-
*
|
|
2967
|
-
* to
|
|
3095
|
+
* The projects an open milestone waits on that put its date in doubt: one
|
|
3096
|
+
* scheduled to end after the milestone is due, or one off track with work
|
|
3097
|
+
* still open. Finished projects and a reached milestone carry no risk.
|
|
2968
3098
|
*/
|
|
2969
|
-
function
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
name: p.name
|
|
3041
|
-
},
|
|
3042
|
-
hint: "portfolio"
|
|
3043
|
-
})),
|
|
3044
|
-
...programs.map((p) => ({
|
|
3045
|
-
label: p.name,
|
|
3046
|
-
value: {
|
|
3047
|
-
scope: "program",
|
|
3048
|
-
id: p.id,
|
|
3049
|
-
name: p.name
|
|
3050
|
-
},
|
|
3051
|
-
hint: "program"
|
|
3052
|
-
})),
|
|
3053
|
-
...projects.map((p) => ({
|
|
3054
|
-
label: p.name,
|
|
3055
|
-
value: {
|
|
3056
|
-
scope: "project",
|
|
3057
|
-
id: p.id,
|
|
3058
|
-
name: p.name
|
|
3059
|
-
},
|
|
3060
|
-
hint: "project"
|
|
3061
|
-
}))
|
|
3062
|
-
], (v) => {
|
|
3063
|
-
setScope(v);
|
|
3064
|
-
setAt(0);
|
|
3065
|
-
});
|
|
3066
|
-
else if (key.return) {
|
|
3067
|
-
const row = rows[at];
|
|
3068
|
-
if (row?.entity === "task" && tasks.some((t) => t.id === row.entityId)) go.openTask(row.entityId);
|
|
3069
|
-
else if (row?.entity === "project" && projects.some((p) => p.id === row.entityId)) go.openProject(row.entityId);
|
|
3070
|
-
else if (row?.entity === "program" && programs.some((p) => p.id === row.entityId)) go.openProgram(row.entityId);
|
|
3071
|
-
}
|
|
3072
|
-
}, { isActive: active });
|
|
3073
|
-
const names = {
|
|
3074
|
-
people,
|
|
3075
|
-
portfolios,
|
|
3076
|
-
programs,
|
|
3099
|
+
function milestoneRisks(milestone, data) {
|
|
3100
|
+
if (milestone.done) return [];
|
|
3101
|
+
return blockersIn(milestone, data.projects).flatMap((project) => {
|
|
3102
|
+
if (!projectHasOpenWork(data, project.id)) return [];
|
|
3103
|
+
if (project.endDate > milestone.dueDate) return [{
|
|
3104
|
+
project,
|
|
3105
|
+
reason: "ends-after"
|
|
3106
|
+
}];
|
|
3107
|
+
if (project.health === "off-track") return [{
|
|
3108
|
+
project,
|
|
3109
|
+
reason: "off-track"
|
|
3110
|
+
}];
|
|
3111
|
+
return [];
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
/**
|
|
3115
|
+
* `projectHealth` defaults to working each project out afresh; stampHealth
|
|
3116
|
+
* passes the health it has just stamped, so no project is derived twice.
|
|
3117
|
+
*/
|
|
3118
|
+
function deriveProgramHealth(program, data, now, projectHealth = (p) => effectiveProjectHealth(p, data, now)) {
|
|
3119
|
+
const kids = data.projects.filter((p) => p.programId === program.id);
|
|
3120
|
+
const blocked = programIsBlocked(data, program) ? "at-risk" : "on-track";
|
|
3121
|
+
const ownMilestones = (data.milestones ?? []).filter((m) => m.programId === program.id);
|
|
3122
|
+
const lateMilestone = lateMilestonesOf(ownMilestones, now).length > 0 || ownMilestones.some((m) => milestoneRisks(m, data).length > 0) ? "at-risk" : "on-track";
|
|
3123
|
+
if (kids.length === 0) return worstTrack([blocked, lateMilestone]);
|
|
3124
|
+
return worstTrack([
|
|
3125
|
+
...kids.map(projectHealth),
|
|
3126
|
+
blocked,
|
|
3127
|
+
lateMilestone
|
|
3128
|
+
]);
|
|
3129
|
+
}
|
|
3130
|
+
function effectiveProgramHealth(program, data, now, projectHealth) {
|
|
3131
|
+
return program.healthOverride ?? deriveProgramHealth(program, data, now, projectHealth);
|
|
3132
|
+
}
|
|
3133
|
+
/** A paused or done program neither helps nor hurts the portfolio. */
|
|
3134
|
+
function derivePortfolioHealth(portfolioId, data, now, programHealth = (p) => effectiveProgramHealth(p, data, now)) {
|
|
3135
|
+
const kids = data.programs.filter((p) => p.portfolioId === portfolioId);
|
|
3136
|
+
if (kids.length === 0) return "on-track";
|
|
3137
|
+
return worstTrack(kids.map(programHealth));
|
|
3138
|
+
}
|
|
3139
|
+
function effectivePortfolioHealth(portfolio, data, now, programHealth) {
|
|
3140
|
+
return portfolio.healthOverride ?? derivePortfolioHealth(portfolio.id, data, now, programHealth);
|
|
3141
|
+
}
|
|
3142
|
+
/** Sort key: trouble first, then fine, then parked or finished. */
|
|
3143
|
+
function rankHealth(h) {
|
|
3144
|
+
return h === "off-track" ? 0 : h === "at-risk" ? 1 : h === "on-track" ? 2 : h === "paused" ? 3 : 4;
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Re-derive every project, program and portfolio health.
|
|
3148
|
+
*
|
|
3149
|
+
* Reference-stable: an entity whose health did not change is returned as the
|
|
3150
|
+
* same object, and a collection with no changed entity as the same array, so
|
|
3151
|
+
* selectors that pick `projects`, `programs` or `portfolios` only re-render
|
|
3152
|
+
* when a health really moved.
|
|
3153
|
+
*/
|
|
3154
|
+
function stampHealth(data, now = clockNow()) {
|
|
3155
|
+
const at = now.toISOString();
|
|
3156
|
+
const projects = restamp(data.projects, (p) => effectiveProjectHealth(p, data, now), at);
|
|
3157
|
+
const mid = projects === data.projects ? data : {
|
|
3158
|
+
...data,
|
|
3159
|
+
projects
|
|
3160
|
+
};
|
|
3161
|
+
const programs = restamp(data.programs, (p) => effectiveProgramHealth(p, mid, now, (project) => project.health), at);
|
|
3162
|
+
const withPrograms = programs === mid.programs ? mid : {
|
|
3163
|
+
...mid,
|
|
3164
|
+
programs
|
|
3165
|
+
};
|
|
3166
|
+
const portfolios = restamp(data.portfolios, (p) => effectivePortfolioHealth(p, withPrograms, now, (program) => program.health), at);
|
|
3167
|
+
if (projects === data.projects && programs === data.programs && portfolios === data.portfolios) return data;
|
|
3168
|
+
return {
|
|
3169
|
+
...data,
|
|
3077
3170
|
projects,
|
|
3078
|
-
|
|
3171
|
+
programs,
|
|
3172
|
+
portfolios
|
|
3079
3173
|
};
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
children: "Loading…"
|
|
3094
|
-
}) : null,
|
|
3095
|
-
state === "failed" ? /* @__PURE__ */ jsx(Text, {
|
|
3096
|
-
color: theme.bad,
|
|
3097
|
-
children: "Couldn’t load History. It tries again after the next save."
|
|
3098
|
-
}) : null,
|
|
3099
|
-
state === "ready" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
3100
|
-
color: theme.muted,
|
|
3101
|
-
children: "Nothing recorded here yet."
|
|
3102
|
-
}) : null,
|
|
3103
|
-
view.items.map((row, i) => {
|
|
3104
|
-
const on = active && view.start + i === at;
|
|
3105
|
-
const { action, detail } = describeEvent(row, names);
|
|
3106
|
-
return /* @__PURE__ */ jsxs(Text, {
|
|
3107
|
-
color: on ? theme.selected : void 0,
|
|
3108
|
-
bold: on,
|
|
3109
|
-
wrap: "truncate-end",
|
|
3110
|
-
children: [
|
|
3111
|
-
on ? "› " : " ",
|
|
3112
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3113
|
-
color: theme.muted,
|
|
3114
|
-
children: `${timeAgo(parseIso(row.at))} `.padEnd(16)
|
|
3115
|
-
}),
|
|
3116
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3117
|
-
bold: true,
|
|
3118
|
-
children: actorOf({
|
|
3119
|
-
...row,
|
|
3120
|
-
via: null
|
|
3121
|
-
}, people)
|
|
3122
|
-
}),
|
|
3123
|
-
row.via ? /* @__PURE__ */ jsxs(Text, {
|
|
3124
|
-
color: theme.muted,
|
|
3125
|
-
children: [" via ", row.via]
|
|
3126
|
-
}) : null,
|
|
3127
|
-
" ",
|
|
3128
|
-
action,
|
|
3129
|
-
" ",
|
|
3130
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3131
|
-
bold: true,
|
|
3132
|
-
children: row.entityName
|
|
3133
|
-
}),
|
|
3134
|
-
detail ? /* @__PURE__ */ jsxs(Text, {
|
|
3135
|
-
color: theme.muted,
|
|
3136
|
-
children: [" ", detail]
|
|
3137
|
-
}) : null,
|
|
3138
|
-
row.note ? /* @__PURE__ */ jsxs(Text, {
|
|
3139
|
-
color: theme.muted,
|
|
3140
|
-
children: [
|
|
3141
|
-
" “",
|
|
3142
|
-
row.note,
|
|
3143
|
-
"”"
|
|
3144
|
-
]
|
|
3145
|
-
}) : null
|
|
3146
|
-
]
|
|
3147
|
-
}, row.id);
|
|
3148
|
-
})
|
|
3149
|
-
]
|
|
3174
|
+
}
|
|
3175
|
+
/** Every row whose health moved gets the new value and a line of history. */
|
|
3176
|
+
function restamp(rows, healthOf, at) {
|
|
3177
|
+
let changed = false;
|
|
3178
|
+
const next = rows.map((row) => {
|
|
3179
|
+
const health = healthOf(row);
|
|
3180
|
+
if (health === row.health) return row;
|
|
3181
|
+
changed = true;
|
|
3182
|
+
return {
|
|
3183
|
+
...row,
|
|
3184
|
+
health,
|
|
3185
|
+
healthHistory: recordHealth(row.healthHistory, health, at, Boolean(row.healthOverride), row.healthOverride ? row.healthNote : null)
|
|
3186
|
+
};
|
|
3150
3187
|
});
|
|
3188
|
+
return changed ? next : rows;
|
|
3189
|
+
}
|
|
3190
|
+
/** Late tasks across a program's projects, from the per-project lookup rather than every task. */
|
|
3191
|
+
function programLateCount(programId, data, now) {
|
|
3192
|
+
let late = 0;
|
|
3193
|
+
for (const p of data.projects) if (p.programId === programId) late += lateTasksOf([...tasksOf(data.tasks, p.id)], now).length;
|
|
3194
|
+
return late;
|
|
3151
3195
|
}
|
|
3152
3196
|
|
|
3153
3197
|
//#endregion
|
|
3154
|
-
//#region src/lib/
|
|
3155
|
-
const
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
review: "Reviews due",
|
|
3167
|
-
assigned: "Assigned to you",
|
|
3168
|
-
idle: "Quiet blockers",
|
|
3169
|
-
mention: "Mentions"
|
|
3170
|
-
};
|
|
3171
|
-
function firstName(name) {
|
|
3172
|
-
return name.split(/\s+/)[0] ?? name;
|
|
3198
|
+
//#region src/lib/brief.ts
|
|
3199
|
+
const BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
|
|
3200
|
+
/** Late tasks worst first, each with what it holds up, so the letter says what to chase. */
|
|
3201
|
+
function lateLinesOf(data, tasks, now) {
|
|
3202
|
+
const waiters = waitersByBlocker(tasks);
|
|
3203
|
+
return lateTasksOf(tasks, now).map((task) => ({
|
|
3204
|
+
task,
|
|
3205
|
+
owner: data.people.find((p) => p.id === task.assigneeId)?.name ?? null,
|
|
3206
|
+
project: data.projects.find((p) => p.id === task.projectId)?.name ?? null,
|
|
3207
|
+
days: daysOverdue(task.dueDate, now),
|
|
3208
|
+
holdsUp: (waiters.get(task.id) ?? []).filter((t) => t.status !== "done" && t.id !== task.id)
|
|
3209
|
+
})).sort((a, b) => b.days - a.days || b.holdsUp.length - a.holdsUp.length);
|
|
3173
3210
|
}
|
|
3174
|
-
/**
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3211
|
+
/**
|
|
3212
|
+
* Each person's latest check-in from the last seven days, newest first.
|
|
3213
|
+
* Check-ins are daily, so without this an owner back after a week would
|
|
3214
|
+
* never read what the crew said while they were away. A scoped brief keeps
|
|
3215
|
+
* the people with work in its projects.
|
|
3216
|
+
*/
|
|
3217
|
+
function saidThisWeek(data, now, inScope) {
|
|
3218
|
+
const since = todayISO(subDays(now, 6));
|
|
3219
|
+
const latest = /* @__PURE__ */ new Map();
|
|
3220
|
+
for (const c of data.checkIns ?? []) {
|
|
3221
|
+
if (c.date < since || !c.body.trim()) continue;
|
|
3222
|
+
if (inScope && !inScope.has(c.personId)) continue;
|
|
3223
|
+
const seen = latest.get(c.personId);
|
|
3224
|
+
if (!seen || c.createdAt > seen.createdAt) latest.set(c.personId, c);
|
|
3180
3225
|
}
|
|
3181
|
-
return (
|
|
3226
|
+
return [...latest.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).flatMap((checkIn) => {
|
|
3227
|
+
const person = data.people.find((p) => p.id === checkIn.personId);
|
|
3228
|
+
return person ? [{
|
|
3229
|
+
person,
|
|
3230
|
+
checkIn
|
|
3231
|
+
}] : [];
|
|
3232
|
+
});
|
|
3182
3233
|
}
|
|
3183
|
-
/**
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
return
|
|
3234
|
+
/** How many late tasks the brief names before "and N more". */
|
|
3235
|
+
const BRIEF_LATE_SHOWN = 8;
|
|
3236
|
+
function byEndDate(a, b) {
|
|
3237
|
+
return a.endDate.localeCompare(b.endDate);
|
|
3187
3238
|
}
|
|
3188
|
-
function
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3239
|
+
function groupProgramsForBrief(programs) {
|
|
3240
|
+
return {
|
|
3241
|
+
offTrack: programs.filter((p) => p.health === "off-track").sort(byEndDate),
|
|
3242
|
+
atRisk: programs.filter((p) => p.health === "at-risk").sort(byEndDate),
|
|
3243
|
+
onTrack: programs.filter((p) => p.health === "on-track").sort(byEndDate),
|
|
3244
|
+
/** Paused or done: out of the letter, but not forgotten. */
|
|
3245
|
+
parked: programs.filter((p) => p.health === "paused" || p.health === "done").sort(byEndDate)
|
|
3246
|
+
};
|
|
3192
3247
|
}
|
|
3193
|
-
|
|
3194
|
-
|
|
3248
|
+
/** A risk scored high (probability × impact ≥ 12), or a legacy high-severity one. */
|
|
3249
|
+
function isHighRisk(r) {
|
|
3250
|
+
const score = scoreOf(r);
|
|
3251
|
+
return score != null ? scoreBand(score) === "high" : r.severity === "high";
|
|
3252
|
+
}
|
|
3253
|
+
/** An issue whose impact is major or severe. */
|
|
3254
|
+
function isMajorIssue(r) {
|
|
3255
|
+
return r.kind === "issue" && (r.impact != null ? r.impact >= 4 : r.severity === "high");
|
|
3256
|
+
}
|
|
3257
|
+
/** The risks and issues worth a line in the brief: live, and either a high-scored risk or a major issue. */
|
|
3258
|
+
function watchListOf(data, scope, limit = 5) {
|
|
3259
|
+
const inScope = (r) => {
|
|
3260
|
+
if (r.projectId) return scope.projectIds.has(r.projectId);
|
|
3261
|
+
if (r.programId) return scope.programIds.has(r.programId);
|
|
3262
|
+
if (r.portfolioId) return scope.portfolioId == null || r.portfolioId === scope.portfolioId;
|
|
3263
|
+
return scope.portfolioId == null;
|
|
3264
|
+
};
|
|
3265
|
+
const live = data.raidItems.filter((r) => r.kind !== "decision" && isActive(r) && inScope(r) && (r.kind === "risk" && isHighRisk(r) || isMajorIssue(r)));
|
|
3266
|
+
return sortRaid(live).slice(0, limit).map((item) => ({
|
|
3267
|
+
item,
|
|
3268
|
+
where: item.projectId && data.projects.find((p) => p.id === item.projectId)?.name || item.programId && data.programs.find((p) => p.id === item.programId)?.name || item.portfolioId && data.portfolios.find((p) => p.id === item.portfolioId)?.name || null,
|
|
3269
|
+
owner: data.people.find((p) => p.id === item.ownerId)?.name ?? null
|
|
3270
|
+
}));
|
|
3195
3271
|
}
|
|
3196
|
-
|
|
3197
|
-
const
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
const since = subDays(now, ASSIGNED_DAYS).toISOString();
|
|
3202
|
-
for (const task of mine) {
|
|
3203
|
-
if (!task.assignedBy || task.assignedBy === personId || !task.assignedAt) continue;
|
|
3204
|
-
if (task.assignedAt < since) continue;
|
|
3205
|
-
const by = data.people.find((p) => p.id === task.assignedBy);
|
|
3206
|
-
const project = projectOf(data, task);
|
|
3207
|
-
pings.push({
|
|
3208
|
-
id: `assigned:${task.id}:${task.assignedAt}`,
|
|
3209
|
-
kind: "assigned",
|
|
3210
|
-
title: by ? `${firstName(by.name)} gave you a task` : "You have a new task",
|
|
3211
|
-
detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
|
|
3212
|
-
at: task.assignedAt,
|
|
3213
|
-
taskId: task.id,
|
|
3214
|
-
personId: task.assignedBy,
|
|
3215
|
-
href: `/projects/${task.projectId}`
|
|
3216
|
-
});
|
|
3272
|
+
function lateOwnersOf(data, tasks, now) {
|
|
3273
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3274
|
+
for (const t of lateTasksOf(tasks, now)) {
|
|
3275
|
+
const id = t.assigneeId ?? null;
|
|
3276
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
3217
3277
|
}
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3278
|
+
return [...counts.entries()].map(([personId, count]) => ({
|
|
3279
|
+
personId,
|
|
3280
|
+
name: personId ? data.people.find((p) => p.id === personId)?.name ?? "Someone" : "Unassigned",
|
|
3281
|
+
count
|
|
3282
|
+
})).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
3283
|
+
}
|
|
3284
|
+
function composeBriefBody(opts) {
|
|
3285
|
+
const date = format(opts.date, "MMMM d, yyyy");
|
|
3286
|
+
const watch = opts.watch ?? [];
|
|
3287
|
+
const milestones = opts.milestones ?? [];
|
|
3288
|
+
const lateMilestones = milestones.filter((m) => m.daysLate > 0);
|
|
3289
|
+
const quiet = opts.offTrack.length === 0 && opts.atRisk.length === 0 && opts.late.length === 0 && watch.length === 0 && lateMilestones.length === 0;
|
|
3290
|
+
const lines = [`Harbor brief — ${opts.workspace} — ${date}`, ""];
|
|
3291
|
+
const done = () => {
|
|
3292
|
+
if (!opts.shipped?.length) return;
|
|
3293
|
+
lines.push("Done this week");
|
|
3294
|
+
for (const t of opts.shipped.slice(0, 8)) lines.push(`• ${t.title}${t.project ? ` (${t.project})` : ""}`);
|
|
3295
|
+
if (opts.shipped.length > 8) lines.push(`• and ${opts.shipped.length - 8} more`);
|
|
3296
|
+
lines.push("");
|
|
3297
|
+
};
|
|
3298
|
+
const said = () => {
|
|
3299
|
+
if (!opts.said?.length) return;
|
|
3300
|
+
lines.push("What the crew said");
|
|
3301
|
+
for (const s of opts.said) lines.push(`• ${s.name}, ${format(/* @__PURE__ */ new Date(`${s.date}T12:00:00`), "EEE")}: ${s.body.trim()}`);
|
|
3302
|
+
lines.push("");
|
|
3303
|
+
};
|
|
3304
|
+
if (quiet) {
|
|
3305
|
+
lines.push(BRIEF_QUIET, "");
|
|
3306
|
+
done();
|
|
3307
|
+
said();
|
|
3308
|
+
return lines.join("\n").trim();
|
|
3238
3309
|
}
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
id: `approve:${m.id}`,
|
|
3245
|
-
kind: "approval",
|
|
3246
|
-
title: `${m.name} needs your sign-off`,
|
|
3247
|
-
detail: `${program ? `${program.name} · ` : ""}a stage gate only you can pass`,
|
|
3248
|
-
at: m.dueDate,
|
|
3249
|
-
programId: m.programId,
|
|
3250
|
-
href: `/programs/${m.programId}?view=milestones`,
|
|
3251
|
-
personId: program?.ownerId ?? null
|
|
3252
|
-
});
|
|
3253
|
-
else if (m.approvalStatus === "changes_requested" && program?.ownerId === personId && m.approverId !== personId) {
|
|
3254
|
-
const approver = data.people.find((p) => p.id === m.approverId);
|
|
3255
|
-
const note = (m.approvalNote ?? "").trim();
|
|
3256
|
-
pings.push({
|
|
3257
|
-
id: `changes:${m.id}:${shortHash(note)}`,
|
|
3258
|
-
kind: "approval",
|
|
3259
|
-
title: `${approver ? firstName(approver.name) : "The approver"} asked for changes to ${m.name}`,
|
|
3260
|
-
detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
|
|
3261
|
-
at: m.dueDate,
|
|
3262
|
-
programId: m.programId,
|
|
3263
|
-
href: `/programs/${m.programId}?view=milestones`,
|
|
3264
|
-
personId: m.approverId
|
|
3265
|
-
});
|
|
3310
|
+
if (opts.offTrack.length) {
|
|
3311
|
+
lines.push("Off track");
|
|
3312
|
+
for (const p of opts.offTrack) {
|
|
3313
|
+
const move = p.nextMove?.trim();
|
|
3314
|
+
lines.push(`• ${p.name} — ${move || "no next move yet"}`);
|
|
3266
3315
|
}
|
|
3316
|
+
lines.push("");
|
|
3267
3317
|
}
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3318
|
+
if (opts.atRisk.length) {
|
|
3319
|
+
lines.push("At risk");
|
|
3320
|
+
for (const p of opts.atRisk) lines.push(`• ${p.name}`);
|
|
3321
|
+
lines.push("");
|
|
3322
|
+
}
|
|
3323
|
+
if (watch.length) {
|
|
3324
|
+
lines.push("Risks & issues");
|
|
3325
|
+
for (const w of watch) {
|
|
3326
|
+
const tail = [w.where, w.owner].filter(Boolean).join(", ");
|
|
3327
|
+
lines.push(`• ${w.kind === "issue" ? "Issue" : "Risk"}: ${w.title}${tail ? ` (${tail})` : ""}`);
|
|
3328
|
+
}
|
|
3329
|
+
lines.push("");
|
|
3330
|
+
}
|
|
3331
|
+
if (milestones.length) {
|
|
3332
|
+
lines.push("Milestones");
|
|
3333
|
+
for (const m of milestones) {
|
|
3334
|
+
const when = m.daysLate > 0 ? `${m.daysLate} day${m.daysLate === 1 ? "" : "s"} late` : m.daysLate === 0 ? "due today" : `due ${format(/* @__PURE__ */ new Date(`${m.dueDate}T12:00:00`), "MMM d")}`;
|
|
3335
|
+
lines.push(`• ${m.name}${m.program ? ` (${m.program})` : ""} — ${when}`);
|
|
3336
|
+
}
|
|
3337
|
+
lines.push("");
|
|
3338
|
+
}
|
|
3339
|
+
if (opts.late.length) {
|
|
3340
|
+
lines.push("Late");
|
|
3341
|
+
for (const row of opts.late.slice(0, 8)) {
|
|
3342
|
+
const when = `${row.days} day${row.days === 1 ? "" : "s"} late`;
|
|
3343
|
+
const held = row.holdsUp?.length ? ` (holds up ${nameList(row.holdsUp.map((t) => `“${t}”`))})` : "";
|
|
3344
|
+
lines.push(`• ${row.title} — ${row.owner ? `${row.owner}, ` : ""}${when}${held}`);
|
|
3345
|
+
}
|
|
3346
|
+
if (opts.late.length > 8) lines.push(`• and ${opts.late.length - 8} more`);
|
|
3347
|
+
lines.push("");
|
|
3348
|
+
}
|
|
3349
|
+
done();
|
|
3350
|
+
said();
|
|
3351
|
+
if (opts.onTrack.length) {
|
|
3352
|
+
lines.push("On track");
|
|
3353
|
+
lines.push(opts.onTrack.map((p) => p.name).join(", "));
|
|
3354
|
+
}
|
|
3355
|
+
return lines.join("\n").trim();
|
|
3356
|
+
}
|
|
3357
|
+
function firstName$1(person) {
|
|
3358
|
+
return person?.name.split(/\s+/)[0] ?? "Someone";
|
|
3359
|
+
}
|
|
3360
|
+
function similar(a, b) {
|
|
3361
|
+
const na = a.toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim();
|
|
3362
|
+
const nb = b.toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim();
|
|
3363
|
+
if (!na || !nb) return false;
|
|
3364
|
+
if (na.includes(nb) || nb.includes(na)) return true;
|
|
3365
|
+
const aw = new Set(na.split(" ").filter((w) => w.length > 3));
|
|
3366
|
+
return nb.split(" ").filter((w) => w.length > 3).filter((w) => aw.has(w)).length >= 3;
|
|
3367
|
+
}
|
|
3368
|
+
function daysUntil(value, now) {
|
|
3369
|
+
try {
|
|
3370
|
+
return Math.round((startOfDay(parseDate$1(value)).getTime() - startOfDay(now).getTime()) / 864e5);
|
|
3371
|
+
} catch {
|
|
3372
|
+
return 99;
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
function scoreDrum(line, risks) {
|
|
3376
|
+
let n = 0;
|
|
3377
|
+
if (line.idle) n += 3;
|
|
3378
|
+
if (line.constraint.kind === "collision") n += 2;
|
|
3379
|
+
if (line.program.health === "off-track") n += 3;
|
|
3380
|
+
else if (line.program.health === "at-risk") n += 2;
|
|
3381
|
+
if (line.flow.impliedDays != null && line.flow.impliedDays > 28) n += 1;
|
|
3382
|
+
if (risks.some((r) => r.programId === line.program.id && isActive(r) && isHighRisk(r))) n += 1;
|
|
3383
|
+
return n;
|
|
3384
|
+
}
|
|
3385
|
+
function headlineOf(drums, flow) {
|
|
3386
|
+
const idle = drums.filter((d) => d.idle);
|
|
3387
|
+
const collision = drums.find((d) => d.constraint.kind === "collision");
|
|
3388
|
+
const system = drums[0];
|
|
3389
|
+
if (idle.length >= 1 && collision && idle.some((d) => d.constraint.id !== collision.constraint.id && d.owner?.id && d.owner.id === collision.owner?.id)) {
|
|
3390
|
+
const starved = idle.find((d) => d.owner?.id === collision.owner?.id && d.constraint.id !== collision.constraint.id);
|
|
3391
|
+
return `${collision.constraint.title}. ${starved.program.name} is starving because of it.`;
|
|
3392
|
+
}
|
|
3393
|
+
if (idle.length === 1) {
|
|
3394
|
+
const d = idle[0];
|
|
3395
|
+
return d.owner ? `${firstName$1(d.owner)} is in play elsewhere. ${d.constraint.title} is starving.` : `${d.constraint.title} is idle. Protect it or retire it.`;
|
|
3396
|
+
}
|
|
3397
|
+
if (idle.length > 1) return `${idle.length} constraints are idle. The work is not at the constraint.`;
|
|
3398
|
+
if (system && flow.impliedDays != null && flow.impliedDays > 21) return `At this load, ${formatWait(flow.impliedDays)}. ${system.constraint.title}.`;
|
|
3399
|
+
if (system) return `${system.constraint.title} still sets the date.`;
|
|
3400
|
+
return "No constraint named yet. Pick the one thing that sets the date.";
|
|
3401
|
+
}
|
|
3402
|
+
function dekOf(brief) {
|
|
3403
|
+
const bits = [];
|
|
3404
|
+
bits.push(`${brief.flow.wip} in play`);
|
|
3405
|
+
bits.push(`${brief.flow.doneInWindow} finished in ${brief.flow.windowDays} days`);
|
|
3406
|
+
if (brief.flow.impliedDays != null) bits.push(`wait ${formatWait(brief.flow.impliedDays)}`);
|
|
3407
|
+
if (brief.idleCount > 0) bits.push(`${brief.idleCount} idle constraint${brief.idleCount === 1 ? "" : "s"}`);
|
|
3408
|
+
if (brief.atRisk.length) bits.push(`${brief.atRisk.length} at risk`);
|
|
3409
|
+
if (brief.overdue.length) bits.push(`${brief.overdue.length} overdue`);
|
|
3410
|
+
return bits.join(" · ");
|
|
3411
|
+
}
|
|
3412
|
+
function briefOf(data, opts = {}) {
|
|
3413
|
+
const now = opts.now ?? clockNow();
|
|
3414
|
+
const portfolioId = opts.portfolioId ?? null;
|
|
3415
|
+
const programs = portfolioId ? data.programs.filter((p) => p.portfolioId === portfolioId) : data.programs;
|
|
3416
|
+
const programIds = new Set(programs.map((p) => p.id));
|
|
3417
|
+
const projects = data.projects.filter((p) => programIds.has(p.programId));
|
|
3418
|
+
const projectIds = new Set(projects.map((p) => p.id));
|
|
3419
|
+
const tasks = data.tasks.filter((t) => projectIds.has(t.projectId));
|
|
3420
|
+
const risks = data.raidItems.filter((r) => r.kind === "risk" && r.programId != null && programIds.has(r.programId));
|
|
3421
|
+
const milestones = data.milestones.filter((m) => programIds.has(m.programId));
|
|
3422
|
+
const portfolio = portfolioId ? data.portfolios.find((p) => p.id === portfolioId) : void 0;
|
|
3423
|
+
const flow = flowOf(tasks, now);
|
|
3424
|
+
const drums = programs.map((program) => {
|
|
3425
|
+
const constraint = (data.constraints ?? []).find((c) => c.programId === program.id && c.status === "active");
|
|
3426
|
+
if (!constraint) return null;
|
|
3427
|
+
const owner = data.people.find((p) => p.id === constraint.ownerId);
|
|
3428
|
+
const line = {
|
|
3429
|
+
constraint,
|
|
3430
|
+
program,
|
|
3431
|
+
portfolio: data.portfolios.find((p) => p.id === program.portfolioId),
|
|
3432
|
+
owner,
|
|
3433
|
+
idle: constraintIdle(data, constraint),
|
|
3434
|
+
flow: flowOf(tasksInProgramOf(data, program.id), now),
|
|
3435
|
+
score: 0
|
|
3436
|
+
};
|
|
3437
|
+
line.score = scoreDrum(line, risks);
|
|
3438
|
+
return line;
|
|
3439
|
+
}).filter((d) => Boolean(d)).sort((a, b) => b.score - a.score || Number(b.idle) - Number(a.idle));
|
|
3440
|
+
const atRisk = projects.filter((p) => p.health === "at-risk" || p.health === "off-track");
|
|
3441
|
+
const overdue = tasks.filter((t) => t.status !== "done" && isOverdue(t.dueDate));
|
|
3442
|
+
const waiting = tasks.filter((t) => t.status !== "done" && isBlocked$1(t, data.tasks));
|
|
3443
|
+
const weekAgo = subDays(now, 7).toISOString();
|
|
3444
|
+
const finished = tasks.filter((t) => t.status === "done" && t.completedAt && t.completedAt >= weekAgo).sort((a, b) => (b.completedAt ?? "").localeCompare(a.completedAt ?? ""));
|
|
3445
|
+
const milestonesSoon = milestones.filter((m) => !m.done && daysUntil(m.dueDate, now) >= 0 && daysUntil(m.dueDate, now) <= 14).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, 5);
|
|
3446
|
+
const milestonesLate = milestones.filter((m) => !m.done && daysUntil(m.dueDate, now) < 0).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
|
|
3447
|
+
const talk = [];
|
|
3448
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3449
|
+
const push = (item) => {
|
|
3450
|
+
if (talk.length >= 6) return;
|
|
3451
|
+
if (seen.has(item.id)) return;
|
|
3452
|
+
if ([...talk].some((t) => similar(t.title, item.title))) return;
|
|
3453
|
+
seen.add(item.id);
|
|
3454
|
+
talk.push(item);
|
|
3455
|
+
};
|
|
3456
|
+
for (const d of drums.filter((x) => x.idle)) push({
|
|
3457
|
+
id: `idle-${d.constraint.id}`,
|
|
3458
|
+
title: `${d.constraint.title} is idle`,
|
|
3459
|
+
detail: d.owner ? `${firstName$1(d.owner)} is in play elsewhere. ${d.program.name} is waiting.` : `${d.program.name} has a named constraint and nobody protecting it.`,
|
|
3460
|
+
href: `/programs/${d.program.id}`
|
|
3461
|
+
});
|
|
3462
|
+
for (const d of drums.filter((x) => x.constraint.kind === "collision" && !x.idle)) push({
|
|
3463
|
+
id: `col-${d.constraint.id}`,
|
|
3464
|
+
title: d.constraint.title,
|
|
3465
|
+
detail: d.constraint.note || "Two things sharing one window. Sequence them.",
|
|
3466
|
+
href: `/programs/${d.program.id}`
|
|
3467
|
+
});
|
|
3468
|
+
for (const r of risks.filter((x) => isActive(x) && isHighRisk(x))) {
|
|
3469
|
+
const program = programs.find((p) => p.id === r.programId);
|
|
3470
|
+
push({
|
|
3471
|
+
id: `risk-${r.id}`,
|
|
3472
|
+
title: r.title,
|
|
3473
|
+
detail: program ? `Open on ${program.name}.` : "Open risk.",
|
|
3474
|
+
href: program ? `/programs/${program.id}` : "/portfolios"
|
|
3281
3475
|
});
|
|
3282
3476
|
}
|
|
3283
|
-
for (const
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
detail: program ? `${constraint.title} · ${program.name} is waiting while you work elsewhere.` : constraint.title,
|
|
3291
|
-
at: program?.startDate ?? constraint.id,
|
|
3292
|
-
programId: constraint.programId,
|
|
3293
|
-
href: `/programs/${constraint.programId}`,
|
|
3294
|
-
personId
|
|
3477
|
+
for (const p of atRisk) {
|
|
3478
|
+
const program = programs.find((pg) => pg.id === p.programId);
|
|
3479
|
+
push({
|
|
3480
|
+
id: `health-${p.id}`,
|
|
3481
|
+
title: `${p.name} is ${p.health.replace("-", " ")}`,
|
|
3482
|
+
detail: program ? program.name : p.description,
|
|
3483
|
+
href: `/projects/${p.id}`
|
|
3295
3484
|
});
|
|
3296
3485
|
}
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
href: `/projects/${task.projectId}`
|
|
3486
|
+
if (waiting.length > 0) push({
|
|
3487
|
+
id: "waiting",
|
|
3488
|
+
title: `${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting on a blocker`,
|
|
3489
|
+
detail: waiting.slice(0, 2).map((t) => t.title).join(" · "),
|
|
3490
|
+
href: "/"
|
|
3491
|
+
});
|
|
3492
|
+
for (const m of milestonesSoon.slice(0, 2)) {
|
|
3493
|
+
const program = programs.find((p) => p.id === m.programId);
|
|
3494
|
+
const d = daysUntil(m.dueDate, now);
|
|
3495
|
+
push({
|
|
3496
|
+
id: `ms-${m.id}`,
|
|
3497
|
+
title: m.name,
|
|
3498
|
+
detail: `${d === 0 ? "Due today" : d === 1 ? "Due tomorrow" : `Due in ${d} days`}${program ? ` · ${program.name}` : ""}`,
|
|
3499
|
+
href: program ? `/programs/${program.id}` : "/calendar"
|
|
3312
3500
|
});
|
|
3313
3501
|
}
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3502
|
+
const idleCount = drums.filter((d) => d.idle).length;
|
|
3503
|
+
const headline = headlineOf(drums, flow);
|
|
3504
|
+
const dek = dekOf({
|
|
3505
|
+
flow,
|
|
3506
|
+
idleCount,
|
|
3507
|
+
atRisk,
|
|
3508
|
+
overdue,
|
|
3509
|
+
drums
|
|
3510
|
+
});
|
|
3511
|
+
const grouped = groupProgramsForBrief(programs);
|
|
3512
|
+
const lateOwners = lateOwnersOf(data, tasks, now);
|
|
3513
|
+
const watch = watchListOf(data, {
|
|
3514
|
+
programIds,
|
|
3515
|
+
projectIds,
|
|
3516
|
+
portfolioId
|
|
3517
|
+
});
|
|
3518
|
+
return {
|
|
3519
|
+
asOf: now,
|
|
3520
|
+
scopeId: portfolioId,
|
|
3521
|
+
scopeLabel: portfolio?.name ?? "All portfolios",
|
|
3522
|
+
headline,
|
|
3523
|
+
dek,
|
|
3524
|
+
drums,
|
|
3525
|
+
system: drums[0] ?? null,
|
|
3526
|
+
idleCount,
|
|
3527
|
+
flow,
|
|
3528
|
+
atRisk,
|
|
3529
|
+
overdue,
|
|
3530
|
+
waiting,
|
|
3531
|
+
finished,
|
|
3532
|
+
said: saidThisWeek(data, now, portfolioId ? new Set(tasks.flatMap((t) => t.assigneeId ? [t.assigneeId] : [])) : null),
|
|
3533
|
+
late: lateLinesOf(data, tasks, now),
|
|
3534
|
+
talk,
|
|
3535
|
+
milestonesSoon,
|
|
3536
|
+
milestonesLate,
|
|
3537
|
+
risks: risks.filter((r) => r.status !== "closed"),
|
|
3538
|
+
watch,
|
|
3539
|
+
offTrackPrograms: grouped.offTrack,
|
|
3540
|
+
atRiskPrograms: grouped.atRisk,
|
|
3541
|
+
onTrackPrograms: grouped.onTrack,
|
|
3542
|
+
lateOwners,
|
|
3543
|
+
quiet: grouped.offTrack.length === 0 && grouped.atRisk.length === 0 && lateOwners.length === 0 && watch.length === 0 && milestonesLate.length === 0
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3546
|
+
function composeBriefBodyFrom(brief, data, workspace) {
|
|
3547
|
+
return composeBriefBody({
|
|
3548
|
+
workspace,
|
|
3549
|
+
date: brief.asOf,
|
|
3550
|
+
offTrack: brief.offTrackPrograms,
|
|
3551
|
+
atRisk: brief.atRiskPrograms,
|
|
3552
|
+
late: brief.late.map((l) => ({
|
|
3553
|
+
title: l.task.title,
|
|
3554
|
+
owner: l.owner,
|
|
3555
|
+
days: l.days,
|
|
3556
|
+
holdsUp: l.holdsUp.map((t) => t.title)
|
|
3557
|
+
})),
|
|
3558
|
+
onTrack: brief.onTrackPrograms,
|
|
3559
|
+
shipped: brief.finished.map((t) => ({
|
|
3560
|
+
title: t.title,
|
|
3561
|
+
project: data.projects.find((p) => p.id === t.projectId)?.name
|
|
3562
|
+
})),
|
|
3563
|
+
said: brief.said.map((s) => ({
|
|
3564
|
+
name: s.person.name,
|
|
3565
|
+
date: s.checkIn.date,
|
|
3566
|
+
body: s.checkIn.body
|
|
3567
|
+
})),
|
|
3568
|
+
watch: brief.watch.map((w) => ({
|
|
3569
|
+
title: w.item.title,
|
|
3570
|
+
kind: w.item.kind === "issue" ? "issue" : "risk",
|
|
3571
|
+
where: w.where,
|
|
3572
|
+
owner: w.owner
|
|
3573
|
+
})),
|
|
3574
|
+
milestones: [...brief.milestonesLate, ...brief.milestonesSoon].map((m) => ({
|
|
3575
|
+
name: m.name,
|
|
3576
|
+
program: data.programs.find((p) => p.id === m.programId)?.name ?? null,
|
|
3577
|
+
dueDate: m.dueDate,
|
|
3578
|
+
daysLate: -daysUntil(m.dueDate, brief.asOf)
|
|
3579
|
+
}))
|
|
3318
3580
|
});
|
|
3319
3581
|
}
|
|
3582
|
+
function renderBriefText(brief, data, workspace = "Harbor") {
|
|
3583
|
+
return composeBriefBodyFrom(brief, data, workspace);
|
|
3584
|
+
}
|
|
3320
3585
|
|
|
3321
3586
|
//#endregion
|
|
3322
|
-
//#region src/tui/views/
|
|
3323
|
-
/**
|
|
3324
|
-
|
|
3325
|
-
|
|
3587
|
+
//#region src/tui/views/brief.tsx
|
|
3588
|
+
/**
|
|
3589
|
+
* The weekly brief, the same letter the website writes: what's off track,
|
|
3590
|
+
* risks to watch, milestones, late work, what got done and what the crew
|
|
3591
|
+
* said. ↑↓ read on, p narrows it to one portfolio.
|
|
3592
|
+
*/
|
|
3593
|
+
function BriefView({ active, width, height, go }) {
|
|
3594
|
+
const { today } = useDeps();
|
|
3595
|
+
const harborName = useHarbor((s) => s.harborName);
|
|
3596
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
3597
|
+
const programs = useHarbor((s) => s.programs);
|
|
3598
|
+
const projects = useHarbor((s) => s.projects);
|
|
3326
3599
|
const tasks = useHarbor((s) => s.tasks);
|
|
3327
|
-
const comments = useHarbor((s) => s.comments);
|
|
3328
3600
|
const milestones = useHarbor((s) => s.milestones);
|
|
3329
3601
|
const raidItems = useHarbor((s) => s.raidItems);
|
|
3330
|
-
const constraints = useHarbor((s) => s.constraints);
|
|
3331
3602
|
const people = useHarbor((s) => s.people);
|
|
3332
|
-
const
|
|
3333
|
-
const
|
|
3334
|
-
const
|
|
3603
|
+
const checkIns = useHarbor((s) => s.checkIns);
|
|
3604
|
+
const constraints = useHarbor((s) => s.constraints);
|
|
3605
|
+
const [portfolio, setPortfolio] = useState(null);
|
|
3606
|
+
const [top, setTop] = useState(0);
|
|
3607
|
+
const day = today();
|
|
3608
|
+
const brief = useMemo(() => {
|
|
3609
|
+
const data = {
|
|
3610
|
+
portfolios,
|
|
3611
|
+
programs,
|
|
3612
|
+
projects,
|
|
3613
|
+
tasks,
|
|
3614
|
+
milestones,
|
|
3615
|
+
raidItems,
|
|
3616
|
+
people,
|
|
3617
|
+
checkIns,
|
|
3618
|
+
constraints
|
|
3619
|
+
};
|
|
3620
|
+
const b = briefOf(data, {
|
|
3621
|
+
portfolioId: portfolio,
|
|
3622
|
+
now: /* @__PURE__ */ new Date(`${day}T12:00:00`)
|
|
3623
|
+
});
|
|
3624
|
+
return {
|
|
3625
|
+
scope: b.scopeLabel,
|
|
3626
|
+
text: renderBriefText(b, data, harborName ?? "Harbor")
|
|
3627
|
+
};
|
|
3628
|
+
}, [
|
|
3629
|
+
portfolios,
|
|
3630
|
+
programs,
|
|
3631
|
+
projects,
|
|
3335
3632
|
tasks,
|
|
3336
|
-
comments,
|
|
3337
3633
|
milestones,
|
|
3338
3634
|
raidItems,
|
|
3339
|
-
constraints,
|
|
3340
3635
|
people,
|
|
3341
|
-
|
|
3342
|
-
|
|
3636
|
+
checkIns,
|
|
3637
|
+
constraints,
|
|
3638
|
+
portfolio,
|
|
3639
|
+
day,
|
|
3640
|
+
harborName
|
|
3343
3641
|
]);
|
|
3344
|
-
const
|
|
3345
|
-
const
|
|
3346
|
-
const
|
|
3347
|
-
const
|
|
3348
|
-
const open = (p) => {
|
|
3349
|
-
store.getState().readPing(p.id);
|
|
3350
|
-
if (p.taskId) go.openTask(p.taskId);
|
|
3351
|
-
else if (p.programId) go.openProgram(p.programId);
|
|
3352
|
-
};
|
|
3642
|
+
const lines = useMemo(() => wrapLines(brief.text.split("\n").slice(2).join("\n"), width - 2), [brief.text, width]);
|
|
3643
|
+
const rows = Math.max(1, height - 2);
|
|
3644
|
+
const last = Math.max(0, lines.length - rows);
|
|
3645
|
+
const scroll = (by) => setTop((t) => Math.max(0, Math.min(last, t + by)));
|
|
3353
3646
|
useInput((input, key) => {
|
|
3354
|
-
if (key.upArrow || input === "k")
|
|
3355
|
-
else if (key.downArrow || input === "j")
|
|
3356
|
-
else if (key.
|
|
3357
|
-
else if (
|
|
3358
|
-
else if (input === "
|
|
3647
|
+
if (key.upArrow || input === "k") scroll(-1);
|
|
3648
|
+
else if (key.downArrow || input === "j") scroll(1);
|
|
3649
|
+
else if (key.pageDown || input === " ") scroll(rows - 1);
|
|
3650
|
+
else if (key.pageUp) scroll(-(rows - 1));
|
|
3651
|
+
else if (input === "p") go.pick("Brief for which portfolio?", [{
|
|
3652
|
+
label: "All portfolios",
|
|
3653
|
+
value: null
|
|
3654
|
+
}, ...portfolios.map((p) => ({
|
|
3655
|
+
label: p.name,
|
|
3656
|
+
value: p.id
|
|
3657
|
+
}))], (v) => {
|
|
3658
|
+
setPortfolio(v ?? null);
|
|
3659
|
+
setTop(0);
|
|
3660
|
+
});
|
|
3359
3661
|
}, { isActive: active });
|
|
3360
|
-
const
|
|
3662
|
+
const shown = lines.slice(Math.min(top, last), Math.min(top, last) + rows);
|
|
3361
3663
|
return /* @__PURE__ */ jsxs(Box, {
|
|
3362
3664
|
flexDirection: "column",
|
|
3363
3665
|
width,
|
|
3364
3666
|
height,
|
|
3365
3667
|
paddingX: 1,
|
|
3366
3668
|
children: [
|
|
3367
|
-
/* @__PURE__ */
|
|
3368
|
-
right: `${
|
|
3369
|
-
children: "
|
|
3669
|
+
/* @__PURE__ */ jsxs(Heading, {
|
|
3670
|
+
right: `${lines.length > rows ? "↑↓ read on · " : ""}p portfolio`,
|
|
3671
|
+
children: ["Brief · ", brief.scope]
|
|
3370
3672
|
}),
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
const fresh = !read.has(p.id);
|
|
3378
|
-
return /* @__PURE__ */ jsxs(Text, {
|
|
3379
|
-
color: on ? theme.selected : void 0,
|
|
3380
|
-
bold: on || fresh,
|
|
3673
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
3674
|
+
shown.map((line, i) => {
|
|
3675
|
+
const heading = line !== "" && !/^[ •]/.test(line) && lines[Math.min(top, last) + i - 1] === "";
|
|
3676
|
+
return /* @__PURE__ */ jsx(Text, {
|
|
3677
|
+
bold: heading,
|
|
3678
|
+
color: heading ? theme.accent : void 0,
|
|
3381
3679
|
wrap: "truncate-end",
|
|
3382
|
-
children:
|
|
3383
|
-
|
|
3384
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3385
|
-
color: fresh ? theme.coral : theme.muted,
|
|
3386
|
-
children: fresh ? "● " : " "
|
|
3387
|
-
}),
|
|
3388
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3389
|
-
color: theme.muted,
|
|
3390
|
-
children: PING_KIND_LABEL[p.kind].padEnd(15)
|
|
3391
|
-
}),
|
|
3392
|
-
p.title,
|
|
3393
|
-
/* @__PURE__ */ jsxs(Text, {
|
|
3394
|
-
color: theme.muted,
|
|
3395
|
-
children: [
|
|
3396
|
-
p.detail ? ` · ${p.detail}` : "",
|
|
3397
|
-
" · ",
|
|
3398
|
-
timeAgo(parseIso(p.at))
|
|
3399
|
-
]
|
|
3400
|
-
})
|
|
3401
|
-
]
|
|
3402
|
-
}, p.id);
|
|
3680
|
+
children: line || " "
|
|
3681
|
+
}, i);
|
|
3403
3682
|
})
|
|
3404
3683
|
]
|
|
3405
3684
|
});
|
|
3406
3685
|
}
|
|
3407
3686
|
|
|
3408
3687
|
//#endregion
|
|
3409
|
-
//#region src/
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
const
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
if (idx >= 0 && idx < weeks) buckets[idx].due += 1;
|
|
3436
|
-
}
|
|
3437
|
-
return {
|
|
3438
|
-
overdue,
|
|
3439
|
-
undated,
|
|
3440
|
-
weeks: buckets
|
|
3441
|
-
};
|
|
3688
|
+
//#region src/tui/moves.ts
|
|
3689
|
+
/**
|
|
3690
|
+
* Moving and adding tasks with the checks the website makes first
|
|
3691
|
+
* (components/gate-asks.ts): a column at its limit says so, and its owner
|
|
3692
|
+
* can raise the limit by one to go ahead. Finishing a task that still waits
|
|
3693
|
+
* on another asks through the store (blockPending), as on the website.
|
|
3694
|
+
*/
|
|
3695
|
+
function moveTask(store, go, task, status) {
|
|
3696
|
+
const s = store.getState();
|
|
3697
|
+
if (task.status === status) return;
|
|
3698
|
+
const overflow = columnWip(s, task.projectId, status, 1);
|
|
3699
|
+
if (!overflow) return s.moveTask(task.id, status);
|
|
3700
|
+
if (s.myRole !== "owner") return go.notify(wipFullMessage(overflow));
|
|
3701
|
+
go.confirm(`${wipFullMessage(overflow)} Raise its limit by one and move it?`, () => {
|
|
3702
|
+
store.getState().raiseColumnCap(task.projectId, status);
|
|
3703
|
+
store.getState().moveTask(task.id, status);
|
|
3704
|
+
}, "Raise the limit");
|
|
3705
|
+
}
|
|
3706
|
+
/** Whether a new task fits its column; an owner's go raises the limit first. */
|
|
3707
|
+
function roomFor(store, projectId, status) {
|
|
3708
|
+
const s = store.getState();
|
|
3709
|
+
const overflow = columnWip(s, projectId, status, 1);
|
|
3710
|
+
if (!overflow) return null;
|
|
3711
|
+
if (s.myRole !== "owner") return wipFullMessage(overflow);
|
|
3712
|
+
s.raiseColumnCap(projectId, status);
|
|
3713
|
+
return null;
|
|
3442
3714
|
}
|
|
3443
3715
|
|
|
3444
3716
|
//#endregion
|
|
3445
|
-
//#region src/tui/views/
|
|
3446
|
-
|
|
3447
|
-
function
|
|
3717
|
+
//#region src/tui/views/common.tsx
|
|
3718
|
+
/** One task on one line: key, title, due, priority, and where it lives. */
|
|
3719
|
+
function TaskLine({ task, projects, people, today, selected, width, showProject = true, showAssignee = false, blocked = false }) {
|
|
3720
|
+
const late = isLate(task, today);
|
|
3721
|
+
const project = showProject ? projects.find((p) => p.id === task.projectId) : null;
|
|
3722
|
+
const who = showAssignee ? people?.find((p) => p.id === task.assigneeId) : null;
|
|
3723
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
3724
|
+
width,
|
|
3725
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
3726
|
+
color: selected ? theme.selected : void 0,
|
|
3727
|
+
bold: selected,
|
|
3728
|
+
wrap: "truncate-end",
|
|
3729
|
+
children: [
|
|
3730
|
+
selected ? "› " : " ",
|
|
3731
|
+
/* @__PURE__ */ jsx(Text, {
|
|
3732
|
+
color: theme.muted,
|
|
3733
|
+
children: keyOf({ projects }, task).padEnd(7)
|
|
3734
|
+
}),
|
|
3735
|
+
task.status === "done" ? /* @__PURE__ */ jsx(Text, {
|
|
3736
|
+
strikethrough: true,
|
|
3737
|
+
children: task.title
|
|
3738
|
+
}) : task.title,
|
|
3739
|
+
" ",
|
|
3740
|
+
/* @__PURE__ */ jsx(PriorityMark, { priority: task.priority }),
|
|
3741
|
+
task.dueDate ? /* @__PURE__ */ jsxs(Text, {
|
|
3742
|
+
color: late ? theme.bad : theme.muted,
|
|
3743
|
+
children: [
|
|
3744
|
+
" ",
|
|
3745
|
+
late ? "late · " : "",
|
|
3746
|
+
shortDay(task.dueDate, today)
|
|
3747
|
+
]
|
|
3748
|
+
}) : null,
|
|
3749
|
+
blocked ? /* @__PURE__ */ jsx(Text, {
|
|
3750
|
+
color: theme.warn,
|
|
3751
|
+
children: " waits"
|
|
3752
|
+
}) : null,
|
|
3753
|
+
who ? /* @__PURE__ */ jsxs(Text, {
|
|
3754
|
+
color: theme.muted,
|
|
3755
|
+
children: [" · ", who.name]
|
|
3756
|
+
}) : null,
|
|
3757
|
+
project ? /* @__PURE__ */ jsxs(Text, {
|
|
3758
|
+
color: theme.muted,
|
|
3759
|
+
children: [" · ", project.name]
|
|
3760
|
+
}) : null
|
|
3761
|
+
]
|
|
3762
|
+
})
|
|
3763
|
+
});
|
|
3764
|
+
}
|
|
3765
|
+
/** Up/down within a list of `length`, wrapping neither way. */
|
|
3766
|
+
function step(at, delta, length) {
|
|
3767
|
+
if (length === 0) return 0;
|
|
3768
|
+
return Math.max(0, Math.min(length - 1, at + delta));
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
//#endregion
|
|
3772
|
+
//#region src/tui/views/calendar.tsx
|
|
3773
|
+
const WEEKDAYS = [
|
|
3774
|
+
"Sun",
|
|
3775
|
+
"Mon",
|
|
3776
|
+
"Tue",
|
|
3777
|
+
"Wed",
|
|
3778
|
+
"Thu",
|
|
3779
|
+
"Fri",
|
|
3780
|
+
"Sat"
|
|
3781
|
+
];
|
|
3782
|
+
/**
|
|
3783
|
+
* The month as the website's calendar shows it: each day with how many tasks
|
|
3784
|
+
* fall due and ◆ for a milestone, and the picked day's list under it. Arrows
|
|
3785
|
+
* move the day, [ ] the month, t comes back to today; Enter goes into the
|
|
3786
|
+
* day's tasks, Esc back out.
|
|
3787
|
+
*/
|
|
3788
|
+
function CalendarView({ active, width, height, go }) {
|
|
3789
|
+
const { store, today } = useDeps();
|
|
3790
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
3791
|
+
const milestones = useHarbor((s) => s.milestones);
|
|
3792
|
+
const projects = useHarbor((s) => s.projects);
|
|
3793
|
+
const programs = useHarbor((s) => s.programs);
|
|
3794
|
+
const people = useHarbor((s) => s.people);
|
|
3795
|
+
const now = today();
|
|
3796
|
+
const [day, setDay] = useState(now);
|
|
3797
|
+
const [inList, setInList] = useState(false);
|
|
3448
3798
|
const [at, setAt] = useState(0);
|
|
3449
|
-
const
|
|
3799
|
+
const due = useMemo(() => dueByDay({
|
|
3800
|
+
tasks,
|
|
3801
|
+
milestones
|
|
3802
|
+
}), [tasks, milestones]);
|
|
3803
|
+
const month = monthOf(day);
|
|
3804
|
+
const weeks = useMemo(() => monthWeeks(month), [month]);
|
|
3805
|
+
const agenda = useMemo(() => {
|
|
3806
|
+
const list = due.tasks.get(day) ?? [];
|
|
3807
|
+
return [...list.filter((t) => t.status !== "done"), ...list.filter((t) => t.status === "done")];
|
|
3808
|
+
}, [due, day]);
|
|
3809
|
+
const marks = due.milestones.get(day) ?? [];
|
|
3810
|
+
const selected = agenda[Math.min(at, agenda.length - 1)];
|
|
3811
|
+
const move = (days) => {
|
|
3812
|
+
setDay((d) => format(addDays(parseIso(d), days), "yyyy-MM-dd"));
|
|
3813
|
+
setAt(0);
|
|
3814
|
+
};
|
|
3815
|
+
const jump = (iso) => {
|
|
3816
|
+
setDay(iso);
|
|
3817
|
+
setAt(0);
|
|
3818
|
+
};
|
|
3450
3819
|
useInput((input, key) => {
|
|
3451
|
-
if (
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3820
|
+
if (inList) {
|
|
3821
|
+
if (key.escape) setInList(false);
|
|
3822
|
+
else if (key.upArrow || input === "k") setAt((i) => step(i, -1, agenda.length));
|
|
3823
|
+
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, agenda.length));
|
|
3824
|
+
else if (key.return && selected) go.openTask(selected.id);
|
|
3825
|
+
else if (input === "x" && selected) moveTask(store, go, selected, "done");
|
|
3826
|
+
return;
|
|
3827
|
+
}
|
|
3828
|
+
if (key.leftArrow || input === "h") move(-1);
|
|
3829
|
+
else if (key.rightArrow || input === "l") move(1);
|
|
3830
|
+
else if (key.upArrow || input === "k") move(-7);
|
|
3831
|
+
else if (key.downArrow || input === "j") move(7);
|
|
3832
|
+
else if (input === "[") jump(shiftMonth(month, -1));
|
|
3833
|
+
else if (input === "]") jump(shiftMonth(month, 1));
|
|
3834
|
+
else if (input === "t") jump(now);
|
|
3835
|
+
else if (key.return && agenda.length) setInList(true);
|
|
3457
3836
|
}, { isActive: active });
|
|
3458
|
-
const
|
|
3837
|
+
const cell = Math.max(5, Math.min(11, Math.floor((width - 2) / 7)));
|
|
3838
|
+
const listRows = Math.max(1, height - 3 - weeks.length - 1 - (marks.length ? marks.length : 0));
|
|
3839
|
+
const view = windowOf(agenda, at, listRows);
|
|
3840
|
+
const open = agenda.filter((t) => t.status !== "done").length;
|
|
3459
3841
|
return /* @__PURE__ */ jsxs(Box, {
|
|
3460
3842
|
flexDirection: "column",
|
|
3461
3843
|
width,
|
|
@@ -3463,1027 +3845,1657 @@ function RowList({ title, right, rows, empty, active, width, height, onOpen, key
|
|
|
3463
3845
|
paddingX: 1,
|
|
3464
3846
|
children: [
|
|
3465
3847
|
/* @__PURE__ */ jsx(Heading, {
|
|
3466
|
-
right,
|
|
3467
|
-
children:
|
|
3848
|
+
right: inList ? "↑↓ task · Enter open · x done · Esc days" : "←→↑↓ day · [ ] month · t today · Enter its tasks",
|
|
3849
|
+
children: format(parseIso(month), "MMMM yyyy")
|
|
3468
3850
|
}),
|
|
3469
|
-
|
|
3851
|
+
/* @__PURE__ */ jsx(Text, {
|
|
3470
3852
|
color: theme.muted,
|
|
3471
|
-
children:
|
|
3472
|
-
})
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3853
|
+
children: WEEKDAYS.map((d) => d.padEnd(cell)).join("")
|
|
3854
|
+
}),
|
|
3855
|
+
weeks.map((week) => /* @__PURE__ */ jsx(Text, {
|
|
3856
|
+
wrap: "truncate-end",
|
|
3857
|
+
children: week.map((iso) => {
|
|
3858
|
+
const left = (due.tasks.get(iso) ?? []).filter((t) => t.status !== "done");
|
|
3859
|
+
const late = left.some((t) => isLate(t, now));
|
|
3860
|
+
const ms = due.milestones.get(iso)?.length ?? 0;
|
|
3861
|
+
const inMonth = iso.slice(0, 7) === month.slice(0, 7);
|
|
3862
|
+
const on = iso === day;
|
|
3863
|
+
const label = `${String(Number(iso.slice(8))).padStart(2)}${ms ? "◆" : " "}${left.length ? String(left.length) : ""}`;
|
|
3864
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
3865
|
+
inverse: on,
|
|
3866
|
+
bold: on || iso === now,
|
|
3867
|
+
color: iso === now && !on ? theme.accent : late ? theme.bad : inMonth ? void 0 : theme.muted,
|
|
3868
|
+
children: [label.padEnd(cell - 1), /* @__PURE__ */ jsx(Text, {
|
|
3869
|
+
inverse: false,
|
|
3870
|
+
children: " "
|
|
3871
|
+
})]
|
|
3872
|
+
}, iso);
|
|
3873
|
+
})
|
|
3874
|
+
}, week[0])),
|
|
3875
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
3876
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
3877
|
+
bold: true,
|
|
3878
|
+
wrap: "truncate-end",
|
|
3879
|
+
children: [format(parseIso(day), "EEEE, MMMM d"), /* @__PURE__ */ jsxs(Text, {
|
|
3880
|
+
color: theme.muted,
|
|
3881
|
+
bold: false,
|
|
3882
|
+
children: [" · ", agenda.length ? `${open} open${agenda.length > open ? `, ${agenda.length - open} done` : ""}` : "nothing due"]
|
|
3883
|
+
})]
|
|
3884
|
+
}),
|
|
3885
|
+
marks.map((m) => /* @__PURE__ */ jsxs(Text, {
|
|
3886
|
+
wrap: "truncate-end",
|
|
3887
|
+
color: m.done ? theme.muted : theme.accent,
|
|
3888
|
+
children: [
|
|
3889
|
+
" ",
|
|
3890
|
+
"◆ ",
|
|
3891
|
+
m.name,
|
|
3892
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
3893
|
+
color: theme.muted,
|
|
3894
|
+
children: [
|
|
3895
|
+
" ",
|
|
3896
|
+
m.done ? "· done " : "",
|
|
3897
|
+
"· ",
|
|
3898
|
+
programs.find((p) => p.id === m.programId)?.name ?? ""
|
|
3899
|
+
]
|
|
3900
|
+
})
|
|
3901
|
+
]
|
|
3902
|
+
}, m.id)),
|
|
3903
|
+
view.items.map((t, i) => /* @__PURE__ */ jsx(TaskLine, {
|
|
3904
|
+
task: t,
|
|
3905
|
+
projects,
|
|
3906
|
+
people,
|
|
3907
|
+
today: now,
|
|
3908
|
+
selected: inList && active && view.start + i === Math.min(at, agenda.length - 1),
|
|
3909
|
+
width: width - 2,
|
|
3910
|
+
showAssignee: true
|
|
3911
|
+
}, t.id))
|
|
3491
3912
|
]
|
|
3492
3913
|
});
|
|
3493
3914
|
}
|
|
3494
|
-
/** Every project, under its program and portfolio, with health and progress. Done
|
|
3495
|
-
* projects and programs stay out of the way until D shows them. */
|
|
3496
|
-
function ProjectsView({ active, width, height, go }) {
|
|
3497
|
-
const portfolios = useHarbor((s) => s.portfolios);
|
|
3498
|
-
const programs = useHarbor((s) => s.programs);
|
|
3499
|
-
const projects = useHarbor((s) => s.projects);
|
|
3500
|
-
const tasks = useHarbor((s) => s.tasks);
|
|
3501
|
-
const { today } = useDeps();
|
|
3502
|
-
const [showDone, setShowDone] = useState(false);
|
|
3503
|
-
const { rows, shown, hidden } = useMemo(() => {
|
|
3504
|
-
const data = {
|
|
3505
|
-
tasks,
|
|
3506
|
-
projects
|
|
3507
|
-
};
|
|
3508
|
-
const out = [];
|
|
3509
|
-
let shown = 0;
|
|
3510
|
-
let hidden = 0;
|
|
3511
|
-
for (const portfolio of portfolios) {
|
|
3512
|
-
out.push({
|
|
3513
|
-
id: portfolio.id,
|
|
3514
|
-
kind: "portfolio",
|
|
3515
|
-
depth: 0,
|
|
3516
|
-
title: portfolio.name,
|
|
3517
|
-
bold: true,
|
|
3518
|
-
health: portfolio.health
|
|
3519
|
-
});
|
|
3520
|
-
for (const program of programs.filter((g) => g.portfolioId === portfolio.id)) {
|
|
3521
|
-
const { open, done } = splitDone(projects.filter((p) => p.programId === program.id));
|
|
3522
|
-
if (program.health === "done" && open.length === 0) {
|
|
3523
|
-
hidden += 1 + done.length;
|
|
3524
|
-
if (!showDone) continue;
|
|
3525
|
-
} else hidden += done.length;
|
|
3526
|
-
out.push({
|
|
3527
|
-
id: program.id,
|
|
3528
|
-
kind: "program",
|
|
3529
|
-
depth: 1,
|
|
3530
|
-
title: program.name,
|
|
3531
|
-
health: program.health
|
|
3532
|
-
});
|
|
3533
|
-
for (const project of showDone ? [...open, ...done] : open) {
|
|
3534
|
-
shown++;
|
|
3535
|
-
const { done: finished, total } = projectSummary(data, project);
|
|
3536
|
-
const late = tasksOf(tasks, project.id).filter((t) => isLate(t, today())).length;
|
|
3537
|
-
out.push({
|
|
3538
|
-
id: project.id,
|
|
3539
|
-
kind: "project",
|
|
3540
|
-
depth: 2,
|
|
3541
|
-
title: `${project.name}${project.key ? ` ${project.key}` : ""}`,
|
|
3542
|
-
health: project.health,
|
|
3543
|
-
detail: `${finished}/${total} done${late ? ` · ${late} late` : ""} · ends ${shortDay(project.endDate, today())}`
|
|
3544
|
-
});
|
|
3545
|
-
}
|
|
3546
|
-
}
|
|
3547
|
-
}
|
|
3548
|
-
return {
|
|
3549
|
-
rows: out,
|
|
3550
|
-
shown,
|
|
3551
|
-
hidden
|
|
3552
|
-
};
|
|
3553
|
-
}, [
|
|
3554
|
-
portfolios,
|
|
3555
|
-
programs,
|
|
3556
|
-
projects,
|
|
3557
|
-
tasks,
|
|
3558
|
-
today,
|
|
3559
|
-
showDone
|
|
3560
|
-
]);
|
|
3561
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
3562
|
-
title: "Portfolios, programs and projects",
|
|
3563
|
-
right: `${count(shown, "project")}${hidden ? ` · ${hidden} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new project · e edit`,
|
|
3564
|
-
rows,
|
|
3565
|
-
empty: "No projects yet. n adds one.",
|
|
3566
|
-
active,
|
|
3567
|
-
width,
|
|
3568
|
-
height,
|
|
3569
|
-
onOpen: (r) => r.kind === "project" ? go.openProject(r.id) : r.kind === "program" ? go.openProgram(r.id) : go.openPortfolio(r.id),
|
|
3570
|
-
keys: {
|
|
3571
|
-
n: (r) => {
|
|
3572
|
-
const programId = r?.kind === "program" ? r.id : r?.kind === "project" ? projects.find((p) => p.id === r.id)?.programId : void 0;
|
|
3573
|
-
go.create("project", programId ? { programId } : {});
|
|
3574
|
-
},
|
|
3575
|
-
e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id),
|
|
3576
|
-
D: () => hidden > 0 && setShowDone((v) => !v)
|
|
3577
|
-
}
|
|
3578
|
-
});
|
|
3579
|
-
}
|
|
3580
|
-
/** Programs with their health and the next move someone wrote down. */
|
|
3581
|
-
function ProgramsView({ active, width, height, go }) {
|
|
3582
|
-
const programs = useHarbor((s) => s.programs);
|
|
3583
|
-
const portfolios = useHarbor((s) => s.portfolios);
|
|
3584
|
-
const milestones = useHarbor((s) => s.milestones);
|
|
3585
|
-
const { today } = useDeps();
|
|
3586
|
-
const [showDone, setShowDone] = useState(false);
|
|
3587
|
-
const split = useMemo(() => splitDone(programs), [programs]);
|
|
3588
|
-
const listed = useMemo(() => showDone ? [...split.open, ...split.done] : split.open, [split, showDone]);
|
|
3589
|
-
const rows = useMemo(() => listed.map((program) => {
|
|
3590
|
-
const portfolio = portfolios.find((p) => p.id === program.portfolioId);
|
|
3591
|
-
const late = milestones.filter((m) => m.programId === program.id && !m.done && m.dueDate < today()).length;
|
|
3592
|
-
return {
|
|
3593
|
-
id: program.id,
|
|
3594
|
-
kind: "program",
|
|
3595
|
-
depth: 0,
|
|
3596
|
-
title: program.name,
|
|
3597
|
-
health: program.health,
|
|
3598
|
-
detail: [
|
|
3599
|
-
portfolio?.name,
|
|
3600
|
-
late ? `${late} milestone${late === 1 ? "" : "s"} late` : null,
|
|
3601
|
-
program.nextMove ? `next: ${program.nextMove}` : null
|
|
3602
|
-
].filter(Boolean).join(" · ")
|
|
3603
|
-
};
|
|
3604
|
-
}), [
|
|
3605
|
-
listed,
|
|
3606
|
-
portfolios,
|
|
3607
|
-
milestones,
|
|
3608
|
-
today
|
|
3609
|
-
]);
|
|
3610
|
-
const done = split.done.length;
|
|
3611
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
3612
|
-
title: "Programs",
|
|
3613
|
-
right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new · e edit`,
|
|
3614
|
-
rows,
|
|
3615
|
-
empty: done ? "Every program is done. D shows them." : "No programs yet. n adds one.",
|
|
3616
|
-
active,
|
|
3617
|
-
width,
|
|
3618
|
-
height,
|
|
3619
|
-
onOpen: (r) => go.openProgram(r.id),
|
|
3620
|
-
keys: {
|
|
3621
|
-
n: (r) => {
|
|
3622
|
-
const portfolioId = r ? programs.find((p) => p.id === r.id)?.portfolioId : void 0;
|
|
3623
|
-
go.create("program", portfolioId ? { portfolioId } : {});
|
|
3624
|
-
},
|
|
3625
|
-
e: (r) => r && go.openPanel("program", r.id),
|
|
3626
|
-
D: () => done > 0 && setShowDone((v) => !v)
|
|
3627
|
-
}
|
|
3628
|
-
});
|
|
3629
|
-
}
|
|
3630
|
-
/** The risks, issues and decisions still open, worst first. */
|
|
3631
|
-
function RisksView({ active, width, height, go }) {
|
|
3632
|
-
const raidItems = useHarbor((s) => s.raidItems);
|
|
3633
|
-
const projects = useHarbor((s) => s.projects);
|
|
3634
|
-
const people = useHarbor((s) => s.people);
|
|
3635
|
-
const [all, setAll] = useState(false);
|
|
3636
|
-
const rows = useMemo(() => sortRaid(all ? raidItems : raidItems.filter(isActive)).map((r) => {
|
|
3637
|
-
const score = scoreOf(r);
|
|
3638
|
-
const band = score != null ? scoreBand(score) : null;
|
|
3639
|
-
return {
|
|
3640
|
-
id: r.id,
|
|
3641
|
-
kind: "raid",
|
|
3642
|
-
depth: 0,
|
|
3643
|
-
title: `${KIND_LABEL[r.kind]}: ${r.title}`,
|
|
3644
|
-
health: band === "high" ? "off-track" : band === "medium" ? "at-risk" : void 0,
|
|
3645
|
-
color: isActive(r) ? void 0 : theme.muted,
|
|
3646
|
-
detail: [
|
|
3647
|
-
STATUS_LABEL$1[r.status],
|
|
3648
|
-
score != null ? `score ${score}` : null,
|
|
3649
|
-
people.find((p) => p.id === r.ownerId)?.name,
|
|
3650
|
-
projects.find((p) => p.id === r.projectId)?.name
|
|
3651
|
-
].filter(Boolean).join(" · ")
|
|
3652
|
-
};
|
|
3653
|
-
}), [
|
|
3654
|
-
raidItems,
|
|
3655
|
-
projects,
|
|
3656
|
-
people,
|
|
3657
|
-
all
|
|
3658
|
-
]);
|
|
3659
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
3660
|
-
title: all ? "Risks, issues and decisions, all of them" : "Risks, issues and decisions, open",
|
|
3661
|
-
right: `${rows.length} · n log one · a ${all ? "open only" : "show closed too"}`,
|
|
3662
|
-
rows,
|
|
3663
|
-
empty: "Nothing open in the register. n logs one.",
|
|
3664
|
-
active,
|
|
3665
|
-
width,
|
|
3666
|
-
height,
|
|
3667
|
-
onOpen: (r) => go.openPanel("raid", r.id),
|
|
3668
|
-
keys: {
|
|
3669
|
-
n: () => go.create("raid", {}),
|
|
3670
|
-
a: () => setAll((v) => !v)
|
|
3671
|
-
}
|
|
3672
|
-
});
|
|
3673
|
-
}
|
|
3674
|
-
/** Who's here: open and late work, and how full each of the next weeks is against their capacity. */
|
|
3675
|
-
function PeopleView({ active, width, height, go }) {
|
|
3676
|
-
const people = useHarbor((s) => s.people);
|
|
3677
|
-
const tasks = useHarbor((s) => s.tasks);
|
|
3678
|
-
const { today } = useDeps();
|
|
3679
|
-
const rows = useMemo(() => {
|
|
3680
|
-
const now = /* @__PURE__ */ new Date(`${today()}T12:00:00`);
|
|
3681
|
-
const nameWidth = Math.max(0, ...people.map((p) => p.name.length));
|
|
3682
|
-
return people.map((p) => {
|
|
3683
|
-
const open = tasks.filter((t) => t.assigneeId === p.id && t.status !== "done");
|
|
3684
|
-
const load = weeklyLoad(tasks, p.id, now, 4);
|
|
3685
|
-
const cap = p.weeklyCapacity ?? 5;
|
|
3686
|
-
const weeks = load.weeks.map((w) => (w.due === 0 ? "·" : w.due > cap ? `${w.due}!` : String(w.due)).padStart(3)).join("");
|
|
3687
|
-
const late = load.overdue ? `${load.overdue} late` : "";
|
|
3688
|
-
return {
|
|
3689
|
-
id: p.id,
|
|
3690
|
-
kind: "person",
|
|
3691
|
-
depth: 0,
|
|
3692
|
-
title: p.name.padEnd(nameWidth),
|
|
3693
|
-
detail: `${String(open.length).padStart(3)} open ${late.padEnd(7)} weeks${weeks} of ${cap} · ${p.role}`
|
|
3694
|
-
};
|
|
3695
|
-
});
|
|
3696
|
-
}, [
|
|
3697
|
-
people,
|
|
3698
|
-
tasks,
|
|
3699
|
-
today
|
|
3700
|
-
]);
|
|
3701
|
-
return /* @__PURE__ */ jsx(RowList, {
|
|
3702
|
-
title: "People",
|
|
3703
|
-
right: `${people.length === 1 ? "1 person" : `${people.length} people`} · Enter to change`,
|
|
3704
|
-
rows,
|
|
3705
|
-
empty: "Nobody here yet. Invite people from the website.",
|
|
3706
|
-
active,
|
|
3707
|
-
width,
|
|
3708
|
-
height,
|
|
3709
|
-
onOpen: (r) => go.openPanel("person", r.id)
|
|
3710
|
-
});
|
|
3711
|
-
}
|
|
3712
3915
|
|
|
3713
3916
|
//#endregion
|
|
3714
|
-
//#region
|
|
3917
|
+
//#region src/lib/history-copy.ts
|
|
3715
3918
|
/**
|
|
3716
|
-
*
|
|
3717
|
-
*
|
|
3718
|
-
*
|
|
3719
|
-
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
3720
|
-
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
3721
|
-
*
|
|
3722
|
-
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
3723
|
-
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
3919
|
+
* History events as sentences: "moved Order sample boxes from In play to
|
|
3920
|
+
* Done". Split from history.ts so the store and the server, which only record
|
|
3921
|
+
* events, don't carry the wording.
|
|
3724
3922
|
*/
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
"
|
|
3728
|
-
"
|
|
3729
|
-
"
|
|
3730
|
-
"
|
|
3731
|
-
"
|
|
3732
|
-
"
|
|
3733
|
-
"
|
|
3734
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3735
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3736
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3737
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3738
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3739
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3740
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3741
|
-
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
3742
|
-
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
3743
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3744
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3745
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3746
|
-
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
3747
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3748
|
-
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
3749
|
-
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
3750
|
-
"...TTTTTTTTTTTTTTTTTT..."
|
|
3751
|
-
];
|
|
3752
|
-
/** 16 by 16, for the keys overlay. */
|
|
3753
|
-
const LOGO_SMALL = [
|
|
3754
|
-
"..TTTTTTTTTTTT..",
|
|
3755
|
-
".TTTTTTTTTTTTTT.",
|
|
3756
|
-
"TTTTWWTTTTWWTTTT",
|
|
3757
|
-
"TTTTWWTTTTWWTTTT",
|
|
3758
|
-
"TPPTWWTTTTWWTTTT",
|
|
3759
|
-
"TCCTWWTTTTWWTTTT",
|
|
3760
|
-
"TCCTWWTTTTWWTTTT",
|
|
3761
|
-
"TTTTWWWWWWWWTTTT",
|
|
3762
|
-
"TTTTWWWWWWWWTTTT",
|
|
3763
|
-
"TTTTWWTTTTWWTPPT",
|
|
3764
|
-
"TTTTWWTPPTWWTCCT",
|
|
3765
|
-
"TTTTWWTCCTWWTCCT",
|
|
3766
|
-
"TTTTWWTCCTWWTTTT",
|
|
3767
|
-
"TTTTWWTTTTWWTTTT",
|
|
3768
|
-
".TTTTTTTTTTTTTT.",
|
|
3769
|
-
"..TTTTTTTTTTTT.."
|
|
3770
|
-
];
|
|
3771
|
-
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
3772
|
-
const LOGO_COLOURS = {
|
|
3773
|
-
T: "#0f6e64",
|
|
3774
|
-
W: "#ffffff",
|
|
3775
|
-
C: "#c44a2c"
|
|
3923
|
+
const NOUN$1 = {
|
|
3924
|
+
portfolio: "portfolio",
|
|
3925
|
+
program: "program",
|
|
3926
|
+
project: "project",
|
|
3927
|
+
task: "task",
|
|
3928
|
+
milestone: "milestone",
|
|
3929
|
+
risk: "risk",
|
|
3930
|
+
issue: "issue",
|
|
3931
|
+
decision: "decision"
|
|
3776
3932
|
};
|
|
3933
|
+
const CREATED = {
|
|
3934
|
+
risk: "raised risk",
|
|
3935
|
+
issue: "logged issue",
|
|
3936
|
+
decision: "recorded decision"
|
|
3937
|
+
};
|
|
3938
|
+
const whole = new Intl.NumberFormat("en-US", {
|
|
3939
|
+
style: "currency",
|
|
3940
|
+
currency: "USD",
|
|
3941
|
+
maximumFractionDigits: 0
|
|
3942
|
+
});
|
|
3943
|
+
function dollars(value) {
|
|
3944
|
+
const n = Number(value ?? 0);
|
|
3945
|
+
return whole.format(Number.isFinite(n) ? n : 0);
|
|
3946
|
+
}
|
|
3947
|
+
function day(value) {
|
|
3948
|
+
return value ? shortDate(value) : "no date";
|
|
3949
|
+
}
|
|
3950
|
+
/** Whole days from one ISO day to another. */
|
|
3951
|
+
function daysBetween(from, to) {
|
|
3952
|
+
const a = Date.parse(`${from.slice(0, 10)}T12:00:00Z`);
|
|
3953
|
+
const b = Date.parse(`${to.slice(0, 10)}T12:00:00Z`);
|
|
3954
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
|
|
3955
|
+
return Math.round((b - a) / 864e5);
|
|
3956
|
+
}
|
|
3957
|
+
function slip(from, to) {
|
|
3958
|
+
if (!from || !to) return "";
|
|
3959
|
+
const d = daysBetween(from, to);
|
|
3960
|
+
if (d === 0) return "";
|
|
3961
|
+
const n = Math.abs(d);
|
|
3962
|
+
return ` (${n} day${n === 1 ? "" : "s"} ${d > 0 ? "later" : "earlier"})`;
|
|
3963
|
+
}
|
|
3964
|
+
function nameOf$1(names, id, fallback = "someone") {
|
|
3965
|
+
if (!id) return fallback;
|
|
3966
|
+
return names.people.find((p) => p.id === id)?.name ?? names.programs.find((p) => p.id === id)?.name ?? names.portfolios.find((p) => p.id === id)?.name ?? names.projects.find((p) => p.id === id)?.name ?? names.tasks.find((t) => t.id === id)?.title ?? fallback;
|
|
3967
|
+
}
|
|
3968
|
+
function statusLabel(entity, value) {
|
|
3969
|
+
if (!value) return "none";
|
|
3970
|
+
if (entity === "task") return TASK_STATUS_LABEL[value] ?? value;
|
|
3971
|
+
return STATUS_LABEL$1[value] ?? value;
|
|
3972
|
+
}
|
|
3777
3973
|
/**
|
|
3778
|
-
*
|
|
3779
|
-
*
|
|
3780
|
-
*/
|
|
3781
|
-
/**
|
|
3782
|
-
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
3783
|
-
* half block's colour, the bottom one its background.
|
|
3784
|
-
* @param {string[]} pixels
|
|
3785
|
-
* @returns {Run[][]}
|
|
3974
|
+
* The verb before the item's name and what follows it, so the page can show
|
|
3975
|
+
* "Maya <verb> <link to the item> <detail>". Both are plain text.
|
|
3786
3976
|
*/
|
|
3787
|
-
function
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3977
|
+
function describeEvent(e, names) {
|
|
3978
|
+
const noun = NOUN$1[e.entity];
|
|
3979
|
+
if (e.field === "count") {
|
|
3980
|
+
const n = Number(e.to ?? 0).toLocaleString("en-US");
|
|
3981
|
+
return e.kind === "created" ? {
|
|
3982
|
+
action: `added ${n} tasks to`,
|
|
3983
|
+
detail: null
|
|
3984
|
+
} : {
|
|
3985
|
+
action: `deleted ${n} tasks from`,
|
|
3986
|
+
detail: null
|
|
3987
|
+
};
|
|
3988
|
+
}
|
|
3989
|
+
if (e.kind === "created") return {
|
|
3990
|
+
action: CREATED[e.entity] ?? `added ${noun}`,
|
|
3991
|
+
detail: null
|
|
3992
|
+
};
|
|
3993
|
+
if (e.kind === "deleted") return {
|
|
3994
|
+
action: `deleted ${noun}`,
|
|
3995
|
+
detail: null
|
|
3996
|
+
};
|
|
3997
|
+
const { from, to } = e;
|
|
3998
|
+
switch (e.field) {
|
|
3999
|
+
case "name":
|
|
4000
|
+
case "title": return {
|
|
4001
|
+
action: `renamed ${noun}`,
|
|
4002
|
+
detail: from ? `from “${from}”` : null
|
|
4003
|
+
};
|
|
4004
|
+
case "status": return e.entity === "task" ? {
|
|
4005
|
+
action: "moved",
|
|
4006
|
+
detail: `from ${statusLabel(e.entity, from)} to ${statusLabel(e.entity, to)}`
|
|
4007
|
+
} : {
|
|
4008
|
+
action: "set",
|
|
4009
|
+
detail: `to ${statusLabel(e.entity, to)}`
|
|
4010
|
+
};
|
|
4011
|
+
case "assigneeId": return to ? {
|
|
4012
|
+
action: "assigned",
|
|
4013
|
+
detail: `to ${nameOf$1(names, to)}`
|
|
4014
|
+
} : {
|
|
4015
|
+
action: "unassigned",
|
|
4016
|
+
detail: from ? `from ${nameOf$1(names, from)}` : null
|
|
4017
|
+
};
|
|
4018
|
+
case "ownerId": return to ? {
|
|
4019
|
+
action: "handed",
|
|
4020
|
+
detail: `to ${nameOf$1(names, to)}`
|
|
4021
|
+
} : {
|
|
4022
|
+
action: "cleared the owner of",
|
|
4023
|
+
detail: null
|
|
4024
|
+
};
|
|
4025
|
+
case "dueDate": return {
|
|
4026
|
+
action: "moved the due date of",
|
|
4027
|
+
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
4028
|
+
};
|
|
4029
|
+
case "startDate": return {
|
|
4030
|
+
action: "moved the start of",
|
|
4031
|
+
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
4032
|
+
};
|
|
4033
|
+
case "endDate": return {
|
|
4034
|
+
action: "moved the end of",
|
|
4035
|
+
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
4036
|
+
};
|
|
4037
|
+
case "budget": return {
|
|
4038
|
+
action: "changed the budget of",
|
|
4039
|
+
detail: `from ${dollars(from)} to ${dollars(to)}`
|
|
4040
|
+
};
|
|
4041
|
+
case "spent": return {
|
|
4042
|
+
action: "moved spend on",
|
|
4043
|
+
detail: `from ${dollars(from)} to ${dollars(to)}`
|
|
4044
|
+
};
|
|
4045
|
+
case "priority": return {
|
|
4046
|
+
action: "set the priority of",
|
|
4047
|
+
detail: `to ${to ? PRIORITY_LABEL[to] ?? to : "none"}`
|
|
4048
|
+
};
|
|
4049
|
+
case "done": return to === "true" ? {
|
|
4050
|
+
action: "reached",
|
|
4051
|
+
detail: null
|
|
4052
|
+
} : {
|
|
4053
|
+
action: "reopened",
|
|
4054
|
+
detail: null
|
|
4055
|
+
};
|
|
4056
|
+
case "approvalStatus":
|
|
4057
|
+
if (to === "approved") return {
|
|
4058
|
+
action: "approved",
|
|
4059
|
+
detail: null
|
|
4060
|
+
};
|
|
4061
|
+
if (to === "changes_requested") return {
|
|
4062
|
+
action: "asked for changes to",
|
|
4063
|
+
detail: null
|
|
4064
|
+
};
|
|
4065
|
+
if (to === "pending") return {
|
|
4066
|
+
action: "asked for sign-off on",
|
|
4067
|
+
detail: null
|
|
4068
|
+
};
|
|
4069
|
+
return {
|
|
4070
|
+
action: "withdrew the sign-off on",
|
|
4071
|
+
detail: null
|
|
4072
|
+
};
|
|
4073
|
+
case "probability":
|
|
4074
|
+
case "impact": return {
|
|
4075
|
+
action: "rescored",
|
|
4076
|
+
detail: `${e.field} ${from ?? "unset"} → ${to ?? "unset"}`
|
|
4077
|
+
};
|
|
4078
|
+
case "healthOverride": return to ? {
|
|
4079
|
+
action: "marked",
|
|
4080
|
+
detail: `as ${HEALTH_LABEL$1[to] ?? to}${e.note ? `: “${e.note}”` : ""}`
|
|
4081
|
+
} : {
|
|
4082
|
+
action: "cleared the health override on",
|
|
4083
|
+
detail: null
|
|
4084
|
+
};
|
|
4085
|
+
case "programId": return {
|
|
4086
|
+
action: "moved",
|
|
4087
|
+
detail: `to ${nameOf$1(names, to, "another program")}`
|
|
4088
|
+
};
|
|
4089
|
+
case "portfolioId": return {
|
|
4090
|
+
action: "moved",
|
|
4091
|
+
detail: `to ${nameOf$1(names, to, "another portfolio")}`
|
|
4092
|
+
};
|
|
4093
|
+
case "blockedByIds": {
|
|
4094
|
+
const ids = to ? to.split(",") : [];
|
|
4095
|
+
return ids.length ? {
|
|
4096
|
+
action: "set",
|
|
4097
|
+
detail: `to wait on ${nameList(ids.map((id) => nameOf$1(names, id, "a deleted item")))}`
|
|
3815
4098
|
} : {
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
bg: b
|
|
4099
|
+
action: "cleared the blockers on",
|
|
4100
|
+
detail: null
|
|
3819
4101
|
};
|
|
3820
|
-
const last = runs.at(-1);
|
|
3821
|
-
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
3822
|
-
else runs.push(cell);
|
|
3823
4102
|
}
|
|
3824
|
-
|
|
4103
|
+
default: return {
|
|
4104
|
+
action: `changed ${e.field ?? "something on"}`,
|
|
4105
|
+
detail: null
|
|
4106
|
+
};
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
/** Who did it: the person row when there is one, then the name the server recorded, and the app an agent used. */
|
|
4110
|
+
function actorOf(row, people) {
|
|
4111
|
+
const person = row.actorPersonId && people.find((p) => p.id === row.actorPersonId) || row.actorUserId && people.find((p) => p.accountId === row.actorUserId);
|
|
4112
|
+
const who = person ? person.name : row.actorName || "Someone";
|
|
4113
|
+
return row.via ? `${who} via ${row.via}` : who;
|
|
4114
|
+
}
|
|
4115
|
+
|
|
4116
|
+
//#endregion
|
|
4117
|
+
//#region src/tui/views/history.tsx
|
|
4118
|
+
/**
|
|
4119
|
+
* Who changed what, newest first, for a portfolio, program or project (s
|
|
4120
|
+
* to pick which): the same History the website keeps, read from the server.
|
|
4121
|
+
*/
|
|
4122
|
+
function HistoryView({ active, width, height, go }) {
|
|
4123
|
+
const { transport } = useDeps();
|
|
4124
|
+
const workspaceId = useHarbor((s) => s.workspaceId);
|
|
4125
|
+
const version = useHarbor((s) => s.workspaceVersion);
|
|
4126
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
4127
|
+
const programs = useHarbor((s) => s.programs);
|
|
4128
|
+
const projects = useHarbor((s) => s.projects);
|
|
4129
|
+
const people = useHarbor((s) => s.people);
|
|
4130
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
4131
|
+
const first = portfolios[0];
|
|
4132
|
+
const [scope, setScope] = useState(first ? {
|
|
4133
|
+
scope: "portfolio",
|
|
4134
|
+
id: first.id,
|
|
4135
|
+
name: first.name
|
|
4136
|
+
} : null);
|
|
4137
|
+
const [rows, setRows] = useState([]);
|
|
4138
|
+
const [more, setMore] = useState(false);
|
|
4139
|
+
const [state, setState] = useState("loading");
|
|
4140
|
+
const [at, setAt] = useState(0);
|
|
4141
|
+
useEffect(() => {
|
|
4142
|
+
if (!scope || !workspaceId) return;
|
|
4143
|
+
let live = true;
|
|
4144
|
+
setState("loading");
|
|
4145
|
+
transport.history({
|
|
4146
|
+
workspaceId,
|
|
4147
|
+
scope: scope.scope,
|
|
4148
|
+
id: scope.id
|
|
4149
|
+
}).then((page) => {
|
|
4150
|
+
if (!live) return;
|
|
4151
|
+
setRows(page.rows);
|
|
4152
|
+
setMore(page.more);
|
|
4153
|
+
setState("ready");
|
|
4154
|
+
}).catch(() => live && setState("failed"));
|
|
4155
|
+
return () => {
|
|
4156
|
+
live = false;
|
|
4157
|
+
};
|
|
4158
|
+
}, [
|
|
4159
|
+
scope,
|
|
4160
|
+
workspaceId,
|
|
4161
|
+
version,
|
|
4162
|
+
transport
|
|
4163
|
+
]);
|
|
4164
|
+
const loadMore = () => {
|
|
4165
|
+
const last = rows.at(-1);
|
|
4166
|
+
if (!more || !last || !scope || !workspaceId) return;
|
|
4167
|
+
transport.history({
|
|
4168
|
+
workspaceId,
|
|
4169
|
+
scope: scope.scope,
|
|
4170
|
+
id: scope.id,
|
|
4171
|
+
before: {
|
|
4172
|
+
at: last.at,
|
|
4173
|
+
id: last.id
|
|
4174
|
+
}
|
|
4175
|
+
}).then((page) => {
|
|
4176
|
+
setRows((r) => [...r, ...page.rows]);
|
|
4177
|
+
setMore(page.more);
|
|
4178
|
+
});
|
|
4179
|
+
};
|
|
4180
|
+
useInput((input, key) => {
|
|
4181
|
+
if (key.upArrow || input === "k") setAt((i) => step(i, -1, rows.length));
|
|
4182
|
+
else if (key.downArrow || input === "j") {
|
|
4183
|
+
if (at >= rows.length - 2) loadMore();
|
|
4184
|
+
setAt((i) => step(i, 1, rows.length));
|
|
4185
|
+
} else if (key.pageDown) setAt((i) => step(i, 10, rows.length));
|
|
4186
|
+
else if (key.pageUp) setAt((i) => step(i, -10, rows.length));
|
|
4187
|
+
else if (input === "s") go.pick("History of", [
|
|
4188
|
+
...portfolios.map((p) => ({
|
|
4189
|
+
label: p.name,
|
|
4190
|
+
value: {
|
|
4191
|
+
scope: "portfolio",
|
|
4192
|
+
id: p.id,
|
|
4193
|
+
name: p.name
|
|
4194
|
+
},
|
|
4195
|
+
hint: "portfolio"
|
|
4196
|
+
})),
|
|
4197
|
+
...programs.map((p) => ({
|
|
4198
|
+
label: p.name,
|
|
4199
|
+
value: {
|
|
4200
|
+
scope: "program",
|
|
4201
|
+
id: p.id,
|
|
4202
|
+
name: p.name
|
|
4203
|
+
},
|
|
4204
|
+
hint: "program"
|
|
4205
|
+
})),
|
|
4206
|
+
...projects.map((p) => ({
|
|
4207
|
+
label: p.name,
|
|
4208
|
+
value: {
|
|
4209
|
+
scope: "project",
|
|
4210
|
+
id: p.id,
|
|
4211
|
+
name: p.name
|
|
4212
|
+
},
|
|
4213
|
+
hint: "project"
|
|
4214
|
+
}))
|
|
4215
|
+
], (v) => {
|
|
4216
|
+
setScope(v);
|
|
4217
|
+
setAt(0);
|
|
4218
|
+
});
|
|
4219
|
+
else if (key.return) {
|
|
4220
|
+
const row = rows[at];
|
|
4221
|
+
if (row?.entity === "task" && tasks.some((t) => t.id === row.entityId)) go.openTask(row.entityId);
|
|
4222
|
+
else if (row?.entity === "project" && projects.some((p) => p.id === row.entityId)) go.openProject(row.entityId);
|
|
4223
|
+
else if (row?.entity === "program" && programs.some((p) => p.id === row.entityId)) go.openProgram(row.entityId);
|
|
4224
|
+
}
|
|
4225
|
+
}, { isActive: active });
|
|
4226
|
+
const names = {
|
|
4227
|
+
people,
|
|
4228
|
+
portfolios,
|
|
4229
|
+
programs,
|
|
4230
|
+
projects,
|
|
4231
|
+
tasks
|
|
4232
|
+
};
|
|
4233
|
+
const view = windowOf(rows, at, Math.max(1, height - 2));
|
|
4234
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
4235
|
+
flexDirection: "column",
|
|
4236
|
+
width,
|
|
4237
|
+
height,
|
|
4238
|
+
paddingX: 1,
|
|
4239
|
+
children: [
|
|
4240
|
+
/* @__PURE__ */ jsxs(Heading, {
|
|
4241
|
+
right: `s pick what · Enter opens`,
|
|
4242
|
+
children: ["History: ", scope?.name ?? "nothing yet"]
|
|
4243
|
+
}),
|
|
4244
|
+
state === "loading" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
4245
|
+
color: theme.muted,
|
|
4246
|
+
children: "Loading…"
|
|
4247
|
+
}) : null,
|
|
4248
|
+
state === "failed" ? /* @__PURE__ */ jsx(Text, {
|
|
4249
|
+
color: theme.bad,
|
|
4250
|
+
children: "Couldn’t load History. It tries again after the next save."
|
|
4251
|
+
}) : null,
|
|
4252
|
+
state === "ready" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
4253
|
+
color: theme.muted,
|
|
4254
|
+
children: "Nothing recorded here yet."
|
|
4255
|
+
}) : null,
|
|
4256
|
+
view.items.map((row, i) => {
|
|
4257
|
+
const on = active && view.start + i === at;
|
|
4258
|
+
const { action, detail } = describeEvent(row, names);
|
|
4259
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
4260
|
+
color: on ? theme.selected : void 0,
|
|
4261
|
+
bold: on,
|
|
4262
|
+
wrap: "truncate-end",
|
|
4263
|
+
children: [
|
|
4264
|
+
on ? "› " : " ",
|
|
4265
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4266
|
+
color: theme.muted,
|
|
4267
|
+
children: `${timeAgo(parseIso(row.at))} `.padEnd(16)
|
|
4268
|
+
}),
|
|
4269
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4270
|
+
bold: true,
|
|
4271
|
+
children: actorOf({
|
|
4272
|
+
...row,
|
|
4273
|
+
via: null
|
|
4274
|
+
}, people)
|
|
4275
|
+
}),
|
|
4276
|
+
row.via ? /* @__PURE__ */ jsxs(Text, {
|
|
4277
|
+
color: theme.muted,
|
|
4278
|
+
children: [" via ", row.via]
|
|
4279
|
+
}) : null,
|
|
4280
|
+
" ",
|
|
4281
|
+
action,
|
|
4282
|
+
" ",
|
|
4283
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4284
|
+
bold: true,
|
|
4285
|
+
children: row.entityName
|
|
4286
|
+
}),
|
|
4287
|
+
detail ? /* @__PURE__ */ jsxs(Text, {
|
|
4288
|
+
color: theme.muted,
|
|
4289
|
+
children: [" ", detail]
|
|
4290
|
+
}) : null,
|
|
4291
|
+
row.note ? /* @__PURE__ */ jsxs(Text, {
|
|
4292
|
+
color: theme.muted,
|
|
4293
|
+
children: [
|
|
4294
|
+
" “",
|
|
4295
|
+
row.note,
|
|
4296
|
+
"”"
|
|
4297
|
+
]
|
|
4298
|
+
}) : null
|
|
4299
|
+
]
|
|
4300
|
+
}, row.id);
|
|
4301
|
+
})
|
|
4302
|
+
]
|
|
4303
|
+
});
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
//#endregion
|
|
4307
|
+
//#region src/lib/inbox.ts
|
|
4308
|
+
const KIND_RANK = {
|
|
4309
|
+
waiting: 0,
|
|
4310
|
+
approval: 1,
|
|
4311
|
+
review: 2,
|
|
4312
|
+
assigned: 3,
|
|
4313
|
+
idle: 4,
|
|
4314
|
+
mention: 5
|
|
4315
|
+
};
|
|
4316
|
+
const PING_KIND_LABEL = {
|
|
4317
|
+
waiting: "Waiting on you",
|
|
4318
|
+
approval: "Sign-offs",
|
|
4319
|
+
review: "Reviews due",
|
|
4320
|
+
assigned: "Assigned to you",
|
|
4321
|
+
idle: "Quiet blockers",
|
|
4322
|
+
mention: "Mentions"
|
|
4323
|
+
};
|
|
4324
|
+
function firstName(name) {
|
|
4325
|
+
return name.split(/\s+/)[0] ?? name;
|
|
4326
|
+
}
|
|
4327
|
+
/** FNV-1a over the text, as eight hex digits: a short, stable name for a set of ids. */
|
|
4328
|
+
function shortHash(text) {
|
|
4329
|
+
let h = 2166136261;
|
|
4330
|
+
for (let i = 0; i < text.length; i++) {
|
|
4331
|
+
h ^= text.charCodeAt(i);
|
|
4332
|
+
h = Math.imul(h, 16777619);
|
|
4333
|
+
}
|
|
4334
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
4335
|
+
}
|
|
4336
|
+
/** "Sam", "Sam and Aya", "Sam, Aya and Wren". */
|
|
4337
|
+
function listNames(names) {
|
|
4338
|
+
if (names.length <= 1) return names[0] ?? "Someone";
|
|
4339
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
4340
|
+
}
|
|
4341
|
+
function excerpt(body) {
|
|
4342
|
+
const t = body.replace(/(?<![\p{L}\p{N}_])@[\p{L}\p{N}_-]+/gu, "").replace(/\s+/g, " ").trim();
|
|
4343
|
+
if (t.length <= 96) return t;
|
|
4344
|
+
return `${t.slice(0, 94)}…`;
|
|
4345
|
+
}
|
|
4346
|
+
function projectOf(data, task) {
|
|
4347
|
+
return data.projects.find((p) => p.id === task.projectId);
|
|
4348
|
+
}
|
|
4349
|
+
/** How long an assignment stays in the inbox. */
|
|
4350
|
+
const ASSIGNED_DAYS = 14;
|
|
4351
|
+
function inboxOf(data, personId, now = clockNow()) {
|
|
4352
|
+
const pings = [];
|
|
4353
|
+
const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
4354
|
+
const since = subDays(now, ASSIGNED_DAYS).toISOString();
|
|
4355
|
+
for (const task of mine) {
|
|
4356
|
+
if (!task.assignedBy || task.assignedBy === personId || !task.assignedAt) continue;
|
|
4357
|
+
if (task.assignedAt < since) continue;
|
|
4358
|
+
const by = data.people.find((p) => p.id === task.assignedBy);
|
|
4359
|
+
const project = projectOf(data, task);
|
|
4360
|
+
pings.push({
|
|
4361
|
+
id: `assigned:${task.id}:${task.assignedAt}`,
|
|
4362
|
+
kind: "assigned",
|
|
4363
|
+
title: by ? `${firstName(by.name)} gave you a task` : "You have a new task",
|
|
4364
|
+
detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
|
|
4365
|
+
at: task.assignedAt,
|
|
4366
|
+
taskId: task.id,
|
|
4367
|
+
personId: task.assignedBy,
|
|
4368
|
+
href: `/projects/${task.projectId}`
|
|
4369
|
+
});
|
|
4370
|
+
}
|
|
4371
|
+
for (const blocker of mine) {
|
|
4372
|
+
const waiting = (waitersByBlocker(data.tasks).get(blocker.id) ?? []).filter((t) => t.status !== "done" && t.id !== blocker.id && t.assigneeId !== blocker.assigneeId);
|
|
4373
|
+
if (waiting.length === 0) continue;
|
|
4374
|
+
const project = projectOf(data, blocker);
|
|
4375
|
+
const names = [...new Set(waiting.map((t) => {
|
|
4376
|
+
const who = data.people.find((p) => p.id === t.assigneeId);
|
|
4377
|
+
return who ? firstName(who.name) : "Someone";
|
|
4378
|
+
}))];
|
|
4379
|
+
const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
|
|
4380
|
+
const upstream = openBlockerNames(blocker, data.tasks);
|
|
4381
|
+
pings.push({
|
|
4382
|
+
id: waiting.length === 1 ? `wait:${waiting[0].id}` : `wait:${blocker.id}:${shortHash(waiting.map((t) => t.id).sort().join(","))}`,
|
|
4383
|
+
kind: "waiting",
|
|
4384
|
+
title: `${listNames(names)} ${names.length === 1 ? "is" : "are"} waiting on you`,
|
|
4385
|
+
detail: `${blocked} ${waiting.length === 1 ? "is" : "are"} blocked by “${blocker.title}”${project ? ` · ${project.name}` : ""}${upstream ? `. Yours waits on ${upstream} first.` : ""}`,
|
|
4386
|
+
at: waiting.reduce((latest, t) => t.createdAt > latest ? t.createdAt : latest, ""),
|
|
4387
|
+
taskId: blocker.id,
|
|
4388
|
+
personId: waiting[0].assigneeId,
|
|
4389
|
+
href: `/projects/${blocker.projectId}`
|
|
4390
|
+
});
|
|
4391
|
+
}
|
|
4392
|
+
for (const m of data.milestones) {
|
|
4393
|
+
if (m.done || !m.approverId) continue;
|
|
4394
|
+
const program = data.programs.find((p) => p.id === m.programId);
|
|
4395
|
+
const where = program ? `${program.name} · ` : "";
|
|
4396
|
+
if (m.approverId === personId && m.approvalStatus !== "approved" && m.approvalStatus !== "changes_requested") pings.push({
|
|
4397
|
+
id: `approve:${m.id}`,
|
|
4398
|
+
kind: "approval",
|
|
4399
|
+
title: `${m.name} needs your sign-off`,
|
|
4400
|
+
detail: `${program ? `${program.name} · ` : ""}a stage gate only you can pass`,
|
|
4401
|
+
at: m.dueDate,
|
|
4402
|
+
programId: m.programId,
|
|
4403
|
+
href: `/programs/${m.programId}?view=milestones`,
|
|
4404
|
+
personId: program?.ownerId ?? null
|
|
4405
|
+
});
|
|
4406
|
+
else if (m.approvalStatus === "changes_requested" && program?.ownerId === personId && m.approverId !== personId) {
|
|
4407
|
+
const approver = data.people.find((p) => p.id === m.approverId);
|
|
4408
|
+
const note = (m.approvalNote ?? "").trim();
|
|
4409
|
+
pings.push({
|
|
4410
|
+
id: `changes:${m.id}:${shortHash(note)}`,
|
|
4411
|
+
kind: "approval",
|
|
4412
|
+
title: `${approver ? firstName(approver.name) : "The approver"} asked for changes to ${m.name}`,
|
|
4413
|
+
detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
|
|
4414
|
+
at: m.dueDate,
|
|
4415
|
+
programId: m.programId,
|
|
4416
|
+
href: `/programs/${m.programId}?view=milestones`,
|
|
4417
|
+
personId: m.approverId
|
|
4418
|
+
});
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
const today = todayISO(now);
|
|
4422
|
+
for (const r of data.raidItems) {
|
|
4423
|
+
if (r.ownerId !== personId || r.kind === "decision" || !isActive(r)) continue;
|
|
4424
|
+
if (!r.dueDate || r.dueDate >= today) continue;
|
|
4425
|
+
const where = r.projectId && data.projects.find((p) => p.id === r.projectId)?.name || r.programId && data.programs.find((p) => p.id === r.programId)?.name || r.portfolioId && data.portfolios.find((p) => p.id === r.portfolioId)?.name || "";
|
|
4426
|
+
pings.push({
|
|
4427
|
+
id: `review:${r.id}:${r.dueDate}`,
|
|
4428
|
+
kind: "review",
|
|
4429
|
+
title: `Time to review “${r.title}”`,
|
|
4430
|
+
detail: `${r.kind === "issue" ? "Issue" : "Risk"}${where ? ` in ${where}` : ""} · its review date has passed`,
|
|
4431
|
+
at: r.dueDate,
|
|
4432
|
+
href: `/risks/${r.id}`,
|
|
4433
|
+
personId: null
|
|
4434
|
+
});
|
|
4435
|
+
}
|
|
4436
|
+
for (const constraint of constraintsOwnedBy(data, personId)) {
|
|
4437
|
+
if (!constraintIdle(data, constraint)) continue;
|
|
4438
|
+
const program = data.programs.find((p) => p.id === constraint.programId);
|
|
4439
|
+
pings.push({
|
|
4440
|
+
id: `idle:${constraint.id}`,
|
|
4441
|
+
kind: "idle",
|
|
4442
|
+
title: "Your blocker has gone quiet",
|
|
4443
|
+
detail: program ? `${constraint.title} · ${program.name} is waiting while you work elsewhere.` : constraint.title,
|
|
4444
|
+
at: program?.startDate ?? constraint.id,
|
|
4445
|
+
programId: constraint.programId,
|
|
4446
|
+
href: `/programs/${constraint.programId}`,
|
|
4447
|
+
personId
|
|
4448
|
+
});
|
|
4449
|
+
}
|
|
4450
|
+
for (const comment of mentionsFor(data.comments, personId)) {
|
|
4451
|
+
const task = data.tasks.find((t) => t.id === comment.taskId);
|
|
4452
|
+
if (!task || task.status === "done") continue;
|
|
4453
|
+
const author = data.people.find((p) => p.id === comment.authorId);
|
|
4454
|
+
const project = projectOf(data, task);
|
|
4455
|
+
const snippet = excerpt(comment.body);
|
|
4456
|
+
pings.push({
|
|
4457
|
+
id: `mention:${comment.id}`,
|
|
4458
|
+
kind: "mention",
|
|
4459
|
+
title: author ? `${firstName(author.name)} mentioned you` : "You were mentioned",
|
|
4460
|
+
detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
|
|
4461
|
+
at: comment.createdAt,
|
|
4462
|
+
taskId: task.id,
|
|
4463
|
+
personId: comment.authorId,
|
|
4464
|
+
href: `/projects/${task.projectId}`
|
|
4465
|
+
});
|
|
3825
4466
|
}
|
|
3826
|
-
return
|
|
4467
|
+
return pings.sort((a, b) => {
|
|
4468
|
+
const rank = KIND_RANK[a.kind] - KIND_RANK[b.kind];
|
|
4469
|
+
if (rank !== 0) return rank;
|
|
4470
|
+
return b.at.localeCompare(a.at);
|
|
4471
|
+
});
|
|
3827
4472
|
}
|
|
3828
4473
|
|
|
3829
4474
|
//#endregion
|
|
3830
|
-
//#region src/tui/
|
|
3831
|
-
/**
|
|
3832
|
-
function
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
4475
|
+
//#region src/tui/views/inbox.tsx
|
|
4476
|
+
/** What's asking for you, the same pings as the website's Inbox: Enter opens one, space marks it read. */
|
|
4477
|
+
function InboxView({ active, width, height, go }) {
|
|
4478
|
+
const { store } = useDeps();
|
|
4479
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
4480
|
+
const comments = useHarbor((s) => s.comments);
|
|
4481
|
+
const milestones = useHarbor((s) => s.milestones);
|
|
4482
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
4483
|
+
const constraints = useHarbor((s) => s.constraints);
|
|
4484
|
+
const people = useHarbor((s) => s.people);
|
|
4485
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
4486
|
+
const readIds = useHarbor((s) => s.readPingIds);
|
|
4487
|
+
const pings = useMemo(() => inboxOf(store.getState(), me), [
|
|
4488
|
+
tasks,
|
|
4489
|
+
comments,
|
|
4490
|
+
milestones,
|
|
4491
|
+
raidItems,
|
|
4492
|
+
constraints,
|
|
4493
|
+
people,
|
|
4494
|
+
me,
|
|
4495
|
+
store
|
|
4496
|
+
]);
|
|
4497
|
+
const read = new Set(readIds ?? []);
|
|
4498
|
+
const unread = pings.filter((p) => !read.has(p.id)).length;
|
|
4499
|
+
const [at, setAt] = useState(0);
|
|
4500
|
+
const ping = pings[Math.min(at, pings.length - 1)];
|
|
4501
|
+
const open = (p) => {
|
|
4502
|
+
store.getState().readPing(p.id);
|
|
4503
|
+
if (p.taskId) go.openTask(p.taskId);
|
|
4504
|
+
else if (p.programId) go.openProgram(p.programId);
|
|
4505
|
+
};
|
|
4506
|
+
useInput((input, key) => {
|
|
4507
|
+
if (key.upArrow || input === "k") setAt((i) => step(i, -1, pings.length));
|
|
4508
|
+
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, pings.length));
|
|
4509
|
+
else if (key.return && ping) open(ping);
|
|
4510
|
+
else if (input === " " && ping) store.getState().readPing(ping.id);
|
|
4511
|
+
else if (input === "a") for (const p of pings) store.getState().readPing(p.id);
|
|
4512
|
+
}, { isActive: active });
|
|
4513
|
+
const view = windowOf(pings, at, Math.max(1, height - 2));
|
|
3848
4514
|
return /* @__PURE__ */ jsxs(Box, {
|
|
3849
|
-
width: columns,
|
|
3850
|
-
height: rows,
|
|
3851
4515
|
flexDirection: "column",
|
|
3852
|
-
|
|
3853
|
-
|
|
4516
|
+
width,
|
|
4517
|
+
height,
|
|
4518
|
+
paddingX: 1,
|
|
3854
4519
|
children: [
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
children:
|
|
3858
|
-
}) : null,
|
|
3859
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3860
|
-
bold: true,
|
|
3861
|
-
color: theme.accent,
|
|
3862
|
-
children: "Harbor"
|
|
4520
|
+
/* @__PURE__ */ jsx(Heading, {
|
|
4521
|
+
right: `${unread} unread · a reads all`,
|
|
4522
|
+
children: "Inbox"
|
|
3863
4523
|
}),
|
|
3864
|
-
/* @__PURE__ */ jsx(Text, {
|
|
4524
|
+
pings.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
3865
4525
|
color: theme.muted,
|
|
3866
|
-
children:
|
|
4526
|
+
children: "Nothing waiting on you. Good."
|
|
4527
|
+
}) : null,
|
|
4528
|
+
view.items.map((p, i) => {
|
|
4529
|
+
const on = active && view.start + i === at;
|
|
4530
|
+
const fresh = !read.has(p.id);
|
|
4531
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
4532
|
+
color: on ? theme.selected : void 0,
|
|
4533
|
+
bold: on || fresh,
|
|
4534
|
+
wrap: "truncate-end",
|
|
4535
|
+
children: [
|
|
4536
|
+
on ? "› " : " ",
|
|
4537
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4538
|
+
color: fresh ? theme.coral : theme.muted,
|
|
4539
|
+
children: fresh ? "● " : " "
|
|
4540
|
+
}),
|
|
4541
|
+
/* @__PURE__ */ jsx(Text, {
|
|
4542
|
+
color: theme.muted,
|
|
4543
|
+
children: PING_KIND_LABEL[p.kind].padEnd(15)
|
|
4544
|
+
}),
|
|
4545
|
+
p.title,
|
|
4546
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
4547
|
+
color: theme.muted,
|
|
4548
|
+
children: [
|
|
4549
|
+
p.detail ? ` · ${p.detail}` : "",
|
|
4550
|
+
" · ",
|
|
4551
|
+
timeAgo(parseIso(p.at))
|
|
4552
|
+
]
|
|
4553
|
+
})
|
|
4554
|
+
]
|
|
4555
|
+
}, p.id);
|
|
3867
4556
|
})
|
|
3868
4557
|
]
|
|
3869
4558
|
});
|
|
3870
4559
|
}
|
|
3871
4560
|
|
|
3872
4561
|
//#endregion
|
|
3873
|
-
//#region src/
|
|
4562
|
+
//#region src/lib/load.ts
|
|
4563
|
+
const iso$1 = (d) => {
|
|
4564
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
4565
|
+
};
|
|
4566
|
+
/** Open tasks for one person, bucketed by the week they are due, plus what is already late or has no date. */
|
|
4567
|
+
function weeklyLoad(tasks, personId, now, weeks = 6) {
|
|
4568
|
+
const mine = tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
4569
|
+
const today = iso$1(now);
|
|
4570
|
+
const origin = startOfWeek(now, { weekStartsOn: 1 });
|
|
4571
|
+
const buckets = Array.from({ length: weeks }, (_, i) => ({
|
|
4572
|
+
start: iso$1(addDays(origin, i * 7)),
|
|
4573
|
+
due: 0
|
|
4574
|
+
}));
|
|
4575
|
+
let overdue = 0;
|
|
4576
|
+
let undated = 0;
|
|
4577
|
+
for (const t of mine) {
|
|
4578
|
+
if (!t.dueDate) {
|
|
4579
|
+
undated += 1;
|
|
4580
|
+
continue;
|
|
4581
|
+
}
|
|
4582
|
+
if (t.dueDate < today) {
|
|
4583
|
+
overdue += 1;
|
|
4584
|
+
continue;
|
|
4585
|
+
}
|
|
4586
|
+
const offset = differenceInCalendarDays(/* @__PURE__ */ new Date(`${t.dueDate}T12:00:00`), origin);
|
|
4587
|
+
const idx = Math.floor(offset / 7);
|
|
4588
|
+
if (idx >= 0 && idx < weeks) buckets[idx].due += 1;
|
|
4589
|
+
}
|
|
4590
|
+
return {
|
|
4591
|
+
overdue,
|
|
4592
|
+
undated,
|
|
4593
|
+
weeks: buckets
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4597
|
+
//#endregion
|
|
4598
|
+
//#region src/lib/priority.ts
|
|
4599
|
+
const DEFAULT_IMPACT = 5;
|
|
4600
|
+
const DEFAULT_RULES = {
|
|
4601
|
+
impactThreshold: 6,
|
|
4602
|
+
budgetShare: .5
|
|
4603
|
+
};
|
|
4604
|
+
/** Rules with anything missing or out of range brought back to the defaults. */
|
|
4605
|
+
function normalizeRules(rules) {
|
|
4606
|
+
const impactThreshold = Number(rules?.impactThreshold);
|
|
4607
|
+
const budgetShare = Number(rules?.budgetShare);
|
|
4608
|
+
return {
|
|
4609
|
+
impactThreshold: Number.isFinite(impactThreshold) ? Math.min(10, Math.max(1, Math.round(impactThreshold))) : DEFAULT_RULES.impactThreshold,
|
|
4610
|
+
budgetShare: Number.isFinite(budgetShare) ? Math.min(1, Math.max(.05, budgetShare)) : DEFAULT_RULES.budgetShare
|
|
4611
|
+
};
|
|
4612
|
+
}
|
|
4613
|
+
/** Where a program sits on the impact-vs-budget matrix, in words. High impact is the threshold or more (default 6 of 10); high budget is at least the share of the biggest in the set (default half). */
|
|
4614
|
+
function quadrantOf(program, maxBudget, rules = DEFAULT_RULES) {
|
|
4615
|
+
const highImpact = (program.impact ?? 5) >= rules.impactThreshold;
|
|
4616
|
+
const highBudget = maxBudget > 0 && program.budget >= maxBudget * rules.budgetShare;
|
|
4617
|
+
if (highImpact) return highBudget ? "Big bet" : "Quick win";
|
|
4618
|
+
return highBudget ? "Reconsider" : "Fill-in";
|
|
4619
|
+
}
|
|
3874
4620
|
/**
|
|
3875
|
-
*
|
|
3876
|
-
*
|
|
3877
|
-
*
|
|
3878
|
-
* on another asks through the store (blockPending), as on the website.
|
|
4621
|
+
* Whether a program has anything to place it on the matrix by: an impact
|
|
4622
|
+
* someone chose, or a budget. A new program has neither, and calling it a
|
|
4623
|
+
* "Fill-in" would be a verdict nobody gave.
|
|
3879
4624
|
*/
|
|
3880
|
-
function
|
|
3881
|
-
|
|
3882
|
-
if (task.status === status) return;
|
|
3883
|
-
const overflow = columnWip(s, task.projectId, status, 1);
|
|
3884
|
-
if (!overflow) return s.moveTask(task.id, status);
|
|
3885
|
-
if (s.myRole !== "owner") return go.notify(wipFullMessage(overflow));
|
|
3886
|
-
go.confirm(`${wipFullMessage(overflow)} Raise its limit by one and move it?`, () => {
|
|
3887
|
-
store.getState().raiseColumnCap(task.projectId, status);
|
|
3888
|
-
store.getState().moveTask(task.id, status);
|
|
3889
|
-
}, "Raise the limit");
|
|
3890
|
-
}
|
|
3891
|
-
/** Whether a new task fits its column; an owner's go raises the limit first. */
|
|
3892
|
-
function roomFor(store, projectId, status) {
|
|
3893
|
-
const s = store.getState();
|
|
3894
|
-
const overflow = columnWip(s, projectId, status, 1);
|
|
3895
|
-
if (!overflow) return null;
|
|
3896
|
-
if (s.myRole !== "owner") return wipFullMessage(overflow);
|
|
3897
|
-
s.raiseColumnCap(projectId, status);
|
|
3898
|
-
return null;
|
|
4625
|
+
function isRated(program) {
|
|
4626
|
+
return program.impact != null || program.budget > 0;
|
|
3899
4627
|
}
|
|
3900
4628
|
|
|
3901
4629
|
//#endregion
|
|
3902
|
-
//#region src/tui/views/
|
|
3903
|
-
|
|
3904
|
-
function
|
|
3905
|
-
const tasks = useHarbor((s) => s.tasks);
|
|
3906
|
-
const projects = useHarbor((s) => s.projects);
|
|
3907
|
-
const [query, setQuery] = useState("");
|
|
4630
|
+
//#region src/tui/views/lists.tsx
|
|
4631
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
4632
|
+
function RowList({ title, right, rows, empty, active, width, height, onOpen, keys = {} }) {
|
|
3908
4633
|
const [at, setAt] = useState(0);
|
|
3909
|
-
const
|
|
3910
|
-
tasks,
|
|
3911
|
-
projects,
|
|
3912
|
-
query
|
|
3913
|
-
]);
|
|
4634
|
+
const row = rows[Math.min(at, rows.length - 1)];
|
|
3914
4635
|
useInput((input, key) => {
|
|
3915
|
-
if (key.
|
|
3916
|
-
if (key.
|
|
3917
|
-
if (key.
|
|
3918
|
-
if (key.
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
if (key.backspace || key.delete) {
|
|
3924
|
-
setAt(0);
|
|
3925
|
-
return setQuery((q) => q.slice(0, -1));
|
|
3926
|
-
}
|
|
3927
|
-
if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
|
|
3928
|
-
if (input) {
|
|
3929
|
-
setAt(0);
|
|
3930
|
-
setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
|
|
3931
|
-
}
|
|
3932
|
-
});
|
|
4636
|
+
if (key.upArrow || input === "k") setAt((i) => step(i, -1, rows.length));
|
|
4637
|
+
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, rows.length));
|
|
4638
|
+
else if (key.pageDown) setAt((i) => step(i, 10, rows.length));
|
|
4639
|
+
else if (key.pageUp) setAt((i) => step(i, -10, rows.length));
|
|
4640
|
+
else if (key.return && row) onOpen(row);
|
|
4641
|
+
else keys[input]?.(row);
|
|
4642
|
+
}, { isActive: active });
|
|
4643
|
+
const view = windowOf(rows, at, Math.max(1, height - 2));
|
|
3933
4644
|
return /* @__PURE__ */ jsxs(Box, {
|
|
3934
4645
|
flexDirection: "column",
|
|
3935
4646
|
width,
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
children: ["/", " "]
|
|
3945
|
-
}),
|
|
3946
|
-
query || /* @__PURE__ */ jsx(Text, {
|
|
3947
|
-
color: theme.muted,
|
|
3948
|
-
children: "a key like RR-42, or words from a title"
|
|
3949
|
-
}),
|
|
3950
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3951
|
-
inverse: true,
|
|
3952
|
-
children: " "
|
|
3953
|
-
})
|
|
3954
|
-
] }),
|
|
3955
|
-
hits.length === 0 && query ? /* @__PURE__ */ jsx(Text, {
|
|
4647
|
+
height,
|
|
4648
|
+
paddingX: 1,
|
|
4649
|
+
children: [
|
|
4650
|
+
/* @__PURE__ */ jsx(Heading, {
|
|
4651
|
+
right,
|
|
4652
|
+
children: title
|
|
4653
|
+
}),
|
|
4654
|
+
rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
3956
4655
|
color: theme.muted,
|
|
3957
|
-
children:
|
|
4656
|
+
children: empty
|
|
3958
4657
|
}) : null,
|
|
3959
|
-
|
|
3960
|
-
const on =
|
|
3961
|
-
const project = projects.find((p) => p.id === item.projectId);
|
|
4658
|
+
view.items.map((r, i) => {
|
|
4659
|
+
const on = active && view.start + i === at;
|
|
3962
4660
|
return /* @__PURE__ */ jsxs(Text, {
|
|
3963
|
-
color: on ? theme.selected :
|
|
3964
|
-
bold: on,
|
|
4661
|
+
color: on ? theme.selected : r.color,
|
|
4662
|
+
bold: on || r.bold,
|
|
3965
4663
|
wrap: "truncate-end",
|
|
3966
4664
|
children: [
|
|
3967
4665
|
on ? "› " : " ",
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
item.title,
|
|
3973
|
-
/* @__PURE__ */ jsxs(Text, {
|
|
4666
|
+
" ".repeat(r.depth),
|
|
4667
|
+
r.health ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(HealthMark, { health: r.health }), " "] }) : null,
|
|
4668
|
+
r.title,
|
|
4669
|
+
r.detail ? /* @__PURE__ */ jsxs(Text, {
|
|
3974
4670
|
color: theme.muted,
|
|
3975
|
-
children: [
|
|
3976
|
-
|
|
3977
|
-
"· ",
|
|
3978
|
-
STATUS_LABEL[item.status],
|
|
3979
|
-
project ? ` · ${project.name}` : ""
|
|
3980
|
-
]
|
|
3981
|
-
})
|
|
4671
|
+
children: [" ", r.detail]
|
|
4672
|
+
}) : null
|
|
3982
4673
|
]
|
|
3983
|
-
},
|
|
3984
|
-
}),
|
|
3985
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3986
|
-
color: theme.muted,
|
|
3987
|
-
children: "↑↓ choose · Enter open · Esc close"
|
|
4674
|
+
}, r.id);
|
|
3988
4675
|
})
|
|
3989
4676
|
]
|
|
3990
4677
|
});
|
|
3991
4678
|
}
|
|
3992
|
-
/**
|
|
3993
|
-
|
|
3994
|
-
|
|
4679
|
+
/** Every project, under its program and portfolio, with health and progress. Done
|
|
4680
|
+
* projects and programs stay out of the way until D shows them. */
|
|
4681
|
+
function ProjectsView({ active, width, height, go }) {
|
|
4682
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
4683
|
+
const programs = useHarbor((s) => s.programs);
|
|
3995
4684
|
const projects = useHarbor((s) => s.projects);
|
|
3996
|
-
const
|
|
3997
|
-
const
|
|
3998
|
-
const
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4685
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
4686
|
+
const { today } = useDeps();
|
|
4687
|
+
const [showDone, setShowDone] = useState(false);
|
|
4688
|
+
const { rows, shown, hidden } = useMemo(() => {
|
|
4689
|
+
const data = {
|
|
4690
|
+
tasks,
|
|
4691
|
+
projects
|
|
4692
|
+
};
|
|
4693
|
+
const out = [];
|
|
4694
|
+
let shown = 0;
|
|
4695
|
+
let hidden = 0;
|
|
4696
|
+
for (const portfolio of portfolios) {
|
|
4697
|
+
out.push({
|
|
4698
|
+
id: portfolio.id,
|
|
4699
|
+
kind: "portfolio",
|
|
4700
|
+
depth: 0,
|
|
4701
|
+
title: portfolio.name,
|
|
4702
|
+
bold: true,
|
|
4703
|
+
health: portfolio.health
|
|
4704
|
+
});
|
|
4705
|
+
for (const program of programs.filter((g) => g.portfolioId === portfolio.id)) {
|
|
4706
|
+
const { open, done } = splitDone(projects.filter((p) => p.programId === program.id));
|
|
4707
|
+
if (program.health === "done" && open.length === 0) {
|
|
4708
|
+
hidden += 1 + done.length;
|
|
4709
|
+
if (!showDone) continue;
|
|
4710
|
+
} else hidden += done.length;
|
|
4711
|
+
out.push({
|
|
4712
|
+
id: program.id,
|
|
4713
|
+
kind: "program",
|
|
4714
|
+
depth: 1,
|
|
4715
|
+
title: program.name,
|
|
4716
|
+
health: program.health
|
|
4717
|
+
});
|
|
4718
|
+
for (const project of showDone ? [...open, ...done] : open) {
|
|
4719
|
+
shown++;
|
|
4720
|
+
const { done: finished, total } = projectSummary(data, project);
|
|
4721
|
+
const late = tasksOf(tasks, project.id).filter((t) => isLate(t, today())).length;
|
|
4722
|
+
out.push({
|
|
4723
|
+
id: project.id,
|
|
4724
|
+
kind: "project",
|
|
4725
|
+
depth: 2,
|
|
4726
|
+
title: `${project.name}${project.key ? ` ${project.key}` : ""}`,
|
|
4727
|
+
health: project.health,
|
|
4728
|
+
detail: `${finished}/${total} done${late ? ` · ${late} late` : ""} · ends ${shortDay(project.endDate, today())}`
|
|
4729
|
+
});
|
|
4730
|
+
}
|
|
4032
4731
|
}
|
|
4033
|
-
}
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4732
|
+
}
|
|
4733
|
+
return {
|
|
4734
|
+
rows: out,
|
|
4735
|
+
shown,
|
|
4736
|
+
hidden
|
|
4737
|
+
};
|
|
4738
|
+
}, [
|
|
4739
|
+
portfolios,
|
|
4740
|
+
programs,
|
|
4741
|
+
projects,
|
|
4742
|
+
tasks,
|
|
4743
|
+
today,
|
|
4744
|
+
showDone
|
|
4745
|
+
]);
|
|
4746
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
4747
|
+
title: "Portfolios, programs and projects",
|
|
4748
|
+
right: `${count(shown, "project")}${hidden ? ` · ${hidden} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new project · e edit`,
|
|
4749
|
+
rows,
|
|
4750
|
+
empty: "No projects yet. n adds one.",
|
|
4751
|
+
active,
|
|
4039
4752
|
width,
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
}
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
})
|
|
4753
|
+
height,
|
|
4754
|
+
onOpen: (r) => r.kind === "project" ? go.openProject(r.id) : r.kind === "program" ? go.openProgram(r.id) : go.openPortfolio(r.id),
|
|
4755
|
+
keys: {
|
|
4756
|
+
n: (r) => {
|
|
4757
|
+
const programId = r?.kind === "program" ? r.id : r?.kind === "project" ? projects.find((p) => p.id === r.id)?.programId : void 0;
|
|
4758
|
+
go.create("project", programId ? { programId } : {});
|
|
4759
|
+
},
|
|
4760
|
+
e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id),
|
|
4761
|
+
D: () => hidden > 0 && setShowDone((v) => !v)
|
|
4762
|
+
}
|
|
4051
4763
|
});
|
|
4052
4764
|
}
|
|
4053
|
-
const HELP = [
|
|
4054
|
-
["1–8 / Tab", "the screens / between the sidebar and a screen"],
|
|
4055
|
-
[":", "anything: a screen, something new, a name"],
|
|
4056
|
-
["/ / H", "find a task by key or words / switch harbor"],
|
|
4057
|
-
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
4058
|
-
["Enter / Esc", "open / back"],
|
|
4059
|
-
["n", "new: a task, or what the screen lists"],
|
|
4060
|
-
["e", "change what's open (health, lead, dates)"],
|
|
4061
|
-
["[ ]", "move a card one column left or right"],
|
|
4062
|
-
["x / d", "mark done / delete (asks first)"],
|
|
4063
|
-
["u / r", "undo your last change / retry saving"],
|
|
4064
|
-
["v", "on a project: board, list, budget"],
|
|
4065
|
-
["m / R", "new milestone on a program / log a risk"],
|
|
4066
|
-
["D", "show or hide Done projects and programs"],
|
|
4067
|
-
["c / a / C", "on a task: comment / add a step / change yours"],
|
|
4068
|
-
["f / c", "on Today: focus on a task / check in"],
|
|
4069
|
-
["f / p", "on a board: filter by owner / priority"],
|
|
4070
|
-
["q", "quit (saves first)"]
|
|
4071
|
-
];
|
|
4072
4765
|
/**
|
|
4073
|
-
* The
|
|
4074
|
-
*
|
|
4075
|
-
*
|
|
4766
|
+
* The review, as on the website: programs worst first, then by end date,
|
|
4767
|
+
* each with its lead, how much is late, where it sits on the priority matrix
|
|
4768
|
+
* and the next move. o narrows to someone's programs, p to a portfolio, m
|
|
4769
|
+
* writes the next move; done programs wait behind D.
|
|
4076
4770
|
*/
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4771
|
+
function ProgramsView({ active, width, height, go }) {
|
|
4772
|
+
const { store, today } = useDeps();
|
|
4773
|
+
const programs = useHarbor((s) => s.programs);
|
|
4774
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
4775
|
+
const projects = useHarbor((s) => s.projects);
|
|
4776
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
4777
|
+
const people = useHarbor((s) => s.people);
|
|
4778
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
4779
|
+
const priorityRules = useHarbor((s) => s.priorityRules);
|
|
4780
|
+
const [showDone, setShowDone] = useState(false);
|
|
4781
|
+
const [owner, setOwner] = useState(null);
|
|
4782
|
+
const [portfolio, setPortfolio] = useState(null);
|
|
4783
|
+
const day = today();
|
|
4784
|
+
const data = useMemo(() => ({
|
|
4785
|
+
projects,
|
|
4786
|
+
tasks
|
|
4787
|
+
}), [projects, tasks]);
|
|
4788
|
+
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)), [
|
|
4789
|
+
programs,
|
|
4790
|
+
portfolio,
|
|
4791
|
+
owner,
|
|
4792
|
+
data
|
|
4793
|
+
]);
|
|
4794
|
+
const split = useMemo(() => splitDone(picked), [picked]);
|
|
4795
|
+
const listed = useMemo(() => showDone ? [...split.open, ...split.done] : split.open, [split, showDone]);
|
|
4796
|
+
const rows = useMemo(() => {
|
|
4797
|
+
const now = /* @__PURE__ */ new Date(`${day}T12:00:00`);
|
|
4798
|
+
const rules = normalizeRules(priorityRules);
|
|
4799
|
+
const maxBudget = Math.max(1, ...programs.map((p) => p.budget));
|
|
4800
|
+
return listed.map((program) => {
|
|
4801
|
+
const late = programLateCount(program.id, data, now);
|
|
4802
|
+
const lead = people.find((p) => p.id === program.ownerId);
|
|
4803
|
+
return {
|
|
4804
|
+
id: program.id,
|
|
4805
|
+
kind: "program",
|
|
4806
|
+
depth: 0,
|
|
4807
|
+
title: program.name,
|
|
4808
|
+
health: program.health,
|
|
4809
|
+
detail: [
|
|
4810
|
+
lead?.name.split(" ")[0],
|
|
4811
|
+
program.endDate ? `ends ${shortDay(program.endDate, day)}` : null,
|
|
4812
|
+
late ? `${late} late` : null,
|
|
4813
|
+
isRated(program) ? quadrantOf(program, maxBudget, rules) : null,
|
|
4814
|
+
program.nextMove ? `next: ${program.nextMove}` : "no next move"
|
|
4815
|
+
].filter(Boolean).join(" · ")
|
|
4816
|
+
};
|
|
4817
|
+
});
|
|
4818
|
+
}, [
|
|
4819
|
+
listed,
|
|
4820
|
+
programs,
|
|
4821
|
+
people,
|
|
4822
|
+
data,
|
|
4823
|
+
day,
|
|
4824
|
+
priorityRules
|
|
4825
|
+
]);
|
|
4826
|
+
const done = split.done.length;
|
|
4827
|
+
const whose = owner === me ? "My programs" : owner ? `${people.find((p) => p.id === owner)?.name ?? "Someone"}'s programs` : "Programs";
|
|
4828
|
+
const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
|
|
4829
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
4830
|
+
title: `${whose}${where}`,
|
|
4831
|
+
right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · m next move · o p narrow`,
|
|
4832
|
+
rows,
|
|
4833
|
+
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.",
|
|
4834
|
+
active,
|
|
4835
|
+
width,
|
|
4836
|
+
height,
|
|
4837
|
+
onOpen: (r) => go.openProgram(r.id),
|
|
4838
|
+
keys: {
|
|
4839
|
+
n: (r) => {
|
|
4840
|
+
const portfolioId = r ? programs.find((p) => p.id === r.id)?.portfolioId : void 0;
|
|
4841
|
+
go.create("program", portfolioId ? { portfolioId } : {});
|
|
4842
|
+
},
|
|
4843
|
+
e: (r) => r && go.openPanel("program", r.id),
|
|
4844
|
+
m: (r) => {
|
|
4845
|
+
const program = r && programs.find((p) => p.id === r.id);
|
|
4846
|
+
if (!program) return;
|
|
4847
|
+
go.ask(`Next move for ${program.name}`, (text) => store.getState().setNextMove(program.id, text.trim()), program.nextMove ?? "");
|
|
4848
|
+
},
|
|
4849
|
+
o: () => go.pick("Whose programs?", [
|
|
4850
|
+
{
|
|
4851
|
+
label: "Everyone's",
|
|
4852
|
+
value: null
|
|
4853
|
+
},
|
|
4854
|
+
{
|
|
4855
|
+
label: "Mine",
|
|
4856
|
+
value: me
|
|
4857
|
+
},
|
|
4858
|
+
...people.filter((p) => p.id !== me).map((p) => ({
|
|
4859
|
+
label: p.name,
|
|
4860
|
+
value: p.id,
|
|
4861
|
+
hint: p.role
|
|
4862
|
+
}))
|
|
4863
|
+
], (v) => setOwner(v ?? null)),
|
|
4864
|
+
p: () => go.pick("In which portfolio?", [{
|
|
4865
|
+
label: "Every portfolio",
|
|
4866
|
+
value: null
|
|
4867
|
+
}, ...portfolios.map((p) => ({
|
|
4868
|
+
label: p.name,
|
|
4869
|
+
value: p.id
|
|
4870
|
+
}))], (v) => setPortfolio(v ?? null)),
|
|
4871
|
+
D: () => done > 0 && setShowDone((v) => !v)
|
|
4872
|
+
}
|
|
4123
4873
|
});
|
|
4124
4874
|
}
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4875
|
+
/** A program is someone's when they lead it, lead one of its projects or hold a task in one. */
|
|
4876
|
+
function programTouches(program, data, personId) {
|
|
4877
|
+
if (program.ownerId === personId) return true;
|
|
4878
|
+
return data.projects.some((p) => p.programId === program.id && (p.ownerId === personId || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId)));
|
|
4879
|
+
}
|
|
4128
4880
|
/**
|
|
4129
|
-
*
|
|
4130
|
-
*
|
|
4881
|
+
* The register, worst first: open items unless a shows closed ones too. As
|
|
4882
|
+
* on the website it narrows by kind (t), owner (o) and portfolio (p).
|
|
4131
4883
|
*/
|
|
4132
|
-
function
|
|
4884
|
+
function RisksView({ active, width, height, go }) {
|
|
4885
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
4133
4886
|
const projects = useHarbor((s) => s.projects);
|
|
4134
4887
|
const programs = useHarbor((s) => s.programs);
|
|
4135
4888
|
const portfolios = useHarbor((s) => s.portfolios);
|
|
4136
|
-
const
|
|
4137
|
-
const [
|
|
4138
|
-
const
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
run: () => open("project", p.id)
|
|
4144
|
-
})),
|
|
4145
|
-
...programs.map((p) => ({
|
|
4146
|
-
label: `Open ${p.name}`,
|
|
4147
|
-
hint: "program",
|
|
4148
|
-
run: () => open("program", p.id)
|
|
4149
|
-
})),
|
|
4150
|
-
...portfolios.map((p) => ({
|
|
4151
|
-
label: `Open ${p.name}`,
|
|
4152
|
-
hint: "portfolio",
|
|
4153
|
-
run: () => open("portfolio", p.id)
|
|
4154
|
-
}))
|
|
4155
|
-
], [
|
|
4156
|
-
base,
|
|
4889
|
+
const people = useHarbor((s) => s.people);
|
|
4890
|
+
const [all, setAll] = useState(false);
|
|
4891
|
+
const [kind, setKind] = useState(null);
|
|
4892
|
+
const [owner, setOwner] = useState(null);
|
|
4893
|
+
const [portfolio, setPortfolio] = useState(null);
|
|
4894
|
+
const inPortfolio = useMemo(() => portfolio ? new Set(portfolioRaidItems({
|
|
4895
|
+
raidItems,
|
|
4157
4896
|
projects,
|
|
4158
|
-
programs
|
|
4159
|
-
|
|
4160
|
-
|
|
4897
|
+
programs
|
|
4898
|
+
}, portfolio).map((r) => r.id)) : null, [
|
|
4899
|
+
portfolio,
|
|
4900
|
+
raidItems,
|
|
4901
|
+
projects,
|
|
4902
|
+
programs
|
|
4161
4903
|
]);
|
|
4162
|
-
const
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4904
|
+
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) => {
|
|
4905
|
+
const score = scoreOf(r);
|
|
4906
|
+
const band = score != null ? scoreBand(score) : null;
|
|
4907
|
+
return {
|
|
4908
|
+
id: r.id,
|
|
4909
|
+
kind: "raid",
|
|
4910
|
+
depth: 0,
|
|
4911
|
+
title: `${KIND_LABEL[r.kind]}: ${r.title}`,
|
|
4912
|
+
health: band === "high" ? "off-track" : band === "medium" ? "at-risk" : void 0,
|
|
4913
|
+
color: isActive(r) ? void 0 : theme.muted,
|
|
4914
|
+
detail: [
|
|
4915
|
+
STATUS_LABEL$1[r.status],
|
|
4916
|
+
score != null ? `score ${score}` : null,
|
|
4917
|
+
people.find((p) => p.id === r.ownerId)?.name,
|
|
4918
|
+
projects.find((p) => p.id === r.projectId)?.name
|
|
4919
|
+
].filter(Boolean).join(" · ")
|
|
4920
|
+
};
|
|
4921
|
+
}), [
|
|
4922
|
+
raidItems,
|
|
4923
|
+
projects,
|
|
4924
|
+
people,
|
|
4925
|
+
all,
|
|
4926
|
+
kind,
|
|
4927
|
+
owner,
|
|
4928
|
+
inPortfolio
|
|
4929
|
+
]);
|
|
4930
|
+
const what = kind ? `${KIND_LABEL[kind].toLowerCase()}s` : "risks, issues and decisions";
|
|
4931
|
+
const whose = owner ? `${people.find((p) => p.id === owner)?.name ?? "Someone"}'s ` : "";
|
|
4932
|
+
const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
|
|
4933
|
+
const phrase = `${whose}${all ? "" : "open "}${what}${where}`;
|
|
4934
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
4935
|
+
title: phrase.charAt(0).toUpperCase() + phrase.slice(1),
|
|
4936
|
+
right: `${rows.length} · t o p a narrow`,
|
|
4937
|
+
rows,
|
|
4938
|
+
empty: kind || owner || portfolio ? "Nothing matches. t, o and p change what's shown." : "Nothing open in the register. n logs one.",
|
|
4939
|
+
active,
|
|
4185
4940
|
width,
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
}) : null,
|
|
4209
|
-
hits.map((c, i) => {
|
|
4210
|
-
const on = i === Math.min(at, hits.length - 1);
|
|
4211
|
-
return /* @__PURE__ */ jsxs(Text, {
|
|
4212
|
-
color: on ? theme.selected : void 0,
|
|
4213
|
-
bold: on,
|
|
4214
|
-
wrap: "truncate-end",
|
|
4215
|
-
children: [
|
|
4216
|
-
on ? "› " : " ",
|
|
4217
|
-
c.label,
|
|
4218
|
-
c.hint ? /* @__PURE__ */ jsxs(Text, {
|
|
4219
|
-
color: theme.muted,
|
|
4220
|
-
children: [" · ", c.hint]
|
|
4221
|
-
}) : null
|
|
4222
|
-
]
|
|
4223
|
-
}, `${c.label}-${i}`);
|
|
4224
|
-
}),
|
|
4225
|
-
/* @__PURE__ */ jsx(Text, {
|
|
4226
|
-
color: theme.muted,
|
|
4227
|
-
children: "↑↓ choose · Enter go · Esc close"
|
|
4228
|
-
})
|
|
4229
|
-
]
|
|
4941
|
+
height,
|
|
4942
|
+
onOpen: (r) => go.openPanel("raid", r.id),
|
|
4943
|
+
keys: {
|
|
4944
|
+
n: () => go.create("raid", {}),
|
|
4945
|
+
a: () => setAll((v) => !v),
|
|
4946
|
+
t: () => setKind((k) => KINDS[(KINDS.indexOf(k) + 1) % KINDS.length] ?? null),
|
|
4947
|
+
o: () => go.pick("Whose?", [{
|
|
4948
|
+
label: "Anyone's",
|
|
4949
|
+
value: null
|
|
4950
|
+
}, ...people.map((p) => ({
|
|
4951
|
+
label: p.name,
|
|
4952
|
+
value: p.id,
|
|
4953
|
+
hint: p.role
|
|
4954
|
+
}))], (v) => setOwner(v ?? null)),
|
|
4955
|
+
p: () => go.pick("In which portfolio?", [{
|
|
4956
|
+
label: "Every portfolio",
|
|
4957
|
+
value: null
|
|
4958
|
+
}, ...portfolios.map((p) => ({
|
|
4959
|
+
label: p.name,
|
|
4960
|
+
value: p.id
|
|
4961
|
+
}))], (v) => setPortfolio(v ?? null))
|
|
4962
|
+
}
|
|
4230
4963
|
});
|
|
4231
4964
|
}
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
const
|
|
4250
|
-
|
|
4251
|
-
};
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
}
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
}
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4965
|
+
const KINDS = [
|
|
4966
|
+
null,
|
|
4967
|
+
"risk",
|
|
4968
|
+
"issue",
|
|
4969
|
+
"decision"
|
|
4970
|
+
];
|
|
4971
|
+
/** Who's here: open and late work, and how full each of the next weeks is against their capacity. */
|
|
4972
|
+
function PeopleView({ active, width, height, go }) {
|
|
4973
|
+
const people = useHarbor((s) => s.people);
|
|
4974
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
4975
|
+
const { today } = useDeps();
|
|
4976
|
+
const rows = useMemo(() => {
|
|
4977
|
+
const now = /* @__PURE__ */ new Date(`${today()}T12:00:00`);
|
|
4978
|
+
const nameWidth = Math.max(0, ...people.map((p) => p.name.length));
|
|
4979
|
+
return people.map((p) => {
|
|
4980
|
+
const open = tasks.filter((t) => t.assigneeId === p.id && t.status !== "done");
|
|
4981
|
+
const load = weeklyLoad(tasks, p.id, now, 4);
|
|
4982
|
+
const cap = p.weeklyCapacity ?? 5;
|
|
4983
|
+
const weeks = load.weeks.map((w) => (w.due === 0 ? "·" : w.due > cap ? `${w.due}!` : String(w.due)).padStart(3)).join("");
|
|
4984
|
+
const late = load.overdue ? `${load.overdue} late` : "";
|
|
4985
|
+
return {
|
|
4986
|
+
id: p.id,
|
|
4987
|
+
kind: "person",
|
|
4988
|
+
depth: 0,
|
|
4989
|
+
title: p.name.padEnd(nameWidth),
|
|
4990
|
+
detail: `${String(open.length).padStart(3)} open ${late.padEnd(7)} weeks${weeks} of ${cap} · ${p.role}`
|
|
4991
|
+
};
|
|
4992
|
+
});
|
|
4993
|
+
}, [
|
|
4994
|
+
people,
|
|
4995
|
+
tasks,
|
|
4996
|
+
today
|
|
4997
|
+
]);
|
|
4998
|
+
return /* @__PURE__ */ jsx(RowList, {
|
|
4999
|
+
title: "People",
|
|
5000
|
+
right: `${people.length === 1 ? "1 person" : `${people.length} people`} · Enter to change`,
|
|
5001
|
+
rows,
|
|
5002
|
+
empty: "Nobody here yet. Invite people from the website.",
|
|
5003
|
+
active,
|
|
5004
|
+
width,
|
|
5005
|
+
height,
|
|
5006
|
+
onOpen: (r) => go.openPanel("person", r.id)
|
|
5007
|
+
});
|
|
4271
5008
|
}
|
|
5009
|
+
|
|
5010
|
+
//#endregion
|
|
5011
|
+
//#region cli/lib/logo.mjs
|
|
4272
5012
|
/**
|
|
4273
|
-
*
|
|
4274
|
-
*
|
|
4275
|
-
*
|
|
5013
|
+
* Harbor's mark for the terminal: the favicon (public/favicon.svg) as
|
|
5014
|
+
* pixels, a teal tile with the white H and three coral boats. Each
|
|
5015
|
+
* terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
|
|
5016
|
+
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
5017
|
+
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
5018
|
+
*
|
|
5019
|
+
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
5020
|
+
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
4276
5021
|
*/
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
5022
|
+
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
5023
|
+
const LOGO = [
|
|
5024
|
+
"...TTTTTTTTTTTTTTTTTT...",
|
|
5025
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
5026
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
5027
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
5028
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
5029
|
+
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
5030
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
5031
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
5032
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
5033
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
5034
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
5035
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
5036
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
5037
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
5038
|
+
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
5039
|
+
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
5040
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
5041
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
5042
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
5043
|
+
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
5044
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
5045
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
5046
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
5047
|
+
"...TTTTTTTTTTTTTTTTTT..."
|
|
5048
|
+
];
|
|
5049
|
+
/** 16 by 16, for the keys overlay. */
|
|
5050
|
+
const LOGO_SMALL = [
|
|
5051
|
+
"..TTTTTTTTTTTT..",
|
|
5052
|
+
".TTTTTTTTTTTTTT.",
|
|
5053
|
+
"TTTTWWTTTTWWTTTT",
|
|
5054
|
+
"TTTTWWTTTTWWTTTT",
|
|
5055
|
+
"TPPTWWTTTTWWTTTT",
|
|
5056
|
+
"TCCTWWTTTTWWTTTT",
|
|
5057
|
+
"TCCTWWTTTTWWTTTT",
|
|
5058
|
+
"TTTTWWWWWWWWTTTT",
|
|
5059
|
+
"TTTTWWWWWWWWTTTT",
|
|
5060
|
+
"TTTTWWTTTTWWTPPT",
|
|
5061
|
+
"TTTTWWTPPTWWTCCT",
|
|
5062
|
+
"TTTTWWTCCTWWTCCT",
|
|
5063
|
+
"TTTTWWTCCTWWTTTT",
|
|
5064
|
+
"TTTTWWTTTTWWTTTT",
|
|
5065
|
+
".TTTTTTTTTTTTTT.",
|
|
5066
|
+
"..TTTTTTTTTTTT.."
|
|
5067
|
+
];
|
|
5068
|
+
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
5069
|
+
const LOGO_COLOURS = {
|
|
5070
|
+
T: "#0f6e64",
|
|
5071
|
+
W: "#ffffff",
|
|
5072
|
+
C: "#c44a2c"
|
|
5073
|
+
};
|
|
5074
|
+
/**
|
|
5075
|
+
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
5076
|
+
* One stretch of a terminal row in the same colours.
|
|
5077
|
+
*/
|
|
5078
|
+
/**
|
|
5079
|
+
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
5080
|
+
* half block's colour, the bottom one its background.
|
|
5081
|
+
* @param {string[]} pixels
|
|
5082
|
+
* @returns {Run[][]}
|
|
5083
|
+
*/
|
|
5084
|
+
function logoRows(pixels = LOGO) {
|
|
5085
|
+
/** @type {Run[][]} */
|
|
5086
|
+
const rows = [];
|
|
5087
|
+
for (let y = 0; y < pixels.length; y += 2) {
|
|
5088
|
+
/** @type {Run[]} */
|
|
5089
|
+
const runs = [];
|
|
5090
|
+
const top = pixels[y] ?? "";
|
|
5091
|
+
const bottom = pixels[y + 1] ?? "";
|
|
5092
|
+
for (let x = 0; x < top.length; x++) {
|
|
5093
|
+
const t = LOGO_COLOURS[top[x]];
|
|
5094
|
+
const b = LOGO_COLOURS[bottom[x]];
|
|
5095
|
+
const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
|
|
5096
|
+
const left = bowRow ? bowRow[x - 1] !== "P" : false;
|
|
5097
|
+
/** @type {Run} */
|
|
5098
|
+
const cell = top[x] === "P" ? {
|
|
5099
|
+
text: left ? "▟" : "▙",
|
|
5100
|
+
fg: LOGO_COLOURS.C,
|
|
5101
|
+
bg: LOGO_COLOURS.T
|
|
5102
|
+
} : bottom[x] === "P" ? {
|
|
5103
|
+
text: left ? "▗" : "▖",
|
|
5104
|
+
fg: LOGO_COLOURS.C,
|
|
5105
|
+
bg: t
|
|
5106
|
+
} : !t && !b ? { text: " " } : t === b || !b ? {
|
|
5107
|
+
text: t === b ? "█" : "▀",
|
|
5108
|
+
fg: t
|
|
5109
|
+
} : !t ? {
|
|
5110
|
+
text: "▄",
|
|
5111
|
+
fg: b
|
|
5112
|
+
} : {
|
|
5113
|
+
text: "▀",
|
|
5114
|
+
fg: t,
|
|
5115
|
+
bg: b
|
|
5116
|
+
};
|
|
5117
|
+
const last = runs.at(-1);
|
|
5118
|
+
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
5119
|
+
else runs.push(cell);
|
|
5120
|
+
}
|
|
5121
|
+
rows.push(runs);
|
|
4343
5122
|
}
|
|
4344
|
-
|
|
4345
|
-
return days;
|
|
4346
|
-
}
|
|
4347
|
-
function incomplete(task) {
|
|
4348
|
-
return task.status !== "done";
|
|
4349
|
-
}
|
|
4350
|
-
function wipLimitOf(status, stored, uncapped) {
|
|
4351
|
-
if (stored != null) return stored;
|
|
4352
|
-
if (uncapped === true) return null;
|
|
4353
|
-
if (status === "doing") return 3;
|
|
4354
|
-
if (status === "review") return 2;
|
|
4355
|
-
return null;
|
|
5123
|
+
return rows;
|
|
4356
5124
|
}
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
5125
|
+
|
|
5126
|
+
//#endregion
|
|
5127
|
+
//#region src/tui/logo.tsx
|
|
5128
|
+
/** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
|
|
5129
|
+
function Logo({ pixels = LOGO }) {
|
|
5130
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
5131
|
+
flexDirection: "column",
|
|
5132
|
+
flexShrink: 0,
|
|
5133
|
+
children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
|
|
5134
|
+
color: r.fg,
|
|
5135
|
+
backgroundColor: r.bg,
|
|
5136
|
+
children: r.text
|
|
5137
|
+
}, i)) }, y))
|
|
4364
5138
|
});
|
|
4365
|
-
const mine = tasksOf(data.tasks, projectId);
|
|
4366
|
-
const doing = mine.filter((t) => t.status === "doing").length;
|
|
4367
|
-
const review = mine.filter((t) => t.status === "review").length;
|
|
4368
|
-
return doing > 3 || review > 2;
|
|
4369
|
-
}
|
|
4370
|
-
function deriveProjectHealth(project, data, now) {
|
|
4371
|
-
const tasks = tasksOf(data.tasks, project.id);
|
|
4372
|
-
const open = tasks.filter(incomplete);
|
|
4373
|
-
const maxOverdue = open.map((t) => daysOverdue(t.dueDate, now)).reduce((a, b) => Math.max(a, b), 0);
|
|
4374
|
-
const until = daysUntil(project.endDate, now);
|
|
4375
|
-
const unfinished = open.length > 0 || tasks.length === 0 && until != null && until < 0;
|
|
4376
|
-
if (maxOverdue > 3 || until != null && until < 0 && unfinished) return "off-track";
|
|
4377
|
-
const blockers = open.some((t) => isBlocked(t, data.tasks)) || projectIsBlocked(data, project);
|
|
4378
|
-
const wip = wipOverflowing(data, project.id);
|
|
4379
|
-
if (maxOverdue >= 1 || until != null && until >= 0 && until <= 7 && (blockers || wip)) return "at-risk";
|
|
4380
|
-
return "on-track";
|
|
4381
5139
|
}
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
5140
|
+
/** What shows while the harbor loads: the mark, centred, and which harbor. */
|
|
5141
|
+
function Splash({ opening }) {
|
|
5142
|
+
const { columns, rows } = useWindowSize();
|
|
5143
|
+
const mark = rows >= 18 && columns >= 30;
|
|
5144
|
+
const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
|
|
5145
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
5146
|
+
width: columns,
|
|
5147
|
+
height: rows,
|
|
5148
|
+
flexDirection: "column",
|
|
5149
|
+
alignItems: "center",
|
|
5150
|
+
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
5151
|
+
children: [
|
|
5152
|
+
mark ? /* @__PURE__ */ jsx(Box, {
|
|
5153
|
+
marginBottom: 1,
|
|
5154
|
+
children: /* @__PURE__ */ jsx(Logo, {})
|
|
5155
|
+
}) : null,
|
|
5156
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5157
|
+
bold: true,
|
|
5158
|
+
color: theme.accent,
|
|
5159
|
+
children: "Harbor"
|
|
5160
|
+
}),
|
|
5161
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5162
|
+
color: theme.muted,
|
|
5163
|
+
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
5164
|
+
})
|
|
5165
|
+
]
|
|
5166
|
+
});
|
|
4386
5167
|
}
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
5168
|
+
|
|
5169
|
+
//#endregion
|
|
5170
|
+
//#region src/tui/views/overlays.tsx
|
|
5171
|
+
/** Find a task by key or words; Enter opens it. */
|
|
5172
|
+
function SearchBox({ width, onOpen, onClose }) {
|
|
5173
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
5174
|
+
const projects = useHarbor((s) => s.projects);
|
|
5175
|
+
const [query, setQuery] = useState("");
|
|
5176
|
+
const [at, setAt] = useState(0);
|
|
5177
|
+
const hits = useMemo(() => searchTasks(tasks, projects, query.trim(), 12), [
|
|
5178
|
+
tasks,
|
|
5179
|
+
projects,
|
|
5180
|
+
query
|
|
5181
|
+
]);
|
|
5182
|
+
useInput((input, key) => {
|
|
5183
|
+
if (key.escape) return onClose();
|
|
5184
|
+
if (key.upArrow) return setAt((i) => step(i, -1, hits.length));
|
|
5185
|
+
if (key.downArrow) return setAt((i) => step(i, 1, hits.length));
|
|
5186
|
+
if (key.return) {
|
|
5187
|
+
const hit = hits[Math.min(at, hits.length - 1)];
|
|
5188
|
+
if (hit) onOpen(hit.item.id);
|
|
5189
|
+
return;
|
|
5190
|
+
}
|
|
5191
|
+
if (key.backspace || key.delete) {
|
|
5192
|
+
setAt(0);
|
|
5193
|
+
return setQuery((q) => q.slice(0, -1));
|
|
5194
|
+
}
|
|
5195
|
+
if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
|
|
5196
|
+
if (input) {
|
|
5197
|
+
setAt(0);
|
|
5198
|
+
setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
|
|
5199
|
+
}
|
|
5200
|
+
});
|
|
5201
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
5202
|
+
flexDirection: "column",
|
|
5203
|
+
width,
|
|
5204
|
+
borderStyle: "round",
|
|
5205
|
+
borderColor: theme.border,
|
|
5206
|
+
paddingX: 1,
|
|
5207
|
+
children: [
|
|
5208
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
5209
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5210
|
+
color: theme.accent,
|
|
5211
|
+
bold: true,
|
|
5212
|
+
children: ["/", " "]
|
|
5213
|
+
}),
|
|
5214
|
+
query || /* @__PURE__ */ jsx(Text, {
|
|
5215
|
+
color: theme.muted,
|
|
5216
|
+
children: "a key like RR-42, or words from a title"
|
|
5217
|
+
}),
|
|
5218
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5219
|
+
inverse: true,
|
|
5220
|
+
children: " "
|
|
5221
|
+
})
|
|
5222
|
+
] }),
|
|
5223
|
+
hits.length === 0 && query ? /* @__PURE__ */ jsx(Text, {
|
|
5224
|
+
color: theme.muted,
|
|
5225
|
+
children: "Nothing matches."
|
|
5226
|
+
}) : null,
|
|
5227
|
+
hits.map(({ item }, i) => {
|
|
5228
|
+
const on = i === Math.min(at, hits.length - 1);
|
|
5229
|
+
const project = projects.find((p) => p.id === item.projectId);
|
|
5230
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
5231
|
+
color: on ? theme.selected : void 0,
|
|
5232
|
+
bold: on,
|
|
5233
|
+
wrap: "truncate-end",
|
|
5234
|
+
children: [
|
|
5235
|
+
on ? "› " : " ",
|
|
5236
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5237
|
+
color: theme.muted,
|
|
5238
|
+
children: keyOf({ projects }, item).padEnd(7)
|
|
5239
|
+
}),
|
|
5240
|
+
item.title,
|
|
5241
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5242
|
+
color: theme.muted,
|
|
5243
|
+
children: [
|
|
5244
|
+
" ",
|
|
5245
|
+
"· ",
|
|
5246
|
+
STATUS_LABEL[item.status],
|
|
5247
|
+
project ? ` · ${project.name}` : ""
|
|
5248
|
+
]
|
|
5249
|
+
})
|
|
5250
|
+
]
|
|
5251
|
+
}, item.id);
|
|
5252
|
+
}),
|
|
5253
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5254
|
+
color: theme.muted,
|
|
5255
|
+
children: "↑↓ choose · Enter open · Esc close"
|
|
5256
|
+
})
|
|
5257
|
+
]
|
|
5258
|
+
});
|
|
4390
5259
|
}
|
|
4391
|
-
/**
|
|
4392
|
-
function
|
|
4393
|
-
const
|
|
4394
|
-
|
|
5260
|
+
/** Add a task: which project (when it isn't known), then its title. */
|
|
5261
|
+
function NewTask({ projectId, status, width, onDone, onFull }) {
|
|
5262
|
+
const { store } = useDeps();
|
|
5263
|
+
const projects = useHarbor((s) => s.projects);
|
|
5264
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
5265
|
+
const [project, setProject] = useState(projectId);
|
|
5266
|
+
const chosen = projects.find((p) => p.id === project);
|
|
5267
|
+
if (!chosen) return /* @__PURE__ */ jsx(Box, {
|
|
5268
|
+
width,
|
|
5269
|
+
children: /* @__PURE__ */ jsx(Picker, {
|
|
5270
|
+
title: projects.length ? "New task in which project?" : "No projects yet. Esc, then 3 for Projects and n to start one.",
|
|
5271
|
+
choices: projects.map((p) => ({
|
|
5272
|
+
label: p.name,
|
|
5273
|
+
value: p.id,
|
|
5274
|
+
hint: p.key
|
|
5275
|
+
})),
|
|
5276
|
+
onCancel: () => onDone(null),
|
|
5277
|
+
onPick: (id) => setProject(id)
|
|
5278
|
+
})
|
|
5279
|
+
});
|
|
5280
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
5281
|
+
width,
|
|
5282
|
+
children: /* @__PURE__ */ jsx(TextField, {
|
|
5283
|
+
label: `New task in ${chosen.name}${status ? ` · ${STATUS_LABEL[status]}` : ""}`,
|
|
5284
|
+
placeholder: "What needs doing?",
|
|
5285
|
+
hint: `Enter adds it (yours, ${STATUS_LABEL[status ?? "todo"].toLowerCase()}) · Esc leaves it`,
|
|
5286
|
+
onCancel: () => onDone(null),
|
|
5287
|
+
onSubmit: (title) => {
|
|
5288
|
+
if (!title.trim()) return onDone(null);
|
|
5289
|
+
const full = roomFor(store, chosen.id, status ?? "todo");
|
|
5290
|
+
if (full) return onFull(full);
|
|
5291
|
+
onDone(store.getState().addTask({
|
|
5292
|
+
projectId: chosen.id,
|
|
5293
|
+
title: title.trim(),
|
|
5294
|
+
description: "",
|
|
5295
|
+
status: status ?? "todo",
|
|
5296
|
+
priority: "medium",
|
|
5297
|
+
assigneeId: me || null,
|
|
5298
|
+
dueDate: null
|
|
5299
|
+
}));
|
|
5300
|
+
}
|
|
5301
|
+
})
|
|
5302
|
+
});
|
|
4395
5303
|
}
|
|
4396
|
-
/**
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
*/
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
reason: "off-track"
|
|
4412
|
-
}];
|
|
4413
|
-
return [];
|
|
5304
|
+
/** Switch to another harbor this account belongs to. */
|
|
5305
|
+
function HarborSwitch({ harbors, current, width, onPick, onClose }) {
|
|
5306
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
5307
|
+
width,
|
|
5308
|
+
children: /* @__PURE__ */ jsx(Picker, {
|
|
5309
|
+
title: "Harbors",
|
|
5310
|
+
choices: harbors.map((h) => ({
|
|
5311
|
+
label: h.name,
|
|
5312
|
+
value: h.id,
|
|
5313
|
+
hint: `${h.role}${h.id === current ? " · open" : ""}`
|
|
5314
|
+
})),
|
|
5315
|
+
initial: Math.max(0, harbors.findIndex((h) => h.id === current)),
|
|
5316
|
+
onCancel: onClose,
|
|
5317
|
+
onPick
|
|
5318
|
+
})
|
|
4414
5319
|
});
|
|
4415
5320
|
}
|
|
5321
|
+
const HELP = [
|
|
5322
|
+
["0–9 / Tab", "the screens / between the sidebar and a screen"],
|
|
5323
|
+
[":", "anything: a screen, something new, a name"],
|
|
5324
|
+
["/ / H", "find a task by key or words / switch harbor"],
|
|
5325
|
+
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
5326
|
+
["Enter / Esc", "open / back"],
|
|
5327
|
+
["n", "new: a task, or what the screen lists"],
|
|
5328
|
+
["e", "change what's open (health, lead, dates)"],
|
|
5329
|
+
["[ ]", "move a card one column left or right"],
|
|
5330
|
+
["x / d", "mark done / delete (asks first)"],
|
|
5331
|
+
["u / r", "undo your last change / retry saving"],
|
|
5332
|
+
["v", "switch layout: board, list, budget, by project"],
|
|
5333
|
+
["m / R", "new milestone on a program / log a risk"],
|
|
5334
|
+
["D", "show or hide Done projects and programs"],
|
|
5335
|
+
["c / a / C", "on a task: comment / add a step / change yours"],
|
|
5336
|
+
["f / c", "on Today: focus on a task / check in"],
|
|
5337
|
+
["f / p", "on a board: filter by owner / priority"],
|
|
5338
|
+
["q", "quit (saves first)"]
|
|
5339
|
+
];
|
|
4416
5340
|
/**
|
|
4417
|
-
*
|
|
4418
|
-
*
|
|
5341
|
+
* The mark sits beside the keys only when both fit: the longest line, a gap,
|
|
5342
|
+
* the 16-pixel mark, and the box's border and padding. Narrower, the lines
|
|
5343
|
+
* would wrap and split the mark.
|
|
4419
5344
|
*/
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
5345
|
+
const HELP_WITH_MARK = 13 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
|
|
5346
|
+
function Help({ width, onClose }) {
|
|
5347
|
+
useInput((input, key) => {
|
|
5348
|
+
if (key.escape || key.return || input === "?" || input === "q") onClose();
|
|
5349
|
+
});
|
|
5350
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
5351
|
+
width,
|
|
5352
|
+
borderStyle: "round",
|
|
5353
|
+
borderColor: theme.border,
|
|
5354
|
+
paddingX: 1,
|
|
5355
|
+
children: [/* @__PURE__ */ jsxs(Box, {
|
|
5356
|
+
flexDirection: "column",
|
|
5357
|
+
flexGrow: 1,
|
|
5358
|
+
children: [
|
|
5359
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5360
|
+
bold: true,
|
|
5361
|
+
color: theme.accent,
|
|
5362
|
+
children: "Keys"
|
|
5363
|
+
}),
|
|
5364
|
+
HELP.map(([k, what]) => /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsx(Text, {
|
|
5365
|
+
bold: true,
|
|
5366
|
+
children: k.padEnd(13)
|
|
5367
|
+
}), what] }, k)),
|
|
5368
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5369
|
+
color: theme.muted,
|
|
5370
|
+
children: "Everything saves as you go and shows on the website."
|
|
5371
|
+
})
|
|
5372
|
+
]
|
|
5373
|
+
}), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
|
|
5374
|
+
flexDirection: "column",
|
|
5375
|
+
alignItems: "center",
|
|
5376
|
+
marginLeft: 2,
|
|
5377
|
+
marginTop: 1,
|
|
5378
|
+
children: [
|
|
5379
|
+
/* @__PURE__ */ jsx(Logo, { pixels: LOGO_SMALL }),
|
|
5380
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5381
|
+
bold: true,
|
|
5382
|
+
color: theme.accent,
|
|
5383
|
+
children: "Harbor"
|
|
5384
|
+
}),
|
|
5385
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5386
|
+
color: theme.muted,
|
|
5387
|
+
children: "harborppm.com"
|
|
5388
|
+
})
|
|
5389
|
+
]
|
|
5390
|
+
}) : null]
|
|
5391
|
+
});
|
|
4443
5392
|
}
|
|
5393
|
+
|
|
5394
|
+
//#endregion
|
|
5395
|
+
//#region src/tui/views/palette.tsx
|
|
4444
5396
|
/**
|
|
4445
|
-
*
|
|
4446
|
-
*
|
|
4447
|
-
* Reference-stable: an entity whose health did not change is returned as the
|
|
4448
|
-
* same object, and a collection with no changed entity as the same array, so
|
|
4449
|
-
* selectors that pick `projects`, `programs` or `portfolios` only re-render
|
|
4450
|
-
* when a health really moved.
|
|
5397
|
+
* `:` — everything in one list: screens, making things, every project and
|
|
5398
|
+
* program by name, other harbors. Type to narrow, Enter to run.
|
|
4451
5399
|
*/
|
|
4452
|
-
function
|
|
4453
|
-
const
|
|
4454
|
-
const
|
|
4455
|
-
const
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
5400
|
+
function Palette({ width, base, open, onClose }) {
|
|
5401
|
+
const projects = useHarbor((s) => s.projects);
|
|
5402
|
+
const programs = useHarbor((s) => s.programs);
|
|
5403
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
5404
|
+
const [query, setQuery] = useState("");
|
|
5405
|
+
const [at, setAt] = useState(0);
|
|
5406
|
+
const all = useMemo(() => [
|
|
5407
|
+
...base,
|
|
5408
|
+
...projects.map((p) => ({
|
|
5409
|
+
label: `Open ${p.name}`,
|
|
5410
|
+
hint: `project ${p.key ?? ""}`,
|
|
5411
|
+
run: () => open("project", p.id)
|
|
5412
|
+
})),
|
|
5413
|
+
...programs.map((p) => ({
|
|
5414
|
+
label: `Open ${p.name}`,
|
|
5415
|
+
hint: "program",
|
|
5416
|
+
run: () => open("program", p.id)
|
|
5417
|
+
})),
|
|
5418
|
+
...portfolios.map((p) => ({
|
|
5419
|
+
label: `Open ${p.name}`,
|
|
5420
|
+
hint: "portfolio",
|
|
5421
|
+
run: () => open("portfolio", p.id)
|
|
5422
|
+
}))
|
|
5423
|
+
], [
|
|
5424
|
+
base,
|
|
4468
5425
|
projects,
|
|
4469
5426
|
programs,
|
|
4470
|
-
portfolios
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
if (
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
5427
|
+
portfolios,
|
|
5428
|
+
open
|
|
5429
|
+
]);
|
|
5430
|
+
const hits = useMemo(() => matchCommands(all, query, 12), [all, query]);
|
|
5431
|
+
useInput((input, key) => {
|
|
5432
|
+
if (key.escape) return onClose();
|
|
5433
|
+
if (key.upArrow) return setAt((i) => step(i, -1, hits.length));
|
|
5434
|
+
if (key.downArrow) return setAt((i) => step(i, 1, hits.length));
|
|
5435
|
+
if (key.return) {
|
|
5436
|
+
const hit = hits[Math.min(at, hits.length - 1)];
|
|
5437
|
+
onClose();
|
|
5438
|
+
if (hit) hit.run();
|
|
5439
|
+
return;
|
|
5440
|
+
}
|
|
5441
|
+
if (key.backspace || key.delete) {
|
|
5442
|
+
setAt(0);
|
|
5443
|
+
return setQuery((q) => q.slice(0, -1));
|
|
5444
|
+
}
|
|
5445
|
+
if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
|
|
5446
|
+
if (input) {
|
|
5447
|
+
setAt(0);
|
|
5448
|
+
setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
|
|
5449
|
+
}
|
|
5450
|
+
});
|
|
5451
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
5452
|
+
flexDirection: "column",
|
|
5453
|
+
width,
|
|
5454
|
+
borderStyle: "round",
|
|
5455
|
+
borderColor: theme.border,
|
|
5456
|
+
paddingX: 1,
|
|
5457
|
+
children: [
|
|
5458
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
5459
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5460
|
+
color: theme.accent,
|
|
5461
|
+
bold: true,
|
|
5462
|
+
children: [":", " "]
|
|
5463
|
+
}),
|
|
5464
|
+
query || /* @__PURE__ */ jsx(Text, {
|
|
5465
|
+
color: theme.muted,
|
|
5466
|
+
children: "a screen, “new …”, or a project, program or portfolio by name"
|
|
5467
|
+
}),
|
|
5468
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5469
|
+
inverse: true,
|
|
5470
|
+
children: " "
|
|
5471
|
+
})
|
|
5472
|
+
] }),
|
|
5473
|
+
hits.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5474
|
+
color: theme.muted,
|
|
5475
|
+
children: "Nothing matches."
|
|
5476
|
+
}) : null,
|
|
5477
|
+
hits.map((c, i) => {
|
|
5478
|
+
const on = i === Math.min(at, hits.length - 1);
|
|
5479
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
5480
|
+
color: on ? theme.selected : void 0,
|
|
5481
|
+
bold: on,
|
|
5482
|
+
wrap: "truncate-end",
|
|
5483
|
+
children: [
|
|
5484
|
+
on ? "› " : " ",
|
|
5485
|
+
c.label,
|
|
5486
|
+
c.hint ? /* @__PURE__ */ jsxs(Text, {
|
|
5487
|
+
color: theme.muted,
|
|
5488
|
+
children: [" · ", c.hint]
|
|
5489
|
+
}) : null
|
|
5490
|
+
]
|
|
5491
|
+
}, `${c.label}-${i}`);
|
|
5492
|
+
}),
|
|
5493
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5494
|
+
color: theme.muted,
|
|
5495
|
+
children: "↑↓ choose · Enter go · Esc close"
|
|
5496
|
+
})
|
|
5497
|
+
]
|
|
4485
5498
|
});
|
|
4486
|
-
return changed ? next : rows;
|
|
4487
5499
|
}
|
|
4488
5500
|
|
|
4489
5501
|
//#endregion
|
|
@@ -4895,6 +5907,7 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
4895
5907
|
const item = useHarbor((s) => s.raidItems.find((r) => r.id === id));
|
|
4896
5908
|
const people = useHarbor((s) => s.people);
|
|
4897
5909
|
const projects = useHarbor((s) => s.projects);
|
|
5910
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
4898
5911
|
if (!item) return null;
|
|
4899
5912
|
const up = (patch) => store.getState().updateRaidItem(id, patch);
|
|
4900
5913
|
const scale = (labels) => [
|
|
@@ -4908,6 +5921,22 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
4908
5921
|
value: n
|
|
4909
5922
|
}));
|
|
4910
5923
|
const score = scoreOf(item);
|
|
5924
|
+
const copy = FIELD_COPY[item.kind];
|
|
5925
|
+
const person = (pid) => people.find((p) => p.id === pid)?.name;
|
|
5926
|
+
const text = (key) => {
|
|
5927
|
+
const label = copy[key];
|
|
5928
|
+
if (!label) return [];
|
|
5929
|
+
const value = item[key] ?? "";
|
|
5930
|
+
return [{
|
|
5931
|
+
label,
|
|
5932
|
+
value: value.replace(/\s+/g, " "),
|
|
5933
|
+
edit: {
|
|
5934
|
+
kind: "text",
|
|
5935
|
+
initial: value,
|
|
5936
|
+
onSave: (v) => up({ [key]: v })
|
|
5937
|
+
}
|
|
5938
|
+
}];
|
|
5939
|
+
};
|
|
4911
5940
|
const fields = [
|
|
4912
5941
|
{
|
|
4913
5942
|
label: "Title",
|
|
@@ -4918,6 +5947,27 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
4918
5947
|
onSave: (v) => v.trim() && up({ title: v.trim() })
|
|
4919
5948
|
}
|
|
4920
5949
|
},
|
|
5950
|
+
{
|
|
5951
|
+
label: "Kind",
|
|
5952
|
+
value: KIND_LABEL[item.kind],
|
|
5953
|
+
edit: {
|
|
5954
|
+
kind: "pick",
|
|
5955
|
+
choices: [
|
|
5956
|
+
"risk",
|
|
5957
|
+
"issue",
|
|
5958
|
+
"decision"
|
|
5959
|
+
].map((k) => ({
|
|
5960
|
+
label: KIND_LABEL[k],
|
|
5961
|
+
value: k
|
|
5962
|
+
})),
|
|
5963
|
+
initial: [
|
|
5964
|
+
"risk",
|
|
5965
|
+
"issue",
|
|
5966
|
+
"decision"
|
|
5967
|
+
].indexOf(item.kind),
|
|
5968
|
+
onPick: (v) => up({ kind: v })
|
|
5969
|
+
}
|
|
5970
|
+
},
|
|
4921
5971
|
{
|
|
4922
5972
|
label: "Status",
|
|
4923
5973
|
value: STATUS_LABEL$1[item.status],
|
|
@@ -4928,12 +5978,16 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
4928
5978
|
value: s
|
|
4929
5979
|
})),
|
|
4930
5980
|
initial: STATUSES[item.kind].indexOf(item.status),
|
|
4931
|
-
onPick: (v) => up(
|
|
5981
|
+
onPick: (v) => up(v === "decided" ? {
|
|
5982
|
+
status: v,
|
|
5983
|
+
decidedAt: item.decidedAt ?? today(),
|
|
5984
|
+
decidedById: item.decidedById ?? me
|
|
5985
|
+
} : { status: v })
|
|
4932
5986
|
}
|
|
4933
5987
|
},
|
|
4934
5988
|
{
|
|
4935
5989
|
label: "Owner",
|
|
4936
|
-
value:
|
|
5990
|
+
value: person(item.ownerId) ?? "Nobody yet",
|
|
4937
5991
|
edit: {
|
|
4938
5992
|
kind: "pick",
|
|
4939
5993
|
choices: peopleChoices(people, true),
|
|
@@ -4960,43 +6014,56 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
4960
6014
|
})
|
|
4961
6015
|
}
|
|
4962
6016
|
},
|
|
4963
|
-
...item.kind === "risk" ? [
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
}
|
|
6017
|
+
...item.kind === "risk" ? [{
|
|
6018
|
+
label: "Likelihood",
|
|
6019
|
+
value: item.probability ? `${item.probability} ${PROBABILITY_LABEL[item.probability]}` : "",
|
|
6020
|
+
edit: {
|
|
6021
|
+
kind: "pick",
|
|
6022
|
+
choices: scale(PROBABILITY_LABEL),
|
|
6023
|
+
initial: (item.probability ?? 3) - 1,
|
|
6024
|
+
onPick: (v) => up({ probability: v })
|
|
6025
|
+
}
|
|
6026
|
+
}] : [],
|
|
6027
|
+
...item.kind !== "decision" ? [{
|
|
6028
|
+
label: "Impact",
|
|
6029
|
+
value: item.impact ? `${item.impact} ${IMPACT_LABEL[item.impact]}` : "",
|
|
6030
|
+
edit: {
|
|
6031
|
+
kind: "pick",
|
|
6032
|
+
choices: scale(IMPACT_LABEL),
|
|
6033
|
+
initial: (item.impact ?? 3) - 1,
|
|
6034
|
+
onPick: (v) => up({ impact: v })
|
|
6035
|
+
}
|
|
6036
|
+
}] : [],
|
|
6037
|
+
...item.kind === "risk" ? [{
|
|
6038
|
+
label: "Score",
|
|
6039
|
+
value: score != null ? String(score) : ""
|
|
6040
|
+
}] : [],
|
|
6041
|
+
...item.kind === "decision" ? [{
|
|
6042
|
+
label: "Decided by",
|
|
6043
|
+
value: person(item.decidedById) ?? "Not yet",
|
|
6044
|
+
edit: {
|
|
6045
|
+
kind: "pick",
|
|
6046
|
+
choices: [{
|
|
6047
|
+
label: "Not yet",
|
|
6048
|
+
value: null
|
|
6049
|
+
}, ...people.map((p) => ({
|
|
6050
|
+
label: p.name,
|
|
6051
|
+
value: p.id
|
|
6052
|
+
}))],
|
|
6053
|
+
initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
|
|
6054
|
+
onPick: (v) => up({ decidedById: v })
|
|
6055
|
+
}
|
|
6056
|
+
}, {
|
|
6057
|
+
label: "Decided on",
|
|
6058
|
+
value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
|
|
6059
|
+
edit: {
|
|
6060
|
+
kind: "date",
|
|
6061
|
+
initial: item.decidedAt ?? null,
|
|
6062
|
+
onSave: (v) => up({ decidedAt: v })
|
|
4996
6063
|
}
|
|
4997
|
-
] : [],
|
|
6064
|
+
}] : [],
|
|
4998
6065
|
{
|
|
4999
|
-
label:
|
|
6066
|
+
label: copy.dueDate,
|
|
5000
6067
|
value: shortDay(item.dueDate, today()),
|
|
5001
6068
|
edit: {
|
|
5002
6069
|
kind: "date",
|
|
@@ -5004,16 +6071,13 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
5004
6071
|
onSave: (v) => up({ dueDate: v })
|
|
5005
6072
|
}
|
|
5006
6073
|
},
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
initial: item.description,
|
|
5013
|
-
onSave: (v) => up({ description: v })
|
|
5014
|
-
}
|
|
5015
|
-
}
|
|
6074
|
+
...text("description"),
|
|
6075
|
+
...text("mitigation"),
|
|
6076
|
+
...text("contingency"),
|
|
6077
|
+
...text("resolution"),
|
|
6078
|
+
...text("rationale")
|
|
5016
6079
|
];
|
|
6080
|
+
const updates = item.updates.slice(-3);
|
|
5017
6081
|
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
5018
6082
|
title: /* @__PURE__ */ jsxs(Text, { children: [
|
|
5019
6083
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -5030,6 +6094,25 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
5030
6094
|
width,
|
|
5031
6095
|
height,
|
|
5032
6096
|
hints: "c add an update · d delete",
|
|
6097
|
+
below: updates.length ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, {
|
|
6098
|
+
color: theme.muted,
|
|
6099
|
+
children: "Updates"
|
|
6100
|
+
}), updates.map((u) => /* @__PURE__ */ jsxs(Text, {
|
|
6101
|
+
wrap: "truncate-end",
|
|
6102
|
+
children: [
|
|
6103
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6104
|
+
bold: true,
|
|
6105
|
+
children: person(u.authorId) ?? "Someone"
|
|
6106
|
+
}),
|
|
6107
|
+
" ",
|
|
6108
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6109
|
+
color: theme.muted,
|
|
6110
|
+
children: shortDay(u.createdAt.slice(0, 10), today())
|
|
6111
|
+
}),
|
|
6112
|
+
" ",
|
|
6113
|
+
u.body.replace(/\s+/g, " ")
|
|
6114
|
+
]
|
|
6115
|
+
}, u.id))] }) : void 0,
|
|
5033
6116
|
keys: {
|
|
5034
6117
|
c: () => go.ask("Update (shows in its history)", (text) => text.trim() && store.getState().addRaidUpdate(id, text.trim())),
|
|
5035
6118
|
d: () => go.confirm(`Delete “${item.title}” from the register? u undoes it.`, () => {
|
|
@@ -7192,9 +8275,17 @@ const NAV = [
|
|
|
7192
8275
|
{
|
|
7193
8276
|
screen: "history",
|
|
7194
8277
|
label: "History"
|
|
8278
|
+
},
|
|
8279
|
+
{
|
|
8280
|
+
screen: "calendar",
|
|
8281
|
+
label: "Calendar"
|
|
8282
|
+
},
|
|
8283
|
+
{
|
|
8284
|
+
screen: "brief",
|
|
8285
|
+
label: "Brief"
|
|
7195
8286
|
}
|
|
7196
8287
|
];
|
|
7197
|
-
/** The sidebar's width: its longest line ("
|
|
8288
|
+
/** The sidebar's width: its longest line ("9 Calendar", ": Anything") with room either side. */
|
|
7198
8289
|
const NAV_WIDTH = 15;
|
|
7199
8290
|
/** What the corner says about saving, and in which of the theme's colours. */
|
|
7200
8291
|
const SAVE_LABEL = {
|
|
@@ -7379,8 +8470,10 @@ function App({ ui }) {
|
|
|
7379
8470
|
if (!store.undoLast()) notify("Nothing to undo.");
|
|
7380
8471
|
return;
|
|
7381
8472
|
}
|
|
7382
|
-
|
|
7383
|
-
|
|
8473
|
+
if (/^[0-9]$/.test(input)) {
|
|
8474
|
+
const n = input === "0" ? 10 : Number(input);
|
|
8475
|
+
if (n <= NAV.length) return goScreen(NAV[n - 1].screen);
|
|
8476
|
+
}
|
|
7384
8477
|
if (focus === "nav") {
|
|
7385
8478
|
const at = NAV.findIndex((x) => x.screen === screen);
|
|
7386
8479
|
if (key.upArrow || input === "k") goScreen(NAV[Math.max(0, at - 1)].screen);
|
|
@@ -7573,7 +8666,7 @@ function App({ ui }) {
|
|
|
7573
8666
|
...page.kind === "program" ? [["m", "milestone"], ["←→", "lists"]] : [],
|
|
7574
8667
|
["Esc", "back"]
|
|
7575
8668
|
] : [
|
|
7576
|
-
["
|
|
8669
|
+
["0–9", "screens"],
|
|
7577
8670
|
[":", "anything"],
|
|
7578
8671
|
["/", "find"],
|
|
7579
8672
|
["n", "new"],
|
|
@@ -7605,6 +8698,8 @@ function App({ ui }) {
|
|
|
7605
8698
|
else if (screen === "risks") screenNode = /* @__PURE__ */ jsx(RisksView, { ...viewProps });
|
|
7606
8699
|
else if (screen === "people") screenNode = /* @__PURE__ */ jsx(PeopleView, { ...viewProps });
|
|
7607
8700
|
else if (screen === "roadmap") screenNode = /* @__PURE__ */ jsx(RoadmapView, { ...viewProps });
|
|
8701
|
+
else if (screen === "calendar") screenNode = /* @__PURE__ */ jsx(CalendarView, { ...viewProps });
|
|
8702
|
+
else if (screen === "brief") screenNode = /* @__PURE__ */ jsx(BriefView, { ...viewProps });
|
|
7608
8703
|
else screenNode = /* @__PURE__ */ jsx(HistoryView, { ...viewProps });
|
|
7609
8704
|
return /* @__PURE__ */ jsxs(Box, {
|
|
7610
8705
|
flexDirection: "column",
|
|
@@ -7666,7 +8761,7 @@ function App({ ui }) {
|
|
|
7666
8761
|
wrap: "truncate-end",
|
|
7667
8762
|
children: [/* @__PURE__ */ jsxs(Text, {
|
|
7668
8763
|
color: theme.muted,
|
|
7669
|
-
children: [i + 1, " "]
|
|
8764
|
+
children: [(i + 1) % 10, " "]
|
|
7670
8765
|
}), x.label]
|
|
7671
8766
|
}, x.screen);
|
|
7672
8767
|
}),
|
|
@@ -8298,22 +9393,6 @@ function sessionSlice({ set, get, write }) {
|
|
|
8298
9393
|
};
|
|
8299
9394
|
}
|
|
8300
9395
|
|
|
8301
|
-
//#endregion
|
|
8302
|
-
//#region src/lib/priority.ts
|
|
8303
|
-
const DEFAULT_RULES = {
|
|
8304
|
-
impactThreshold: 6,
|
|
8305
|
-
budgetShare: .5
|
|
8306
|
-
};
|
|
8307
|
-
/** Rules with anything missing or out of range brought back to the defaults. */
|
|
8308
|
-
function normalizeRules(rules) {
|
|
8309
|
-
const impactThreshold = Number(rules?.impactThreshold);
|
|
8310
|
-
const budgetShare = Number(rules?.budgetShare);
|
|
8311
|
-
return {
|
|
8312
|
-
impactThreshold: Number.isFinite(impactThreshold) ? Math.min(10, Math.max(1, Math.round(impactThreshold))) : DEFAULT_RULES.impactThreshold,
|
|
8313
|
-
budgetShare: Number.isFinite(budgetShare) ? Math.min(1, Math.max(.05, budgetShare)) : DEFAULT_RULES.budgetShare
|
|
8314
|
-
};
|
|
8315
|
-
}
|
|
8316
|
-
|
|
8317
9396
|
//#endregion
|
|
8318
9397
|
//#region src/lib/sample-project.ts
|
|
8319
9398
|
/**
|