@harborppm/cli 0.43.0 → 0.44.0

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