@tuturuuu/ui 0.12.0 → 0.13.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 (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -4
  3. package/src/components/ui/calendar-app/components/habits-panel.tsx +5 -2
  4. package/src/components/ui/calendar-app/components/priority-view.tsx +15 -7
  5. package/src/components/ui/calendar-app/components/scheduling-dialog.tsx +7 -3
  6. package/src/components/ui/calendar-app/components/task-scheduler-panel.tsx +7 -3
  7. package/src/components/ui/calendar-app/components/time-tracker/index.tsx +14 -9
  8. package/src/components/ui/custom/settings/task-settings.tsx +6 -2
  9. package/src/components/ui/custom/tables/data-table.tsx +58 -8
  10. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -1
  11. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +3 -6
  12. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +11 -1
  13. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -3
  14. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.test.ts +113 -0
  15. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.ts +50 -0
  16. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +65 -18
  17. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +1 -1
  18. package/src/components/ui/tu-do/cycles/task-cycles-client.tsx +23 -14
  19. package/src/components/ui/tu-do/estimates/use-task-estimates.ts +9 -4
  20. package/src/components/ui/tu-do/habits/client.tsx +4 -1
  21. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +6 -3
  22. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +19 -11
  23. package/src/components/ui/tu-do/my-tasks/use-my-tasks-query.ts +15 -6
  24. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +18 -13
  25. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +17 -10
  26. package/src/components/ui/tu-do/shared/fade-setting-initializer.tsx +3 -1
  27. package/src/components/ui/tu-do/shared/list-view.tsx +1 -1
  28. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +6 -2
  29. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-update-shared-task.test.ts +9 -5
  30. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +13 -8
  31. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-overrides.ts +21 -9
  32. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-update-shared-task.ts +10 -5
  33. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +4 -2
  34. package/src/components/ui/tu-do/shared/task-estimation-picker.tsx +5 -2
  35. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +1 -1
  36. package/src/hooks/__tests__/use-task-actions.test.tsx +51 -0
  37. package/src/hooks/task-actions-personal-external.ts +2 -3
  38. package/src/hooks/use-board-actions.ts +27 -17
  39. package/src/hooks/use-calendar.tsx +7 -3
  40. package/src/hooks/use-settings-dialog-shortcut.ts +51 -0
  41. package/src/lib/task-personal-external.ts +49 -0
  42. package/src/lib/tasks-app-url.ts +85 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0](https://github.com/tutur3u/platform/compare/ui-v0.12.0...ui-v0.13.0) (2026-07-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **satellite:** open settings on Cmd/Ctrl+, in satellite apps ([841f425](https://github.com/tutur3u/platform/commit/841f42557dd49eb31fd797900b1ec6ade6642dba))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **tasks:** harden satellite task route hydration ([9d51a38](https://github.com/tutur3u/platform/commit/9d51a38ad948ebb398bec794f082f2bbf8d466cc))
14
+ * **tasks:** keep personal board moves on workspace route ([60e29b0](https://github.com/tutur3u/platform/commit/60e29b093314b3b185f3f63a69042a665644ed3d))
15
+ * **tasks:** keep personal moves off placement API ([d8c41d7](https://github.com/tutur3u/platform/commit/d8c41d790f5edbe637b9ce71bbd5040b7fddbfa5))
16
+ * **tasks:** restore satellite resource access ([ec5e0a8](https://github.com/tutur3u/platform/commit/ec5e0a8d75398047e0151ae2ac3cd9a25a4fd675))
17
+ * **tasks:** route native personal task moves normally ([5bbba3c](https://github.com/tutur3u/platform/commit/5bbba3c3f193e722cc87cdfe2eb2734f61442cd0))
18
+
3
19
  ## [0.12.0](https://github.com/tutur3u/platform/compare/ui-v0.11.1...ui-v0.12.0) (2026-07-03)
4
20
 
5
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -85,12 +85,12 @@
85
85
  "@tiptap/react": "3.27.1",
86
86
  "@tiptap/starter-kit": "3.27.1",
87
87
  "@tuturuuu/ai": "0.2.2",
88
- "@tuturuuu/apis": "0.7.0",
88
+ "@tuturuuu/apis": "0.7.1",
89
89
  "@tuturuuu/hooks": "0.0.2",
90
90
  "@tuturuuu/icons": "0.0.6",
91
- "@tuturuuu/internal-api": "0.14.0",
91
+ "@tuturuuu/internal-api": "0.14.1",
92
92
  "@tuturuuu/supabase": "0.4.0",
93
- "@tuturuuu/utils": "0.12.1",
93
+ "@tuturuuu/utils": "0.13.0",
94
94
  "@types/debug": "^4.1.13",
95
95
  "browser-image-compression": "^2.0.2",
96
96
  "class-variance-authority": "^0.7.1",
@@ -15,6 +15,7 @@ import { getRecurrenceDescription } from '@tuturuuu/types/primitives/Habit';
15
15
  import { cn } from '@tuturuuu/utils/format';
16
16
  import Link from 'next/link';
17
17
  import { useState } from 'react';
18
+ import { getTaskApiUrl, getTasksAppUrl } from '../../../../lib/tasks-app-url';
18
19
  import { Badge } from '../../badge';
19
20
  import { Button } from '../../button';
20
21
  import { Card, CardContent } from '../../card';
@@ -65,7 +66,9 @@ export function HabitsPanel({ wsId }: HabitsPanelProps) {
65
66
  } = useQuery({
66
67
  queryKey: ['habits', wsId],
67
68
  queryFn: async () => {
68
- const response = await fetch(`/api/v1/workspaces/${wsId}/habits`);
69
+ const response = await fetch(
70
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/habits`)
71
+ );
69
72
  if (!response.ok) {
70
73
  throw new Error('Failed to fetch habits');
71
74
  }
@@ -308,7 +311,7 @@ export function HabitsPanel({ wsId }: HabitsPanelProps) {
308
311
 
309
312
  {/* Footer */}
310
313
  <div className="border-t p-3">
311
- <Link href={`/${wsId}/tasks/habits`}>
314
+ <Link href={getTasksAppUrl(`/${wsId}/habits`)}>
312
315
  <Button variant="outline" size="sm" className="w-full">
313
316
  Manage Habits
314
317
  </Button>
@@ -32,6 +32,7 @@ import { useRouter } from 'next/navigation';
32
32
  import React, { useEffect, useMemo, useState } from 'react';
33
33
  import { useDebouncedCallback } from 'use-debounce';
34
34
  import { useCalendar } from '../../../../hooks/use-calendar';
35
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
35
36
  import { Progress } from '../../progress';
36
37
  import { toast } from '../../sonner';
37
38
  import type { ExtendedWorkspaceTask } from '../../time-tracker/types';
@@ -263,9 +264,11 @@ export default function PriorityView({
263
264
  const results = await Promise.allSettled(
264
265
  tasksToFetchSchedule.map(async (task) => {
265
266
  const response = await fetch(
266
- isPersonalWorkspace
267
- ? `/api/v1/users/me/tasks/${task.id}/schedule`
268
- : `/api/v1/workspaces/${task.ws_id ?? wsId}/tasks/${task.id}/schedule`,
267
+ getTaskApiUrl(
268
+ isPersonalWorkspace
269
+ ? `/api/v1/users/me/tasks/${task.id}/schedule`
270
+ : `/api/v1/workspaces/${task.ws_id ?? wsId}/tasks/${task.id}/schedule`
271
+ ),
269
272
  { cache: 'no-store' }
270
273
  );
271
274
  if (!response.ok) {
@@ -378,10 +381,15 @@ export default function PriorityView({
378
381
  const task = combinedTasks.find((t) => t.id === taskId);
379
382
  const taskWsId = task?.ws_id ?? wsId;
380
383
 
381
- const response = await fetch(`/api/${taskWsId}/task/${taskId}/edit`, {
382
- method: 'PATCH',
383
- body: JSON.stringify({ priority: newPriority }),
384
- });
384
+ const response = await fetch(
385
+ getTaskApiUrl(`/api/${taskWsId}/task/${taskId}/edit`),
386
+ {
387
+ method: 'PATCH',
388
+ credentials: 'include',
389
+ headers: { 'Content-Type': 'application/json' },
390
+ body: JSON.stringify({ priority: newPriority }),
391
+ }
392
+ );
385
393
 
386
394
  if (!response.ok) {
387
395
  throw new Error('Failed to update task priority');
@@ -17,6 +17,7 @@ import { createClient } from '@tuturuuu/supabase/next/client';
17
17
  import { cn } from '@tuturuuu/utils/format';
18
18
  import { useRouter } from 'next/navigation';
19
19
  import { useCallback, useEffect, useRef, useState } from 'react';
20
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
20
21
  import { Button } from '../../button';
21
22
  import {
22
23
  Dialog,
@@ -226,9 +227,12 @@ export function SchedulingDialog({
226
227
  queryKey: ['task-personal-schedule', task?.id, open],
227
228
  enabled: open && !!task?.id,
228
229
  queryFn: async () => {
229
- const res = await fetch(`/api/v1/users/me/tasks/${task!.id}/schedule`, {
230
- cache: 'no-store',
231
- });
230
+ const res = await fetch(
231
+ getTaskApiUrl(`/api/v1/users/me/tasks/${task!.id}/schedule`),
232
+ {
233
+ cache: 'no-store',
234
+ }
235
+ );
232
236
  if (!res.ok) return null;
233
237
  return (await res.json()) as null | {
234
238
  task: {
@@ -15,6 +15,7 @@ import {
15
15
  import type { TaskWithScheduling } from '@tuturuuu/types';
16
16
  import { cn } from '@tuturuuu/utils/format';
17
17
  import { useState } from 'react';
18
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
18
19
  import { Badge } from '../../badge';
19
20
  import { Button } from '../../button';
20
21
  import { Input } from '../../input';
@@ -147,11 +148,14 @@ export function TaskSchedulerPanel({
147
148
 
148
149
  try {
149
150
  const response = await fetch(
150
- isPersonalWorkspace
151
- ? `/api/v1/users/me/tasks/${task.id}/schedule`
152
- : `/api/v1/workspaces/${wsId}/tasks/${task.id}/schedule`,
151
+ getTaskApiUrl(
152
+ isPersonalWorkspace
153
+ ? `/api/v1/users/me/tasks/${task.id}/schedule`
154
+ : `/api/v1/workspaces/${wsId}/tasks/${task.id}/schedule`
155
+ ),
153
156
  {
154
157
  method: 'POST',
158
+ credentials: 'include',
155
159
  headers: { 'Content-Type': 'application/json' },
156
160
  }
157
161
  );
@@ -17,6 +17,7 @@ import {
17
17
  import type { TimeTrackingCategory } from '@tuturuuu/types';
18
18
  import { cn } from '@tuturuuu/utils/format';
19
19
  import { useCallback, useEffect, useState } from 'react';
20
+ import { getTaskApiUrl } from '../../../../../lib/tasks-app-url';
20
21
  import { Badge } from '../../../badge';
21
22
  import { Button } from '../../../button';
22
23
  import { Card, CardContent, CardHeader, CardTitle } from '../../../card';
@@ -201,15 +202,19 @@ export default function TimeTracker({ wsId, tasks = [] }: TimeTrackerProps) {
201
202
  setIsCreatingTask(true);
202
203
 
203
204
  try {
204
- const response = await fetch(`/api/v1/workspaces/${wsId}/tasks`, {
205
- method: 'POST',
206
- headers: { 'Content-Type': 'application/json' },
207
- body: JSON.stringify({
208
- name: newTaskName,
209
- description: newTaskDescription || null,
210
- listId: selectedListId,
211
- }),
212
- });
205
+ const response = await fetch(
206
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/tasks`),
207
+ {
208
+ method: 'POST',
209
+ credentials: 'include',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({
212
+ name: newTaskName,
213
+ description: newTaskDescription || null,
214
+ listId: selectedListId,
215
+ }),
216
+ }
217
+ );
213
218
 
214
219
  if (!response.ok) throw new Error('Failed to create task');
215
220
 
@@ -27,6 +27,7 @@ import { toast } from '@tuturuuu/ui/sonner';
27
27
  import { Switch } from '@tuturuuu/ui/switch';
28
28
  import { useTranslations } from 'next-intl';
29
29
  import { useEffect } from 'react';
30
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
30
31
  import {
31
32
  normalizeTaskDialogPresentation,
32
33
  TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID,
@@ -145,7 +146,9 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
145
146
  const { data: settings, isLoading } = useQuery({
146
147
  queryKey: ['user-task-settings'],
147
148
  queryFn: async (): Promise<TaskSettingsData> => {
148
- const res = await fetch('/api/v1/users/task-settings');
149
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
150
+ credentials: 'include',
151
+ });
149
152
  if (!res.ok) throw new Error('Failed to fetch task settings');
150
153
  return res.json();
151
154
  },
@@ -162,8 +165,9 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
162
165
 
163
166
  const updateSettings = useMutation({
164
167
  mutationFn: async (data: Partial<TaskSettingsData>) => {
165
- const res = await fetch('/api/v1/users/task-settings', {
168
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
166
169
  method: 'PATCH',
170
+ credentials: 'include',
167
171
  headers: { 'Content-Type': 'application/json' },
168
172
  body: JSON.stringify(data),
169
173
  });
@@ -9,6 +9,7 @@ import {
9
9
  getFacetedUniqueValues,
10
10
  getFilteredRowModel,
11
11
  getSortedRowModel,
12
+ type Row,
12
13
  type SortingState,
13
14
  useReactTable,
14
15
  type VisibilityState,
@@ -27,6 +28,15 @@ import {
27
28
  import { DataTablePagination } from './data-table-pagination';
28
29
  import { DataTableToolbar } from './data-table-toolbar';
29
30
 
31
+ function isInteractiveTarget(target: EventTarget | null) {
32
+ if (!(target instanceof HTMLElement)) return false;
33
+ return Boolean(
34
+ target.closest(
35
+ 'a, button, input, select, textarea, label, [role="menuitem"], [data-no-row-activate]'
36
+ )
37
+ );
38
+ }
39
+
30
40
  /**
31
41
  * Options for column generator functions.
32
42
  * Using named parameters improves readability and maintainability.
@@ -79,6 +89,8 @@ export interface DataTableProps<TData, TValue> {
79
89
  /** Custom toolbar actions rendered directly (not wrapped in dialogs) */
80
90
  toolbarActions?: ReactNode;
81
91
  className?: string;
92
+ tableCardClassName?: string;
93
+ tableClassName?: string;
82
94
  preserveParams?: string[];
83
95
  onRefresh?: () => void;
84
96
  selectedRowsActions?: (selectedRows: TData[]) => ReactNode;
@@ -87,6 +99,7 @@ export interface DataTableProps<TData, TValue> {
87
99
 
88
100
  onRowClick?: (row: TData) => void;
89
101
  onRowDoubleClick?: (row: TData) => void;
102
+ getRowId?: (originalRow: TData, index: number, parent?: Row<TData>) => string;
90
103
 
91
104
  setParams?: (params: {
92
105
  page?: number;
@@ -127,11 +140,14 @@ export function DataTable<TData, TValue>({
127
140
  toolbarExportContent,
128
141
  toolbarActions,
129
142
  className,
143
+ tableCardClassName,
144
+ tableClassName,
130
145
  onRefresh,
131
146
  selectedRowsActions,
132
147
  onSearch,
133
148
  onRowClick,
134
149
  onRowDoubleClick,
150
+ getRowId,
135
151
  setParams,
136
152
  resetParams,
137
153
  columnGenerator,
@@ -169,6 +185,7 @@ export function DataTable<TData, TValue>({
169
185
  : undefined,
170
186
  enableRowSelection: true,
171
187
  autoResetPageIndex: true,
188
+ getRowId,
172
189
  onRowSelectionChange: setRowSelection,
173
190
  onSortingChange: enableServerSideSorting
174
191
  ? (updaterOrValue) => {
@@ -232,16 +249,23 @@ export function DataTable<TData, TValue>({
232
249
  toolbarActions={toolbarActions}
233
250
  />
234
251
  )}
235
- <Card>
236
- <Table>
252
+ <Card className={tableCardClassName}>
253
+ <Table className={tableClassName}>
237
254
  <TableHeader>
238
255
  {table.getHeaderGroups().map((headerGroup) => (
239
256
  <TableRow key={headerGroup.id}>
240
257
  {headerGroup.headers.map((header) => {
258
+ const meta = header.column.columnDef.meta as
259
+ | { className?: string }
260
+ | undefined;
261
+
241
262
  return (
242
263
  <TableHead
243
264
  key={header.id}
244
- className="bg-foreground/5 font-semibold text-foreground/70 first:rounded-tl-xl last:rounded-tr-xl"
265
+ className={cn(
266
+ 'bg-foreground/5 font-semibold text-foreground/70 first:rounded-tl-xl last:rounded-tr-xl',
267
+ meta?.className
268
+ )}
245
269
  >
246
270
  {header.isPlaceholder
247
271
  ? null
@@ -258,16 +282,42 @@ export function DataTable<TData, TValue>({
258
282
  <TableBody>
259
283
  {table.getRowModel().rows?.length ? (
260
284
  table.getRowModel().rows.map((row) => {
285
+ const isInteractiveRow = Boolean(
286
+ onRowClick || onRowDoubleClick
287
+ );
261
288
  const tableRow = (
262
289
  <TableRow
263
290
  key={`${namespace}-${row.id}`}
264
291
  data-state={row.getIsSelected() && 'selected'}
265
- className="cursor-pointer"
266
- onClick={() => onRowClick?.(row.original)}
267
- onDoubleClick={() => onRowDoubleClick?.(row.original)}
292
+ className={cn(isInteractiveRow && 'cursor-pointer')}
293
+ onClick={(event) => {
294
+ if (isInteractiveTarget(event.target)) return;
295
+ onRowClick?.(row.original);
296
+ }}
297
+ onDoubleClick={(event) => {
298
+ if (isInteractiveTarget(event.target)) return;
299
+ onRowDoubleClick?.(row.original);
300
+ }}
301
+ onKeyDown={(event) => {
302
+ if (!onRowClick) return;
303
+ if (event.key !== 'Enter' && event.key !== ' ') return;
304
+ if (isInteractiveTarget(event.target)) return;
305
+ event.preventDefault();
306
+ onRowClick(row.original);
307
+ }}
308
+ tabIndex={onRowClick ? 0 : undefined}
268
309
  >
269
310
  {row.getVisibleCells().map((cell) => (
270
- <TableCell key={`${namespace}-${cell.id}`}>
311
+ <TableCell
312
+ key={`${namespace}-${cell.id}`}
313
+ className={
314
+ (
315
+ cell.column.columnDef.meta as
316
+ | { cellClassName?: string }
317
+ | undefined
318
+ )?.cellClassName
319
+ }
320
+ >
271
321
  {flexRender(
272
322
  cell.column.columnDef.cell,
273
323
  cell.getContext()
@@ -293,7 +343,7 @@ export function DataTable<TData, TValue>({
293
343
  >
294
344
  {data
295
345
  ? `${t?.('common.no-results')}.`
296
- : `${t?.('common.loading')}...`}
346
+ : `${t?.('common.loading')}…`}
297
347
  </TableCell>
298
348
  </TableRow>
299
349
  )}
@@ -1,7 +1,23 @@
1
1
  import { QueryClient } from '@tanstack/react-query';
2
2
  import type { Task } from '@tuturuuu/types/primitives/Task';
3
3
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
4
- import { describe, expect, it } from 'vitest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ vi.mock('@tuturuuu/utils/task-helper', () => {
7
+ const prefix = 'personal-external-staging:';
8
+
9
+ return {
10
+ getPersonalExternalStagingBoardId: (listId: string | null) =>
11
+ listId?.startsWith(prefix) && listId.length > prefix.length
12
+ ? listId.slice(prefix.length)
13
+ : null,
14
+ getPersonalExternalStagingListId: (boardId: string) =>
15
+ `${prefix}${boardId}`,
16
+ isPersonalExternalStagingListId: (listId: string | null) =>
17
+ Boolean(listId?.startsWith(prefix) && listId.length > prefix.length),
18
+ };
19
+ });
20
+
5
21
  import {
6
22
  applyTaskDropPreviewToCache,
7
23
  getProjectedTaskDropOrderFromPreview,
@@ -14,6 +30,7 @@ import {
14
30
  insertTaskAtDropPosition,
15
31
  mergePersonalPlacementMutationTask,
16
32
  mergeTaskIntoBoardTaskCache,
33
+ usesPersonalPlacement,
17
34
  } from './use-kanban-dnd';
18
35
 
19
36
  function createTask(overrides: Partial<Task> = {}): Task {
@@ -116,6 +133,51 @@ describe('mergePersonalPlacementMutationTask', () => {
116
133
  });
117
134
  });
118
135
 
136
+ describe('usesPersonalPlacement', () => {
137
+ it('does not treat a personal-workspace task as external from personal_board_id alone', () => {
138
+ const personalTask = createTask({
139
+ is_personal_external: false,
140
+ personal_board_id: 'personal-board',
141
+ personal_list_id: 'list-1',
142
+ source_board_id: null,
143
+ source_list_id: null,
144
+ source_workspace_id: null,
145
+ } as Partial<Task>);
146
+
147
+ expect(usesPersonalPlacement(personalTask)).toBe(false);
148
+ });
149
+
150
+ it('does not treat an explicitly native personal task as external when source metadata is present', () => {
151
+ const personalTask = createTask({
152
+ is_personal_external: false,
153
+ personal_board_id: 'personal-board',
154
+ personal_list_id: 'list-1',
155
+ source_workspace_id: 'personal-workspace',
156
+ source_board_id: 'personal-board',
157
+ source_list_id: 'list-1',
158
+ } as Partial<Task>);
159
+
160
+ expect(usesPersonalPlacement(personalTask)).toBe(false);
161
+ });
162
+
163
+ it('does not treat same-board personal metadata as external without an explicit flag', () => {
164
+ const personalTask = createTask({
165
+ is_personal_external: undefined,
166
+ personal_board_id: 'personal-board',
167
+ personal_list_id: 'list-1',
168
+ source_workspace_id: 'personal-workspace',
169
+ source_board_id: 'personal-board',
170
+ source_list_id: 'list-1',
171
+ } as Partial<Task>);
172
+
173
+ expect(usesPersonalPlacement(personalTask)).toBe(false);
174
+ });
175
+
176
+ it('keeps explicit external overlays on the personal-placement path', () => {
177
+ expect(usesPersonalPlacement(createTask())).toBe(true);
178
+ });
179
+ });
180
+
119
181
  describe('mergeTaskIntoBoardTaskCache', () => {
120
182
  it('updates an existing task in the board cache', () => {
121
183
  const existingTask = createTask({ list_id: 'source-list' });
@@ -21,6 +21,7 @@ import {
21
21
  } from '@tuturuuu/utils/task-helper';
22
22
  import { hasDraggableData } from '@tuturuuu/utils/task-helpers';
23
23
  import { useCallback, useRef, useState } from 'react';
24
+ import { isPersonalExternalOverlayTask } from '../../../../../../../lib/task-personal-external';
24
25
  import { useBoardBroadcast } from '../../../../shared/board-broadcast-context';
25
26
  import { invalidateKanbanDeadlineTasks } from '../data/kanban-deadline-query';
26
27
  import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
@@ -121,12 +122,8 @@ interface UseKanbanDndProps {
121
122
  scrollContainerRef: React.RefObject<HTMLDivElement | null>;
122
123
  }
123
124
 
124
- function usesPersonalPlacement(task: Task) {
125
- return (
126
- task.is_personal_external === true ||
127
- Boolean(task.personal_board_id) ||
128
- isPersonalExternalStagingListId(task.list_id)
129
- );
125
+ export function usesPersonalPlacement(task: Task) {
126
+ return isPersonalExternalOverlayTask(task);
130
127
  }
131
128
 
132
129
  function getTaskDropPosition(
@@ -76,7 +76,7 @@ describe('getTaskCardHydratingOpenOptions', () => {
76
76
  taskWsId: 'source-workspace',
77
77
  taskWorkspacePersonal: false,
78
78
  canUseBoardAssignees: true,
79
- assigneeMemberSource: 'workspace',
79
+ assigneeMemberSource: 'board',
80
80
  initialSharedContext: {
81
81
  boardConfig: {
82
82
  id: 'source-board',
@@ -151,4 +151,14 @@ describe('getTaskCardHydratingOpenOptions', () => {
151
151
  ).toBe(true);
152
152
  expect(isExternalTaskSnapshot(task)).toBe(false);
153
153
  });
154
+
155
+ it('does not treat personal placement metadata alone as an external snapshot', () => {
156
+ expect(
157
+ isExternalTaskSnapshot({
158
+ ...task,
159
+ personal_board_id: 'board-1',
160
+ personal_list_id: 'list-1',
161
+ })
162
+ ).toBe(false);
163
+ });
154
164
  });
@@ -1,5 +1,6 @@
1
1
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
2
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
+ import { isPersonalExternalOverlayTask } from '../../../../../../lib/task-personal-external';
3
4
  import type { SharedTaskContext } from '../../../shared/task-edit-dialog/hooks/use-task-data';
4
5
 
5
6
  interface TaskCardOpenOptionsInput {
@@ -13,9 +14,12 @@ interface TaskCardOpenOptionsInput {
13
14
  }
14
15
 
15
16
  export function isExternalTaskSnapshot(task: Task) {
17
+ if (task.is_personal_external === false) {
18
+ return false;
19
+ }
20
+
16
21
  return (
17
- task.is_personal_external === true ||
18
- Boolean(task.personal_board_id) ||
22
+ isPersonalExternalOverlayTask(task) ||
19
23
  Boolean(task.source_workspace_id) ||
20
24
  Boolean(task.source_board_id)
21
25
  );
@@ -131,7 +135,7 @@ export function getTaskCardHydratingOpenOptions({
131
135
  taskWorkspacePersonal: sourceWorkspaceId ? false : isPersonalWorkspace,
132
136
  canUseBoardAssignees: canUseBoardAssignees ?? sourceBoardAssigneesEnabled,
133
137
  assigneeMemberSource:
134
- assigneeMemberSource ?? (sourceWorkspaceId ? 'workspace' : undefined),
138
+ assigneeMemberSource ?? (sourceWorkspaceId ? 'board' : undefined),
135
139
  initialSharedContext,
136
140
  };
137
141
  }
@@ -0,0 +1,113 @@
1
+ import type { Task } from '@tuturuuu/types/primitives/Task';
2
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { getTaskCardResourceContext } from './task-card-resource-context';
5
+
6
+ const pageList = {
7
+ id: 'page-list',
8
+ name: 'Page list',
9
+ board_id: 'page-board',
10
+ position: 0,
11
+ status: 'not_started',
12
+ color: 'BLUE',
13
+ created_at: '2026-07-04T00:00:00.000Z',
14
+ creator_id: 'user-1',
15
+ archived: false,
16
+ deleted: false,
17
+ } satisfies TaskList;
18
+
19
+ const task = {
20
+ id: 'task-1',
21
+ name: 'Task',
22
+ description: '',
23
+ list_id: 'page-list',
24
+ display_number: 1,
25
+ created_at: '2026-07-04T00:00:00.000Z',
26
+ end_date: null,
27
+ priority: 'normal',
28
+ } satisfies Task;
29
+
30
+ describe('getTaskCardResourceContext', () => {
31
+ it('keeps local task resources on the page workspace and board', () => {
32
+ expect(
33
+ getTaskCardResourceContext({
34
+ boardId: 'page-board',
35
+ pageWorkspaceId: 'page-workspace',
36
+ propAvailableLists: [pageList],
37
+ task,
38
+ })
39
+ ).toEqual({
40
+ boardViewableMembersBoardId: 'page-board',
41
+ boardViewableMembersWorkspaceId: 'page-workspace',
42
+ effectiveWorkspaceId: 'page-workspace',
43
+ initialAvailableLists: [pageList],
44
+ isSourceWorkspaceTask: false,
45
+ taskBoardId: 'page-board',
46
+ });
47
+ });
48
+
49
+ it('routes source task labels, projects, lists, and members to the source workspace and board', () => {
50
+ expect(
51
+ getTaskCardResourceContext({
52
+ boardId: 'personal-board',
53
+ pageWorkspaceId: 'personal-workspace',
54
+ propAvailableLists: [pageList],
55
+ task: {
56
+ ...task,
57
+ list_id: 'personal-list',
58
+ source_board_id: 'source-board',
59
+ source_workspace_id: 'source-workspace',
60
+ },
61
+ })
62
+ ).toEqual({
63
+ boardViewableMembersBoardId: 'source-board',
64
+ boardViewableMembersWorkspaceId: 'source-workspace',
65
+ effectiveWorkspaceId: 'source-workspace',
66
+ initialAvailableLists: undefined,
67
+ isSourceWorkspaceTask: true,
68
+ taskBoardId: 'source-board',
69
+ });
70
+ });
71
+
72
+ it('uses the page workspace when a source board is present without source workspace metadata', () => {
73
+ expect(
74
+ getTaskCardResourceContext({
75
+ boardId: 'page-board',
76
+ pageWorkspaceId: 'page-workspace',
77
+ propAvailableLists: [pageList],
78
+ task: {
79
+ ...task,
80
+ source_board_id: 'source-board',
81
+ },
82
+ })
83
+ ).toMatchObject({
84
+ boardViewableMembersBoardId: 'source-board',
85
+ boardViewableMembersWorkspaceId: 'page-workspace',
86
+ effectiveWorkspaceId: 'page-workspace',
87
+ initialAvailableLists: undefined,
88
+ isSourceWorkspaceTask: true,
89
+ taskBoardId: 'source-board',
90
+ });
91
+ });
92
+
93
+ it('keeps page board list data when only source workspace metadata is available', () => {
94
+ expect(
95
+ getTaskCardResourceContext({
96
+ boardId: 'page-board',
97
+ pageWorkspaceId: 'page-workspace',
98
+ propAvailableLists: [pageList],
99
+ task: {
100
+ ...task,
101
+ source_workspace_id: 'source-workspace',
102
+ },
103
+ })
104
+ ).toMatchObject({
105
+ boardViewableMembersBoardId: 'page-board',
106
+ boardViewableMembersWorkspaceId: 'source-workspace',
107
+ effectiveWorkspaceId: 'source-workspace',
108
+ initialAvailableLists: [pageList],
109
+ isSourceWorkspaceTask: true,
110
+ taskBoardId: 'page-board',
111
+ });
112
+ });
113
+ });