@harborppm/cli 0.11.2 → 0.13.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
@@ -1485,6 +1485,97 @@ function forecastAtCompletion(item, now) {
1485
1485
  };
1486
1486
  }
1487
1487
 
1488
+ //#endregion
1489
+ //#region src/lib/flow.ts
1490
+ function tasksInProgramOf(data, programId) {
1491
+ const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
1492
+ return data.tasks.filter((t) => ids.has(t.projectId));
1493
+ }
1494
+ function constraintOf(data, programId) {
1495
+ const rows = data.constraints ?? [];
1496
+ return rows.find((c) => c.programId === programId && c.status === "active") ?? rows.find((c) => c.programId === programId);
1497
+ }
1498
+ function activeConstraints(data) {
1499
+ return (data.constraints ?? []).filter((c) => c.status === "active");
1500
+ }
1501
+ function constraintsOwnedBy(data, personId) {
1502
+ return activeConstraints(data).filter((c) => c.ownerId === personId);
1503
+ }
1504
+ function constraintIdle(data, constraint) {
1505
+ if (constraint.status !== "active" || !constraint.ownerId) return false;
1506
+ const tasks = tasksInProgramOf(data, constraint.programId);
1507
+ if (!tasks.some((t) => t.status !== "done")) return false;
1508
+ if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
1509
+ const here = new Set(tasks.map((t) => t.id));
1510
+ return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
1511
+ }
1512
+
1513
+ //#endregion
1514
+ //#region src/lib/spend.ts
1515
+ function spendEntriesOf(data, projectId) {
1516
+ return (data.spendEntries ?? []).filter((e) => e.projectId === projectId).sort((a, b) => a.date.localeCompare(b.date));
1517
+ }
1518
+ function spentFromEntries(entries) {
1519
+ return entries.reduce((sum, e) => sum + e.amount, 0);
1520
+ }
1521
+ /** The entry that makes the ledger agree with a total typed by hand. */
1522
+ function adjustmentEntry(projectId, oldSpent, newSpent, date, id) {
1523
+ const delta = Math.round((newSpent - oldSpent) * 100) / 100;
1524
+ if (delta === 0) return null;
1525
+ return {
1526
+ id,
1527
+ projectId,
1528
+ amount: delta,
1529
+ date,
1530
+ note: "Adjusted the total by hand"
1531
+ };
1532
+ }
1533
+
1534
+ //#endregion
1535
+ //#region src/lib/selectors.ts
1536
+ function programsIn(data, portfolioId) {
1537
+ return data.programs.filter((p) => p.portfolioId === portfolioId);
1538
+ }
1539
+ function projectsInProgram(data, programId) {
1540
+ return data.projects.filter((p) => p.programId === programId);
1541
+ }
1542
+ /** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
1543
+ function splitDone(items) {
1544
+ const open = [];
1545
+ const done = [];
1546
+ for (const item of items) (item.health === "done" ? done : open).push(item);
1547
+ return {
1548
+ open,
1549
+ done
1550
+ };
1551
+ }
1552
+ function projectsInPortfolio(data, portfolioId) {
1553
+ const ids = new Set(programsIn(data, portfolioId).map((p) => p.id));
1554
+ return data.projects.filter((p) => ids.has(p.programId));
1555
+ }
1556
+ function sum(values) {
1557
+ return values.reduce((a, b) => a + b, 0);
1558
+ }
1559
+ /** Projects are the leaf: their own `spent` field, no rollup needed. */
1560
+ function programSpent(data, programId) {
1561
+ return sum(projectsInProgram(data, programId).map((p) => p.spent));
1562
+ }
1563
+ /** Sum of a program's own projects' budgets — compare against the program's stated budget to see over/under-allocation. */
1564
+ function programBudgetAllocated(data, programId) {
1565
+ return sum(projectsInProgram(data, programId).map((p) => p.budget));
1566
+ }
1567
+ /** This program's own items plus everything raised in its projects. */
1568
+ function programRaidItems(data, programId) {
1569
+ const projectIds = new Set(projectsInProgram(data, programId).map((p) => p.id));
1570
+ return data.raidItems.filter((r) => r.programId === programId || r.projectId != null && projectIds.has(r.projectId));
1571
+ }
1572
+ /** This portfolio's own items plus everything raised in its programs and their projects. */
1573
+ function portfolioRaidItems(data, portfolioId) {
1574
+ const programIds = new Set(programsIn(data, portfolioId).map((p) => p.id));
1575
+ const projectIds = new Set(projectsInPortfolio(data, portfolioId).map((p) => p.id));
1576
+ return data.raidItems.filter((r) => r.portfolioId === portfolioId || r.programId != null && programIds.has(r.programId) || r.projectId != null && projectIds.has(r.projectId));
1577
+ }
1578
+
1488
1579
  //#endregion
1489
1580
  //#region src/lib/baseline.ts
1490
1581
  const day$2 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
@@ -1823,27 +1914,6 @@ function searchTasks(tasks, projects, query, limit = 12) {
1823
1914
  }, limit);
1824
1915
  }
1825
1916
 
1826
- //#endregion
1827
- //#region src/lib/spend.ts
1828
- function spendEntriesOf(data, projectId) {
1829
- return (data.spendEntries ?? []).filter((e) => e.projectId === projectId).sort((a, b) => a.date.localeCompare(b.date));
1830
- }
1831
- function spentFromEntries(entries) {
1832
- return entries.reduce((sum, e) => sum + e.amount, 0);
1833
- }
1834
- /** The entry that makes the ledger agree with a total typed by hand. */
1835
- function adjustmentEntry(projectId, oldSpent, newSpent, date, id) {
1836
- const delta = Math.round((newSpent - oldSpent) * 100) / 100;
1837
- if (delta === 0) return null;
1838
- return {
1839
- id,
1840
- projectId,
1841
- amount: delta,
1842
- date,
1843
- note: "Adjusted the total by hand"
1844
- };
1845
- }
1846
-
1847
1917
  //#endregion
1848
1918
  //#region src/lib/work.ts
1849
1919
  /** Every task this one waits on that still exists, done or not. */
@@ -2088,6 +2158,82 @@ function forecastLine(f) {
2088
2158
  const side = f.variance >= 0 ? "under" : "over";
2089
2159
  return `At this rate it ends at ${money(Math.round(f.eac))} (${money(gap)} ${side} budget)`;
2090
2160
  }
2161
+ const NO_FILTER = {
2162
+ who: "any",
2163
+ priority: "any"
2164
+ };
2165
+ function filterTasks(tasks, f) {
2166
+ if (f.who === "any" && f.priority === "any") return tasks;
2167
+ return tasks.filter((t) => (f.who === "any" || (f.who === "none" ? t.assigneeId === null : t.assigneeId === f.who)) && (f.priority === "any" || t.priority === f.priority));
2168
+ }
2169
+ /**
2170
+ * Bands of lines, each line owned by one item (a program or a project):
2171
+ * the first line to show so the selected band fits, scrolling as little
2172
+ * as it can from the top.
2173
+ */
2174
+ function bandStart(owners, selected, rows) {
2175
+ const first = owners.indexOf(selected);
2176
+ if (first < 0) return 0;
2177
+ const last = owners.lastIndexOf(selected);
2178
+ return Math.max(0, Math.min(first, last - rows + 1));
2179
+ }
2180
+ /** The next milestones still to reach, soonest first. */
2181
+ function nextMarks(data, programId, n) {
2182
+ return data.milestones.filter((m) => m.programId === programId && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, n);
2183
+ }
2184
+ /** Open risks and issues, worst first. */
2185
+ function worst(items, n) {
2186
+ return sortRaid(items.filter(isActive)).slice(0, n);
2187
+ }
2188
+ function projectRow(data, project, today) {
2189
+ const tasks = tasksOf(data.tasks, project.id);
2190
+ const open = {};
2191
+ let done = 0;
2192
+ let late = 0;
2193
+ for (const t of tasks) {
2194
+ if (t.status === "done") done++;
2195
+ else open[t.status] = (open[t.status] ?? 0) + 1;
2196
+ if (isLate(t, today)) late++;
2197
+ }
2198
+ return {
2199
+ project,
2200
+ progress: tasks.length ? Math.round(done / tasks.length * 100) : 0,
2201
+ late,
2202
+ open
2203
+ };
2204
+ }
2205
+ function activeConstraint(data, programId) {
2206
+ const c = constraintOf(data, programId);
2207
+ return c?.status === "active" ? c.title : null;
2208
+ }
2209
+ /** A portfolio read program by program, as the website's By program layout. */
2210
+ function programBands(data, portfolioId, today, showDone) {
2211
+ const split = splitDone(data.programs.filter((p) => p.portfolioId === portfolioId));
2212
+ return (showDone ? [...split.open, ...split.done] : split.open).map((program) => {
2213
+ const mine = splitDone(data.projects.filter((p) => p.programId === program.id));
2214
+ const rows = (showDone ? [...mine.open, ...mine.done] : mine.open).map((p) => projectRow(data, p, today));
2215
+ const all = [...mine.open, ...mine.done].map((p) => tasksOf(data.tasks, p.id));
2216
+ const total = all.reduce((n, t) => n + t.length, 0);
2217
+ const done = all.reduce((n, t) => n + t.filter((x) => x.status === "done").length, 0);
2218
+ return {
2219
+ program,
2220
+ progress: total ? Math.round(done / total * 100) : 0,
2221
+ constraint: activeConstraint(data, program.id),
2222
+ projects: rows,
2223
+ doneProjects: showDone ? 0 : mine.done.length,
2224
+ milestones: nextMarks(data, program.id, 2),
2225
+ risks: worst(programRaidItems(data, program.id), 2)
2226
+ };
2227
+ });
2228
+ }
2229
+ /** A program read project by project, as the website's By project layout. */
2230
+ function projectBands(data, programId, today, showDone) {
2231
+ const split = splitDone(data.projects.filter((p) => p.programId === programId));
2232
+ return (showDone ? [...split.open, ...split.done] : split.open).map((p) => ({
2233
+ ...projectRow(data, p, today),
2234
+ risks: worst(data.raidItems.filter((r) => r.projectId === p.id), 2)
2235
+ }));
2236
+ }
2091
2237
 
