@tuturuuu/ui 0.8.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 (182) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/biome.json +1 -1
  3. package/package.json +73 -71
  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/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  29. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  30. package/src/components/ui/custom/combobox.test.tsx +195 -0
  31. package/src/components/ui/custom/combobox.tsx +273 -156
  32. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  33. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  34. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  35. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  36. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  37. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  38. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  39. package/src/components/ui/custom/workspace-select.tsx +8 -3
  40. package/src/components/ui/dialog.test.tsx +52 -0
  41. package/src/components/ui/dialog.tsx +6 -2
  42. package/src/components/ui/dropdown-menu.tsx +5 -1
  43. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  44. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  45. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  46. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  47. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  48. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  49. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  50. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  51. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  52. package/src/components/ui/finance/invoices/utils.ts +3 -1
  53. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  54. package/src/components/ui/finance/transactions/form-types.ts +1 -0
  55. package/src/components/ui/finance/transactions/form.tsx +2 -0
  56. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  57. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  58. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  59. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  60. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  61. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  62. package/src/components/ui/finance/wallets/form.tsx +15 -4
  63. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  64. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  65. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  66. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  67. package/src/components/ui/input-otp.tsx +1 -1
  68. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  69. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  70. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  71. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  72. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  73. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  74. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  75. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  76. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  77. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  78. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  79. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  80. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  81. package/src/components/ui/navigation-menu.tsx +1 -1
  82. package/src/components/ui/pagination.tsx +1 -1
  83. package/src/components/ui/radio-group.tsx +1 -1
  84. package/src/components/ui/select.tsx +5 -1
  85. package/src/components/ui/sheet.tsx +1 -1
  86. package/src/components/ui/sidebar.tsx +1 -1
  87. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  88. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  89. package/src/components/ui/storefront/cart-summary.tsx +93 -154
  90. package/src/components/ui/storefront/checkout-overlay.tsx +4 -5
  91. package/src/components/ui/storefront/listing-card.tsx +1 -1
  92. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  93. package/src/components/ui/storefront/product-detail.tsx +1 -1
  94. package/src/components/ui/storefront/storefront-surface.test.tsx +106 -11
  95. package/src/components/ui/storefront/storefront-surface.tsx +101 -166
  96. package/src/components/ui/storefront/types.ts +4 -0
  97. package/src/components/ui/storefront/utils.ts +6 -0
  98. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  99. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  100. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  101. package/src/components/ui/text-editor/editor.tsx +69 -14
  102. package/src/components/ui/text-editor/extensions.ts +8 -2
  103. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  104. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  105. package/src/components/ui/toast.tsx +1 -1
  106. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  107. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  108. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  109. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +112 -43
  110. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  111. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  112. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  113. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  114. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  115. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  116. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +38 -9
  117. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  118. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  119. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +81 -30
  120. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  121. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  122. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  123. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  124. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  125. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  126. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  127. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  128. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  129. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  130. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  131. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +397 -2
  132. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +103 -13
  133. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  134. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  135. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  136. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +26 -4
  137. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +5 -2
  138. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  139. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  140. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  141. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  142. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  143. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  144. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  145. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  146. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  147. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  148. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  149. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  150. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  151. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  152. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  153. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  154. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +203 -2
  155. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  156. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  157. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  158. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  159. package/src/components/ui/tu-do/shared/board-header.tsx +464 -975
  160. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  161. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  162. package/src/components/ui/tu-do/shared/board-views.tsx +587 -75
  163. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  164. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  165. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  166. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  167. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  168. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  169. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  170. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  171. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  172. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +2 -1
  173. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  174. package/src/declarations.d.ts +1 -0
  175. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  176. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  177. package/src/hooks/use-calendar-sync.tsx +247 -243
  178. package/src/hooks/use-calendar.tsx +323 -138
  179. package/src/hooks/use-task-actions.ts +24 -0
  180. package/src/hooks/use-user-workspace-config.ts +75 -0
  181. package/src/hooks/use-workspace-currency.ts +8 -3
  182. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
