@tuturuuu/ui 0.14.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 (86) hide show
  1. package/CHANGELOG.md +78 -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-dialog-shell.tsx +18 -12
  12. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  13. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  14. package/src/components/ui/custom/statistic-card.tsx +104 -0
  15. package/src/components/ui/custom/structure.test.tsx +29 -0
  16. package/src/components/ui/custom/structure.tsx +103 -88
  17. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  18. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  23. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  24. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  25. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  26. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  27. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  28. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  29. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  30. package/src/components/ui/finance/invoices/utils.ts +56 -134
  31. package/src/components/ui/resizable.test.ts +15 -0
  32. package/src/components/ui/resizable.tsx +28 -6
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
  36. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  37. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  38. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  39. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  40. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  41. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  45. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  46. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  47. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  51. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  52. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  53. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  54. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  55. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  56. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  57. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  58. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  59. package/src/components/ui/tu-do/projects/types.ts +4 -1
  60. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  61. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  62. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  63. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  64. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  65. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  66. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  69. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  75. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  76. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  77. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  78. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  79. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  80. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  81. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  82. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  83. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  84. package/src/hooks/task-actions-personal-external.ts +12 -6
  85. package/src/hooks/use-notifications.ts +14 -2
  86. package/vitest.config.ts +4 -0
@@ -187,4 +187,66 @@ describe('bulk move mutations', () => {
187
187
  intensity: 1.2,
188
188
  });
189
189
  });
190
+
191
+ it('bulk-moves selected external tasks to done through terminal personal placement', async () => {
192
+ const doneList = {
193
+ ...targetList,
194
+ id: 'done-list',
195
+ name: 'Done',
196
+ status: 'done',
197
+ } as unknown as TaskList;
198
+
199
+ queryClient.setQueryData(['tasks', 'board-1'], [localTask, externalTask]);
200
+ mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValueOnce({
201
+ task: {
202
+ ...externalTask,
203
+ list_id: 'done-list',
204
+ personal_list_id: 'done-list',
205
+ source_list_id: 'source-done-list',
206
+ source_list_status: 'done',
207
+ },
208
+ });
209
+
210
+ const { result } = renderHook(
211
+ () =>
212
+ useBulkOperations({
213
+ queryClient,
214
+ wsId: 'personal-ws',
215
+ boardId: 'board-1',
216
+ selectedTasks: new Set(['local-task', 'external-task']),
217
+ columns: [sourceList, doneList],
218
+ workspaceLabels: [],
219
+ workspaceProjects: [],
220
+ workspaceMembers: [],
221
+ setBulkWorking: vi.fn(),
222
+ clearSelection: vi.fn(),
223
+ setBulkDeleteOpen: vi.fn(),
224
+ }),
225
+ { wrapper }
226
+ );
227
+
228
+ await act(async () => {
229
+ await result.current.bulkMoveToList('done-list', 'Done');
230
+ });
231
+
232
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledWith(
233
+ 'personal-ws',
234
+ {
235
+ taskIds: ['local-task'],
236
+ operation: {
237
+ type: 'move_to_list',
238
+ listId: 'done-list',
239
+ },
240
+ },
241
+ expect.anything()
242
+ );
243
+ expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
244
+ 'external-task',
245
+ expect.objectContaining({
246
+ personal_board_id: 'board-1',
247
+ personal_list_id: 'done-list',
248
+ terminal_status: 'done',
249
+ })
250
+ );
251
+ });
190
252
  });
