@tuturuuu/ui 0.37.1 → 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 (64) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  6. package/src/components/ui/custom/education-todo.tsx +191 -0
  7. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  8. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  9. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  10. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  11. package/src/components/ui/custom/structure.tsx +37 -5
  12. package/src/components/ui/custom/user-filters.tsx +2 -1
  13. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  14. package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
  15. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  16. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  17. package/src/components/ui/custom/workspace-select.tsx +95 -359
  18. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  19. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  20. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  21. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  22. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  23. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  24. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  25. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  26. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  27. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  28. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  29. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  30. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  31. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  32. package/src/components/ui/popover.tsx +3 -1
  33. package/src/components/ui/resizable.tsx +2 -1
  34. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  35. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  36. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  37. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  38. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  39. package/src/components/ui/text-editor/editor.tsx +8 -3
  40. package/src/components/ui/text-editor/extensions.ts +8 -2
  41. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  42. package/src/components/ui/text-editor/markdown.ts +11 -0
  43. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  44. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  45. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  46. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  47. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  48. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  49. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  52. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  53. package/src/hooks/calendar-creation-request.test.ts +62 -0
  54. package/src/hooks/calendar-creation-request.ts +34 -0
  55. package/src/hooks/calendar-event-write-payload.ts +24 -0
  56. package/src/hooks/notification-subscription.ts +5 -2
  57. package/src/hooks/supabase-provider.ts +38 -72
  58. package/src/hooks/use-calendar.tsx +21 -45
  59. package/src/hooks/use-exchange-rates.ts +4 -15
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-presence.ts +9 -9
  62. package/src/hooks/use-workspace-user.ts +15 -3
  63. package/src/hooks/use-workspace-visibility.ts +234 -0
  64. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -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
