@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,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 persistedState = this.config.loadState
281
- ? await this.config.loadState()
282
- : await (async () => {
283
- const { data, status } = await this.supabase
284
- .from(this.config.tableName as any)
285
- .select<string, { [key: string]: number[] }>(
286
- `${this.config.columnName}`
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.logger('constructor initializing');
420
- this.logger('connecting to Cloudflare realtime', doc.guid);
421
- this.logger(`save debounce set to ${this.saveDebounceMs}ms`);
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.logger('disconnected');
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
- // Type for calendar connection
37
- type CalendarConnection = {
38
- id: string;
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
- // Sync status type
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
- : (fetchedData ?? activeCachedDatabaseEvents ?? []),
645
- [activeCachedDatabaseEvents, externalEvents, fetchedData, hasExternalEvents]
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
- // Note: We can't access settings here easily, so we use default timezone detection
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
- // Note: We can't access settings here easily, so we use default timezone detection
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: externalEventsLoading || isDatabaseLoading || isGoogleLoading,
806
+ isLoading:
807
+ externalEventsLoading ||
808
+ isDatabaseLoading ||
809
+ isGoogleLoading ||
810
+ (!hasExternalEvents && dates.length > 0 && nativeOccurrences.isLoading),
832
811
  isSyncing,
833
812
  };
834
813