@tuturuuu/ui 0.13.0 → 0.16.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 (96) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shouldRenderTaskCardQuickArchive } from './task-card-archive-visibility';
3
+
4
+ describe('shouldRenderTaskCardQuickArchive', () => {
5
+ it('shows quick archive for document lists when a closed list exists', () => {
6
+ expect(
7
+ shouldRenderTaskCardQuickArchive({
8
+ hasTargetClosedList: true,
9
+ isOverlay: false,
10
+ taskListStatus: 'documents',
11
+ })
12
+ ).toBe(true);
13
+ });
14
+
15
+ it('keeps quick archive hidden for document lists without a closed list', () => {
16
+ expect(
17
+ shouldRenderTaskCardQuickArchive({
18
+ hasTargetClosedList: false,
19
+ isOverlay: false,
20
+ taskListStatus: 'documents',
21
+ })
22
+ ).toBe(false);
23
+ });
24
+
25
+ it('keeps non-done workflow lists unchanged', () => {
26
+ expect(
27
+ shouldRenderTaskCardQuickArchive({
28
+ hasTargetClosedList: true,
29
+ isOverlay: false,
30
+ taskListStatus: 'active',
31
+ })
32
+ ).toBe(false);
33
+ });
34
+
35
+ it('hides quick archive while rendering a drag overlay', () => {
36
+ expect(
37
+ shouldRenderTaskCardQuickArchive({
38
+ hasTargetClosedList: true,
39
+ isOverlay: true,
40
+ taskListStatus: 'documents',
41
+ })
42
+ ).toBe(false);
43
+ });
44
+ });
@@ -0,0 +1,17 @@
1
+ interface TaskCardQuickArchiveVisibilityOptions {
2
+ hasTargetClosedList: boolean;
3
+ isOverlay: boolean;
4
+ taskListStatus?: string | null;
5
+ }
6
+
7
+ export function shouldRenderTaskCardQuickArchive({
8
+ hasTargetClosedList,
9
+ isOverlay,
10
+ taskListStatus,
11
+ }: TaskCardQuickArchiveVisibilityOptions) {
12
+ return (
13
+ !isOverlay &&
14
+ hasTargetClosedList &&
15
+ (taskListStatus === 'done' || taskListStatus === 'documents')
16
+ );
17
+ }
@@ -102,6 +102,8 @@ describe('getTaskCardHydratingOpenOptions', () => {
102
102
  workspaceMembers: [],
103
103
  workspaceProjects: [],
104
104
  },
105
+ visibleBoardId: 'personal-board',
106
+ visibleTaskSnapshot: externalTask,
105
107
  });
106
108
  });
107
109
 