2092
2238
  //#endregion
2093
2239
  //#region src/tui/theme.ts
@@ -2973,27 +3119,6 @@ function HistoryView({ active, width, height, go }) {
2973
3119
  });
2974
3120
  }
2975
3121
 
2976
- //#endregion
2977
- //#region src/lib/flow.ts
2978
- function tasksInProgramOf(data, programId) {
2979
- const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
2980
- return data.tasks.filter((t) => ids.has(t.projectId));
2981
- }
2982
- function activeConstraints(data) {
2983
- return (data.constraints ?? []).filter((c) => c.status === "active");
2984
- }
2985
- function constraintsOwnedBy(data, personId) {
2986
- return activeConstraints(data).filter((c) => c.ownerId === personId);
2987
- }
2988
- function constraintIdle(data, constraint) {
2989
- if (constraint.status !== "active" || !constraint.ownerId) return false;
2990
- const tasks = tasksInProgramOf(data, constraint.programId);
2991
- if (!tasks.some((t) => t.status !== "done")) return false;
2992
- if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
2993
- const here = new Set(tasks.map((t) => t.id));
2994
- return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
2995
- }
2996
-
2997
3122
  //#endregion
2998
3123
  //#region src/lib/inbox.ts
2999
3124
  const KIND_RANK = {
@@ -3285,19 +3410,6 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
3285
3410
  };
3286
3411
  }
3287
3412
 
3288
- //#endregion
3289
- //#region src/lib/selectors.ts
3290
- /** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
3291
- function splitDone(items) {
3292
- const open = [];
3293
- const done = [];
3294
- for (const item of items) (item.health === "done" ? done : open).push(item);
3295
- return {
3296
- open,
3297
- done
3298
- };
3299
- }
3300
-
3301
3413
  //#endregion
3302
3414
  //#region src/tui/views/lists.tsx
3303
3415
  const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
@@ -3908,11 +4020,9 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
3908
4020
  });
3909
4021
  }
3910
4022
  const HELP = [
3911
- ["1–8", "the screens, as the sidebar lists them"],
4023
+ ["1–8 / Tab", "the screens / between the sidebar and a screen"],
3912
4024
  [":", "anything: a screen, something new, a name"],
3913
- ["/", "find a task by key or words"],
3914
- ["H", "switch harbor"],
3915
- ["Tab", "between the sidebar and the screen"],
4025
+ ["/ / H", "find a task by key or words / switch harbor"],
3916
4026
  ["↑↓ or j k", "move; ←→ or h l across columns and lists"],
3917
4027
  ["Enter / Esc", "open / back"],
3918
4028
  ["n", "new: a task, or what the screen lists"],
@@ -3923,7 +4033,9 @@ const HELP = [
3923
4033
  ["v", "on a project: board, list, budget"],
3924
4034
  ["m / R", "new milestone on a program / log a risk"],
3925
4035
  ["D", "show or hide Done projects and programs"],
3926
- ["c / a", "on a task: comment / add a checklist step"],
4036
+ ["c / a / C", "on a task: comment / add a step / change yours"],
4037
+ ["f / c", "on Today: focus on a task / check in"],
4038
+ ["f / p", "on a board: filter by owner / priority"],
3927
4039
  ["q", "quit (saves first)"]
3928
4040
  ];
3929
4041
  /**
@@ -4132,7 +4244,7 @@ function healthField(kind, item, go, set, clear) {
4132
4244
  }
4133
4245
  };
4134
4246
  }
4135
- function ProgramPanel({ id, width, height, go, onClose }) {
4247
+ function ProgramPanel({ id, width, height, go, active, onClose }) {
4136
4248
  const { store, today } = useDeps();
4137
4249
  const program = useHarbor((s) => s.programs.find((p) => p.id === id));
4138
4250
  const people = useHarbor((s) => s.people);
@@ -4228,10 +4340,11 @@ function ProgramPanel({ id, width, height, go, onClose }) {
4228
4340
  onClose();
4229
4341
  }, "Delete");
4230
4342
  } },
4343
+ active,
4231
4344
  onClose
4232
4345
  });
4233
4346
  }
4234
- function PortfolioPanel({ id, width, height, go, onClose }) {
4347
+ function PortfolioPanel({ id, width, height, go, active, onClose }) {
4235
4348
  const { store } = useDeps();
4236
4349
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
4237
4350
  const people = useHarbor((s) => s.people);
@@ -4295,10 +4408,11 @@ function PortfolioPanel({ id, width, height, go, onClose }) {
4295
4408
  onClose();
4296
4409
  }, "Delete");
4297
4410
  } },
4411
+ active,
4298
4412
  onClose
4299
4413
  });
4300
4414
  }
4301
- function ProjectPanel({ id, width, height, go, onClose }) {
4415
+ function ProjectPanel({ id, width, height, go, active, onClose }) {
4302
4416
  const { store, today } = useDeps();
4303
4417
  const project = useHarbor((s) => s.projects.find((p) => p.id === id));
4304
4418
  const people = useHarbor((s) => s.people);
@@ -4408,10 +4522,11 @@ function ProjectPanel({ id, width, height, go, onClose }) {
4408
4522
  onClose();
4409
4523
  }, "Delete");
4410
4524
  } },
4525
+ active,
4411
4526
  onClose
4412
4527
  });
4413
4528
  }
4414
- function MilestonePanel({ id, width, height, go, onClose }) {
4529
+ function MilestonePanel({ id, width, height, go, active, onClose }) {
4415
4530
  const { store, today } = useDeps();
4416
4531
  const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
4417
4532
  const people = useHarbor((s) => s.people);
@@ -4482,10 +4597,11 @@ function MilestonePanel({ id, width, height, go, onClose }) {
4482
4597
  onClose();
4483
4598
  }, "Delete")
4484
4599
  },
4600
+ active,
4485
4601
  onClose
4486
4602
  });
4487
4603
  }
4488
- function RaidPanel({ id, width, height, go, onClose }) {
4604
+ function RaidPanel({ id, width, height, go, active, onClose }) {
4489
4605
  const { store, today } = useDeps();
4490
4606
  const item = useHarbor((s) => s.raidItems.find((r) => r.id === id));
4491
4607
  const people = useHarbor((s) => s.people);
@@ -4632,10 +4748,11 @@ function RaidPanel({ id, width, height, go, onClose }) {
4632
4748
  onClose();
4633
4749
  }, "Delete")
4634
4750
  },
4751
+ active,
4635
4752
  onClose
4636
4753
  });
4637
4754
  }
4638
- function PersonPanel({ id, width, height, go, onClose }) {
4755
+ function PersonPanel({ id, width, height, go, active, onClose }) {
4639
4756
  const { store } = useDeps();
4640
4757
  const person = useHarbor((s) => s.people.find((p) => p.id === id));
4641
4758
  const programs = useHarbor((s) => s.programs);
@@ -4719,122 +4836,429 @@ function PersonPanel({ id, width, height, go, onClose }) {
4719
4836
  ]
4720
4837
  }, a.programId))
4721
4838
  ] }) : null,
4839
+ active,
4722
4840
  onClose
4723
4841
  });
4724
4842
  }
4725
4843
 
4726
4844
  //#endregion
4727
- //#region src/tui/views/program.tsx
4845
+ //#region src/tui/views/bands.tsx
4728
4846
  /**
4729
- * One program: its health (and why, when set by hand), next move, its
4730
- * projects and its milestones. ←→ between the two lists. Projects set to
4731
- * Done stay out of the list until D shows them.
4847
+ * The website's band layouts in the terminal: a portfolio read program by
4848
+ * program, a program read project by project. Each band is a few lines; the
4849
+ * one you're on stays in view.
4732
4850
  */
