@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
@@ -111,6 +111,28 @@ describe('TaskBoardServerPage', () => {
111
111
  expect(mocks.getWorkspace).not.toHaveBeenCalled();
112
112
  });
113
113
 
114
+ it('treats invalid board routes as not found instead of crashing the server render', async () => {
115
+ mocks.getWorkspaceTaskBoard.mockRejectedValue(
116
+ new mocks.InternalApiError('Invalid workspace or board ID', 400)
117
+ );
118
+
119
+ await expect(renderServerPage()).rejects.toThrow('NEXT_NOT_FOUND');
120
+
121
+ expect(mocks.getWorkspace).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('keeps unexpected board loader failures visible', async () => {
125
+ mocks.getWorkspaceTaskBoard.mockRejectedValue(
126
+ new mocks.InternalApiError('Failed to load task board', 500)
127
+ );
128
+
129
+ await expect(renderServerPage()).rejects.toThrow(
130
+ 'Failed to load task board'
131
+ );
132
+
133
+ expect(mocks.getWorkspace).not.toHaveBeenCalled();
134
+ });
135
+
114
136
  it('uses a minimal workspace shell for board guests', async () => {
115
137
  mocks.getWorkspaceTaskBoard.mockResolvedValue({
116
138
  board: {
@@ -134,10 +156,10 @@ describe('TaskBoardServerPage', () => {
134
156
  expect(element.props.currentUserId).toBe('user-1');
135
157
  });
136
158
 
137
- it('loads the full member workspace only after board access succeeds', async () => {
159
+ it('loads the full member workspace from the resolved board workspace', async () => {
138
160
  const workspace = {
139
161
  creator_id: 'creator-1',
140
- id: 'ws-1',
162
+ id: 'ws-board',
141
163
  joined: true,
142
164
  name: 'Member Workspace',
143
165
  personal: false,
@@ -147,14 +169,14 @@ describe('TaskBoardServerPage', () => {
147
169
  board: {
148
170
  access_type: 'member',
149
171
  id: BOARD_ID,
150
- ws_id: 'ws-1',
172
+ ws_id: 'ws-board',
151
173
  },
152
174
  });
153
175
  mocks.getWorkspace.mockResolvedValue(workspace);
154
176
 
155
177
  const element = await renderServerPage();
156
178
 
157
- expect(mocks.getWorkspace).toHaveBeenCalledWith('ws-1', {
179
+ expect(mocks.getWorkspace).toHaveBeenCalledWith('ws-board', {
158
180
  useAdmin: true,
159
181
  });
160
182
  expect(element.type).toBe(mocks.BoardClient);
@@ -53,7 +53,10 @@ async function getAuthorizedBoard(wsId: string, boardId: string) {
53
53
  } catch (error) {
54
54
  if (
55
55
  error instanceof InternalApiError &&
56
- (error.status === 401 || error.status === 403 || error.status === 404)
56
+ (error.status === 400 ||
57
+ error.status === 401 ||
58
+ error.status === 403 ||
59
+ error.status === 404)
57
60
  ) {
58
61
  return null;
59
62
  }
@@ -82,7 +85,7 @@ export default async function TaskBoardServerPage({
82
85
 
83
86
  const isMemberBoardAccess = board.access_type === 'member';
84
87
  const workspace = isMemberBoardAccess
85
- ? await getWorkspace(id, { useAdmin: true })
88
+ ? await getWorkspace(board.ws_id, { useAdmin: true })
86
89
  : createBoardGuestWorkspace(board.ws_id);
87
90
  if (!workspace) notFound();
88
91
 
@@ -21,6 +21,7 @@ interface MeasuredTaskCardProps {
21
21
  optimisticUpdateInProgress?: Set<string>;
22
22
  selectedTasks?: Set<string>;
23
23
  bulkUpdateCustomDueDate?: (date: Date | null) => Promise<void>;
24
+ readOnly?: boolean;
24
25
  }
25
26
 
26
27
  export function MeasuredTaskCard({
@@ -41,6 +42,7 @@ export function MeasuredTaskCard({
41
42
  optimisticUpdateInProgress,
42
43
  selectedTasks,
43
44
  bulkUpdateCustomDueDate,
45
+ readOnly = false,
44
46
  }: MeasuredTaskCardProps) {
45
47
  const ref = useRef<HTMLDivElement | null>(null);
46
48
  const onHeightRef = useRef(onHeight);
@@ -103,6 +105,7 @@ export function MeasuredTaskCard({
103
105
  optimisticUpdateInProgress={optimisticUpdateInProgress}
104
106
  selectedTasks={selectedTasks}
105
107
  bulkUpdateCustomDueDate={bulkUpdateCustomDueDate}
108
+ readOnly={readOnly}
106
109
  />
107
110
  </div>
108
111
  );
@@ -25,6 +25,7 @@ export function areTaskCardPropsEqual(
25
25
  next: TaskCardProps
26
26
  ) {
27
27
  if (prev.isOverlay !== next.isOverlay) return false;
28
+ if (prev.readOnly !== next.readOnly) return false;
28
29
  if (prev.isSelected !== next.isSelected) return false;
29
30
  if (prev.isMultiSelectMode !== next.isMultiSelectMode) return false;
30
31
  if (
@@ -36,6 +37,8 @@ export function areTaskCardPropsEqual(
36
37
  }
37
38
  if (prev.boardId !== next.boardId) return false;
38
39
  if (prev.workspaceId !== next.workspaceId) return false;
40
+ if (prev.deadlineContext !== next.deadlineContext) return false;
41
+ if (prev.deadlineNow !== next.deadlineNow) return false;
39
42
  if (prev.dragDisabled !== next.dragDisabled) return false;
40
43
  if (prev.sortableId !== next.sortableId) return false;
41
44
  if (prev.suppressSortableTransform !== next.suppressSortableTransform) {
@@ -69,7 +69,7 @@ import {
69
69
  import { isTaskBoardResolvedStatus } from '@tuturuuu/utils/task-list-status';
70
70
  import { getDescriptionMetadata } from '@tuturuuu/utils/text-helper';
71
71
  import { getTimeFormatPattern } from '@tuturuuu/utils/time-helper';
72
- import { format, formatDistanceToNow } from 'date-fns';
72
+ import { format, formatDistance } from 'date-fns';
73
73
  import { enUS, vi } from 'date-fns/locale';
74
74
  import Link from 'next/link';
75
75
  import { useParams } from 'next/navigation';
@@ -116,6 +116,7 @@ import {
116
116
  import { formatSmartDate } from '../../../utils/taskDateUtils';
117
117
  import { getPriorityIndicator } from '../../../utils/taskPriorityUtils';
118
118
  import { sortByDisplayName } from '../board-text-utils';
119
+ import { invalidateKanbanDeadlineTasks } from '../kanban/data/kanban-deadline-query';
119
120
  import {
120
121
  TaskAssigneesMenu,
121
122
  TaskBlockingMenu,
@@ -170,6 +171,128 @@ export interface TaskCardProps {
170
171
  optimisticUpdateInProgress?: Set<string>;
171
172
  selectedTasks?: Set<string>; // For bulk operations
172
173
  bulkUpdateCustomDueDate?: (date: Date | null) => Promise<void>; // From useBulkOperations
174
+ deadlineContext?: 'overdue' | 'upcoming';
175
+ deadlineNow?: number;
176
+ readOnly?: boolean;
177
+ }
178
+
179
+ function ReadOnlyTaskCard({ task, taskList }: TaskCardProps) {
180
+ const t = useTranslations('common');
181
+ const publicBoardT = useTranslations('ws-task-boards.public');
182
+ const priorityT = useTranslations('ws-task-boards.dialog.priority');
183
+ const locale = useLocale();
184
+ const dateLocale = locale === 'vi' ? vi : enUS;
185
+ const ticketPrefix = (task as Task & { ticket_prefix?: string | null })
186
+ .ticket_prefix;
187
+ const ticketIdentifier =
188
+ typeof task.display_number === 'number' && task.display_number > 0
189
+ ? getTicketIdentifier(ticketPrefix, task.display_number)
190
+ : null;
191
+ const dueDate = task.end_date
192
+ ? format(new Date(task.end_date), 'MMM d, yyyy', { locale: dateLocale })
193
+ : null;
194
+
195
+ return (
196
+ <Card
197
+ className={cn(
198
+ 'relative overflow-hidden rounded-lg border border-l-4 bg-background p-3 shadow-xs',
199
+ getCardColorClassesUtil(taskList, task.priority),
200
+ task.closed_at && 'opacity-60 saturate-50'
201
+ )}
202
+ data-task-card-id={task.id}
203
+ data-task-read-only="true"
204
+ >
205
+ <TaskCardIdentifierRow
206
+ externalSourceLabel=""
207
+ isMultiSelectMode={false}
208
+ isPersonalExternalTask={false}
209
+ isSelected={false}
210
+ selectTaskLabel={t('select_task', { name: task.name })}
211
+ taskListStatus={taskList?.status}
212
+ ticketBadgeClassName={getTicketBadgeColorClasses(
213
+ taskList,
214
+ task.priority
215
+ )}
216
+ ticketIdentifier={ticketIdentifier}
217
+ ticketTitle={ticketIdentifier ?? ''}
218
+ />
219
+
220
+ <div className="space-y-2">
221
+ <h3
222
+ className={cn(
223
+ 'line-clamp-2 break-words font-medium text-sm leading-5',
224
+ (task.completed_at || task.closed_at) &&
225
+ 'text-muted-foreground line-through'
226
+ )}
227
+ >
228
+ {task.name}
229
+ </h3>
230
+
231
+ <div className="flex flex-wrap items-center gap-1.5">
232
+ {task.priority && (
233
+ <Badge variant="outline" className="h-5 px-1.5 font-normal text-xs">
234
+ {priorityT(task.priority)}
235
+ </Badge>
236
+ )}
237
+ {dueDate && (
238
+ <Badge
239
+ variant="secondary"
240
+ className="h-5 gap-1 px-1.5 font-normal text-xs"
241
+ >
242
+ <Calendar className="h-3 w-3" />
243
+ {dueDate}
244
+ </Badge>
245
+ )}
246
+ {typeof task.estimation_points === 'number' && (
247
+ <Badge variant="outline" className="h-5 px-1.5 font-normal text-xs">
248
+ {publicBoardT('points', { count: task.estimation_points })}
249
+ </Badge>
250
+ )}
251
+ </div>
252
+
253
+ {(task.labels?.length ||
254
+ task.projects?.length ||
255
+ task.assignees?.length) && (
256
+ <div className="flex flex-wrap items-center gap-1.5">
257
+ {task.labels?.map((label) => (
258
+ <Badge
259
+ key={label.id}
260
+ variant="outline"
261
+ className="h-5 gap-1 px-1.5 font-normal text-xs"
262
+ >
263
+ <span
264
+ aria-hidden="true"
265
+ className="h-2 w-2 rounded-full"
266
+ style={{ backgroundColor: label.color }}
267
+ />
268
+ {label.name}
269
+ </Badge>
270
+ ))}
271
+ {task.projects?.map((project) => (
272
+ <Badge
273
+ key={project.id}
274
+ variant="outline"
275
+ className="h-5 gap-1 px-1.5 font-normal text-xs"
276
+ >
277
+ <Box className="h-3 w-3" />
278
+ {project.name}
279
+ </Badge>
280
+ ))}
281
+ {task.assignees?.map((assignee) => (
282
+ <Badge
283
+ key={assignee.id}
284
+ variant="secondary"
285
+ className="h-5 px-1.5 font-normal text-xs"
286
+ >
287
+ {assignee.display_name ||
288
+ (assignee.handle ? `@${assignee.handle}` : t('assignee'))}
289
+ </Badge>
290
+ ))}
291
+ </div>
292
+ )}
293
+ </div>
294
+ </Card>
295
+ );
173
296
  }
174
297
 
175
298
  // Memoized full TaskCard
@@ -192,6 +315,8 @@ function TaskCardInner({
192
315
  optimisticUpdateInProgress,
193
316
  selectedTasks,
194
317
  bulkUpdateCustomDueDate,
318
+ deadlineContext,
319
+ deadlineNow,
195
320
  }: TaskCardProps) {
196
321
  const { wsId: rawWsId } = useParams();
197
322
  const wsId = Array.isArray(rawWsId) ? rawWsId[0] : rawWsId;
@@ -769,7 +894,7 @@ function TaskCardInner({
769
894
  opacity: isOverlay ? 1 : isOptimistic ? 0.6 : undefined,
770
895
  };
771
896
 
772
- const now = new Date();
897
+ const now = useMemo(() => new Date(deadlineNow ?? Date.now()), [deadlineNow]);
773
898
  const shouldRenderDueDate = shouldShowTaskDueDate({
774
899
  completedAt: task.completed_at,
775
900
  closedAt: task.closed_at,
@@ -789,6 +914,18 @@ function TaskCardInner({
789
914
  const isResolvedListStatus = isTaskBoardResolvedStatus(taskList?.status);
790
915
  const startDate = task.start_date ? new Date(task.start_date) : null;
791
916
  const endDate = task.end_date ? new Date(task.end_date) : null;
917
+ const upcomingDeadlineCountdown =
918
+ deadlineContext === 'upcoming' && endDate
919
+ ? formatDistance(endDate, now, {
920
+ addSuffix: true,
921
+ locale: dateLocale,
922
+ })
923
+ : null;
924
+ const upcomingDeadlineExactDate = endDate
925
+ ? format(endDate, `MMM dd '${t('at')}' ${timePattern}`, {
926
+ locale: dateLocale,
927
+ })
928
+ : null;
792
929
  const selectionCheckboxClassName = cn(
793
930
  getTaskCardSelectionCheckboxToneClasses(taskList?.color as SupportedColor),
794
931
  isOverdue &&
@@ -1025,6 +1162,7 @@ function TaskCardInner({
1025
1162
  )
1026
1163
  );
1027
1164
 
1165
+ void invalidateKanbanDeadlineTasks(queryClient, boardId);
1028
1166
  toast.success(tTasks('moved_to_external_tasks'));
1029
1167
  } catch (error) {
1030
1168
  console.error('Failed to move task to external staging:', error);
@@ -1045,6 +1183,7 @@ function TaskCardInner({
1045
1183
  old?.filter((candidate) => candidate.id !== task.id)
1046
1184
  );
1047
1185
 
1186
+ void invalidateKanbanDeadlineTasks(queryClient, boardId);
1048
1187
  toast.success(tTasks('removed_from_personal_board'));
1049
1188
  } catch (error) {
1050
1189
  console.error('Failed to remove task from personal board:', error);
@@ -2247,30 +2386,46 @@ function TaskCardInner({
2247
2386
  : 'text-muted-foreground'
2248
2387
  )}
2249
2388
  >
2250
- <Calendar className="h-2.5 w-2.5 shrink-0" />
2251
- <span className="truncate">
2252
- {t('due_at', {
2253
- date: formatSmartDate(
2254
- endDate,
2255
- {
2256
- today: t('today'),
2257
- tomorrow: t('tomorrow'),
2258
- yesterday: t('yesterday'),
2259
- },
2260
- dateLocale
2261
- ),
2262
- })}
2263
- </span>
2264
- {isOverdue && !task.closed_at ? (
2265
- <Badge className="ml-1 h-4 bg-dynamic-red px-1 font-semibold text-[9px] text-white tracking-wide">
2266
- {t('overdue')}
2267
- </Badge>
2389
+ {upcomingDeadlineCountdown && upcomingDeadlineExactDate ? (
2390
+ <Tooltip>
2391
+ <TooltipTrigger asChild>
2392
+ <span className="inline-flex min-w-0 items-center gap-1 truncate font-medium">
2393
+ <Timer className="h-2.5 w-2.5" />
2394
+ <span className="truncate">
2395
+ {upcomingDeadlineCountdown}
2396
+ </span>
2397
+ </span>
2398
+ </TooltipTrigger>
2399
+ <TooltipContent side="top" className="text-xs">
2400
+ {upcomingDeadlineExactDate}
2401
+ </TooltipContent>
2402
+ </Tooltip>
2268
2403
  ) : (
2269
- <span className="ml-1 hidden text-[10px] text-muted-foreground md:inline">
2270
- {format(endDate, `MMM dd '${t('at')}' ${timePattern}`, {
2271
- locale: dateLocale,
2272
- })}
2273
- </span>
2404
+ <>
2405
+ <Calendar className="h-2.5 w-2.5 shrink-0" />
2406
+ <span className="truncate">
2407
+ {t('due_at', {
2408
+ date: formatSmartDate(
2409
+ endDate,
2410
+ {
2411
+ today: t('today'),
2412
+ tomorrow: t('tomorrow'),
2413
+ yesterday: t('yesterday'),
2414
+ },
2415
+ dateLocale
2416
+ ),
2417
+ })}
2418
+ </span>
2419
+ {isOverdue && !task.closed_at ? (
2420
+ <Badge className="ml-1 h-4 bg-dynamic-red px-1 font-semibold text-[9px] text-white tracking-wide">
2421
+ {t('overdue')}
2422
+ </Badge>
2423
+ ) : (
2424
+ <span className="ml-1 hidden text-[10px] text-muted-foreground md:inline">
2425
+ {upcomingDeadlineExactDate}
2426
+ </span>
2427
+ )}
2428
+ </>
2274
2429
  )}
2275
2430
  </div>
2276
2431
  )}
@@ -2293,7 +2448,7 @@ function TaskCardInner({
2293
2448
  <CheckCircle2 className="h-2.5 w-2.5 shrink-0" />
2294
2449
  <span className="truncate">
2295
2450
  {t('completed')}{' '}
2296
- {formatDistanceToNow(new Date(task.completed_at), {
2451
+ {formatDistance(new Date(task.completed_at), now, {
2297
2452
  addSuffix: true,
2298
2453
  locale: dateLocale,
2299
2454
  })}
@@ -2317,7 +2472,7 @@ function TaskCardInner({
2317
2472
  <CircleSlash className="h-2.5 w-2.5 shrink-0" />
2318
2473
  <span className="truncate">
2319
2474
  {t('closed')}{' '}
2320
- {formatDistanceToNow(new Date(task.closed_at), {
2475
+ {formatDistance(new Date(task.closed_at), now, {
2321
2476
  addSuffix: true,
2322
2477
  locale: dateLocale,
2323
2478
  })}
@@ -2590,4 +2745,12 @@ function TaskCardInner({
2590
2745
  );
2591
2746
  }
2592
2747
 
2593
- export const TaskCard = React.memo(TaskCardInner, areTaskCardPropsEqual);
2748
+ function TaskCardComponent(props: TaskCardProps) {
2749
+ if (props.readOnly) {
2750
+ return <ReadOnlyTaskCard {...props} />;
2751
+ }
2752
+
2753
+ return <TaskCardInner {...props} />;
2754
+ }
2755
+
2756
+ export const TaskCard = React.memo(TaskCardComponent, areTaskCardPropsEqual);
@@ -0,0 +1,152 @@
1
+ import '@testing-library/jest-dom';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import type React from 'react';
5
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import type { TaskFilters } from '../../shared/task-filter.types';
7
+ import { TaskFilter } from './task-filter';
8
+
9
+ vi.mock('next-intl', () => ({
10
+ useTranslations: () => (key: string) => key,
11
+ }));
12
+
13
+ vi.mock('@tuturuuu/internal-api/tasks', () => ({
14
+ listWorkspaceLabels: vi.fn(() => Promise.resolve([])),
15
+ listWorkspaceTaskBoards: vi.fn(() =>
16
+ Promise.resolve({ boards: [], count: 0 })
17
+ ),
18
+ listWorkspaceTaskProjects: vi.fn(() => Promise.resolve([])),
19
+ }));
20
+
21
+ vi.mock('@tuturuuu/internal-api/workspaces', () => ({
22
+ listWorkspaces: vi.fn(() => Promise.resolve([])),
23
+ }));
24
+
25
+ vi.mock('@tuturuuu/ui/hooks/use-workspace-members', () => ({
26
+ useWorkspaceMembers: () => ({ data: [] }),
27
+ }));
28
+
29
+ vi.mock('@tuturuuu/ui/custom/combobox', () => ({
30
+ Combobox: ({
31
+ placeholder,
32
+ }: {
33
+ children?: React.ReactNode;
34
+ placeholder?: string;
35
+ }) => (
36
+ <button type="button" aria-label={placeholder}>
37
+ {placeholder}
38
+ </button>
39
+ ),
40
+ }));
41
+
42
+ beforeAll(() => {
43
+ class ResizeObserverMock {
44
+ observe() {}
45
+ unobserve() {}
46
+ disconnect() {}
47
+ }
48
+
49
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
50
+ });
51
+
52
+ const baseFilters: TaskFilters = {
53
+ assignees: [],
54
+ dueDateRange: null,
55
+ estimationRange: null,
56
+ includeMyTasks: false,
57
+ includeUnassigned: false,
58
+ labels: [],
59
+ priorities: [],
60
+ projects: [],
61
+ sourceBoardIds: [],
62
+ sourceScope: 'all_visible',
63
+ sourceWorkspaceIds: [],
64
+ };
65
+
66
+ function renderTaskFilter(
67
+ overrides?: Partial<React.ComponentProps<typeof TaskFilter>>
68
+ ) {
69
+ const queryClient = new QueryClient({
70
+ defaultOptions: {
71
+ queries: {
72
+ retry: false,
73
+ },
74
+ },
75
+ });
76
+ const onFiltersChange = vi.fn();
77
+
78
+ render(
79
+ <QueryClientProvider client={queryClient}>
80
+ <TaskFilter
81
+ currentUserId="user-1"
82
+ filters={baseFilters}
83
+ onFiltersChange={onFiltersChange}
84
+ wsId="ws-1"
85
+ {...overrides}
86
+ />
87
+ </QueryClientProvider>
88
+ );
89
+
90
+ return { onFiltersChange };
91
+ }
92
+
93
+ describe('TaskFilter', () => {
94
+ beforeEach(() => {
95
+ vi.clearAllMocks();
96
+ });
97
+
98
+ it('renders compact filter sections with responsive due-date controls', () => {
99
+ const { onFiltersChange } = renderTaskFilter();
100
+
101
+ fireEvent.click(screen.getByRole('button', { name: 'common.filters' }));
102
+
103
+ expect(screen.getByText('common.quick_filters')).toBeInTheDocument();
104
+ expect(
105
+ screen.getAllByText('ws-tasks.filter_source_scope').length
106
+ ).toBeGreaterThan(0);
107
+ expect(screen.getByText('common.people')).toBeInTheDocument();
108
+ expect(screen.getByText('common.details')).toBeInTheDocument();
109
+ expect(screen.getAllByText('common.due_date').length).toBeGreaterThan(0);
110
+ expect(screen.getByLabelText('common.from')).toHaveAttribute(
111
+ 'type',
112
+ 'date'
113
+ );
114
+ expect(screen.getByLabelText('common.to')).toHaveAttribute('type', 'date');
115
+ expect(screen.getByLabelText('common.clear')).toBeInTheDocument();
116
+ expect(
117
+ screen.queryByRole('grid', { name: /calendar/i })
118
+ ).not.toBeInTheDocument();
119
+
120
+ fireEvent.change(screen.getByLabelText('common.from'), {
121
+ target: { value: '2026-06-22' },
122
+ });
123
+
124
+ expect(onFiltersChange).toHaveBeenCalledWith(
125
+ expect.objectContaining({
126
+ dueDateRange: expect.objectContaining({
127
+ from: expect.any(Date),
128
+ }),
129
+ })
130
+ );
131
+ });
132
+
133
+ it('shows active count badges for compact sections', () => {
134
+ renderTaskFilter({
135
+ filters: {
136
+ ...baseFilters,
137
+ dueDateRange: { from: new Date(2026, 5, 22), to: undefined },
138
+ includeMyTasks: true,
139
+ sourceScope: 'external_current_workspace',
140
+ },
141
+ });
142
+
143
+ expect(
144
+ screen.getByRole('button', { name: 'common.filters' })
145
+ ).toHaveTextContent('3');
146
+
147
+ fireEvent.click(screen.getByRole('button', { name: 'common.filters' }));
148
+
149
+ expect(screen.getByText('common.quick_filters')).toBeInTheDocument();
150
+ expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(3);
151
+ });
152
+ });