@tuturuuu/ui 0.38.0 → 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 (93) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  4. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  5. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  6. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  7. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  8. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  9. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  10. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  11. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  12. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  13. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  14. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  15. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  16. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  17. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  18. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  20. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  21. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  22. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  23. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  24. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  25. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  26. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  27. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  28. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  29. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  30. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  31. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  32. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  33. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  34. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  35. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  36. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  37. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  38. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  39. package/src/components/ui/finance/invoices/types.ts +3 -3
  40. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  41. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  43. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  44. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  45. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  46. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  47. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  48. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  49. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  50. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  51. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
  52. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  53. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  54. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  55. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  56. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  57. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  58. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  59. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  60. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  61. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  62. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  63. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  64. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  65. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  66. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  67. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  68. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  69. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
  70. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
  71. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  72. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  73. package/src/components/ui/text-editor/editor.tsx +3 -0
  74. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  75. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  76. package/src/components/ui/text-editor/tool-bar.tsx +18 -74
  77. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  78. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  79. package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
  80. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
  81. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  82. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  83. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
  84. package/src/hooks/calendar-connection-types.ts +49 -0
  85. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  86. package/src/hooks/calendar-event-write-payload.ts +33 -0
  87. package/src/hooks/collaboration-document-hydration.ts +35 -0
  88. package/src/hooks/supabase-provider.ts +39 -41
  89. package/src/hooks/use-calendar-sync.tsx +41 -62
  90. package/src/hooks/use-calendar.tsx +24 -38
  91. package/src/hooks/use-local-storage.ts +9 -11
  92. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  93. package/src/hooks/use-yjs-collaboration.ts +23 -0
@@ -35,13 +35,13 @@ 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;
@@ -842,6 +842,7 @@ export function ToolBar({
842
842
  }
843
843
 
844
844
  interface FixedToolbarProps {
845
+ toolsLabel?: string;
845
846
  editor: Editor | null;
846
847
  leadingContent?: React.ReactNode;
847
848
  workspaceId?: string;
@@ -853,6 +854,7 @@ interface FixedToolbarProps {
853
854
  copyLabels?: EditorCopyLabels;
854
855
  }
855
856
  export function FixedToolbar({
857
+ toolsLabel,
856
858
  editor,
857
859
  leadingContent,
858
860
  workspaceId,
@@ -1108,78 +1110,20 @@ export function FixedToolbar({
1108
1110
  <ToolbarSeparator />
1109
1111
  </>
1110
1112
  ) : null}
1111
- {/* Grouped formatting options with separators */}
1112
- {TOOLBAR_GROUPS.map((group, gi) => (
1113
- <div key={gi} className="contents">
1114
- {gi > 0 && <ToolbarSeparator />}
1115
- {group.map((key) => {
1116
- const opt = formattingOptions.get(key);
1117
- if (!opt) return null;
1118
- return (
1119
- <ToolbarButton
1120
- key={key}
1121
- id={key}
1122
- label={key === 'toggle-block' ? toggleBlockLabel : undefined}
1123
- icon={opt.icon}
1124
- pressed={opt.pressed}
1125
- onClick={opt.onClick}
1126
- />
1127
- );
1128
- })}
1129
- </div>
1130
- ))}
1131
-
1132
- <ToolbarSeparator />
1133
- <TextEditorColorControls editor={editor} />
1134
-
1135
- {onImageUpload && (
1136
- <>
1137
- <ToolbarSeparator />
1138
- <ToolbarButton
1139
- id="image"
1140
- icon={
1141
- isUploadingImage ? (
1142
- <Loader2 className="size-4 animate-spin" />
1143
- ) : (
1144
- <ImageIcon className="size-4" />
1145
- )
1146
- }
1147
- pressed={false}
1148
- onClick={() => fileInputRef.current?.click()}
1149
- disabled={isUploadingImage}
1150
- />
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
- </>
1167
- )}
1168
-
1169
- {onConvertToTask && (
1170
- <>
1171
- <ToolbarSeparator />
1172
- <ToolbarButton
1173
- id="convert-to-task"
1174
- icon={<CirclePlus className="size-4" />}
1175
- pressed={false}
1176
- onClick={onConvertToTask}
1177
- />
1178
- </>
1179
- )}
1180
-
1181
- <ToolbarSeparator />
1182
- <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
+ />
1183
1127
  </div>
1184
1128
  );
1185
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,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
+ });
@@ -66,9 +66,7 @@ describe('SupabaseProvider', () => {
66
66
 
67
67
  channel.trigger('SUBSCRIBED');
68
68
 
69
- await Promise.resolve();
70
- await Promise.resolve();
71
- expect(provider.connected).toBe(true);
69
+ await waitFor(() => expect(provider.connected).toBe(true));
72
70
  expect(provider.synced).toBe(true);
73
71
 
74
72
  doc.getMap('prosemirror').set('text', 'hello');
@@ -165,8 +163,7 @@ describe('SupabaseProvider', () => {
165
163
  try {
166
164
  channel.trigger('SUBSCRIBED');
167
165
 
168
- await Promise.resolve();
169
- await Promise.resolve();
166
+ await vi.advanceTimersByTimeAsync(0);
170
167
  expect(provider.connected).toBe(true);
171
168
 
172
169
  const sendsBefore = channel.send.mock.calls.length;
@@ -223,8 +220,7 @@ describe('SupabaseProvider', () => {
223
220
  try {
224
221
  channel.trigger('SUBSCRIBED');
225
222
 
226
- await Promise.resolve();
227
- await Promise.resolve();
223
+ await vi.advanceTimersByTimeAsync(0);
228
224
  expect(provider.connected).toBe(true);
229
225
 
230
226
  const sendsBefore = channel.send.mock.calls.length;
@@ -353,3 +349,101 @@ describe('SupabaseProvider', () => {
353
349
  provider.destroy();
354
350
  });
355
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
+ });
@@ -530,4 +530,31 @@ describe('CalendarProvider Read-Only Mode', () => {
530
530
  baseEvent,
531
531
  ]);
532
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
+ });
533
560
  });
@@ -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
+ });
@@ -16,6 +16,7 @@ vi.mock('@tuturuuu/ui/hooks/supabase-provider', () => ({
16
16
  default: class {
17
17
  id: number;
18
18
  destroyed = false;
19
+ hydrated = false;
19
20
  awareness: { destroy: () => void };
20
21
  private listeners = new Map<string, (value: unknown) => void>();
21
22
  constructor(
@@ -31,6 +32,7 @@ vi.mock('@tuturuuu/ui/hooks/supabase-provider', () => ({
31
32
  this.listeners.set(event, callback);
32
33
  }
33
34
  emit(event: string, value: unknown) {
35
+ if (event === 'hydrated') this.hydrated = true;
34
36
  this.listeners.get(event)?.(value);
35
37
  }
36
38
  destroy() {
@@ -105,4 +107,41 @@ describe('collaboration hook lifecycle', () => {
105
107
  expect(second.destroyed).toBe(true);
106
108
  expect(vi.getTimerCount()).toBe(0);
107
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
+ });
108
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,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
+ }