@tuturuuu/ui 0.38.0 → 0.39.1

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 (98) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/custom/public-profile-menu-item.tsx +25 -0
  4. package/src/components/ui/custom/satellite-user-menu-items.test.tsx +61 -0
  5. package/src/components/ui/custom/satellite-user-menu-items.tsx +8 -0
  6. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  7. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  8. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  9. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  10. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  11. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  12. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  13. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  14. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  15. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  16. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  17. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  18. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  22. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  23. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  24. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  25. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  26. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  27. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  28. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  29. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  30. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  31. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +489 -0
  33. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  34. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  35. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  36. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  37. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  38. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  39. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  40. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  41. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  42. package/src/components/ui/finance/invoices/types.ts +3 -3
  43. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  44. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  45. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  46. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  47. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  48. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  49. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  50. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  51. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  52. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  53. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  54. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
  55. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  56. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  57. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  58. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  59. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  60. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  61. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  62. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  63. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  64. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  65. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  66. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  68. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  69. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  70. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  71. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  72. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
  73. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
  74. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  75. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  76. package/src/components/ui/public-link-button.test.tsx +116 -0
  77. package/src/components/ui/public-link-button.tsx +81 -0
  78. package/src/components/ui/text-editor/editor.tsx +3 -0
  79. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  80. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  81. package/src/components/ui/text-editor/tool-bar.tsx +18 -74
  82. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  83. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  84. package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
  85. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
  86. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  87. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  88. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
  89. package/src/hooks/calendar-connection-types.ts +49 -0
  90. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  91. package/src/hooks/calendar-event-write-payload.ts +33 -0
  92. package/src/hooks/collaboration-document-hydration.ts +35 -0
  93. package/src/hooks/supabase-provider.ts +39 -41
  94. package/src/hooks/use-calendar-sync.tsx +41 -62
  95. package/src/hooks/use-calendar.tsx +24 -38
  96. package/src/hooks/use-local-storage.ts +9 -11
  97. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  98. package/src/hooks/use-yjs-collaboration.ts +23 -0