4733
- function ProgramView({ programId, active, width, height, go, onBack }) {
4734
- const { store, today } = useDeps();
4735
- const program = useHarbor((s) => s.programs.find((p) => p.id === programId));
4736
- const projects = useHarbor((s) => s.projects);
4737
- const tasks = useHarbor((s) => s.tasks);
4738
- const milestones = useHarbor((s) => s.milestones);
4739
- const people = useHarbor((s) => s.people);
4740
- const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === program?.portfolioId));
4741
- const [showDone, setShowDone] = useState(false);
4742
- const split = useMemo(() => splitDone(projects.filter((p) => p.programId === programId)), [projects, programId]);
4743
- const mine = showDone ? [...split.open, ...split.done] : split.open;
4744
- const marks = useMemo(() => milestonesOf({ milestones }, programId, today()), [
4745
- milestones,
4746
- programId,
4747
- today
4748
- ]);
4749
- const [side, setSide] = useState("projects");
4750
- const [pAt, setPAt] = useState(0);
4751
- const [mAt, setMAt] = useState(0);
4752
- const project = mine[Math.min(pAt, mine.length - 1)];
4753
- const mark = marks[Math.min(mAt, marks.length - 1)]?.milestone;
4754
- useInput((input, key) => {
4755
- if (key.escape || key.backspace) return onBack();
4756
- if (key.leftArrow || input === "h") return setSide("projects");
4757
- if (key.rightArrow || input === "l") return setSide("milestones");
4758
- if (input === "e") return go.openPanel("program", programId);
4759
- if (input === "n") return go.create("project", { programId });
4760
- if (input === "m") return go.create("milestone", { programId });
4761
- if (input === "R") return go.create("raid", {});
4762
- if (input === "D" && split.done.length > 0) {
4763
- setSide("projects");
4764
- return setShowDone((v) => !v);
4765
- }
4766
- if (side === "projects") {
4767
- if (key.upArrow || input === "k") setPAt((i) => step(i, -1, mine.length));
4768
- else if (key.downArrow || input === "j") setPAt((i) => step(i, 1, mine.length));
4769
- else if (key.return && project) go.openProject(project.id);
4770
- } else if (key.upArrow || input === "k") setMAt((i) => step(i, -1, marks.length));
4771
- else if (key.downArrow || input === "j") setMAt((i) => step(i, 1, marks.length));
4772
- else if (key.return && mark) go.openPanel("milestone", mark.id);
4773
- else if (input === " " && mark) store.getState().toggleMilestone(mark.id);
4774
- else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
4775
- }, { isActive: active });
4776
- if (!program) return /* @__PURE__ */ jsx(Text, {
4777
- color: theme.muted,
4778
- children: " That program isn’t in this harbor any more. Esc goes back."
4779
- });
4780
- const lead = people.find((p) => p.id === program.ownerId);
4781
- const half = Math.floor((width - 3) / 2);
4782
- const rows = height - 6;
4783
- const data = {
4784
- tasks,
4785
- projects
4786
- };
4787
- return /* @__PURE__ */ jsxs(Box, {
4851
+ const NAME$1 = 24;
4852
+ function Lines({ lines, selected, rows }) {
4853
+ const start = bandStart(lines.map((l) => l.owner), selected, rows);
4854
+ const shown = lines.slice(start, start + rows);
4855
+ return /* @__PURE__ */ jsx(Box, {
4788
4856
  flexDirection: "column",
4789
- width,
4790
- height,
4791
- paddingX: 1,
4857
+ height: rows,
4858
+ children: shown.map((l, i) => /* @__PURE__ */ jsx(Box, { children: l.node }, start + i))
4859
+ });
4860
+ }
4861
+ function fit(text, n) {
4862
+ return text.length > n ? `${text.slice(0, n - 1)}…` : text.padEnd(n);
4863
+ }
4864
+ function riskLine(r, today) {
4865
+ const score = scoreOf(r);
4866
+ return /* @__PURE__ */ jsxs(Text, {
4867
+ wrap: "truncate-end",
4792
4868
  children: [
4793
- /* @__PURE__ */ jsxs(Box, {
4794
- justifyContent: "space-between",
4795
- children: [/* @__PURE__ */ jsxs(Text, {
4796
- wrap: "truncate-end",
4797
- children: [
4798
- /* @__PURE__ */ jsx(Text, {
4799
- bold: true,
4800
- children: program.name
4801
- }),
4802
- " ",
4803
- /* @__PURE__ */ jsx(HealthMark, {
4804
- health: program.health,
4805
- label: true
4806
- }),
4807
- program.healthOverride ? /* @__PURE__ */ jsxs(Text, {
4808
- color: theme.muted,
4809
- children: [
4810
- " (by hand: ",
4811
- program.healthNote || "no note",
4812
- ")"
4813
- ]
4814
- }) : null
4815
- ]
4816
- }), /* @__PURE__ */ jsxs(Text, {
4817
- color: theme.muted,
4818
- children: [
4819
- portfolio?.name,
4820
- lead ? ` · ${lead.name}` : "",
4821
- " · ",
4822
- shortDay(program.startDate, today()),
4823
- "–",
4824
- shortDay(program.endDate, today())
4825
- ]
4826
- })]
4869
+ " ",
4870
+ /* @__PURE__ */ jsx(Text, {
4871
+ color: r.kind === "issue" ? theme.bad : r.kind === "risk" ? theme.warn : theme.muted,
4872
+ children: KIND_LABEL[r.kind].toLowerCase()
4827
4873
  }),
4874
+ " ",
4875
+ r.title,
4828
4876
  /* @__PURE__ */ jsxs(Text, {
4829
- wrap: "truncate-end",
4830
- color: program.nextMove ? void 0 : theme.muted,
4831
- children: ["Next move: ", program.nextMove || "none written yet (e to write one)"]
4832
- }),
4833
- /* @__PURE__ */ jsx(Text, { children: " " }),
4834
- /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
4835
- flexDirection: "column",
4836
- width: half,
4837
- marginRight: 1,
4877
+ color: theme.muted,
4878
+ children: [score != null ? ` · score ${score}` : "", r.dueDate ? ` · review ${shortDay(r.dueDate, today)}` : ""]
4879
+ })
4880
+ ]
4881
+ });
4882
+ }
4883
+ function projectLine(row, w, bar, today, indent = 4) {
4884
+ const p = row.project;
4885
+ return /* @__PURE__ */ jsxs(Text, {
4886
+ wrap: "truncate-end",
4887
+ children: [
4888
+ " ".repeat(indent),
4889
+ /* @__PURE__ */ jsx(Text, {
4890
+ color: HEALTH_COLOR[p.health],
4891
+ children: "●"
4892
+ }),
4893
+ " ",
4894
+ fit(p.name, NAME$1 - indent),
4895
+ " ",
4896
+ /* @__PURE__ */ jsx(Text, {
4897
+ color: HEALTH_COLOR[p.health],
4898
+ children: roadmapCells(p.startDate, p.endDate, [], w, bar)
4899
+ }),
4900
+ " ",
4901
+ /* @__PURE__ */ jsxs(Text, {
4902
+ color: theme.muted,
4903
+ children: [String(row.progress).padStart(3), "%"]
4904
+ }),
4905
+ row.late ? /* @__PURE__ */ jsxs(Text, {
4906
+ color: theme.bad,
4907
+ children: [
4908
+ " ",
4909
+ row.late,
4910
+ " late"
4911
+ ]
4912
+ }) : null
4913
+ ]
4914
+ });
4915
+ }
4916
+ /** The months over the bars, lined up with them. */
4917
+ function Axis({ w, bar }) {
4918
+ return /* @__PURE__ */ jsxs(Text, {
4919
+ color: theme.muted,
4920
+ wrap: "truncate-end",
4921
+ children: [" ".repeat(27), roadmapHeader(w, bar)]
4922
+ });
4923
+ }
4924
+ /** A portfolio, program by program: each with its projects' bars, next milestones and worst risks. */
4925
+ function ProgramBands({ bands, at, active, width, height, w, today }) {
4926
+ const bar = Math.max(10, width - NAME$1 - 19);
4927
+ const lines = [];
4928
+ bands.forEach((b, i) => {
4929
+ const on = active && i === at;
4930
+ const p = b.program;
4931
+ lines.push({
4932
+ owner: i,
4933
+ node: /* @__PURE__ */ jsxs(Text, {
4934
+ wrap: "truncate-end",
4935
+ color: on ? theme.selected : void 0,
4936
+ bold: on,
4937
+ children: [
4938
+ on ? "› " : " ",
4939
+ /* @__PURE__ */ jsx(Text, {
4940
+ color: HEALTH_COLOR[p.health],
4941
+ children: "●"
4942
+ }),
4943
+ " ",
4944
+ p.name,
4945
+ " ",
4946
+ /* @__PURE__ */ jsxs(Text, {
4947
+ color: theme.muted,
4948
+ bold: false,
4949
+ children: [
4950
+ HEALTH_LABEL[p.health],
4951
+ " · ",
4952
+ b.progress,
4953
+ "% done",
4954
+ p.nextMove ? ` · next: ${p.nextMove}` : ""
4955
+ ]
4956
+ })
4957
+ ]
4958
+ })
4959
+ });
4960
+ if (b.constraint) lines.push({
4961
+ owner: i,
4962
+ node: /* @__PURE__ */ jsxs(Text, {
4963
+ color: theme.warn,
4964
+ wrap: "truncate-end",
4965
+ children: [
4966
+ " ",
4967
+ "Held back by ",
4968
+ b.constraint
4969
+ ]
4970
+ })
4971
+ });
4972
+ for (const row of b.projects) lines.push({
4973
+ owner: i,
4974
+ node: projectLine(row, w, bar, today)
4975
+ });
4976
+ if (b.projects.length === 0) lines.push({
4977
+ owner: i,
4978
+ node: /* @__PURE__ */ jsxs(Text, {
4979
+ color: theme.muted,
4980
+ children: [" ", b.doneProjects ? "Every project here is done." : "No projects yet."]
4981
+ })
4982
+ });
4983
+ else if (b.doneProjects) lines.push({
4984
+ owner: i,
4985
+ node: /* @__PURE__ */ jsxs(Text, {
4986
+ color: theme.muted,
4987
+ children: [
4988
+ " ",
4989
+ b.doneProjects,
4990
+ " done hidden (D)"
4991
+ ]
4992
+ })
4993
+ });
4994
+ for (const m of b.milestones) lines.push({
4995
+ owner: i,
4996
+ node: /* @__PURE__ */ jsxs(Text, {
4997
+ wrap: "truncate-end",
4998
+ children: [
4999
+ " ",
5000
+ /* @__PURE__ */ jsx(Text, {
5001
+ color: m.dueDate < today ? theme.bad : theme.accent,
5002
+ children: "◆"
5003
+ }),
5004
+ " ",
5005
+ m.name,
5006
+ /* @__PURE__ */ jsxs(Text, {
5007
+ color: m.dueDate < today ? theme.bad : theme.muted,
5008
+ children: [
5009
+ " ",
5010
+ "· ",
5011
+ m.dueDate < today ? "late, " : "",
5012
+ shortDay(m.dueDate, today)
5013
+ ]
5014
+ })
5015
+ ]
5016
+ })
5017
+ });
5018
+ for (const r of b.risks) lines.push({
5019
+ owner: i,
5020
+ node: riskLine(r, today)
5021
+ });
5022
+ lines.push({
5023
+ owner: i,
5024
+ node: /* @__PURE__ */ jsx(Text, { children: " " })
5025
+ });
5026
+ });
5027
+ return /* @__PURE__ */ jsxs(Box, {
5028
+ flexDirection: "column",
5029
+ height,
5030
+ children: [/* @__PURE__ */ jsx(Axis, {
5031
+ w,
5032
+ bar
5033
+ }), /* @__PURE__ */ jsx(Lines, {
5034
+ lines,
5035
+ selected: at,
5036
+ rows: Math.max(1, height - 1)
5037
+ })]
5038
+ });
5039
+ }
5040
+ /** A program, project by project: each with its bar, its open tasks by status and its worst risks. */
5041
+ function ProjectBands({ bands, at, active, width, height, w, today }) {
5042
+ const bar = Math.max(10, width - NAME$1 - 14);
5043
+ const lines = [];
5044
+ bands.forEach((b, i) => {
5045
+ const on = active && i === at;
5046
+ const p = b.project;
5047
+ lines.push({
5048
+ owner: i,
5049
+ node: /* @__PURE__ */ jsxs(Text, {
5050
+ wrap: "truncate-end",
5051
+ color: on ? theme.selected : void 0,
5052
+ bold: on,
5053
+ children: [
5054
+ on ? "› " : " ",
5055
+ /* @__PURE__ */ jsx(Text, {
5056
+ color: HEALTH_COLOR[p.health],
5057
+ children: "●"
5058
+ }),
5059
+ " ",
5060
+ fit(p.name, 22),
5061
+ " ",
5062
+ /* @__PURE__ */ jsx(Text, {
5063
+ color: HEALTH_COLOR[p.health],
5064
+ bold: false,
5065
+ children: roadmapCells(p.startDate, p.endDate, [], w, bar)
5066
+ }),
5067
+ " ",
5068
+ /* @__PURE__ */ jsxs(Text, {
5069
+ color: theme.muted,
5070
+ bold: false,
5071
+ children: [String(b.progress).padStart(3), "%"]
5072
+ })
5073
+ ]
5074
+ })
5075
+ });
5076
+ const open = STATUS_ORDER.filter((s) => s !== "done" && b.open[s]).map((s) => `${STATUS_LABEL[s].toLowerCase()} ${b.open[s]}`);
5077
+ lines.push({
5078
+ owner: i,
5079
+ node: /* @__PURE__ */ jsxs(Text, {
5080
+ wrap: "truncate-end",
5081
+ color: theme.muted,
5082
+ children: [
5083
+ " ",
5084
+ HEALTH_LABEL[p.health],
5085
+ " · ",
5086
+ open.length ? open.join(" · ") : "nothing open",
5087
+ b.late ? /* @__PURE__ */ jsxs(Text, {
5088
+ color: theme.bad,
5089
+ children: [
5090
+ " · ",
5091
+ b.late,
5092
+ " late"
5093
+ ]
5094
+ }) : null,
5095
+ " · ends",
5096
+ " ",
5097
+ shortDay(p.endDate, today)
5098
+ ]
5099
+ })
5100
+ });
5101
+ for (const r of b.risks) lines.push({
5102
+ owner: i,
5103
+ node: riskLine(r, today)
5104
+ });
5105
+ lines.push({
5106
+ owner: i,
5107
+ node: /* @__PURE__ */ jsx(Text, { children: " " })
5108
+ });
5109
+ });
5110
+ return /* @__PURE__ */ jsxs(Box, {
5111
+ flexDirection: "column",
5112
+ height,
5113
+ children: [/* @__PURE__ */ jsx(Axis, {
5114
+ w,
5115
+ bar
5116
+ }), /* @__PURE__ */ jsx(Lines, {
5117
+ lines,
5118
+ selected: at,
5119
+ rows: Math.max(1, height - 1)
5120
+ })]
5121
+ });
5122
+ }
5123
+
5124
+ //#endregion
5125
+ //#region src/tui/views/program.tsx
5126
+ /**
5127
+ * One program: its health (and why, when set by hand), next move, its
5128
+ * projects and its milestones. ←→ between the two lists. Projects set to
5129
+ * Done stay out of the list until D shows them. v reads it project by
5130
+ * project instead, as the website's By project layout.
5131
+ */
5132
+ function ProgramView({ programId, active, width, height, go, onBack }) {
5133
+ const { store, today } = useDeps();
5134
+ const program = useHarbor((s) => s.programs.find((p) => p.id === programId));
5135
+ const projects = useHarbor((s) => s.projects);
5136
+ const tasks = useHarbor((s) => s.tasks);
5137
+ const milestones = useHarbor((s) => s.milestones);
5138
+ const people = useHarbor((s) => s.people);
5139
+ const raidItems = useHarbor((s) => s.raidItems);
5140
+ const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === program?.portfolioId));
5141
+ const [showDone, setShowDone] = useState(false);
5142
+ const split = useMemo(() => splitDone(projects.filter((p) => p.programId === programId)), [projects, programId]);
5143
+ const mine = showDone ? [...split.open, ...split.done] : split.open;
5144
+ const marks = useMemo(() => milestonesOf({ milestones }, programId, today()), [
5145
+ milestones,
5146
+ programId,
5147
+ today
5148
+ ]);
5149
+ const [side, setSide] = useState("projects");
5150
+ const [layout, setLayout] = useState("board");
5151
+ const [pAt, setPAt] = useState(0);
5152
+ const [mAt, setMAt] = useState(0);
5153
+ const project = mine[Math.min(pAt, mine.length - 1)];
5154
+ const mark = marks[Math.min(mAt, marks.length - 1)]?.milestone;
5155
+ useInput((input, key) => {
5156
+ if (key.escape || key.backspace) return onBack();
5157
+ if (input === "v") {
5158
+ setSide("projects");
5159
+ return setLayout((l) => l === "board" ? "bands" : "board");
5160
+ }
5161
+ if (layout === "board" && (key.leftArrow || input === "h")) return setSide("projects");
5162
+ if (layout === "board" && (key.rightArrow || input === "l")) return setSide("milestones");
5163
+ if (input === "e") return go.openPanel("program", programId);
5164
+ if (input === "n") return go.create("project", { programId });
5165
+ if (input === "m") return go.create("milestone", { programId });
5166
+ if (input === "R") return go.create("raid", {});
5167
+ if (input === "D" && split.done.length > 0) {
5168
+ setSide("projects");
5169
+ return setShowDone((v) => !v);
5170
+ }
5171
+ if (side === "projects") {
5172
+ if (key.upArrow || input === "k") setPAt((i) => step(i, -1, mine.length));
5173
+ else if (key.downArrow || input === "j") setPAt((i) => step(i, 1, mine.length));
5174
+ else if (key.return && project) go.openProject(project.id);
5175
+ } else if (key.upArrow || input === "k") setMAt((i) => step(i, -1, marks.length));
5176
+ else if (key.downArrow || input === "j") setMAt((i) => step(i, 1, marks.length));
5177
+ else if (key.return && mark) go.openPanel("milestone", mark.id);
5178
+ else if (input === " " && mark) store.getState().toggleMilestone(mark.id);
5179
+ else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
5180
+ }, { isActive: active });
5181
+ if (!program) return /* @__PURE__ */ jsx(Text, {
5182
+ color: theme.muted,
5183
+ children: " That program isn’t in this harbor any more. Esc goes back."
5184
+ });
5185
+ const lead = people.find((p) => p.id === program.ownerId);
5186
+ const half = Math.floor((width - 3) / 2);
5187
+ const rows = height - 6;
5188
+ const data = {
5189
+ tasks,
5190
+ projects
5191
+ };
5192
+ const bands = layout === "bands" ? projectBands({
5193
+ tasks,
5194
+ projects,
5195
+ raidItems
5196
+ }, programId, today(), showDone) : [];
5197
+ return /* @__PURE__ */ jsxs(Box, {
5198
+ flexDirection: "column",
5199
+ width,
5200
+ height,
5201
+ paddingX: 1,
5202
+ children: [
5203
+ /* @__PURE__ */ jsxs(Box, {
5204
+ justifyContent: "space-between",
5205
+ children: [/* @__PURE__ */ jsxs(Text, {
5206
+ wrap: "truncate-end",
5207
+ children: [
5208
+ /* @__PURE__ */ jsx(Text, {
5209
+ bold: true,
5210
+ children: program.name
5211
+ }),
5212
+ " ",
5213
+ /* @__PURE__ */ jsx(HealthMark, {
5214
+ health: program.health,
5215
+ label: true
5216
+ }),
5217
+ program.healthOverride ? /* @__PURE__ */ jsxs(Text, {
5218
+ color: theme.muted,
5219
+ children: [
5220
+ " (by hand: ",
5221
+ program.healthNote || "no note",
5222
+ ")"
5223
+ ]
5224
+ }) : null
5225
+ ]
5226
+ }), /* @__PURE__ */ jsxs(Text, {
5227
+ color: theme.muted,
5228
+ children: [
5229
+ portfolio?.name,
5230
+ lead ? ` · ${lead.name}` : "",
5231
+ " · ",
5232
+ shortDay(program.startDate, today()),
5233
+ "–",
5234
+ shortDay(program.endDate, today())
5235
+ ]
5236
+ })]
5237
+ }),
5238
+ /* @__PURE__ */ jsxs(Text, {
5239
+ wrap: "truncate-end",
5240
+ color: program.nextMove ? void 0 : theme.muted,
5241
+ children: ["Next move: ", program.nextMove || "none written yet (e to write one)"]
5242
+ }),
5243
+ /* @__PURE__ */ jsx(Text, { children: " " }),
5244
+ layout === "bands" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Heading, {
5245
+ right: `${mine.length}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""} · v board`,
5246
+ children: "By project"
5247
+ }), mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
5248
+ color: theme.muted,
5249
+ children: split.done.length > 0 ? "Every project here is done. D shows them." : "None yet. n adds one."
5250
+ }) : /* @__PURE__ */ jsx(ProjectBands, {
5251
+ bands,
5252
+ at: Math.min(pAt, bands.length - 1),
5253
+ active,
5254
+ width: width - 2,
5255
+ height: height - 4,
5256
+ w: roadmapWindow("half", today()),
5257
+ today: today()
5258
+ })] }) : /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
5259
+ flexDirection: "column",
5260
+ width: half,
5261
+ marginRight: 1,
4838
5262
  children: [
4839
5263
  /* @__PURE__ */ jsx(Heading, {
4840
5264
  right: split.done.length > 0 ? `${mine.length} · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : `${mine.length}`,
@@ -4916,20 +5340,54 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4916
5340
  ]
4917
5341
  });
4918
5342
  }
4919
- /** One portfolio: its health and its programs, each with theirs; Done ones wait for D. */
5343
+ /**
5344
+ * One portfolio, as the website's board: its programs, with its worst
5345
+ * risks, next milestones and money beside them when there's room. v reads
5346
+ * it program by program instead (By program). Done programs wait for D.
5347
+ */
4920
5348
  function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
5349
+ const { today } = useDeps();
4921
5350
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === portfolioId));
4922
5351
  const programs = useHarbor((s) => s.programs);
4923
5352
  const projects = useHarbor((s) => s.projects);
5353
+ const tasks = useHarbor((s) => s.tasks);
5354
+ const milestones = useHarbor((s) => s.milestones);
5355
+ const raidItems = useHarbor((s) => s.raidItems);
5356
+ const constraints = useHarbor((s) => s.constraints);
4924
5357
  const [showDone, setShowDone] = useState(false);
5358
+ const [layout, setLayout] = useState("board");
4925
5359
  const split = useMemo(() => splitDone(programs.filter((p) => p.portfolioId === portfolioId)), [programs, portfolioId]);
4926
5360
  const list = showDone ? [...split.open, ...split.done] : split.open;
4927
5361
  const [at, setAt] = useState(0);
4928
5362
  const program = list[Math.min(at, list.length - 1)];
5363
+ const day = today();
5364
+ const data = useMemo(() => ({
5365
+ programs,
5366
+ projects,
5367
+ tasks,
5368
+ milestones,
5369
+ raidItems,
5370
+ constraints
5371
+ }), [
5372
+ programs,
5373
+ projects,
5374
+ tasks,
5375
+ milestones,
5376
+ raidItems,
5377
+ constraints
5378
+ ]);
5379
+ const bands = useMemo(() => layout === "bands" ? programBands(data, portfolioId, day, showDone) : [], [
5380
+ layout,
5381
+ data,
5382
+ portfolioId,
5383
+ day,
5384
+ showDone
5385
+ ]);
4929
5386
  useInput((input, key) => {
4930
5387
  if (key.escape || key.backspace) return onBack();
4931
5388
  if (input === "e") return go.openPanel("portfolio", portfolioId);
4932
5389
  if (input === "n") return go.create("program", { portfolioId });
5390
+ if (input === "v") return setLayout((l) => l === "board" ? "bands" : "board");
4933
5391
  if (input === "D" && split.done.length > 0) return setShowDone((v) => !v);
4934
5392
  if (key.upArrow || input === "k") setAt((i) => step(i, -1, list.length));
4935
5393
  else if (key.downArrow || input === "j") setAt((i) => step(i, 1, list.length));
@@ -4939,6 +5397,15 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
4939
5397
  color: theme.muted,
4940
5398
  children: " That portfolio isn’t in this harbor any more. Esc goes back."
4941
5399
  });
5400
+ const side = layout === "board" && width >= 96 ? Math.floor((width - 3) * .4) : 0;
5401
+ const main = width - 2 - (side ? side + 1 : 0);
5402
+ const body = height - 4;
5403
+ const risks = sortRaid(portfolioRaidItems(data, portfolioId).filter(isActive));
5404
+ const ids = new Set(list.map((p) => p.id));
5405
+ const next = milestones.filter((m) => ids.has(m.programId) && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
5406
+ const budget = list.reduce((n, p) => n + programBudgetAllocated(data, p.id), 0);
5407
+ const spent = list.reduce((n, p) => n + programSpent(data, p.id), 0);
5408
+ const listRows = Math.max(1, body - 1);
4942
5409
  return /* @__PURE__ */ jsxs(Box, {
4943
5410
  flexDirection: "column",
4944
5411
  width,
@@ -4967,50 +5434,136 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
4967
5434
  }) : null
4968
5435
  ]
4969
5436
  }),
4970
- /* @__PURE__ */ jsx(Text, {
5437
+ /* @__PURE__ */ jsxs(Text, {
4971
5438
  color: theme.muted,
4972
5439
  wrap: "truncate-end",
4973
- children: portfolio.theme || "Portfolio"
5440
+ children: [
5441
+ portfolio.theme || "Portfolio",
5442
+ " · ",
5443
+ layout === "board" ? "board" : "by program",
5444
+ " · v switches"
5445
+ ]
4974
5446
  }),
4975
5447
  /* @__PURE__ */ jsx(Text, { children: " " }),
4976
- /* @__PURE__ */ jsx(Heading, {
4977
- right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
4978
- children: "Programs"
4979
- }),
4980
- list.length === 0 ? /* @__PURE__ */ jsx(Text, {
4981
- color: theme.muted,
4982
- children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
4983
- }) : null,
4984
- windowOf(list, at, height - 5).items.map((p) => {
4985
- const on = active && p.id === program?.id;
4986
- const count = projects.filter((x) => x.programId === p.id).length;
4987
- return /* @__PURE__ */ jsxs(Text, {
4988
- color: on ? theme.selected : void 0,
4989
- bold: on,
4990
- wrap: "truncate-end",
4991
- children: [
4992
- on ? "› " : " ",
4993
- /* @__PURE__ */ jsx(Text, {
4994
- color: HEALTH_COLOR[p.health],
4995
- children: "●"
4996
- }),
4997
- " ",
4998
- p.name,
4999
- " ",
5000
- /* @__PURE__ */ jsxs(Text, {
5001
- color: theme.muted,
5002
- children: [
5003
- HEALTH_LABEL[p.health],
5004
- " · ",
5005
- count,
5006
- " project",
5007
- count === 1 ? "" : "s",
5008
- p.nextMove ? ` · next: ${p.nextMove}` : ""
5009
- ]
5010
- })
5011
- ]
5012
- }, p.id);
5013
- })
5448
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
5449
+ flexDirection: "column",
5450
+ width: main,
5451
+ children: [/* @__PURE__ */ jsx(Heading, {
5452
+ right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
5453
+ children: layout === "board" ? "Programs" : "By program"
5454
+ }), list.length === 0 ? /* @__PURE__ */ jsx(Text, {
5455
+ color: theme.muted,
5456
+ children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
5457
+ }) : layout === "bands" ? /* @__PURE__ */ jsx(ProgramBands, {
5458
+ bands,
5459
+ at: Math.min(at, bands.length - 1),
5460
+ active,
5461
+ width: main,
5462
+ height: listRows,
5463
+ w: roadmapWindow("half", day),
5464
+ today: day
5465
+ }) : windowOf(list, at, listRows).items.map((p) => {
5466
+ const on = active && p.id === program?.id;
5467
+ const count = projects.filter((x) => x.programId === p.id).length;
5468
+ return /* @__PURE__ */ jsxs(Text, {
5469
+ color: on ? theme.selected : void 0,
5470
+ bold: on,
5471
+ wrap: "truncate-end",
5472
+ children: [
5473
+ on ? "› " : " ",
5474
+ /* @__PURE__ */ jsx(Text, {
5475
+ color: HEALTH_COLOR[p.health],
5476
+ children: "●"
5477
+ }),
5478
+ " ",
5479
+ p.name,
5480
+ " ",
5481
+ /* @__PURE__ */ jsxs(Text, {
5482
+ color: theme.muted,
5483
+ children: [
5484
+ HEALTH_LABEL[p.health],
5485
+ " · ",
5486
+ count,
5487
+ " project",
5488
+ count === 1 ? "" : "s",
5489
+ p.nextMove ? ` · next: ${p.nextMove}` : ""
5490
+ ]
5491
+ })
5492
+ ]
5493
+ }, p.id);
5494
+ })]
5495
+ }), side ? /* @__PURE__ */ jsxs(Box, {
5496
+ flexDirection: "column",
5497
+ width: side,
5498
+ marginLeft: 1,
5499
+ children: [
5500
+ /* @__PURE__ */ jsx(Heading, {
5501
+ right: `${risks.length} open`,
5502
+ children: "Risks & issues"
5503
+ }),
5504
+ risks.length === 0 ? /* @__PURE__ */ jsx(Text, {
5505
+ color: theme.muted,
5506
+ children: "Nothing open."
5507
+ }) : null,
5508
+ risks.slice(0, 5).map((r) => /* @__PURE__ */ jsxs(Text, {
5509
+ wrap: "truncate-end",
5510
+ children: [
5511
+ /* @__PURE__ */ jsx(Text, {
5512
+ color: r.kind === "issue" ? theme.bad : r.kind === "risk" ? theme.warn : theme.muted,
5513
+ children: r.kind
5514
+ }),
5515
+ " ",
5516
+ r.title
5517
+ ]
5518
+ }, r.id)),
5519
+ /* @__PURE__ */ jsx(Text, { children: " " }),
5520
+ /* @__PURE__ */ jsx(Heading, { children: "Next milestones" }),
5521
+ next.length === 0 ? /* @__PURE__ */ jsx(Text, {
5522
+ color: theme.muted,
5523
+ children: "None to come."
5524
+ }) : null,
5525
+ next.slice(0, 4).map((m) => /* @__PURE__ */ jsxs(Text, {
5526
+ wrap: "truncate-end",
5527
+ children: [
5528
+ /* @__PURE__ */ jsx(Text, {
5529
+ color: m.dueDate < day ? theme.bad : theme.accent,
5530
+ children: "◆"
5531
+ }),
5532
+ " ",
5533
+ m.name,
5534
+ " ",
5535
+ /* @__PURE__ */ jsx(Text, {
5536
+ color: m.dueDate < day ? theme.bad : theme.muted,
5537
+ children: shortDay(m.dueDate, day)
5538
+ })
5539
+ ]
5540
+ }, m.id)),
5541
+ /* @__PURE__ */ jsx(Text, { children: " " }),
5542
+ /* @__PURE__ */ jsx(Heading, { children: "Money" }),
5543
+ /* @__PURE__ */ jsxs(Text, {
5544
+ wrap: "truncate-end",
5545
+ children: [
5546
+ money(spent),
5547
+ " ",
5548
+ /* @__PURE__ */ jsx(Text, {
5549
+ color: theme.muted,
5550
+ children: "spent of"
5551
+ }),
5552
+ " ",
5553
+ money(budget),
5554
+ budget > 0 ? /* @__PURE__ */ jsxs(Text, {
5555
+ color: spent > budget ? theme.bad : theme.muted,
5556
+ children: [
5557
+ " ",
5558
+ "(",
5559
+ Math.round(spent / budget * 100),
5560
+ "%)"
5561
+ ]
5562
+ }) : null
5563
+ ]
5564
+ })
5565
+ ]
5566
+ }) : null] })
5014
5567
  ]
5015
5568
  });
5016
5569
  }
@@ -5036,8 +5589,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5036
5589
  people,
5037
5590
  boardConfigs
5038
5591
  ]);
5039
- const columns = useMemo(() => boardOf(data, projectId), [data, projectId]);
5040
- const list = useMemo(() => projectTasks(data, projectId), [data, projectId]);
5592
+ const [filter, setFilter] = useState(NO_FILTER);
5593
+ const columns = useMemo(() => boardOf(data, projectId).map((c) => ({
5594
+ ...c,
5595
+ all: c.tasks.length,
5596
+ tasks: filterTasks(c.tasks, filter)
5597
+ })), [
5598
+ data,
5599
+ projectId,
5600
+ filter
5601
+ ]);
5602
+ const list = useMemo(() => filterTasks(projectTasks(data, projectId), filter), [
5603
+ data,
5604
+ projectId,
5605
+ filter
5606
+ ]);
5041
5607
  const [mode, setMode] = useState("board");
5042
5608
  const [spendAt, setSpendAt] = useState(0);
5043
5609
  const spendEntries = useHarbor((s) => s.spendEntries) ?? [];
@@ -5069,6 +5635,38 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5069
5635
  if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
5070
5636
  if (input === "e") return go.openPanel("project", projectId);
5071
5637
  if (input === "R") return go.create("raid", { projectId });
5638
+ if (input === "f" && mode !== "budget") return go.pick("Show cards assigned to", [
5639
+ {
5640
+ label: "Anyone",
5641
+ value: "any"
5642
+ },
5643
+ {
5644
+ label: "Me",
5645
+ value: store.getState().currentUserId
5646
+ },
5647
+ {
5648
+ label: "Nobody yet",
5649
+ value: "none"
5650
+ },
5651
+ ...people.map((p) => ({
5652
+ label: p.name,
5653
+ value: p.id,
5654
+ hint: p.role
5655
+ }))
5656
+ ], (who) => setFilter((f) => ({
5657
+ ...f,
5658
+ who
5659
+ })));
5660
+ if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
5661
+ label: "Any priority",
5662
+ value: "any"
5663
+ }, ...PRIORITIES.map((p) => ({
5664
+ label: PRIORITY_LABEL[p],
5665
+ value: p
5666
+ }))], (priority) => setFilter((f) => ({
5667
+ ...f,
5668
+ priority
5669
+ })));
5072
5670
  if (mode === "budget") {
5073
5671
  const entries = project ? budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date()).entries : [];
5074
5672
  const entry = entries[Math.min(spendAt, entries.length - 1)];
@@ -5108,6 +5706,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5108
5706
  })
5109
5707
  });
5110
5708
  const { done, total } = projectSummary(data, project);
5709
+ const filtered = [filter.who === "none" ? "nobody's" : filter.who !== "any" ? `${people.find((p) => p.id === filter.who)?.name ?? "someone"}'s` : null, filter.priority !== "any" ? PRIORITY_LABEL[filter.priority].toLowerCase() : null].filter(Boolean);
5111
5710
  const lead = people.find((p) => p.id === project.ownerId);
5112
5711
  const body = height - 3;
5113
5712
  const fit = Math.max(1, Math.floor((width - 2) / 14));
@@ -5156,7 +5755,16 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5156
5755
  lead ? ` · ${lead.name}` : "",
5157
5756
  " · ",
5158
5757
  mode,
5159
- " · v next · e details"
5758
+ filtered.length && mode !== "budget" ? /* @__PURE__ */ jsxs(Text, {
5759
+ color: theme.warn,
5760
+ children: [
5761
+ " · only ",
5762
+ filtered.join(", "),
5763
+ " cards"
5764
+ ]
5765
+ }) : null,
5766
+ " ",
5767
+ "· v next · f p filter · e details"
5160
5768
  ]
5161
5769
  })
