@tuturuuu/ui 0.37.1 → 0.38.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 (64) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  6. package/src/components/ui/custom/education-todo.tsx +191 -0
  7. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  8. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  9. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  10. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  11. package/src/components/ui/custom/structure.tsx +37 -5
  12. package/src/components/ui/custom/user-filters.tsx +2 -1
  13. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  14. package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
  15. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  16. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  17. package/src/components/ui/custom/workspace-select.tsx +95 -359
  18. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  19. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  20. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  21. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  22. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  23. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  24. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  25. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  26. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  27. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  28. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  29. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  30. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  31. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  32. package/src/components/ui/popover.tsx +3 -1
  33. package/src/components/ui/resizable.tsx +2 -1
  34. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  35. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  36. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  37. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  38. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  39. package/src/components/ui/text-editor/editor.tsx +8 -3
  40. package/src/components/ui/text-editor/extensions.ts +8 -2
  41. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  42. package/src/components/ui/text-editor/markdown.ts +11 -0
  43. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  44. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  45. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  46. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  47. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  48. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  49. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  52. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  53. package/src/hooks/calendar-creation-request.test.ts +62 -0
  54. package/src/hooks/calendar-creation-request.ts +34 -0
  55. package/src/hooks/calendar-event-write-payload.ts +24 -0
  56. package/src/hooks/notification-subscription.ts +5 -2
  57. package/src/hooks/supabase-provider.ts +38 -72
  58. package/src/hooks/use-calendar.tsx +21 -45
  59. package/src/hooks/use-exchange-rates.ts +4 -15
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-presence.ts +9 -9
  62. package/src/hooks/use-workspace-user.ts +15 -3
  63. package/src/hooks/use-workspace-visibility.ts +234 -0
  64. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -0,0 +1,147 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import type { GoogleProviderColorOptions } from '@tuturuuu/types/primitives/google-calendar-color';
