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.
- package/dist/index.cjs +570 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +235 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +526 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -0
- package/src/engines/computeImpact.ts +98 -0
- package/src/engines/computeLayout.ts +73 -0
- package/src/engines/scheduling.ts +155 -0
- package/src/index.ts +9 -0
- package/src/services/checkCyclicDependency.ts +54 -0
- package/src/services/computeTimeScale.ts +71 -0
- package/src/services/flattenTasks.ts +38 -0
- package/src/services/normalizeLinks.ts +69 -0
- package/src/types/index.ts +241 -0
- package/src/utils/date.ts +54 -0
- package/tests/core.test.ts +137 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
export type ViewMode = "day" | "week" | "month" | "quarter" | "year"
|
|
2
|
+
|
|
3
|
+
export type LinkType = "FS" | "SS" | "FF" | "SF"
|
|
4
|
+
export type LagUnit = "calendar" | "working"
|
|
5
|
+
|
|
6
|
+
export type Result<T> =
|
|
7
|
+
| { ok: true; data: T }
|
|
8
|
+
| { ok: false; error: GanttError }
|
|
9
|
+
|
|
10
|
+
export interface GanttError {
|
|
11
|
+
code:
|
|
12
|
+
| "CYCLE_DEPENDENCY"
|
|
13
|
+
| "INVALID_DATE"
|
|
14
|
+
| "MISSING_TASK"
|
|
15
|
+
| "ORPHAN_DEPENDENCY"
|
|
16
|
+
| "INVALID_DEPENDENCY"
|
|
17
|
+
message: string
|
|
18
|
+
details?: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DateRange {
|
|
22
|
+
start: string | Date
|
|
23
|
+
end: string | Date
|
|
24
|
+
progress?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Dependency {
|
|
28
|
+
id?: string
|
|
29
|
+
predecessorId: string
|
|
30
|
+
type: LinkType
|
|
31
|
+
lag: number
|
|
32
|
+
lagUnit: LagUnit
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GanttTask {
|
|
36
|
+
id: string
|
|
37
|
+
name: string
|
|
38
|
+
type: "task" | "milestone" | "summary"
|
|
39
|
+
plan: DateRange
|
|
40
|
+
actual: Required<DateRange>
|
|
41
|
+
dependencies?: Dependency[]
|
|
42
|
+
parentId?: string | null
|
|
43
|
+
color?: string
|
|
44
|
+
planColor?: string
|
|
45
|
+
calendarId?: string
|
|
46
|
+
resources?: string[]
|
|
47
|
+
segments?: Array<{ start: string | Date; end: string | Date }>
|
|
48
|
+
constraint?: {
|
|
49
|
+
type: "SNET" | "SNLT" | "MSO" | "MFO" | "ASAP" | "ALAP"
|
|
50
|
+
date?: string | Date
|
|
51
|
+
}
|
|
52
|
+
schedulingMode?: "auto" | "manual"
|
|
53
|
+
duration?: number
|
|
54
|
+
custom?: Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface GanttMarker {
|
|
58
|
+
id: string
|
|
59
|
+
name: string
|
|
60
|
+
date: string | Date
|
|
61
|
+
color?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface GanttEditorField {
|
|
65
|
+
key: string
|
|
66
|
+
label: string
|
|
67
|
+
visible?: boolean
|
|
68
|
+
editable?: boolean
|
|
69
|
+
type?: "text" | "number" | "date" | "select"
|
|
70
|
+
options?: Array<{ label: string; value: string | number }>
|
|
71
|
+
placeholder?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CustomColumn {
|
|
75
|
+
key: string
|
|
76
|
+
label: string
|
|
77
|
+
width?: number
|
|
78
|
+
visible?: boolean
|
|
79
|
+
align?: "left" | "center" | "right"
|
|
80
|
+
editable?: boolean
|
|
81
|
+
type?: "text" | "number" | "date" | "select"
|
|
82
|
+
options?: Array<{ label: string; value: string | number }>
|
|
83
|
+
placeholder?: string
|
|
84
|
+
editor?: Omit<GanttEditorField, "key" | "label">
|
|
85
|
+
formatter?: (value: unknown, task: GanttTask) => string
|
|
86
|
+
cellClass?: string | ((task: GanttTask) => string)
|
|
87
|
+
cellStyle?: Record<string, string | number> | ((task: GanttTask) => Record<string, string | number>)
|
|
88
|
+
render?: (task: GanttTask) => { text: string; className?: string }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface GanttConfig {
|
|
92
|
+
viewMode: ViewMode
|
|
93
|
+
rowHeight: number
|
|
94
|
+
columnWidth: number
|
|
95
|
+
headerHeight: number
|
|
96
|
+
taskListWidth: number
|
|
97
|
+
width?: string | number
|
|
98
|
+
height?: string | number
|
|
99
|
+
locale: string
|
|
100
|
+
firstDayOfWeek: 0 | 1
|
|
101
|
+
dateFormat: string
|
|
102
|
+
customColumns?: CustomColumn[]
|
|
103
|
+
columns?: CustomColumn[]
|
|
104
|
+
editorFields?: GanttEditorField[]
|
|
105
|
+
columnWidths?: Partial<Record<ViewMode, number>>
|
|
106
|
+
theme?: "light" | "dark" | string
|
|
107
|
+
visibleRange?: {
|
|
108
|
+
start: string | Date
|
|
109
|
+
end: string | Date
|
|
110
|
+
}
|
|
111
|
+
showPlanBar?: boolean
|
|
112
|
+
showActualBar?: boolean
|
|
113
|
+
showTimelineWhenEmpty?: boolean
|
|
114
|
+
builtInTaskEditor?: boolean
|
|
115
|
+
builtInMarkerEditor?: boolean
|
|
116
|
+
editablePlan?: boolean
|
|
117
|
+
editableActual?: boolean
|
|
118
|
+
enableLinkCreation?: boolean
|
|
119
|
+
showLinkRejectionNotice?: boolean
|
|
120
|
+
virtualScroll?: boolean
|
|
121
|
+
taskColors?: {
|
|
122
|
+
task?: string
|
|
123
|
+
summary?: string
|
|
124
|
+
milestone?: string
|
|
125
|
+
plan?: string
|
|
126
|
+
progress?: string
|
|
127
|
+
}
|
|
128
|
+
autoSchedule?: boolean
|
|
129
|
+
editable?: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface GanttLink {
|
|
133
|
+
id: string
|
|
134
|
+
sourceId: string
|
|
135
|
+
targetId: string
|
|
136
|
+
type: LinkType
|
|
137
|
+
lag?: number
|
|
138
|
+
lagUnit?: LagUnit
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface TaskLayout {
|
|
142
|
+
taskId: string
|
|
143
|
+
rowIndex: number
|
|
144
|
+
left: number
|
|
145
|
+
width: number
|
|
146
|
+
top: number
|
|
147
|
+
depth: number
|
|
148
|
+
isCritical: boolean
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface FlatTask {
|
|
152
|
+
task: GanttTask
|
|
153
|
+
depth: number
|
|
154
|
+
rowIndex: number
|
|
155
|
+
hasChildren: boolean
|
|
156
|
+
collapsed: boolean
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface Calendar {
|
|
160
|
+
id: string
|
|
161
|
+
workingDays: number[]
|
|
162
|
+
holidays: string[]
|
|
163
|
+
hours: [number, number][]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface TimeScale {
|
|
167
|
+
start: Date
|
|
168
|
+
end: Date
|
|
169
|
+
left: number
|
|
170
|
+
width: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface Viewport {
|
|
174
|
+
scrollTop: number
|
|
175
|
+
scrollLeft: number
|
|
176
|
+
clientWidth: number
|
|
177
|
+
clientHeight: number
|
|
178
|
+
dpr: number
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface AffectedTasks {
|
|
182
|
+
changed: Array<{
|
|
183
|
+
taskId: string
|
|
184
|
+
actualStart: string | Date
|
|
185
|
+
actualEnd: string | Date
|
|
186
|
+
rowIndex: number
|
|
187
|
+
}>
|
|
188
|
+
conflicts: Array<{
|
|
189
|
+
taskId: string
|
|
190
|
+
constraintType: string
|
|
191
|
+
message: string
|
|
192
|
+
}>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface PatchTask {
|
|
196
|
+
planStart?: string | Date
|
|
197
|
+
planEnd?: string | Date
|
|
198
|
+
actualStart?: string | Date
|
|
199
|
+
actualEnd?: string | Date
|
|
200
|
+
progress?: number
|
|
201
|
+
name?: string
|
|
202
|
+
type?: GanttTask["type"]
|
|
203
|
+
parentId?: string | null
|
|
204
|
+
color?: string
|
|
205
|
+
planColor?: string
|
|
206
|
+
duration?: number
|
|
207
|
+
schedulingMode?: "auto" | "manual"
|
|
208
|
+
resources?: string[]
|
|
209
|
+
calendarId?: string
|
|
210
|
+
custom?: Record<string, unknown>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const defaultConfig: GanttConfig = {
|
|
214
|
+
viewMode: "month",
|
|
215
|
+
rowHeight: 44,
|
|
216
|
+
columnWidth: 30,
|
|
217
|
+
headerHeight: 50,
|
|
218
|
+
taskListWidth: 280,
|
|
219
|
+
locale: "zh-CN",
|
|
220
|
+
firstDayOfWeek: 0,
|
|
221
|
+
dateFormat: "YYYY-MM-DD",
|
|
222
|
+
showPlanBar: true,
|
|
223
|
+
showActualBar: true,
|
|
224
|
+
showTimelineWhenEmpty: false,
|
|
225
|
+
builtInTaskEditor: true,
|
|
226
|
+
builtInMarkerEditor: true,
|
|
227
|
+
editablePlan: false,
|
|
228
|
+
editableActual: true,
|
|
229
|
+
enableLinkCreation: true,
|
|
230
|
+
showLinkRejectionNotice: true,
|
|
231
|
+
virtualScroll: true,
|
|
232
|
+
taskColors: {
|
|
233
|
+
task: "#2563eb",
|
|
234
|
+
summary: "#475467",
|
|
235
|
+
milestone: "#d97706",
|
|
236
|
+
plan: "#cbd5e1",
|
|
237
|
+
progress: "#0f766e"
|
|
238
|
+
},
|
|
239
|
+
autoSchedule: true,
|
|
240
|
+
editable: true
|
|
241
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Result } from "../types"
|
|
2
|
+
|
|
3
|
+
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
4
|
+
|
|
5
|
+
export function toDate(value: string | Date): Date {
|
|
6
|
+
if (value instanceof Date) {
|
|
7
|
+
return startOfDay(value)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
|
|
11
|
+
if (match) {
|
|
12
|
+
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return startOfDay(new Date(value))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function startOfDay(date: Date): Date {
|
|
19
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function addDays(date: string | Date, days: number): Date {
|
|
23
|
+
const next = toDate(date)
|
|
24
|
+
next.setDate(next.getDate() + days)
|
|
25
|
+
return startOfDay(next)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function diffDays(start: string | Date, end: string | Date): number {
|
|
29
|
+
return Math.round((toDate(end).getTime() - toDate(start).getTime()) / DAY_MS)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function inclusiveDays(start: string | Date, end: string | Date): number {
|
|
33
|
+
return Math.max(0, diffDays(start, end) + 1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isValidDate(value: string | Date): boolean {
|
|
37
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
38
|
+
return !Number.isNaN(date.getTime())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatDate(date: string | Date): string {
|
|
42
|
+
const value = toDate(date)
|
|
43
|
+
const year = value.getFullYear()
|
|
44
|
+
const month = String(value.getMonth() + 1).padStart(2, "0")
|
|
45
|
+
const day = String(value.getDate()).padStart(2, "0")
|
|
46
|
+
return `${year}-${month}-${day}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resultInvalidDate<T>(message: string, details?: unknown): Result<T> {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: { code: "INVALID_DATE", message, details }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
checkCyclicDependency,
|
|
4
|
+
computeImpact,
|
|
5
|
+
computeLayout,
|
|
6
|
+
computeTimeScale,
|
|
7
|
+
flattenTasks,
|
|
8
|
+
formatDate,
|
|
9
|
+
normalizeLinks,
|
|
10
|
+
scheduleByDependencies,
|
|
11
|
+
type GanttConfig,
|
|
12
|
+
type GanttTask
|
|
13
|
+
} from "../src"
|
|
14
|
+
|
|
15
|
+
const baseConfig: Partial<GanttConfig> = {
|
|
16
|
+
viewMode: "day",
|
|
17
|
+
rowHeight: 36,
|
|
18
|
+
columnWidth: 40,
|
|
19
|
+
headerHeight: 50,
|
|
20
|
+
taskListWidth: 280,
|
|
21
|
+
firstDayOfWeek: 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const tasks: GanttTask[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "1",
|
|
27
|
+
name: "Design",
|
|
28
|
+
type: "task",
|
|
29
|
+
plan: { start: "2026-07-01", end: "2026-07-05", progress: 100 },
|
|
30
|
+
actual: { start: "2026-07-01", end: "2026-07-05", progress: 100 }
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "2",
|
|
34
|
+
name: "Build",
|
|
35
|
+
type: "task",
|
|
36
|
+
plan: { start: "2026-07-06", end: "2026-07-12", progress: 50 },
|
|
37
|
+
actual: { start: "2026-07-06", end: "2026-07-12", progress: 50 },
|
|
38
|
+
dependencies: [{ predecessorId: "1", type: "FS", lag: 0, lagUnit: "calendar" }]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "3",
|
|
42
|
+
name: "Test",
|
|
43
|
+
type: "task",
|
|
44
|
+
plan: { start: "2026-07-13", end: "2026-07-15", progress: 0 },
|
|
45
|
+
actual: { start: "2026-07-13", end: "2026-07-15", progress: 0 },
|
|
46
|
+
dependencies: [{ predecessorId: "2", type: "FS", lag: 0, lagUnit: "calendar" }]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
describe("ct-gantt-core", () => {
|
|
51
|
+
it("normalizes embedded dependencies and removes duplicates", () => {
|
|
52
|
+
const links = normalizeLinks(tasks, [
|
|
53
|
+
{ id: "manual-duplicate", sourceId: "1", targetId: "2", type: "SS", lag: 2, lagUnit: "working" }
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
expect(links).toHaveLength(2)
|
|
57
|
+
expect(links[0]).toMatchObject({
|
|
58
|
+
sourceId: "1",
|
|
59
|
+
targetId: "2",
|
|
60
|
+
type: "FS",
|
|
61
|
+
lag: 0,
|
|
62
|
+
lagUnit: "calendar"
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("keeps existing dependencies when a later link would create a cycle", () => {
|
|
67
|
+
const links = normalizeLinks(tasks.map((task) => ({ ...task, dependencies: [] })), [
|
|
68
|
+
{ id: "a", sourceId: "1", targetId: "2", type: "FS" },
|
|
69
|
+
{ id: "b", sourceId: "2", targetId: "3", type: "FS" },
|
|
70
|
+
{ id: "cycle", sourceId: "3", targetId: "1", type: "FS" }
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
expect(links.map((link) => link.id)).toEqual(["a", "b"])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("detects cycles", () => {
|
|
77
|
+
const result = checkCyclicDependency([
|
|
78
|
+
{ id: "a", sourceId: "1", targetId: "2", type: "FS" },
|
|
79
|
+
{ id: "b", sourceId: "2", targetId: "1", type: "FS" }
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
expect(result.hasCycle).toBe(true)
|
|
83
|
+
expect(result.cyclePath).toContain("1")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("flattens task trees with collapsed summaries", () => {
|
|
87
|
+
const tree: GanttTask[] = [
|
|
88
|
+
{ ...tasks[0], id: "root", type: "summary" },
|
|
89
|
+
{ ...tasks[1], id: "child", parentId: "root", dependencies: [] }
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
expect(flattenTasks(tree)).toHaveLength(2)
|
|
93
|
+
expect(flattenTasks(tree, new Set(["root"]))).toHaveLength(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("computes a day scale and Appendix A layout", () => {
|
|
97
|
+
const scale = computeTimeScale(new Date(2026, 6, 1), new Date(2026, 6, 15), "day", 40, 1)
|
|
98
|
+
expect(scale).toHaveLength(21)
|
|
99
|
+
expect(formatDate(scale[0].start)).toBe("2026-06-29")
|
|
100
|
+
expect(formatDate(scale[20].end)).toBe("2026-07-19")
|
|
101
|
+
|
|
102
|
+
const layout = computeLayout(tasks, [], baseConfig)
|
|
103
|
+
expect(layout.ok).toBe(true)
|
|
104
|
+
if (layout.ok) {
|
|
105
|
+
expect(layout.data).toEqual([
|
|
106
|
+
{ taskId: "1", rowIndex: 0, left: 0, width: 200, top: 50, depth: 0, isCritical: false },
|
|
107
|
+
{ taskId: "2", rowIndex: 1, left: 200, width: 280, top: 86, depth: 0, isCritical: false },
|
|
108
|
+
{ taskId: "3", rowIndex: 2, left: 480, width: 120, top: 122, depth: 0, isCritical: false }
|
|
109
|
+
])
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("supports FS, SS, FF, and SF scheduling", () => {
|
|
114
|
+
const scheduled = scheduleByDependencies(
|
|
115
|
+
[
|
|
116
|
+
{ ...tasks[0], id: "a", actual: { start: "2026-07-01", end: "2026-07-05", progress: 0 } },
|
|
117
|
+
{ ...tasks[1], id: "b", actual: { start: "2026-07-01", end: "2026-07-03", progress: 0 }, dependencies: [] }
|
|
118
|
+
],
|
|
119
|
+
[{ id: "ss", sourceId: "a", targetId: "b", type: "SS", lag: 2, lagUnit: "calendar" }]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(scheduled.ok).toBe(true)
|
|
123
|
+
if (scheduled.ok) {
|
|
124
|
+
expect(scheduled.data.find((task) => task.id === "b")?.actual.start).toEqual(new Date(2026, 6, 3))
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("computes downstream impact after dragging a task", () => {
|
|
129
|
+
const links = normalizeLinks(tasks)
|
|
130
|
+
const impact = computeImpact("1", { actualStart: "2026-07-03" }, tasks, links, baseConfig)
|
|
131
|
+
|
|
132
|
+
expect(impact.ok).toBe(true)
|
|
133
|
+
if (impact.ok) {
|
|
134
|
+
expect(impact.data.changed.map((item) => item.taskId)).toEqual(["1", "2", "3"])
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
})
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED