@tuturuuu/ui 0.30.2 → 0.32.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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
  16. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  17. package/src/components/ui/custom/nav-link.tsx +7 -7
  18. package/src/components/ui/custom/nav.tsx +15 -63
  19. package/src/components/ui/custom/notification-card.tsx +361 -0
  20. package/src/components/ui/custom/notification-list.tsx +178 -0
  21. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  22. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  24. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  25. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  26. package/src/components/ui/custom/satellite-content.tsx +148 -0
  27. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  28. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  29. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  30. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  31. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  32. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  33. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  34. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  35. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  36. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  37. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  38. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  39. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  40. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  41. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  42. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  43. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  44. package/src/components/ui/custom/structure.tsx +14 -0
  45. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  46. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  47. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  48. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  49. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  50. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  51. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  53. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  54. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  55. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  56. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  57. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  58. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  59. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  61. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  62. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  63. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  65. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  66. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  67. package/src/components/ui/pwa-status.test.tsx +53 -0
  68. package/src/components/ui/pwa-status.tsx +113 -0
  69. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  70. package/src/components/ui/report-problem-dialog.tsx +6 -975
  71. package/src/components/ui/report-problem-model.ts +133 -0
  72. package/src/components/ui/report-problem-mutation.ts +122 -0
  73. package/src/components/ui/save-notification.test.tsx +36 -0
  74. package/src/components/ui/save-notification.tsx +17 -0
  75. package/src/components/ui/use-report-problem.ts +488 -0
  76. package/src/declarations.d.ts +2 -0
  77. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  78. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  79. package/src/hooks/calendar-date-utils.ts +11 -0
  80. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  81. package/src/hooks/calendar-event-lookup.ts +47 -0
  82. package/src/hooks/calendar-range-cache.ts +52 -0
  83. package/src/hooks/pwa-install-prompt.ts +44 -0
  84. package/src/hooks/use-calendar-sync.tsx +23 -100
  85. package/src/hooks/use-calendar.tsx +21 -77
  86. package/src/hooks/use-notifications.ts +9 -24
  87. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  88. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  89. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  90. package/src/hooks/use-workspace-presence.ts +0 -18
  91. package/src/lib/tasks-app-url.test.ts +25 -0
  92. package/src/lib/tasks-app-url.ts +8 -0
