@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
@@ -147,6 +147,48 @@ describe('useLocalStorage', () => {
147
147
  });
148
148
 
149
149
  describe('error handling', () => {
150
+ it('completes initialization with defaults when getItem is denied', () => {
151
+ vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => {
152
+ throw new DOMException('Synthetic denied read', 'SecurityError');
153
+ });
154
+ const { result } = renderHook(() =>
155
+ useLocalStorage('invoice-compact-view', false)
156
+ );
157
+ expect(result.current).toEqual([false, expect.any(Function), true]);
158
+ expect(console.error).toHaveBeenCalledWith(
159
+ 'Unable to read browser preferences.'
160
+ );
161
+ act(() => result.current[1](true));
162
+ expect(result.current[0]).toBe(true);
163
+ });
164
+
165
+ it('completes initialization when the storage getter itself is denied', () => {
166
+ const storage = window.localStorage;
167
+ Object.defineProperty(window, 'localStorage', {
168
+ configurable: true,
169
+ get() {
170
+ throw new DOMException('Synthetic denied getter', 'SecurityError');
171
+ },
172
+ });
173
+ try {
174
+ const { result } = renderHook(() =>
175
+ useLocalStorage('printAfterCreate', true)
176
+ );
177
+ expect(result.current).toEqual([true, expect.any(Function), true]);
178
+ act(() => result.current[1](false));
179
+ expect(result.current[0]).toBe(false);
180
+ expect(console.error).toHaveBeenLastCalledWith(
181
+ 'Unable to save browser preferences.'
182
+ );
183
+ } finally {
184
+ Object.defineProperty(window, 'localStorage', {
185
+ configurable: true,
186
+ writable: true,
187
+ value: storage,
188
+ });
189
+ }
190
+ });
191
+
150
192
  it('should handle invalid JSON in localStorage gracefully', () => {
151
193
  window.localStorage.setItem(TEST_KEY, 'invalid-json');
152
194
 
@@ -0,0 +1,140 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
4
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
5
+ import type { ReactNode } from 'react';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ const mock = vi.hoisted(() => ({
9
+ list: vi.fn(),
10
+ actor: { actorId: 'actor', assertActive: vi.fn() },
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/calendar-series', () => ({
13
+ listNativeCalendarOccurrences: mock.list,
14
+ }));
15
+ vi.mock('../use-workspace-visibility', () => ({
16
+ useWorkspaceActor: () => mock.actor,
17
+ }));
18
+
19
+ import {
20
+ EMPTY_NATIVE_OCCURRENCES,
21
+ nativeOccurrencesKey,
22
+ useNativeCalendarOccurrences,
23
+ visibleNativeOccurrences,
24
+ } from '../use-native-calendar-occurrences';
25
+
26
+ const event: CalendarEvent = {
27
+ id: 'event',
28
+ seriesId: 'series',
29
+ start_at: '2026-10-05T09:00:00Z',
30
+ end_at: '2026-10-05T10:00:00Z',
31
+ };
32
+ const dates = [new Date(2026, 9, 5)];
33
+ function setup() {
34
+ const client = new QueryClient({
35
+ defaultOptions: { queries: { retry: false } },
36
+ });
37
+ return {
38
+ client,
39
+ wrapper: ({ children }: { children: ReactNode }) => (
40
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
41
+ ),
42
+ };
43
+ }
44
+ beforeEach(() => {
45
+ mock.list.mockReset();
46
+ mock.actor.actorId = 'actor';
47
+ mock.actor.assertActive.mockReset();
48
+ mock.list.mockResolvedValue({ data: [event] });
49
+ });
50
+ describe('native occurrence SWR feed', () => {
51
+ it('uses one stable empty reference before data and for denied/external feeds', () => {
52
+ expect(visibleNativeOccurrences(undefined, null)).toBe(
53
+ EMPTY_NATIVE_OCCURRENCES
54
+ );
55
+ expect(
56
+ visibleNativeOccurrences([event], new InternalApiError('denied', 403))
57
+ ).toBe(EMPTY_NATIVE_OCCURRENCES);
58
+ });
59
+ it.each([
60
+ new TypeError('Failed to fetch'),
61
+ new InternalApiError('busy', 429),
62
+ new InternalApiError('failed', 503),
63
+ new InternalApiError('verify', 403, 'MFA_REQUIRED'),
64
+ ])('retains authorized rows for recoverable failure %o', (error) => {
65
+ const data = [event];
66
+ expect(visibleNativeOccurrences(data, error)).toBe(data);
67
+ });
68
+ it('loads the requested workspace range using an actor-scoped key', async () => {
69
+ const { client, wrapper } = setup();
70
+ const { result } = renderHook(
71
+ () => useNativeCalendarOccurrences('ws', dates, 'UTC', false),
72
+ { wrapper }
73
+ );
74
+ await waitFor(() => expect(result.current.data).toEqual([event]));
75
+ expect(mock.list).toHaveBeenCalledWith(
76
+ 'ws',
77
+ { from: '2026-10-05T00:00:00.000Z', to: '2026-10-06T00:00:00.000Z' },
78
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
79
+ );
80
+ expect(client.getQueryCache().getAll()[0]?.queryKey).toContain('actor');
81
+ });
82
+ it('retains cached rows while a transient refresh fails', async () => {
83
+ const { wrapper } = setup();
84
+ const { result } = renderHook(
85
+ () => useNativeCalendarOccurrences('ws', dates, 'UTC', false),
86
+ { wrapper }
87
+ );
88
+ await waitFor(() => expect(result.current.data).toEqual([event]));
89
+ mock.list.mockRejectedValue(new InternalApiError('busy', 429));
90
+ await act(async () => {
91
+ await result.current.refetch();
92
+ });
93
+ expect(result.current.data).toEqual([event]);
94
+ await waitFor(() => expect(result.current.isError).toBe(true));
95
+ });
96
+ it.each([401, 403, 404])(
97
+ 'clears retained and durable rows after definitive status %s',
98
+ async (status) => {
99
+ const { client, wrapper } = setup();
100
+ const { result } = renderHook(
101
+ () => useNativeCalendarOccurrences('ws', dates, 'UTC', false),
102
+ { wrapper }
103
+ );
104
+ await waitFor(() => expect(result.current.data).toEqual([event]));
105
+ mock.list.mockRejectedValue(new InternalApiError('denied', status));
106
+ await act(async () => {
107
+ await result.current.refetch();
108
+ });
109
+ await waitFor(() =>
110
+ expect(result.current.data).toBe(EMPTY_NATIVE_OCCURRENCES)
111
+ );
112
+ expect(
113
+ client.getQueriesData({ queryKey: nativeOccurrencesKey('ws') })[0]?.[1]
114
+ ).toEqual([]);
115
+ }
116
+ );
117
+ it('does not read native data for externally supplied calendars or empty dates', () => {
118
+ const { wrapper } = setup();
119
+ const { result, rerender } = renderHook(
120
+ ({ external, range }) =>
121
+ useNativeCalendarOccurrences('ws', range, 'UTC', external),
122
+ { wrapper, initialProps: { external: true, range: dates } }
123
+ );
124
+ expect(result.current.data).toBe(EMPTY_NATIVE_OCCURRENCES);
125
+ rerender({ external: false, range: [] });
126
+ expect(mock.list).not.toHaveBeenCalled();
127
+ });
128
+ it('does not carry a previous actor snapshot into another account', async () => {
129
+ const { wrapper } = setup();
130
+ const { result, rerender } = renderHook(
131
+ () => useNativeCalendarOccurrences('ws', dates, 'UTC', false),
132
+ { wrapper }
133
+ );
134
+ await waitFor(() => expect(result.current.data).toEqual([event]));
135
+ mock.actor.actorId = 'other';
136
+ mock.list.mockImplementation(() => new Promise(() => {}));
137
+ rerender();
138
+ expect(result.current.data).toBe(EMPTY_NATIVE_OCCURRENCES);
139
+ });
140
+ });
@@ -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,147 @@
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
+ hydrated = false;
20
+ awareness: { destroy: () => void };
21
+ private listeners = new Map<string, (value: unknown) => void>();
22
+ constructor(
23
+ private doc: { clientID: number; destroy: () => void },
24
+ _client: unknown,
25
+ config: { awareness: { destroy: () => void } }
26
+ ) {
27
+ this.id = doc.clientID;
28
+ this.awareness = config.awareness;
29
+ instances.push(this);
30
+ }
31
+ on(event: string, callback: (value: unknown) => void) {
32
+ this.listeners.set(event, callback);
33
+ }
34
+ emit(event: string, value: unknown) {
35
+ if (event === 'hydrated') this.hydrated = true;
36
+ this.listeners.get(event)?.(value);
37
+ }
38
+ destroy() {
39
+ this.destroyed = true;
40
+ this.awareness.destroy();
41
+ this.doc.destroy();
42
+ }
43
+ },
44
+ }));
45
+ const user = { id: 'actor', name: 'Ada', color: '#123456' };
46
+ const config = {
47
+ channel: 'task-editor-one',
48
+ tableName: 'tasks',
49
+ columnName: 'description_yjs_state',
50
+ id: 'one',
51
+ user,
52
+ };
53
+ const wrapper = ({ children }: { children: ReactNode }) => (
54
+ <StrictMode>{children}</StrictMode>
55
+ );
56
+ describe('collaboration hook lifecycle', () => {
57
+ beforeEach(() => {
58
+ vi.useFakeTimers();
59
+ instances.length = 0;
60
+ });
61
+ afterEach(() => {
62
+ vi.useRealTimers();
63
+ });
64
+ it('does not start awareness before authentication and reuses StrictMode providers', async () => {
65
+ const { result, rerender, unmount } = renderHook(
66
+ (actor) => useYjsCollaboration({ ...config, user: actor }),
67
+ { wrapper, initialProps: null as typeof user | null }
68
+ );
69
+ expect(result.current.doc).toBeNull();
70
+ expect(result.current.awareness).toBeNull();
71
+ expect(instances).toHaveLength(0);
72
+ rerender(user);
73
+ expect(instances).toHaveLength(1);
74
+ const provider = result.current.provider;
75
+ expect(provider).not.toBeNull();
76
+ rerender({ ...user, name: 'Ada updated' });
77
+ expect(result.current.provider).toBe(provider);
78
+ expect(result.current.awareness?.getLocalState()?.user.name).toBe(
79
+ 'Ada updated'
80
+ );
81
+ unmount();
82
+ await act(() => vi.advanceTimersByTimeAsync(100));
83
+ expect(instances[0]?.destroyed).toBe(true);
84
+ expect(vi.getTimerCount()).toBe(0);
85
+ });
86
+ it('isolates account/resource switches and ignores late callbacks from old providers', async () => {
87
+ const { result, rerender, unmount } = renderHook(
88
+ (id) =>
89
+ useYjsCollaboration({ ...config, id, channel: `task-editor-${id}` }),
90
+ { wrapper, initialProps: 'one' }
91
+ );
92
+ const first = instances[0]!;
93
+ const firstDoc = result.current.doc;
94
+ rerender('two');
95
+ expect(instances).toHaveLength(2);
96
+ expect(first.destroyed).toBe(true);
97
+ expect(result.current.doc).not.toBe(firstDoc);
98
+ const second = instances[1]!;
99
+ act(() => second.emit('status', [{ status: 'connected' }]));
100
+ expect(result.current.connected).toBe(true);
101
+ act(() => first.emit('disconnect', []));
102
+ expect(result.current.connected).toBe(true);
103
+ act(() => first.emit('synced', [true]));
104
+ expect(result.current.synced).toBe(false);
105
+ unmount();
106
+ await act(() => vi.advanceTimersByTimeAsync(100));
107
+ expect(second.destroyed).toBe(true);
108
+ expect(vi.getTimerCount()).toBe(0);
109
+ });
110
+ it('publishes durable hydration independently and fences an old document', async () => {
111
+ const { result, rerender, unmount } = renderHook(
112
+ (id) =>
113
+ useYjsCollaboration({ ...config, id, channel: `task-editor-${id}` }),
114
+ { wrapper, initialProps: 'one' }
115
+ );
116
+ const first = instances[0]!;
117
+ act(() => first.emit('hydrated', undefined));
118
+ expect(result.current.hydrated).toBe(true);
119
+ expect(result.current.connected).toBe(false);
120
+ rerender('two');
121
+ expect(result.current.hydrated).toBe(false);
122
+ act(() => first.emit('hydrated', undefined));
123
+ expect(result.current.hydrated).toBe(false);
124
+ unmount();
125
+ await act(() => vi.advanceTimersByTimeAsync(100));
126
+ });
127
+ it('reports a durable read failure until a successful hydration', async () => {
128
+ const { result, unmount } = renderHook(() => useYjsCollaboration(config), {
129
+ wrapper,
130
+ });
131
+ const provider = instances[0]!;
132
+ act(() =>
133
+ provider.emit('error', {
134
+ message: 'Unable to load',
135
+ status: 'DOCUMENT_ERROR',
136
+ channel: config.channel,
137
+ })
138
+ );
139
+ expect(result.current.hydrationFailed).toBe(true);
140
+ expect(result.current.hydrated).toBe(false);
141
+ act(() => provider.emit('hydrated', undefined));
142
+ expect(result.current.hydrationFailed).toBe(false);
143
+ expect(result.current.hydrated).toBe(true);
144
+ unmount();
145
+ await act(() => vi.advanceTimersByTimeAsync(100));
146
+ });
147
+ });
@@ -0,0 +1,49 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ // Type for calendar connection
3
+ export type CalendarConnection = {
4
+ id: string;
5
+ ws_id: string;
6
+ calendar_id: string;
7
+ calendar_name: string;
8
+ is_enabled: boolean;
9
+ color: string | null;
10
+ provider?: 'google' | 'microsoft' | string;
11
+ auth_token_id?: string | null;
12
+ workspace_calendar_id?: string | null;
13
+ access_role?: string | null;
14
+ created_at: string;
15
+ updated_at: string;
16
+ };
17
+
18
+ export type CalendarSyncStatus = {
19
+ state: 'idle' | 'syncing' | 'success' | 'error';
20
+ message?: string;
21
+ lastSyncTime?: Date;
22
+ direction?: 'google-to-tuturuuu' | 'tuturuuu-to-google' | 'both';
23
+ };
24
+ export type CalendarOptimisticStatus =
25
+ | 'creating'
26
+ | 'updating'
27
+ | 'deleting'
28
+ | 'error';
29
+
30
+ export type OptimisticCalendarSyncEvent = Partial<
31
+ Omit<CalendarEvent, 'color' | 'description' | 'location'>
32
+ > &
33
+ Pick<CalendarEvent, 'id'> & {
34
+ color?: CalendarEvent['color'] | string | null;
35
+ description?: string | null;
36
+ location?: string | null;
37
+ _optimisticStatus?: CalendarOptimisticStatus;
38
+ };
39
+
40
+ export type OptimisticCalendarPatchOptions = {
41
+ removeIds?: string[];
42
+ clearIds?: string[];
43
+ status?: CalendarOptimisticStatus;
44
+ };
45
+
46
+ export type OptimisticCalendarState = {
47
+ events: Record<string, OptimisticCalendarSyncEvent>;
48
+ removedIds: string[];
49
+ };
@@ -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,12 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+
3
+ export interface PendingEventUpdate extends Partial<CalendarEvent> {
4
+ _updateId?: string;
5
+ _timestamp: number;
6
+ _eventId: string;
7
+ _previousEvent?: CalendarEvent;
8
+ _resolvers?: Array<{
9
+ resolve: (value: CalendarEvent) => void;
10
+ reject: (reason: unknown) => void;
11
+ }>;
12
+ }
@@ -0,0 +1,57 @@
1
+ import type { WorkspaceCalendarEventUpdatePayload } from '@tuturuuu/internal-api';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import { isProviderRecurrenceReadonly } from '@tuturuuu/utils/calendar-provider-readonly';
4
+
5
+ export function calendarEventUpdatePayload(
6
+ input: Partial<CalendarEvent>
7
+ ): WorkspaceCalendarEventUpdatePayload {
8
+ const payload: WorkspaceCalendarEventUpdatePayload = {};
9
+ for (const field of [
10
+ 'title',
11
+ 'description',
12
+ 'location',
13
+ 'start_at',
14
+ 'end_at',
15
+ 'locked',
16
+ 'source',
17
+ 'providerColor',
18
+ ] as const) {
19
+ if (input[field] !== undefined)
20
+ Object.assign(payload, { [field]: input[field] });
21
+ }
22
+ if (!input.providerColor && input.color !== undefined)
23
+ payload.color = input.color;
24
+ return payload;
25
+ }
26
+
27
+ export function cleanCalendarEventUpdates(input: Partial<CalendarEvent>) {
28
+ const result: Partial<CalendarEvent> = {};
29
+ for (const field of [
30
+ 'title',
31
+ 'description',
32
+ 'start_at',
33
+ 'end_at',
34
+ 'color',
35
+ 'location',
36
+ 'locked',
37
+ 'source',
38
+ ] as const) {
39
+ if (input[field] !== undefined)
40
+ Object.assign(result, { [field]: input[field] });
41
+ }
42
+ return result;
43
+ }
44
+ export function calendarEventByIdentity(events: CalendarEvent[], id: string) {
45
+ return events.find((event) => event.id === id || event._originalId === id);
46
+ }
47
+ export function assertOrdinaryCalendarEvent(
48
+ events: CalendarEvent[],
49
+ id: string
50
+ ) {
51
+ if (isProviderRecurrenceReadonly(calendarEventByIdentity(events, id)))
52
+ throw new Error('Unsupported provider recurrence is read only');
53
+ if (calendarEventByIdentity(events, id)?.seriesId)
54
+ throw new Error(
55
+ 'Recurring occurrences require an explicit series edit scope'
56
+ );
57
+ }
@@ -0,0 +1,35 @@
1
+ interface DocumentHydrationOptions {
2
+ load: () => Promise<number[] | null>;
3
+ apply: (state: Uint8Array) => void;
4
+ isActive: () => boolean;
5
+ onLoaded: () => void;
6
+ onError: () => void;
7
+ }
8
+
9
+ /** Coalesces durable reads and fences late completion after resource teardown. */
10
+ export class DocumentHydration {
11
+ private pending: Promise<boolean> | undefined;
12
+
13
+ constructor(private readonly options: DocumentHydrationOptions) {}
14
+
15
+ load(): Promise<boolean> {
16
+ if (this.pending) return this.pending;
17
+ this.pending = this.read().finally(() => {
18
+ this.pending = undefined;
19
+ });
20
+ return this.pending;
21
+ }
22
+
23
+ private async read(): Promise<boolean> {
24
+ try {
25
+ const state = await this.options.load();
26
+ if (!this.options.isActive()) return false;
27
+ if (state?.length) this.options.apply(Uint8Array.from(state));
28
+ this.options.onLoaded();
29
+ return true;
30
+ } catch {
31
+ if (this.options.isActive()) this.options.onError();
32
+ return false;
33
+ }
34
+ }
35
+ }