@harborppm/cli 0.4.0 → 0.5.1
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 +3 -1
- package/dist/app.mjs +349 -252
- package/lib/cli.mjs +32 -1
- package/lib/config.mjs +16 -0
- package/lib/logo.mjs +1 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Needs Node 22 or later.
|
|
|
12
12
|
|
|
13
13
|
## The full-screen app
|
|
14
14
|
|
|
15
|
-
`harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads
|
|
15
|
+
`harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
|
|
16
16
|
|
|
17
17
|
1. **Today**: what's on you, late first; who's waiting on you; your risks and issues.
|
|
18
18
|
2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
|
|
@@ -27,6 +27,8 @@ A task changes in place too: title, status, owner, due date (`2026-10-14`, `toda
|
|
|
27
27
|
|
|
28
28
|
Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes back, `:` finds anything (a screen, "new project", a project or program by name), `/` finds a task by its key or words, `n` makes something new, `e` changes what's open, `[` `]` move a card a column, `x` marks it done, `d` deletes (it asks first), `u` undoes your last change, `H` switches harbor, `?` lists them all.
|
|
29
29
|
|
|
30
|
+
It has colours for dark terminals and for light ones, and picks by asking the terminal for its background (or reading `COLORFGBG`); a terminal that doesn't say gets the dark ones. `harbor theme light` or `harbor theme dark` settles it, `harbor theme auto` goes back to asking, and `HARBOR_THEME` overrides it for one run.
|
|
31
|
+
|
|
30
32
|
`harbor help` shows the mark too, in a terminal with colour; piped, or with `NO_COLOR` set, it's plain text. `harbor --version` says which version you have.
|
|
31
33
|
|
|
32
34
|
## Staying up to date
|
package/dist/app.mjs
CHANGED
|
@@ -192,137 +192,6 @@ const createImpl = (createState) => {
|
|
|
192
192
|
};
|
|
193
193
|
const create$1 = ((createState) => createState ? createImpl(createState) : createImpl);
|
|
194
194
|
|
|
195
|
-
//#endregion
|
|
196
|
-
//#region cli/lib/logo.mjs
|
|
197
|
-
/**
|
|
198
|
-
* Harbor's mark for the terminal: the favicon (public/favicon.svg) as
|
|
199
|
-
* pixels, a teal tile with the white H and three coral boats. Each
|
|
200
|
-
* terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
|
|
201
|
-
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
202
|
-
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
203
|
-
*
|
|
204
|
-
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
205
|
-
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
206
|
-
*/
|
|
207
|
-
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
208
|
-
const LOGO = [
|
|
209
|
-
"...TTTTTTTTTTTTTTTTTT...",
|
|
210
|
-
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
211
|
-
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
212
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
213
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
214
|
-
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
215
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
216
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
217
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
218
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
219
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
220
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
221
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
222
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
223
|
-
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
224
|
-
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
225
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
226
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
227
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
228
|
-
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
229
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
230
|
-
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
231
|
-
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
232
|
-
"...TTTTTTTTTTTTTTTTTT..."
|
|
233
|
-
];
|
|
234
|
-
/** 16 by 16, for narrow places. */
|
|
235
|
-
const LOGO_SMALL = [
|
|
236
|
-
"..TTTTTTTTTTTT..",
|
|
237
|
-
".TTTTTTTTTTTTTT.",
|
|
238
|
-
"TTTTWWTTTTWWTTTT",
|
|
239
|
-
"TTTTWWTTTTWWTTTT",
|
|
240
|
-
"TPPTWWTTTTWWTTTT",
|
|
241
|
-
"TCCTWWTTTTWWTTTT",
|
|
242
|
-
"TCCTWWTTTTWWTTTT",
|
|
243
|
-
"TTTTWWWWWWWWTTTT",
|
|
244
|
-
"TTTTWWWWWWWWTTTT",
|
|
245
|
-
"TTTTWWTTTTWWTPPT",
|
|
246
|
-
"TTTTWWTPPTWWTCCT",
|
|
247
|
-
"TTTTWWTCCTWWTCCT",
|
|
248
|
-
"TTTTWWTCCTWWTTTT",
|
|
249
|
-
"TTTTWWTTTTWWTTTT",
|
|
250
|
-
".TTTTTTTTTTTTTT.",
|
|
251
|
-
"..TTTTTTTTTTTT.."
|
|
252
|
-
];
|
|
253
|
-
/** 12 by 12, for the full-screen app's sidebar: posts and side boats a pixel wide, no bows. */
|
|
254
|
-
const LOGO_MINI = [
|
|
255
|
-
"..TTTTTTTT..",
|
|
256
|
-
".TTTTTTTTTT.",
|
|
257
|
-
"TTTWTTTTWTTT",
|
|
258
|
-
"TCTWTTTTWTTT",
|
|
259
|
-
"TCTWTTTTWTTT",
|
|
260
|
-
"TCTWWWWWWTTT",
|
|
261
|
-
"TTTWWWWWWTTT",
|
|
262
|
-
"TTTWTTTTWTCT",
|
|
263
|
-
"TTTWTCCTWTCT",
|
|
264
|
-
"TTTWTCCTWTCT",
|
|
265
|
-
".TTTTTTTTTT.",
|
|
266
|
-
"..TTTTTTTT.."
|
|
267
|
-
];
|
|
268
|
-
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
269
|
-
const LOGO_COLOURS = {
|
|
270
|
-
T: "#0f6e64",
|
|
271
|
-
W: "#ffffff",
|
|
272
|
-
C: "#c44a2c"
|
|
273
|
-
};
|
|
274
|
-
/**
|
|
275
|
-
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
276
|
-
* One stretch of a terminal row in the same colours.
|
|
277
|
-
*/
|
|
278
|
-
/**
|
|
279
|
-
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
280
|
-
* half block's colour, the bottom one its background.
|
|
281
|
-
* @param {string[]} pixels
|
|
282
|
-
* @returns {Run[][]}
|
|
283
|
-
*/
|
|
284
|
-
function logoRows(pixels = LOGO) {
|
|
285
|
-
/** @type {Run[][]} */
|
|
286
|
-
const rows = [];
|
|
287
|
-
for (let y = 0; y < pixels.length; y += 2) {
|
|
288
|
-
/** @type {Run[]} */
|
|
289
|
-
const runs = [];
|
|
290
|
-
const top = pixels[y] ?? "";
|
|
291
|
-
const bottom = pixels[y + 1] ?? "";
|
|
292
|
-
for (let x = 0; x < top.length; x++) {
|
|
293
|
-
const t = LOGO_COLOURS[top[x]];
|
|
294
|
-
const b = LOGO_COLOURS[bottom[x]];
|
|
295
|
-
const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
|
|
296
|
-
const left = bowRow ? bowRow[x - 1] !== "P" : false;
|
|
297
|
-
/** @type {Run} */
|
|
298
|
-
const cell = top[x] === "P" ? {
|
|
299
|
-
text: left ? "▟" : "▙",
|
|
300
|
-
fg: LOGO_COLOURS.C,
|
|
301
|
-
bg: LOGO_COLOURS.T
|
|
302
|
-
} : bottom[x] === "P" ? {
|
|
303
|
-
text: left ? "▗" : "▖",
|
|
304
|
-
fg: LOGO_COLOURS.C,
|
|
305
|
-
bg: t
|
|
306
|
-
} : !t && !b ? { text: " " } : t === b || !b ? {
|
|
307
|
-
text: t === b ? "█" : "▀",
|
|
308
|
-
fg: t
|
|
309
|
-
} : !t ? {
|
|
310
|
-
text: "▄",
|
|
311
|
-
fg: b
|
|
312
|
-
} : {
|
|
313
|
-
text: "▀",
|
|
314
|
-
fg: t,
|
|
315
|
-
bg: b
|
|
316
|
-
};
|
|
317
|
-
const last = runs.at(-1);
|
|
318
|
-
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
319
|
-
else runs.push(cell);
|
|
320
|
-
}
|
|
321
|
-
rows.push(runs);
|
|
322
|
-
}
|
|
323
|
-
return rows;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
195
|
//#endregion
|
|
327
196
|
//#region src/tui/context.tsx
|
|
328
197
|
/**
|
|
@@ -341,71 +210,6 @@ function useHarbor(selector) {
|
|
|
341
210
|
return useStore(useDeps().store, selector);
|
|
342
211
|
}
|
|
343
212
|
|
|
344
|
-
//#endregion
|
|
345
|
-
//#region src/tui/theme.ts
|
|
346
|
-
const theme = {
|
|
347
|
-
accent: "#3dbcab",
|
|
348
|
-
coral: "#e0714f",
|
|
349
|
-
muted: "gray",
|
|
350
|
-
selected: "#3dbcab"
|
|
351
|
-
};
|
|
352
|
-
const HEALTH_COLOR = {
|
|
353
|
-
"on-track": "green",
|
|
354
|
-
"at-risk": "yellow",
|
|
355
|
-
"off-track": "red",
|
|
356
|
-
paused: "gray",
|
|
357
|
-
done: "cyan"
|
|
358
|
-
};
|
|
359
|
-
const PRIORITY_COLOR = {
|
|
360
|
-
urgent: "red",
|
|
361
|
-
high: "#e0714f",
|
|
362
|
-
medium: void 0,
|
|
363
|
-
low: "gray"
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
//#endregion
|
|
367
|
-
//#region src/tui/logo.tsx
|
|
368
|
-
/** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
|
|
369
|
-
function Logo({ pixels = LOGO }) {
|
|
370
|
-
return /* @__PURE__ */ jsx(Box, {
|
|
371
|
-
flexDirection: "column",
|
|
372
|
-
flexShrink: 0,
|
|
373
|
-
children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
|
|
374
|
-
color: r.fg,
|
|
375
|
-
backgroundColor: r.bg,
|
|
376
|
-
children: r.text
|
|
377
|
-
}, i)) }, y))
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
/** What shows while the harbor loads: the mark, centred, and which harbor. */
|
|
381
|
-
function Splash({ opening }) {
|
|
382
|
-
const { columns, rows } = useWindowSize();
|
|
383
|
-
const mark = rows >= 18 && columns >= 30;
|
|
384
|
-
const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
|
|
385
|
-
return /* @__PURE__ */ jsxs(Box, {
|
|
386
|
-
width: columns,
|
|
387
|
-
height: rows,
|
|
388
|
-
flexDirection: "column",
|
|
389
|
-
alignItems: "center",
|
|
390
|
-
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
391
|
-
children: [
|
|
392
|
-
mark ? /* @__PURE__ */ jsx(Box, {
|
|
393
|
-
marginBottom: 1,
|
|
394
|
-
children: /* @__PURE__ */ jsx(Logo, {})
|
|
395
|
-
}) : null,
|
|
396
|
-
/* @__PURE__ */ jsx(Text, {
|
|
397
|
-
bold: true,
|
|
398
|
-
color: theme.accent,
|
|
399
|
-
children: "Harbor"
|
|
400
|
-
}),
|
|
401
|
-
/* @__PURE__ */ jsx(Text, {
|
|
402
|
-
color: theme.muted,
|
|
403
|
-
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
404
|
-
})
|
|
405
|
-
]
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
|
|
409
213
|
//#endregion
|
|
410
214
|
//#region node_modules/date-fns/constants.js
|
|
411
215
|
/**
|
|
@@ -2271,6 +2075,66 @@ function forecastLine(f) {
|
|
|
2271
2075
|
return `At this rate it ends at ${money(Math.round(f.eac))} (${money(gap)} ${side} budget)`;
|
|
2272
2076
|
}
|
|
2273
2077
|
|
|
2078
|
+
//#endregion
|
|
2079
|
+
//#region src/tui/theme.ts
|
|
2080
|
+
/**
|
|
2081
|
+
* Dark: Harbor's teal and coral lifted so they read on black, and status in
|
|
2082
|
+
* softened hues rather than the terminal's own loud green and yellow (a
|
|
2083
|
+
* 16-colour terminal still gets its nearest plain colour). Light: the brand's own darker teal
|
|
2084
|
+
* and coral, and status in hues dark enough for text on white (4.5:1 or more).
|
|
2085
|
+
*/
|
|
2086
|
+
const PALETTES = {
|
|
2087
|
+
dark: {
|
|
2088
|
+
accent: "#3dbcab",
|
|
2089
|
+
coral: "#e0714f",
|
|
2090
|
+
muted: "gray",
|
|
2091
|
+
selected: "#3dbcab",
|
|
2092
|
+
border: "gray",
|
|
2093
|
+
good: "#86c294",
|
|
2094
|
+
warn: "#e3b55c",
|
|
2095
|
+
bad: "#f2665d",
|
|
2096
|
+
done: "#7fb6cc"
|
|
2097
|
+
},
|
|
2098
|
+
light: {
|
|
2099
|
+
accent: "#0f6e64",
|
|
2100
|
+
coral: "#b8432c",
|
|
2101
|
+
muted: "#5f5e58",
|
|
2102
|
+
selected: "#0f6e64",
|
|
2103
|
+
border: "#a8a69e",
|
|
2104
|
+
good: "#1f7a3a",
|
|
2105
|
+
warn: "#8a5a0a",
|
|
2106
|
+
bad: "#b8432c",
|
|
2107
|
+
done: "#146c6b"
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
const theme = {
|
|
2111
|
+
name: "dark",
|
|
2112
|
+
...PALETTES.dark
|
|
2113
|
+
};
|
|
2114
|
+
const HEALTH_COLOR = {};
|
|
2115
|
+
const PRIORITY_COLOR = {};
|
|
2116
|
+
/** Switch every colour to one set. Call before the app draws. */
|
|
2117
|
+
function applyTheme(name) {
|
|
2118
|
+
Object.assign(theme, {
|
|
2119
|
+
name,
|
|
2120
|
+
...PALETTES[name]
|
|
2121
|
+
});
|
|
2122
|
+
Object.assign(HEALTH_COLOR, {
|
|
2123
|
+
"on-track": theme.good,
|
|
2124
|
+
"at-risk": theme.warn,
|
|
2125
|
+
"off-track": theme.bad,
|
|
2126
|
+
paused: theme.muted,
|
|
2127
|
+
done: theme.done
|
|
2128
|
+
});
|
|
2129
|
+
Object.assign(PRIORITY_COLOR, {
|
|
2130
|
+
urgent: theme.bad,
|
|
2131
|
+
high: theme.coral,
|
|
2132
|
+
medium: void 0,
|
|
2133
|
+
low: theme.muted
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
applyTheme("dark");
|
|
2137
|
+
|
|
2274
2138
|
//#endregion
|
|
2275
2139
|
//#region src/tui/ui.tsx
|
|
2276
2140
|
/**
|
|
@@ -2292,7 +2156,7 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
|
|
|
2292
2156
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2293
2157
|
flexDirection: "column",
|
|
2294
2158
|
borderStyle: "round",
|
|
2295
|
-
borderColor: theme.
|
|
2159
|
+
borderColor: theme.border,
|
|
2296
2160
|
paddingX: 1,
|
|
2297
2161
|
children: [
|
|
2298
2162
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -2326,7 +2190,7 @@ function Picker({ title, choices, initial = 0, rows = 10, onPick, onCancel }) {
|
|
|
2326
2190
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2327
2191
|
flexDirection: "column",
|
|
2328
2192
|
borderStyle: "round",
|
|
2329
|
-
borderColor: theme.
|
|
2193
|
+
borderColor: theme.border,
|
|
2330
2194
|
paddingX: 1,
|
|
2331
2195
|
children: [
|
|
2332
2196
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -2395,7 +2259,6 @@ function Hints({ keys }) {
|
|
|
2395
2259
|
children: keys.map(([k, label], i) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
2396
2260
|
i > 0 ? " " : "",
|
|
2397
2261
|
/* @__PURE__ */ jsx(Text, {
|
|
2398
|
-
color: theme.accent,
|
|
2399
2262
|
bold: true,
|
|
2400
2263
|
children: k
|
|
2401
2264
|
}),
|
|
@@ -2423,7 +2286,6 @@ function Heading({ children, right }) {
|
|
|
2423
2286
|
justifyContent: "space-between",
|
|
2424
2287
|
children: [/* @__PURE__ */ jsx(Text, {
|
|
2425
2288
|
bold: true,
|
|
2426
|
-
color: theme.accent,
|
|
2427
2289
|
children
|
|
2428
2290
|
}), right ? /* @__PURE__ */ jsx(Text, {
|
|
2429
2291
|
color: theme.muted,
|
|
@@ -2518,7 +2380,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2518
2380
|
width,
|
|
2519
2381
|
height,
|
|
2520
2382
|
borderStyle: "round",
|
|
2521
|
-
borderColor: theme.
|
|
2383
|
+
borderColor: theme.border,
|
|
2522
2384
|
paddingX: 1,
|
|
2523
2385
|
children: [
|
|
2524
2386
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -2861,7 +2723,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
|
|
|
2861
2723
|
" ",
|
|
2862
2724
|
/* @__PURE__ */ jsx(PriorityMark, { priority: task.priority }),
|
|
2863
2725
|
task.dueDate ? /* @__PURE__ */ jsxs(Text, {
|
|
2864
|
-
color: late ?
|
|
2726
|
+
color: late ? theme.bad : theme.muted,
|
|
2865
2727
|
children: [
|
|
2866
2728
|
" ",
|
|
2867
2729
|
late ? "late · " : "",
|
|
@@ -2869,7 +2731,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
|
|
|
2869
2731
|
]
|
|
2870
2732
|
}) : null,
|
|
2871
2733
|
blocked ? /* @__PURE__ */ jsx(Text, {
|
|
2872
|
-
color:
|
|
2734
|
+
color: theme.warn,
|
|
2873
2735
|
children: " waits"
|
|
2874
2736
|
}) : null,
|
|
2875
2737
|
who ? /* @__PURE__ */ jsxs(Text, {
|
|
@@ -3023,7 +2885,7 @@ function HistoryView({ active, width, height, go }) {
|
|
|
3023
2885
|
children: "Loading…"
|
|
3024
2886
|
}) : null,
|
|
3025
2887
|
state === "failed" ? /* @__PURE__ */ jsx(Text, {
|
|
3026
|
-
color:
|
|
2888
|
+
color: theme.bad,
|
|
3027
2889
|
children: "Couldn’t load History. It tries again after the next save."
|
|
3028
2890
|
}) : null,
|
|
3029
2891
|
state === "ready" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
@@ -3601,17 +3463,19 @@ function PeopleView({ active, width, height, go }) {
|
|
|
3601
3463
|
const { today } = useDeps();
|
|
3602
3464
|
const rows = useMemo(() => {
|
|
3603
3465
|
const now = /* @__PURE__ */ new Date(`${today()}T12:00:00`);
|
|
3466
|
+
const nameWidth = Math.max(0, ...people.map((p) => p.name.length));
|
|
3604
3467
|
return people.map((p) => {
|
|
3605
3468
|
const open = tasks.filter((t) => t.assigneeId === p.id && t.status !== "done");
|
|
3606
3469
|
const load = weeklyLoad(tasks, p.id, now, 4);
|
|
3607
3470
|
const cap = p.weeklyCapacity ?? 5;
|
|
3608
|
-
const weeks = load.weeks.map((w) => w.due > cap ? `${w.due}!` : String(w.due)).join("
|
|
3471
|
+
const weeks = load.weeks.map((w) => (w.due === 0 ? "·" : w.due > cap ? `${w.due}!` : String(w.due)).padStart(3)).join("");
|
|
3472
|
+
const late = load.overdue ? `${load.overdue} late` : "";
|
|
3609
3473
|
return {
|
|
3610
3474
|
id: p.id,
|
|
3611
3475
|
kind: "person",
|
|
3612
3476
|
depth: 0,
|
|
3613
|
-
title: p.name,
|
|
3614
|
-
detail: `${
|
|
3477
|
+
title: p.name.padEnd(nameWidth),
|
|
3478
|
+
detail: `${String(open.length).padStart(3)} open ${late.padEnd(7)} weeks${weeks} of ${cap} · ${p.role}`
|
|
3615
3479
|
};
|
|
3616
3480
|
});
|
|
3617
3481
|
}, [
|
|
@@ -3631,6 +3495,165 @@ function PeopleView({ active, width, height, go }) {
|
|
|
3631
3495
|
});
|
|
3632
3496
|
}
|
|
3633
3497
|
|
|
3498
|
+
//#endregion
|
|
3499
|
+
//#region cli/lib/logo.mjs
|
|
3500
|
+
/**
|
|
3501
|
+
* Harbor's mark for the terminal: the favicon (public/favicon.svg) as
|
|
3502
|
+
* pixels, a teal tile with the white H and three coral boats. Each
|
|
3503
|
+
* terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
|
|
3504
|
+
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
3505
|
+
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
3506
|
+
*
|
|
3507
|
+
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
3508
|
+
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
3509
|
+
*/
|
|
3510
|
+
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
3511
|
+
const LOGO = [
|
|
3512
|
+
"...TTTTTTTTTTTTTTTTTT...",
|
|
3513
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
3514
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
3515
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3516
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3517
|
+
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
3518
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3519
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3520
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3521
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3522
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3523
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3524
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3525
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3526
|
+
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
3527
|
+
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
3528
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3529
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3530
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3531
|
+
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
3532
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3533
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
3534
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
3535
|
+
"...TTTTTTTTTTTTTTTTTT..."
|
|
3536
|
+
];
|
|
3537
|
+
/** 16 by 16, for the keys overlay. */
|
|
3538
|
+
const LOGO_SMALL = [
|
|
3539
|
+
"..TTTTTTTTTTTT..",
|
|
3540
|
+
".TTTTTTTTTTTTTT.",
|
|
3541
|
+
"TTTTWWTTTTWWTTTT",
|
|
3542
|
+
"TTTTWWTTTTWWTTTT",
|
|
3543
|
+
"TPPTWWTTTTWWTTTT",
|
|
3544
|
+
"TCCTWWTTTTWWTTTT",
|
|
3545
|
+
"TCCTWWTTTTWWTTTT",
|
|
3546
|
+
"TTTTWWWWWWWWTTTT",
|
|
3547
|
+
"TTTTWWWWWWWWTTTT",
|
|
3548
|
+
"TTTTWWTTTTWWTPPT",
|
|
3549
|
+
"TTTTWWTPPTWWTCCT",
|
|
3550
|
+
"TTTTWWTCCTWWTCCT",
|
|
3551
|
+
"TTTTWWTCCTWWTTTT",
|
|
3552
|
+
"TTTTWWTTTTWWTTTT",
|
|
3553
|
+
".TTTTTTTTTTTTTT.",
|
|
3554
|
+
"..TTTTTTTTTTTT.."
|
|
3555
|
+
];
|
|
3556
|
+
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
3557
|
+
const LOGO_COLOURS = {
|
|
3558
|
+
T: "#0f6e64",
|
|
3559
|
+
W: "#ffffff",
|
|
3560
|
+
C: "#c44a2c"
|
|
3561
|
+
};
|
|
3562
|
+
/**
|
|
3563
|
+
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
3564
|
+
* One stretch of a terminal row in the same colours.
|
|
3565
|
+
*/
|
|
3566
|
+
/**
|
|
3567
|
+
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
3568
|
+
* half block's colour, the bottom one its background.
|
|
3569
|
+
* @param {string[]} pixels
|
|
3570
|
+
* @returns {Run[][]}
|
|
3571
|
+
*/
|
|
3572
|
+
function logoRows(pixels = LOGO) {
|
|
3573
|
+
/** @type {Run[][]} */
|
|
3574
|
+
const rows = [];
|
|
3575
|
+
for (let y = 0; y < pixels.length; y += 2) {
|
|
3576
|
+
/** @type {Run[]} */
|
|
3577
|
+
const runs = [];
|
|
3578
|
+
const top = pixels[y] ?? "";
|
|
3579
|
+
const bottom = pixels[y + 1] ?? "";
|
|
3580
|
+
for (let x = 0; x < top.length; x++) {
|
|
3581
|
+
const t = LOGO_COLOURS[top[x]];
|
|
3582
|
+
const b = LOGO_COLOURS[bottom[x]];
|
|
3583
|
+
const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
|
|
3584
|
+
const left = bowRow ? bowRow[x - 1] !== "P" : false;
|
|
3585
|
+
/** @type {Run} */
|
|
3586
|
+
const cell = top[x] === "P" ? {
|
|
3587
|
+
text: left ? "▟" : "▙",
|
|
3588
|
+
fg: LOGO_COLOURS.C,
|
|
3589
|
+
bg: LOGO_COLOURS.T
|
|
3590
|
+
} : bottom[x] === "P" ? {
|
|
3591
|
+
text: left ? "▗" : "▖",
|
|
3592
|
+
fg: LOGO_COLOURS.C,
|
|
3593
|
+
bg: t
|
|
3594
|
+
} : !t && !b ? { text: " " } : t === b || !b ? {
|
|
3595
|
+
text: t === b ? "█" : "▀",
|
|
3596
|
+
fg: t
|
|
3597
|
+
} : !t ? {
|
|
3598
|
+
text: "▄",
|
|
3599
|
+
fg: b
|
|
3600
|
+
} : {
|
|
3601
|
+
text: "▀",
|
|
3602
|
+
fg: t,
|
|
3603
|
+
bg: b
|
|
3604
|
+
};
|
|
3605
|
+
const last = runs.at(-1);
|
|
3606
|
+
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
3607
|
+
else runs.push(cell);
|
|
3608
|
+
}
|
|
3609
|
+
rows.push(runs);
|
|
3610
|
+
}
|
|
3611
|
+
return rows;
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
//#endregion
|
|
3615
|
+
//#region src/tui/logo.tsx
|
|
3616
|
+
/** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
|
|
3617
|
+
function Logo({ pixels = LOGO }) {
|
|
3618
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
3619
|
+
flexDirection: "column",
|
|
3620
|
+
flexShrink: 0,
|
|
3621
|
+
children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
|
|
3622
|
+
color: r.fg,
|
|
3623
|
+
backgroundColor: r.bg,
|
|
3624
|
+
children: r.text
|
|
3625
|
+
}, i)) }, y))
|
|
3626
|
+
});
|
|
3627
|
+
}
|
|
3628
|
+
/** What shows while the harbor loads: the mark, centred, and which harbor. */
|
|
3629
|
+
function Splash({ opening }) {
|
|
3630
|
+
const { columns, rows } = useWindowSize();
|
|
3631
|
+
const mark = rows >= 18 && columns >= 30;
|
|
3632
|
+
const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
|
|
3633
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
3634
|
+
width: columns,
|
|
3635
|
+
height: rows,
|
|
3636
|
+
flexDirection: "column",
|
|
3637
|
+
alignItems: "center",
|
|
3638
|
+
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
3639
|
+
children: [
|
|
3640
|
+
mark ? /* @__PURE__ */ jsx(Box, {
|
|
3641
|
+
marginBottom: 1,
|
|
3642
|
+
children: /* @__PURE__ */ jsx(Logo, {})
|
|
3643
|
+
}) : null,
|
|
3644
|
+
/* @__PURE__ */ jsx(Text, {
|
|
3645
|
+
bold: true,
|
|
3646
|
+
color: theme.accent,
|
|
3647
|
+
children: "Harbor"
|
|
3648
|
+
}),
|
|
3649
|
+
/* @__PURE__ */ jsx(Text, {
|
|
3650
|
+
color: theme.muted,
|
|
3651
|
+
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
3652
|
+
})
|
|
3653
|
+
]
|
|
3654
|
+
});
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3634
3657
|
//#endregion
|
|
3635
3658
|
//#region src/tui/moves.ts
|
|
3636
3659
|
/**
|
|
@@ -3696,7 +3719,7 @@ function SearchBox({ width, onOpen, onClose }) {
|
|
|
3696
3719
|
flexDirection: "column",
|
|
3697
3720
|
width,
|
|
3698
3721
|
borderStyle: "round",
|
|
3699
|
-
borderColor: theme.
|
|
3722
|
+
borderColor: theme.border,
|
|
3700
3723
|
paddingX: 1,
|
|
3701
3724
|
children: [
|
|
3702
3725
|
/* @__PURE__ */ jsxs(Text, { children: [
|
|
@@ -3833,6 +3856,12 @@ const HELP = [
|
|
|
3833
3856
|
["r", "retry saving now"],
|
|
3834
3857
|
["q", "quit (saves first)"]
|
|
3835
3858
|
];
|
|
3859
|
+
/**
|
|
3860
|
+
* The mark sits beside the keys only when both fit: the longest line, a gap,
|
|
3861
|
+
* the 16-pixel mark, and the box's border and padding. Narrower, the lines
|
|
3862
|
+
* would wrap and split the mark.
|
|
3863
|
+
*/
|
|
3864
|
+
const HELP_WITH_MARK = 11 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
|
|
3836
3865
|
function Help({ width, onClose }) {
|
|
3837
3866
|
useInput((input, key) => {
|
|
3838
3867
|
if (key.escape || key.return || input === "?" || input === "q") onClose();
|
|
@@ -3840,7 +3869,7 @@ function Help({ width, onClose }) {
|
|
|
3840
3869
|
return /* @__PURE__ */ jsxs(Box, {
|
|
3841
3870
|
width,
|
|
3842
3871
|
borderStyle: "round",
|
|
3843
|
-
borderColor: theme.
|
|
3872
|
+
borderColor: theme.border,
|
|
3844
3873
|
paddingX: 1,
|
|
3845
3874
|
children: [/* @__PURE__ */ jsxs(Box, {
|
|
3846
3875
|
flexDirection: "column",
|
|
@@ -3860,7 +3889,7 @@ function Help({ width, onClose }) {
|
|
|
3860
3889
|
children: "Everything saves as you go, and shows on the website for everyone."
|
|
3861
3890
|
})
|
|
3862
3891
|
]
|
|
3863
|
-
}), width >=
|
|
3892
|
+
}), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
|
|
3864
3893
|
flexDirection: "column",
|
|
3865
3894
|
alignItems: "center",
|
|
3866
3895
|
marginLeft: 2,
|
|
@@ -3942,7 +3971,7 @@ function Palette({ width, base, open, onClose }) {
|
|
|
3942
3971
|
flexDirection: "column",
|
|
3943
3972
|
width,
|
|
3944
3973
|
borderStyle: "round",
|
|
3945
|
-
borderColor: theme.
|
|
3974
|
+
borderColor: theme.border,
|
|
3946
3975
|
paddingX: 1,
|
|
3947
3976
|
children: [
|
|
3948
3977
|
/* @__PURE__ */ jsxs(Text, { children: [
|
|
@@ -4332,7 +4361,7 @@ function MilestonePanel({ id, width, height, go, onClose }) {
|
|
|
4332
4361
|
{
|
|
4333
4362
|
label: "Due",
|
|
4334
4363
|
value: `${shortDay(milestone.dueDate, today())}${late ? " (late)" : ""}`,
|
|
4335
|
-
color: late ?
|
|
4364
|
+
color: late ? theme.bad : void 0,
|
|
4336
4365
|
edit: {
|
|
4337
4366
|
kind: "date",
|
|
4338
4367
|
initial: milestone.dueDate,
|
|
@@ -4795,7 +4824,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4795
4824
|
}) : m.name,
|
|
4796
4825
|
" ",
|
|
4797
4826
|
/* @__PURE__ */ jsxs(Text, {
|
|
4798
|
-
color: late ?
|
|
4827
|
+
color: late ? theme.bad : theme.muted,
|
|
4799
4828
|
children: [late ? "late · " : "", shortDay(m.dueDate, today())]
|
|
4800
4829
|
}),
|
|
4801
4830
|
m.approverId ? /* @__PURE__ */ jsx(Text, {
|
|
@@ -5064,7 +5093,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5064
5093
|
flexDirection: "column",
|
|
5065
5094
|
width: w,
|
|
5066
5095
|
borderStyle: "round",
|
|
5067
|
-
borderColor: on ? theme.accent :
|
|
5096
|
+
borderColor: on ? theme.accent : theme.border,
|
|
5068
5097
|
paddingX: 1,
|
|
5069
5098
|
children: [
|
|
5070
5099
|
/* @__PURE__ */ jsxs(Text, {
|
|
@@ -5075,7 +5104,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5075
5104
|
c.label.toUpperCase(),
|
|
5076
5105
|
" ",
|
|
5077
5106
|
/* @__PURE__ */ jsxs(Text, {
|
|
5078
|
-
color: over ?
|
|
5107
|
+
color: over ? theme.bad : theme.muted,
|
|
5079
5108
|
children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
|
|
5080
5109
|
})
|
|
5081
5110
|
]
|
|
@@ -5140,7 +5169,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5140
5169
|
]
|
|
5141
5170
|
});
|
|
5142
5171
|
}
|
|
5143
|
-
/** One card on the board: its key,
|
|
5172
|
+
/** One card on the board: its key, due date and owner, then the title on up to two lines. */
|
|
5144
5173
|
function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
5145
5174
|
return /* @__PURE__ */ jsxs(Box, {
|
|
5146
5175
|
flexDirection: "column",
|
|
@@ -5156,17 +5185,17 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
|
5156
5185
|
}),
|
|
5157
5186
|
" ",
|
|
5158
5187
|
/* @__PURE__ */ jsx(PriorityMark, { priority }),
|
|
5159
|
-
owner ? /* @__PURE__ */ jsxs(Text, {
|
|
5160
|
-
color: selected ? void 0 : theme.muted,
|
|
5161
|
-
children: [" ", owner]
|
|
5162
|
-
}) : null,
|
|
5163
5188
|
due ? /* @__PURE__ */ jsxs(Text, {
|
|
5164
|
-
color: late ?
|
|
5189
|
+
color: late ? theme.bad : selected ? void 0 : theme.muted,
|
|
5165
5190
|
children: [" ", due]
|
|
5166
5191
|
}) : null,
|
|
5167
5192
|
waits ? /* @__PURE__ */ jsx(Text, {
|
|
5168
|
-
color:
|
|
5193
|
+
color: theme.warn,
|
|
5169
5194
|
children: " waits"
|
|
5195
|
+
}) : null,
|
|
5196
|
+
owner ? /* @__PURE__ */ jsxs(Text, {
|
|
5197
|
+
color: selected ? void 0 : theme.muted,
|
|
5198
|
+
children: [" ", owner]
|
|
5170
5199
|
}) : null
|
|
5171
5200
|
]
|
|
5172
5201
|
}), /* @__PURE__ */ jsx(Box, {
|
|
@@ -5183,7 +5212,7 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
|
5183
5212
|
/** A project's money: budget, spent, what's left and the forecast, then the ledger. */
|
|
5184
5213
|
function BudgetPane({ project, spendEntries, at, active, height, today }) {
|
|
5185
5214
|
const b = budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date(`${today}T12:00:00`));
|
|
5186
|
-
const tone = b.forecast?.tone === "over" ?
|
|
5215
|
+
const tone = b.forecast?.tone === "over" ? theme.bad : b.forecast?.tone === "tight" ? theme.warn : theme.good;
|
|
5187
5216
|
return /* @__PURE__ */ jsxs(Box, {
|
|
5188
5217
|
flexDirection: "column",
|
|
5189
5218
|
height,
|
|
@@ -5211,7 +5240,7 @@ function BudgetPane({ project, spendEntries, at, active, height, today }) {
|
|
|
5211
5240
|
" ",
|
|
5212
5241
|
/* @__PURE__ */ jsxs(Text, {
|
|
5213
5242
|
bold: true,
|
|
5214
|
-
color: b.left < 0 ?
|
|
5243
|
+
color: b.left < 0 ? theme.bad : void 0,
|
|
5215
5244
|
children: [money(Math.abs(b.left)), b.left < 0 ? " over" : ""]
|
|
5216
5245
|
})
|
|
5217
5246
|
] }),
|
|
@@ -5438,7 +5467,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5438
5467
|
status: STATUS_LABEL[task.status],
|
|
5439
5468
|
assignee: owner?.name ?? "Nobody yet",
|
|
5440
5469
|
due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
|
|
5441
|
-
priority: task.priority,
|
|
5470
|
+
priority: PRIORITY_LABEL[task.priority],
|
|
5442
5471
|
project: project ? `${project.name}${project.key ? ` (${project.key})` : ""}` : "",
|
|
5443
5472
|
notes: task.description.replace(/\s+/g, " ").trim() || "None"
|
|
5444
5473
|
};
|
|
@@ -5499,7 +5528,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5499
5528
|
} else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
|
|
5500
5529
|
title: "Priority",
|
|
5501
5530
|
choices: PRIORITIES.map((p) => ({
|
|
5502
|
-
label: p,
|
|
5531
|
+
label: PRIORITY_LABEL[p],
|
|
5503
5532
|
value: p
|
|
5504
5533
|
})),
|
|
5505
5534
|
initial: PRIORITIES.indexOf(task.priority),
|
|
@@ -5551,7 +5580,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5551
5580
|
width,
|
|
5552
5581
|
height,
|
|
5553
5582
|
borderStyle: "round",
|
|
5554
|
-
borderColor: theme.
|
|
5583
|
+
borderColor: theme.border,
|
|
5555
5584
|
paddingX: 1,
|
|
5556
5585
|
children: [
|
|
5557
5586
|
/* @__PURE__ */ jsxs(Text, {
|
|
@@ -5568,7 +5597,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5568
5597
|
children: task.title
|
|
5569
5598
|
}),
|
|
5570
5599
|
viewOnly ? /* @__PURE__ */ jsx(Text, {
|
|
5571
|
-
color:
|
|
5600
|
+
color: theme.warn,
|
|
5572
5601
|
children: " view only"
|
|
5573
5602
|
}) : null
|
|
5574
5603
|
]
|
|
@@ -5588,7 +5617,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5588
5617
|
]
|
|
5589
5618
|
}, f.field)),
|
|
5590
5619
|
waits.length > 0 ? /* @__PURE__ */ jsxs(Text, {
|
|
5591
|
-
color:
|
|
5620
|
+
color: theme.warn,
|
|
5592
5621
|
wrap: "truncate-end",
|
|
5593
5622
|
children: [
|
|
5594
5623
|
" ",
|
|
@@ -5602,7 +5631,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5602
5631
|
children: [
|
|
5603
5632
|
" ",
|
|
5604
5633
|
"Checklist",
|
|
5605
|
-
task.checklist.length ? ` ${task.checklist.filter((c) => c.done).length}/${task.checklist.length}` : ""
|
|
5634
|
+
task.checklist.length ? ` ${task.checklist.filter((c) => c.done).length}/${task.checklist.length}` : " · none yet, a adds a step"
|
|
5606
5635
|
]
|
|
5607
5636
|
}),
|
|
5608
5637
|
task.checklist.map((item, i) => {
|
|
@@ -5772,7 +5801,7 @@ function TodayView({ active, width, height, go }) {
|
|
|
5772
5801
|
children: [
|
|
5773
5802
|
" ",
|
|
5774
5803
|
/* @__PURE__ */ jsx(Text, {
|
|
5775
|
-
color: r.kind === "risk" ?
|
|
5804
|
+
color: r.kind === "risk" ? theme.warn : r.kind === "issue" ? theme.bad : theme.muted,
|
|
5776
5805
|
children: r.kind
|
|
5777
5806
|
}),
|
|
5778
5807
|
" ",
|
|
@@ -5826,14 +5855,15 @@ const NAV = [
|
|
|
5826
5855
|
label: "History"
|
|
5827
5856
|
}
|
|
5828
5857
|
];
|
|
5829
|
-
/** The sidebar's
|
|
5830
|
-
const
|
|
5858
|
+
/** The sidebar's width: its longest line ("8 History", ": Anything") with room either side. */
|
|
5859
|
+
const NAV_WIDTH = 15;
|
|
5860
|
+
/** What the corner says about saving, and in which of the theme's colours. */
|
|
5831
5861
|
const SAVE_LABEL = {
|
|
5832
|
-
saved: ["● saved", "
|
|
5833
|
-
pending: ["● saving soon", "
|
|
5834
|
-
saving: ["● saving…", "
|
|
5835
|
-
retrying: ["● not saved, retrying", "
|
|
5836
|
-
offline: ["● offline, will save", "
|
|
5862
|
+
saved: ["● saved", "good"],
|
|
5863
|
+
pending: ["● saving soon", "warn"],
|
|
5864
|
+
saving: ["● saving…", "warn"],
|
|
5865
|
+
retrying: ["● not saved, retrying", "bad"],
|
|
5866
|
+
offline: ["● offline, will save", "bad"]
|
|
5837
5867
|
};
|
|
5838
5868
|
function App({ ui }) {
|
|
5839
5869
|
const { store, sync, account, harbors, today } = useDeps();
|
|
@@ -6021,11 +6051,10 @@ function App({ ui }) {
|
|
|
6021
6051
|
if (input === "u" && !store.undoLast()) notify("Nothing to undo.");
|
|
6022
6052
|
}, { isActive: !covered && panel !== null });
|
|
6023
6053
|
const bodyHeight = Math.max(8, rows - 1 - 2);
|
|
6024
|
-
const
|
|
6025
|
-
const navWidth = (mark?.[0]?.length ?? LOGO_MINI.length) + 3;
|
|
6026
|
-
const mainWidth = Math.max(30, columns - navWidth - 1);
|
|
6054
|
+
const mainWidth = Math.max(30, columns - NAV_WIDTH - 1);
|
|
6027
6055
|
const mainActive = focus === "main" && !covered && panel === null;
|
|
6028
|
-
const [saveText,
|
|
6056
|
+
const [saveText, saveTone] = SAVE_LABEL[save];
|
|
6057
|
+
const saveColor = theme[saveTone];
|
|
6029
6058
|
const viewProps = {
|
|
6030
6059
|
active: mainActive,
|
|
6031
6060
|
width: mainWidth,
|
|
@@ -6256,7 +6285,7 @@ function App({ ui }) {
|
|
|
6256
6285
|
children: harborName ?? "…"
|
|
6257
6286
|
}),
|
|
6258
6287
|
viewOnly ? /* @__PURE__ */ jsx(Text, {
|
|
6259
|
-
color:
|
|
6288
|
+
color: theme.warn,
|
|
6260
6289
|
children: " · view only"
|
|
6261
6290
|
}) : null
|
|
6262
6291
|
]
|
|
@@ -6272,13 +6301,13 @@ function App({ ui }) {
|
|
|
6272
6301
|
height: bodyHeight,
|
|
6273
6302
|
children: [/* @__PURE__ */ jsxs(Box, {
|
|
6274
6303
|
flexDirection: "column",
|
|
6275
|
-
width:
|
|
6304
|
+
width: NAV_WIDTH,
|
|
6276
6305
|
paddingX: 1,
|
|
6277
6306
|
borderStyle: "single",
|
|
6278
6307
|
borderTop: false,
|
|
6279
6308
|
borderBottom: false,
|
|
6280
6309
|
borderLeft: false,
|
|
6281
|
-
borderColor: focus === "nav" ? theme.accent :
|
|
6310
|
+
borderColor: focus === "nav" ? theme.accent : theme.border,
|
|
6282
6311
|
children: [
|
|
6283
6312
|
NAV.map((x, i) => {
|
|
6284
6313
|
const on = x.screen === screen && stack.length === 0;
|
|
@@ -6300,8 +6329,7 @@ function App({ ui }) {
|
|
|
6300
6329
|
/* @__PURE__ */ jsx(Text, {
|
|
6301
6330
|
color: theme.muted,
|
|
6302
6331
|
children: ": Anything"
|
|
6303
|
-
})
|
|
6304
|
-
mark ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Box, { flexGrow: 1 }), /* @__PURE__ */ jsx(Logo, { pixels: mark })] }) : null
|
|
6332
|
+
})
|
|
6305
6333
|
]
|
|
6306
6334
|
}), /* @__PURE__ */ jsxs(Box, {
|
|
6307
6335
|
flexDirection: "column",
|
|
@@ -7036,12 +7064,24 @@ function sessionSlice({ set, get, write }) {
|
|
|
7036
7064
|
keepInterval: k.interval,
|
|
7037
7065
|
stripeReady: k.stripe
|
|
7038
7066
|
}),
|
|
7039
|
-
ensureAccountPerson: ({ accountId, name }) => {
|
|
7067
|
+
ensureAccountPerson: ({ accountId, name, personId }) => {
|
|
7040
7068
|
const hit = get().people.find((p) => p.accountId === accountId);
|
|
7041
7069
|
if (hit) {
|
|
7042
7070
|
if (get().currentUserId !== hit.id) set({ currentUserId: hit.id });
|
|
7043
7071
|
return hit.id;
|
|
7044
7072
|
}
|
|
7073
|
+
const named = personId ? get().people.find((p) => p.id === personId && !p.accountId) : null;
|
|
7074
|
+
if (named) {
|
|
7075
|
+
set({
|
|
7076
|
+
people: get().people.map((p) => p.id === named.id ? {
|
|
7077
|
+
...p,
|
|
7078
|
+
accountId
|
|
7079
|
+
} : p),
|
|
7080
|
+
currentUserId: named.id
|
|
7081
|
+
});
|
|
7082
|
+
get().log("came aboard", "/people");
|
|
7083
|
+
return named.id;
|
|
7084
|
+
}
|
|
7045
7085
|
const used = new Set(get().people.map((p) => p.swatch));
|
|
7046
7086
|
const swatch = SWATCHES.find((s) => !used.has(s)) ?? "pine";
|
|
7047
7087
|
const id = uid("p");
|
|
@@ -7402,7 +7442,8 @@ function hierarchySlice({ get, write }) {
|
|
|
7402
7442
|
write({
|
|
7403
7443
|
programs: add(state.programs, rows.programs),
|
|
7404
7444
|
projects: add(state.projects, rows.projects),
|
|
7405
|
-
tasks: add(state.tasks, rows.tasks)
|
|
7445
|
+
tasks: add(state.tasks, rows.tasks),
|
|
7446
|
+
people: add(state.people, rows.people)
|
|
7406
7447
|
});
|
|
7407
7448
|
const n = rows.tasks?.length ?? 0;
|
|
7408
7449
|
get().log(`imported ${n} task${n === 1 ? "" : "s"} from a spreadsheet`);
|
|
@@ -7412,12 +7453,18 @@ function hierarchySlice({ get, write }) {
|
|
|
7412
7453
|
const gone = {
|
|
7413
7454
|
programs: new Set(ids.programs ?? []),
|
|
7414
7455
|
projects: new Set(ids.projects ?? []),
|
|
7415
|
-
tasks: new Set(ids.tasks ?? [])
|
|
7456
|
+
tasks: new Set(ids.tasks ?? []),
|
|
7457
|
+
people: new Set(ids.people ?? [])
|
|
7416
7458
|
};
|
|
7459
|
+
const tasks = state.tasks.filter((r) => !gone.tasks.has(r.id));
|
|
7460
|
+
if (gone.people.size) {
|
|
7461
|
+
for (const t of tasks) if (t.assigneeId) gone.people.delete(t.assigneeId);
|
|
7462
|
+
}
|
|
7417
7463
|
write({
|
|
7418
7464
|
programs: state.programs.filter((r) => !gone.programs.has(r.id)),
|
|
7419
7465
|
projects: state.projects.filter((r) => !gone.projects.has(r.id)),
|
|
7420
|
-
tasks
|
|
7466
|
+
tasks,
|
|
7467
|
+
people: gone.people.size ? state.people.filter((r) => !gone.people.has(r.id)) : state.people,
|
|
7421
7468
|
selectedTaskId: gone.tasks.has(state.selectedTaskId ?? "") ? null : state.selectedTaskId
|
|
7422
7469
|
});
|
|
7423
7470
|
},
|
|
@@ -8899,6 +8946,53 @@ function createSync(opts) {
|
|
|
8899
8946
|
};
|
|
8900
8947
|
}
|
|
8901
8948
|
|
|
8949
|
+
//#endregion
|
|
8950
|
+
//#region src/tui/theme-detect.ts
|
|
8951
|
+
const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
|
|
8952
|
+
/** COLORFGBG is "fg;bg" (sometimes "fg;x;bg"): background 7 or 15 is a light terminal. */
|
|
8953
|
+
function themeFromColorFgBg(value) {
|
|
8954
|
+
const bg = value?.split(";").at(-1);
|
|
8955
|
+
if (!bg || !/^\d+$/.test(bg)) return null;
|
|
8956
|
+
return bg === "7" || bg === "15" ? "light" : "dark";
|
|
8957
|
+
}
|
|
8958
|
+
/** A terminal's answer to OSC 11, like "\x1b]11;rgb:ffff/ffff/ffff\x07": light when its luminance is over half. */
|
|
8959
|
+
function themeFromReply(reply) {
|
|
8960
|
+
const m = /rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(reply);
|
|
8961
|
+
if (!m) return null;
|
|
8962
|
+
const [r, g, b] = m.slice(1, 4).map((hex) => parseInt(hex, 16) / (16 ** hex.length - 1));
|
|
8963
|
+
return .2126 * r + .7152 * g + .0722 * b > .5 ? "light" : "dark";
|
|
8964
|
+
}
|
|
8965
|
+
/** Ask the terminal for its background colour; null when it doesn't say in time. */
|
|
8966
|
+
function askBackground(stdin, stdout, ms = 300) {
|
|
8967
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") return Promise.resolve(null);
|
|
8968
|
+
return new Promise((resolve) => {
|
|
8969
|
+
let reply = "";
|
|
8970
|
+
const done = (theme) => {
|
|
8971
|
+
clearTimeout(timer);
|
|
8972
|
+
stdin.off("data", onData);
|
|
8973
|
+
stdin.setRawMode(false);
|
|
8974
|
+
stdin.pause();
|
|
8975
|
+
resolve(theme);
|
|
8976
|
+
};
|
|
8977
|
+
const onData = (chunk) => {
|
|
8978
|
+
reply += chunk.toString();
|
|
8979
|
+
if (/\x07|\x1b\\/.test(reply)) done(themeFromReply(reply));
|
|
8980
|
+
};
|
|
8981
|
+
const timer = setTimeout(() => done(null), ms);
|
|
8982
|
+
stdin.setRawMode(true);
|
|
8983
|
+
stdin.resume();
|
|
8984
|
+
stdin.on("data", onData);
|
|
8985
|
+
stdout.write("\x1B]11;?\x1B\\");
|
|
8986
|
+
});
|
|
8987
|
+
}
|
|
8988
|
+
/** The set to draw with, from the saved setting, the environment, then the terminal itself. */
|
|
8989
|
+
async function pickTheme(setting, env = process.env, ask = () => askBackground(process.stdin, process.stdout)) {
|
|
8990
|
+
const fromEnv = env.HARBOR_THEME?.toLowerCase();
|
|
8991
|
+
const chosen = isThemeSetting(fromEnv) ? fromEnv : setting;
|
|
8992
|
+
if (chosen === "light" || chosen === "dark") return chosen;
|
|
8993
|
+
return themeFromColorFgBg(env.COLORFGBG) ?? await ask() ?? "dark";
|
|
8994
|
+
}
|
|
8995
|
+
|
|
8902
8996
|
//#endregion
|
|
8903
8997
|
//#region src/tui/ui-state.ts
|
|
8904
8998
|
/** What the shell shows beside the harbor: the save state and the latest notice. */
|
|
@@ -8924,6 +9018,7 @@ const SPLASH_MS = 1200;
|
|
|
8924
9018
|
* most, the store and the sync loop, drawn with Ink.
|
|
8925
9019
|
*/
|
|
8926
9020
|
async function startApp(deps, options = {}) {
|
|
9021
|
+
applyTheme(deps.theme ?? "dark");
|
|
8927
9022
|
const transport = plainTransport(deps.transport);
|
|
8928
9023
|
const account = plainDeep(deps.account);
|
|
8929
9024
|
const harbors = plainDeep(deps.harbors);
|
|
@@ -8970,6 +9065,7 @@ async function startApp(deps, options = {}) {
|
|
|
8970
9065
|
}
|
|
8971
9066
|
/** `harbor`: sign-in already done, take over the terminal until q. */
|
|
8972
9067
|
async function runApp(opts) {
|
|
9068
|
+
const theme = await pickTheme(opts.theme);
|
|
8973
9069
|
const transport = httpTransport(opts.source, opts.fetcher);
|
|
8974
9070
|
const { account, harbors } = await transport.harbors();
|
|
8975
9071
|
if (harbors.length === 0) throw new Error("You don't belong to a harbor yet. Make one at harborppm.com first.");
|
|
@@ -8981,7 +9077,8 @@ async function runApp(opts) {
|
|
|
8981
9077
|
account,
|
|
8982
9078
|
harbors,
|
|
8983
9079
|
workspaceId: pick?.id,
|
|
8984
|
-
splashMs: SPLASH_MS
|
|
9080
|
+
splashMs: SPLASH_MS,
|
|
9081
|
+
theme
|
|
8985
9082
|
}, { alternateScreen: true });
|
|
8986
9083
|
await instance.waitUntilExit();
|
|
8987
9084
|
}
|
package/lib/cli.mjs
CHANGED
|
@@ -8,7 +8,14 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import { parseArgs } from "./args.mjs";
|
|
9
9
|
import { hasFullAccess, loadApp, tokenSource } from "./app.mjs";
|
|
10
10
|
import { login } from "./auth.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_SERVER,
|
|
13
|
+
forgetCredentials,
|
|
14
|
+
readCredentials,
|
|
15
|
+
readSettings,
|
|
16
|
+
writeCredentials,
|
|
17
|
+
writeSettings,
|
|
18
|
+
} from "./config.mjs";
|
|
12
19
|
import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
|
|
13
20
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
14
21
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
@@ -20,6 +27,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
20
27
|
harbor login [--server URL] [--read-only] sign in (opens your browser once)
|
|
21
28
|
harbor logout forget the sign-in on this machine
|
|
22
29
|
harbor harbors the harbors you belong to
|
|
30
|
+
harbor theme [auto|light|dark] the app's colours (auto asks the terminal)
|
|
23
31
|
harbor --version which version this is
|
|
24
32
|
|
|
25
33
|
harbor mine what's on you, late first
|
|
@@ -55,6 +63,8 @@ function banner(io, env, words) {
|
|
|
55
63
|
return `\n${logoLines(depth, words({ bold, dim })).join("\n")}\n`;
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
const THEMES = ["auto", "light", "dark"];
|
|
67
|
+
|
|
58
68
|
const COMMON = { flags: ["json", "help"], options: ["harbor"] };
|
|
59
69
|
const spec = (flags = [], options = []) => ({
|
|
60
70
|
flags: [...COMMON.flags, ...flags],
|
|
@@ -184,6 +194,7 @@ export async function run(argv, io = {}) {
|
|
|
184
194
|
source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
|
|
185
195
|
fetcher,
|
|
186
196
|
harbor: values.harbor,
|
|
197
|
+
theme: (await readSettings(env)).theme,
|
|
187
198
|
});
|
|
188
199
|
return 0;
|
|
189
200
|
}
|
|
@@ -216,6 +227,26 @@ export async function run(argv, io = {}) {
|
|
|
216
227
|
return 0;
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
if (command === "theme") {
|
|
231
|
+
const { positionals } = parseArgs(rest, { flags: [], options: [] });
|
|
232
|
+
const settings = await readSettings(env);
|
|
233
|
+
const [choice] = positionals;
|
|
234
|
+
if (!choice) {
|
|
235
|
+
out(`The app's colours: ${settings.theme ?? "auto"}.`);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
if (!THEMES.includes(choice)) {
|
|
239
|
+
throw new HarborError(`Pick auto, light or dark (not "${choice}").`);
|
|
240
|
+
}
|
|
241
|
+
await writeSettings({ ...settings, theme: choice }, env);
|
|
242
|
+
out(
|
|
243
|
+
choice === "auto"
|
|
244
|
+
? "The app will ask the terminal for its background and match it."
|
|
245
|
+
: `The app will use its ${choice} colours.`,
|
|
246
|
+
);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
219
250
|
if (command === "logout") {
|
|
220
251
|
await forgetCredentials(env);
|
|
221
252
|
out(
|
package/lib/config.mjs
CHANGED
|
@@ -38,3 +38,19 @@ export async function writeCredentials(creds, env = process.env) {
|
|
|
38
38
|
export async function forgetCredentials(env = process.env) {
|
|
39
39
|
await rm(file(env), { force: true });
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
const settingsFile = (env) => join(configDir(env), "settings.json");
|
|
43
|
+
|
|
44
|
+
/** Choices that aren't a sign-in (the app's colours), or {} when none are saved. */
|
|
45
|
+
export async function readSettings(env = process.env) {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(await readFile(settingsFile(env), "utf8"));
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function writeSettings(settings, env = process.env) {
|
|
54
|
+
await mkdir(configDir(env), { recursive: true, mode: 0o700 });
|
|
55
|
+
await writeFile(settingsFile(env), JSON.stringify(settings, null, 2));
|
|
56
|
+
}
|
package/lib/logo.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export const LOGO = [
|
|
|
37
37
|
"...TTTTTTTTTTTTTTTTTT...",
|
|
38
38
|
];
|
|
39
39
|
|
|
40
|
-
/** 16 by 16, for
|
|
40
|
+
/** 16 by 16, for the keys overlay. */
|
|
41
41
|
export const LOGO_SMALL = [
|
|
42
42
|
"..TTTTTTTTTTTT..",
|
|
43
43
|
".TTTTTTTTTTTTTT.",
|
|
@@ -57,22 +57,6 @@ export const LOGO_SMALL = [
|
|
|
57
57
|
"..TTTTTTTTTTTT..",
|
|
58
58
|
];
|
|
59
59
|
|
|
60
|
-
/** 12 by 12, for the full-screen app's sidebar: posts and side boats a pixel wide, no bows. */
|
|
61
|
-
export const LOGO_MINI = [
|
|
62
|
-
"..TTTTTTTT..",
|
|
63
|
-
".TTTTTTTTTT.",
|
|
64
|
-
"TTTWTTTTWTTT",
|
|
65
|
-
"TCTWTTTTWTTT",
|
|
66
|
-
"TCTWTTTTWTTT",
|
|
67
|
-
"TCTWWWWWWTTT",
|
|
68
|
-
"TTTWWWWWWTTT",
|
|
69
|
-
"TTTWTTTTWTCT",
|
|
70
|
-
"TTTWTCCTWTCT",
|
|
71
|
-
"TTTWTCCTWTCT",
|
|
72
|
-
".TTTTTTTTTT.",
|
|
73
|
-
"..TTTTTTTT..",
|
|
74
|
-
];
|
|
75
|
-
|
|
76
60
|
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
77
61
|
export const LOGO_COLOURS = { T: "#0f6e64", W: "#ffffff", C: "#c44a2c" };
|
|
78
62
|
|