5162
5770
  })]
@@ -5180,7 +5788,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5180
5788
  const on = active && ci === col;
5181
5789
  const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
5182
5790
  const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
5183
- const over = c.limit != null && c.tasks.length > c.limit;
5791
+ const over = c.limit != null && c.all > c.limit;
5184
5792
  return /* @__PURE__ */ jsxs(Box, {
5185
5793
  flexDirection: "column",
5186
5794
  width: w,
@@ -5205,7 +5813,11 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5205
5813
  " ",
5206
5814
  /* @__PURE__ */ jsxs(Text, {
5207
5815
  color: over ? theme.bad : theme.muted,
5208
- children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5816
+ children: [
5817
+ c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
5818
+ c.all,
5819
+ c.limit != null ? `/${c.limit}` : ""
5820
+ ]
5209
5821
  })
5210
5822
  ]
5211
5823
  })
@@ -5497,6 +6109,78 @@ function RoadmapView({ active, width, height, go }) {
5497
6109
  });
5498
6110
  }
5499
6111
 
6112
+ //#endregion
6113
+ //#region src/lib/deps.ts
6114
+ /**
6115
+ * Everything that waits on `id`, directly or through others: the items that
6116
+ * would close a loop if `id` waited on them. One walk backwards from `id`,
6117
+ * instead of a walk forwards from every candidate.
6118
+ */
6119
+ function waitingOnChain(items, id) {
6120
+ const waiters = /* @__PURE__ */ new Map();
6121
+ for (const n of items) for (const b of blockerIds(n)) {
6122
+ const list = waiters.get(b);
6123
+ if (list) list.push(n.id);
6124
+ else waiters.set(b, [n.id]);
6125
+ }
6126
+ const out = /* @__PURE__ */ new Set();
6127
+ const stack = [...waiters.get(id) ?? []];
6128
+ while (stack.length) {
6129
+ const cursor = stack.pop();
6130
+ if (out.has(cursor)) continue;
6131
+ out.add(cursor);
6132
+ stack.push(...waiters.get(cursor) ?? []);
6133
+ }
6134
+ return out;
6135
+ }
6136
+ /** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
6137
+ function blockerOptions(items, id) {
6138
+ const loops = waitingOnChain(items, id);
6139
+ return items.filter((n) => n.id !== id && !loops.has(n.id));
6140
+ }
6141
+
6142
+ //#endregion
6143
+ //#region src/lib/recurrence.ts
6144
+ const REPEAT_LABEL = {
6145
+ weekly: "Every week",
6146
+ biweekly: "Every two weeks",
6147
+ monthly: "Every month"
6148
+ };
6149
+ const iso$1 = (d) => {
6150
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
6151
+ };
6152
+ /** The due date one interval after `from`. */
6153
+ function nextDueDate(from, repeat) {
6154
+ const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
6155
+ if (repeat === "weekly") return iso$1(addDays(d, 7));
6156
+ if (repeat === "biweekly") return iso$1(addDays(d, 14));
6157
+ return iso$1(addMonths(d, 1));
6158
+ }
6159
+ /**
6160
+ * The next occurrence of a repeating task once this one is done: same
6161
+ * title, owner, priority and checklist (unticked), due one interval after
6162
+ * the old due date (or after today when it had none), back in To do.
6163
+ * Null when the task does not repeat.
6164
+ */
6165
+ function nextOccurrence(task, id, today, createdAt) {
6166
+ if (!task.repeat) return null;
6167
+ const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
6168
+ return {
6169
+ ...task,
6170
+ id,
6171
+ status: "todo",
6172
+ dueDate: nextDueDate(anchor, task.repeat),
6173
+ createdAt,
6174
+ completedAt: null,
6175
+ blockedById: null,
6176
+ blockedByIds: [],
6177
+ checklist: (task.checklist ?? []).map((c) => ({
6178
+ ...c,
6179
+ done: false
6180
+ }))
6181
+ };
6182
+ }
6183
+
5500
6184
  //#endregion