@@ -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';
@@ -21,7 +21,10 @@ vi.mock('@dnd-kit/sortable', () => ({
21
21
 
22
22
  vi.mock('../../board-column', () => ({
23
23
  BoardColumn: ({ column }: { column: TaskList }) => (
24
- <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
+ />
25
28
  ),
26
29
  }));
27
30
 
@@ -317,10 +320,18 @@ describe('KanbanColumns', () => {
317
320
  );
318
321
 
319
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
+ );
320
329
  const firstColumn = screen.getByTestId('column-list-1');
321
330
  const sharedTaskCard = screen.getByTestId('shared-task-card-overdue-task');
322
331
 
332
+ expect(overdueSection).toHaveClass('border-dashed');
323
333
  expect(deadlinePanels).toHaveTextContent('Overdue');
334
+ expect(overdueCount).toHaveTextContent('1');
324
335
  expect(sharedTaskCard).toHaveTextContent('Overdue task');
325
336
  expect(deadlinePanels.compareDocumentPosition(firstColumn)).toBe(
326
337
  Node.DOCUMENT_POSITION_FOLLOWING
@@ -347,6 +358,281 @@ describe('KanbanColumns', () => {
347
358
  ).toEqual(['list-1', 'list-2']);
348
359
  });
349
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
+
350
636
  it('renders external deadline cards with their staging list context without exposing the staging list as a move target', () => {
351
637
  render(
352
638
  <KanbanColumns
@@ -403,6 +689,115 @@ describe('KanbanColumns', () => {
403
689
  expect(
404
690
  externalCardProps.availableLists.map((list: TaskList) => list.id)
405
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
+ );
406
801
  });
407
802
 
408
803
  it('omits deadline panels when both deadline sections are empty', () => {
@@ -7,18 +7,25 @@ import {
7
7
  import type { Task } from '@tuturuuu/types/primitives/Task';
8
8
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
9
9
  import { getBoardRealtimeChannelName } from '@tuturuuu/ui/hooks/useBoardRealtime.types';
10
+ import { useLayoutEffect, useRef } from 'react';
10
11
  import type { ListStatusFilter } from '../../../../shared/board-header';
11
12
  import CursorOverlayMultiWrapper from '../../../../shared/cursor-overlay-multi-wrapper';
13
+ import type {
14
+ SpecialTaskListPin,
15
+ SpecialTaskListPinState,
16
+ } from '../../../../shared/special-task-list-pins';
12
17
  import { BoardColumn } from '../../board-column';
13
18
  import type { TaskFilters } from '../../task-filter';
14
19
  import { TaskListForm } from '../../task-list-form';
20
+ import { compareTasksByEffectiveSortKey } from '../dnd/task-sort-key';
15
21
  import type { DragPreviewPosition } from '../dnd/use-kanban-dnd';
16
22
  import { isKanbanColumnCollapsed } from '../kanban-column-collapse';
17
- import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
18
23
  import { getKanbanColumnWidth } from './kanban-column-width';
19
24
  import {
25
+ type KanbanDeadlineCollapsedState,
20
26
  type KanbanDeadlineLabels,
21
27
  KanbanDeadlinePanels,
28
+ type KanbanDeadlineSection,
22
29
  } from './kanban-deadline-panels';
23
30
  import type { KanbanDeadlineSections } from './kanban-deadline-tasks';
24
31
 
@@ -55,6 +62,18 @@ interface KanbanColumnsProps {
55
62
  onTaskListCollapsedChange?: (listId: string, collapsed: boolean) => void;
56
63
  deadlineLabels?: KanbanDeadlineLabels;
57
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;
58
77
  }
59
78
 
60
79
  export function KanbanColumns({
@@ -85,16 +104,64 @@ export function KanbanColumns({
85
104
  onTaskListCollapsedChange,
86
105
  deadlineLabels,
87
106
  deadlineSections,
107
+ deadlineSectionsCollapsed,
108
+ deadlineNow,
109
+ onDeadlineSectionCollapsedChange,
110
+ specialTaskListPins,
111
+ onSpecialTaskListPinnedChange,
112
+ readOnly = false,
88
113
  }: KanbanColumnsProps) {
114
+ const initialScrollAnchoredBoardRef = useRef<string | null>(null);
89
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
+ : [];
90
123
  const snapEdgePadding = columns.length > 0 ? '0.5rem' : '0px';
91
- const collapsedColumnCount = columns.filter(isKanbanColumnCollapsed).length;
124
+ const collapsedColumnCount =
125
+ columns.filter(isKanbanColumnCollapsed).length +
126
+ visibleDeadlineSections.filter(
127
+ (section) => deadlineSectionsCollapsed?.[section] === true
128
+ ).length;
92
129
  const dynamicColumnWidth = getKanbanColumnWidth({
93
- columnCount: columns.length,
130
+ columnCount: columns.length + visibleDeadlineSections.length,
94
131
  collapsedColumnCount,
95
132
  snapEdgePadding,
96
133
  fillAvailableWidth: listStatusFilter === 'all',
97
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]);
98
165
 
99
166
  return (
100
167
  <div
@@ -121,7 +188,7 @@ export function KanbanColumns({
121
188
  paddingRight: 'var(--kanban-snap-right-padding)',
122
189
  }}
123
190
  >
124
- {deadlineSections && deadlineLabels && (
191
+ {!readOnly && deadlineSections && deadlineLabels && (
125
192
  <KanbanDeadlinePanels
126
193
  availableLists={realColumns}
127
194
  boardId={boardId}
@@ -130,10 +197,20 @@ export function KanbanColumns({
130
197
  isPersonalWorkspace={isPersonalWorkspace}
131
198
  labels={deadlineLabels}
132
199
  onClearSelection={onClearSelection}
200
+ onSectionCollapsedChange={onDeadlineSectionCollapsedChange}
133
201
  onTaskSelect={onTaskSelect}
134
202
  onUpdate={onUpdate}
135
203
  optimisticUpdateInProgress={optimisticUpdateInProgress}
136
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
+ }
137
214
  selectedTasks={selectedTasks}
138
215
  taskLists={columns}
139
216
  workspaceId={workspaceId}
@@ -170,14 +247,7 @@ export function KanbanColumns({
170
247
 
171
248
  // For all other lists, only sort by sort_key if parent hasn't already sorted
172
249
  if (!disableSort) {
173
- const sortA = a.sort_key ?? MAX_SAFE_INTEGER_SORT;
174
- const sortB = b.sort_key ?? MAX_SAFE_INTEGER_SORT;
175
- if (sortA !== sortB) return sortA - sortB;
176
- if (!a.created_at || !b.created_at) return 0;
177
- return (
178
- new Date(a.created_at).getTime() -
179
- new Date(b.created_at).getTime()
180
- );
250
+ return compareTasksByEffectiveSortKey(a, b);
181
251
  }
182
252
 
183
253
  return 0;
@@ -210,10 +280,30 @@ export function KanbanColumns({
210
280
  wsId={workspaceId}
211
281
  onExternalTasksCollapsedChange={onExternalTasksCollapsedChange}
212
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}
213
301
  />
214
302
  );
215
303
  })}
216
- <TaskListForm boardId={boardId ?? ''} onListCreated={onUpdate} />
304
+ {!readOnly && (
305
+ <TaskListForm boardId={boardId ?? ''} onListCreated={onUpdate} />
306
+ )}
217
307
  </div>
218
308
  </SortableContext>
219
309