/** * Mosaic Stack Gantt API Client * * Provides typed client for querying project timelines, tasks, and dependencies * from Mosaic Stack's API. * * @example * ```typescript * const client = new GanttClient({ * apiUrl: process.env.MOSAIC_API_URL, * workspaceId: process.env.MOSAIC_WORKSPACE_ID, * apiToken: process.env.MOSAIC_API_TOKEN, * }); * * const projects = await client.listProjects(); * const timeline = await client.getProjectTimeline('project-id'); * const criticalPath = await client.calculateCriticalPath('project-id'); * ``` */ export interface GanttClientConfig { apiUrl: string; workspaceId: string; apiToken: string; } export type ProjectStatus = "PLANNING" | "ACTIVE" | "ON_HOLD" | "COMPLETED" | "ARCHIVED"; export type TaskStatus = "NOT_STARTED" | "IN_PROGRESS" | "PAUSED" | "COMPLETED" | "ARCHIVED"; export type TaskPriority = "LOW" | "MEDIUM" | "HIGH" | "URGENT"; export interface ProjectMetadata { [key: string]: unknown; } export interface Project { id: string; workspaceId: string; name: string; description: string | null; status: ProjectStatus; startDate: string | null; endDate: string | null; color: string | null; metadata: ProjectMetadata; createdAt: string; updatedAt: string; tasks?: Task[]; _count?: { tasks: number; events: number; }; } export interface TaskMetadata { startDate?: string; dependencies?: string[]; [key: string]: unknown; } export interface Task { id: string; workspaceId: string; title: string; description: string | null; status: TaskStatus; priority: TaskPriority; dueDate: string | null; completedAt: string | null; projectId: string | null; assigneeId: string | null; creatorId: string; parentId: string | null; sortOrder: number; metadata: TaskMetadata; createdAt: string; updatedAt: string; project?: { id: string; name: string; color: string | null; }; } export interface PaginatedResponse { data: T[]; meta: { total: number; page: number; limit: number; totalPages: number; }; } export interface ProjectTimeline { project: Project; tasks: Task[]; stats: { total: number; completed: number; inProgress: number; notStarted: number; paused: number; targetPassed: number; }; } export interface DependencyChain { task: Task; blockedBy: Task[]; blocks: Task[]; } export interface CriticalPath { path: Array<{ task: Task; duration: number; cumulativeDuration: number; }>; totalDuration: number; nonCriticalTasks: Array<{ task: Task; slack: number; }>; } export class GanttClient { private readonly config: GanttClientConfig; constructor(config: GanttClientConfig) { this.config = config; } /** * Make an authenticated API request */ private async request(endpoint: string, options: RequestInit = {}): Promise { const url = `${this.config.apiUrl}${endpoint}`; const headers = { "Content-Type": "application/json", "X-Workspace-Id": this.config.workspaceId, Authorization: `Bearer ${this.config.apiToken}`, ...options.headers, }; const response = await fetch(url, { ...options, headers, }); if (!response.ok) { const error = await response.text(); throw new Error(`API request failed: ${response.status} ${error}`); } return response.json() as Promise; } /** * List all projects with pagination */ async listProjects(params?: { page?: number; limit?: number; status?: ProjectStatus; }): Promise> { const queryParams = new URLSearchParams(); if (params?.page) queryParams.set("page", params.page.toString()); if (params?.limit) queryParams.set("limit", params.limit.toString()); if (params?.status) queryParams.set("status", params.status); const query = queryParams.toString(); const endpoint = `/projects${query ? `?${query}` : ""}`; return this.request>(endpoint); } /** * Get a single project with tasks */ async getProject(projectId: string): Promise { return this.request(`/projects/${projectId}`); } /** * Get tasks with optional filters */ async getTasks(params?: { projectId?: string; status?: TaskStatus; priority?: TaskPriority; assigneeId?: string; page?: number; limit?: number; }): Promise> { const queryParams = new URLSearchParams(); if (params?.projectId) queryParams.set("projectId", params.projectId); if (params?.status) queryParams.set("status", params.status); if (params?.priority) queryParams.set("priority", params.priority); if (params?.assigneeId) queryParams.set("assigneeId", params.assigneeId); if (params?.page) queryParams.set("page", params.page.toString()); if (params?.limit) queryParams.set("limit", params.limit.toString()); const query = queryParams.toString(); const endpoint = `/tasks${query ? `?${query}` : ""}`; return this.request>(endpoint); } /** * Get a single task */ async getTask(taskId: string): Promise { return this.request(`/tasks/${taskId}`); } /** * Get project timeline with statistics */ async getProjectTimeline(projectId: string): Promise { const project = await this.getProject(projectId); const tasksResponse = await this.getTasks({ projectId, limit: 1000 }); const tasks = tasksResponse.data; const now = new Date(); const stats = { total: tasks.length, completed: tasks.filter((t) => t.status === "COMPLETED").length, inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length, notStarted: tasks.filter((t) => t.status === "NOT_STARTED").length, paused: tasks.filter((t) => t.status === "PAUSED").length, targetPassed: tasks.filter((t) => { if (!t.dueDate || t.status === "COMPLETED") return false; return new Date(t.dueDate) < now; }).length, }; return { project, tasks, stats }; } /** * Get dependency chain for a task */ async getDependencyChain(taskId: string): Promise { const task = await this.getTask(taskId); const dependencyIds = task.metadata.dependencies ?? []; // Fetch tasks this task depends on (blocking tasks) const blockedBy = await Promise.all(dependencyIds.map((id) => this.getTask(id))); // Find tasks that depend on this task const allTasksResponse = await this.getTasks({ projectId: task.projectId ?? undefined, limit: 1000, }); const blocks = allTasksResponse.data.filter((t) => t.metadata.dependencies?.includes(taskId)); return { task, blockedBy, blocks }; } /** * Calculate critical path for a project * * Uses the Critical Path Method (CPM) to find the longest dependency chain */ async calculateCriticalPath(projectId: string): Promise { const tasksResponse = await this.getTasks({ projectId, limit: 1000 }); const tasks = tasksResponse.data; // Build dependency graph const taskMap = new Map(tasks.map((t) => [t.id, t])); const durations = new Map(); const earliestStart = new Map(); const latestStart = new Map(); // Calculate task durations (days between start and due date, or default to 1) for (const task of tasks) { const start = task.metadata.startDate ? new Date(task.metadata.startDate) : new Date(task.createdAt); const end = task.dueDate ? new Date(task.dueDate) : new Date(); const duration = Math.max( 1, Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) ); durations.set(task.id, duration); } // Forward pass: calculate earliest start times const visited = new Set(); const calculateEarliestStart = (taskId: string): number => { if (visited.has(taskId)) { return earliestStart.get(taskId) ?? 0; } visited.add(taskId); const task = taskMap.get(taskId); if (!task) return 0; const deps = task.metadata.dependencies ?? []; let maxEnd = 0; for (const depId of deps) { const depStart = calculateEarliestStart(depId); const depDuration = durations.get(depId) ?? 1; maxEnd = Math.max(maxEnd, depStart + depDuration); } earliestStart.set(taskId, maxEnd); return maxEnd; }; // Calculate earliest start for all tasks for (const task of tasks) { calculateEarliestStart(task.id); } // Find project completion time (max earliest finish) let projectDuration = 0; for (const task of tasks) { const start = earliestStart.get(task.id) ?? 0; const duration = durations.get(task.id) ?? 1; projectDuration = Math.max(projectDuration, start + duration); } // Backward pass: calculate latest start times const calculateLatestStart = (taskId: string): number => { const task = taskMap.get(taskId); if (!task) return projectDuration; // Find all tasks that depend on this task const dependents = tasks.filter((t) => t.metadata.dependencies?.includes(taskId)); if (dependents.length === 0) { // No dependents, latest start = project end - duration const duration = durations.get(taskId) ?? 1; const latest = projectDuration - duration; latestStart.set(taskId, latest); return latest; } // Latest start = min(dependent latest starts) - duration let minDependentStart = projectDuration; for (const dependent of dependents) { const depLatest = latestStart.get(dependent.id) ?? calculateLatestStart(dependent.id); minDependentStart = Math.min(minDependentStart, depLatest); } const duration = durations.get(taskId) ?? 1; const latest = minDependentStart - duration; latestStart.set(taskId, latest); return latest; }; // Calculate latest start for all tasks for (const task of tasks) { if (!latestStart.has(task.id)) { calculateLatestStart(task.id); } } // Identify critical path (tasks with zero slack) const criticalTasks: Task[] = []; const nonCriticalTasks: Array<{ task: Task; slack: number }> = []; for (const task of tasks) { const early = earliestStart.get(task.id) ?? 0; const late = latestStart.get(task.id) ?? 0; const slack = late - early; if (slack === 0) { criticalTasks.push(task); } else { nonCriticalTasks.push({ task, slack }); } } // Build critical path chain const path = criticalTasks .sort((a, b) => (earliestStart.get(a.id) ?? 0) - (earliestStart.get(b.id) ?? 0)) .map((task) => ({ task, duration: durations.get(task.id) ?? 1, cumulativeDuration: (earliestStart.get(task.id) ?? 0) + (durations.get(task.id) ?? 1), })); return { path, totalDuration: projectDuration, nonCriticalTasks, }; } /** * Find tasks approaching their due date (within specified days) */ async getTasksApproachingDueDate(projectId: string, daysThreshold: number = 7): Promise { const tasksResponse = await this.getTasks({ projectId, limit: 1000 }); const now = new Date(); const threshold = new Date(now.getTime() + daysThreshold * 24 * 60 * 60 * 1000); return tasksResponse.data.filter((task) => { if (!task.dueDate || task.status === "COMPLETED") return false; const dueDate = new Date(task.dueDate); return dueDate >= now && dueDate <= threshold; }); } } /** * Create a client instance from environment variables */ export function createGanttClientFromEnv(): GanttClient { const apiUrl = process.env.MOSAIC_API_URL; const workspaceId = process.env.MOSAIC_WORKSPACE_ID; const apiToken = process.env.MOSAIC_API_TOKEN; if (!apiUrl || !workspaceId || !apiToken) { throw new Error( "Missing required environment variables: MOSAIC_API_URL, MOSAIC_WORKSPACE_ID, MOSAIC_API_TOKEN" ); } return new GanttClient({ apiUrl, workspaceId, apiToken }); }