5501
6185
  //#region src/tui/views/task.tsx
5502
6186
  const FIELDS = [
@@ -5524,19 +6208,34 @@ const FIELDS = [
5524
6208
  field: "project",
5525
6209
  label: "Project"
5526
6210
  },
6211
+ {
6212
+ field: "waits",
6213
+ label: "Waits on"
6214
+ },
6215
+ {
6216
+ field: "repeat",
6217
+ label: "Repeats"
6218
+ },
5527
6219
  {
5528
6220
  field: "notes",
5529
6221
  label: "Notes"
5530
6222
  }
5531
6223
  ];
6224
+ const REPEATS = [
6225
+ null,
6226
+ "weekly",
6227
+ "biweekly",
6228
+ "monthly"
6229
+ ];
5532
6230
  /** Everything about one task, every field editable: ↑↓ to a field, Enter to change it. */
5533
- function TaskPanel({ taskId, width, height, go, onClose }) {
6231
+ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
5534
6232
  const { store, today } = useDeps();
5535
6233
  const tasks = useHarbor((s) => s.tasks);
5536
6234
  const projects = useHarbor((s) => s.projects);
5537
6235
  const people = useHarbor((s) => s.people);
5538
6236
  const comments = useHarbor((s) => s.comments);
5539
6237
  const viewOnly = useHarbor((s) => s.myRole === "viewer" || s.keepPaused);
6238
+ const me = useHarbor((s) => s.currentUserId);
5540
6239
  const task = tasks.find((t) => t.id === taskId);
5541
6240
  const [at, setAt] = useState(0);
5542
6241
  const [editing, setEditing] = useState(null);
@@ -5549,6 +6248,10 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5549
6248
  if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows));