@@ -0,0 +1,59 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import type { Editor } from '@tiptap/react';
3
+ import { TooltipProvider } from '@tuturuuu/ui/tooltip';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { FixedToolbarContents } from './fixed-toolbar-contents';
6
+
7
+ vi.mock('./color-controls', () => ({
8
+ TextEditorColorControls: () => <button type="button">Colors</button>,
9
+ }));
10
+ vi.mock('./copy-menu', () => ({
11
+ EditorCopyMenu: () => <button type="button">Copy tools</button>,
12
+ }));
13
+
14
+ function mount(toolsLabel?: string) {
15
+ const heading = vi.fn();
16
+ const bold = vi.fn();
17
+ const options = new Map([
18
+ ['bold', { icon: <span>B</span>, pressed: false, onClick: bold }],
19
+ ['heading-1', { icon: <span>H1</span>, pressed: false, onClick: heading }],
20
+ ]);
21
+ render(
22
+ <TooltipProvider>
23
+ <FixedToolbarContents
24
+ editor={{} as Editor}
25
+ options={options}
26
+ toolsLabel={toolsLabel}
27
+ imageUpload={false}
28
+ videoUpload={false}
29
+ uploadingImage={false}
30
+ uploadingVideo={false}
31
+ onImage={vi.fn()}
32
+ onVideo={vi.fn()}
33
+ />
34
+ </TooltipProvider>
35
+ );
36
+ return { heading, bold };
37
+ }
38
+
39
+ describe('optional compact document toolbar', () => {
40
+ it('retains the existing full toolbar by default', () => {
41
+ const { heading } = mount();
42
+ expect(screen.queryByRole('button', { name: 'Tools' })).toBeNull();
43
+ fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }));
44
+ expect(heading).toHaveBeenCalledOnce();
45
+ expect(screen.getByRole('button', { name: 'Colors' })).toBeTruthy();
46
+ });
47
+ it('keeps primary formatting reachable and places secondary controls in Tools', () => {
48
+ const { heading, bold } = mount('Tools');
49
+ expect(screen.queryByRole('button', { name: 'Heading 1' })).toBeNull();
50
+ expect(screen.queryByRole('button', { name: 'Colors' })).toBeNull();
51
+ fireEvent.click(screen.getByRole('button', { name: 'Bold' }));
52
+ expect(bold).toHaveBeenCalledOnce();
53
+ fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
54
+ fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }));
55
+ expect(heading).toHaveBeenCalledOnce();
56
+ expect(screen.getByRole('button', { name: 'Colors' })).toBeTruthy();
57
+ expect(screen.getByRole('button', { name: 'Copy tools' })).toBeTruthy();
58
+ });
59
+ });
@@ -0,0 +1,156 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import {
3
+ CirclePlus,
4
+ FileVideo,
5
+ ImageIcon,
6
+ Loader2,
7
+ Settings2,
8
+ } from '@tuturuuu/icons';
9
+ import { Button } from '@tuturuuu/ui/button';
10
+ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
11
+ import type { ReactNode } from 'react';
12
+ import { TextEditorColorControls } from './color-controls';
13
+ import { type EditorCopyLabels, EditorCopyMenu } from './copy-menu';
14
+ import { TOOLBAR_GROUPS } from './toolbar-config';
15
+ import { ToolbarButton, ToolbarSeparator } from './toolbar-controls';
16
+
17
+ interface Props {
18
+ editor: Editor;
19
+ options: Map<
20
+ string,
21
+ { onClick: () => void; pressed: boolean; icon: ReactNode }
22
+ >;
23
+ toolsLabel?: string;
24
+ toggleBlockLabel?: string;
25
+ copyLabels?: EditorCopyLabels;
26
+ imageUpload: boolean;
27
+ videoUpload: boolean;
28
+ uploadingImage: boolean;
29
+ uploadingVideo: boolean;
30
+ onImage: () => void;
31
+ onVideo: () => void;
32
+ onConvertToTask?: () => void | Promise<void>;
33
+ }
34
+
35
+ /** Optional compact presentation; existing editors keep the full toolbar. */
36
+ export function FixedToolbarContents({
37
+ editor,
38
+ options,
39
+ toolsLabel,
40
+ toggleBlockLabel,
41
+ copyLabels,
42
+ imageUpload,
43
+ videoUpload,
44
+ uploadingImage,
45
+ uploadingVideo,
46
+ onImage,
47
+ onVideo,
48
+ onConvertToTask,
49
+ }: Props) {
50
+ function control(key: string) {
51
+ const option = options.get(key);
52
+ if (!option) return null;
53
+ return (
54
+ <ToolbarButton
55
+ key={key}
56
+ id={key}
57
+ label={key === 'toggle-block' ? toggleBlockLabel : undefined}
58
+ icon={option.icon}
59
+ pressed={option.pressed}
60
+ onClick={option.onClick}
61
+ />
62
+ );
63
+ }
64
+ const primary = new Set(['bold', 'italic', 'strike']);
65
+ const groups = TOOLBAR_GROUPS.map((group, index) => {
66
+ const keys = toolsLabel ? group.filter((key) => !primary.has(key)) : group;
67
+ if (!keys.length) return null;
68
+ return (
69
+ <div
70
+ key={index}
71
+ className={toolsLabel ? 'flex items-center gap-1' : 'contents'}
72
+ >
73
+ {!toolsLabel && index > 0 && <ToolbarSeparator />}
74
+ {keys.map(control)}
75
+ </div>
76
+ );
77
+ });
78
+ const secondary = (
79
+ <>
80
+ {groups}
81
+ <ToolbarSeparator />
82
+ <TextEditorColorControls editor={editor} />
83
+ {imageUpload && (
84
+ <>
85
+ <ToolbarSeparator />
86
+ <ToolbarButton
87
+ id="image"
88
+ icon={
89
+ uploadingImage ? (
90
+ <Loader2 className="size-4 animate-spin" />
91
+ ) : (
92
+ <ImageIcon className="size-4" />
93
+ )
94
+ }
95
+ pressed={false}
96
+ onClick={onImage}
97
+ disabled={uploadingImage}
98
+ />
99
+ {videoUpload && (
100
+ <ToolbarButton
101
+ id="video"
102
+ icon={
103
+ uploadingVideo ? (
104
+ <Loader2 className="size-4 animate-spin" />
105
+ ) : (
106
+ <FileVideo className="size-4" />
107
+ )
108
+ }
109
+ pressed={false}
110
+ onClick={onVideo}
111
+ disabled={uploadingVideo}
112
+ />
113
+ )}
114
+ </>
115
+ )}
116
+ {onConvertToTask && (
117
+ <>
118
+ <ToolbarSeparator />
119
+ <ToolbarButton
120
+ id="convert-to-task"
121
+ icon={<CirclePlus className="size-4" />}
122
+ pressed={false}
123
+ onClick={onConvertToTask}
124
+ />
125
+ </>
126
+ )}
127
+ <ToolbarSeparator />
128
+ <EditorCopyMenu editor={editor} labels={copyLabels} />
129
+ </>
130
+ );
131
+ if (!toolsLabel) return secondary;
132
+ return (
133
+ <>
134
+ {[...primary].map(control)}
135
+ <Popover>
136
+ <PopoverTrigger asChild>
137
+ <Button
138
+ type="button"
139
+ variant="ghost"
140
+ size="sm"
141
+ className="h-8 shrink-0"
142
+ >
143
+ <Settings2 className="size-4" aria-hidden="true" />
144
+ {toolsLabel}
145
+ </Button>
146
+ </PopoverTrigger>
147
+ <PopoverContent
148
+ align="start"
149
+ className="flex w-80 max-w-[calc(100vw-2rem)] flex-wrap items-center gap-1"
150
+ >
151
+ {secondary}
152
+ </PopoverContent>
153
+ </Popover>
154
+ </>
155
+ );
156
+ }
@@ -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