@tuturuuu/ui 0.1.0 → 0.3.1

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 (128) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +82 -70
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +43 -13
  12. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +138 -74
  13. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +70 -0
  14. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +60 -1
  15. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +13 -5
  16. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +110 -0
  17. package/src/components/ui/chat/chat-sidebar-panel.tsx +13 -3
  18. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  19. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  20. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  21. package/src/components/ui/custom/common-footer.tsx +16 -1
  22. package/src/components/ui/custom/production-indicator.tsx +1 -1
  23. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  24. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  25. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  26. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  27. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  28. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  29. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  30. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  31. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  32. package/src/components/ui/custom/workspace-select.tsx +33 -12
  33. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  34. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  35. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  36. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  37. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  38. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  39. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  40. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  41. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  42. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  43. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  44. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  46. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  47. package/src/components/ui/finance/invoices/utils.ts +75 -17
  48. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  49. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  50. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  51. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  52. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  53. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  54. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  55. package/src/components/ui/finance/transactions/form.tsx +60 -0
  56. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  57. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  58. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  59. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  60. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  61. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  62. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  63. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  64. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  65. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  66. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  67. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  68. package/src/components/ui/legacy/meet/page.tsx +87 -39
  69. package/src/components/ui/legacy/meet/planId/page.tsx +10 -4
  70. package/src/components/ui/text-editor/__tests__/task-mention-chip.test.tsx +203 -6
  71. package/src/components/ui/text-editor/task-mention-chip.tsx +29 -7
  72. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  73. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  74. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  75. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  77. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  78. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  79. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  80. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  81. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  82. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  83. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  84. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  85. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  86. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  87. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  88. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  89. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  90. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  91. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  92. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  93. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  94. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  95. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  96. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  97. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  98. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  99. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  100. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  101. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  102. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  103. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  104. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  105. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  106. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  107. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  108. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  109. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  110. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  111. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  112. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  113. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-realtime-sync.test.tsx +37 -9
  114. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  115. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +89 -70
  116. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  117. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  118. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  119. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  120. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  121. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  122. package/src/hooks/use-calendar-sync.tsx +22 -277
  123. package/src/hooks/use-calendar.tsx +95 -525
  124. package/src/hooks/use-task-actions.ts +43 -117
  125. package/src/hooks/use-user-config.ts +1 -1
  126. package/src/hooks/use-workspace-config.ts +6 -2
  127. package/src/hooks/use-workspace-presence.ts +1 -1
  128. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -0,0 +1,109 @@
