@tuturuuu/ui 0.4.1 → 0.6.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 (107) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +126 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  22. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  23. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  24. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  25. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  28. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  29. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  30. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  31. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
  34. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
  35. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  36. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  37. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  38. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  39. package/src/components/ui/finance/wallets/form.tsx +41 -197
  40. package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
  41. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  42. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  43. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  48. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
  49. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
  50. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  51. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  52. package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
  53. package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
  54. package/src/components/ui/storefront/accent-button.tsx +33 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  56. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  58. package/src/components/ui/storefront/image-panel.tsx +40 -0
  59. package/src/components/ui/storefront/index.ts +12 -0
  60. package/src/components/ui/storefront/listing-card.tsx +129 -0
  61. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  62. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  63. package/src/components/ui/storefront/types.ts +99 -0
  64. package/src/components/ui/storefront/utils.ts +90 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  67. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  68. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  69. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  70. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  71. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  72. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  73. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  74. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  75. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
  76. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  77. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  79. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  80. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  81. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  83. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  100. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
  101. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  102. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  103. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  104. package/src/hooks/use-task-actions.ts +45 -0
  105. package/src/hooks/useBoardRealtime.ts +54 -1
  106. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  107. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -0,0 +1,134 @@
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 {
5
+ getTaskCardHydratingOpenOptions,
6
+ isExternalTaskSnapshot,
7
+ } from './task-card-open-options';
8
+
9
+ const task = {
10
+ id: 'task-1',
11
+ name: 'Visible task',
12
+ description: '',
13
+ list_id: 'list-1',
14
+ display_number: 7,
15
+ created_at: '2026-06-12T00:00:00.000Z',
16
+ end_date: null,
17
+ priority: 'normal',
18
+ } satisfies Task;
19
+
20
+ const list = {
21
+ id: 'list-1',
22
+ name: 'To Do',
23
+ board_id: 'board-1',
24
+ position: 0,
25
+ status: 'not_started',
26
+ color: 'BLUE',
27
+ created_at: '2026-06-12T00:00:00.000Z',
28
+ creator_id: 'user-1',
29
+ archived: false,
30
+ deleted: false,
31
+ } satisfies TaskList;
32
+
33
+ describe('getTaskCardHydratingOpenOptions', () => {
34
+ it('opens external task cards from the visible snapshot while hydrating source details', () => {
35
+ const externalTask = {
36
+ ...task,
37
+ list_id: 'personal-list',
38
+ source_workspace_id: 'source-workspace',
39
+ source_workspace_name: 'Source workspace',
40
+ source_board_id: 'source-board',
41
+ source_board_name: 'Source board',
42
+ source_list_id: 'source-list',
43
+ source_list_name: 'Doing',
44
+ source_list_status: 'active',
45
+ ticket_prefix: 'SRC',
46
+ } satisfies Task & { ticket_prefix: string };
47
+
48
+ expect(
49
+ getTaskCardHydratingOpenOptions({
50
+ task: externalTask,
51
+ boardId: 'personal-board',
52
+ availableLists: [list],
53
+ effectiveWorkspaceId: 'personal-workspace',
54
+ isPersonalWorkspace: true,
55
+ })
56
+ ).toEqual({
57
+ initialTask: {
58
+ ...externalTask,
59
+ list_id: 'source-list',
60
+ },
61
+ boardId: 'source-board',
62
+ availableLists: [
63
+ {
64
+ id: 'source-list',
65
+ name: 'Doing',
66
+ board_id: 'source-board',
67
+ position: 0,
68
+ status: 'active',
69
+ color: 'GRAY',
70
+ created_at: '2026-06-12T00:00:00.000Z',
71
+ creator_id: '',
72
+ archived: false,
73
+ deleted: false,
74
+ },
75
+ ],
76
+ taskWsId: 'source-workspace',
77
+ taskWorkspacePersonal: false,
78
+ initialSharedContext: {
79
+ boardConfig: {
80
+ id: 'source-board',
81
+ name: 'Source board',
82
+ ws_id: 'source-workspace',
83
+ ticket_prefix: 'SRC',
84
+ },
85
+ availableLists: [
86
+ {
87
+ id: 'source-list',
88
+ name: 'Doing',
89
+ board_id: 'source-board',
90
+ position: 0,
91
+ status: 'active',
92
+ color: 'GRAY',
93
+ created_at: '2026-06-12T00:00:00.000Z',
94
+ creator_id: '',
95
+ archived: false,
96
+ deleted: false,
97
+ },
98
+ ],
99
+ workspaceLabels: [],
100
+ workspaceMembers: [],
101
+ workspaceProjects: [],
102
+ },
103
+ });
104
+ });
105
+
106
+ it('keeps local board metadata for non-source personal tasks', () => {
107
+ expect(
108
+ getTaskCardHydratingOpenOptions({
109
+ task,
110
+ boardId: 'board-1',
111
+ availableLists: [list],
112
+ effectiveWorkspaceId: 'workspace-1',
113
+ isPersonalWorkspace: true,
114
+ })
115
+ ).toEqual({
116
+ initialTask: task,
117
+ boardId: 'board-1',
118
+ availableLists: [list],
119
+ taskWsId: 'workspace-1',
120
+ taskWorkspacePersonal: true,
121
+ initialSharedContext: undefined,
122
+ });
123
+ });
124
+
125
+ it('treats source metadata as an external task snapshot', () => {
126
+ expect(
127
+ isExternalTaskSnapshot({
128
+ ...task,
129
+ source_workspace_id: 'source-workspace',
130
+ })
131
+ ).toBe(true);
132
+ expect(isExternalTaskSnapshot(task)).toBe(false);
133
+ });
134
+ });
@@ -0,0 +1,127 @@
1
+ import type { Task } from '@tuturuuu/types/primitives/Task';
2
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
+ import type { SharedTaskContext } from '../../../shared/task-edit-dialog/hooks/use-task-data';
4
+
5
+ interface TaskCardOpenOptionsInput {
6
+ task: Task;
7
+ boardId: string;
8
+ availableLists?: TaskList[];
9
+ effectiveWorkspaceId?: string;
10
+ isPersonalWorkspace: boolean;
11
+ }
12
+
13
+ export function isExternalTaskSnapshot(task: Task) {
14
+ return (
15
+ task.is_personal_external === true ||
16
+ Boolean(task.personal_board_id) ||
17
+ Boolean(task.source_workspace_id) ||
18
+ Boolean(task.source_board_id)
19
+ );
20
+ }
21
+
22
+ function normalizeSourceListStatus(status?: string | null): TaskList['status'] {
23
+ switch (status) {
24
+ case 'documents':
25
+ case 'not_started':
26
+ case 'active':
27
+ case 'review':
28
+ case 'done':
29
+ case 'closed':
30
+ return status;
31
+ default:
32
+ return 'not_started';
33
+ }
34
+ }
35
+
36
+ function getTaskTicketPrefix(task: Task) {
37
+ return 'ticket_prefix' in task && typeof task.ticket_prefix === 'string'
38
+ ? task.ticket_prefix
39
+ : undefined;
40
+ }
41
+
42
+ function buildInitialSourceList(
43
+ task: Task,
44
+ sourceBoardId: string
45
+ ): TaskList | undefined {
46
+ if (!task.source_list_id) {
47
+ return undefined;
48
+ }
49
+
50
+ return {
51
+ id: task.source_list_id,
52
+ name: task.source_list_name ?? task.source_list_id,
53
+ archived: false,
54
+ deleted: false,
55
+ created_at: task.created_at,
56
+ board_id: sourceBoardId,
57
+ creator_id: '',
58
+ status: normalizeSourceListStatus(task.source_list_status),
59
+ color: 'GRAY',
60
+ position: 0,
61
+ };
62
+ }
63
+
64
+ function buildInitialSourceContext(
65
+ task: Task,
66
+ sourceWorkspaceId?: string,
67
+ sourceBoardId?: string
68
+ ): SharedTaskContext | undefined {
69
+ if (!sourceBoardId) {
70
+ return undefined;
71
+ }
72
+
73
+ const sourceList = buildInitialSourceList(task, sourceBoardId);
74
+
75
+ return {
76
+ boardConfig: {
77
+ id: sourceBoardId,
78
+ name: task.source_board_name ?? sourceBoardId,
79
+ ws_id: sourceWorkspaceId,
80
+ ticket_prefix: getTaskTicketPrefix(task),
81
+ },
82
+ availableLists: sourceList ? [sourceList] : undefined,
83
+ workspaceLabels: task.labels ?? [],
84
+ workspaceMembers:
85
+ task.assignees?.map((assignee) => ({
86
+ id: assignee.id,
87
+ user_id: assignee.id,
88
+ display_name: assignee.display_name ?? assignee.email ?? assignee.id,
89
+ avatar_url: assignee.avatar_url ?? null,
90
+ })) ?? [],
91
+ workspaceProjects: task.projects ?? [],
92
+ };
93
+ }
94
+
95
+ export function getTaskCardHydratingOpenOptions({
96
+ task,
97
+ boardId,
98
+ availableLists,
99
+ effectiveWorkspaceId,
100
+ isPersonalWorkspace,
101
+ }: TaskCardOpenOptionsInput) {
102
+ const sourceWorkspaceId = task.source_workspace_id;
103
+ const sourceBoardId = task.source_board_id;
104
+ const initialSharedContext = buildInitialSourceContext(
105
+ task,
106
+ sourceWorkspaceId ?? undefined,
107
+ sourceBoardId ?? undefined
108
+ );
109
+ const initialTask = {
110
+ ...task,
111
+ list_id: task.source_list_id ?? task.list_id,
112
+ };
113
+
114
+ return {
115
+ initialTask,
116
+ boardId: sourceBoardId ?? boardId,
117
+ availableLists:
118
+ sourceBoardId && initialSharedContext?.availableLists
119
+ ? initialSharedContext.availableLists
120
+ : sourceBoardId
121
+ ? undefined
122
+ : availableLists,
123
+ taskWsId: sourceWorkspaceId ?? effectiveWorkspaceId,
124
+ taskWorkspacePersonal: sourceWorkspaceId ? false : isPersonalWorkspace,
125
+ initialSharedContext,
126
+ };
127
+ }
@@ -144,6 +144,10 @@ import { areTaskCardPropsEqual } from './task-card-comparator';
144
144
  import { shouldRenderTaskCardCompletionCheckbox } from './task-card-completion-checkbox-visibility';
