@tuturuuu/ui 0.32.0 → 0.33.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 (78) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  23. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  24. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  25. package/src/components/ui/custom/report-preview.tsx +2 -3
  26. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  27. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  28. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  29. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  31. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  33. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  34. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  35. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  36. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  37. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  38. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  39. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  40. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  41. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  42. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  43. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  44. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  45. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  47. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  48. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  49. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  50. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  51. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  52. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  53. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  54. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  55. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  64. package/src/components/ui/public-workspace-prices.tsx +13 -0
  65. package/src/components/ui/pwa-status.test.tsx +13 -2
  66. package/src/components/ui/pwa-status.tsx +15 -3
  67. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  68. package/src/components/ui/text-editor/editor.tsx +11 -3
  69. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  70. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  71. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  72. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  73. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  74. package/src/hooks/calendar-provider-sync.ts +33 -0
  75. package/src/hooks/notification-subscription.ts +74 -0
  76. package/src/hooks/use-calendar-sync.tsx +11 -14
  77. package/src/hooks/use-calendar.tsx +2 -2
  78. package/src/hooks/use-notifications.ts +5 -141
@@ -0,0 +1,321 @@
1
+ import {
2
+ focusManager,
3
+ onlineManager,
4
+ QueryClient,
5
+ QueryClientProvider,
6
+ useQuery,
7
+ } from '@tanstack/react-query';
8
+ import { act, renderHook } from '@testing-library/react';
9
+ import type { CalendarSyncStatusResponse } from '@tuturuuu/internal-api/calendar';
10
+ import {
11
+ getWorkspaceCalendarSyncStatus,
12
+ syncWorkspaceCalendar,
13
+ } from '@tuturuuu/internal-api/calendar';
14
+ import type { ReactNode } from 'react';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import { runCalendarProviderSync } from '../../../../hooks/calendar-provider-sync';
17
+ import {
18
+ calendarSyncStatusQueryOptions,
19
+ refreshCalendarSyncStatus,
20
+ } from './refresh-calendar-sync-status';
21
+
22
+ vi.mock('@tuturuuu/internal-api/calendar', () => ({
23
+ getWorkspaceCalendarSyncStatus: vi.fn(),
24
+ syncWorkspaceCalendar: vi.fn(),
25
+ }));
26
+ const interval = 5 * 60_000;
27
+ let client: QueryClient;
28
+ let status: CalendarSyncStatusResponse;
29
+ const getStatus = vi.mocked(getWorkspaceCalendarSyncStatus);
30
+ const sync = vi.mocked(syncWorkspaceCalendar);
31
+ beforeEach(() => {
32
+ vi.useFakeTimers();
33
+ vi.setSystemTime(new Date('2026-09-11T12:00:00Z'));
34
+ focusManager.setFocused(true);
35
+ onlineManager.setOnline(true);
36
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
37
+ status = {
38
+ health: {
39
+ state: 'degraded',
40
+ reason: 'sync_stale',
41
+ currentlyRunning: false,
42
+ lastSuccessAt: null,
43
+ lastFailureAt: null,
44
+ retryAfterSeconds: null,
45
+ },
46
+ accountsSummary: { total: 1, google: 1, microsoft: 0 },
47
+ connectionsSummary: { total: 2, enabled: 2 },
48
+ };
49
+ getStatus.mockReset().mockImplementation(async () => structuredClone(status));
50
+ sync.mockReset().mockImplementation(async () => {
51
+ status.health = {
52
+ ...status.health,
53
+ state: 'healthy',
54
+ reason: 'ok',
55
+ lastSuccessAt: new Date().toISOString(),
56
+ };
57
+ return { ok: true };
58
+ });
59
+ });
60
+ afterEach(() => {
61
+ client.clear();
62
+ vi.restoreAllMocks();
63
+ focusManager.setFocused(undefined);
64
+ onlineManager.setOnline(true);
65
+ vi.useRealTimers();
66
+ });
67
+
68
+ describe('automatic provider synchronization', () => {
69
+ it('imports stale calendars and refreshes every loaded event range without touching other workspaces', async () => {
70
+ client.setQueryData(['databaseCalendarEvents', 'ws', 'week'], []);
71
+ client.setQueryData(['databaseCalendarEvents', 'ws', 'month'], []);
72
+ client.setQueryData(['databaseCalendarEvents', 'other'], []);
73
+ const result = await refreshCalendarSyncStatus(client, 'ws');
74
+ expect(sync).toHaveBeenCalledExactlyOnceWith('ws');
75
+ expect(result.health.state).toBe('healthy');
76
+ expect(
77
+ client.getQueryState(['databaseCalendarEvents', 'ws', 'week'])
78
+ ?.isInvalidated
79
+ ).toBe(true);
80
+ expect(
81
+ client.getQueryState(['databaseCalendarEvents', 'ws', 'month'])
82
+ ?.isInvalidated
83
+ ).toBe(true);
84
+ expect(
85
+ client.getQueryState(['databaseCalendarEvents', 'other'])?.isInvalidated
86
+ ).toBe(false);
87
+ });
88
+
89
+ it.each(['paused', 'disconnected', 'syncing'] as const)(
90
+ 'does not start a sync when %s',
91
+ async (state) => {
92
+ status.health.state = state;
93
+ status.health.currentlyRunning = state === 'syncing';
94
+ await refreshCalendarSyncStatus(client, 'ws');
95
+ expect(sync).not.toHaveBeenCalled();
96
+ }
97
+ );
98
+ it.each(['auth', 'reconnect_required', 'configuration'])(
99
+ 'leaves %s failures for recovery',
100
+ async (reason) => {
101
+ status.health.reason = reason;
102
+ await refreshCalendarSyncStatus(client, 'ws');
103
+ expect(sync).not.toHaveBeenCalled();
104
+ }
105
+ );
106
+ it('honors cooldowns, disabled calendars and the active-sync switch', async () => {
107
+ status.health.retryAfterSeconds = 30;
108
+ await refreshCalendarSyncStatus(client, 'ws');
109
+ status.health.retryAfterSeconds = null;
110
+ status.connectionsSummary.enabled = 0;
111
+ await refreshCalendarSyncStatus(client, 'ws');
112
+ status.connectionsSummary.enabled = 2;
113
+ await refreshCalendarSyncStatus(client, 'ws', true);
114
+ expect(sync).not.toHaveBeenCalled();
115
+ });
116
+ it('waits for an in-flight manual sync', async () => {
117
+ let finish!: () => void;
118
+ const mutation = client.getMutationCache().build(client, {
119
+ mutationKey: ['calendar-provider-sync', 'ws'],
120
+ mutationFn: () =>
121
+ new Promise<void>((resolve) => {
122
+ finish = resolve;
123
+ }),
124
+ });
125
+ const pending = mutation.execute(undefined);
126
+ await vi.advanceTimersByTimeAsync(0);
127
+ await refreshCalendarSyncStatus(client, 'ws');
128
+ expect(sync).not.toHaveBeenCalled();
129
+ finish();
130
+ await pending;
131
+ });
132
+ it('waits until healthy calendars are five minutes old', async () => {
133
+ status.health.state = 'healthy';
134
+ status.health.lastSuccessAt = new Date().toISOString();
135
+ await refreshCalendarSyncStatus(client, 'ws');
136
+ expect(sync).not.toHaveBeenCalled();
137
+ vi.advanceTimersByTime(interval);
138
+ await refreshCalendarSyncStatus(client, 'ws');
139
+ expect(sync).toHaveBeenCalledOnce();
140
+ });
141
+ it('deduplicates overlapping controls while exposing neutral syncing state', async () => {
142
+ let finish!: () => void;
143
+ sync.mockImplementationOnce(
144
+ () =>
145
+ new Promise((resolve) => {
146
+ finish = () => resolve({ ok: true });
147
+ })
148
+ );
149
+ const first = refreshCalendarSyncStatus(client, 'ws');
150
+ await vi.advanceTimersByTimeAsync(0);
151
+ expect(
152
+ client.getQueryData<CalendarSyncStatusResponse>([
153
+ 'calendar-sync-status',
154
+ 'ws',
155
+ ])?.health.currentlyRunning
156
+ ).toBe(true);
157
+ await refreshCalendarSyncStatus(client, 'ws');
158
+ expect(sync).toHaveBeenCalledOnce();
159
+ finish();
160
+ await first;
161
+ });
162
+ it('backs off after partial failures and still refreshes imported events', async () => {
163
+ sync.mockResolvedValue({ ok: false, partialFailure: true });
164
+ const invalidate = vi.spyOn(client, 'invalidateQueries');
165
+ await refreshCalendarSyncStatus(client, 'ws');
166
+ expect(invalidate).toHaveBeenCalledWith({
167
+ queryKey: ['databaseCalendarEvents', 'ws'],
168
+ });
169
+ vi.advanceTimersByTime(interval);
170
+ await refreshCalendarSyncStatus(client, 'ws');
171
+ expect(sync).toHaveBeenCalledTimes(1);
172
+ vi.advanceTimersByTime(interval);
173
+ await refreshCalendarSyncStatus(client, 'ws');
174
+ expect(sync).toHaveBeenCalledTimes(2);
175
+ vi.advanceTimersByTime(interval * 2);
176
+ await refreshCalendarSyncStatus(client, 'ws');
177
+ expect(sync).toHaveBeenCalledTimes(2);
178
+ });
179
+ it('handles network failures quietly and respects provider retry-after', async () => {
180
+ sync.mockRejectedValueOnce(new Error('Network unavailable'));
181
+ await expect(
182
+ refreshCalendarSyncStatus(client, 'ws')
183
+ ).resolves.toBeDefined();
184
+ vi.advanceTimersByTime(interval * 2);
185
+ sync.mockResolvedValueOnce({ ok: false, retryAfterSeconds: 3600 });
186
+ await refreshCalendarSyncStatus(client, 'ws');
187
+ vi.advanceTimersByTime(interval * 7);
188
+ await refreshCalendarSyncStatus(client, 'ws');
189
+ expect(sync).toHaveBeenCalledTimes(2);
190
+ });
191
+ it('does not sync while hidden or offline', async () => {
192
+ focusManager.setFocused(false);
193
+ await refreshCalendarSyncStatus(client, 'ws');
194
+ focusManager.setFocused(true);
195
+ onlineManager.setOnline(false);
196
+ await refreshCalendarSyncStatus(client, 'ws');
197
+ expect(sync).not.toHaveBeenCalled();
198
+ });
199
+ });
200
+
201
+ it('polls while open and catches up on focus and reconnect without a manual click', async () => {
202
+ const wrapper = ({ children }: { children: ReactNode }) => (
203
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
204
+ );
205
+ const view = renderHook(
206
+ () => useQuery(calendarSyncStatusQueryOptions(client, 'ws')),
207
+ { wrapper }
208
+ );
209
+ await act(() => vi.advanceTimersByTimeAsync(1));
210
+ expect(sync).toHaveBeenCalledTimes(1);
211
+ await act(() => vi.advanceTimersByTimeAsync(interval));
212
+ expect(sync).toHaveBeenCalledTimes(2);
213
+ act(() => focusManager.setFocused(false));
214
+ await act(() => vi.advanceTimersByTimeAsync(interval * 2));
215
+ expect(sync).toHaveBeenCalledTimes(2);
216
+ await act(async () => {
217
+ focusManager.setFocused(true);
218
+ await vi.advanceTimersByTimeAsync(1);
219
+ });
220
+ expect(sync).toHaveBeenCalledTimes(3);
221
+ act(() => onlineManager.setOnline(false));
222
+ await act(() => vi.advanceTimersByTimeAsync(interval * 2));
223
+ expect(sync).toHaveBeenCalledTimes(3);
224
+ await act(async () => {
225
+ onlineManager.setOnline(true);
226
+ await vi.advanceTimersByTimeAsync(1);
227
+ });
228
+ expect(sync).toHaveBeenCalledTimes(4);
229
+ view.unmount();
230
+ await act(() => vi.advanceTimersByTimeAsync(interval * 2));
231
+ expect(sync).toHaveBeenCalledTimes(4);
232
+ });
233
+
234
+ it('shares provider work with legacy/manual sync started during a health fetch', async () => {
235
+ let finishStatus!: (value: CalendarSyncStatusResponse) => void;
236
+ let finishSync!: () => void;
237
+ getStatus.mockImplementationOnce(
238
+ () =>
239
+ new Promise((resolve) => {
240
+ finishStatus = resolve;
241
+ })
242
+ );
243
+ sync.mockImplementationOnce(
244
+ () =>
245
+ new Promise((resolve) => {
246
+ finishSync = () => resolve({ ok: true });
247
+ })
248
+ );
249
+ const polling = refreshCalendarSyncStatus(client, 'ws');
250
+ const manual = runCalendarProviderSync(client, 'ws');
251
+ expect(runCalendarProviderSync(client, 'ws')).toBe(manual);
252
+ finishStatus(structuredClone(status));
253
+ await polling;
254
+ expect(sync).toHaveBeenCalledOnce();
255
+ finishSync();
256
+ await manual;
257
+ await runCalendarProviderSync(client, 'ws');
258
+ expect(sync).toHaveBeenCalledTimes(2);
259
+ });
260
+
261
+ it('seeds a new session cooldown from the last server failure', async () => {
262
+ status.health.reason = 'api_limit';
263
+ status.health.lastFailureAt = new Date().toISOString();
264
+ await refreshCalendarSyncStatus(client, 'ws');
265
+ vi.advanceTimersByTime(interval);
266
+ await refreshCalendarSyncStatus(client, 'ws');
267
+ expect(sync).not.toHaveBeenCalled();
268
+ vi.advanceTimersByTime(interval);
269
+ await refreshCalendarSyncStatus(client, 'ws');
270
+ expect(sync).toHaveBeenCalledOnce();
271
+ });
272
+
273
+ it('checks again shortly when another session already started provider work', async () => {
274
+ sync.mockResolvedValueOnce({ ok: true, alreadyRunning: true });
275
+ await refreshCalendarSyncStatus(client, 'ws');
276
+ vi.advanceTimersByTime(29_999);
277
+ await refreshCalendarSyncStatus(client, 'ws');
278
+ expect(sync).toHaveBeenCalledTimes(1);
279
+ vi.advanceTimersByTime(1);
280
+ await refreshCalendarSyncStatus(client, 'ws');
281
+ expect(sync).toHaveBeenCalledTimes(2);
282
+ });
283
+
284
+ it('does not sync a hidden document even when the focus manager reports focused', async () => {
285
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
286
+ await refreshCalendarSyncStatus(client, 'ws');
287
+ expect(sync).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it('treats a healthy response with no success timestamp as stale but throttles retries', async () => {
291
+ status.health.state = 'healthy';
292
+ sync.mockResolvedValue({ ok: true });
293
+ await refreshCalendarSyncStatus(client, 'ws');
294
+ await refreshCalendarSyncStatus(client, 'ws');
295
+ expect(sync).toHaveBeenCalledOnce();
296
+ vi.advanceTimersByTime(interval);
297
+ await refreshCalendarSyncStatus(client, 'ws');
298
+ expect(sync).toHaveBeenCalledTimes(2);
299
+ });
300
+
301
+ it('refreshes event ranges after another session finishes importing, not on its pending response', async () => {
302
+ const invalidate = vi.spyOn(client, 'invalidateQueries');
303
+ sync.mockImplementationOnce(async () => {
304
+ status.health.state = 'syncing';
305
+ status.health.currentlyRunning = true;
306
+ return { ok: true, alreadyRunning: true };
307
+ });
308
+ await refreshCalendarSyncStatus(client, 'ws');
309
+ expect(invalidate).not.toHaveBeenCalled();
310
+ status.health.state = 'healthy';
311
+ status.health.currentlyRunning = false;
312
+ status.health.lastSuccessAt = new Date().toISOString();
313
+ await refreshCalendarSyncStatus(client, 'ws');
314
+ expect(invalidate).toHaveBeenCalledWith({
315
+ queryKey: ['databaseCalendarEvents', 'ws'],
316
+ });
317
+ expect(sync).toHaveBeenCalledOnce();
318
+ invalidate.mockClear();
319
+ await refreshCalendarSyncStatus(client, 'ws');
320
+ expect(invalidate).not.toHaveBeenCalled();
321
+ });
@@ -0,0 +1,182 @@
1
+ import {
2
+ focusManager,
3
+ onlineManager,
4
+ type QueryClient,
5
+ queryOptions,
6
+ } from '@tanstack/react-query';
7
+ import type { CalendarSyncStatusResponse } from '@tuturuuu/internal-api/calendar';
8
+ import { getWorkspaceCalendarSyncStatus } from '@tuturuuu/internal-api/calendar';
9
+
10
+ import {
11
+ isCalendarProviderSyncRunning,
12
+ runCalendarProviderSync,
13
+ } from '../../../../hooks/calendar-provider-sync';
14
+
15
+ const SYNC_INTERVAL_MS = 5 * 60_000;
16
+ const MAX_BACKOFF_MS = 30 * 60_000;
17
+ type Attempt = { running: boolean; nextAt: number; failures: number };
18
+ // All calendar controls in one app share a coordinator, scoped to their session.
19
+ const attempts = new WeakMap<QueryClient, Map<string, Attempt>>();
20
+
21
+ const observedHealth = new WeakMap<
22
+ QueryClient,
23
+ Map<string, CalendarSyncStatusResponse['health']>
24
+ >();
25
+
26
+ function observeSyncCompletion(
27
+ client: QueryClient,
28
+ wsId: string,
29
+ status: CalendarSyncStatusResponse
30
+ ) {
31
+ let workspaces = observedHealth.get(client);
32
+ if (!workspaces) {
33
+ workspaces = new Map();
34
+ observedHealth.set(client, workspaces);
35
+ }
36
+ const previous = workspaces.get(wsId);
37
+ const health = status.health;
38
+ workspaces.set(wsId, { ...health });
39
+ return (
40
+ !!previous &&
41
+ !health.currentlyRunning &&
42
+ (previous.currentlyRunning ||
43
+ previous.lastSuccessAt !== health.lastSuccessAt ||
44
+ previous.lastFailureAt !== health.lastFailureAt)
45
+ );
46
+ }
47
+
48
+ async function invalidateImportedEvents(client: QueryClient, wsId: string) {
49
+ await Promise.all([
50
+ client.invalidateQueries({ queryKey: ['databaseCalendarEvents', wsId] }),
51
+ client.invalidateQueries({ queryKey: ['provider-calendar-list', wsId] }),
52
+ ]);
53
+ }
54
+
55
+ export function calendarSyncStatusQueryOptions(
56
+ queryClient: QueryClient,
57
+ wsId: string,
58
+ syncBlocked = false
59
+ ) {
60
+ return queryOptions({
61
+ queryKey: ['calendar-sync-status', wsId],
62
+ queryFn: () => refreshCalendarSyncStatus(queryClient, wsId, syncBlocked),
63
+ enabled: !!wsId,
64
+ staleTime: 15_000,
65
+ refetchOnWindowFocus: 'always',
66
+ refetchOnReconnect: 'always',
67
+ refetchIntervalInBackground: false,
68
+ retry: 1,
69
+ refetchInterval: (query) =>
70
+ query.state.data?.health.currentlyRunning ||
71
+ query.state.data?.health.retryAfterSeconds
72
+ ? 5_000
73
+ : 30_000,
74
+ });
75
+ }
76
+
77
+ /** The foreground health poll also keeps connected providers up to date. */
78
+ export async function refreshCalendarSyncStatus(
79
+ queryClient: QueryClient,
80
+ wsId: string,
81
+ syncBlocked = false
82
+ ): Promise<CalendarSyncStatusResponse> {
83
+ const status = await getWorkspaceCalendarSyncStatus(wsId);
84
+ const { health } = status;
85
+ if (observeSyncCompletion(queryClient, wsId, status)) {
86
+ await invalidateImportedEvents(queryClient, wsId);
87
+ }
88
+ const now = Date.now();
89
+ let workspaceAttempts = attempts.get(queryClient);
90
+ if (!workspaceAttempts) {
91
+ workspaceAttempts = new Map();
92
+ attempts.set(queryClient, workspaceAttempts);
93
+ }
94
+ const previous = workspaceAttempts.get(wsId);
95
+ const lastSuccess = Date.parse(health.lastSuccessAt ?? '');
96
+ const lastFailure = Date.parse(health.lastFailureAt ?? '');
97
+ const recentFailure =
98
+ health.state === 'degraded' && now - lastFailure < SYNC_INTERVAL_MS * 2;
99
+ if (
100
+ !focusManager.isFocused() ||
101
+ !onlineManager.isOnline() ||
102
+ (typeof document !== 'undefined' &&
103
+ document.visibilityState === 'hidden') ||
104
+ syncBlocked ||
105
+ recentFailure ||
106
+ isCalendarProviderSyncRunning(queryClient, wsId) ||
107
+ queryClient.isMutating({ mutationKey: ['calendar-provider-sync', wsId] }) >
108
+ 0 ||
109
+ previous?.running ||
110
+ (previous?.nextAt ?? 0) > now ||
111
+ !status.accountsSummary.total ||
112
+ !status.connectionsSummary.enabled ||
113
+ health.currentlyRunning ||
114
+ health.state === 'paused' ||
115
+ health.state === 'disconnected' ||
116
+ (health.retryAfterSeconds ?? 0) > 0 ||
117
+ ['auth', 'reconnect_required', 'configuration'].includes(health.reason) ||
118
+ // Missing or malformed timestamps are treated as stale, with normal throttling.
119
+ (health.state === 'healthy' &&
120
+ Number.isFinite(lastSuccess) &&
121
+ now - lastSuccess < SYNC_INTERVAL_MS)
122
+ )
123
+ return status;
124
+
125
+ const attempt: Attempt = {
126
+ running: true,
127
+ nextAt: now + SYNC_INTERVAL_MS,
128
+ failures: previous?.failures ?? 0,
129
+ };
130
+ workspaceAttempts.set(wsId, attempt);
131
+ queryClient.setQueryData(['calendar-sync-status', wsId], {
132
+ ...status,
133
+ health: { ...health, state: 'syncing', currentlyRunning: true },
134
+ });
135
+ let failed = false;
136
+ let alreadyRunning = false;
137
+ try {
138
+ const result = await runCalendarProviderSync(queryClient, wsId);
139
+ alreadyRunning = !!result.alreadyRunning;
140
+ if (alreadyRunning) attempt.nextAt = Date.now() + 30_000;
141
+ failed = !result.ok;
142
+ if (result.retryAfterSeconds) {
143
+ attempt.nextAt = Math.max(
144
+ attempt.nextAt,
145
+ Date.now() + result.retryAfterSeconds * 1000
146
+ );
147
+ }
148
+ } catch {
149
+ // Polling failures stay in the recovery UI; background work never spams toasts.
150
+ failed = true;
151
+ } finally {
152
+ attempt.running = false;
153
+ if (!alreadyRunning) attempt.failures = failed ? attempt.failures + 1 : 0;
154
+ if (!alreadyRunning)
155
+ attempt.nextAt = Math.max(
156
+ attempt.nextAt,
157
+ Date.now() +
158
+ Math.min(
159
+ MAX_BACKOFF_MS,
160
+ SYNC_INTERVAL_MS * 2 ** Math.min(attempt.failures, 3)
161
+ )
162
+ );
163
+ // Only completed requests can have imported changes. Remote runs are
164
+ // observed by subsequent health polls before their ranges are invalidated.
165
+ if (!alreadyRunning) await invalidateImportedEvents(queryClient, wsId);
166
+ }
167
+ const refreshed = await getWorkspaceCalendarSyncStatus(wsId);
168
+ const remoteCompleted = observeSyncCompletion(queryClient, wsId, refreshed);
169
+ if (alreadyRunning && remoteCompleted)
170
+ await invalidateImportedEvents(queryClient, wsId);
171
+ if (failed && refreshed.health.state === 'healthy') {
172
+ return {
173
+ ...refreshed,
174
+ health: {
175
+ ...refreshed.health,
176
+ state: 'degraded',
177
+ reason: 'last_run_failed',
178
+ },
179
+ };
180
+ }
181
+ return refreshed;
182
+ }
@@ -3,10 +3,8 @@ import {
3
3
  getGoogleCalendarAuthUrl,
4
4
  getWorkspaceCalendarDefaultSource,
5
5
  getWorkspaceCalendarSyncPreferences,
6
- getWorkspaceCalendarSyncStatus,
7
6
  listCalendarAccounts,
8
7
  listProviderCalendars,
9
- syncWorkspaceCalendar,
10
8
  updateCalendarConnection as updateCalendarConnectionRequest,
11
9
  updateWorkspaceCalendarDefaultSource,
12
10
  updateWorkspaceCalendarSyncPreferences,
@@ -15,6 +13,7 @@ import { createClient } from '@tuturuuu/supabase/next/client';
15
13
  import { useRouter } from 'next/navigation';
16
14
  import { useTranslations } from 'next-intl';
17
15
  import { useMemo, useState } from 'react';
16
+ import { runCalendarProviderSync } from '../../../../hooks/calendar-provider-sync';
18
17
  import { useCalendarSync } from '../../../../hooks/use-calendar-sync';
19
18
  import { toast } from '../../sonner';
20
19
  import {
@@ -25,6 +24,7 @@ import {
25
24
  } from './calendar-connections-manager-helpers';
26
25
  import type { AuthResponse } from './calendar-types';
27
26
  import { mergeProviderCalendarsByAccount } from './merge-provider-calendars';
27
+ import { calendarSyncStatusQueryOptions } from './refresh-calendar-sync-status';
28
28
 
29
29
  export type CalendarConnectionsUnifiedVariant =
30
30
  | 'compact'
@@ -57,11 +57,13 @@ export function useCalendarConnectionsManager(wsId: string) {
57
57
  updateCalendarConnection,
58
58
  setCalendarConnections,
59
59
  syncToTuturuuu,
60
+ isActiveSyncOn,
60
61
  isSyncing,
61
62
  } = useCalendarSync();
62
63
 
63
64
  const syncMutation = useMutation({
64
- mutationFn: () => syncWorkspaceCalendar(wsId),
65
+ mutationKey: ['calendar-provider-sync', wsId],
66
+ mutationFn: () => runCalendarProviderSync(queryClient, wsId),
65
67
  onSuccess: (result) => {
66
68
  if (!result.ok) toast.error(t('sync_recovery.partial_failure'));
67
69
  else if (result.alreadyRunning) toast.info(t('syncing_calendars'));
@@ -116,17 +118,13 @@ export function useCalendarConnectionsManager(wsId: string) {
116
118
  const accounts = accountsQuery.data?.accounts || [];
117
119
  const isLoadingAccounts = accountsQuery.isLoading;
118
120
  const hasConnectedAccounts = accounts.length > 0;
119
- const syncStatusQuery = useQuery({
120
- queryKey: ['calendar-sync-status', wsId],
121
- queryFn: () => getWorkspaceCalendarSyncStatus(wsId),
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,
129
- });
121
+ const syncStatusQuery = useQuery(
122
+ calendarSyncStatusQueryOptions(
123
+ queryClient,
124
+ wsId,
125
+ isSyncing || !isActiveSyncOn
126
+ )
127
+ );
130
128
  const syncStatusData = syncStatusQuery.data;
131
129
 
132
130
  const { data: defaultSourceData } = useQuery({
@@ -0,0 +1,47 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { expect, it, vi } from 'vitest';
3
+ import { AiMessageParts } from './ai-message-parts';
4
+
5
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
6
+ vi.mock('./ai-message-markdown', () => ({
7
+ AssistantMarkdown: ({ text }: { text: string }) => <p>{text}</p>,
8
+ }));
9
+ vi.mock('./ai-message-tool-part', () => ({
10
+ isAiToolPart: (part: { type: string }) => part.type === 'dynamic-tool',
11
+ ToolGroup: ({ parts }: { parts: { toolCallId: string }[] }) => (
12
+ <div>{parts.map((part) => part.toolCallId).join(',')}</div>
13
+ ),
14
+ }));
15
+
16
+ it('keeps text between tool batches in the rendered history timeline', () => {
17
+ const { container } = render(
18
+ <AiMessageParts
19
+ parts={[
20
+ { type: 'text', text: 'Before lookup' },
21
+ { type: 'dynamic-tool', toolCallId: 'lookup-1' },
22
+ { type: 'dynamic-tool', toolCallId: 'lookup-2' },
23
+ { type: 'text', text: 'After lookup' },
24
+ { type: 'dynamic-tool', toolCallId: 'save-1' },
25
+ { type: 'text', text: 'Saved' },
26
+ ]}
27
+ />
28
+ );
29
+ expect(container.textContent).toBe(
30
+ 'Before lookuplookup-1,lookup-2After lookupsave-1Saved'
31
+ );
32
+ expect(screen.getByText('lookup-1,lookup-2')).toBeInTheDocument();
33
+ });
34
+
35
+ it('restores compact text references in place without duplicating fallback text', () => {
36
+ const { container } = render(
37
+ <AiMessageParts
38
+ textFallback={'Before\n\nAfter'}
39
+ parts={[
40
+ { type: 'text', textStart: 0, textLength: 6 },
41
+ { type: 'dynamic-tool', toolCallId: 'lookup' },
42
+ { type: 'text', textStart: 8, textLength: 5 },
43
+ ]}
44
+ />
45
+ );
46
+ expect(container.textContent).toBe('BeforelookupAfter');
47
+ });
@@ -51,27 +51,34 @@ export function AiMessageParts({
51
51
  thinking: t('ai_thinking_mode'),
52
52
  thought: t('ai_thought'),
53
53
  };
54
- const contentParts = normalizedParts.filter((part) => !isAiToolPart(part));
55
- const toolParts = normalizedParts.filter(isAiToolPart);
54
+ const groups: AiMessagePart[][] = [];
55
+ for (const part of normalizedParts) {
56
+ const previous = groups.at(-1);
57
+ if (isAiToolPart(part) && previous?.every(isAiToolPart))
58
+ previous.push(part);
59
+ else groups.push([part]);
60
+ }
56
61
 
57
62
  if (normalizedParts.length === 0) return null;
58
63
 
59
64
  return (
60
65
  <div className={cn('flex min-w-0 max-w-full flex-col gap-2', className)}>
61
- {contentParts.map((part, index) => (
62
- <AiPartRenderer
63
- isStreaming={Boolean(isStreaming)}
64
- key={getPartKey(part, index)}
65
- labels={labels}
66
- part={part}
67
- />
68
- ))}
69
- {toolParts.length > 0 && (
70
- <ToolGroup
71
- isStreaming={Boolean(isStreaming)}
72
- labels={labels}
73
- parts={toolParts}
74
- />
66
+ {groups.map((group, index) =>
67
+ isAiToolPart(group[0]!) ? (
68
+ <ToolGroup
69
+ key={getPartKey(group[0]!, index)}
70
+ isStreaming={Boolean(isStreaming)}
71
+ labels={labels}
72
+ parts={group}
73
+ />
74
+ ) : (
75
+ <AiPartRenderer
76
+ key={getPartKey(group[0]!, index)}
77
+ isStreaming={Boolean(isStreaming)}
78
+ labels={labels}
79
+ part={group[0]!}
80
+ />
81
+ )
75
82
  )}
76
83
  </div>
77
84
  );