5550
6249
  if (input === "c") return setEditing({ kind: "comment" });
5551
6250
  if (input === "a") return setEditing({ kind: "step" });
6251
+ if (input === "C") {
6252
+ if (comments.some((c) => c.taskId === task.id && c.authorId === me)) return setEditing({ kind: "mine" });
6253
+ return go.notify("You haven't commented on this task.");
6254
+ }
5552
6255
  if (input === "x") return moveTask(store, go, task, "done");
5553
6256
  if (input === "d") return go.confirm(`Delete ${keyOf({ projects }, task)} “${task.title}”? u undoes it.`, () => {
5554
6257
  store.getState().deleteTask(task.id);
@@ -5561,12 +6264,14 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5561
6264
  kind: "field",
5562
6265
  field: FIELDS[at].field
5563
6266
  });
5564
- }, { isActive: editing === null });
6267
+ }, { isActive: active && editing === null });
5565
6268
  if (!task) return null;
5566
6269
  const project = projects.find((p) => p.id === task.projectId);
5567
6270
  const owner = people.find((p) => p.id === task.assigneeId);
5568
6271
  const waits = openBlockerNames(task, tasks);
5569
6272
  const thread = comments.filter((c) => c.taskId === task.id).slice(-4);
6273
+ const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
6274
+ const blockers = blockersOf(task, tasks);
5570
6275
  const done = () => setEditing(null);