@@ -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' });
@@ -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
@@ -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
  )}
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shouldRenderTaskCardQuickArchive } from './task-card-archive-visibility';
3
+
4
+ describe('shouldRenderTaskCardQuickArchive', () => {
5
+ it('shows quick archive for document lists when a closed list exists', () => {
6
+ expect(
7
+ shouldRenderTaskCardQuickArchive({
8
+ hasTargetClosedList: true,
9
+ isOverlay: false,
10
+ taskListStatus: 'documents',
11
+ })
12
+ ).toBe(true);
13
+ });
14
+
15
+ it('keeps quick archive hidden for document lists without a closed list', () => {
16
+ expect(
17
+ shouldRenderTaskCardQuickArchive({
18
+ hasTargetClosedList: false,
19
+ isOverlay: false,
20
+ taskListStatus: 'documents',
21
+ })
22
+ ).toBe(false);
23
+ });
24
+
25
+ it('keeps non-done workflow lists unchanged', () => {
26
+ expect(
27
+ shouldRenderTaskCardQuickArchive({
28
+ hasTargetClosedList: true,
29
+ isOverlay: false,
30
+ taskListStatus: 'active',
31
+ })
32
+ ).toBe(false);
33
+ });
34
+
35
+ it('hides quick archive while rendering a drag overlay', () => {
36
+ expect(
37
+ shouldRenderTaskCardQuickArchive({
38
+ hasTargetClosedList: true,
39
+ isOverlay: true,
40
+ taskListStatus: 'documents',
41
+ })
42
+ ).toBe(false);
43
+ });
44
+ });
@@ -0,0 +1,17 @@
1
+ interface TaskCardQuickArchiveVisibilityOptions {
2
+ hasTargetClosedList: boolean;
3
+ isOverlay: boolean;
4
+ taskListStatus?: string | null;
5
+ }
6
+
7
+ export function shouldRenderTaskCardQuickArchive({
8
+ hasTargetClosedList,
9
+ isOverlay,
10
+ taskListStatus,
11
+ }: TaskCardQuickArchiveVisibilityOptions) {
12
+ return (
13
+ !isOverlay &&
14
+ hasTargetClosedList &&
15
+ (taskListStatus === 'done' || taskListStatus === 'documents')
16
+ );
17
+ }
@@ -102,6 +102,8 @@ describe('getTaskCardHydratingOpenOptions', () => {
102
102
  workspaceMembers: [],
103
103
  workspaceProjects: [],
104
104
  },
105
+ visibleBoardId: 'personal-board',
106
+ visibleTaskSnapshot: externalTask,
105
107
  });
106
108
  });
107
109
 
