@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.
- package/CHANGELOG.md +57 -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-markdown.tsx +17 -2
- 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/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/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/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.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-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.ts +1 -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-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 +44 -45
- 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/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
|
@@ -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 {
|
|
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,
|