@tuturuuu/ui 0.10.0 → 0.11.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 (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -4
  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/tu-do/shared/__tests__/board-client.test.tsx +7 -0
  14. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
  15. package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
  16. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
  17. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  18. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  19. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  20. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  21. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  22. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  23. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  24. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  25. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  26. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  27. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  28. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
  29. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  30. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  31. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  32. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  33. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  34. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  35. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  36. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle, History, Loader2, RotateCcw } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
6
+
7
+ interface TaskDescriptionRestoreBannerProps {
8
+ latestVersion: RecoverableTaskDescriptionVersion | null;
9
+ versionCount: number;
10
+ isRestoring: boolean;
11
+ onRestoreLatest: () => void;
12
+ onViewVersions: () => void;
13
+ t: (
14
+ key: string,
15
+ options?: { count?: number; defaultValue?: string }
16
+ ) => string;
17
+ }
18
+
19
+ export function TaskDescriptionRestoreBanner({
20
+ latestVersion,
21
+ versionCount,
22
+ isRestoring,
23
+ onRestoreLatest,
24
+ onViewVersions,
25
+ t,
26
+ }: TaskDescriptionRestoreBannerProps) {
27
+ if (!latestVersion) return null;
28
+
29
+ return (
30
+ <div className="mx-4 mb-3 rounded-md border border-dynamic-orange/30 bg-dynamic-orange/5 p-3 md:mx-8">
31
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
32
+ <div className="flex min-w-0 gap-2">
33
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-dynamic-orange" />
34
+ <div className="min-w-0 space-y-1">
35
+ <p className="font-medium text-sm">
36
+ {t('description_restore_banner_title', {
37
+ defaultValue: 'A tracked description version is available',
38
+ })}
39
+ </p>
40
+ <p className="text-muted-foreground text-xs">
41
+ {t('description_restore_banner_description', {
42
+ count: versionCount,
43
+ defaultValue:
44
+ 'Current content differs from the latest tracked version. Restore it or compare all tracked versions.',
45
+ })}
46
+ </p>
47
+ </div>
48
+ </div>
49
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
50
+ <Button
51
+ className="h-8 gap-1.5 px-2.5 text-xs"
52
+ disabled={isRestoring}
53
+ onClick={onRestoreLatest}
54
+ size="sm"
55
+ type="button"
56
+ variant="default"
57
+ >
58
+ {isRestoring ? (
59
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
60
+ ) : (
61
+ <RotateCcw className="h-3.5 w-3.5" />
62
+ )}
63
+ {t('restore_latest_tracked', {
64
+ defaultValue: 'Restore latest tracked',
65
+ })}
66
+ </Button>
67
+ <Button
68
+ className="h-8 gap-1.5 px-2.5 text-xs"
69
+ onClick={onViewVersions}
70
+ size="sm"
71
+ type="button"
72
+ variant="outline"
73
+ >
74
+ <History className="h-3.5 w-3.5" />
75
+ {t('view_description_versions', {
76
+ defaultValue: 'View versions',
77
+ })}
78
+ </Button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,120 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import type { ReactNode } from 'react';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
7
+ import { TaskDescriptionRestoreBanner } from './task-description-restore-banner';
8
+ import { TaskDescriptionVersionRestoreDialog } from './task-description-version-restore-dialog';
9
+
10
+ vi.mock('./description-diff-viewer', () => ({
11
+ DescriptionDiffViewer: ({ trigger }: { trigger?: ReactNode }) =>
12
+ trigger ?? <button type="button">compare</button>,
13
+ }));
14
+
15
+ const t = (_key: string, options?: { count?: number; defaultValue?: string }) =>
16
+ options?.defaultValue ?? _key;
17
+
18
+ const makeVersion = (
19
+ overrides: Partial<RecoverableTaskDescriptionVersion> = {}
20
+ ): RecoverableTaskDescriptionVersion => ({
21
+ id: 'history-1:new_value',
22
+ historyId: 'history-1',
23
+ changedAt: '2026-06-27T00:00:00.000Z',
24
+ source: 'new_value',
25
+ reason: 'tracked',
26
+ description:
27
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Tracked description"}]}]}',
28
+ content: {
29
+ type: 'doc',
30
+ content: [
31
+ {
32
+ type: 'paragraph',
33
+ content: [{ type: 'text', text: 'Tracked description' }],
34
+ },
35
+ ],
36
+ },
37
+ previewText: 'Tracked description',
38
+ user: { id: 'user-1', name: 'User' },
39
+ ...overrides,
40
+ });
41
+
42
+ describe('task description restore UI', () => {
43
+ it('shows the recovery banner and wires restore/view actions', () => {
44
+ const onRestoreLatest = vi.fn();
45
+ const onViewVersions = vi.fn();
46
+
47
+ render(
48
+ <TaskDescriptionRestoreBanner
49
+ isRestoring={false}
50
+ latestVersion={makeVersion()}
51
+ onRestoreLatest={onRestoreLatest}
52
+ onViewVersions={onViewVersions}
53
+ t={t}
54
+ versionCount={1}
55
+ />
56
+ );
57
+
58
+ fireEvent.click(
59
+ screen.getByRole('button', { name: /restore latest tracked/i })
60
+ );
61
+ fireEvent.click(screen.getByRole('button', { name: /view versions/i }));
62
+
63
+ expect(onRestoreLatest).toHaveBeenCalledTimes(1);
64
+ expect(onViewVersions).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('does not show the recovery banner without a newer tracked version', () => {
68
+ const { container } = render(
69
+ <TaskDescriptionRestoreBanner
70
+ isRestoring={false}
71
+ latestVersion={null}
72
+ onRestoreLatest={vi.fn()}
73
+ onViewVersions={vi.fn()}
74
+ t={t}
75
+ versionCount={0}
76
+ />
77
+ );
78
+
79
+ expect(container).toBeEmptyDOMElement();
80
+ });
81
+
82
+ it('shows an empty version picker state when history has no recoverable content', () => {
83
+ render(
84
+ <TaskDescriptionVersionRestoreDialog
85
+ currentDescription={null}
86
+ isOpen
87
+ onClose={vi.fn()}
88
+ onRestoreVersion={vi.fn()}
89
+ t={t}
90
+ versions={[]}
91
+ />
92
+ );
93
+
94
+ expect(
95
+ screen.getByText(/no restorable description versions were found/i)
96
+ ).toBeInTheDocument();
97
+ });
98
+
99
+ it('restores a selected version from the version picker', () => {
100
+ const onRestoreVersion = vi.fn();
101
+ const version = makeVersion();
102
+
103
+ render(
104
+ <TaskDescriptionVersionRestoreDialog
105
+ currentDescription={null}
106
+ isOpen
107
+ onClose={vi.fn()}
108
+ onRestoreVersion={onRestoreVersion}
109
+ t={t}
110
+ versions={[version]}
111
+ />
112
+ );
113
+
114
+ expect(screen.getByText('Tracked description')).toBeInTheDocument();
115
+
116
+ fireEvent.click(screen.getByRole('button', { name: /^restore$/i }));
117
+
118
+ expect(onRestoreVersion).toHaveBeenCalledWith(version);
119
+ });
120
+ });
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { Clock, Eye, Loader2, RotateCcw } from '@tuturuuu/icons';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@tuturuuu/ui/dialog';
13
+ import { ScrollArea } from '@tuturuuu/ui/scroll-area';
14
+ import { format, formatDistanceToNow } from 'date-fns';
15
+ import { enUS, vi } from 'date-fns/locale';
16
+ import { DescriptionDiffViewer } from './description-diff-viewer';
17
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
18
+
19
+ interface TaskDescriptionVersionRestoreDialogProps {
20
+ currentDescription: string | null;
21
+ isOpen: boolean;
22
+ locale?: string;
23
+ onClose: () => void;
24
+ onRestoreVersion: (
25
+ version: RecoverableTaskDescriptionVersion
26
+ ) => Promise<void> | void;
27
+ restoringVersionId?: string | null;
28
+ t: (
29
+ key: string,
30
+ options?: { count?: number; defaultValue?: string }
31
+ ) => string;
32
+ versions: RecoverableTaskDescriptionVersion[];
33
+ }
34
+
35
+ export function TaskDescriptionVersionRestoreDialog({
36
+ currentDescription,
37
+ isOpen,
38
+ locale = 'en',
39
+ onClose,
40
+ onRestoreVersion,
41
+ restoringVersionId,
42
+ t,
43
+ versions,
44
+ }: TaskDescriptionVersionRestoreDialogProps) {
45
+ const dateLocale = locale === 'vi' ? vi : enUS;
46
+
47
+ return (
48
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
49
+ <DialogContent className="grid max-h-[85vh] w-full max-w-2xl grid-rows-[auto_1fr] overflow-hidden md:max-w-3xl">
50
+ <DialogHeader>
51
+ <DialogTitle>
52
+ {t('description_versions_title', {
53
+ defaultValue: 'Description versions',
54
+ })}
55
+ </DialogTitle>
56
+ <DialogDescription>
57
+ {t('description_versions_description', {
58
+ defaultValue:
59
+ 'Compare tracked description versions and restore the one that should be current.',
60
+ })}
61
+ </DialogDescription>
62
+ </DialogHeader>
63
+
64
+ <ScrollArea className="-mx-6 min-h-0 px-6">
65
+ <div className="space-y-3 py-4">
66
+ {versions.length === 0 ? (
67
+ <div className="rounded-md border border-dashed p-4 text-center text-muted-foreground text-sm">
68
+ {t('no_recoverable_description_versions', {
69
+ defaultValue:
70
+ 'No restorable description versions were found in history.',
71
+ })}
72
+ </div>
73
+ ) : (
74
+ versions.map((version, index) => {
75
+ const isRestoring = restoringVersionId === version.id;
76
+ const time = new Date(version.changedAt);
77
+ const exactTime = format(time, 'PPpp', {
78
+ locale: dateLocale,
79
+ });
80
+ const relativeTime = formatDistanceToNow(time, {
81
+ addSuffix: true,
82
+ locale: dateLocale,
83
+ });
84
+
85
+ return (
86
+ <div
87
+ className="rounded-md border bg-card p-3"
88
+ key={version.id}
89
+ >
90
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
91
+ <div className="min-w-0 space-y-2">
92
+ <div className="flex flex-wrap items-center gap-2">
93
+ {index === 0 && (
94
+ <Badge variant="secondary">
95
+ {t('latest_version', {
96
+ defaultValue: 'Latest',
97
+ })}
98
+ </Badge>
99
+ )}
100
+ {version.reason === 'before_clear' && (
101
+ <Badge
102
+ className="border-dynamic-orange/30 text-dynamic-orange"
103
+ variant="outline"
104
+ >
105
+ {t('before_clear_version', {
106
+ defaultValue: 'Before clear',
107
+ })}
108
+ </Badge>
109
+ )}
110
+ <span
111
+ className="inline-flex items-center gap-1 text-muted-foreground text-xs"
112
+ title={exactTime}
113
+ >
114
+ <Clock className="h-3.5 w-3.5" />
115
+ {relativeTime}
116
+ </span>
117
+ </div>
118
+ <p className="line-clamp-2 text-sm">
119
+ {version.previewText ||
120
+ t('description_version_no_preview', {
121
+ defaultValue: 'No text preview',
122
+ })}
123
+ </p>
124
+ {version.user?.name && (
125
+ <p className="text-muted-foreground text-xs">
126
+ {t('tracked_by_user', {
127
+ defaultValue: 'Tracked by',
128
+ })}
129
+ {` ${version.user.name}`}
130
+ </p>
131
+ )}
132
+ </div>
133
+
134
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
135
+ <DescriptionDiffViewer
136
+ newValue={version.description}
137
+ oldValue={currentDescription}
138
+ t={t}
139
+ trigger={
140
+ <Button
141
+ className="h-8 gap-1.5 px-2.5 text-xs"
142
+ size="sm"
143
+ type="button"
144
+ variant="outline"
145
+ >
146
+ <Eye className="h-3.5 w-3.5" />
147
+ {t('compare_version', {
148
+ defaultValue: 'Compare',
149
+ })}
150
+ </Button>
151
+ }
152
+ />
153
+ <Button
154
+ className="h-8 gap-1.5 px-2.5 text-xs"
155
+ disabled={!!restoringVersionId}
156
+ onClick={() => onRestoreVersion(version)}
157
+ size="sm"
158
+ type="button"
159
+ >
160
+ {isRestoring ? (
161
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
162
+ ) : (
163
+ <RotateCcw className="h-3.5 w-3.5" />
164
+ )}
165
+ {t('restore_version', {
166
+ defaultValue: 'Restore',
167
+ })}
168
+ </Button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ );
173
+ })
174
+ )}
175
+ </div>
176
+ </ScrollArea>
177
+ </DialogContent>
178
+ </Dialog>
179
+ );
180
+ }
@@ -17,12 +17,16 @@ vi.mock('./hooks/task-api', () => ({
17
17
  import {
18
18
  broadcastTaskDescriptionUpsert,
19
19
  buildTaskDescriptionUpdatePayload,
20
+ canPersistTaskDescriptionSnapshot,
21
+ createTaskDescriptionPersistenceGuardState,
20
22
  getTaskDescriptionPercentLeft,
21
23
  getTaskDescriptionPreviewText,
22
24
  getTaskDescriptionStorageLength,
25
+ recordTaskDescriptionEditorSnapshot,
23
26
  saveAndVerifyYjsDescriptionToDatabase,
24
27
  saveYjsDescriptionToDatabase,
25
28
  serializeTaskDescriptionContent,
29
+ serializeTaskDescriptionPersistenceSnapshot,
26
30
  updateTaskDescriptionCaches,
27
31
  } from './utils';
28
32
 
@@ -36,6 +40,10 @@ describe('task edit dialog utils', () => {
36
40
  },
37
41
  ],
38
42
  };
43
+ const emptyContent: JSONContent = {
44
+ type: 'doc',
45
+ content: [{ type: 'paragraph' }],
46
+ };
39
47
 
40
48
  beforeEach(() => {
41
49
  vi.resetAllMocks();
@@ -47,6 +55,98 @@ describe('task edit dialog utils', () => {
47
55
  );
48
56
  });
49
57
 
58
+ it('blocks untrusted empty snapshots from clearing persisted content', () => {
59
+ const guardState = createTaskDescriptionPersistenceGuardState({
60
+ persistedDescription: JSON.stringify(content),
61
+ });
62
+
63
+ expect(
64
+ canPersistTaskDescriptionSnapshot({
65
+ currentSerializedDescription: null,
66
+ guardState,
67
+ })
68
+ ).toBe(false);
69
+ expect(
70
+ canPersistTaskDescriptionSnapshot({
71
+ currentSerializedDescription:
72
+ serializeTaskDescriptionPersistenceSnapshot(emptyContent),
73
+ guardState,
74
+ })
75
+ ).toBe(false);
76
+ });
77
+
78
+ it('allows no-op empty saves when the persisted description is already empty', () => {
79
+ const guardState = createTaskDescriptionPersistenceGuardState({
80
+ persistedDescription: null,
81
+ });
82
+
83
+ expect(
84
+ canPersistTaskDescriptionSnapshot({
85
+ currentSerializedDescription: null,
86
+ guardState,
87
+ })
88
+ ).toBe(true);
89
+ });
90
+
91
+ it('allows confirmed intentional clears after observing non-empty editor content', () => {
92
+ const initialGuardState = createTaskDescriptionPersistenceGuardState({
93
+ persistedDescription: JSON.stringify(content),
94
+ });
95
+
96
+ const nonEmptySeenState = recordTaskDescriptionEditorSnapshot(
97
+ initialGuardState,
98
+ content
99
+ );
100
+ const confirmedClearState = recordTaskDescriptionEditorSnapshot(
101
+ nonEmptySeenState,
102
+ null,
103
+ { canConfirmEmptySnapshot: true }
104
+ );
105
+
106
+ expect(
107
+ canPersistTaskDescriptionSnapshot({
108
+ currentSerializedDescription: null,
109
+ guardState: confirmedClearState,
110
+ })
111
+ ).toBe(true);
112
+ });
113
+
114
+ it('does not confirm empty snapshots before editor clears are trusted', () => {
115
+ const initialGuardState = createTaskDescriptionPersistenceGuardState({
116
+ persistedDescription: JSON.stringify(content),
117
+ });
118
+ const nonEmptySeenState = recordTaskDescriptionEditorSnapshot(
119
+ initialGuardState,
120
+ content
121
+ );
122
+ const untrustedEmptyState = recordTaskDescriptionEditorSnapshot(
123
+ nonEmptySeenState,
124
+ null,
125
+ { canConfirmEmptySnapshot: false }
126
+ );
127
+
128
+ expect(
129
+ canPersistTaskDescriptionSnapshot({
130
+ currentSerializedDescription: null,
131
+ guardState: untrustedEmptyState,
132
+ })
133
+ ).toBe(false);
134
+ });
135
+
136
+ it('allows non-empty saves even when the guard has not observed hydration', () => {
137
+ const guardState = createTaskDescriptionPersistenceGuardState({
138
+ persistedDescription: JSON.stringify(content),
139
+ });
140
+
141
+ expect(
142
+ canPersistTaskDescriptionSnapshot({
143
+ currentSerializedDescription:
144
+ serializeTaskDescriptionPersistenceSnapshot(content),
145
+ guardState,
146
+ })
147
+ ).toBe(true);
148
+ });
149
+
50
150
  it('returns zero for empty description storage length', () => {
51
151
  expect(getTaskDescriptionStorageLength(null)).toBe(0);
52
152
  });
@@ -45,6 +45,115 @@ export function serializeTaskDescriptionContent(
45
45
  }
46
46
  }
47
47
 
48
+ function hasTaskDescriptionContent(content: JSONContent | null): boolean {
49
+ if (!content) return false;
50
+
51
+ if (content.text && content.text.trim().length > 0) {
52
+ return true;
53
+ }
54
+
55
+ if (
56
+ content.type &&
57
+ ['image', 'imageResize', 'youtube', 'video', 'mention', 'table'].includes(
58
+ content.type
59
+ )
60
+ ) {
61
+ return true;
62
+ }
63
+
64
+ return content.content?.some(hasTaskDescriptionContent) ?? false;
65
+ }
66
+
67
+ function isSerializedTaskDescriptionEmpty(description: string | null): boolean {
68
+ if (description === null) return true;
69
+ if (description.trim().length === 0) return true;
70
+
71
+ try {
72
+ return !hasTaskDescriptionContent(JSON.parse(description) as JSONContent);
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ export function normalizeTaskDescriptionSnapshot(
79
+ content: JSONContent | null
80
+ ): JSONContent | null {
81
+ return hasTaskDescriptionContent(content) ? content : null;
82
+ }
83
+
84
+ export function serializeTaskDescriptionPersistenceSnapshot(
85
+ content: JSONContent | null
86
+ ): string | null {
87
+ return serializeTaskDescriptionContent(
88
+ normalizeTaskDescriptionSnapshot(content)
89
+ );
90
+ }
91
+
92
+ export type TaskDescriptionPersistenceGuardState = {
93
+ persistedDescription: string | null;
94
+ hasSeenNonEmptyEditorSnapshot: boolean;
95
+ hasConfirmedEmptyEditorSnapshot: boolean;
96
+ };
97
+
98
+ export function createTaskDescriptionPersistenceGuardState({
99
+ persistedDescription,
100
+ trustPersistedDescription = false,
101
+ }: {
102
+ persistedDescription: string | null;
103
+ trustPersistedDescription?: boolean;
104
+ }): TaskDescriptionPersistenceGuardState {
105
+ const persistedDescriptionIsEmpty =
106
+ isSerializedTaskDescriptionEmpty(persistedDescription);
107
+
108
+ return {
109
+ persistedDescription,
110
+ hasSeenNonEmptyEditorSnapshot:
111
+ trustPersistedDescription && !persistedDescriptionIsEmpty,
112
+ hasConfirmedEmptyEditorSnapshot: persistedDescriptionIsEmpty,
113
+ };
114
+ }
115
+
116
+ export function recordTaskDescriptionEditorSnapshot(
117
+ state: TaskDescriptionPersistenceGuardState,
118
+ content: JSONContent | null,
119
+ options: { canConfirmEmptySnapshot?: boolean } = {}
120
+ ): TaskDescriptionPersistenceGuardState {
121
+ const serializedDescription =
122
+ serializeTaskDescriptionPersistenceSnapshot(content);
123
+
124
+ if (serializedDescription !== null) {
125
+ return {
126
+ ...state,
127
+ hasSeenNonEmptyEditorSnapshot: true,
128
+ hasConfirmedEmptyEditorSnapshot: false,
129
+ };
130
+ }
131
+
132
+ if (options.canConfirmEmptySnapshot && state.hasSeenNonEmptyEditorSnapshot) {
133
+ return {
134
+ ...state,
135
+ hasConfirmedEmptyEditorSnapshot: true,
136
+ };
137
+ }
138
+
139
+ return state;
140
+ }
141
+
142
+ export function canPersistTaskDescriptionSnapshot({
143
+ currentSerializedDescription,
144
+ guardState,
145
+ }: {
146
+ currentSerializedDescription: string | null;
147
+ guardState: TaskDescriptionPersistenceGuardState;
148
+ }): boolean {
149
+ if (currentSerializedDescription !== null) return true;
150
+ if (isSerializedTaskDescriptionEmpty(guardState.persistedDescription)) {
151
+ return true;
152
+ }
153
+
154
+ return guardState.hasConfirmedEmptyEditorSnapshot;
155
+ }
156
+
48
157
  export function getTaskDescriptionStorageLength(
49
158
  content: JSONContent | null
50
159
  ): number {