@tuturuuu/ui 0.7.0 → 0.9.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 (226) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/biome.json +1 -1
  3. package/package.json +75 -73
  4. package/src/components/ui/accordion.tsx +1 -1
  5. package/src/components/ui/breadcrumb.tsx +1 -1
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
  8. package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
  9. package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
  10. package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
  12. package/src/components/ui/calendar.tsx +1 -1
  13. package/src/components/ui/carousel.tsx +1 -1
  14. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
  15. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
  16. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
  17. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
  18. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
  19. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
  20. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
  21. package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
  22. package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
  23. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
  24. package/src/components/ui/checkbox.tsx +1 -1
  25. package/src/components/ui/color-picker.tsx +1 -1
  26. package/src/components/ui/command.tsx +1 -1
  27. package/src/components/ui/context-menu.tsx +5 -1
  28. package/src/components/ui/currency-input.test.tsx +43 -0
  29. package/src/components/ui/currency-input.tsx +1 -1
  30. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  31. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  32. package/src/components/ui/custom/combobox.test.tsx +195 -0
  33. package/src/components/ui/custom/combobox.tsx +273 -156
  34. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  35. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  36. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  37. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  38. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  39. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  40. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  41. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  42. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  43. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  44. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  45. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  46. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  47. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  48. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  49. package/src/components/ui/custom/workspace-select.tsx +8 -3
  50. package/src/components/ui/dialog.test.tsx +52 -0
  51. package/src/components/ui/dialog.tsx +6 -2
  52. package/src/components/ui/dropdown-menu.tsx +5 -1
  53. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  54. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  55. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  56. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  57. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  58. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  59. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  60. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  61. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  62. package/src/components/ui/finance/invoices/utils.ts +3 -1
  63. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  64. package/src/components/ui/finance/transactions/form-types.ts +3 -0
  65. package/src/components/ui/finance/transactions/form.tsx +2 -0
  66. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  67. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  68. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  69. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  70. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  71. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  72. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  73. package/src/components/ui/finance/wallets/form.tsx +15 -4
  74. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  75. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  76. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  77. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  78. package/src/components/ui/input-otp.tsx +1 -1
  79. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  80. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  81. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  82. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  83. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  84. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  85. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  86. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  87. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  88. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  89. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  90. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  91. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  92. package/src/components/ui/money-input.test.tsx +64 -0
  93. package/src/components/ui/money-input.tsx +63 -0
  94. package/src/components/ui/navigation-menu.tsx +1 -1
  95. package/src/components/ui/pagination.tsx +1 -1
  96. package/src/components/ui/radio-group.tsx +1 -1
  97. package/src/components/ui/select.tsx +5 -1
  98. package/src/components/ui/sheet.tsx +1 -1
  99. package/src/components/ui/sidebar.tsx +1 -1
  100. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  101. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  102. package/src/components/ui/storefront/cart-summary.tsx +104 -80
  103. package/src/components/ui/storefront/checkout-overlay.tsx +26 -0
  104. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  105. package/src/components/ui/storefront/image-panel.tsx +6 -0
  106. package/src/components/ui/storefront/index.ts +11 -0
  107. package/src/components/ui/storefront/listing-card.tsx +84 -22
  108. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  109. package/src/components/ui/storefront/product-detail.tsx +289 -0
  110. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  111. package/src/components/ui/storefront/storefront-surface.test.tsx +221 -3
  112. package/src/components/ui/storefront/storefront-surface.tsx +288 -153
  113. package/src/components/ui/storefront/types.ts +27 -1
  114. package/src/components/ui/storefront/utils.ts +117 -27
  115. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  116. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  117. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  118. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  119. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  120. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  121. package/src/components/ui/text-editor/content-migration.ts +41 -18
  122. package/src/components/ui/text-editor/editor.tsx +69 -14
  123. package/src/components/ui/text-editor/extensions.ts +9 -3
  124. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  125. package/src/components/ui/text-editor/image-extension.ts +40 -18
  126. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  127. package/src/components/ui/text-editor/video-extension.ts +11 -2
  128. package/src/components/ui/toast.tsx +1 -1
  129. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  130. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  131. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  132. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  133. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  134. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +113 -46
  135. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  136. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  137. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  138. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  139. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  140. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  141. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +51 -9
  142. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  143. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  144. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  145. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +127 -38
  146. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  147. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  148. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  149. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  150. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  151. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  152. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  153. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  154. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  155. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  156. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  157. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +410 -4
  158. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +106 -14
  159. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  160. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  161. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  162. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +186 -0
  163. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +59 -2
  164. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  165. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  166. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  167. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  168. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  169. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  170. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  171. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  172. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  173. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  174. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  175. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  176. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  177. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  178. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  179. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  180. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  181. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  182. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  183. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  184. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  185. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  186. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +237 -3
  187. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  188. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  189. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  190. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  191. package/src/components/ui/tu-do/shared/board-header.tsx +465 -937
  192. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  193. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  194. package/src/components/ui/tu-do/shared/board-views.tsx +596 -82
  195. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  196. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  197. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  198. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  199. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  200. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  201. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  202. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  203. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  204. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  205. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  206. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  207. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  208. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  209. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  210. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +44 -15
  211. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  212. package/src/declarations.d.ts +1 -0
  213. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  214. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  215. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  216. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  217. package/src/hooks/use-calendar-sync.tsx +247 -243
  218. package/src/hooks/use-calendar.tsx +323 -138
  219. package/src/hooks/use-task-actions.ts +24 -0
  220. package/src/hooks/use-user-workspace-config.ts +75 -0
  221. package/src/hooks/use-workspace-currency.ts +8 -3
  222. package/src/hooks/useBoardRealtime.ts +6 -3
  223. package/src/hooks/useBoardRealtime.types.ts +11 -0
  224. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
  225. package/src/hooks/useCursorTracking.ts +91 -27
  226. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -1,5 +1,5 @@
