@tuturuuu/ui 0.37.2 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +9 -6
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  11. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  12. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  13. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  14. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  15. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  16. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  17. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  18. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  19. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  20. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  21. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  22. package/src/components/ui/popover.tsx +3 -1
  23. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  24. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  25. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  26. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  27. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  28. package/src/components/ui/text-editor/editor.tsx +8 -3
  29. package/src/components/ui/text-editor/extensions.ts +8 -2
  30. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  31. package/src/components/ui/text-editor/markdown.ts +11 -0
  32. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  33. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  34. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  35. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  36. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  37. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  38. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  39. package/src/hooks/calendar-creation-request.test.ts +62 -0
  40. package/src/hooks/calendar-creation-request.ts +34 -0
  41. package/src/hooks/calendar-event-write-payload.ts +24 -0
  42. package/src/hooks/notification-subscription.ts +5 -2
  43. package/src/hooks/supabase-provider.ts +38 -72
  44. package/src/hooks/use-calendar.tsx +21 -45
  45. package/src/hooks/use-exchange-rates.ts +4 -15
  46. package/src/hooks/use-workspace-presence.ts +9 -9
  47. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -1,5 +1,8 @@
1
+ import {
2
+ createRealtimeClient,
3
+ type RealtimeChannel,
4
+ } from '@tuturuuu/internal-api/realtime';
1
5
  import type { SupabaseClient } from '@tuturuuu/supabase/next/client';
2
- import type { RealtimeChannel } from '@tuturuuu/supabase/next/realtime';
3
6
  import debug from 'debug';
4
7
  import { EventEmitter } from 'eventemitter3';
5
8
  import * as awarenessProtocol from 'y-protocols/awareness';
@@ -22,12 +25,15 @@ export interface SupabaseProviderConfig {
22
25
  broadcastDebounceMs?: number; // Debounce time for broadcasting updates (0 = immediate, default: 0)
23
26
  loadState?: () => Promise<number[] | null>;
24
27
  saveState?: (state: number[]) => Promise<boolean | undefined>;
28
+ ownsDocument?: boolean;
25
29
  }
26
30
 
27
31
  export default class SupabaseProvider extends EventEmitter {
28
32
  public awareness: awarenessProtocol.Awareness;
29
33
  public connected = false;
30
34
  private channel: RealtimeChannel | null = null;
35
+ private realtime = createRealtimeClient();
36
+ private pendingAwarenessClients = new Set<number>();
31
37
 
32
38
  private _synced: boolean = false;
33
39
  private resyncInterval: NodeJS.Timeout | undefined;
@@ -71,13 +77,11 @@ export default class SupabaseProvider extends EventEmitter {
71
77
  return;
72
78
  }
73
79
 
74
- // Only broadcast and save if connected and not destroyed
75
80
  if (!this.connected || this.destroyed) {
76
81
  this.logger('skipping broadcast - not connected or destroyed');
77
82
  return;
78
83
  }
79
84
 
80
- // Validate update size (avoid broadcasting empty or corrupted updates)
81
85
  if (!update || update.length === 0) {
82
86
  this.logger('skipping broadcast - empty update');
83
87
  return;
@@ -91,8 +95,6 @@ export default class SupabaseProvider extends EventEmitter {
91
95
  );
92
96
 
93
97
  if (this.broadcastDebounceMs > 0) {
94
- // Debounced broadcast for free tier — merge rapid incremental edits so
95
- // peers do not miss insertion structs that later updates depend on.
96
98
  this.pendingBroadcastUpdate = this.pendingBroadcastUpdate
97
99
  ? Y.mergeUpdates([this.pendingBroadcastUpdate, update])
98
100
  : update;
@@ -110,7 +112,6 @@ export default class SupabaseProvider extends EventEmitter {
110
112
  }
111
113
  }, this.broadcastDebounceMs);
112
114
  } else {
113
- // Immediate broadcast for paid tier
114
115
  this.emit('message', update);
115
116
  }