5571
6276
  const value = {
5572
6277
  title: task.title,
@@ -5575,6 +6280,8 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5575
6280
  due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
5576
6281
  priority: PRIORITY_LABEL[task.priority],
5577
6282
  project: project ? `${project.name}${project.key ? ` (${project.key})` : ""}` : "",
6283
+ waits: blockers.length ? blockers.map((b) => `${keyOf({ projects }, b)}${b.status === "done" ? " (done)" : ""}`).join(", ") : "Nothing",
6284
+ repeat: task.repeat ? REPEAT_LABEL[task.repeat] : "Never",
5578
6285
  notes: task.description.replace(/\s+/g, " ").trim() || "None"
5579
6286
  };
5580
6287
  let editor = null;
@@ -5595,7 +6302,44 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5595
6302
  done();
5596
6303
  }
5597
6304
  });
5598
- else if (editing?.kind === "field") {
6305
+ else if (editing?.kind === "mine") editor = /* @__PURE__ */ jsx(Picker, {
6306
+ title: "One of your comments: Enter to change it or take it out",
6307
+ choices: mine.map((c) => ({
6308
+ label: c.body.replace(/\s+/g, " "),
6309
+ value: c.id
6310
+ })),
6311
+ initial: mine.length - 1,
6312
+ onCancel: done,
6313
+ onPick: (id) => {
6314
+ const c = mine.find((x) => x.id === id);
6315
+ setEditing(null);
6316
+ go.pick("This comment", [{
6317
+ label: "Change the words",
6318
+ value: "edit"
6319
+ }, {
6320
+ label: "Take it out",
6321
+ value: "delete"
6322
+ }], (what) => {
6323
+ if (what === "edit") setEditing({
6324
+ kind: "reword",
6325
+ id: c.id
6326
+ });
6327
+ else go.confirm("Take out your comment? u undoes it.", () => store.getState().deleteComment(c.id), "Take it out");
6328
+ });
6329
+ }
6330
+ });
6331
+ else if (editing?.kind === "reword") {
6332
+ const c = comments.find((x) => x.id === editing.id);
6333
+ editor = /* @__PURE__ */ jsx(TextField, {
6334
+ label: "Your comment",
6335
+ initial: c?.body ?? "",
6336
+ onCancel: done,
6337
+ onSubmit: (text) => {
6338
+ if (c && text.trim() && text.trim() !== c.body) store.getState().updateComment(c.id, text.trim());
6339
+ done();
6340
+ }
6341
+ });
6342
+ } else if (editing?.kind === "field") {
5599
6343
  const f = editing.field;
5600
6344
  if (f === "title" || f === "notes") editor = /* @__PURE__ */ jsx(TextField, {
5601
6345
  label: f === "title" ? "Title" : "Notes",
@@ -5663,7 +6407,24 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5663
6407
  done();
5664
6408
  }
5665
6409
  });
5666
- } else if (f === "project") {
6410
+ } else if (f === "repeat") editor = /* @__PURE__ */ jsx(Picker, {
6411
+ title: "Repeats (finishing it queues the next one)",
6412
+ choices: REPEATS.map((r) => ({
6413
+ label: r ? REPEAT_LABEL[r] : "Never",
6414
+ value: r
6415
+ })),
6416
+ initial: REPEATS.indexOf(task.repeat ?? null),
6417
+ onCancel: done,
6418
+ onPick: (r) => {
6419
+ store.getState().updateTask(task.id, { repeat: r });
6420
+ done();
6421
+ }
6422
+ });
6423
+ else if (f === "waits") editor = /* @__PURE__ */ jsx(WaitsEditor, {
6424
+ task,
6425
+ onDone: done
6426
+ });
6427
+ else if (f === "project") {
5667
6428
  const choices = projects.map((p) => ({
5668
6429
  label: p.name,
5669
6430
  value: p.id,
@@ -5796,7 +6557,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5796
6557
  editor ?? /* @__PURE__ */ jsx(Text, {
5797
6558
  color: theme.muted,
5798
6559
  wrap: "truncate-end",
5799
- children: "↑↓ field · Enter change · c comment · a add step · space tick · x done · d delete · Esc close"
6560
+ children: "↑↓ field · Enter change · c comment · C yours · a add step · space tick · x done · d delete · Esc close"
5800
6561
  })
5801
6562
  ]
5802
6563
  });
@@ -5819,16 +6580,140 @@ function DueField({ initial, today, onSubmit, onCancel }) {
5819
6580
  })
5820
6581
  });
5821
6582
  }
6583
+ /**
6584
+ * What a task waits on. Empty, it lists the current ones (Enter stops
6585
+ * waiting on one); typing finds others by key or words (Enter adds it).
6586
+ * Tasks already waiting on this one aren't offered, so nothing loops.
6587
+ */
6588
+ function WaitsEditor({ task, onDone }) {
6589
+ const { store } = useDeps();
6590
+ const tasks = useHarbor((s) => s.tasks);
6591
+ const projects = useHarbor((s) => s.projects);
6592
+ const [query, setQuery] = useState("");
6593
+ const [at, setAt] = useState(0);
6594
+ const current = blockersOf(task, tasks);
6595
+ const hits = useMemo(() => {
6596
+ if (!query.trim()) return [];
6597
+ const taken = new Set(current.map((b) => b.id));
6598
+ const open = blockerOptions(tasks, task.id).filter((t) => !taken.has(t.id));
6599
+ return searchTasks(open, projects, query.trim(), 8).map((h) => h.item);
6600
+ }, [
6601
+ query,
6602
+ tasks,
6603
+ projects,
6604
+ task.id,
6605
+ current
6606
+ ]);
6607
+ const list = query.trim() ? hits : current;
6608
+ const set = (ids) => store.getState().updateTask(task.id, blockersPatch(ids));
6609
+ useInput((input, key) => {
6610
+ if (key.escape) return onDone();
6611
+ if (key.upArrow) return setAt((i) => step(i, -1, list.length));
6612
+ if (key.downArrow) return setAt((i) => step(i, 1, list.length));
6613
+ if (key.return) {
6614
+ const pick = list[Math.min(at, list.length - 1)];
6615
+ if (!pick) return onDone();
6616
+ if (query.trim()) {
6617
+ set([...current.map((b) => b.id), pick.id]);
6618
+ setQuery("");
6619
+ } else set(current.filter((b) => b.id !== pick.id).map((b) => b.id));
6620
+ return setAt(0);
6621
+ }
6622
+ if (key.backspace || key.delete) {
6623
+ setAt(0);
6624
+ return setQuery((q) => q.slice(0, -1));
6625
+ }
6626
+ if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
6627
+ if (input) {
6628
+ setAt(0);
6629
+ setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
6630
+ }
6631
+ });
6632
+ return /* @__PURE__ */ jsxs(Box, {
6633
+ flexDirection: "column",
6634
+ borderStyle: "round",
6635
+ borderColor: theme.border,
6636
+ paddingX: 1,
6637
+ children: [
6638
+ /* @__PURE__ */ jsx(Text, {
6639
+ bold: true,
6640
+ children: "Waits on"
6641
+ }),
6642
+ /* @__PURE__ */ jsxs(Text, { children: [query || /* @__PURE__ */ jsx(Text, {
6643
+ color: theme.muted,
6644
+ children: "type a key like RR-42, or words, to add one"
6645
+ }), /* @__PURE__ */ jsx(Text, {
6646
+ inverse: true,
6647
+ children: " "
6648
+ })] }),
6649
+ list.length === 0 ? /* @__PURE__ */ jsx(Text, {
6650
+ color: theme.muted,
6651
+ children: query.trim() ? "Nothing matches." : "Nothing yet."
6652
+ }) : null,
6653
+ list.map((t, i) => {
6654
+ const on = i === Math.min(at, list.length - 1);
6655
+ return /* @__PURE__ */ jsxs(Text, {
6656
+ color: on ? theme.selected : void 0,
6657
+ bold: on,
6658
+ wrap: "truncate-end",
6659
+ children: [
6660
+ on ? "› " : " ",
6661
+ /* @__PURE__ */ jsx(Text, {
6662
+ color: theme.muted,
6663
+ children: keyOf({ projects }, t).padEnd(7)
6664
+ }),
6665
+ t.title,
6666
+ /* @__PURE__ */ jsxs(Text, {
6667
+ color: theme.muted,
6668
+ children: [" · ", STATUS_LABEL[t.status]]
6669
+ })
6670
+ ]
6671
+ }, t.id);
6672
+ }),
6673
+ /* @__PURE__ */ jsxs(Text, {
6674
+ color: theme.muted,
6675
+ children: [query.trim() ? "Enter adds it" : "Enter stops waiting on it", " · Esc done"]
6676
+ })
6677
+ ]
6678
+ });
6679
+ }
6680
+
6681
+ //#endregion
6682
+ //#region src/lib/focus.ts
6683
+ const FOCUS_CAP = 3;
6684
+ const CHECK_IN_QUESTION = "On the blocker, or on something else?";
6685
+ function focusIdsOf(data, personId, date = todayISO()) {
6686
+ return (data.ropes ?? []).find((r) => r.personId === personId && r.date === date)?.taskIds ?? [];
6687
+ }
6688
+ function upsertFocus(ropes, personId, date, taskIds) {
6689
+ const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
6690
+ if (taskIds.length === 0) return rest;
6691
+ return [...rest, {
6692
+ personId,
6693
+ date,
6694
+ taskIds: taskIds.slice(0, 3)
6695
+ }];
6696
+ }
6697
+ function myCheckIn(data, personId, date = todayISO()) {
6698
+ return (data.checkIns ?? []).find((c) => c.personId === personId && c.date === date);
6699
+ }
5822
6700
 
