compassmock/src/app/actions/schedule.ts
Nicholai 67fed00bbd feat(schedule): add gantt schedule with task management and dependencies
Implements the schedule module for COMPASS construction PM:

- D1/Drizzle schema: tasks, dependencies, phases tables
- frappe-gantt integration for interactive timeline view
- Critical path analysis (forward/backward pass, float calc)
- Dependency validation with cycle detection
- Business day calculations (skip weekends/holidays)
- Date propagation engine for cascading schedule changes
- Task CRUD with phase assignment and progress tracking
- Dependency management (FS/FF/SS/SF with lag support)
- Dual view: sortable list view + gantt chart view

Also includes full Next.js app scaffold with dashboard,
shadcn/ui components, and Cloudflare Workers deployment config.
2026-01-23 19:34:24 -07:00

352 lines
10 KiB
TypeScript
Executable File

"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { scheduleTasks, taskDependencies, projects } from "@/db/schema"
import { eq, asc } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { calculateEndDate } from "@/lib/schedule/business-days"
import { findCriticalPath } from "@/lib/schedule/critical-path"
import { wouldCreateCycle } from "@/lib/schedule/dependency-validation"
import { propagateDates } from "@/lib/schedule/propagate-dates"
import type {
TaskStatus,
DependencyType,
ScheduleData,
} from "@/lib/schedule/types"
export async function getSchedule(
projectId: string
): Promise<ScheduleData> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
.orderBy(asc(scheduleTasks.sortOrder))
const deps = await db.select().from(taskDependencies)
// filter deps to only include tasks in this project
const taskIds = new Set(tasks.map((t) => t.id))
const projectDeps = deps.filter(
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
)
return {
tasks: tasks.map((t) => ({
...t,
status: t.status as TaskStatus,
phase: t.phase,
})),
dependencies: projectDeps.map((d) => ({
...d,
type: d.type as DependencyType,
})),
}
}
export async function createTask(
projectId: string,
data: {
title: string
startDate: string
workdays: number
phase: string
isMilestone?: boolean
}
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const endDate = calculateEndDate(data.startDate, data.workdays)
const now = new Date().toISOString()
// get next sort order
const existing = await db
.select({ sortOrder: scheduleTasks.sortOrder })
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
.orderBy(asc(scheduleTasks.sortOrder))
const nextOrder = existing.length > 0
? existing[existing.length - 1].sortOrder + 1
: 0
const id = crypto.randomUUID()
await db.insert(scheduleTasks).values({
id,
projectId,
title: data.title,
startDate: data.startDate,
workdays: data.workdays,
endDateCalculated: endDate,
phase: data.phase,
status: "PENDING",
isCriticalPath: false,
isMilestone: data.isMilestone ?? false,
sortOrder: nextOrder,
createdAt: now,
updatedAt: now,
})
await recalcCriticalPath(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to create task:", error)
return { success: false, error: "Failed to create task" }
}
}
export async function updateTask(
taskId: string,
data: {
title?: string
startDate?: string
workdays?: number
phase?: string
isMilestone?: boolean
}
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
.limit(1)
if (!task) return { success: false, error: "Task not found" }
const startDate = data.startDate ?? task.startDate
const workdays = data.workdays ?? task.workdays
const endDate = calculateEndDate(startDate, workdays)
await db
.update(scheduleTasks)
.set({
...(data.title && { title: data.title }),
startDate,
workdays,
endDateCalculated: endDate,
...(data.phase && { phase: data.phase }),
...(data.isMilestone !== undefined && { isMilestone: data.isMilestone }),
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, taskId))
// propagate date changes to downstream tasks
const schedule = await getSchedule(task.projectId)
const updatedTask = { ...task, startDate, workdays, endDateCalculated: endDate }
const allTasks = schedule.tasks.map((t) =>
t.id === taskId ? updatedTask : t
)
const { updatedTasks } = propagateDates(taskId, allTasks, schedule.dependencies)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
await recalcCriticalPath(db, task.projectId)
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to update task:", error)
return { success: false, error: "Failed to update task" }
}
}
export async function deleteTask(
taskId: string
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
.limit(1)
if (!task) return { success: false, error: "Task not found" }
await db.delete(scheduleTasks).where(eq(scheduleTasks.id, taskId))
await recalcCriticalPath(db, task.projectId)
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to delete task:", error)
return { success: false, error: "Failed to delete task" }
}
}
export async function reorderTasks(
projectId: string,
items: { id: string; sortOrder: number }[]
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
for (const item of items) {
await db
.update(scheduleTasks)
.set({ sortOrder: item.sortOrder })
.where(eq(scheduleTasks.id, item.id))
}
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to reorder tasks:", error)
return { success: false, error: "Failed to reorder tasks" }
}
}
export async function createDependency(data: {
predecessorId: string
successorId: string
type: DependencyType
lagDays: number
projectId: string
}): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
// get existing deps for cycle check
const schedule = await getSchedule(data.projectId)
if (wouldCreateCycle(schedule.dependencies, data.predecessorId, data.successorId)) {
return { success: false, error: "This dependency would create a cycle" }
}
await db.insert(taskDependencies).values({
id: crypto.randomUUID(),
predecessorId: data.predecessorId,
successorId: data.successorId,
type: data.type,
lagDays: data.lagDays,
})
// propagate dates from predecessor
const updatedSchedule = await getSchedule(data.projectId)
const { updatedTasks } = propagateDates(
data.predecessorId,
updatedSchedule.tasks,
updatedSchedule.dependencies
)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
await recalcCriticalPath(db, data.projectId)
revalidatePath(`/dashboard/projects/${data.projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to create dependency:", error)
return { success: false, error: "Failed to create dependency" }
}
}
export async function deleteDependency(
depId: string,
projectId: string
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db.delete(taskDependencies).where(eq(taskDependencies.id, depId))
await recalcCriticalPath(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to delete dependency:", error)
return { success: false, error: "Failed to delete dependency" }
}
}
export async function updateTaskStatus(
taskId: string,
status: TaskStatus
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
.limit(1)
if (!task) return { success: false, error: "Task not found" }
await db
.update(scheduleTasks)
.set({ status, updatedAt: new Date().toISOString() })
.where(eq(scheduleTasks.id, taskId))
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to update task status:", error)
return { success: false, error: "Failed to update status" }
}
}
// recalculates critical path and updates all tasks
async function recalcCriticalPath(
db: ReturnType<typeof getDb>,
projectId: string
) {
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
const deps = await db.select().from(taskDependencies)
const taskIds = new Set(tasks.map((t) => t.id))
const projectDeps = deps.filter(
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
)
const criticalSet = findCriticalPath(
tasks.map((t) => ({ ...t, status: t.status as TaskStatus })),
projectDeps.map((d) => ({ ...d, type: d.type as DependencyType }))
)
for (const task of tasks) {
const isCritical = criticalSet.has(task.id)
if (task.isCriticalPath !== isCritical) {
await db
.update(scheduleTasks)
.set({ isCriticalPath: isCritical })
.where(eq(scheduleTasks.id, task.id))
}
}
}