@tuturuuu/ui 0.7.0 → 0.8.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 (67) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/currency-input.test.tsx +43 -0
  4. package/src/components/ui/currency-input.tsx +1 -1
  5. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  6. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  7. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  8. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  9. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  10. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  12. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  13. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  14. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  15. package/src/components/ui/money-input.test.tsx +64 -0
  16. package/src/components/ui/money-input.tsx +63 -0
  17. package/src/components/ui/storefront/cart-summary.tsx +114 -29
  18. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  19. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  20. package/src/components/ui/storefront/image-panel.tsx +6 -0
  21. package/src/components/ui/storefront/index.ts +11 -0
  22. package/src/components/ui/storefront/listing-card.tsx +84 -22
  23. package/src/components/ui/storefront/product-detail.tsx +289 -0
  24. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  25. package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
  26. package/src/components/ui/storefront/storefront-surface.tsx +333 -133
  27. package/src/components/ui/storefront/types.ts +23 -1
  28. package/src/components/ui/storefront/utils.ts +111 -27
  29. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  30. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  31. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  32. package/src/components/ui/text-editor/content-migration.ts +41 -18
  33. package/src/components/ui/text-editor/extensions.ts +1 -1
  34. package/src/components/ui/text-editor/image-extension.ts +40 -18
  35. package/src/components/ui/text-editor/video-extension.ts +11 -2
  36. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  37. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  38. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  40. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  42. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  43. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  44. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  46. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  50. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  51. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  52. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  53. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  54. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  55. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  56. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  57. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  58. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  59. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  60. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  61. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  62. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  63. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  64. package/src/hooks/useBoardRealtime.ts +6 -3
  65. package/src/hooks/useBoardRealtime.types.ts +11 -0
  66. package/src/hooks/useCursorTracking.ts +91 -27
  67. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -1,6 +1,17 @@
1
+ import {
2
+ getWorkspaceTaskBoard,
3
+ InternalApiError,
4
+ withForwardedInternalApiAuth,
5
+ } from '@tuturuuu/internal-api';
6
+ import type {
7
+ Workspace,
8
+ WorkspaceProductTier,
9
+ WorkspaceTaskBoard,
10
+ } from '@tuturuuu/types';
1
11
  import { BoardClient } from '@tuturuuu/ui/tu-do/shared/board-client';
2
12
  import { getCurrentUser } from '@tuturuuu/utils/user-helper';
3
13
  import { getWorkspace } from '@tuturuuu/utils/workspace-helper';
14
+ import { headers } from 'next/headers';
4
15
  import { notFound, redirect } from 'next/navigation';
5
16
  import type { ReactNode } from 'react';
6
17
 
@@ -14,6 +25,43 @@ interface Props {
14
25
  idleBottomIsland?: ReactNode;
15
26
  }
16
27
 
