kankaku-tui 0.1.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/LICENSE +21 -0
- package/README.md +230 -0
- package/dist/adapters/app-info.js +13 -0
- package/dist/adapters/hub.js +101 -0
- package/dist/adapters/project-discovery.js +43 -0
- package/dist/adapters/tui-config.js +35 -0
- package/dist/adapters/worklog-reader.js +9 -0
- package/dist/cli.js +347 -0
- package/dist/domain/catalog-model.js +45 -0
- package/dist/domain/dashboard-model.js +82 -0
- package/dist/domain/list-window.js +31 -0
- package/dist/domain/nav-model.js +69 -0
- package/dist/domain/quick-actions.js +49 -0
- package/dist/domain/sync-model.js +17 -0
- package/dist/domain/tasks-model.js +51 -0
- package/dist/domain/text-wrap.js +47 -0
- package/dist/domain/today-model.js +75 -0
- package/dist/ports/project-source.js +1 -0
- package/dist/ui/app.js +65 -0
- package/dist/ui/catalog-screen.js +100 -0
- package/dist/ui/components/bar.js +20 -0
- package/dist/ui/components/header-bar.js +8 -0
- package/dist/ui/components/key-hints.js +8 -0
- package/dist/ui/components/panel.js +32 -0
- package/dist/ui/components/sidebar.js +30 -0
- package/dist/ui/components/sparkline.js +19 -0
- package/dist/ui/components/table.js +55 -0
- package/dist/ui/dashboard-screen.js +217 -0
- package/dist/ui/layout.js +54 -0
- package/dist/ui/sync-screen.js +127 -0
- package/dist/ui/tasks-screen.js +166 -0
- package/dist/ui/theme.js +81 -0
- package/dist/ui/today-screen.js +161 -0
- package/package.json +38 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { render } from "ink";
|
|
7
|
+
import { buildTasks, localDay } from "kankaku/domain";
|
|
8
|
+
import { buildSyncStatusLines, formatCatalogRefreshLines, formatSyncSummaryLines, formatTasks } from "kankaku/hub";
|
|
9
|
+
import { readTuiConfig } from "./adapters/tui-config.js";
|
|
10
|
+
import { discoverProjects } from "./adapters/project-discovery.js";
|
|
11
|
+
import { readProjectRecords } from "./adapters/worklog-reader.js";
|
|
12
|
+
import { computeProjectSyncStatus, createCatalog, refreshCatalog as refreshCatalogAdapter, resolveHub, syncProject } from "./adapters/hub.js";
|
|
13
|
+
import { readOwnVersion } from "./adapters/app-info.js";
|
|
14
|
+
import { buildCatalogModel } from "./domain/catalog-model.js";
|
|
15
|
+
import { buildTodayRows, formatTodayLines } from "./domain/today-model.js";
|
|
16
|
+
import { buildDashboardModel } from "./domain/dashboard-model.js";
|
|
17
|
+
import { App } from "./ui/app.js";
|
|
18
|
+
import { buildTasksModel } from "./domain/tasks-model.js";
|
|
19
|
+
import { buildSyncRows } from "./domain/sync-model.js";
|
|
20
|
+
import { DEFAULT_THEME, ThemeProvider, resolveTheme } from "./ui/theme.js";
|
|
21
|
+
const USAGE = "usage: kankaku [today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]] [--roots a,b] [--theme name]\n";
|
|
22
|
+
/** Load today's model for `roots`: discover projects, read their worklogs, build rows. */
|
|
23
|
+
export function loadToday(roots) {
|
|
24
|
+
const projects = discoverProjects(roots);
|
|
25
|
+
const withRecords = projects.map((project) => ({ name: project.name, records: readProjectRecords(project) }));
|
|
26
|
+
return buildTodayRows(withRecords);
|
|
27
|
+
}
|
|
28
|
+
/** Load the Tasks screen's model for `roots`: same project/record discovery as `loadToday`, restricted to today unless `options.all`. */
|
|
29
|
+
export function loadTasks(roots, options) {
|
|
30
|
+
const projects = discoverProjects(roots);
|
|
31
|
+
const withRecords = projects.map((project) => ({ name: project.name, records: readProjectRecords(project) }));
|
|
32
|
+
return buildTasksModel(withRecords, options);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Load the Today screen's dashboard model: every project's records (kept
|
|
36
|
+
* whole — `dashboard-model.ts` derives both today's rows and the last 7
|
|
37
|
+
* days from the same set, so nothing extra is read beyond what
|
|
38
|
+
* `readProjectRecords` already loads), plus the Hub card from local
|
|
39
|
+
* no-network sync status and the cached catalog. Without hub credentials,
|
|
40
|
+
* or with no catalog cached yet, the Hub card degrades to `"unavailable"`
|
|
41
|
+
* without touching the network.
|
|
42
|
+
*/
|
|
43
|
+
export function loadDashboard(roots, deps) {
|
|
44
|
+
const projects = discoverProjects(roots);
|
|
45
|
+
const withRecords = projects.map((project) => ({ name: project.name, records: readProjectRecords(project) }));
|
|
46
|
+
const { now } = envDeps(deps);
|
|
47
|
+
const today = localDay(new Date(now()).toISOString());
|
|
48
|
+
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
49
|
+
if (!hub.ok) {
|
|
50
|
+
return buildDashboardModel(withRecords, undefined, undefined, { today });
|
|
51
|
+
}
|
|
52
|
+
const { fetch: fetchOverride } = envDeps(deps);
|
|
53
|
+
const hubEntries = projects.map((project) => ({
|
|
54
|
+
name: project.name,
|
|
55
|
+
status: computeProjectSyncStatus(project, hub.credentials, deps.env ?? {}),
|
|
56
|
+
}));
|
|
57
|
+
const catalog = createCatalog(hub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
|
|
58
|
+
const catalogModel = buildCatalogModel(catalog.read(), now());
|
|
59
|
+
const catalogSummary = catalogModel.status === "ready"
|
|
60
|
+
? { url: catalogModel.url, clientCount: catalogModel.clients.length, projectCount: catalogModel.clients.reduce((sum, client) => sum + client.projects.length, 0) }
|
|
61
|
+
: undefined;
|
|
62
|
+
return buildDashboardModel(withRecords, hubEntries, catalogSummary, { today });
|
|
63
|
+
}
|
|
64
|
+
function parseRoots(argv, deps) {
|
|
65
|
+
const flagIndex = argv.indexOf("--roots");
|
|
66
|
+
if (flagIndex === -1) {
|
|
67
|
+
return { roots: readTuiConfig(deps.homeDir, deps.cwd).roots, rest: argv };
|
|
68
|
+
}
|
|
69
|
+
const value = argv[flagIndex + 1] ?? "";
|
|
70
|
+
const roots = value.split(",").filter((root) => root.length > 0);
|
|
71
|
+
const rest = [...argv.slice(0, flagIndex), ...argv.slice(flagIndex + 2)];
|
|
72
|
+
return { roots, rest };
|
|
73
|
+
}
|
|
74
|
+
/** Strip `--theme <name>` from `argv` (present anywhere), returning its value (if any) and the rest. */
|
|
75
|
+
function parseThemeFlag(argv) {
|
|
76
|
+
const flagIndex = argv.indexOf("--theme");
|
|
77
|
+
if (flagIndex === -1)
|
|
78
|
+
return { theme: undefined, rest: argv };
|
|
79
|
+
const value = argv[flagIndex + 1];
|
|
80
|
+
const rest = [...argv.slice(0, flagIndex), ...argv.slice(flagIndex + 2)];
|
|
81
|
+
return { theme: value, rest };
|
|
82
|
+
}
|
|
83
|
+
function parseProjectFlag(argv, cwd) {
|
|
84
|
+
const flagIndex = argv.indexOf("--project");
|
|
85
|
+
if (flagIndex === -1)
|
|
86
|
+
return { project: undefined, rest: argv };
|
|
87
|
+
const value = argv[flagIndex + 1];
|
|
88
|
+
const rest = [...argv.slice(0, flagIndex), ...argv.slice(flagIndex + 2)];
|
|
89
|
+
return { project: value !== undefined ? resolve(cwd, value) : undefined, rest };
|
|
90
|
+
}
|
|
91
|
+
function envDeps(deps) {
|
|
92
|
+
return {
|
|
93
|
+
env: deps.env ?? {},
|
|
94
|
+
homeDir: () => deps.homeDir,
|
|
95
|
+
now: deps.now ?? (() => Date.now()),
|
|
96
|
+
hostname: deps.hostname ?? (() => hostname()),
|
|
97
|
+
...(deps.fetch ? { fetch: deps.fetch } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Filter `tasks` to `day` (local calendar day), or return every task when `day` is `undefined`. */
|
|
101
|
+
function filterTasksForDay(tasks, day) {
|
|
102
|
+
return day === undefined ? tasks : tasks.filter((task) => localDay(task.startedAt) === day);
|
|
103
|
+
}
|
|
104
|
+
/** `kankaku tasks [--all]`: kankaku's own `formatTasks` output per project, prefixed by a `== <project> ==` header. */
|
|
105
|
+
function runTasksCommand(args, roots, deps) {
|
|
106
|
+
const all = args.includes("--all");
|
|
107
|
+
const targetDay = all ? undefined : localDay(new Date().toISOString());
|
|
108
|
+
const projects = discoverProjects(roots);
|
|
109
|
+
const blocks = [];
|
|
110
|
+
for (const project of projects) {
|
|
111
|
+
const tasks = filterTasksForDay(buildTasks(readProjectRecords(project)), targetDay);
|
|
112
|
+
if (tasks.length === 0)
|
|
113
|
+
continue;
|
|
114
|
+
blocks.push(`== ${project.name} ==\n${formatTasks(tasks)}`);
|
|
115
|
+
}
|
|
116
|
+
deps.stdout(blocks.length > 0 ? blocks.join("\n\n") : "no tasks");
|
|
117
|
+
}
|
|
118
|
+
/** `kankaku catalog [refresh]`. Without hub credentials: a one-line note, exit 0. With credentials: `refresh` fetches over the network (exit 1 on failure); without `refresh`, only the local cache is read (no network) and reported. */
|
|
119
|
+
async function runCatalogCommand(args, deps) {
|
|
120
|
+
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
121
|
+
const doRefresh = args[0] === "refresh";
|
|
122
|
+
if (!hub.ok) {
|
|
123
|
+
if (doRefresh) {
|
|
124
|
+
deps.stderr(`kankaku catalog refresh: ${hub.reason}\n`);
|
|
125
|
+
deps.exit(1);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
deps.stdout(hub.reason);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const { now, fetch: fetchOverride } = envDeps(deps);
|
|
133
|
+
const catalog = createCatalog(hub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
|
|
134
|
+
if (doRefresh) {
|
|
135
|
+
const snapshot = await refreshCatalogAdapter(catalog);
|
|
136
|
+
deps.stdout(formatCatalogRefreshLines(snapshot).join("\n"));
|
|
137
|
+
if (!snapshot)
|
|
138
|
+
deps.exit(1);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const model = buildCatalogModel(catalog.read(), now());
|
|
142
|
+
if (model.status === "unavailable") {
|
|
143
|
+
deps.stdout("no catalog cached yet; run 'kankaku catalog refresh'");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const clientCount = model.clients.length;
|
|
147
|
+
const projectCount = model.clients.reduce((sum, client) => sum + client.projects.length, 0);
|
|
148
|
+
deps.stdout(`${model.url}: ${clientCount} client(s), ${projectCount} project(s)${model.stale ? " (stale)" : ""}`);
|
|
149
|
+
}
|
|
150
|
+
/** Resolve the target projects for `sync`: `--project <dir>` restricts to exactly that directory; otherwise every discovered project under `roots`. */
|
|
151
|
+
function resolveTargetProjects(roots, projectFlag) {
|
|
152
|
+
if (projectFlag !== undefined) {
|
|
153
|
+
return [{ name: projectFlag.split(/[\\/]/).filter(Boolean).pop() ?? projectFlag, dir: projectFlag }];
|
|
154
|
+
}
|
|
155
|
+
return discoverProjects(roots);
|
|
156
|
+
}
|
|
157
|
+
/** `kankaku sync [status|all] [--project <dir>]`. Without hub credentials: a one-line note for `status` (exit 0), an error for an actual sync attempt (exit 1). */
|
|
158
|
+
async function runSyncCommand(args, roots, deps) {
|
|
159
|
+
const { project: projectFlag, rest } = parseProjectFlag(args, deps.cwd);
|
|
160
|
+
const mode = rest[0] === "status" || rest[0] === "all" ? rest[0] : undefined;
|
|
161
|
+
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
162
|
+
const targets = resolveTargetProjects(roots, projectFlag);
|
|
163
|
+
if (!hub.ok) {
|
|
164
|
+
if (mode === "status") {
|
|
165
|
+
deps.stdout(hub.reason);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
deps.stderr(`kankaku sync: ${hub.reason}\n`);
|
|
169
|
+
deps.exit(1);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (mode === "status") {
|
|
174
|
+
const blocks = targets.map((project) => {
|
|
175
|
+
const status = computeProjectSyncStatus(project, hub.credentials, deps.env ?? {});
|
|
176
|
+
return `== ${project.name} ==\n${buildSyncStatusLines(status).join("\n")}`;
|
|
177
|
+
});
|
|
178
|
+
deps.stdout(blocks.join("\n\n"));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const runnerDeps = envDeps(deps);
|
|
182
|
+
let failed = false;
|
|
183
|
+
const blocks = [];
|
|
184
|
+
for (const project of targets) {
|
|
185
|
+
const summary = await syncProject(project, hub.credentials, { full: mode === "all" }, runnerDeps);
|
|
186
|
+
if (summary.locked || summary.error !== undefined || summary.failed.length > 0)
|
|
187
|
+
failed = true;
|
|
188
|
+
blocks.push(`== ${project.name} ==\n${formatSyncSummaryLines(summary).join("\n")}`);
|
|
189
|
+
}
|
|
190
|
+
deps.stdout(blocks.join("\n\n"));
|
|
191
|
+
if (failed)
|
|
192
|
+
deps.exit(1);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Resolve the `--theme`/`KANKAKU_TUI_THEME` preset (flag wins), defaulting
|
|
196
|
+
* to {@link DEFAULT_THEME} when neither is given. An explicit but unknown
|
|
197
|
+
* name is a usage error, never a silent fallback.
|
|
198
|
+
*/
|
|
199
|
+
function resolveCliTheme(themeFlag, env) {
|
|
200
|
+
const requested = themeFlag ?? env?.KANKAKU_TUI_THEME;
|
|
201
|
+
if (requested === undefined)
|
|
202
|
+
return { ok: true, theme: DEFAULT_THEME };
|
|
203
|
+
return resolveTheme(requested);
|
|
204
|
+
}
|
|
205
|
+
/** Parse `argv` and run the requested mode against injected `deps`. No logic beyond argv handling belongs here. */
|
|
206
|
+
export async function runCli(argv, deps) {
|
|
207
|
+
const { theme: themeFlag, rest: argvAfterTheme } = parseThemeFlag(argv);
|
|
208
|
+
const themeResult = resolveCliTheme(themeFlag, deps.env);
|
|
209
|
+
if (!themeResult.ok) {
|
|
210
|
+
deps.stderr(`kankaku: ${themeResult.reason}\n`);
|
|
211
|
+
deps.exit(1);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const theme = themeResult.theme;
|
|
215
|
+
const { roots, rest } = parseRoots(argvAfterTheme, deps);
|
|
216
|
+
const [command] = rest;
|
|
217
|
+
if (command === undefined) {
|
|
218
|
+
deps.renderApp(roots, theme);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (command === "today") {
|
|
222
|
+
const model = loadToday(roots);
|
|
223
|
+
deps.stdout(formatTodayLines(model.rows, model.total).join("\n"));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (command === "tasks") {
|
|
227
|
+
runTasksCommand(rest.slice(1), roots, deps);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (command === "catalog") {
|
|
231
|
+
await runCatalogCommand(rest.slice(1), deps);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (command === "sync") {
|
|
235
|
+
await runSyncCommand(rest.slice(1), roots, deps);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
deps.stderr(USAGE);
|
|
239
|
+
deps.exit(1);
|
|
240
|
+
}
|
|
241
|
+
/** Build the Catalog screen's `load`/`refresh` deps for the interactive app; used only by `renderApp`. */
|
|
242
|
+
function catalogScreenDeps(deps) {
|
|
243
|
+
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
244
|
+
const { now, fetch: fetchOverride } = envDeps(deps);
|
|
245
|
+
if (!hub.ok) {
|
|
246
|
+
return { load: () => ({ status: "unavailable", reason: hub.reason }), refresh: async () => ({ status: "unavailable", reason: hub.reason }) };
|
|
247
|
+
}
|
|
248
|
+
const catalog = createCatalog(hub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
|
|
249
|
+
return {
|
|
250
|
+
load: () => buildCatalogModel(catalog.read(), now()),
|
|
251
|
+
refresh: async () => buildCatalogModel(await refreshCatalogAdapter(catalog), now()),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** Build the Sync screen's `load`/`syncOne`/`syncAll` deps for the interactive app; used only by `renderApp`. */
|
|
255
|
+
function syncScreenDeps(deps, roots) {
|
|
256
|
+
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
257
|
+
const runnerDeps = envDeps(deps);
|
|
258
|
+
if (!hub.ok) {
|
|
259
|
+
const reason = hub.reason;
|
|
260
|
+
return {
|
|
261
|
+
load: () => ({ status: "unavailable", reason }),
|
|
262
|
+
syncOne: async () => ({ ok: false, message: reason }),
|
|
263
|
+
syncAll: async () => [],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const credentials = hub.credentials;
|
|
267
|
+
const load = () => {
|
|
268
|
+
const projects = discoverProjects(roots);
|
|
269
|
+
const entries = projects.map((project) => ({ project, status: computeProjectSyncStatus(project, credentials, deps.env ?? {}) }));
|
|
270
|
+
return { status: "ready", rows: buildSyncRows(entries) };
|
|
271
|
+
};
|
|
272
|
+
const findProject = (name) => discoverProjects(roots).find((project) => project.name === name);
|
|
273
|
+
return {
|
|
274
|
+
load,
|
|
275
|
+
syncOne: async (row, options) => {
|
|
276
|
+
const project = findProject(row.name) ?? { name: row.name, dir: row.dir };
|
|
277
|
+
const summary = await syncProject(project, credentials, options, runnerDeps);
|
|
278
|
+
return { ok: summary.error === undefined && summary.failed.length === 0 && !summary.locked, message: formatSyncSummaryLines(summary).join("; ") };
|
|
279
|
+
},
|
|
280
|
+
syncAll: async () => {
|
|
281
|
+
const projects = discoverProjects(roots);
|
|
282
|
+
const results = [];
|
|
283
|
+
for (const project of projects) {
|
|
284
|
+
const summary = await syncProject(project, credentials, {}, runnerDeps);
|
|
285
|
+
results.push({ ok: summary.error === undefined && summary.failed.length === 0 && !summary.locked, message: formatSyncSummaryLines(summary).join("; ") });
|
|
286
|
+
}
|
|
287
|
+
return results;
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Build the Dashboard screen's Quick actions deps for the interactive app:
|
|
293
|
+
* `refreshCatalog` refreshes the cached catalog through `createCatalog`/
|
|
294
|
+
* `refreshCatalogAdapter`, `syncAll` runs `syncProject` over every
|
|
295
|
+
* discovered project — both reuse kankaku's own `formatCatalogRefreshLines`/
|
|
296
|
+
* `formatSyncSummaryLines` for the result message, never reimplementing
|
|
297
|
+
* them. Without hub credentials, `hubAvailable` is `false` and neither
|
|
298
|
+
* function is ever called by the screen. Used only by `renderApp`.
|
|
299
|
+
*/
|
|
300
|
+
function dashboardActionsDeps(deps, roots) {
|
|
301
|
+
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
302
|
+
if (!hub.ok) {
|
|
303
|
+
const reason = hub.reason;
|
|
304
|
+
return { hubAvailable: false, refreshCatalog: async () => reason, syncAll: async () => reason };
|
|
305
|
+
}
|
|
306
|
+
const credentials = hub.credentials;
|
|
307
|
+
const runnerDeps = envDeps(deps);
|
|
308
|
+
const { now, fetch: fetchOverride } = envDeps(deps);
|
|
309
|
+
return {
|
|
310
|
+
hubAvailable: true,
|
|
311
|
+
refreshCatalog: async () => {
|
|
312
|
+
const catalog = createCatalog(credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
|
|
313
|
+
const snapshot = await refreshCatalogAdapter(catalog);
|
|
314
|
+
return formatCatalogRefreshLines(snapshot).join(" · ");
|
|
315
|
+
},
|
|
316
|
+
syncAll: async (options) => {
|
|
317
|
+
const projects = discoverProjects(roots);
|
|
318
|
+
if (projects.length === 0)
|
|
319
|
+
return "no projects to sync";
|
|
320
|
+
const summaries = await Promise.all(projects.map((project) => syncProject(project, credentials, options, runnerDeps)));
|
|
321
|
+
return summaries.flatMap((summary) => formatSyncSummaryLines(summary)).join(" · ");
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
326
|
+
if (isMain) {
|
|
327
|
+
const realDeps = {
|
|
328
|
+
homeDir: homedir(),
|
|
329
|
+
cwd: process.cwd(),
|
|
330
|
+
env: process.env,
|
|
331
|
+
now: () => Date.now(),
|
|
332
|
+
hostname: () => hostname(),
|
|
333
|
+
stdout: (text) => {
|
|
334
|
+
console.log(text);
|
|
335
|
+
},
|
|
336
|
+
stderr: (text) => {
|
|
337
|
+
process.stderr.write(text);
|
|
338
|
+
},
|
|
339
|
+
exit: (code) => {
|
|
340
|
+
process.exit(code);
|
|
341
|
+
},
|
|
342
|
+
renderApp: (roots, theme) => {
|
|
343
|
+
render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), dashboardActions: dashboardActionsDeps(realDeps, roots) }) }), { alternateScreen: true, exitOnCtrlC: true });
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
void runCli(process.argv.slice(2), realDeps);
|
|
347
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Mirrors `CachedCatalog`'s own default TTL (`cached-catalog.ts`): six hours. */
|
|
2
|
+
const DEFAULT_STALE_MS = 6 * 60 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Build a {@link CatalogModel} from a cached {@link CatalogSnapshot}:
|
|
5
|
+
* active clients, each with its active projects (sorted by name) and their
|
|
6
|
+
* open/doing hub task counts. `undefined` (no snapshot cached yet) reports
|
|
7
|
+
* `"unavailable"`. `now` is injected (never `Date.now()` here) so age and
|
|
8
|
+
* staleness stay deterministic under test.
|
|
9
|
+
*/
|
|
10
|
+
export function buildCatalogModel(snapshot, now, staleMs = DEFAULT_STALE_MS) {
|
|
11
|
+
if (!snapshot)
|
|
12
|
+
return { status: "unavailable" };
|
|
13
|
+
const taskCountsByProject = new Map();
|
|
14
|
+
for (const task of snapshot.tasks ?? []) {
|
|
15
|
+
const counts = taskCountsByProject.get(task.projectId) ?? { open: 0, doing: 0 };
|
|
16
|
+
if (task.status === "open")
|
|
17
|
+
counts.open += 1;
|
|
18
|
+
else if (task.status === "doing")
|
|
19
|
+
counts.doing += 1;
|
|
20
|
+
taskCountsByProject.set(task.projectId, counts);
|
|
21
|
+
}
|
|
22
|
+
const clients = snapshot.clients
|
|
23
|
+
.filter((client) => client.active)
|
|
24
|
+
.map((client) => ({
|
|
25
|
+
id: client.id,
|
|
26
|
+
name: client.name,
|
|
27
|
+
code: client.code,
|
|
28
|
+
projects: snapshot.projects
|
|
29
|
+
.filter((project) => project.active && project.clientId === client.id)
|
|
30
|
+
.map((project) => {
|
|
31
|
+
const counts = taskCountsByProject.get(project.id) ?? { open: 0, doing: 0 };
|
|
32
|
+
return {
|
|
33
|
+
id: project.id,
|
|
34
|
+
name: project.name,
|
|
35
|
+
...(project.code !== undefined ? { code: project.code } : {}),
|
|
36
|
+
openCount: counts.open,
|
|
37
|
+
doingCount: counts.doing,
|
|
38
|
+
};
|
|
39
|
+
})
|
|
40
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
41
|
+
}))
|
|
42
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
43
|
+
const ageMs = now - snapshot.fetchedAt;
|
|
44
|
+
return { status: "ready", url: snapshot.url, fetchedAt: snapshot.fetchedAt, ageMs, stale: ageMs > staleMs, clients };
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { summarize } from "kankaku/hub";
|
|
2
|
+
import { buildTodayRows } from "./today-model.js";
|
|
3
|
+
const WEEKDAY_LABELS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
4
|
+
/** `day` shifted by `deltaDays` (may be negative), as a `YYYY-MM-DD` string. */
|
|
5
|
+
function shiftDay(day, deltaDays) {
|
|
6
|
+
const date = new Date(`${day}T00:00:00`);
|
|
7
|
+
date.setDate(date.getDate() + deltaDays);
|
|
8
|
+
const year = date.getFullYear();
|
|
9
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
10
|
+
const dayOfMonth = String(date.getDate()).padStart(2, "0");
|
|
11
|
+
return `${year}-${month}-${dayOfMonth}`;
|
|
12
|
+
}
|
|
13
|
+
function weekdayLabel(day) {
|
|
14
|
+
const date = new Date(`${day}T00:00:00`);
|
|
15
|
+
return WEEKDAY_LABELS[date.getDay()] ?? "";
|
|
16
|
+
}
|
|
17
|
+
/** Sum `workMs`/`cost` across every project for each of the 7 days ending on `today`, via kankaku's own `summarize` per day — never reimplementing its aggregation. */
|
|
18
|
+
function buildLast7Days(projects, today) {
|
|
19
|
+
const days = Array.from({ length: 7 }, (_, index) => shiftDay(today, index - 6));
|
|
20
|
+
return days.map((day) => {
|
|
21
|
+
let workMs = 0;
|
|
22
|
+
let cost = 0;
|
|
23
|
+
for (const project of projects) {
|
|
24
|
+
const summary = summarize(project.records, { all: false, day });
|
|
25
|
+
workMs += summary.tasks.workMs;
|
|
26
|
+
cost += summary.tasks.cost;
|
|
27
|
+
}
|
|
28
|
+
return { day, weekday: weekdayLabel(day), workMs, cost };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Attach `share` (`workMs / max workMs`) to each of today's rows. */
|
|
32
|
+
function withShare(rows) {
|
|
33
|
+
const maxWorkMs = rows.reduce((max, row) => Math.max(max, row.workMs), 0);
|
|
34
|
+
return rows.map((row) => ({ ...row, share: maxWorkMs > 0 ? row.workMs / maxWorkMs : 0 }));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the Hub card from per-project {@link SyncStatusSnapshot}s
|
|
38
|
+
* (`computeSyncStatus`, never reimplemented here) and an optional catalog
|
|
39
|
+
* summary. `entries === undefined` means the hub is not configured at
|
|
40
|
+
* all, distinct from a configured-but-empty project list.
|
|
41
|
+
*/
|
|
42
|
+
function buildHubCard(entries, catalog) {
|
|
43
|
+
if (entries === undefined)
|
|
44
|
+
return { status: "unavailable" };
|
|
45
|
+
let pending = 0;
|
|
46
|
+
let staleOutsideWindow = 0;
|
|
47
|
+
let lastSyncAt;
|
|
48
|
+
let lastSyncOk = true;
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
pending += entry.status.pending;
|
|
51
|
+
staleOutsideWindow += entry.status.staleOutsideWindow;
|
|
52
|
+
const syncedThrough = entry.status.state?.syncedThrough;
|
|
53
|
+
if (syncedThrough !== undefined && (lastSyncAt === undefined || syncedThrough > lastSyncAt)) {
|
|
54
|
+
lastSyncAt = syncedThrough;
|
|
55
|
+
}
|
|
56
|
+
if (entry.status.state?.lastError !== undefined)
|
|
57
|
+
lastSyncOk = false;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
status: "ready",
|
|
61
|
+
pending,
|
|
62
|
+
staleOutsideWindow,
|
|
63
|
+
lastSyncOk,
|
|
64
|
+
...(lastSyncAt !== undefined ? { lastSyncAt } : {}),
|
|
65
|
+
...(catalog !== undefined ? { catalog } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build the Today dashboard's model: today's total and per-project rows
|
|
70
|
+
* (`today-model.ts#buildTodayRows`, not reimplemented), the last 7 days'
|
|
71
|
+
* work/cost series, and the Hub card. Pure — every "now" comes from
|
|
72
|
+
* `options.today`, and every hub fact comes from already-resolved inputs.
|
|
73
|
+
*/
|
|
74
|
+
export function buildDashboardModel(projects, hubEntries, catalogSummary, options) {
|
|
75
|
+
const { rows, total } = buildTodayRows(projects, { today: options.today });
|
|
76
|
+
return {
|
|
77
|
+
today: total,
|
|
78
|
+
last7Days: buildLast7Days(projects, options.today),
|
|
79
|
+
projects: withShare(rows),
|
|
80
|
+
hub: buildHubCard(hubEntries, catalogSummary),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smallest window of at most `maxRows` rows out of `total` that still
|
|
3
|
+
* contains `selected`. When `previousStart` puts `selected` inside the
|
|
4
|
+
* window it already describes, that window is kept as-is (no scrolling on
|
|
5
|
+
* every selection move); otherwise the window scrolls the minimum amount
|
|
6
|
+
* needed to bring `selected` back into view.
|
|
7
|
+
*
|
|
8
|
+
* Pure and stateless: callers persist `previousStart` themselves (e.g. in a
|
|
9
|
+
* `useRef`) and pass it back in on the next call.
|
|
10
|
+
*/
|
|
11
|
+
export function windowRows(total, selected, maxRows, previousStart = 0) {
|
|
12
|
+
const size = Math.max(Math.min(maxRows, total), 0);
|
|
13
|
+
if (size === 0) {
|
|
14
|
+
return { start: 0, end: 0, hiddenAbove: 0, hiddenBelow: 0 };
|
|
15
|
+
}
|
|
16
|
+
if (size >= total) {
|
|
17
|
+
return { start: 0, end: total, hiddenAbove: 0, hiddenBelow: 0 };
|
|
18
|
+
}
|
|
19
|
+
const clampedSelected = Math.min(Math.max(selected, 0), total - 1);
|
|
20
|
+
const maxStart = total - size;
|
|
21
|
+
let start = Math.min(Math.max(previousStart, 0), maxStart);
|
|
22
|
+
if (clampedSelected < start) {
|
|
23
|
+
start = clampedSelected;
|
|
24
|
+
}
|
|
25
|
+
else if (clampedSelected >= start + size) {
|
|
26
|
+
start = clampedSelected - size + 1;
|
|
27
|
+
}
|
|
28
|
+
start = Math.min(Math.max(start, 0), maxStart);
|
|
29
|
+
const end = start + size;
|
|
30
|
+
return { start, end, hiddenAbove: start, hiddenBelow: total - end };
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const SCREENS = [
|
|
2
|
+
{ id: "dashboard", label: "Dashboard", key: "1" },
|
|
3
|
+
{ id: "tasks", label: "Tasks", key: "2" },
|
|
4
|
+
{ id: "catalog", label: "Catalog", key: "3" },
|
|
5
|
+
{ id: "sync", label: "Sync", key: "4" },
|
|
6
|
+
];
|
|
7
|
+
/** The screen bound to `key` (one of `SCREENS`' digit keys), or `undefined` for any other key. */
|
|
8
|
+
export function screenForKey(key) {
|
|
9
|
+
return SCREENS.find((screen) => screen.key === key)?.id;
|
|
10
|
+
}
|
|
11
|
+
/** One entry per screen for the tab bar, in `SCREENS` order, flagging which one is active. */
|
|
12
|
+
export function buildTabBar(active) {
|
|
13
|
+
return SCREENS.map((screen) => ({ id: screen.id, text: `${screen.key} ${screen.label}`, active: screen.id === active }));
|
|
14
|
+
}
|
|
15
|
+
export const INITIAL_NAV_STATE = { screen: "dashboard", focus: "sidebar" };
|
|
16
|
+
/** Switch the active screen, keeping any existing project filter and focus (e.g. `1`-`4` while Tasks is already filtered). */
|
|
17
|
+
export function switchScreen(state, screen) {
|
|
18
|
+
return { ...state, screen };
|
|
19
|
+
}
|
|
20
|
+
/** `enter` on a Dashboard project row: jump to Tasks filtered to `project`, keeping the current focus. */
|
|
21
|
+
export function openProjectInTasks(state, project) {
|
|
22
|
+
return { ...state, screen: "tasks", projectFilter: project };
|
|
23
|
+
}
|
|
24
|
+
/** `esc` on Tasks: drop the project filter, keeping the current screen and focus. */
|
|
25
|
+
export function clearProjectFilter(state) {
|
|
26
|
+
const { projectFilter, ...rest } = state;
|
|
27
|
+
void projectFilter;
|
|
28
|
+
return rest;
|
|
29
|
+
}
|
|
30
|
+
/** Focus the main content zone (e.g. `enter`/`→`/`Tab` from the sidebar). */
|
|
31
|
+
export function focusMain(state) {
|
|
32
|
+
return { ...state, focus: "main" };
|
|
33
|
+
}
|
|
34
|
+
/** Focus the sidebar zone (e.g. `←`/`Tab`/`esc` from the main content). */
|
|
35
|
+
export function focusSidebar(state) {
|
|
36
|
+
return { ...state, focus: "sidebar" };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Move the active screen by `delta` positions in `SCREENS` order (sidebar
|
|
40
|
+
* focused `↑`/`↓`), clamping at the first/last screen rather than
|
|
41
|
+
* wrapping.
|
|
42
|
+
*/
|
|
43
|
+
export function moveSidebar(state, delta) {
|
|
44
|
+
const currentIndex = SCREENS.findIndex((screen) => screen.id === state.screen);
|
|
45
|
+
const nextIndex = Math.min(Math.max(currentIndex + delta, 0), SCREENS.length - 1);
|
|
46
|
+
const next = SCREENS[nextIndex];
|
|
47
|
+
return next === undefined ? state : switchScreen(state, next.id);
|
|
48
|
+
}
|
|
49
|
+
/** The fixed footer hints shown while the sidebar is focused. */
|
|
50
|
+
const SIDEBAR_HINTS = [
|
|
51
|
+
{ key: "↑↓", label: "choose" },
|
|
52
|
+
{ key: "enter/→", label: "open" },
|
|
53
|
+
{ key: "1-4", label: "screens" },
|
|
54
|
+
{ key: "q", label: "quit" },
|
|
55
|
+
];
|
|
56
|
+
/** Appended to a screen's own hints when the main zone is focused, to return to the sidebar. */
|
|
57
|
+
const MAIN_MENU_HINT = { key: "←", label: "menu" };
|
|
58
|
+
/**
|
|
59
|
+
* The footer key hints for `screen` at the given `focus`: sidebar focus
|
|
60
|
+
* always shows the fixed navigation hints (`extras` is ignored, since the
|
|
61
|
+
* sidebar's own keys never depend on the active screen); main focus shows
|
|
62
|
+
* the screen's own `extras` (its list/action keys) followed by `← menu`.
|
|
63
|
+
*/
|
|
64
|
+
export function hintsFor(screen, focus, extras) {
|
|
65
|
+
void screen;
|
|
66
|
+
if (focus === "sidebar")
|
|
67
|
+
return SIDEBAR_HINTS;
|
|
68
|
+
return [...extras, MAIN_MENU_HINT];
|
|
69
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Dashboard screen's `[ Quick actions ]` panel: four fixed actions
|
|
3
|
+
* (`c` refresh the catalog, `s` sync every project, `S` full-sync every
|
|
4
|
+
* project, `r` reload the dashboard model) plus a status line describing
|
|
5
|
+
* what is running, what just finished, or why the panel is unavailable.
|
|
6
|
+
* Pure: no I/O, no Ink, no `Date.now()` — the panel component renders
|
|
7
|
+
* `QUICK_ACTIONS` itself (coloring the key) and this module's
|
|
8
|
+
* `formatQuickActionLines` for the status text underneath.
|
|
9
|
+
*/
|
|
10
|
+
/** The four quick actions, in the order they are listed in the panel. */
|
|
11
|
+
export const QUICK_ACTIONS = [
|
|
12
|
+
{ key: "c", label: "refresh catalog" },
|
|
13
|
+
{ key: "s", label: "sync all projects" },
|
|
14
|
+
{ key: "S", label: "full sync all" },
|
|
15
|
+
{ key: "r", label: "reload" },
|
|
16
|
+
];
|
|
17
|
+
/** `QUICK_ACTIONS`' label for `key` (every `QuickActionKey` is present, so this is never `undefined` in practice). */
|
|
18
|
+
function labelFor(key) {
|
|
19
|
+
return QUICK_ACTIONS.find((action) => action.key === key)?.label ?? key;
|
|
20
|
+
}
|
|
21
|
+
/** The raw (unbounded) status text for `state`, or `""` when there is nothing to show yet (`idle`). */
|
|
22
|
+
function statusText(state) {
|
|
23
|
+
switch (state.status) {
|
|
24
|
+
case "idle":
|
|
25
|
+
return "";
|
|
26
|
+
case "busy":
|
|
27
|
+
return `… ${labelFor(state.key)}`;
|
|
28
|
+
case "done":
|
|
29
|
+
return state.message;
|
|
30
|
+
case "unavailable":
|
|
31
|
+
return state.reason;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** `text` cut to at most `width` visible characters, trailing with `…` when something was cut off. `width` below 1 is treated as 1. */
|
|
35
|
+
function truncate(text, width) {
|
|
36
|
+
const safeWidth = Math.max(Math.floor(width), 1);
|
|
37
|
+
if (text.length <= safeWidth)
|
|
38
|
+
return text;
|
|
39
|
+
return `${text.slice(0, Math.max(safeWidth - 1, 0))}…`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The quick actions panel's status line for `state`, truncated to `width`
|
|
43
|
+
* (the panel's own inner content width) — always exactly one line, so the
|
|
44
|
+
* panel's own fixed height never changes with the message length (see the
|
|
45
|
+
* layout-stability rule other Dashboard panels follow). Empty for `idle`.
|
|
46
|
+
*/
|
|
47
|
+
export function formatQuickActionLines(width, state) {
|
|
48
|
+
return [truncate(statusText(state), width)];
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build one {@link SyncRow} per project from its own
|
|
3
|
+
* {@link SyncStatusSnapshot} (`kankaku/hub`'s `computeSyncStatus`, never
|
|
4
|
+
* reimplemented here), sorted by project name.
|
|
5
|
+
*/
|
|
6
|
+
export function buildSyncRows(entries) {
|
|
7
|
+
return entries
|
|
8
|
+
.map(({ project, status }) => ({
|
|
9
|
+
name: project.name,
|
|
10
|
+
dir: project.dir,
|
|
11
|
+
pending: status.pending,
|
|
12
|
+
staleOutsideWindow: status.staleOutsideWindow,
|
|
13
|
+
...(status.state?.syncedThrough !== undefined ? { syncedThrough: status.state.syncedThrough } : {}),
|
|
14
|
+
...(status.state?.lastError !== undefined ? { lastError: status.state.lastError } : {}),
|
|
15
|
+
}))
|
|
16
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
17
|
+
}
|