1
+ import type { CalendarHoursType } from '@tuturuuu/types/primitives/Task';
2
+
3
+ export function taskDurationHoursToMinutes(durationHours: number | null) {
4
+ if (!durationHours || durationHours <= 0) return 0;
5
+ return Math.round(durationHours * 60);
6
+ }
7
+
8
+ export function taskDurationMinutesToHours(durationMinutes: number) {
9
+ if (durationMinutes <= 0) return null;
10
+ return Number((durationMinutes / 60).toFixed(2));
11
+ }
12
+
13
+ export function formatTaskDurationLabel(durationHours: number | null) {
14
+ const totalMinutes = taskDurationHoursToMinutes(durationHours);
15
+ if (totalMinutes <= 0) return null;
16
+
17
+ const hours = Math.floor(totalMinutes / 60);
18
+ const minutes = totalMinutes % 60;
19
+
20
+ if (hours === 0) return `${minutes}m`;
21
+ if (minutes === 0) return `${hours}h`;
22
+ return `${hours}h ${minutes}m`;
23
+ }
24
+
25
+ export interface TaskSchedulingBadgeTitleLabels {
26
+ autoSchedule: string;
27
+ estimatedDuration: string;
28
+ meetingHours: string;
29
+ personalHours: string;
30
+ splittable: string;
31
+ workHours: string;
32
+ }
33
+
34
+ export function getTaskSchedulingHourTypeLabel(
35
+ calendarHours: CalendarHoursType | null | undefined,
36
+ labels: Pick<
37
+ TaskSchedulingBadgeTitleLabels,
38
+ 'meetingHours' | 'personalHours' | 'workHours'
39
+ >
40
+ ) {
41
+ switch (calendarHours) {
42
+ case 'work_hours':
43
+ return labels.workHours;
44
+ case 'meeting_hours':
45
+ return labels.meetingHours;
46
+ case 'personal_hours':
47
+ return labels.personalHours;
48
+ default:
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function formatSplitMinutesRangeLabel(
54
+ minSplitDurationMinutes: number | null | undefined,
55
+ maxSplitDurationMinutes: number | null | undefined
56
+ ) {
57
+ if (!minSplitDurationMinutes || !maxSplitDurationMinutes) {
58
+ return null;
59
+ }
60
+
61
+ const minLabel = formatTaskDurationLabel(minSplitDurationMinutes / 60);
62
+ const maxLabel = formatTaskDurationLabel(maxSplitDurationMinutes / 60);
63
+ if (!minLabel || !maxLabel) return null;
64
+
65
+ return `${minLabel}-${maxLabel}`;
66
+ }
67
+
68
+ export function formatTaskSchedulingBadgeTitle({
69
+ autoSchedule,
70
+ calendarHours,
71
+ durationLabel,
72
+ isSplittable,
73
+ labels,
74
+ maxSplitDurationMinutes,
75
+ minSplitDurationMinutes,
76
+ }: {
77
+ autoSchedule?: boolean | null;
78
+ calendarHours?: CalendarHoursType | null;
79
+ durationLabel: string;
80
+ isSplittable?: boolean | null;
81
+ labels: TaskSchedulingBadgeTitleLabels;
82
+ maxSplitDurationMinutes?: number | null;
83
+ minSplitDurationMinutes?: number | null;
84
+ }) {
85
+ const titleParts = [`${labels.estimatedDuration}: ${durationLabel}`];
86
+ const hourTypeLabel = getTaskSchedulingHourTypeLabel(calendarHours, labels);
87
+
88
+ if (hourTypeLabel) {
89
+ titleParts.push(hourTypeLabel);
90
+ }
91
+
92
+ if (isSplittable) {
93
+ const splitRangeLabel = formatSplitMinutesRangeLabel(
94
+ minSplitDurationMinutes,
95
+ maxSplitDurationMinutes
96
+ );
97
+ titleParts.push(
98
+ splitRangeLabel
99
+ ? `${labels.splittable}: ${splitRangeLabel}`
100
+ : labels.splittable
101
+ );
102
+ }
103
+
104
+ if (autoSchedule) {
105
+ titleParts.push(labels.autoSchedule);
106
+ }
107
+
108
+ return titleParts.join(' | ');
109
+ }
@@ -2,6 +2,7 @@ import { BoardClient } from '@tuturuuu/ui/tu-do/shared/board-client';
2
2
  import { getCurrentUser } from '@tuturuuu/utils/user-helper';
3
3
  import { getWorkspace } from '@tuturuuu/utils/workspace-helper';
4
4
  import { notFound, redirect } from 'next/navigation';
5
+ import type { ReactNode } from 'react';
5
6
 
6
7
  interface Props {
7
8
  params: Promise<{
@@ -10,6 +11,7 @@ interface Props {
10
11
  }>;
11
12
  /** Route prefix for tasks URLs. Defaults to '/tasks' (web app). Set to '' for satellite apps. */
12
13
  routePrefix?: string;
14
+ idleBottomIsland?: ReactNode;
13
15
  }
14
16
 
15
17
  /**
@@ -18,6 +20,7 @@ interface Props {
18
20
  * Used by both apps/web and apps/tasks.
19
21
  */
20
22
  export default async function TaskBoardServerPage({
23
+ idleBottomIsland,
21
24
  params,
22
25
  routePrefix = '/tasks',
23
26
  }: Props) {
@@ -36,6 +39,7 @@ export default async function TaskBoardServerPage({
36
39
  workspaceTier={(workspace as any)?.tier ?? null}
37
40
  currentUserId={user.id}
38
41
  routePrefix={routePrefix}
42
+ idleBottomIsland={idleBottomIsland}
39
43
  />
40
44
  );
41
45
  }
@@ -5,8 +5,11 @@ import { Checkbox } from '@tuturuuu/ui/checkbox';
5
5
  import { cn } from '@tuturuuu/utils/format';
6
6
  import { isTaskBoardResolvedStatus } from '@tuturuuu/utils/task-list-status';
7
7
  import { type MouseEvent, memo } from 'react';
8
- import { getListColorClasses } from '../../../utils/taskColorUtils';
9
8
  import { isOverdue } from '../../../utils/taskDateUtils';
9
+ import {
10
+ getTaskCardCheckboxToneClasses,
11
+ TASK_CARD_OVERDUE_CHECKBOX_TONE_CLASSES,
12
+ } from './task-card-checkbox-style';
10
13
 
11
14
  interface TaskCardCheckboxProps {
12
15
  task: Task;
@@ -31,11 +34,11 @@ export const TaskCardCheckbox = memo(function TaskCardCheckbox({
31
34
  'h-4 w-4 flex-none transition-all duration-200',
32
35
  'data-[state=checked]:border-dynamic-green/70 data-[state=checked]:bg-dynamic-green/70',
33
36
  'hover:scale-110 hover:border-primary/50',
34
- getListColorClasses(taskList?.color as SupportedColor),
37
+ getTaskCardCheckboxToneClasses(taskList?.color as SupportedColor),
35
38
  taskIsOverdue &&
36
39
  !task.closed_at &&
37
40
  !isInResolvedList &&
38
- 'border-dynamic-red/70 bg-dynamic-red/10 ring-1 ring-dynamic-red/20'
41
+ TASK_CARD_OVERDUE_CHECKBOX_TONE_CLASSES
39
42
  )}
40
43
  disabled={isLoading}
41
44
  onCheckedChange={onToggle}
@@ -3,13 +3,18 @@ import type { Task } from '@tuturuuu/types/primitives/Task';
3
3
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
4
4
  import { Badge } from '@tuturuuu/ui/badge';
5
5
  import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
6
+ import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
6
7
  import { cn } from '@tuturuuu/utils/format';
7
- import { isTaskBoardResolvedStatus } from '@tuturuuu/utils/task-list-status';
8
8
  import { getTimeFormatPattern } from '@tuturuuu/utils/time-helper';
9
9
  import { format } from 'date-fns';
10
10
  import { enUS, vi } from 'date-fns/locale';
11
11
  import { useLocale, useTranslations } from 'next-intl';
12
12
  import { memo } from 'react';
13
+ import {
14
+ shouldShowTaskDueDate,
15
+ shouldShowTaskStartDate,
16
+ TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID,
17
+ } from '../../../shared/task-due-date-visibility';
13
18
  import {
14
19
  formatSmartDate,
15
20
  isFutureDate,
@@ -29,25 +34,37 @@ export const TaskCardDates = memo(function TaskCardDates({
29
34
  const locale = useLocale();
30
35
  const dateLocale = locale === 'vi' ? vi : enUS;
31
36
  const { timeFormat } = useCalendarPreferences();
37
+ const { value: showReviewDueDates } = useUserBooleanConfig(
38
+ TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID,
39
+ false
40
+ );
32
41
  const timePattern = getTimeFormatPattern(timeFormat);
33
42
  const startDate = task.start_date ? new Date(task.start_date) : null;
34
43
  const endDate = task.end_date ? new Date(task.end_date) : null;
35
44
  const taskIsOverdue = isOverdue(endDate);
36
-
37
- // Hide dates when the list itself represents resolved work.
38
- if (isTaskBoardResolvedStatus(taskList?.status)) {
39
- return null;
40
- }
45
+ const shouldRenderStartDate = shouldShowTaskStartDate({
46
+ completedAt: task.completed_at,
47
+ closedAt: task.closed_at,
48
+ listStatus: taskList?.status,
49
+ startDate: task.start_date,
50
+ });
51
+ const shouldRenderDueDate = shouldShowTaskDueDate({
52
+ completedAt: task.completed_at,
53
+ closedAt: task.closed_at,
54
+ dueDate: task.end_date,
55
+ listStatus: taskList?.status,
56
+ showReviewDueDates,
57
+ });
41
58
 
42
59
  // Don't render if no dates
43
- if (!startDate && !endDate) {
60
+ if (!shouldRenderStartDate && !shouldRenderDueDate) {
44
61
  return null;
45
62
  }
46
63
 
47
64
  return (
48
65
  <div className="mb-1 space-y-0.5 text-[10px] leading-snug">
49
66
  {/* Show start only if in the future (hide historical start for visual simplicity) */}
50
- {startDate && isFutureDate(startDate) && (
67
+ {shouldRenderStartDate && startDate && isFutureDate(startDate) && (
51
68
  <div className="flex items-center gap-1 text-muted-foreground">
52
69
  <Clock className="h-2.5 w-2.5 shrink-0" />
53
70
  <span className="truncate">
@@ -65,7 +82,7 @@ export const TaskCardDates = memo(function TaskCardDates({
65
82
  </span>
66
83
  </div>
67
84
  )}
68
- {endDate && (
85
+ {shouldRenderDueDate && endDate && (
69
86
  <div
70
87
  className={cn(
71
88
  'flex items-center gap-1',
@@ -0,0 +1,39 @@
1
+ import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
2
+ import { cn } from '@tuturuuu/utils/format';
3
+ import { getListColorClasses } from '../../../utils/taskColorUtils';
4
+
5
+ const CHECKED_LIST_TONE_CLASSES: Record<SupportedColor, string> = {
6
+ BLUE: 'data-[state=checked]:border-dynamic-blue/70 data-[state=checked]:bg-dynamic-blue/20 data-[state=checked]:text-dynamic-blue',
7
+ CYAN: 'data-[state=checked]:border-dynamic-cyan/70 data-[state=checked]:bg-dynamic-cyan/20 data-[state=checked]:text-dynamic-cyan',
8
+ GRAY: 'data-[state=checked]:border-dynamic-gray/70 data-[state=checked]:bg-dynamic-gray/20 data-[state=checked]:text-foreground',
9
+ GREEN:
10
+ 'data-[state=checked]:border-dynamic-green/70 data-[state=checked]:bg-dynamic-green/20 data-[state=checked]:text-dynamic-green',
11
+ INDIGO:
12
+ 'data-[state=checked]:border-dynamic-indigo/70 data-[state=checked]:bg-dynamic-indigo/20 data-[state=checked]:text-dynamic-indigo',
13
+ ORANGE:
14
+ 'data-[state=checked]:border-dynamic-orange/70 data-[state=checked]:bg-dynamic-orange/20 data-[state=checked]:text-dynamic-orange',
15
+ PINK: 'data-[state=checked]:border-dynamic-pink/70 data-[state=checked]:bg-dynamic-pink/20 data-[state=checked]:text-dynamic-pink',
16
+ PURPLE:
17
+ 'data-[state=checked]:border-dynamic-purple/70 data-[state=checked]:bg-dynamic-purple/20 data-[state=checked]:text-dynamic-purple',
18
+ RED: 'data-[state=checked]:border-dynamic-red/70 data-[state=checked]:bg-dynamic-red/20 data-[state=checked]:text-dynamic-red',
19
+ YELLOW:
20
+ 'data-[state=checked]:border-dynamic-yellow/70 data-[state=checked]:bg-dynamic-yellow/20 data-[state=checked]:text-dynamic-yellow',
21
+ };
22
+
23
+ export const TASK_CARD_OVERDUE_CHECKBOX_TONE_CLASSES =
24
+ 'border-dynamic-red/70 bg-dynamic-red/10 ring-1 ring-dynamic-red/20 data-[state=checked]:border-dynamic-red/70 data-[state=checked]:bg-dynamic-red/20 data-[state=checked]:text-dynamic-red';
25
+
26
+ export function getTaskCardCheckboxToneClasses(color?: SupportedColor | null) {
27
+ return getListColorClasses(color ?? 'GRAY');
28
+ }
29
+
30
+ export function getTaskCardSelectionCheckboxToneClasses(
31
+ color?: SupportedColor | null
32
+ ) {
33
+ const resolvedColor = color ?? 'GRAY';
34
+
35
+ return cn(
36
+ getTaskCardCheckboxToneClasses(resolvedColor),
37
+ CHECKED_LIST_TONE_CLASSES[resolvedColor]
38
+ );
39
+ }
@@ -58,4 +58,47 @@ describe('areTaskCardPropsEqual', () => {
58
58
  )
59
59
  ).toBe(false);
60
60
  });
61
+
62
+ it('rerenders when personal scheduling duration changes', () => {
63
+ expect(
64
+ areTaskCardPropsEqual(
65
+ taskCardProps({ task: { ...task, total_duration: null } }),
66
+ taskCardProps({ task: { ...task, total_duration: 1.5 } })
67
+ )
68
+ ).toBe(false);
69
+ });
70
+
71
+ it('rerenders a selected card when the bulk selection grows', () => {
72
+ expect(
73
+ areTaskCardPropsEqual(
74
+ taskCardProps({
75
+ isMultiSelectMode: true,
76
+ isSelected: true,
77
+ selectedTasks: new Set(['task-1']),
78
+ }),
79
+ taskCardProps({
80
+ isMultiSelectMode: true,
81
+ isSelected: true,
82
+ selectedTasks: new Set(['task-1', 'task-2']),
83
+ })
84
+ )
85
+ ).toBe(false);
86
+ });
87
+
88
+ it('rerenders a selected card when the bulk selection changes with the same size', () => {
89
+ expect(
90
+ areTaskCardPropsEqual(
91
+ taskCardProps({
92
+ isMultiSelectMode: true,
93
+ isSelected: true,
94
+ selectedTasks: new Set(['task-1', 'task-2']),
95
+ }),
96
+ taskCardProps({
97
+ isMultiSelectMode: true,
98
+ isSelected: true,
99
+ selectedTasks: new Set(['task-1', 'task-3']),
100
+ })
101
+ )
102
+ ).toBe(false);
103
+ });
61
104
  });
@@ -1,5 +1,25 @@
1
1
  import type { TaskCardProps } from './task-card';
2
2
 
3
+ function areSelectedTaskSetsEqual(
4
+ previousSelectedTasks?: Set<string>,
5
+ nextSelectedTasks?: Set<string>
6
+ ) {
7
+ if (previousSelectedTasks === nextSelectedTasks) return true;
8
+ if (!previousSelectedTasks || !nextSelectedTasks) {
9
+ return (
10
+ (previousSelectedTasks?.size ?? 0) === (nextSelectedTasks?.size ?? 0)
11
+ );
12
+ }
13
+
14
+ if (previousSelectedTasks.size !== nextSelectedTasks.size) return false;
15
+
16
+ for (const taskId of previousSelectedTasks) {
17
+ if (!nextSelectedTasks.has(taskId)) return false;
18
+ }
19
+
20
+ return true;
21
+ }
22
+
3
23
  export function areTaskCardPropsEqual(
4
24
  prev: TaskCardProps,
5
25
  next: TaskCardProps
@@ -7,6 +27,13 @@ export function areTaskCardPropsEqual(
7
27
  if (prev.isOverlay !== next.isOverlay) return false;
8
28
  if (prev.isSelected !== next.isSelected) return false;
9
29
  if (prev.isMultiSelectMode !== next.isMultiSelectMode) return false;
30
+ if (
31
+ (prev.isSelected || next.isSelected) &&
32
+ (prev.isMultiSelectMode || next.isMultiSelectMode) &&
33
+ !areSelectedTaskSetsEqual(prev.selectedTasks, next.selectedTasks)
34
+ ) {
35
+ return false;
36
+ }
10
37
  if (prev.boardId !== next.boardId) return false;
11
38
  if (prev.workspaceId !== next.workspaceId) return false;
12
39
  if (prev.dragDisabled !== next.dragDisabled) return false;
@@ -42,6 +69,12 @@ export function areTaskCardPropsEqual(
42
69
  'start_date',
43
70
  'completed_at',
44
71
  'estimation_points',
72
+ 'total_duration',
73
+ 'is_splittable',
74
+ 'min_split_duration_minutes',
75
+ 'max_split_duration_minutes',
76
+ 'calendar_hours',
77
+ 'auto_schedule',
45
78
  'relationship_summary',
46
79
  'list_id',
47
80
  ];
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shouldRenderTaskCardCompletionCheckbox } from './task-card-completion-checkbox-visibility';
3
+
4
+ describe('shouldRenderTaskCardCompletionCheckbox', () => {
5
+ it('renders the done checkbox in normal task mode', () => {
6
+ expect(
7
+ shouldRenderTaskCardCompletionCheckbox({
8
+ isMultiSelectMode: false,
9
+ taskListStatus: 'active',
10
+ })
11
+ ).toBe(true);
12
+ });
13
+
14
+ it('hides the done checkbox in multi-select mode', () => {
15
+ expect(
16
+ shouldRenderTaskCardCompletionCheckbox({
17
+ isMultiSelectMode: true,
18
+ taskListStatus: 'active',
19
+ })
20
+ ).toBe(false);
21
+ });
22
+
23
+ it('keeps document-list completion checkboxes hidden', () => {
24
+ expect(
25
+ shouldRenderTaskCardCompletionCheckbox({
26
+ isMultiSelectMode: false,
27
+ taskListStatus: 'documents',
28
+ })
29
+ ).toBe(false);
30
+ });
31
+ });
@@ -0,0 +1,9 @@
1
+ export function shouldRenderTaskCardCompletionCheckbox({
2
+ isMultiSelectMode,
3
+ taskListStatus,
4
+ }: {
5
+ isMultiSelectMode: boolean;
6
+ taskListStatus?: string | null;
7
+ }) {
8
+ return taskListStatus !== 'documents' && !isMultiSelectMode;
9
+ }
@@ -0,0 +1,124 @@
1
+ import '@testing-library/jest-dom';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { getTaskCardSelectionCheckboxToneClasses } from './task-card-checkbox-style';
5
+ import { TaskCardIdentifierRow } from './task-card-identifier-row';
6
+
7
+ describe('TaskCardIdentifierRow', () => {
8
+ it('renders the selection checkbox before the external source and task identifier', () => {
9
+ const onSelect = vi.fn();
10
+
11
+ render(
12
+ <TaskCardIdentifierRow
13
+ externalSourceLabel="Upskii"
14
+ externalSourceTitle="Upskii / Roadmap / Review"
15
+ isMultiSelectMode
16
+ isPersonalExternalTask
17
+ isSelected={false}
18
+ onSelect={onSelect}
19
+ selectTaskLabel="Select Draft response"
20
+ selectionCheckboxClassName={getTaskCardSelectionCheckboxToneClasses(
21
+ 'BLUE'
22
+ )}
23
+ taskListStatus="active"
24
+ ticketBadgeClassName="text-dynamic-blue"
25
+ ticketIdentifier="OH-167"
26
+ ticketTitle="Task OH-167"
27
+ />
28
+ );
29
+
30
+ const source = screen.getByTestId('task-card-external-source');
31
+ const checkbox = screen.getByTestId('task-card-selection-checkbox');
32
+ const ticket = screen.getByTestId('task-card-ticket-identifier');
33
+
34
+ expect(checkbox.compareDocumentPosition(source)).toBe(
35
+ Node.DOCUMENT_POSITION_FOLLOWING
36
+ );
37
+ expect(source.compareDocumentPosition(ticket)).toBe(
38
+ Node.DOCUMENT_POSITION_FOLLOWING
39
+ );
40
+ expect(checkbox).toHaveClass('border-dynamic-blue/70');
41
+ expect(checkbox).toHaveClass('bg-dynamic-blue/5');
42
+ expect(checkbox.className).toContain(
43
+ 'data-[state=checked]:border-dynamic-blue/70'
44
+ );
45
+ expect(checkbox).not.toHaveClass('absolute');
46
+ expect(checkbox).not.toHaveClass('bg-background/80');
47
+
48
+ fireEvent.click(checkbox);
49
+ expect(onSelect).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it('renders the selection checkbox before an internal task identifier', () => {
53
+ render(
54
+ <TaskCardIdentifierRow
55
+ externalSourceLabel="Source"
56
+ isMultiSelectMode
57
+ isPersonalExternalTask={false}
58
+ isSelected={false}
59
+ selectTaskLabel="Select task"
60
+ selectionCheckboxClassName={getTaskCardSelectionCheckboxToneClasses(
61
+ 'GREEN'
62
+ )}
63
+ taskListStatus="active"
64
+ ticketIdentifier="OH-174"
65
+ ticketTitle="Task OH-174"
66
+ />
67
+ );
68
+
69
+ const checkbox = screen.getByTestId('task-card-selection-checkbox');
70
+ const ticket = screen.getByTestId('task-card-ticket-identifier');
71
+
72
+ expect(checkbox.compareDocumentPosition(ticket)).toBe(
73
+ Node.DOCUMENT_POSITION_FOLLOWING
74
+ );
75
+ expect(screen.queryByTestId('task-card-external-source')).toBeNull();
76
+ });
77
+
78
+ it('keeps the selection checkbox first when document lists omit the identifier', () => {
79
+ render(
80
+ <TaskCardIdentifierRow
81
+ externalSourceLabel="Exocorpse"
82
+ externalSourceTitle="Exocorpse / Web"
83
+ isMultiSelectMode
84
+ isPersonalExternalTask
85
+ isSelected={false}
86
+ selectTaskLabel="Select task"
87
+ selectionCheckboxClassName={getTaskCardSelectionCheckboxToneClasses(
88
+ 'PURPLE'
89
+ )}
90
+ taskListStatus="documents"
91
+ ticketIdentifier="WEB-54"
92
+ ticketTitle="Task WEB-54"
93
+ />
94
+ );
95
+
96
+ const checkbox = screen.getByTestId('task-card-selection-checkbox');
97
+ const source = screen.getByTestId('task-card-external-source');
98
+
99
+ expect(checkbox.compareDocumentPosition(source)).toBe(
100
+ Node.DOCUMENT_POSITION_FOLLOWING
101
+ );
102
+ expect(screen.queryByTestId('task-card-ticket-identifier')).toBeNull();
103
+ });
104
+
105
+ it('omits the selector outside multi-select mode', () => {
106
+ render(
107
+ <TaskCardIdentifierRow
108
+ externalSourceLabel="Source"
109
+ isMultiSelectMode={false}
110
+ isPersonalExternalTask={false}
111
+ isSelected={false}
112
+ selectTaskLabel="Select task"
113
+ taskListStatus="active"
114
+ ticketIdentifier="OH-174"
115
+ ticketTitle="Task OH-174"
116
+ />
117
+ );
118
+
119
+ expect(screen.queryByTestId('task-card-selection-checkbox')).toBeNull();
120
+ expect(screen.getByTestId('task-card-ticket-identifier')).toHaveTextContent(
121
+ 'OH-174'
122
+ );
123
+ });
124
+ });
@@ -0,0 +1,88 @@
1
+ import { ExternalLink } from '@tuturuuu/icons';
2
+ import { Badge } from '@tuturuuu/ui/badge';
3
+ import { Checkbox } from '@tuturuuu/ui/checkbox';
4
+ import { cn } from '@tuturuuu/utils/format';
5
+ import type { MouseEvent } from 'react';
6
+
7
+ interface TaskCardIdentifierRowProps {
8
+ externalSourceLabel: string;
9
+ externalSourceTitle?: string;
10
+ isMultiSelectMode: boolean;
11
+ isPersonalExternalTask: boolean;
12
+ isSelected: boolean;
13
+ onSelect?: (event: MouseEvent<HTMLButtonElement>) => void;
14
+ selectTaskLabel: string;
15
+ selectionCheckboxClassName?: string;
16
+ taskListStatus?: string | null;
17
+ ticketBadgeClassName?: string;
18
+ ticketIdentifier: string | null;
19
+ ticketTitle: string;
20
+ }
21
+
22
+ export function TaskCardIdentifierRow({
23
+ externalSourceLabel,
24
+ externalSourceTitle,
25
+ isMultiSelectMode,
26
+ isPersonalExternalTask,
27
+ isSelected,
28
+ onSelect,
29
+ selectTaskLabel,
30
+ selectionCheckboxClassName,
31
+ taskListStatus,
32
+ ticketBadgeClassName,
33
+ ticketIdentifier,
34
+ ticketTitle,
35
+ }: TaskCardIdentifierRowProps) {
36
+ return (
37
+ <div
38
+ className={cn(
39
+ 'mb-1 flex min-w-0 flex-wrap items-center gap-1',
40
+ isPersonalExternalTask && 'gap-x-1.5'
41
+ )}
42
+ >
43
+ {isMultiSelectMode && (
44
+ <Checkbox
45
+ checked={isSelected}
46
+ aria-label={selectTaskLabel}
47
+ data-testid="task-card-selection-checkbox"
48
+ className={cn(
49
+ 'h-4 w-4 shrink-0 border-2 shadow-sm transition-all duration-200 hover:scale-110 hover:border-primary/50',
50
+ selectionCheckboxClassName
51
+ )}
52
+ onPointerDown={(event) => {
53
+ event.stopPropagation();
54
+ }}
55
+ onClick={(event) => {
56
+ event.preventDefault();
57
+ event.stopPropagation();
58
+ onSelect?.(event);
59
+ }}
60
+ />
61
+ )}
62
+ {isPersonalExternalTask && (
63
+ <Badge
64
+ variant="secondary"
65
+ className="h-5 min-w-0 max-w-[70%] gap-1 border border-dynamic-cyan/30 bg-dynamic-cyan/10 px-1.5 text-[10px] text-dynamic-cyan"
66
+ title={externalSourceTitle}
67
+ data-testid="task-card-external-source"
68
+ >
69
+ <ExternalLink className="h-2.5 w-2.5 shrink-0" />
70
+ <span className="truncate">{externalSourceLabel}</span>
71
+ </Badge>
72
+ )}
73
+ {taskListStatus !== 'documents' && ticketIdentifier && (
74
+ <Badge
75
+ variant="outline"
76
+ className={cn(
77
+ 'w-fit px-1 py-0 font-mono text-[10px]',
78
+ ticketBadgeClassName
79
+ )}
80
+ title={ticketTitle}
81
+ data-testid="task-card-ticket-identifier"
82
+ >
83
+ {ticketIdentifier}
84
+ </Badge>
85
+ )}
86
+ </div>
87
+ );
88
+ }