@tuturuuu/ui 0.31.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 (79) hide show
  1. package/CHANGELOG.md +57 -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-markdown.tsx +17 -2
  11. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  12. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  13. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  14. package/src/components/ui/commercial-comparison-style.ts +104 -0
  15. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  16. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  17. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  18. package/src/components/ui/commercial-comparison.tsx +224 -0
  19. package/src/components/ui/comparison-value.test.tsx +32 -0
  20. package/src/components/ui/comparison-value.tsx +72 -0
  21. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  22. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  23. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  24. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  25. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  26. package/src/components/ui/custom/report-preview.tsx +2 -3
  27. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  28. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  29. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  31. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  33. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  34. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  35. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  36. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  37. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  38. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  39. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  40. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  41. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  42. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  43. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  44. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  45. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  47. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  48. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  49. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  50. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  51. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  52. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  53. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  54. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  55. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  64. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  65. package/src/components/ui/public-workspace-prices.tsx +13 -0
  66. package/src/components/ui/pwa-status.test.tsx +13 -2
  67. package/src/components/ui/pwa-status.tsx +15 -3
  68. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  69. package/src/components/ui/text-editor/editor.tsx +11 -3
  70. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  71. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  72. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  73. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  74. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  75. package/src/hooks/calendar-provider-sync.ts +33 -0
  76. package/src/hooks/notification-subscription.ts +74 -0
  77. package/src/hooks/use-calendar-sync.tsx +11 -14
  78. package/src/hooks/use-calendar.tsx +2 -2
  79. package/src/hooks/use-notifications.ts +5 -141
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { Check, Minus, Square } from '@tuturuuu/icons';
4
+ import { useTranslations } from 'next-intl';
5
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
6
+ import { Popover, PopoverAnchor, PopoverContent } from '../popover';
7
+ import type { TriStateChecked } from './task-item-checkbox';
8
+
9
+ export function TaskItemStatusPicker({
10
+ children,
11
+ state,
12
+ disabled,
13
+ onSelect,
14
+ }: {
15
+ children: ReactNode;
16
+ state: TriStateChecked;
17
+ disabled: boolean;
18
+ onSelect: (state: TriStateChecked) => void;
19
+ }) {
20
+ const t = useTranslations('common.checklist');
21
+ const [open, setOpen] = useState(false);
22
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+ const keyboardOpen = useRef(false);
24
+ const anchor = useRef<HTMLDivElement>(null);
25
+ const cancelTimer = () => {
26
+ if (timer.current) clearTimeout(timer.current);
27
+ timer.current = null;
28
+ };
29
+ useEffect(
30
+ () => () => {
31
+ if (timer.current) clearTimeout(timer.current);
32
+ },
33
+ []
34
+ );
35
+ useEffect(() => {
36
+ if (!disabled) return;
37
+ if (timer.current) clearTimeout(timer.current);
38
+ timer.current = null;
39
+ keyboardOpen.current = false;
40
+ setOpen(false);
41
+ }, [disabled]);
42
+ const closeSoon = () => {
43
+ cancelTimer();
44
+ if (keyboardOpen.current) return;
45
+ timer.current = setTimeout(() => setOpen(false), 200);
46
+ };
47
+ const options = [
48
+ { value: false, label: t('unchecked'), Icon: Square },
49
+ { value: 'indeterminate', label: t('in_progress'), Icon: Minus },
50
+ { value: true, label: t('completed'), Icon: Check },
51
+ ] as const;
52
+
53
+ return (
54
+ <Popover open={open && !disabled} onOpenChange={setOpen}>
55
+ <PopoverAnchor asChild>
56
+ <div
57
+ ref={anchor}
58
+ onPointerEnter={(event) => {
59
+ if (disabled || event.pointerType === 'touch') return;
60
+ cancelTimer();
61
+ keyboardOpen.current = false;
62
+ timer.current = setTimeout(() => setOpen(true), 500);
63
+ }}
64
+ onContextMenu={(event) => {
65
+ if (disabled) return;
66
+ event.preventDefault();
67
+ cancelTimer();
68
+ keyboardOpen.current = true;
69
+ setOpen(true);
70
+ }}
71
+ onPointerLeave={closeSoon}
72
+ onKeyDown={(event) => {
73
+ if (disabled || event.key !== 'ArrowDown') return;
74
+ event.preventDefault();
75
+ event.stopPropagation();
76
+ cancelTimer();
77
+ keyboardOpen.current = true;
78
+ setOpen(true);
79
+ }}
80
+ >
81
+ {children}
82
+ </div>
83
+ </PopoverAnchor>
84
+ <PopoverContent
85
+ side="top"
86
+ align="start"
87
+ sideOffset={6}
88
+ className="w-auto max-w-[calc(100vw-2rem)] p-1"
89
+ contentEditable={false}
90
+ aria-label={t('status')}
91
+ onPointerEnter={cancelTimer}
92
+ onPointerLeave={closeSoon}
93
+ onFocusCapture={cancelTimer}
94
+ onInteractOutside={() => {
95
+ keyboardOpen.current = false;
96
+ cancelTimer();
97
+ }}
98
+ onOpenAutoFocus={(event) => {
99
+ if (!keyboardOpen.current) event.preventDefault();
100
+ }}
101
+ onCloseAutoFocus={(event) => {
102
+ event.preventDefault();
103
+ if (keyboardOpen.current)
104
+ anchor.current?.querySelector('button')?.focus();
105
+ }}
106
+ >
107
+ <div className="flex items-center gap-0.5">
108
+ {options.map(({ value, label, Icon }) => (
109
+ <button
110
+ key={String(value)}
111
+ type="button"
112
+ aria-label={label}
113
+ aria-pressed={state === value}
114
+ className="flex min-h-9 items-center gap-1.5 rounded-sm px-2 text-muted-foreground text-xs hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-accent aria-pressed:text-accent-foreground"
115
+ onMouseDown={(event) => event.preventDefault()}
116
+ onClick={() => {
117
+ onSelect(value);
118
+ cancelTimer();
119
+ setOpen(false);
120
+ }}
121
+ >
122
+ <Icon className="size-3.5 shrink-0" />
123
+ {label}
124
+ </button>
125
+ ))}
126
+ </div>
127
+ </PopoverContent>
128
+ </Popover>
129
+ );
130
+ }
@@ -50,7 +50,7 @@ function renderCalendarSync({
50
50
  </QueryClientProvider>
51
51
  );
52
52
 
53
- return renderHook(() => useCalendarSync(), { wrapper });
53
+ return { ...renderHook(() => useCalendarSync(), { wrapper }), queryClient };
54
54
  }
