@harborppm/cli 0.21.0 → 0.22.0

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