28
+ type AuthorizedWorkspace = Workspace & {
29
+ joined: boolean;
30
+ tier: WorkspaceProductTier | null;
31
+ };
32
+
33
+ function createBoardGuestWorkspace(wsId: string): AuthorizedWorkspace {
34
+ return {
35
+ id: wsId,
36
+ joined: false,
37
+ personal: false,
38
+ tier: null,
39
+ } as AuthorizedWorkspace;
40
+ }
41
+
42
+ async function getAuthorizedBoard(wsId: string, boardId: string) {
43
+ try {
44
+ const { board } = await getWorkspaceTaskBoard(
45
+ wsId,
46
+ boardId,
47
+ withForwardedInternalApiAuth(await headers())
48
+ );
49
+
50
+ return board as WorkspaceTaskBoard & {
51
+ access_type?: 'member' | 'guest';
52
+ };
53
+ } catch (error) {
54
+ if (
55
+ error instanceof InternalApiError &&
56
+ (error.status === 401 || error.status === 403 || error.status === 404)
57
+ ) {
58
+ return null;
59
+ }
60
+
61
+ throw error;
62
+ }
63
+ }
64
+
17
65
  /**
18
66
  * Shared Task Board Server Page component.
19
67
  * Handles workspace resolution and authentication.
@@ -29,14 +77,20 @@ export default async function TaskBoardServerPage({
29
77
  const user = await getCurrentUser();
30
78
  if (!user) redirect('/login');
31
79
 
32
- const workspace = await getWorkspace(id, { useAdmin: true });
80
+ const board = await getAuthorizedBoard(id, boardId);
81
+ if (!board?.id) notFound();
82
+
83
+ const isMemberBoardAccess = board.access_type === 'member';
84
+ const workspace = isMemberBoardAccess
85
+ ? await getWorkspace(id, { useAdmin: true })
86
+ : createBoardGuestWorkspace(board.ws_id);
33
87
  if (!workspace) notFound();
34
88
 
35
89
  return (
36
90
  <BoardClient
37
91
  boardId={boardId}
38
92
  workspace={workspace}
39
- workspaceTier={(workspace as any)?.tier ?? null}
93
+ workspaceTier={workspace.tier ?? null}
40
94
  currentUserId={user.id}
41
95
  routePrefix={routePrefix}
42
96
  idleBottomIsland={idleBottomIsland}
@@ -14,6 +14,15 @@ export const HANDLE_WIDTH = 12;
14
14
  export const DRAG_ACTIVATION_PX = 6;
15
15
  export const COLLAPSED_UNSCHEDULED_PREVIEW_COUNT = 4;
16
16
 
17
+ export function getTimelineDayGridBackground(dayWidth: number, opacity = 0.45) {
18
+ const safeDayWidth = Math.max(1, dayWidth);
19
+ const lineStart = Math.max(0, safeDayWidth - 1);
20
+
21
+ return {
22
+ backgroundImage: `repeating-linear-gradient(to right, transparent 0, transparent ${lineStart}px, hsl(var(--border) / ${opacity}) ${lineStart}px, hsl(var(--border) / ${opacity}) ${safeDayWidth}px)`,
23
+ };
24
+ }
25
+
17
26
  export function getDensityConfig(density: Density) {
18
27
  switch (density) {
19
28
  case 'compact':
@@ -6,7 +6,10 @@ import { cn } from '@tuturuuu/utils/format';
6
6
  import dayjs from 'dayjs';
7
7
  import type { useTranslations } from 'next-intl';
8
8
  import type { PointerEvent as ReactPointerEvent } from 'react';
9
- import { getListStatusBadgeClasses } from './timeline-display';
9
+ import {
10
+ getListStatusBadgeClasses,
11
+ getTimelineDayGridBackground,
12
+ } from './timeline-display';
10
13
  import { TimelineTaskRow } from './timeline-task-row';
11
14
  import type {
12
15
  TimelineInteractionMode,
@@ -197,7 +200,6 @@ export function TimelineGrid({
197
200
  groupId={group.id}
198
201
  localTasks={localTasks}
199
202
  dayWidth={dayWidth}
200
- timelineWidth={timelineWidth}
201
203
  groupHeaderHeight={groupHeaderHeight}
202
204
  isMoveTargetGroup={isMoveTargetGroup}
203
205
  isPreviewGroup={isPreviewGroup}
@@ -257,7 +259,6 @@ export function TimelineGrid({
257
259
  groupId={group.id}
258
260
  localTasks={localTasks}
259
261
  dayWidth={dayWidth}
260
- timelineWidth={timelineWidth}
261
262
  groupHeaderHeight={rowHeight}
262
263
  isMoveTargetGroup={isMoveTargetGroup}
263
264
  isPreviewGroup={isPreviewGroup}
@@ -281,7 +282,6 @@ function TimelineGroupDropTarget({
281
282
  groupId,
282
283
  localTasks,
283
284
  dayWidth,
284
- timelineWidth,
285
285
  groupHeaderHeight,
286
286
  isMoveTargetGroup,
287
287
  isPreviewGroup,
@@ -295,7 +295,6 @@ function TimelineGroupDropTarget({
295
295
  groupId: string;
296
296
  localTasks: Task[];
297
297
  dayWidth: number;
298
- timelineWidth: number;
299
298
  groupHeaderHeight: number;
300
299
  isMoveTargetGroup: boolean;
301
300
  isPreviewGroup: boolean;
@@ -364,17 +363,10 @@ function TimelineGroupDropTarget({
364
363
  </div>
365
364
  );
366
365
  })()}
367
- <div className="pointer-events-none absolute inset-0 flex">
368
- {Array.from({
369
- length: Math.max(1, Math.ceil(timelineWidth / dayWidth)),
370
- }).map((_, index) => (
371
- <div
372
- key={`${groupId}-drop-${index}`}
373
- className="h-full border-border/35 border-r"
374
- style={{ width: dayWidth }}
375
- />
376
- ))}
377
- </div>
366
+ <div
367
+ className="pointer-events-none absolute inset-0"
368
+ style={getTimelineDayGridBackground(dayWidth, 0.35)}
369
+ />
378
370
  </div>
379
371
  );
380
372
  }
@@ -18,7 +18,6 @@ import {
18
18
  DropdownMenuSubTrigger,
19
19
  } from '@tuturuuu/ui/dropdown-menu';
20
20
  import { cn } from '@tuturuuu/utils/format';
21
- import dayjs from 'dayjs';
22
21
  import type { useTranslations } from 'next-intl';
23
22
  import type { PointerEvent as ReactPointerEvent } from 'react';
24
23
  import { useState } from 'react';
@@ -26,6 +25,7 @@ import { TaskRowActionsMenu } from '../../../shared/task-row-actions-menu';
26
25
  import {
27
26
  getStatusToneClasses,
28
27
  getTaskEyebrow,
28
+ getTimelineDayGridBackground,
29
29
  HANDLE_WIDTH,
30
30
  } from './timeline-display';
31
31
  import type {
@@ -263,30 +263,10 @@ export function TimelineTaskRow({
263
263
  style={{ minHeight: rowHeight }}
264
264
  onDoubleClick={() => onOpenEditor(item.task)}
265
265
  >
266
- <div className="pointer-events-none absolute inset-0 flex">
267
- {Array.from({
268
- length: Math.max(1, Math.ceil(timelineWidth / dayWidth)),
269
- }).map((_, index) => {
270
- const isToday = todayVisible && index === todayIndex;
271
- const date = item.start
272
- ? dayjs(item.start).add(index - item.offsetDays, 'day')
273
- : null;
274
- const isWeekend = date ? [0, 6].includes(date.day()) : false;
275
-
276
- return (
277
- <div
278
- key={`${item.task.id}-${index}`}
279
- className={cn(
280
- 'h-full border-border/45 border-r',
281
- index === 0 && 'border-l border-l-border/50',
282
- isWeekend && 'bg-muted/[0.12]',
283
- isToday && 'bg-dynamic-blue/[0.035]'
284
- )}
285
- style={{ width: dayWidth }}
286
- />
287
- );
288
- })}
289
- </div>
266
+ <div
267
+ className="pointer-events-none absolute inset-0 border-border/50 border-l"
268
+ style={getTimelineDayGridBackground(dayWidth)}
269
+ />
290
270
 
291
271
  {todayVisible && (
292
272
  <div
@@ -1,7 +1,11 @@
1
1
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
2
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
4
- import { buildTimelineModel, computeTimelineSpans } from './timeline-utils';
4
+ import {
5
+ buildTimelineModel,
6
+ computeTimelineSpans,
7
+ MAX_TIMELINE_DAYS,
8
+ } from './timeline-utils';
5
9
 
6
10
  const lists: TaskList[] = [
7
11
  {
@@ -108,6 +112,37 @@ describe('timeline row model', () => {
108
112
  );
109
113
  });
110
114
 
115
+ it('caps the rendered day window for extreme scheduled task ranges', () => {
116
+ vi.useFakeTimers();
117
+ vi.setSystemTime(new Date('2026-05-07T12:00:00.000Z'));
118
+
119
+ const model = buildTimelineModel(
120
+ [
121
+ task({
122
+ id: 'old-task',
123
+ name: 'Very old task',
124
+ start_date: '2020-01-01T00:00:00.000Z',
125
+ end_date: '2020-01-02T23:59:59.999Z',
126
+ }),
127
+ task({
128
+ id: 'future-task',
129
+ name: 'Very future task',
130
+ start_date: '2035-01-01T00:00:00.000Z',
131
+ end_date: '2035-01-02T23:59:59.999Z',
132
+ }),
133
+ ],
134
+ lists
135
+ );
136
+
137
+ expect(model.days).toHaveLength(MAX_TIMELINE_DAYS);
138
+ expect(model.rangeStart.getTime()).toBeLessThanOrEqual(
139
+ new Date('2026-05-07T00:00:00.000Z').getTime()
140
+ );
141
+ expect(model.rangeEnd.getTime()).toBeGreaterThanOrEqual(
142
+ new Date('2026-05-07T00:00:00.000Z').getTime()
143
+ );
144
+ });
145
+
111
146
  it('keeps span computation stable for existing analytics exports', () => {
112
147
  vi.useFakeTimers();
113
148
  vi.setSystemTime(new Date('2026-05-07T12:00:00.000Z'));
@@ -62,6 +62,7 @@ const DEFAULT_PAST_PADDING_DAYS = 3;
62
62
  const DEFAULT_FUTURE_PADDING_DAYS = 6;
63
63
  const EMPTY_PAST_PADDING_DAYS = 7;
64
64
  const EMPTY_FUTURE_PADDING_DAYS = 14;
65
+ export const MAX_TIMELINE_DAYS = 180;
65
66
  const TIMELINE_LIST_STATUS_ORDER: Record<string, number> = {
66
67
  not_started: 0,
67
68
  active: 1,
@@ -126,6 +127,48 @@ export function buildMonthSegments(days: Date[]) {
126
127
  return segments;
127
128
  }
128
129
 
130
+ function capTimelineRange(
131
+ rangeStart: Date,
132
+ rangeEnd: Date,
133
+ todayMid: dayjs.Dayjs
134
+ ) {
135
+ const start = dayjs(rangeStart).startOf('day');
136
+ const end = dayjs(rangeEnd).startOf('day');
137
+ const totalDays = end.diff(start, 'day') + 1;
138
+
139
+ if (totalDays <= MAX_TIMELINE_DAYS) {
140
+ return {
141
+ rangeStart: start.toDate(),
142
+ rangeEnd: end.toDate(),
143
+ };
144
+ }
145
+
146
+ const latestStart = end.subtract(MAX_TIMELINE_DAYS - 1, 'day');
147
+ let cappedStart = start;
148
+
149
+ if (
150
+ (todayMid.isAfter(start) || todayMid.isSame(start, 'day')) &&
151
+ (todayMid.isBefore(end) || todayMid.isSame(end, 'day'))
152
+ ) {
153
+ cappedStart = todayMid.subtract(Math.floor(MAX_TIMELINE_DAYS / 2), 'day');
154
+ } else if (todayMid.isAfter(end)) {
155
+ cappedStart = latestStart;
156
+ }
157
+
158
+ if (cappedStart.isBefore(start)) {
159
+ cappedStart = start;
160
+ }
161
+
162
+ if (cappedStart.isAfter(latestStart)) {
163
+ cappedStart = latestStart;
164
+ }
165
+
166
+ return {
167
+ rangeStart: cappedStart.toDate(),
168
+ rangeEnd: cappedStart.add(MAX_TIMELINE_DAYS - 1, 'day').toDate(),
169
+ };
170
+ }
171
+
129
172
  function withTaskRows(
130
173
  items: Array<Omit<TimelineItem, 'offsetDays'>>,
131
174
  rangeStart: Date
@@ -280,7 +323,7 @@ export function buildTimelineModel(
280
323
  });
281
324
  }
282
325
 
283
- const rangeStart = scheduled.length
326
+ const rawRangeStart = scheduled.length
284
327
  ? dayjs(
285
328
  scheduled.reduce(
286
329
  (min, item) => (item.start < min ? item.start : min),
@@ -292,7 +335,7 @@ export function buildTimelineModel(
292
335
  .toDate()
293
336
  : todayMid.subtract(EMPTY_PAST_PADDING_DAYS, 'day').toDate();
294
337
 
295
- const rangeEnd = scheduled.length
338
+ const rawRangeEnd = scheduled.length
296
339
  ? dayjs(
297
340
  scheduled.reduce(
298
341
  (max, item) => (item.end > max ? item.end : max),
@@ -304,6 +347,12 @@ export function buildTimelineModel(
304
347
  .toDate()
305
348
  : todayMid.add(EMPTY_FUTURE_PADDING_DAYS, 'day').toDate();
306
349
 
350
+ const { rangeStart, rangeEnd } = capTimelineRange(
351
+ rawRangeStart,
352
+ rawRangeEnd,
353
+ todayMid
354
+ );
355
+
307
356
  const days = enumerateDays(rangeStart, rangeEnd);
308
357
  const monthSegments = buildMonthSegments(days);
309
358
  const todayIndex = Math.round(
@@ -72,6 +72,15 @@ export default function WorkspaceProjectsClientPage({
72
72
  const isPermissionLoading = permissionQuery.isLoading;
73
73
 
74
74
  const resolvedWsId = workspace?.id;
75
+ const workspaceAccess = workspace as
76
+ | { access_type?: string; guest_products?: string[] }
77
+ | null
78
+ | undefined;
79
+ const isWorkspaceGuestTasksAccess =
80
+ workspaceAccess?.access_type === 'guest' &&
81
+ workspaceAccess.guest_products?.includes('tasks');
82
+ const canReadBoards =
83
+ canManageProjects === true || isWorkspaceGuestTasksAccess;
75
84
 
76
85
  const {
77
86
  data: boardsPayload,
@@ -85,9 +94,10 @@ export default function WorkspaceProjectsClientPage({
85
94
  page: Number.parseInt(page, 10),
86
95
  pageSize: Number.parseInt(pageSize, 10),
87
96
  }),
88
- enabled: Boolean(resolvedWsId),
97
+ enabled: Boolean(resolvedWsId && canReadBoards),
89
98
  });
90
- const isGuestBoardAccess = boardsPayload?.access_type === 'guest';
99
+ const isGuestBoardAccess =
100
+ isWorkspaceGuestTasksAccess || boardsPayload?.access_type === 'guest';
91
101
 
92
102
  useEffect(() => {
93
103
  if (
@@ -112,7 +122,7 @@ export default function WorkspaceProjectsClientPage({
112
122
  isWorkspacePending ||
113
123
  isWorkspaceUserLoading ||
114
124
  (isPermissionLoading && !isGuestBoardAccess) ||
115
- isBoardsPending
125
+ (canReadBoards && isBoardsPending)
116
126
  ) {
117
127
  return <BoardsListSkeleton />;
118
128
  }
@@ -167,6 +167,7 @@ const mockTasks: Task[] = [
167
167
  const mockWorkspaceLabels: WorkspaceLabel[] = [];
168
168
 
169
169
  function renderBoardViews(overrides?: {
170
+ board?: Record<string, unknown>;
170
171
  idleBottomIsland?: React.ReactNode;
171
172
  lists?: TaskList[];
172
173
  tasks?: Task[];
@@ -184,7 +185,7 @@ function renderBoardViews(overrides?: {
184
185
  <QueryClientProvider client={queryClient}>
185
186
  <HotkeysProvider>
186
187
  <BoardViews
187
- board={mockBoard as any}
188
+ board={(overrides?.board ?? mockBoard) as any}
188
189
  currentUserId="user-1"
189
190
  lists={overrides?.lists ?? mockLists}
190
191
  tasks={overrides?.tasks ?? mockTasks}
@@ -323,6 +324,38 @@ describe('BoardViews', () => {
323
324
  );
324
325
  });
325
326
 
327
+ it('creates a task in the board default list when configured', () => {
328
+ renderBoardViews({
329
+ board: { ...mockBoard, default_list_id: 'list-2' },
330
+ });
331
+
332
+ fireEvent.keyDown(document, { key: 'c' });
333
+
334
+ expect(createTaskMock).toHaveBeenCalledTimes(1);
335
+ expect(createTaskMock).toHaveBeenCalledWith(
336
+ 'board-1',
337
+ 'list-2',
338
+ mockLists,
339
+ expect.objectContaining({ labels: [] })
340
+ );
341
+ });
342
+
343
+ it('falls back to the first list when the default list is unavailable', () => {
344
+ renderBoardViews({
345
+ board: { ...mockBoard, default_list_id: 'list-does-not-exist' },
346
+ });
347
+
348
+ fireEvent.keyDown(document, { key: 'c' });
349
+
350
+ expect(createTaskMock).toHaveBeenCalledTimes(1);
351
+ expect(createTaskMock).toHaveBeenCalledWith(
352
+ 'board-1',
353
+ 'list-1',
354
+ mockLists,
355
+ expect.objectContaining({ labels: [] })
356
+ );
357
+ });
358
+
326
359
  it('pins a collapsed virtual external task list on personal boards without assigned external tasks', () => {
327
360
  renderBoardViews({
328
361
  workspace: {
@@ -104,6 +104,7 @@ interface Props {
104
104
  > & {
105
105
  ws_id?: WorkspaceTaskBoard['ws_id'] | null;
106
106
  icon?: WorkspaceTaskBoard['icon'];
107
+ default_list_id?: WorkspaceTaskBoard['default_list_id'] | null;
107
108
  };
108
109
  currentUserId?: string;
109
110
  currentView: ViewType;
@@ -158,6 +159,9 @@ export function BoardHeader({
158
159
  const [showArchiveDialog, setShowArchiveDialog] = useState(false);
159
160
  const [showUnarchiveDialog, setShowUnarchiveDialog] = useState(false);
160
161
  const [ticketPrefix, setTicketPrefix] = useState(board.ticket_prefix || '');
162
+ const [defaultListId, setDefaultListId] = useState<string | null>(
163
+ board.default_list_id ?? null
164
+ );
161
165
  const [localSearchQuery, setLocalSearchQuery] = useState(
162
166
  filters.searchQuery || ''
163
167
  );
@@ -247,6 +251,7 @@ export function BoardHeader({
247
251
 
248
252
  await updateWorkspaceTaskBoard(workspaceId, board.id, {
249
253
  ticket_prefix: nextTicketPrefix,
254
+ default_list_id: defaultListId,
250
255
  });
251
256
 
252
257
  syncBoardTicketPrefixCaches({
@@ -911,6 +916,7 @@ export function BoardHeader({
911
916
  <DropdownMenuItem
912
917
  onClick={() => {
913
918
  setTicketPrefix(board.ticket_prefix || '');
919
+ setDefaultListId(board.default_list_id ?? null);
914
920
  setBoardSettingsOpen(true);
915
921
  setBoardMenuOpen(false);
916
922
  }}
@@ -1163,6 +1169,39 @@ export function BoardHeader({
1163
1169
  {t('ws-task-boards.settings.ticket_prefix_description')}
1164
1170
  </p>
1165
1171
  </div>
1172
+ <div className="grid gap-2">
1173
+ <label htmlFor="defaultList" className="font-medium text-sm">
1174
+ {t('ws-task-boards.settings.default_list')}
1175
+ </label>
1176
+ <Select
1177
+ value={defaultListId ?? '__none__'}
1178
+ onValueChange={(value) =>
1179
+ setDefaultListId(value === '__none__' ? null : value)
1180
+ }
1181
+ >
1182
+ <SelectTrigger id="defaultList">
1183
+ <SelectValue />
1184
+ </SelectTrigger>
1185
+ <SelectContent>
1186
+ <SelectItem value="__none__">
1187
+ {t('ws-task-boards.settings.default_list_none')}
1188
+ </SelectItem>
1189
+ {lists
1190
+ .filter(
1191
+ (list) => !list.deleted && !list.is_external_staging
1192
+ )
1193
+ .map((list) => (
1194
+ <SelectItem key={list.id} value={list.id}>
1195
+ {list.name ||
1196
+ t('ws-task-boards.settings.untitled_list')}
1197
+ </SelectItem>
1198
+ ))}
1199
+ </SelectContent>
1200
+ </Select>
1201
+ <p className="text-muted-foreground text-xs">
1202
+ {t('ws-task-boards.settings.default_list_description')}
1203
+ </p>
1204
+ </div>
1166
1205
  </div>
1167
1206
  <DialogFooter>
1168
1207
  <Button
@@ -519,14 +519,16 @@ export function BoardViews({
519
519
  useHotkey(
520
520
  HOTKEY_CREATE_TASK,
521
521
  () => {
522
- const firstList = filteredLists.find((list) => !list.is_external_staging);
523
- if (!firstList) return;
524
- createTask(
525
- board.id,
526
- firstList.id,
527
- filteredLists.filter((list) => !list.is_external_staging),
528
- filters
522
+ const selectableLists = filteredLists.filter(
523
+ (list) => !list.is_external_staging
529
524
  );
525
+ // Prefer the board's configured default list for new tasks, falling back
526
+ // to the first selectable list when unset or the list is unavailable.
527
+ const targetList =
528
+ selectableLists.find((list) => list.id === board.default_list_id) ??
529
+ selectableLists[0];
530
+ if (!targetList) return;
531
+ createTask(board.id, targetList.id, selectableLists, filters);
530
532
  },
531
533
  {
532
534
  enabled: filteredLists.some((list) => !list.is_external_staging),
@@ -11,6 +11,14 @@ import type { ListStatusFilter } from './board-header';
11
11
  import CursorOverlay from './cursor-overlay';
12
12
  import type { BoardFiltersMetadata, TaskFilters } from './task-filter.types';
13
13
 
14
+ type CursorScopeMetadata =
15
+ | { type: 'board'; boardId: string }
16
+ | { type: 'task-description'; taskId: string };
17
+
18
+ type CursorOverlayMetadata = Partial<BoardFiltersMetadata> & {
19
+ cursorScope: CursorScopeMetadata;
20
+ };
21
+
14
22
  /**
15
23
  * Efficiently compares two sorted arrays of primitive values
16
24
  */
