@tuturuuu/ui 0.37.2 → 0.39.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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  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/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -35,20 +35,18 @@ import { toast } from '@tuturuuu/ui/sonner';
35
35
  import { cn } from '@tuturuuu/utils/format';
36
36
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
37
37
  import { TextEditorColorControls } from './color-controls';
38
- import { type EditorCopyLabels, EditorCopyMenu } from './copy-menu';
38
+ import type { EditorCopyLabels } from './copy-menu';
39
+ import { FixedToolbarContents } from './fixed-toolbar-contents';
39
40
  import {
40
41
  MAX_IMAGE_SIZE,
41
42
  MAX_VIDEO_SIZE,
42
43
  StorageQuotaError,
43
44
  } from './media-utils';
44
- import { TOOLBAR_GROUPS } from './toolbar-config';
45
45
  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
  ]
@@ -843,6 +842,7 @@ export function ToolBar({
843
842
  }
844
843
 
845
844
  interface FixedToolbarProps {
845
+ toolsLabel?: string;
846
846
  editor: Editor | null;
847
847
  leadingContent?: React.ReactNode;
848
848
  workspaceId?: string;
@@ -854,6 +854,7 @@ interface FixedToolbarProps {
854
854
  copyLabels?: EditorCopyLabels;
855
855
  }
856
856
  export function FixedToolbar({
857
+ toolsLabel,
857
858
  editor,
858
859
  leadingContent,
859
860
  workspaceId,
@@ -1109,78 +1110,20 @@ export function FixedToolbar({
1109
1110
  <ToolbarSeparator />
1110
1111
  </>
1111
1112
  ) : null}
1112
- {/* Grouped formatting options with separators */}
1113
- {TOOLBAR_GROUPS.map((group, gi) => (
1114
- <div key={gi} className="contents">
1115
- {gi > 0 && <ToolbarSeparator />}
1116
- {group.map((key) => {
1117
- const opt = formattingOptions.get(key);
1118
- if (!opt) return null;
1119
- return (
1120
- <ToolbarButton
1121
- key={key}
1122
- id={key}
1123
- label={key === 'toggle-block' ? toggleBlockLabel : undefined}
1124
- icon={opt.icon}
1125
- pressed={opt.pressed}
1126
- onClick={opt.onClick}
1127
- />
1128
- );
1129
- })}
1130
- </div>
1131
- ))}
1132
-
1133
- <ToolbarSeparator />
1134
- <TextEditorColorControls editor={editor} />
1135
-
1136
- {/* Media group */}
1137
- {workspaceId && onImageUpload && (
1138
- <>
1139
- <ToolbarSeparator />
1140
- <ToolbarButton
1141
- id="image"
1142
- icon={
1143
- isUploadingImage ? (
1144
- <Loader2 className="size-4 animate-spin" />
1145
- ) : (
1146
- <ImageIcon className="size-4" />
1147
- )
1148
- }
1149
- pressed={false}
1150
- onClick={() => fileInputRef.current?.click()}
1151
- disabled={isUploadingImage}
1152
- />
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
- />
1166
- </>
1167
- )}
1168
-
1169
- {/* Convert to task */}
1170
- {onConvertToTask && (
1171
- <>
1172
- <ToolbarSeparator />
1173
- <ToolbarButton
1174
- id="convert-to-task"
1175
- icon={<CirclePlus className="size-4" />}
1176
- pressed={false}
1177
- onClick={onConvertToTask}
1178
- />
1179
- </>
1180
- )}
1181
-
1182
- <ToolbarSeparator />
1183
- <EditorCopyMenu editor={editor} labels={copyLabels} />
1113
+ <FixedToolbarContents
1114
+ editor={editor}
1115
+ options={formattingOptions}
1116
+ toolsLabel={toolsLabel}
1117
+ toggleBlockLabel={toggleBlockLabel}
1118
+ copyLabels={copyLabels}
1119
+ imageUpload={!!onImageUpload}
1120
+ videoUpload={!!workspaceId}
1121
+ uploadingImage={isUploadingImage}
1122
+ uploadingVideo={isUploadingVideo}
1123
+ onImage={() => fileInputRef.current?.click()}
1124
+ onVideo={() => videoInputRef.current?.click()}
1125
+ onConvertToTask={onConvertToTask}
1126
+ />
1184
1127
  </div>
1185
1128
  );
1186
1129
  }
@@ -0,0 +1,40 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { assertOrdinaryCalendarEvent } from '../calendar-event-write-payload';
4
+
5
+ const event = {
6
+ id: 'instance',
7
+ start_at: '2026-10-06T09:00:00Z',
8
+ end_at: '2026-10-06T10:00:00Z',
9
+ _originalId: 'original',
10
+ scheduling_metadata: {
11
+ provider_recurrence: {
12
+ state: 'unsupported',
13
+ provider: 'google',
14
+ master_id: 'master',
15
+ },
16
+ },
17
+ } as CalendarEvent;
18
+ describe('provider readonly ordinary mutation fence', () => {
19
+ it.each(['instance', 'original'])(
20
+ 'blocks updates/deletes by %s identity even if user unlocks the local event',
21
+ (id) =>
22
+ expect(() =>
23
+ assertOrdinaryCalendarEvent([{ ...event, locked: false }], id)
24
+ ).toThrow('read only')
25
+ );
26
+ it('permits an ordinary independently locked event', () =>
27
+ expect(() =>
28
+ assertOrdinaryCalendarEvent(
29
+ [{ id: 'ordinary', locked: true } as CalendarEvent],
30
+ 'ordinary'
31
+ )
32
+ ).not.toThrow());
33
+ it('retains the scoped native occurrence fence', () =>
34
+ expect(() =>
35
+ assertOrdinaryCalendarEvent(
36
+ [{ id: 'native', seriesId: 'series' } as CalendarEvent],
37
+ 'native'
38
+ )
39
+ ).toThrow('explicit series edit scope'));
40
+ });
@@ -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
+ });
@@ -0,0 +1,45 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ assertOrdinaryCalendarEvent,
5
+ calendarEventByIdentity,
6
+ cleanCalendarEventUpdates,
7
+ } from '../calendar-event-write-payload';
8
+
9
+ const virtual: CalendarEvent = {
10
+ id: '123e4567-e89b-42d3-a456-426614174000',
11
+ seriesId: 'series',
12
+ originalStartLocal: '2026-10-05T09:00:00',
13
+ start_at: '2026-10-05T09:00:00Z',
14
+ end_at: '2026-10-05T10:00:00Z',
15
+ };
16
+ describe('native virtual mutation fence', () => {
17
+ it('resolves full UUID identities without truncating at the first dash', () => {
18
+ expect(calendarEventByIdentity([virtual], virtual.id)).toBe(virtual);
19
+ });
20
+ it('rejects ordinary update/delete/drag identity paths for virtual rows and split rows', () => {
21
+ expect(() => assertOrdinaryCalendarEvent([virtual], virtual.id)).toThrow();
22
+ expect(() =>
23
+ assertOrdinaryCalendarEvent(
24
+ [{ ...virtual, id: 'split', _originalId: virtual.id }],
25
+ virtual.id
26
+ )
27
+ ).toThrow();
28
+ });
29
+ it('preserves ordinary event mutation behavior', () => {
30
+ expect(() =>
31
+ assertOrdinaryCalendarEvent(
32
+ [{ ...virtual, seriesId: undefined }],
33
+ virtual.id
34
+ )
35
+ ).not.toThrow();
36
+ expect(
37
+ cleanCalendarEventUpdates({
38
+ title: 'Title',
39
+ seriesId: 'injected',
40
+ originalStartLocal: 'injected',
41
+ start_at: '2026-10-05T12:00:00Z',
42
+ })
43
+ ).toEqual({ title: 'Title', start_at: '2026-10-05T12:00:00Z' });
44
+ });
45
+ });
@@ -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
 