4
+ import type { ComponentProps, ReactNode } from 'react';
5
+ import { beforeEach, expect, it, vi } from 'vitest';
6
+ import { ProviderColorPicker } from './provider-color-picker';
7
+
8
+ const mocks = vi.hoisted(() => ({ query: vi.fn(), get: vi.fn() }));
9
+ vi.mock('@tanstack/react-query', () => ({ useQuery: mocks.query }));
10
+ vi.mock('@tuturuuu/internal-api', () => ({
11
+ getGoogleCalendarColorOptions: mocks.get,
12
+ }));
13
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
14
+ vi.mock('@tuturuuu/ui/label', () => ({
15
+ Label: ({ children }: { children: ReactNode }) => <span>{children}</span>,
16
+ }));
17
+ vi.mock('./event-form-components', () => ({
18
+ EventColorPicker: () => <div>Legacy palette</div>,
19
+ }));
20
+ vi.mock('@tuturuuu/ui/select', () => ({
21
+ Select: ({
22
+ children,
23
+ value,
24
+ onValueChange,
25
+ }: {
26
+ children: ReactNode;
27
+ value?: string;
28
+ onValueChange: (value: string) => void;
29
+ }) => (
30
+ <select
31
+ aria-label="Provider palette"
32
+ value={value}
33
+ onChange={(event) => onValueChange(event.target.value)}
34
+ >
35
+ {children}
36
+ </select>
37
+ ),
38
+ SelectContent: ({ children }: { children: ReactNode }) => children,
39
+ SelectTrigger: () => null,
40
+ SelectValue: () => null,
41
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
42
+ <option value={value}>{children}</option>
43
+ ),
44
+ }));
45
+ const options: GoogleProviderColorOptions = {
46
+ provider: 'google',
47
+ providerColorWrites: true,
48
+ connectionId: 'owned',
49
+ calendarId: 'source',
50
+ sourceColor: { background: '#123456', foreground: '#ffffff' },
51
+ options: [
52
+ {
53
+ kind: 'inherit',
54
+ id: null,
55
+ name: null,
56
+ background: '#123456',
57
+ foreground: null,
58
+ },
59
+ {
60
+ kind: 'event',
61
+ id: '3',
62
+ name: null,
63
+ background: '#987654',
64
+ foreground: null,
65
+ },
66
+ {
67
+ kind: 'label',
68
+ id: 'label-id',
69
+ name: 'Synthetic label',
70
+ background: '#abcdef',
71
+ foreground: null,
72
+ },
73
+ ],
74
+ };
75
+ const props = (): ComponentProps<typeof ProviderColorPicker> => ({
76
+ wsId: 'workspace',
77
+ source: { id: 'source', provider: 'google', connectionId: 'owned' } as never,
78
+ value: 'BLUE',
79
+ onNativeChange: vi.fn(),
80
+ onProviderChange: vi.fn(),
81
+ });
82
+ beforeEach(() => {
83
+ vi.clearAllMocks();
84
+ mocks.query.mockReturnValue({ data: options });
85
+ });
86
+ it('keeps the legacy picker when capability is disabled', () => {
87
+ mocks.query.mockReturnValue({
88
+ data: { ...options, providerColorWrites: false },
89
+ });
90
+ render(<ProviderColorPicker {...props()} />);
91
+ expect(screen.getByText('Legacy palette')).toBeTruthy();
92
+ expect(screen.queryByRole('combobox')).toBeNull();
93
+ });
94
+ it('keeps another connection palette from supplying choices', () => {
95
+ mocks.query.mockReturnValue({
96
+ data: { ...options, connectionId: 'foreign' },
97
+ });
98
+ render(<ProviderColorPicker {...props()} />);
99
+ expect(screen.getByText('Legacy palette')).toBeTruthy();
100
+ });
101
+ it('sends exact provider namespace and ID for label and inherited choices', () => {
102
+ const input = props();
103
+ render(<ProviderColorPicker {...input} />);
104
+ fireEvent.change(screen.getByRole('combobox'), {
105
+ target: { value: 'label:label-id' },
106
+ });
107
+ expect(input.onProviderChange).toHaveBeenLastCalledWith({
108
+ connectionId: 'owned',
109
+ kind: 'label',
110
+ id: 'label-id',
111
+ });
112
+ fireEvent.change(screen.getByRole('combobox'), {
113
+ target: { value: 'inherit' },
114
+ });
115
+ expect(input.onProviderChange).toHaveBeenLastCalledWith({
116
+ connectionId: 'owned',
117
+ kind: 'inherit',
118
+ });
119
+ expect(input.onNativeChange).not.toHaveBeenCalled();
120
+ mocks.query.mock.calls[0]?.[0].queryFn();
121
+ expect(mocks.get).toHaveBeenCalledWith('workspace', 'owned');
122
+ });
123
+ it('initializes an imported label identity rather than the compatibility color', () => {
124
+ render(
125
+ <ProviderColorPicker
126
+ {...props()}
127
+ metadata={{
128
+ google_color: {
129
+ version: 1,
130
+ event_label_id: 'label-id',
131
+ inherited: false,
132
+ resolved_rgb: '#abcdef',
133
+ },
134
+ }}
135
+ />
136
+ );
137
+ expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
138
+ 'label:label-id'
139
+ );
140
+ });
141
+
142
+ it('keeps the legacy picker while query data is unavailable', () => {
143
+ mocks.query.mockReturnValue({ data: undefined });
144
+ render(<ProviderColorPicker {...props()} />);
145
+ expect(screen.getByText('Legacy palette')).toBeTruthy();
146
+ expect(screen.queryByRole('combobox')).toBeNull();
147
+ });
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import {
4
+ type CalendarSourceOption,
5
+ type GoogleProviderColorChoice,
6
+ getGoogleCalendarColorOptions,
7
+ } from '@tuturuuu/internal-api';
8
+ import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
9
+ import { Label } from '@tuturuuu/ui/label';
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@tuturuuu/ui/select';
17
+ import { readGoogleEventColor } from '@tuturuuu/utils/google-calendar-colors';
18
+ import { useTranslations } from 'next-intl';
19
+ import { EventColorPicker } from './event-form-components';
20
+
21
+ export function ProviderColorPicker({
22
+ wsId,
23
+ source,
24
+ value,
25
+ metadata,
26
+ choice,
27
+ onNativeChange,
28
+ onProviderChange,
29
+ }: {
30
+ wsId?: string;
31
+ source?: CalendarSourceOption;
32
+ value: SupportedColor;
33
+ metadata?: unknown;
34
+ choice?: GoogleProviderColorChoice;
35
+ onNativeChange: (value: SupportedColor) => void;
36
+ onProviderChange: (choice: GoogleProviderColorChoice) => void;
37
+ }) {
38
+ const t = useTranslations('calendar');
39
+ const connectionId =
40
+ source?.provider === 'google' ? source.connectionId : null;
41
+ const { data } = useQuery({
42
+ queryKey: ['google-calendar-color-options', wsId, connectionId],
43
+ enabled: !!wsId && !!connectionId,
44
+ queryFn: () => getGoogleCalendarColorOptions(wsId!, connectionId!),
45
+ });
46
+ if (
47
+ !connectionId ||
48
+ !data?.providerColorWrites ||
49
+ data.connectionId !== connectionId ||
50
+ !data.options.length
51
+ )
52
+ return <EventColorPicker value={value} onChange={onNativeChange} />;
53
+ const current = readGoogleEventColor(metadata);
54
+ const selected =
55
+ choice?.connectionId === connectionId
56
+ ? choice.kind === 'inherit'
57
+ ? 'inherit'
58
+ : `${choice.kind}:${choice.id}`
59
+ : current?.inherited
60
+ ? 'inherit'
61
+ : current?.event_label_id
62
+ ? `label:${current.event_label_id}`
63
+ : current?.color_id
64
+ ? `event:${current.color_id}`
65
+ : undefined;
66
+ return (
67
+ <div className="space-y-1">
68
+ <Label>{t('provider_color_label')}</Label>
69
+ <Select
70
+ value={selected}
71
+ onValueChange={(key) => {
72
+ const option = data.options.find(
73
+ (item) =>
74
+ (item.kind === 'inherit'
75
+ ? 'inherit'
76
+ : `${item.kind}:${item.id}`) === key
77
+ );
78
+ if (option)
79
+ onProviderChange(
80
+ option.kind === 'inherit'
81
+ ? { connectionId, kind: 'inherit' }
82
+ : { connectionId, kind: option.kind, id: option.id! }
83
+ );
84
+ }}
85
+ >
86
+ <SelectTrigger aria-label={t('provider_color_label')}>
87
+ <SelectValue placeholder={t('provider_color_label')} />
88
+ </SelectTrigger>
89
+ <SelectContent>
90
+ {data.options.map((option) => (
91
+ <SelectItem
92
+ key={`${option.kind}:${option.id}`}
93
+ value={
94
+ option.kind === 'inherit'
95
+ ? 'inherit'
96
+ : `${option.kind}:${option.id}`
97
+ }
98
+ >
99
+ <span className="flex items-center gap-2">
100
+ <span
101
+ className="size-3 rounded-full"
102
+ style={{ backgroundColor: option.background }}
103
+ />
104
+ {option.kind === 'inherit'
105
+ ? t('inherit_calendar_color')
106
+ : (option.name ??
107
+ t('google_event_color', { id: option.id ?? '' }))}
108
+ </span>
109
+ </SelectItem>
110
+ ))}
111
+ </SelectContent>
112
+ </Select>
113
+ </div>
114
+ );
115
+ }
@@ -1,6 +1,13 @@
1
1
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
2
  import { expect, it, vi } from 'vitest';
