@tuturuuu/ui 0.37.2 → 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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +9 -6
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  11. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  12. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  13. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  14. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  15. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  16. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  17. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  18. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  19. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  20. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  21. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  22. package/src/components/ui/popover.tsx +3 -1
  23. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  24. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  25. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  26. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  27. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  28. package/src/components/ui/text-editor/editor.tsx +8 -3
  29. package/src/components/ui/text-editor/extensions.ts +8 -2
  30. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  31. package/src/components/ui/text-editor/markdown.ts +11 -0
  32. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  33. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  34. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  35. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  36. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  37. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  38. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  39. package/src/hooks/calendar-creation-request.test.ts +62 -0
  40. package/src/hooks/calendar-creation-request.ts +34 -0
  41. package/src/hooks/calendar-event-write-payload.ts +24 -0
  42. package/src/hooks/notification-subscription.ts +5 -2
  43. package/src/hooks/supabase-provider.ts +38 -72
  44. package/src/hooks/use-calendar.tsx +21 -45
  45. package/src/hooks/use-exchange-rates.ts +4 -15
  46. package/src/hooks/use-workspace-presence.ts +9 -9
  47. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -46,9 +46,7 @@ import { ToolbarButton, ToolbarSeparator } from './toolbar-controls';
46
46
 
47
47
  type LinkEditorContext = 'bubble' | 'popover' | null;
48
48
 
49
- // ---------------------------------------------------------------------------
50
49
  // Main ToolBar (BubbleMenu)
51
- // ---------------------------------------------------------------------------
52
50
 
