@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.
- package/CHANGELOG.md +49 -0
- package/biome.json +1 -1
- package/package.json +95 -49
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- 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 {
|
|
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: (
|
|
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 =
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
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 =
|
|
520
|
-
(
|
|
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
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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',
|