3
- import { saveCalendarEventDrafts } from './save-calendar-event-drafts';
3
+ import {
4
+ createCalendarCreationRequests,
5
+ newCalendarCreationRequestId,
6
+ } from '../../../../hooks/calendar-creation-request';
7
+ import {
8
+ prepareCalendarEventDrafts,
9
+ saveCalendarEventDrafts,
10
+ } from './save-calendar-event-drafts';
4
11
 
5
12
  it('retains rejected or unconfirmed drafts and retries only those events', async () => {
6
13
  const drafts = [
@@ -23,3 +30,30 @@ it('retains rejected or unconfirmed drafts and retries only those events', async
23
30
  'Unconfirmed',
24
31
  ]);
25
32
  });
33
+
34
+ it('retains distinct AI draft IDs and immutable payload across a lost-response batch retry', async () => {
35
+ const controller = createCalendarCreationRequests();
36
+ const manualId = newCalendarCreationRequestId();
37
+ controller.forAttempt({ title: 'Manual' }, manualId);
38
+ const drafts = prepareCalendarEventDrafts(
39
+ ['First AI', 'Second AI'].map((title) => ({ title }))
40
+ );
41
+ const attempts: { id: string; event: unknown }[] = [];
42
+ const addEvent = vi.fn(async (event, options) => {
43
+ attempts.push({
44
+ id: controller.forAttempt(event, options?.requestId),
45
+ event,
46
+ });
47
+ if (attempts.length <= 2) throw new Error('lost response');
48
+ return { id: 'confirmed' } as CalendarEvent;
49
+ });
50
+ const failed = await saveCalendarEventDrafts(drafts, addEvent, undefined);
51
+ expect(failed.failedEvents).toEqual(drafts);
52
+ await saveCalendarEventDrafts(failed.failedEvents, addEvent, undefined);
53
+ expect(new Set([manualId, attempts[0]!.id, attempts[1]!.id]).size).toBe(3);
54
+ expect(attempts[2]).toEqual(attempts[0]);
55
+ expect(attempts[3]).toEqual(attempts[1]);
56
+ expect(drafts.map((d) => d.requestId)).toEqual(
57
+ attempts.slice(0, 2).map((a) => a.id)
58
+ );
59
+ });
@@ -1,10 +1,12 @@
1
1
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { newCalendarCreationRequestId } from '../../../../hooks/calendar-creation-request';
2
3
 
