@tuturuuu/ui 0.10.0 → 0.11.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 (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +47 -47
  3. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  4. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  5. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  6. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  7. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  8. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  9. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  10. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  11. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  12. package/src/components/ui/finance/invoices/utils.ts +240 -7
  13. package/src/components/ui/text-editor/__tests__/extensions.test.ts +22 -0
  14. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -0
  15. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
  16. package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
  17. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
  18. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  19. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +18 -2
  20. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  21. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  22. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  23. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  24. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  25. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  26. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  27. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  28. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +157 -4
  29. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +178 -14
  30. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  31. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  32. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
  33. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  34. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  35. package/src/components/ui/tu-do/templates/task-template-client.test.tsx +52 -0
  36. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  37. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  38. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  39. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  40. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  41. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
@@ -69,6 +69,8 @@ export const getLinkedFinanceCategorySelection = (
69
69
 
70
70
  const MONTH_VALUE_PATTERN = /^(\d{4})-(\d{2})$/;
71
71
  const DATE_VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
72
+ export const MAX_PREPAID_MONTH_COUNT = 12;
73
+ export const PREPAID_MONTH_OPTION_HORIZON = 12;
72
74
 
73
75
  export const parseLocalCalendarDate = (
74
76
  value: string | Date | null | undefined
@@ -122,6 +124,79 @@ export const formatMonthLabel = (month: string, locale: string): string => {
122
124
  });
123
125
  };
124
126
 
127
+ export const addMonthsToMonthValue = (
128
+ month: string,
129
+ monthOffset: number
130
+ ): string => {
131
+ const date = getMonthStartDate(month);
132
+ if (Number.isNaN(date.getTime())) return month;
133
+
134
+ date.setMonth(date.getMonth() + monthOffset);
135
+ return formatMonthValue(date);
136
+ };
137
+
138
+ export const normalizePrepaidMonthCount = (
139
+ value: number | null | undefined
140
+ ): number => {
141
+ if (!Number.isFinite(value) || !value) return 1;
142
+
143
+ return Math.min(MAX_PREPAID_MONTH_COUNT, Math.max(1, Math.trunc(value)));
144
+ };
145
+
146
+ export const getCoverageMonths = (
147
+ selectedMonth: string,
148
+ prepaidMonthCount = 1
149
+ ): string[] => {
150
+ const monthCount = normalizePrepaidMonthCount(prepaidMonthCount);
151
+ const startMonth = getMonthStartDate(selectedMonth);
152
+ if (Number.isNaN(startMonth.getTime())) return [];
153
+
154
+ return Array.from({ length: monthCount }, (_, index) => {
155
+ const date = new Date(startMonth);
156
+ date.setMonth(date.getMonth() + index);
157
+ return formatMonthValue(date);
158
+ });
159
+ };
160
+
161
+ export const getCoverageEndMonthValue = (
162
+ selectedMonth: string,
163
+ prepaidMonthCount = 1
164
+ ): string => {
165
+ const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
166
+ return coverageMonths[coverageMonths.length - 1] ?? selectedMonth;
167
+ };
168
+
169
+ export const getCoverageValidUntilMonthValue = (
170
+ selectedMonth: string,
171
+ prepaidMonthCount = 1
172
+ ): string => addMonthsToMonthValue(selectedMonth, prepaidMonthCount);
173
+
174
+ export const formatCoverageRangeLabel = ({
175
+ locale,
176
+ prepaidMonthCount,
177
+ selectedMonth,
178
+ }: {
179
+ locale: string;
180
+ prepaidMonthCount?: number | null;
181
+ selectedMonth: string;
182
+ }): string => {
183
+ const coverageMonths = getCoverageMonths(
184
+ selectedMonth,
185
+ prepaidMonthCount ?? 1
186
+ );
187
+ const firstMonth = coverageMonths[0];
188
+ const lastMonth = coverageMonths[coverageMonths.length - 1];
189
+
190
+ if (!firstMonth || !lastMonth || firstMonth === lastMonth) {
191
+ return formatMonthLabel(selectedMonth, locale);
192
+ }
193
+
194
+ return `${formatMonthLabel(firstMonth, locale)} - ${formatMonthLabel(
195
+ lastMonth,
196
+ locale
197
+ )}`;
198
+ };
199
+
125
200
  const getComparableTimestamp = (value: string | null | undefined): number => {
126
201
  if (!value) return 0;
127
202
  const timestamp = new Date(value).getTime();
@@ -183,6 +258,38 @@ export const isSubscriptionMonthPaidForGroup = (
183
258
  getSubscriptionCoverageInvoiceForGroup(latestInvoices, groupId)
184
259
  );
185
260
 
261
+ export const isSubscriptionRangePaidForGroup = (
262
+ groupId: string,
263
+ selectedMonth: string,
264
+ prepaidMonthCount: number,
265
+ latestInvoices: SubscriptionCoverageInvoice[]
266
+ ): boolean => {
267
+ const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
268
+ if (coverageMonths.length === 0) return false;
269
+
270
+ return coverageMonths.every((month) =>
271
+ isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices)
272
+ );
273
+ };
274
+
275
+ export const isSubscriptionRangeFullyPaidForGroups = (
276
+ groupIds: string[],
277
+ selectedMonth: string,
278
+ prepaidMonthCount: number,
279
+ latestInvoices: SubscriptionCoverageInvoice[]
280
+ ): boolean => {
281
+ if (groupIds.length === 0) return false;
282
+
283
+ return groupIds.every((groupId) =>
284
+ isSubscriptionRangePaidForGroup(
285
+ groupId,
286
+ selectedMonth,
287
+ prepaidMonthCount,
288
+ latestInvoices
289
+ )
290
+ );
291
+ };
292
+
186
293
  export const getAttendanceStats = (
187
294
  attendance: AttendanceRecord[]
188
295
  ): AttendanceStats => {
@@ -283,6 +390,17 @@ export const getBillableSessionsForGroups = (
283
390
  });
284
391
  };