@@ -109,6 +109,7 @@ export function getTaskCardHydratingOpenOptions({
109
109
  }: TaskCardOpenOptionsInput) {
110
110
  const sourceWorkspaceId = task.source_workspace_id;
111
111
  const sourceBoardId = task.source_board_id;
112
+ const isExternalSnapshot = isExternalTaskSnapshot(task);
112
113
  const initialSharedContext = buildInitialSourceContext(
113
114
  task,
114
115
  sourceWorkspaceId ?? undefined,
@@ -137,5 +138,11 @@ export function getTaskCardHydratingOpenOptions({
137
138
  assigneeMemberSource:
138
139
  assigneeMemberSource ?? (sourceWorkspaceId ? 'board' : undefined),
139
140
  initialSharedContext,
141
+ ...(isExternalSnapshot
142
+ ? {
143
+ visibleBoardId: boardId,
144
+ visibleTaskSnapshot: task,
145
+ }
146
+ : {}),
140
147
  };
141
148
  }
@@ -143,6 +143,7 @@ import { TaskNewLabelDialog } from '../task-dialogs/TaskNewLabelDialog';
143
143
  import { TaskNewProjectDialog } from '../task-dialogs/TaskNewProjectDialog';
144
144
  import { getTaskCardParentBadgeState } from '../task-parent-badge-state';
145
145
  import { TaskCardCheckbox } from './TaskCardCheckbox';
146
+ import { shouldRenderTaskCardQuickArchive } from './task-card-archive-visibility';
146
147
  import {
147
148
  getTaskCardSelectionCheckboxToneClasses,
148
149
  TASK_CARD_OVERDUE_CHECKBOX_TONE_CLASSES,
@@ -780,8 +781,12 @@ function TaskCardInner({
780
781
 
781
782
  // Find the first list with 'done' or 'closed' status
782
783
  const getTargetCompletionList = () => {
783
- const doneList = availableLists.find((list) => list.status === 'done');
784
- const closedList = availableLists.find((list) => list.status === 'closed');
784
+ const doneList = availableLists.find(
785
+ (list) => list.status === 'done' && !list.deleted
786
+ );
787
+ const closedList = availableLists.find(
788
+ (list) => list.status === 'closed' && !list.deleted
789
+ );
785
790
  return doneList || closedList || null;
786
791
  };
787
792
 
@@ -874,7 +879,11 @@ function TaskCardInner({
874
879
 
875
880
  // Find specifically the closed list
876
881
  const getTargetClosedList = () => {
877
- return availableLists.find((list) => list.status === 'closed') || null;
882
+ return (
883
+ availableLists.find(
884
+ (list) => list.status === 'closed' && !list.deleted
885
+ ) || null
886
+ );
878
887
  };
879
888
 
880
889
  const targetCompletionList = getTargetCompletionList();
@@ -883,8 +892,15 @@ function TaskCardInner({
883
892
  targetCompletionList && targetCompletionList.id !== task.list_id;
884
893
  const canMoveToClose =
885
894
  targetClosedList && targetClosedList.id !== task.list_id;
886
- const canQuickArchive =
887
- taskList?.status === 'done' && !isOverlay && Boolean(canMoveToClose);
895
+ const isDocumentList = taskList?.status === 'documents';
896
+ const canQuickArchive = shouldRenderTaskCardQuickArchive({
897
+ hasTargetClosedList: Boolean(canMoveToClose),
898
+ isOverlay: Boolean(isOverlay),
899
+ taskListStatus: taskList?.status,
900
+ });
901
+ const canShowCloseAction =
902
+ Boolean(canMoveToClose) &&
903
+ (isDocumentList || targetClosedList?.id !== targetCompletionList?.id);
888
904
  const quickArchiveToneClass = taskList?.color
889
905
  ? DESTINATION_TONE_COLORS[taskList.color]
890
906
  : DESTINATION_TONE_COLORS.GRAY;
@@ -2035,7 +2051,7 @@ function TaskCardInner({
2035
2051
  e.stopPropagation(); // Prevent triggering task card click
2036
2052
  }}
2037
2053
  >
2038
- {taskList?.status !== 'documents' && (
2054
+ {!isDocumentList && (
2039
2055
  <DropdownMenuItem
2040
2056
  className="cursor-pointer"
2041
2057
  disabled={isLoading}
@@ -2051,11 +2067,9 @@ function TaskCardInner({
2051
2067
  </Link>
2052
2068
  </DropdownMenuItem>
2053
2069
  )}
2054
- {taskList?.status !== 'documents' && (
2055
- <DropdownMenuSeparator />
2056
- )}
2070
+ {!isDocumentList && <DropdownMenuSeparator />}
2057
2071
  {/* Quick Completion Action */}
2058
- {taskList?.status !== 'documents' && canMoveToCompletion && (
2072
+ {!isDocumentList && canMoveToCompletion && (
2059
2073
  <DropdownMenuItem
2060
2074
  onSelect={(e) =>
2061
2075
  handleMenuItemSelect(
@@ -2074,28 +2088,28 @@ function TaskCardInner({
2074
2088
  )}
2075
2089
 
2076
2090
  {/* Mark as Closed Action - Only show if closed list exists and is different from the generic completion */}
2077
- {taskList?.status !== 'documents' &&
2078
- canMoveToClose &&
2079
- targetClosedList?.id !== targetCompletionList?.id && (
2080
- <DropdownMenuItem
2081
- onSelect={(e) =>
2082
- handleMenuItemSelect(
2083
- e as unknown as Event,
2084
- handleMoveToClose
2085
- )
2086
- }
2087
- className="cursor-pointer"
2088
- disabled={isLoading}
2089
- >
2091
+ {canShowCloseAction && (
2092
+ <DropdownMenuItem
2093
+ onSelect={(e) =>
2094
+ handleMenuItemSelect(
2095
+ e as unknown as Event,
2096
+ handleMoveToClose
2097
+ )
2098
+ }
2099
+ className="cursor-pointer"
2100
+ disabled={isLoading}
2101
+ >
2102
+ {isDocumentList ? (
2103
+ <Archive className="h-4 w-4 text-dynamic-purple" />
2104
+ ) : (
2090
2105
  <CircleSlash className="h-4 w-4 text-dynamic-purple" />
2091
- {t('mark_as_closed')}
2092
- </DropdownMenuItem>
2093
- )}
2106
+ )}
2107
+ {isDocumentList ? t('archive') : t('mark_as_closed')}
2108
+ </DropdownMenuItem>
2109
+ )}
2094
2110
 
2095
- {taskList?.status !== 'documents' &&
2096
- (canMoveToCompletion || canMoveToClose) && (
2097
- <DropdownMenuSeparator />
2098
- )}
2111
+ {((!isDocumentList && canMoveToCompletion) ||
2112
+ canShowCloseAction) && <DropdownMenuSeparator />}
2099
2113
 
2100
2114
  {/* Priority Menu */}
2101
2115
  <TaskPriorityMenu
@@ -15,6 +15,7 @@ const {
15
15
  mockT,
16
16
  mockToastError,
17
17
  mockUpdateWorkspaceTask,
18
+ mockUpsertCurrentUserTaskPersonalPlacement,
18
19
  } = vi.hoisted(() => ({
19
20
  mockAddWorkspaceTaskLabel: vi.fn(),
20
21
  mockDeleteWorkspaceTask: vi.fn(),
@@ -26,6 +27,7 @@ const {
26
27
  mockT: vi.fn((key: string) => key),
27
28
  mockToastError: vi.fn(),
28
29
  mockUpdateWorkspaceTask: vi.fn(),
30
+ mockUpsertCurrentUserTaskPersonalPlacement: vi.fn(),
29
31
  }));
30
32
 
31
33
  vi.mock('@tanstack/react-query', () => ({
@@ -45,6 +47,8 @@ vi.mock('@tuturuuu/internal-api', () => ({
45
47
  listWorkspaceTaskLists: mockListWorkspaceTaskLists,
46
48
  removeWorkspaceTaskLabel: mockRemoveWorkspaceTaskLabel,
47
49
  updateWorkspaceTask: mockUpdateWorkspaceTask,
50
+ upsertCurrentUserTaskPersonalPlacement:
51
+ mockUpsertCurrentUserTaskPersonalPlacement,
48
52
  }));
49
53
 
50
54
  vi.mock('next-intl', () => ({
@@ -106,6 +110,9 @@ describe('useTaskContextActions', () => {
106
110
  });
107
111
  mockRemoveWorkspaceTaskLabel.mockResolvedValue({ success: true });
108
112
  mockUpdateWorkspaceTask.mockResolvedValue({ task: null });
113
+ mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValue({
114
+ task: { id: 'task-123' },
115
+ });
109
116
  });
110
117
 
111
118
  it('handleDoneWithMyPart sends PUT with personally_unassigned=true', async () => {
@@ -230,6 +237,53 @@ describe('useTaskContextActions', () => {
230
237
  );
231
238
  });
232
239
 
240
+ it('handleComplete uses terminal personal placement for personal external tasks', async () => {
241
+ const taskWithPersonalPlacement = {
242
+ ...mockTask,
243
+ overrides: {
244
+ task_id: 'task-123',
245
+ user_id: 'user-1',
246
+ self_managed: false,
247
+ completed_at: null,
248
+ priority_override: null,
249
+ due_date_override: null,
250
+ estimation_override: null,
251
+ personally_unassigned: false,
252
+ notes: null,
253
+ personal_board_id: 'personal-board',
254
+ personal_list_id: 'personal-list',
255
+ created_at: '2026-01-01T00:00:00Z',
256
+ updated_at: '2026-01-01T00:00:00Z',
257
+ },
258
+ } as TaskWithRelations;
259
+
260
+ const { result } = renderHook(() =>
261
+ useTaskContextActions({
262
+ task: taskWithPersonalPlacement,
263
+ userId,
264
+ onTaskUpdate,
265
+ onClose,
266
+ })
267
+ );
268
+
269
+ await act(async () => {
270
+ await result.current.handleComplete();
271
+ });
272
+
273
+ expect(mockListWorkspaceTaskLists).not.toHaveBeenCalled();
274
+ expect(mockUpdateWorkspaceTask).not.toHaveBeenCalled();
275
+ expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
276
+ 'task-123',
277
+ {
278
+ personal_board_id: 'personal-board',
279
+ personal_list_id: 'personal-list',
280
+ terminal_status: 'done',
281
+ }
282
+ );
283
+ expect(onTaskUpdate).toHaveBeenCalled();
284
+ expect(onClose).toHaveBeenCalled();
285
+ });
286
+
233
287
  it('handleComplete clears overrides when task has personally_unassigned', async () => {
234
288
  const taskWithPersonallyUnassigned = {
235
289
  ...mockTask,
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  listWorkspaceTaskLists,
16
16
  updateWorkspaceTask,
17
+ upsertCurrentUserTaskPersonalPlacement,
17
18
  } from '@tuturuuu/internal-api';
18
19
  import type { TaskWithRelations } from '@tuturuuu/types';
19
20
  import type { Task as PrimitiveTask } from '@tuturuuu/types/primitives/Task';
@@ -200,8 +201,50 @@ export default function TaskListWithCompletion({
200
201
 
201
202
  setCompletingTasks((prev) => new Set(prev).add(task.id));
202
203
  const snapshot = snapshotCache();
204
+ const isCompleted = task.list?.status === 'done';
205
+ const personalPlacement = task.overrides as
206
+ | (NonNullable<typeof task.overrides> & {
207
+ personal_board_id?: string | null;
208
+ personal_list_id?: string | null;
209
+ })
210
+ | null;
211
+ const personalBoardId = personalPlacement?.personal_board_id ?? null;
203
212
 
204
213
  try {
214
+ if (personalBoardId && !isCompleted) {
215
+ removeTaskFromCache(task.id);
216
+
217
+ await upsertCurrentUserTaskPersonalPlacement(task.id, {
218
+ personal_board_id: personalBoardId,
219
+ personal_list_id: personalPlacement?.personal_list_id ?? null,
220
+ terminal_status: 'done',
221
+ });
222
+
223
+ if (
224
+ task.overrides?.completed_at ||
225
+ task.overrides?.personally_unassigned
226
+ ) {
227
+ await fetch(
228
+ getTaskApiUrl(`/api/v1/users/me/tasks/${task.id}/overrides`),
229
+ {
230
+ method: 'PUT',
231
+ credentials: 'include',
232
+ headers: { 'Content-Type': 'application/json' },
233
+ body: JSON.stringify({
234
+ completed_at: null,
235
+ personally_unassigned: false,
236
+ }),
237
+ }
238
+ ).catch(() => {
239
+ // Non-critical cleanup — don't block the completion flow
240
+ });
241
+ }
242
+
243
+ toast.success(t('task_completed_toast'));
244
+ onTaskUpdate?.();
245
+ return;
246
+ }
247
+
205
248
  // Find the board's done list
206
249
  const { lists } = await listWorkspaceTaskLists(
207
250
  task.list.board.ws_id,
@@ -221,7 +264,6 @@ export default function TaskListWithCompletion({
221
264
  return;
222
265
  }
223
266
 
224
- const isCompleted = task.list?.status === 'done';
225
267
  const targetListId = isCompleted ? notStartedList.id : doneList.id;
226
268
 
227
269
  // Optimistic: remove from view (completion hides task)
@@ -7,6 +7,7 @@ import {
7
7
  listWorkspaceTaskLists,
8
8
  removeWorkspaceTaskLabel,
9
9
  updateWorkspaceTask,
10
+ upsertCurrentUserTaskPersonalPlacement,
10
11
  } from '@tuturuuu/internal-api';
11
12
  import type { TaskWithRelations } from '@tuturuuu/types';
12
13
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
@@ -221,23 +222,40 @@ export function useTaskContextActions({
221
222
  if (!taskBoardId || !taskWorkspaceId) return;
222
223
  setIsLoading(true);
223
224
  try {
224
- const { lists } = await listWorkspaceTaskLists(
225
- taskWorkspaceId,
226
- taskBoardId
227
- );
225
+ const personalPlacement = task.overrides as
226
+ | (NonNullable<typeof task.overrides> & {
227
+ personal_board_id?: string | null;
228
+ personal_list_id?: string | null;
229
+ })
230
+ | null;
231
+ const personalBoardId = personalPlacement?.personal_board_id ?? null;
228
232
 
229
- const doneList = lists?.find((l) => l.status === 'done');
230
- if (!doneList) {
231
- toast.error('No done list found');
232
- return;
233
- }
233
+ if (personalBoardId) {
234
+ await upsertCurrentUserTaskPersonalPlacement(task.id, {
235
+ personal_board_id: personalBoardId,
236
+ personal_list_id: personalPlacement?.personal_list_id ?? null,
237
+ terminal_status: 'done',
238
+ });
239
+ broadcastTaskUpsert({});
240
+ } else {
241
+ const { lists } = await listWorkspaceTaskLists(
242
+ taskWorkspaceId,
243
+ taskBoardId
244
+ );
234
245
 
235
- await updateWorkspaceTask(taskWorkspaceId, task.id, {
236
- list_id: doneList.id,
237
- });
238
- broadcastTaskUpsert({
239
- list_id: doneList.id,
240
- });
246
+ const doneList = lists?.find((l) => l.status === 'done');
247
+ if (!doneList) {
248
+ toast.error('No done list found');
249
+ return;
250
+ }
251
+
252
+ await updateWorkspaceTask(taskWorkspaceId, task.id, {
253
+ list_id: doneList.id,
254
+ });
255
+ broadcastTaskUpsert({
256
+ list_id: doneList.id,
257
+ });
258
+ }
241
259
 
242
260
  // Clear redundant personal overrides when task is actually done
243
261
  if (
@@ -19,7 +19,7 @@ function MetricSelect({
19
19
  }) {
20
20
  return (
21
21
  <select
22
- className="h-10 rounded-md border bg-background px-3 text-sm"
22
+ className="h-11 w-full rounded-xl border bg-background px-3 text-sm shadow-sm outline-none focus:border-dynamic-blue focus:ring-2 focus:ring-dynamic-blue/15"
23
23
  defaultValue={selectedMetric?.id}
24
24
  name="metric_id"
25
25
  required
@@ -55,8 +55,8 @@ export function LeaderboardsPanel(props: {
55
55
  } = props;
56
56
 
57
57
  return (
58
- <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
59
- <Card>
58
+ <div className="grid gap-4 xl:grid-cols-[minmax(20rem,0.75fr)_minmax(0,1.25fr)]">
59
+ <Card className="h-fit xl:sticky xl:top-6">
60
60
  <CardHeader>
61
61
  <CardTitle>{t('leaderboards.create_leaderboard')}</CardTitle>
62
62
  </CardHeader>
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
- import { BarChart3, Flag, Target, TrendingUp, Trophy } from '@tuturuuu/icons';
4
+ import { BarChart3, Flag, Sparkles, Target, Trophy } from '@tuturuuu/icons';
5
5
  import {
6
6
  createTaskLeaderboard,
7
7
  createTaskLeaderboardTeam,
@@ -40,6 +40,7 @@ export type TaskProgressView =
40
40
  | 'import';
41
41
 
42
42
  interface TaskProgressPageProps {
43
+ onViewChange?: (view: TaskProgressView) => void;
43
44
  routeWsId: string;
44
45
  view: TaskProgressView;
45
46
  wsId: string;
@@ -87,6 +88,7 @@ function parseImportRows(text: string, metrics: TaskProgressMetric[]) {
87
88
  }
88
89
 
89
90
  export function TaskProgressPage({
91
+ onViewChange,
90
92
  routeWsId,
91
93
  view,
92
94
  wsId,
@@ -95,6 +97,7 @@ export function TaskProgressPage({
95
97
  const queryClient = useQueryClient();
96
98
  const [importText, setImportText] = useState('');
97
99
  const [importPreviewCount, setImportPreviewCount] = useState(0);
100
+ const [selectedMetricId, setSelectedMetricId] = useState<string | null>(null);
98
101
  const queryRoot = ['task-progress', wsId];
99
102
 
100
103
  const metricsQuery = useQuery({
@@ -103,7 +106,12 @@ export function TaskProgressPage({
103
106
  });
104
107
  const metrics =
105
108
  metricsQuery.data?.ok === true ? metricsQuery.data.metrics : [];
106
- const selectedMetric = useMemo(() => metricOption(metrics), [metrics]);
109
+ const selectedMetric = useMemo(
110
+ () =>
111
+ metrics.find((metric) => metric.id === selectedMetricId) ??
112
+ metricOption(metrics),
113
+ [metrics, selectedMetricId]
114
+ );
107
115
 
108
116
  const entriesQuery = useQuery({
109
117
  queryKey: [...queryRoot, 'entries', selectedMetric?.id],
@@ -238,40 +246,78 @@ export function TaskProgressPage({
238
246
  : [];
239
247
 
240
248
  return (
241
- <div className="flex flex-col gap-6 p-4 md:p-6">
242
- <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
243
- <div className="space-y-2">
244
- <div className="flex items-center gap-3">
245
- <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-dynamic-blue/10 ring-1 ring-dynamic-blue/20">
246
- <TrendingUp className="h-5 w-5 text-dynamic-blue" />
249
+ <div className="mx-auto flex w-full max-w-[1600px] flex-col gap-6 p-4 md:p-6 lg:p-8">
250
+ <section className="relative overflow-hidden rounded-3xl border bg-gradient-to-br from-background via-background to-dynamic-blue/10 p-5 shadow-sm md:p-7">
251
+ <div className="pointer-events-none absolute -top-24 -right-20 h-64 w-64 rounded-full bg-dynamic-blue/10 blur-3xl" />
252
+ <div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
253
+ <div className="max-w-2xl space-y-3">
254
+ <div className="flex items-center gap-2 font-semibold text-dynamic-blue text-xs uppercase tracking-[0.18em]">
255
+ <Sparkles className="h-4 w-4" />
256
+ {t('views.progress.title')}
247
257
  </div>
248
258
  <div>
249
- <h1 className="font-bold text-2xl tracking-tight">
259
+ <h1 className="font-bold text-3xl tracking-tight md:text-4xl">
250
260
  {t(`views.${view}.title`)}
251
261
  </h1>
252
- <p className="text-muted-foreground text-sm">
262
+ <p className="mt-2 text-muted-foreground">
253
263
  {t(`views.${view}.description`)}
254
264
  </p>
255
265
  </div>
266
+ {metrics.length > 0 ? (
267
+ <label className="inline-flex items-center gap-3 rounded-xl border bg-background/80 px-3 py-2 text-sm shadow-sm backdrop-blur">
268
+ <span className="text-muted-foreground">
269
+ {t('fields.metric_name')}
270
+ </span>
271
+ <select
272
+ className="min-w-32 bg-transparent font-medium outline-none"
273
+ onChange={(event) => setSelectedMetricId(event.target.value)}
274
+ value={selectedMetric?.id ?? ''}
275
+ >
276
+ {metrics.map((metric) => (
277
+ <option key={metric.id} value={metric.id}>
278
+ {metric.name}
279
+ </option>
280
+ ))}
281
+ </select>
282
+ </label>
283
+ ) : null}
256
284
  </div>
285
+ <nav className="flex max-w-full gap-1 overflow-x-auto rounded-2xl border bg-background/80 p-1.5 shadow-sm backdrop-blur">
286
+ {(
287
+ ['progress', 'goals', 'stats', 'leaderboards', 'import'] as const
288
+ ).map((tab) =>
289
+ onViewChange ? (
290
+ <Button
291
+ key={tab}
292
+ onClick={() => onViewChange(tab)}
293
+ size="sm"
294
+ type="button"
295
+ variant={tab === view ? 'default' : 'ghost'}
296
+ >
297
+ {t(`tabs.${tab}`)}
298
+ </Button>
299
+ ) : (
300
+ <Button
301
+ key={tab}
302
+ asChild
303
+ size="sm"
304
+ variant={tab === view ? 'default' : 'ghost'}
305
+ >
306
+ <Link
307
+ href={
308
+ tab === 'progress'
309
+ ? `/${routeWsId}/progress`
310
+ : `/${routeWsId}/progress/${tab}`
311
+ }
312
+ >
313
+ {t(`tabs.${tab}`)}
314
+ </Link>
315
+ </Button>
316
+ )
317
+ )}
318
+ </nav>
257
319
  </div>
258
- <div className="flex flex-wrap gap-2">
259
- {(
260
- ['progress', 'goals', 'stats', 'leaderboards', 'import'] as const
261
- ).map((tab) => (
262
- <Button
263
- key={tab}
264
- asChild
265
- size="sm"
266
- variant={tab === view ? 'default' : 'outline'}
267
- >
268
- <Link href={`/${routeWsId}/tasks/${tab}`}>
269
- {t(`tabs.${tab}`)}
270
- </Link>
271
- </Button>
272
- ))}
273
- </div>
274
- </div>
320
+ </section>
275
321
 
276
322
  {hasPendingSchema ? (
277
323
  <Card>
@@ -281,7 +327,7 @@ export function TaskProgressPage({
281
327
  </Card>
282
328
  ) : null}
283
329
 
284
- <div className="grid gap-4 md:grid-cols-4">
330
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
285
331
  <SummaryCard
286
332
  icon={<BarChart3 className="h-4 w-4" />}
287
333
  label={t('summary.total')}