ct-gantt-core 1.0.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.
@@ -0,0 +1,98 @@
1
+ import type { AffectedTasks, GanttConfig, GanttLink, GanttTask, PatchTask, Result } from "../types"
2
+ import { flattenTasks } from "../services/flattenTasks"
3
+ import { scheduleByDependencies, shiftTask } from "./scheduling"
4
+ import { addDays, inclusiveDays, toDate } from "../utils/date"
5
+
6
+ export function computeImpact(
7
+ taskId: string,
8
+ patch: PatchTask,
9
+ tasks: GanttTask[],
10
+ links: GanttLink[],
11
+ config: Partial<GanttConfig>
12
+ ): Result<AffectedTasks> {
13
+ const target = tasks.find((task) => task.id === taskId)
14
+ if (!target) {
15
+ return {
16
+ ok: false,
17
+ error: {
18
+ code: "MISSING_TASK",
19
+ message: `Task ${taskId} does not exist.`
20
+ }
21
+ }
22
+ }
23
+
24
+ const patchedTasks = tasks.map((task) => {
25
+ if (task.id !== taskId) {
26
+ return task
27
+ }
28
+
29
+ let next = { ...task, actual: { ...task.actual } }
30
+ if (patch.actualStart) {
31
+ next = shiftTask(next, patch.actualStart)
32
+ }
33
+ if (patch.actualEnd) {
34
+ next.actual.end = patch.actualEnd
35
+ }
36
+ if (typeof patch.progress === "number") {
37
+ next.actual.progress = patch.progress
38
+ }
39
+ if (patch.duration) {
40
+ next.duration = patch.duration
41
+ next.actual.end = addDays(toDate(next.actual.start), Math.max(0, patch.duration - 1))
42
+ }
43
+ if (patch.name) {
44
+ next.name = patch.name
45
+ }
46
+ if (patch.type) {
47
+ next.type = patch.type
48
+ }
49
+ if ("parentId" in patch) {
50
+ next.parentId = patch.parentId
51
+ }
52
+ if (patch.color) {
53
+ next.color = patch.color
54
+ }
55
+ if ("planColor" in patch) {
56
+ next.planColor = patch.planColor
57
+ }
58
+ if (patch.schedulingMode) {
59
+ next.schedulingMode = patch.schedulingMode
60
+ }
61
+ return next
62
+ })
63
+
64
+ const scheduled = config.autoSchedule === false
65
+ ? { ok: true as const, data: patchedTasks }
66
+ : scheduleByDependencies(patchedTasks, links)
67
+
68
+ if (!scheduled.ok) {
69
+ return scheduled
70
+ }
71
+
72
+ const rows = new Map(flattenTasks(scheduled.data).map((flat) => [flat.task.id, flat.rowIndex]))
73
+ const originalById = new Map(tasks.map((task) => [task.id, task]))
74
+ const changed = scheduled.data
75
+ .filter((task) => {
76
+ const original = originalById.get(task.id)
77
+ return original && (
78
+ String(original.actual.start) !== String(task.actual.start) ||
79
+ String(original.actual.end) !== String(task.actual.end)
80
+ )
81
+ })
82
+ .map((task) => ({
83
+ taskId: task.id,
84
+ actualStart: task.actual.start,
85
+ actualEnd: task.actual.end,
86
+ rowIndex: rows.get(task.id) ?? 0
87
+ }))
88
+
89
+ const conflicts = scheduled.data
90
+ .filter((task) => task.constraint?.date && inclusiveDays(task.actual.start, task.constraint.date) < 0)
91
+ .map((task) => ({
92
+ taskId: task.id,
93
+ constraintType: task.constraint!.type,
94
+ message: "Task violates its configured constraint date."
95
+ }))
96
+
97
+ return { ok: true, data: { changed, conflicts } }
98
+ }
@@ -0,0 +1,73 @@
1
+ import type { GanttConfig, GanttLink, GanttTask, Result, TaskLayout, Viewport } from "../types"
2
+ import { defaultConfig } from "../types"
3
+ import { flattenTasks } from "../services/flattenTasks"
4
+ import { normalizeLinks } from "../services/normalizeLinks"
5
+ import { scheduleByDependencies } from "./scheduling"
6
+ import { diffDays, inclusiveDays, isValidDate, toDate } from "../utils/date"
7
+
8
+ export function computeLayout(
9
+ tasks: GanttTask[],
10
+ links: GanttLink[],
11
+ config: Partial<GanttConfig>,
12
+ collapsedIds: Set<string> = new Set(),
13
+ viewport?: Viewport
14
+ ): Result<TaskLayout[]> {
15
+ const mergedConfig = { ...defaultConfig, ...config }
16
+
17
+ for (const task of tasks) {
18
+ if (!isValidDate(task.actual.start) || !isValidDate(task.actual.end)) {
19
+ return {
20
+ ok: false,
21
+ error: {
22
+ code: "INVALID_DATE",
23
+ message: `Task ${task.id} has invalid actual dates.`
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ const normalizedLinks = normalizeLinks(tasks, links)
30
+ const scheduled = mergedConfig.autoSchedule === false
31
+ ? { ok: true as const, data: tasks }
32
+ : scheduleByDependencies(tasks, normalizedLinks)
33
+
34
+ if (!scheduled.ok) {
35
+ return scheduled
36
+ }
37
+
38
+ const flatTasks = flattenTasks(scheduled.data, collapsedIds)
39
+ const firstDate = mergedConfig.visibleRange
40
+ ? toDate(mergedConfig.visibleRange.start)
41
+ : minDate(scheduled.data.map((task) => toDate(task.actual.start)))
42
+
43
+ const visibleStart = viewport
44
+ ? Math.max(0, Math.floor(viewport.scrollTop / mergedConfig.rowHeight) - 8)
45
+ : 0
46
+ const visibleEnd = viewport
47
+ ? Math.ceil((viewport.scrollTop + viewport.clientHeight) / mergedConfig.rowHeight) + 8
48
+ : Number.POSITIVE_INFINITY
49
+
50
+ const layouts = flatTasks
51
+ .filter((flat) => flat.rowIndex >= visibleStart && flat.rowIndex <= visibleEnd)
52
+ .map<TaskLayout>((flat) => {
53
+ const start = toDate(flat.task.actual.start)
54
+ const end = toDate(flat.task.actual.end)
55
+ const duration = flat.task.type === "milestone" ? 0 : inclusiveDays(start, end)
56
+
57
+ return {
58
+ taskId: flat.task.id,
59
+ rowIndex: flat.rowIndex,
60
+ left: diffDays(firstDate, start) * mergedConfig.columnWidth,
61
+ width: Math.max(flat.task.type === "milestone" ? mergedConfig.columnWidth / 2 : mergedConfig.columnWidth, duration * mergedConfig.columnWidth),
62
+ top: mergedConfig.headerHeight + flat.rowIndex * mergedConfig.rowHeight,
63
+ depth: flat.depth,
64
+ isCritical: false
65
+ }
66
+ })
67
+
68
+ return { ok: true, data: layouts }
69
+ }
70
+
71
+ function minDate(dates: Date[]): Date {
72
+ return new Date(Math.min(...dates.map((date) => date.getTime())))
73
+ }
@@ -0,0 +1,155 @@
1
+ import type { GanttLink, GanttTask, LinkType, Result } from "../types"
2
+ import { addDays, diffDays, inclusiveDays, toDate } from "../utils/date"
3
+ import { checkCyclicDependency } from "../services/checkCyclicDependency"
4
+
5
+ export function scheduleByDependencies(tasks: GanttTask[], links: GanttLink[]): Result<GanttTask[]> {
6
+ const cycle = checkCyclicDependency(links)
7
+ if (cycle.hasCycle) {
8
+ return {
9
+ ok: false,
10
+ error: {
11
+ code: "CYCLE_DEPENDENCY",
12
+ message: "Dependency graph contains a cycle.",
13
+ details: cycle.cyclePath
14
+ }
15
+ }
16
+ }
17
+
18
+ const taskMap = new Map(tasks.map((task) => [task.id, cloneTask(task)]))
19
+ const incoming = new Map<string, GanttLink[]>()
20
+ const outgoing = new Map<string, GanttLink[]>()
21
+
22
+ for (const link of links) {
23
+ const source = taskMap.get(link.sourceId)
24
+ const target = taskMap.get(link.targetId)
25
+ if (!source || !target) {
26
+ return {
27
+ ok: false,
28
+ error: {
29
+ code: "ORPHAN_DEPENDENCY",
30
+ message: `Dependency ${link.id} references a missing task.`
31
+ }
32
+ }
33
+ }
34
+ incoming.set(link.targetId, [...(incoming.get(link.targetId) ?? []), link])
35
+ outgoing.set(link.sourceId, [...(outgoing.get(link.sourceId) ?? []), link])
36
+ }
37
+
38
+ const ordered = topologicalOrder(tasks.map((task) => task.id), links)
39
+
40
+ for (const taskId of ordered) {
41
+ const task = taskMap.get(taskId)
42
+ if (!task || task.schedulingMode === "manual") {
43
+ continue
44
+ }
45
+
46
+ const linksToTask = incoming.get(taskId) ?? []
47
+ let start = toDate(task.actual.start)
48
+ let end = toDate(task.actual.end)
49
+ const duration = Math.max(0, task.duration ?? inclusiveDays(start, end))
50
+
51
+ for (const link of linksToTask) {
52
+ const source = taskMap.get(link.sourceId)
53
+ if (!source) {
54
+ continue
55
+ }
56
+
57
+ const candidate = applyDependency(source, start, end, duration, link.type, link.lag ?? 0)
58
+ if (candidate.start.getTime() > start.getTime()) {
59
+ start = candidate.start
60
+ }
61
+ if (candidate.end.getTime() > end.getTime()) {
62
+ end = candidate.end
63
+ }
64
+ }
65
+
66
+ const normalizedEnd = task.type === "milestone" ? start : addDays(start, Math.max(0, duration - 1))
67
+ task.actual = {
68
+ ...task.actual,
69
+ start,
70
+ end: normalizedEnd
71
+ }
72
+ }
73
+
74
+ return { ok: true, data: tasks.map((task) => taskMap.get(task.id) ?? task) }
75
+ }
76
+
77
+ function applyDependency(
78
+ source: GanttTask,
79
+ currentStart: Date,
80
+ currentEnd: Date,
81
+ duration: number,
82
+ type: LinkType,
83
+ lag: number
84
+ ): { start: Date; end: Date } {
85
+ const sourceStart = toDate(source.actual.start)
86
+ const sourceEnd = toDate(source.actual.end)
87
+ const span = Math.max(0, duration - 1)
88
+
89
+ if (type === "FS") {
90
+ const start = addDays(sourceEnd, lag + 1)
91
+ return { start, end: addDays(start, span) }
92
+ }
93
+
94
+ if (type === "SS") {
95
+ const start = addDays(sourceStart, lag)
96
+ return { start, end: addDays(start, span) }
97
+ }
98
+
99
+ if (type === "FF") {
100
+ const end = addDays(sourceEnd, lag)
101
+ return { start: addDays(end, -span), end }
102
+ }
103
+
104
+ const end = addDays(sourceStart, lag)
105
+ return { start: addDays(end, -span), end: end.getTime() > currentEnd.getTime() ? end : currentEnd }
106
+ }
107
+
108
+ function topologicalOrder(ids: string[], links: GanttLink[]): string[] {
109
+ const indegree = new Map(ids.map((id) => [id, 0]))
110
+ const outgoing = new Map<string, string[]>()
111
+
112
+ for (const link of links) {
113
+ indegree.set(link.targetId, (indegree.get(link.targetId) ?? 0) + 1)
114
+ outgoing.set(link.sourceId, [...(outgoing.get(link.sourceId) ?? []), link.targetId])
115
+ }
116
+
117
+ const queue = ids.filter((id) => (indegree.get(id) ?? 0) === 0)
118
+ const ordered: string[] = []
119
+
120
+ while (queue.length) {
121
+ const current = queue.shift()!
122
+ ordered.push(current)
123
+ for (const target of outgoing.get(current) ?? []) {
124
+ indegree.set(target, (indegree.get(target) ?? 1) - 1)
125
+ if (indegree.get(target) === 0) {
126
+ queue.push(target)
127
+ }
128
+ }
129
+ }
130
+
131
+ return ordered.length === ids.length ? ordered : ids
132
+ }
133
+
134
+ function cloneTask(task: GanttTask): GanttTask {
135
+ return {
136
+ ...task,
137
+ plan: { ...task.plan },
138
+ actual: { ...task.actual },
139
+ dependencies: task.dependencies?.map((dependency) => ({ ...dependency }))
140
+ }
141
+ }
142
+
143
+ export function shiftTask(task: GanttTask, start: string | Date): GanttTask {
144
+ const currentStart = toDate(task.actual.start)
145
+ const nextStart = toDate(start)
146
+ const delta = diffDays(currentStart, nextStart)
147
+ return {
148
+ ...task,
149
+ actual: {
150
+ ...task.actual,
151
+ start: nextStart,
152
+ end: addDays(toDate(task.actual.end), delta)
153
+ }
154
+ }
155
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./types"
2
+ export * from "./services/checkCyclicDependency"
3
+ export * from "./services/normalizeLinks"
4
+ export * from "./services/flattenTasks"
5
+ export * from "./services/computeTimeScale"
6
+ export * from "./engines/computeLayout"
7
+ export * from "./engines/computeImpact"
8
+ export * from "./engines/scheduling"
9
+ export * from "./utils/date"
@@ -0,0 +1,54 @@
1
+ import type { GanttLink } from "../types"
2
+
3
+ export function checkCyclicDependency(
4
+ links: GanttLink[]
5
+ ): { hasCycle: boolean; cyclePath?: string[] } {
6
+ const graph = new Map<string, string[]>()
7
+
8
+ for (const link of links) {
9
+ const targets = graph.get(link.sourceId) ?? []
10
+ targets.push(link.targetId)
11
+ graph.set(link.sourceId, targets)
12
+ if (!graph.has(link.targetId)) {
13
+ graph.set(link.targetId, [])
14
+ }
15
+ }
16
+
17
+ const visiting = new Set<string>()
18
+ const visited = new Set<string>()
19
+ const path: string[] = []
20
+
21
+ const dfs = (node: string): string[] | undefined => {
22
+ if (visiting.has(node)) {
23
+ const start = path.indexOf(node)
24
+ return [...path.slice(start), node]
25
+ }
26
+ if (visited.has(node)) {
27
+ return undefined
28
+ }
29
+
30
+ visiting.add(node)
31
+ path.push(node)
32
+
33
+ for (const next of graph.get(node) ?? []) {
34
+ const cycle = dfs(next)
35
+ if (cycle) {
36
+ return cycle
37
+ }
38
+ }
39
+
40
+ path.pop()
41
+ visiting.delete(node)
42
+ visited.add(node)
43
+ return undefined
44
+ }
45
+
46
+ for (const node of graph.keys()) {
47
+ const cyclePath = dfs(node)
48
+ if (cyclePath) {
49
+ return { hasCycle: true, cyclePath }
50
+ }
51
+ }
52
+
53
+ return { hasCycle: false }
54
+ }
@@ -0,0 +1,71 @@
1
+ import type { TimeScale, ViewMode } from "../types"
2
+ import { addDays, diffDays, startOfDay } from "../utils/date"
3
+
4
+ export function computeTimeScale(
5
+ start: Date,
6
+ end: Date,
7
+ viewMode: ViewMode,
8
+ columnWidth: number,
9
+ firstDayOfWeek: 0 | 1
10
+ ): TimeScale[] {
11
+ const rangeStart = alignStart(start, viewMode, firstDayOfWeek)
12
+ const endDate = startOfDay(end)
13
+ const rangeEnd = viewMode === "day"
14
+ ? addDays(endDate, (firstDayOfWeek + 6 - endDate.getDay() + 7) % 7)
15
+ : endDate
16
+ const scale: TimeScale[] = []
17
+ let cursor = rangeStart
18
+ let left = 0
19
+
20
+ while (cursor.getTime() <= rangeEnd.getTime()) {
21
+ const next = nextUnit(cursor, viewMode)
22
+ const unitEnd = addDays(next, -1)
23
+ const width = Math.max(1, diffDays(cursor, next)) * columnWidth
24
+
25
+ scale.push({
26
+ start: cursor,
27
+ end: unitEnd,
28
+ left,
29
+ width
30
+ })
31
+
32
+ left += width
33
+ cursor = next
34
+ }
35
+
36
+ return scale
37
+ }
38
+
39
+ function alignStart(date: Date, viewMode: ViewMode, firstDayOfWeek: 0 | 1): Date {
40
+ const start = startOfDay(date)
41
+ if (viewMode === "day" || viewMode === "week") {
42
+ const delta = (start.getDay() - firstDayOfWeek + 7) % 7
43
+ return addDays(start, -delta)
44
+ }
45
+ if (viewMode === "month") {
46
+ return new Date(start.getFullYear(), start.getMonth(), 1)
47
+ }
48
+ if (viewMode === "quarter") {
49
+ return new Date(start.getFullYear(), Math.floor(start.getMonth() / 3) * 3, 1)
50
+ }
51
+ if (viewMode === "year") {
52
+ return new Date(start.getFullYear(), 0, 1)
53
+ }
54
+ return start
55
+ }
56
+
57
+ function nextUnit(date: Date, viewMode: ViewMode): Date {
58
+ if (viewMode === "week") {
59
+ return addDays(date, 7)
60
+ }
61
+ if (viewMode === "month") {
62
+ return new Date(date.getFullYear(), date.getMonth() + 1, 1)
63
+ }
64
+ if (viewMode === "quarter") {
65
+ return new Date(date.getFullYear(), date.getMonth() + 3, 1)
66
+ }
67
+ if (viewMode === "year") {
68
+ return new Date(date.getFullYear() + 1, 0, 1)
69
+ }
70
+ return addDays(date, 1)
71
+ }
@@ -0,0 +1,38 @@
1
+ import type { FlatTask, GanttTask } from "../types"
2
+
3
+ export function flattenTasks(tasks: GanttTask[], collapsedIds: Set<string> = new Set()): FlatTask[] {
4
+ const children = new Map<string | null, GanttTask[]>()
5
+
6
+ for (const task of tasks) {
7
+ const parentId = task.parentId ?? null
8
+ const bucket = children.get(parentId) ?? []
9
+ bucket.push(task)
10
+ children.set(parentId, bucket)
11
+ }
12
+
13
+ const flattened: FlatTask[] = []
14
+ const visit = (task: GanttTask, depth: number) => {
15
+ const childTasks = children.get(task.id) ?? []
16
+ const collapsed = collapsedIds.has(task.id)
17
+
18
+ flattened.push({
19
+ task,
20
+ depth,
21
+ rowIndex: flattened.length,
22
+ hasChildren: childTasks.length > 0,
23
+ collapsed
24
+ })
25
+
26
+ if (!collapsed) {
27
+ for (const child of childTasks) {
28
+ visit(child, depth + 1)
29
+ }
30
+ }
31
+ }
32
+
33
+ for (const root of children.get(null) ?? []) {
34
+ visit(root, 0)
35
+ }
36
+
37
+ return flattened
38
+ }
@@ -0,0 +1,69 @@
1
+ import type { GanttLink, GanttTask } from "../types"
2
+
3
+ export function normalizeLinks(tasks: GanttTask[], standaloneLinks: GanttLink[] = []): GanttLink[] {
4
+ const taskIds = new Set(tasks.map((task) => task.id))
5
+ const byTaskPair = new Map<string, GanttLink>()
6
+ const outgoing = new Map<string, Set<string>>()
7
+
8
+ const createsCycle = (sourceId: string, targetId: string): boolean => {
9
+ const pending = [targetId]
10
+ const visited = new Set<string>()
11
+ while (pending.length > 0) {
12
+ const current = pending.pop()!
13
+ if (current === sourceId) {
14
+ return true
15
+ }
16
+ if (visited.has(current)) {
17
+ continue
18
+ }
19
+ visited.add(current)
20
+ pending.push(...(outgoing.get(current) ?? []))
21
+ }
22
+ return false
23
+ }
24
+
25
+ const append = (link: GanttLink) => {
26
+ if (
27
+ !taskIds.has(link.sourceId)
28
+ || !taskIds.has(link.targetId)
29
+ || link.sourceId === link.targetId
30
+ ) {
31
+ return
32
+ }
33
+
34
+ const normalized: GanttLink = {
35
+ ...link,
36
+ id: link.id || `link-${link.sourceId}-${link.targetId}`,
37
+ lag: link.lag ?? 0,
38
+ lagUnit: link.lagUnit ?? "calendar"
39
+ }
40
+ const pairKey = `${normalized.sourceId}|${normalized.targetId}`
41
+ if (byTaskPair.has(pairKey) || createsCycle(normalized.sourceId, normalized.targetId)) {
42
+ return
43
+ }
44
+
45
+ byTaskPair.set(pairKey, normalized)
46
+ const targets = outgoing.get(normalized.sourceId) ?? new Set<string>()
47
+ targets.add(normalized.targetId)
48
+ outgoing.set(normalized.sourceId, targets)
49
+ }
50
+
51
+ for (const task of tasks) {
52
+ for (const dependency of task.dependencies ?? []) {
53
+ append({
54
+ id: dependency.id || `link-${dependency.predecessorId}-${task.id}`,
55
+ sourceId: dependency.predecessorId,
56
+ targetId: task.id,
57
+ type: dependency.type,
58
+ lag: dependency.lag,
59
+ lagUnit: dependency.lagUnit
60
+ })
61
+ }
62
+ }
63
+
64
+ for (const link of standaloneLinks) {
65
+ append(link)
66
+ }
67
+
68
+ return [...byTaskPair.values()]
69
+ }