5823
6701
  //#endregion
5824
6702
  //#region src/tui/views/today.tsx
5825
- /** What's on you: your open tasks, late first; who waits on you; your risks and issues. */
6703
+ /**
6704
+ * What's on you: today's focus and check-in, your open tasks, late first;
6705
+ * who waits on you; your risks and issues.
6706
+ */
5826
6707
  function TodayView({ active, width, height, go }) {
5827
6708
  const { store, today } = useDeps();
5828
6709
  const tasks = useHarbor((s) => s.tasks);
5829
6710
  const projects = useHarbor((s) => s.projects);
5830
6711
  const raidItems = useHarbor((s) => s.raidItems);
5831
6712
  const me = useHarbor((s) => s.currentUserId);
6713
+ const ropes = useHarbor((s) => s.ropes);
6714
+ const checkIns = useHarbor((s) => s.checkIns);
6715
+ const focus = focusIdsOf({ ropes }, me).map((id) => tasks.find((t) => t.id === id)).filter((t) => t !== void 0 && t.status !== "done");
6716
+ const said = myCheckIn({ checkIns }, me);
5832
6717
  const day = today();
5833
6718
  const data = useMemo(() => ({
5834
6719
  tasks,
@@ -5857,8 +6742,24 @@ function TodayView({ active, width, height, go }) {
5857
6742
  else if (key.return && selected) go.openTask(selected.id);
5858
6743
  else if (input === "x" && selected) moveTask(store, go, selected, "done");
5859
6744
  else if (input === "n") go.newTask(selected?.projectId ?? null);
6745
+ else if (input === "f" && selected) {
6746
+ if (store.getState().toggleFocus(selected.id) === "full") go.notify(`Today's focus holds ${3}. f on one of them takes it off first.`);
6747
+ } else if (input === "c") go.ask("Check in: what are you on today? (one sentence)", (text) => {
6748
+ const note = text.trim().slice(0, 280);
6749
+ if (!note) return;
6750
+ go.pick(CHECK_IN_QUESTION, [{
6751
+ label: "On the blocker",
6752
+ value: true
6753
+ }, {
6754
+ label: "Other work",
6755
+ value: false
6756
+ }], (onDrum) => {
6757
+ store.getState().submitCheckIn(note, onDrum === true);
6758
+ go.notify("Checked in for today.");
6759
+ });
6760
+ }, said?.body);
5860
6761
  }, { isActive: active });
5861
- const extra = (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
6762
+ const extra = (focus.length ? focus.length + 2 : 0) + 1 + (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
5862
6763
  const rows = Math.max(3, height - 2 - extra);
5863
6764
  const view = windowOf(mine, at, rows);
5864
6765
  return /* @__PURE__ */ jsxs(Box, {
@@ -5867,8 +6768,32 @@ function TodayView({ active, width, height, go }) {
5867
6768
  height,
5868
6769
  paddingX: 1,
5869
6770
  children: [
6771
+ focus.length > 0 && /* @__PURE__ */ jsxs(Box, {
6772
+ flexDirection: "column",
6773
+ marginBottom: 1,
6774
+ children: [/* @__PURE__ */ jsx(Heading, {
6775
+ right: `${focus.length} of ${3} · f takes one off`,
6776
+ children: "Today's focus"
6777
+ }), focus.map((t) => /* @__PURE__ */ jsxs(Text, {
6778
+ wrap: "truncate-end",
6779
+ children: [
6780
+ " ",
6781
+ /* @__PURE__ */ jsx(Text, {
6782
+ color: theme.accent,
6783
+ children: keyOf({ projects }, t)
6784
+ }),
6785
+ " ",
6786
+ t.title
6787
+ ]
6788
+ }, t.id))]
6789
+ }),
6790
+ /* @__PURE__ */ jsx(Text, {
6791
+ color: theme.muted,
6792
+ wrap: "truncate-end",
6793
+ children: said ? `Your check-in: ${said.body}${said.onDrum ? " (on the blocker)" : ""} · c changes it` : "No check-in today · c checks in"
6794
+ }),
5870
6795
  /* @__PURE__ */ jsx(Heading, {
5871
- right: `${mine.length} open${late ? ` · ${late} late` : ""}`,
6796
+ right: `${mine.length} open${late ? ` · ${late} late` : ""} · f focus`,
5872
6797
  children: "On you"
5873
6798
  }),
5874
6799
  mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
@@ -6288,6 +7213,7 @@ function App({ ui }) {
6288
7213
  width: mainWidth,
6289
7214
  height: bodyHeight,
6290
7215
  go,
7216
+ active: !covered,
6291
7217
  onClose: () => setPanel(null)
6292
7218
  };
6293
7219
  let panelNode = null;
@@ -6327,6 +7253,7 @@ function App({ ui }) {
6327
7253
  ["x", "done"],
6328
7254
  ["d", "delete"],
6329
7255
  ["v", "board/list/budget"],
7256
+ ["f p", "filter"],
6330
7257
  ["e", "details"],
6331
7258
  ["Esc", "back"]
6332
7259
  ] : page ? [
@@ -6334,6 +7261,7 @@ function App({ ui }) {
6334
7261
  ["Enter", "open"],
6335
7262
  ["e", "edit"],
6336
7263
  ["n", "new"],
7264
+ ["v", "layout"],
6337
7265
  ...page.kind === "program" ? [["m", "milestone"], ["←→", "lists"]] : [],
6338
7266
  ["Esc", "back"]
6339
7267
  ] : [
@@ -6453,9 +7381,8 @@ function App({ ui }) {
6453
7381
  display: overlayNode || panelNode ? "none" : "flex",
6454
7382
  children: screenNode
6455
7383
  }),
6456
- panelNode && !overlayNode ? panelNode : null,
6457
- panelNode && overlayNode ? /* @__PURE__ */ jsx(Box, {
6458
- display: "none",
7384
+ panelNode ? /* @__PURE__ */ jsx(Box, {
7385
+ display: overlayNode ? "none" : "flex",
6459
7386
  children: panelNode
6460
7387
  }) : null
6461
7388
  ]
@@ -6493,7 +7420,7 @@ function recordHealth(history, health, at, override, note) {
6493
7420
 
6494
7421
  //#endregion
6495
7422
  //#region src/lib/derived-health.ts
6496
- const iso$1 = (d) => {
7423
+ const iso = (d) => {
6497
7424
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
6498
7425
  };
6499
7426
  function parseDate(value) {
@@ -6536,7 +7463,7 @@ let overdueDay = "";
6536
7463
  const overdueByDue = /* @__PURE__ */ new Map();
6537
7464
  function daysOverdue(dueDate, now) {
6538
7465
  if (!dueDate) return 0;
6539
- const today = iso$1(now);
7466
+ const today = iso(now);
6540
7467
  if (today !== overdueDay) {
6541
7468
  overdueDay = today;
6542
7469
  overdueByDue.clear();
@@ -6557,7 +7484,7 @@ let untilDay = "";
6557
7484
  const untilByDate = /* @__PURE__ */ new Map();
6558
7485
  function daysUntil(date, now) {
6559
7486
  if (!date) return null;
6560
- const today = iso$1(now);
7487
+ const today = iso(now);
6561
7488
  if (today !== untilDay) {
6562
7489
  untilDay = today;
6563
7490
  untilByDate.clear();
@@ -6618,7 +7545,7 @@ function effectiveProjectHealth(project, data, now) {
6618
7545
  }
6619
7546
  /** Milestones past their date and still open. */
6620
7547
  function lateMilestonesOf(milestones, now) {
6621
- const today = iso$1(now);
7548
+ const today = iso(now);
6622
7549
  return milestones.filter((m) => !m.done && m.dueDate < today);
6623
7550
  }
6624
7551
  /**
@@ -7841,56 +8768,6 @@ function applyAutomations(project, prev, next, opts) {
7841
8768
  };
7842
8769
  }
7843
8770
 
7844
- //#endregion
7845
- //#region src/lib/focus.ts
7846
- const FOCUS_CAP = 3;
7847
- function upsertFocus(ropes, personId, date, taskIds) {
7848
- const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
7849
- if (taskIds.length === 0) return rest;
7850
- return [...rest, {
7851
- personId,
7852
- date,
7853
- taskIds: taskIds.slice(0, 3)
7854
- }];
7855
- }
7856
-
7857
- //#endregion
7858
- //#region src/lib/recurrence.ts
7859
- const iso = (d) => {
7860
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
7861
- };
7862
- /** The due date one interval after `from`. */
7863
- function nextDueDate(from, repeat) {
7864
- const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
7865
- if (repeat === "weekly") return iso(addDays(d, 7));
7866
- if (repeat === "biweekly") return iso(addDays(d, 14));
7867
- return iso(addMonths(d, 1));
7868
- }
7869
- /**
7870
- * The next occurrence of a repeating task once this one is done: same
7871
- * title, owner, priority and checklist (unticked), due one interval after
7872
- * the old due date (or after today when it had none), back in To do.
7873
- * Null when the task does not repeat.
7874
- */
7875
- function nextOccurrence(task, id, today, createdAt) {
7876
- if (!task.repeat) return null;
7877
- const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
7878
- return {
7879
- ...task,
7880
- id,
7881
- status: "todo",
7882
- dueDate: nextDueDate(anchor, task.repeat),
7883
- createdAt,
7884
- completedAt: null,
7885
- blockedById: null,
7886
- blockedByIds: [],
7887
- checklist: (task.checklist ?? []).map((c) => ({
7888
- ...c,
7889
- done: false
7890
- }))
7891
- };
7892
- }
7893
-
7894
8771
  //#endregion
7895
8772
  //#region src/lib/assignment.ts
7896
8773
  /**