@@ -57,9 +66,7 @@ describe('SupabaseProvider', () => {
57
66
 
58
67
  channel.trigger('SUBSCRIBED');
59
68
 
60
- await Promise.resolve();
61
- await Promise.resolve();
62
- expect(provider.connected).toBe(true);
69
+ await waitFor(() => expect(provider.connected).toBe(true));
63
70
  expect(provider.synced).toBe(true);
64
71
 
65
72
  doc.getMap('prosemirror').set('text', 'hello');
@@ -156,8 +163,7 @@ describe('SupabaseProvider', () => {
156
163
  try {
157
164
  channel.trigger('SUBSCRIBED');
158
165
 
159
- await Promise.resolve();
160
- await Promise.resolve();
166
+ await vi.advanceTimersByTimeAsync(0);
161
167
  expect(provider.connected).toBe(true);
162
168
 
163
169
  const sendsBefore = channel.send.mock.calls.length;
@@ -214,8 +220,7 @@ describe('SupabaseProvider', () => {
214
220
  try {
215
221
  channel.trigger('SUBSCRIBED');
216
222
 
217
- await Promise.resolve();
218
- await Promise.resolve();
223
+ await vi.advanceTimersByTimeAsync(0);
219
224
  expect(provider.connected).toBe(true);
220
225
 
221
226
  const sendsBefore = channel.send.mock.calls.length;
@@ -344,3 +349,101 @@ describe('SupabaseProvider', () => {
344
349
  provider.destroy();
345
350
  });
346
351
  });
352
+
353
+ describe('durable hydration without peer transport', () => {
354
+ it('loads saved content even when the channel never subscribes', async () => {
355
+ createRealtimeChannel();
356
+ const saved = new Y.Doc();
357
+ saved.getText('description').insert(0, 'saved text');
358
+ const doc = new Y.Doc();
359
+ const provider = new SupabaseProvider(doc, {} as any, {
360
+ channel: 'task-editor-offline',
361
+ tableName: 'tasks',
362
+ columnName: 'state',
363
+ id: 'offline',
364
+ loadState: async () => Array.from(Y.encodeStateAsUpdate(saved)),
365
+ resyncInterval: false,
366
+ });
367
+ await waitFor(() => expect(provider.hydrated).toBe(true));
368
+ expect(doc.getText('description').toString()).toBe('saved text');
369
+ expect(provider.connected).toBe(false);
370
+ expect(provider.synced).toBe(false);
371
+ provider.destroy();
372
+ saved.destroy();
373
+ });
374
+
375
+ it('coalesces startup and connection loads and ignores disconnect during hydration', async () => {
376
+ const channel = createRealtimeChannel();
377
+ let finish!: (state: number[] | null) => void;
378
+ const loadState = vi.fn(
379
+ () =>
380
+ new Promise<number[] | null>((resolve) => {
381
+ finish = resolve;
382
+ })
383
+ );
384
+ const provider = new SupabaseProvider(new Y.Doc(), {} as any, {
385
+ channel: 'task-editor-pending',
386
+ tableName: 'tasks',
387
+ columnName: 'state',
388
+ id: 'pending',
389
+ loadState,
390
+ resyncInterval: false,
391
+ });
392
+ channel.trigger('SUBSCRIBED');
393
+ channel.trigger('CLOSED');
394
+ finish(null);
395
+ await waitFor(() => expect(provider.hydrated).toBe(true));
396
+ expect(loadState).toHaveBeenCalledTimes(1);
397
+ expect(provider.connected).toBe(false);
398
+ provider.destroy();
399
+ });
400
+
401
+ it('does not hydrate a denied read and retries without unhandled rejection', async () => {
402
+ const channel = createRealtimeChannel();
403
+ const loadState = vi
404
+ .fn()
405
+ .mockRejectedValueOnce(new Error('Access denied'))
406
+ .mockResolvedValueOnce(null);
407
+ const provider = new SupabaseProvider(new Y.Doc(), {} as any, {
408
+ channel: 'task-editor-denied',
409
+ tableName: 'tasks',
410
+ columnName: 'state',
411
+ id: 'denied',
412
+ loadState,
413
+ resyncInterval: false,
414
+ });
415
+ const report = vi.fn();
416
+ provider.on('error', report);
417
+ await waitFor(() => expect(report).toHaveBeenCalledTimes(1));
418
+ expect(provider.hydrated).toBe(false);
419
+ channel.trigger('SUBSCRIBED');
420
+ await waitFor(() => expect(provider.connected).toBe(true));
421
+ expect(provider.hydrated).toBe(true);
422
+ provider.destroy();
423
+ });
424
+
425
+ it('ignores a durable read completing after teardown', async () => {
426
+ createRealtimeChannel();
427
+ let finish!: (state: number[] | null) => void;
428
+ const doc = new Y.Doc();
429
+ const provider = new SupabaseProvider(doc, {} as any, {
430
+ channel: 'task-editor-old',
431
+ tableName: 'tasks',
432
+ columnName: 'state',
433
+ id: 'old',
434
+ loadState: () =>
435
+ new Promise((resolve) => {
436
+ finish = resolve;
437
+ }),
438
+ resyncInterval: false,
439
+ });
440
+ const apply = vi.fn();
441
+ doc.on('update', apply);
442
+ provider.destroy();
443
+ finish(null);
444
+ await Promise.resolve();
445
+ await Promise.resolve();
446
+ expect(provider.hydrated).toBe(false);
447
+ expect(apply).not.toHaveBeenCalled();
448
+ });
449
+ });
@@ -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
  });
@@ -520,4 +530,31 @@ describe('CalendarProvider Read-Only Mode', () => {
520
530
  baseEvent,
521
531
  ]);
522
532
  });
533
+ it('blocks ordinary update/delete/drag writes for native recurrence occurrences', async () => {
534
+ const occurrence = {
535
+ ...baseEvent,
536
+ seriesId: 'series',
537
+ seriesRevision: 3,
538
+ originalStartLocal: '2026-06-22T09:00:00',
539
+ };
540
+ calendarMockState.events = [occurrence];
541
+ const { result } = renderHook(() => useCalendar(), {
542
+ wrapper: createWrapper(),
543
+ });
544
+ await expect(
545
+ result.current.updateEvent(occurrence.id, {
546
+ start_at: '2026-06-23T09:00:00Z',
547
+ })
548
+ ).rejects.toThrow('explicit series edit scope');
549
+ await expect(result.current.deleteEvent(occurrence.id)).rejects.toThrow(
550
+ 'explicit series edit scope'
551
+ );
552
+ expect(
553
+ internalApiMocks.updateWorkspaceCalendarEvent
554
+ ).not.toHaveBeenCalled();
555
+ expect(
556
+ internalApiMocks.deleteWorkspaceCalendarEvent
557
+ ).not.toHaveBeenCalled();
558
+ expect(result.current.isEventReadOnly(occurrence)).toBe(true);
559
+ });
523
560
  });