@@ -109,6 +109,7 @@ export function getTaskCardHydratingOpenOptions({
109
109
  }: TaskCardOpenOptionsInput) {
110
110
  const sourceWorkspaceId = task.source_workspace_id;
111
111
  const sourceBoardId = task.source_board_id;
112
+ const isExternalSnapshot = isExternalTaskSnapshot(task);
112
113
  const initialSharedContext = buildInitialSourceContext(
113
114
  task,
114
115
  sourceWorkspaceId ?? undefined,
@@ -137,5 +138,11 @@ export function getTaskCardHydratingOpenOptions({
137
138
  assigneeMemberSource:
138
139
  assigneeMemberSource ?? (sourceWorkspaceId ? 'board' : undefined),
139
140
  initialSharedContext,
141
+ ...(isExternalSnapshot
142
+ ? {
143
+ visibleBoardId: boardId,
144
+ visibleTaskSnapshot: task,
145
+ }
146
+ : {}),
140
147
  };
141
148
  }
@@ -143,6 +143,7 @@ import { TaskNewLabelDialog } from '../task-dialogs/TaskNewLabelDialog';
143
143
  import { TaskNewProjectDialog } from '../task-dialogs/TaskNewProjectDialog';
144
144
  import { getTaskCardParentBadgeState } from '../task-parent-badge-state';
145
145
  import { TaskCardCheckbox } from './TaskCardCheckbox';
146
+ import { shouldRenderTaskCardQuickArchive } from './task-card-archive-visibility';
146
147
  import {
147
148
  getTaskCardSelectionCheckboxToneClasses,
148
149
  TASK_CARD_OVERDUE_CHECKBOX_TONE_CLASSES,
@@ -780,8 +781,12 @@ function TaskCardInner({
780
781
 
781
782
  // Find the first list with 'done' or 'closed' status
782
783
  const getTargetCompletionList = () => {
783
- const doneList = availableLists.find((list) => list.status === 'done');
784
- const closedList = availableLists.find((list) => list.status === 'closed');
784
+ const doneList = availableLists.find(
785
+ (list) => list.status === 'done' && !list.deleted
786
+ );
787
+ const closedList = availableLists.find(
788
+ (list) => list.status === 'closed' && !list.deleted
789
+ );
785
790
  return doneList || closedList || null;
786
791
  };
787
792
 
@@ -874,7 +879,11 @@ function TaskCardInner({
874
879
 
875
880
  // Find specifically the closed list
876
881
  const getTargetClosedList = () => {
877
- return availableLists.find((list) => list.status === 'closed') || null;
882
+ return (
883
+ availableLists.find(
884
+ (list) => list.status === 'closed' && !list.deleted
885
+ ) || null
886
+ );
878
887
  };
879
888
 
880
889
  const targetCompletionList = getTargetCompletionList();
@@ -883,8 +892,15 @@ function TaskCardInner({
883
892
  targetCompletionList && targetCompletionList.id !== task.list_id;
884
893
  const canMoveToClose =
885
894
  targetClosedList && targetClosedList.id !== task.list_id;
886
- const canQuickArchive =
887
- taskList?.status === 'done' && !isOverlay && Boolean(canMoveToClose);
895
+ const isDocumentList = taskList?.status === 'documents';
896
+ const canQuickArchive = shouldRenderTaskCardQuickArchive({
897
+ hasTargetClosedList: Boolean(canMoveToClose),
898
+ isOverlay: Boolean(isOverlay),
899
+ taskListStatus: taskList?.status,
900
+ });
901
+ const canShowCloseAction =
902
+ Boolean(canMoveToClose) &&
903
+ (isDocumentList || targetClosedList?.id !== targetCompletionList?.id);
888
904
  const quickArchiveToneClass = taskList?.color
889
905
  ? DESTINATION_TONE_COLORS[taskList.color]
890
906
  : DESTINATION_TONE_COLORS.GRAY;
@@ -2035,7 +2051,7 @@ function TaskCardInner({
2035
2051
  e.stopPropagation(); // Prevent triggering task card click
2036
2052
  }}
2037
2053
  >
2038
- {taskList?.status !== 'documents' && (
2054
+ {!isDocumentList && (
2039
2055
  <DropdownMenuItem
2040
2056
  className="cursor-pointer"
2041
2057
  disabled={isLoading}
@@ -2051,11 +2067,9 @@ function TaskCardInner({
2051
2067
  </Link>
2052
2068
  </DropdownMenuItem>
2053
2069
  )}
2054
- {taskList?.status !== 'documents' && (
2055
- <DropdownMenuSeparator />
2056
- )}
2070
+ {!isDocumentList && <DropdownMenuSeparator />}
2057
2071
  {/* Quick Completion Action */}
2058
- {taskList?.status !== 'documents' && canMoveToCompletion && (
2072
+ {!isDocumentList && canMoveToCompletion && (
2059
2073
  <DropdownMenuItem
2060
2074
  onSelect={(e) =>
2061
2075
  handleMenuItemSelect(
@@ -2074,28 +2088,28 @@ function TaskCardInner({
2074
2088
  )}
2075
2089
 
2076
2090
  {/* Mark as Closed Action - Only show if closed list exists and is different from the generic completion */}
2077
- {taskList?.status !== 'documents' &&
2078
- canMoveToClose &&
2079
- targetClosedList?.id !== targetCompletionList?.id && (
2080
- <DropdownMenuItem
2081
- onSelect={(e) =>
2082
- handleMenuItemSelect(
2083
- e as unknown as Event,
2084
- handleMoveToClose
2085
- )
2086
- }
2087
- className="cursor-pointer"
2088
- disabled={isLoading}
2089
- >
2091
+ {canShowCloseAction && (
2092
+ <DropdownMenuItem
2093
+ onSelect={(e) =>
2094
+ handleMenuItemSelect(
2095
+ e as unknown as Event,
2096
+ handleMoveToClose
2097
+ )
2098
+ }
2099
+ className="cursor-pointer"
2100
+ disabled={isLoading}
2101
+ >
2102
+ {isDocumentList ? (
2103
+ <Archive className="h-4 w-4 text-dynamic-purple" />
2104
+ ) : (
2090
2105
  <CircleSlash className="h-4 w-4 text-dynamic-purple" />
2091
- {t('mark_as_closed')}
2092
- </DropdownMenuItem>
2093
- )}
2106
+ )}
2107
+ {isDocumentList ? t('archive') : t('mark_as_closed')}
2108
+ </DropdownMenuItem>
2109
+ )}
2094
2110
 
2095
- {taskList?.status !== 'documents' &&
2096
- (canMoveToCompletion || canMoveToClose) && (
2097
- <DropdownMenuSeparator />
2098
- )}
2111
+ {((!isDocumentList && canMoveToCompletion) ||
2112
+ canShowCloseAction) && <DropdownMenuSeparator />}
2099
2113
 
2100
2114
  {/* Priority Menu */}
2101
2115
  <TaskPriorityMenu