@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.
- package/CHANGELOG.md +34 -0
- package/package.json +8 -8
- package/src/components/ui/custom/public-profile-menu-item.tsx +25 -0
- package/src/components/ui/custom/satellite-user-menu-items.test.tsx +61 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +8 -0
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +489 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/public-link-button.test.tsx +116 -0
- package/src/components/ui/public-link-button.tsx +81 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-yjs-collaboration.ts +23 -0
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { WorkspaceCalendarEventUpdatePayload } from '@tuturuuu/internal-api';
|
|
2
2
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
3
|
+
import { isProviderRecurrenceReadonly } from '@tuturuuu/utils/calendar-provider-readonly';
|
|
3
4
|
|
|
4
5
|
export function calendarEventUpdatePayload(
|
|
5
6
|
input: Partial<CalendarEvent>
|
|
@@ -22,3 +23,35 @@ export function calendarEventUpdatePayload(
|
|
|
22
23
|
payload.color = input.color;
|
|
23
24
|
return payload;
|
|
24
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
|
+
}
|
|
@@ -7,6 +7,7 @@ import debug from 'debug';
|
|
|
7
7
|
import { EventEmitter } from 'eventemitter3';
|
|
8
8
|
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
9
9
|
import * as Y from 'yjs';
|
|
10
|
+
import { DocumentHydration } from './collaboration-document-hydration';
|
|
10
11
|
import { isPageVisible } from './use-page-visibility';
|
|
11
12
|
|
|
12
13
|
export const SUPABASE_PROVIDER_SYNC_ORIGIN = Symbol.for(
|
|
@@ -31,6 +32,9 @@ export interface SupabaseProviderConfig {
|
|
|
31
32
|
export default class SupabaseProvider extends EventEmitter {
|
|
32
33
|
public awareness: awarenessProtocol.Awareness;
|
|
33
34
|
public connected = false;
|
|
35
|
+
public hydrated = false;
|
|
36
|
+
private connectionGeneration = 0;
|
|
37
|
+
private hydration: DocumentHydration;
|
|
34
38
|
private channel: RealtimeChannel | null = null;
|
|
35
39
|
private realtime = createRealtimeClient();
|
|
36
40
|
private pendingAwarenessClients = new Set<number>();
|
|
@@ -78,12 +82,10 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
if (!this.connected || this.destroyed) {
|
|
81
|
-
this.logger('skipping broadcast - not connected or destroyed');
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
if (!update || update.length === 0) {
|
|
86
|
-
this.logger('skipping broadcast - empty update');
|
|
87
89
|
return;
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -125,7 +127,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
125
127
|
|
|
126
128
|
this.saveTimeout = setTimeout(() => {
|
|
127
129
|
this.saveTimeout = undefined;
|
|
128
|
-
this.logger('debounce period elapsed, saving to database');
|
|
129
130
|
void this.save();
|
|
130
131
|
}, this.saveDebounceMs);
|
|
131
132
|
}
|
|
@@ -145,7 +146,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
145
146
|
if (this.saveTimeout) {
|
|
146
147
|
clearTimeout(this.saveTimeout);
|
|
147
148
|
this.saveTimeout = undefined;
|
|
148
|
-
this.logger('flushing pending save immediately');
|
|
149
149
|
await this.save();
|
|
150
150
|
}
|
|
151
151
|
}
|
|
@@ -269,39 +269,20 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
private async onConnect() {
|
|
272
|
-
this.logger('connected');
|
|
273
|
-
|
|
274
272
|
this.reconnectAttempts = 0;
|
|
275
273
|
if (this.reconnectTimeout) {
|
|
276
274
|
clearTimeout(this.reconnectTimeout);
|
|
277
275
|
this.reconnectTimeout = undefined;
|
|
278
276
|
}
|
|
279
277
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
288
|
-
.eq(this.config.idName || 'id', this.config.id)
|
|
289
|
-
.single();
|
|
290
|
-
|
|
291
|
-
this.logger('retrieved data from supabase', status);
|
|
292
|
-
return data?.[this.config.columnName] ?? null;
|
|
293
|
-
})();
|
|
294
|
-
|
|
295
|
-
if (persistedState && persistedState.length > 0) {
|
|
296
|
-
this.logger('applying update to yjs');
|
|
297
|
-
try {
|
|
298
|
-
this.applyUpdate(Uint8Array.from(persistedState), this);
|
|
299
|
-
} catch (error) {
|
|
300
|
-
this.logger(error);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
278
|
+
const generation = this.connectionGeneration;
|
|
279
|
+
if (
|
|
280
|
+
!(await this.hydration.load()) ||
|
|
281
|
+
this.destroyed ||
|
|
282
|
+
generation !== this.connectionGeneration
|
|
283
|
+
)
|
|
284
|
+
return;
|
|
303
285
|
|
|
304
|
-
this.logger('setting connected flag to true');
|
|
305
286
|
this.isOnline(true);
|
|
306
287
|
|
|
307
288
|
this.synced = true;
|
|
@@ -416,9 +397,33 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
416
397
|
this.logger = debug(`y-${doc.clientID}`);
|
|
417
398
|
this.logger.enabled = true;
|
|
418
399
|
|
|
419
|
-
this.
|
|
420
|
-
|
|
421
|
-
|
|
400
|
+
this.hydration = new DocumentHydration({
|
|
401
|
+
load:
|
|
402
|
+
this.config.loadState ??
|
|
403
|
+
(async () => {
|
|
404
|
+
const { data, error } = await this.supabase
|
|
405
|
+
.from(this.config.tableName as any)
|
|
406
|
+
.select<string, { [key: string]: number[] }>(this.config.columnName)
|
|
407
|
+
.eq(this.config.idName || 'id', this.config.id)
|
|
408
|
+
.single();
|
|
409
|
+
if (error) throw error;
|
|
410
|
+
return data?.[this.config.columnName] ?? null;
|
|
411
|
+
}),
|
|
412
|
+
apply: (state) => Y.applyUpdate(this.doc, state, this),
|
|
413
|
+
isActive: () => !this.destroyed,
|
|
414
|
+
onLoaded: () => {
|
|
415
|
+
this.hydrated = true;
|
|
416
|
+
this.emit('hydrated');
|
|
417
|
+
},
|
|
418
|
+
onError: () =>
|
|
419
|
+
this.emit('error', {
|
|
420
|
+
message: 'Unable to load collaboration document',
|
|
421
|
+
channel: this.config.channel,
|
|
422
|
+
status: 'DOCUMENT_ERROR',
|
|
423
|
+
}),
|
|
424
|
+
});
|
|
425
|
+
// Durable reads must not depend on a successful peer transport connection.
|
|
426
|
+
void this.hydration.load();
|
|
422
427
|
|
|
423
428
|
if (
|
|
424
429
|
this.config.resyncInterval ||
|
|
@@ -438,12 +443,10 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
438
443
|
}
|
|
439
444
|
|
|
440
445
|
if (!isPageVisible()) {
|
|
441
|
-
this.logger('skipping resync - page not visible');
|
|
442
446
|
return;
|
|
443
447
|
}
|
|
444
448
|
|
|
445
449
|
if (!this._dirty) {
|
|
446
|
-
this.logger('skipping resync - no local changes');
|
|
447
450
|
return;
|
|
448
451
|
}
|
|
449
452
|
|
|
@@ -542,7 +545,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
542
545
|
|
|
543
546
|
public onConnecting() {
|
|
544
547
|
if (!this.isOnline()) {
|
|
545
|
-
this.logger('connecting');
|
|
546
548
|
this.emit('status', [{ status: 'connecting' }]);
|
|
547
549
|
}
|
|
548
550
|
}
|
|
@@ -551,7 +553,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
551
553
|
if (this.destroyed || this.reconnectTimeout) return;
|
|
552
554
|
|
|
553
555
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
554
|
-
this.logger('max reconnect attempts reached');
|
|
555
556
|
this.emit('reconnect-failed');
|
|
556
557
|
return;
|
|
557
558
|
}
|
|
@@ -581,7 +582,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
581
582
|
public resetAndReconnect() {
|
|
582
583
|
if (this.destroyed || this.connected) return;
|
|
583
584
|
|
|
584
|
-
this.logger('resetAndReconnect: resetting backoff and reconnecting');
|
|
585
585
|
this.reconnectAttempts = 0;
|
|
586
586
|
|
|
587
587
|
if (this.reconnectTimeout) {
|
|
@@ -594,11 +594,10 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
public onDisconnect() {
|
|
597
|
-
this.
|
|
597
|
+
this.connectionGeneration++;
|
|
598
598
|
|
|
599
599
|
this.synced = false;
|
|
600
600
|
this.isOnline(false);
|
|
601
|
-
this.logger('set connected flag to false');
|
|
602
601
|
if (this.isOnline()) {
|
|
603
602
|
this.emit('status', [{ status: 'disconnected' }]);
|
|
604
603
|
}
|
|
@@ -633,7 +632,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
633
632
|
if (!message) {
|
|
634
633
|
this.logger(`Permission denied to channel`);
|
|
635
634
|
}
|
|
636
|
-
this.logger('processed message (type = MessageAuth)');
|
|
637
635
|
}
|
|
638
636
|
|
|
639
637
|
public destroy() {
|
|
@@ -21,6 +21,14 @@ import {
|
|
|
21
21
|
} from 'react';
|
|
22
22
|
import { toast } from '../components/ui/sonner';
|
|
23
23
|
import { calendarQueryRange } from '../lib/calendar-day';
|
|
24
|
+
import type {
|
|
25
|
+
CalendarConnection,
|
|
26
|
+
CalendarOptimisticStatus,
|
|
27
|
+
OptimisticCalendarPatchOptions,
|
|
28
|
+
OptimisticCalendarState,
|
|
29
|
+
OptimisticCalendarSyncEvent,
|
|
30
|
+
CalendarSyncStatus as SyncStatus,
|
|
31
|
+
} from './calendar-connection-types';
|
|
24
32
|
import { runCalendarProviderSync } from './calendar-provider-sync';
|
|
25
33
|
import {
|
|
26
34
|
type CacheUpdate,
|
|
@@ -32,22 +40,10 @@ import {
|
|
|
32
40
|
calendarRangeCacheKey,
|
|
33
41
|
calendarRangeIncludesToday,
|
|
34
42
|
} from './calendar-sync-range';
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
ws_id: string;
|
|
40
|
-
calendar_id: string;
|
|
41
|
-
calendar_name: string;
|
|
42
|
-
is_enabled: boolean;
|
|
43
|
-
color: string | null;
|
|
44
|
-
provider?: 'google' | 'microsoft' | string;
|
|
45
|
-
auth_token_id?: string | null;
|
|
46
|
-
workspace_calendar_id?: string | null;
|
|
47
|
-
access_role?: string | null;
|
|
48
|
-
created_at: string;
|
|
49
|
-
updated_at: string;
|
|
50
|
-
};
|
|
43
|
+
import {
|
|
44
|
+
nativeOccurrencesKey,
|
|
45
|
+
useNativeCalendarOccurrences,
|
|
46
|
+
} from './use-native-calendar-occurrences';
|
|
51
47
|
|
|
52
48
|
// Extended CalendarEvent type with habit flags
|
|
53
49
|
export type CalendarEventWithHabitInfo = CalendarEvent & {
|
|
@@ -56,40 +52,7 @@ export type CalendarEventWithHabitInfo = CalendarEvent & {
|
|
|
56
52
|
_optimisticStatus?: CalendarOptimisticStatus;
|
|
57
53
|
};
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
type SyncStatus = {
|
|
61
|
-
state: 'idle' | 'syncing' | 'success' | 'error';
|
|
62
|
-
message?: string;
|
|
63
|
-
lastSyncTime?: Date;
|
|
64
|
-
direction?: 'google-to-tuturuuu' | 'tuturuuu-to-google' | 'both';
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
export type CalendarOptimisticStatus =
|
|
68
|
-
| 'creating'
|
|
69
|
-
| 'updating'
|
|
70
|
-
| 'deleting'
|
|
71
|
-
| 'error';
|
|
72
|
-
|
|
73
|
-
type OptimisticCalendarSyncEvent = Partial<
|
|
74
|
-
Omit<CalendarEvent, 'color' | 'description' | 'location'>
|
|
75
|
-
> &
|
|
76
|
-
Pick<CalendarEvent, 'id'> & {
|
|
77
|
-
color?: CalendarEvent['color'] | string | null;
|
|
78
|
-
description?: string | null;
|
|
79
|
-
location?: string | null;
|
|
80
|
-
_optimisticStatus?: CalendarOptimisticStatus;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
type OptimisticCalendarPatchOptions = {
|
|
84
|
-
removeIds?: string[];
|
|
85
|
-
clearIds?: string[];
|
|
86
|
-
status?: CalendarOptimisticStatus;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
type OptimisticCalendarState = {
|
|
90
|
-
events: Record<string, OptimisticCalendarSyncEvent>;
|
|
91
|
-
removedIds: string[];
|
|
92
|
-
};
|
|
55
|
+
export type { CalendarOptimisticStatus } from './calendar-connection-types';
|
|
93
56
|
|
|
94
57
|
const CalendarSyncContext = createContext<{
|
|
95
58
|
data: WorkspaceCalendarEvent[] | null;
|
|
@@ -222,6 +185,12 @@ export const CalendarSyncProvider = ({
|
|
|
222
185
|
const isForcedRef = useRef<boolean>(false);
|
|
223
186
|
const lastSyncTimeRef = useRef<number>(0);
|
|
224
187
|
const queryClient = useQueryClient();
|
|
188
|
+
const nativeOccurrences = useNativeCalendarOccurrences(
|
|
189
|
+
wsId,
|
|
190
|
+
dates,
|
|
191
|
+
timezone,
|
|
192
|
+
hasExternalEvents
|
|
193
|
+
);
|
|
225
194
|
|
|
226
195
|
// Calendar connections state
|
|
227
196
|
const [calendarConnections, setCalendarConnectionsState] = useState<
|
|
@@ -514,6 +483,7 @@ export const CalendarSyncProvider = ({
|
|
|
514
483
|
if (!activeCacheKey) return null;
|
|
515
484
|
|
|
516
485
|
isForcedRef.current = true;
|
|
486
|
+
queryClient.invalidateQueries({ queryKey: nativeOccurrencesKey(wsId) });
|
|
517
487
|
|
|
518
488
|
queryClient.invalidateQueries({
|
|
519
489
|
queryKey: ['databaseCalendarEvents', wsId, activeCacheKey],
|
|
@@ -641,8 +611,17 @@ export const CalendarSyncProvider = ({
|
|
|
641
611
|
() =>
|
|
642
612
|
hasExternalEvents
|
|
643
613
|
? ((externalEvents ?? []) as WorkspaceCalendarEvent[])
|
|
644
|
-
:
|
|
645
|
-
|
|
614
|
+
: [
|
|
615
|
+
...(fetchedData ?? activeCachedDatabaseEvents ?? []),
|
|
616
|
+
...nativeOccurrences.data,
|
|
617
|
+
],
|
|
618
|
+
[
|
|
619
|
+
activeCachedDatabaseEvents,
|
|
620
|
+
externalEvents,
|
|
621
|
+
fetchedData,
|
|
622
|
+
hasExternalEvents,
|
|
623
|
+
nativeOccurrences.data,
|
|
624
|
+
]
|
|
646
625
|
);
|
|
647
626
|
|
|
648
627
|
const visibleEventsWithOptimisticState = useMemo(() => {
|
|
@@ -768,20 +747,16 @@ export const CalendarSyncProvider = ({
|
|
|
768
747
|
const eventsWithoutAllDays = useMemo(() => {
|
|
769
748
|
// Process events immediately when they change
|
|
770
749
|
return events.filter((event) => {
|
|
771
|
-
|
|
772
|
-
// This is acceptable since this is used for layout purposes mainly
|
|
773
|
-
return !isAllDayEvent(event);
|
|
750
|
+
return !isAllDayEvent(event, timezone);
|
|
774
751
|
});
|
|
775
|
-
}, [events]);
|
|
752
|
+
}, [events, timezone]);
|
|
776
753
|
|
|
777
754
|
const allDayEvents = useMemo(() => {
|
|
778
755
|
// Process events immediately when they change
|
|
779
756
|
return events.filter((event) => {
|
|
780
|
-
|
|
781
|
-
// This is acceptable since this is used for layout purposes mainly
|
|
782
|
-
return isAllDayEvent(event);
|
|
757
|
+
return isAllDayEvent(event, timezone);
|
|
783
758
|
});
|
|
784
|
-
}, [events]);
|
|
759
|
+
}, [events, timezone]);
|
|
785
760
|
|
|
786
761
|
const syncToGoogle = useCallback(async () => {
|
|
787
762
|
toast.info('Provider events sync when you create or edit them.');
|
|
@@ -798,7 +773,7 @@ export const CalendarSyncProvider = ({
|
|
|
798
773
|
? ((externalEvents ?? []) as WorkspaceCalendarEvent[])
|
|
799
774
|
: (fetchedData ?? activeCachedDatabaseEvents ?? null),
|
|
800
775
|
googleData,
|
|
801
|
-
error,
|
|
776
|
+
error: error ?? nativeOccurrences.error,
|
|
802
777
|
dates,
|
|
803
778
|
setDates,
|
|
804
779
|
timezone,
|
|
@@ -828,7 +803,11 @@ export const CalendarSyncProvider = ({
|
|
|
828
803
|
syncStatus,
|
|
829
804
|
|
|
830
805
|
// Loading states
|
|
831
|
-
isLoading:
|
|
806
|
+
isLoading:
|
|
807
|
+
externalEventsLoading ||
|
|
808
|
+
isDatabaseLoading ||
|
|
809
|
+
isGoogleLoading ||
|
|
810
|
+
(!hasExternalEvents && dates.length > 0 && nativeOccurrences.isLoading),
|
|
832
811
|
isSyncing,
|
|
833
812
|
};
|
|
834
813
|
|