+ });
@@ -0,0 +1,79 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { newCalendarCreationRequestId } from '../../../../hooks/calendar-creation-request';
3
+
4
+ /** Existing edits send only changed fields so a color-only command neither
5
+ * broadcasts invitations nor accidentally becomes a calendar transfer. */
6
+ export function eventSavePayload(
7
+ draft: Partial<CalendarEvent>,
8
+ original?: Partial<CalendarEvent>,
9
+ sourceChanged = false
10
+ ) {
11
+ if (!original || original.id === 'new') return draft;
12
+ const updates: Partial<CalendarEvent> = {};
13
+ for (const field of [
14
+ 'title',
15
+ 'description',
16
+ 'location',
17
+ 'start_at',
18
+ 'end_at',
19
+ 'locked',
20
+ ] as const) {
21
+ if (
22
+ (draft[field] ?? (field === 'locked' ? false : '')) !==
23
+ (original[field] ?? (field === 'locked' ? false : ''))
24
+ )
25
+ Object.assign(updates, { [field]: draft[field] });
26
+ }
27
+ if (draft.providerColor) updates.providerColor = draft.providerColor;
28
+ else if (draft.color !== original.color) updates.color = draft.color;
29
+ if (sourceChanged) updates.source = draft.source;
30
+ return updates;
31
+ }
32
+
33
+ export function eventModalSavePayload(
34
+ draft: Partial<CalendarEvent>,
35
+ original: Partial<CalendarEvent> | undefined,
36
+ source: CalendarEvent['source'],
37
+ sourceChanged: boolean
38
+ ) {
39
+ return eventSavePayload(
40
+ {
41
+ title: draft.title || '',
42
+ description: draft.description || '',
43
+ start_at: draft.start_at,
44
+ end_at: draft.end_at,
45
+ color: draft.color || 'BLUE',
46
+ location: draft.location || '',
47
+ locked: draft.locked || false,
48
+ source,
49
+ providerColor: draft.providerColor,
50
+ },
51
+ original,
52
+ sourceChanged
53
+ );
54
+ }
55
+
56
+ export function eventModalDraft(
57
+ activeEvent: CalendarEvent
58
+ ): Partial<CalendarEvent> {
59
+ return {
60
+ id: activeEvent.id,
61
+ requestId:
62
+ activeEvent.id === 'new' ? newCalendarCreationRequestId() : undefined,
63
+ title: activeEvent.title || '',
64
+ description: activeEvent.description || '',
65
+ start_at: activeEvent.start_at,
66
+ end_at: activeEvent.end_at,
67
+ color: activeEvent.color || 'BLUE',
68
+ location: activeEvent.location || '',
69
+ locked: activeEvent.locked || false,
70
+ ws_id: activeEvent.ws_id,
71
+ provider: activeEvent.provider,
72
+ source_calendar_id: activeEvent.source_calendar_id,
73
+ external_calendar_id: activeEvent.external_calendar_id,
74
+ external_event_id: activeEvent.external_event_id,
75
+ google_event_id: activeEvent.google_event_id,
76
+ google_calendar_id: activeEvent.google_calendar_id,
77
+ scheduling_metadata: activeEvent.scheduling_metadata,
78
+ };
79
+ }
@@ -0,0 +1,95 @@
1
+ import type { CalendarSourceOption } from '@tuturuuu/internal-api';
2
+ import { expect, it } from 'vitest';
3
+ import { eventModalSavePayload } from './event-save-payload';
4
+ import {
5
+ eventSourceChanged,
6
+ findEventSourceOption,
7
+ selectedEventSource,
8
+ } from './event-source-selection';
9
+
10
+ const options = ['one', 'two'].map((id) => ({
11
+ id,
12
+ provider: 'google',
13
+ connectionId: id,
14
+ workspaceCalendarId: id,
15
+ externalCalendarId: 'shared-provider-calendar',
16
+ })) as CalendarSourceOption[];
17
+ it('matches the canonical workspace source despite identical provider calendar IDs', () => {
18
+ expect(
19
+ findEventSourceOption(options, {
20
+ provider: 'google',
21
+ source_calendar_id: 'two',
22
+ external_calendar_id: 'shared-provider-calendar',
23
+ })?.id
24
+ ).toBe('two');
25
+ expect(
26
+ findEventSourceOption(options, {
27
+ provider: 'google',
28
+ source_calendar_id: 'missing',
29
+ external_calendar_id: 'shared-provider-calendar',
30
+ })
31
+ ).toBeUndefined();
32
+ });
33
+ it('fails closed when a legacy external calendar ID is ambiguous across accounts', () => {
34
+ expect(
35
+ findEventSourceOption(options, {
36
+ provider: 'google',
37
+ external_calendar_id: 'shared-provider-calendar',
38
+ })
39
+ ).toBeUndefined();
40
+ expect(
41
+ findEventSourceOption(options.slice(0, 1), {
42
+ provider: 'google',
43
+ external_calendar_id: 'shared-provider-calendar',
44
+ })?.id
45
+ ).toBe('one');
46
+ });
47
+
48
+ it('leaves an unresolved existing event on its original source and preserves new defaults', () => {
49
+ const original = {
50
+ id: 'saved',
51
+ provider: 'google' as const,
52
+ source_calendar_id: 'missing',
53
+ external_calendar_id: 'shared-provider-calendar',
54
+ };
55
+ expect(
56
+ selectedEventSource(options, original, null, options[0])
57
+ ).toBeUndefined();
58
+ expect(eventSourceChanged(options, original, null)).toBe(false);
59
+ expect(
60
+ selectedEventSource(options, { id: 'new' }, null, options[0])?.id
61
+ ).toBe('one');
62
+ expect(selectedEventSource(options, original, 'two', options[0])?.id).toBe(
63
+ 'two'
64
+ );
65
+ expect(eventSourceChanged(options, original, 'two')).toBe(true);
66
+ });
67
+
68
+ it('sends only a title edit when an existing source cannot be resolved', () => {
69
+ const original = {
70
+ id: 'saved',
71
+ title: 'Original',
72
+ color: 'BLUE' as const,
73
+ provider: 'google' as const,
74
+ source_calendar_id: 'missing',
75
+ start_at: '2026-10-02T10:00:00Z',
76
+ end_at: '2026-10-02T11:00:00Z',
77
+ };
78
+ const selected = selectedEventSource(options, original, null, options[0]);
79
+ expect(
80
+ eventModalSavePayload(
81
+ { ...original, title: 'Changed' },
82
+ original,
83
+ selected?.provider === 'google'
84
+ ? { provider: 'google', connectionId: selected.connectionId }
85
+ : undefined,
86
+ eventSourceChanged(options, original, null)
87
+ )
88
+ ).toEqual({ title: 'Changed' });
89
+ });
90
+
91
+ it('recognizes a legacy Google event before considering native sources', () => {
92
+ const legacy = { google_event_id: 'external', source_calendar_id: 'two' };
93
+ expect(findEventSourceOption(options, legacy)?.id).toBe('two');
94
+ expect(eventSourceChanged(options, legacy, 'two')).toBe(false);
95
+ });
@@ -0,0 +1,78 @@
1
+ import type {
2
+ CalendarSourceInput,
3
+ CalendarSourceOption,
4
+ } from '@tuturuuu/internal-api';
5
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
6
+
7
+ export function sourceInputFromOption(
8
+ option?: CalendarSourceOption | null
9
+ ): CalendarSourceInput | undefined {
10
+ if (!option) return undefined;
11
+
12
+ if (option.provider === 'tuturuuu') {
13
+ return {
14
+ provider: 'tuturuuu',
15
+ workspaceCalendarId: option.workspaceCalendarId,
16
+ };
17
+ }
18
+
19
+ return {
20
+ provider: option.provider,
21
+ connectionId: option.connectionId,
22
+ };
23
+ }
24
+
25
+ export function findEventSourceOption(
26
+ options: CalendarSourceOption[],
27
+ event: Partial<CalendarEvent>
28
+ ) {
29
+ const provider =
30
+ event.provider ?? (event.google_event_id ? 'google' : undefined);
31
+ if (provider === 'google' || provider === 'microsoft') {
32
+ const matches = options.filter((option) => {
33
+ if (option.provider === 'tuturuuu' || option.provider !== provider)
34
+ return false;
35
+ if (event.source_calendar_id)
36
+ return option.workspaceCalendarId === event.source_calendar_id;
37
+ return (
38
+ option.externalCalendarId ===
39
+ (event.external_calendar_id ?? event.google_calendar_id)
40
+ );
41
+ });
42
+ // Legacy rows without a canonical source must not choose another account
43
+ // merely because it contains the same provider calendar ID.
44
+ return matches.length === 1 ? matches[0] : undefined;
45
+ }
46
+
47
+ return options.find(
48
+ (option) =>
49
+ option.provider === 'tuturuuu' &&
50
+ option.workspaceCalendarId === event.source_calendar_id
51
+ );
52
+ }
53
+
54
+ export function selectedEventSource(
55
+ options: CalendarSourceOption[],
56
+ event: Partial<CalendarEvent>,
57
+ selectedId: string | null,
58
+ defaultSource?: CalendarSourceOption
59
+ ) {
60
+ const selected = options.find((option) => option.id === selectedId);
61
+ const existing = !!event.id && event.id !== 'new';
62
+ return (
63
+ selected ??
64
+ findEventSourceOption(options, event) ??
65
+ (existing ? undefined : (defaultSource ?? options[0]))
66
+ );
67
+ }
68
+ export function eventSourceChanged(
69
+ options: CalendarSourceOption[],
70
+ event: Partial<CalendarEvent>,
71
+ selectedId: string | null
72
+ ) {
73
+ return (
74
+ selectedId !== null &&
75
+ options.some((option) => option.id === selectedId) &&
76
+ selectedId !== findEventSourceOption(options, event)?.id
77
+ );
78
+ }