@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
@@ -3,6 +3,7 @@
3
3
  import { ArrowLeft, Eye, EyeOff, Plus, Search, Star, X } from '@tuturuuu/icons';
4
4
  import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
5
5
  import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
6
+ import { cn } from '@tuturuuu/utils/format';
6
7
  import { useTranslations } from 'next-intl';
7
8
  import { type ReactNode, useRef, useState } from 'react';
8
9
  import type { useWorkspaceVisibility } from '../../../hooks/use-workspace-visibility';
@@ -14,6 +15,7 @@ import {
14
15
  DialogTitle,
15
16
  } from '../dialog';
16
17
  import { Input } from '../input';
18
+ import { PopoverClose, PopoverContent } from '../popover';
17
19
  import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
18
20
  import { WorkspaceIcon } from './workspace-select-icon';
19
21
 
@@ -32,6 +34,8 @@ export function WorkspacePickerContent({
32
34
  listLoading = false,
33
35
  listError,
34
36
  onRetryList,
37
+ presentation = 'fullscreen',
38
+ onClose,
35
39
  }: {
36
40
  workspaces: InternalApiWorkspaceSummary[];
37
41
  currentId?: string;
@@ -46,10 +50,15 @@ export function WorkspacePickerContent({
46
50
  listLoading?: boolean;
47
51
  listError?: Error | null;
48
52
  onRetryList?: () => void;
53
+ presentation?: 'fullscreen' | 'dropdown';
54
+ onClose?: () => void;
49
55
  }) {
56
+ const dropdown = presentation === 'dropdown';
57
+ const Title = dropdown ? 'h2' : DialogTitle;
58
+ const Description = dropdown ? 'p' : DialogDescription;
50
59
  const t = useTranslations('common');
51
60
  const [restoring, setRestoring] = useState(restoreOnly);
52
- const [searchVisible, setSearchVisible] = useState(false);
61
+ const [searchVisible, setSearchVisible] = useState(dropdown);
53
62
  const [search, setSearch] = useState('');
54
63
  const input = useRef<HTMLInputElement>(null);
55
64
  const query = search.trim().toLocaleLowerCase();
@@ -75,13 +84,14 @@ export function WorkspacePickerContent({
75
84
  setSearchVisible(true);
76
85
  requestAnimationFrame(() => input.current?.focus());
77
86
  }
78
- return (
79
- <DialogContent
80
- presentation="fullscreen"
81
- showCloseButton={false}
82
- className="flex-col"
83
- >
84
- <div className="flex items-center gap-3 border-b p-4 pt-[max(1rem,env(safe-area-inset-top))]">
87
+ const content = (
88
+ <>
89
+ <div
90
+ className={cn(
91
+ 'flex items-center gap-3 border-b p-4',
92
+ !dropdown && 'pt-[max(1rem,env(safe-area-inset-top))]'
93
+ )}
94
+ >
85
95
  {restoring && !restoreOnly && (
86
96
  <Button
87
97
  size="icon"
@@ -96,18 +106,31 @@ export function WorkspacePickerContent({
96
106
  </Button>
97
107
  )}
98
108
  <WorkspaceIcon fallbackLogoUrl={TUTURUUU_LOGO_URL} name="Tuturuuu" />
99
- <DialogTitle className="min-w-0 flex-1">
109
+ <Title className="min-w-0 flex-1 font-semibold text-lg leading-none">
100
110
  {restoring ? t('hidden_workspaces') : t('workspaces')}
101
- </DialogTitle>
102
- <DialogClose asChild>
103
- <Button size="icon" variant="ghost" aria-label={t('close')}>
104
- <X />
105
- </Button>
106
- </DialogClose>
111
+ </Title>
112
+ {dropdown ? (
113
+ <PopoverClose asChild>
114
+ <Button
115
+ size="icon"
116
+ variant="ghost"
117
+ aria-label={t('close')}
118
+ onClick={onClose}
119
+ >
120
+ <X />
121
+ </Button>
122
+ </PopoverClose>
123
+ ) : (
124
+ <DialogClose asChild>
125
+ <Button size="icon" variant="ghost" aria-label={t('close')}>
126
+ <X />
127
+ </Button>
128
+ </DialogClose>
129
+ )}
107
130
  </div>
108
- <DialogDescription className={restoring ? 'px-4 pt-3' : 'sr-only'}>
131
+ <Description className={restoring ? 'px-4 pt-3' : 'sr-only'}>
109
132
  {restoring ? t('hidden_workspaces_description') : t('select_workspace')}
110
- </DialogDescription>
133
+ </Description>
111
134
  {searchVisible && (
112
135
  <div className="flex gap-2 px-4 pt-3">
113
136
  <Input
@@ -148,7 +171,12 @@ export function WorkspacePickerContent({
148
171
  </Button>
149
172
  </div>
150
173
  )}
151
- <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4 pb-28">
174
+ <div
175
+ className={cn(
176
+ 'min-h-0 flex-1 overflow-y-auto px-4 pt-4',
177
+ dropdown ? 'pb-3' : 'pb-28'
178
+ )}
179
+ >
152
180
  {!visibility.known || listLoading || listError ? (
153
181
  <p role="status">
154
182
  {visibility.isError || listError
@@ -161,7 +189,10 @@ export function WorkspacePickerContent({
161
189
  {results.map((workspace) => (
162
190
  <div
163
191
  key={workspace.id}
164
- className="mb-2 flex items-center gap-2 rounded-xl border p-2"
192
+ className={cn(
193
+ 'flex items-center gap-2',
194
+ dropdown ? 'mb-1 rounded-md' : 'mb-2 rounded-xl border p-2'
195
+ )}
165
196
  >
166
197
  {restoring ? (
167
198
  <div className="flex min-w-0 flex-1 items-center gap-3 p-2">
@@ -174,7 +205,10 @@ export function WorkspacePickerContent({
174
205
  ) : (
175
206
  <Button
176
207
  variant="ghost"
177
- className="h-auto min-w-0 flex-1 justify-start gap-3 whitespace-normal py-3 text-start"
208
+ className={cn(
209
+ 'h-auto min-w-0 flex-1 justify-start gap-3 whitespace-normal text-start',
210
+ dropdown ? 'py-2' : 'py-3'
211
+ )}
178
212
  aria-label={[
179
213
  workspace.name ?? workspace.id,
180
214
  workspace.tier ?? 'FREE',
@@ -268,26 +302,55 @@ export function WorkspacePickerContent({
268
302
  </Button>
269
303
  )}
270
304
  </div>
271
- <div className="absolute right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] flex gap-3 rounded-full border bg-background p-2 shadow-lg">
272
- <Button
273
- size="icon"
274
- className="rounded-full"
275
- aria-label={t('search_workspace')}
276
- onClick={openSearch}
277
- >
278
- <Search />
279
- </Button>
280
- {!restoring && onCreate && (
305
+ <div
306
+ className={
307
+ dropdown
308
+ ? restoring || !onCreate
309
+ ? 'hidden'
310
+ : 'flex gap-2 border-t p-2'
311
+ : 'absolute right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] flex gap-3 rounded-full border bg-background p-2 shadow-lg'
312
+ }
313
+ >
314
+ {!dropdown && (
281
315
  <Button
282
316
  size="icon"
283
317
  className="rounded-full"
318
+ aria-label={t('search_workspace')}
319
+ onClick={openSearch}
320
+ >
321
+ <Search />
322
+ </Button>
323
+ )}
324
+ {!restoring && onCreate && (
325
+ <Button
326
+ size={dropdown ? 'sm' : 'icon'}
327
+ className={dropdown ? 'w-full justify-start' : 'rounded-full'}
284
328
  aria-label={t('create_workspace_action')}
285
329
  onClick={onCreate}
286
330
  >
287
331
  <Plus />
332
+ {dropdown && t('create_workspace_action')}
288
333
  </Button>
289
334
  )}
290
335
  </div>
336
+ </>
337
+ );
338
+ return dropdown ? (
339
+ <PopoverContent
340
+ align="start"
341
+ aria-label={t(restoring ? 'hidden_workspaces' : 'workspaces')}
342
+ collisionPadding={8}
343
+ className="flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-80 max-w-[min(calc(100vw-1rem),var(--radix-popover-content-available-width))] flex-col overflow-hidden p-0"
344
+ >
345
+ {content}
346
+ </PopoverContent>
347
+ ) : (
348
+ <DialogContent
349
+ presentation="fullscreen"
350
+ showCloseButton={false}
351
+ className="flex-col"
352
+ >
353
+ {content}
291
354
  </DialogContent>
292
355
  );
293
356
  }
@@ -54,6 +54,7 @@ import {
54
54
  FormMessage,
55
55
  } from '../form';
56
56
  import { Input } from '../input';
57
+ import { Popover, PopoverTrigger } from '../popover';
57
58
  import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
58
59
  import { WorkspacePickerContent } from './workspace-picker-content';
59
60
  import {
@@ -93,6 +94,7 @@ export function WorkspaceSelect({
93
94
  triggerClassName,
94
95
  platformWorkspaceSetupUrl,
95
96
  cacheScope,
97
+ presentation = 'dropdown',
96
98
  }: {
97
99
  wsId: string;
98
100
  hideLeading?: boolean;
@@ -113,7 +115,11 @@ export function WorkspaceSelect({
113
115
  platformWorkspaceSetupUrl?: string;
114
116
  /** Authenticated identity used to isolate user-specific picker caches. */
115
117
  cacheScope?: string;
118
+ presentation?: 'fullscreen' | 'dropdown';
116
119
  }) {
120
+ const Picker = presentation === 'dropdown' ? Popover : Dialog;
121
+ const PickerTrigger =
122
+ presentation === 'dropdown' ? PopoverTrigger : DialogTrigger;
117
123
  const t = useTranslations();
118
124
  const locale = useLocale();
119
125
  const router = useRouter();
@@ -417,8 +423,8 @@ export function WorkspaceSelect({
417
423
  setShowNewWorkspaceDialog(open);
418
424
  }}
419
425
  >
420
- <Dialog open={open} onOpenChange={setOpen}>
421
- <DialogTrigger asChild disabled={!hasSelectableWorkspaces}>
426
+ <Picker open={open} onOpenChange={setOpen}>
427
+ <PickerTrigger asChild disabled={!hasSelectableWorkspaces}>
422
428
  <Button
423
429
  size="xs"
424
430
  variant="outline"
@@ -485,8 +491,10 @@ export function WorkspaceSelect({
485
491
  <ChevronDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
486
492
  )}
487
493
  </Button>
488
- </DialogTrigger>
494
+ </PickerTrigger>
489
495
  <WorkspacePickerContent
496
+ presentation={presentation}
497
+ onClose={() => setOpen(false)}
490
498
  workspaces={workspaces ?? []}
491
499
  currentId={workspace?.id}
492
500
  defaultId={defaultWorkspaceId}
@@ -517,7 +525,7 @@ export function WorkspaceSelect({
517
525
  </Command>
518
526
  }
519
527
  />
520
- </Dialog>
528
+ </Picker>
521
529
  <DialogContent>
522
530
  <DialogHeader>
523
531
  <DialogTitle>{t('common.create_workspace')}</DialogTitle>
@@ -0,0 +1,34 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+
3
+ export function createCalendarDraftRecovery() {
4
+ let submitted:
5
+ | {
6
+ payload: Partial<CalendarEvent>;
7
+ requestId: string;
8
+ recoverable: boolean;
9
+ }
10
+ | undefined;
11
+ let recovered: CalendarEvent | undefined;
12
+ return {
13
+ original: () => recovered,
14
+ submitted: () => submitted,
15
+ capture(
16
+ payload: Partial<CalendarEvent>,
17
+ requestId: string,
18
+ recoverable: boolean
19
+ ) {
20
+ // Snapshot once. An uncertain request never changes its ID or contents.
21
+ submitted ??= {
22
+ payload: structuredClone(payload),
23
+ requestId,
24
+ recoverable,
25
+ };
26
+ return submitted;
27
+ },
28
+ complete(event: CalendarEvent) {
29
+ if (!event.id || event.id === 'new')
30
+ throw new Error('Unconfirmed creation');
31
+ recovered = event;
32
+ },
33
+ };
34
+ }
@@ -25,11 +25,7 @@ import {
25
25
  Unlock,
26
26
  X,
27
27
  } from '@tuturuuu/icons';
28
- import {
29
- type CalendarSourceInput,
30
- type CalendarSourceOption,
31
- getWorkspaceCalendarDefaultSource,
32
- } from '@tuturuuu/internal-api';
28
+ import { getWorkspaceCalendarDefaultSource } from '@tuturuuu/internal-api';
33
29
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
34
30
  import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
35
31
  import { Badge } from '@tuturuuu/ui/badge';
@@ -44,6 +40,7 @@ import {
44
40
  FormLabel,
45
41
  FormMessage,
46
42
  } from '@tuturuuu/ui/form';
43
+ import { newCalendarCreationRequestId } from '@tuturuuu/ui/hooks/calendar-creation-request';
47
44
  import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
48
45
  import { useForm } from '@tuturuuu/ui/hooks/use-form';
49
46
  import { notifySave } from '@tuturuuu/ui/save-notification';
@@ -76,13 +73,12 @@ import {
76
73
  useState,
77
74
  } from 'react';
78
75
  import { z } from 'zod';
76
+ import { useCalendarDraftSave } from './use-calendar-draft-save';
79
77
  import { Alert, AlertDescription, AlertTitle } from '../../alert';
80
78
  import { AutosizeTextarea } from '../../custom/autosize-textarea';
81
- import { getCalendarMeetingMetadata } from './calendar-meeting-link';
82
79
  import {
83
80
  COLOR_OPTIONS,
84
81
  DateError,
85
- EventColorPicker,
86
82
  EventDateTimePicker,
87
83
  EventDescriptionInput,
88
84
  EventLocationInput,
@@ -92,7 +88,17 @@ import {
92
88
  } from './event-form-components';
93
89
  import { EventModalHeader } from './event-modal-header';
94
90
  import { eventEndPickerBounds } from './event-picker-bounds';
95
- import { saveCalendarEventDrafts } from './save-calendar-event-drafts';
91
+ import { eventModalDraft, eventModalSavePayload } from './event-save-payload';
92
+ import {
93
+ eventSourceChanged,
94
+ selectedEventSource,
95
+ sourceInputFromOption,
96
+ } from './event-source-selection';
97
+ import { ProviderColorPicker } from './provider-color-picker';
98
+ import {
99
+ prepareCalendarEventDrafts,
100
+ saveCalendarEventDrafts,
101
+ } from './save-calendar-event-drafts';
96
102
  import { useCalendarSettings } from './settings/settings-context';
97
103
  import { useEventDraftSession } from './use-event-draft-session';
98
104
 
@@ -107,53 +113,6 @@ const AIFormSchema = z.object({
107
113
  smart_scheduling: z.boolean().default(true),
108
114
  });
109
115
 
110
- function sourceInputFromOption(
111
- option?: CalendarSourceOption | null
112
- ): CalendarSourceInput | undefined {
113
- if (!option) return undefined;
114
-
115
- if (option.provider === 'tuturuuu') {
116
- return {
117
- provider: 'tuturuuu',
118
- workspaceCalendarId: option.workspaceCalendarId,
119
- };
120
- }
121
-
122
- return {
123
- provider: option.provider,
124
- connectionId: option.connectionId,
125
- };
126
- }
127
-
128
- function findEventSourceOption(
129
- options: CalendarSourceOption[],
130
- event: Partial<CalendarEvent>
131
- ) {
132
- if (event.provider === 'google' || event.provider === 'microsoft') {
133
- return options.find((option) => {
134
- if (option.provider === 'tuturuuu') return false;
135
- if (option.provider !== event.provider) return false;
136
- if (
137
- event.source_calendar_id &&
138
- option.workspaceCalendarId === event.source_calendar_id
139
- ) {
140
- return true;
141
- }
142
-
143
- return (
144
- option.externalCalendarId ===
145
- (event.external_calendar_id ?? event.google_calendar_id)
146
- );
147
- });
148
- }
149
-
150
- return options.find(
151
- (option) =>
152
- option.provider === 'tuturuuu' &&
153
- option.workspaceCalendarId === event.source_calendar_id
154
- );
155
- }
156
-
157
116
  export function EventModal() {
158
117
  const toast = notifySave;
159
118
  const startPickerRef = useRef<HTMLButtonElement>(null);
@@ -195,11 +154,12 @@ export function EventModal() {
195
154
  () => sourceData?.options ?? [],
196
155
  [sourceData?.options]
197
156
  );
198
- const selectedSourceOption =
199
- sourceOptions.find((option) => option.id === selectedSourceId) ??
200
- findEventSourceOption(sourceOptions, event) ??
201
- sourceData?.defaultSource ??
202
- sourceOptions[0];
157
+ const selectedSourceOption = selectedEventSource(
158
+ sourceOptions,
159
+ event,
160
+ selectedSourceId,
161
+ sourceData?.defaultSource
162
+ );
203
163
 
204
164
  // State for AI event generation
205
165
  const [generatedEvents, setGeneratedEvents] =
@@ -284,7 +244,7 @@ export function EventModal() {
284
244
 
285
245
  const receiveGeneratedEvents = useEffectEvent(() => {
286
246
  const processedEvents = (object?.events || []) as Partial<CalendarEvent>[];
287
- setGeneratedEvents(processedEvents);
247
+ setGeneratedEvents(prepareCalendarEventDrafts(processedEvents));
288
248
  setCurrentEventIndex(0);
289
249
  const firstEvent = processedEvents[0];
290
250
  if (
@@ -307,32 +267,16 @@ export function EventModal() {
307
267
  workspaceId: activeEvent?.ws_id,
308
268
  initialize: () => {
309
269
  if (activeEvent) {
310
- const cleanEventData: Partial<CalendarEvent> = {
311
- id: activeEvent.id,
312
- title: activeEvent.title || '',
313
- description: activeEvent.description || '',
314
- start_at: activeEvent.start_at,
315
- end_at: activeEvent.end_at,
316
- color: activeEvent.color || 'BLUE',
317
- location: activeEvent.location || '',
318
- locked: activeEvent.locked || false,
319
- ws_id: activeEvent.ws_id,
320
- provider: activeEvent.provider,
321
- source_calendar_id: activeEvent.source_calendar_id,
322
- external_calendar_id: activeEvent.external_calendar_id,
323
- external_event_id: activeEvent.external_event_id,
324
- google_event_id: activeEvent.google_event_id,
325
- google_calendar_id: activeEvent.google_calendar_id,
326
- scheduling_metadata: getCalendarMeetingMetadata(activeEvent),
327
- };
270
+ const cleanEventData = eventModalDraft(activeEvent);
328
271
 
329
272
  setEvent(cleanEventData);
330
- const sourceOption = findEventSourceOption(
331
- sourceOptions,
332
- cleanEventData
333
- );
334
273
  setSelectedSourceId(
335
- sourceOption?.id ?? sourceData?.defaultSource?.id ?? null
274
+ selectedEventSource(
275
+ sourceOptions,
276
+ cleanEventData,
277
+ null,
278
+ sourceData?.defaultSource
279
+ )?.id ?? null
336
280
  );
337
281
 
338
282
  if (activeEvent.id !== 'new') {
@@ -350,6 +294,7 @@ export function EventModal() {
350
294
  const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
351
295
 
352
296
  const newEvent = {
297
+ requestId: newCalendarCreationRequestId(),
353
298
  title: '',
354
299
  description: '',
355
300
  start_at: now.toISOString(),
@@ -372,61 +317,38 @@ export function EventModal() {
372
317
  },
373
318
  });
374
319
 
375
- // Handle manual event save
320
+ const draftSave = useCalendarDraftSave({
321
+ draft: event,
322
+ original: activeEvent,
323
+ buildPayload: () =>
324
+ eventModalSavePayload(
325
+ event,
326
+ activeEvent,
327
+ sourceInputFromOption(selectedSourceOption),
328
+ eventSourceChanged(sourceOptions, activeEvent ?? {}, selectedSourceId)
329
+ ),
330
+ addEvent,
331
+ updateEvent,
332
+ closeModal,
333
+ setIsSaving,
334
+ isSaving,
335
+ source: selectedSourceOption,
336
+ wsId,
337
+ onError: () =>
338
+ toast({
339
+ title: 'Error',
340
+ description: 'Failed to save or sync event. Please try again.',
341
+ variant: 'destructive',
342
+ }),
343
+ });
376
344
  const handleManualSave = async () => {
377
345
  if (!event.start_at || !event.end_at) return;
378
-
379
- const startDate = new Date(event.start_at);
380
- const endDate = new Date(event.end_at);
381
-
382
- if (endDate <= startDate) {
346
+ if (new Date(event.end_at) <= new Date(event.start_at)) {
383
347
  setDateError('End date must be after start date');
384
348
  return;
385
349
  }
386
-
387
350
  setDateError(null);
388
- setIsSaving(true);
389
-
390
- try {
391
- // Clean event data to only include fields that should be updated
392
- const eventData: Partial<CalendarEvent> = {
393
- title: event.title || '',
394
- description: event.description || '',
395
- start_at: event.start_at,
396
- end_at: event.end_at,
397
- color: event.color || 'BLUE',
398
- location: event.location || '',
399
- locked: event.locked || false,
400
- source: sourceInputFromOption(selectedSourceOption),
401
- };
402
-
403
- if (activeEvent?.id === 'new') {
404
- const saved = await addEvent(eventData as Omit<CalendarEvent, 'id'>);
405
- if (!saved?.id) throw new Error();
406
- } else if (activeEvent?.id) {
407
- // For multi-day events, always use the original event ID
408
- // The activeEvent should already contain the original event from the database
409
- const eventId = activeEvent.id;
410
-
411
- if (eventId && eventId !== 'new') {
412
- await updateEvent(eventId, eventData);
413
- } else {
414
- throw new Error('Invalid event ID');
415
- }
416
- } else {
417
- throw new Error('No event to save');
418
- }
419
-
420
- closeModal();
421
- } catch (_) {
422
- toast({
423
- title: 'Error',
424
- description: 'Failed to save or sync event. Please try again.',
425
- variant: 'destructive',
426
- });
427
- } finally {
428
- setIsSaving(false);
429
- }
351
+ await draftSave.save();
430
352
  };
431
353
 
432
354
  // Handle AI event generation
@@ -993,7 +915,13 @@ export function EventModal() {
993
915
  </div>
994
916
  <Select
995
917
  value={selectedSourceOption?.id}
996
- onValueChange={(value) => setSelectedSourceId(value)}
918
+ onValueChange={(value) => {
919
+ setSelectedSourceId(value);
920
+ setEvent((previous) => ({
921
+ ...previous,
922
+ providerColor: undefined,
923
+ }));
924
+ }}
997
925
  disabled={sourceOptions.length === 0 || isSaving}
998
926
  >
999
927
  <SelectTrigger id="event-source" className="w-full">
@@ -1087,10 +1015,21 @@ export function EventModal() {
1087
1015
 
1088
1016
  {/* Color and Options Row */}
1089
1017
  <div className="flex items-end justify-between gap-4">
1090
- <EventColorPicker
1018
+ <ProviderColorPicker
1019
+ wsId={wsId}
1020
+ source={selectedSourceOption}
1091
1021
  value={event.color || 'BLUE'}
1092
- onChange={(value) =>
1093
- setEvent({ ...event, color: value })
1022
+ metadata={event.scheduling_metadata}
1023
+ choice={event.providerColor}
1024
+ onNativeChange={(value) =>
1025
+ setEvent({
1026
+ ...event,
1027
+ color: value,
1028
+ providerColor: undefined,
1029
+ })
1030
+ }
1031
+ onProviderChange={(providerColor) =>
1032
+ setEvent({ ...event, providerColor })
1094
1033
  }
1095
1034
  />
1096
1035
  <button
@@ -1147,6 +1086,7 @@ export function EventModal() {
1147
1086
 
1148
1087
  {/* Action Buttons */}
1149
1088
  <div className="mt-auto border-t p-2">
1089
+ {draftSave.recoveryNotice}
1150
1090
  <div className="flex justify-between">
1151
1091
  {isEditing ? (
1152
1092
  <Button
@@ -0,0 +1,40 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { expect, it } from 'vitest';
3
+ import { calendarEventUpdatePayload } from '../../../../hooks/calendar-event-write-payload';
4
+ import { eventSavePayload } from './event-save-payload';
5
+
6
+ const original = {
7
+ id: 'saved',
8
+ title: 'Synthetic',
9
+ description: '',
10
+ location: '',
11
+ start_at: '2026-10-02T10:00:00Z',
12
+ end_at: '2026-10-02T11:00:00Z',
13
+ color: 'BLUE',
14
+ locked: false,
15
+ } as CalendarEvent;
16
+ it('sends only a provider color command for an unchanged existing event', () => {
17
+ const providerColor = { connectionId: 'owned', kind: 'inherit' as const };
18
+ const payload = eventSavePayload(
19
+ {
20
+ ...original,
21
+ providerColor,
22
+ source: { provider: 'google', connectionId: 'owned' },
23
+ },
24
+ original,
25
+ false
26
+ );
27
+ expect(payload).toEqual({ providerColor });
28
+ expect(calendarEventUpdatePayload(payload)).toEqual({ providerColor });
29
+ });
30
+ it('does not include unchanged native color or source with an ordinary title edit', () => {
31
+ expect(eventSavePayload({ ...original, title: 'Changed' }, original)).toEqual(
32
+ { title: 'Changed' }
33
+ );
34
+ });
35
+ it('includes a calendar transfer only when its source actually changed', () => {
36
+ const source = { provider: 'google' as const, connectionId: 'new' };
37
+ expect(eventSavePayload({ ...original, source }, original, true)).toEqual({
38
+ source,
39
+ });
40
+ });