1
1
  import '@testing-library/jest-dom';
2
- import { render, screen } from '@testing-library/react';
2
+ import { act, fireEvent, render, screen, within } 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
5
  import type React from 'react';
@@ -7,7 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { DEFAULT_KANBAN_COLUMN_WIDTH } from './kanban-column-width';
8
8
  import { KanbanColumns } from './kanban-columns';
9
9
 
10
- const { taskCardMock } = vi.hoisted(() => ({
10
+ const { cursorOverlayMock, taskCardMock } = vi.hoisted(() => ({
11
+ cursorOverlayMock: vi.fn(),
11
12
  taskCardMock: vi.fn(),
12
13
  }));
13
14
 
@@ -20,7 +21,10 @@ vi.mock('@dnd-kit/sortable', () => ({
20
21
 
21
22
  vi.mock('../../board-column', () => ({
22
23
  BoardColumn: ({ column }: { column: TaskList }) => (
23
- <section data-testid={`column-${column.id}`} />
24
+ <section
25
+ data-kanban-real-column={column.is_external_staging ? undefined : 'true'}
26
+ data-testid={`column-${column.id}`}
27
+ />
24
28
  ),
25
29
  }));
26
30
 
@@ -41,7 +45,10 @@ vi.mock('../../task', () => ({
41
45
  }));
42
46
 
43
47
  vi.mock('../../../../shared/cursor-overlay-multi-wrapper', () => ({
44
- default: () => <div data-testid="cursor-overlay" />,
48
+ default: (props: Record<string, unknown>) => {
49
+ cursorOverlayMock(props);
50
+ return <div data-testid="cursor-overlay" />;
51
+ },
45
52
  }));
46
53
 
47
54
  const lists: TaskList[] = [
@@ -112,6 +119,7 @@ function task(overrides: Partial<Task>): Task {
112
119
 
113
120
  describe('KanbanColumns', () => {
114
121
  beforeEach(() => {
122
+ cursorOverlayMock.mockClear();
115
123
  taskCardMock.mockClear();
116
124
  });
117
125
 
@@ -263,6 +271,12 @@ describe('KanbanColumns', () => {
263
271
  );
264
272
 
265
273
  expect(screen.getByTestId('cursor-overlay')).toBeInTheDocument();
274
+ expect(cursorOverlayMock).toHaveBeenCalledWith(
275
+ expect.objectContaining({
276
+ channelName: 'board-realtime-board-1',
277
+ cursorScope: { boardId: 'board-1', type: 'board' },
278
+ })
279
+ );
266
280
  });
267
281
 
268
282
  it('renders populated deadline panels before the regular kanban columns', () => {
@@ -306,10 +320,18 @@ describe('KanbanColumns', () => {
306
320
  );
307
321
 
308
322
  const deadlinePanels = screen.getByTestId('kanban-deadline-panels');
323
+ const overdueSection = screen.getByTestId(
324
+ 'kanban-deadline-section-overdue'
325
+ );
326
+ const overdueCount = screen.getByTestId(
327
+ 'kanban-deadline-section-overdue-count'
328
+ );
309
329
  const firstColumn = screen.getByTestId('column-list-1');
310
330
  const sharedTaskCard = screen.getByTestId('shared-task-card-overdue-task');
311
331
 
332
+ expect(overdueSection).toHaveClass('border-dashed');
312
333
  expect(deadlinePanels).toHaveTextContent('Overdue');
334
+ expect(overdueCount).toHaveTextContent('1');
313
335
  expect(sharedTaskCard).toHaveTextContent('Overdue task');
314
336
  expect(deadlinePanels.compareDocumentPosition(firstColumn)).toBe(
315
337
  Node.DOCUMENT_POSITION_FOLLOWING
@@ -336,6 +358,281 @@ describe('KanbanColumns', () => {
336
358
  ).toEqual(['list-1', 'list-2']);
337
359
  });
338
360
 
361
+ it('filters deadline cards by document and external source controls', () => {
362
+ render(
363
+ <KanbanColumns
364
+ columns={[...lists, externalList]}
365
+ tasks={[]}
366
+ boardId="board-1"
367
+ workspaceId="ws-1"
368
+ isPersonalWorkspace
369
+ disableSort={false}
370
+ selectedTasks={new Set()}
371
+ isMultiSelectMode={false}
372
+ setIsMultiSelectMode={vi.fn()}
373
+ onTaskSelect={vi.fn()}
374
+ onClearSelection={vi.fn()}
375
+ onUpdate={vi.fn()}
376
+ createTask={vi.fn()}
377
+ taskHeightsRef={{ current: new Map() }}
378
+ optimisticUpdateInProgress={new Set()}
379
+ bulkUpdateCustomDueDate={vi.fn()}
380
+ boardRef={{ current: null }}
381
+ columnsId={[...lists, externalList].map((list) => list.id)}
382
+ deadlineLabels={{
383
+ filter: 'Filters',
384
+ overdue: 'Overdue',
385
+ showDocuments: 'Show document-list tasks',
386
+ showExternalTasks: 'External tasks',
387
+ upcoming: 'Upcoming',
388
+ }}
389
+ deadlineSections={{
390
+ overdue: [],
391
+ upcoming: [
392
+ task({
393
+ end_date: '2026-06-01T00:00:00.000Z',
394
+ id: 'regular-deadline',
395
+ list_id: 'list-1',
396
+ name: 'Regular deadline task',
397
+ }),
398
+ task({
399
+ end_date: '2026-06-02T00:00:00.000Z',
400
+ id: 'document-deadline',
401
+ list_id: 'list-1',
402
+ name: 'Document deadline task',
403
+ source_list_status: 'documents',
404
+ }),
405
+ task({
406
+ end_date: '2026-06-03T00:00:00.000Z',
407
+ id: 'external-deadline',
408
+ list_id: 'external-list',
409
+ name: 'External deadline task',
410
+ source_workspace_id: 'source-ws',
411
+ }),
412
+ ],
413
+ }}
414
+ />
415
+ );
416
+
417
+ const upcomingSection = screen.getByTestId(
418
+ 'kanban-deadline-section-upcoming'
419
+ );
420
+
421
+ expect(
422
+ screen.getByTestId('shared-task-card-regular-deadline')
423
+ ).toBeInTheDocument();
424
+ expect(
425
+ screen.getByTestId('shared-task-card-document-deadline')
426
+ ).toBeInTheDocument();
427
+ expect(
428
+ screen.getByTestId('shared-task-card-external-deadline')
429
+ ).toBeInTheDocument();
430
+
431
+ fireEvent.pointerDown(
432
+ within(upcomingSection).getByRole('button', { name: 'Filters' }),
433
+ { button: 0, ctrlKey: false }
434
+ );
435
+ fireEvent.click(
436
+ screen.getByRole('menuitemcheckbox', {
437
+ name: 'Show document-list tasks',
438
+ })
439
+ );
440
+ fireEvent.click(
441
+ screen.getByRole('menuitemcheckbox', { name: 'External tasks' })
442
+ );
443
+
444
+ expect(
445
+ screen.getByTestId('shared-task-card-regular-deadline')
446
+ ).toBeInTheDocument();
447
+ expect(
448
+ screen.queryByTestId('shared-task-card-document-deadline')
449
+ ).not.toBeInTheDocument();
450
+ expect(
451
+ screen.queryByTestId('shared-task-card-external-deadline')
452
+ ).not.toBeInTheDocument();
453
+ expect(
454
+ screen.getByTestId('kanban-deadline-section-upcoming-count')
455
+ ).toHaveTextContent('1');
456
+ });
457
+
458
+ it('sorts deadline cards using local deadline sort controls', () => {
459
+ render(
460
+ <KanbanColumns
461
+ columns={lists}
462
+ tasks={[]}
463
+ boardId="board-1"
464
+ workspaceId="ws-1"
465
+ isPersonalWorkspace={false}
466
+ disableSort={false}
467
+ selectedTasks={new Set()}
468
+ isMultiSelectMode={false}
469
+ setIsMultiSelectMode={vi.fn()}
470
+ onTaskSelect={vi.fn()}
471
+ onClearSelection={vi.fn()}
472
+ onUpdate={vi.fn()}
473
+ createTask={vi.fn()}
474
+ taskHeightsRef={{ current: new Map() }}
475
+ optimisticUpdateInProgress={new Set()}
476
+ bulkUpdateCustomDueDate={vi.fn()}
477
+ boardRef={{ current: null }}
478
+ columnsId={lists.map((list) => list.id)}
479
+ deadlineLabels={{
480
+ overdue: 'Overdue',
481
+ sort: 'Sort',
482
+ sortNameAsc: 'Task name',
483
+ upcoming: 'Upcoming',
484
+ }}
485
+ deadlineSections={{
486
+ overdue: [],
487
+ upcoming: [
488
+ task({
489
+ end_date: '2026-06-02T00:00:00.000Z',
490
+ id: 'z-deadline',
491
+ list_id: 'list-1',
492
+ name: 'Zulu task',
493
+ }),
494
+ task({
495
+ end_date: '2026-06-03T00:00:00.000Z',
496
+ id: 'a-deadline',
497
+ list_id: 'list-1',
498
+ name: 'Alpha task',
499
+ }),
500
+ ],
501
+ }}
502
+ />
503
+ );
504
+
505
+ const upcomingSection = screen.getByTestId(
506
+ 'kanban-deadline-section-upcoming'
507
+ );
508
+
509
+ expect(
510
+ within(upcomingSection)
511
+ .getAllByTestId(/shared-task-card-/)
512
+ .map((item) => item.textContent)
513
+ ).toEqual(['Zulu task', 'Alpha task']);
514
+
515
+ fireEvent.pointerDown(
516
+ within(upcomingSection).getByRole('button', { name: 'Sort' }),
517
+ { button: 0, ctrlKey: false }
518
+ );
519
+ fireEvent.click(screen.getByRole('menuitemradio', { name: 'Task name' }));
520
+
521
+ expect(
522
+ within(upcomingSection)
523
+ .getAllByTestId(/shared-task-card-/)
524
+ .map((item) => item.textContent)
525
+ ).toEqual(['Alpha task', 'Zulu task']);
526
+ });
527
+
528
+ it('anchors the first load on the first real task list when special columns render to the left', () => {
529
+ const frameCallbacks: FrameRequestCallback[] = [];
530
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
531
+ const originalCancelAnimationFrame = window.cancelAnimationFrame;
532
+
533
+ window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
534
+ frameCallbacks.push(callback);
535
+ return frameCallbacks.length;
536
+ });
537
+ window.cancelAnimationFrame = vi.fn();
538
+
539
+ try {
540
+ const { container, rerender } = render(
541
+ <KanbanColumns
542
+ columns={[externalList, ...lists]}
543
+ tasks={[]}
544
+ boardId="board-1"
545
+ workspaceId="ws-1"
546
+ isPersonalWorkspace
547
+ disableSort={false}
548
+ selectedTasks={new Set()}
549
+ isMultiSelectMode={false}
550
+ setIsMultiSelectMode={vi.fn()}
551
+ onTaskSelect={vi.fn()}
552
+ onClearSelection={vi.fn()}
553
+ onUpdate={vi.fn()}
554
+ createTask={vi.fn()}
555
+ taskHeightsRef={{ current: new Map() }}
556
+ optimisticUpdateInProgress={new Set()}
557
+ bulkUpdateCustomDueDate={vi.fn()}
558
+ boardRef={{ current: null }}
559
+ columnsId={[externalList, ...lists].map((list) => list.id)}
560
+ deadlineLabels={{
561
+ overdue: 'Overdue',
562
+ upcoming: 'Upcoming',
563
+ }}
564
+ deadlineSections={{
565
+ overdue: [],
566
+ upcoming: [
567
+ task({
568
+ end_date: '2026-06-02T00:00:00.000Z',
569
+ id: 'upcoming-task',
570
+ list_id: 'list-1',
571
+ name: 'Upcoming task',
572
+ }),
573
+ ],
574
+ }}
575
+ />
576
+ );
577
+ const scrollContainer = container.firstElementChild as HTMLElement;
578
+ const firstRealColumn = screen.getByTestId('column-list-1');
579
+ Object.defineProperty(firstRealColumn, 'offsetLeft', {
580
+ configurable: true,
581
+ value: 320,
582
+ });
583
+
584
+ act(() => {
585
+ for (const callback of frameCallbacks) callback(0);
586
+ });
587
+
588
+ expect(scrollContainer.scrollLeft).toBe(312);
589
+
590
+ scrollContainer.scrollLeft = 64;
591
+ rerender(
592
+ <KanbanColumns
593
+ columns={[externalList, ...lists]}
594
+ tasks={[]}
595
+ boardId="board-1"
596
+ workspaceId="ws-1"
597
+ isPersonalWorkspace
598
+ disableSort={false}
599
+ selectedTasks={new Set()}
600
+ isMultiSelectMode={false}
601
+ setIsMultiSelectMode={vi.fn()}
602
+ onTaskSelect={vi.fn()}
603
+ onClearSelection={vi.fn()}
604
+ onUpdate={vi.fn()}
605
+ createTask={vi.fn()}
606
+ taskHeightsRef={{ current: new Map() }}
607
+ optimisticUpdateInProgress={new Set()}
608
+ bulkUpdateCustomDueDate={vi.fn()}
609
+ boardRef={{ current: null }}
610
+ columnsId={[externalList, ...lists].map((list) => list.id)}
611
+ deadlineLabels={{
612
+ overdue: 'Overdue',
613
+ upcoming: 'Upcoming',
614
+ }}
615
+ deadlineSections={{
616
+ overdue: [],
617
+ upcoming: [
618
+ task({
619
+ end_date: '2026-06-02T00:00:00.000Z',
620
+ id: 'upcoming-task',
621
+ list_id: 'list-1',
622
+ name: 'Upcoming task',
623
+ }),
624
+ ],
625
+ }}
626
+ />
627
+ );
628
+
629
+ expect(scrollContainer.scrollLeft).toBe(64);
630
+ } finally {
631
+ window.requestAnimationFrame = originalRequestAnimationFrame;
632
+ window.cancelAnimationFrame = originalCancelAnimationFrame;
633
+ }
634
+ });
635
+
339
636
  it('renders external deadline cards with their staging list context without exposing the staging list as a move target', () => {
340
637
  render(
341
638
  <KanbanColumns
@@ -392,6 +689,115 @@ describe('KanbanColumns', () => {
392
689
  expect(
393
690
  externalCardProps.availableLists.map((list: TaskList) => list.id)
394
691
  ).toEqual(['list-1', 'list-2']);
692
+ expect(externalCardProps).toEqual(
693
+ expect.objectContaining({
694
+ deadlineContext: 'upcoming',
695
+ })
696
+ );
697
+ });
698
+
699
+ it('renders collapsed deadline sections with counts and expand labels', () => {
700
+ render(
701
+ <KanbanColumns
702
+ columns={lists}
703
+ tasks={[]}
704
+ boardId="board-1"
705
+ workspaceId="ws-1"
706
+ isPersonalWorkspace={false}
707
+ disableSort={false}
708
+ selectedTasks={new Set()}
709
+ isMultiSelectMode={false}
710
+ setIsMultiSelectMode={vi.fn()}
711
+ onTaskSelect={vi.fn()}
712
+ onClearSelection={vi.fn()}
713
+ onUpdate={vi.fn()}
714
+ createTask={vi.fn()}
715
+ taskHeightsRef={{ current: new Map() }}
716
+ optimisticUpdateInProgress={new Set()}
717
+ bulkUpdateCustomDueDate={vi.fn()}
718
+ boardRef={{ current: null }}
719
+ columnsId={lists.map((list) => list.id)}
720
+ deadlineLabels={{
721
+ expandSection: (name) => `Expand ${name}`,
722
+ overdue: 'Overdue',
723
+ upcoming: 'Upcoming',
724
+ }}
725
+ deadlineSections={{
726
+ overdue: [
727
+ task({
728
+ end_date: '2026-05-06T00:00:00.000Z',
729
+ id: 'overdue-task',
730
+ list_id: 'list-1',
731
+ name: 'Overdue task',
732
+ }),
733
+ ],
734
+ upcoming: [],
735
+ }}
736
+ deadlineSectionsCollapsed={{ overdue: true }}
737
+ />
738
+ );
739
+
740
+ const collapsedOverdue = screen.getByTestId(
741
+ 'kanban-deadline-section-overdue-collapsed'
742
+ );
743
+
744
+ expect(collapsedOverdue).toHaveTextContent('Overdue');
745
+ expect(collapsedOverdue).toHaveTextContent('1');
746
+ expect(
747
+ screen.getByRole('button', { name: 'Expand Overdue' })
748
+ ).toBeInTheDocument();
749
+ });
750
+
751
+ it('passes deadline tick props to upcoming deadline cards', () => {
752
+ render(
753
+ <KanbanColumns
754
+ columns={lists}
755
+ tasks={[]}
756
+ boardId="board-1"
757
+ workspaceId="ws-1"
758
+ isPersonalWorkspace={false}
759
+ disableSort={false}
760
+ selectedTasks={new Set()}
761
+ isMultiSelectMode={false}
762
+ setIsMultiSelectMode={vi.fn()}
763
+ onTaskSelect={vi.fn()}
764
+ onClearSelection={vi.fn()}
765
+ onUpdate={vi.fn()}
766
+ createTask={vi.fn()}
767
+ taskHeightsRef={{ current: new Map() }}
768
+ optimisticUpdateInProgress={new Set()}
769
+ bulkUpdateCustomDueDate={vi.fn()}
770
+ boardRef={{ current: null }}
771
+ columnsId={lists.map((list) => list.id)}
772
+ deadlineLabels={{
773
+ overdue: 'Overdue',
774
+ upcoming: 'Upcoming',
775
+ }}
776
+ deadlineNow={1_779_840_000_000}
777
+ deadlineSections={{
778
+ overdue: [],
779
+ upcoming: [
780
+ task({
781
+ end_date: '2026-06-01T00:00:00.000Z',
782
+ id: 'upcoming-task',
783
+ list_id: 'list-1',
784
+ name: 'Upcoming task',
785
+ }),
786
+ ],
787
+ }}
788
+ />
789
+ );
790
+
791
+ const upcomingCardProps = taskCardMock.mock.calls.find(
792
+ ([props]) => props.task.id === 'upcoming-task'
793
+ )?.[0];
794
+
795
+ expect(upcomingCardProps).toEqual(
796
+ expect.objectContaining({
797
+ deadlineContext: 'upcoming',
798
+ deadlineNow: 1_779_840_000_000,
799
+ })
800
+ );
395
801
  });
396
802
 
397
803
  it('omits deadline panels when both deadline sections are empty', () => {
@@ -6,18 +6,26 @@ import {
6
6
  } from '@dnd-kit/sortable';
7
7
  import type { Task } from '@tuturuuu/types/primitives/Task';
8
8
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
9
+ import { getBoardRealtimeChannelName } from '@tuturuuu/ui/hooks/useBoardRealtime.types';
10
+ import { useLayoutEffect, useRef } from 'react';
9
11
  import type { ListStatusFilter } from '../../../../shared/board-header';
10
12
  import CursorOverlayMultiWrapper from '../../../../shared/cursor-overlay-multi-wrapper';
13
+ import type {
14
+ SpecialTaskListPin,
15
+ SpecialTaskListPinState,
16
+ } from '../../../../shared/special-task-list-pins';
11
17
  import { BoardColumn } from '../../board-column';
12
18
  import type { TaskFilters } from '../../task-filter';
13
19
  import { TaskListForm } from '../../task-list-form';
20
+ import { compareTasksByEffectiveSortKey } from '../dnd/task-sort-key';
14
21
  import type { DragPreviewPosition } from '../dnd/use-kanban-dnd';
15
22
  import { isKanbanColumnCollapsed } from '../kanban-column-collapse';
16
- import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
17
23
  import { getKanbanColumnWidth } from './kanban-column-width';
18
24
  import {
25
+ type KanbanDeadlineCollapsedState,
19
26
  type KanbanDeadlineLabels,
20
27
  KanbanDeadlinePanels,
28
+ type KanbanDeadlineSection,
21
29
  } from './kanban-deadline-panels';
22
30
  import type { KanbanDeadlineSections } from './kanban-deadline-tasks';
23
31
 
@@ -54,6 +62,18 @@ interface KanbanColumnsProps {
54
62
  onTaskListCollapsedChange?: (listId: string, collapsed: boolean) => void;
55
63
  deadlineLabels?: KanbanDeadlineLabels;
56
64
  deadlineSections?: KanbanDeadlineSections;
65
+ deadlineSectionsCollapsed?: KanbanDeadlineCollapsedState;
66
+ deadlineNow?: number;
67
+ onDeadlineSectionCollapsedChange?: (
68
+ section: KanbanDeadlineSection,
69
+ collapsed: boolean
70
+ ) => void;
71
+ specialTaskListPins?: SpecialTaskListPinState;
72
+ onSpecialTaskListPinnedChange?: (
73
+ pin: SpecialTaskListPin,
74
+ pinned: boolean
75
+ ) => void;
76
+ readOnly?: boolean;
57
77
  }
58
78
 
59
79
  export function KanbanColumns({
@@ -84,16 +104,64 @@ export function KanbanColumns({
84
104
  onTaskListCollapsedChange,
85
105
  deadlineLabels,
86
106
  deadlineSections,
107
+ deadlineSectionsCollapsed,
108
+ deadlineNow,
109
+ onDeadlineSectionCollapsedChange,
110
+ specialTaskListPins,
111
+ onSpecialTaskListPinnedChange,
112
+ readOnly = false,
87
113
  }: KanbanColumnsProps) {
114
+ const initialScrollAnchoredBoardRef = useRef<string | null>(null);
88
115
  const realColumns = columns.filter((column) => !column.is_external_staging);
116
+ const deadlineSectionOrder: KanbanDeadlineSection[] = ['overdue', 'upcoming'];
117
+ const visibleDeadlineSections =
118
+ !readOnly && deadlineSections
119
+ ? deadlineSectionOrder.filter(
120
+ (section) => deadlineSections[section].length > 0
121
+ )
122
+ : [];
89
123
  const snapEdgePadding = columns.length > 0 ? '0.5rem' : '0px';
90
- const collapsedColumnCount = columns.filter(isKanbanColumnCollapsed).length;
124
+ const collapsedColumnCount =
125
+ columns.filter(isKanbanColumnCollapsed).length +
126
+ visibleDeadlineSections.filter(
127
+ (section) => deadlineSectionsCollapsed?.[section] === true
128
+ ).length;
91
129
  const dynamicColumnWidth = getKanbanColumnWidth({
92
- columnCount: columns.length,
130
+ columnCount: columns.length + visibleDeadlineSections.length,
93
131
  collapsedColumnCount,
94
132
  snapEdgePadding,
95
133
  fillAvailableWidth: listStatusFilter === 'all',
96
134
  });
135
+ const hasLeftSpecialColumns =
136
+ visibleDeadlineSections.length > 0 ||
137
+ columns.some((column) => column.is_external_staging);
138
+
139
+ useLayoutEffect(() => {
140
+ if (!hasLeftSpecialColumns) return;
141
+ if (initialScrollAnchoredBoardRef.current === boardId) return;
142
+
143
+ const container = boardRef.current;
144
+ if (!container) return;
145
+
146
+ const target = container.querySelector<HTMLElement>(
147
+ '[data-kanban-real-column="true"]'
148
+ );
149
+ if (!target) return;
150
+
151
+ initialScrollAnchoredBoardRef.current = boardId;
152
+
153
+ const anchor = () => {
154
+ container.scrollLeft = Math.max(0, target.offsetLeft - 8);
155
+ };
156
+
157
+ if (typeof window.requestAnimationFrame !== 'function') {
158
+ anchor();
159
+ return;
160
+ }
161
+
162
+ const frame = window.requestAnimationFrame(anchor);
163
+ return () => window.cancelAnimationFrame?.(frame);
164
+ }, [boardId, boardRef, hasLeftSpecialColumns]);
97
165
 
98
166
  return (
99
167
  <div
@@ -120,7 +188,7 @@ export function KanbanColumns({
120
188
  paddingRight: 'var(--kanban-snap-right-padding)',
121
189
  }}
122
190
  >
123
- {deadlineSections && deadlineLabels && (
191
+ {!readOnly && deadlineSections && deadlineLabels && (
124
192
  <KanbanDeadlinePanels
125
193
  availableLists={realColumns}
126
194
  boardId={boardId}
@@ -129,10 +197,20 @@ export function KanbanColumns({
129
197
  isPersonalWorkspace={isPersonalWorkspace}
130
198
  labels={deadlineLabels}
131
199
  onClearSelection={onClearSelection}
200
+ onSectionCollapsedChange={onDeadlineSectionCollapsedChange}
132
201
  onTaskSelect={onTaskSelect}
133
202
  onUpdate={onUpdate}
134
203
  optimisticUpdateInProgress={optimisticUpdateInProgress}
135
204
  sections={deadlineSections}
205
+ collapsedSections={deadlineSectionsCollapsed}
206
+ deadlineNow={deadlineNow}
207
+ pinnedSections={{
208
+ overdue: specialTaskListPins?.overdue,
209
+ upcoming: specialTaskListPins?.upcoming,
210
+ }}
211
+ onSectionPinnedChange={(section, pinned) =>
212
+ onSpecialTaskListPinnedChange?.(section, pinned)
213
+ }
136
214
  selectedTasks={selectedTasks}
137
215
  taskLists={columns}
138
216
  workspaceId={workspaceId}
@@ -169,14 +247,7 @@ export function KanbanColumns({
169
247
 
170
248
  // For all other lists, only sort by sort_key if parent hasn't already sorted
171
249
  if (!disableSort) {
172
- const sortA = a.sort_key ?? MAX_SAFE_INTEGER_SORT;
173
- const sortB = b.sort_key ?? MAX_SAFE_INTEGER_SORT;
174
- if (sortA !== sortB) return sortA - sortB;
175
- if (!a.created_at || !b.created_at) return 0;
176
- return (
177
- new Date(a.created_at).getTime() -
178
- new Date(b.created_at).getTime()
179
- );
250
+ return compareTasksByEffectiveSortKey(a, b);
180
251
  }
181
252
 
182
253
  return 0;
@@ -209,18 +280,39 @@ export function KanbanColumns({
209
280
  wsId={workspaceId}
210
281
  onExternalTasksCollapsedChange={onExternalTasksCollapsedChange}
211
282
  onTaskListCollapsedChange={onTaskListCollapsedChange}
283
+ specialPinned={
284
+ list.is_external_staging
285
+ ? specialTaskListPins?.external_tasks === true
286
+ : list.status === 'closed'
287
+ ? specialTaskListPins?.closed_tasks === true
288
+ : false
289
+ }
290
+ onSpecialPinnedChange={(pinned) => {
291
+ if (list.is_external_staging) {
292
+ onSpecialTaskListPinnedChange?.('external_tasks', pinned);
293
+ return;
294
+ }
295
+
296
+ if (list.status === 'closed') {
297
+ onSpecialTaskListPinnedChange?.('closed_tasks', pinned);
298
+ }
299
+ }}
300
+ readOnly={readOnly}
212
301
  />
213
302
  );
214
303
  })}
215
- <TaskListForm boardId={boardId ?? ''} onListCreated={onUpdate} />
304
+ {!readOnly && (
305
+ <TaskListForm boardId={boardId ?? ''} onListCreated={onUpdate} />
306
+ )}
216
307
  </div>
217
308
  </SortableContext>
218
309
 
219
310
  {/* Overlay for collaborator cursors (gated on tier — free workspaces don't get board cursors) */}
220
311
  {!isPersonalWorkspace && boardId && cursorsEnabled && (
221
312
  <CursorOverlayMultiWrapper
222
- channelName={`board-cursor-${boardId}`}
313
+ channelName={getBoardRealtimeChannelName(boardId)}
223
314
  containerRef={boardRef}
315
+ cursorScope={{ boardId, type: 'board' }}
224
316
  listStatusFilter={listStatusFilter}
225
317
  filters={filters}
226
318
  />