@tuturuuu/ui 0.12.0 → 0.14.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 (51) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +4 -4
  3. package/src/components/ui/calendar-app/components/habits-panel.tsx +5 -2
  4. package/src/components/ui/calendar-app/components/priority-view.tsx +15 -7
  5. package/src/components/ui/calendar-app/components/scheduling-dialog.tsx +7 -3
  6. package/src/components/ui/calendar-app/components/task-scheduler-panel.tsx +7 -3
  7. package/src/components/ui/calendar-app/components/time-tracker/index.tsx +14 -9
  8. package/src/components/ui/custom/settings/task-settings.tsx +61 -2
  9. package/src/components/ui/custom/tables/data-table.tsx +58 -8
  10. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  11. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  12. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  13. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -1
  14. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +21 -10
  15. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  16. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  17. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +11 -1
  18. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -3
  19. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.test.ts +113 -0
  20. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.ts +50 -0
  21. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +65 -18
  22. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +1 -1
  23. package/src/components/ui/tu-do/cycles/task-cycles-client.tsx +23 -14
  24. package/src/components/ui/tu-do/estimates/use-task-estimates.ts +9 -4
  25. package/src/components/ui/tu-do/habits/client.tsx +4 -1
  26. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +6 -3
  27. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +19 -11
  28. package/src/components/ui/tu-do/my-tasks/use-my-tasks-query.ts +15 -6
  29. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +18 -13
  30. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +17 -10
  31. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  32. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  33. package/src/components/ui/tu-do/shared/fade-setting-initializer.tsx +3 -1
  34. package/src/components/ui/tu-do/shared/list-view.tsx +1 -1
  35. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +6 -2
  36. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-update-shared-task.test.ts +9 -5
  37. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +13 -8
  38. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-overrides.ts +21 -9
  39. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-update-shared-task.ts +10 -5
  40. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +4 -2
  41. package/src/components/ui/tu-do/shared/task-estimation-picker.tsx +5 -2
  42. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  43. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +1 -1
  44. package/src/hooks/__tests__/use-task-actions.test.tsx +51 -0
  45. package/src/hooks/task-actions-personal-external.ts +2 -3
  46. package/src/hooks/use-board-actions.ts +27 -17
  47. package/src/hooks/use-calendar.tsx +7 -3
  48. package/src/hooks/use-settings-dialog-shortcut.ts +51 -0
  49. package/src/hooks/use-user-config.ts +10 -2
  50. package/src/lib/task-personal-external.ts +49 -0
  51. package/src/lib/tasks-app-url.ts +85 -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
  }
@@ -1,7 +1,23 @@
1
1
  import { QueryClient } from '@tanstack/react-query';
2
2
  import type { Task } from '@tuturuuu/types/primitives/Task';
3
3
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
4
- import { describe, expect, it } from 'vitest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ vi.mock('@tuturuuu/utils/task-helper', () => {
7
+ const prefix = 'personal-external-staging:';
8
+
9
+ return {
10
+ getPersonalExternalStagingBoardId: (listId: string | null) =>
11
+ listId?.startsWith(prefix) && listId.length > prefix.length
12
+ ? listId.slice(prefix.length)
13
+ : null,
14
+ getPersonalExternalStagingListId: (boardId: string) =>
15
+ `${prefix}${boardId}`,
16
+ isPersonalExternalStagingListId: (listId: string | null) =>
17
+ Boolean(listId?.startsWith(prefix) && listId.length > prefix.length),
18
+ };
19
+ });
20
+
5
21
  import {
6
22
  applyTaskDropPreviewToCache,
7
23
  getProjectedTaskDropOrderFromPreview,
@@ -14,6 +30,7 @@ import {
14
30
  insertTaskAtDropPosition,
15
31
  mergePersonalPlacementMutationTask,
16
32
  mergeTaskIntoBoardTaskCache,
33
+ usesPersonalPlacement,
17
34
  } from './use-kanban-dnd';
18
35
 
19
36
  function createTask(overrides: Partial<Task> = {}): Task {
@@ -116,6 +133,51 @@ describe('mergePersonalPlacementMutationTask', () => {
116
133
  });
117
134
  });
118
135
 
