@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/README.md +9 -5
- package/dist/app.mjs +1159 -282
- package/lib/cli.mjs +60 -7
- package/lib/format.mjs +25 -0
- package/package.json +1 -1
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
|
|
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
|
|
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/
|
|
4845
|
+
//#region src/tui/views/bands.tsx
|
|
4728
4846
|
/**
|
|
4729
|
-
*
|
|
4730
|
-
*
|
|
4731
|
-
*
|
|
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
|
-
|
|
4734
|
-
|
|
4735
|
-
const
|
|
4736
|
-
const
|
|
4737
|
-
|
|
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
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
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
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
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
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
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
|
-
/**
|
|
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__ */
|
|
5437
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
4971
5438
|
color: theme.muted,
|
|
4972
5439
|
wrap: "truncate-end",
|
|
4973
|
-
children:
|
|
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__ */
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
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
|
|
5040
|
-
const
|
|
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
|
-
"
|
|
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.
|
|
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: [
|
|
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 === "
|
|
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 === "
|
|
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
|
-
/**
|
|
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
|
|
6457
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
/**
|