@tuturuuu/ui 0.30.2 → 0.31.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 (91) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  16. package/src/components/ui/custom/nav-link.tsx +7 -7
  17. package/src/components/ui/custom/nav.tsx +15 -63
  18. package/src/components/ui/custom/notification-card.tsx +361 -0
  19. package/src/components/ui/custom/notification-list.tsx +178 -0
  20. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  21. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  22. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  23. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  24. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  25. package/src/components/ui/custom/satellite-content.tsx +148 -0
  26. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  27. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  28. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  29. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  30. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  31. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  32. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  33. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  34. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  35. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  36. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  37. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  38. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  39. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  40. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  41. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  42. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  43. package/src/components/ui/custom/structure.tsx +14 -0
  44. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  45. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  46. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  47. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  48. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  49. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  50. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  51. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  53. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  54. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  56. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  57. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  58. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  59. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  61. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  62. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  63. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  65. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  66. package/src/components/ui/pwa-status.test.tsx +53 -0
  67. package/src/components/ui/pwa-status.tsx +113 -0
  68. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  69. package/src/components/ui/report-problem-dialog.tsx +6 -975
  70. package/src/components/ui/report-problem-model.ts +133 -0
  71. package/src/components/ui/report-problem-mutation.ts +122 -0
  72. package/src/components/ui/save-notification.test.tsx +36 -0
  73. package/src/components/ui/save-notification.tsx +17 -0
  74. package/src/components/ui/use-report-problem.ts +488 -0
  75. package/src/declarations.d.ts +2 -0
  76. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  77. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  78. package/src/hooks/calendar-date-utils.ts +11 -0
  79. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  80. package/src/hooks/calendar-event-lookup.ts +47 -0
  81. package/src/hooks/calendar-range-cache.ts +52 -0
  82. package/src/hooks/pwa-install-prompt.ts +44 -0
  83. package/src/hooks/use-calendar-sync.tsx +23 -100
  84. package/src/hooks/use-calendar.tsx +21 -77
  85. package/src/hooks/use-notifications.ts +9 -24
  86. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  87. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  88. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  89. package/src/hooks/use-workspace-presence.ts +0 -18
  90. package/src/lib/tasks-app-url.test.ts +25 -0
  91. package/src/lib/tasks-app-url.ts +8 -0
@@ -11,7 +11,6 @@ import {
11
11
  type SupabaseClient,
12
12
  } from '@tuturuuu/supabase/next/realtime-browser';
13
13
  import { useEffect } from 'react';
14
-
15
14
  export type NotificationType =
16
15
  | 'task_assigned'
17
16
  | 'task_updated'
@@ -44,13 +43,11 @@ export type NotificationType =
44
43
  | 'time_tracking_request_approved'
45
44
  | 'time_tracking_request_rejected'
46
45
  | 'time_tracking_request_needs_info';
47
-
48
46
  export interface NotificationActor {
49
47
  id: string;
50
48
  display_name: string | null;
51
49
  avatar_url: string | null;
52
50
  }
53
-
54
51
  export interface Notification {
55
52
  id: string;
56
53
  ws_id: string | null; // Can be null for user-scoped notifications
@@ -66,14 +63,12 @@ export interface Notification {
66
63
  created_by: string | null;
67
64
  actor: NotificationActor | null;
68
65
  }
69
-
70
66
  interface NotificationsPage {
71
67
  notifications: Notification[];
72
68
  count: number;
73
69
  limit: number;
74
70
  offset: number;
75
71
  }
76
-
77
72
  interface UseNotificationsOptions {
78
73
  cacheScope?: string;
79
74
  wsId?: string;
@@ -83,22 +78,18 @@ interface UseNotificationsOptions {
83
78
  readOnly?: boolean;
84
79
  type?: NotificationType;
85
80
  }
86
-
87
81
  interface NotificationSubscriptionEntry {
88
82
  channel: RealtimeChannel;
89
83
  queryClientRefs: Map<QueryClient, number>;
90
84
  subscriberCount: number;
91
85
  supabase: SupabaseClient;
92
86
  }
93
-
94
87
  export const UNREAD_COUNT_STALE_TIME_MS = 5 * 60 * 1000;
95
88
  export const UNREAD_COUNT_FALLBACK_INTERVAL_MS = 15 * 60 * 1000;
96
-
97
89
  const notificationSubscriptionRegistry = new Map<
98
90
  string,
99
91
  NotificationSubscriptionEntry
100
92
  >();
101
-
102
93
  function invalidateNotificationQueries(
103
94
  queryClientRefs: Map<QueryClient, number>
104
95
  ) {
@@ -109,7 +100,6 @@ function invalidateNotificationQueries(
109
100
  });
110
101
  }
111
102
  }
