-
-
-
- Schedule
+ {/* Header: breadcrumb + view toggle + new task */}
+
+
+
+
+ {/* View switcher */}
+
+ {VIEW_OPTIONS.map(({ value, icon: Icon, label }) => (
+
+ ))}
+
+
+
-
setTopTab(v as TopTab)}
- >
-
- Schedule
- Baseline
-
- Workday Exceptions
-
-
-
-
setTopTab(v as TopTab)}
- className="flex flex-col flex-1 min-h-0"
- >
-
- setTaskFormOpen(true)}
- filters={filters}
- onFiltersChange={setFilters}
- projectName={projectName}
- tasksCount={filteredTasks.length}
- tasks={filteredTasks}
+ {/* Action bar: search, filters, overflow */}
+
+ {/* Search */}
+
+
+ setFilters({ ...filters, search: e.target.value })}
+ className="h-8 pl-8 text-sm"
/>
+
-
setSubTab(v as ScheduleSubTab)}
- className="flex flex-col flex-1 min-h-0"
- >
-
-
- Calendar
-
-
- List
-
-
- Gantt
-
-
-
-
- {isMobile ? (
-
- ) : (
-
+ {/* Filter popover */}
+
+
+
+
+
+
+
+
+
+
+ {STATUS_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+
+
+ {PHASE_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+
+
+ setFilters({ ...filters, assignedTo: e.target.value })
+ }
+ className="mt-1.5 h-8 text-sm"
+ />
+
+ {activeFilterCount > 0 && (
+
+ )}
+
+
+
-
-
-
+ {/* Active filter chips */}
+
+ {filters.status.map((s) => (
+ removeStatusChip(s)}
+ >
+ {STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s}
+
+
+ ))}
+ {filters.phase.map((p) => (
+ removePhaseChip(p)}
+ >
+ {p}
+
+
+ ))}
+ {filters.assignedTo && (
+ setFilters({ ...filters, assignedTo: "" })}
+ >
+ {filters.assignedTo}
+
+
+ )}
+
-
-
-
-
-
+
+
+ {filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""}
+
-
-
+
+
+
+
+
+
+ Export CSV
+
+ setImportDialogOpen(true)}>
+
+ Import CSV
+
+ window.print()}>
+
+ Print
+
+
+ setBaselinesOpen(true)}>
+
+ Baselines
+
+ setExceptionsOpen(true)}>
+
+ Workday Exceptions
+
+
+
+
+
+
+ {/* View content */}
+
+ {view === "calendar" && (
+ isMobile ? (
+
+ ) : (
+
+ )
+ )}
+ {view === "list" && (
+
-
-
-
-
-
-
+ )}
+
+ {/* New task dialog */}
+
+ {/* Import dialog */}
+
+
+ {/* Baselines sheet */}
+
+
+
+ Baselines
+
+ Save and compare schedule snapshots.
+
+
+
+
+
+
+
+
+ {/* Exceptions sheet */}
+
+
+
+ Workday Exceptions
+
+ Holidays, vacation days, and other non-working days.
+
+
+
+
+
+
+
)
}
diff --git a/src/components/schedule/task-form-dialog.tsx b/src/components/schedule/task-form-dialog.tsx
index 4b74383..4cf9d79 100755
--- a/src/components/schedule/task-form-dialog.tsx
+++ b/src/components/schedule/task-form-dialog.tsx
@@ -1,15 +1,16 @@
"use client"
import * as React from "react"
-import { useEffect, useMemo } from "react"
+import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import {
- ResponsiveDialog,
- ResponsiveDialogBody,
- ResponsiveDialogFooter,
-} from "@/components/ui/responsive-dialog"
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
import {
Form,
FormControl,
@@ -19,13 +20,25 @@ import {
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
-import { IconCalendar } from "@tabler/icons-react"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+import {
+ IconCalendar,
+ IconPlus,
+ IconTrash,
+ IconChevronDown,
+ IconChevronRight,
+} from "@tabler/icons-react"
import { format, parseISO } from "date-fns"
import { Slider } from "@/components/ui/slider"
import {
@@ -37,26 +50,46 @@ import {
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
-import { createTask, updateTask } from "@/app/actions/schedule"
+import {
+ createTask,
+ updateTask,
+ createDependency,
+ deleteDependency,
+} from "@/app/actions/schedule"
import { calculateEndDate } from "@/lib/schedule/business-days"
-import type { ScheduleTaskData } from "@/lib/schedule/types"
-import { PHASE_ORDER, PHASE_LABELS } from "@/lib/schedule/phase-colors"
+import type {
+ ScheduleTaskData,
+ TaskDependencyData,
+ DependencyType,
+} from "@/lib/schedule/types"
+import { PHASE_ORDER, PHASE_LABELS, getPhaseColor } from "@/lib/schedule/phase-colors"
+import { STATUS_OPTIONS } from "@/lib/schedule/types"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
+import { cn } from "@/lib/utils"
const phases = PHASE_ORDER.map((value) => ({
value,
label: PHASE_LABELS[value],
}))
+const DEPENDENCY_TYPES: readonly { value: DependencyType; label: string }[] = [
+ { value: "FS", label: "Finish-to-Start" },
+ { value: "SS", label: "Start-to-Start" },
+ { value: "FF", label: "Finish-to-Finish" },
+ { value: "SF", label: "Start-to-Finish" },
+]
+
const taskSchema = z.object({
title: z.string().min(1, "Title is required"),
startDate: z.string().min(1, "Start date is required"),
workdays: z.number().min(1, "Must be at least 1 day"),
phase: z.string().min(1, "Phase is required"),
+ status: z.string(),
isMilestone: z.boolean(),
percentComplete: z.number().min(0).max(100),
assignedTo: z.string(),
+ notes: z.string(),
})
type TaskFormValues = z.infer
@@ -66,6 +99,14 @@ interface TaskFormDialogProps {
onOpenChange: (open: boolean) => void
projectId: string
editingTask: ScheduleTaskData | null
+ allTasks?: readonly ScheduleTaskData[]
+ dependencies?: readonly TaskDependencyData[]
+}
+
+interface PendingPredecessor {
+ taskId: string
+ type: DependencyType
+ lagDays: number
}
export function TaskFormDialog({
@@ -73,9 +114,24 @@ export function TaskFormDialog({
onOpenChange,
projectId,
editingTask,
+ allTasks = [],
+ dependencies = [],
}: TaskFormDialogProps) {
const router = useRouter()
const isEditing = !!editingTask
+ const [detailsOpen, setDetailsOpen] = useState(false)
+ const [pendingPredecessors, setPendingPredecessors] = useState<
+ PendingPredecessor[]
+ >([])
+
+ const existingPredecessors = useMemo(() => {
+ if (!editingTask) return []
+ return dependencies.filter((d) => d.successorId === editingTask.id)
+ }, [editingTask, dependencies])
+
+ const availableTasks = useMemo(() => {
+ return allTasks.filter((t) => t.id !== editingTask?.id)
+ }, [allTasks, editingTask])
const form = useForm({
resolver: zodResolver(taskSchema),
@@ -84,9 +140,11 @@ export function TaskFormDialog({
startDate: new Date().toISOString().split("T")[0],
workdays: 5,
phase: "preconstruction",
+ status: "PENDING",
isMilestone: false,
percentComplete: 0,
assignedTo: "",
+ notes: "",
},
})
@@ -97,25 +155,35 @@ export function TaskFormDialog({
startDate: editingTask.startDate,
workdays: editingTask.workdays,
phase: editingTask.phase,
+ status: editingTask.status,
isMilestone: editingTask.isMilestone,
percentComplete: editingTask.percentComplete,
assignedTo: editingTask.assignedTo ?? "",
+ notes: "",
})
+ // expand details when editing since they likely want to see everything
+ setDetailsOpen(true)
} else {
form.reset({
title: "",
startDate: new Date().toISOString().split("T")[0],
workdays: 5,
phase: "preconstruction",
+ status: "PENDING",
isMilestone: false,
percentComplete: 0,
assignedTo: "",
+ notes: "",
})
+ setDetailsOpen(false)
}
+ setPendingPredecessors([])
}, [editingTask, form])
const watchedStart = form.watch("startDate")
const watchedWorkdays = form.watch("workdays")
+ const watchedPhase = form.watch("phase")
+ const watchedPercent = form.watch("percentComplete")
const calculatedEnd = useMemo(() => {
if (!watchedStart || !watchedWorkdays || watchedWorkdays < 1) return ""
@@ -137,6 +205,17 @@ export function TaskFormDialog({
}
if (result.success) {
+ for (const pred of pendingPredecessors) {
+ if (pred.taskId) {
+ await createDependency({
+ predecessorId: pred.taskId,
+ successorId: editingTask?.id ?? "",
+ type: pred.type,
+ lagDays: pred.lagDays,
+ projectId,
+ })
+ }
+ }
onOpenChange(false)
router.refresh()
} else {
@@ -144,206 +223,470 @@ export function TaskFormDialog({
}
}
- const page1 = (
- <>
- (
-
- Title
-
-
-
-
-
- )}
- />
+ const addPendingPredecessor = () => {
+ setPendingPredecessors((prev) => [
+ ...prev,
+ { taskId: "", type: "FS", lagDays: 0 },
+ ])
+ }
- (
-
- Start Date
-
-
-
-
-
-
-
- {
- if (date) {
- field.onChange(format(date, "yyyy-MM-dd"))
- }
- }}
- initialFocus
- />
-
-
-
-
- )}
- />
+ const removePendingPredecessor = (index: number) => {
+ setPendingPredecessors((prev) => prev.filter((_, i) => i !== index))
+ }
- (
-
- Workdays
-
-
- field.onChange(Number(e.target.value) || 0)
- }
- onBlur={field.onBlur}
- ref={field.ref}
- name={field.name}
- />
-
-
-
- )}
- />
- >
- )
+ const updatePendingPredecessor = (
+ index: number,
+ field: keyof PendingPredecessor,
+ value: string | number
+ ) => {
+ setPendingPredecessors((prev) =>
+ prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))
+ )
+ }
- const page2 = (
- <>
- (
-
- Phase
-
-
-
- )}
- />
+ const handleDeleteExistingDep = async (depId: string) => {
+ const result = await deleteDependency(depId, projectId)
+ if (result.success) {
+ router.refresh()
+ } else {
+ toast.error(result.error)
+ }
+ }
- (
-
- Assigned To
-
-
-
-
-
- )}
- />
- >
- )
-
- const page3 = (
- <>
- {calculatedEnd && (
-
- End date: {calculatedEnd}
-
- )}
-
- (
-
-
- Complete: {field.value}%
-
-
- field.onChange(val)}
- />
-
-
-
- )}
- />
-
- (
-
-
-
-
- Milestone
-
- )}
- />
- >
- )
+ const hasPredecessors =
+ existingPredecessors.length > 0 || pendingPredecessors.length > 0
return (
-
-
-
-
+
+
+
+
)
}
diff --git a/src/lib/agent/system-prompt.ts b/src/lib/agent/system-prompt.ts
index 5b8a3e6..d7ca2da 100755
--- a/src/lib/agent/system-prompt.ts
+++ b/src/lib/agent/system-prompt.ts
@@ -30,6 +30,7 @@ type ToolCategory =
| "github"
| "skills"
| "feedback"
+ | "schedule"
interface ToolMeta {
readonly name: string
@@ -222,6 +223,54 @@ const TOOL_REGISTRY: ReadonlyArray = [
"the user before deleting.",
category: "ui",
},
+ {
+ name: "getProjectSchedule",
+ summary:
+ "Get a project's full schedule: tasks, dependencies, " +
+ "exceptions, and a computed summary (counts, overall %, " +
+ "critical path). Always call before mutations to resolve " +
+ "task names to UUIDs.",
+ category: "schedule",
+ },
+ {
+ name: "createScheduleTask",
+ summary:
+ "Create a new task on a project schedule. Provide " +
+ "projectId, title, startDate (YYYY-MM-DD), workdays, " +
+ "and phase. Optional: isMilestone, percentComplete, " +
+ "assignedTo.",
+ category: "schedule",
+ },
+ {
+ name: "updateScheduleTask",
+ summary:
+ "Update a schedule task by ID. Provide only the " +
+ "fields to change: title, startDate, workdays, phase, " +
+ "status (PENDING/IN_PROGRESS/COMPLETE/BLOCKED), " +
+ "isMilestone, percentComplete, assignedTo.",
+ category: "schedule",
+ },
+ {
+ name: "deleteScheduleTask",
+ summary:
+ "Delete a schedule task. Always confirm with the " +
+ "user before deleting.",
+ category: "schedule",
+ },
+ {
+ name: "createScheduleDependency",
+ summary:
+ "Create a dependency between two tasks. Types: " +
+ "FS (finish-to-start), SS, FF, SF. Optional lagDays. " +
+ "Has built-in cycle detection.",
+ category: "schedule",
+ },
+ {
+ name: "deleteScheduleDependency",
+ summary:
+ "Delete a dependency between tasks by its ID.",
+ category: "schedule",
+ },
]
// categories included in minimal mode
@@ -229,6 +278,7 @@ const MINIMAL_CATEGORIES: ReadonlySet = new Set([
"data",
"navigation",
"ui",
+ "schedule",
])
// categories included in demo mode (read-only subset)
@@ -236,6 +286,7 @@ const DEMO_CATEGORIES: ReadonlySet = new Set([
"data",
"navigation",
"ui",
+ "schedule",
])
// --- derived state ---
@@ -661,6 +712,62 @@ function buildDashboardRules(
return lines
}
+function buildScheduleGuidance(
+ mode: PromptMode,
+): ReadonlyArray {
+ if (mode !== "full") return []
+ return [
+ "## Schedule Management",
+ "You can read and modify project schedules directly.",
+ "",
+ "**Resolving the projectId:**",
+ "- If the user is on a project page (URL contains " +
+ "/dashboard/projects/{id}), extract the projectId " +
+ "from the currentPage URL.",
+ "- Otherwise, ask which project or use queryData " +
+ '(queryType: "projects") to search by name.',
+ "",
+ "**Workflow — always read before writing:**",
+ "1. Call getProjectSchedule to load all tasks and " +
+ "dependencies.",
+ "2. Match the user's task name to a task UUID in the " +
+ "returned list.",
+ "3. Then call createScheduleTask, updateScheduleTask, " +
+ "deleteScheduleTask, createScheduleDependency, or " +
+ "deleteScheduleDependency as needed.",
+ "",
+ "**Construction phases:** preconstruction, sitework, " +
+ "foundation, framing, roofing, electrical, plumbing, " +
+ "hvac, insulation, drywall, finish, landscaping, closeout.",
+ "",
+ "**Task statuses:** PENDING, IN_PROGRESS, COMPLETE, BLOCKED.",
+ "",
+ "**Dependency types:**",
+ "- FS (finish-to-start): successor starts after " +
+ "predecessor finishes. Most common.",
+ "- SS (start-to-start): both start together.",
+ "- FF (finish-to-finish): both finish together.",
+ "- SF (start-to-finish): predecessor start triggers " +
+ "successor finish.",
+ "",
+ "**When to use getProjectSchedule vs queryData:**",
+ "- getProjectSchedule: full schedule with dependencies, " +
+ "critical path, exceptions — use for schedule questions " +
+ "and before any mutations.",
+ '- queryData (queryType: "schedule_tasks"): flat search ' +
+ "across ALL projects — use for cross-project task lookups.",
+ "",
+ "**Common patterns:**",
+ '- "mark X complete" → getProjectSchedule, find task ID, ' +
+ "updateScheduleTask with status: COMPLETE and " +
+ "percentComplete: 100.",
+ '- "what\'s on the critical path?" → getProjectSchedule, ' +
+ "read summary.criticalPath.",
+ '- "link X to Y" → getProjectSchedule, find both IDs, ' +
+ "createScheduleDependency with type FS.",
+ ]
+}
+
function buildGuidelines(
mode: PromptMode,
): ReadonlyArray {
@@ -749,6 +856,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
buildGitHubGuidance(state.mode),
buildThemingRules(state.mode),
buildDashboardRules(ctx, state.mode),
+ buildScheduleGuidance(state.mode),
buildGuidelines(state.mode),
buildPluginSections(ctx.pluginSections, state.mode),
]
diff --git a/src/lib/agent/tools.ts b/src/lib/agent/tools.ts
index 33e7e77..412d891 100755
--- a/src/lib/agent/tools.ts
+++ b/src/lib/agent/tools.ts
@@ -24,9 +24,106 @@ import {
} from "@/app/actions/dashboards"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types"
-import { projects, scheduleTasks } from "@/db/schema"
+import { projects, scheduleTasks, taskDependencies, workdayExceptions } from "@/db/schema"
import { invoices, vendorBills } from "@/db/schema-netsuite"
-import { eq, and, like } from "drizzle-orm"
+import { eq, and, like, asc } from "drizzle-orm"
+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 { revalidatePath } from "next/cache"
+import { isDemoUser } from "@/lib/demo"
+import type {
+ TaskStatus,
+ DependencyType,
+ ExceptionCategory,
+ ExceptionRecurrence,
+ WorkdayExceptionData,
+} from "@/lib/schedule/types"
+
+// shared auth + project verification for schedule tools.
+// uses ID-only lookup (no org check) to match the schedule
+// page behavior -- the middleware already restricts access
+// to authenticated users.
+type ScheduleCtxOk = {
+ readonly ok: true
+ readonly db: ReturnType
+ readonly userId: string
+}
+type ScheduleCtxErr = {
+ readonly ok: false
+ readonly error: string
+}
+type ScheduleCtxResult = ScheduleCtxOk | ScheduleCtxErr
+
+async function requireScheduleCtx(
+ projectId: string,
+ writeable?: boolean,
+): Promise {
+ const user = await getCurrentUser()
+ if (!user) return { ok: false, error: "not authenticated" }
+ if (writeable && isDemoUser(user.id)) {
+ return { ok: false, error: "DEMO_READ_ONLY" }
+ }
+
+ const { env } = await getCloudflareContext()
+ const db = getDb(env.DB)
+
+ const [project] = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1)
+
+ if (!project) {
+ return { ok: false, error: "Project not found" }
+ }
+ return { ok: true, db, userId: user.id }
+}
+
+// fetch workday exceptions for a project (typed)
+async function fetchProjectExceptions(
+ db: ReturnType,
+ projectId: string,
+): Promise {
+ const rows = await db
+ .select()
+ .from(workdayExceptions)
+ .where(eq(workdayExceptions.projectId, projectId))
+ return rows.map((r) => ({
+ ...r,
+ category: r.category as ExceptionCategory,
+ recurrence: r.recurrence as ExceptionRecurrence,
+ }))
+}
+
+// load deps filtered to a project's tasks
+async function fetchProjectDeps(
+ db: ReturnType,
+ projectId: string,
+) {
+ const tasks = await db
+ .select()
+ .from(scheduleTasks)
+ .where(eq(scheduleTasks.projectId, projectId))
+ const allDeps = await db.select().from(taskDependencies)
+ const taskIdSet = new Set(tasks.map((t) => t.id))
+ const deps = allDeps.filter(
+ (d) =>
+ taskIdSet.has(d.predecessorId) &&
+ taskIdSet.has(d.successorId),
+ )
+ return {
+ tasks: tasks.map((t) => ({
+ ...t,
+ status: t.status as TaskStatus,
+ })),
+ deps: deps.map((d) => ({
+ ...d,
+ type: d.type as DependencyType,
+ })),
+ }
+}
const queryDataInputSchema = z.object({
queryType: z.enum([
@@ -900,4 +997,593 @@ export const agentTools = {
}
},
}),
+
+ getProjectSchedule: tool({
+ description:
+ "Get the full schedule for a project including tasks, " +
+ "dependencies, workday exceptions, and a computed summary " +
+ "(counts, overall %, critical path). Always call this " +
+ "before making schedule mutations to resolve task names " +
+ "to IDs.",
+ inputSchema: z.object({
+ projectId: z.string().describe("The project UUID"),
+ }),
+ execute: async (input: { projectId: string }) => {
+ const ctx = await requireScheduleCtx(input.projectId)
+ if (!ctx.ok) return { error: ctx.error }
+ const { db } = ctx
+
+ const { tasks: typedTasks, deps: typedDeps } =
+ await fetchProjectDeps(db, input.projectId)
+ const exceptions = await fetchProjectExceptions(
+ db,
+ input.projectId,
+ )
+
+ const total = typedTasks.length
+ const completed = typedTasks.filter(
+ (t) => t.status === "COMPLETE",
+ ).length
+ const inProgress = typedTasks.filter(
+ (t) => t.status === "IN_PROGRESS",
+ ).length
+ const blocked = typedTasks.filter(
+ (t) => t.status === "BLOCKED",
+ ).length
+ const overallPercent =
+ total > 0
+ ? Math.round(
+ typedTasks.reduce(
+ (sum, t) => sum + t.percentComplete,
+ 0,
+ ) / total,
+ )
+ : 0
+ const criticalPath = typedTasks
+ .filter((t) => t.isCriticalPath)
+ .map((t) => ({
+ id: t.id,
+ title: t.title,
+ status: t.status,
+ startDate: t.startDate,
+ endDate: t.endDateCalculated,
+ }))
+
+ return {
+ tasks: typedTasks,
+ dependencies: typedDeps,
+ exceptions,
+ summary: {
+ total,
+ completed,
+ inProgress,
+ blocked,
+ pending: total - completed - inProgress - blocked,
+ overallPercent,
+ criticalPath,
+ },
+ }
+ },
+ }),
+
+ createScheduleTask: tool({
+ description:
+ "Create a new task on a project schedule. Returns a " +
+ "toast confirmation. Dates are ISO format (YYYY-MM-DD).",
+ inputSchema: z.object({
+ projectId: z.string().describe("The project UUID"),
+ title: z.string().describe("Task title"),
+ startDate: z
+ .string()
+ .describe("Start date in YYYY-MM-DD format"),
+ workdays: z
+ .number()
+ .describe("Duration in working days"),
+ phase: z
+ .string()
+ .describe(
+ "Construction phase (preconstruction, sitework, " +
+ "foundation, framing, roofing, electrical, plumbing, " +
+ "hvac, insulation, drywall, finish, landscaping, closeout)",
+ ),
+ isMilestone: z
+ .boolean()
+ .optional()
+ .describe("Whether this is a milestone (0 workdays)"),
+ percentComplete: z
+ .number()
+ .min(0)
+ .max(100)
+ .optional()
+ .describe("Initial percent complete (0-100)"),
+ assignedTo: z
+ .string()
+ .optional()
+ .describe("Name of the person assigned"),
+ }),
+ execute: async (input: {
+ projectId: string
+ title: string
+ startDate: string
+ workdays: number
+ phase: string
+ isMilestone?: boolean
+ percentComplete?: number
+ assignedTo?: string
+ }) => {
+ const ctx = await requireScheduleCtx(
+ input.projectId,
+ true,
+ )
+ if (!ctx.ok) return { error: ctx.error }
+ const { db } = ctx
+
+ const exceptions = await fetchProjectExceptions(
+ db,
+ input.projectId,
+ )
+
+ const endDate = calculateEndDate(
+ input.startDate,
+ input.workdays,
+ exceptions,
+ )
+ const now = new Date().toISOString()
+
+ const existing = await db
+ .select({ sortOrder: scheduleTasks.sortOrder })
+ .from(scheduleTasks)
+ .where(eq(scheduleTasks.projectId, input.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: input.projectId,
+ title: input.title,
+ startDate: input.startDate,
+ workdays: input.workdays,
+ endDateCalculated: endDate,
+ phase: input.phase,
+ status: "PENDING",
+ isCriticalPath: false,
+ isMilestone: input.isMilestone ?? false,
+ percentComplete: input.percentComplete ?? 0,
+ assignedTo: input.assignedTo ?? null,
+ sortOrder: nextOrder,
+ createdAt: now,
+ updatedAt: now,
+ })
+
+ await recalcCriticalPathDirect(db, input.projectId)
+ revalidatePath(
+ `/dashboard/projects/${input.projectId}/schedule`,
+ )
+
+ return {
+ action: "toast" as const,
+ message: `Task "${input.title}" created`,
+ type: "success",
+ }
+ },
+ }),
+
+ updateScheduleTask: tool({
+ description:
+ "Update an existing schedule task. Provide only the " +
+ "fields to change. Use getProjectSchedule first to " +
+ "resolve task names to IDs.",
+ inputSchema: z.object({
+ taskId: z.string().describe("The task UUID"),
+ title: z.string().optional().describe("New title"),
+ startDate: z
+ .string()
+ .optional()
+ .describe("New start date (YYYY-MM-DD)"),
+ workdays: z
+ .number()
+ .optional()
+ .describe("New duration in working days"),
+ phase: z.string().optional().describe("New phase"),
+ status: z
+ .enum(["PENDING", "IN_PROGRESS", "COMPLETE", "BLOCKED"])
+ .optional()
+ .describe("New status"),
+ isMilestone: z
+ .boolean()
+ .optional()
+ .describe("Set milestone flag"),
+ percentComplete: z
+ .number()
+ .min(0)
+ .max(100)
+ .optional()
+ .describe("New percent complete (0-100)"),
+ assignedTo: z
+ .string()
+ .nullable()
+ .optional()
+ .describe("New assignee (null to unassign)"),
+ }),
+ execute: async (input: {
+ taskId: string
+ title?: string
+ startDate?: string
+ workdays?: number
+ phase?: string
+ status?: "PENDING" | "IN_PROGRESS" | "COMPLETE" | "BLOCKED"
+ isMilestone?: boolean
+ percentComplete?: number
+ assignedTo?: string | null
+ }) => {
+ const user = await getCurrentUser()
+ if (!user) return { error: "not authenticated" }
+ if (isDemoUser(user.id)) {
+ return { error: "DEMO_READ_ONLY" }
+ }
+ const { env } = await getCloudflareContext()
+ const db = getDb(env.DB)
+
+ const [task] = await db
+ .select()
+ .from(scheduleTasks)
+ .where(eq(scheduleTasks.id, input.taskId))
+ .limit(1)
+
+ if (!task) return { error: "Task not found" }
+
+ const { taskId, status, ...fields } = input
+ const hasFields = Object.keys(fields).length > 0
+
+ if (hasFields) {
+ const exceptions = await fetchProjectExceptions(
+ db,
+ task.projectId,
+ )
+
+ const startDate = fields.startDate ?? task.startDate
+ const workdays = fields.workdays ?? task.workdays
+ const endDate = calculateEndDate(
+ startDate,
+ workdays,
+ exceptions,
+ )
+
+ await db
+ .update(scheduleTasks)
+ .set({
+ ...(fields.title && { title: fields.title }),
+ startDate,
+ workdays,
+ endDateCalculated: endDate,
+ ...(fields.phase && { phase: fields.phase }),
+ ...(fields.isMilestone !== undefined && {
+ isMilestone: fields.isMilestone,
+ }),
+ ...(fields.percentComplete !== undefined && {
+ percentComplete: fields.percentComplete,
+ }),
+ ...(fields.assignedTo !== undefined && {
+ assignedTo: fields.assignedTo,
+ }),
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(scheduleTasks.id, taskId))
+
+ // propagate date changes to downstream tasks
+ const allTasks = await db
+ .select()
+ .from(scheduleTasks)
+ .where(eq(scheduleTasks.projectId, task.projectId))
+ const allDeps = await db
+ .select()
+ .from(taskDependencies)
+ const taskIdSet = new Set(allTasks.map((t) => t.id))
+ const projectDeps = allDeps
+ .filter(
+ (d) =>
+ taskIdSet.has(d.predecessorId) &&
+ taskIdSet.has(d.successorId),
+ )
+ .map((d) => ({
+ ...d,
+ type: d.type as DependencyType,
+ }))
+
+ const updatedTask = {
+ ...task,
+ status: task.status as TaskStatus,
+ startDate,
+ workdays,
+ endDateCalculated: endDate,
+ }
+ const typedAll = allTasks.map((t) =>
+ t.id === taskId
+ ? updatedTask
+ : { ...t, status: t.status as TaskStatus },
+ )
+ const { updatedTasks } = propagateDates(
+ taskId,
+ typedAll,
+ projectDeps,
+ exceptions,
+ )
+
+ 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))
+ }
+ }
+
+ if (status) {
+ await db
+ .update(scheduleTasks)
+ .set({
+ status,
+ updatedAt: new Date().toISOString(),
+ })
+ .where(eq(scheduleTasks.id, taskId))
+ }
+
+ if (!hasFields && !status) {
+ return { error: "No fields provided to update" }
+ }
+
+ await recalcCriticalPathDirect(db, task.projectId)
+ revalidatePath(
+ `/dashboard/projects/${task.projectId}/schedule`,
+ )
+
+ return {
+ action: "toast" as const,
+ message: "Task updated",
+ type: "success",
+ }
+ },
+ }),
+
+ deleteScheduleTask: tool({
+ description:
+ "Delete a schedule task. Always confirm with the user " +
+ "before deleting. This also removes any dependencies " +
+ "involving the task.",
+ inputSchema: z.object({
+ taskId: z.string().describe("The task UUID to delete"),
+ }),
+ execute: async (input: { taskId: string }) => {
+ const user = await getCurrentUser()
+ if (!user) return { error: "not authenticated" }
+ if (isDemoUser(user.id)) {
+ return { error: "DEMO_READ_ONLY" }
+ }
+ const { env } = await getCloudflareContext()
+ const db = getDb(env.DB)
+
+ const [task] = await db
+ .select()
+ .from(scheduleTasks)
+ .where(eq(scheduleTasks.id, input.taskId))
+ .limit(1)
+
+ if (!task) return { error: "Task not found" }
+
+ await db
+ .delete(scheduleTasks)
+ .where(eq(scheduleTasks.id, input.taskId))
+ await recalcCriticalPathDirect(db, task.projectId)
+ revalidatePath(
+ `/dashboard/projects/${task.projectId}/schedule`,
+ )
+
+ return {
+ action: "toast" as const,
+ message: "Task deleted",
+ type: "success",
+ }
+ },
+ }),
+
+ createScheduleDependency: tool({
+ description:
+ "Create a dependency between two tasks. Has built-in " +
+ "cycle detection. Use getProjectSchedule first to " +
+ "resolve task names to IDs.",
+ inputSchema: z.object({
+ projectId: z
+ .string()
+ .describe("The project UUID"),
+ predecessorId: z
+ .string()
+ .describe("UUID of the predecessor task"),
+ successorId: z
+ .string()
+ .describe("UUID of the successor task"),
+ type: z
+ .enum(["FS", "SS", "FF", "SF"])
+ .describe(
+ "Dependency type: FS (finish-to-start), " +
+ "SS (start-to-start), FF (finish-to-finish), " +
+ "SF (start-to-finish)",
+ ),
+ lagDays: z
+ .number()
+ .optional()
+ .describe("Lag in working days (default 0)"),
+ }),
+ execute: async (input: {
+ projectId: string
+ predecessorId: string
+ successorId: string
+ type: "FS" | "SS" | "FF" | "SF"
+ lagDays?: number
+ }) => {
+ const ctx = await requireScheduleCtx(
+ input.projectId,
+ true,
+ )
+ if (!ctx.ok) return { error: ctx.error }
+ const { db } = ctx
+
+ // load schedule for cycle check + propagation
+ const { tasks: typedTasks, deps: existingDeps } =
+ await fetchProjectDeps(db, input.projectId)
+
+ if (
+ wouldCreateCycle(
+ existingDeps,
+ input.predecessorId,
+ input.successorId,
+ )
+ ) {
+ return {
+ error: "This dependency would create a cycle",
+ }
+ }
+
+ const depId = crypto.randomUUID()
+ await db.insert(taskDependencies).values({
+ id: depId,
+ predecessorId: input.predecessorId,
+ successorId: input.successorId,
+ type: input.type,
+ lagDays: input.lagDays ?? 0,
+ })
+
+ // propagate dates
+ const exceptions = await fetchProjectExceptions(
+ db,
+ input.projectId,
+ )
+
+ const updatedDeps = [
+ ...existingDeps,
+ {
+ id: depId,
+ predecessorId: input.predecessorId,
+ successorId: input.successorId,
+ type: input.type as DependencyType,
+ lagDays: input.lagDays ?? 0,
+ },
+ ]
+ const { updatedTasks } = propagateDates(
+ input.predecessorId,
+ typedTasks,
+ updatedDeps,
+ exceptions,
+ )
+
+ 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 recalcCriticalPathDirect(db, input.projectId)
+ revalidatePath(
+ `/dashboard/projects/${input.projectId}/schedule`,
+ )
+
+ return {
+ action: "toast" as const,
+ message: "Dependency created",
+ type: "success",
+ }
+ },
+ }),
+
+ deleteScheduleDependency: tool({
+ description:
+ "Delete a dependency between tasks. Use " +
+ "getProjectSchedule first to find the dependency ID.",
+ inputSchema: z.object({
+ dependencyId: z
+ .string()
+ .describe("The dependency UUID to delete"),
+ projectId: z
+ .string()
+ .describe("The project UUID (for revalidation)"),
+ }),
+ execute: async (input: {
+ dependencyId: string
+ projectId: string
+ }) => {
+ const ctx = await requireScheduleCtx(
+ input.projectId,
+ true,
+ )
+ if (!ctx.ok) return { error: ctx.error }
+ const { db } = ctx
+
+ await db
+ .delete(taskDependencies)
+ .where(eq(taskDependencies.id, input.dependencyId))
+ await recalcCriticalPathDirect(db, input.projectId)
+ revalidatePath(
+ `/dashboard/projects/${input.projectId}/schedule`,
+ )
+
+ return {
+ action: "toast" as const,
+ message: "Dependency removed",
+ type: "success",
+ }
+ },
+ }),
+}
+
+// recalculates critical path for a project (inline version
+// that doesn't depend on server action auth context)
+async function recalcCriticalPathDirect(
+ db: ReturnType,
+ projectId: string,
+): Promise {
+ const tasks = await db
+ .select()
+ .from(scheduleTasks)
+ .where(eq(scheduleTasks.projectId, projectId))
+
+ const allDeps = await db.select().from(taskDependencies)
+ const taskIdSet = new Set(tasks.map((t) => t.id))
+ const projectDeps = allDeps.filter(
+ (d) =>
+ taskIdSet.has(d.predecessorId) &&
+ taskIdSet.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))
+ }
+ }
}
diff --git a/src/lib/schedule/types.ts b/src/lib/schedule/types.ts
index 738815a..323dab6 100755
--- a/src/lib/schedule/types.ts
+++ b/src/lib/schedule/types.ts
@@ -79,3 +79,40 @@ export interface ScheduleData {
dependencies: TaskDependencyData[]
exceptions: WorkdayExceptionData[]
}
+
+export interface TaskFilters {
+ readonly status: readonly TaskStatus[]
+ readonly phase: readonly ConstructionPhase[]
+ readonly assignedTo: string
+ readonly search: string
+}
+
+export const EMPTY_FILTERS: TaskFilters = {
+ status: [],
+ phase: [],
+ assignedTo: "",
+ search: "",
+}
+
+export const STATUS_OPTIONS: readonly { readonly value: TaskStatus; readonly label: string }[] = [
+ { value: "PENDING", label: "Pending" },
+ { value: "IN_PROGRESS", label: "In Progress" },
+ { value: "COMPLETE", label: "Complete" },
+ { value: "BLOCKED", label: "Blocked" },
+] as const
+
+export const PHASE_OPTIONS: readonly { readonly value: ConstructionPhase; readonly label: string }[] = [
+ { value: "preconstruction", label: "Preconstruction" },
+ { value: "sitework", label: "Sitework" },
+ { value: "foundation", label: "Foundation" },
+ { value: "framing", label: "Framing" },
+ { value: "roofing", label: "Roofing" },
+ { value: "electrical", label: "Electrical" },
+ { value: "plumbing", label: "Plumbing" },
+ { value: "hvac", label: "HVAC" },
+ { value: "insulation", label: "Insulation" },
+ { value: "drywall", label: "Drywall" },
+ { value: "finish", label: "Finish" },
+ { value: "landscaping", label: "Landscaping" },
+ { value: "closeout", label: "Closeout" },
+] as const