@harborppm/cli 0.4.0 → 0.5.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 +3 -1
- package/dist/app.mjs +335 -241
- 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,65 @@ 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, with the plain
|
|
2082
|
+
* colours every terminal draws for status. Light: the brand's own darker teal
|
|
2083
|
+
* and coral, and status in hues dark enough for text on white (4.5:1 or more).
|
|
2084
|
+
*/
|
|
2085
|
+
const PALETTES = {
|
|
2086
|
+
dark: {
|
|
2087
|
+
accent: "#3dbcab",
|
|
2088
|
+
coral: "#e0714f",
|
|
2089
|
+
muted: "gray",
|
|
2090
|
+
selected: "#3dbcab",
|
|
2091
|
+
border: "gray",
|
|
2092
|
+
good: "green",
|
|
2093
|
+
warn: "yellow",
|
|
2094
|
+
bad: "red",
|
|
2095
|
+
done: "cyan"
|
|
2096
|
+
},
|
|
2097
|
+
light: {
|
|
2098
|
+
accent: "#0f6e64",
|
|
2099
|
+
coral: "#b8432c",
|
|
2100
|
+
muted: "#5f5e58",
|
|
2101
|
+
selected: "#0f6e64",
|
|
2102
|
+
border: "#a8a69e",
|
|
2103
|
+
good: "#1f7a3a",
|
|
2104
|
+
warn: "#8a5a0a",
|
|
2105
|
+
bad: "#b8432c",
|
|
2106
|
+
done: "#146c6b"
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
const theme = {
|
|
2110
|
+
name: "dark",
|
|
2111
|
+
...PALETTES.dark
|
|
2112
|
+
};
|
|
2113
|
+
const HEALTH_COLOR = {};
|
|
2114
|
+
const PRIORITY_COLOR = {};
|
|
2115
|
+
/** Switch every colour to one set. Call before the app draws. */
|
|
2116
|
+
function applyTheme(name) {
|
|
2117
|
+
Object.assign(theme, {
|
|
2118
|
+
name,
|
|
2119
|
+
...PALETTES[name]
|
|
2120
|
+
});
|
|
2121
|
+
Object.assign(HEALTH_COLOR, {
|
|
2122
|
+
"on-track": theme.good,
|
|
2123
|
+
"at-risk": theme.warn,
|
|
2124
|
+
"off-track": theme.bad,
|
|
2125
|
+
paused: theme.muted,
|
|
2126
|
+
done: theme.done
|
|
2127
|
+
});
|
|
2128
|
+
Object.assign(PRIORITY_COLOR, {
|
|
2129
|
+
urgent: theme.bad,
|
|
2130
|
+
high: theme.coral,
|
|
2131
|
+
medium: void 0,
|
|
2132
|
+
low: theme.muted
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
applyTheme("dark");
|
|
2136
|
+
|
|
2274
2137
|
//#endregion
|
|
2275
2138
|
//#region src/tui/ui.tsx
|
|
2276
2139
|
/**
|
|
@@ -2292,7 +2155,7 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
|
|
|
2292
2155
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2293
2156
|
flexDirection: "column",
|
|
2294
2157
|
borderStyle: "round",
|
|
2295
|
-
borderColor: theme.
|
|
2158
|
+
borderColor: theme.border,
|
|
2296
2159
|
paddingX: 1,
|
|
2297
2160
|
children: [
|
|
2298
2161
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -2326,7 +2189,7 @@ function Picker({ title, choices, initial = 0, rows = 10, onPick, onCancel }) {
|
|
|
2326
2189
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2327
2190
|
flexDirection: "column",
|
|
2328
2191
|
borderStyle: "round",
|
|
2329
|
-
borderColor: theme.
|
|
2192
|
+
borderColor: theme.border,
|
|
2330
2193
|
paddingX: 1,
|
|
2331
2194
|
children: [
|
|
2332
2195
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -2395,7 +2258,6 @@ function Hints({ keys }) {
|
|
|
2395
2258
|
children: keys.map(([k, label], i) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
2396
2259
|
i > 0 ? " " : "",
|
|
2397
2260
|
/* @__PURE__ */ jsx(Text, {
|
|
2398
|
-
color: theme.accent,
|
|
2399
2261
|
bold: true,
|
|
2400
2262
|
children: k
|
|
2401
2263
|
}),
|
|
@@ -2423,7 +2285,6 @@ function Heading({ children, right }) {
|
|
|
2423
2285
|
justifyContent: "space-between",
|
|
2424
2286
|
children: [/* @__PURE__ */ jsx(Text, {
|
|
2425
2287
|
bold: true,
|
|
2426
|
-
color: theme.accent,
|
|
2427
2288
|
children
|
|
2428
2289
|
}), right ? /* @__PURE__ */ jsx(Text, {
|
|
2429
2290
|
color: theme.muted,
|
|
@@ -2518,7 +2379,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2518
2379
|
width,
|
|
2519
2380
|
height,
|
|
2520
2381
|
borderStyle: "round",
|
|
2521
|
-
borderColor: theme.
|
|
2382
|
+
borderColor: theme.border,
|
|
2522
2383
|
paddingX: 1,
|
|
2523
2384
|
children: [
|
|
2524
2385
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -2861,7 +2722,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
|
|
|
2861
2722
|
" ",
|
|
2862
2723
|
/* @__PURE__ */ jsx(PriorityMark, { priority: task.priority }),
|
|
2863
2724
|
task.dueDate ? /* @__PURE__ */ jsxs(Text, {
|
|
2864
|
-
color: late ?
|
|
2725
|
+
color: late ? theme.bad : theme.muted,
|
|
2865
2726
|
children: [
|
|
2866
2727
|
" ",
|
|
2867
2728
|
late ? "late · " : "",
|
|
@@ -2869,7 +2730,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
|
|
|
2869
2730
|
]
|
|
2870
2731
|
}) : null,
|
|
2871
2732
|
blocked ? /* @__PURE__ */ jsx(Text, {
|
|
2872
|
-
color:
|
|
2733
|
+
color: theme.warn,
|
|
2873
2734
|
children: " waits"
|
|
2874
2735
|
}) : null,
|
|
2875
2736
|
who ? /* @__PURE__ */ jsxs(Text, {
|
|
@@ -3023,7 +2884,7 @@ function HistoryView({ active, width, height, go }) {
|
|
|
3023
2884
|
children: "Loading…"
|
|
3024
2885
|
}) : null,
|
|
3025
2886
|
state === "failed" ? /* @__PURE__ */ jsx(Text, {
|
|
3026
|
-
color:
|
|
2887
|
+
color: theme.bad,
|
|
3027
2888
|
children: "Couldn’t load History. It tries again after the next save."
|
|
3028
2889
|
}) : null,
|
|
3029
2890
|
state === "ready" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
@@ -3631,6 +3492,165 @@ function PeopleView({ active, width, height, go }) {
|
|
|
3631
3492
|
});
|
|
3632
3493
|
}
|
|
3633
3494
|
|
|
3495
|
+
//#endregion
|
|
3496
|
+
//#region cli/lib/logo.mjs
|
|
3497
|
+
/**
|
|
3498
|
+
* Harbor's mark for the terminal: the favicon (public/favicon.svg) as
|
|
3499
|
+
* pixels, a teal tile with the white H and three coral boats. Each
|
|
3500
|
+
* terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
|
|
3501
|
+
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
3502
|
+
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
3503
|
+
*
|
|
3504
|
+
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
3505
|
+
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
3506
|
+
*/
|
|
3507
|
+
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
3508
|
+
const LOGO = [
|
|
3509
|
+
"...TTTTTTTTTTTTTTTTTT...",
|
|
3510
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
3511
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
3512
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3513
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3514
|
+
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
3515
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3516
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3517
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3518
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3519
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3520
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3521
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3522
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3523
|
+
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
3524
|
+
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
3525
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3526
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3527
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3528
|
+
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
3529
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3530
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
3531
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
3532
|
+
"...TTTTTTTTTTTTTTTTTT..."
|
|
3533
|
+
];
|
|
3534
|
+
/** 16 by 16, for the keys overlay. */
|
|
3535
|
+
const LOGO_SMALL = [
|
|
3536
|
+
"..TTTTTTTTTTTT..",
|
|
3537
|
+
".TTTTTTTTTTTTTT.",
|
|
3538
|
+
"TTTTWWTTTTWWTTTT",
|
|
3539
|
+
"TTTTWWTTTTWWTTTT",
|
|
3540
|
+
"TPPTWWTTTTWWTTTT",
|
|
3541
|
+
"TCCTWWTTTTWWTTTT",
|
|
3542
|
+
"TCCTWWTTTTWWTTTT",
|
|
3543
|
+
"TTTTWWWWWWWWTTTT",
|
|
3544
|
+
"TTTTWWWWWWWWTTTT",
|
|
3545
|
+
"TTTTWWTTTTWWTPPT",
|
|
3546
|
+
"TTTTWWTPPTWWTCCT",
|
|
3547
|
+
"TTTTWWTCCTWWTCCT",
|
|
3548
|
+
"TTTTWWTCCTWWTTTT",
|
|
3549
|
+
"TTTTWWTTTTWWTTTT",
|
|
3550
|
+
".TTTTTTTTTTTTTT.",
|
|
3551
|
+
"..TTTTTTTTTTTT.."
|
|
3552
|
+
];
|
|
3553
|
+
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
3554
|
+
const LOGO_COLOURS = {
|
|
3555
|
+
T: "#0f6e64",
|
|
3556
|
+
W: "#ffffff",
|
|
3557
|
+
C: "#c44a2c"
|
|
3558
|
+
};
|
|
3559
|
+
/**
|
|
3560
|
+
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
3561
|
+
* One stretch of a terminal row in the same colours.
|
|
3562
|
+
*/
|
|
3563
|
+
/**
|
|
3564
|
+
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
3565
|
+
* half block's colour, the bottom one its background.
|
|
3566
|
+
* @param {string[]} pixels
|
|
3567
|
+
* @returns {Run[][]}
|
|
3568
|
+
*/
|
|
3569
|
+
function logoRows(pixels = LOGO) {
|
|
3570
|
+
/** @type {Run[][]} */
|
|
3571
|
+
const rows = [];
|
|
3572
|
+
for (let y = 0; y < pixels.length; y += 2) {
|
|
3573
|
+
/** @type {Run[]} */
|
|
3574
|
+
const runs = [];
|
|
3575
|
+
const top = pixels[y] ?? "";
|
|
3576
|
+
const bottom = pixels[y + 1] ?? "";
|
|
3577
|
+
for (let x = 0; x < top.length; x++) {
|
|
3578
|
+
const t = LOGO_COLOURS[top[x]];
|
|
3579
|
+
const b = LOGO_COLOURS[bottom[x]];
|
|
3580
|
+
const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
|
|
3581
|
+
const left = bowRow ? bowRow[x - 1] !== "P" : false;
|
|
3582
|
+
/** @type {Run} */
|
|
3583
|
+
const cell = top[x] === "P" ? {
|
|
3584
|
+
text: left ? "▟" : "▙",
|
|
3585
|
+
fg: LOGO_COLOURS.C,
|
|
3586
|
+
bg: LOGO_COLOURS.T
|
|
3587
|
+
} : bottom[x] === "P" ? {
|
|
3588
|
+
text: left ? "▗" : "▖",
|
|
3589
|
+
fg: LOGO_COLOURS.C,
|
|
3590
|
+
bg: t
|
|
3591
|
+
} : !t && !b ? { text: " " } : t === b || !b ? {
|
|
3592
|
+
text: t === b ? "█" : "▀",
|
|
3593
|
+
fg: t
|
|
3594
|
+
} : !t ? {
|
|
3595
|
+
text: "▄",
|
|
3596
|
+
fg: b
|
|
3597
|
+
} : {
|
|
3598
|
+
text: "▀",
|
|
3599
|
+
fg: t,
|
|
3600
|
+
bg: b
|
|
3601
|
+
};
|
|
3602
|
+
const last = runs.at(-1);
|
|
3603
|
+
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
3604
|
+
else runs.push(cell);
|
|
3605
|
+
}
|
|
3606
|
+
rows.push(runs);
|
|
3607
|
+
}
|
|
3608
|
+
return rows;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
//#endregion
|
|
3612
|
+
//#region src/tui/logo.tsx
|
|
3613
|
+
/** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
|
|
3614
|
+
function Logo({ pixels = LOGO }) {
|
|
3615
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
3616
|
+
flexDirection: "column",
|
|
3617
|
+
flexShrink: 0,
|
|
3618
|
+
children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
|
|
3619
|
+
color: r.fg,
|
|
3620
|
+
backgroundColor: r.bg,
|
|
3621
|
+
children: r.text
|
|
3622
|
+
}, i)) }, y))
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
/** What shows while the harbor loads: the mark, centred, and which harbor. */
|
|
3626
|
+
function Splash({ opening }) {
|
|
3627
|
+
const { columns, rows } = useWindowSize();
|
|
3628
|
+
const mark = rows >= 18 && columns >= 30;
|
|
3629
|
+
const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
|
|
3630
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
3631
|
+
width: columns,
|
|
3632
|
+
height: rows,
|
|
3633
|
+
flexDirection: "column",
|
|
3634
|
+
alignItems: "center",
|
|
3635
|
+
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
3636
|
+
children: [
|
|
3637
|
+
mark ? /* @__PURE__ */ jsx(Box, {
|
|
3638
|
+
marginBottom: 1,
|
|
3639
|
+
children: /* @__PURE__ */ jsx(Logo, {})
|
|
3640
|
+
}) : null,
|
|
3641
|
+
/* @__PURE__ */ jsx(Text, {
|
|
3642
|
+
bold: true,
|
|
3643
|
+
color: theme.accent,
|
|
3644
|
+
children: "Harbor"
|
|
3645
|
+
}),
|
|
3646
|
+
/* @__PURE__ */ jsx(Text, {
|
|
3647
|
+
color: theme.muted,
|
|
3648
|
+
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
3649
|
+
})
|
|
3650
|
+
]
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3634
3654
|
//#endregion
|
|
3635
3655
|
//#region src/tui/moves.ts
|
|
3636
3656
|
/**
|
|
@@ -3696,7 +3716,7 @@ function SearchBox({ width, onOpen, onClose }) {
|
|
|
3696
3716
|
flexDirection: "column",
|
|
3697
3717
|
width,
|
|
3698
3718
|
borderStyle: "round",
|
|
3699
|
-
borderColor: theme.
|
|
3719
|
+
borderColor: theme.border,
|
|
3700
3720
|
paddingX: 1,
|
|
3701
3721
|
children: [
|
|
3702
3722
|
/* @__PURE__ */ jsxs(Text, { children: [
|
|
@@ -3833,6 +3853,12 @@ const HELP = [
|
|
|
3833
3853
|
["r", "retry saving now"],
|
|
3834
3854
|
["q", "quit (saves first)"]
|
|
3835
3855
|
];
|
|
3856
|
+
/**
|
|
3857
|
+
* The mark sits beside the keys only when both fit: the longest line, a gap,
|
|
3858
|
+
* the 16-pixel mark, and the box's border and padding. Narrower, the lines
|
|
3859
|
+
* would wrap and split the mark.
|
|
3860
|
+
*/
|
|
3861
|
+
const HELP_WITH_MARK = 11 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
|
|
3836
3862
|
function Help({ width, onClose }) {
|
|
3837
3863
|
useInput((input, key) => {
|
|
3838
3864
|
if (key.escape || key.return || input === "?" || input === "q") onClose();
|
|
@@ -3840,7 +3866,7 @@ function Help({ width, onClose }) {
|
|
|
3840
3866
|
return /* @__PURE__ */ jsxs(Box, {
|
|
3841
3867
|
width,
|
|
3842
3868
|
borderStyle: "round",
|
|
3843
|
-
borderColor: theme.
|
|
3869
|
+
borderColor: theme.border,
|
|
3844
3870
|
paddingX: 1,
|
|
3845
3871
|
children: [/* @__PURE__ */ jsxs(Box, {
|
|
3846
3872
|
flexDirection: "column",
|
|
@@ -3860,7 +3886,7 @@ function Help({ width, onClose }) {
|
|
|
3860
3886
|
children: "Everything saves as you go, and shows on the website for everyone."
|
|
3861
3887
|
})
|
|
3862
3888
|
]
|
|
3863
|
-
}), width >=
|
|
3889
|
+
}), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
|
|
3864
3890
|
flexDirection: "column",
|
|
3865
3891
|
alignItems: "center",
|
|
3866
3892
|
marginLeft: 2,
|
|
@@ -3942,7 +3968,7 @@ function Palette({ width, base, open, onClose }) {
|
|
|
3942
3968
|
flexDirection: "column",
|
|
3943
3969
|
width,
|
|
3944
3970
|
borderStyle: "round",
|
|
3945
|
-
borderColor: theme.
|
|
3971
|
+
borderColor: theme.border,
|
|
3946
3972
|
paddingX: 1,
|
|
3947
3973
|
children: [
|
|
3948
3974
|
/* @__PURE__ */ jsxs(Text, { children: [
|
|
@@ -4332,7 +4358,7 @@ function MilestonePanel({ id, width, height, go, onClose }) {
|
|
|
4332
4358
|
{
|
|
4333
4359
|
label: "Due",
|
|
4334
4360
|
value: `${shortDay(milestone.dueDate, today())}${late ? " (late)" : ""}`,
|
|
4335
|
-
color: late ?
|
|
4361
|
+
color: late ? theme.bad : void 0,
|
|
4336
4362
|
edit: {
|
|
4337
4363
|
kind: "date",
|
|
4338
4364
|
initial: milestone.dueDate,
|
|
@@ -4795,7 +4821,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4795
4821
|
}) : m.name,
|
|
4796
4822
|
" ",
|
|
4797
4823
|
/* @__PURE__ */ jsxs(Text, {
|
|
4798
|
-
color: late ?
|
|
4824
|
+
color: late ? theme.bad : theme.muted,
|
|
4799
4825
|
children: [late ? "late · " : "", shortDay(m.dueDate, today())]
|
|
4800
4826
|
}),
|
|
4801
4827
|
m.approverId ? /* @__PURE__ */ jsx(Text, {
|
|
@@ -5064,7 +5090,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5064
5090
|
flexDirection: "column",
|
|
5065
5091
|
width: w,
|
|
5066
5092
|
borderStyle: "round",
|
|
5067
|
-
borderColor: on ? theme.accent :
|
|
5093
|
+
borderColor: on ? theme.accent : theme.border,
|
|
5068
5094
|
paddingX: 1,
|
|
5069
5095
|
children: [
|
|
5070
5096
|
/* @__PURE__ */ jsxs(Text, {
|
|
@@ -5075,7 +5101,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5075
5101
|
c.label.toUpperCase(),
|
|
5076
5102
|
" ",
|
|
5077
5103
|
/* @__PURE__ */ jsxs(Text, {
|
|
5078
|
-
color: over ?
|
|
5104
|
+
color: over ? theme.bad : theme.muted,
|
|
5079
5105
|
children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
|
|
5080
5106
|
})
|
|
5081
5107
|
]
|
|
@@ -5161,11 +5187,11 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
|
5161
5187
|
children: [" ", owner]
|
|
5162
5188
|
}) : null,
|
|
5163
5189
|
due ? /* @__PURE__ */ jsxs(Text, {
|
|
5164
|
-
color: late ?
|
|
5190
|
+
color: late ? theme.bad : selected ? void 0 : theme.muted,
|
|
5165
5191
|
children: [" ", due]
|
|
5166
5192
|
}) : null,
|
|
5167
5193
|
waits ? /* @__PURE__ */ jsx(Text, {
|
|
5168
|
-
color:
|
|
5194
|
+
color: theme.warn,
|
|
5169
5195
|
children: " waits"
|
|
5170
5196
|
}) : null
|
|
5171
5197
|
]
|
|
@@ -5183,7 +5209,7 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
|
5183
5209
|
/** A project's money: budget, spent, what's left and the forecast, then the ledger. */
|
|
5184
5210
|
function BudgetPane({ project, spendEntries, at, active, height, today }) {
|
|
5185
5211
|
const b = budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date(`${today}T12:00:00`));
|
|
5186
|
-
const tone = b.forecast?.tone === "over" ?
|
|
5212
|
+
const tone = b.forecast?.tone === "over" ? theme.bad : b.forecast?.tone === "tight" ? theme.warn : theme.good;
|
|
5187
5213
|
return /* @__PURE__ */ jsxs(Box, {
|
|
5188
5214
|
flexDirection: "column",
|
|
5189
5215
|
height,
|
|
@@ -5211,7 +5237,7 @@ function BudgetPane({ project, spendEntries, at, active, height, today }) {
|
|
|
5211
5237
|
" ",
|
|
5212
5238
|
/* @__PURE__ */ jsxs(Text, {
|
|
5213
5239
|
bold: true,
|
|
5214
|
-
color: b.left < 0 ?
|
|
5240
|
+
color: b.left < 0 ? theme.bad : void 0,
|
|
5215
5241
|
children: [money(Math.abs(b.left)), b.left < 0 ? " over" : ""]
|
|
5216
5242
|
})
|
|
5217
5243
|
] }),
|
|
@@ -5551,7 +5577,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5551
5577
|
width,
|
|
5552
5578
|
height,
|
|
5553
5579
|
borderStyle: "round",
|
|
5554
|
-
borderColor: theme.
|
|
5580
|
+
borderColor: theme.border,
|
|
5555
5581
|
paddingX: 1,
|
|
5556
5582
|
children: [
|
|
5557
5583
|
/* @__PURE__ */ jsxs(Text, {
|
|
@@ -5568,7 +5594,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5568
5594
|
children: task.title
|
|
5569
5595
|
}),
|
|
5570
5596
|
viewOnly ? /* @__PURE__ */ jsx(Text, {
|
|
5571
|
-
color:
|
|
5597
|
+
color: theme.warn,
|
|
5572
5598
|
children: " view only"
|
|
5573
5599
|
}) : null
|
|
5574
5600
|
]
|
|
@@ -5588,7 +5614,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
|
|
|
5588
5614
|
]
|
|
5589
5615
|
}, f.field)),
|
|
5590
5616
|
waits.length > 0 ? /* @__PURE__ */ jsxs(Text, {
|
|
5591
|
-
color:
|
|
5617
|
+
color: theme.warn,
|
|
5592
5618
|
wrap: "truncate-end",
|
|
5593
5619
|
children: [
|
|
5594
5620
|
" ",
|
|
@@ -5772,7 +5798,7 @@ function TodayView({ active, width, height, go }) {
|
|
|
5772
5798
|
children: [
|
|
5773
5799
|
" ",
|
|
5774
5800
|
/* @__PURE__ */ jsx(Text, {
|
|
5775
|
-
color: r.kind === "risk" ?
|
|
5801
|
+
color: r.kind === "risk" ? theme.warn : r.kind === "issue" ? theme.bad : theme.muted,
|
|
5776
5802
|
children: r.kind
|
|
5777
5803
|
}),
|
|
5778
5804
|
" ",
|
|
@@ -5826,14 +5852,15 @@ const NAV = [
|
|
|
5826
5852
|
label: "History"
|
|
5827
5853
|
}
|
|
5828
5854
|
];
|
|
5829
|
-
/** The sidebar's
|
|
5830
|
-
const
|
|
5855
|
+
/** The sidebar's width: its longest line ("8 History", ": Anything") with room either side. */
|
|
5856
|
+
const NAV_WIDTH = 15;
|
|
5857
|
+
/** What the corner says about saving, and in which of the theme's colours. */
|
|
5831
5858
|
const SAVE_LABEL = {
|
|
5832
|
-
saved: ["● saved", "
|
|
5833
|
-
pending: ["● saving soon", "
|
|
5834
|
-
saving: ["● saving…", "
|
|
5835
|
-
retrying: ["● not saved, retrying", "
|
|
5836
|
-
offline: ["● offline, will save", "
|
|
5859
|
+
saved: ["● saved", "good"],
|
|
5860
|
+
pending: ["● saving soon", "warn"],
|
|
5861
|
+
saving: ["● saving…", "warn"],
|
|
5862
|
+
retrying: ["● not saved, retrying", "bad"],
|
|
5863
|
+
offline: ["● offline, will save", "bad"]
|
|
5837
5864
|
};
|
|
5838
5865
|
function App({ ui }) {
|
|
5839
5866
|
const { store, sync, account, harbors, today } = useDeps();
|
|
@@ -6021,11 +6048,10 @@ function App({ ui }) {
|
|
|
6021
6048
|
if (input === "u" && !store.undoLast()) notify("Nothing to undo.");
|
|
6022
6049
|
}, { isActive: !covered && panel !== null });
|
|
6023
6050
|
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);
|
|
6051
|
+
const mainWidth = Math.max(30, columns - NAV_WIDTH - 1);
|
|
6027
6052
|
const mainActive = focus === "main" && !covered && panel === null;
|
|
6028
|
-
const [saveText,
|
|
6053
|
+
const [saveText, saveTone] = SAVE_LABEL[save];
|
|
6054
|
+
const saveColor = theme[saveTone];
|
|
6029
6055
|
const viewProps = {
|
|
6030
6056
|
active: mainActive,
|
|
6031
6057
|
width: mainWidth,
|
|
@@ -6256,7 +6282,7 @@ function App({ ui }) {
|
|
|
6256
6282
|
children: harborName ?? "…"
|
|
6257
6283
|
}),
|
|
6258
6284
|
viewOnly ? /* @__PURE__ */ jsx(Text, {
|
|
6259
|
-
color:
|
|
6285
|
+
color: theme.warn,
|
|
6260
6286
|
children: " · view only"
|
|
6261
6287
|
}) : null
|
|
6262
6288
|
]
|
|
@@ -6272,13 +6298,13 @@ function App({ ui }) {
|
|
|
6272
6298
|
height: bodyHeight,
|
|
6273
6299
|
children: [/* @__PURE__ */ jsxs(Box, {
|
|
6274
6300
|
flexDirection: "column",
|
|
6275
|
-
width:
|
|
6301
|
+
width: NAV_WIDTH,
|
|
6276
6302
|
paddingX: 1,
|
|
6277
6303
|
borderStyle: "single",
|
|
6278
6304
|
borderTop: false,
|
|
6279
6305
|
borderBottom: false,
|
|
6280
6306
|
borderLeft: false,
|
|
6281
|
-
borderColor: focus === "nav" ? theme.accent :
|
|
6307
|
+
borderColor: focus === "nav" ? theme.accent : theme.border,
|
|
6282
6308
|
children: [
|
|
6283
6309
|
NAV.map((x, i) => {
|
|
6284
6310
|
const on = x.screen === screen && stack.length === 0;
|
|
@@ -6300,8 +6326,7 @@ function App({ ui }) {
|
|
|
6300
6326
|
/* @__PURE__ */ jsx(Text, {
|
|
6301
6327
|
color: theme.muted,
|
|
6302
6328
|
children: ": Anything"
|
|
6303
|
-
})
|
|
6304
|
-
mark ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Box, { flexGrow: 1 }), /* @__PURE__ */ jsx(Logo, { pixels: mark })] }) : null
|
|
6329
|
+
})
|
|
6305
6330
|
]
|
|
6306
6331
|
}), /* @__PURE__ */ jsxs(Box, {
|
|
6307
6332
|
flexDirection: "column",
|
|
@@ -7036,12 +7061,24 @@ function sessionSlice({ set, get, write }) {
|
|
|
7036
7061
|
keepInterval: k.interval,
|
|
7037
7062
|
stripeReady: k.stripe
|
|
7038
7063
|
}),
|
|
7039
|
-
ensureAccountPerson: ({ accountId, name }) => {
|
|
7064
|
+
ensureAccountPerson: ({ accountId, name, personId }) => {
|
|
7040
7065
|
const hit = get().people.find((p) => p.accountId === accountId);
|
|
7041
7066
|
if (hit) {
|
|
7042
7067
|
if (get().currentUserId !== hit.id) set({ currentUserId: hit.id });
|
|
7043
7068
|
return hit.id;
|
|
7044
7069
|
}
|
|
7070
|
+
const named = personId ? get().people.find((p) => p.id === personId && !p.accountId) : null;
|
|
7071
|
+
if (named) {
|
|
7072
|
+
set({
|
|
7073
|
+
people: get().people.map((p) => p.id === named.id ? {
|
|
7074
|
+
...p,
|
|
7075
|
+
accountId
|
|
7076
|
+
} : p),
|
|
7077
|
+
currentUserId: named.id
|
|
7078
|
+
});
|
|
7079
|
+
get().log("came aboard", "/people");
|
|
7080
|
+
return named.id;
|
|
7081
|
+
}
|
|
7045
7082
|
const used = new Set(get().people.map((p) => p.swatch));
|
|
7046
7083
|
const swatch = SWATCHES.find((s) => !used.has(s)) ?? "pine";
|
|
7047
7084
|
const id = uid("p");
|
|
@@ -7402,7 +7439,8 @@ function hierarchySlice({ get, write }) {
|
|
|
7402
7439
|
write({
|
|
7403
7440
|
programs: add(state.programs, rows.programs),
|
|
7404
7441
|
projects: add(state.projects, rows.projects),
|
|
7405
|
-
tasks: add(state.tasks, rows.tasks)
|
|
7442
|
+
tasks: add(state.tasks, rows.tasks),
|
|
7443
|
+
people: add(state.people, rows.people)
|
|
7406
7444
|
});
|
|
7407
7445
|
const n = rows.tasks?.length ?? 0;
|
|
7408
7446
|
get().log(`imported ${n} task${n === 1 ? "" : "s"} from a spreadsheet`);
|
|
@@ -7412,12 +7450,18 @@ function hierarchySlice({ get, write }) {
|
|
|
7412
7450
|
const gone = {
|
|
7413
7451
|
programs: new Set(ids.programs ?? []),
|
|
7414
7452
|
projects: new Set(ids.projects ?? []),
|
|
7415
|
-
tasks: new Set(ids.tasks ?? [])
|
|
7453
|
+
tasks: new Set(ids.tasks ?? []),
|
|
7454
|
+
people: new Set(ids.people ?? [])
|
|
7416
7455
|
};
|
|
7456
|
+
const tasks = state.tasks.filter((r) => !gone.tasks.has(r.id));
|
|
7457
|
+
if (gone.people.size) {
|
|
7458
|
+
for (const t of tasks) if (t.assigneeId) gone.people.delete(t.assigneeId);
|
|
7459
|
+
}
|
|
7417
7460
|
write({
|
|
7418
7461
|
programs: state.programs.filter((r) => !gone.programs.has(r.id)),
|
|
7419
7462
|
projects: state.projects.filter((r) => !gone.projects.has(r.id)),
|
|
7420
|
-
tasks
|
|
7463
|
+
tasks,
|
|
7464
|
+
people: gone.people.size ? state.people.filter((r) => !gone.people.has(r.id)) : state.people,
|
|
7421
7465
|
selectedTaskId: gone.tasks.has(state.selectedTaskId ?? "") ? null : state.selectedTaskId
|
|
7422
7466
|
});
|
|
7423
7467
|
},
|
|
@@ -8899,6 +8943,53 @@ function createSync(opts) {
|
|
|
8899
8943
|
};
|
|
8900
8944
|
}
|
|
8901
8945
|
|
|
8946
|
+
//#endregion
|
|
8947
|
+
//#region src/tui/theme-detect.ts
|
|
8948
|
+
const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
|
|
8949
|
+
/** COLORFGBG is "fg;bg" (sometimes "fg;x;bg"): background 7 or 15 is a light terminal. */
|
|
8950
|
+
function themeFromColorFgBg(value) {
|
|
8951
|
+
const bg = value?.split(";").at(-1);
|
|
8952
|
+
if (!bg || !/^\d+$/.test(bg)) return null;
|
|
8953
|
+
return bg === "7" || bg === "15" ? "light" : "dark";
|
|
8954
|
+
}
|
|
8955
|
+
/** A terminal's answer to OSC 11, like "\x1b]11;rgb:ffff/ffff/ffff\x07": light when its luminance is over half. */
|
|
8956
|
+
function themeFromReply(reply) {
|
|
8957
|
+
const m = /rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(reply);
|
|
8958
|
+
if (!m) return null;
|
|
8959
|
+
const [r, g, b] = m.slice(1, 4).map((hex) => parseInt(hex, 16) / (16 ** hex.length - 1));
|
|
8960
|
+
return .2126 * r + .7152 * g + .0722 * b > .5 ? "light" : "dark";
|
|
8961
|
+
}
|
|
8962
|
+
/** Ask the terminal for its background colour; null when it doesn't say in time. */
|
|
8963
|
+
function askBackground(stdin, stdout, ms = 300) {
|
|
8964
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") return Promise.resolve(null);
|
|
8965
|
+
return new Promise((resolve) => {
|
|
8966
|
+
let reply = "";
|
|
8967
|
+
const done = (theme) => {
|
|
8968
|
+
clearTimeout(timer);
|
|
8969
|
+
stdin.off("data", onData);
|
|
8970
|
+
stdin.setRawMode(false);
|
|
8971
|
+
stdin.pause();
|
|
8972
|
+
resolve(theme);
|
|
8973
|
+
};
|
|
8974
|
+
const onData = (chunk) => {
|
|
8975
|
+
reply += chunk.toString();
|
|
8976
|
+
if (/\x07|\x1b\\/.test(reply)) done(themeFromReply(reply));
|
|
8977
|
+
};
|
|
8978
|
+
const timer = setTimeout(() => done(null), ms);
|
|
8979
|
+
stdin.setRawMode(true);
|
|
8980
|
+
stdin.resume();
|
|
8981
|
+
stdin.on("data", onData);
|
|
8982
|
+
stdout.write("\x1B]11;?\x1B\\");
|
|
8983
|
+
});
|
|
8984
|
+
}
|
|
8985
|
+
/** The set to draw with, from the saved setting, the environment, then the terminal itself. */
|
|
8986
|
+
async function pickTheme(setting, env = process.env, ask = () => askBackground(process.stdin, process.stdout)) {
|
|
8987
|
+
const fromEnv = env.HARBOR_THEME?.toLowerCase();
|
|
8988
|
+
const chosen = isThemeSetting(fromEnv) ? fromEnv : setting;
|
|
8989
|
+
if (chosen === "light" || chosen === "dark") return chosen;
|
|
8990
|
+
return themeFromColorFgBg(env.COLORFGBG) ?? await ask() ?? "dark";
|
|
8991
|
+
}
|
|
8992
|
+
|
|
8902
8993
|
//#endregion
|
|
8903
8994
|
//#region src/tui/ui-state.ts
|
|
8904
8995
|
/** What the shell shows beside the harbor: the save state and the latest notice. */
|
|
@@ -8924,6 +9015,7 @@ const SPLASH_MS = 1200;
|
|
|
8924
9015
|
* most, the store and the sync loop, drawn with Ink.
|
|
8925
9016
|
*/
|
|
8926
9017
|
async function startApp(deps, options = {}) {
|
|
9018
|
+
applyTheme(deps.theme ?? "dark");
|
|
8927
9019
|
const transport = plainTransport(deps.transport);
|
|
8928
9020
|
const account = plainDeep(deps.account);
|
|
8929
9021
|
const harbors = plainDeep(deps.harbors);
|
|
@@ -8970,6 +9062,7 @@ async function startApp(deps, options = {}) {
|
|
|
8970
9062
|
}
|
|
8971
9063
|
/** `harbor`: sign-in already done, take over the terminal until q. */
|
|
8972
9064
|
async function runApp(opts) {
|
|
9065
|
+
const theme = await pickTheme(opts.theme);
|
|
8973
9066
|
const transport = httpTransport(opts.source, opts.fetcher);
|
|
8974
9067
|
const { account, harbors } = await transport.harbors();
|
|
8975
9068
|
if (harbors.length === 0) throw new Error("You don't belong to a harbor yet. Make one at harborppm.com first.");
|
|
@@ -8981,7 +9074,8 @@ async function runApp(opts) {
|
|
|
8981
9074
|
account,
|
|
8982
9075
|
harbors,
|
|
8983
9076
|
workspaceId: pick?.id,
|
|
8984
|
-
splashMs: SPLASH_MS
|
|
9077
|
+
splashMs: SPLASH_MS,
|
|
9078
|
+
theme
|
|
8985
9079
|
}, { alternateScreen: true });
|
|
8986
9080
|
await instance.waitUntilExit();
|
|
8987
9081
|
}
|
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
|
|