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