@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
@@ -34,13 +34,7 @@ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
34
34
  import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
35
35
  import { Badge } from '@tuturuuu/ui/badge';
36
36
  import { Button } from '@tuturuuu/ui/button';
37
- import {
38
- Dialog,
39
- DialogContent,
40
- DialogDescription,
41
- DialogHeader,
42
- DialogTitle,
43
- } from '@tuturuuu/ui/dialog';
37
+ import { Dialog, DialogContent } from '@tuturuuu/ui/dialog';
44
38
  import {
45
39
  Form,
46
40
  FormControl,
@@ -52,7 +46,7 @@ import {
52
46
  } from '@tuturuuu/ui/form';
53
47
  import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
54
48
  import { useForm } from '@tuturuuu/ui/hooks/use-form';
55
- import { useToast } from '@tuturuuu/ui/hooks/use-toast';
49
+ import { notifySave } from '@tuturuuu/ui/save-notification';
56
50
  import { ScrollArea } from '@tuturuuu/ui/scroll-area';
57
51
  import {
58
52
  Select,
@@ -73,10 +67,18 @@ import { format } from 'date-fns';
73
67
  import dayjs from 'dayjs';
74
68
  import ts from 'dayjs/plugin/timezone';
75
69
  import utc from 'dayjs/plugin/utc';
76
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
70
+ import {
71
+ useCallback,
72
+ useEffect,
73
+ useEffectEvent,
74
+ useMemo,
75
+ useRef,
76
+ useState,
77
+ } from 'react';
77
78
  import { z } from 'zod';
78
79
  import { Alert, AlertDescription, AlertTitle } from '../../alert';
79
80
  import { AutosizeTextarea } from '../../custom/autosize-textarea';
81
+ import { getCalendarMeetingMetadata } from './calendar-meeting-link';
80
82
  import {
81
83
  COLOR_OPTIONS,
82
84
  DateError,
@@ -88,11 +90,10 @@ import {
88
90
  EventToggleSwitch,
89
91
  OverlapWarning,
90
92
  } from './event-form-components';
91
- import {
92
- CalendarEventProviderIcon,
93
- getCalendarEventProviderDisplay,
94
- } from './event-provider-display';
93
+ import { EventModalHeader } from './event-modal-header';
94
+ import { saveCalendarEventDrafts } from './save-calendar-event-drafts';
95
95
  import { useCalendarSettings } from './settings/settings-context';
96
+ import { useEventDraftSession } from './use-event-draft-session';
96
97
 
97
98
  dayjs.extend(ts);
98
99
  dayjs.extend(utc);
@@ -153,7 +154,7 @@ function findEventSourceOption(
153
154
  }
154
155
 
155
156
  export function EventModal() {
156
- const { toast } = useToast();
157
+ const toast = notifySave;
157
158
  const startPickerRef = useRef<HTMLButtonElement>(null);
158
159
  const endPickerRef = useRef<HTMLButtonElement>(null);
159
160
 
@@ -171,7 +172,6 @@ export function EventModal() {
171
172
 
172
173
  const tz = settings?.timezone?.timezone;
173
174
 
174
- // State for manual event creation/editing
175
175
  const [event, setEvent] = useState<Partial<CalendarEvent>>({
176
176
  title: '',
177
177
  description: '',
@@ -196,6 +196,7 @@ export function EventModal() {
196
196
  );
197
197
  const selectedSourceOption =
198
198
  sourceOptions.find((option) => option.id === selectedSourceId) ??
199
+ findEventSourceOption(sourceOptions, event) ??
199
200
  sourceData?.defaultSource ??
200
201
  sourceOptions[0];
201
202
 
@@ -207,13 +208,11 @@ export function EventModal() {
207
208
  Intl.DateTimeFormat().resolvedOptions().timeZone
208
209
  );
209
210
 
210
- // Get the current event being previewed
211
211
  const generatedEvent = generatedEvents?.[currentEventIndex];
212
212
 
213
213
  // Determine if we're editing an existing event
214
214
  const isEditing = !!(activeEvent?.id && activeEvent.id !== 'new');
215
215
 
216
- // Shared state
217
216
  const [activeTab, setActiveTab] = useState<'manual' | 'ai' | 'preview'>(
218
217
  isEditing ? 'manual' : defaultNewEventTab // Default tab for new events
219
218
  );
@@ -243,13 +242,11 @@ export function EventModal() {
243
242
  },
244
243
  });
245
244
 
246
- // AI generation
247
245
  const { object, submit, error, isLoading } = useObject({
248
246
  api: '/api/v1/calendar/events/generate',
249
247
  schema: calendarEventsSchema,
250
248
  });
251
249
 
252
- // Function to check for overlapping events
253
250
  const checkForOverlaps = useCallback(
254
251
  (eventToCheck: Partial<CalendarEvent>) => {
255
252
  if (!eventToCheck.start_at || !eventToCheck.end_at) return;
@@ -284,104 +281,95 @@ export function EventModal() {
284
281
  [activeEvent, getEvents]
285
282
  );
286
283
 
287
- // Handle AI-generated events
284
+ const receiveGeneratedEvents = useEffectEvent(() => {
285
+ const processedEvents = (object?.events || []) as Partial<CalendarEvent>[];
286
+ setGeneratedEvents(processedEvents);
287
+ setCurrentEventIndex(0);
288
+ const firstEvent = processedEvents[0];
289
+ if (
290
+ firstEvent?.start_at &&
291
+ firstEvent.end_at &&
292
+ aiForm.getValues().smart_scheduling
293
+ ) {
294
+ checkForOverlaps(firstEvent);
295
+ }
296
+ setActiveTab('preview');
297
+ });
288
298
  useEffect(() => {
289
- if (object?.events && !isLoading) {
290
- // Process the generated events
291
- const processedEvents = (object.events || []) as Partial<CalendarEvent>[];
299
+ if (object?.events && !isLoading) receiveGeneratedEvents();
300
+ }, [object, isLoading]);
301
+
302
+ // Initialize once per dialog session; live calendar refreshes must not erase edits.
303
+ useEventDraftSession({
304
+ isOpen: isModalOpen,
305
+ eventId: activeEvent?.id,
306
+ workspaceId: activeEvent?.ws_id,
307
+ initialize: () => {
308
+ if (activeEvent) {
309
+ const cleanEventData: Partial<CalendarEvent> = {
310
+ id: activeEvent.id,
311
+ title: activeEvent.title || '',
312
+ description: activeEvent.description || '',
313
+ start_at: activeEvent.start_at,
314
+ end_at: activeEvent.end_at,
315
+ color: activeEvent.color || 'BLUE',
316
+ location: activeEvent.location || '',
317
+ locked: activeEvent.locked || false,
318
+ ws_id: activeEvent.ws_id,
319
+ provider: activeEvent.provider,
320
+ source_calendar_id: activeEvent.source_calendar_id,
321
+ external_calendar_id: activeEvent.external_calendar_id,
322
+ external_event_id: activeEvent.external_event_id,
323
+ google_event_id: activeEvent.google_event_id,
324
+ google_calendar_id: activeEvent.google_calendar_id,
325
+ scheduling_metadata: getCalendarMeetingMetadata(activeEvent),
326
+ };
292
327
 
293
- setGeneratedEvents(processedEvents);
294
- setCurrentEventIndex(0);
328
+ setEvent(cleanEventData);
329
+ const sourceOption = findEventSourceOption(
330
+ sourceOptions,
331
+ cleanEventData
332
+ );
333
+ setSelectedSourceId(
334
+ sourceOption?.id ?? sourceData?.defaultSource?.id ?? null
335
+ );
295
336
 
296
- // Find overlapping events for the first event
297
- if (processedEvents.length > 0 && aiForm.getValues().smart_scheduling) {
298
- const firstEvent = processedEvents[0];
299
- if (firstEvent?.start_at && firstEvent.end_at) {
300
- checkForOverlaps(firstEvent as Partial<CalendarEvent>);
337
+ if (activeEvent.id !== 'new') {
338
+ setIsAllDay(isAllDayEvent(cleanEventData as CalendarEvent));
339
+ } else {
340
+ setIsAllDay(false);
301
341
  }
302
- }
303
-
304
- setActiveTab('preview');
305
- }
306
- }, [object, isLoading, aiForm, checkForOverlaps]);
307
-
308
- // Reset form when modal opens/closes or active event changes
309
- useEffect(() => {
310
- if (activeEvent) {
311
- // Clean the event data to only include valid CalendarEvent fields
312
- const cleanEventData: Partial<CalendarEvent> = {
313
- id: activeEvent.id,
314
- title: activeEvent.title || '',
315
- description: activeEvent.description || '',
316
- start_at: activeEvent.start_at,
317
- end_at: activeEvent.end_at,
318
- color: activeEvent.color || 'BLUE',
319
- location: activeEvent.location || '',
320
- locked: activeEvent.locked || false,
321
- ws_id: activeEvent.ws_id,
322
- provider: activeEvent.provider,
323
- source_calendar_id: activeEvent.source_calendar_id,
324
- external_calendar_id: activeEvent.external_calendar_id,
325
- external_event_id: activeEvent.external_event_id,
326
- google_event_id: activeEvent.google_event_id,
327
- google_calendar_id: activeEvent.google_calendar_id,
328
- };
329
342
 
330
- setEvent(cleanEventData);
331
- const sourceOption = findEventSourceOption(sourceOptions, cleanEventData);
332
- setSelectedSourceId(
333
- sourceOption?.id ?? sourceData?.defaultSource?.id ?? null
334
- );
343
+ // Check for overlapping events
344
+ checkForOverlaps(cleanEventData);
335
345
 
336
- // Only check for all-day if this is an existing event (not a new one)
337
- if (activeEvent.id !== 'new') {
338
- setIsAllDay(isAllDayEvent(cleanEventData as CalendarEvent));
346
+ setActiveTab(isEditing ? 'manual' : defaultNewEventTab);
339
347
  } else {
340
- // For new events, always start with isAllDay as false
341
- setIsAllDay(false);
342
- }
343
-
344
- // Check for overlapping events
345
- checkForOverlaps(cleanEventData);
346
-
347
- // Set active tab to manual when editing an existing event
348
- setActiveTab(isEditing ? 'manual' : defaultNewEventTab);
349
- } else {
350
- // Set default values for new event
351
- const now = new Date();
352
- const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
353
-
354
- const newEvent = {
355
- title: '',
356
- description: '',
357
- start_at: now.toISOString(),
358
- end_at: oneHourLater.toISOString(),
359
- color: 'BLUE' as SupportedColor,
360
- location: '',
361
- locked: false,
362
- ws_id: '',
363
- };
348
+ const now = new Date();
349
+ const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
350
+
351
+ const newEvent = {
352
+ title: '',
353
+ description: '',
354
+ start_at: now.toISOString(),
355
+ end_at: oneHourLater.toISOString(),
356
+ color: 'BLUE' as SupportedColor,
357
+ location: '',
358
+ locked: false,
359
+ ws_id: '',
360
+ };
364
361
 
365
- setEvent(newEvent);
366
- setSelectedSourceId(sourceData?.defaultSource?.id ?? null);
367
- setIsAllDay(false);
362
+ setEvent(newEvent);
363
+ setSelectedSourceId(sourceData?.defaultSource?.id ?? null);
364
+ setIsAllDay(false);
368
365
 
369
- // Reset AI form
370
- aiForm.reset();
371
- setGeneratedEvents([]);
372
- }
366
+ aiForm.reset();
367
+ setGeneratedEvents([]);
368
+ }
373
369
 
374
- // Clear any error messages
375
- setDateError(null);
376
- }, [
377
- activeEvent,
378
- checkForOverlaps,
379
- aiForm,
380
- defaultNewEventTab,
381
- isEditing,
382
- sourceData?.defaultSource?.id,
383
- sourceOptions,
384
- ]);
370
+ setDateError(null);
371
+ },
372
+ });
385
373
 
386
374
  // Handle manual event save
387
375
  const handleManualSave = async () => {
@@ -412,7 +400,8 @@ export function EventModal() {
412
400
  };
413
401
 
414
402
  if (activeEvent?.id === 'new') {
415
- await addEvent(eventData as Omit<CalendarEvent, 'id'>);
403
+ const saved = await addEvent(eventData as Omit<CalendarEvent, 'id'>);
404
+ if (!saved?.id) throw new Error();
416
405
  } else if (activeEvent?.id) {
417
406
  // For multi-day events, always use the original event ID
418
407
  // The activeEvent should already contain the original event from the database
@@ -499,29 +488,13 @@ export function EventModal() {
499
488
  setIsSaving(true);
500
489
  try {
501
490
  const eventsToSave = generatedEvents;
502
- const savedEvents: CalendarEvent[] = [];
503
- const failedEvents: Array<{ event: CalendarEvent; error: unknown }> = [];
504
-
505
- // Save each event individually
506
- for (const eventData of eventsToSave || []) {
507
- try {
508
- const calendarEvent: Omit<CalendarEvent, 'id'> = {
509
- title: eventData.title || 'New Event',
510
- description: eventData.description || '',
511
- start_at: eventData.start_at || '',
512
- end_at: eventData.end_at || '',
513
- color: eventData.color || 'BLUE',
514
- location: eventData.location || '',
515
- locked: eventData.locked || false,
516
- source: sourceInputFromOption(selectedSourceOption),
517
- };
518
-
519
- const savedEvent = await addEvent(calendarEvent);
520
- if (savedEvent) savedEvents.push(savedEvent);
521
- } catch (error) {
522
- failedEvents.push({ event: eventData as CalendarEvent, error });
523
- }
524
- }
491
+ const { savedEvents, failedEvents } = await saveCalendarEventDrafts(
492
+ eventsToSave ?? [],
493
+ addEvent,
494
+ sourceInputFromOption(selectedSourceOption)
495
+ );
496
+ setGeneratedEvents(failedEvents);
497
+ setCurrentEventIndex(0);
525
498
 
526
499
  // Show success notification
527
500
  if (savedEvents.length > 0) {
@@ -529,7 +502,7 @@ export function EventModal() {
529
502
  title: 'Success',
530
503
  description: `${savedEvents.length}/${eventsToSave?.length || 0} event${savedEvents.length > 1 ? 's' : ''} saved`,
531
504
  });
532
- closeModal();
505
+ if (failedEvents.length === 0) closeModal();
533
506
  }
534
507
 
535
508
  // If there are failed events, show an error notification
@@ -958,32 +931,10 @@ export function EventModal() {
958
931
  }
959
932
  };
960
933
 
961
- const providerDisplay = getCalendarEventProviderDisplay(event);
962
-
963
934
  return (
964
935
  <Dialog open={isModalOpen} onOpenChange={(open) => !open && closeModal()}>
965
936
  <DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden p-0">
966
- <DialogHeader className="border-b px-6 pt-6 pb-4">
967
- <DialogTitle className="flex items-center gap-2 font-semibold text-xl">
968
- <span>{isEditing ? 'Edit Event' : 'Create Event'}</span>
969
- {providerDisplay && (
970
- <div className="ml-3 flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-1 text-sm">
971
- <CalendarEventProviderIcon
972
- event={event}
973
- className="h-4.5 w-4.5"
974
- />
975
- <span className="font-medium text-xs">
976
- {providerDisplay.label}
977
- </span>
978
- </div>
979
- )}
980
- </DialogTitle>
981
- <DialogDescription>
982
- {isEditing
983
- ? 'Make changes to your existing event'
984
- : 'Add a new event to your calendar'}
985
- </DialogDescription>
986
- </DialogHeader>
937
+ <EventModalHeader event={event} isEditing={isEditing} />
987
938
 
988
939
  <Tabs
989
940
  value={activeTab}
@@ -1040,7 +991,7 @@ export function EventModal() {
1040
991
  )}
1041
992
  </div>
1042
993
  <Select
1043
- value={selectedSourceId ?? undefined}
994
+ value={selectedSourceOption?.id}
1044
995
  onValueChange={(value) => setSelectedSourceId(value)}
1045
996
  disabled={sourceOptions.length === 0 || isSaving}
1046
997
  >