@tuturuuu/ui 0.5.0 → 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 (88) hide show
  1. package/CHANGELOG.md +29 -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 +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  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/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -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,
@@ -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<{
@@ -10,6 +10,7 @@ import {
10
10
  } from '@tuturuuu/internal-api';
11
11
  import type { TaskWithRelations } from '@tuturuuu/types';
12
12
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
13
+ import { getActiveTaskUserBroadcast } from '@tuturuuu/ui/hooks/useTaskUserRealtime';
13
14
  import { toast } from '@tuturuuu/ui/sonner';
14
15
  import { useTranslations } from 'next-intl';
15
16
  import { useCallback, useState } from 'react';
@@ -38,6 +39,7 @@ export function useTaskContextActions({
38
39
  const [isLoading, setIsLoading] = useState(false);
39
40
  const taskWorkspaceId = task.list?.board?.ws_id ?? null;
40
41
  const taskBoardId = task.list?.board?.id ?? null;
42
+ const taskListId = task.list_id ?? task.list?.id ?? null;
41
43
 
42
44
  const invalidateQueries = useCallback(() => {
43
45
  queryClient.invalidateQueries({ queryKey: [MY_TASKS_QUERY_KEY] });
@@ -66,6 +68,63 @@ export function useTaskContextActions({
66
68
  [queryClient, task.id]
67
69
  );
68
70
 
71
+ const broadcastTaskUpsert = useCallback(
72
+ (updates: Record<string, unknown>) => {
73
+ const broadcast = getActiveTaskUserBroadcast();
74
+ if (!broadcast) return;
75
+
76
+ const nextListId =
77
+ typeof updates.list_id === 'string' ? updates.list_id : taskListId;
78
+ broadcast('task:upsert', {
79
+ actor_user_id: userId,
80
+ actorUserId: userId,
81
+ boardId: taskBoardId,
82
+ listId: nextListId,
83
+ task: {
84
+ id: task.id,
85
+ name: task.name,
86
+ description: task.description ?? null,
87
+ priority: task.priority ?? null,
88
+ start_date: task.start_date ?? null,
89
+ end_date: task.end_date ?? null,
90
+ list_id: nextListId,
91
+ created_at: task.created_at ?? null,
92
+ list: task.list,
93
+ overrides: task.overrides,
94
+ ...updates,
95
+ },
96
+ });
97
+ },
98
+ [task, taskBoardId, taskListId, userId]
99
+ );
100
+
101
+ const broadcastTaskDelete = useCallback(() => {
102
+ const broadcast = getActiveTaskUserBroadcast();
103
+ if (!broadcast) return;
104
+
105
+ broadcast('task:delete', {
106
+ actor_user_id: userId,
107
+ actorUserId: userId,
108
+ boardId: taskBoardId,
109
+ listId: taskListId,
110
+ taskId: task.id,
111
+ });
112
+ }, [task.id, taskBoardId, taskListId, userId]);
113
+
114
+ const broadcastTaskRelationsChanged = useCallback(() => {
115
+ const broadcast = getActiveTaskUserBroadcast();
116
+ if (!broadcast) return;
117
+
118
+ broadcast('task:relations-changed', {
119
+ actor_user_id: userId,
120
+ actorUserId: userId,
121
+ boardId: taskBoardId,
122
+ listId: taskListId,
123
+ taskId: task.id,
124
+ taskIds: [task.id],
125
+ });
126
+ }, [task.id, taskBoardId, taskListId, userId]);
127
+
69
128
  const handlePriorityChange = useCallback(
70
129
  async (priority: TaskPriority | null) => {
71
130
  setIsLoading(true);
@@ -73,6 +132,7 @@ export function useTaskContextActions({
73
132
  try {
74
133
  if (!taskWorkspaceId) throw new Error('Task workspace not found');
75
134
  await updateWorkspaceTask(taskWorkspaceId, task.id, { priority });
135
+ broadcastTaskUpsert({ priority });
76
136
  invalidateQueries();
77
137
  dispatchTaskSoundCue('update');
78
138
  } catch {
@@ -82,7 +142,14 @@ export function useTaskContextActions({
82
142
  setIsLoading(false);
83
143
  }
84
144
  },
85
- [task.id, taskWorkspaceId, updateTaskInCache, invalidateQueries, t]
145
+ [
146
+ task.id,
147
+ taskWorkspaceId,
148
+ updateTaskInCache,
149
+ invalidateQueries,
150
+ broadcastTaskUpsert,
151
+ t,
152
+ ]
86
153
  );
87
154
 
88
155
  const handleDueDateChange = useCallback(
@@ -98,6 +165,7 @@ export function useTaskContextActions({
98
165
  await updateWorkspaceTask(taskWorkspaceId, task.id, {
99
166
  end_date: newDate,
100
167
  });
168
+ broadcastTaskUpsert({ end_date: newDate });
101
169
  invalidateQueries();
102
170
  dispatchTaskSoundCue('update');
103
171
  } catch {
@@ -107,7 +175,14 @@ export function useTaskContextActions({
107
175
  setIsLoading(false);
108
176
  }
109
177
  },
110
- [task.id, taskWorkspaceId, updateTaskInCache, invalidateQueries, t]
178
+ [
179
+ task.id,
180
+ taskWorkspaceId,
181
+ updateTaskInCache,
182
+ invalidateQueries,
183
+ broadcastTaskUpsert,
184
+ t,
185
+ ]
111
186
  );
112
187
 
113
188
  const handleToggleLabel = useCallback(
@@ -121,6 +196,7 @@ export function useTaskContextActions({
121
196
  } else {
122
197
  await addWorkspaceTaskLabel(taskWorkspaceId, task.id, labelId);
123
198
  }
199
+ broadcastTaskRelationsChanged();
124
200
  invalidateQueries();
125
201
  dispatchTaskSoundCue('update');
126
202
  } catch {
@@ -130,7 +206,14 @@ export function useTaskContextActions({
130
206
  setIsLoading(false);
131
207
  }
132
208
  },
133
- [task.id, task.labels, taskWorkspaceId, invalidateQueries, t]
209
+ [
210
+ task.id,
211
+ task.labels,
212
+ taskWorkspaceId,
213
+ invalidateQueries,
214
+ broadcastTaskRelationsChanged,
215
+ t,
216
+ ]
134
217
  );
135
218
 
136
219
  const handleComplete = useCallback(async () => {
@@ -151,6 +234,9 @@ export function useTaskContextActions({
151
234
  await updateWorkspaceTask(taskWorkspaceId, task.id, {
152
235
  list_id: doneList.id,
153
236
  });
237
+ broadcastTaskUpsert({
238
+ list_id: doneList.id,
239
+ });
154
240
 
155
241
  // Clear redundant personal overrides when task is actually done
156
242
  if (
@@ -182,6 +268,7 @@ export function useTaskContextActions({
182
268
  taskBoardId,
183
269
  taskWorkspaceId,
184
270
  task.overrides,
271
+ broadcastTaskUpsert,
185
272
  onTaskUpdate,
186
273
  onClose,
187
274
  t,
@@ -199,6 +286,7 @@ export function useTaskContextActions({
199
286
  }
200
287
  );
201
288
  if (!response.ok) throw new Error('Failed');
289
+ broadcastTaskDelete();
202
290
  onTaskUpdate();
203
291
  onClose();
204
292
  dispatchTaskSoundCue('complete');
@@ -207,7 +295,7 @@ export function useTaskContextActions({
207
295
  } finally {
208
296
  setIsLoading(false);
209
297
  }
210
- }, [task.id, onTaskUpdate, onClose, t]);
298
+ }, [task.id, broadcastTaskDelete, onTaskUpdate, onClose, t]);
211
299
 
212
300
  const handleUndoDoneWithMyPart = useCallback(async () => {
213
301
  setIsLoading(true);
@@ -224,6 +312,13 @@ export function useTaskContextActions({
224
312
  }
225
313
  );
226
314
  if (!response.ok) throw new Error('Failed');
315
+ broadcastTaskUpsert({
316
+ overrides: {
317
+ ...task.overrides,
318
+ personally_unassigned: false,
319
+ completed_at: null,
320
+ },
321
+ });
227
322
  onTaskUpdate();
228
323
  onClose();
229
324
  dispatchTaskSoundCue('update');
@@ -232,7 +327,7 @@ export function useTaskContextActions({
232
327
  } finally {
233
328
  setIsLoading(false);
234
329
  }
235
- }, [task.id, onTaskUpdate, onClose, t]);
330
+ }, [task.id, task.overrides, broadcastTaskUpsert, onTaskUpdate, onClose, t]);
236
331
 
237
332
  const handleUndoComplete = useCallback(async () => {
238
333
  if (!taskBoardId || !taskWorkspaceId) return;
@@ -254,6 +349,7 @@ export function useTaskContextActions({
254
349
  await updateWorkspaceTask(taskWorkspaceId, task.id, {
255
350
  list_id: activeList.id,
256
351
  });
352
+ broadcastTaskUpsert({ list_id: activeList.id });
257
353
 
258
354
  onTaskUpdate();
259
355
  onClose();
@@ -263,7 +359,15 @@ export function useTaskContextActions({
263
359
  } finally {
264
360
  setIsLoading(false);
265
361
  }
266
- }, [task.id, taskBoardId, taskWorkspaceId, onTaskUpdate, onClose, t]);
362
+ }, [
363
+ task.id,
364
+ taskBoardId,
365
+ taskWorkspaceId,
366
+ broadcastTaskUpsert,
367
+ onTaskUpdate,
368
+ onClose,
369
+ t,
370
+ ]);
267
371
 
268
372
  const handleUnassignMe = useCallback(async () => {
269
373
  setIsLoading(true);
@@ -291,6 +395,7 @@ export function useTaskContextActions({
291
395
  Boolean(assigneeId && assigneeId !== userId)
292
396
  ),
293
397
  });
398
+ broadcastTaskDelete();
294
399
  onTaskUpdate();
295
400
  onClose();
296
401
  dispatchTaskSoundCue('update');
@@ -310,6 +415,7 @@ export function useTaskContextActions({
310
415
  invalidateQueries,
311
416
  t,
312
417
  task.assignees,
418
+ broadcastTaskDelete,
313
419
  ]);
314
420
 
315
421
  const handleDelete = useCallback(async () => {
@@ -317,6 +423,7 @@ export function useTaskContextActions({
317
423
  try {
318
424
  if (!taskWorkspaceId) throw new Error('Task workspace not found');
319
425
  await deleteWorkspaceTask(taskWorkspaceId, task.id);
426
+ broadcastTaskDelete();
320
427
  onTaskUpdate();
321
428
  onClose();
322
429
  dispatchTaskSoundCue('delete');
@@ -325,7 +432,7 @@ export function useTaskContextActions({
325
432
  } finally {
326
433
  setIsLoading(false);
327
434
  }
328
- }, [task.id, taskWorkspaceId, onTaskUpdate, onClose, t]);
435
+ }, [task.id, taskWorkspaceId, broadcastTaskDelete, onTaskUpdate, onClose, t]);
329
436
 
330
437
  return {
331
438
  isLoading,