116
117
 
@@ -118,12 +119,10 @@ export default class SupabaseProvider extends EventEmitter {
118
119
  }
119
120
 
120
121
  debouncedSave() {
121
- // Clear any pending save
122
122
  if (this.saveTimeout) {
123
123
  clearTimeout(this.saveTimeout);
124
124
  }
125
125
 
126
- // Schedule a new save after the debounce period
127
126
  this.saveTimeout = setTimeout(() => {
128
127
  this.saveTimeout = undefined;
129
128
  this.logger('debounce period elapsed, saving to database');
@@ -131,12 +130,7 @@ export default class SupabaseProvider extends EventEmitter {
131
130
  }, this.saveDebounceMs);
132
131
  }
133
132
 
134
- /**
135
- * Immediately save any pending changes without waiting for debounce period.
136
- * Also flushes any pending broadcast.
137
- */
138
133
  async flushSave() {
139
- // Flush pending broadcast first
140
134
  if (this.broadcastDebounceTimeout) {
141
135
  clearTimeout(this.broadcastDebounceTimeout);
142
136
  this.broadcastDebounceTimeout = undefined;
@@ -156,24 +150,30 @@ export default class SupabaseProvider extends EventEmitter {
156
150
  }
157
151
  }
158
152
 
159
- onAwarenessUpdate({ added, updated, removed }: any, _origin: any) {
160
- const changedClients = added.concat(updated).concat(removed);
161
- const awarenessUpdate = awarenessProtocol.encodeAwarenessUpdate(
162
- this.awareness,
163
- changedClients
164
- );
165
-
166
- // Debounce awareness broadcasts to coalesce rapid cursor/selection changes
167
- if (this.awarenessDebounceTimeout) {
168
- clearTimeout(this.awarenessDebounceTimeout);
169
- }
153
+ onAwarenessUpdate(
154
+ {
155
+ added,
156
+ updated,
157
+ removed,
158
+ }: { added: number[]; updated: number[]; removed: number[] },
159
+ origin: unknown
160
+ ) {
161
+ if (origin === this || this.destroyed) return;
162
+ for (const id of [...added, ...updated, ...removed])
163
+ this.pendingAwarenessClients.add(id);
164
+ if (this.awarenessDebounceTimeout) return;
170
165
  this.awarenessDebounceTimeout = setTimeout(() => {
171
- this.emit('awareness', awarenessUpdate);
166
+ this.awarenessDebounceTimeout = undefined;
167
+ const clients = [...this.pendingAwarenessClients];
168
+ this.pendingAwarenessClients.clear();
169
+ this.emit(
170
+ 'awareness',
171
+ awarenessProtocol.encodeAwarenessUpdate(this.awareness, clients)
172
+ );
172
173
  }, 150);
173
174
  }
174
175
 
175
176
  removeSelfFromAwarenessOnUnload() {
176
- // Flush any pending saves before unload
177
177
  this.flushSave();
178
178
  awarenessProtocol.removeAwarenessStates(
179
179
  this.awareness,
@@ -184,7 +184,6 @@ export default class SupabaseProvider extends EventEmitter {
184
184
 
185
185
  async save() {
186
186
  try {
187
- // Don't save if not connected or destroyed
188
187
  if (!this.connected || this.destroyed) {
189
188
  this.logger('skipping save - not connected or destroyed');
190
189
  return false;
@@ -192,13 +191,11 @@ export default class SupabaseProvider extends EventEmitter {
192
191
 
193
192
  const content = Array.from(Y.encodeStateAsUpdate(this.doc));
194
193
 
195
- // Skip save if content is empty or too small (likely invalid)
196
194
  if (!content || content.length === 0) {
197
195
  this.logger('skipping save - empty content');
198
196
  return false;
199
197
  }
200
198
 
201
- // Skip save if content is suspiciously small (might be corrupted)
202
199
  if (content.length < 10) {
203
200
  this.logger('skipping save - content too small, possibly corrupted');
204
201
  return false;
@@ -229,7 +226,6 @@ export default class SupabaseProvider extends EventEmitter {
229
226
  if (error) {
230
227
  this.logger(`save failed with status ${status}:`, error);
231
228
 
232
- // Handle 422 errors - task was deleted, validation failed, or RLS rejected
233
229
  if (status === 422) {
234
230
  console.warn(
235
231
  `Failed to save Yjs state (422 - Unprocessable Entity):`,
@@ -241,15 +237,12 @@ export default class SupabaseProvider extends EventEmitter {
241
237
  }
242
238
  );
243
239
 
244
- // Mark as not synced so UI shows warning
245
240
  this.synced = false;
246
241
 
247
- // Stop trying to save this document
248
242
  this.logger('stopping future saves due to 422 error');
249
243
  return false;
250
244
  }
251
245
 
252
- // Handle 404 errors - task doesn't exist
253
246
  if (status === 404) {
254
247
  console.warn(`Task ${this.config.id} not found, stopping saves`);
255
248
  this.synced = false;
@@ -278,7 +271,6 @@ export default class SupabaseProvider extends EventEmitter {
278
271
  private async onConnect() {
279
272
  this.logger('connected');
280
273
 
281
- // Reset reconnect attempts on successful connection
282
274
  this.reconnectAttempts = 0;
283
275
  if (this.reconnectTimeout) {
284
276
  clearTimeout(this.reconnectTimeout);
@@ -312,7 +304,6 @@ export default class SupabaseProvider extends EventEmitter {
312
304
  this.logger('setting connected flag to true');
313
305
  this.isOnline(true);
314
306
 
315
- // Mark as synced after loading initial data
316
307
  this.synced = true;
317
308
 
318
309
  this.emit('status', [{ status: 'connected' }]);
@@ -331,9 +322,6 @@ export default class SupabaseProvider extends EventEmitter {
331
322
  this.version++;
332
323
  Y.applyUpdate(this.doc, update, origin);
333
324
  } catch (error) {
334
- // Handle DOM reconciliation errors during Yjs sync
335
- // This happens when ProseMirror/Tiptap tries to manipulate DOM nodes
336
- // that React has already cleaned up during reconnection scenarios
337
325
  if (
338
326
  error instanceof DOMException &&
339
327
  error.name === 'NotFoundError' &&
@@ -343,33 +331,31 @@ export default class SupabaseProvider extends EventEmitter {
343
331
  'DOM reconciliation error during Yjs update - this can happen after AFK reconnection'
344
332
  );
345
333
  console.warn(
346
- '[SupabaseProvider] DOM reconciliation error during Yjs sync. ' +
334
+ '[Cloudflare collaboration] DOM reconciliation error during Yjs sync. ' +
347
335
  'This is usually harmless and occurs when the editor reconnects after being idle.'
348
336
  );
349
- // Emit event so the component can handle recovery if needed
350
337
  this.emit('dom-error', error);
351
338
  return;
352
339
  }
353
- // Re-throw other errors
354
340
  throw error;
355
341
  }
356
342
  }
357
343
 
358
344
  private disconnect() {
359
345
  if (this.channel) {
360
- this.supabase.removeChannel(this.channel);
346
+ this.realtime.removeChannel(this.channel);
361
347
  this.channel = null;
362
348
  }
363
349
  }
364
350
 
365
351
  private connect() {
366
- this.channel = this.supabase.channel(this.config.channel);
352
+ this.channel = this.realtime.channel(this.config.channel);
367
353
  if (this.channel) {
368
354
  this.channel
369
- .on('broadcast', { event: 'message' }, ({ payload }) => {
355
+ .on<number[]>('broadcast', { event: 'message' }, ({ payload }) => {
370
356
  this.onMessage(Uint8Array.from(payload), this);
371
357
  })
372
- .on('broadcast', { event: 'awareness' }, ({ payload }) => {
358
+ .on<number[]>('broadcast', { event: 'awareness' }, ({ payload }) => {
373
359
  this.onAwareness(Uint8Array.from(payload));
374
360
  })
375
361
  .subscribe((status, err) => {
@@ -420,7 +406,6 @@ export default class SupabaseProvider extends EventEmitter {
420
406
  this.config = config || {};
421
407
  this.id = doc.clientID;
422
408
 
423
- // Initialize debounce times
424
409
  (this as any).saveDebounceMs = this.config.saveDebounceMs ?? 1000;
425
410
  (this as any).broadcastDebounceMs = this.config.broadcastDebounceMs ?? 0;
426
411
 
@@ -429,11 +414,10 @@ export default class SupabaseProvider extends EventEmitter {
429
414
  this.on('disconnect', this.onDisconnect);
430
415
 
431
416
  this.logger = debug(`y-${doc.clientID}`);
432
- // turn on debug logging to the console
433
417
  this.logger.enabled = true;
434
418
 
435
419
  this.logger('constructor initializing');
436
- this.logger('connecting to Supabase Realtime', doc.guid);
420
+ this.logger('connecting to Cloudflare realtime', doc.guid);
437
421
  this.logger(`save debounce set to ${this.saveDebounceMs}ms`);
438
422
 
439
423
  if (
@@ -448,19 +432,16 @@ export default class SupabaseProvider extends EventEmitter {
448
432
  `setting resync interval to every ${interval / 1000} seconds`
449
433
  );
450
434
  this.resyncInterval = setInterval(() => {
451
- // Only resync if connected and not destroyed
452
435
  if (!this.connected || this.destroyed) {
453
436
  this.logger('skipping resync - not connected or destroyed');
454
437
  return;
455
438
  }
456
439
 
457
- // Skip resync when page is hidden (no one is watching)
458
440
  if (!isPageVisible()) {
459
441
  this.logger('skipping resync - page not visible');
460
442
  return;
461
443
  }
462
444
 
463
- // Skip resync if no local changes since last resync
464
445
  if (!this._dirty) {
465
446
  this.logger('skipping resync - no local changes');
466
447
  return;
@@ -471,7 +452,6 @@ export default class SupabaseProvider extends EventEmitter {
471
452
  this.logger('resyncing (resync interval elapsed)');
472
453
  const update = Y.encodeStateAsUpdate(this.doc);
473
454
 
474
- // Validate update before broadcasting
475
455
  if (!update || update.length === 0) {
476
456
  this.logger('skipping resync - empty update');
477
457
  return;
@@ -497,9 +477,7 @@ export default class SupabaseProvider extends EventEmitter {
497
477
  process.on('exit', this.processExitHandler);
498
478
  }
499
479
  this.on('awareness', (update) => {
500
- // Only broadcast if connected and channel exists
501
480
  if (this.connected && this.channel && !this.destroyed) {
502
- // Validate update before broadcasting
503
481
  if (update && update.length > 0) {
504
482
  this.channel.send({
505
483
  type: 'broadcast',
@@ -510,9 +488,7 @@ export default class SupabaseProvider extends EventEmitter {
510
488
  }
511
489
  });
512
490
  this.on('message', (update) => {
513
- // Only broadcast if connected and channel exists
514
491
  if (this.connected && this.channel && !this.destroyed) {
515
- // Validate update before broadcasting
516
492
  if (update && update.length > 0) {
517
493
  this.channel.send({
518
494
  type: 'broadcast',
@@ -525,7 +501,6 @@ export default class SupabaseProvider extends EventEmitter {
525
501
 
526
502
  this.connect();
527
503
 
528
- // Reconnect when user returns from AFK (hidden → visible)
529
504
  if (typeof document !== 'undefined') {
530
505
  this._handleVisibilityChange = () => {
531
506
  if (this.destroyed) return;
@@ -535,7 +510,6 @@ export default class SupabaseProvider extends EventEmitter {
535
510
  this.resetAndReconnect();
536
511
  }
537
512
 
538
- // Pause reconnect timer when page goes hidden to conserve attempts
539
513
  if (document.visibilityState === 'hidden' && this.reconnectTimeout) {
540
514
  this.logger('page hidden during reconnect backoff — pausing timer');
541
515
  clearTimeout(this.reconnectTimeout);
@@ -550,7 +524,6 @@ export default class SupabaseProvider extends EventEmitter {
550
524
 
551
525
  this.awareness.on('update', this.boundAwarenessUpdate);
552
526
 
553
- // Listen to document updates and broadcast them to peers
554
527
  this.doc.on('update', this.boundDocumentUpdate);
555
528
  }
556
529
 
@@ -583,7 +556,6 @@ export default class SupabaseProvider extends EventEmitter {
583
556
  return;
584
557
  }
585
558
 
586
- // Exponential backoff with jitter
587
559
  const delay = Math.min(
588
560
  this.reconnectDelay * 2 ** this.reconnectAttempts,
589
561
  this.maxReconnectDelay
@@ -600,19 +572,12 @@ export default class SupabaseProvider extends EventEmitter {
600
572
  this.reconnectAttempts++;
601
573
  this.logger(`reconnect attempt ${this.reconnectAttempts}`);
602
574
 
603
- // Disconnect old channel if it exists
604
575
  this.disconnect();
605
576
 
606
- // Try to reconnect
607
577
  this.connect();
608
578
  }, actualDelay);
609
579
  }
610
580
 
611
- /**
612
- * Reset backoff state and force a fresh connection cycle.
613
- * Used when the user returns from AFK (visibilitychange) or when
614
- * reconnect-failed fires while the page is still active.
615
- */
616
581
  public resetAndReconnect() {
617
582
  if (this.destroyed || this.connected) return;
618
583
 
@@ -638,8 +603,6 @@ export default class SupabaseProvider extends EventEmitter {
638
603
  this.emit('status', [{ status: 'disconnected' }]);
639
604
  }
640
605
 
641
- // update awareness (keep all users except local)
642
- // FIXME? compare to broadcast channel behavior
643
606
  const states = Array.from(this.awareness.getStates().keys()).filter(
644
607
  (client) => client !== this.doc.clientID
645
608
  );
@@ -674,7 +637,9 @@ export default class SupabaseProvider extends EventEmitter {
674
637
  }
675
638
 
676
639
  public destroy() {
640
+ if (this.destroyed) return;
677
641
  this.logger('destroying');
642
+ const pendingSave = this.flushSave();
678
643
  this.destroyed = true;
679
644
 
680
645
  // Clear reconnect timeout
@@ -696,9 +661,6 @@ export default class SupabaseProvider extends EventEmitter {
696
661
  }
697
662
  this.pendingBroadcastUpdate = undefined;
698
663
 
699
- // Save any pending changes before destroying
700
- this.flushSave();
701
-
702
664
  // Remove visibility change listener
703
665
  if (this._handleVisibilityChange && typeof document !== 'undefined') {
704
666
  document.removeEventListener(
@@ -721,5 +683,9 @@ export default class SupabaseProvider extends EventEmitter {
721
683
  this.doc.off('update', this.boundDocumentUpdate);
722
684
 
723
685
  if (this.channel) this.disconnect();
686
+ if (this.config.ownsDocument) {
687
+ this.awareness.destroy();
688
+ void pendingSave.finally(() => this.doc.destroy());
689
+ }
724
690
  }
725
691
  }
@@ -3,7 +3,6 @@ import {
3
3
  deleteWorkspaceCalendarEvent,
4
4
  updateWorkspaceCalendarEvent,
5
5
  type WorkspaceCalendarEventCreatePayload,
6
- type WorkspaceCalendarEventUpdatePayload,
7
6
  } from '@tuturuuu/internal-api';
8
7
  import { createClient } from '@tuturuuu/supabase/next/client';
9
8
  import type {
@@ -28,8 +27,13 @@ import {
28
27
  useState,
29
28
  } from 'react';
30
29
  import { getTaskApiUrl } from '../lib/tasks-app-url';
30
+ import {
31
+ createCalendarCreationRequests,
32
+ createOptimisticEventId,
33
+ } from './calendar-creation-request';
31
34
  import { roundToNearest15Minutes } from './calendar-date-utils';
32
35
  import { createCalendarEventLookup } from './calendar-event-lookup';
36
+ import { calendarEventUpdatePayload } from './calendar-event-write-payload';
33
37
  import { useCalendarSync } from './use-calendar-sync';
34
38
  import { useOpenInitialCalendarEvent } from './use-open-initial-calendar-event';
35
39
 
@@ -39,17 +43,6 @@ type TaskDragData = {
39
43
  totalDuration?: number;
40
44
  };
41
45
 
42
- const createOptimisticEventId = () => {
43
- if (
44
- typeof crypto !== 'undefined' &&
45
- typeof crypto.randomUUID === 'function'
46
- ) {
47
- return `optimistic-${crypto.randomUUID()}`;
48
- }
49
-
50
- return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
51
- };
52
-
53
46
  const noStoreFetchOptions = {
54
47
  fetch: (input: RequestInfo | URL, init?: RequestInit) =>
55
48
  fetch(input, { ...init, cache: 'no-store' }),
@@ -101,7 +94,8 @@ const CalendarContext = createContext<{
101
94
  getGoogleEvents: () => CalendarEvent[];
102
95
  getEventLevel: (eventId: string) => number;
103
96
  addEvent: (
104
- event: Omit<CalendarEvent, 'id'>
97
+ event: Omit<CalendarEvent, 'id'>,
98
+ options?: { requestId?: string }
105
99
  ) => Promise<CalendarEvent | undefined>;
106
100
  addEmptyEvent: (date: Date, isAllDay?: boolean) => CalendarEvent;
107
101
  addEmptyEventWithDuration: (startDate: Date, endDate: Date) => CalendarEvent;
@@ -265,11 +259,7 @@ export type CalendarEventAdapter = {
265
259
  ) => Promise<CalendarEvent | undefined> | CalendarEvent | undefined;
266
260
  };
267
261
 
268
- /**
269
- * Syncs task total_duration after a calendar event is resized or moved.
270
- * - Uses canonical workspace calendar events as the source of truth
271
- * - If total scheduled time exceeds task's total_duration, auto-increase it
272
- */
262
+ /** Increase task duration when its canonical calendar schedule exceeds it. */
273
263
  async function syncTaskDurationAfterEventChange(
274
264
  supabase: any,
275
265
  eventId: string,
@@ -442,6 +432,7 @@ export const CalendarProvider = ({
442
432
  const [activeEventId, setActiveEventId] = useState<string | null>(null);
443
433
  const [previewEventId, setPreviewEventId] = useState<string | null>(null);
444
434
  const [isModalHidden, setModalHidden] = useState(false);
435
+ const creationRequests = useRef(createCalendarCreationRequests());
445
436
  const [pendingNewEvent, setPendingNewEvent] =
446
437
  useState<Partial<CalendarEvent> | null>(null);
447
438
  const [defaultNewEventTab, setDefaultNewEventTab] = useState<'manual' | 'ai'>(
@@ -583,13 +574,15 @@ export const CalendarProvider = ({
583
574
 
584
575
  // CRUD operations with Supabase
585
576
  const addEvent = useCallback(
586
- async (event: Omit<CalendarEvent, 'id'>) => {
577
+ async (
578
+ event: Omit<CalendarEvent, 'id'>,
579
+ options?: { requestId?: string }
580
+ ) => {
587
581
  if (readOnly) {
588
582
  console.warn('Calendar is in read-only mode');
589
583
  return undefined;
590
584
  }
591
585
 
592
- // Round start and end times to nearest 15-minute interval
593
586
  const startDate = roundToNearest15Minutes(new Date(event.start_at));
594
587
  const endDate = roundToNearest15Minutes(new Date(event.end_at));
595
588
 
@@ -619,7 +612,13 @@ export const CalendarProvider = ({
619
612
  locked: true,
620
613
  task_id: (event as CalendarEvent & { task_id?: string | null }).task_id,
621
614
  source: event.source,
615
+ ...(event.providerColor && { providerColor: event.providerColor }),
622
616
  };
617
+ if (event.providerColor) delete payload.color;
618
+ payload.requestId = creationRequests.current.forAttempt(
619
+ { wsId: ws.id, ...payload },
620
+ options?.requestId
621
+ );
623
622
  const optimisticId = createOptimisticEventId();
624
623
  const optimisticEvent = {
625
624
  ...event,
@@ -670,10 +669,7 @@ export const CalendarProvider = ({
670
669
 
671
670
  const addEmptyEvent = useCallback(
672
671
  (date: Date, isAllDay?: boolean) => {
673
- // NOTE: This implementation uses createAllDayEvent helper for proper timezone handling
674
- // This ensures all-day events are created at midnight in the user's timezone rather than UTC
675
- // The workaround is necessary because dayjs timezone handling for all-day events can be inconsistent
676
- // across different browsers and timezone configurations
672
+ // Keep all-day boundaries in the user timezone.
677
673
  const selectedDate = dayjs(date);
678
674
 
679
675
  let start_at: string;
@@ -701,10 +697,8 @@ export const CalendarProvider = ({
701
697
  end_at = endTime.toISOString();
702
698
  }
703
699
 
704
- // Use default color
705
700
  const defaultColor = 'BLUE';
706
701
 
707
- // Create a new event with default values
708
702
  const newEvent: CalendarEvent = {
709
703
  id: 'new',
710
704
  title: '',
@@ -736,14 +730,11 @@ export const CalendarProvider = ({
736
730
 
737
731
  const addEmptyEventWithDuration = useCallback(
738
732
  (startDate: Date, endDate: Date) => {
739
- // Round start and end times to nearest 15-minute interval
740
733
  const roundedStartDate = roundToNearest15Minutes(startDate);
741
734
  const roundedEndDate = roundToNearest15Minutes(endDate);
742
735
 
743
- // Use default color
744
736
  const defaultColor = 'BLUE';
745
737
 
746
- // Create a new event with default values
747
738
  const newEvent: CalendarEvent = {
748
739
  id: 'new',
749
740
  title: '',
@@ -832,22 +823,7 @@ export const CalendarProvider = ({
832
823
 
833
824
  try {
834
825
  // Clean up the update data to ensure no undefined values and exclude system fields
835
- const cleanUpdateData: WorkspaceCalendarEventUpdatePayload = {
836
- ...(updateData.title !== undefined && { title: updateData.title }),
837
- ...(updateData.description !== undefined && {
838
- description: updateData.description,
839
- }),
840
- ...(updateData.start_at !== undefined && {
841
- start_at: updateData.start_at,
842
- }),
843
- ...(updateData.end_at !== undefined && { end_at: updateData.end_at }),
844
- ...(updateData.color !== undefined && { color: updateData.color }),
845
- ...(updateData.location !== undefined && {
846
- location: updateData.location,
847
- }),
848
- ...(updateData.locked !== undefined && { locked: updateData.locked }),
849
- ...(updateData.source !== undefined && { source: updateData.source }),
850
- };
826
+ const cleanUpdateData = calendarEventUpdatePayload(updateData);
851
827
 
852
828
  // ws is guaranteed to be defined here (validated above at line 732)
853
829
  const wsId = ws!.id;
@@ -1,23 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery } from '@tanstack/react-query';
4
- import type { ExchangeRate } from '@tuturuuu/utils/exchange-rates';
5
-
6
- interface ExchangeRatesResponse {
7
- data: ExchangeRate[];
8
- date: string | null;
9
- }
4
+ import { getExchangeRates } from '@tuturuuu/internal-api/finance-exchange-rates';
10
5
 
11
6
  export function useExchangeRates() {
12
- return useQuery<ExchangeRatesResponse>({
7
+ return useQuery({
13
8
  queryKey: ['exchange-rates', 'latest'],
14
- queryFn: async () => {
15
- const res = await fetch('/api/v1/exchange-rates', {
16
- cache: 'no-store',
17
- });
18
- if (!res.ok) throw new Error('Failed to fetch exchange rates');
19
- return res.json();
20
- },
21
- staleTime: 60 * 60 * 1000, // 1 hour — rates update daily
9
+ queryFn: () => getExchangeRates(),
10
+ staleTime: 60 * 60 * 1000,
22
11
  });
23
12
  }
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
+ import type { RealtimePresenceState } from '@tuturuuu/internal-api/realtime';
4
+ import { createRealtimeClient } from '@tuturuuu/internal-api/realtime';
3
5
  import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
4
- import { createClient } from '@tuturuuu/supabase/next/client';
5
- import type { RealtimePresenceState } from '@tuturuuu/supabase/next/realtime';
6
6
  import type { User } from '@tuturuuu/types/primitives/User';
7
7
  import { DEV_MODE } from '@tuturuuu/utils/constants';
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -149,14 +149,14 @@ export function useWorkspacePresence({
149
149
  if (setupPromiseRef.current) return setupPromiseRef.current;
150
150
 
151
151
  const promise = (async (): Promise<boolean> => {
152
- const supabase = createClient();
152
+ const realtime = createRealtimeClient();
153
153
 
154
154
  try {
155
155
  if (isCleanedUpRef.current) return false;
156
156
 
157
157
  const {
158
158
  data: { user },
159
- } = await supabase.auth.getUser();
159
+ } = await realtime.auth.getUser();
160
160
 
161
161
  if (!user?.id || isCleanedUpRef.current) return false;
162
162
 
@@ -184,12 +184,12 @@ export function useWorkspacePresence({
184
184
 
185
185
  // Clean up any stale channel
186
186
  if (channelRef.current) {
187
- await supabase.removeChannel(channelRef.current);
187
+ await realtime.removeChannel(channelRef.current);
188
188
  channelRef.current = null;
189
189
  lastTrackSignatureRef.current = null;
190
190
  }
191
191
 
192
- const channel = supabase.channel(channelKey, {
192
+ const channel = realtime.channel(channelKey, {
193
193
  config: {
194
194
  presence: {
195
195
  key: user.id,
@@ -238,7 +238,7 @@ export function useWorkspacePresence({
238
238
  setupPromiseRef.current = null;
239
239
  lastTrackSignatureRef.current = null;
240
240
  if (deadCh) {
241
- supabase.removeChannel(deadCh).catch(() => {});
241
+ realtime.removeChannel(deadCh).catch(() => {});
242
242
  }
243
243
  if (
244
244
  retryCountRef.current < MAX_RETRIES &&
@@ -269,7 +269,7 @@ export function useWorkspacePresence({
269
269
  setupPromiseRef.current = null;
270
270
  lastTrackSignatureRef.current = null;
271
271
  if (deadCh) {
272
- supabase.removeChannel(deadCh).catch(() => {});
272
+ realtime.removeChannel(deadCh).catch(() => {});
273
273
  }
274
274
  // Attempt reconnection
275
275
  if (retryCountRef.current < MAX_RETRIES) {
@@ -320,7 +320,7 @@ export function useWorkspacePresence({
320
320
  setupPromiseRef.current = null;
321
321
  if (channelRef.current) {
322
322
  channelRef.current.untrack?.().catch(() => {});
323
- createClient().removeChannel(channelRef.current);
323
+ createRealtimeClient().removeChannel(channelRef.current);
324
324
  channelRef.current = null;
325
325
  lastTrackSignatureRef.current = null;
326
326
  }