285
392
 
393
+ export const getBillableSessionsForGroupsInRange = (
394
+ userGroups: UserGroup[],
395
+ groupIds: string[],
396
+ selectedMonth: string,
397
+ prepaidMonthCount = 1,
398
+ latestInvoices: SubscriptionCoverageInvoice[] = []
399
+ ): BillableSession[] =>
400
+ getCoverageMonths(selectedMonth, prepaidMonthCount).flatMap((month) =>
401
+ getBillableSessionsForGroups(userGroups, groupIds, month, latestInvoices)
402
+ );
403
+
286
404
  export const getBillableAttendanceRecords = (
287
405
  attendance: AttendanceRecord[],
288
406
  groupIds: string[],
@@ -305,6 +423,17 @@ export const getBillableAttendanceRecords = (
305
423
  });
306
424
  };
307
425
 
426
+ export const getBillableAttendanceRecordsInRange = (
427
+ attendance: AttendanceRecord[],
428
+ groupIds: string[],
429
+ selectedMonth: string,
430
+ prepaidMonthCount = 1,
431
+ latestInvoices: SubscriptionCoverageInvoice[] = []
432
+ ): AttendanceRecord[] =>
433
+ getCoverageMonths(selectedMonth, prepaidMonthCount).flatMap((month) =>
434
+ getBillableAttendanceRecords(attendance, groupIds, month, latestInvoices)
435
+ );
436
+
308
437
  export const getSessionsForMonth = (
309
438
  sessionsArray: string[] | null,
310
439
  month: string
@@ -453,26 +582,35 @@ export const getAvailableMonths = (
453
582
  groupIds: string[],
454
583
  latestInvoices: SubscriptionCoverageInvoice[],
455
584
  locale: string,
456
- selectedMonthFallback: string | null = null
585
+ selectedMonthFallback: string | null = null,
586
+ futureMonthHorizon = 0
457
587
  ): AvailableMonthOption[] => {
458
588
  if (groupIds.length === 0) return [];
459
589
  const { earliestStart, latestEnd } = getGroupsDateRange(userGroups, groupIds);
460
590
  if (!earliestStart) return [];
461
591
 
592
+ const currentMonthStart = (() => {
593
+ const date = new Date();
594
+ date.setDate(1);
595
+ return date;
596
+ })();
597
+ const futureHorizonEnd = new Date(currentMonthStart);
598
+ futureHorizonEnd.setMonth(
599
+ futureHorizonEnd.getMonth() + Math.max(0, futureMonthHorizon)
600
+ );
601
+
462
602
  const resolvedLatestEnd = latestEnd
463
603
  ? latestEnd
464
604
  : selectedMonthFallback
465
605
  ? getMonthStartDate(selectedMonthFallback)
466
- : (() => {
467
- const d = new Date();
468
- d.setDate(1);
469
- return d;
470
- })();
606
+ : currentMonthStart;
607
+ const resolvedRangeEnd =
608
+ futureMonthHorizon > 0 && !latestEnd ? futureHorizonEnd : resolvedLatestEnd;
471
609
 
472
610
  const months: AvailableMonthOption[] = [];
473
611
  const currentDate = new Date(earliestStart);
474
612
  currentDate.setDate(1);
475
- const normalizedLatestEnd = new Date(resolvedLatestEnd);
613
+ const normalizedLatestEnd = new Date(resolvedRangeEnd);
476
614
  normalizedLatestEnd.setDate(1);
477
615
 
478
616
  while (currentDate <= normalizedLatestEnd) {
@@ -504,6 +642,101 @@ export const getTotalSessionsForGroups = (
504
642
  ).length;
505
643
  };
506
644
 
645
+ export const getBillableQuantityForGroupRange = ({
646
+ groupId,
647
+ latestInvoices,
648
+ now = new Date(),
649
+ prepaidMonthCount = 1,
650
+ selectedMonth,
651
+ useAttendanceBased,
652
+ userAttendance,
653
+ userGroups,
654
+ }: {
655
+ groupId: string;
656
+ latestInvoices?: SubscriptionCoverageInvoice[];
657
+ now?: Date;
658
+ prepaidMonthCount?: number;
659
+ selectedMonth: string;
660
+ useAttendanceBased: boolean;
661
+ userAttendance: AttendanceRecord[];
662
+ userGroups: UserGroup[];
663
+ }): number => {
664
+ const currentMonthStart = getMonthStartDate(now);
665
+ const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
666
+
667
+ return coverageMonths.reduce((total, month) => {
668
+ if (isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices ?? [])) {
669
+ return total;
670
+ }
671
+
672
+ const monthStart = getMonthStartDate(month);
673
+ const useScheduledSessions =
674
+ !useAttendanceBased ||
675
+ (!Number.isNaN(monthStart.getTime()) &&
676
+ !Number.isNaN(currentMonthStart.getTime()) &&
677
+ monthStart > currentMonthStart);
678
+
679
+ if (useScheduledSessions) {
680
+ return (
681
+ total +
682
+ getBillableSessionsForGroups(
683
+ userGroups,
684
+ [groupId],
685
+ month,
686
+ latestInvoices
687
+ ).length
688
+ );
689
+ }
690
+
691
+ return (
692
+ total +
693
+ getEffectiveAttendanceDays(
694
+ getBillableAttendanceRecords(
695
+ userAttendance,
696
+ [groupId],
697
+ month,
698
+ latestInvoices
699
+ )
700
+ )
701
+ );
702
+ }, 0);
703
+ };
704
+
705
+ export const getBillableQuantityMapForGroupsRange = ({
706
+ groupIds,
707
+ latestInvoices,
708
+ now,
709
+ prepaidMonthCount = 1,
710
+ selectedMonth,
711
+ useAttendanceBased,
712
+ userAttendance,
713
+ userGroups,
714
+ }: {
715
+ groupIds: string[];
716
+ latestInvoices?: SubscriptionCoverageInvoice[];
717
+ now?: Date;
718
+ prepaidMonthCount?: number;
719
+ selectedMonth: string;
720
+ useAttendanceBased: boolean;
721
+ userAttendance: AttendanceRecord[];
722
+ userGroups: UserGroup[];
723
+ }): Record<string, number> =>
724
+ Object.fromEntries(
725
+ groupIds.map((groupId) => [
726
+ groupId,
727
+ getBillableQuantityForGroupRange({
728
+ groupId,
729
+ latestInvoices,
730
+ now,
731
+ prepaidMonthCount,
732
+ selectedMonth,
733
+ useAttendanceBased,
734
+ userAttendance,
735
+ userGroups,
736
+ }),
737
+ ])
738
+ );
739
+
507
740
  /** Days before valid_until to consider "expiring soon" */
508
741
  const EXPIRING_SOON_DAYS = 14;
509
742
 
@@ -1,5 +1,6 @@
1
1
  import { generateHTML, generateJSON } from '@tiptap/core';
2
2
  import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
3
+ import { parseTaskDescriptionInput } from '@tuturuuu/utils/task-description-codec';
3
4
  import { describe, expect, it } from 'vitest';
4
5
  import * as Y from 'yjs';
5
6
  import { getEditorExtensions } from '../extensions';
@@ -157,6 +158,27 @@ describe('text editor extensions', () => {
157
158
  expect(html).toContain('#FFF59D');
158
159
  });
159
160
 
161
+ it('renders shared markdown-codec table JSON with editor table extensions', () => {
162
+ const extensions = getEditorExtensions({ readOnly: true });
163
+ const content = parseTaskDescriptionInput(
164
+ ['| Field | Value |', '| --- | --- |', '| Owner | Platform |'].join('\n'),
165
+ 'markdown'
166
+ );
167
+
168
+ const html = generateHTML(content, extensions);
169
+
170
+ expect(html).toContain('<table');
171
+ expect(html).toContain('<th');
172
+ expect(html).toContain('<td');
173
+ expect(html).toContain('Platform');
174
+
175
+ const parsed = generateJSON(html, extensions);
176
+ expect(parsed.content?.[0]?.type).toBe('table');
177
+ expect(parsed.content?.[0]?.content?.[0]?.content?.[0]?.type).toBe(
178
+ 'tableHeader'
179
+ );
180
+ });
181
+
160
182
  it('round-trips task mention workspace metadata through HTML attrs', () => {
161
183
  const renderOutput = (Mention.config as any).renderHTML({
162
184
  HTMLAttributes: {
@@ -181,6 +181,9 @@ describe('BoardClient', () => {
181
181
  '-m-4'
182
182
  );
183
183
  expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
184
+ expect(
185
+ screen.queryByTestId('task-board-header-skeleton')
186
+ ).not.toBeInTheDocument();
184
187
  expect(screen.queryByText('Loading board...')).not.toBeInTheDocument();
185
188
  });
186
189
 
@@ -210,6 +213,10 @@ describe('BoardClient', () => {
210
213
  'h-[calc(100dvh+2rem)]',
211
214
  'w-[calc(100%+2rem)]'
212
215
  );
216
+ expect(
217
+ screen.getByTestId('task-board-header-skeleton')
218
+ ).toBeInTheDocument();
219
+ expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
213
220
  });
214
221
 
215
222
  it('can revalidate loaded board lists without invalidating visible task caches', async () => {
@@ -20,6 +20,9 @@ describe('TaskBoardLoadingState', () => {
20
20
  'pr-0'
21
21
  );
22
22
  expect(screen.getByTestId('kanban-skeleton-frame')).not.toHaveClass('p-2');
23
+ expect(
24
+ screen.queryByTestId('task-board-header-skeleton')
25
+ ).not.toBeInTheDocument();
23
26
  });
24
27
 
25
28
  it('keeps embedded loading skeletons constrained to the parent width', () => {
@@ -34,5 +37,39 @@ describe('TaskBoardLoadingState', () => {
34
37
  'w-[calc(100%+2rem)]'
35
38
  );
36
39
  expect(screen.getByTestId('kanban-skeleton-frame')).toHaveClass('p-2');
40
+ expect(
41
+ screen.queryByTestId('task-board-header-skeleton')
42
+ ).not.toBeInTheDocument();
43
+ });
44
+
45
+ it('can include the board header skeleton above the kanban skeleton', () => {
46
+ render(<TaskBoardLoadingState root showHeader />);
47
+
48
+ expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
49
+ '-m-4',
50
+ 'h-[calc(100dvh+2rem)]',
51
+ 'w-[calc(100%+2rem)]',
52
+ 'flex',
53
+ 'flex-col'
54
+ );
55
+ expect(screen.getByTestId('task-board-header-skeleton')).toHaveAttribute(
56
+ 'aria-hidden',
57
+ 'true'
58
+ );
59
+ expect(screen.getByTestId('task-board-header-skeleton')).toHaveClass(
60
+ 'border-b',
61
+ 'px-2',
62
+ 'pt-2',
63
+ 'pb-2'
64
+ );
65
+ expect(screen.getByTestId('task-board-header-skeleton')).not.toHaveClass(
66
+ '-mt-2'
67
+ );
68
+ expect(screen.getByTestId('task-board-loading-body')).toHaveClass(
69
+ 'min-h-0',
70
+ 'flex-1',
71
+ 'overflow-hidden'
72
+ );
73
+ expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
37
74
  });
38
75
  });
@@ -247,7 +247,9 @@ export function BoardClient({
247
247
  ]);
248
248
 
249
249
  if (boardLoading && !board) {
250
- return <TaskBoardLoadingState root={rootLoading} />;
250
+ return (
251
+ <TaskBoardLoadingState root={rootLoading} showHeader={rootLoading} />
252
+ );
251
253
  }
252
254
 
253
255
  if (!board?.id) {
@@ -1,14 +1,55 @@
1
1
  'use client';
2
2
 
3
+ import { Skeleton } from '@tuturuuu/ui/skeleton';
3
4
  import { cn } from '@tuturuuu/utils/format';
4
5
  import { KanbanSkeleton } from '../boards/boardId/kanban/rendering/kanban-skeleton';
5
6
 
7
+ const HEADER_ACTIONS = [
8
+ 'focus',
9
+ 'select',
10
+ 'view',
11
+ 'status',
12
+ 'sort',
13
+ 'settings',
14
+ ];
15
+
16
+ function TaskBoardHeaderSkeleton() {
17
+ return (
18
+ <div
19
+ aria-hidden="true"
20
+ className="border-b px-2 pt-2 pb-2"
21
+ data-testid="task-board-header-skeleton"
22
+ >
23
+ <div className="flex flex-wrap items-center justify-between gap-1.5 sm:gap-2">
24
+ <div className="flex min-w-0 items-center gap-2">
25
+ <Skeleton className="h-7 w-44 rounded-md sm:h-8 sm:w-56" />
26
+ </div>
27
+
28
+ <div className="min-w-0 flex-1 basis-72">
29
+ <Skeleton className="h-6 w-full rounded-md sm:h-8" />
30
+ </div>
31
+
32
+ <div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
33
+ {HEADER_ACTIONS.map((action) => (
34
+ <Skeleton
35
+ className="h-7 w-7 rounded-md sm:h-8 sm:w-8"
36
+ key={action}
37
+ />
38
+ ))}
39
+ </div>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
6
45
  export function TaskBoardLoadingState({
7
46
  className,
8
47
  root = false,
48
+ showHeader = false,
9
49
  }: {
10
50
  className?: string;
11
51
  root?: boolean;
52
+ showHeader?: boolean;
12
53
  }) {
13
54
  return (
14
55
  <div
@@ -18,11 +59,24 @@ export function TaskBoardLoadingState({
18
59
  root
19
60
  ? '-m-4 h-[calc(100dvh+2rem)] min-h-[calc(32rem+2rem)] w-[calc(100%+2rem)] min-w-[calc(100%+2rem)]'
20
61
  : 'h-[calc(100dvh-1rem)] min-h-[32rem] w-full',
62
+ showHeader && 'flex flex-col',
21
63
  className
22
64
  )}
23
65
  data-testid="task-board-loading-state"
24
66
  >
25
- <KanbanSkeleton root={root} />
67
+ {showHeader ? (
68
+ <>
69
+ <TaskBoardHeaderSkeleton />
70
+ <div
71
+ className="min-h-0 flex-1 overflow-hidden"
72
+ data-testid="task-board-loading-body"
73
+ >
74
+ <KanbanSkeleton root={root} />
75
+ </div>
76
+ </>
77
+ ) : (
78
+ <KanbanSkeleton root={root} />
79
+ )}
26
80
  </div>
27
81
  );
28
82
  }
@@ -54,6 +54,7 @@ export interface TaskDescriptionEditorProps {
54
54
  onImageUpload?: (file: File) => Promise<string>;
55
55
  onEditorReady: (editor: Editor) => void;
56
56
  onConvertToTask?: () => void | Promise<void>;
57
+ onDescriptionSnapshotChange?: (description: JSONContent | null) => void;
57
58
  onDescriptionStorageLengthChange: (storageLength: number) => void;
58
59
  descriptionStorageLength: number;
59
60
  descriptionPercentLeft: number;
@@ -109,6 +110,7 @@ export function TaskDescriptionEditor({
109
110
  onImageUpload,
110
111
  onEditorReady,
111
112
  onConvertToTask,
113
+ onDescriptionSnapshotChange,
112
114
  onDescriptionStorageLengthChange,
113
115
  descriptionStorageLength,
114
116
  descriptionPercentLeft,
@@ -290,6 +292,7 @@ export function TaskDescriptionEditor({
290
292
  content={description}
291
293
  onChange={setDescription}
292
294
  onImmediateChange={(nextDescription) => {
295
+ onDescriptionSnapshotChange?.(nextDescription);
293
296
  if (allowYjsSync) {
294
297
  setDescription(nextDescription);
295
298
  }
@@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
5
5
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
6
6
  import { cn } from '@tuturuuu/utils/format';
7
7
  import { useTranslations } from 'next-intl';
8
- import { useMemo, useState } from 'react';
8
+ import { type ComponentProps, useMemo, useState } from 'react';
9
9
  import { CreateListDialog } from '../../create-list-dialog';
10
10
  import { translateTaskListNameForDisplay } from '../../utils/translate-task-list-display-name';
11
11
  import { TaskListPickerPanel } from './task-list-picker-panel';
@@ -23,7 +23,14 @@ interface TaskListSelectorProps {
23
23
  compact?: boolean;
24
24
  open?: boolean;
25
25
  onOpenChange?: (open: boolean) => void;
26
+ onPopoverCloseAutoFocus?: ComponentProps<
27
+ typeof PopoverContent
28
+ >['onCloseAutoFocus'];
29
+ onPopoverInteractOutside?: ComponentProps<
30
+ typeof PopoverContent
31
+ >['onInteractOutside'];
26
32
  onListChange: (listId: string) => void;
33
+ propertyPopoverId?: string;
27
34
  }
28
35
 
29
36
  export function TaskListSelector({
@@ -35,7 +42,10 @@ export function TaskListSelector({
35
42
  compact = false,
36
43
  open,
37
44
  onOpenChange,
45
+ onPopoverCloseAutoFocus,
46
+ onPopoverInteractOutside,
38
47
  onListChange,
48
+ propertyPopoverId,
39
49
  }: TaskListSelectorProps) {
40
50
  const t = useTranslations();
41
51
  const [uncontrolledPopoverOpen, setUncontrolledPopoverOpen] = useState(false);
@@ -80,6 +90,7 @@ export function TaskListSelector({
80
90
  const triggerButton = (
81
91
  <button
82
92
  type="button"
93
+ data-task-property-popover-trigger={propertyPopoverId}
83
94
  disabled={disabled}
84
95
  aria-label={compact ? triggerLabel : undefined}
85
96
  className={cn(
@@ -111,7 +122,12 @@ export function TaskListSelector({
111
122
  ) : (
112
123
  <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
113
124
  )}
114
- <PopoverContent align="start" className="w-80 p-0">
125
+ <PopoverContent
126
+ align="start"
127
+ className="w-80 p-0"
128
+ onCloseAutoFocus={onPopoverCloseAutoFocus}
129
+ onInteractOutside={onPopoverInteractOutside}
130
+ >
115
131
  <TaskListPickerPanel
116
132
  selectedListId={selectedListId}
117
133
  availableLists={availableLists}
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildRecoverableTaskDescriptionVersions,
4
+ extractRecoverableTaskDescriptionValue,
5
+ } from './description-versions';
6
+
7
+ const makeDoc = (text: string) =>
8
+ JSON.stringify({
9
+ type: 'doc',
10
+ content: [
11
+ {
12
+ type: 'paragraph',
13
+ content: [{ type: 'text', text }],
14
+ },
15
+ ],
16
+ });
17
+
18
+ describe('task description tracked versions', () => {
19
+ it('does not treat null, empty docs, or legacy placeholders as recoverable', () => {
20
+ expect(extractRecoverableTaskDescriptionValue(null)).toBeNull();
21
+ expect(extractRecoverableTaskDescriptionValue('')).toBeNull();
22
+ expect(extractRecoverableTaskDescriptionValue('has_content')).toBeNull();
23
+ expect(
24
+ extractRecoverableTaskDescriptionValue(
25
+ JSON.stringify({
26
+ type: 'doc',
27
+ content: [{ type: 'paragraph' }],
28
+ })
29
+ )
30
+ ).toBeNull();
31
+ });
32
+
33
+ it('normalizes plain text and serialized TipTap content', () => {
34
+ const plain = extractRecoverableTaskDescriptionValue('Plain description');
35
+ const serialized = extractRecoverableTaskDescriptionValue(
36
+ makeDoc('Serialized description')
37
+ );
38
+
39
+ expect(plain?.description).toContain('Plain description');
40
+ expect(plain?.previewText).toBe('Plain description');
41
+ expect(serialized?.description).toContain('Serialized description');
42
+ expect(serialized?.previewText).toBe('Serialized description');
43
+ });
44
+
45
+ it('prefers the previous value from the latest wipe entry', () => {
46
+ const versions = buildRecoverableTaskDescriptionVersions([
47
+ {
48
+ id: 'older',
49
+ changed_at: '2026-06-25T10:00:00.000Z',
50
+ change_type: 'field_updated',
51
+ field_name: 'description',
52
+ old_value: makeDoc('Older version'),
53
+ new_value: makeDoc('Intermediate version'),
54
+ },
55
+ {
56
+ id: 'wipe',
57
+ changed_at: '2026-06-26T10:00:00.000Z',
58
+ change_type: 'field_updated',
59
+ field_name: 'description',
60
+ old_value: makeDoc('Latest real version'),
61
+ new_value: null,
62
+ },
63
+ ]);
64
+
65
+ expect(versions[0]?.historyId).toBe('wipe');
66
+ expect(versions[0]?.source).toBe('old_value');
67
+ expect(versions[0]?.reason).toBe('before_clear');
68
+ expect(versions[0]?.previewText).toBe('Latest real version');
69
+ });
70
+
71
+ it('dedupes identical canonical descriptions while keeping newest first', () => {
72
+ const duplicated = makeDoc('Same description');
73
+ const versions = buildRecoverableTaskDescriptionVersions([
74
+ {
75
+ id: 'newer',
76
+ changed_at: '2026-06-26T10:00:00.000Z',
77
+ change_type: 'field_updated',
78
+ field_name: 'description',
79
+ old_value: makeDoc('Different description'),
80
+ new_value: duplicated,
81
+ },
82
+ {
83
+ id: 'older',
84
+ changed_at: '2026-06-25T10:00:00.000Z',
85
+ change_type: 'field_updated',
86
+ field_name: 'description',
87
+ old_value: duplicated,
88
+ new_value: duplicated,
89
+ },
90
+ ]);
91
+
92
+ expect(versions.map((version) => version.previewText)).toEqual([
93
+ 'Same description',
94
+ 'Different description',
95
+ ]);
96
+ });
97
+ });