@@ -0,0 +1,77 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { createCalendarEventLookup } from './calendar-event-lookup';
4
+ import {
5
+ type CalendarCache,
6
+ updateCalendarRangeCache,
7
+ } from './calendar-range-cache';
8
+
9
+ const event = (id: string, start: string, end: string): CalendarEvent => ({
10
+ id,
11
+ start_at: start,
12
+ end_at: end,
13
+ });
14
+ describe('indexed calendar lookup', () => {
15
+ it('preserves event order and exclusive all-day ends across a year boundary', () => {
16
+ const events = [
17
+ event('timed', '2026-12-31T23:00:00', '2027-01-01T01:00:00'),
18
+ event('all-day', '2026-12-31T00:00:00', '2027-01-01T00:00:00'),
19
+ ];
20
+ const lookup = createCalendarEventLookup(events);
21
+ expect(lookup(new Date(2026, 11, 31)).map((item) => item.id)).toEqual([
22
+ 'timed',
23
+ 'all-day',
24
+ ]);
25
+ expect(lookup(new Date(2027, 0, 1)).map((item) => item.id)).toEqual([
26
+ 'timed',
27
+ ]);
28
+ expect(lookup(new Date(2027, 0, 1))).toBe(lookup(new Date(2027, 0, 1)));
29
+ });
30
+ it('excludes the end day of all-day events across daylight saving changes', () => {
31
+ vi.stubEnv('TZ', 'America/New_York');
32
+ try {
33
+ const lookup = createCalendarEventLookup([
34
+ event('dst', '2026-03-08T00:00:00-05:00', '2026-03-09T00:00:00-04:00'),
35
+ ]);
36
+ expect(lookup(new Date(2026, 2, 8))).toHaveLength(1);
37
+ expect(lookup(new Date(2026, 2, 9))).toHaveLength(0);
38
+ } finally {
39
+ vi.unstubAllEnvs();
40
+ }
41
+ });
42
+ it('handles sparse busy years and invalid event dates', () => {
43
+ const events = Array.from({ length: 3000 }, (_, index) =>
44
+ event(
45
+ String(index),
46
+ new Date(2026, 0, 1 + index, 9).toISOString(),
47
+ new Date(2026, 0, 1 + index, 10).toISOString()
48
+ )
49
+ );
50
+ const lookup = createCalendarEventLookup([
51
+ ...events,
52
+ event('invalid', 'invalid', 'invalid'),
53
+ ]);
54
+ expect(lookup(new Date(2026, 11, 31)).map((item) => item.id)).toEqual([
55
+ '364',
56
+ ]);
57
+ });
58
+ it('bounds workspace-scoped range snapshots and preserves partial cache updates', () => {
59
+ let cache: CalendarCache = {};
60
+ for (let index = 1; index <= 15; index++)
61
+ cache = updateCalendarRangeCache(cache, `workspace:${index}`, {
62
+ dbLastUpdated: index,
63
+ });
64
+ expect(Object.keys(cache)).toHaveLength(12);
65
+ expect(cache['workspace:1']).toBeUndefined();
66
+ cache = updateCalendarRangeCache(cache, 'other-workspace:15', {
67
+ googleLastUpdated: 20,
68
+ });
69
+ expect(cache['workspace:15']?.dbLastUpdated).toBe(15);
70
+ expect(cache['other-workspace:15']?.dbLastUpdated).toBe(0);
71
+ cache = updateCalendarRangeCache(cache, 'active-range', {
72
+ dbLastUpdated: 0,
73
+ });
74
+ expect(cache['active-range']).toBeDefined();
75
+ expect(Object.keys(cache)).toHaveLength(12);
76
+ });
77
+ });
@@ -0,0 +1,47 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
3
+
4
+ const dayStamp = (date: Date) =>
5
+ new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
6
+ /** Parse once, binary-search starts, and prune intervals that already ended. */
7
+ export function createCalendarEventLookup(events: CalendarEvent[]) {
8
+ const intervals = events
9
+ .map((event, order) => ({
10
+ event,
11
+ order,
12
+ start: dayStamp(new Date(event.start_at)),
13
+ end: dayStamp(new Date(event.end_at)) - (isAllDayEvent(event) ? 1 : 0),
14
+ }))
15
+ .filter((item) => Number.isFinite(item.start) && Number.isFinite(item.end))
16
+ .sort((a, b) => a.start - b.start);
17
+ let max = -Infinity;
18
+ const latestEnd = intervals.map((item) => (max = Math.max(max, item.end)));
19
+ const cache = new Map<number, CalendarEvent[]>();
20
+ return (date = new Date()) => {
21
+ const target = dayStamp(date);
22
+ const cached = cache.get(target);
23
+ if (cached) return cached;
24
+ let low = 0,
25
+ high = intervals.length;
26
+ while (low < high) {
27
+ const mid = (low + high) >>> 1;
28
+ if (intervals[mid]!.start <= target) low = mid + 1;
29
+ else high = mid;
30
+ }
31
+ const matches: typeof intervals = [];
32
+ for (
33
+ let index = low - 1;
34
+ index >= 0 && latestEnd[index]! >= target;
35
+ index--
36
+ ) {
37
+ const interval = intervals[index]!;
38
+ if (interval.end >= target) matches.push(interval);
39
+ }
40
+ const result = matches
41
+ .sort((a, b) => a.order - b.order)
42
+ .map((item) => item.event);
43
+ if (cache.size >= 400) cache.delete(cache.keys().next().value!);
44
+ cache.set(target, result);
45
+ return result;
46
+ };
47
+ }
@@ -0,0 +1,52 @@
1
+ import type { WorkspaceCalendarEvent } from '@tuturuuu/types';
2
+ // Add a type for the cache
3
+ export type CalendarCache = {
4
+ [key: string]: {
5
+ dbEvents: WorkspaceCalendarEvent[];
6
+ googleEvents: WorkspaceCalendarEvent[];
7
+ dbLastUpdated: number;
8
+ googleLastUpdated: number;
9
+ };
10
+ };
11
+
12
+ // Helper type for cache updates
13
+ export type CacheUpdate = {
14
+ dbEvents?: WorkspaceCalendarEvent[];
15
+ googleEvents?: WorkspaceCalendarEvent[];
16
+ dbLastUpdated?: number;
17
+ googleLastUpdated?: number;
18
+ };
19
+
20
+ /** Bound private in-memory snapshots; nothing is persisted to browser disk. */
21
+ export function updateCalendarRangeCache(
22
+ previous: CalendarCache,
23
+ key: string,
24
+ update: CacheUpdate
25
+ ): CalendarCache {
26
+ const existing = previous[key] ?? {
27
+ dbEvents: [],
28
+ googleEvents: [],
29
+ dbLastUpdated: 0,
30
+ googleLastUpdated: 0,
31
+ };
32
+ const next = {
33
+ ...previous,
34
+ [key]: {
35
+ ...existing,
36
+ ...Object.fromEntries(
37
+ Object.entries(update).filter(([, value]) => value !== undefined)
38
+ ),
39
+ },
40
+ };
41
+ return Object.fromEntries(
42
+ Object.entries(next)
43
+ .filter(([entryKey]) => entryKey !== key)
44
+ .sort(
45
+ (a, b) =>
46
+ Math.max(b[1].dbLastUpdated, b[1].googleLastUpdated) -
47
+ Math.max(a[1].dbLastUpdated, a[1].googleLastUpdated)
48
+ )
49
+ .slice(0, 11)
50
+ .concat([[key, next[key]!]])
51
+ );
52
+ }
@@ -0,0 +1,44 @@
1
+ export interface PwaInstallPrompt extends Event {
2
+ prompt: () => Promise<void>;
3
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
4
+ }
5
+ type InstallStore = {
6
+ prompt: PwaInstallPrompt | null;
7
+ listeners: Set<() => void>;
8
+ };
9
+ declare global {
10
+ interface Window {
11
+ __tuturuuuPwaInstall?: InstallStore;
12
+ }
13
+ }
14
+ /** Capture before hydration, retaining the one-shot prompt across component mounts. */
15
+ export function initializePwaInstall() {
16
+ if (typeof window === 'undefined' || window.__tuturuuuPwaInstall) return;
17
+ const store: InstallStore = { prompt: null, listeners: new Set() };
18
+ window.__tuturuuuPwaInstall = store;
19
+ window.addEventListener('beforeinstallprompt', (event) => {
20
+ event.preventDefault();
21
+ store.prompt = event as PwaInstallPrompt;
22
+ for (const listener of store.listeners) listener();
23
+ });
24
+ window.addEventListener('appinstalled', clearPwaInstallPrompt);
25
+ }
26
+ export function getPwaInstallPrompt() {
27
+ return typeof window === 'undefined'
28
+ ? null
29
+ : (window.__tuturuuuPwaInstall?.prompt ?? null);
30
+ }
31
+ export function subscribePwaInstall(listener: () => void) {
32
+ initializePwaInstall();
33
+ const store = window.__tuturuuuPwaInstall!;
34
+ store.listeners.add(listener);
35
+ return () => {
36
+ store.listeners.delete(listener);
37
+ };
38
+ }
39
+ export function clearPwaInstallPrompt() {
40
+ const store = window.__tuturuuuPwaInstall;
41
+ if (!store) return;
42
+ store.prompt = null;
43
+ for (const listener of store.listeners) listener();
44
+ }
@@ -1,7 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
4
- import { listWorkspaceCalendars } from '@tuturuuu/internal-api/calendar';
4
+ import {
5
+ listWorkspaceCalendars,
6
+ syncWorkspaceCalendar,
7
+ } from '@tuturuuu/internal-api/calendar';
5
8
  import type {
6
9
  Workspace,
7
10
  WorkspaceCalendarEvent,
@@ -12,6 +15,7 @@ import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
12
15
  import dayjs from 'dayjs';
13
16
  import {
14
17
  createContext,
18
+ type SetStateAction,
15
19
  useCallback,
16
20
  useContext,
17
21
  useEffect,
@@ -20,6 +24,11 @@ import {
20
24
  useState,
21
25
  } from 'react';
22
26
  import { toast } from '../components/ui/sonner';
27
+ import {
28
+ type CacheUpdate,
29
+ type CalendarCache,
30
+ updateCalendarRangeCache,
31
+ } from './calendar-range-cache';
23
32
 
24
33
  // Type for calendar connection
25
34
  type CalendarConnection = {
@@ -119,7 +128,9 @@ const CalendarSyncContext = createContext<{
119
128
  calendarConnections: CalendarConnection[];
120
129
  enabledCalendarIds: Set<string>;
121
130
  updateCalendarConnection: (connectionId: string, isEnabled: boolean) => void;
122
- setCalendarConnections: (connections: CalendarConnection[]) => void;
131
+ setCalendarConnections: (
132
+ connections: SetStateAction<CalendarConnection[]>
133
+ ) => void;
123
134
 
124
135
  // Sync status
125
136
  syncStatus: SyncStatus;
@@ -164,24 +175,6 @@ const CalendarSyncContext = createContext<{
164
175
  isSyncing: false,
165
176
  });
166
177
 
167
- // Add a type for the cache
168
- type CalendarCache = {
169
- [key: string]: {
170
- dbEvents: WorkspaceCalendarEvent[];
171
- googleEvents: WorkspaceCalendarEvent[];
172
- dbLastUpdated: number;
173
- googleLastUpdated: number;
174
- };
175
- };
176
-
177
- // Helper type for cache updates
178
- type CacheUpdate = {
179
- dbEvents?: WorkspaceCalendarEvent[];
180
- googleEvents?: WorkspaceCalendarEvent[];
181
- dbLastUpdated?: number;
182
- googleLastUpdated?: number;
183
- };
184
-
185
178
  export const CalendarSyncProvider = ({
186
179
  children,
187
180
  wsId,
@@ -264,12 +257,7 @@ export const CalendarSyncProvider = ({
264
257
  []
265
258
  );
266
259
 
267
- const setCalendarConnections = useCallback(
268
- (connections: CalendarConnection[]) => {
269
- setCalendarConnectionsState(connections);
270
- },
271
- []
272
- );
260
+ const setCalendarConnections = setCalendarConnectionsState;
273
261
 
274
262
  // Helper to generate cache key from dates
275
263
  const getCacheKey = useCallback((dateRange: Date[]) => {
@@ -280,8 +268,8 @@ export const CalendarSyncProvider = ({
280
268
  }, []);
281
269
 
282
270
  const activeCacheKey = useMemo(
283
- () => getCacheKey(dates),
284
- [dates, getCacheKey]
271
+ () => (dates.length ? `${wsId}:${getCacheKey(dates)}` : ''),
272
+ [dates, getCacheKey, wsId]
285
273
  );
286
274
  const activeCachedDatabaseEvents = activeCacheKey
287
275
  ? calendarCache[activeCacheKey]?.dbEvents
@@ -329,36 +317,10 @@ export const CalendarSyncProvider = ({
329
317
  return isStale;
330
318
  };
331
319
 
332
- // Helper to update cache safely
333
320
  const updateCache = useCallback((cacheKey: string, update: CacheUpdate) => {
334
- setCalendarCache((prev) => {
335
- const existing = prev[cacheKey] || {
336
- dbEvents: [],
337
- googleEvents: [],
338
- dbLastUpdated: 0,
339
- googleLastUpdated: 0,
340
- };
341
-
342
- return {
343
- ...prev,
344
- [cacheKey]: {
345
- dbEvents:
346
- update.dbEvents !== undefined ? update.dbEvents : existing.dbEvents,
347
- googleEvents:
348
- update.googleEvents !== undefined
349
- ? update.googleEvents
350
- : existing.googleEvents,
351
- dbLastUpdated:
352
- update.dbLastUpdated !== undefined
353
- ? update.dbLastUpdated
354
- : existing.dbLastUpdated,
355
- googleLastUpdated:
356
- update.googleLastUpdated !== undefined
357
- ? update.googleLastUpdated
358
- : existing.googleLastUpdated,
359
- },
360
- };
361
- });
321
+ setCalendarCache((prev) =>
322
+ updateCalendarRangeCache(prev, cacheKey, update)
323
+ );
362
324
  }, []);
363
325
 
364
326
  const isVisibleInCurrentRange = useCallback(
@@ -634,46 +596,10 @@ export const CalendarSyncProvider = ({
634
596
  });
635
597
 
636
598
  try {
637
- // Use fixed date range for consistent incremental sync (60 days past, 90 days future)
638
- // This ensures sync tokens work properly instead of constantly changing ranges
639
- // Reduced from 270 days to 150 days for better performance
640
- const activeSyncResponse = await fetch(
641
- `/api/v1/workspaces/${wsId}/calendar/sync`,
642
- {
643
- method: 'POST',
644
- credentials: 'include',
645
- headers: { 'Content-Type': 'application/json' },
646
- body: JSON.stringify({
647
- direction: 'inbound',
648
- source: 'manual',
649
- }),
650
- }
651
- );
652
-
653
- if (!activeSyncResponse.ok) {
654
- const errorData = await activeSyncResponse.json();
655
- const errorMessage =
656
- errorData.error ||
657
- (errorData.code === 'sync_already_running'
658
- ? 'sync_in_progress'
659
- : 'Failed to sync calendar');
660
-
661
- setError(new Error(errorMessage));
662
- setSyncStatus({
663
- state: 'error',
664
- message: errorMessage,
665
- lastSyncTime: new Date(),
666
- });
667
-
668
- // Show detailed error toast
669
- toast.error('Calendar sync failed', {
670
- description: errorMessage,
671
- duration: 5000,
672
- });
673
- return;
674
- }
675
-
676
- await activeSyncResponse.json();
599
+ const result = await syncWorkspaceCalendar(wsId);
600
+ // Partial imports can change events even when another calendar fails.
601
+ refresh();
602
+ if (!result.ok) throw new Error(result.error || 'Calendar sync failed');
677
603
 
678
604
  setError(null);
679
605
  setSyncStatus({
@@ -683,9 +609,6 @@ export const CalendarSyncProvider = ({
683
609
  direction: 'google-to-tuturuuu',
684
610
  });
685
611
 
686
- // Refresh the cache to trigger queryClient to refetch the data from database
687
- refresh();
688
-
689
612
  if (progressCallback) {
690
613
  progressCallback({
691
614
  phase: 'complete',
@@ -5,6 +5,7 @@ import {
5
5
  type WorkspaceCalendarEventCreatePayload,
6
6
  type WorkspaceCalendarEventUpdatePayload,
7
7
  } from '@tuturuuu/internal-api';
8
+ import { syncWorkspaceCalendar } from '@tuturuuu/internal-api/calendar';
8
9
  import { createClient } from '@tuturuuu/supabase/next/client';
9
10
  import type {
10
11
  Workspace,
@@ -12,10 +13,7 @@ import type {
12
13
  } from '@tuturuuu/types';
13
14
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
14
15
  import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
15
- import {
16
- createAllDayEvent,
17
- isAllDayEvent,
18
- } from '@tuturuuu/utils/calendar-utils';
16
+ import { createAllDayEvent } from '@tuturuuu/utils/calendar-utils';
19
17
  import dayjs from 'dayjs';
20
18
  import moment from 'moment';
21
19
  import 'moment/locale/vi';
@@ -30,20 +28,10 @@ import {
30
28
  useState,
31
29
  } from 'react';
32
30
  import { getTaskApiUrl } from '../lib/tasks-app-url';
31
+ import { roundToNearest15Minutes } from './calendar-date-utils';
32
+ import { createCalendarEventLookup } from './calendar-event-lookup';
33
33
  import { useCalendarSync } from './use-calendar-sync';
34
-
35
- // Utility function to round time to nearest 15-minute interval
36
- const roundToNearest15Minutes = (date: Date): Date => {
37
- const minutes = date.getMinutes();
38
- const remainder = minutes % 15;
39
- const roundedMinutes =
40
- remainder < 8 ? minutes - remainder : minutes + (15 - remainder);
41
- const roundedDate = new Date(date);
42
- roundedDate.setMinutes(roundedMinutes);
43
- roundedDate.setSeconds(0);
44
- roundedDate.setMilliseconds(0);
45
- return roundedDate;
46
- };
34
+ import { useOpenInitialCalendarEvent } from './use-open-initial-calendar-event';
47
35
 
48
36
  type TaskDragData = {
49
37
  name?: string;
@@ -426,6 +414,7 @@ export const CalendarProvider = ({
426
414
  useQueryClient,
427
415
  children,
428
416
  experimentalGoogleToken: _experimentalGoogleToken,
417
+ initialEventId,
429
418
  eventAdapter,
430
419
  readOnly = false,
431
420
  }: {
@@ -434,24 +423,22 @@ export const CalendarProvider = ({
434
423
  useQueryClient: any;
435
424
  children: ReactNode;
436
425
  experimentalGoogleToken?: WorkspaceCalendarGoogleTokenClient | null;
426
+ initialEventId?: string;
437
427
  eventAdapter?: CalendarEventAdapter;
438
428
  readOnly?: boolean;
439
429
  }) => {
440
430
  const queryClient = useQueryClient();
441
431
 
442
- // Add debounce timer reference for update events
443
432
  const updateDebounceTimerRef = useRef<NodeJS.Timeout | null>(null);
444
433
  const pendingUpdatesRef = useRef<Map<string, PendingEventUpdate>>(
445
434
  new Map<string, PendingEventUpdate>()
446
435
  );
447
436
 
448
- // Queue for processing updates in order
449
437
  const updateQueueRef = useRef<PendingEventUpdate[]>([]);
450
438
  const isProcessingQueueRef = useRef<boolean>(false);
451
439
 
452
440
  const { events, refresh, patchVisibleEvents } = useCalendarSync();
453
441
 
454
- // Modal state
455
442
  const [activeEventId, setActiveEventId] = useState<string | null>(null);
456
443
  const [previewEventId, setPreviewEventId] = useState<string | null>(null);
457
444
  const [isModalHidden, setModalHidden] = useState(false);
@@ -461,7 +448,6 @@ export const CalendarProvider = ({
461
448
  'manual'
462
449
  );
463
450
 
464
- // Callback for when a task is scheduled (allows components to refresh)
465
451
  const [onTaskScheduled, setOnTaskScheduled] = useState<
466
452
  (() => void) | undefined
467
453
  >(undefined);
@@ -516,44 +502,8 @@ export const CalendarProvider = ({
516
502
  [events]
517
503
  );
518
504
 
519
- const getCurrentEvents = useCallback(
520
- (date?: Date) => {
521
- const targetDate = date || new Date();
522
- const targetDay = new Date(
523
- targetDate.getFullYear(),
524
- targetDate.getMonth(),
525
- targetDate.getDate()
526
- );
527
-
528
- return events.filter((e: CalendarEvent) => {
529
- const eventStart = new Date(e.start_at);
530
- const eventEnd = new Date(e.end_at);
531
-
532
- // Normalize dates to compare just the date part (ignoring time)
533
- const eventStartDay = new Date(
534
- eventStart.getFullYear(),
535
- eventStart.getMonth(),
536
- eventStart.getDate()
537
- );
538
-
539
- const eventEndDay = new Date(
540
- eventEnd.getFullYear(),
541
- eventEnd.getMonth(),
542
- eventEnd.getDate()
543
- );
544
-
545
- // Use only time-based logic for all-day detection
546
- const isAllDay = isAllDayEvent(e);
547
-
548
- // For all-day events, treat end date as exclusive (consistent with week view)
549
- // For timed events, treat end date as inclusive
550
- if (isAllDay) {
551
- return eventStartDay <= targetDay && eventEndDay > targetDay;
552
- } else {
553
- return eventStartDay <= targetDay && eventEndDay >= targetDay;
554
- }
555
- });
556
- },
505
+ const getCurrentEvents = useMemo(
506
+ () => createCalendarEventLookup(events),
557
507
  [events]
558
508
  );
559
509
 
@@ -1310,6 +1260,12 @@ export const CalendarProvider = ({
1310
1260
  [ws?.id, eventAdapter, events]
1311
1261
  );
1312
1262
 
1263
+ useOpenInitialCalendarEvent({
1264
+ events,
1265
+ initialEventId,
1266
+ onOpen: openEventEditor,
1267
+ });
1268
+
1313
1269
  const openModal = useCallback(
1314
1270
  (
1315
1271
  eventId?: string,
@@ -1412,19 +1368,12 @@ export const CalendarProvider = ({
1412
1368
  });
1413
1369
  }
1414
1370
 
1415
- const response = await fetch(
1416
- `/api/v1/workspaces/${ws.id}/calendar/sync`,
1417
- {
1418
- method: 'POST',
1419
- headers: { 'Content-Type': 'application/json' },
1420
- body: JSON.stringify({ direction: 'inbound', source: 'manual' }),
1421
- }
1422
- );
1423
-
1424
- const result = await response.json().catch(() => null);
1425
- if (!response.ok) {
1426
- throw new Error(result?.error || 'Calendar sync failed');
1427
- }
1371
+ const result = await syncWorkspaceCalendar(ws.id);
1372
+ await queryClient.invalidateQueries({
1373
+ queryKey: ['databaseCalendarEvents', ws.id],
1374
+ exact: false,
1375
+ });
1376
+ if (!result.ok) throw new Error(result.error || 'Calendar sync failed');
1428
1377
 
1429
1378
  const inserted =
1430
1379
  (result?.summary?.google?.inserted ?? 0) +
@@ -1437,11 +1386,6 @@ export const CalendarProvider = ({
1437
1386
  (result?.summary?.microsoft?.deleted ?? 0);
1438
1387
  const changesMade = inserted + updated + deleted > 0;
1439
1388
 
1440
- await queryClient.invalidateQueries({
1441
- queryKey: ['databaseCalendarEvents', ws.id],
1442
- exact: false,
1443
- });
1444
-
1445
1389
  if (progressCallback) {
1446
1390
  progressCallback({
1447
1391
  phase: 'complete',