@tuturuuu/ui 0.32.0 → 0.34.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 +62 -0
- package/biome.json +1 -1
- package/package.json +53 -51
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
- package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
- package/src/components/ui/chat/ai-message-parts.tsx +23 -16
- package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
- package/src/components/ui/commercial-comparison-style.ts +104 -0
- package/src/components/ui/commercial-comparison-table.tsx +159 -0
- package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
- package/src/components/ui/commercial-comparison.test.tsx +86 -0
- package/src/components/ui/commercial-comparison.tsx +224 -0
- package/src/components/ui/comparison-value.test.tsx +32 -0
- package/src/components/ui/comparison-value.tsx +72 -0
- package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
- package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
- package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
- package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
- package/src/components/ui/custom/report-preview-pagination.ts +5 -3
- package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
- package/src/components/ui/custom/report-preview.tsx +2 -3
- package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
- package/src/components/ui/finance/invoices/columns.tsx +2 -0
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
- package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
- package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- package/src/components/ui/finance/invoices/hooks.ts +5 -110
- package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
- package/src/components/ui/finance/invoices/internal-api.ts +19 -1
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
- package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
- package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
- package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
- package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
- package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
- package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
- package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
- package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
- package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
- package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
- package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
- package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
- package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- package/src/components/ui/legacy/calendar/working-location.ts +35 -0
- package/src/components/ui/public-workspace-prices.tsx +13 -0
- package/src/components/ui/pwa-status.test.tsx +13 -2
- package/src/components/ui/pwa-status.tsx +15 -3
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
- package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
- package/src/hooks/calendar-provider-sync.ts +33 -0
- package/src/hooks/notification-subscription.ts +74 -0
- package/src/hooks/use-calendar-sync.tsx +11 -14
- package/src/hooks/use-calendar.tsx +2 -2
- package/src/hooks/use-notifications.ts +5 -141
|
@@ -1,230 +1,149 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @vitest-environment jsdom
|
|
3
|
-
*/
|
|
4
|
-
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
5
2
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
6
|
-
import {
|
|
3
|
+
import { act, renderHook } from '@testing-library/react';
|
|
7
4
|
import type { ReactNode } from 'react';
|
|
8
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { NOTIFICATION_REFRESH_INTERVAL_MS } from '../notification-subscription';
|
|
9
7
|
import {
|
|
10
8
|
__resetNotificationSubscriptionRegistryForTests,
|
|
11
|
-
UNREAD_COUNT_FALLBACK_INTERVAL_MS,
|
|
12
|
-
UNREAD_COUNT_STALE_TIME_MS,
|
|
13
9
|
useInfiniteNotifications,
|
|
14
10
|
useNotificationSubscription,
|
|
15
|
-
useUnreadCount,
|
|
16
11
|
} from '../use-notifications';
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
new?: {
|
|
20
|
-
read_at?: string | null;
|
|
21
|
-
data?: {
|
|
22
|
-
action_taken?: boolean;
|
|
23
|
-
};
|
|
24
|
-
};
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const {
|
|
28
|
-
channelMock,
|
|
29
|
-
createRealtimeClientMock,
|
|
30
|
-
postgresCallbacks,
|
|
31
|
-
removeChannelMock,
|
|
32
|
-
subscribeMock,
|
|
33
|
-
} = vi.hoisted(() => {
|
|
34
|
-
const postgresCallbacks: Array<(payload: RealtimePayload) => void> = [];
|
|
35
|
-
const removeChannelMock = vi.fn();
|
|
36
|
-
const subscribeMock = vi.fn();
|
|
37
|
-
const channel = {
|
|
38
|
-
on: vi.fn(
|
|
39
|
-
(
|
|
40
|
-
type: string,
|
|
41
|
-
_config: Record<string, unknown>,
|
|
42
|
-
callback: (payload: RealtimePayload) => void
|
|
43
|
-
) => {
|
|
44
|
-
if (type === 'postgres_changes') {
|
|
45
|
-
postgresCallbacks.push(callback);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return channel;
|
|
49
|
-
}
|
|
50
|
-
),
|
|
51
|
-
subscribe: subscribeMock,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const channelMock = vi.fn(() => channel);
|
|
55
|
-
|
|
56
|
-
subscribeMock.mockReturnValue(channel);
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
channelMock,
|
|
60
|
-
createRealtimeClientMock: vi.fn(() => ({
|
|
61
|
-
channel: channelMock,
|
|
62
|
-
removeChannel: removeChannelMock,
|
|
63
|
-
})),
|
|
64
|
-
postgresCallbacks,
|
|
65
|
-
removeChannelMock,
|
|
66
|
-
subscribeMock,
|
|
67
|
-
};
|
|
68
|
-
});
|
|
69
|
-
|
|
13
|
+
const createRealtimeClient = vi.hoisted(() => vi.fn());
|
|
70
14
|
vi.mock('@tuturuuu/supabase/next/realtime-browser', () => ({
|
|
71
|
-
createRealtimeClient
|
|
15
|
+
createRealtimeClient,
|
|
72
16
|
}));
|
|
73
17
|
|
|
74
|
-
function
|
|
75
|
-
return new QueryClient({
|
|
76
|
-
defaultOptions: {
|
|
77
|
-
mutations: { retry: false },
|
|
78
|
-
queries: { retry: false },
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function createWrapper(queryClient: QueryClient) {
|
|
18
|
+
function wrapper(client: QueryClient) {
|
|
84
19
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
85
20
|
return (
|
|
86
|
-
<QueryClientProvider client={
|
|
21
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
87
22
|
);
|
|
88
23
|
};
|
|
89
24
|
}
|
|
90
25
|
|
|
91
|
-
describe('
|
|
26
|
+
describe('notification API refresh subscriptions', () => {
|
|
92
27
|
beforeEach(() => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
31
|
+
configurable: true,
|
|
32
|
+
value: 'visible',
|
|
33
|
+
});
|
|
98
34
|
});
|
|
99
|
-
|
|
100
35
|
afterEach(() => {
|
|
101
36
|
__resetNotificationSubscriptionRegistryForTests();
|
|
37
|
+
vi.useRealTimers();
|
|
102
38
|
vi.unstubAllGlobals();
|
|
103
39
|
});
|
|
104
40
|
|
|
105
|
-
it('
|
|
106
|
-
const
|
|
107
|
-
vi.stubGlobal('fetch',
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
()
|
|
112
|
-
useInfiniteNotifications({
|
|
113
|
-
enabled: false,
|
|
114
|
-
unreadOnly: true,
|
|
115
|
-
}),
|
|
116
|
-
{ wrapper: createWrapper(queryClient) }
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('uses realtime-first unread counts with a low-frequency fallback', () => {
|
|
123
|
-
const fetchMock = vi.fn();
|
|
124
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
125
|
-
const queryClient = createQueryClient();
|
|
126
|
-
|
|
127
|
-
renderHook(() => useUnreadCount(undefined, { enabled: false }), {
|
|
128
|
-
wrapper: createWrapper(queryClient),
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const queryOptions = queryClient.getQueryCache().find({
|
|
132
|
-
queryKey: ['notifications', 'unread-count', 'all'],
|
|
133
|
-
})?.options as { refetchInterval?: number; staleTime?: number } | undefined;
|
|
134
|
-
|
|
135
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
136
|
-
expect(queryOptions?.staleTime).toBe(UNREAD_COUNT_STALE_TIME_MS);
|
|
137
|
-
expect(queryOptions?.refetchInterval).toBe(
|
|
138
|
-
UNREAD_COUNT_FALLBACK_INTERVAL_MS
|
|
41
|
+
it('keeps closed inbox requests lazy', () => {
|
|
42
|
+
const fetch = vi.fn();
|
|
43
|
+
vi.stubGlobal('fetch', fetch);
|
|
44
|
+
const client = new QueryClient();
|
|
45
|
+
const hook = renderHook(
|
|
46
|
+
() => useInfiniteNotifications({ enabled: false, unreadOnly: true }),
|
|
47
|
+
{ wrapper: wrapper(client) }
|
|
139
48
|
);
|
|
49
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
50
|
+
hook.unmount();
|
|
51
|
+
client.clear();
|
|
140
52
|
});
|
|
141
53
|
|
|
142
|
-
it('shares one
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
54
|
+
it('shares one refresh per user and query client without opening a denied realtime channel', () => {
|
|
55
|
+
const client = new QueryClient();
|
|
56
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries');
|
|
146
57
|
const first = renderHook(
|
|
147
58
|
() => useNotificationSubscription(null, 'user-1'),
|
|
148
|
-
{ wrapper }
|
|
59
|
+
{ wrapper: wrapper(client) }
|
|
149
60
|
);
|
|
150
61
|
const second = renderHook(
|
|
151
|
-
() => useNotificationSubscription('workspace
|
|
152
|
-
{ wrapper }
|
|
62
|
+
() => useNotificationSubscription('workspace', 'user-1'),
|
|
63
|
+
{ wrapper: wrapper(client) }
|
|
153
64
|
);
|
|
154
65
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
66
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
67
|
+
expect(invalidate).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['notifications'] });
|
|
69
|
+
expect(createRealtimeClient).not.toHaveBeenCalled();
|
|
160
70
|
first.unmount();
|
|
161
|
-
|
|
162
|
-
expect(
|
|
163
|
-
|
|
71
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
72
|
+
expect(invalidate).toHaveBeenCalledTimes(2);
|
|
164
73
|
second.unmount();
|
|
165
|
-
|
|
166
|
-
expect(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
it('invalidates unread counts for read-state-only realtime updates', async () => {
|
|
170
|
-
const queryClient = createQueryClient();
|
|
171
|
-
const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
|
|
172
|
-
const subscription = renderHook(
|
|
173
|
-
() => useNotificationSubscription(null, 'user-1'),
|
|
174
|
-
{ wrapper: createWrapper(queryClient) }
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
await waitFor(() => {
|
|
178
|
-
expect(postgresCallbacks).toHaveLength(3);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
postgresCallbacks[1]?.({ new: { read_at: new Date().toISOString() } });
|
|
182
|
-
|
|
183
|
-
expect(invalidate).toHaveBeenCalledWith({
|
|
184
|
-
queryKey: ['notifications'],
|
|
185
|
-
});
|
|
186
|
-
expect(invalidate).toHaveBeenCalledWith({
|
|
187
|
-
queryKey: ['notifications', 'unread-count'],
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
subscription.unmount();
|
|
74
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
75
|
+
expect(invalidate).toHaveBeenCalledTimes(2);
|
|
76
|
+
client.clear();
|
|
191
77
|
});
|
|
192
78
|
|
|
193
|
-
it('
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
79
|
+
it('refreshes every mounted query client and removes unmounted clients', () => {
|
|
80
|
+
const a = new QueryClient();
|
|
81
|
+
const b = new QueryClient();
|
|
82
|
+
const invalidateA = vi.spyOn(a, 'invalidateQueries');
|
|
83
|
+
const invalidateB = vi.spyOn(b, 'invalidateQueries');
|
|
199
84
|
const first = renderHook(
|
|
200
85
|
() => useNotificationSubscription(null, 'user-1'),
|
|
201
|
-
{ wrapper:
|
|
86
|
+
{ wrapper: wrapper(a) }
|
|
202
87
|
);
|
|
203
88
|
const second = renderHook(
|
|
204
|
-
() => useNotificationSubscription(
|
|
205
|
-
{ wrapper:
|
|
89
|
+
() => useNotificationSubscription(null, 'user-1'),
|
|
90
|
+
{ wrapper: wrapper(b) }
|
|
206
91
|
);
|
|
92
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
93
|
+
expect(invalidateA).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(invalidateB).toHaveBeenCalledTimes(1);
|
|
95
|
+
first.unmount();
|
|
96
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
97
|
+
expect(invalidateA).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(invalidateB).toHaveBeenCalledTimes(2);
|
|
99
|
+
second.unmount();
|
|
100
|
+
a.clear();
|
|
101
|
+
b.clear();
|
|
102
|
+
});
|
|
207
103
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
expect(firstInvalidate).toHaveBeenCalledWith({
|
|
215
|
-
queryKey: ['notifications'],
|
|
216
|
-
});
|
|
217
|
-
expect(firstInvalidate).toHaveBeenCalledWith({
|
|
218
|
-
queryKey: ['notifications', 'unread-count'],
|
|
104
|
+
it('pauses hidden tabs and refreshes when they become visible', () => {
|
|
105
|
+
const client = new QueryClient();
|
|
106
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries');
|
|
107
|
+
const hook = renderHook(() => useNotificationSubscription(null, 'user-1'), {
|
|
108
|
+
wrapper: wrapper(client),
|
|
219
109
|
});
|
|
220
|
-
|
|
221
|
-
|
|
110
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
111
|
+
configurable: true,
|
|
112
|
+
value: 'hidden',
|
|
222
113
|
});
|
|
223
|
-
|
|
224
|
-
|
|
114
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS * 3));
|
|
115
|
+
expect(invalidate).not.toHaveBeenCalled();
|
|
116
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
117
|
+
configurable: true,
|
|
118
|
+
value: 'visible',
|
|
225
119
|
});
|
|
120
|
+
act(() => document.dispatchEvent(new Event('visibilitychange')));
|
|
121
|
+
expect(invalidate).toHaveBeenCalledTimes(1);
|
|
122
|
+
hook.unmount();
|
|
123
|
+
act(() => document.dispatchEvent(new Event('visibilitychange')));
|
|
124
|
+
expect(invalidate).toHaveBeenCalledTimes(1);
|
|
125
|
+
client.clear();
|
|
126
|
+
});
|
|
226
127
|
|
|
227
|
-
|
|
228
|
-
|
|
128
|
+
it('does not refresh without an actor and cleans up when signing out', () => {
|
|
129
|
+
const client = new QueryClient();
|
|
130
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries');
|
|
131
|
+
const hook = renderHook(
|
|
132
|
+
({ user }) => useNotificationSubscription(null, user),
|
|
133
|
+
{
|
|
134
|
+
initialProps: { user: '' },
|
|
135
|
+
wrapper: wrapper(client),
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
139
|
+
expect(invalidate).not.toHaveBeenCalled();
|
|
140
|
+
hook.rerender({ user: 'user-1' });
|
|
141
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
142
|
+
expect(invalidate).toHaveBeenCalledTimes(1);
|
|
143
|
+
hook.rerender({ user: '' });
|
|
144
|
+
act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
|
|
145
|
+
expect(invalidate).toHaveBeenCalledTimes(1);
|
|
146
|
+
hook.unmount();
|
|
147
|
+
client.clear();
|
|
229
148
|
});
|
|
230
149
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
type CalendarSyncResult,
|
|
4
|
+
syncWorkspaceCalendar,
|
|
5
|
+
} from '@tuturuuu/internal-api/calendar';
|
|
6
|
+
|
|
7
|
+
const activeRequests = new WeakMap<
|
|
8
|
+
QueryClient,
|
|
9
|
+
Map<string, Promise<CalendarSyncResult>>
|
|
10
|
+
>();
|
|
11
|
+
|
|
12
|
+
export function isCalendarProviderSyncRunning(
|
|
13
|
+
client: QueryClient,
|
|
14
|
+
wsId: string
|
|
15
|
+
) {
|
|
16
|
+
return activeRequests.get(client)?.has(wsId) ?? false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Manual, legacy and automatic controls share the same in-flight request. */
|
|
20
|
+
export function runCalendarProviderSync(client: QueryClient, wsId: string) {
|
|
21
|
+
let requests = activeRequests.get(client);
|
|
22
|
+
if (!requests) {
|
|
23
|
+
requests = new Map();
|
|
24
|
+
activeRequests.set(client, requests);
|
|
25
|
+
}
|
|
26
|
+
const active = requests.get(wsId);
|
|
27
|
+
if (active) return active;
|
|
28
|
+
const pending = syncWorkspaceCalendar(wsId).finally(() =>
|
|
29
|
+
requests.delete(wsId)
|
|
30
|
+
);
|
|
31
|
+
requests.set(wsId, pending);
|
|
32
|
+
return pending;
|
|
33
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
export const NOTIFICATION_REFRESH_INTERVAL_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
interface NotificationSubscriptionEntry {
|
|
7
|
+
queryClients: Map<QueryClient, number>;
|
|
8
|
+
interval: ReturnType<typeof setInterval>;
|
|
9
|
+
onVisibilityChange: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const registry = new Map<string, NotificationSubscriptionEntry>();
|
|
13
|
+
|
|
14
|
+
function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
|
|
15
|
+
if (document.visibilityState === 'hidden') return;
|
|
16
|
+
for (const queryClient of entry.queryClients.keys()) {
|
|
17
|
+
// Only active queries refetch; closed inboxes remain lazy.
|
|
18
|
+
void queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stopEntry(entry: NotificationSubscriptionEntry) {
|
|
23
|
+
clearInterval(entry.interval);
|
|
24
|
+
document.removeEventListener('visibilitychange', entry.onVisibilityChange);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Share authenticated API refreshes across notification consumers.
|
|
29
|
+
* Notifications are server-owned: postgres_changes cannot filter their columns
|
|
30
|
+
* using browser roles, even when a user has a valid application session.
|
|
31
|
+
*/
|
|
32
|
+
export function useNotificationSubscription(
|
|
33
|
+
_wsId: string | null,
|
|
34
|
+
userId: string
|
|
35
|
+
) {
|
|
36
|
+
const queryClient = useQueryClient();
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!userId) return;
|
|
39
|
+
let entry = registry.get(userId);
|
|
40
|
+
if (!entry) {
|
|
41
|
+
const queryClients = new Map<QueryClient, number>();
|
|
42
|
+
const refresh = () => {
|
|
43
|
+
const current = registry.get(userId);
|
|
44
|
+
if (current) invalidateVisibleNotifications(current);
|
|
45
|
+
};
|
|
46
|
+
entry = {
|
|
47
|
+
queryClients,
|
|
48
|
+
interval: setInterval(refresh, NOTIFICATION_REFRESH_INTERVAL_MS),
|
|
49
|
+
onVisibilityChange: refresh,
|
|
50
|
+
};
|
|
51
|
+
registry.set(userId, entry);
|
|
52
|
+
document.addEventListener('visibilitychange', refresh);
|
|
53
|
+
}
|
|
54
|
+
entry.queryClients.set(
|
|
55
|
+
queryClient,
|
|
56
|
+
(entry.queryClients.get(queryClient) ?? 0) + 1
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
const count = entry.queryClients.get(queryClient) ?? 0;
|
|
61
|
+
if (count <= 1) entry.queryClients.delete(queryClient);
|
|
62
|
+
else entry.queryClients.set(queryClient, count - 1);
|
|
63
|
+
if (entry.queryClients.size === 0) {
|
|
64
|
+
stopEntry(entry);
|
|
65
|
+
registry.delete(userId);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}, [userId, queryClient]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function __resetNotificationSubscriptionRegistryForTests() {
|
|
72
|
+
for (const entry of registry.values()) stopEntry(entry);
|
|
73
|
+
registry.clear();
|
|
74
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
-
import {
|
|
5
|
-
listWorkspaceCalendars,
|
|
6
|
-
syncWorkspaceCalendar,
|
|
7
|
-
} from '@tuturuuu/internal-api/calendar';
|
|
4
|
+
import { listWorkspaceCalendars } from '@tuturuuu/internal-api/calendar';
|
|
8
5
|
import type {
|
|
9
6
|
Workspace,
|
|
10
7
|
WorkspaceCalendarEvent,
|
|
@@ -24,6 +21,7 @@ import {
|
|
|
24
21
|
useState,
|
|
25
22
|
} from 'react';
|
|
26
23
|
import { toast } from '../components/ui/sonner';
|
|
24
|
+
import { runCalendarProviderSync } from './calendar-provider-sync';
|
|
27
25
|
import {
|
|
28
26
|
type CacheUpdate,
|
|
29
27
|
type CalendarCache,
|
|
@@ -308,13 +306,7 @@ export const CalendarSyncProvider = ({
|
|
|
308
306
|
const isCurrentWeek = includesCurrentWeek(dateRange);
|
|
309
307
|
// 30 seconds for current week, 5 minutes for other weeks
|
|
310
308
|
const staleTime = isCurrentWeek ? 30 * 1000 : 5 * 60 * 1000; // 30 seconds
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (isCurrentWeek && isStale) {
|
|
314
|
-
// Current week cache is stale, forcing fresh fetch
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return isStale;
|
|
309
|
+
return Date.now() - lastUpdated >= staleTime;
|
|
318
310
|
};
|
|
319
311
|
|
|
320
312
|
const updateCache = useCallback((cacheKey: string, update: CacheUpdate) => {
|
|
@@ -414,7 +406,12 @@ export const CalendarSyncProvider = ({
|
|
|
414
406
|
if (
|
|
415
407
|
cachedData &&
|
|
416
408
|
!isCacheStaleEnhanced(cachedData.dbLastUpdated, dates) &&
|
|
417
|
-
!isForcedRef.current
|
|
409
|
+
!isForcedRef.current &&
|
|
410
|
+
!queryClient.getQueryState([
|
|
411
|
+
'databaseCalendarEvents',
|
|
412
|
+
wsId,
|
|
413
|
+
activeCacheKey,
|
|
414
|
+
])?.isInvalidated
|
|
418
415
|
) {
|
|
419
416
|
return cachedData.dbEvents;
|
|
420
417
|
}
|
|
@@ -596,7 +593,7 @@ export const CalendarSyncProvider = ({
|
|
|
596
593
|
});
|
|
597
594
|
|
|
598
595
|
try {
|
|
599
|
-
const result = await
|
|
596
|
+
const result = await runCalendarProviderSync(queryClient, wsId);
|
|
600
597
|
// Partial imports can change events even when another calendar fails.
|
|
601
598
|
refresh();
|
|
602
599
|
if (!result.ok) throw new Error(result.error || 'Calendar sync failed');
|
|
@@ -640,7 +637,7 @@ export const CalendarSyncProvider = ({
|
|
|
640
637
|
setIsSyncing(false);
|
|
641
638
|
}
|
|
642
639
|
},
|
|
643
|
-
[wsId, isActiveSyncOn, refresh]
|
|
640
|
+
[wsId, isActiveSyncOn, refresh, queryClient]
|
|
644
641
|
);
|
|
645
642
|
|
|
646
643
|
// Trigger refetch from DB when changing views (optimized to reduce load)
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
type WorkspaceCalendarEventCreatePayload,
|
|
6
6
|
type WorkspaceCalendarEventUpdatePayload,
|
|
7
7
|
} from '@tuturuuu/internal-api';
|
|
8
|
-
import { syncWorkspaceCalendar } from '@tuturuuu/internal-api/calendar';
|
|
9
8
|
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
10
9
|
import type {
|
|
11
10
|
Workspace,
|
|
@@ -16,6 +15,7 @@ import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors'
|
|
|
16
15
|
import { createAllDayEvent } from '@tuturuuu/utils/calendar-utils';
|
|
17
16
|
import dayjs from 'dayjs';
|
|
18
17
|
import moment from 'moment';
|
|
18
|
+
import { runCalendarProviderSync } from './calendar-provider-sync';
|
|
19
19
|
import 'moment/locale/vi';
|
|
20
20
|
import {
|
|
21
21
|
createContext,
|
|
@@ -1368,7 +1368,7 @@ export const CalendarProvider = ({
|
|
|
1368
1368
|
});
|
|
1369
1369
|
}
|
|
1370
1370
|
|
|
1371
|
-
const result = await
|
|
1371
|
+
const result = await runCalendarProviderSync(queryClient, ws.id);
|
|
1372
1372
|
await queryClient.invalidateQueries({
|
|
1373
1373
|
queryKey: ['databaseCalendarEvents', ws.id],
|
|
1374
1374
|
exact: false,
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type QueryClient,
|
|
3
2
|
useInfiniteQuery,
|
|
4
3
|
useMutation,
|
|
5
4
|
useQuery,
|
|
6
5
|
useQueryClient,
|
|
7
6
|
} from '@tanstack/react-query';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from '
|
|
13
|
-
import { useEffect } from 'react';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
__resetNotificationSubscriptionRegistryForTests,
|
|
10
|
+
useNotificationSubscription,
|
|
11
|
+
} from './notification-subscription';
|
|
14
12
|
export type NotificationType =
|
|
15
13
|
| 'task_assigned'
|
|
16
14
|
| 'task_updated'
|
|
@@ -78,98 +76,8 @@ interface UseNotificationsOptions {
|
|
|
78
76
|
readOnly?: boolean;
|
|
79
77
|
type?: NotificationType;
|
|
80
78
|
}
|
|
81
|
-
interface NotificationSubscriptionEntry {
|
|
82
|
-
channel: RealtimeChannel;
|
|
83
|
-
queryClientRefs: Map<QueryClient, number>;
|
|
84
|
-
subscriberCount: number;
|
|
85
|
-
supabase: SupabaseClient;
|
|
86
|
-
}
|
|
87
79
|
export const UNREAD_COUNT_STALE_TIME_MS = 5 * 60 * 1000;
|
|
88
80
|
export const UNREAD_COUNT_FALLBACK_INTERVAL_MS = 15 * 60 * 1000;
|
|
89
|
-
const notificationSubscriptionRegistry = new Map<
|
|
90
|
-
string,
|
|
91
|
-
NotificationSubscriptionEntry
|
|
92
|
-
>();
|
|
93
|
-
function invalidateNotificationQueries(
|
|
94
|
-
queryClientRefs: Map<QueryClient, number>
|
|
95
|
-
) {
|
|
96
|
-
for (const queryClient of queryClientRefs.keys()) {
|
|
97
|
-
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
98
|
-
queryClient.invalidateQueries({
|
|
99
|
-
queryKey: ['notifications', 'unread-count'],
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
function addNotificationQueryClientRef(
|
|
104
|
-
entry: NotificationSubscriptionEntry,
|
|
105
|
-
queryClient: QueryClient
|
|
106
|
-
) {
|
|
107
|
-
entry.subscriberCount += 1;
|
|
108
|
-
entry.queryClientRefs.set(
|
|
109
|
-
queryClient,
|
|
110
|
-
(entry.queryClientRefs.get(queryClient) ?? 0) + 1
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
function releaseNotificationQueryClientRef(
|
|
114
|
-
entry: NotificationSubscriptionEntry,
|
|
115
|
-
queryClient: QueryClient
|
|
116
|
-
) {
|
|
117
|
-
entry.subscriberCount -= 1;
|
|
118
|
-
const currentRefCount = entry.queryClientRefs.get(queryClient) ?? 0;
|
|
119
|
-
if (currentRefCount <= 1) {
|
|
120
|
-
entry.queryClientRefs.delete(queryClient);
|
|
121
|
-
} else {
|
|
122
|
-
entry.queryClientRefs.set(queryClient, currentRefCount - 1);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
function createNotificationSubscriptionEntry(
|
|
126
|
-
userId: string,
|
|
127
|
-
queryClient: QueryClient
|
|
128
|
-
) {
|
|
129
|
-
const queryClientRefs = new Map<QueryClient, number>([[queryClient, 1]]);
|
|
130
|
-
const supabase = createRealtimeClient();
|
|
131
|
-
const invalidateQueries = () =>
|
|
132
|
-
invalidateNotificationQueries(queryClientRefs);
|
|
133
|
-
const channel = supabase
|
|
134
|
-
.channel(`notifications-${userId}`)
|
|
135
|
-
.on(
|
|
136
|
-
'postgres_changes',
|
|
137
|
-
{
|
|
138
|
-
event: 'INSERT',
|
|
139
|
-
schema: 'public',
|
|
140
|
-
table: 'notifications',
|
|
141
|
-
filter: `user_id=eq.${userId}`,
|
|
142
|
-
},
|
|
143
|
-
invalidateQueries
|
|
144
|
-
)
|
|
145
|
-
.on(
|
|
146
|
-
'postgres_changes',
|
|
147
|
-
{
|
|
148
|
-
event: 'UPDATE',
|
|
149
|
-
schema: 'public',
|
|
150
|
-
table: 'notifications',
|
|
151
|
-
filter: `user_id=eq.${userId}`,
|
|
152
|
-
},
|
|
153
|
-
invalidateQueries
|
|
154
|
-
)
|
|
155
|
-
.on(
|
|
156
|
-
'postgres_changes',
|
|
157
|
-
{
|
|
158
|
-
event: 'DELETE',
|
|
159
|
-
schema: 'public',
|
|
160
|
-
table: 'notifications',
|
|
161
|
-
filter: `user_id=eq.${userId}`,
|
|
162
|
-
},
|
|
163
|
-
invalidateQueries
|
|
164
|
-
)
|
|
165
|
-
.subscribe();
|
|
166
|
-
return {
|
|
167
|
-
channel,
|
|
168
|
-
queryClientRefs,
|
|
169
|
-
subscriberCount: 1,
|
|
170
|
-
supabase,
|
|
171
|
-
} satisfies NotificationSubscriptionEntry;
|
|
172
|
-
}
|
|
173
81
|
export function dedupeNotifications(
|
|
174
82
|
notifications: Notification[]
|
|
175
83
|
): Notification[] {
|
|
@@ -665,47 +573,3 @@ export function useDeleteNotification() {
|
|
|
665
573
|
},
|
|
666
574
|
});
|
|
667
575
|
}
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Hook to subscribe to realtime notification updates
|
|
671
|
-
* Subscribes to all notifications for the user, regardless of workspace
|
|
672
|
-
*/
|
|
673
|
-
export function useNotificationSubscription(
|
|
674
|
-
_wsId: string | null,
|
|
675
|
-
userId: string
|
|
676
|
-
) {
|
|
677
|
-
const queryClient = useQueryClient();
|
|
678
|
-
|
|
679
|
-
useEffect(() => {
|
|
680
|
-
if (!userId) return;
|
|
681
|
-
|
|
682
|
-
const registryKey = userId;
|
|
683
|
-
let entry = notificationSubscriptionRegistry.get(registryKey);
|
|
684
|
-
|
|
685
|
-
if (entry) {
|
|
686
|
-
addNotificationQueryClientRef(entry, queryClient);
|
|
687
|
-
} else {
|
|
688
|
-
entry = createNotificationSubscriptionEntry(userId, queryClient);
|
|
689
|
-
notificationSubscriptionRegistry.set(registryKey, entry);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
return () => {
|
|
693
|
-
if (!entry) return;
|
|
694
|
-
|
|
695
|
-
releaseNotificationQueryClientRef(entry, queryClient);
|
|
696
|
-
|
|
697
|
-
if (entry.subscriberCount <= 0) {
|
|
698
|
-
notificationSubscriptionRegistry.delete(registryKey);
|
|
699
|
-
entry.supabase.removeChannel(entry.channel);
|
|
700
|
-
}
|
|
701
|
-
};
|
|
702
|
-
}, [userId, queryClient]);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
export function __resetNotificationSubscriptionRegistryForTests() {
|
|
706
|
-
for (const entry of notificationSubscriptionRegistry.values()) {
|
|
707
|
-
entry.supabase.removeChannel(entry.channel);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
notificationSubscriptionRegistry.clear();
|
|
711
|
-
}
|