@critical-path/react 0.14.0 → 0.14.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/src/hooks.ts DELETED
@@ -1,1318 +0,0 @@
1
- 'use client';
2
-
3
- import type { CreateAttachmentBody, CreateCommentBody, CreateTaskBody, UpdateCommentBody, UpdateDeliverableBody, UpdateTaskBody } from '@critical-path/core/schemas';
4
- import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
5
- import type {
6
- Project,
7
- Task,
8
- TaskStatus,
9
- Workflow,
10
- Comment,
11
- CommentReaction,
12
- Attachment,
13
- Deliverable,
14
- DeliverableSummary,
15
- CreateDeliverableInput,
16
- StatusDefinition,
17
- SemanticStatus,
18
- CriticalPathAnalysis,
19
- TimelineLadder,
20
- TimelineLadderOptions,
21
- TaskLadderView,
22
- AbstractionLevel,
23
- TaskMetrics,
24
- TaskProgressHistory,
25
- WorkloadDistribution,
26
- WorkloadDistributionOptions,
27
- WorkloadInterval,
28
- WorkloadGroupBy,
29
- WorkloadMetric
30
- } from '@critical-path/core';
31
- import { resolveStatusDefinition, isTempTaskId } from '@critical-path/core';
32
- import { registerWebMcpTools } from '@critical-path/mcp/web';
33
- import { useCriticalPathClient } from './provider.js';
34
-
35
- export function useWorkflows() {
36
- const client = useCriticalPathClient();
37
- const [workflows, setWorkflows] = useState<Workflow[]>([]);
38
- const [loading, setLoading] = useState(true);
39
- const [error, setError] = useState<Error | null>(null);
40
-
41
- const fetchWorkflows = useCallback(async (signal?: AbortSignal) => {
42
- const api = signal ? client.with({ signal }) : client;
43
- try {
44
- setLoading(true);
45
- const data = await api.getWorkflows();
46
- if (signal?.aborted) return;
47
- setWorkflows(data);
48
- setError(null);
49
- } catch (err) {
50
- if (signal?.aborted) return;
51
- setError(err instanceof Error ? err : new Error(String(err)));
52
- } finally {
53
- if (!signal?.aborted) setLoading(false);
54
- }
55
- }, [client]);
56
-
57
- useEffect(() => {
58
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
59
- // response for old inputs can never overwrite newer data.
60
- const controller = new AbortController();
61
- fetchWorkflows(controller.signal);
62
- return () => controller.abort();
63
- }, [fetchWorkflows]);
64
-
65
- const createWorkflow = async (input: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>) => {
66
- try {
67
- const created = await client.createWorkflow(input);
68
- setWorkflows((prev) => [...prev, created]);
69
- return created;
70
- } catch (err) {
71
- const errorObj = err instanceof Error ? err : new Error(String(err));
72
- setError(errorObj);
73
- throw errorObj;
74
- }
75
- };
76
-
77
- const updateWorkflow = async (id: string, updates: Partial<Workflow>) => {
78
- try {
79
- const updated = await client.updateWorkflow(id, updates);
80
- setWorkflows((prev) => prev.map((w) => (w.id === id ? updated : w)));
81
- return updated;
82
- } catch (err) {
83
- const errorObj = err instanceof Error ? err : new Error(String(err));
84
- setError(errorObj);
85
- throw errorObj;
86
- }
87
- };
88
-
89
- const deleteWorkflow = async (id: string) => {
90
- try {
91
- await client.deleteWorkflow(id);
92
- setWorkflows((prev) => prev.filter((w) => w.id !== id));
93
- } catch (err) {
94
- const errorObj = err instanceof Error ? err : new Error(String(err));
95
- setError(errorObj);
96
- throw errorObj;
97
- }
98
- };
99
-
100
- return { workflows, loading, error, refresh: () => fetchWorkflows(), createWorkflow, updateWorkflow, deleteWorkflow };
101
- }
102
-
103
- export function useTaskTransitions(taskId?: string) {
104
- const client = useCriticalPathClient();
105
- const [allowedTransitions, setAllowedTransitions] = useState<string[]>([]);
106
- const [loading, setLoading] = useState(false);
107
- const [error, setError] = useState<Error | null>(null);
108
-
109
- const fetchTransitions = useCallback(async (signal?: AbortSignal) => {
110
- const api = signal ? client.with({ signal }) : client;
111
- if (!taskId) {
112
- setAllowedTransitions([]);
113
- return;
114
- }
115
- try {
116
- setLoading(true);
117
- const data = await api.getAllowedTaskTransitions(taskId);
118
- if (signal?.aborted) return;
119
- setAllowedTransitions(data);
120
- setError(null);
121
- } catch (err) {
122
- if (signal?.aborted) return;
123
- setError(err instanceof Error ? err : new Error(String(err)));
124
- } finally {
125
- if (!signal?.aborted) setLoading(false);
126
- }
127
- }, [client, taskId]);
128
-
129
- useEffect(() => {
130
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
131
- // response for old inputs can never overwrite newer data.
132
- const controller = new AbortController();
133
- fetchTransitions(controller.signal);
134
- return () => controller.abort();
135
- }, [fetchTransitions]);
136
-
137
- return { allowedTransitions, loading, error, refresh: () => fetchTransitions() };
138
- }
139
-
140
- export function useProjects() {
141
- const client = useCriticalPathClient();
142
- const [projects, setProjects] = useState<Project[]>([]);
143
- const [loading, setLoading] = useState(true);
144
- const [error, setError] = useState<Error | null>(null);
145
-
146
- const fetchProjects = useCallback(async (signal?: AbortSignal) => {
147
- const api = signal ? client.with({ signal }) : client;
148
- try {
149
- setLoading(true);
150
- const data = await api.getProjects();
151
- if (signal?.aborted) return;
152
- setProjects(data);
153
- setError(null);
154
- } catch (err) {
155
- if (signal?.aborted) return;
156
- setError(err instanceof Error ? err : new Error(String(err)));
157
- } finally {
158
- if (!signal?.aborted) setLoading(false);
159
- }
160
- }, [client]);
161
-
162
- useEffect(() => {
163
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
164
- // response for old inputs can never overwrite newer data.
165
- const controller = new AbortController();
166
- fetchProjects(controller.signal);
167
- return () => controller.abort();
168
- }, [fetchProjects]);
169
-
170
- const createProject = async (input: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>) => {
171
- const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
172
- const now = new Date().toISOString();
173
- const tempProject: Project = {
174
- ...input,
175
- id: tempId,
176
- createdAt: now,
177
- updatedAt: now
178
- };
179
-
180
- setProjects((prev) => [...prev, tempProject]);
181
-
182
- try {
183
- const created = await client.createProject(input);
184
- setProjects((prev) => prev.map((p) => (p.id === tempId ? created : p)));
185
- return created;
186
- } catch (err) {
187
- setProjects((prev) => prev.filter((p) => p.id !== tempId));
188
- const errorObj = err instanceof Error ? err : new Error(String(err));
189
- setError(errorObj);
190
- throw errorObj;
191
- }
192
- };
193
-
194
- return { projects, loading, error, refresh: () => fetchProjects(), createProject };
195
- }
196
-
197
- export function useTasks(projectId?: string) {
198
- const client = useCriticalPathClient();
199
- const [tasks, setTasks] = useState<Task[]>([]);
200
- const [loading, setLoading] = useState(true);
201
- const [error, setError] = useState<Error | null>(null);
202
-
203
- const fetchTasks = useCallback(async (signal?: AbortSignal) => {
204
- const api = signal ? client.with({ signal }) : client;
205
- try {
206
- setLoading(true);
207
- const data = await api.getTasks(projectId);
208
- if (signal?.aborted) return;
209
- setTasks(data);
210
- setError(null);
211
- } catch (err) {
212
- if (signal?.aborted) return;
213
- setError(err instanceof Error ? err : new Error(String(err)));
214
- } finally {
215
- if (!signal?.aborted) setLoading(false);
216
- }
217
- }, [client, projectId]);
218
-
219
- const pendingCreationsRef = useRef<Map<string, Promise<Task>>>(new Map());
220
- const tempToRealIdMapRef = useRef<Map<string, string>>(new Map());
221
-
222
- useEffect(() => {
223
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
224
- // response for old inputs can never overwrite newer data.
225
- const controller = new AbortController();
226
- fetchTasks(controller.signal);
227
- return () => controller.abort();
228
- }, [fetchTasks]);
229
-
230
- const createTask = async (input: CreateTaskBody) => {
231
- const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
232
- const now = new Date().toISOString();
233
- const tempTask: Task = {
234
- ...input,
235
- // Placeholders until the server responds with the workflow's real defaults
236
- status: input.status ?? 'todo',
237
- priority: input.priority ?? 'medium',
238
- id: tempId,
239
- createdAt: now,
240
- updatedAt: now
241
- };
242
-
243
- setTasks((prev) => [...prev, tempTask]);
244
-
245
- const promise = client.createTask(input);
246
- pendingCreationsRef.current.set(tempId, promise);
247
-
248
- try {
249
- const created = await promise;
250
- tempToRealIdMapRef.current.set(tempId, created.id);
251
- setTasks((prev) => prev.map((t) => (t.id === tempId ? created : t)));
252
- return created;
253
- } catch (err) {
254
- setTasks((prev) => prev.filter((t) => t.id !== tempId));
255
- const errorObj = err instanceof Error ? err : new Error(String(err));
256
- setError(errorObj);
257
- throw errorObj;
258
- } finally {
259
- pendingCreationsRef.current.delete(tempId);
260
- }
261
- };
262
-
263
- const updateTask = async (taskId: string, updates: UpdateTaskBody) => {
264
- let targetId = taskId;
265
- if (tempToRealIdMapRef.current.has(taskId)) {
266
- targetId = tempToRealIdMapRef.current.get(taskId)!;
267
- }
268
-
269
- let previousTask: Task | undefined;
270
-
271
- // 1. Immediately apply optimistic updates to local tasks
272
- setTasks((prev) => {
273
- previousTask = prev.find((t) => t.id === targetId || t.id === taskId);
274
- if (!previousTask) return prev;
275
- return prev.map((t) =>
276
- t.id === targetId || t.id === taskId
277
- ? { ...t, ...updates, updatedAt: new Date().toISOString() }
278
- : t
279
- );
280
- });
281
-
282
- // 2. If it's a temporary task, await pending creation if in flight
283
- if (isTempTaskId(targetId)) {
284
- const pendingPromise =
285
- pendingCreationsRef.current.get(targetId) ||
286
- (pendingCreationsRef.current.size === 1
287
- ? Array.from(pendingCreationsRef.current.values())[0]
288
- : undefined);
289
-
290
- if (pendingPromise) {
291
- try {
292
- const created = await pendingPromise;
293
- if (created && created.id) {
294
- targetId = created.id;
295
- tempToRealIdMapRef.current.set(taskId, targetId);
296
- }
297
- } catch {
298
- throw new Error(`Failed to update task: task creation failed for "${taskId}"`);
299
- }
300
- }
301
- }
302
-
303
- // If still a temp ID, the task exists purely in local state; avoid sending unresolvable ID to backend
304
- if (isTempTaskId(targetId)) {
305
- let resolvedTask: Task | undefined;
306
- setTasks((prev) => {
307
- resolvedTask = prev.find((t) => t.id === targetId || t.id === taskId);
308
- return prev;
309
- });
310
- return (resolvedTask || { id: targetId, ...updates }) as Task;
311
- }
312
-
313
- try {
314
- const updated = await client.updateTask(targetId, updates);
315
- setTasks((prev) =>
316
- prev.map((t) => (t.id === targetId || t.id === taskId ? updated : t))
317
- );
318
- return updated;
319
- } catch (err) {
320
- if (previousTask) {
321
- const revertTask = previousTask;
322
- setTasks((prev) =>
323
- prev.map((t) => (t.id === targetId || t.id === taskId ? revertTask : t))
324
- );
325
- }
326
- const errorObj = err instanceof Error ? err : new Error(String(err));
327
- setError(errorObj);
328
- throw errorObj;
329
- }
330
- };
331
-
332
- const updateTaskStatus = async (taskId: string, status: TaskStatus) => {
333
- return updateTask(taskId, { status });
334
- };
335
-
336
- const deleteTask = async (taskId: string) => {
337
- let targetId = taskId;
338
- if (tempToRealIdMapRef.current.has(taskId)) {
339
- targetId = tempToRealIdMapRef.current.get(taskId)!;
340
- }
341
-
342
- let previousTask: Task | undefined;
343
- let previousIndex = -1;
344
-
345
- setTasks((prev) => {
346
- previousIndex = prev.findIndex((t) => t.id === targetId || t.id === taskId);
347
- if (previousIndex !== -1) {
348
- previousTask = prev[previousIndex];
349
- }
350
- return prev.filter((t) => t.id !== targetId && t.id !== taskId);
351
- });
352
-
353
- if (isTempTaskId(targetId)) {
354
- const pendingPromise = pendingCreationsRef.current.get(targetId);
355
- if (pendingPromise) {
356
- try {
357
- const created = await pendingPromise;
358
- if (created && created.id) {
359
- await client.deleteTask(created.id);
360
- }
361
- } catch {
362
- // If creation failed anyway, task is already gone
363
- }
364
- }
365
- return;
366
- }
367
-
368
- try {
369
- await client.deleteTask(targetId);
370
- } catch (err) {
371
- if (previousTask && previousIndex !== -1) {
372
- const restoreTask = previousTask;
373
- const indexToInsert = previousIndex;
374
- setTasks((prev) => {
375
- const restored = [...prev];
376
- restored.splice(indexToInsert, 0, restoreTask);
377
- return restored;
378
- });
379
- }
380
- const errorObj = err instanceof Error ? err : new Error(String(err));
381
- setError(errorObj);
382
- throw errorObj;
383
- }
384
- };
385
-
386
- return { tasks, loading, error, refresh: () => fetchTasks(), createTask, updateTask, updateTaskStatus, deleteTask };
387
- }
388
-
389
- export interface UseKanbanOptions {
390
- groupBy?: 'workflow' | 'semantic';
391
- customDefinitions?: StatusDefinition[];
392
- }
393
-
394
- export function useKanban(projectId?: string, options?: UseKanbanOptions) {
395
- const { tasks, loading, error, refresh, updateTaskStatus, createTask } = useTasks(projectId);
396
-
397
- const columns = useMemo<Record<string, Task[]>>(() => {
398
- if (options?.groupBy === 'semantic') {
399
- const semanticCols: Record<SemanticStatus, Task[]> = {
400
- not_started: [],
401
- in_progress: [],
402
- completed: [],
403
- canceled: []
404
- };
405
- for (const task of tasks) {
406
- const category = task.semanticStatus || resolveStatusDefinition(task.status, options?.customDefinitions).category;
407
- if (semanticCols[category]) {
408
- semanticCols[category].push(task);
409
- } else {
410
- semanticCols.not_started.push(task);
411
- }
412
- }
413
- return semanticCols;
414
- }
415
-
416
- const workflowCols: Record<string, Task[]> = {};
417
- if (options?.customDefinitions && options.customDefinitions.length > 0) {
418
- for (const def of options.customDefinitions) {
419
- workflowCols[def.key] = [];
420
- }
421
- } else {
422
- workflowCols.backlog = [];
423
- workflowCols.todo = [];
424
- workflowCols.in_progress = [];
425
- workflowCols.in_review = [];
426
- workflowCols.done = [];
427
- workflowCols.canceled = [];
428
- }
429
-
430
- for (const task of tasks) {
431
- if (!workflowCols[task.status]) {
432
- workflowCols[task.status] = [];
433
- }
434
- workflowCols[task.status].push(task);
435
- }
436
-
437
- return workflowCols;
438
- }, [tasks, options?.groupBy, options?.customDefinitions]);
439
-
440
- const moveTask = async (taskId: string, targetStatus: TaskStatus) => {
441
- return updateTaskStatus(taskId, targetStatus);
442
- };
443
-
444
- return { columns, tasks, loading, error, refresh, moveTask, createTask };
445
- }
446
-
447
- export interface ThreadedComment extends Comment {
448
- replies: ThreadedComment[];
449
- }
450
-
451
- export function useComments(taskId: string) {
452
- const client = useCriticalPathClient();
453
- const [comments, setComments] = useState<Comment[]>([]);
454
- const [loading, setLoading] = useState(true);
455
- const [error, setError] = useState<Error | null>(null);
456
-
457
- const fetchComments = useCallback(async (signal?: AbortSignal) => {
458
- const api = signal ? client.with({ signal }) : client;
459
- if (!taskId) {
460
- setComments([]);
461
- setLoading(false);
462
- return;
463
- }
464
- try {
465
- setLoading(true);
466
- const data = await api.getComments(taskId);
467
- if (signal?.aborted) return;
468
- setComments(data);
469
- setError(null);
470
- } catch (err) {
471
- if (signal?.aborted) return;
472
- setError(err instanceof Error ? err : new Error(String(err)));
473
- } finally {
474
- if (!signal?.aborted) setLoading(false);
475
- }
476
- }, [client, taskId]);
477
-
478
- useEffect(() => {
479
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
480
- // response for old inputs can never overwrite newer data.
481
- const controller = new AbortController();
482
- fetchComments(controller.signal);
483
- return () => controller.abort();
484
- }, [fetchComments]);
485
-
486
- const addComment = async (input: Omit<CreateCommentBody, 'taskId'>) => {
487
- try {
488
- const created = await client.addComment({ ...input, taskId });
489
- setComments((prev) => [...prev, created]);
490
- return created;
491
- } catch (err) {
492
- const errorObj = err instanceof Error ? err : new Error(String(err));
493
- setError(errorObj);
494
- throw errorObj;
495
- }
496
- };
497
-
498
- const updateComment = async (id: string, updates: UpdateCommentBody) => {
499
- try {
500
- const updated = await client.updateComment(id, updates);
501
- setComments((prev) => prev.map((c) => (c.id === id ? updated : c)));
502
- return updated;
503
- } catch (err) {
504
- const errorObj = err instanceof Error ? err : new Error(String(err));
505
- setError(errorObj);
506
- throw errorObj;
507
- }
508
- };
509
-
510
- const deleteComment = async (id: string) => {
511
- try {
512
- await client.deleteComment(id);
513
- setComments((prev) => prev.filter((c) => c.id !== id));
514
- } catch (err) {
515
- const errorObj = err instanceof Error ? err : new Error(String(err));
516
- setError(errorObj);
517
- throw errorObj;
518
- }
519
- };
520
-
521
- const addReaction = async (
522
- commentId: string,
523
- emoji: string
524
- ) => {
525
- try {
526
- const payload = { emoji };
527
- const updated = await client.addCommentReaction(commentId, payload);
528
- setComments((prev) => prev.map((c) => (c.id === commentId ? updated : c)));
529
- return updated;
530
- } catch (err) {
531
- const errorObj = err instanceof Error ? err : new Error(String(err));
532
- setError(errorObj);
533
- throw errorObj;
534
- }
535
- };
536
-
537
- const removeReaction = async (
538
- commentId: string,
539
- emoji: string
540
- ) => {
541
- try {
542
- const payload = { emoji };
543
- const updated = await client.removeCommentReaction(commentId, payload);
544
- setComments((prev) => prev.map((c) => (c.id === commentId ? updated : c)));
545
- return updated;
546
- } catch (err) {
547
- const errorObj = err instanceof Error ? err : new Error(String(err));
548
- setError(errorObj);
549
- throw errorObj;
550
- }
551
- };
552
-
553
- // Build tree of threaded comments
554
- const threads = useMemo(() => {
555
- const map = new Map<string, ThreadedComment>();
556
- const roots: ThreadedComment[] = [];
557
-
558
- // First pass: wrap comments
559
- for (const c of comments) {
560
- map.set(c.id, { ...c, replies: [] });
561
- }
562
-
563
- // Second pass: link replies to parents
564
- for (const c of comments) {
565
- const threaded = map.get(c.id)!;
566
- if (c.parentId && map.has(c.parentId)) {
567
- map.get(c.parentId)!.replies.push(threaded);
568
- } else {
569
- roots.push(threaded);
570
- }
571
- }
572
-
573
- return roots;
574
- }, [comments]);
575
-
576
- return {
577
- comments,
578
- threads,
579
- loading,
580
- error,
581
- refresh: () => fetchComments(),
582
- addComment,
583
- updateComment,
584
- deleteComment,
585
- addReaction,
586
- removeReaction
587
- };
588
- }
589
-
590
- export function useAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }) {
591
- const client = useCriticalPathClient();
592
- const [attachments, setAttachments] = useState<Attachment[]>([]);
593
- const [loading, setLoading] = useState(true);
594
- const [error, setError] = useState<Error | null>(null);
595
-
596
- const filterKey = JSON.stringify(filter || {});
597
-
598
- const fetchAttachments = useCallback(async (signal?: AbortSignal) => {
599
- const api = signal ? client.with({ signal }) : client;
600
- try {
601
- setLoading(true);
602
- const data = await api.getAttachments(filter);
603
- if (signal?.aborted) return;
604
- setAttachments(data);
605
- setError(null);
606
- } catch (err) {
607
- if (signal?.aborted) return;
608
- setError(err instanceof Error ? err : new Error(String(err)));
609
- } finally {
610
- if (!signal?.aborted) setLoading(false);
611
- }
612
- }, [client, filterKey]);
613
-
614
- useEffect(() => {
615
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
616
- // response for old inputs can never overwrite newer data.
617
- const controller = new AbortController();
618
- fetchAttachments(controller.signal);
619
- return () => controller.abort();
620
- }, [fetchAttachments]);
621
-
622
- const createAttachment = async (input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>) => {
623
- try {
624
- const created = await client.createAttachment(input);
625
- setAttachments((prev) => [created, ...prev]);
626
- return created;
627
- } catch (err) {
628
- const errorObj = err instanceof Error ? err : new Error(String(err));
629
- setError(errorObj);
630
- throw errorObj;
631
- }
632
- };
633
-
634
- const deleteAttachment = async (id: string) => {
635
- try {
636
- await client.deleteAttachment(id);
637
- setAttachments((prev) => prev.filter((a) => a.id !== id));
638
- } catch (err) {
639
- const errorObj = err instanceof Error ? err : new Error(String(err));
640
- setError(errorObj);
641
- throw errorObj;
642
- }
643
- };
644
-
645
- return {
646
- attachments,
647
- loading,
648
- error,
649
- refresh: () => fetchAttachments(),
650
- createAttachment,
651
- deleteAttachment
652
- };
653
- }
654
-
655
- export function useDeliverables(projectId?: string) {
656
- const client = useCriticalPathClient();
657
- const [deliverables, setDeliverables] = useState<Deliverable[]>([]);
658
- const [loading, setLoading] = useState(true);
659
- const [error, setError] = useState<Error | null>(null);
660
-
661
- const fetchDeliverables = useCallback(async (signal?: AbortSignal) => {
662
- const api = signal ? client.with({ signal }) : client;
663
- if (!projectId) {
664
- setDeliverables([]);
665
- setLoading(false);
666
- return;
667
- }
668
- try {
669
- setLoading(true);
670
- const data = await api.getDeliverables(projectId);
671
- if (signal?.aborted) return;
672
- setDeliverables(data);
673
- setError(null);
674
- } catch (err) {
675
- if (signal?.aborted) return;
676
- setError(err instanceof Error ? err : new Error(String(err)));
677
- } finally {
678
- if (!signal?.aborted) setLoading(false);
679
- }
680
- }, [client, projectId]);
681
-
682
- useEffect(() => {
683
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
684
- // response for old inputs can never overwrite newer data.
685
- const controller = new AbortController();
686
- fetchDeliverables(controller.signal);
687
- return () => controller.abort();
688
- }, [fetchDeliverables]);
689
-
690
- const createDeliverable = async (input: CreateDeliverableInput) => {
691
- try {
692
- const created = await client.createDeliverable(input);
693
- setDeliverables((prev) => [...prev, created]);
694
- return created;
695
- } catch (err) {
696
- const errorObj = err instanceof Error ? err : new Error(String(err));
697
- setError(errorObj);
698
- throw errorObj;
699
- }
700
- };
701
-
702
- const updateDeliverable = async (id: string, updates: UpdateDeliverableBody) => {
703
- try {
704
- const updated = await client.updateDeliverable(id, updates);
705
- setDeliverables((prev) => prev.map((d) => (d.id === id ? updated : d)));
706
- return updated;
707
- } catch (err) {
708
- const errorObj = err instanceof Error ? err : new Error(String(err));
709
- setError(errorObj);
710
- throw errorObj;
711
- }
712
- };
713
-
714
- const deleteDeliverable = async (id: string) => {
715
- try {
716
- await client.deleteDeliverable(id);
717
- setDeliverables((prev) => prev.filter((d) => d.id !== id));
718
- } catch (err) {
719
- const errorObj = err instanceof Error ? err : new Error(String(err));
720
- setError(errorObj);
721
- throw errorObj;
722
- }
723
- };
724
-
725
- return {
726
- deliverables,
727
- loading,
728
- error,
729
- refresh: () => fetchDeliverables(),
730
- createDeliverable,
731
- updateDeliverable,
732
- deleteDeliverable
733
- };
734
- }
735
-
736
- export function useDeliverableSummary(deliverableId?: string) {
737
- const client = useCriticalPathClient();
738
- const [summary, setSummary] = useState<DeliverableSummary | null>(null);
739
- const [loading, setLoading] = useState(true);
740
- const [error, setError] = useState<Error | null>(null);
741
-
742
- const fetchSummary = useCallback(async (signal?: AbortSignal) => {
743
- const api = signal ? client.with({ signal }) : client;
744
- if (!deliverableId) {
745
- setSummary(null);
746
- setLoading(false);
747
- return;
748
- }
749
- try {
750
- setLoading(true);
751
- const data = await api.getDeliverableSummary(deliverableId);
752
- if (signal?.aborted) return;
753
- setSummary(data);
754
- setError(null);
755
- } catch (err) {
756
- if (signal?.aborted) return;
757
- setError(err instanceof Error ? err : new Error(String(err)));
758
- } finally {
759
- if (!signal?.aborted) setLoading(false);
760
- }
761
- }, [client, deliverableId]);
762
-
763
- useEffect(() => {
764
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
765
- // response for old inputs can never overwrite newer data.
766
- const controller = new AbortController();
767
- fetchSummary(controller.signal);
768
- return () => controller.abort();
769
- }, [fetchSummary]);
770
-
771
- return {
772
- summary,
773
- loading,
774
- error,
775
- refresh: () => fetchSummary()
776
- };
777
- }
778
-
779
- export interface UseWebMCPOptions {
780
- projectId?: string;
781
- tools?: string[];
782
- enabled?: boolean;
783
- onToolExecuted?: (toolName: string, input: any, result: any) => void;
784
- }
785
-
786
- export function useWebMCP(options: UseWebMCPOptions = {}) {
787
- const client = useCriticalPathClient();
788
- const { projectId, tools, enabled = true, onToolExecuted } = options;
789
- const [registered, setRegistered] = useState(false);
790
- const [registeredTools, setRegisteredTools] = useState<string[]>([]);
791
- const [error, setError] = useState<Error | null>(null);
792
-
793
- useEffect(() => {
794
- if (!enabled) {
795
- setRegistered(false);
796
- setRegisteredTools([]);
797
- return;
798
- }
799
-
800
- try {
801
- const handle = registerWebMcpTools({
802
- client,
803
- projectId,
804
- tools,
805
- onToolExecuted
806
- });
807
-
808
- setRegistered(true);
809
- setRegisteredTools(handle.getRegisteredTools().map((t) => t.name));
810
- setError(null);
811
-
812
- return () => {
813
- handle.unregister();
814
- setRegistered(false);
815
- setRegisteredTools([]);
816
- };
817
- } catch (err) {
818
- setError(err instanceof Error ? err : new Error(String(err)));
819
- setRegistered(false);
820
- }
821
- }, [client, projectId, enabled, tools, onToolExecuted]);
822
-
823
- return {
824
- registered,
825
- tools: registeredTools,
826
- error
827
- };
828
- }
829
-
830
- export interface ThreadedCommentWithAttachments extends Comment {
831
- attachments: Attachment[];
832
- replies: ThreadedCommentWithAttachments[];
833
- }
834
-
835
- export function useTaskActivity(taskId?: string) {
836
- const client = useCriticalPathClient();
837
- const [comments, setComments] = useState<Comment[]>([]);
838
- const [attachments, setAttachments] = useState<Attachment[]>([]);
839
- const [loading, setLoading] = useState(false);
840
- const [error, setError] = useState<Error | null>(null);
841
-
842
- const fetchActivity = useCallback(async (signal?: AbortSignal) => {
843
- const api = signal ? client.with({ signal }) : client;
844
- if (!taskId) {
845
- setComments([]);
846
- setAttachments([]);
847
- setLoading(false);
848
- return;
849
- }
850
- try {
851
- setLoading(true);
852
- const [fetchedComments, fetchedAttachments] = await Promise.all([
853
- api.getComments(taskId),
854
- api.getAttachments({ taskId })
855
- ]);
856
- if (signal?.aborted) return;
857
- setComments(fetchedComments);
858
- setAttachments(fetchedAttachments);
859
- setError(null);
860
- } catch (err) {
861
- if (signal?.aborted) return;
862
- setError(err instanceof Error ? err : new Error(String(err)));
863
- } finally {
864
- if (!signal?.aborted) setLoading(false);
865
- }
866
- }, [client, taskId]);
867
-
868
- useEffect(() => {
869
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
870
- // response for old inputs can never overwrite newer data.
871
- const controller = new AbortController();
872
- fetchActivity(controller.signal);
873
- return () => controller.abort();
874
- }, [fetchActivity]);
875
-
876
- const threads = useMemo<ThreadedCommentWithAttachments[]>(() => {
877
- const map = new Map<string, ThreadedCommentWithAttachments>();
878
- const roots: ThreadedCommentWithAttachments[] = [];
879
-
880
- const attachmentsByComment = new Map<string, Attachment[]>();
881
- for (const a of attachments) {
882
- if (a.commentId) {
883
- if (!attachmentsByComment.has(a.commentId)) {
884
- attachmentsByComment.set(a.commentId, []);
885
- }
886
- attachmentsByComment.get(a.commentId)!.push(a);
887
- }
888
- }
889
-
890
- for (const c of comments) {
891
- map.set(c.id, {
892
- ...c,
893
- attachments: attachmentsByComment.get(c.id) || [],
894
- replies: []
895
- });
896
- }
897
-
898
- for (const c of comments) {
899
- const threaded = map.get(c.id)!;
900
- if (c.parentId && map.has(c.parentId)) {
901
- map.get(c.parentId)!.replies.push(threaded);
902
- } else {
903
- roots.push(threaded);
904
- }
905
- }
906
-
907
- return roots;
908
- }, [comments, attachments]);
909
-
910
- const standaloneAttachments = useMemo<Attachment[]>(() => {
911
- return attachments.filter((a) => !a.commentId);
912
- }, [attachments]);
913
-
914
- const addComment = async (
915
- input: Omit<CreateCommentBody, 'taskId'>,
916
- attachmentInputs?: Array<Omit<CreateAttachmentBody, 'taskId' | 'commentId'>>
917
- ) => {
918
- if (!taskId) {
919
- throw new Error('useTaskActivity requires a taskId to add comments.');
920
- }
921
- try {
922
- const comment = await client.addComment({ ...input, taskId });
923
- let createdAttachments: Attachment[] = [];
924
-
925
- if (attachmentInputs && attachmentInputs.length > 0) {
926
- createdAttachments = await Promise.all(
927
- attachmentInputs.map((att) =>
928
- client.createAttachment({
929
- ...att,
930
- taskId,
931
- commentId: comment.id
932
- })
933
- )
934
- );
935
- }
936
-
937
- setComments((prev) => [...prev, comment]);
938
- if (createdAttachments.length > 0) {
939
- setAttachments((prev) => [...prev, ...createdAttachments]);
940
- }
941
- return { comment, attachments: createdAttachments };
942
- } catch (err) {
943
- const errorObj = err instanceof Error ? err : new Error(String(err));
944
- setError(errorObj);
945
- throw errorObj;
946
- }
947
- };
948
-
949
- const addAttachment = async (input: Omit<Attachment, 'id' | 'taskId' | 'createdAt' | 'updatedAt'>) => {
950
- if (!taskId) {
951
- throw new Error('useTaskActivity requires a taskId to add attachments.');
952
- }
953
- try {
954
- const created = await client.createAttachment({ ...input, taskId });
955
- setAttachments((prev) => [created, ...prev]);
956
- return created;
957
- } catch (err) {
958
- const errorObj = err instanceof Error ? err : new Error(String(err));
959
- setError(errorObj);
960
- throw errorObj;
961
- }
962
- };
963
-
964
- const deleteComment = async (id: string) => {
965
- let previousComments: Comment[] = [];
966
- setComments((prev) => {
967
- previousComments = prev;
968
- return prev.filter((c) => c.id !== id);
969
- });
970
-
971
- try {
972
- await client.deleteComment(id);
973
- } catch (err) {
974
- setComments(previousComments);
975
- const errorObj = err instanceof Error ? err : new Error(String(err));
976
- setError(errorObj);
977
- throw errorObj;
978
- }
979
- };
980
-
981
- const deleteAttachment = async (id: string) => {
982
- let previousAttachments: Attachment[] = [];
983
- setAttachments((prev) => {
984
- previousAttachments = prev;
985
- return prev.filter((a) => a.id !== id);
986
- });
987
-
988
- try {
989
- await client.deleteAttachment(id);
990
- } catch (err) {
991
- setAttachments(previousAttachments);
992
- const errorObj = err instanceof Error ? err : new Error(String(err));
993
- setError(errorObj);
994
- throw errorObj;
995
- }
996
- };
997
-
998
- const addReaction = async (
999
- commentId: string,
1000
- emoji: string
1001
- ) => {
1002
- try {
1003
- const payload = { emoji };
1004
- const updated = await client.addCommentReaction(commentId, payload);
1005
- setComments((prev) => prev.map((c) => (c.id === commentId ? updated : c)));
1006
- return updated;
1007
- } catch (err) {
1008
- const errorObj = err instanceof Error ? err : new Error(String(err));
1009
- setError(errorObj);
1010
- throw errorObj;
1011
- }
1012
- };
1013
-
1014
- const removeReaction = async (
1015
- commentId: string,
1016
- emoji: string
1017
- ) => {
1018
- try {
1019
- const payload = { emoji };
1020
- const updated = await client.removeCommentReaction(commentId, payload);
1021
- setComments((prev) => prev.map((c) => (c.id === commentId ? updated : c)));
1022
- return updated;
1023
- } catch (err) {
1024
- const errorObj = err instanceof Error ? err : new Error(String(err));
1025
- setError(errorObj);
1026
- throw errorObj;
1027
- }
1028
- };
1029
-
1030
- return {
1031
- comments,
1032
- attachments,
1033
- threads,
1034
- standaloneAttachments,
1035
- loading,
1036
- error,
1037
- refresh: () => fetchActivity(),
1038
- addComment,
1039
- addAttachment,
1040
- deleteComment,
1041
- deleteAttachment,
1042
- addReaction,
1043
- removeReaction
1044
- };
1045
- }
1046
-
1047
- export function useCriticalPath(projectId: string | undefined) {
1048
- const client = useCriticalPathClient();
1049
- const [analysis, setAnalysis] = useState<CriticalPathAnalysis | null>(null);
1050
- const [loading, setLoading] = useState(true);
1051
- const [error, setError] = useState<Error | null>(null);
1052
-
1053
- const fetchCriticalPath = useCallback(async (signal?: AbortSignal) => {
1054
- const api = signal ? client.with({ signal }) : client;
1055
- if (!projectId) {
1056
- setAnalysis(null);
1057
- setLoading(false);
1058
- return;
1059
- }
1060
- try {
1061
- setLoading(true);
1062
- const data = await api.calculateCriticalPath(projectId);
1063
- if (signal?.aborted) return;
1064
- setAnalysis(data);
1065
- setError(null);
1066
- } catch (err) {
1067
- if (signal?.aborted) return;
1068
- setError(err instanceof Error ? err : new Error(String(err)));
1069
- } finally {
1070
- if (!signal?.aborted) setLoading(false);
1071
- }
1072
- }, [client, projectId]);
1073
-
1074
- useEffect(() => {
1075
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
1076
- // response for old inputs can never overwrite newer data.
1077
- const controller = new AbortController();
1078
- fetchCriticalPath(controller.signal);
1079
- return () => controller.abort();
1080
- }, [fetchCriticalPath]);
1081
-
1082
- return {
1083
- analysis,
1084
- loading,
1085
- error,
1086
- refresh: () => fetchCriticalPath()
1087
- };
1088
- }
1089
-
1090
- export function useTimelineLadder(
1091
- projectId: string | undefined,
1092
- initialOptions: TimelineLadderOptions = {}
1093
- ) {
1094
- const client = useCriticalPathClient();
1095
- const [level, setLevel] = useState<AbstractionLevel>(initialOptions.level || 'all');
1096
- const [ladder, setLadder] = useState<TimelineLadder | null>(null);
1097
- const [loading, setLoading] = useState(true);
1098
- const [error, setError] = useState<Error | null>(null);
1099
-
1100
- const fetchLadder = useCallback(async (signal?: AbortSignal) => {
1101
- const api = signal ? client.with({ signal }) : client;
1102
- if (!projectId) {
1103
- setLadder(null);
1104
- setLoading(false);
1105
- return;
1106
- }
1107
- try {
1108
- setLoading(true);
1109
- const data = await api.getTimelineLadder(projectId, {
1110
- ...initialOptions,
1111
- level
1112
- });
1113
- if (signal?.aborted) return;
1114
- setLadder(data);
1115
- setError(null);
1116
- } catch (err) {
1117
- if (signal?.aborted) return;
1118
- setError(err instanceof Error ? err : new Error(String(err)));
1119
- } finally {
1120
- if (!signal?.aborted) setLoading(false);
1121
- }
1122
- }, [client, projectId, level, initialOptions.containerId, initialOptions.iterationId]);
1123
-
1124
- useEffect(() => {
1125
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
1126
- // response for old inputs can never overwrite newer data.
1127
- const controller = new AbortController();
1128
- fetchLadder(controller.signal);
1129
- return () => controller.abort();
1130
- }, [fetchLadder]);
1131
-
1132
- return {
1133
- ladder,
1134
- level,
1135
- setLevel,
1136
- macro: ladder?.macro ?? null,
1137
- standard: ladder?.standard ?? null,
1138
- concrete: ladder?.concrete ?? null,
1139
- loading,
1140
- error,
1141
- refresh: () => fetchLadder()
1142
- };
1143
- }
1144
-
1145
- export function useTaskLadder(taskId: string | undefined) {
1146
- const client = useCriticalPathClient();
1147
- const [taskLadder, setTaskLadder] = useState<TaskLadderView | null>(null);
1148
- const [loading, setLoading] = useState(true);
1149
- const [error, setError] = useState<Error | null>(null);
1150
-
1151
- const fetchTaskLadder = useCallback(async (signal?: AbortSignal) => {
1152
- const api = signal ? client.with({ signal }) : client;
1153
- if (!taskId) {
1154
- setTaskLadder(null);
1155
- setLoading(false);
1156
- return;
1157
- }
1158
- try {
1159
- setLoading(true);
1160
- const data = await api.getTaskLadder(taskId);
1161
- if (signal?.aborted) return;
1162
- setTaskLadder(data);
1163
- setError(null);
1164
- } catch (err) {
1165
- if (signal?.aborted) return;
1166
- setError(err instanceof Error ? err : new Error(String(err)));
1167
- } finally {
1168
- if (!signal?.aborted) setLoading(false);
1169
- }
1170
- }, [client, taskId]);
1171
-
1172
- useEffect(() => {
1173
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
1174
- // response for old inputs can never overwrite newer data.
1175
- const controller = new AbortController();
1176
- fetchTaskLadder(controller.signal);
1177
- return () => controller.abort();
1178
- }, [fetchTaskLadder]);
1179
-
1180
- return {
1181
- taskLadder,
1182
- macroPhase: taskLadder?.macroPhase ?? null,
1183
- standard: taskLadder?.standard ?? null,
1184
- concrete: taskLadder?.concrete ?? null,
1185
- metrics: taskLadder?.metrics ?? null,
1186
- loading,
1187
- error,
1188
- refresh: () => fetchTaskLadder()
1189
- };
1190
- }
1191
-
1192
- export function useTaskMetrics(taskId: string | undefined) {
1193
- const client = useCriticalPathClient();
1194
- const [metrics, setMetrics] = useState<TaskMetrics | null>(null);
1195
- const [history, setHistory] = useState<TaskProgressHistory | null>(null);
1196
- const [loading, setLoading] = useState(true);
1197
- const [error, setError] = useState<Error | null>(null);
1198
-
1199
- const fetchMetrics = useCallback(async (signal?: AbortSignal) => {
1200
- const api = signal ? client.with({ signal }) : client;
1201
- if (!taskId) {
1202
- setMetrics(null);
1203
- setHistory(null);
1204
- setLoading(false);
1205
- return;
1206
- }
1207
- try {
1208
- setLoading(true);
1209
- const [m, h] = await Promise.all([
1210
- api.getTaskMetrics(taskId),
1211
- api.getTaskProgressHistory(taskId)
1212
- ]);
1213
- if (signal?.aborted) return;
1214
- setMetrics(m);
1215
- setHistory(h);
1216
- setError(null);
1217
- } catch (err) {
1218
- if (signal?.aborted) return;
1219
- setError(err instanceof Error ? err : new Error(String(err)));
1220
- } finally {
1221
- if (!signal?.aborted) setLoading(false);
1222
- }
1223
- }, [client, taskId]);
1224
-
1225
- useEffect(() => {
1226
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
1227
- // response for old inputs can never overwrite newer data.
1228
- const controller = new AbortController();
1229
- fetchMetrics(controller.signal);
1230
- return () => controller.abort();
1231
- }, [fetchMetrics]);
1232
-
1233
- return {
1234
- metrics,
1235
- history,
1236
- inferredActuals: metrics?.inferredActuals ?? null,
1237
- realityDelta: metrics?.realityDelta ?? null,
1238
- progress: metrics?.progress ?? null,
1239
- evm: metrics?.evm ?? null,
1240
- curveProfile: history?.curveProfile ?? null,
1241
- loading,
1242
- error,
1243
- refresh: () => fetchMetrics()
1244
- };
1245
- }
1246
-
1247
- export function useWorkloadDistribution(
1248
- projectId?: string,
1249
- initialOptions: WorkloadDistributionOptions = {}
1250
- ) {
1251
- const client = useCriticalPathClient();
1252
- const [interval, setInterval] = useState<WorkloadInterval>(initialOptions.interval || 'week');
1253
- const [groupBy, setGroupBy] = useState<WorkloadGroupBy>(initialOptions.groupBy || 'assignee');
1254
- const [metric, setMetric] = useState<WorkloadMetric>(initialOptions.metric || 'blended');
1255
- const [workload, setWorkload] = useState<WorkloadDistribution | null>(null);
1256
- const [loading, setLoading] = useState(true);
1257
- const [error, setError] = useState<Error | null>(null);
1258
-
1259
- const fetchWorkload = useCallback(async (signal?: AbortSignal) => {
1260
- const api = signal ? client.with({ signal }) : client;
1261
- try {
1262
- setLoading(true);
1263
- const data = await api.getWorkloadDistribution(projectId, {
1264
- ...initialOptions,
1265
- interval,
1266
- groupBy,
1267
- metric
1268
- });
1269
- if (signal?.aborted) return;
1270
- setWorkload(data);
1271
- setError(null);
1272
- } catch (err) {
1273
- if (signal?.aborted) return;
1274
- setError(err instanceof Error ? err : new Error(String(err)));
1275
- } finally {
1276
- if (!signal?.aborted) setLoading(false);
1277
- }
1278
- }, [
1279
- client,
1280
- projectId,
1281
- interval,
1282
- groupBy,
1283
- metric,
1284
- initialOptions.startDate,
1285
- initialOptions.endDate,
1286
- initialOptions.defaultWeeklyCapacityHours
1287
- ]);
1288
-
1289
- useEffect(() => {
1290
- // Abort the in-flight request when inputs change or the component unmounts, so a slow
1291
- // response for old inputs can never overwrite newer data.
1292
- const controller = new AbortController();
1293
- fetchWorkload(controller.signal);
1294
- return () => controller.abort();
1295
- }, [fetchWorkload]);
1296
-
1297
- return {
1298
- workload,
1299
- interval,
1300
- setInterval,
1301
- groupBy,
1302
- setGroupBy,
1303
- metric,
1304
- setMetric,
1305
- buckets: workload?.buckets ?? [],
1306
- seriesKeys: workload?.seriesKeys ?? [],
1307
- seriesLabels: workload?.seriesLabels ?? {},
1308
- totalHours: workload?.totalHours ?? 0,
1309
- totalCapacity: workload?.totalCapacity ?? 0,
1310
- averageUtilization: workload?.averageUtilization,
1311
- loading,
1312
- error,
1313
- refresh: () => fetchWorkload()
1314
- };
1315
- }
1316
-
1317
-
1318
-