@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
@@ -1,6 +1,34 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import type { Task } from '@tuturuuu/types/primitives/Task';
2
4
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
- import { describe, expect, it, vi } from 'vitest';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ const taskSchedulingApiMock = vi.hoisted(() => ({
8
+ getCurrentUserTaskSchedule: vi.fn(),
9
+ updateCurrentUserTaskSchedulingSettings: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('@tuturuuu/internal-api', () => taskSchedulingApiMock);
13
+
14
+ vi.mock('@tuturuuu/ui/sonner', () => ({
15
+ toast: {
16
+ error: vi.fn(),
17
+ success: vi.fn(),
18
+ },
19
+ }));
20
+
21
+ vi.mock('@tuturuuu/ui/switch', () => ({
22
+ Switch: ({ checked, disabled, onCheckedChange }: any) => (
23
+ <button
24
+ aria-checked={checked}
25
+ disabled={disabled}
26
+ onClick={() => onCheckedChange?.(!checked)}
27
+ role="switch"
28
+ type="button"
29
+ />
30
+ ),
31
+ }));
4
32
 
5
33
  vi.mock('next-intl', () => ({
6
34
  useTranslations: () => (key: string) => {
@@ -28,6 +56,11 @@ import { TaskLabelsMenu } from '../task-labels-menu';
28
56
  import { TaskMoveMenu } from '../task-move-menu';
29
57
  import { TaskPriorityMenu } from '../task-priority-menu';
30
58
  import { TaskProjectsMenu } from '../task-projects-menu';
59
+ import { TaskSchedulingMenu } from '../task-scheduling-menu';
60
+ import {
61
+ formatTaskDurationLabel,
62
+ formatTaskSchedulingBadgeTitle,
63
+ } from '../task-scheduling-utils';
31
64
 
32
65
  // Mock dropdown components to avoid Radix UI context issues in tests
33
66
  vi.mock('@tuturuuu/ui/dropdown-menu', () => ({
@@ -169,6 +202,152 @@ describe('TaskDueDateMenu', () => {
169
202
  });
170
203
  });
171
204
 
205
+ describe('task scheduling utilities', () => {
206
+ it('formats compact task duration labels', () => {
207
+ expect(formatTaskDurationLabel(null)).toBeNull();
208
+ expect(formatTaskDurationLabel(0)).toBeNull();
209
+ expect(formatTaskDurationLabel(0.75)).toBe('45m');
210
+ expect(formatTaskDurationLabel(2)).toBe('2h');
211
+ expect(formatTaskDurationLabel(1.5)).toBe('1h 30m');
212
+ });
213
+
214
+ it('formats compact scheduling badge titles with split and auto-schedule details', () => {
215
+ expect(
216
+ formatTaskSchedulingBadgeTitle({
217
+ autoSchedule: true,
218
+ calendarHours: 'work_hours',
219
+ durationLabel: '2h',
220
+ isSplittable: true,
221
+ labels: {
222
+ autoSchedule: 'Auto-schedule',
223
+ estimatedDuration: 'Estimated Duration',
224
+ meetingHours: 'Meeting Hours',
225
+ personalHours: 'Personal Hours',
226
+ splittable: 'Splittable',
227
+ workHours: 'Work Hours',
228
+ },
229
+ maxSplitDurationMinutes: 90,
230
+ minSplitDurationMinutes: 30,
231
+ })
232
+ ).toBe(
233
+ 'Estimated Duration: 2h | Work Hours | Splittable: 30m-1h 30m | Auto-schedule'
234
+ );
235
+ });
236
+ });
237
+
238
+ describe('TaskSchedulingMenu', () => {
239
+ beforeEach(() => {
240
+ taskSchedulingApiMock.getCurrentUserTaskSchedule.mockReset();
241
+ taskSchedulingApiMock.updateCurrentUserTaskSchedulingSettings.mockReset();
242
+ });
243
+
244
+ function renderSchedulingMenu(taskOverrides: Partial<Task> = {}) {
245
+ const queryClient = new QueryClient({
246
+ defaultOptions: {
247
+ mutations: { retry: false },
248
+ queries: { retry: false },
249
+ },
250
+ });
251
+ const task: Task = {
252
+ created_at: '2026-06-04T00:00:00.000Z',
253
+ display_number: 1,
254
+ id: 'task-1',
255
+ list_id: 'list-1',
256
+ name: 'Schedulable task',
257
+ total_duration: 1.5,
258
+ calendar_hours: 'work_hours',
259
+ auto_schedule: true,
260
+ ...taskOverrides,
261
+ };
262
+ const onUpdate = vi.fn();
263
+
264
+ render(
265
+ <QueryClientProvider client={queryClient}>
266
+ <TaskSchedulingMenu
267
+ boardId="board-1"
268
+ onUpdate={onUpdate}
269
+ task={task}
270
+ translations={{
271
+ autoSchedule: 'Auto-schedule',
272
+ clear: 'Clear',
273
+ estimatedDuration: 'Estimated Duration',
274
+ error: 'Error',
275
+ h: 'h',
276
+ hourType: 'Hour Type',
277
+ m: 'm',
278
+ maxSplit: 'Max split',
279
+ meetingHours: 'Meeting Hours',
280
+ minSplit: 'Min split',
281
+ personalHours: 'Personal Hours',
282
+ save: 'Save',
283
+ saved: 'Saved',
284
+ schedule: 'Schedule',
285
+ splittable: 'Splittable',
286
+ workHours: 'Work Hours',
287
+ }}
288
+ />
289
+ </QueryClientProvider>
290
+ );
291
+
292
+ return { onUpdate, queryClient };
293
+ }
294
+
295
+ it('saves personal scheduling settings from the menu', async () => {
296
+ taskSchedulingApiMock.updateCurrentUserTaskSchedulingSettings.mockResolvedValue(
297
+ { ok: true, task_ws_id: 'workspace-1' }
298
+ );
299
+ const { onUpdate } = renderSchedulingMenu();
300
+
301
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
302
+
303
+ await waitFor(() =>
304
+ expect(
305
+ taskSchedulingApiMock.updateCurrentUserTaskSchedulingSettings
306
+ ).toHaveBeenCalledWith(
307
+ 'task-1',
308
+ expect.objectContaining({
309
+ auto_schedule: true,
310
+ calendar_hours: 'work_hours',
311
+ is_splittable: false,
312
+ max_split_duration_minutes: null,
313
+ min_split_duration_minutes: null,
314
+ total_duration: 1.5,
315
+ })
316
+ )
317
+ );
318
+ expect(onUpdate).toHaveBeenCalledTimes(1);
319
+ });
320
+
321
+ it('clears personal scheduling settings from the menu', async () => {
322
+ taskSchedulingApiMock.updateCurrentUserTaskSchedulingSettings.mockResolvedValue(
323
+ { ok: true, task_ws_id: 'workspace-1' }
324
+ );
325
+ renderSchedulingMenu({
326
+ is_splittable: true,
327
+ max_split_duration_minutes: 90,
328
+ min_split_duration_minutes: 30,
329
+ });
330
+
331
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
332
+
333
+ await waitFor(() =>
334
+ expect(
335
+ taskSchedulingApiMock.updateCurrentUserTaskSchedulingSettings
336
+ ).toHaveBeenCalledWith(
337
+ 'task-1',
338
+ expect.objectContaining({
339
+ auto_schedule: false,
340
+ calendar_hours: null,
341
+ is_splittable: false,
342
+ max_split_duration_minutes: null,
343
+ min_split_duration_minutes: null,
344
+ total_duration: null,
345
+ })
346
+ )
347
+ );
348
+ });
349
+ });
350
+
172
351
  describe('TaskEstimationMenu', () => {
173
352
  const mockOnEstimationChange = vi.fn();
174
353
  const mockOnMenuItemSelect = vi.fn((_, action) => action());
@@ -9,3 +9,4 @@ export { TaskPickerPopover } from './task-picker-popover';
9
9
  export { TaskPriorityMenu } from './task-priority-menu';
10
10
  export { TaskProjectsMenu } from './task-projects-menu';
11
11
  export { TaskRelatedMenu } from './task-related-menu';
12
+ export { TaskSchedulingMenu } from './task-scheduling-menu';
@@ -0,0 +1,463 @@
1
+ 'use client';
2
+
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ Briefcase,
6
+ Calendar,
7
+ CalendarClock,
8
+ Clock,
9
+ Loader2,
10
+ Minus,
11
+ Plus,
12
+ Save,
13
+ Scissors,
14
+ User,
15
+ X,
16
+ Zap,
17
+ } from '@tuturuuu/icons';
18
+ import {
19
+ getCurrentUserTaskSchedule,
20
+ updateCurrentUserTaskSchedulingSettings,
21
+ } from '@tuturuuu/internal-api';
22
+ import type { CalendarHoursType, Task } from '@tuturuuu/types/primitives/Task';
23
+ import { Button } from '@tuturuuu/ui/button';
24
+ import {
25
+ DropdownMenuSub,
26
+ DropdownMenuSubContent,
27
+ DropdownMenuSubTrigger,
28
+ } from '@tuturuuu/ui/dropdown-menu';
29
+ import { Label } from '@tuturuuu/ui/label';
30
+ import { toast } from '@tuturuuu/ui/sonner';
31
+ import { Switch } from '@tuturuuu/ui/switch';
32
+ import { cn } from '@tuturuuu/utils/format';
33
+ import type { ChangeEvent } from 'react';
34
+ import { useEffect, useMemo, useState } from 'react';
35
+ import {
36
+ formatTaskDurationLabel,
37
+ taskDurationHoursToMinutes,
38
+ taskDurationMinutesToHours,
39
+ } from './task-scheduling-utils';
40
+
41
+ interface TaskSchedulingMenuTranslations {
42
+ schedule?: string;
43
+ estimatedDuration?: string;
44
+ h?: string;
45
+ m?: string;
46
+ splittable?: string;
47
+ minSplit?: string;
48
+ maxSplit?: string;
49
+ hourType?: string;
50
+ workHours?: string;
51
+ meetingHours?: string;
52
+ personalHours?: string;
53
+ autoSchedule?: string;
54
+ save?: string;
55
+ clear?: string;
56
+ saved?: string;
57
+ error?: string;
58
+ }
59
+
60
+ interface TaskSchedulingMenuProps {
61
+ task: Task;
62
+ boardId: string;
63
+ isLoading?: boolean;
64
+ onUpdate: () => void;
65
+ onClose?: () => void;
66
+ translations?: TaskSchedulingMenuTranslations;
67
+ }
68
+
69
+ const DEFAULT_MIN_SPLIT_MINUTES = 30;
70
+ const DEFAULT_MAX_SPLIT_MINUTES = 120;
71
+
72
+ function getTranslations(translations?: TaskSchedulingMenuTranslations) {
73
+ return {
74
+ schedule: translations?.schedule ?? 'Schedule',
75
+ estimatedDuration: translations?.estimatedDuration ?? 'Estimated Duration',
76
+ h: translations?.h ?? 'h',
77
+ m: translations?.m ?? 'm',
78
+ splittable: translations?.splittable ?? 'Splittable',
79
+ minSplit: translations?.minSplit ?? 'Min split',
80
+ maxSplit: translations?.maxSplit ?? 'Max split',
81
+ hourType: translations?.hourType ?? 'Hour Type',
82
+ workHours: translations?.workHours ?? 'Work Hours',
83
+ meetingHours: translations?.meetingHours ?? 'Meeting Hours',
84
+ personalHours: translations?.personalHours ?? 'Personal Hours',
85
+ autoSchedule: translations?.autoSchedule ?? 'Auto-schedule',
86
+ save: translations?.save ?? 'Save',
87
+ clear: translations?.clear ?? 'Clear',
88
+ saved: translations?.saved ?? 'Saved',
89
+ error: translations?.error ?? 'Error',
90
+ };
91
+ }
92
+
93
+ function clampDuration(value: number, min = 0, max = 999) {
94
+ if (Number.isNaN(value)) return min;
95
+ return Math.max(min, Math.min(value, max));
96
+ }
97
+
98
+ interface DurationInputProps {
99
+ value: number;
100
+ label: string;
101
+ min?: number;
102
+ max?: number;
103
+ step?: number;
104
+ disabled?: boolean;
105
+ onChange: (value: number) => void;
106
+ }
107
+
108
+ function DurationInput({
109
+ value,
110
+ label,
111
+ min = 0,
112
+ max = 999,
113
+ step = 1,
114
+ disabled = false,
115
+ onChange,
116
+ }: DurationInputProps) {
117
+ const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
118
+ onChange(clampDuration(Number.parseInt(event.target.value, 10), min, max));
119
+ };
120
+
121
+ return (
122
+ <div className="flex items-center gap-1">
123
+ <div className="flex h-8 items-center overflow-hidden rounded-md border bg-background">
124
+ <button
125
+ type="button"
126
+ className="flex h-full w-7 items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
127
+ disabled={disabled || value <= min}
128
+ onClick={() => onChange(clampDuration(value - step, min, max))}
129
+ >
130
+ <Minus className="h-3 w-3" />
131
+ </button>
132
+ <input
133
+ aria-label={label}
134
+ className="h-full w-10 border-x bg-transparent text-center text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
135
+ disabled={disabled}
136
+ inputMode="numeric"
137
+ onChange={handleInputChange}
138
+ pattern="[0-9]*"
139
+ type="text"
140
+ value={value}
141
+ />
142
+ <button
143
+ type="button"
144
+ className="flex h-full w-7 items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
145
+ disabled={disabled || value >= max}
146
+ onClick={() => onChange(clampDuration(value + step, min, max))}
147
+ >
148
+ <Plus className="h-3 w-3" />
149
+ </button>
150
+ </div>
151
+ <span className="text-muted-foreground text-xs">{label}</span>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ function getInitialDurationMinutes(task: Task) {
157
+ return taskDurationHoursToMinutes(task.total_duration ?? null);
158
+ }
159
+
160
+ export function TaskSchedulingMenu({
161
+ task,
162
+ boardId,
163
+ isLoading = false,
164
+ onUpdate,
165
+ onClose,
166
+ translations,
167
+ }: TaskSchedulingMenuProps) {
168
+ const queryClient = useQueryClient();
169
+ const t = useMemo(() => getTranslations(translations), [translations]);
170
+ const [open, setOpen] = useState(false);
171
+ const [saving, setSaving] = useState(false);
172
+ const [durationMinutes, setDurationMinutes] = useState(() =>
173
+ getInitialDurationMinutes(task)
174
+ );
175
+ const [isSplittable, setIsSplittable] = useState(
176
+ () => task.is_splittable ?? false
177
+ );
178
+ const [minSplitDurationMinutes, setMinSplitDurationMinutes] = useState(
179
+ () => task.min_split_duration_minutes ?? DEFAULT_MIN_SPLIT_MINUTES
180
+ );
181
+ const [maxSplitDurationMinutes, setMaxSplitDurationMinutes] = useState(
182
+ () => task.max_split_duration_minutes ?? DEFAULT_MAX_SPLIT_MINUTES
183
+ );
184
+ const [calendarHours, setCalendarHours] = useState<CalendarHoursType | null>(
185
+ () => task.calendar_hours ?? null
186
+ );
187
+ const [autoSchedule, setAutoSchedule] = useState(
188
+ () => task.auto_schedule ?? false
189
+ );
190
+
191
+ const { data: personalSchedule, isFetching } = useQuery({
192
+ queryKey: ['task-personal-schedule', task.id],
193
+ queryFn: () => getCurrentUserTaskSchedule(task.id),
194
+ enabled: open && Boolean(task.id) && task.id !== 'new',
195
+ staleTime: 5000,
196
+ });
197
+
198
+ useEffect(() => {
199
+ const source = personalSchedule?.task ?? task;
200
+ setDurationMinutes(
201
+ taskDurationHoursToMinutes(source.total_duration ?? null)
202
+ );
203
+ setIsSplittable(source.is_splittable ?? false);
204
+ setMinSplitDurationMinutes(
205
+ source.min_split_duration_minutes ?? DEFAULT_MIN_SPLIT_MINUTES
206
+ );
207
+ setMaxSplitDurationMinutes(
208
+ source.max_split_duration_minutes ?? DEFAULT_MAX_SPLIT_MINUTES
209
+ );
210
+ setCalendarHours(source.calendar_hours ?? null);
211
+ setAutoSchedule(source.auto_schedule ?? false);
212
+ }, [personalSchedule, task]);
213
+
214
+ const durationHours = Math.floor(durationMinutes / 60);
215
+ const minutesRemainder = durationMinutes % 60;
216
+ const disabled = isLoading || saving || isFetching;
217
+ const formattedDuration = formatTaskDurationLabel(
218
+ taskDurationMinutesToHours(durationMinutes)
219
+ );
220
+
221
+ const updateTaskCache = (updates: Partial<Task>) => {
222
+ queryClient.setQueryData(
223
+ ['tasks', boardId],
224
+ (oldTasks: Task[] | undefined) =>
225
+ oldTasks?.map((cachedTask) =>
226
+ cachedTask.id === task.id ? { ...cachedTask, ...updates } : cachedTask
227
+ )
228
+ );
229
+ };
230
+
231
+ const saveSettings = async (clear = false) => {
232
+ const nextDurationMinutes = clear ? 0 : durationMinutes;
233
+ const nextDuration = taskDurationMinutesToHours(nextDurationMinutes);
234
+ const updates = {
235
+ total_duration: nextDuration,
236
+ is_splittable: clear ? false : isSplittable,
237
+ min_split_duration_minutes:
238
+ clear || !isSplittable ? null : minSplitDurationMinutes,
239
+ max_split_duration_minutes:
240
+ clear || !isSplittable ? null : maxSplitDurationMinutes,
241
+ calendar_hours: clear ? null : calendarHours,
242
+ auto_schedule: clear ? false : autoSchedule,
243
+ } satisfies Partial<Task>;
244
+
245
+ const previousTasks = queryClient.getQueryData<Task[]>(['tasks', boardId]);
246
+ updateTaskCache(updates);
247
+ setSaving(true);
248
+
249
+ try {
250
+ await updateCurrentUserTaskSchedulingSettings(task.id, updates);
251
+ queryClient.setQueryData(
252
+ ['task-personal-schedule', task.id],
253
+ (oldSchedule: typeof personalSchedule | undefined) =>
254
+ oldSchedule
255
+ ? {
256
+ ...oldSchedule,
257
+ task: {
258
+ ...oldSchedule.task,
259
+ ...updates,
260
+ },
261
+ }
262
+ : oldSchedule
263
+ );
264
+ await queryClient.invalidateQueries({
265
+ queryKey: ['task-personal-schedule', task.id],
266
+ });
267
+ toast.success(t.saved);
268
+ onUpdate();
269
+ setOpen(false);
270
+ onClose?.();
271
+ } catch (error) {
272
+ queryClient.setQueryData(['tasks', boardId], previousTasks);
273
+ toast.error(error instanceof Error ? error.message : t.error);
274
+ } finally {
275
+ setSaving(false);
276
+ }
277
+ };
278
+
279
+ const hourTypeOptions = [
280
+ {
281
+ value: 'work_hours' as const,
282
+ label: t.workHours,
283
+ icon: Briefcase,
284
+ },
285
+ {
286
+ value: 'meeting_hours' as const,
287
+ label: t.meetingHours,
288
+ icon: Calendar,
289
+ },
290
+ {
291
+ value: 'personal_hours' as const,
292
+ label: t.personalHours,
293
+ icon: User,
294
+ },
295
+ ];
296
+
297
+ return (
298
+ <DropdownMenuSub open={open} onOpenChange={setOpen}>
299
+ <DropdownMenuSubTrigger>
300
+ <CalendarClock className="h-4 w-4 text-dynamic-amber" />
301
+ <div className="flex w-full items-center justify-between gap-2">
302
+ <span>{t.schedule}</span>
303
+ {formattedDuration && (
304
+ <span className="ml-auto text-muted-foreground text-xs">
305
+ {formattedDuration}
306
+ </span>
307
+ )}
308
+ </div>
309
+ </DropdownMenuSubTrigger>
310
+ <DropdownMenuSubContent
311
+ className="w-72 p-0"
312
+ onClick={(event) => event.stopPropagation()}
313
+ onKeyDown={(event) => event.stopPropagation()}
314
+ >
315
+ <div className="space-y-3 p-3">
316
+ <div className="space-y-2">
317
+ <Label className="flex items-center gap-1.5 font-normal text-muted-foreground text-xs">
318
+ <Clock className="h-3.5 w-3.5" />
319
+ {t.estimatedDuration}
320
+ </Label>
321
+ <div className="flex items-center gap-3">
322
+ <DurationInput
323
+ disabled={disabled}
324
+ label={t.h}
325
+ onChange={(value) =>
326
+ setDurationMinutes(value * 60 + minutesRemainder)
327
+ }
328
+ value={durationHours}
329
+ />
330
+ <DurationInput
331
+ disabled={disabled}
332
+ label={t.m}
333
+ max={45}
334
+ onChange={(value) =>
335
+ setDurationMinutes(durationHours * 60 + value)
336
+ }
337
+ step={15}
338
+ value={minutesRemainder}
339
+ />
340
+ </div>
341
+ </div>
342
+
343
+ <div className="flex items-center justify-between">
344
+ <Label className="flex cursor-pointer items-center gap-1.5 font-normal text-muted-foreground text-xs">
345
+ <Scissors className="h-3.5 w-3.5" />
346
+ {t.splittable}
347
+ </Label>
348
+ <Switch
349
+ checked={isSplittable}
350
+ disabled={disabled}
351
+ onCheckedChange={setIsSplittable}
352
+ />
353
+ </div>
354
+
355
+ {isSplittable && (
356
+ <div className="grid grid-cols-2 gap-3">
357
+ <div className="space-y-1.5">
358
+ <Label className="font-normal text-muted-foreground text-xs">
359
+ {t.minSplit}
360
+ </Label>
361
+ <DurationInput
362
+ disabled={disabled}
363
+ label={t.m}
364
+ max={maxSplitDurationMinutes}
365
+ min={15}
366
+ onChange={setMinSplitDurationMinutes}
367
+ step={15}
368
+ value={minSplitDurationMinutes}
369
+ />
370
+ </div>
371
+ <div className="space-y-1.5">
372
+ <Label className="font-normal text-muted-foreground text-xs">
373
+ {t.maxSplit}
374
+ </Label>
375
+ <DurationInput
376
+ disabled={disabled}
377
+ label={t.m}
378
+ max={480}
379
+ min={minSplitDurationMinutes}
380
+ onChange={setMaxSplitDurationMinutes}
381
+ step={15}
382
+ value={maxSplitDurationMinutes}
383
+ />
384
+ </div>
385
+ </div>
386
+ )}
387
+
388
+ <div className="space-y-1.5">
389
+ <Label className="flex items-center gap-1.5 font-normal text-muted-foreground text-xs">
390
+ <Briefcase className="h-3.5 w-3.5" />
391
+ {t.hourType}
392
+ </Label>
393
+ <div className="inline-flex rounded-md border border-border p-0.5">
394
+ {hourTypeOptions.map((option) => {
395
+ const Icon = option.icon;
396
+ const selected = calendarHours === option.value;
397
+ return (
398
+ <button
399
+ key={option.value}
400
+ type="button"
401
+ className={cn(
402
+ 'flex items-center gap-1.5 rounded px-2 py-1 text-xs',
403
+ selected
404
+ ? 'bg-primary text-primary-foreground'
405
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'
406
+ )}
407
+ disabled={disabled}
408
+ onClick={() => setCalendarHours(option.value)}
409
+ title={option.label}
410
+ >
411
+ <Icon className="h-3.5 w-3.5" />
412
+ <span>{option.label.split(' ')[0]}</span>
413
+ </button>
414
+ );
415
+ })}
416
+ </div>
417
+ </div>
418
+
419
+ <div className="flex items-center justify-between">
420
+ <Label className="flex cursor-pointer items-center gap-1.5 font-normal text-muted-foreground text-xs">
421
+ <Zap className="h-3.5 w-3.5" />
422
+ {t.autoSchedule}
423
+ </Label>
424
+ <Switch
425
+ checked={autoSchedule}
426
+ disabled={disabled}
427
+ onCheckedChange={setAutoSchedule}
428
+ />
429
+ </div>
430
+
431
+ <div className="flex items-center gap-2 border-t pt-3">
432
+ <Button
433
+ className="h-8 flex-1 gap-1.5"
434
+ disabled={disabled}
435
+ onClick={() => void saveSettings(false)}
436
+ size="sm"
437
+ type="button"
438
+ variant="default"
439
+ >
440
+ {saving ? (
441
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
442
+ ) : (
443
+ <Save className="h-3.5 w-3.5" />
444
+ )}
445
+ {t.save}
446
+ </Button>
447
+ <Button
448
+ className="h-8 gap-1.5"
449
+ disabled={disabled}
450
+ onClick={() => void saveSettings(true)}
451
+ size="sm"
452
+ type="button"
453
+ variant="outline"
454
+ >
455
+ <X className="h-3.5 w-3.5" />
456
+ {t.clear}
457
+ </Button>
458
+ </div>
459
+ </div>
460
+ </DropdownMenuSubContent>
461
+ </DropdownMenuSub>
462
+ );
463
+ }