53
51
  interface ToolBarProps {
54
52
  editor: Editor | null;
@@ -59,12 +57,14 @@ interface ToolBarProps {
59
57
  onConvertToTask?: () => void | Promise<void>;
60
58
  /** When true, the BubbleMenu is hidden because the fixed toolbar is visible */
61
59
  fixedToolbarVisible?: boolean;
60
+ allowEmbeds?: boolean;
62
61
  toggleBlockLabel?: string;
63
62
  }
64
63
 
65
64
  export function ToolBar({
66
65
  editor,
67
66
  fixedToolbarVisible,
67
+ allowEmbeds = true,
68
68
  workspaceId,
69
69
  onImageUpload,
70
70
  onConvertToTask,
@@ -459,9 +459,7 @@ export function ToolBar({
459
459
  toast.success('YouTube video added');
460
460
  }, [editor, youtubeUrl]);
461
461
 
462
- // -----------------------------------------------------------------------
463
462
  // Render helpers
464
- // -----------------------------------------------------------------------
465
463
 
466
464
  /** Renders the core formatting buttons (used by both fixed and bubble toolbar) */
467
465
  const renderFormattingOptions = useCallback(
@@ -478,11 +476,9 @@ export function ToolBar({
478
476
  />
479
477
  ))}
480
478
 
481
- {/* Color controls */}
482
479
  <ToolbarSeparator />
483
480
  <TextEditorColorControls editor={editor} />
484
481
 
485
- {/* Link */}
486
482
  <ToolbarSeparator />
487
483
  <ToolbarButton
488
484
  id="link"
@@ -491,8 +487,7 @@ export function ToolBar({
491
487
  onClick={() => openLinkEditor(source)}
492
488
  />
493
489
 
494
- {/* Media uploads */}
495
- {workspaceId && onImageUpload && (
490
+ {onImageUpload && (
496
491
  <>
497
492
  <ToolbarSeparator />
498
493
  <ToolbarButton
@@ -508,30 +503,33 @@ export function ToolBar({
508
503
  onClick={triggerImageUpload}
509
504
  disabled={isUploadingImage}
510
505
  />
511
- <ToolbarButton
512
- id="video"
513
- icon={
514
- isUploadingVideo ? (
515
- <Loader2 className="size-4 animate-spin" />
516
- ) : (
517
- <FileVideo className="size-4" />
518
- )
519
- }
520
- pressed={false}
521
- onClick={triggerVideoUpload}
522
- disabled={isUploadingVideo}
523
- />
506
+ {workspaceId && (
507
+ <ToolbarButton
508
+ id="video"
509
+ icon={
510
+ isUploadingVideo ? (
511
+ <Loader2 className="size-4 animate-spin" />
512
+ ) : (
513
+ <FileVideo className="size-4" />
514
+ )
515
+ }
516
+ pressed={false}
517
+ onClick={triggerVideoUpload}
518
+ disabled={isUploadingVideo}
519
+ />
520
+ )}
524
521
  </>
525
522
  )}
526
523
 
527
- <ToolbarButton
528
- id="youtube"
529
- icon={<YoutubeIcon className="size-4" />}
530
- pressed={showYoutubeInput}
531
- onClick={() => setShowYoutubeInput(!showYoutubeInput)}
532
- />
524
+ {allowEmbeds && (
525
+ <ToolbarButton
526
+ id="youtube"
527
+ icon={<YoutubeIcon className="size-4" />}
528
+ pressed={showYoutubeInput}
529
+ onClick={() => setShowYoutubeInput(!showYoutubeInput)}
530
+ />
531
+ )}
533
532
 
534
- {/* Convert to task */}
535
533
  {onConvertToTask && (
536
534
  <>
537
535
  <ToolbarSeparator />
@@ -557,6 +555,7 @@ export function ToolBar({
557
555
  triggerVideoUpload,
558
556
  isUploadingVideo,
559
557
  showYoutubeInput,
558
+ allowEmbeds,
560
559
  onConvertToTask,
561
560
  toggleBlockLabel,
562
561
  ]
@@ -1133,8 +1132,7 @@ export function FixedToolbar({
1133
1132
  <ToolbarSeparator />
1134
1133
  <TextEditorColorControls editor={editor} />
1135
1134
 
1136
- {/* Media group */}
1137
- {workspaceId && onImageUpload && (
1135
+ {onImageUpload && (
1138
1136
  <>
1139
1137
  <ToolbarSeparator />
1140
1138
  <ToolbarButton
@@ -1150,23 +1148,24 @@ export function FixedToolbar({
1150
1148
  onClick={() => fileInputRef.current?.click()}
1151
1149
  disabled={isUploadingImage}
1152
1150
  />
1153
- <ToolbarButton
1154
- id="video"
1155
- icon={
1156
- isUploadingVideo ? (
1157
- <Loader2 className="size-4 animate-spin" />
1158
- ) : (
1159
- <FileVideo className="size-4" />
1160
- )
1161
- }
1162
- pressed={false}
1163
- onClick={() => videoInputRef.current?.click()}
1164
- disabled={isUploadingVideo}
1165
- />
1151
+ {workspaceId && (
1152
+ <ToolbarButton
1153
+ id="video"
1154
+ icon={
1155
+ isUploadingVideo ? (
1156
+ <Loader2 className="size-4 animate-spin" />
1157
+ ) : (
1158
+ <FileVideo className="size-4" />
1159
+ )
1160
+ }
1161
+ pressed={false}
1162
+ onClick={() => videoInputRef.current?.click()}
1163
+ disabled={isUploadingVideo}
1164
+ />
1165
+ )}
1166
1166
  </>
1167
1167
  )}
1168
1168
 
1169
- {/* Convert to task */}
1170
1169
  {onConvertToTask && (
1171
1170
  <>
1172
1171
  <ToolbarSeparator />
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import * as awarenessProtocol from 'y-protocols/awareness';
3
+ import * as Y from 'yjs';
4
+ import SupabaseProvider from '../supabase-provider';
5
+
6
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
7
+ createRealtimeClient: () => {
8
+ const channel = {
9
+ on: () => channel,
10
+ subscribe: () => channel,
11
+ send: vi.fn(),
12
+ };
13
+ return { channel: () => channel, removeChannel: vi.fn() };
14
+ },
15
+ }));
16
+ afterEach(() => vi.useRealTimers());
17
+ describe('collaboration cursor batching', () => {
18
+ it('merges all changed client IDs and flushes during continuous updates', async () => {
19
+ vi.useFakeTimers();
20
+ const doc = new Y.Doc();
21
+ const provider = new SupabaseProvider(
22
+ doc,
23
+ {} as ConstructorParameters<typeof SupabaseProvider>[1],
24
+ {
25
+ channel: 'task-editor-fixture',
26
+ id: 'fixture',
27
+ tableName: 'tasks',
28
+ columnName: 'description_yjs_state',
29
+ loadState: async () => null,
30
+ saveState: async () => true,
31
+ resyncInterval: false,
32
+ }
33
+ );
34
+ const a = new Y.Doc();
35
+ const b = new Y.Doc();
36
+ const first = new awarenessProtocol.Awareness(a);
37
+ const second = new awarenessProtocol.Awareness(b);
38
+ first.setLocalState({ user: { id: 'a' }, cursor: 1 });
39
+ second.setLocalState({ user: { id: 'b' }, cursor: 2 });
40
+ const sent = vi.fn();
41
+ provider.on('awareness', sent);
42
+ awarenessProtocol.applyAwarenessUpdate(
43
+ provider.awareness,
44
+ awarenessProtocol.encodeAwarenessUpdate(first, [a.clientID]),
45
+ 'local-fixture'
46
+ );
47
+ await vi.advanceTimersByTimeAsync(100);
48
+ awarenessProtocol.applyAwarenessUpdate(
49
+ provider.awareness,
50
+ awarenessProtocol.encodeAwarenessUpdate(second, [b.clientID]),
51
+ 'local-fixture'
52
+ );
53
+ await vi.advanceTimersByTimeAsync(50);
54
+ expect(sent).toHaveBeenCalledOnce();
55
+ const receiverDoc = new Y.Doc();
56
+ const receiver = new awarenessProtocol.Awareness(receiverDoc);
57
+ awarenessProtocol.applyAwarenessUpdate(
58
+ receiver,
59
+ sent.mock.calls[0]![0],
60
+ 'fixture'
61
+ );
62
+ expect(receiver.getStates().get(a.clientID)).toMatchObject({ cursor: 1 });
63
+ expect(receiver.getStates().get(b.clientID)).toMatchObject({ cursor: 2 });
64
+ provider.destroy();
65
+ provider.awareness.destroy();
66
+ first.destroy();
67
+ second.destroy();
68
+ receiver.destroy();
69
+ for (const d of [doc, a, b, receiverDoc]) d.destroy();
70
+ });
71
+ it('does not rebroadcast peer awareness back to the room', async () => {
72
+ vi.useFakeTimers();
73
+ const doc = new Y.Doc();
74
+ const peerDoc = new Y.Doc();
75
+ const provider = new SupabaseProvider(
76
+ doc,
77
+ {} as ConstructorParameters<typeof SupabaseProvider>[1],
78
+ {
79
+ channel: 'task-editor-fixture',
80
+ id: 'fixture',
81
+ tableName: 'tasks',
82
+ columnName: 'description_yjs_state',
83
+ loadState: async () => null,
84
+ saveState: async () => true,
85
+ resyncInterval: false,
86
+ }
87
+ );
88
+ const peer = new awarenessProtocol.Awareness(peerDoc);
89
+ peer.setLocalState({ cursor: 4 });
90
+ const sent = vi.fn();
91
+ provider.on('awareness', sent);
92
+ provider.onAwareness(
93
+ awarenessProtocol.encodeAwarenessUpdate(peer, [peerDoc.clientID])
94
+ );
95
+ await vi.advanceTimersByTimeAsync(200);
96
+ expect(sent).not.toHaveBeenCalled();
97
+ expect(provider.awareness.getStates().get(peerDoc.clientID)).toMatchObject({
98
+ cursor: 4,
99
+ });
100
+ provider.destroy();
101
+ provider.awareness.destroy();
102
+ peer.destroy();
103
+ doc.destroy();
104
+ peerDoc.destroy();
105
+ });
106
+ });
@@ -5,6 +5,14 @@ import SupabaseProvider, {
5
5
  SUPABASE_PROVIDER_SYNC_ORIGIN,
6
6
  } from '../supabase-provider';
7
7
 
8
+ const realtimeMock = vi.hoisted(() => ({ channel: null as unknown }));
9
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
10
+ createRealtimeClient: () => ({
11
+ channel: () => realtimeMock.channel,
12
+ removeChannel: vi.fn(),
13
+ }),
14
+ }));
15
+
8
16
  function createRealtimeChannel() {
9
17
  let subscribeHandler:
10
18
  | ((status: string, err?: { message?: string }) => void)
@@ -22,6 +30,7 @@ function createRealtimeChannel() {
22
30
  },
23
31
  };
24
32
 
33
+ realtimeMock.channel = channel;
25
34
  return channel;
26
35
  }
27
36
 
@@ -294,10 +294,15 @@ describe('CalendarProvider Read-Only Mode', () => {
294
294
  await createPromise!;
295
295
  });
296
296
 
297
+ const optimisticId = calendarMockState.patchVisibleEvents.mock.calls.find(
298
+ ([, options]) => options?.status === 'creating'
299
+ )?.[0]?.[0]?.id;
300
+ expect(optimisticId).toMatch(/^optimistic[0-9a-f]{32}$/);
301
+
297
302
  expect(calendarMockState.patchVisibleEvents).toHaveBeenLastCalledWith(
298
303
  [serverEvent],
299
304
  expect.objectContaining({
300
- clearIds: [expect.stringMatching(/^optimistic-/)],
305
+ clearIds: [optimisticId],
301
306
  })
302
307
  );
303
308
  });
@@ -322,10 +327,15 @@ describe('CalendarProvider Read-Only Mode', () => {
322
327
  ).rejects.toThrow('create failed');
323
328
  });
324
329
 
330
+ const optimisticId = calendarMockState.patchVisibleEvents.mock.calls.find(
331
+ ([, options]) => options?.status === 'creating'
332
+ )?.[0]?.[0]?.id;
333
+ expect(optimisticId).toMatch(/^optimistic[0-9a-f]{32}$/);
334
+
325
335
  expect(calendarMockState.patchVisibleEvents).toHaveBeenLastCalledWith(
326
336
  [],
327
337
  expect.objectContaining({
328
- clearIds: [expect.stringMatching(/^optimistic-/)],
338
+ clearIds: [optimisticId],
329
339
  })
330
340
  );
331
341
  });
@@ -11,7 +11,7 @@ import {
11
11
  } from '../use-notifications';
12
12
 
13
13
  const createRealtimeClient = vi.hoisted(() => vi.fn());
14
- vi.mock('@tuturuuu/supabase/next/realtime-browser', () => ({
14
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
15
15
  createRealtimeClient,
16
16
  }));
17
17
 
@@ -31,6 +31,10 @@ describe('notification API refresh subscriptions', () => {
31
31
  configurable: true,
32
32
  value: 'visible',
33
33
  });
34
+ Object.defineProperty(navigator, 'onLine', {
35
+ configurable: true,
36
+ value: true,
37
+ });
34
38
  });
35
39
  afterEach(() => {
36
40
  __resetNotificationSubscriptionRegistryForTests();
@@ -51,6 +55,32 @@ describe('notification API refresh subscriptions', () => {
51
55
  client.clear();
52
56
  });
53
57
 
58
+ it('makes 30 shared refreshes per visible hour, pauses offline, and refreshes on reconnect', () => {
59
+ const client = new QueryClient();
60
+ const invalidate = vi.spyOn(client, 'invalidateQueries');
61
+ const hook = renderHook(() => useNotificationSubscription(null, 'user-1'), {
62
+ wrapper: wrapper(client),
63
+ });
64
+ act(() => vi.advanceTimersByTime(60 * 60_000));
65
+ expect(invalidate).toHaveBeenCalledTimes(30);
66
+ Object.defineProperty(navigator, 'onLine', {
67
+ configurable: true,
68
+ value: false,
69
+ });
70
+ act(() => vi.advanceTimersByTime(60 * 60_000));
71
+ expect(invalidate).toHaveBeenCalledTimes(30);
72
+ Object.defineProperty(navigator, 'onLine', {
73
+ configurable: true,
74
+ value: true,
75
+ });
76
+ act(() => window.dispatchEvent(new Event('online')));
77
+ expect(invalidate).toHaveBeenCalledTimes(31);
78
+ hook.unmount();
79
+ act(() => window.dispatchEvent(new Event('online')));
80
+ expect(invalidate).toHaveBeenCalledTimes(31);
81
+ client.clear();
82
+ });
83
+
54
84
  it('shares one refresh per user and query client without opening a denied realtime channel', () => {
55
85
  const client = new QueryClient();
56
86
  const invalidate = vi.spyOn(client, 'invalidateQueries');
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { act, renderHook, waitFor } from '@testing-library/react';
6
- import type { RealtimePresenceState } from '@tuturuuu/supabase/next/realtime';
6
+ import type { RealtimePresenceState } from '@tuturuuu/internal-api/realtime';
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import {
9
9
  type UseWorkspacePresenceResult,
@@ -16,8 +16,8 @@ const { createClientMock, getCurrentUserProfileMock } = vi.hoisted(() => ({
16
16
  getCurrentUserProfileMock: vi.fn(),
17
17
  }));
18
18
 
19
- vi.mock('@tuturuuu/supabase/next/client', () => ({
20
- createClient: createClientMock,
19
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
20
+ createRealtimeClient: createClientMock,
21
21
  }));
22
22
 
23
23
  vi.mock('@tuturuuu/internal-api/users', () => ({
@@ -0,0 +1,108 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { type ReactNode, StrictMode } from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { useYjsCollaboration } from '../use-yjs-collaboration';
5
+
6
+ const instances = vi.hoisted(
7
+ () =>
8
+ [] as {
9
+ id: number;
10
+ destroyed: boolean;
11
+ emit: (event: string, value: unknown) => void;
12
+ }[]
13
+ );
14
+ vi.mock('@tuturuuu/supabase/next/client', () => ({ createClient: () => ({}) }));
15
+ vi.mock('@tuturuuu/ui/hooks/supabase-provider', () => ({
16
+ default: class {
17
+ id: number;
18
+ destroyed = false;
19
+ awareness: { destroy: () => void };
20
+ private listeners = new Map<string, (value: unknown) => void>();
21
+ constructor(
22
+ private doc: { clientID: number; destroy: () => void },
23
+ _client: unknown,
24
+ config: { awareness: { destroy: () => void } }
25
+ ) {
26
+ this.id = doc.clientID;
27
+ this.awareness = config.awareness;
28
+ instances.push(this);
29
+ }
30
+ on(event: string, callback: (value: unknown) => void) {
31
+ this.listeners.set(event, callback);
32
+ }
33
+ emit(event: string, value: unknown) {
34
+ this.listeners.get(event)?.(value);
35
+ }
36
+ destroy() {
37
+ this.destroyed = true;
38
+ this.awareness.destroy();
39
+ this.doc.destroy();
40
+ }
41
+ },
42
+ }));
43
+ const user = { id: 'actor', name: 'Ada', color: '#123456' };
44
+ const config = {
45
+ channel: 'task-editor-one',
46
+ tableName: 'tasks',
47
+ columnName: 'description_yjs_state',
48
+ id: 'one',
49
+ user,
50
+ };
51
+ const wrapper = ({ children }: { children: ReactNode }) => (
52
+ <StrictMode>{children}</StrictMode>
53
+ );
54
+ describe('collaboration hook lifecycle', () => {
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ instances.length = 0;
58
+ });
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ });
62
+ it('does not start awareness before authentication and reuses StrictMode providers', async () => {
63
+ const { result, rerender, unmount } = renderHook(
64
+ (actor) => useYjsCollaboration({ ...config, user: actor }),
65
+ { wrapper, initialProps: null as typeof user | null }
66
+ );
67
+ expect(result.current.doc).toBeNull();
68
+ expect(result.current.awareness).toBeNull();
69
+ expect(instances).toHaveLength(0);
70
+ rerender(user);
71
+ expect(instances).toHaveLength(1);
72
+ const provider = result.current.provider;
73
+ expect(provider).not.toBeNull();
74
+ rerender({ ...user, name: 'Ada updated' });
75
+ expect(result.current.provider).toBe(provider);
76
+ expect(result.current.awareness?.getLocalState()?.user.name).toBe(
77
+ 'Ada updated'
78
+ );
79
+ unmount();
80
+ await act(() => vi.advanceTimersByTimeAsync(100));
81
+ expect(instances[0]?.destroyed).toBe(true);
82
+ expect(vi.getTimerCount()).toBe(0);
83
+ });
84
+ it('isolates account/resource switches and ignores late callbacks from old providers', async () => {
85
+ const { result, rerender, unmount } = renderHook(
86
+ (id) =>
87
+ useYjsCollaboration({ ...config, id, channel: `task-editor-${id}` }),
88
+ { wrapper, initialProps: 'one' }
89
+ );
90
+ const first = instances[0]!;
91
+ const firstDoc = result.current.doc;
92
+ rerender('two');
93
+ expect(instances).toHaveLength(2);
94
+ expect(first.destroyed).toBe(true);
95
+ expect(result.current.doc).not.toBe(firstDoc);
96
+ const second = instances[1]!;
97
+ act(() => second.emit('status', [{ status: 'connected' }]));
98
+ expect(result.current.connected).toBe(true);
99
+ act(() => first.emit('disconnect', []));
100
+ expect(result.current.connected).toBe(true);
101
+ act(() => first.emit('synced', [true]));
102
+ expect(result.current.synced).toBe(false);
103
+ unmount();
104
+ await act(() => vi.advanceTimersByTimeAsync(100));
105
+ expect(second.destroyed).toBe(true);
106
+ expect(vi.getTimerCount()).toBe(0);
107
+ });
108
+ });
@@ -0,0 +1,62 @@
1
+ import { validate, version } from 'uuid';
2
+ import { expect, it } from 'vitest';
3
+ import {
4
+ createCalendarCreationRequests,
5
+ createOptimisticEventId,
6
+ newCalendarCreationRequestId,
7
+ } from './calendar-creation-request';
8
+
9
+ it('keeps explicit draft IDs on lost responses and separates manual and AI drafts', () => {
10
+ const ids = createCalendarCreationRequests();
11
+ const manual = newCalendarCreationRequestId();
12
+ const ai = [newCalendarCreationRequestId(), newCalendarCreationRequestId()];
13
+ expect(new Set([manual, ...ai]).size).toBe(3);
14
+ for (const [index, id] of [manual, ...ai].entries()) {
15
+ expect(validate(id)).toBe(true);
16
+ expect(version(id)).toBe(7);
17
+ const payload = {
18
+ title: `draft ${index}`,
19
+ source: { provider: 'google', connectionId: 'owned' },
20
+ };
21
+ expect(ids.forAttempt(payload, id)).toBe(id);
22
+ expect(ids.forAttempt({ ...payload }, id)).toBe(id);
23
+ }
24
+ });
25
+ it('rejects changed intent for an uncertain draft before dispatch', () => {
26
+ const ids = createCalendarCreationRequests();
27
+ const id = newCalendarCreationRequestId();
28
+ ids.forAttempt({ title: 'Original' }, id);
29
+ expect(() => ids.forAttempt({ title: 'Edited' }, id)).toThrow(
30
+ 'draft changed'
31
+ );
32
+ expect(
33
+ ids.forAttempt({ title: 'Edited' }, newCalendarCreationRequestId())
34
+ ).not.toBe(id);
35
+ });
36
+ it('allocates a new independent intent per call unless the caller supplies a retry ID', () => {
37
+ const ids = createCalendarCreationRequests();
38
+ const event = { title: 'Same independent contents' };
39
+ expect(ids.forAttempt(event)).not.toBe(ids.forAttempt(event));
40
+ });
41
+
42
+ it('accepts the same serialized intent when object key order changes', () => {
43
+ const ids = createCalendarCreationRequests();
44
+ const id = newCalendarCreationRequestId();
45
+ ids.forAttempt(
46
+ { title: 'Same', source: { provider: 'google', connectionId: 'owned' } },
47
+ id
48
+ );
49
+ expect(
50
+ ids.forAttempt(
51
+ { source: { connectionId: 'owned', provider: 'google' }, title: 'Same' },
52
+ id
53
+ )
54
+ ).toBe(id);
55
+ });
56
+
57
+ it('keeps optimistic identifiers separate from multi-day segment delimiters', () => {
58
+ const id = createOptimisticEventId();
59
+ expect(id).toMatch(/^optimistic[a-zA-Z0-9]+$/);
60
+ expect(id).not.toContain('-');
61
+ expect(createOptimisticEventId()).not.toBe(id);
62
+ });
@@ -0,0 +1,34 @@
1
+ import { v7 } from 'uuid';
2
+
3
+ export const newCalendarCreationRequestId = () => v7();
4
+ /** Explicit draft IDs survive retries. Independent calls receive separate IDs;
5
+ * changed intent after an uncertain response must start an explicit new draft. */
6
+ export function createCalendarCreationRequests() {
7
+ const intents = new Map<string, string>();
8
+ return {
9
+ forAttempt(event: object, requestId?: string) {
10
+ const id = requestId ?? v7();
11
+ const intent = JSON.stringify(event, (_key, value) =>
12
+ value && typeof value === 'object' && !Array.isArray(value)
13
+ ? Object.fromEntries(
14
+ Object.keys(value)
15
+ .sort()
16
+ .map((key) => [key, value[key]])
17
+ )
18
+ : value
19
+ );
20
+ const previous = intents.get(id);
21
+ if (previous !== undefined && previous !== intent)
22
+ throw new Error(
23
+ 'This calendar draft changed after submission; recover it before starting a new draft'
24
+ );
25
+ if (requestId) intents.set(id, intent);
26
+ return id;
27
+ },
28
+ };
29
+ }
30
+ export const createOptimisticEventId = () => {
31
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
32
+ return `optimistic${crypto.randomUUID().replaceAll('-', '')}`;
33
+ return `optimistic${Date.now()}${Math.random().toString(36).slice(2)}`;
34
+ };
@@ -0,0 +1,24 @@
1
+ import type { WorkspaceCalendarEventUpdatePayload } from '@tuturuuu/internal-api';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+
4
+ export function calendarEventUpdatePayload(
5
+ input: Partial<CalendarEvent>
6
+ ): WorkspaceCalendarEventUpdatePayload {
7
+ const payload: WorkspaceCalendarEventUpdatePayload = {};
8
+ for (const field of [
9
+ 'title',
10
+ 'description',
11
+ 'location',
12
+ 'start_at',
13
+ 'end_at',
14
+ 'locked',
15
+ 'source',
16
+ 'providerColor',
17
+ ] as const) {
18
+ if (input[field] !== undefined)
19
+ Object.assign(payload, { [field]: input[field] });
20
+ }
21
+ if (!input.providerColor && input.color !== undefined)
22
+ payload.color = input.color;
23
+ return payload;
24
+ }
@@ -1,7 +1,7 @@
1
1
  import { type QueryClient, useQueryClient } from '@tanstack/react-query';
2
2
  import { useEffect } from 'react';
3
3
 
4
- export const NOTIFICATION_REFRESH_INTERVAL_MS = 30_000;
4
+ export const NOTIFICATION_REFRESH_INTERVAL_MS = 120_000;
5
5
 
6
6
  interface NotificationSubscriptionEntry {
7
7
  queryClients: Map<QueryClient, number>;
@@ -12,7 +12,8 @@ interface NotificationSubscriptionEntry {
12
12
  const registry = new Map<string, NotificationSubscriptionEntry>();
13
13
 
14
14
  function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
15
- if (document.visibilityState === 'hidden') return;
15
+ if (document.visibilityState === 'hidden' || navigator.onLine === false)
16
+ return;
16
17
  for (const queryClient of entry.queryClients.keys()) {
17
18
  // Only active queries refetch; closed inboxes remain lazy.
18
19
  void queryClient.invalidateQueries({ queryKey: ['notifications'] });
@@ -22,6 +23,7 @@ function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
22
23
  function stopEntry(entry: NotificationSubscriptionEntry) {
23
24
  clearInterval(entry.interval);
24
25
  document.removeEventListener('visibilitychange', entry.onVisibilityChange);
26
+ window.removeEventListener('online', entry.onVisibilityChange);
25
27
  }
26
28
 
27
29
  /**
@@ -50,6 +52,7 @@ export function useNotificationSubscription(
50
52
  };
51
53
  registry.set(userId, entry);
52
54
  document.addEventListener('visibilitychange', refresh);
55
+ window.addEventListener('online', refresh);
53
56
  }
54
57
  entry.queryClients.set(
55
58
  queryClient,