@tuturuuu/ui 0.37.2 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  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/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -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
 
@@ -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 {
@@ -27,9 +26,21 @@ import {
27
26
  useRef,
28
27
  useState,
29
28
  } from 'react';
29
+ import { NativeRecurrenceContextMenu } from '../components/ui/legacy/calendar/native-recurrence-context-menu';
30
30
  import { getTaskApiUrl } from '../lib/tasks-app-url';
31
+ import {
32
+ createCalendarCreationRequests,
33
+ createOptimisticEventId,
34
+ } from './calendar-creation-request';
31
35
  import { roundToNearest15Minutes } from './calendar-date-utils';
32
36
  import { createCalendarEventLookup } from './calendar-event-lookup';
37
+ import type { PendingEventUpdate } from './calendar-event-update-queue-types';
38
+ import {
39
+ assertOrdinaryCalendarEvent,
40
+ calendarEventByIdentity,
41
+ calendarEventUpdatePayload,
42
+ cleanCalendarEventUpdates,
43
+ } from './calendar-event-write-payload';
33
44
  import { useCalendarSync } from './use-calendar-sync';
34
45
  import { useOpenInitialCalendarEvent } from './use-open-initial-calendar-event';
35
46
 
@@ -39,17 +50,6 @@ type TaskDragData = {
39
50
  totalDuration?: number;
40
51
  };
41
52
 
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
53
  const noStoreFetchOptions = {
54
54
  fetch: (input: RequestInfo | URL, init?: RequestInit) =>
55
55
  fetch(input, { ...init, cache: 'no-store' }),
@@ -101,7 +101,8 @@ const CalendarContext = createContext<{
101
101
  getGoogleEvents: () => CalendarEvent[];
102
102
  getEventLevel: (eventId: string) => number;
103
103
  addEvent: (
104
- event: Omit<CalendarEvent, 'id'>
104
+ event: Omit<CalendarEvent, 'id'>,
105
+ options?: { requestId?: string }
105
106
  ) => Promise<CalendarEvent | undefined>;
106
107
  addEmptyEvent: (date: Date, isAllDay?: boolean) => CalendarEvent;
107
108
  addEmptyEventWithDuration: (startDate: Date, endDate: Date) => CalendarEvent;
@@ -235,18 +236,6 @@ const CalendarContext = createContext<{
235
236
  readOnly: false,
236
237
  });
237
238
 
238
- // Add this interface before the updateEvent function
239
- interface PendingEventUpdate extends Partial<CalendarEvent> {
240
- _updateId?: string;
241
- _timestamp: number;
242
- _eventId: string;
243
- _previousEvent?: CalendarEvent;
244
- _resolvers?: Array<{
245
- resolve: (value: CalendarEvent) => void;
246
- reject: (reason: unknown) => void;
247
- }>;
248
- }
249
-
250
239
  export type CalendarEventAdapter = {
251
240
  disableBuiltInEventUi?: boolean;
252
241
  preservePastEventOpacity?: boolean;
@@ -265,11 +254,7 @@ export type CalendarEventAdapter = {
265
254
  ) => Promise<CalendarEvent | undefined> | CalendarEvent | undefined;
266
255
  };
267
256
 
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
- */
257
+ /** Increase task duration when its canonical calendar schedule exceeds it. */
273
258
  async function syncTaskDurationAfterEventChange(
274
259
  supabase: any,
275
260
  eventId: string,
@@ -442,6 +427,7 @@ export const CalendarProvider = ({
442
427
  const [activeEventId, setActiveEventId] = useState<string | null>(null);
443
428
  const [previewEventId, setPreviewEventId] = useState<string | null>(null);
444
429
  const [isModalHidden, setModalHidden] = useState(false);
430
+ const creationRequests = useRef(createCalendarCreationRequests());
445
431
  const [pendingNewEvent, setPendingNewEvent] =
446
432
  useState<Partial<CalendarEvent> | null>(null);
447
433
  const [defaultNewEventTab, setDefaultNewEventTab] = useState<'manual' | 'ai'>(
@@ -493,11 +479,7 @@ export const CalendarProvider = ({
493
479
  // Event getters
494
480
  const getEvent = useCallback(
495
481
  (eventId: string) => {
496
- // Handle IDs for split multi-day events (they contain a dash and date)
497
- const originalId = eventId.includes('-')
498
- ? eventId.split('-')[0]
499
- : eventId;
500
- return events.find((e: Partial<CalendarEvent>) => e.id === originalId);
482
+ return calendarEventByIdentity(events, eventId);
501
483
  },
502
484
  [events]
503
485
  );
@@ -583,13 +565,15 @@ export const CalendarProvider = ({
583
565
 
584
566
  // CRUD operations with Supabase
585
567
  const addEvent = useCallback(
586
- async (event: Omit<CalendarEvent, 'id'>) => {
568
+ async (
569
+ event: Omit<CalendarEvent, 'id'>,
570
+ options?: { requestId?: string }
571
+ ) => {
587
572
  if (readOnly) {
588
573
  console.warn('Calendar is in read-only mode');
589
574
  return undefined;
590
575
  }
591
576
 
592
- // Round start and end times to nearest 15-minute interval
593
577
  const startDate = roundToNearest15Minutes(new Date(event.start_at));
594
578
  const endDate = roundToNearest15Minutes(new Date(event.end_at));
595
579
 
@@ -619,7 +603,13 @@ export const CalendarProvider = ({
619
603
  locked: true,
620
604
  task_id: (event as CalendarEvent & { task_id?: string | null }).task_id,
621
605
  source: event.source,
606
+ ...(event.providerColor && { providerColor: event.providerColor }),
622
607
  };
608
+ if (event.providerColor) delete payload.color;
609
+ payload.requestId = creationRequests.current.forAttempt(
610
+ { wsId: ws.id, ...payload },
611
+ options?.requestId
612
+ );
623
613
  const optimisticId = createOptimisticEventId();
624
614
  const optimisticEvent = {
625
615
  ...event,
@@ -670,10 +660,7 @@ export const CalendarProvider = ({
670
660
 
671
661
  const addEmptyEvent = useCallback(
672
662
  (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
663
+ // Keep all-day boundaries in the user timezone.
677
664
  const selectedDate = dayjs(date);
678
665
 
679
666
  let start_at: string;
@@ -701,10 +688,8 @@ export const CalendarProvider = ({
701
688
  end_at = endTime.toISOString();
702
689
  }
703
690
 
704
- // Use default color
705
691
  const defaultColor = 'BLUE';
706
692
 
707
- // Create a new event with default values
708
693
  const newEvent: CalendarEvent = {
709
694
  id: 'new',
710
695
  title: '',
@@ -736,14 +721,11 @@ export const CalendarProvider = ({
736
721
 
737
722
  const addEmptyEventWithDuration = useCallback(
738
723
  (startDate: Date, endDate: Date) => {
739
- // Round start and end times to nearest 15-minute interval
740
724
  const roundedStartDate = roundToNearest15Minutes(startDate);
741
725
  const roundedEndDate = roundToNearest15Minutes(endDate);
742
726
 
743
- // Use default color
744
727
  const defaultColor = 'BLUE';
745
728
 
746
- // Create a new event with default values
747
729
  const newEvent: CalendarEvent = {
748
730
  id: 'new',
749
731
  title: '',
@@ -832,22 +814,7 @@ export const CalendarProvider = ({
832
814
 
833
815
  try {
834
816
  // 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
- };
817
+ const cleanUpdateData = calendarEventUpdatePayload(updateData);
851
818
 
852
819
  // ws is guaranteed to be defined here (validated above at line 732)
853
820
  const wsId = ws!.id;
@@ -941,24 +908,8 @@ export const CalendarProvider = ({
941
908
  return undefined;
942
909
  }
943
910
 
944
- // Clean and validate the event updates - only allow known CalendarEvent fields
945
- const allowedFields: (keyof CalendarEvent)[] = [
946
- 'title',
947
- 'description',
948
- 'start_at',
949
- 'end_at',
950
- 'color',
951
- 'location',
952
- 'locked',
953
- 'source',
954
- ];
955
-
956
- const cleanedUpdates: Partial<CalendarEvent> = {};
957
- for (const field of allowedFields) {
958
- if (eventUpdates[field] !== undefined) {
959
- (cleanedUpdates as any)[field] = eventUpdates[field];
960
- }
961
- }
911
+ assertOrdinaryCalendarEvent(events, eventId);
912
+ const cleanedUpdates = cleanCalendarEventUpdates(eventUpdates);
962
913
 
963
914
  // Round start and end times to nearest 15-minute interval if they exist
964
915
  if (cleanedUpdates.start_at) {
@@ -1099,6 +1050,7 @@ export const CalendarProvider = ({
1099
1050
  console.warn('Calendar is in read-only mode');
1100
1051
  return;
1101
1052
  }
1053
+ assertOrdinaryCalendarEvent(events, eventId);
1102
1054
  // If this is a pending new event that hasn't been saved yet
1103
1055
  if (pendingNewEvent && eventId === 'new') {
1104
1056
  // Just clear the pending event
@@ -1642,8 +1594,18 @@ export const CalendarProvider = ({
1642
1594
  defaultNewEventTab,
1643
1595
  disableBuiltInEventUi: eventAdapter?.disableBuiltInEventUi ?? false,
1644
1596
  preservePastEventOpacity: eventAdapter?.preservePastEventOpacity ?? false,
1645
- renderEventContextMenu: eventAdapter?.renderContextMenu,
1646
- isEventReadOnly: eventAdapter?.isEventReadOnly ?? (() => false),
1597
+ renderEventContextMenu: (event: CalendarEvent) =>
1598
+ event.seriesId ? (
1599
+ <NativeRecurrenceContextMenu
1600
+ onEdit={() => openEventEditor(event._originalId ?? event.id)}
1601
+ readOnly={readOnly}
1602
+ />
1603
+ ) : (
1604
+ eventAdapter?.renderContextMenu?.(event)
1605
+ ),
1606
+ isEventReadOnly: (event: CalendarEvent) =>
1607
+ Boolean(event.seriesId) ||
1608
+ (eventAdapter?.isEventReadOnly?.(event) ?? false),
1647
1609
  readOnly,
1648
1610
  };
1649
1611
 
@@ -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
  }
@@ -9,16 +9,14 @@ export const useLocalStorage = <T>(
9
9
  const [initialized, setInitialized] = useState(false);
10
10
 
11
11
  useEffect(() => {
12
- // Retrieve from localStorage
13
- const item = window.localStorage.getItem(key);
14
- if (item) {
15
- try {
16
- setStoredValue(JSON.parse(item));
17
- } catch (error) {
18
- console.error(error);
19
- }
12
+ try {
13
+ const item = window.localStorage.getItem(key);
14
+ if (item) setStoredValue(JSON.parse(item));
15
+ } catch {
16
+ console.error('Unable to read browser preferences.');
17
+ } finally {
18
+ setInitialized(true);
20
19
  }
21
- setInitialized(true);
22
20
  }, [key]);
23
21
 
24
22
  const setValue = useCallback(
@@ -29,8 +27,8 @@ export const useLocalStorage = <T>(
29
27
  // Save to localStorage
30
28
  try {
31
29
  window.localStorage.setItem(key, JSON.stringify(valueToStore));
32
- } catch (error) {
33
- console.error(error);
30
+ } catch {
31
+ console.error('Unable to save browser preferences.');
34
32
  }
35
33
  return valueToStore;
36
34
  });
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { listNativeCalendarOccurrences } from '@tuturuuu/internal-api/calendar-series';
4
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
5
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
6
+ import { useEffect } from 'react';
7
+ import { calendarQueryRange } from '../lib/calendar-day';
8
+ import { useWorkspaceActor } from './use-workspace-visibility';
9
+ export const EMPTY_NATIVE_OCCURRENCES: CalendarEvent[] = [];
10
+ export const nativeOccurrencesKey = (wsId: string) =>
11
+ ['native-calendar-occurrences', wsId] as const;
12
+ export function deniedNativeOccurrences(error: unknown) {
13
+ return (
14
+ error instanceof InternalApiError &&
15
+ (error.status === 401 ||
16
+ error.status === 404 ||
17
+ (error.status === 403 && error.code !== 'MFA_REQUIRED'))
18
+ );
19
+ }
20
+ /** A denied snapshot is never rendered as stale data. MFA is a recoverable challenge. */
21
+ export function visibleNativeOccurrences(
22
+ data: CalendarEvent[] | undefined,
23
+ error: unknown
24
+ ) {
25
+ return deniedNativeOccurrences(error)
26
+ ? EMPTY_NATIVE_OCCURRENCES
27
+ : (data ?? EMPTY_NATIVE_OCCURRENCES);
28
+ }
29
+ export function useNativeCalendarOccurrences(
30
+ wsId: string,
31
+ dates: Date[],
32
+ timezone: string | undefined,
33
+ external: boolean
34
+ ) {
35
+ const actor = useWorkspaceActor();
36
+ const actorId = actor?.actorId;
37
+ const client = useQueryClient();
38
+ const range = dates.length ? calendarQueryRange(dates, timezone) : undefined;
39
+ const from = range?.start.toISOString();
40
+ const to = range?.end.toISOString();
41
+ const key = [...nativeOccurrencesKey(wsId), actorId, from, to];
42
+ const enabled = !external && !!actor && !!wsId && !!from && !!to;
43
+ useEffect(
44
+ () => () => {
45
+ client.removeQueries({
46
+ queryKey: [...nativeOccurrencesKey(wsId), actorId],
47
+ });
48
+ },
49
+ [client, wsId, actorId]
50
+ );
51
+ const query = useQuery({
52
+ queryKey: key,
53
+ enabled,
54
+ queryFn: async ({ signal }) => {
55
+ actor!.assertActive();
56
+ try {
57
+ const result = await listNativeCalendarOccurrences(
58
+ wsId,
59
+ { from: from!, to: to! },
60
+ { signal }
61
+ );
62
+ actor!.assertActive();
63
+ return result.data;
64
+ } catch (error) {
65
+ if (deniedNativeOccurrences(error))
66
+ client.setQueryData(key, EMPTY_NATIVE_OCCURRENCES);
67
+ throw error;
68
+ }
69
+ },
70
+ staleTime: 30_000,
71
+ gcTime: 30 * 60_000,
72
+ retry: (count, error) =>
73
+ !(
74
+ error instanceof InternalApiError &&
75
+ error.status >= 400 &&
76
+ error.status < 500
77
+ ) && count < 2,
78
+ });
79
+ return {
80
+ ...query,
81
+ data: enabled
82
+ ? visibleNativeOccurrences(query.data, query.error)
83
+ : EMPTY_NATIVE_OCCURRENCES,
84
+ };
85
+ }
@@ -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
  }