@startsimpli/ui 0.4.7 → 0.4.9

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.
Files changed (62) hide show
  1. package/package.json +21 -23
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/account/__tests__/account.test.tsx +315 -0
  4. package/src/components/command-palette/CommandGroup.tsx +23 -0
  5. package/src/components/command-palette/CommandPalette.tsx +183 -200
  6. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  7. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  8. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  9. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  10. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  11. package/src/components/command-palette/index.ts +6 -0
  12. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  13. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  14. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  15. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  16. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  17. package/src/components/dashboard/index.ts +6 -0
  18. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  19. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  20. package/src/components/dialog/index.ts +3 -0
  21. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  22. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  23. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  24. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  25. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  26. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  27. package/src/components/email-editor/editor-sidebar.tsx +6 -731
  28. package/src/components/email-editor/email-editor.tsx +78 -467
  29. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  30. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  31. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  32. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  33. package/src/components/email-editor/index.ts +1 -0
  34. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  35. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  36. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  37. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  38. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  39. package/src/components/email-editor/panels/index.ts +3 -0
  40. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  41. package/src/components/gantt/GanttBoardView.tsx +71 -0
  42. package/src/components/gantt/GanttChart.tsx +134 -881
  43. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  44. package/src/components/gantt/GanttListView.tsx +63 -0
  45. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  46. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  47. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  48. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  49. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  50. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  51. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  52. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  53. package/src/components/gantt/index.ts +10 -0
  54. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  55. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  56. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  57. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  58. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  59. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  60. package/src/components/safe-html.tsx +9 -8
  61. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  62. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -0,0 +1,644 @@
