@tuturuuu/ui 0.37.2 → 0.38.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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +9 -6
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  11. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  12. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  13. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  14. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  15. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  16. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  17. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  18. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  19. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  20. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  21. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  22. package/src/components/ui/popover.tsx +3 -1
  23. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  24. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  25. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  26. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  27. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  28. package/src/components/ui/text-editor/editor.tsx +8 -3
  29. package/src/components/ui/text-editor/extensions.ts +8 -2
  30. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  31. package/src/components/ui/text-editor/markdown.ts +11 -0
  32. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  33. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  34. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  35. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  36. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  37. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  38. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  39. package/src/hooks/calendar-creation-request.test.ts +62 -0
  40. package/src/hooks/calendar-creation-request.ts +34 -0
  41. package/src/hooks/calendar-event-write-payload.ts +24 -0
  42. package/src/hooks/notification-subscription.ts +5 -2
  43. package/src/hooks/supabase-provider.ts +38 -72
  44. package/src/hooks/use-calendar.tsx +21 -45
  45. package/src/hooks/use-exchange-rates.ts +4 -15
  46. package/src/hooks/use-workspace-presence.ts +9 -9
  47. package/src/hooks/use-yjs-collaboration.ts +45 -27
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.38.0](https://github.com/tutur3u/platform/compare/ui-v0.37.2...ui-v0.38.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **calendar:** support full Google color choices and scoped edits ([#5661](https://github.com/tutur3u/platform/issues/5661)) ([8cafac5](https://github.com/tutur3u/platform/commit/8cafac53438b504eafa8bcc8213d18448e4a17bc))
9
+ * **education:** add personal to do destinations to Teach and Learn ([9587942](https://github.com/tutur3u/platform/commit/9587942deeebcb6aca3a774f4289178771bcabd6))
10
+ * **education:** add personal To do in Teach and Learn ([#5785](https://github.com/tutur3u/platform/issues/5785)) ([0a5e302](https://github.com/tutur3u/platform/commit/0a5e302fa9b09c140c717751005bbda23a1dc0b1))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **calendar:** gate uncertain creation retries on durable capability ([4a0a088](https://github.com/tutur3u/platform/commit/4a0a0883ddfff464be0880afd13758908e65203f))
16
+ * **calendar:** recover edited drafts and align provider color parity ([d97142a](https://github.com/tutur3u/platform/commit/d97142abe22188b6a31a4a6b0e7b3a97d35d07f5))
17
+ * **finance:** authenticate exchange rates with satellite sessions ([33368f1](https://github.com/tutur3u/platform/commit/33368f1c00f86f71b4a66702899af262a90122e2)) ([#5760](https://github.com/tutur3u/platform/issues/5760)) ([759b1e5](https://github.com/tutur3u/platform/commit/759b1e52f8867aced2d64288af43b861d5ebccdc))
18
+ * **identity:** integrate main and order unapplied creator migrations ([5512e24](https://github.com/tutur3u/platform/commit/5512e2414abaa0c78b68cbdd3186a1d4bab861e7))
19
+ * **notifications:** preserve Colab polling policy ([ed09b9d](https://github.com/tutur3u/platform/commit/ed09b9ddaabd9b1865ca11e0459f23b0141137df))
20
+ * **ui:** close workspace popover without callback ([b0693b8](https://github.com/tutur3u/platform/commit/b0693b869721b9333d81d791554864291bec769d))
21
+ * **ui:** default browser workspace selectors to dropdowns ([9a8da02](https://github.com/tutur3u/platform/commit/9a8da02ceb7c03ee7eb05fb6cdc729144da6192a))
22
+ * **ui:** refine workspace dropdown recovery and viewport bounds ([06eeee7](https://github.com/tutur3u/platform/commit/06eeee77a26c1fc632907b72a5e0218d5d3fb49d))
23
+ * **web:** restore anchored workspace dropdown ([2df0661](https://github.com/tutur3u/platform/commit/2df0661f911eaec319cacca5d29242804d0e76ee))
24
+ * **web:** restore standard workspace dropdown ([#5739](https://github.com/tutur3u/platform/issues/5739)) ([f84ae74](https://github.com/tutur3u/platform/commit/f84ae741f2abab2a21aadfacda56bd7eba623466))
25
+
26
+
27
+ ### Performance Improvements
28
+
29
+ * **notifications:** reduce idle API polling ([32ee541](https://github.com/tutur3u/platform/commit/32ee5410e51d900698a06724b440b63df363fee9)) ([#5773](https://github.com/tutur3u/platform/issues/5773)) ([55089e0](https://github.com/tutur3u/platform/commit/55089e06ed6728f4bd6543787dcae48de3cfd734))
30
+
3
31
  ## [0.37.2](https://github.com/tutur3u/platform/compare/ui-v0.37.1...ui-v0.37.2) (2026-10-02)
4
32
 
5
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -87,13 +87,14 @@
87
87
  "@tiptap/pm": "3.31.3",
88
88
  "@tiptap/react": "3.31.3",
89
89
  "@tiptap/starter-kit": "3.31.3",
90
- "@tuturuuu/ai": "0.16.1",
90
+ "@tuturuuu/ai": "0.17.0",
91
91
  "@tuturuuu/apis": "0.13.0",
92
92
  "@tuturuuu/hooks": "0.1.2",
93
93
  "@tuturuuu/icons": "0.1.1",
94
- "@tuturuuu/internal-api": "0.48.0",
94
+ "@tuturuuu/internal-api": "0.49.0",
95
+ "@tuturuuu/realtime": "0.10.0",
95
96
  "@tuturuuu/supabase": "0.5.2",
96
- "@tuturuuu/utils": "0.33.3",
97
+ "@tuturuuu/utils": "0.34.0",
97
98
  "@types/debug": "^4.1.13",
98
99
  "browser-image-compression": "^2.0.2",
99
100
  "class-variance-authority": "^0.7.1",
@@ -154,7 +155,7 @@
154
155
  "@tanstack/react-table": "^9.2.4",
155
156
  "@testing-library/jest-dom": "^7.0.1",
156
157
  "@testing-library/react": "^16.3.3",
157
- "@tuturuuu/types": "0.33.1",
158
+ "@tuturuuu/types": "0.34.0",
158
159
  "@tuturuuu/typescript-config": "0.1.2",
159
160
  "@types/html2canvas": "^1.0.0",
160
161
  "@types/lodash": "^4.17.25",
@@ -179,6 +180,7 @@
179
180
  "@tanstack/react-table": "^9.0.0"
180
181
  },
181
182
  "exports": {
183
+ "./custom/education-todo": "./src/components/ui/custom/education-todo.tsx",
182
184
  "./globals.css": "./src/globals.css",
183
185
  "./marketing": "./src/components/ui/marketing/index.ts",
184
186
  "./postcss.config": "./postcss.config.mjs",
@@ -497,7 +499,8 @@
497
499
  "./custom/satellite-notification-popover": "./src/components/ui/custom/satellite-notification-popover.tsx",
498
500
  "./custom/notification-runtime": "./src/components/ui/custom/notification-runtime.tsx",
499
501
  "./finance/invoices/query-invalidation": "./src/components/ui/finance/invoices/query-invalidation.ts",
500
- "./finance/shared/charts/use-finance-confidential-visibility": "./src/components/ui/finance/shared/charts/use-finance-confidential-visibility.ts"
502
+ "./finance/shared/charts/use-finance-confidential-visibility": "./src/components/ui/finance/shared/charts/use-finance-confidential-visibility.ts",
503
+ "./text-editor/collaboration-carets": "./src/components/ui/text-editor/collaboration-carets.ts"
501
504
  },
502
505
  "packageManager": "bun@1.4.1"
503
506
  }
@@ -3,6 +3,7 @@ import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import type { useWorkspaceVisibility } from '../../../../hooks/use-workspace-visibility';
5
5
  import { Dialog } from '../../dialog';
6
+ import { Popover, PopoverTrigger } from '../../popover';
6
7
  import { WorkspacePickerContent } from '../workspace-picker-content';
7
8
 
8
9
  vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
@@ -179,3 +180,88 @@ describe('workspace sheet rendered identities', () => {
179
180
  );
180
181
  });
181
182
  });
183
+
184
+ describe('workspace browser dropdown', () => {
185
+ it('keeps search and Hidden recovery in an anchored scrolling dropdown', async () => {
186
+ const onSelect = vi.fn();
187
+ const onDefault = vi.fn();
188
+ const onCreate = vi.fn();
189
+ const state = visibility({ hiddenIds: [other.id] });
190
+ render(
191
+ <Popover defaultOpen>
192
+ <PopoverTrigger>Choose workspace</PopoverTrigger>
193
+ <WorkspacePickerContent
194
+ presentation="dropdown"
195
+ workspaces={[one, other]}
196
+ visibility={state}
197
+ onSelect={onSelect}
198
+ onDefault={onDefault}
199
+ onCreate={onCreate}
200
+ />
201
+ </Popover>
202
+ );
203
+ const dropdown = screen.getByRole('dialog', { name: 'workspaces' });
204
+ expect(dropdown).toHaveAttribute('data-slot', 'popover-content');
205
+ expect(dropdown).not.toHaveClass('inset-0');
206
+ expect(dropdown).not.toHaveClass('h-dvh');
207
+ expect(dropdown).toHaveClass(
208
+ 'max-h-[min(32rem,var(--radix-popover-content-available-height))]'
209
+ );
210
+ expect(dropdown).toHaveClass('overflow-hidden', 'flex-col');
211
+ const input = screen.getByRole('textbox', { name: 'search_workspace' });
212
+ const row = screen.getByRole('button', { name: 'Repeated name PRO' });
213
+ expect(row.closest('.overflow-y-auto')).not.toBeNull();
214
+ expect(screen.queryByText('Another workspace')).toBeNull();
215
+ fireEvent.click(row);
216
+ expect(onSelect).toHaveBeenCalledWith(one);
217
+ fireEvent.click(screen.getByRole('button', { name: 'default_workspace' }));
218
+ expect(onDefault).toHaveBeenCalledWith(one.id);
219
+ const create = screen.getByRole('button', {
220
+ name: 'create_workspace_action',
221
+ });
222
+ const createFooter = create.parentElement;
223
+ expect(createFooter).not.toHaveClass('absolute');
224
+ fireEvent.click(create);
225
+ expect(onCreate).toHaveBeenCalledOnce();
226
+ fireEvent.change(input, { target: { value: 'missing' } });
227
+ expect(
228
+ screen.queryByRole('button', { name: 'Repeated name PRO' })
229
+ ).toBeNull();
230
+ fireEvent.click(screen.getByRole('button', { name: 'hidden_workspaces' }));
231
+ expect(input).toHaveValue('');
232
+ expect(dropdown).toHaveAccessibleName('hidden_workspaces');
233
+ expect(createFooter).toHaveClass('hidden');
234
+ fireEvent.click(
235
+ screen.getByRole('button', {
236
+ name: 'restore_workspace: Another workspace',
237
+ })
238
+ );
239
+ expect(state.setHidden).toHaveBeenCalledWith(other.id, false);
240
+ expect(onSelect).toHaveBeenCalledTimes(1);
241
+ fireEvent.keyDown(dropdown, { key: 'Escape' });
242
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
243
+ });
244
+ });
245
+
246
+ it.each([false, true])(
247
+ 'closes its popover with an optional callback: %s',
248
+ async (withCallback) => {
249
+ const onClose = vi.fn();
250
+ render(
251
+ <Popover defaultOpen>
252
+ <PopoverTrigger>Choose workspace</PopoverTrigger>
253
+ <WorkspacePickerContent
254
+ presentation="dropdown"
255
+ workspaces={[one]}
256
+ visibility={visibility()}
257
+ onSelect={vi.fn()}
258
+ onDefault={vi.fn()}
259
+ onClose={withCallback ? onClose : undefined}
260
+ />
261
+ </Popover>
262
+ );
263
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
264
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
265
+ expect(onClose).toHaveBeenCalledTimes(withCallback ? 1 : 0);
266
+ }
267
+ );
@@ -0,0 +1,131 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { EducationTodo } from './education-todo';
4
+
5
+ const state = vi.hoisted(() => ({
6
+ query: vi.fn(),
7
+ refetch: vi.fn(),
8
+ error: false,
9
+ }));
10
+ vi.mock('@tanstack/react-query', () => ({
11
+ useQuery: (options: unknown) => {
12
+ state.query(options);
13
+ return {
14
+ isError: state.error,
15
+ isPending: false,
16
+ refetch: state.refetch,
17
+ data: {
18
+ count: 30,
19
+ totalPages: 2,
20
+ data: [
21
+ {
22
+ id: 'done',
23
+ title: 'Completed synthetic work',
24
+ courseId: 'course',
25
+ completed: true,
26
+ },
27
+ {
28
+ id: 'open',
29
+ title: 'Open synthetic work',
30
+ courseId: 'course',
31
+ completed: false,
32
+ },
33
+ ],
34
+ },
35
+ };
36
+ },
37
+ }));
38
+ vi.mock('@tuturuuu/internal-api/teach', () => ({ listEducationTodo: vi.fn() }));
39
+ const translate = (key: string) => key;
40
+ describe('personal education work controls', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ state.error = false;
44
+ });
45
+ it('separates actor caches, paginates and resets page on category switch', () => {
46
+ const { rerender } = render(
47
+ <EducationTodo
48
+ app="learn"
49
+ wsId="workspace"
50
+ actorId="actor-one"
51
+ t={translate}
52
+ />
53
+ );
54
+ expect(state.query).toHaveBeenLastCalledWith(
55
+ expect.objectContaining({
56
+ queryKey: [
57
+ 'education-todo',
58
+ 'learn',
59
+ 'workspace',
60
+ 'actor-one',
61
+ 'tutoring',
62
+ 1,
63
+ ],
64
+ })
65
+ );
66
+ fireEvent.click(screen.getByRole('button', { name: 'next' }));
67
+ expect(state.query).toHaveBeenLastCalledWith(
68
+ expect.objectContaining({
69
+ queryKey: [
70
+ 'education-todo',
71
+ 'learn',
72
+ 'workspace',
73
+ 'actor-one',
74
+ 'tutoring',
75
+ 2,
76
+ ],
77
+ })
78
+ );
79
+ fireEvent.click(screen.getByRole('button', { name: 'assignments' }));
80
+ expect(screen.queryByText('Completed synthetic work')).toBeNull();
81
+ expect(screen.getByText('Open synthetic work')).toBeTruthy();
82
+ expect(state.query).toHaveBeenLastCalledWith(
83
+ expect.objectContaining({
84
+ queryKey: [
85
+ 'education-todo',
86
+ 'learn',
87
+ 'workspace',
88
+ 'actor-one',
89
+ 'assignments',
90
+ 1,
91
+ ],
92
+ })
93
+ );
94
+ fireEvent.click(screen.getByRole('checkbox'));
95
+ expect(screen.getByText('Completed synthetic work')).toBeTruthy();
96
+ rerender(
97
+ <EducationTodo
98
+ app="learn"
99
+ wsId="workspace"
100
+ actorId="actor-two"
101
+ t={translate}
102
+ />
103
+ );
104
+ expect(state.query).toHaveBeenLastCalledWith(
105
+ expect.objectContaining({
106
+ queryKey: [
107
+ 'education-todo',
108
+ 'learn',
109
+ 'workspace',
110
+ 'actor-two',
111
+ 'assignments',
112
+ 1,
113
+ ],
114
+ })
115
+ );
116
+ });
117
+ it('offers retry on errors without rendering stale assigned rows', () => {
118
+ state.error = true;
119
+ render(
120
+ <EducationTodo
121
+ app="teach"
122
+ wsId="workspace"
123
+ actorId="actor"
124
+ t={translate}
125
+ />
126
+ );
127
+ expect(screen.queryByText('Open synthetic work')).toBeNull();
128
+ fireEvent.click(screen.getByRole('button', { name: 'retry' }));
129
+ expect(state.refetch).toHaveBeenCalledOnce();
130
+ });
131
+ });
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import {
5
+ BookOpen,
6
+ CalendarCheck,
7
+ CheckCheck,
8
+ ClipboardList,
9
+ } from '@tuturuuu/icons';
10
+ import {
11
+ type EducationTodoItem,
12
+ type EducationTodoKind,
13
+ listEducationTodo,
14
+ } from '@tuturuuu/internal-api/teach';
15
+ import { Button } from '@tuturuuu/ui/button';
16
+ import { Checkbox } from '@tuturuuu/ui/checkbox';
17
+ import Link from 'next/link';
18
+ import { useState } from 'react';
19
+
20
+ const kinds = ['tutoring', 'assignments', 'lessons', 'tests'] as const;
21
+ const icons = {
22
+ tutoring: CalendarCheck,
23
+ assignments: ClipboardList,
24
+ lessons: BookOpen,
25
+ tests: ClipboardList,
26
+ };
27
+ function itemHref(
28
+ app: 'learn' | 'teach',
29
+ wsId: string,
30
+ kind: EducationTodoKind,
31
+ row: EducationTodoItem
32
+ ) {
33
+ if (kind === 'tutoring' || !row.courseId) return null;
34
+ if (kind === 'tests')
35
+ return `/${wsId}/courses/${row.courseId}${app === 'learn' ? `/tests/${row.id}` : ''}`;
36
+ if (kind === 'assignments')
37
+ return `/${wsId}/assignments${app === 'teach' ? `?course=${row.courseId}` : ''}`;
38
+ return `/${wsId}/courses/${row.courseId}${app === 'teach' ? `/${row.id}` : ''}`;
39
+ }
40
+
41
+ export function EducationTodo({
42
+ app,
43
+ wsId,
44
+ actorId,
45
+ t,
46
+ }: {
47
+ app: 'learn' | 'teach';
48
+ wsId: string;
49
+ actorId: string;
50
+ t: (key: string, values?: Record<string, string | number>) => string;
51
+ }) {
52
+ const [kind, setKind] = useState<EducationTodoKind>('tutoring');
53
+ const [page, setPage] = useState(1);
54
+ const [showCompleted, setShowCompleted] = useState(false);
55
+ const query = useQuery({
56
+ queryKey: ['education-todo', app, wsId, actorId, kind, page],
57
+ queryFn: () => listEducationTodo(app, wsId, { kind, page }),
58
+ });
59
+ const rows = (query.data?.data ?? []).filter(
60
+ (row) => showCompleted || !row.completed
61
+ );
62
+ return (
63
+ <main className="mx-auto max-w-5xl space-y-5 p-5 md:p-8">
64
+ <header className="space-y-2">
65
+ <h1 className="font-semibold text-3xl">{t('title')}</h1>
66
+ <p className="max-w-2xl text-muted-foreground">
67
+ {t(app === 'learn' ? 'learnDescription' : 'teachDescription')}
68
+ </p>
69
+ </header>
70
+ <nav aria-label={t('categories')} className="flex flex-wrap gap-2">
71
+ {kinds.map((value) => {
72
+ const Icon = icons[value];
73
+ return (
74
+ <Button
75
+ key={value}
76
+ variant={kind === value ? 'default' : 'outline'}
77
+ aria-pressed={kind === value}
78
+ onClick={() => {
79
+ setKind(value);
80
+ setPage(1);
81
+ }}
82
+ >
83
+ <Icon className="size-4" />
84
+ {t(value)}
85
+ </Button>
86
+ );
87
+ })}
88
+ </nav>
89
+ {kind !== 'tutoring' && app === 'learn' ? (
90
+ <label className="flex items-center gap-2 text-sm">
91
+ <Checkbox
92
+ checked={showCompleted}
93
+ onCheckedChange={(value) => setShowCompleted(value === true)}
94
+ />
95
+ {t('showCompleted')}
96
+ </label>
97
+ ) : null}
98
+ {query.isError ? (
99
+ <div role="alert" className="space-y-3 rounded-xl border p-5">
100
+ <p>{t('error')}</p>
101
+ <Button onClick={() => void query.refetch()} variant="outline">
102
+ {t('retry')}
103
+ </Button>
104
+ </div>
105
+ ) : query.isPending ? (
106
+ <p role="status">{t('loading')}</p>
107
+ ) : (
108
+ <>
109
+ <p className="text-muted-foreground text-sm">
110
+ {t(app === 'learn' ? 'assignedCount' : 'pendingCount', {
111
+ count: query.data?.count ?? 0,
112
+ })}
113
+ </p>
114
+ <ul className="space-y-3">
115
+ {rows.map((row) => {
116
+ const href = itemHref(app, wsId, kind, row);
117
+ return (
118
+ <li
119
+ key={row.id}
120
+ className="flex flex-wrap items-center justify-between gap-4 rounded-xl border bg-card p-4"
121
+ >
122
+ <div className="min-w-0 space-y-1">
123
+ <h2 className="line-clamp-3 break-words font-medium">
124
+ {row.title ||
125
+ t(kind === 'tutoring' ? 'tutoring' : 'untitled')}
126
+ </h2>
127
+ {row.participantName ? (
128
+ <p className="text-muted-foreground text-sm">
129
+ {t(app === 'teach' ? 'student' : 'teacher')}:{' '}
130
+ {row.participantName}
131
+ </p>
132
+ ) : null}
133
+ {row.date ? (
134
+ <p className="text-muted-foreground text-sm">
135
+ {row.date}
136
+ {row.startTime ? ` · ${row.startTime.slice(0, 5)}` : ''}
137
+ {row.durationMinutes
138
+ ? ` · ${t('minutes', { count: row.durationMinutes })}`
139
+ : ''}
140
+ </p>
141
+ ) : null}
142
+ {row.completed ? (
143
+ <p className="flex items-center gap-1 text-muted-foreground text-sm">
144
+ <CheckCheck className="size-4" />
145
+ {t('completed')}
146
+ </p>
147
+ ) : null}
148
+ </div>
149
+ {href ? (
150
+ <Button asChild size="sm" variant="outline">
151
+ <Link href={href}>{t('open')}</Link>
152
+ </Button>
153
+ ) : null}
154
+ </li>
155
+ );
156
+ })}
157
+ </ul>
158
+ {!rows.length ? (
159
+ <p className="rounded-xl border border-dashed p-6 text-muted-foreground">
160
+ {t((query.data?.count ?? 0) ? 'emptyPage' : 'empty')}
161
+ </p>
162
+ ) : null}
163
+ {(query.data?.totalPages ?? 0) > 1 ? (
164
+ <nav
165
+ aria-label={t('pages')}
166
+ className="flex items-center justify-between gap-3"
167
+ >
168
+ <Button
169
+ disabled={page === 1}
170
+ onClick={() => setPage((value) => value - 1)}
171
+ variant="outline"
172
+ >
173
+ {t('previous')}
174
+ </Button>
175
+ <span className="text-sm">
176
+ {t('page', { page, total: query.data?.totalPages ?? 0 })}
177
+ </span>
178
+ <Button
179
+ disabled={page >= (query.data?.totalPages ?? 0)}
180
+ onClick={() => setPage((value) => value + 1)}
181
+ variant="outline"
182
+ >
183
+ {t('next')}
184
+ </Button>
185
+ </nav>
186
+ ) : null}
187
+ </>
188
+ )}
189
+ </main>
190
+ );
191
+ }
@@ -0,0 +1,61 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { cleanup, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { NotificationRuntime } from './notification-runtime';
5
+ import SatelliteNotificationPopover from './satellite-notification-popover';
6
+
7
+ const { unread, list } = vi.hoisted(() => ({ unread: vi.fn(), list: vi.fn() }));
8
+ vi.mock('@tuturuuu/ui/hooks/use-notifications', () => ({
9
+ useUnreadCount: unread,
10
+ useInfiniteNotifications: list,
11
+ useNotificationSubscription: vi.fn(),
12
+ useMarkAllAsRead: () => ({ mutate: vi.fn(), isPending: false }),
13
+ useUpdateNotification: () => ({ mutate: vi.fn(), isPending: false }),
14
+ dedupeNotifications: () => [],
15
+ }));
16
+ vi.mock('./notification-list', () => ({ NotificationList: () => null }));
17
+ describe('satellite freshness policy', () => {
18
+ afterEach(() => {
19
+ cleanup();
20
+ vi.clearAllMocks();
21
+ });
22
+ it.each([
23
+ [undefined, 120_000],
24
+ [30_000, 30_000],
25
+ ])(
26
+ 'uses the default or an explicit app polling interval',
27
+ (pollIntervalMs, expected) => {
28
+ unread.mockReturnValue({ data: 0 });
29
+ list.mockReturnValue({ data: { pages: [] }, isLoading: false });
30
+ render(
31
+ <QueryClientProvider client={new QueryClient()}>
32
+ <NotificationRuntime
33
+ value={{
34
+ params: { wsId: 'personal' },
35
+ pathname: '/',
36
+ router: { push: vi.fn(), refresh: vi.fn() },
37
+ Link: ({ children }) => <span>{children}</span>,
38
+ realtime: false,
39
+ }}
40
+ >
41
+ <SatelliteNotificationPopover
42
+ userId="actor"
43
+ pollIntervalMs={pollIntervalMs}
44
+ noNotificationsText="Empty"
45
+ notificationsText="Notifications"
46
+ viewAllText="View all"
47
+ markAsReadText="Read"
48
+ markAsUnreadText="Unread"
49
+ />
50
+ </NotificationRuntime>
51
+ </QueryClientProvider>
52
+ );
53
+ expect(unread.mock.calls[0]?.[1].refetchInterval).toBe(expected);
54
+ expect(
55
+ list.mock.calls.every(
56
+ ([options]) => options.refetchInterval === expected
57
+ )
58
+ ).toBe(true);
59
+ }
60
+ );
61
+ });
@@ -22,10 +22,13 @@ import { NotificationPopoverTriggerButton } from './notification-popover-trigger
22
22
  import { useNotificationRuntime } from './notification-runtime';
23
23
 
24
24
  dayjs.extend(relativeTime);
25
+ const NOTIFICATION_POLL_INTERVAL_MS = 2 * 60_000;
25
26
  type TabType = 'inbox' | 'archive';
26
27
 
27
28
  export interface NotificationPopoverClientProps {
28
29
  userId?: string;
30
+ /** Optional app-specific freshness policy for non-subscription consumers. */
31
+ pollIntervalMs?: number;
29
32
  noNotificationsText: string;
30
33
  notificationsText: string;
31
34
  viewAllText: string;
@@ -63,6 +66,7 @@ function isValidWorkspaceFilterId(value: unknown): value is string {
63
66
 
64
67
  export default function SatelliteNotificationPopover({
65
68
  userId,
69
+ pollIntervalMs = NOTIFICATION_POLL_INTERVAL_MS,
66
70
  noNotificationsText,
67
71
  notificationsText,
68
72
  viewAllText,
@@ -98,7 +102,7 @@ export default function SatelliteNotificationPopover({
98
102
  const { data: unreadCount = 0 } = useUnreadCount(wsIdForFiltering, {
99
103
  cacheScope: userId,
100
104
  enabled: Boolean(userId),
101
- refetchInterval: realtime ? undefined : 30_000,
105
+ refetchInterval: realtime ? undefined : pollIntervalMs,
102
106
  });
103
107
 
104
108
  // Infinite scroll for inbox (unread) and archive (read)
@@ -107,7 +111,7 @@ export default function SatelliteNotificationPopover({
107
111
  wsId: wsIdForFiltering,
108
112
  unreadOnly: true,
109
113
  pageSize: 15,
110
- refetchInterval: realtime ? undefined : 30_000,
114
+ refetchInterval: realtime ? undefined : pollIntervalMs,
111
115
  enabled: Boolean(userId) && open && activeTab === 'inbox',
112
116
  });
113
117
 
@@ -116,7 +120,7 @@ export default function SatelliteNotificationPopover({
116
120
  wsId: wsIdForFiltering,
117
121
  readOnly: true,
118
122
  pageSize: 15,
119
- refetchInterval: realtime ? undefined : 30_000,
123
+ refetchInterval: realtime ? undefined : pollIntervalMs,
120
124
  enabled: Boolean(userId) && open && activeTab === 'archive',
121
125
  });
122
126