@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,47 @@
|
|
|
1
|
+
import { AlertTriangle } from '@tuturuuu/icons';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Button } from '../../button';
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
DialogTrigger,
|
|
11
|
+
} from '../../dialog';
|
|
12
|
+
import { CalendarConnectionsSettingsContent } from './calendar-connections-settings-content';
|
|
13
|
+
import { needsCalendarSyncAttention } from './calendar-sync-recovery';
|
|
14
|
+
import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
|
|
15
|
+
|
|
16
|
+
/** Keep recovery visible when the sidebar replaces the header calendar picker. */
|
|
17
|
+
export function CalendarSyncAttentionButton({
|
|
18
|
+
state,
|
|
19
|
+
}: {
|
|
20
|
+
state: CalendarConnectionsManagerState;
|
|
21
|
+
}) {
|
|
22
|
+
const [open, setOpen] = useState(false);
|
|
23
|
+
if (!open && !needsCalendarSyncAttention(state)) return null;
|
|
24
|
+
return (
|
|
25
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
26
|
+
<DialogTrigger asChild>
|
|
27
|
+
<Button
|
|
28
|
+
variant="outline"
|
|
29
|
+
size="sm"
|
|
30
|
+
className="gap-1.5 border-dynamic-orange/40 text-dynamic-orange"
|
|
31
|
+
>
|
|
32
|
+
<AlertTriangle className="size-4 shrink-0" />
|
|
33
|
+
{state.t('sync_recovery.attention')}
|
|
34
|
+
</Button>
|
|
35
|
+
</DialogTrigger>
|
|
36
|
+
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-lg">
|
|
37
|
+
<DialogHeader>
|
|
38
|
+
<DialogTitle>{state.t('manage_calendar_accounts')}</DialogTitle>
|
|
39
|
+
<DialogDescription>
|
|
40
|
+
{state.t('manage_calendar_accounts_desc')}
|
|
41
|
+
</DialogDescription>
|
|
42
|
+
</DialogHeader>
|
|
43
|
+
<CalendarConnectionsSettingsContent state={state} />
|
|
44
|
+
</DialogContent>
|
|
45
|
+
</Dialog>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
CalendarSyncRecovery,
|
|
5
|
+
needsCalendarSyncAttention,
|
|
6
|
+
} from './calendar-sync-recovery';
|
|
7
|
+
import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
|
|
8
|
+
|
|
9
|
+
function state(overrides: Partial<CalendarConnectionsManagerState> = {}) {
|
|
10
|
+
return {
|
|
11
|
+
accounts: [],
|
|
12
|
+
failedCalendars: [],
|
|
13
|
+
pauseFailedCalendarMutation: { mutate: vi.fn(), isPending: false },
|
|
14
|
+
providerAccountStatuses: {},
|
|
15
|
+
syncStatusError: false,
|
|
16
|
+
syncHealth: {
|
|
17
|
+
state: 'degraded',
|
|
18
|
+
reason: 'sync_stalled',
|
|
19
|
+
currentlyRunning: false,
|
|
20
|
+
lastSuccessAt: null,
|
|
21
|
+
lastFailureAt: null,
|
|
22
|
+
retryAfterSeconds: null,
|
|
23
|
+
},
|
|
24
|
+
retrySyncStatus: vi.fn(),
|
|
25
|
+
syncMutation: { mutate: vi.fn(), isPending: false },
|
|
26
|
+
manualSyncDisabled: false,
|
|
27
|
+
googleAuthMutation: { mutate: vi.fn(), isPending: false },
|
|
28
|
+
microsoftAuthMutation: { mutate: vi.fn(), isPending: false },
|
|
29
|
+
t: (key: string) => key,
|
|
30
|
+
...overrides,
|
|
31
|
+
} as unknown as CalendarConnectionsManagerState;
|
|
32
|
+
}
|
|
33
|
+
describe('calendar sync recovery actions', () => {
|
|
34
|
+
it('shows a persistent warning with a working retry for the stalled-run case', () => {
|
|
35
|
+
const value = state();
|
|
36
|
+
render(<CalendarSyncRecovery state={value} />);
|
|
37
|
+
expect(screen.getByRole('alert').textContent).toContain(
|
|
38
|
+
'sync_recovery.stalled'
|
|
39
|
+
);
|
|
40
|
+
fireEvent.click(screen.getByRole('button', { name: 'sync_now' }));
|
|
41
|
+
expect(value.syncMutation.mutate).toHaveBeenCalledOnce();
|
|
42
|
+
expect(screen.queryByText('sync_recovery.reconnect_google')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it('rechecks failed status requests instead of starting an unobserved sync', () => {
|
|
45
|
+
const value = state({ syncStatusError: true });
|
|
46
|
+
render(<CalendarSyncRecovery state={value} />);
|
|
47
|
+
fireEvent.click(
|
|
48
|
+
screen.getByRole('button', { name: 'sync_recovery.check_again' })
|
|
49
|
+
);
|
|
50
|
+
expect(value.retrySyncStatus).toHaveBeenCalledOnce();
|
|
51
|
+
expect(value.syncMutation.mutate).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
it('offers Google reconnection for the affected account', () => {
|
|
54
|
+
const value = state({
|
|
55
|
+
accounts: [
|
|
56
|
+
{
|
|
57
|
+
id: 'google-account',
|
|
58
|
+
provider: 'google',
|
|
59
|
+
account_email: 'test@example.com',
|
|
60
|
+
} as never,
|
|
61
|
+
],
|
|
62
|
+
providerAccountStatuses: {
|
|
63
|
+
'google-account': { state: 'reconnect_required' },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
render(<CalendarSyncRecovery state={value} />);
|
|
67
|
+
fireEvent.click(
|
|
68
|
+
screen.getByRole('button', { name: 'sync_recovery.reconnect_google' })
|
|
69
|
+
);
|
|
70
|
+
expect(value.googleAuthMutation.mutate).toHaveBeenCalledOnce();
|
|
71
|
+
});
|
|
72
|
+
it('does not send users through OAuth for a temporary provider outage', () => {
|
|
73
|
+
const value = state({
|
|
74
|
+
providerAccountStatuses: { google: { state: 'temporarily_unavailable' } },
|
|
75
|
+
});
|
|
76
|
+
render(<CalendarSyncRecovery state={value} />);
|
|
77
|
+
expect(screen.getByRole('alert').textContent).toContain(
|
|
78
|
+
'sync_recovery.provider_unavailable'
|
|
79
|
+
);
|
|
80
|
+
expect(screen.queryByText('sync_recovery.reconnect_google')).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
it('hides the warning after a healthy sync', () => {
|
|
83
|
+
const value = state();
|
|
84
|
+
value.syncHealth!.state = 'healthy';
|
|
85
|
+
expect(needsCalendarSyncAttention(value)).toBe(false);
|
|
86
|
+
render(<CalendarSyncRecovery state={value} />);
|
|
87
|
+
expect(screen.queryByRole('alert')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('can pause an identified calendar during the retry cooldown without deleting saved events', () => {
|
|
92
|
+
const value = state({
|
|
93
|
+
manualSyncDisabled: true,
|
|
94
|
+
failedCalendars: [
|
|
95
|
+
{
|
|
96
|
+
connectionId: 'broken',
|
|
97
|
+
calendarName: 'Unavailable calendar',
|
|
98
|
+
code: 'not_found',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
render(<CalendarSyncRecovery state={value} />);
|
|
103
|
+
expect(screen.getByRole('button', { name: 'sync_now' })).toBeDisabled();
|
|
104
|
+
expect(screen.getByText('Unavailable calendar')).toBeVisible();
|
|
105
|
+
fireEvent.click(
|
|
106
|
+
screen.getByRole('button', { name: 'sync_recovery.pause_calendar' })
|
|
107
|
+
);
|
|
108
|
+
expect(value.pauseFailedCalendarMutation.mutate).toHaveBeenCalledWith(
|
|
109
|
+
'broken'
|
|
110
|
+
);
|
|
111
|
+
expect(value.syncMutation.mutate).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { AlertTriangle, RefreshCw } from '@tuturuuu/icons';
|
|
2
|
+
import { Alert, AlertDescription, AlertTitle } from '../../alert';
|
|
3
|
+
import { Button } from '../../button';
|
|
4
|
+
import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
|
|
5
|
+
|
|
6
|
+
type RecoveryState = Pick<
|
|
7
|
+
CalendarConnectionsManagerState,
|
|
8
|
+
| 'failedCalendars'
|
|
9
|
+
| 'pauseFailedCalendarMutation'
|
|
10
|
+
| 'accounts'
|
|
11
|
+
| 'providerAccountStatuses'
|
|
12
|
+
| 'syncHealth'
|
|
13
|
+
| 'syncStatusError'
|
|
14
|
+
| 'retrySyncStatus'
|
|
15
|
+
| 'syncMutation'
|
|
16
|
+
| 'manualSyncDisabled'
|
|
17
|
+
| 'googleAuthMutation'
|
|
18
|
+
| 'microsoftAuthMutation'
|
|
19
|
+
| 't'
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
export function needsCalendarSyncAttention(
|
|
23
|
+
state: Pick<
|
|
24
|
+
RecoveryState,
|
|
25
|
+
| 'syncHealth'
|
|
26
|
+
| 'syncStatusError'
|
|
27
|
+
| 'providerAccountStatuses'
|
|
28
|
+
| 'syncMutation'
|
|
29
|
+
>
|
|
30
|
+
) {
|
|
31
|
+
const lastSuccess = state.syncHealth?.lastSuccessAt;
|
|
32
|
+
const failedAttemptIsNewer =
|
|
33
|
+
!lastSuccess || Date.parse(lastSuccess) < state.syncMutation.submittedAt;
|
|
34
|
+
return (
|
|
35
|
+
state.syncStatusError ||
|
|
36
|
+
(failedAttemptIsNewer &&
|
|
37
|
+
(state.syncMutation.isError || state.syncMutation.data?.ok === false)) ||
|
|
38
|
+
state.syncHealth?.state === 'degraded' ||
|
|
39
|
+
Object.values(state.providerAccountStatuses).some(
|
|
40
|
+
(status) => status.state !== 'connected'
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function CalendarSyncRecovery({ state }: { state: RecoveryState }) {
|
|
46
|
+
const { t, syncHealth, syncStatusError, providerAccountStatuses } = state;
|
|
47
|
+
if (!needsCalendarSyncAttention(state) && !state.syncMutation.isPending)
|
|
48
|
+
return null;
|
|
49
|
+
const reconnectAccounts = state.accounts.filter(
|
|
50
|
+
(account) =>
|
|
51
|
+
providerAccountStatuses[account.id]?.state === 'reconnect_required'
|
|
52
|
+
);
|
|
53
|
+
const reason = syncStatusError
|
|
54
|
+
? 'unavailable'
|
|
55
|
+
: reconnectAccounts.length
|
|
56
|
+
? 'reconnect_required'
|
|
57
|
+
: Object.values(providerAccountStatuses).some(
|
|
58
|
+
(status) => status.state === 'temporarily_unavailable'
|
|
59
|
+
)
|
|
60
|
+
? 'provider_unavailable'
|
|
61
|
+
: syncHealth?.state === 'degraded'
|
|
62
|
+
? syncHealth.reason
|
|
63
|
+
: 'last_run_failed';
|
|
64
|
+
const description =
|
|
65
|
+
reason === 'sync_stalled'
|
|
66
|
+
? 'sync_recovery.stalled'
|
|
67
|
+
: reason === 'sync_stale'
|
|
68
|
+
? 'sync_recovery.stale'
|
|
69
|
+
: reason === 'not_synced'
|
|
70
|
+
? 'sync_recovery.not_synced'
|
|
71
|
+
: reason === 'unavailable'
|
|
72
|
+
? 'sync_recovery.status_failed'
|
|
73
|
+
: reason === 'reconnect_required' || reason === 'auth'
|
|
74
|
+
? 'sync_recovery.reconnect_description'
|
|
75
|
+
: reason === 'configuration'
|
|
76
|
+
? 'sync_recovery.configuration'
|
|
77
|
+
: reason === 'api_limit'
|
|
78
|
+
? 'sync_recovery.rate_limited'
|
|
79
|
+
: reason === 'provider_unavailable'
|
|
80
|
+
? 'sync_recovery.provider_unavailable'
|
|
81
|
+
: 'sync_recovery.failed';
|
|
82
|
+
const reconnectProviders = new Set(
|
|
83
|
+
reconnectAccounts.map((account) => account.provider)
|
|
84
|
+
);
|
|
85
|
+
if (
|
|
86
|
+
!reconnectProviders.size &&
|
|
87
|
+
(reason === 'auth' || reason === 'reconnect_required')
|
|
88
|
+
) {
|
|
89
|
+
for (const provider of state.accounts.length
|
|
90
|
+
? state.accounts.map((account) => account.provider)
|
|
91
|
+
: (['google', 'microsoft'] as const))
|
|
92
|
+
reconnectProviders.add(provider);
|
|
93
|
+
}
|
|
94
|
+
return (
|
|
95
|
+
<Alert className="border-dynamic-orange/30 bg-dynamic-orange/5 text-foreground">
|
|
96
|
+
<AlertTriangle className="size-4 text-dynamic-orange" />
|
|
97
|
+
<AlertTitle>{t('sync_recovery.attention')}</AlertTitle>
|
|
98
|
+
<AlertDescription className="space-y-3">
|
|
99
|
+
<p>
|
|
100
|
+
{t(
|
|
101
|
+
state.syncMutation.isPending
|
|
102
|
+
? 'sync_recovery.syncing_description'
|
|
103
|
+
: description
|
|
104
|
+
)}
|
|
105
|
+
</p>
|
|
106
|
+
<p className="text-muted-foreground text-xs">
|
|
107
|
+
{t('sync_recovery.saved_events')}
|
|
108
|
+
</p>
|
|
109
|
+
{!!state.failedCalendars?.length && (
|
|
110
|
+
<ul className="space-y-2">
|
|
111
|
+
{state.failedCalendars.map((failure) => (
|
|
112
|
+
<li
|
|
113
|
+
key={failure.connectionId}
|
|
114
|
+
className="space-y-2 rounded-md border p-3"
|
|
115
|
+
>
|
|
116
|
+
<p className="break-words font-medium">
|
|
117
|
+
{failure.calendarName}
|
|
118
|
+
</p>
|
|
119
|
+
<p className="text-muted-foreground text-xs">
|
|
120
|
+
{t(
|
|
121
|
+
failure.code === 'not_found' ||
|
|
122
|
+
failure.code === 'access_denied'
|
|
123
|
+
? 'sync_recovery.calendar_unavailable'
|
|
124
|
+
: 'sync_recovery.calendar_failed'
|
|
125
|
+
)}
|
|
126
|
+
</p>
|
|
127
|
+
<Button
|
|
128
|
+
size="sm"
|
|
129
|
+
variant="outline"
|
|
130
|
+
disabled={state.pauseFailedCalendarMutation.isPending}
|
|
131
|
+
onClick={() =>
|
|
132
|
+
state.pauseFailedCalendarMutation.mutate(
|
|
133
|
+
failure.connectionId
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
>
|
|
137
|
+
{t('sync_recovery.pause_calendar')}
|
|
138
|
+
</Button>
|
|
139
|
+
</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
)}
|
|
143
|
+
{syncHealth?.lastSuccessAt && (
|
|
144
|
+
<p className="text-xs">
|
|
145
|
+
{t('last_synced_at', {
|
|
146
|
+
time: new Date(syncHealth.lastSuccessAt).toLocaleString(),
|
|
147
|
+
})}
|
|
148
|
+
</p>
|
|
149
|
+
)}
|
|
150
|
+
{reconnectAccounts.map((account) => (
|
|
151
|
+
<p key={account.id} className="break-all text-xs">
|
|
152
|
+
{account.account_email}
|
|
153
|
+
</p>
|
|
154
|
+
))}
|
|
155
|
+
<div className="flex flex-wrap gap-2">
|
|
156
|
+
<Button
|
|
157
|
+
size="sm"
|
|
158
|
+
variant="outline"
|
|
159
|
+
disabled={syncStatusError ? false : state.manualSyncDisabled}
|
|
160
|
+
onClick={() =>
|
|
161
|
+
syncStatusError
|
|
162
|
+
? void state.retrySyncStatus()
|
|
163
|
+
: state.syncMutation.mutate()
|
|
164
|
+
}
|
|
165
|
+
>
|
|
166
|
+
<RefreshCw
|
|
167
|
+
className={
|
|
168
|
+
state.syncMutation.isPending ? 'size-4 animate-spin' : 'size-4'
|
|
169
|
+
}
|
|
170
|
+
/>
|
|
171
|
+
{syncStatusError
|
|
172
|
+
? t('sync_recovery.check_again')
|
|
173
|
+
: state.syncMutation.isPending
|
|
174
|
+
? t('syncing_calendars')
|
|
175
|
+
: t('sync_now')}
|
|
176
|
+
</Button>
|
|
177
|
+
{reconnectProviders.has('google') && (
|
|
178
|
+
<Button
|
|
179
|
+
size="sm"
|
|
180
|
+
disabled={state.googleAuthMutation.isPending}
|
|
181
|
+
onClick={() => state.googleAuthMutation.mutate()}
|
|
182
|
+
>
|
|
183
|
+
{t('sync_recovery.reconnect_google')}
|
|
184
|
+
</Button>
|
|
185
|
+
)}
|
|
186
|
+
{reconnectProviders.has('microsoft') && (
|
|
187
|
+
<Button
|
|
188
|
+
size="sm"
|
|
189
|
+
disabled={state.microsoftAuthMutation.isPending}
|
|
190
|
+
onClick={() => state.microsoftAuthMutation.mutate()}
|
|
191
|
+
>
|
|
192
|
+
{t('sync_recovery.reconnect_microsoft')}
|
|
193
|
+
</Button>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
{(syncHealth?.retryAfterSeconds ?? 0) > 0 && (
|
|
197
|
+
<p className="text-xs">
|
|
198
|
+
{t('sync_recovery.retry_after', {
|
|
199
|
+
seconds: syncHealth!.retryAfterSeconds!,
|
|
200
|
+
})}
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
</AlertDescription>
|
|
204
|
+
</Alert>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -37,7 +37,7 @@ export interface CalendarAuthToken {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export interface CalendarSyncHealth {
|
|
40
|
-
state: 'disconnected' | 'syncing' | 'healthy' | 'degraded';
|
|
40
|
+
state: 'paused' | 'disconnected' | 'syncing' | 'healthy' | 'degraded';
|
|
41
41
|
reason: string;
|
|
42
42
|
lastSuccessAt: string | null;
|
|
43
43
|
lastFailureAt: string | null;
|
|
@@ -3,6 +3,10 @@ import {
|
|
|
3
3
|
getGoogleCalendarAuthUrl,
|
|
4
4
|
getWorkspaceCalendarDefaultSource,
|
|
5
5
|
getWorkspaceCalendarSyncPreferences,
|
|
6
|
+
getWorkspaceCalendarSyncStatus,
|
|
7
|
+
listCalendarAccounts,
|
|
8
|
+
listProviderCalendars,
|
|
9
|
+
syncWorkspaceCalendar,
|
|
6
10
|
updateCalendarConnection as updateCalendarConnectionRequest,
|
|
7
11
|
updateWorkspaceCalendarDefaultSource,
|
|
8
12
|
updateWorkspaceCalendarSyncPreferences,
|
|
@@ -16,18 +20,16 @@ import { toast } from '../../sonner';
|
|
|
16
20
|
import {
|
|
17
21
|
type AccountsResponse,
|
|
18
22
|
getCalendarColor,
|
|
19
|
-
type ManualSyncResponse,
|
|
20
23
|
sourceInputFromOption,
|
|
21
24
|
type WorkspaceCalendarsResponse,
|
|
22
25
|
} from './calendar-connections-manager-helpers';
|
|
23
|
-
import type {
|
|
24
|
-
AuthResponse,
|
|
25
|
-
CalendarSyncHealth,
|
|
26
|
-
ProviderCalendar,
|
|
27
|
-
} from './calendar-types';
|
|
26
|
+
import type { AuthResponse } from './calendar-types';
|
|
28
27
|
import { mergeProviderCalendarsByAccount } from './merge-provider-calendars';
|
|
29
28
|
|
|
30
|
-
export type CalendarConnectionsUnifiedVariant =
|
|
29
|
+
export type CalendarConnectionsUnifiedVariant =
|
|
30
|
+
| 'compact'
|
|
31
|
+
| 'settings'
|
|
32
|
+
| 'status';
|
|
31
33
|
|
|
32
34
|
export function useCalendarConnectionsManager(wsId: string) {
|
|
33
35
|
const t = useTranslations('calendar');
|
|
@@ -59,81 +61,73 @@ export function useCalendarConnectionsManager(wsId: string) {
|
|
|
59
61
|
} = useCalendarSync();
|
|
60
62
|
|
|
61
63
|
const syncMutation = useMutation({
|
|
62
|
-
mutationFn:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return result;
|
|
64
|
+
mutationFn: () => syncWorkspaceCalendar(wsId),
|
|
65
|
+
onSuccess: (result) => {
|
|
66
|
+
if (!result.ok) toast.error(t('sync_recovery.partial_failure'));
|
|
67
|
+
else if (result.alreadyRunning) toast.info(t('syncing_calendars'));
|
|
68
|
+
else toast.success(t('sync_recovery.completed'));
|
|
69
|
+
},
|
|
70
|
+
onError: () => toast.error(t('sync_recovery.failed')),
|
|
71
|
+
onSettled: async () => {
|
|
72
|
+
await Promise.all(
|
|
73
|
+
[
|
|
74
|
+
['calendar-sync-status', wsId],
|
|
75
|
+
['provider-calendar-list', wsId],
|
|
76
|
+
['databaseCalendarEvents', wsId],
|
|
77
|
+
].map((queryKey) => queryClient.invalidateQueries({ queryKey }))
|
|
78
|
+
);
|
|
80
79
|
},
|
|
81
|
-
|
|
82
|
-
await queryClient.invalidateQueries({
|
|
83
|
-
queryKey: ['calendar-sync-status', wsId],
|
|
84
|
-
});
|
|
85
|
-
await queryClient.invalidateQueries({
|
|
86
|
-
queryKey: ['databaseCalendarEvents', wsId],
|
|
87
|
-
exact: false,
|
|
88
|
-
});
|
|
80
|
+
});
|
|
89
81
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
82
|
+
const pauseFailedCalendarMutation = useMutation({
|
|
83
|
+
mutationFn: (connectionId: string) =>
|
|
84
|
+
updateCalendarConnectionRequest({
|
|
85
|
+
id: connectionId,
|
|
86
|
+
wsId,
|
|
87
|
+
syncInboundEnabled: false,
|
|
88
|
+
}),
|
|
89
|
+
onSuccess: async (_, connectionId) => {
|
|
90
|
+
setCalendarConnections((current) =>
|
|
91
|
+
current.map((connection) =>
|
|
92
|
+
connection.id === connectionId
|
|
93
|
+
? { ...connection, sync_inbound_enabled: false }
|
|
94
|
+
: connection
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
await Promise.all(
|
|
98
|
+
[
|
|
99
|
+
['calendar-sync-status', wsId],
|
|
100
|
+
['calendar-connections', wsId],
|
|
101
|
+
['provider-calendar-list', wsId],
|
|
102
|
+
].map((queryKey) => queryClient.invalidateQueries({ queryKey }))
|
|
103
|
+
);
|
|
104
|
+
toast.success(t('sync_recovery.calendar_paused'));
|
|
96
105
|
},
|
|
106
|
+
onError: () => toast.error(t('sync_recovery.pause_failed')),
|
|
97
107
|
});
|
|
98
108
|
|
|
99
109
|
// Fetch connected accounts
|
|
100
|
-
const
|
|
110
|
+
const accountsQuery = useQuery({
|
|
101
111
|
queryKey: ['calendar-accounts', wsId],
|
|
102
|
-
queryFn:
|
|
103
|
-
const response = await fetch(
|
|
104
|
-
`/api/v1/calendar/auth/accounts?wsId=${wsId}`,
|
|
105
|
-
{ cache: 'no-store' }
|
|
106
|
-
);
|
|
107
|
-
if (!response.ok)
|
|
108
|
-
return {
|
|
109
|
-
accounts: [],
|
|
110
|
-
grouped: { google: [], microsoft: [] },
|
|
111
|
-
total: 0,
|
|
112
|
-
};
|
|
113
|
-
return response.json() as Promise<AccountsResponse>;
|
|
114
|
-
},
|
|
112
|
+
queryFn: () => listCalendarAccounts(wsId) as Promise<AccountsResponse>,
|
|
115
113
|
staleTime: 30_000,
|
|
114
|
+
retry: 1,
|
|
116
115
|
});
|
|
117
|
-
|
|
118
|
-
const
|
|
116
|
+
const accounts = accountsQuery.data?.accounts || [];
|
|
117
|
+
const isLoadingAccounts = accountsQuery.isLoading;
|
|
119
118
|
const hasConnectedAccounts = accounts.length > 0;
|
|
120
|
-
|
|
121
|
-
const { data: syncStatusData } = useQuery({
|
|
119
|
+
const syncStatusQuery = useQuery({
|
|
122
120
|
queryKey: ['calendar-sync-status', wsId],
|
|
123
|
-
queryFn:
|
|
124
|
-
const response = await fetch(
|
|
125
|
-
`/api/v1/workspaces/${wsId}/calendar/sync-status`,
|
|
126
|
-
{ cache: 'no-store' }
|
|
127
|
-
);
|
|
128
|
-
if (!response.ok) return null;
|
|
129
|
-
return response.json() as Promise<{
|
|
130
|
-
health: CalendarSyncHealth;
|
|
131
|
-
accountsSummary: { total: number; google: number; microsoft: number };
|
|
132
|
-
connectionsSummary: { total: number; enabled: number };
|
|
133
|
-
}>;
|
|
134
|
-
},
|
|
121
|
+
queryFn: () => getWorkspaceCalendarSyncStatus(wsId),
|
|
135
122
|
staleTime: 15_000,
|
|
123
|
+
retry: 1,
|
|
124
|
+
refetchInterval: (query) =>
|
|
125
|
+
query.state.data?.health.currentlyRunning ||
|
|
126
|
+
query.state.data?.health.retryAfterSeconds
|
|
127
|
+
? 5_000
|
|
128
|
+
: 30_000,
|
|
136
129
|
});
|
|
130
|
+
const syncStatusData = syncStatusQuery.data;
|
|
137
131
|
|
|
138
132
|
const { data: defaultSourceData } = useQuery({
|
|
139
133
|
queryKey: ['calendar-default-source', wsId],
|
|
@@ -571,37 +565,35 @@ export function useCalendarConnectionsManager(wsId: string) {
|
|
|
571
565
|
? 'bg-dynamic-blue/10 text-dynamic-blue'
|
|
572
566
|
: syncHealth?.state === 'healthy'
|
|
573
567
|
? 'bg-dynamic-green/10 text-dynamic-green'
|
|
574
|
-
: syncHealth?.state === 'disconnected'
|
|
568
|
+
: syncHealth?.state === 'disconnected' || syncHealth?.state === 'paused'
|
|
575
569
|
? 'bg-muted text-muted-foreground'
|
|
576
570
|
: syncHealth?.state === 'degraded'
|
|
577
571
|
? 'bg-dynamic-orange/10 text-dynamic-orange'
|
|
578
572
|
: 'bg-dynamic-red/10 text-dynamic-red';
|
|
579
573
|
|
|
580
574
|
// Fetch Google calendars from API (must be before any conditional returns)
|
|
581
|
-
const
|
|
575
|
+
const providerCalendarsQuery = useQuery({
|
|
582
576
|
queryKey: ['provider-calendar-list', wsId],
|
|
583
577
|
enabled: hasConnectedAccounts,
|
|
584
|
-
queryFn:
|
|
585
|
-
const response = await fetch(
|
|
586
|
-
`/api/v1/calendar/auth/provider-calendars?wsId=${wsId}`,
|
|
587
|
-
{ cache: 'no-store' }
|
|
588
|
-
);
|
|
589
|
-
if (!response.ok)
|
|
590
|
-
return { calendars: [], byAccount: {}, accountStatuses: {} };
|
|
591
|
-
return response.json() as Promise<{
|
|
592
|
-
calendars: ProviderCalendar[];
|
|
593
|
-
byAccount: Record<string, ProviderCalendar[]>;
|
|
594
|
-
accountStatuses: Record<
|
|
595
|
-
string,
|
|
596
|
-
{ state: 'connected' | 'reconnect_required' }
|
|
597
|
-
>;
|
|
598
|
-
}>;
|
|
599
|
-
},
|
|
578
|
+
queryFn: () => listProviderCalendars(wsId),
|
|
600
579
|
staleTime: 30_000,
|
|
580
|
+
refetchInterval: 60_000,
|
|
581
|
+
retry: 1,
|
|
601
582
|
});
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const providerAccountStatuses =
|
|
583
|
+
const calendarsByAccountFromAPI =
|
|
584
|
+
providerCalendarsQuery.data?.byAccount || {};
|
|
585
|
+
const providerAccountStatuses =
|
|
586
|
+
providerCalendarsQuery.data?.accountStatuses || {};
|
|
587
|
+
const syncStatusError =
|
|
588
|
+
accountsQuery.isError ||
|
|
589
|
+
syncStatusQuery.isError ||
|
|
590
|
+
providerCalendarsQuery.isError;
|
|
591
|
+
const retrySyncStatus = () =>
|
|
592
|
+
Promise.all([
|
|
593
|
+
accountsQuery.refetch(),
|
|
594
|
+
syncStatusQuery.refetch(),
|
|
595
|
+
providerCalendarsQuery.refetch(),
|
|
596
|
+
]);
|
|
605
597
|
|
|
606
598
|
const calendarsByAccount = mergeProviderCalendarsByAccount({
|
|
607
599
|
accounts,
|
|
@@ -614,6 +606,8 @@ export function useCalendarConnectionsManager(wsId: string) {
|
|
|
614
606
|
|
|
615
607
|
return {
|
|
616
608
|
accounts,
|
|
609
|
+
failedCalendars: syncStatusData?.failedCalendars ?? [],
|
|
610
|
+
pauseFailedCalendarMutation,
|
|
617
611
|
calendarConnections,
|
|
618
612
|
calendarsByAccount,
|
|
619
613
|
providerAccountStatuses,
|
|
@@ -642,6 +636,8 @@ export function useCalendarConnectionsManager(wsId: string) {
|
|
|
642
636
|
setShowCreateCalendarDialog,
|
|
643
637
|
showCreateCalendarDialog,
|
|
644
638
|
syncHealth,
|
|
639
|
+
syncStatusError,
|
|
640
|
+
retrySyncStatus,
|
|
645
641
|
syncMutation,
|
|
646
642
|
syncPreferencesData,
|
|
647
643
|
syncPreferencesMutation,
|
|
@@ -4,8 +4,13 @@ import { cjk } from '@streamdown/cjk';
|
|
|
4
4
|
import { code } from '@streamdown/code';
|
|
5
5
|
import { createMathPlugin } from '@streamdown/math';
|
|
6
6
|
import { mermaid as mermaidPlugin } from '@streamdown/mermaid';
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
Component,
|
|
9
|
+
type ComponentProps,
|
|
10
|
+
type ErrorInfo,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { defaultRemarkPlugins, Streamdown } from 'streamdown';
|
|
9
14
|
|
|
10
15
|
const math = createMathPlugin({ singleDollarTextMath: true });
|
|
11
16
|
const markdownPlugins = {
|
|
@@ -40,9 +45,13 @@ class AiMarkdownErrorBoundary extends Component<
|
|
|
40
45
|
export function AssistantMarkdown({
|
|
41
46
|
isAnimating,
|
|
42
47
|
text,
|
|
48
|
+
components,
|
|
49
|
+
remarkPlugins,
|
|
43
50
|
}: {
|
|
44
51
|
isAnimating?: boolean;
|
|
45
52
|
text: string;
|
|
53
|
+
components?: ComponentProps<typeof Streamdown>['components'];
|
|
54
|
+
remarkPlugins?: ComponentProps<typeof Streamdown>['remarkPlugins'];
|
|
46
55
|
}) {
|
|
47
56
|
return (
|
|
48
57
|
<div className="wrap-break-word [&_pre]:overflow-x-hidden! [&_pre]:whitespace-pre-wrap! [&_pre_code]:whitespace-pre-wrap! min-w-0 max-w-full overflow-hidden [&_a]:break-all [&_pre]:max-w-full">
|
|
@@ -50,6 +59,12 @@ export function AssistantMarkdown({
|
|
|
50
59
|
fallback={<p className="whitespace-pre-wrap">{text}</p>}
|
|
51
60
|
>
|
|
52
61
|
<Streamdown
|
|
62
|
+
components={components}
|
|
63
|
+
remarkPlugins={
|
|
64
|
+
remarkPlugins
|
|
65
|
+
? [...Object.values(defaultRemarkPlugins), ...remarkPlugins]
|
|
66
|
+
: undefined
|
|
67
|
+
}
|
|
53
68
|
caret="block"
|
|
54
69
|
controls={{ code: !isAnimating, mermaid: !isAnimating }}
|
|
55
70
|
isAnimating={isAnimating}
|