1
+ import { useMemo, useState, useRef, useEffect, useCallback } from 'react'
2
+ import {
3
+ addDays,
4
+ differenceInDays,
5
+ format,
6
+ isWeekend,
7
+ isToday,
8
+ startOfDay,
9
+ endOfDay,
10
+ startOfMonth,
11
+ getMonth,
12
+ getWeek,
13
+ isAfter,
14
+ isBefore,
15
+ } from 'date-fns'
16
+ import type {
17
+ TimelineItem,
18
+ GanttTask,
19
+ GanttChartProps,
20
+ GanttFilterState,
21
+ GanttViewMode,
22
+ } from '../types'
23
+ import { parseDateRangeFromTitle, getHierarchyLevel } from '../lib/dates'
24
+ import { calculateExpectedProgress, calculateHealthStatus, getHealthColor } from '../lib/progress'
25
+
26
+ export const ROW_HEIGHT = 36
27
+ export const ZOOM_LEVELS = [8, 12, 16, 24, 32, 48]
28
+ const DEFAULT_ZOOM_INDEX = 3
29
+ export const INDENT_WIDTH = 20
30
+
31
+ const DEFAULT_CATEGORY_COLORS: Record<string, string> = {
32
+ financial: '#22c55e',
33
+ product: '#3b82f6',
34
+ team: '#a855f7',
35
+ market: '#f97316',
36
+ other: '#6b7280',
37
+ }
38
+
39
+ function defaultStatusToProgress(status: string): number {
40
+ if (status === 'completed') return 100
41
+ if (status === 'in_progress' || status === 'active' || status === 'on_track') return 50
42
+ return 0
43
+ }
44
+
45
+ const DEFAULT_FILTER_STATE: GanttFilterState = {
46
+ search: '',
47
+ statuses: [],
48
+ categories: [],
49
+ dateRange: { start: null, end: null },
50
+ }
51
+
52
+ interface DragState {
53
+ taskId: string
54
+ type: 'move' | 'resize-start' | 'resize-end'
55
+ startX: number
56
+ originalStart: Date
57
+ originalEnd: Date
58
+ currentStart: Date
59
+ currentEnd: Date
60
+ }
61
+
62
+ export function useGanttState({
63
+ items,
64
+ dependencies = [],
65
+ onItemClick,
66
+ onDateChange,
67
+ statusToProgress = defaultStatusToProgress,
68
+ categoryColors = DEFAULT_CATEGORY_COLORS,
69
+ hierarchical = true,
70
+ initialZoom = DEFAULT_ZOOM_INDEX,
71
+ showFilterBar = false,
72
+ initialViewMode = 'timeline',
73
+ boardStatuses,
74
+ onItemEdit,
75
+ onStatusChange,
76
+ persistCollapseKey,
77
+ }: GanttChartProps) {
78
+ const [zoomIndex, setZoomIndex] = useState(initialZoom)
79
+ const [collapsed, setCollapsed] = useState<Set<string>>(() => {
80
+ if (persistCollapseKey && typeof window !== 'undefined') {
81
+ try {
82
+ const saved = localStorage.getItem(`gantt-collapse-${persistCollapseKey}`)
83
+ if (saved) return new Set(JSON.parse(saved))
84
+ } catch { /* ignore */ }
85
+ }
86
+ return new Set()
87
+ })
88
+ const [mounted, setMounted] = useState(false)
89
+ const [dragState, setDragState] = useState<DragState | null>(null)
90
+ const [filters, setFilters] = useState<GanttFilterState>(DEFAULT_FILTER_STATE)
91
+ const [isFullscreen, setIsFullscreen] = useState(false)
92
+ const [viewMode, setViewMode] = useState<GanttViewMode>(initialViewMode)
93
+ const [detailItem, setDetailItem] = useState<TimelineItem | null>(null)
94
+ const [focusedRowIndex, setFocusedRowIndex] = useState(-1)
95
+ const [boardDragItem, setBoardDragItem] = useState<string | null>(null)
96
+
97
+ const dayWidth = ZOOM_LEVELS[zoomIndex]
98
+ const bodyScrollRef = useRef<HTMLDivElement>(null)
99
+ const headerTimelineRef = useRef<HTMLDivElement>(null)
100
+ const wrapperRef = useRef<HTMLDivElement>(null)
101
+ const infoColumnRef = useRef<HTMLDivElement>(null)
102
+
103
+ useEffect(() => { setMounted(true) }, [])
104
+
105
+ // Persist collapse state to localStorage
106
+ useEffect(() => {
107
+ if (persistCollapseKey && typeof window !== 'undefined') {
108
+ localStorage.setItem(`gantt-collapse-${persistCollapseKey}`, JSON.stringify([...collapsed]))
109
+ }
110
+ }, [collapsed, persistCollapseKey])
111
+
112
+ // Fullscreen change listener
113
+ useEffect(() => {
114
+ const handleFullscreenChange = () => {
115
+ setIsFullscreen(!!document.fullscreenElement)
116
+ }
117
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
118
+ return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
119
+ }, [])
120
+
121
+ const toggleFullscreen = useCallback(() => {
122
+ if (!wrapperRef.current) return
123
+ if (document.fullscreenElement) {
124
+ document.exitFullscreen()
125
+ } else {
126
+ wrapperRef.current.requestFullscreen()
127
+ }
128
+ }, [])
129
+
130
+ // Handle item click — open detail modal if onItemEdit is provided, otherwise delegate to onItemClick
131
+ const handleItemClick = useCallback((item: TimelineItem) => {
132
+ if (onItemEdit) {
133
+ setDetailItem(item)
134
+ }
135
+ onItemClick?.(item)
136
+ }, [onItemEdit, onItemClick])
137
+
138
+ // Extract unique statuses and categories from items for filter dropdowns
139
+ const { uniqueStatuses, uniqueCategories } = useMemo(() => {
140
+ const statuses = new Set<string>()
141
+ const categories = new Set<string>()
142
+ const collect = (itemList: TimelineItem[]) => {
143
+ for (const item of itemList) {
144
+ statuses.add(item.status)
145
+ if (item.category) categories.add(item.category)
146
+ if (item.children) collect(item.children)
147
+ }
148
+ }
149
+ collect(items)
150
+ return { uniqueStatuses: [...statuses].sort(), uniqueCategories: [...categories].sort() }
151
+ }, [items])
152
+
153
+ // Filter items
154
+ const filteredItems = useMemo(() => {
155
+ if (!showFilterBar) return items
156
+ const { search, statuses, categories, dateRange } = filters
157
+ const hasFilters = search || statuses.length > 0 || categories.length > 0 || dateRange.start || dateRange.end
158
+ if (!hasFilters) return items
159
+
160
+ const searchLower = search.toLowerCase()
161
+
162
+ const matchesItem = (item: TimelineItem): boolean => {
163
+ if (search && !item.title.toLowerCase().includes(searchLower) && !item.description?.toLowerCase().includes(searchLower)) return false
164
+ if (statuses.length > 0 && !statuses.includes(item.status)) return false
165
+ if (categories.length > 0 && (!item.category || !categories.includes(item.category))) return false
166
+ if (dateRange.start || dateRange.end) {
167
+ const itemEnd = item.endDate ? new Date(item.endDate) : null
168
+ const itemStart = item.startDate ? new Date(item.startDate) : null
169
+ if (dateRange.start && itemEnd && isBefore(itemEnd, dateRange.start)) return false
170
+ if (dateRange.end && itemStart && isAfter(itemStart, dateRange.end)) return false
171
+ }
172
+ return true
173
+ }
174
+
175
+ const filterTree = (itemList: TimelineItem[]): TimelineItem[] => {
176
+ const result: TimelineItem[] = []
177
+ for (const item of itemList) {
178
+ const filteredChildren = item.children ? filterTree(item.children) : undefined
179
+ if (matchesItem(item) || (filteredChildren && filteredChildren.length > 0)) {
180
+ result.push({ ...item, children: filteredChildren })
181
+ }
182
+ }
183
+ return result
184
+ }
185
+
186
+ return filterTree(items)
187
+ }, [items, filters, showFilterBar])
188
+
189
+ const hasActiveFilters = filters.search || filters.statuses.length > 0 || filters.categories.length > 0 || filters.dateRange.start || filters.dateRange.end
190
+
191
+ const toggleCollapse = useCallback((id: string) => {
192
+ setCollapsed((prev) => {
193
+ const next = new Set(prev)
194
+ if (next.has(id)) next.delete(id)
195
+ else next.add(id)
196
+ return next
197
+ })
198
+ }, [])
199
+
200
+ // Build hierarchy from children[] arrays or dependencies
201
+ const { childrenMap, parentMap, itemMap } = useMemo(() => {
202
+ const childrenMap = new Map<string, string[]>()
203
+ const parentMap = new Map<string, string>()
204
+ const itemMap = new Map(filteredItems.map((item) => [item.id, item]))
205
+
206
+ if (hierarchical) {
207
+ for (const item of filteredItems) {
208
+ if (item.children && item.children.length > 0) {
209
+ const childIds = item.children
210
+ .filter((child) => itemMap.has(child.id))
211
+ .map((child) => child.id)
212
+ if (childIds.length > 0) {
213
+ childrenMap.set(item.id, childIds)
214
+ for (const childId of childIds) {
215
+ parentMap.set(childId, item.id)
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ for (const dep of dependencies) {
222
+ if (dep.type === 'blocks') {
223
+ const parentItem = itemMap.get(dep.fromId)
224
+ const childItem = itemMap.get(dep.toId)
225
+ if (!parentItem || !childItem) continue
226
+ const parentLevel = getHierarchyLevel(parentItem.title)
227
+ const childLevel = getHierarchyLevel(childItem.title)
228
+ if (parentLevel > childLevel && !parentMap.has(dep.toId)) {
229
+ const children = childrenMap.get(dep.fromId) || []
230
+ children.push(dep.toId)
231
+ childrenMap.set(dep.fromId, children)
232
+ parentMap.set(dep.toId, dep.fromId)
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ return { childrenMap, parentMap, itemMap }
239
+ }, [filteredItems, dependencies, hierarchical])
240
+
241
+ // Parse dates for all items
242
+ const itemDates = useMemo(() => {
243
+ const now = new Date()
244
+ const currentYear = now.getFullYear()
245
+ const dates = new Map<string, { start: Date; end: Date }>()
246
+
247
+ for (const item of filteredItems) {
248
+ const parsedRange = parseDateRangeFromTitle(item.title, currentYear)
249
+ if (parsedRange) { dates.set(item.id, parsedRange); continue }
250
+
251
+ const startStr = item.startDate
252
+ const endStr = item.endDate
253
+
254
+ if (startStr && endStr) {
255
+ const s = new Date(startStr)
256
+ const e = new Date(endStr)
257
+ if (!isNaN(s.getTime()) && !isNaN(e.getTime())) {
258
+ dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue
259
+ }
260
+ }
261
+
262
+ if (endStr) {
263
+ const e = new Date(endStr)
264
+ if (!isNaN(e.getTime())) {
265
+ const s = item.startDate ? new Date(item.startDate) :
266
+ item.createdAt ? new Date(item.createdAt) : addDays(e, -14)
267
+ dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue
268
+ }
269
+ }
270
+
271
+ if (startStr) {
272
+ const s = new Date(startStr)
273
+ if (!isNaN(s.getTime())) {
274
+ dates.set(item.id, { start: startOfDay(s), end: endOfDay(addDays(s, 14)) }); continue
275
+ }
276
+ }
277
+
278
+ const fallbackStart = item.createdAt ? new Date(item.createdAt) : now
279
+ dates.set(item.id, { start: startOfDay(fallbackStart), end: endOfDay(addDays(fallbackStart, 14)) })
280
+ }
281
+
282
+ return dates
283
+ }, [filteredItems])
284
+
285
+ // Build flattened task tree
286
+ const tasks = useMemo(() => {
287
+ const roots: string[] = []
288
+ for (const item of filteredItems) {
289
+ const parentId = parentMap.get(item.id)
290
+ if (!parentId || !itemMap.has(parentId)) roots.push(item.id)
291
+ }
292
+
293
+ const sortByDate = (ids: string[]) => [...ids].sort((a, b) => {
294
+ const dateA = itemDates.get(a)?.start || new Date()
295
+ const dateB = itemDates.get(b)?.start || new Date()
296
+ return dateA.getTime() - dateB.getTime()
297
+ })
298
+
299
+ const taskList: GanttTask[] = []
300
+ const visited = new Set<string>()
301
+
302
+ const traverse = (id: string, depth: number) => {
303
+ if (visited.has(id)) return
304
+ visited.add(id)
305
+ const item = itemMap.get(id)
306
+ if (!item) return
307
+ const dates = itemDates.get(id)
308
+ if (!dates) return
309
+
310
+ const children = childrenMap.get(id) || []
311
+ const hasChildren = children.length > 0
312
+ const progress = item.progress ?? statusToProgress(item.status)
313
+
314
+ const now = new Date()
315
+ const taskStart = startOfDay(dates.start)
316
+ const taskEnd = endOfDay(dates.end)
317
+ const totalDuration = taskEnd.getTime() - taskStart.getTime()
318
+ const elapsed = now.getTime() - taskStart.getTime()
319
+ const timeProgress = totalDuration > 0
320
+ ? Math.max(0, Math.min(100, (elapsed / totalDuration) * 100))
321
+ : 0
322
+
323
+ const expectedProgress = calculateExpectedProgress(taskStart, taskEnd, now)
324
+ const healthResult = calculateHealthStatus(progress, expectedProgress, item.status === 'blocked')
325
+
326
+ taskList.push({
327
+ item, start: taskStart, end: taskEnd, progress, timeProgress,
328
+ depth, hasChildren, parentId: parentMap.get(id) || null,
329
+ healthStatus: healthResult.status,
330
+ })
331
+
332
+ if (hasChildren && !collapsed.has(id)) {
333
+ for (const childId of sortByDate(children)) traverse(childId, depth + 1)
334
+ }
335
+ }
336
+
337
+ for (const rootId of sortByDate(roots)) traverse(rootId, 0)
338
+ return taskList
339
+ }, [filteredItems, itemDates, itemMap, childrenMap, parentMap, collapsed, statusToProgress])
340
+
341
+ // Keyboard navigation
342
+ useEffect(() => {
343
+ const wrapper = wrapperRef.current
344
+ if (!wrapper) return
345
+
346
+ const handleKeyDown = (e: KeyboardEvent) => {
347
+ if (!wrapper.contains(document.activeElement) && document.activeElement !== wrapper) return
348
+
349
+ if (e.key === 'ArrowDown') {
350
+ e.preventDefault()
351
+ setFocusedRowIndex((prev) => Math.min(prev + 1, tasks.length - 1))
352
+ } else if (e.key === 'ArrowUp') {
353
+ e.preventDefault()
354
+ setFocusedRowIndex((prev) => Math.max(prev - 1, 0))
355
+ } else if ((e.key === 'Enter' || e.key === ' ') && focusedRowIndex >= 0 && focusedRowIndex < tasks.length) {
356
+ e.preventDefault()
357
+ handleItemClick(tasks[focusedRowIndex].item)
358
+ } else if (e.key === 'Escape') {
359
+ if (detailItem) {
360
+ setDetailItem(null)
361
+ } else {
362
+ setFocusedRowIndex(-1)
363
+ wrapper.blur()
364
+ }
365
+ } else if (e.key === 'ArrowRight' && focusedRowIndex >= 0) {
366
+ const task = tasks[focusedRowIndex]
367
+ if (task?.hasChildren && collapsed.has(task.item.id)) {
368
+ toggleCollapse(task.item.id)
369
+ }
370
+ } else if (e.key === 'ArrowLeft' && focusedRowIndex >= 0) {
371
+ const task = tasks[focusedRowIndex]
372
+ if (task?.hasChildren && !collapsed.has(task.item.id)) {
373
+ toggleCollapse(task.item.id)
374
+ }
375
+ }
376
+ }
377
+
378
+ wrapper.addEventListener('keydown', handleKeyDown)
379
+ return () => wrapper.removeEventListener('keydown', handleKeyDown)
380
+ }, [tasks, focusedRowIndex, collapsed, detailItem, handleItemClick, toggleCollapse])
381
+
382
+ // Scroll focused row into view
383
+ useEffect(() => {
384
+ if (focusedRowIndex >= 0 && infoColumnRef.current) {
385
+ const row = infoColumnRef.current.children[focusedRowIndex] as HTMLElement
386
+ row?.scrollIntoView({ block: 'nearest' })
387
+ }
388
+ }, [focusedRowIndex])
389
+
390
+ // Calculate date range
391
+ const { startDate, days } = useMemo(() => {
392
+ const now = new Date()
393
+ const currentYear = now.getFullYear()
394
+ let minDate = new Date(currentYear, 0, 1)
395
+ let maxDate = new Date(currentYear, 11, 31)
396
+
397
+ for (const task of tasks) {
398
+ if (task.start < minDate) minDate = startOfDay(task.start)
399
+ if (task.end > maxDate) maxDate = endOfDay(task.end)
400
+ }
401
+
402
+ minDate = startOfMonth(minDate)
403
+ maxDate = endOfDay(addDays(maxDate, 30))
404
+ const dayCount = differenceInDays(maxDate, minDate) + 1
405
+ const daysArr: Date[] = []
406
+ for (let i = 0; i < dayCount; i++) daysArr.push(addDays(minDate, i))
407
+ return { startDate: minDate, days: daysArr }
408
+ }, [tasks])
409
+
410
+ // Group days by month
411
+ const months = useMemo(() => {
412
+ const monthsArr: { month: Date; days: number; label: string }[] = []
413
+ let currentMonth = -1
414
+ let monthDays = 0
415
+
416
+ for (const day of days) {
417
+ const m = getMonth(day)
418
+ if (m !== currentMonth) {
419
+ if (currentMonth !== -1) {
420
+ monthsArr.push({ month: addDays(day, -1), days: monthDays, label: format(addDays(day, -1), 'MMM yyyy') })
421
+ }
422
+ currentMonth = m
423
+ monthDays = 1
424
+ } else { monthDays++ }
425
+ }
426
+ if (monthDays > 0 && days.length > 0) {
427
+ monthsArr.push({ month: days[days.length - 1], days: monthDays, label: format(days[days.length - 1], 'MMM yyyy') })
428
+ }
429
+ return monthsArr
430
+ }, [days])
431
+
432
+ // Time header units
433
+ const timeHeaderUnits = useMemo(() => {
434
+ if (dayWidth >= 16) {
435
+ return {
436
+ mode: 'days' as const,
437
+ units: days.map((day, i) => ({
438
+ date: day, width: dayWidth,
439
+ label: day.getDate() === 1 || i === 0 ? format(day, 'd') : String(day.getDate()),
440
+ isWeekend: isWeekend(day), isToday: mounted && isToday(day),
441
+ })),
442
+ }
443
+ } else if (dayWidth >= 12) {
444
+ return {
445
+ mode: 'sparse-days' as const,
446
+ units: days.map((day, i) => ({
447
+ date: day, width: dayWidth,
448
+ label: (day.getDay() === 1 || day.getDate() === 1 || i === 0) ? String(day.getDate()) : '',
449
+ isWeekend: isWeekend(day), isToday: mounted && isToday(day),
450
+ })),
451
+ }
452
+ } else {
453
+ const weeks: { startDate: Date; endDate: Date; days: number; label: string; hasToday: boolean }[] = []
454
+ let currentWeek = -1, weekDays = 0, weekStart: Date | null = null, weekHasToday = false
455
+
456
+ for (const day of days) {
457
+ const week = getWeek(day)
458
+ if (week !== currentWeek) {
459
+ if (currentWeek !== -1 && weekStart) {
460
+ weeks.push({ startDate: weekStart, endDate: addDays(day, -1), days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday })
461
+ }
462
+ currentWeek = week; weekDays = 1; weekStart = day; weekHasToday = mounted && isToday(day)
463
+ } else {
464
+ weekDays++
465
+ if (mounted && isToday(day)) weekHasToday = true
466
+ }
467
+ }
468
+ if (weekDays > 0 && weekStart) {
469
+ weeks.push({ startDate: weekStart, endDate: days[days.length - 1], days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday })
470
+ }
471
+ return { mode: 'weeks' as const, units: weeks.map((w) => ({ ...w, width: w.days * dayWidth })) }
472
+ }
473
+ }, [days, dayWidth, mounted])
474
+
475
+ // Scroll to today on mount
476
+ useEffect(() => {
477
+ if (bodyScrollRef.current) {
478
+ const today = new Date()
479
+ const daysFromStart = differenceInDays(today, startDate)
480
+ bodyScrollRef.current.scrollLeft = Math.max(0, daysFromStart * dayWidth - 200)
481
+ }
482
+ }, [startDate, dayWidth])
483
+
484
+ const handleBodyScroll = useCallback(() => {
485
+ if (bodyScrollRef.current && headerTimelineRef.current) {
486
+ headerTimelineRef.current.scrollLeft = bodyScrollRef.current.scrollLeft
487
+ }
488
+ }, [])
489
+
490
+ const getBarPosition = useCallback((task: GanttTask, rowIndex: number, useDragDates = false) => {
491
+ const dates = useDragDates && dragState?.taskId === task.item.id
492
+ ? { start: dragState.currentStart, end: dragState.currentEnd }
493
+ : { start: task.start, end: task.end }
494
+ const startOffset = differenceInDays(dates.start, startDate)
495
+ const duration = differenceInDays(dates.end, dates.start) + 1
496
+ const heights = [22, 20, 18, 16]
497
+ const barHeight = heights[Math.min(task.depth, 3)]
498
+ const topOffset = (ROW_HEIGHT - barHeight) / 2
499
+ return { left: startOffset * dayWidth, width: Math.max(duration * dayWidth - 2, 16), top: rowIndex * ROW_HEIGHT + topOffset, height: barHeight }
500
+ }, [dragState, startDate, dayWidth])
501
+
502
+ // Drag handlers
503
+ const handleDragStart = useCallback((e: React.MouseEvent, task: GanttTask, type: 'move' | 'resize-start' | 'resize-end') => {
504
+ if (!onDateChange) return
505
+ e.preventDefault(); e.stopPropagation()
506
+ setDragState({ taskId: task.item.id, type, startX: e.clientX, originalStart: task.start, originalEnd: task.end, currentStart: task.start, currentEnd: task.end })
507
+ }, [onDateChange])
508
+
509
+ useEffect(() => {
510
+ if (!dragState) return
511
+ const handleMouseMove = (e: MouseEvent) => {
512
+ const deltaDays = Math.round((e.clientX - dragState.startX) / dayWidth)
513
+ if (deltaDays === 0 && dragState.currentStart.getTime() === dragState.originalStart.getTime()) return
514
+ let newStart = dragState.originalStart, newEnd = dragState.originalEnd
515
+ if (dragState.type === 'move') { newStart = addDays(dragState.originalStart, deltaDays); newEnd = addDays(dragState.originalEnd, deltaDays) }
516
+ else if (dragState.type === 'resize-start') { newStart = addDays(dragState.originalStart, deltaDays); if (newStart >= newEnd) newStart = addDays(newEnd, -1) }
517
+ else if (dragState.type === 'resize-end') { newEnd = addDays(dragState.originalEnd, deltaDays); if (newEnd <= newStart) newEnd = addDays(newStart, 1) }
518
+ setDragState((prev) => prev ? { ...prev, currentStart: newStart, currentEnd: newEnd } : null)
519
+ }
520
+ const handleMouseUp = () => {
521
+ if (dragState && onDateChange) {
522
+ const startChanged = dragState.currentStart.getTime() !== dragState.originalStart.getTime()
523
+ const endChanged = dragState.currentEnd.getTime() !== dragState.originalEnd.getTime()
524
+ if (startChanged || endChanged) onDateChange(dragState.taskId, dragState.currentStart, dragState.currentEnd)
525
+ }
526
+ setDragState(null)
527
+ }
528
+ document.addEventListener('mousemove', handleMouseMove)
529
+ document.addEventListener('mouseup', handleMouseUp)
530
+ return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp) }
531
+ }, [dragState, dayWidth, onDateChange])
532
+
533
+ // Dependency arrow paths
534
+ const dependencyPaths = useMemo(() => {
535
+ const paths: Array<{ path: string; fromId: string; toId: string }> = []
536
+ const taskIndexMap = new Map<string, number>()
537
+ tasks.forEach((task, index) => taskIndexMap.set(task.item.id, index))
538
+
539
+ for (const dep of dependencies) {
540
+ const fromIdx = taskIndexMap.get(dep.fromId)
541
+ const toIdx = taskIndexMap.get(dep.toId)
542
+ if (fromIdx !== undefined && toIdx !== undefined) {
543
+ const fromPos = getBarPosition(tasks[fromIdx], fromIdx)
544
+ const toPos = getBarPosition(tasks[toIdx], toIdx)
545
+ const x1 = fromPos.left + fromPos.width, y1 = fromPos.top + fromPos.height / 2
546
+ const x2 = toPos.left, y2 = toPos.top + toPos.height / 2
547
+ const midX = (x1 + x2) / 2
548
+ paths.push({ path: `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`, fromId: dep.fromId, toId: dep.toId })
549
+ }
550
+ }
551
+ return paths
552
+ }, [tasks, dependencies, getBarPosition])
553
+
554
+ const timelineWidth = days.length * dayWidth
555
+ const bodyHeight = tasks.length * ROW_HEIGHT
556
+
557
+ // Board view: group items by status
558
+ const boardColumns = useMemo(() => {
559
+ if (viewMode !== 'board') return []
560
+ const statuses = boardStatuses || uniqueStatuses
561
+ const columns: Array<{ status: string; items: TimelineItem[] }> = statuses.map((s) => ({ status: s, items: [] }))
562
+ const statusSet = new Set(statuses)
563
+
564
+ const flatItems = (itemList: TimelineItem[]): TimelineItem[] => {
565
+ const result: TimelineItem[] = []
566
+ for (const item of itemList) {
567
+ result.push(item)
568
+ if (item.children) result.push(...flatItems(item.children))
569
+ }
570
+ return result
571
+ }
572
+
573
+ for (const item of flatItems(filteredItems)) {
574
+ if (statusSet.has(item.status)) {
575
+ columns.find((c) => c.status === item.status)?.items.push(item)
576
+ }
577
+ }
578
+ return columns
579
+ }, [viewMode, filteredItems, boardStatuses, uniqueStatuses])
580
+
581
+ // List view: flatten all items
582
+ const listItems = useMemo(() => {
583
+ if (viewMode !== 'list') return []
584
+ return tasks.map((t) => t)
585
+ }, [viewMode, tasks])
586
+
587
+ return {
588
+ // State
589
+ zoomIndex,
590
+ setZoomIndex,
591
+ collapsed,
592
+ mounted,
593
+ dragState,
594
+ filters,
595
+ setFilters,
596
+ isFullscreen,
597
+ viewMode,
598
+ setViewMode,
599
+ detailItem,
600
+ setDetailItem,
601
+ focusedRowIndex,
602
+ boardDragItem,
603
+ setBoardDragItem,
604
+
605
+ // Derived values
606
+ dayWidth,
607
+ hasActiveFilters,
608
+ uniqueStatuses,
609
+ uniqueCategories,
610
+ filteredItems,
611
+ tasks,
612
+ startDate,
613
+ days,
614
+ months,
615
+ timeHeaderUnits,
616
+ dependencyPaths,
617
+ timelineWidth,
618
+ bodyHeight,
619
+ boardColumns,
620
+ listItems,
621
+
622
+ // Refs
623
+ bodyScrollRef,
624
+ headerTimelineRef,
625
+ wrapperRef,
626
+ infoColumnRef,
627
+
628
+ // Handlers
629
+ toggleFullscreen,
630
+ handleItemClick,
631
+ toggleCollapse,
632
+ handleBodyScroll,
633
+ getBarPosition,
634
+ handleDragStart,
635
+
636
+ // Props pass-through (needed by the render shell)
637
+ categoryColors,
638
+ onDateChange,
639
+ onItemEdit,
640
+ onStatusChange,
641
+ }
642
+ }
643
+
644
+ export type UseGanttStateReturn = ReturnType<typeof useGanttState>
@@ -1,4 +1,14 @@
1
1
  export { GanttChart } from './GanttChart'
2
+ export { GanttTimelineView } from './GanttTimelineView'
3
+ export type { GanttTimelineViewProps } from './GanttTimelineView'
4
+ export { GanttBoardView } from './GanttBoardView'
5
+ export type { GanttBoardViewProps } from './GanttBoardView'
6
+ export { GanttListView } from './GanttListView'
7
+ export type { GanttListViewProps } from './GanttListView'
8
+ export { GanttFilterBar } from './GanttFilterBar'
9
+ export type { GanttFilterBarProps } from './GanttFilterBar'
10
+ export { useGanttState } from './hooks/useGanttState'
11
+ export type { UseGanttStateReturn } from './hooks/useGanttState'
2
12
  export type {
3
13
  TimelineItem,
4
14
  TimelineDependency,