112
-
113
103
  function addNotificationQueryClientRef(
114
104
  entry: NotificationSubscriptionEntry,
115
105
  queryClient: QueryClient
@@ -120,22 +110,18 @@ function addNotificationQueryClientRef(
120
110
  (entry.queryClientRefs.get(queryClient) ?? 0) + 1
121
111
  );
122
112
  }
123
-
124
113
  function releaseNotificationQueryClientRef(
125
114
  entry: NotificationSubscriptionEntry,
126
115
  queryClient: QueryClient
127
116
  ) {
128
117
  entry.subscriberCount -= 1;
129
-
130
118
  const currentRefCount = entry.queryClientRefs.get(queryClient) ?? 0;
131
-
132
119
  if (currentRefCount <= 1) {
133
120
  entry.queryClientRefs.delete(queryClient);
134
121
  } else {
135
122
  entry.queryClientRefs.set(queryClient, currentRefCount - 1);
136
123
  }
137
124
  }
138
-
139
125
  function createNotificationSubscriptionEntry(
140
126
  userId: string,
141
127
  queryClient: QueryClient
@@ -144,7 +130,6 @@ function createNotificationSubscriptionEntry(
144
130
  const supabase = createRealtimeClient();
145
131
  const invalidateQueries = () =>
146
132
  invalidateNotificationQueries(queryClientRefs);
147
-
148
133
  const channel = supabase
149
134
  .channel(`notifications-${userId}`)
150
135
  .on(
@@ -178,7 +163,6 @@ function createNotificationSubscriptionEntry(
178
163
  invalidateQueries
179
164
  )
180
165
  .subscribe();
181
-
182
166
  return {
183
167
  channel,
184
168
  queryClientRefs,
@@ -186,22 +170,18 @@ function createNotificationSubscriptionEntry(
186
170
  supabase,
187
171
  } satisfies NotificationSubscriptionEntry;
188
172
  }
189
-
190
173
  export function dedupeNotifications(
191
174
  notifications: Notification[]
192
175
  ): Notification[] {
193
176
  const seen = new Set<string>();
194
-
195
177
  return notifications.filter((notification) => {
196
178
  if (seen.has(notification.id)) {
197
179
  return false;
198
180
  }
199
-
200
181
  seen.add(notification.id);
201
182
  return true;
202
183
  });
203
184
  }
204
-
205
185
  /**
206
186
  * Hook to fetch notifications with pagination
207
187
  * @param wsId - If provided, filters to specific workspace. If omitted, fetches all notifications across all workspaces.
@@ -268,6 +248,7 @@ export function useInfiniteNotifications({
268
248
  readOnly = false,
269
249
  pageSize = 20,
270
250
  enabled = true,
251
+ refetchInterval,
271
252
  }: {
272
253
  cacheScope?: string;
273
254
  wsId?: string;
@@ -275,6 +256,7 @@ export function useInfiniteNotifications({
275
256
  readOnly?: boolean;
276
257
  pageSize?: number;
277
258
  enabled?: boolean;
259
+ refetchInterval?: number;
278
260
  }) {
279
261
  return useInfiniteQuery({
280
262
  queryKey: [
@@ -306,6 +288,8 @@ export function useInfiniteNotifications({
306
288
  },
307
289
  initialPageParam: 0,
308
290
  enabled,
291
+ refetchInterval,
292
+ refetchIntervalInBackground: false,
309
293
  getNextPageParam: (lastPage) => {
310
294
  const nextOffset = lastPage.offset + lastPage.limit;
311
295
  return nextOffset < lastPage.count ? nextOffset : undefined;
@@ -321,7 +305,7 @@ export function useInfiniteNotifications({
321
305
  */
322
306
  export function useUnreadCount(
323
307
  wsId?: string,
324
- options?: { cacheScope?: string; enabled?: boolean }
308
+ options?: { cacheScope?: string; enabled?: boolean; refetchInterval?: number }
325
309
  ) {
326
310
  return useQuery({
327
311
  queryKey: [
@@ -340,14 +324,15 @@ export function useUnreadCount(
340
324
  throw new Error('Failed to fetch unread count');
341
325
  }
342
326
 
343
- const data = await response.json();
344
- return data.count as number;
327
+ const data = (await response.json()) as { count: number };
328
+ return data.count;
345
329
  },
346
330
  enabled: options?.enabled ?? true,
347
331
  staleTime: UNREAD_COUNT_STALE_TIME_MS,
348
332
  // Realtime invalidation is the primary update path. Keep a low-frequency
349
333
  // refresh as a safety net for disconnected or suspended browser sessions.
350
- refetchInterval: UNREAD_COUNT_FALLBACK_INTERVAL_MS,
334
+ refetchInterval:
335
+ options?.refetchInterval ?? UNREAD_COUNT_FALLBACK_INTERVAL_MS,
351
336
  refetchIntervalInBackground: false,
352
337
  refetchOnReconnect: true,
353
338
  refetchOnWindowFocus: true,
@@ -0,0 +1,27 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { useEffect, useRef } from 'react';
3
+
4
+ export function useOpenInitialCalendarEvent({
5
+ events,
6
+ initialEventId,
7
+ onOpen,
8
+ }: {
9
+ events: CalendarEvent[];
10
+ initialEventId?: string;
11
+ onOpen: (eventId: string) => void;
12
+ }) {
13
+ const openedEventIdRef = useRef<string | null>(null);
14
+
15
+ useEffect(() => {
16
+ if (
17
+ !initialEventId ||
18
+ openedEventIdRef.current === initialEventId ||
19
+ !events.some((event) => event.id === initialEventId)
20
+ ) {
21
+ return;
22
+ }
23
+
24
+ onOpen(initialEventId);
25
+ openedEventIdRef.current = initialEventId;
26
+ }, [events, initialEventId, onOpen]);
27
+ }
@@ -0,0 +1,41 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { useSettingsDialogShortcut } from './use-settings-dialog-shortcut';
4
+
5
+ function Shell({ onOpen }: { onOpen: () => void }) {
6
+ useSettingsDialogShortcut({ enabled: true, onOpen });
7
+ return (
8
+ <div contentEditable suppressContentEditableWarning>
9
+ <span>Editor content</span>
10
+ </div>
11
+ );
12
+ }
13
+ describe('shared app settings shortcut', () => {
14
+ it('supports both platforms without stealing editor, composition, or modified commands', () => {
15
+ const onOpen = vi.fn();
16
+ render(<Shell onOpen={onOpen} />);
17
+ fireEvent.keyDown(window, { key: ',', ctrlKey: true });
18
+ fireEvent.keyDown(window, { key: ',', metaKey: true });
19
+ expect(onOpen).toHaveBeenCalledTimes(2);
20
+ fireEvent.keyDown(screen.getByText('Editor content'), {
21
+ key: ',',
22
+ ctrlKey: true,
23
+ });
24
+ for (const options of [
25
+ { isComposing: true },
26
+ { repeat: true },
27
+ { shiftKey: true },
28
+ { altKey: true },
29
+ ]) {
30
+ fireEvent.keyDown(window, { key: ',', ctrlKey: true, ...options });
31
+ }
32
+ const event = new KeyboardEvent('keydown', {
33
+ key: ',',
34
+ ctrlKey: true,
35
+ cancelable: true,
36
+ });
37
+ event.preventDefault();
38
+ fireEvent(window, event);
39
+ expect(onOpen).toHaveBeenCalledTimes(2);
40
+ });
41
+ });
@@ -1,5 +1,10 @@
1
1
  'use client';
2
2
 
3
+ import {
4
+ isEditableShortcutTarget,
5
+ isShortcutEventIgnored,
6
+ } from '@tuturuuu/utils/keyboard-shortcuts';
7
+
3
8
  import { useEffect } from 'react';
4
9
 
5
10
  interface UseSettingsDialogShortcutOptions {
@@ -7,23 +12,15 @@ interface UseSettingsDialogShortcutOptions {
7
12
  onOpen: () => void;
8
13
  }
9
14
 
10
- function isEditableShortcutTarget(target: EventTarget | null) {
11
- if (!(target instanceof HTMLElement)) return false;
12
-
13
- const tagName = target.tagName.toLowerCase();
14
-
15
+ function isSettingsDialogShortcut(event: KeyboardEvent) {
15
16
  return (
16
- target.isContentEditable ||
17
- tagName === 'input' ||
18
- tagName === 'textarea' ||
19
- tagName === 'select'
17
+ (event.metaKey || event.ctrlKey) &&
18
+ !event.altKey &&
19
+ !event.shiftKey &&
20
+ event.key === ','
20
21
  );
21
22
  }
22
23
 
23
- function isSettingsDialogShortcut(event: KeyboardEvent) {
24
- return (event.metaKey || event.ctrlKey) && !event.altKey && event.key === ',';
25
- }
26
-
27
24
  /**
28
25
  * Opens the app settings dialog on Cmd/Ctrl+, — the platform-wide convention.
29
26
  * Ignores the shortcut while typing in editable fields and when another handler
@@ -38,7 +35,8 @@ export function useSettingsDialogShortcut({
38
35
  if (!enabled) return;
39
36
 
40
37
  const handleKeyDown = (event: KeyboardEvent) => {
41
- if (event.defaultPrevented || !isSettingsDialogShortcut(event)) return;
38
+ if (isShortcutEventIgnored(event) || !isSettingsDialogShortcut(event))
39
+ return;
42
40
  if (isEditableShortcutTarget(event.target)) return;
43
41
 
44
42
  event.preventDefault();
@@ -56,24 +56,6 @@ function createPresenceSessionId(): string {
56
56
  return crypto.randomUUID();
57
57
  }
58
58
 
59
- // Fallback to Node.js crypto if available
60
- try {
61
- // eslint-disable-next-line @typescript-eslint/no-var-requires
62
- const nodeCrypto = require('node:crypto') as
63
- | { randomUUID?: () => string; randomBytes: (size: number) => Buffer }
64
- | undefined;
65
-
66
- if (nodeCrypto?.randomUUID) {
67
- return nodeCrypto.randomUUID();
68
- }
69
-
70
- if (typeof nodeCrypto?.randomBytes === 'function') {
71
- return nodeCrypto.randomBytes(16).toString('hex');
72
- }
73
- } catch {
74
- // Ignore and fall through to final fallback
75
- }
76
-
77
59
  // Final fallback using Web Crypto getRandomValues if available
78
60
  if (
79
61
  typeof crypto !== 'undefined' &&
@@ -0,0 +1,25 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { getTaskApiUrl, getTasksAppUrl } from './tasks-app-url';
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ vi.unstubAllEnvs();
7
+ });
8
+
9
+ describe('Calendar task API routing', () => {
10
+ it.each(['calendar.tuturuuu.com', 'calendar.tuturuuu.localhost'])(
11
+ 'uses the local authenticated proxy on %s',
12
+ (hostname) => {
13
+ vi.stubGlobal('window', {
14
+ location: { hostname, protocol: 'https:', port: '' },
15
+ });
16
+ vi.stubEnv('NEXT_PUBLIC_TASKS_APP_URL', 'https://tasks.tuturuuu.com');
17
+ expect(getTaskApiUrl('/api/v1/workspaces/ws/tasks/journal')).toBe(
18
+ '/api/v1/workspaces/ws/tasks/journal'
19
+ );
20
+ expect(getTasksAppUrl('/personal/tasks')).toBe(
21
+ 'https://tasks.tuturuuu.com/personal/tasks'
22
+ );
23
+ }
24
+ );
25
+ });
@@ -81,5 +81,13 @@ export function getTasksAppUrl(path: string) {
81
81
  }
82
82
 
83
83
  export function getTaskApiUrl(path: string) {
84
+ if (
85
+ typeof window !== 'undefined' &&
86
+ ['calendar.tuturuuu.com', 'calendar.tuturuuu.localhost'].includes(
87
+ window.location.hostname.toLowerCase()
88
+ )
89
+ ) {
90
+ return path.startsWith('/') ? path : `/${path}`;
91
+ }
84
92
  return getTasksAppUrl(path);
85
93
  }