@harborppm/cli 0.12.0 → 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/README.md +1 -1
- package/dist/app.mjs +640 -100
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ harbor # name your harbor and its first project, and you're in
|
|
|
29
29
|
1. **Today**: today's focus (`f` adds or takes off the task you're on, up to three) and your check-in (`c`); what's on you, late first; who's waiting on you; your risks and issues.
|
|
30
30
|
2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
|
|
31
31
|
3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side; `f` and `p` show only someone's cards or one priority), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
|
|
32
|
-
4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health.
|
|
32
|
+
4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health; `v` reads it project by project, each with its bar across the months, open tasks by status and worst risks. A portfolio opens as a board (programs, with risks, next milestones and money beside them); `v` reads it program by program.
|
|
33
33
|
5. **Risks**: the register, worst first. Enter changes one (status, owner, likelihood, impact, mitigation); `n` logs a risk, issue or decision.
|
|
34
34
|
6. **People**: open and late work and the next four weeks against each person's capacity; Enter changes their capacity, role, or the time they're promised to a program.
|
|
35
35
|
7. **Roadmap**: a portfolio's programs and projects across the months, in their health's colour, ◆ for milestones.
|
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. */
|
|
@@ -2096,6 +2166,74 @@ function filterTasks(tasks, f) {
|
|
|
2096
2166
|
if (f.who === "any" && f.priority === "any") return tasks;
|
|
2097
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));
|
|
2098
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
|
+
}
|
|
2099
2237
|
|
|
2100
2238
|
//#endregion
|
|
2101
2239
|
//#region src/tui/theme.ts
|
|
@@ -2981,27 +3119,6 @@ function HistoryView({ active, width, height, go }) {
|
|
|
2981
3119
|
});
|
|
2982
3120
|
}
|
|
2983
3121
|
|
|
2984
|
-
//#endregion
|
|
2985
|
-
//#region src/lib/flow.ts
|
|
2986
|
-
function tasksInProgramOf(data, programId) {
|
|
2987
|
-
const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
|
|
2988
|
-
return data.tasks.filter((t) => ids.has(t.projectId));
|
|
2989
|
-
}
|
|
2990
|
-
function activeConstraints(data) {
|
|
2991
|
-
return (data.constraints ?? []).filter((c) => c.status === "active");
|
|
2992
|
-
}
|
|
2993
|
-
function constraintsOwnedBy(data, personId) {
|
|
2994
|
-
return activeConstraints(data).filter((c) => c.ownerId === personId);
|
|
2995
|
-
}
|
|
2996
|
-
function constraintIdle(data, constraint) {
|
|
2997
|
-
if (constraint.status !== "active" || !constraint.ownerId) return false;
|
|
2998
|
-
const tasks = tasksInProgramOf(data, constraint.programId);
|
|
2999
|
-
if (!tasks.some((t) => t.status !== "done")) return false;
|
|
3000
|
-
if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
|
|
3001
|
-
const here = new Set(tasks.map((t) => t.id));
|
|
3002
|
-
return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
|
|
3003
|
-
}
|
|
3004
|
-
|
|
3005
3122
|
//#endregion
|
|
3006
3123
|
//#region src/lib/inbox.ts
|
|
3007
3124
|
const KIND_RANK = {
|
|
@@ -3293,19 +3410,6 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
|
|
|
3293
3410
|
};
|
|
3294
3411
|
}
|
|
3295
3412
|
|
|
3296
|
-
//#endregion
|
|
3297
|
-
//#region src/lib/selectors.ts
|
|
3298
|
-
/** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
|
|
3299
|
-
function splitDone(items) {
|
|
3300
|
-
const open = [];
|
|
3301
|
-
const done = [];
|
|
3302
|
-
for (const item of items) (item.health === "done" ? done : open).push(item);
|
|
3303
|
-
return {
|
|
3304
|
-
open,
|
|
3305
|
-
done
|
|
3306
|
-
};
|
|
3307
|
-
}
|
|
3308
|
-
|
|
3309
3413
|
//#endregion
|
|
3310
3414
|
//#region src/tui/views/lists.tsx
|
|
3311
3415
|
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
@@ -4737,12 +4841,293 @@ function PersonPanel({ id, width, height, go, active, onClose }) {
|
|
|
4737
4841
|
});
|
|
4738
4842
|
}
|
|
4739
4843
|
|
|
4844
|
+
//#endregion
|
|
4845
|
+
//#region src/tui/views/bands.tsx
|
|
4846
|
+
/**
|
|
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.
|
|
4850
|
+
*/
|
|
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, {
|
|
4856
|
+
flexDirection: "column",
|
|
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",
|
|
4868
|
+
children: [
|
|
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()
|
|
4873
|
+
}),
|
|
4874
|
+
" ",
|
|
4875
|
+
r.title,
|
|
4876
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
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
|
+
|
|
4740
5124
|
//#endregion
|
|
4741
5125
|
//#region src/tui/views/program.tsx
|
|
4742
5126
|
/**
|
|
4743
5127
|
* One program: its health (and why, when set by hand), next move, its
|
|
4744
5128
|
* projects and its milestones. ←→ between the two lists. Projects set to
|
|
4745
|
-
* Done stay out of the list until D shows them.
|
|
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.
|
|
4746
5131
|
*/
|
|
4747
5132
|
function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
4748
5133
|
const { store, today } = useDeps();
|
|
@@ -4751,6 +5136,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4751
5136
|
const tasks = useHarbor((s) => s.tasks);
|
|
4752
5137
|
const milestones = useHarbor((s) => s.milestones);
|
|
4753
5138
|
const people = useHarbor((s) => s.people);
|
|
5139
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
4754
5140
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === program?.portfolioId));
|
|
4755
5141
|
const [showDone, setShowDone] = useState(false);
|
|
4756
5142
|
const split = useMemo(() => splitDone(projects.filter((p) => p.programId === programId)), [projects, programId]);
|
|
@@ -4761,14 +5147,19 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4761
5147
|
today
|
|
4762
5148
|
]);
|
|
4763
5149
|
const [side, setSide] = useState("projects");
|
|
5150
|
+
const [layout, setLayout] = useState("board");
|
|
4764
5151
|
const [pAt, setPAt] = useState(0);
|
|
4765
5152
|
const [mAt, setMAt] = useState(0);
|
|
4766
5153
|
const project = mine[Math.min(pAt, mine.length - 1)];
|
|
4767
5154
|
const mark = marks[Math.min(mAt, marks.length - 1)]?.milestone;
|
|
4768
5155
|
useInput((input, key) => {
|
|
4769
5156
|
if (key.escape || key.backspace) return onBack();
|
|
4770
|
-
if (
|
|
4771
|
-
|
|
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");
|
|
4772
5163
|
if (input === "e") return go.openPanel("program", programId);
|
|
4773
5164
|
if (input === "n") return go.create("project", { programId });
|
|
4774
5165
|
if (input === "m") return go.create("milestone", { programId });
|
|
@@ -4798,6 +5189,11 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4798
5189
|
tasks,
|
|
4799
5190
|
projects
|
|
4800
5191
|
};
|
|
5192
|
+
const bands = layout === "bands" ? projectBands({
|
|
5193
|
+
tasks,
|
|
5194
|
+
projects,
|
|
5195
|
+
raidItems
|
|
5196
|
+
}, programId, today(), showDone) : [];
|
|
4801
5197
|
return /* @__PURE__ */ jsxs(Box, {
|
|
4802
5198
|
flexDirection: "column",
|
|
4803
5199
|
width,
|
|
@@ -4845,7 +5241,21 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4845
5241
|
children: ["Next move: ", program.nextMove || "none written yet (e to write one)"]
|
|
4846
5242
|
}),
|
|
4847
5243
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
4848
|
-
/* @__PURE__ */ jsxs(
|
|
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, {
|
|
4849
5259
|
flexDirection: "column",
|
|
4850
5260
|
width: half,
|
|
4851
5261
|
marginRight: 1,
|
|
@@ -4930,20 +5340,54 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4930
5340
|
]
|
|
4931
5341
|
});
|
|
4932
5342
|
}
|
|
4933
|
-
/**
|
|
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
|
+
*/
|
|
4934
5348
|
function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
5349
|
+
const { today } = useDeps();
|
|
4935
5350
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === portfolioId));
|
|
4936
5351
|
const programs = useHarbor((s) => s.programs);
|
|
4937
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);
|
|
4938
5357
|
const [showDone, setShowDone] = useState(false);
|
|
5358
|
+
const [layout, setLayout] = useState("board");
|
|
4939
5359
|
const split = useMemo(() => splitDone(programs.filter((p) => p.portfolioId === portfolioId)), [programs, portfolioId]);
|
|
4940
5360
|
const list = showDone ? [...split.open, ...split.done] : split.open;
|
|
4941
5361
|
const [at, setAt] = useState(0);
|
|
4942
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
|
+
]);
|
|
4943
5386
|
useInput((input, key) => {
|
|
4944
5387
|
if (key.escape || key.backspace) return onBack();
|
|
4945
5388
|
if (input === "e") return go.openPanel("portfolio", portfolioId);
|
|
4946
5389
|
if (input === "n") return go.create("program", { portfolioId });
|
|
5390
|
+
if (input === "v") return setLayout((l) => l === "board" ? "bands" : "board");
|
|
4947
5391
|
if (input === "D" && split.done.length > 0) return setShowDone((v) => !v);
|
|
4948
5392
|
if (key.upArrow || input === "k") setAt((i) => step(i, -1, list.length));
|
|
4949
5393
|
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, list.length));
|
|
@@ -4953,6 +5397,15 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
4953
5397
|
color: theme.muted,
|
|
4954
5398
|
children: " That portfolio isn’t in this harbor any more. Esc goes back."
|
|
4955
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);
|
|
4956
5409
|
return /* @__PURE__ */ jsxs(Box, {
|
|
4957
5410
|
flexDirection: "column",
|
|
4958
5411
|
width,
|
|
@@ -4981,50 +5434,136 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
4981
5434
|
}) : null
|
|
4982
5435
|
]
|
|
4983
5436
|
}),
|
|
4984
|
-
/* @__PURE__ */
|
|
5437
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
4985
5438
|
color: theme.muted,
|
|
4986
5439
|
wrap: "truncate-end",
|
|
4987
|
-
children:
|
|
5440
|
+
children: [
|
|
5441
|
+
portfolio.theme || "Portfolio",
|
|
5442
|
+
" · ",
|
|
5443
|
+
layout === "board" ? "board" : "by program",
|
|
5444
|
+
" · v switches"
|
|
5445
|
+
]
|
|
4988
5446
|
}),
|
|
4989
5447
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
4990
|
-
/* @__PURE__ */
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
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] })
|
|
5028
5567
|
]
|
|
5029
5568
|
});
|
|
5030
5569
|
}
|
|
@@ -6722,6 +7261,7 @@ function App({ ui }) {
|
|
|
6722
7261
|
["Enter", "open"],
|
|
6723
7262
|
["e", "edit"],
|
|
6724
7263
|
["n", "new"],
|
|
7264
|
+
["v", "layout"],
|
|
6725
7265
|
...page.kind === "program" ? [["m", "milestone"], ["←→", "lists"]] : [],
|
|
6726
7266
|
["Esc", "back"]
|
|
6727
7267
|
] : [
|