@tuturuuu/ui 0.7.0 → 0.9.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 (226) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/biome.json +1 -1
  3. package/package.json +75 -73
  4. package/src/components/ui/accordion.tsx +1 -1
  5. package/src/components/ui/breadcrumb.tsx +1 -1
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
  8. package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
  9. package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
  10. package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
  12. package/src/components/ui/calendar.tsx +1 -1
  13. package/src/components/ui/carousel.tsx +1 -1
  14. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
  15. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
  16. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
  17. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
  18. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
  19. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
  20. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
  21. package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
  22. package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
  23. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
  24. package/src/components/ui/checkbox.tsx +1 -1
  25. package/src/components/ui/color-picker.tsx +1 -1
  26. package/src/components/ui/command.tsx +1 -1
  27. package/src/components/ui/context-menu.tsx +5 -1
  28. package/src/components/ui/currency-input.test.tsx +43 -0
  29. package/src/components/ui/currency-input.tsx +1 -1
  30. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  31. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  32. package/src/components/ui/custom/combobox.test.tsx +195 -0
  33. package/src/components/ui/custom/combobox.tsx +273 -156
  34. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  35. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  36. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  37. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  38. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  39. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  40. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  41. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  42. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  43. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  44. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  45. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  46. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  47. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  48. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  49. package/src/components/ui/custom/workspace-select.tsx +8 -3
  50. package/src/components/ui/dialog.test.tsx +52 -0
  51. package/src/components/ui/dialog.tsx +6 -2
  52. package/src/components/ui/dropdown-menu.tsx +5 -1
  53. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  54. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  55. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  56. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  57. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  58. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  59. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  60. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  61. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  62. package/src/components/ui/finance/invoices/utils.ts +3 -1
  63. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  64. package/src/components/ui/finance/transactions/form-types.ts +3 -0
  65. package/src/components/ui/finance/transactions/form.tsx +2 -0
  66. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  67. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  68. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  69. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  70. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  71. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  72. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  73. package/src/components/ui/finance/wallets/form.tsx +15 -4
  74. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  75. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  76. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  77. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  78. package/src/components/ui/input-otp.tsx +1 -1
  79. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  80. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  81. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  82. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  83. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  84. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  85. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  86. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  87. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  88. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  89. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  90. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  91. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  92. package/src/components/ui/money-input.test.tsx +64 -0
  93. package/src/components/ui/money-input.tsx +63 -0
  94. package/src/components/ui/navigation-menu.tsx +1 -1
  95. package/src/components/ui/pagination.tsx +1 -1
  96. package/src/components/ui/radio-group.tsx +1 -1
  97. package/src/components/ui/select.tsx +5 -1
  98. package/src/components/ui/sheet.tsx +1 -1
  99. package/src/components/ui/sidebar.tsx +1 -1
  100. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  101. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  102. package/src/components/ui/storefront/cart-summary.tsx +104 -80
  103. package/src/components/ui/storefront/checkout-overlay.tsx +26 -0
  104. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  105. package/src/components/ui/storefront/image-panel.tsx +6 -0
  106. package/src/components/ui/storefront/index.ts +11 -0
  107. package/src/components/ui/storefront/listing-card.tsx +84 -22
  108. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  109. package/src/components/ui/storefront/product-detail.tsx +289 -0
  110. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  111. package/src/components/ui/storefront/storefront-surface.test.tsx +221 -3
  112. package/src/components/ui/storefront/storefront-surface.tsx +288 -153
  113. package/src/components/ui/storefront/types.ts +27 -1
  114. package/src/components/ui/storefront/utils.ts +117 -27
  115. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  116. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  117. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  118. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  119. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  120. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  121. package/src/components/ui/text-editor/content-migration.ts +41 -18
  122. package/src/components/ui/text-editor/editor.tsx +69 -14
  123. package/src/components/ui/text-editor/extensions.ts +9 -3
  124. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  125. package/src/components/ui/text-editor/image-extension.ts +40 -18
  126. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  127. package/src/components/ui/text-editor/video-extension.ts +11 -2
  128. package/src/components/ui/toast.tsx +1 -1
  129. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  130. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  131. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  132. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  133. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  134. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +113 -46
  135. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  136. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  137. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  138. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  139. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  140. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  141. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +51 -9
  142. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  143. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  144. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  145. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +127 -38
  146. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  147. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  148. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  149. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  150. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  151. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  152. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  153. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  154. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  155. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  156. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  157. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +410 -4
  158. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +106 -14
  159. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  160. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  161. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  162. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +186 -0
  163. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +59 -2
  164. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  165. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  166. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  167. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  168. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  169. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  170. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  171. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  172. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  173. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  174. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  175. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  176. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  177. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  178. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  179. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  180. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  181. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  182. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  183. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  184. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  185. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  186. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +237 -3
  187. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  188. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  189. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  190. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  191. package/src/components/ui/tu-do/shared/board-header.tsx +465 -937
  192. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  193. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  194. package/src/components/ui/tu-do/shared/board-views.tsx +596 -82
  195. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  196. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  197. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  198. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  199. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  200. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  201. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  202. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  203. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  204. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  205. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  206. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  207. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  208. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  209. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  210. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +44 -15
  211. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  212. package/src/declarations.d.ts +1 -0
  213. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  214. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  215. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  216. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  217. package/src/hooks/use-calendar-sync.tsx +247 -243
  218. package/src/hooks/use-calendar.tsx +323 -138
  219. package/src/hooks/use-task-actions.ts +24 -0
  220. package/src/hooks/use-user-workspace-config.ts +75 -0
  221. package/src/hooks/use-workspace-currency.ts +8 -3
  222. package/src/hooks/useBoardRealtime.ts +6 -3
  223. package/src/hooks/useBoardRealtime.types.ts +11 -0
  224. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
  225. package/src/hooks/useCursorTracking.ts +91 -27
  226. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -40,6 +40,7 @@ type CalendarConnection = {
40
40
  export type CalendarEventWithHabitInfo = CalendarEvent & {
41
41
  _isHabit?: boolean;
42
42
  _habitCompleted?: boolean;
43
+ _optimisticStatus?: CalendarOptimisticStatus;
43
44
  };
44
45
 
45
46
  // Sync status type
@@ -50,13 +51,31 @@ type SyncStatus = {
50
51
  direction?: 'google-to-tuturuuu' | 'tuturuuu-to-google' | 'both';
51
52
  };
52
53
 
53
- type OptimisticCalendarSyncEvent = {
54
- id: string;
55
- title: string;
56
- start_at: string;
57
- end_at: string;
58
- color?: string | null;
59
- locked?: boolean;
54
+ export type CalendarOptimisticStatus =
55
+ | 'creating'
56
+ | 'updating'
57
+ | 'deleting'
58
+ | 'error';
59
+
60
+ type OptimisticCalendarSyncEvent = Partial<
61
+ Omit<CalendarEvent, 'color' | 'description' | 'location'>
62
+ > &
63
+ Pick<CalendarEvent, 'id'> & {
64
+ color?: CalendarEvent['color'] | string | null;
65
+ description?: string | null;
66
+ location?: string | null;
67
+ _optimisticStatus?: CalendarOptimisticStatus;
68
+ };
69
+
70
+ type OptimisticCalendarPatchOptions = {
71
+ removeIds?: string[];
72
+ clearIds?: string[];
73
+ status?: CalendarOptimisticStatus;
74
+ };
75
+
76
+ type OptimisticCalendarState = {
77
+ events: Record<string, OptimisticCalendarSyncEvent>;
78
+ removedIds: string[];
60
79
  };
61
80
 
62
81
  const CalendarSyncContext = createContext<{
@@ -90,7 +109,7 @@ const CalendarSyncContext = createContext<{
90
109
  refresh: () => void;
91
110
  patchVisibleEvents: (
92
111
  events: OptimisticCalendarSyncEvent[],
93
- options?: { removeIds?: string[] }
112
+ options?: OptimisticCalendarPatchOptions
94
113
  ) => void;
95
114
 
96
115
  syncToGoogle: () => Promise<void>;
@@ -167,15 +186,21 @@ export const CalendarSyncProvider = ({
167
186
  wsId,
168
187
  experimentalGoogleToken: _experimentalGoogleToken,
169
188
  initialCalendarConnections = [],
189
+ externalEvents,
190
+ externalEventsLoading = false,
191
+ externalRefresh,
170
192
  }: {
171
193
  children: React.ReactNode;
172
194
  wsId: Workspace['id'];
173
195
  experimentalGoogleToken?: WorkspaceCalendarGoogleTokenClient | null;
174
196
  initialCalendarConnections?: CalendarConnection[];
197
+ externalEvents?: CalendarEvent[];
198
+ externalEventsLoading?: boolean;
199
+ externalRefresh?: () => void;
175
200
  }) => {
176
- const [data, setData] = useState<WorkspaceCalendarEvent[] | null>(null);
177
201
  const [googleData] = useState<WorkspaceCalendarEvent[] | null>(null);
178
202
  const [events, setEvents] = useState<CalendarEventWithHabitInfo[]>([]);
203
+ const hasExternalEvents = externalEvents !== undefined;
179
204
 
180
205
  const [error, setError] = useState<Error | null>(null);
181
206
  const [dates, setDates] = useState<Date[]>([]);
@@ -186,6 +211,11 @@ export const CalendarSyncProvider = ({
186
211
  const [calendarCache, setCalendarCache] = useState<CalendarCache>({});
187
212
  const [isSyncing, setIsSyncing] = useState(false);
188
213
  const [syncStatus, setSyncStatus] = useState<SyncStatus>({ state: 'idle' });
214
+ const [optimisticState, setOptimisticState] =
215
+ useState<OptimisticCalendarState>({
216
+ events: {},
217
+ removedIds: [],
218
+ });
189
219
  const prevDatesRef = useRef<string>('');
190
220
  const isForcedRef = useRef<boolean>(false);
191
221
  const lastSyncTimeRef = useRef<number>(0);
@@ -232,6 +262,14 @@ export const CalendarSyncProvider = ({
232
262
  return `${dateRange[0]!.toISOString()}-${dateRange[dateRange.length - 1]!.toISOString()}`;
233
263
  }, []);
234
264
 
265
+ const activeCacheKey = useMemo(
266
+ () => getCacheKey(dates),
267
+ [dates, getCacheKey]
268
+ );
269
+ const activeCachedDatabaseEvents = activeCacheKey
270
+ ? calendarCache[activeCacheKey]?.dbEvents
271
+ : undefined;
272
+
235
273
  // Helper to check if a date range includes today (current week issue)
236
274
  const includesCurrentWeek = useCallback((dateRange: Date[]) => {
237
275
  if (!dateRange || dateRange.length === 0) return false;
@@ -306,104 +344,112 @@ export const CalendarSyncProvider = ({
306
344
  });
307
345
  }, []);
308
346
 
309
- const patchVisibleEvents = useCallback(
310
- (
311
- incomingEvents: OptimisticCalendarSyncEvent[],
312
- options?: { removeIds?: string[] }
313
- ) => {
314
- const cacheKey = getCacheKey(dates);
315
- if (!cacheKey) return;
347
+ const isVisibleInCurrentRange = useCallback(
348
+ (event: { start_at?: string; end_at?: string }) => {
349
+ if (dates.length === 0) return true;
316
350
 
317
351
  const firstDate = dates[0];
318
352
  const lastDate = dates[dates.length - 1];
319
- if (!firstDate || !lastDate) return;
353
+ if (!firstDate || !lastDate) return true;
354
+
355
+ if (!event.start_at && !event.end_at) return true;
320
356
 
321
357
  const rangeStart = dayjs(firstDate).startOf('day').valueOf();
322
358
  const rangeEnd = dayjs(lastDate).endOf('day').valueOf();
323
- const removeIds = new Set(options?.removeIds ?? []);
324
-
325
- const isVisibleInRange = (event: {
326
- start_at?: string;
327
- end_at?: string;
328
- }) => {
329
- const startAt = event.start_at ? dayjs(event.start_at).valueOf() : NaN;
330
- const endAt = event.end_at ? dayjs(event.end_at).valueOf() : startAt;
331
-
332
- return !Number.isNaN(startAt) && !Number.isNaN(endAt)
333
- ? startAt <= rangeEnd && endAt >= rangeStart
334
- : false;
335
- };
359
+ const startAt = event.start_at ? dayjs(event.start_at).valueOf() : NaN;
360
+ const endAt = event.end_at ? dayjs(event.end_at).valueOf() : startAt;
336
361
 
337
- const mergeEvents = (existingData: WorkspaceCalendarEvent[] | null) => {
338
- const existing = Array.isArray(existingData) ? existingData : [];
339
- const byId = new Map(
340
- existing
341
- .filter((event) => !removeIds.has(event.id))
342
- .map((event) => [event.id, event])
343
- );
362
+ if (Number.isNaN(startAt) || Number.isNaN(endAt)) {
363
+ return true;
364
+ }
365
+
366
+ return startAt <= rangeEnd && endAt >= rangeStart;
367
+ },
368
+ [dates]
369
+ );
370
+
371
+ const patchVisibleEvents = useCallback(
372
+ (
373
+ incomingEvents: OptimisticCalendarSyncEvent[],
374
+ options?: OptimisticCalendarPatchOptions
375
+ ) => {
376
+ setOptimisticState((prev) => {
377
+ const events = { ...prev.events };
378
+ const removedIds = new Set(prev.removedIds);
379
+
380
+ for (const id of options?.clearIds ?? []) {
381
+ delete events[id];
382
+ removedIds.delete(id);
383
+ }
384
+
385
+ for (const id of options?.removeIds ?? []) {
386
+ delete events[id];
387
+ removedIds.add(id);
388
+ }
344
389
 
345
390
  for (const event of incomingEvents) {
346
- if (!event.id || !isVisibleInRange(event)) continue;
347
- byId.set(event.id, {
348
- ...(byId.get(event.id) ?? {}),
391
+ if (!event.id) continue;
392
+
393
+ if (!isVisibleInCurrentRange(event)) {
394
+ delete events[event.id];
395
+ removedIds.add(event.id);
396
+ continue;
397
+ }
398
+
399
+ removedIds.delete(event.id);
400
+
401
+ const nextEvent: OptimisticCalendarSyncEvent = {
402
+ ...(events[event.id] ?? {}),
349
403
  ...event,
350
- } as WorkspaceCalendarEvent);
351
- }
404
+ };
352
405
 
353
- return [...byId.values()].sort(
354
- (left, right) =>
355
- new Date(left.start_at).getTime() -
356
- new Date(right.start_at).getTime()
357
- );
358
- };
406
+ if (options?.status) {
407
+ nextEvent._optimisticStatus = options.status;
408
+ } else {
409
+ delete nextEvent._optimisticStatus;
410
+ }
359
411
 
360
- const nextDbEvents = mergeEvents(
361
- calendarCache[cacheKey]?.dbEvents ?? data ?? []
362
- );
412
+ events[event.id] = nextEvent;
413
+ }
363
414
 
364
- updateCache(cacheKey, {
365
- dbEvents: nextDbEvents,
366
- dbLastUpdated: Date.now(),
415
+ return {
416
+ events,
417
+ removedIds: [...removedIds],
418
+ };
367
419
  });
368
- setData(nextDbEvents);
369
- queryClient.setQueryData(
370
- ['databaseCalendarEvents', wsId, cacheKey],
371
- nextDbEvents
372
- );
373
420
  },
374
- [calendarCache, data, dates, getCacheKey, queryClient, updateCache, wsId]
421
+ [isVisibleInCurrentRange]
375
422
  );
376
423
 
377
424
  // Fetch database events with caching
378
425
  const { data: fetchedData, isLoading: isDatabaseLoading } = useQuery({
379
- queryKey: ['databaseCalendarEvents', wsId, getCacheKey(dates)],
380
- enabled: !!wsId && dates.length > 0,
426
+ queryKey: ['databaseCalendarEvents', wsId, activeCacheKey],
427
+ enabled: !hasExternalEvents && !!wsId && dates.length > 0,
381
428
  staleTime: 30000, // Consider data fresh for 30 seconds
382
429
  gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
383
430
  queryFn: async () => {
384
- const cacheKey = getCacheKey(dates);
385
- if (!cacheKey) return null;
431
+ if (!activeCacheKey) return null;
386
432
 
387
- const cachedData = calendarCache[cacheKey];
433
+ const cachedData = calendarCache[activeCacheKey];
388
434
 
389
435
  // If we have cached data and it's not stale, return it immediately
390
436
  if (
391
- cachedData?.dbEvents &&
392
- cachedData.dbEvents.length > 0 &&
437
+ cachedData &&
393
438
  !isCacheStaleEnhanced(cachedData.dbLastUpdated, dates) &&
394
439
  !isForcedRef.current
395
440
  ) {
396
- setData(cachedData.dbEvents);
397
441
  return cachedData.dbEvents;
398
442
  }
399
443
 
400
444
  // Otherwise fetch fresh data via API (which handles E2EE decryption)
401
445
  const startDate = dayjs(dates[0]).startOf('day');
402
- const endDate = dayjs(dates[dates.length - 1]).endOf('day');
446
+ const endDate = dayjs(dates[dates.length - 1])
447
+ .add(1, 'day')
448
+ .startOf('day');
403
449
 
404
450
  try {
405
451
  const response = await fetch(
406
- `/api/v1/workspaces/${wsId}/calendar/events?start_at=${startDate.toISOString()}&end_at=${endDate.add(1, 'day').toISOString()}`,
452
+ `/api/v1/workspaces/${wsId}/calendar/events?start_at=${startDate.toISOString()}&end_at=${endDate.toISOString()}`,
407
453
  { cache: 'no-store' }
408
454
  );
409
455
 
@@ -416,7 +462,7 @@ export const CalendarSyncProvider = ({
416
462
  const fetchedData = result.data || [];
417
463
 
418
464
  // Update cache with new data and reset isForced flag
419
- updateCache(cacheKey, {
465
+ updateCache(activeCacheKey, {
420
466
  dbEvents: fetchedData,
421
467
  dbLastUpdated: Date.now(),
422
468
  });
@@ -424,7 +470,7 @@ export const CalendarSyncProvider = ({
424
470
  // Reset the ref immediately (synchronous)
425
471
  isForcedRef.current = false;
426
472
 
427
- setData(fetchedData);
473
+ setError(null);
428
474
  return fetchedData;
429
475
  } catch (err) {
430
476
  const errorMessage =
@@ -443,7 +489,7 @@ export const CalendarSyncProvider = ({
443
489
  lastSyncTime: new Date(),
444
490
  });
445
491
 
446
- return null;
492
+ return cachedData?.dbEvents ?? [];
447
493
  }
448
494
  },
449
495
  refetchInterval: 60000, // Reduced from 30s to 60s to lower load
@@ -452,7 +498,7 @@ export const CalendarSyncProvider = ({
452
498
  // Legacy direct Google fetch/reconcile is disabled. Provider inbound sync is
453
499
  // owned by the workspace sync route so account/calendar identity stays scoped.
454
500
  const { isLoading: isGoogleLoading } = useQuery({
455
- queryKey: ['googleCalendarEvents', wsId, getCacheKey(dates)],
501
+ queryKey: ['googleCalendarEvents', wsId, activeCacheKey],
456
502
  enabled: false,
457
503
  staleTime: 30000, // Consider data fresh for 30 seconds
458
504
  gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
@@ -462,17 +508,19 @@ export const CalendarSyncProvider = ({
462
508
 
463
509
  // Fetch habit calendar events to identify which events are habits
464
510
  const { data: habitEventData } = useQuery({
465
- queryKey: ['habitCalendarEvents', wsId, getCacheKey(dates)],
466
- enabled: !!wsId && dates.length > 0,
511
+ queryKey: ['habitCalendarEvents', wsId, activeCacheKey],
512
+ enabled: !hasExternalEvents && !!wsId && dates.length > 0,
467
513
  staleTime: 60000, // Consider data fresh for 1 minute
468
514
  gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
469
515
  queryFn: async () => {
470
516
  const startDate = dayjs(dates[0]).startOf('day');
471
- const endDate = dayjs(dates[dates.length - 1]).endOf('day');
517
+ const endDate = dayjs(dates[dates.length - 1])
518
+ .add(1, 'day')
519
+ .startOf('day');
472
520
 
473
521
  try {
474
522
  const response = await fetch(
475
- `/api/v1/workspaces/${wsId}/calendar/habit-events?start_at=${startDate.toISOString()}&end_at=${endDate.add(1, 'day').toISOString()}`,
523
+ `/api/v1/workspaces/${wsId}/calendar/habit-events?start_at=${startDate.toISOString()}&end_at=${endDate.toISOString()}`,
476
524
  { cache: 'no-store' }
477
525
  );
478
526
 
@@ -516,15 +564,19 @@ export const CalendarSyncProvider = ({
516
564
 
517
565
  // Invalidate and refetch events
518
566
  const refresh = useCallback(() => {
519
- const cacheKey = getCacheKey(dates);
520
- if (!cacheKey) return null;
567
+ if (hasExternalEvents) {
568
+ externalRefresh?.();
569
+ return null;
570
+ }
571
+
572
+ if (!activeCacheKey) return null;
521
573
 
522
574
  isForcedRef.current = true;
523
575
 
524
576
  queryClient.invalidateQueries({
525
- queryKey: ['databaseCalendarEvents', wsId, cacheKey],
577
+ queryKey: ['databaseCalendarEvents', wsId, activeCacheKey],
526
578
  });
527
- }, [queryClient, wsId, dates, getCacheKey]);
579
+ }, [queryClient, wsId, activeCacheKey, hasExternalEvents, externalRefresh]);
528
580
 
529
581
  // Sync Google events of current view to Tuturuuu database
530
582
  const syncToTuturuuu = useCallback(
@@ -547,9 +599,6 @@ export const CalendarSyncProvider = ({
547
599
  const SYNC_COOLDOWN_MS = 30000; // 30 seconds
548
600
 
549
601
  if (!options?.skipCooldown && timeSinceLastSync < SYNC_COOLDOWN_MS) {
550
- console.log(
551
- `🔒 Sync skipped - cooldown active (${Math.ceil((SYNC_COOLDOWN_MS - timeSinceLastSync) / 1000)}s remaining)`
552
- );
553
602
  return;
554
603
  }
555
604
 
@@ -650,13 +699,16 @@ export const CalendarSyncProvider = ({
650
699
 
651
700
  // Trigger refetch from DB when changing views (optimized to reduce load)
652
701
  useEffect(() => {
702
+ if (hasExternalEvents) {
703
+ return;
704
+ }
705
+
653
706
  // Skip if dates haven't actually changed
654
707
  if (areDatesEqual(dates)) {
655
708
  return;
656
709
  }
657
710
 
658
- const cacheKey = getCacheKey(dates);
659
- const cacheData = calendarCache[cacheKey];
711
+ const cacheData = calendarCache[activeCacheKey];
660
712
 
661
713
  // For current week, force a fresh database fetch
662
714
  const isCurrentWeek = includesCurrentWeek(dates);
@@ -664,170 +716,137 @@ export const CalendarSyncProvider = ({
664
716
  if (cacheData && isCurrentWeek) {
665
717
  isForcedRef.current = true;
666
718
  // For current week, reset database cache timestamp to force refresh
667
- updateCache(cacheKey, {
719
+ updateCache(activeCacheKey, {
668
720
  dbLastUpdated: 0,
669
721
  });
670
722
  }
671
-
672
- // Only invalidate database queries (cheap) - not Google queries (expensive)
673
- // Google sync will happen automatically via the 30s cooldown mechanism
674
- queryClient.invalidateQueries({
675
- queryKey: ['databaseCalendarEvents', wsId, getCacheKey(dates)],
676
- });
677
723
  }, [
678
724
  dates,
679
- queryClient,
680
- wsId,
681
725
  calendarCache,
682
726
  includesCurrentWeek,
683
727
  areDatesEqual,
684
- getCacheKey,
728
+ activeCacheKey,
685
729
  updateCache,
730
+ hasExternalEvents,
686
731
  ]);
687
732
 
688
733
  /*
689
734
  Show data from database to Tuturuuu
690
735
  */
691
736
 
692
- // Create a unique signature for an event based on its content
693
- const createEventSignature = useCallback((event: CalendarEvent): string => {
694
- return `${event.title}|${event.description || ''}|${event.start_at}|${event.end_at}`;
695
- }, []);
696
-
697
- // Detect and remove duplicate events
698
- const removeDuplicateEvents = useCallback(
699
- async (eventsData: CalendarEvent[]) => {
700
- if (!wsId || !eventsData || eventsData.length === 0) return eventsData;
701
-
702
- // Group events by their signature
703
- const eventGroups = new Map<string, CalendarEvent[]>();
737
+ const visibleDatabaseEvents = useMemo(
738
+ () =>
739
+ hasExternalEvents
740
+ ? ((externalEvents ?? []) as WorkspaceCalendarEvent[])
741
+ : (fetchedData ?? activeCachedDatabaseEvents ?? []),
742
+ [activeCachedDatabaseEvents, externalEvents, fetchedData, hasExternalEvents]
743
+ );
704
744
 
705
- eventsData.forEach((event) => {
706
- const signature = createEventSignature(event);
707
- if (!eventGroups.has(signature)) {
708
- eventGroups.set(signature, []);
709
- }
710
- eventGroups.get(signature)!.push(event);
711
- });
745
+ const visibleEventsWithOptimisticState = useMemo(() => {
746
+ const removedIds = new Set(optimisticState.removedIds);
747
+
748
+ // Filter external events by enabled provider calendars. Local Tuturuuu
749
+ // events are always shown here; native calendar visibility is handled
750
+ // by the workspace calendar endpoints.
751
+ const filteredEvents =
752
+ !hasExternalEvents && calendarConnections.length > 0
753
+ ? (visibleDatabaseEvents as CalendarEvent[]).filter((event) => {
754
+ const eventCalendarId =
755
+ (event as any).external_calendar_id ||
756
+ (event as any).google_calendar_id;
757
+ return !eventCalendarId || enabledCalendarIds.has(eventCalendarId);
758
+ })
759
+ : (visibleDatabaseEvents as CalendarEvent[]);
760
+
761
+ const byId = new Map<string, CalendarEvent>();
762
+
763
+ for (const event of filteredEvents) {
764
+ if (!event.id || removedIds.has(event.id)) continue;
765
+ byId.set(event.id, event);
766
+ }
712
767
 
713
- // Find duplicates that need to be removed
714
- const eventsToDelete: string[] = [];
715
- let deletionPerformed = false;
716
-
717
- eventGroups.forEach((eventGroup) => {
718
- if (eventGroup.length > 1) {
719
- // Sort by creation time if available, otherwise by ID
720
- // Keep the first/oldest event, delete the rest
721
- const sortedEvents = [...eventGroup].sort((a, b) => {
722
- // If we have created_at field, use it (check with optional chaining)
723
- const aCreatedAt = (a as any)?.created_at;
724
- const bCreatedAt = (b as any)?.created_at;
725
- if (aCreatedAt && bCreatedAt) {
726
- return (
727
- new Date(aCreatedAt).getTime() - new Date(bCreatedAt).getTime()
728
- );
729
- }
730
- // Otherwise sort by ID which is often sequential
731
- return a.id.localeCompare(b.id);
732
- });
768
+ for (const optimisticEvent of Object.values(optimisticState.events)) {
769
+ if (!optimisticEvent.id || removedIds.has(optimisticEvent.id)) continue;
770
+ if (!isVisibleInCurrentRange(optimisticEvent)) continue;
733
771
 
734
- // Keep the first event (oldest), mark the rest for deletion
735
- const eventsToRemove = sortedEvents.slice(1);
736
- eventsToRemove.forEach((event) => {
737
- eventsToDelete.push(event.id);
738
- });
739
- }
740
- });
772
+ byId.set(optimisticEvent.id, {
773
+ ...(byId.get(optimisticEvent.id) ?? {}),
774
+ ...optimisticEvent,
775
+ } as CalendarEvent);
776
+ }
741
777
 
742
- // Delete duplicate events if any were found
743
- if (eventsToDelete.length > 0) {
744
- try {
745
- for (const eventId of eventsToDelete) {
746
- const response = await fetch(
747
- `/api/v1/workspaces/${wsId}/calendar/events/${eventId}`,
748
- {
749
- method: 'DELETE',
750
- }
751
- );
752
-
753
- if (!response.ok) {
754
- // Error deleting duplicate events
755
- continue;
756
- }
757
-
758
- deletionPerformed = true;
759
- }
778
+ return [...byId.values()].sort(
779
+ (left, right) =>
780
+ new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
781
+ );
782
+ }, [
783
+ calendarConnections.length,
784
+ enabledCalendarIds,
785
+ hasExternalEvents,
786
+ isVisibleInCurrentRange,
787
+ optimisticState.events,
788
+ optimisticState.removedIds,
789
+ visibleDatabaseEvents,
790
+ ]);
760
791
 
761
- // If events were deleted, refresh to get updated data
762
- if (deletionPerformed) {
763
- queryClient.invalidateQueries({
764
- queryKey: ['databaseCalendarEvents', wsId],
765
- exact: false,
766
- });
767
- }
768
- } catch (err) {
769
- // Failed to delete duplicate events
770
- console.error(err);
792
+ useEffect(() => {
793
+ setOptimisticState((prev) => {
794
+ const serverEventsById = new Map(
795
+ (visibleDatabaseEvents as CalendarEvent[])
796
+ .filter((event) => event.id)
797
+ .map((event) => [event.id, event])
798
+ );
799
+ const nextEvents = { ...prev.events };
800
+ const nextRemovedIds = prev.removedIds.filter((id) =>
801
+ serverEventsById.has(id)
802
+ );
803
+ let changed = nextRemovedIds.length !== prev.removedIds.length;
804
+
805
+ for (const [id, event] of Object.entries(prev.events)) {
806
+ const serverEvent = serverEventsById.get(id);
807
+
808
+ if (
809
+ !event._optimisticStatus &&
810
+ serverEvent &&
811
+ (event.title === undefined || serverEvent.title === event.title) &&
812
+ (event.description === undefined ||
813
+ serverEvent.description === event.description) &&
814
+ (event.start_at === undefined ||
815
+ serverEvent.start_at === event.start_at) &&
816
+ (event.end_at === undefined || serverEvent.end_at === event.end_at) &&
817
+ (event.color === undefined || serverEvent.color === event.color) &&
818
+ (event.location === undefined ||
819
+ serverEvent.location === event.location) &&
820
+ (event.locked === undefined || serverEvent.locked === event.locked)
821
+ ) {
822
+ delete nextEvents[id];
823
+ changed = true;
771
824
  }
772
825
  }
773
826
 
774
- // Return the filtered list without duplicates
775
- return eventsData.filter((event) => !eventsToDelete.includes(event.id));
776
- },
777
- [wsId, queryClient, createEventSignature]
778
- );
779
-
780
- const visibleDatabaseEvents = data ?? fetchedData ?? null;
827
+ return changed
828
+ ? {
829
+ events: nextEvents,
830
+ removedIds: nextRemovedIds,
831
+ }
832
+ : prev;
833
+ });
834
+ }, [visibleDatabaseEvents]);
781
835
 
782
836
  useEffect(() => {
783
- const processEvents = async () => {
784
- if (visibleDatabaseEvents) {
785
- const result = await removeDuplicateEvents(
786
- visibleDatabaseEvents as CalendarEvent[]
787
- );
837
+ const habitEventIds = habitEventData?.habitEventIds || new Set<string>();
838
+ const completedHabitEventIds =
839
+ habitEventData?.completedHabitEventIds || new Set<string>();
788
840
 
789
- // Filter external events by enabled provider calendars. Local Tuturuuu
790
- // events are always shown here; native calendar visibility is handled
791
- // by the workspace calendar endpoints.
792
- const filteredEvents =
793
- calendarConnections.length > 0
794
- ? result.filter((event) => {
795
- const eventCalendarId =
796
- (event as any).external_calendar_id ||
797
- (event as any).google_calendar_id;
798
- return (
799
- !eventCalendarId || enabledCalendarIds.has(eventCalendarId)
800
- );
801
- })
802
- : result;
803
-
804
- // Merge habit info into events
805
- const habitEventIds =
806
- habitEventData?.habitEventIds || new Set<string>();
807
- const completedHabitEventIds =
808
- habitEventData?.completedHabitEventIds || new Set<string>();
809
-
810
- const eventsWithHabitInfo: CalendarEventWithHabitInfo[] =
811
- filteredEvents.map((event) => ({
812
- ...event,
813
- _isHabit: habitEventIds.has(event.id),
814
- _habitCompleted: completedHabitEventIds.has(event.id),
815
- }));
841
+ const eventsWithHabitInfo: CalendarEventWithHabitInfo[] =
842
+ visibleEventsWithOptimisticState.map((event) => ({
843
+ ...event,
844
+ _isHabit: habitEventIds.has(event.id),
845
+ _habitCompleted: completedHabitEventIds.has(event.id),
846
+ }));
816
847
 
817
- setEvents(eventsWithHabitInfo);
818
- } else {
819
- setEvents([]);
820
- }
821
- };
822
-
823
- processEvents();
824
- }, [
825
- visibleDatabaseEvents,
826
- removeDuplicateEvents,
827
- calendarConnections.length,
828
- enabledCalendarIds,
829
- habitEventData,
830
- ]);
848
+ setEvents(eventsWithHabitInfo);
849
+ }, [habitEventData, visibleEventsWithOptimisticState]);
831
850
 
832
851
  const eventsWithoutAllDays = useMemo(() => {
833
852
  // Process events immediately when they change
@@ -847,23 +866,6 @@ export const CalendarSyncProvider = ({
847
866
  });
848
867
  }, [events]);
849
868
 
850
- // Add a ref to track if we've processed the initial data
851
- const hasProcessedInitialData = useRef(false);
852
-
853
- // Effect to process initial data
854
- useEffect(() => {
855
- if (fetchedData && !hasProcessedInitialData.current) {
856
- hasProcessedInitialData.current = true;
857
- // Force a re-render by updating the data state
858
- setData(fetchedData);
859
- }
860
- }, [fetchedData]);
861
-
862
- // Effect to reset the processed flag when dates change
863
- useEffect(() => {
864
- hasProcessedInitialData.current = false;
865
- }, []);
866
-
867
869
  const syncToGoogle = useCallback(async () => {
868
870
  toast.info('Provider events sync when you create or edit them.');
869
871
  setSyncStatus({
@@ -875,7 +877,9 @@ export const CalendarSyncProvider = ({
875
877
  }, []);
876
878
 
877
879
  const value = {
878
- data,
880
+ data: hasExternalEvents
881
+ ? ((externalEvents ?? []) as WorkspaceCalendarEvent[])
882
+ : (fetchedData ?? activeCachedDatabaseEvents ?? null),
879
883
  googleData,
880
884
  error,
881
885
  dates,
@@ -905,7 +909,7 @@ export const CalendarSyncProvider = ({
905
909
  syncStatus,
906
910
 
907
911
  // Loading states
908
- isLoading: isDatabaseLoading || isGoogleLoading,
912
+ isLoading: externalEventsLoading || isDatabaseLoading || isGoogleLoading,
909
913
  isSyncing,
910
914
  };
911
915