@@ -26,10 +34,16 @@ function arraysEqual<T extends string | number>(arr1: T[], arr2: T[]): boolean {
26
34
  * Checks if two cursor metadata objects represent the same view configuration
27
35
  */
28
36
  function isMatchingFilters(
29
- metadata1?: BoardFiltersMetadata,
30
- metadata2?: BoardFiltersMetadata
37
+ metadata1?: CursorOverlayMetadata,
38
+ metadata2?: CursorOverlayMetadata
31
39
  ): boolean {
32
40
  if (!metadata1 || !metadata2) return true; // Show all cursors if no metadata
41
+ if (!isMatchingCursorScope(metadata1.cursorScope, metadata2.cursorScope)) {
42
+ return false;
43
+ }
44
+
45
+ if (!metadata1.filters || !metadata2.filters) return true;
46
+ if (!metadata1.listStatusFilter || !metadata2.listStatusFilter) return true;
33
47
 
34
48
  // Check list status filter
35
49
  if (metadata1.listStatusFilter !== metadata2.listStatusFilter) return false;
@@ -81,14 +95,33 @@ function isMatchingFilters(
81
95
  return true;
82
96
  }
83
97
 
98
+ function isMatchingCursorScope(
99
+ scope1: CursorScopeMetadata,
100
+ scope2: CursorScopeMetadata
101
+ ) {
102
+ if (scope1.type !== scope2.type) return false;
103
+ if (scope1.type === 'board' && scope2.type === 'board') {
104
+ return scope1.boardId === scope2.boardId;
105
+ }
106
+ if (
107
+ scope1.type === 'task-description' &&
108
+ scope2.type === 'task-description'
109
+ ) {
110
+ return scope1.taskId === scope2.taskId;
111
+ }
112
+ return false;
113
+ }
114
+
84
115
  export default function CursorOverlayMultiWrapper({
85
116
  channelName,
86
117
  containerRef,
118
+ cursorScope,
87
119
  listStatusFilter,
88
120
  filters,
89
121
  }: {
90
122
  channelName: string;
91
123
  containerRef: React.RefObject<HTMLDivElement | null>;
124
+ cursorScope: CursorScopeMetadata;
92
125
  listStatusFilter?: ListStatusFilter;
93
126
  filters?: TaskFilters;
94
127
  }) {
@@ -97,6 +130,9 @@ export default function CursorOverlayMultiWrapper({
97
130
  height: number;
98
131
  }>({ width: 0, height: 0 });
99
132
  const [currentUser, setCurrentUser] = useState<User | null>(null);
133
+ const cursorScopeType = cursorScope.type;
134
+ const cursorScopeId =
135
+ cursorScope.type === 'board' ? cursorScope.boardId : cursorScope.taskId;
100
136
 
101
137
  // Fetch current user
102
138
  useEffect(() => {
@@ -105,10 +141,9 @@ export default function CursorOverlayMultiWrapper({
105
141
  const userData = await getCurrentUserProfile();
106
142
  if (!userData?.id) return;
107
143
  setCurrentUser({
108
- id: userData.id,
109
- display_name: userData.display_name,
110
- email: userData.email,
111
144
  avatar_url: userData.avatar_url,
145
+ display_name: userData.display_name,
146
+ id: userData.id,
112
147
  });
113
148
  } catch (err) {
114
149
  console.warn('Error fetching user:', err);
@@ -118,14 +153,22 @@ export default function CursorOverlayMultiWrapper({
118
153
  fetchUser();
119
154
  }, []);
120
155
 
121
- // Create metadata object from view options (only if both are provided)
122
- const metadata: BoardFiltersMetadata | undefined = useMemo(() => {
123
- if (!listStatusFilter || !filters) return undefined;
156
+ const metadata: CursorOverlayMetadata = useMemo(() => {
157
+ const resolvedCursorScope =
158
+ cursorScopeType === 'board'
159
+ ? ({ boardId: cursorScopeId, type: 'board' } as const)
160
+ : ({ taskId: cursorScopeId, type: 'task-description' } as const);
161
+
162
+ if (!listStatusFilter || !filters) {
163
+ return { cursorScope: resolvedCursorScope };
164
+ }
165
+
124
166
  return {
167
+ cursorScope: resolvedCursorScope,
125
168
  listStatusFilter,
126
169
  filters,
127
170
  };
128
- }, [listStatusFilter, filters]);
171
+ }, [cursorScopeId, cursorScopeType, listStatusFilter, filters]);
129
172
 
130
173
  const { cursors, error } = useCursorTracking(
131
174
  channelName,
@@ -139,15 +182,13 @@ export default function CursorOverlayMultiWrapper({
139
182
  const filtered = new Map<string, CursorPosition>();
140
183
 
141
184
  for (const [userId, cursor] of cursors.entries()) {
142
- // Always show cursors without metadata (backward compatibility)
143
185
  if (!cursor.metadata) {
144
- filtered.set(userId, cursor);
145
186
  continue;
146
187
  }
147
188
 
148
189
  // Filter based on view matching
149
190
  if (
150
- isMatchingFilters(metadata, cursor.metadata as BoardFiltersMetadata)
191
+ isMatchingFilters(metadata, cursor.metadata as CursorOverlayMetadata)
151
192
  ) {
152
193
  filtered.set(userId, cursor);
153
194
  }