@tuturuuu/ui 0.13.0 → 0.16.0

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 (96) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -1,86 +1,160 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef } from 'react';
3
+ import type {
4
+ DragMoveEvent,
5
+ DragOverEvent,
6
+ DragStartEvent,
7
+ } from '@dnd-kit/core';
8
+ import { useCallback, useEffect, useRef } from 'react';
9
+
10
+ export const KANBAN_EDGE_AUTO_SCROLL_THRESHOLD = 100;
11
+ const KANBAN_EDGE_AUTO_SCROLL_SPEED = 10;
12
+ const KANBAN_EDGE_AUTO_SCROLL_MAX_SPEED = 30;
13
+
14
+ type KanbanDragAutoScrollEvent = DragMoveEvent | DragOverEvent | DragStartEvent;
15
+
16
+ interface HorizontalRect {
17
+ left: number;
18
+ right: number;
19
+ }
20
+
21
+ function getTouchListClientX(touches: unknown) {
22
+ if (!touches || typeof touches !== 'object') return null;
23
+
24
+ const list = touches as ArrayLike<{ clientX?: unknown }> & {
25
+ item?: (index: number) => { clientX?: unknown } | null;
26
+ };
27
+ const touch = list.item?.(0) ?? list[0];
28
+
29
+ return typeof touch?.clientX === 'number' ? touch.clientX : null;
30
+ }
31
+
32
+ function getPointerEventClientX(event: Event) {
33
+ if ('clientX' in event && typeof event.clientX === 'number') {
34
+ return event.clientX;
35
+ }
36
+
37
+ if ('touches' in event) {
38
+ const touchX = getTouchListClientX(event.touches);
39
+ if (touchX !== null) return touchX;
40
+ }
41
+
42
+ if ('changedTouches' in event) {
43
+ return getTouchListClientX(event.changedTouches);
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ function getDragEventDeltaX(event: KanbanDragAutoScrollEvent) {
50
+ return 'delta' in event && typeof event.delta?.x === 'number'
51
+ ? event.delta.x
52
+ : 0;
53
+ }
54
+
55
+ function getDragActiveCenterX(event: KanbanDragAutoScrollEvent) {
56
+ const activeRect =
57
+ event.active.rect.current.translated ?? event.active.rect.current.initial;
58
+
59
+ return activeRect ? activeRect.left + activeRect.width / 2 : null;
60
+ }
61
+
62
+ export function getKanbanDragAutoScrollPointerX(
63
+ event: KanbanDragAutoScrollEvent
64
+ ) {
65
+ const pointerStartX = getPointerEventClientX(event.activatorEvent);
66
+
67
+ if (pointerStartX !== null) {
68
+ return pointerStartX + getDragEventDeltaX(event);
69
+ }
70
+
71
+ return getDragActiveCenterX(event);
72
+ }
73
+
74
+ export function getKanbanEdgeAutoScrollAmount(
75
+ pointerX: number | null,
76
+ rect: HorizontalRect,
77
+ options: {
78
+ maxSpeed?: number;
79
+ speed?: number;
80
+ threshold?: number;
81
+ } = {}
82
+ ) {
83
+ if (pointerX === null) return 0;
84
+
85
+ const threshold = options.threshold ?? KANBAN_EDGE_AUTO_SCROLL_THRESHOLD;
86
+ const speed = options.speed ?? KANBAN_EDGE_AUTO_SCROLL_SPEED;
87
+ const maxSpeed = options.maxSpeed ?? KANBAN_EDGE_AUTO_SCROLL_MAX_SPEED;
88
+
89
+ if (pointerX <= rect.left + threshold) {
90
+ const distanceFromLeft = Math.max(0, pointerX - rect.left);
91
+ const intensity = 1 - distanceFromLeft / threshold;
92
+ return -Math.min(speed + intensity * (maxSpeed - speed), maxSpeed);
93
+ }
94
+
95
+ if (pointerX >= rect.right - threshold) {
96
+ const distanceFromRight = Math.max(0, rect.right - pointerX);
97
+ const intensity = 1 - distanceFromRight / threshold;
98
+ return Math.min(speed + intensity * (maxSpeed - speed), maxSpeed);
99
+ }
100
+
101
+ return 0;
102
+ }
4
103
 
5
104
  export function useAutoScroll(
6
- isDraggingRef: React.MutableRefObject<boolean>,
7
105
  scrollContainerRef: React.RefObject<HTMLDivElement | null>
8
106
  ) {
9
107
  const autoScrollRafRef = useRef<number | null>(null);
108
+ const isAutoScrollActiveRef = useRef(false);
109
+ const pointerXRef = useRef<number | null>(null);
10
110
 
11
- useEffect(() => {
12
- const EDGE_THRESHOLD = 100; // Distance from edge to trigger scroll (px)
13
- const SCROLL_SPEED = 10; // Base scroll speed (px per frame)
14
- const MAX_SCROLL_SPEED = 30; // Maximum scroll speed (px per frame)
111
+ const stopAutoScroll = useCallback(() => {
112
+ isAutoScrollActiveRef.current = false;
113
+ pointerXRef.current = null;
15
114
 
16
- let currentPointerX = 0;
115
+ if (autoScrollRafRef.current !== null) {
116
+ cancelAnimationFrame(autoScrollRafRef.current);
117
+ autoScrollRafRef.current = null;
118
+ }
119
+ }, []);
120
+
121
+ const autoScroll = useCallback(() => {
122
+ if (!isAutoScrollActiveRef.current || !scrollContainerRef.current) {
123
+ autoScrollRafRef.current = null;
124
+ return;
125
+ }
126
+
127
+ const container = scrollContainerRef.current;
128
+ const scrollAmount = getKanbanEdgeAutoScrollAmount(
129
+ pointerXRef.current,
130
+ container.getBoundingClientRect()
131
+ );
17
132
 
18
- function handlePointerMove(event: PointerEvent) {
19
- currentPointerX = event.clientX;
133
+ if (scrollAmount !== 0) {
134
+ container.scrollLeft += scrollAmount;
20
135
  }
21
136
 
22
- function autoScroll() {
23
- if (!isDraggingRef.current || !scrollContainerRef.current) {
24
- autoScrollRafRef.current = null;
25
- return;
26
- }
27
-
28
- const container = scrollContainerRef.current;
29
- const rect = container.getBoundingClientRect();
30
-
31
- // Calculate distance from edges
32
- const distanceFromLeft = currentPointerX - rect.left;
33
- const distanceFromRight = rect.right - currentPointerX;
34
-
35
- let scrollAmount = 0;
36
-
37
- // Scroll left when near left edge
38
- if (distanceFromLeft < EDGE_THRESHOLD && distanceFromLeft > 0) {
39
- const intensity = 1 - distanceFromLeft / EDGE_THRESHOLD;
40
- scrollAmount = -Math.min(
41
- SCROLL_SPEED + intensity * (MAX_SCROLL_SPEED - SCROLL_SPEED),
42
- MAX_SCROLL_SPEED
43
- );
44
- }
45
- // Scroll right when near right edge
46
- else if (distanceFromRight < EDGE_THRESHOLD && distanceFromRight > 0) {
47
- const intensity = 1 - distanceFromRight / EDGE_THRESHOLD;
48
- scrollAmount = Math.min(
49
- SCROLL_SPEED + intensity * (MAX_SCROLL_SPEED - SCROLL_SPEED),
50
- MAX_SCROLL_SPEED
51
- );
52
- }
53
-
54
- // Apply scroll if needed
55
- if (scrollAmount !== 0) {
56
- container.scrollLeft += scrollAmount;
57
- }
58
-
59
- // Continue the animation loop
137
+ autoScrollRafRef.current = requestAnimationFrame(autoScroll);
138
+ }, [scrollContainerRef]);
139
+
140
+ const startAutoScroll = useCallback(() => {
141
+ isAutoScrollActiveRef.current = true;
142
+
143
+ if (autoScrollRafRef.current === null) {
60
144
  autoScrollRafRef.current = requestAnimationFrame(autoScroll);
61
145
  }
146
+ }, [autoScroll]);
147
+
148
+ const updateAutoScrollPointerX = useCallback((pointerX: number | null) => {
149
+ pointerXRef.current = pointerX;
150
+ }, []);
151
+
152
+ useEffect(() => stopAutoScroll, [stopAutoScroll]);
62
153
 
63
- // Start auto-scroll loop when dragging starts (triggered by isDraggingRef)
64
- window.addEventListener('pointermove', handlePointerMove);
65
-
66
- // Watch for drag start to initialize auto-scroll loop
67
- const startAutoScrollLoop = () => {
68
- if (isDraggingRef.current && !autoScrollRafRef.current) {
69
- autoScrollRafRef.current = requestAnimationFrame(autoScroll);
70
- }
71
- };
72
-
73
- // Poll for drag start (alternative: trigger from onDragStart)
74
- const pollInterval = setInterval(startAutoScrollLoop, 100);
75
-
76
- return () => {
77
- window.removeEventListener('pointermove', handlePointerMove);
78
- clearInterval(pollInterval);
79
- if (autoScrollRafRef.current) {
80
- cancelAnimationFrame(autoScrollRafRef.current);
81
- }
82
- };
83
- }, [isDraggingRef, scrollContainerRef]);
84
-
85
- return { autoScrollRafRef };
154
+ return {
155
+ autoScrollRafRef,
156
+ startAutoScroll,
157
+ stopAutoScroll,
158
+ updateAutoScrollPointerX,
159
+ };
86
160
  }
@@ -20,6 +20,7 @@ vi.mock('@tuturuuu/utils/task-helper', () => {
20
20
 
21
21
  import {
22
22
  applyTaskDropPreviewToCache,
23
+ getPersonalPlacementTargetBoardId,
23
24
  getProjectedTaskDropOrderFromPreview,
24
25
  getTaskDropEndPreviewFromRects,
25
26
  getTaskDropPositionFromRects,
@@ -178,6 +179,53 @@ describe('usesPersonalPlacement', () => {
178
179
  });
179
180
  });
180
181
 
182
+ describe('getPersonalPlacementTargetBoardId', () => {
183
+ it('uses the target list board id for real-list moves', () => {
184
+ expect(
185
+ getPersonalPlacementTargetBoardId({
186
+ boardId: 'fallback-board',
187
+ columns: [
188
+ createList({
189
+ board_id: 'canonical-board',
190
+ id: 'target-list',
191
+ } as Partial<TaskList>),
192
+ ],
193
+ targetListId: 'target-list',
194
+ })
195
+ ).toBe('canonical-board');
196
+ });
197
+
198
+ it('keeps staging board ids from virtual staging lanes', () => {
199
+ expect(
200
+ getPersonalPlacementTargetBoardId({
201
+ boardId: 'fallback-board',
202
+ columns: [
203
+ createList({
204
+ board_id: 'canonical-board',
205
+ id: 'target-list',
206
+ } as Partial<TaskList>),
207
+ ],
208
+ targetListId: 'personal-external-staging:staging-board',
209
+ })
210
+ ).toBe('staging-board');
211
+ });
212
+
213
+ it('falls back to the current board id when list metadata is absent', () => {
214
+ expect(
215
+ getPersonalPlacementTargetBoardId({
216
+ boardId: 'fallback-board',
217
+ columns: [
218
+ createList({
219
+ board_id: 'other-board',
220
+ id: 'other-list',
221
+ } as Partial<TaskList>),
222
+ ],
223
+ targetListId: 'target-list',
224
+ })
225
+ ).toBe('fallback-board');
226
+ });
227
+ });
228
+
181
229
  describe('mergeTaskIntoBoardTaskCache', () => {
182
230
  it('updates an existing task in the board cache', () => {
183
231
  const existingTask = createTask({ list_id: 'source-list' });
@@ -25,7 +25,7 @@ import { isPersonalExternalOverlayTask } from '../../../../../../../lib/task-per
25
25
  import { useBoardBroadcast } from '../../../../shared/board-broadcast-context';
26
26
  import { invalidateKanbanDeadlineTasks } from '../data/kanban-deadline-query';
27
27
  import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
28
- import { useAutoScroll } from './auto-scroll';
28
+ import { getKanbanDragAutoScrollPointerX, useAutoScroll } from './auto-scroll';
29
29
  import { getColumnReorderUpdates } from './column-reorder';
30
30
  import { calculateSortKeyWithRetry as createCalculateSortKeyWithRetry } from './kanban-sort-helpers';
31
31
  import {
@@ -126,6 +126,23 @@ export function usesPersonalPlacement(task: Task) {
126
126
  return isPersonalExternalOverlayTask(task);
127
127
  }
128
128
 
129
+ export function getPersonalPlacementTargetBoardId({
130
+ boardId,
131
+ columns,
132
+ targetListId,
133
+ }: {
134
+ boardId: string | null;
135
+ columns: Pick<TaskList, 'board_id' | 'id'>[];
136
+ targetListId: string;
137
+ }) {
138
+ const stagingBoardId = getPersonalExternalStagingBoardId(targetListId);
139
+ if (stagingBoardId) return stagingBoardId;
140
+
141
+ return (
142
+ columns.find((column) => column.id === targetListId)?.board_id ?? boardId
143
+ );
144
+ }
145
+
129
146
  function getTaskDropPosition(
130
147
  event: DragMoveEvent | DragEndEvent,
131
148
  pointerY?: number | null,
@@ -442,8 +459,20 @@ export function useKanbanDnd({
442
459
  }
443
460
 
444
461
  const stagingBoardId = getPersonalExternalStagingBoardId(targetListId);
445
- const targetBoardId = stagingBoardId ?? boardId;
462
+ const targetBoardId = getPersonalPlacementTargetBoardId({
463
+ boardId,
464
+ columns,
465
+ targetListId,
466
+ });
467
+ if (!targetBoardId) {
468
+ throw new Error('Board ID is required');
469
+ }
446
470
  const isStagingTarget = stagingBoardId !== null;
471
+ const targetList = columns.find((column) => column.id === targetListId);
472
+ const terminalStatus =
473
+ targetList?.status === 'done' || targetList?.status === 'closed'
474
+ ? targetList.status
475
+ : undefined;
447
476
  const nextTask = {
448
477
  ...task,
449
478
  list_id: isStagingTarget
@@ -483,6 +512,7 @@ export function useKanbanDnd({
483
512
  personal_sort_key: newSortKey,
484
513
  previous_task_id: order?.previousTaskId ?? null,
485
514
  next_task_id: order?.nextTaskId ?? null,
515
+ terminal_status: terminalStatus,
486
516
  });
487
517
 
488
518
  if (isStagingTarget) {
@@ -540,7 +570,7 @@ export function useKanbanDnd({
540
570
  throw error;
541
571
  }
542
572
  },
543
- [boardId, queryClient]
573
+ [boardId, columns, queryClient]
544
574
  );
545
575
 
546
576
  // Use the extracted calculateSortKeyWithRetry helper
@@ -566,8 +596,15 @@ export function useKanbanDnd({
566
596
  [wsId]
567
597
  );
568
598
 
569
- // Initialize auto-scroll
570
- useAutoScroll(isDraggingRef, scrollContainerRef);
599
+ const { startAutoScroll, stopAutoScroll, updateAutoScrollPointerX } =
600
+ useAutoScroll(scrollContainerRef);
601
+
602
+ const updateAutoScrollFromDragEvent = useCallback(
603
+ (event: DragMoveEvent | DragOverEvent | DragStartEvent) => {
604
+ updateAutoScrollPointerX(getKanbanDragAutoScrollPointerX(event));
605
+ },
606
+ [updateAutoScrollPointerX]
607
+ );
571
608
 
572
609
  const resetDragState = useCallback(
573
610
  (clearOptimisticUpdates = false) => {
@@ -582,12 +619,13 @@ export function useKanbanDnd({
582
619
  dragSessionMetricsRef.current = null;
583
620
  dragStartTaskIndexesByListRef.current.clear();
584
621
  isDraggingRef.current = false;
622
+ stopAutoScroll();
585
623
 
586
624
  if (clearOptimisticUpdates) {
587
625
  setOptimisticUpdateInProgress(new Set());
588
626
  }
589
627
  },
590
- [setDragPreviewPosition]
628
+ [setDragPreviewPosition, stopAutoScroll]
591
629
  );
592
630
 
593
631
  const markTaskIdsPending = useCallback((taskIds: string[]) => {
@@ -606,6 +644,8 @@ export function useKanbanDnd({
606
644
 
607
645
  const processTaskDragPreview = useCallback(
608
646
  (event: DragMoveEvent) => {
647
+ updateAutoScrollFromDragEvent(event);
648
+
609
649
  const { active, over } = event;
610
650
  const activeType = active.data?.current?.type;
611
651
  if (!activeType) return;
@@ -730,6 +770,7 @@ export function useKanbanDnd({
730
770
  getDragPreviewForList,
731
771
  getDragPreviewForListSurface,
732
772
  setDragPreviewPosition,
773
+ updateAutoScrollFromDragEvent,
733
774
  wsId,
734
775
  ]
735
776
  );
@@ -742,6 +783,8 @@ export function useKanbanDnd({
742
783
 
743
784
  // Enable auto-scroll
744
785
  isDraggingRef.current = true;
786
+ updateAutoScrollFromDragEvent(event);
787
+ startAutoScroll();
745
788
 
746
789
  const { type } = active.data.current;
747
790
  if (type === 'Column') {
@@ -750,6 +793,7 @@ export function useKanbanDnd({
750
793
  }
751
794
  if (type === 'Task') {
752
795
  if (!wsId) {
796
+ resetDragState(true);
753
797
  return;
754
798
  }
755
799
 
@@ -79,6 +79,7 @@ interface KanbanColumnsProps {
79
79
  pin: SpecialTaskListPin,
80
80
  pinned: boolean
81
81
  ) => void;
82
+ onHoveredTaskListChange?: (listId: string | null) => void;
82
83
  readOnly?: boolean;
83
84
  }
84
85
 
@@ -156,6 +157,7 @@ export function KanbanColumns({
156
157
  onDeadlineSectionCollapsedChange,
157
158
  specialTaskListPins,
158
159
  onSpecialTaskListPinnedChange,
160
+ onHoveredTaskListChange,
159
161
  readOnly = false,
160
162
  }: KanbanColumnsProps) {
161
163
  const initialScrollAnchoredBoardRef = useRef<string | null>(null);
@@ -270,6 +272,7 @@ export function KanbanColumns({
270
272
  return (
271
273
  <div
272
274
  ref={boardRef}
275
+ onPointerLeave={() => onHoveredTaskListChange?.(null)}
273
276
  className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent relative flex h-full w-full snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain scroll-smooth"
274
277
  style={
275
278
  {
@@ -416,6 +419,9 @@ export function KanbanColumns({
416
419
  onSpecialTaskListPinnedChange?.('closed_tasks', pinned);
417
420
  }
418
421
  }}
422
+ onHoverTaskList={
423
+ list.is_external_staging ? undefined : onHoveredTaskListChange
424
+ }
419
425
  readOnly={readOnly}
420
426
  />
421
427
  );
@@ -104,6 +104,7 @@ interface Props {
104
104
  onBulkSelectionActiveChange?: (active: boolean) => void;
105
105
  canUseBoardAssignees?: boolean;
106
106
  assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
107
+ onHoveredTaskListChange?: (listId: string | null) => void;
107
108
  readOnly?: boolean;
108
109
  }
109
110
 
@@ -129,6 +130,7 @@ export function KanbanBoard({
129
130
  onBulkSelectionActiveChange,
130
131
  canUseBoardAssignees,
131
132
  assigneeMemberSource,
133
+ onHoveredTaskListChange,
132
134
  readOnly = false,
133
135
  }: Props) {
134
136
  const tCommon = useTranslations('common');
@@ -150,7 +152,6 @@ export function KanbanBoard({
150
152
  const [assigneeSearchQuery, setAssigneeSearchQuery] = useState('');
151
153
 
152
154
  // Refs
153
- const scrollContainerRef = useRef<HTMLDivElement>(null);
154
155
  const taskHeightsRef = useRef<Map<string, number>>(new Map());
155
156
  const boardRef = useRef<HTMLDivElement>(null);
156
157
 
@@ -412,7 +413,7 @@ export function KanbanBoard({
412
413
  ),
413
414
  reorderTaskMutation,
414
415
  taskHeightsRef,
415
- scrollContainerRef,
416
+ scrollContainerRef: boardRef,
416
417
  });
417
418
 
418
419
  const sensors = useSensors(
@@ -565,6 +566,7 @@ export function KanbanBoard({
565
566
  onTaskListCollapsedChange={onTaskListCollapsedChange}
566
567
  specialTaskListPins={specialTaskListPins}
567
568
  onSpecialTaskListPinnedChange={onSpecialTaskListPinnedChange}
569
+ onHoveredTaskListChange={onHoveredTaskListChange}
568
570
  readOnly={readOnly}
569
571
  />
570
572
 
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
2
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import type { Task } from '@tuturuuu/types/primitives/Task';
4
4
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
5
- import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
 
7
7
  const taskSchedulingApiMock = vi.hoisted(() => ({
8
8
  getCurrentUserTaskSchedule: vi.fn(),
@@ -67,8 +67,8 @@ vi.mock('@tuturuuu/ui/dropdown-menu', () => ({
67
67
  DropdownMenuSub: ({ children }: any) => (
68
68
  <div data-testid="dropdown-sub">{children}</div>
69
69
  ),
70
- DropdownMenuSubTrigger: ({ children }: any) => (
71
- <button type="button" data-testid="dropdown-trigger">
70
+ DropdownMenuSubTrigger: ({ children, className }: any) => (
71
+ <button type="button" className={className} data-testid="dropdown-trigger">
72
72
  {children}
73
73
  </button>
74
74
  ),
@@ -151,6 +151,10 @@ describe('TaskDueDateMenu', () => {
151
151
  const mockOnMenuItemSelect = vi.fn((_, action) => action());
152
152
  const mockOnClose = vi.fn();
153
153
 
154
+ afterEach(() => {
155
+ vi.useRealTimers();
156
+ });
157
+
154
158
  it('should render due date menu', () => {
155
159
  render(
156
160
  <TaskDueDateMenu
@@ -200,6 +204,40 @@ describe('TaskDueDateMenu', () => {
200
204
 
201
205
  expect(screen.getByText('Remove Due Date')).toBeInTheDocument();
202
206
  });
207
+
208
+ it('should render compact due date summaries without wrapping the trigger label', () => {
209
+ vi.useFakeTimers();
210
+ vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z'));
211
+
212
+ render(
213
+ <TaskDueDateMenu
214
+ endDate="2026-03-16T12:00:00.000Z"
215
+ isLoading={false}
216
+ onDueDateChange={mockOnDueDateChange}
217
+ onCustomDateClick={mockOnCustomDateClick}
218
+ onMenuItemSelect={mockOnMenuItemSelect}
219
+ onClose={mockOnClose}
220
+ />
221
+ );
222
+
223
+ const triggerLabel = screen.getByText('Due Date');
224
+ const compactDate = screen.getByText('in 2mo');
225
+
226
+ expect(screen.getByTestId('dropdown-trigger')).toHaveClass('min-w-0');
227
+ expect(triggerLabel).toHaveClass(
228
+ 'min-w-0',
229
+ 'flex-1',
230
+ 'truncate',
231
+ 'whitespace-nowrap'
232
+ );
233
+ expect(compactDate).toHaveClass(
234
+ 'shrink-0',
235
+ 'truncate',
236
+ 'whitespace-nowrap',
237
+ 'text-right'
238
+ );
239
+ expect(screen.queryByText('in about 2 months')).not.toBeInTheDocument();
240
+ });
203
241
  });
204
242
 
205
243
  describe('task scheduling utilities', () => {
@@ -7,9 +7,10 @@ import {
7
7
  DropdownMenuSubTrigger,
8
8
  } from '@tuturuuu/ui/dropdown-menu';
9
9
  import {
10
- formatDistanceToNow,
10
+ differenceInCalendarDays,
11
11
  isToday,
12
12
  isTomorrow,
13
+ isValid,
13
14
  isYesterday,
14
15
  } from 'date-fns';
15
16
  import { calculateDaysUntilEndOfWeek } from '../../../utils/weekDateUtils';
@@ -55,6 +56,22 @@ const calculateDaysForPreset = (
55
56
  }
56
57
  };
57
58
 
59
+ const formatCompactRelativeDate = (date: Date, now = new Date()) => {
60
+ const days = differenceInCalendarDays(date, now);
61
+ const absoluteDays = Math.abs(days);
62
+
63
+ const [value, unit] =
64
+ absoluteDays < 7
65
+ ? [absoluteDays, 'd']
66
+ : absoluteDays < 30
67
+ ? [Math.max(1, Math.round(absoluteDays / 7)), 'wk']
68
+ : absoluteDays < 365
69
+ ? [Math.max(1, Math.round(absoluteDays / 30)), 'mo']
70
+ : [Math.max(1, Math.round(absoluteDays / 365)), 'y'];
71
+
72
+ return days > 0 ? `in ${value}${unit}` : `${value}${unit} ago`;
73
+ };
74
+
58
75
  export function TaskDueDateMenu({
59
76
  endDate,
60
77
  isLoading,
@@ -82,7 +99,7 @@ export function TaskDueDateMenu({
82
99
  if (isToday(date)) return t.today;
83
100
  if (isTomorrow(date)) return t.tomorrow;
84
101
  if (isYesterday(date)) return t.yesterday;
85
- return formatDistanceToNow(date, { addSuffix: true });
102
+ return formatCompactRelativeDate(date);
86
103
  };
87
104
 
88
105
  const dueDateOptionsTranslated = [
@@ -104,19 +121,23 @@ export function TaskDueDateMenu({
104
121
  },
105
122
  ];
106
123
 
107
- const currentDateDisplay = endDate
108
- ? formatSmartDateTranslated(new Date(endDate))
109
- : t.none;
124
+ const parsedEndDate = endDate ? new Date(endDate) : null;
125
+ const currentDateDisplay =
126
+ parsedEndDate && isValid(parsedEndDate)
127
+ ? formatSmartDateTranslated(parsedEndDate)
128
+ : t.none;
110
129
 
111
130
  return (
112
131
  <DropdownMenuSub>
113
- <DropdownMenuSubTrigger>
114
- <div className="h-4 w-4">
132
+ <DropdownMenuSubTrigger className="min-w-0">
133
+ <div className="h-4 w-4 shrink-0">
115
134
  <Calendar className="h-4 w-4 text-dynamic-purple" />
116
135
  </div>
117
- <div className="flex w-full items-center justify-between">
118
- <span>{t.dueDate}</span>
119
- <span className="ml-auto text-muted-foreground text-xs">
136
+ <div className="flex min-w-0 flex-1 items-center gap-2">
137
+ <span className="min-w-0 flex-1 truncate whitespace-nowrap">
138
+ {t.dueDate}
139
+ </span>
140
+ <span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
120
141
  {currentDateDisplay}
121
142
  </span>
122
143
  </div>
@@ -104,13 +104,15 @@ export function TaskPriorityMenu({
104
104
 
105
105
  return (
106
106
  <DropdownMenuSub>
107
- <DropdownMenuSubTrigger>
108
- <div className="h-4 w-4">
107
+ <DropdownMenuSubTrigger className="min-w-0">
108
+ <div className="h-4 w-4 shrink-0">
109
109
  <Flag className="h-4 w-4 text-dynamic-orange" />
110
110
  </div>
111
- <div className="flex w-full items-center justify-between">
112
- <span>{t.priority}</span>
113
- <span className="ml-auto text-muted-foreground text-xs">
111
+ <div className="flex min-w-0 flex-1 items-center gap-2">
112
+ <span className="min-w-0 flex-1 truncate whitespace-nowrap">
113
+ {t.priority}
114
+ </span>
115
+ <span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
114
116
  {currentPriority
115
117
  ? priorityLabelTranslated[currentPriority]
116
118
  : t.none}
@@ -296,12 +296,14 @@ export function TaskSchedulingMenu({
296
296
 
297
297
  return (
298
298
  <DropdownMenuSub open={open} onOpenChange={setOpen}>
299
- <DropdownMenuSubTrigger>
300
- <CalendarClock className="h-4 w-4 text-dynamic-amber" />
301
- <div className="flex w-full items-center justify-between gap-2">
302
- <span>{t.schedule}</span>
299
+ <DropdownMenuSubTrigger className="min-w-0">
300
+ <CalendarClock className="h-4 w-4 shrink-0 text-dynamic-amber" />
301
+ <div className="flex min-w-0 flex-1 items-center gap-2">
302
+ <span className="min-w-0 flex-1 truncate whitespace-nowrap">
303
+ {t.schedule}
304
+ </span>
303
305
  {formattedDuration && (
304
- <span className="ml-auto text-muted-foreground text-xs">
306
+ <span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
305
307
  {formattedDuration}
306
308
  </span>
307
309
  )}