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,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
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "declarationMap": true,
6
+ "outDir": "dist",
7
+ "rootDir": "src"
8
+ },
9
+ "include": ["src"],
10
+ "exclude": ["dist", "tests"]
11
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "tsup"
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["esm", "cjs"],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ target: "es2022"
10
+ })
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["tests/**/*.test.ts"]
7
+ }
8
+ })