145
145
  import { TaskCardIdentifierRow } from './task-card-identifier-row';
146
146
  import { mergeTaskCardLabelOptions } from './task-card-label-options';
147
+ import {
148
+ getTaskCardHydratingOpenOptions,
149
+ isExternalTaskSnapshot,
150
+ } from './task-card-open-options';
147
151
  import { getTaskCardVisibilityState } from './task-card-visibility';
148
152
  import { TaskSchedulingBadge } from './task-scheduling-badge';
149
153
 
@@ -662,8 +666,7 @@ function TaskCardInner({
662
666
 
663
667
  // Check if task is optimistically added (pending realtime confirmation)
664
668
  const isOptimistic = '_isOptimistic' in task && task._isOptimistic === true;
665
- const isPersonalExternalTask =
666
- task.is_personal_external === true || Boolean(task.personal_board_id);
669
+ const isPersonalExternalTask = isExternalTaskSnapshot(task);
667
670
  const sourceBoardUrl =
668
671
  task.source_workspace_id && task.source_board_id
669
672
  ? `/${task.source_workspace_id}${tasksHref(`/boards/${task.source_board_id}`)}`
@@ -887,51 +890,23 @@ function TaskCardInner({
887
890
  // Removed explicit drag handle – entire card is now draggable for better UX.
888
891
  // Keep attributes/listeners to spread onto root interactive area.
889
892
 
890
- const openExternalTask = useCallback(async () => {
891
- const sourceWorkspaceId = task.source_workspace_id;
892
- const sourceBoardId = task.source_board_id;
893
-
894
- if (sourceWorkspaceId && sourceBoardId) {
895
- try {
896
- const { task: sourceTask } = await getWorkspaceTask(
897
- sourceWorkspaceId,
898
- task.id
899
- );
900
- let sourceLists: TaskList[] | undefined;
901
-
902
- try {
903
- sourceLists = (
904
- await listWorkspaceTaskLists(sourceWorkspaceId, sourceBoardId)
905
- ).lists;
906
- } catch {
907
- sourceLists = undefined;
908
- }
909
-
910
- openTask(sourceTask as Task, sourceBoardId, sourceLists, false, {
911
- taskWsId: sourceWorkspaceId,
912
- taskWorkspacePersonal: false,
913
- });
914
- return;
915
- } catch {
916
- const opened = await openTaskById(task.id);
917
- if (opened) return;
918
- }
919
- } else {
920
- const opened = await openTaskById(task.id);
921
- if (opened) return;
922
- }
923
-
924
- openTask(task, sourceBoardId ?? boardId, availableLists, false, {
925
- taskWsId: sourceWorkspaceId ?? effectiveWorkspaceId,
926
- taskWorkspacePersonal: sourceWorkspaceId ? false : isPersonalWorkspace,
927
- });
893
+ const openExternalTask = useCallback(() => {
894
+ void openTaskById(
895
+ task.id,
896
+ getTaskCardHydratingOpenOptions({
897
+ task,
898
+ boardId,
899
+ availableLists,
900
+ effectiveWorkspaceId,
901
+ isPersonalWorkspace,
902
+ })
903
+ );
928
904
  }, [
929
905
  task,
930
906
  boardId,
931
907
  availableLists,
932
908
  effectiveWorkspaceId,
933
909
  isPersonalWorkspace,
934
- openTask,
935
910
  openTaskById,
936
911
  ]);
937
912
 
@@ -956,7 +931,7 @@ function TaskCardInner({
956
931
  ) {
957
932
  // Only open edit dialog if not in multi-select mode, not dragging, and no other dialogs are open
958
933
  if (isPersonalExternalTask) {
959
- void openExternalTask();
934
+ openExternalTask();
960
935
  return;
961
936
  }
962
937
 
@@ -0,0 +1,164 @@
1
+ import '@testing-library/jest-dom';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import type { Task } from '@tuturuuu/types/primitives/Task';
5
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { TimelineBoard } from './timeline-board';
8
+
9
+ const createTaskMock = vi.hoisted(() => vi.fn());
10
+ const openTaskMock = vi.hoisted(() => vi.fn());
11
+ const openTaskByIdMock = vi.hoisted(() => vi.fn());
12
+ const updateWorkspaceTaskMock = vi.hoisted(() => vi.fn());
13
+ const deleteWorkspaceTaskMock = vi.hoisted(() => vi.fn());
14
+
15
+ class MockResizeObserver {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ }
20
+
21
+ globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
22
+
23
+ vi.mock('next-intl', () => ({
24
+ useLocale: () => 'en',
25
+ useTranslations: () => (key: string) => key,
26
+ }));
27
+
28
+ vi.mock('@tuturuuu/internal-api', () => ({
29
+ updateWorkspaceTask: updateWorkspaceTaskMock,
30
+ deleteWorkspaceTask: deleteWorkspaceTaskMock,
31
+ }));
32
+
33
+ vi.mock('../../hooks/useTaskDialog', () => ({
34
+ useTaskDialog: () => ({
35
+ createTask: createTaskMock,
36
+ openTask: openTaskMock,
37
+ openTaskById: openTaskByIdMock,
38
+ }),
39
+ }));
40
+
41
+ vi.mock('./timeline/timeline-grid', () => ({
42
+ TimelineGrid: ({
43
+ localTasks,
44
+ onOpenTask,
45
+ }: {
46
+ localTasks: Task[];
47
+ onOpenTask: (task: Task) => void;
48
+ }) => (
49
+ <button
50
+ type="button"
51
+ data-testid="open-timeline-task"
52
+ onClick={() => onOpenTask(localTasks[0]!)}
53
+ >
54
+ Open timeline task
55
+ </button>
56
+ ),
57
+ }));
58
+
59
+ const lists: TaskList[] = [
60
+ {
61
+ archived: false,
62
+ board_id: 'board-1',
63
+ color: 'GRAY',
64
+ created_at: '2026-05-01T00:00:00.000Z',
65
+ creator_id: 'user-1',
66
+ deleted: false,
67
+ id: 'todo',
68
+ name: 'To Do',
69
+ position: 0,
70
+ status: 'not_started',
71
+ },
72
+ ];
73
+
74
+ function task(overrides: Partial<Task> & Pick<Task, 'id' | 'name'>): Task {
75
+ return {
76
+ created_at: '2026-05-01T00:00:00.000Z',
77
+ display_number: 1,
78
+ end_date: null,
79
+ labels: [],
80
+ list_id: 'todo',
81
+ priority: 'normal',
82
+ sort_key: 1,
83
+ start_date: null,
84
+ ...overrides,
85
+ } as Task;
86
+ }
87
+
88
+ function renderTimeline(tasks: Task[]) {
89
+ const queryClient = new QueryClient({
90
+ defaultOptions: {
91
+ mutations: { retry: false },
92
+ queries: { retry: false },
93
+ },
94
+ });
95
+
96
+ return render(
97
+ <QueryClientProvider client={queryClient}>
98
+ <TimelineBoard
99
+ boardId="board-1"
100
+ lists={lists}
101
+ tasks={tasks}
102
+ wsId="ws-1"
103
+ />
104
+ </QueryClientProvider>
105
+ );
106
+ }
107
+
108
+ describe('TimelineBoard task opening', () => {
109
+ beforeEach(() => {
110
+ createTaskMock.mockReset();
111
+ openTaskMock.mockReset();
112
+ openTaskByIdMock.mockReset();
113
+ updateWorkspaceTaskMock.mockReset();
114
+ deleteWorkspaceTaskMock.mockReset();
115
+ });
116
+
117
+ it('opens external source tasks through the hydrating task-by-id path immediately', () => {
118
+ renderTimeline([
119
+ task({
120
+ id: 'external-timeline-task',
121
+ name: 'External timeline task',
122
+ list_id: 'personal-list',
123
+ personal_board_id: 'board-1',
124
+ is_personal_external: true,
125
+ source_workspace_id: 'source-ws',
126
+ source_board_id: 'source-board',
127
+ source_board_name: 'Source board',
128
+ source_list_id: 'source-list',
129
+ source_list_name: 'Source list',
130
+ }),
131
+ ]);
132
+
133
+ fireEvent.click(screen.getByTestId('open-timeline-task'));
134
+
135
+ expect(openTaskByIdMock).toHaveBeenCalledWith(
136
+ 'external-timeline-task',
137
+ expect.objectContaining({
138
+ boardId: 'source-board',
139
+ taskWsId: 'source-ws',
140
+ taskWorkspacePersonal: false,
141
+ initialTask: expect.objectContaining({
142
+ id: 'external-timeline-task',
143
+ list_id: 'source-list',
144
+ name: 'External timeline task',
145
+ }),
146
+ initialSharedContext: expect.objectContaining({
147
+ boardConfig: expect.objectContaining({
148
+ id: 'source-board',
149
+ name: 'Source board',
150
+ ws_id: 'source-ws',
151
+ }),
152
+ availableLists: [
153
+ expect.objectContaining({
154
+ id: 'source-list',
155
+ name: 'Source list',
156
+ board_id: 'source-board',
157
+ }),
158
+ ],
159
+ }),
160
+ })
161
+ );
162
+ expect(openTaskMock).not.toHaveBeenCalled();
163
+ });
164
+ });
@@ -32,6 +32,10 @@ import {
32
32
  useState,
33
33
  } from 'react';
34
34
  import { useTaskDialog } from '../../hooks/useTaskDialog';
35
+ import {
36
+ getTaskCardHydratingOpenOptions,
37
+ isExternalTaskSnapshot,
38
+ } from './task-card/task-card-open-options';
35
39
  import { TaskEditDialog } from './timeline/task-edit-dialog';
36
40
  import {
37
41
  DEFAULT_DAY_WIDTH,
@@ -111,7 +115,7 @@ export function TimelineBoard({
111
115
  }: TimelineProps) {
112
116
  const t = useTranslations('common');
113
117
  const locale = useLocale();
114
- const { createTask, openTask } = useTaskDialog();
118
+ const { createTask, openTask, openTaskById } = useTaskDialog();
115
119
  const scrollRef = useRef<HTMLDivElement>(null);
116
120
  const [dayWidth, setDayWidth] = useState(DEFAULT_DAY_WIDTH);
117
121
  const [density, setDensity] = useState<Density>('comfortable');
@@ -226,23 +230,28 @@ export function TimelineBoard({
226
230
 
227
231
  const openTimelineTask = useCallback(
228
232
  (task: Task) => {
229
- const targetBoardId = task.source_board_id ?? boardId;
230
- const targetWorkspaceId = task.source_workspace_id ?? wsId;
231
-
232
- if (!targetBoardId) return;
233
+ if (!boardId) return;
234
+
235
+ if (isExternalTaskSnapshot(task)) {
236
+ void openTaskById(
237
+ task.id,
238
+ getTaskCardHydratingOpenOptions({
239
+ task,
240
+ boardId,
241
+ availableLists: lists,
242
+ effectiveWorkspaceId: wsId,
243
+ isPersonalWorkspace: Boolean(wsId),
244
+ })
245
+ );
246
+ return;
247
+ }
233
248
 
234
- openTask(
235
- task,
236
- targetBoardId,
237
- task.source_board_id ? undefined : lists,
238
- false,
239
- {
240
- taskWsId: targetWorkspaceId,
241
- taskWorkspacePersonal: Boolean(wsId) && !task.source_workspace_id,
242
- }
243
- );
249
+ openTask(task, boardId, lists, false, {
250
+ taskWsId: wsId,
251
+ taskWorkspacePersonal: Boolean(wsId),
252
+ });
244
253
  },
245
- [boardId, lists, openTask, wsId]
254
+ [boardId, lists, openTask, openTaskById, wsId]
246
255
  );
247
256
 
248
257
  const clearDraft = useCallback((taskId: string) => {
@@ -1,3 +1,4 @@
1
+ import type { WorkspaceProductTier } from '@tuturuuu/types';
1
2
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
3
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
4
  import type { TaskFilters } from '@tuturuuu/ui/tu-do/boards/boardId/task-filter';
@@ -5,6 +6,7 @@ import {
5
6
  type PendingRelationshipType,
6
7
  useTaskDialogContext,
7
8
  } from '../providers/task-dialog-provider';
9
+ import type { SharedTaskContext } from '../shared/task-edit-dialog/hooks/use-task-data';
8
10
 
9
11
  /**
10
12
  * Hook to open and manage the centralized task dialog
@@ -50,7 +52,19 @@ export function useTaskDialog(): {
50
52
  taskWorkspacePersonal?: boolean;
51
53
  }
52
54
  ) => void;
53
- openTaskById: (taskId: string) => Promise<boolean>;
55
+ openTaskById: (
56
+ taskId: string,
57
+ options?: {
58
+ initialTask?: Partial<Task>;
59
+ boardId?: string;
60
+ availableLists?: TaskList[];
61
+ fakeTaskUrl?: boolean;
62
+ taskWsId?: string;
63
+ taskWorkspacePersonal?: boolean;
64
+ taskWorkspaceTier?: WorkspaceProductTier;
65
+ initialSharedContext?: SharedTaskContext;
66
+ }
67
+ ) => Promise<boolean>;
54
68
  createTask: (
55
69
  boardId: string,
56
70
  listId: string,
@@ -7,6 +7,7 @@ import { useTaskContextActions } from '../use-task-context-actions';
7
7
  const {
8
8
  mockAddWorkspaceTaskLabel,
9
9
  mockDeleteWorkspaceTask,
10
+ mockDispatchTaskSoundCue,
10
11
  mockInvalidateQueries,
11
12
  mockListWorkspaceTaskLists,
12
13
  mockRemoveWorkspaceTaskLabel,
@@ -17,6 +18,7 @@ const {
17
18
  } = vi.hoisted(() => ({
18
19
  mockAddWorkspaceTaskLabel: vi.fn(),
19
20
  mockDeleteWorkspaceTask: vi.fn(),
21
+ mockDispatchTaskSoundCue: vi.fn(),
20
22
  mockInvalidateQueries: vi.fn(),
21
23
  mockListWorkspaceTaskLists: vi.fn(),
22
24
  mockRemoveWorkspaceTaskLabel: vi.fn(),
@@ -55,6 +57,10 @@ vi.mock('@tuturuuu/ui/sonner', () => ({
55
57
  },
56
58
  }));
57
59
 
60
+ vi.mock('../../shared/task-sound-effects', () => ({
61
+ dispatchTaskSoundCue: mockDispatchTaskSoundCue,
62
+ }));
63
+
58
64
  // Mock fetch
59
65
  global.fetch = vi.fn();
60
66
 
@@ -131,6 +137,7 @@ describe('useTaskContextActions', () => {
131
137
  );
132
138
  expect(onTaskUpdate).toHaveBeenCalled();
133
139
  expect(onClose).toHaveBeenCalled();
140
+ expect(mockDispatchTaskSoundCue).toHaveBeenCalledWith('complete');
134
141
  });
135
142
 
136
143
  it('handleUndoDoneWithMyPart sends PUT with both flags cleared', async () => {
@@ -165,6 +172,7 @@ describe('useTaskContextActions', () => {
165
172
  );
166
173
  expect(onTaskUpdate).toHaveBeenCalled();
167
174
  expect(onClose).toHaveBeenCalled();
175
+ expect(mockDispatchTaskSoundCue).toHaveBeenCalledWith('update');
168
176
  });
169
177
 
170
178
  it('handleComplete clears overrides when task has completed_at override', async () => {
@@ -279,6 +287,7 @@ describe('useTaskContextActions', () => {
279
287
  expect(mockDeleteWorkspaceTask).toHaveBeenCalledWith('ws-1', mockTask.id);
280
288
  expect(onTaskUpdate).toHaveBeenCalled();
281
289
  expect(onClose).toHaveBeenCalled();
290
+ expect(mockDispatchTaskSoundCue).toHaveBeenCalledWith('delete');
282
291
  });
283
292
 
284
293
  it('handlePriorityChange updates task priority via internal API', async () => {
@@ -299,6 +308,7 @@ describe('useTaskContextActions', () => {
299
308
  priority: 'high',
300
309
  });
301
310
  expect(mockInvalidateQueries).toHaveBeenCalled();
311
+ expect(mockDispatchTaskSoundCue).toHaveBeenCalledWith('update');
302
312
  });
303
313
 
304
314
  it('handleUnassignMe updates assignee_ids via internal API', async () => {
@@ -512,5 +522,6 @@ describe('useTaskContextActions', () => {
512
522
  });
513
523
  expect(onTaskUpdate).toHaveBeenCalled();
514
524
  expect(onClose).toHaveBeenCalled();
525
+ expect(mockDispatchTaskSoundCue).toHaveBeenCalledWith('move');
515
526
  });
516
527
  });
@@ -16,6 +16,7 @@ import {
16
16
  } from '@tuturuuu/internal-api';
17
17
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
18
18
  import { AI_CREDITS_QUERY_KEY } from '@tuturuuu/ui/hooks/use-ai-credits';
19
+ import { useTaskUserRealtime } from '@tuturuuu/ui/hooks/useTaskUserRealtime';
19
20
  import { toast } from '@tuturuuu/ui/sonner';
20
21
  import { useTaskDialog } from '@tuturuuu/ui/tu-do/hooks/useTaskDialog';
21
22
  import { useBoardConfig } from '@tuturuuu/utils/task-helper';
@@ -51,6 +52,7 @@ export function useMyTasksState({
51
52
  const t = useTranslations();
52
53
  const queryClient = useQueryClient();
53
54
  const { onUpdate, openTaskById } = useTaskDialog();
55
+ useTaskUserRealtime(userId);
54
56
 
55
57
  // Filter state (declared before query so it can be passed as param)
56
58
  const [taskFilters, setTaskFilters] = useState<{