55
55
 
56
56
  function mockCalendarFetch(
@@ -570,3 +570,22 @@ describe('CalendarSyncProvider optimistic visible events', () => {
570
570
  });
571
571
  });
572
572
  });
573
+
574
+ it('refetches invalidated event ranges even while the local range cache is fresh', async () => {
575
+ const fetchMock = mockCalendarFetch([createEvent('before')]);
576
+ vi.stubGlobal('fetch', fetchMock);
577
+ const { result, queryClient } = renderCalendarSync();
578
+ act(() => result.current.setDates(createWeekDates('2026-06-22')));
579
+ await waitFor(() =>
580
+ expect(result.current.events.map((event) => event.id)).toEqual(['before'])
581
+ );
582
+ fetchMock.mockImplementation(mockCalendarFetch([createEvent('after')]));
583
+ await act(() =>
584
+ queryClient.invalidateQueries({
585
+ queryKey: ['databaseCalendarEvents', 'workspace-1'],
586
+ })
587
+ );
588
+ await waitFor(() =>
589
+ expect(result.current.events.map((event) => event.id)).toEqual(['after'])
590
+ );
591
+ });
@@ -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 { renderHook, waitFor } from '@testing-library/react';
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
- type RealtimePayload = {
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: createRealtimeClientMock,
15
+ createRealtimeClient,
72
16
  }));
73
17
 
74
- function createQueryClient() {
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={queryClient}>{children}</QueryClientProvider>
21
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
87
22
  );
88
23
  };
89
24
  }
90
25
 
91
- describe('useNotificationSubscription', () => {
26
+ describe('notification API refresh subscriptions', () => {
92
27
  beforeEach(() => {
93
- channelMock.mockClear();
94
- createRealtimeClientMock.mockClear();
95
- postgresCallbacks.length = 0;
96
- removeChannelMock.mockClear();
97
- subscribeMock.mockClear();
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('defers notification list requests until the consumer enables them', () => {
106
- const fetchMock = vi.fn();
107
- vi.stubGlobal('fetch', fetchMock);
108
- const queryClient = createQueryClient();
109
-
110
- renderHook(
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 realtime channel across multiple consumers for the same user', async () => {
143
- const queryClient = createQueryClient();
144
- const wrapper = createWrapper(queryClient);
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-1', 'user-1'),
152
- { wrapper }
62
+ () => useNotificationSubscription('workspace', 'user-1'),
63
+ { wrapper: wrapper(client) }
153
64
  );
154
65
 
155
- await waitFor(() => {
156
- expect(channelMock).toHaveBeenCalledTimes(1);
157
- expect(subscribeMock).toHaveBeenCalledTimes(1);
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(removeChannelMock).not.toHaveBeenCalled();
163
-
71
+ act(() => vi.advanceTimersByTime(NOTIFICATION_REFRESH_INTERVAL_MS));
72
+ expect(invalidate).toHaveBeenCalledTimes(2);
164
73
  second.unmount();
165
-
166
- expect(removeChannelMock).toHaveBeenCalledTimes(1);
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('invalidates every mounted query client from the shared subscription', async () => {
194
- const firstQueryClient = createQueryClient();
195
- const secondQueryClient = createQueryClient();
196
- const firstInvalidate = vi.spyOn(firstQueryClient, 'invalidateQueries');
197
- const secondInvalidate = vi.spyOn(secondQueryClient, 'invalidateQueries');
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: createWrapper(firstQueryClient) }
86
+ { wrapper: wrapper(a) }
202
87
  );
203
88
  const second = renderHook(
204
- () => useNotificationSubscription('workspace-1', 'user-1'),
205
- { wrapper: createWrapper(secondQueryClient) }
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
- await waitFor(() => {
209
- expect(postgresCallbacks).toHaveLength(3);
210
- });
211
-
212
- postgresCallbacks[0]?.({});
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
- expect(secondInvalidate).toHaveBeenCalledWith({
221
- queryKey: ['notifications'],
110
+ Object.defineProperty(document, 'visibilityState', {
111
+ configurable: true,
112
+ value: 'hidden',
222
113
  });
223
- expect(secondInvalidate).toHaveBeenCalledWith({
224
- queryKey: ['notifications', 'unread-count'],
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
- first.unmount();
228
- second.unmount();
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
- const isStale = Date.now() - lastUpdated >= staleTime;
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 syncWorkspaceCalendar(wsId);
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 syncWorkspaceCalendar(ws.id);
1371
+ const result = await runCalendarProviderSync(queryClient, ws.id);
1372
1372
  await queryClient.invalidateQueries({
1373
1373
  queryKey: ['databaseCalendarEvents', ws.id],
1374
1374
  exact: false,