136
+ describe('usesPersonalPlacement', () => {
137
+ it('does not treat a personal-workspace task as external from personal_board_id alone', () => {
138
+ const personalTask = createTask({
139
+ is_personal_external: false,
140
+ personal_board_id: 'personal-board',
141
+ personal_list_id: 'list-1',
142
+ source_board_id: null,
143
+ source_list_id: null,
144
+ source_workspace_id: null,
145
+ } as Partial<Task>);
146
+
147
+ expect(usesPersonalPlacement(personalTask)).toBe(false);
148
+ });
149
+
150
+ it('does not treat an explicitly native personal task as external when source metadata is present', () => {
151
+ const personalTask = createTask({
152
+ is_personal_external: false,
153
+ personal_board_id: 'personal-board',
154
+ personal_list_id: 'list-1',
155
+ source_workspace_id: 'personal-workspace',
156
+ source_board_id: 'personal-board',
157
+ source_list_id: 'list-1',
158
+ } as Partial<Task>);
159
+
160
+ expect(usesPersonalPlacement(personalTask)).toBe(false);
161
+ });
162
+
163
+ it('does not treat same-board personal metadata as external without an explicit flag', () => {
164
+ const personalTask = createTask({
165
+ is_personal_external: undefined,
166
+ personal_board_id: 'personal-board',
167
+ personal_list_id: 'list-1',
168
+ source_workspace_id: 'personal-workspace',
169
+ source_board_id: 'personal-board',
170
+ source_list_id: 'list-1',
171
+ } as Partial<Task>);
172
+
173
+ expect(usesPersonalPlacement(personalTask)).toBe(false);
174
+ });
175
+
176
+ it('keeps explicit external overlays on the personal-placement path', () => {
177
+ expect(usesPersonalPlacement(createTask())).toBe(true);
178
+ });
179
+ });
180
+
119
181
  describe('mergeTaskIntoBoardTaskCache', () => {
120
182
  it('updates an existing task in the board cache', () => {
121
183
  const existingTask = createTask({ list_id: 'source-list' });
@@ -21,10 +21,11 @@ import {
21
21
  } from '@tuturuuu/utils/task-helper';
22
22
  import { hasDraggableData } from '@tuturuuu/utils/task-helpers';
23
23
  import { useCallback, useRef, useState } from 'react';
24
+ import { isPersonalExternalOverlayTask } from '../../../../../../../lib/task-personal-external';
24
25
  import { useBoardBroadcast } from '../../../../shared/board-broadcast-context';
25
26
  import { invalidateKanbanDeadlineTasks } from '../data/kanban-deadline-query';
26
27
  import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
27
- import { useAutoScroll } from './auto-scroll';
28
+ import { getKanbanDragAutoScrollPointerX, useAutoScroll } from './auto-scroll';
28
29
  import { getColumnReorderUpdates } from './column-reorder';
29
30
  import { calculateSortKeyWithRetry as createCalculateSortKeyWithRetry } from './kanban-sort-helpers';
30
31
  import {
@@ -121,12 +122,8 @@ interface UseKanbanDndProps {
121
122
  scrollContainerRef: React.RefObject<HTMLDivElement | null>;
122
123
  }
123
124
 
124
- function usesPersonalPlacement(task: Task) {
125
- return (
126
- task.is_personal_external === true ||
127
- Boolean(task.personal_board_id) ||
128
- isPersonalExternalStagingListId(task.list_id)
129
- );
125
+ export function usesPersonalPlacement(task: Task) {
126
+ return isPersonalExternalOverlayTask(task);
130
127
  }
131
128
 
132
129
  function getTaskDropPosition(
@@ -569,8 +566,15 @@ export function useKanbanDnd({
569
566
  [wsId]
570
567
  );
571
568
 
572
- // Initialize auto-scroll
573
- useAutoScroll(isDraggingRef, scrollContainerRef);
569
+ const { startAutoScroll, stopAutoScroll, updateAutoScrollPointerX } =
570
+ useAutoScroll(scrollContainerRef);
571
+
572
+ const updateAutoScrollFromDragEvent = useCallback(
573
+ (event: DragMoveEvent | DragOverEvent | DragStartEvent) => {
574
+ updateAutoScrollPointerX(getKanbanDragAutoScrollPointerX(event));
575
+ },
576
+ [updateAutoScrollPointerX]
577
+ );
574
578
 
575
579
  const resetDragState = useCallback(
576
580
  (clearOptimisticUpdates = false) => {
@@ -585,12 +589,13 @@ export function useKanbanDnd({
585
589
  dragSessionMetricsRef.current = null;
586
590
  dragStartTaskIndexesByListRef.current.clear();
587
591
  isDraggingRef.current = false;
592
+ stopAutoScroll();
588
593
 
589
594
  if (clearOptimisticUpdates) {
590
595
  setOptimisticUpdateInProgress(new Set());
591
596
  }
592
597
  },
593
- [setDragPreviewPosition]
598
+ [setDragPreviewPosition, stopAutoScroll]
594
599
  );
595
600
 
596
601
  const markTaskIdsPending = useCallback((taskIds: string[]) => {
@@ -609,6 +614,8 @@ export function useKanbanDnd({
609
614
 
610
615
  const processTaskDragPreview = useCallback(
611
616
  (event: DragMoveEvent) => {
617
+ updateAutoScrollFromDragEvent(event);
618
+
612
619
  const { active, over } = event;
613
620
  const activeType = active.data?.current?.type;
614
621
  if (!activeType) return;
@@ -733,6 +740,7 @@ export function useKanbanDnd({
733
740
  getDragPreviewForList,
734
741
  getDragPreviewForListSurface,
735
742
  setDragPreviewPosition,
743
+ updateAutoScrollFromDragEvent,
736
744
  wsId,
737
745
  ]
738
746
  );
@@ -745,6 +753,8 @@ export function useKanbanDnd({
745
753
 
746
754
  // Enable auto-scroll
747
755
  isDraggingRef.current = true;
756
+ updateAutoScrollFromDragEvent(event);
757
+ startAutoScroll();
748
758
 
749
759
  const { type } = active.data.current;
750
760
  if (type === 'Column') {
@@ -753,6 +763,7 @@ export function useKanbanDnd({
753
763
  }
754
764
  if (type === 'Task') {
755
765
  if (!wsId) {
766
+ resetDragState(true);
756
767
  return;
757
768
  }
758
769
 
@@ -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
 
@@ -76,7 +76,7 @@ describe('getTaskCardHydratingOpenOptions', () => {
76
76
  taskWsId: 'source-workspace',
77
77
  taskWorkspacePersonal: false,
78
78
  canUseBoardAssignees: true,
79
- assigneeMemberSource: 'workspace',
79
+ assigneeMemberSource: 'board',
80
80
  initialSharedContext: {
81
81
  boardConfig: {
82
82
  id: 'source-board',
@@ -151,4 +151,14 @@ describe('getTaskCardHydratingOpenOptions', () => {
151
151
  ).toBe(true);
152
152
  expect(isExternalTaskSnapshot(task)).toBe(false);
153
153
  });
154
+
155
+ it('does not treat personal placement metadata alone as an external snapshot', () => {
156
+ expect(
157
+ isExternalTaskSnapshot({
158
+ ...task,
159
+ personal_board_id: 'board-1',
160
+ personal_list_id: 'list-1',
161
+ })
162
+ ).toBe(false);
163
+ });
154
164
  });
@@ -1,5 +1,6 @@
1
1
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
2
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
+ import { isPersonalExternalOverlayTask } from '../../../../../../lib/task-personal-external';
3
4
  import type { SharedTaskContext } from '../../../shared/task-edit-dialog/hooks/use-task-data';
4
5
 
5
6
  interface TaskCardOpenOptionsInput {
@@ -13,9 +14,12 @@ interface TaskCardOpenOptionsInput {
13
14
  }
14
15
 
15
16
  export function isExternalTaskSnapshot(task: Task) {
17
+ if (task.is_personal_external === false) {
18
+ return false;
19
+ }
20
+
16
21
  return (
17
- task.is_personal_external === true ||
18
- Boolean(task.personal_board_id) ||
22
+ isPersonalExternalOverlayTask(task) ||
19
23
  Boolean(task.source_workspace_id) ||
20
24
  Boolean(task.source_board_id)
21
25
  );
@@ -131,7 +135,7 @@ export function getTaskCardHydratingOpenOptions({
131
135
  taskWorkspacePersonal: sourceWorkspaceId ? false : isPersonalWorkspace,
132
136
  canUseBoardAssignees: canUseBoardAssignees ?? sourceBoardAssigneesEnabled,
133
137
  assigneeMemberSource:
134
- assigneeMemberSource ?? (sourceWorkspaceId ? 'workspace' : undefined),
138
+ assigneeMemberSource ?? (sourceWorkspaceId ? 'board' : undefined),
135
139
  initialSharedContext,
136
140
  };
137
141
  }
@@ -0,0 +1,113 @@
1
+ import type { Task } from '@tuturuuu/types/primitives/Task';
2
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { getTaskCardResourceContext } from './task-card-resource-context';
5
+
6
+ const pageList = {
7
+ id: 'page-list',
8
+ name: 'Page list',
9
+ board_id: 'page-board',
10
+ position: 0,
11
+ status: 'not_started',
12
+ color: 'BLUE',
13
+ created_at: '2026-07-04T00:00:00.000Z',
14
+ creator_id: 'user-1',
15
+ archived: false,
16
+ deleted: false,
17
+ } satisfies TaskList;
18
+
19
+ const task = {
20
+ id: 'task-1',
21
+ name: 'Task',
22
+ description: '',
23
+ list_id: 'page-list',
24
+ display_number: 1,
25
+ created_at: '2026-07-04T00:00:00.000Z',
26
+ end_date: null,
27
+ priority: 'normal',
28
+ } satisfies Task;
29
+
30
+ describe('getTaskCardResourceContext', () => {
31
+ it('keeps local task resources on the page workspace and board', () => {
32
+ expect(
33
+ getTaskCardResourceContext({
34
+ boardId: 'page-board',
35
+ pageWorkspaceId: 'page-workspace',
36
+ propAvailableLists: [pageList],
37
+ task,
38
+ })
39
+ ).toEqual({
40
+ boardViewableMembersBoardId: 'page-board',
41
+ boardViewableMembersWorkspaceId: 'page-workspace',
42
+ effectiveWorkspaceId: 'page-workspace',
43
+ initialAvailableLists: [pageList],
44
+ isSourceWorkspaceTask: false,
45
+ taskBoardId: 'page-board',
46
+ });
47
+ });
48
+
49
+ it('routes source task labels, projects, lists, and members to the source workspace and board', () => {
50
+ expect(
51
+ getTaskCardResourceContext({
52
+ boardId: 'personal-board',
53
+ pageWorkspaceId: 'personal-workspace',
54
+ propAvailableLists: [pageList],
55
+ task: {
56
+ ...task,
57
+ list_id: 'personal-list',
58
+ source_board_id: 'source-board',
59
+ source_workspace_id: 'source-workspace',
60
+ },
61
+ })
62
+ ).toEqual({
63
+ boardViewableMembersBoardId: 'source-board',
64
+ boardViewableMembersWorkspaceId: 'source-workspace',
65
+ effectiveWorkspaceId: 'source-workspace',
66
+ initialAvailableLists: undefined,
67
+ isSourceWorkspaceTask: true,
68
+ taskBoardId: 'source-board',
69
+ });
70
+ });
71
+
72
+ it('uses the page workspace when a source board is present without source workspace metadata', () => {
73
+ expect(
74
+ getTaskCardResourceContext({
75
+ boardId: 'page-board',
76
+ pageWorkspaceId: 'page-workspace',
77
+ propAvailableLists: [pageList],
78
+ task: {
79
+ ...task,
80
+ source_board_id: 'source-board',
81
+ },
82
+ })
83
+ ).toMatchObject({
84
+ boardViewableMembersBoardId: 'source-board',
85
+ boardViewableMembersWorkspaceId: 'page-workspace',
86
+ effectiveWorkspaceId: 'page-workspace',
87
+ initialAvailableLists: undefined,
88
+ isSourceWorkspaceTask: true,
89
+ taskBoardId: 'source-board',
90
+ });
91
+ });
92
+
93
+ it('keeps page board list data when only source workspace metadata is available', () => {
94
+ expect(
95
+ getTaskCardResourceContext({
96
+ boardId: 'page-board',
97
+ pageWorkspaceId: 'page-workspace',
98
+ propAvailableLists: [pageList],
99
+ task: {
100
+ ...task,
101
+ source_workspace_id: 'source-workspace',
102
+ },
103
+ })
104
+ ).toMatchObject({
105
+ boardViewableMembersBoardId: 'page-board',
106
+ boardViewableMembersWorkspaceId: 'source-workspace',
107
+ effectiveWorkspaceId: 'source-workspace',
108
+ initialAvailableLists: [pageList],
109
+ isSourceWorkspaceTask: true,
110
+ taskBoardId: 'page-board',
111
+ });
112
+ });
113
+ });