@harborppm/cli 0.42.0 → 0.44.0

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