3
4
  /** Keep only unconfirmed drafts after a batch so retry never resends saved events. */
4
5
  export async function saveCalendarEventDrafts(
5
6
  drafts: Partial<CalendarEvent>[],
6
7
  addEvent: (
7
- event: Omit<CalendarEvent, 'id'>
8
+ event: Omit<CalendarEvent, 'id'>,
9
+ options?: { requestId?: string }
8
10
  ) => Promise<CalendarEvent | undefined>,
9
11
  source: CalendarEvent['source']
10
12
  ) {
@@ -12,16 +14,19 @@ export async function saveCalendarEventDrafts(
12
14
  const failedEvents: Partial<CalendarEvent>[] = [];
13
15
  for (const draft of drafts) {
14
16
  try {
15
- const saved = await addEvent({
16
- title: draft.title || 'New Event',
17
- description: draft.description || '',
18
- start_at: draft.start_at || '',
19
- end_at: draft.end_at || '',
20
- color: draft.color || 'BLUE',
21
- location: draft.location || '',
22
- locked: draft.locked || false,
23
- source,
24
- });
17
+ const saved = await addEvent(
18
+ {
19
+ title: draft.title || 'New Event',
20
+ description: draft.description || '',
21
+ start_at: draft.start_at || '',
22
+ end_at: draft.end_at || '',
23
+ color: draft.color || 'BLUE',
24
+ location: draft.location || '',
25
+ locked: draft.locked || false,
26
+ source,
27
+ },
28
+ { requestId: draft.requestId }
29
+ );
25
30
  if (saved?.id) savedEvents.push(saved);
26
31
  else failedEvents.push(draft);
27
32
  } catch {
@@ -30,3 +35,10 @@ export async function saveCalendarEventDrafts(
30
35
  }
31
36
  return { savedEvents, failedEvents };
32
37
  }
38
+
39
+ export function prepareCalendarEventDrafts(drafts: Partial<CalendarEvent>[]) {
40
+ return drafts.map((draft) => ({
41
+ ...draft,
42
+ requestId: newCalendarCreationRequestId(),
43
+ }));
44
+ }
@@ -1,42 +1,23 @@
1
1
  'use client';
2
2
 
3
- import { useQuery } from '@tanstack/react-query';
4
3
  import { Calendar, Download, RefreshCw } from '@tuturuuu/icons';
5
- import type { Workspace } from '@tuturuuu/types';
6
4
  import { Button } from '@tuturuuu/ui/button';
7
5
  import { AnalyticsCharts } from '@tuturuuu/ui/legacy/calendar/settings/analytics-charts';
8
6
  import { SummaryCards } from '@tuturuuu/ui/legacy/calendar/settings/summary-cards';
9
7
  import { SyncLogsTable } from '@tuturuuu/ui/legacy/calendar/settings/sync-logs-table';
10
8
  import { useMemo, useState } from 'react';
9
+ import { useVisibleWorkspaces } from '../../../../../hooks/use-visible-workspaces';
11
10
  import type { SyncLog } from './types';
12
11
 
13
- const getWorkspaces = async () => {
14
- const workspaces = await fetch('/api/workspaces');
15
- if (!workspaces.ok) {
16
- throw new Error(`Failed to fetch workspaces: ${workspaces.status}`);
17
- }
18
- return workspaces.json();
19
- };
20
-
21
12
  export function CalendarSyncDashboard({ syncLogs }: { syncLogs: SyncLog[] }) {
22
13
  const [filterType, setFilterType] = useState('all');
23
14
  const [filterWorkspace, setFilterWorkspace] = useState('all');
24
15
  const [searchTerm, setSearchTerm] = useState('');
25
16
 
26
- // Get workspaces from API
27
- const workspacesQuery = useQuery({
28
- queryKey: ['workspaces'],
29
- queryFn: () => getWorkspaces(),
30
- refetchInterval: 1000 * 60 * 5, // 5 minutes
17
+ const workspacesQuery = useVisibleWorkspaces(true, undefined, {
18
+ refetchInterval: 300_000,
31
19
  });
32
-
33
- // Fallback to mock workspaces if API fails
34
- const workspaces: Workspace[] = workspacesQuery.data || [
35
- { id: 'ws_1', name: 'Marketing Team' },
36
- { id: 'ws_2', name: 'Engineering' },
37
- { id: 'ws_3', name: 'Sales Department' },
38
- { id: 'ws_4', name: 'Executive Team' },
39
- ];
20
+ const workspaces = workspacesQuery.data ?? [];
40
21
 
41
22
  const filteredLogs = useMemo(() => {
42
23
  return syncLogs.filter((log) => {
@@ -44,7 +44,7 @@ import type { SyncLog } from './types';
44
44
 
45
45
  interface SyncLogsTableProps {
46
46
  syncLogs: SyncLog[];
47
- workspaces: Workspace[];
47
+ workspaces: Pick<Workspace, 'id' | 'name'>[];
48
48
  filterType: string;
49
49
  filterWorkspace: string;
50
50
  searchTerm: string;
@@ -221,11 +221,8 @@ export function SyncLogsTable({
221
221
  </SelectTrigger>
222
222
  <SelectContent>
223
223
  <SelectItem value="all">All Workspaces</SelectItem>
224
- {workspaces.map((workspace, index) => (
225
- <SelectItem
226
- key={`${workspace.id}-${index}`}
227
- value={workspace.id}
228
- >
224
+ {workspaces.map((workspace) => (
225
+ <SelectItem key={workspace.id} value={workspace.id}>
229
226
  <div className="flex items-center gap-2">
230
227
  <div className="h-2 w-2 rounded-full bg-blue-500" />
231
228
  {workspace.name}
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { expect, it, vi } from 'vitest';
4
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
5
+ import { useCalendarDraftSave } from './use-calendar-draft-save';
6
+
7
+ const query = vi.hoisted(() => ({
8
+ data: { connectionId: 'owned', providerColorWrites: true },
9
+ }));
10
+ vi.mock('@tanstack/react-query', () => ({ useQuery: () => query }));
11
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
12
+ vi.mock('@tuturuuu/ui/button', () => ({
13
+ Button: (props: any) => <button {...props} />,
14
+ }));
15
+
16
+ function fixture() {
17
+ query.data = { connectionId: 'owned', providerColorWrites: true };
18
+ const draft = {
19
+ requestId: '01900000-0000-7000-8000-000000000001',
20
+ ws_id: 'workspace',
21
+ title: 'Original',
22
+ };
23
+ const args = {
24
+ draft,
25
+ wsId: 'workspace',
26
+ source: { provider: 'google' as const, connectionId: 'owned' } as any,
27
+ original: { id: 'new' } as CalendarEvent,
28
+ buildPayload: () => ({ title: args.draft.title }),
29
+ addEvent: vi.fn().mockRejectedValueOnce(new Error('lost response')),
30
+ updateEvent: vi.fn().mockResolvedValue(undefined),
31
+ closeModal: vi.fn(),
32
+ setIsSaving: vi.fn(),
33
+ onError: vi.fn(),
34
+ isSaving: false,
35
+ };
36
+ return args;
37
+ }
38
+
39
+ it('retries immutable uncertain contents and ID, preserves edits, then explicitly updates the confirmed ID', async () => {
40
+ const args = fixture();
41
+ const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
42
+ await act(() => result.current.save());
43
+ expect(result.current.recoveryNotice).not.toBeNull();
44
+ args.draft = { ...args.draft, title: 'Edited after uncertainty' };
45
+ rerender();
46
+ await act(() => result.current.save());
47
+ expect(args.addEvent).toHaveBeenCalledTimes(1);
48
+ args.addEvent.mockResolvedValueOnce({ id: 'confirmed', title: 'Original' });
49
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
50
+ .onClick;
51
+ await act(() => retry());
52
+ expect(args.addEvent).toHaveBeenLastCalledWith(
53
+ { title: 'Original' },
54
+ { requestId: args.draft.requestId }
55
+ );
56
+ expect(args.closeModal).not.toHaveBeenCalled();
57
+ expect(args.draft.title).toBe('Edited after uncertainty');
58
+ await act(() => result.current.save());
59
+ expect(args.addEvent).toHaveBeenCalledTimes(2);
60
+ expect(args.updateEvent).toHaveBeenCalledWith('confirmed', {
61
+ title: 'Edited after uncertainty',
62
+ });
63
+ expect(args.closeModal).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('keeps failed recovery uncertain without dispatching edited intent', async () => {
67
+ const args = fixture();
68
+ const { result } = renderHook(() => useCalendarDraftSave(args));
69
+ await act(() => result.current.save());
70
+ args.addEvent.mockRejectedValueOnce(new Error('still uncertain'));
71
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
72
+ .onClick;
73
+ await act(() => retry());
74
+ expect(result.current.recoveryNotice).not.toBeNull();
75
+ expect(args.updateEvent).not.toHaveBeenCalled();
76
+ expect(args.closeModal).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('does not close or recover another draft after a stale retry response', async () => {
80
+ const args = fixture();
81
+ const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
82
+ await act(() => result.current.save());
83
+ let resolve!: (event: unknown) => void;
84
+ args.addEvent.mockReturnValueOnce(
85
+ new Promise((r) => {
86
+ resolve = r;
87
+ })
88
+ );
89
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
90
+ .onClick;
91
+ let pending!: Promise<void>;
92
+ act(() => {
93
+ pending = retry();
94
+ });
95
+ args.draft = {
96
+ ...args.draft,
97
+ requestId: 'another-draft',
98
+ title: 'New draft',
99
+ };
100
+ rerender();
101
+ await act(async () => {
102
+ resolve({ id: 'old-confirmed', title: 'Original' });
103
+ await pending;
104
+ });
105
+ expect(args.closeModal).not.toHaveBeenCalled();
106
+ expect(result.current.recoveryNotice).toBeNull();
107
+ expect(args.updateEvent).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('keeps legacy unconfirmed creates out of automatic retry', async () => {
111
+ const args = fixture();
112
+ query.data.providerColorWrites = false;
113
+ const { result } = renderHook(() => useCalendarDraftSave(args));
114
+ await act(() => result.current.save());
115
+ expect(result.current.recoveryNotice!.props.children[1]).toBe(false);
116
+ await act(() => result.current.save());
117
+ expect(args.addEvent).toHaveBeenCalledTimes(1);
118
+ expect(args.updateEvent).not.toHaveBeenCalled();
119
+ });