@task-mcp/shared 1.0.9 → 1.0.11

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.
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Shared dashboard renderer for CLI and MCP server
3
+ * Provides consistent dashboard layout across all interfaces
4
+ */
5
+
6
+ import type { Task, Project, InboxItem } from "../schemas/index.js";
7
+ import {
8
+ c,
9
+ box,
10
+ progressBar,
11
+ table,
12
+ icons,
13
+ sideBySide,
14
+ truncateStr,
15
+ pad,
16
+ banner,
17
+ stripAnsi,
18
+ type TableColumn,
19
+ } from "./terminal-ui.js";
20
+
21
+ // =============================================================================
22
+ // Types
23
+ // =============================================================================
24
+
25
+ export interface DashboardStats {
26
+ total: number;
27
+ completed: number;
28
+ inProgress: number;
29
+ pending: number;
30
+ blocked: number;
31
+ cancelled: number;
32
+ byPriority: {
33
+ critical: number;
34
+ high: number;
35
+ medium: number;
36
+ low: number;
37
+ };
38
+ }
39
+
40
+ export interface DependencyMetrics {
41
+ readyToWork: number;
42
+ blockedByDependencies: number;
43
+ mostDependedOn?: {
44
+ id: string;
45
+ title: string;
46
+ dependentCount: number;
47
+ } | undefined;
48
+ }
49
+
50
+ export interface DashboardData {
51
+ tasks: Task[];
52
+ projects: Project[];
53
+ inboxItems?: InboxItem[] | undefined;
54
+ currentProject?: Project | undefined;
55
+ version?: string | undefined;
56
+ }
57
+
58
+ // =============================================================================
59
+ // Statistics Calculators
60
+ // =============================================================================
61
+
62
+ export function calculateStats(tasks: Task[]): DashboardStats {
63
+ const stats: DashboardStats = {
64
+ total: tasks.length,
65
+ completed: 0,
66
+ inProgress: 0,
67
+ pending: 0,
68
+ blocked: 0,
69
+ cancelled: 0,
70
+ byPriority: { critical: 0, high: 0, medium: 0, low: 0 },
71
+ };
72
+
73
+ for (const task of tasks) {
74
+ switch (task.status) {
75
+ case "completed":
76
+ stats.completed++;
77
+ break;
78
+ case "in_progress":
79
+ stats.inProgress++;
80
+ break;
81
+ case "pending":
82
+ stats.pending++;
83
+ break;
84
+ case "blocked":
85
+ stats.blocked++;
86
+ break;
87
+ case "cancelled":
88
+ stats.cancelled++;
89
+ break;
90
+ }
91
+
92
+ const priority = task.priority ?? "medium";
93
+ if (priority in stats.byPriority) {
94
+ stats.byPriority[priority as keyof typeof stats.byPriority]++;
95
+ }
96
+ }
97
+
98
+ return stats;
99
+ }
100
+
101
+ export function calculateDependencyMetrics(tasks: Task[]): DependencyMetrics {
102
+ const completedIds = new Set(
103
+ tasks.filter((t) => t.status === "completed").map((t) => t.id)
104
+ );
105
+
106
+ let readyToWork = 0;
107
+ let blockedByDependencies = 0;
108
+
109
+ // Count dependents for each task
110
+ const dependentCounts = new Map<string, number>();
111
+
112
+ for (const task of tasks) {
113
+ if (task.status === "completed" || task.status === "cancelled") continue;
114
+
115
+ const deps = task.dependencies ?? [];
116
+ if (deps.length === 0) {
117
+ readyToWork++;
118
+ } else {
119
+ const allSatisfied = deps.every((d) => completedIds.has(d.taskId));
120
+ if (allSatisfied) {
121
+ readyToWork++;
122
+ } else {
123
+ blockedByDependencies++;
124
+ }
125
+ }
126
+
127
+ // Track dependent counts
128
+ for (const dep of deps) {
129
+ dependentCounts.set(
130
+ dep.taskId,
131
+ (dependentCounts.get(dep.taskId) ?? 0) + 1
132
+ );
133
+ }
134
+ }
135
+
136
+ // Find most depended on task
137
+ let mostDependedOn: DependencyMetrics["mostDependedOn"];
138
+ let maxCount = 0;
139
+
140
+ for (const [taskId, count] of dependentCounts) {
141
+ if (count > maxCount) {
142
+ maxCount = count;
143
+ const task = tasks.find((t) => t.id === taskId);
144
+ if (task) {
145
+ mostDependedOn = {
146
+ id: taskId,
147
+ title: task.title,
148
+ dependentCount: count,
149
+ };
150
+ }
151
+ }
152
+ }
153
+
154
+ return { readyToWork, blockedByDependencies, mostDependedOn };
155
+ }
156
+
157
+ // =============================================================================
158
+ // Formatters
159
+ // =============================================================================
160
+
161
+ function formatPriority(priority: Task["priority"]): string {
162
+ const colors: Record<string, (s: string) => string> = {
163
+ critical: c.red,
164
+ high: c.yellow,
165
+ medium: c.blue,
166
+ low: c.gray,
167
+ };
168
+ return (colors[priority] ?? c.gray)(priority);
169
+ }
170
+
171
+ function getTimeAgo(date: Date): string {
172
+ const now = new Date();
173
+ const diffMs = now.getTime() - date.getTime();
174
+ const diffMins = Math.floor(diffMs / 60000);
175
+ const diffHours = Math.floor(diffMins / 60);
176
+ const diffDays = Math.floor(diffHours / 24);
177
+
178
+ if (diffMins < 60) return `${diffMins}m ago`;
179
+ if (diffHours < 24) return `${diffHours}h ago`;
180
+ if (diffDays < 7) return `${diffDays}d ago`;
181
+ return date.toLocaleDateString();
182
+ }
183
+
184
+ // =============================================================================
185
+ // Widget Renderers
186
+ // =============================================================================
187
+
188
+ /**
189
+ * Render Status widget (progress, counts, priorities, dependencies)
190
+ */
191
+ export function renderStatusWidget(
192
+ tasks: Task[],
193
+ _projects: Project[]
194
+ ): string {
195
+ const stats = calculateStats(tasks);
196
+ const depMetrics = calculateDependencyMetrics(tasks);
197
+ const today = getTodayTasks(tasks);
198
+ const overdue = getOverdueTasks(tasks);
199
+ const activeTasks = stats.total - stats.cancelled;
200
+ const percent =
201
+ activeTasks > 0 ? Math.round((stats.completed / activeTasks) * 100) : 0;
202
+
203
+ const lines: string[] = [];
204
+
205
+ // Progress bar with fraction
206
+ const bar = progressBar(stats.completed, activeTasks, { width: 24 });
207
+ lines.push(`${bar} ${c.bold(`${percent}%`)} ${stats.completed}/${activeTasks} tasks`);
208
+ lines.push("");
209
+
210
+ // Status counts
211
+ lines.push(
212
+ `${c.green("Done")}: ${stats.completed} ` +
213
+ `${c.blue("Progress")}: ${stats.inProgress} ` +
214
+ `${c.yellow("Pending")}: ${stats.pending} ` +
215
+ `${c.red("Blocked")}: ${stats.blocked}`
216
+ );
217
+
218
+ // Schedule info
219
+ const scheduleInfo = [];
220
+ if (overdue.length > 0) scheduleInfo.push(c.red(`Overdue: ${overdue.length}`));
221
+ if (today.length > 0) scheduleInfo.push(c.yellow(`Today: ${today.length}`));
222
+ if (scheduleInfo.length > 0) {
223
+ lines.push(scheduleInfo.join(" "));
224
+ }
225
+
226
+ lines.push("");
227
+
228
+ // Priority breakdown
229
+ lines.push(
230
+ `${c.red("Critical")}: ${stats.byPriority.critical} ` +
231
+ `${c.yellow("High")}: ${stats.byPriority.high} ` +
232
+ `${c.blue("Medium")}: ${stats.byPriority.medium} ` +
233
+ `${c.gray("Low")}: ${stats.byPriority.low}`
234
+ );
235
+
236
+ // Dependencies summary
237
+ lines.push(
238
+ `${c.green("Ready")}: ${depMetrics.readyToWork} ` +
239
+ `${c.red("Blocked")}: ${depMetrics.blockedByDependencies}` +
240
+ (depMetrics.mostDependedOn
241
+ ? ` ${c.dim("Bottleneck:")} ${truncateStr(depMetrics.mostDependedOn.title, 15)}`
242
+ : "")
243
+ );
244
+
245
+ return box(lines.join("\n"), {
246
+ title: "Status",
247
+ borderColor: "cyan",
248
+ padding: 1,
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Render Next Actions widget (top ready tasks)
254
+ */
255
+ export function renderActionsWidget(tasks: Task[]): string {
256
+ const lines: string[] = [];
257
+
258
+ // Get top 4 ready tasks sorted by priority
259
+ const readyTasks = tasks
260
+ .filter(
261
+ (t) =>
262
+ t.status === "pending" &&
263
+ (!t.dependencies || t.dependencies.length === 0)
264
+ )
265
+ .sort((a, b) => {
266
+ const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
267
+ return (
268
+ (priorityOrder[a.priority] ?? 2) - (priorityOrder[b.priority] ?? 2)
269
+ );
270
+ })
271
+ .slice(0, 4);
272
+
273
+ if (readyTasks.length > 0) {
274
+ for (const task of readyTasks) {
275
+ const deps = task.dependencies?.length ?? 0;
276
+ const depsInfo = deps > 0 ? `${deps} deps` : c.green("ready");
277
+ lines.push(`${c.cyan("→")} ${truncateStr(task.title, 24)}`);
278
+ lines.push(` ${formatPriority(task.priority)}, ${depsInfo}`);
279
+ }
280
+ } else {
281
+ lines.push(c.dim("No tasks ready"));
282
+ }
283
+
284
+ return box(lines.join("\n"), {
285
+ title: "Next Actions",
286
+ borderColor: "green",
287
+ padding: 1,
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Render Inbox widget (pending items)
293
+ */
294
+ export function renderInboxWidget(inboxItems: InboxItem[]): string | null {
295
+ const pendingItems = inboxItems.filter((item) => item.status === "pending");
296
+
297
+ if (pendingItems.length === 0) {
298
+ return null;
299
+ }
300
+
301
+ const lines: string[] = [];
302
+ lines.push(`${c.yellow("Pending")}: ${pendingItems.length} items`);
303
+ lines.push("");
304
+
305
+ // Show up to 3 items
306
+ for (const item of pendingItems.slice(0, 3)) {
307
+ const date = new Date(item.capturedAt);
308
+ const ago = getTimeAgo(date);
309
+ const tags = item.tags?.length ? c.dim(` #${item.tags[0]}`) : "";
310
+ lines.push(`${c.yellow("○")} ${truncateStr(item.content, 40)}${tags}`);
311
+ lines.push(` ${c.dim(ago)}`);
312
+ }
313
+
314
+ if (pendingItems.length > 3) {
315
+ lines.push(c.gray(`+${pendingItems.length - 3} more`));
316
+ }
317
+
318
+ return box(lines.join("\n"), {
319
+ title: "Inbox",
320
+ borderColor: "yellow",
321
+ padding: 1,
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Render Projects table
327
+ */
328
+ export function renderProjectsTable(
329
+ projects: Project[],
330
+ getProjectTasks: (projectId: string) => Task[]
331
+ ): string {
332
+ if (projects.length === 0) {
333
+ return c.gray("No projects found.");
334
+ }
335
+
336
+ const rows: {
337
+ name: string;
338
+ progress: string;
339
+ ready: number;
340
+ blocked: number;
341
+ total: number;
342
+ }[] = [];
343
+
344
+ for (const project of projects.slice(0, 10)) {
345
+ const tasks = getProjectTasks(project.id);
346
+ const stats = calculateStats(tasks);
347
+ const depMetrics = calculateDependencyMetrics(tasks);
348
+ const activeTasks = stats.total - stats.cancelled;
349
+ const percent =
350
+ activeTasks > 0
351
+ ? Math.round((stats.completed / activeTasks) * 100)
352
+ : 0;
353
+
354
+ // Create mini progress bar
355
+ const barWidth = 8;
356
+ const filled = Math.round((percent / 100) * barWidth);
357
+ const empty = barWidth - filled;
358
+ const miniBar =
359
+ c.green("█".repeat(filled)) + c.gray("░".repeat(empty));
360
+
361
+ rows.push({
362
+ name: truncateStr(project.name, 20),
363
+ progress: `${miniBar} ${pad(String(percent) + "%", 4, "right")}`,
364
+ ready: depMetrics.readyToWork,
365
+ blocked: depMetrics.blockedByDependencies,
366
+ total: activeTasks,
367
+ });
368
+ }
369
+
370
+ const columns: TableColumn[] = [
371
+ { key: "name", header: "Project", width: 22 },
372
+ { key: "progress", header: "Progress", width: 16 },
373
+ { key: "total", header: "Tasks", width: 6, align: "right" },
374
+ {
375
+ key: "ready",
376
+ header: "Ready",
377
+ width: 6,
378
+ align: "right",
379
+ format: (v) => c.green(String(v)),
380
+ },
381
+ {
382
+ key: "blocked",
383
+ header: "Blocked",
384
+ width: 8,
385
+ align: "right",
386
+ format: (v) => (Number(v) > 0 ? c.red(String(v)) : c.gray(String(v))),
387
+ },
388
+ ];
389
+
390
+ return table(rows as unknown as Record<string, unknown>[], columns);
391
+ }
392
+
393
+ /**
394
+ * Render Tasks table for single project view
395
+ */
396
+ export function renderTasksTable(tasks: Task[], limit: number = 10): string {
397
+ const activeTasks = tasks.filter(
398
+ (t) => t.status !== "completed" && t.status !== "cancelled"
399
+ );
400
+
401
+ if (activeTasks.length === 0) {
402
+ return c.gray("No active tasks.");
403
+ }
404
+
405
+ const displayTasks = activeTasks.slice(0, limit);
406
+
407
+ const columns: TableColumn[] = [
408
+ { key: "title", header: "Title", width: 40 },
409
+ {
410
+ key: "status",
411
+ header: "Status",
412
+ width: 12,
413
+ format: (v) => {
414
+ const icon = icons[v as Task["status"]] ?? icons.pending;
415
+ return `${icon} ${v}`;
416
+ },
417
+ },
418
+ {
419
+ key: "priority",
420
+ header: "Priority",
421
+ width: 10,
422
+ format: (v) => formatPriority(v as Task["priority"]),
423
+ },
424
+ ];
425
+
426
+ let result = table(
427
+ displayTasks as unknown as Record<string, unknown>[],
428
+ columns
429
+ );
430
+
431
+ if (activeTasks.length > limit) {
432
+ result += `\n${c.gray(`(+${activeTasks.length - limit} more tasks)`)}`;
433
+ }
434
+
435
+ return result;
436
+ }
437
+
438
+ // =============================================================================
439
+ // Date Helpers
440
+ // =============================================================================
441
+
442
+ function getTodayTasks(tasks: Task[]): Task[] {
443
+ const today = new Date().toISOString().split("T")[0];
444
+ return tasks.filter(
445
+ (t) =>
446
+ t.dueDate === today &&
447
+ t.status !== "completed" &&
448
+ t.status !== "cancelled"
449
+ );
450
+ }
451
+
452
+ function getOverdueTasks(tasks: Task[]): Task[] {
453
+ const today = new Date().toISOString().split("T")[0] ?? "";
454
+ return tasks.filter(
455
+ (t) =>
456
+ t.dueDate &&
457
+ t.dueDate < today &&
458
+ t.status !== "completed" &&
459
+ t.status !== "cancelled"
460
+ );
461
+ }
462
+
463
+ // =============================================================================
464
+ // Full Dashboard Renderer
465
+ // =============================================================================
466
+
467
+ export interface RenderDashboardOptions {
468
+ showBanner?: boolean | undefined;
469
+ showInbox?: boolean | undefined;
470
+ showProjects?: boolean | undefined;
471
+ showTasks?: boolean | undefined;
472
+ stripAnsiCodes?: boolean | undefined;
473
+ }
474
+
475
+ /**
476
+ * Render full dashboard (CLI and MCP compatible)
477
+ */
478
+ export function renderDashboard(
479
+ data: DashboardData,
480
+ getProjectTasks: (projectId: string) => Task[],
481
+ options: RenderDashboardOptions = {}
482
+ ): string {
483
+ const {
484
+ showBanner = true,
485
+ showInbox = true,
486
+ showProjects = true,
487
+ showTasks = true,
488
+ stripAnsiCodes = false,
489
+ } = options;
490
+
491
+ const { tasks, projects, inboxItems = [], currentProject, version } = data;
492
+ const lines: string[] = [];
493
+
494
+ // Banner
495
+ if (showBanner) {
496
+ lines.push("");
497
+ lines.push(banner("TASK MCP"));
498
+ lines.push("");
499
+ }
500
+
501
+ // Project info header
502
+ const projectInfo = currentProject
503
+ ? `${c.bold("Project:")} ${currentProject.name}`
504
+ : `${c.bold("All Projects")} (${projects.length} projects)`;
505
+
506
+ if (version) {
507
+ lines.push(c.dim(`v${version} ${projectInfo}`));
508
+ } else {
509
+ lines.push(projectInfo);
510
+ }
511
+ lines.push("");
512
+
513
+ // Status + Actions widgets side by side
514
+ const statusWidget = renderStatusWidget(tasks, projects);
515
+ const actionsWidget = renderActionsWidget(tasks);
516
+ lines.push(sideBySide([statusWidget, actionsWidget], 2));
517
+ lines.push("");
518
+
519
+ // Inbox widget
520
+ if (showInbox && inboxItems.length > 0) {
521
+ const inboxWidget = renderInboxWidget(inboxItems);
522
+ if (inboxWidget) {
523
+ lines.push(inboxWidget);
524
+ lines.push("");
525
+ }
526
+ }
527
+
528
+ // Projects table (only for all-projects view)
529
+ if (showProjects && !currentProject && projects.length > 1) {
530
+ lines.push(c.bold("Projects"));
531
+ lines.push("");
532
+ lines.push(renderProjectsTable(projects, getProjectTasks));
533
+ lines.push("");
534
+ }
535
+
536
+ // Tasks table (for single project view)
537
+ if (showTasks && (currentProject || projects.length === 1)) {
538
+ const activeTasks = tasks.filter(
539
+ (t) => t.status !== "completed" && t.status !== "cancelled"
540
+ );
541
+ if (activeTasks.length > 0) {
542
+ lines.push(c.bold(`Tasks (${activeTasks.length})`));
543
+ lines.push("");
544
+ lines.push(renderTasksTable(tasks));
545
+ }
546
+ }
547
+
548
+ lines.push("");
549
+
550
+ const output = lines.join("\n");
551
+ return stripAnsiCodes ? stripAnsi(output) : output;
552
+ }
553
+
554
+ /**
555
+ * Render single project dashboard
556
+ */
557
+ export function renderProjectDashboard(
558
+ project: Project,
559
+ tasks: Task[],
560
+ options: { stripAnsiCodes?: boolean; version?: string } = {}
561
+ ): string {
562
+ const data: DashboardData = {
563
+ tasks,
564
+ projects: [project],
565
+ currentProject: project,
566
+ version: options.version,
567
+ };
568
+
569
+ return renderDashboard(
570
+ data,
571
+ () => tasks,
572
+ {
573
+ showBanner: true,
574
+ showInbox: false,
575
+ showProjects: false,
576
+ showTasks: true,
577
+ stripAnsiCodes: options.stripAnsiCodes,
578
+ }
579
+ );
580
+ }
581
+
582
+ /**
583
+ * Render global dashboard (all projects)
584
+ */
585
+ export function renderGlobalDashboard(
586
+ projects: Project[],
587
+ allTasks: Task[],
588
+ inboxItems: InboxItem[],
589
+ getProjectTasks: (projectId: string) => Task[],
590
+ options: { stripAnsiCodes?: boolean; version?: string } = {}
591
+ ): string {
592
+ const data: DashboardData = {
593
+ tasks: allTasks,
594
+ projects,
595
+ inboxItems,
596
+ version: options.version,
597
+ };
598
+
599
+ return renderDashboard(data, getProjectTasks, {
600
+ showBanner: true,
601
+ showInbox: true,
602
+ showProjects: true,
603
+ showTasks: projects.length === 1,
604
+ stripAnsiCodes: options.stripAnsiCodes,
605
+ });
606
+ }
@@ -45,3 +45,65 @@ export {
45
45
  truncate,
46
46
  summarizeList,
47
47
  } from "./projection.js";
48
+
49
+ // Dashboard renderer
50
+ export {
51
+ calculateStats,
52
+ calculateDependencyMetrics,
53
+ renderStatusWidget,
54
+ renderActionsWidget,
55
+ renderInboxWidget,
56
+ renderProjectsTable,
57
+ renderTasksTable,
58
+ renderDashboard,
59
+ renderProjectDashboard,
60
+ renderGlobalDashboard,
61
+ type DashboardStats,
62
+ type DependencyMetrics,
63
+ type DashboardData,
64
+ type RenderDashboardOptions,
65
+ } from "./dashboard-renderer.js";
66
+
67
+ // Terminal UI utilities
68
+ export {
69
+ // Colors and styles
70
+ color,
71
+ style,
72
+ styled,
73
+ c,
74
+ // Box drawing
75
+ BOX,
76
+ box,
77
+ drawBox,
78
+ hline,
79
+ // String utilities
80
+ stripAnsi,
81
+ displayWidth,
82
+ visibleLength,
83
+ pad,
84
+ padEnd,
85
+ padStart,
86
+ center,
87
+ truncateStr,
88
+ // Progress bar
89
+ progressBar,
90
+ type ProgressBarOptions,
91
+ // Layout
92
+ sideBySide,
93
+ sideBySideArrays,
94
+ type BoxOptions,
95
+ // Tables
96
+ table,
97
+ renderTable,
98
+ type TableColumn,
99
+ // Formatters
100
+ statusColors,
101
+ statusIcons,
102
+ icons,
103
+ formatStatus,
104
+ priorityColors,
105
+ formatPriority,
106
+ formatDependencies,
107
+ // Banner
108
+ banner,
109
+ } from "./terminal-ui.js";