@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
@@ -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
+ }
@@ -0,0 +1,147 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import type { GoogleProviderColorOptions } from '@tuturuuu/types/primitives/google-calendar-color';
4
+ import type { ComponentProps, ReactNode } from 'react';
5
+ import { beforeEach, expect, it, vi } from 'vitest';
6
+ import { ProviderColorPicker } from './provider-color-picker';
7
+
8
+ const mocks = vi.hoisted(() => ({ query: vi.fn(), get: vi.fn() }));
9
+ vi.mock('@tanstack/react-query', () => ({ useQuery: mocks.query }));
10
+ vi.mock('@tuturuuu/internal-api', () => ({
11
+ getGoogleCalendarColorOptions: mocks.get,
12
+ }));
13
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
14
+ vi.mock('@tuturuuu/ui/label', () => ({
15
+ Label: ({ children }: { children: ReactNode }) => <span>{children}</span>,
16
+ }));
17
+ vi.mock('./event-form-components', () => ({
18
+ EventColorPicker: () => <div>Legacy palette</div>,
19
+ }));
20
+ vi.mock('@tuturuuu/ui/select', () => ({
21
+ Select: ({
22
+ children,
23
+ value,
24
+ onValueChange,
25
+ }: {
26
+ children: ReactNode;
27
+ value?: string;
28
+ onValueChange: (value: string) => void;
29
+ }) => (
30
+ <select
31
+ aria-label="Provider palette"
32
+ value={value}
33
+ onChange={(event) => onValueChange(event.target.value)}
34
+ >
35
+ {children}
36
+ </select>
37
+ ),
38
+ SelectContent: ({ children }: { children: ReactNode }) => children,
39
+ SelectTrigger: () => null,
40
+ SelectValue: () => null,
41
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
42
+ <option value={value}>{children}</option>
43
+ ),
44
+ }));
45
+ const options: GoogleProviderColorOptions = {
46
+ provider: 'google',
47
+ providerColorWrites: true,
48
+ connectionId: 'owned',
49
+ calendarId: 'source',
50
+ sourceColor: { background: '#123456', foreground: '#ffffff' },
51
+ options: [
52
+ {
53
+ kind: 'inherit',
54
+ id: null,
55
+ name: null,
56
+ background: '#123456',
57
+ foreground: null,
58
+ },
59
+ {
60
+ kind: 'event',
61
+ id: '3',
62
+ name: null,
63
+ background: '#987654',
64
+ foreground: null,
65
+ },
66
+ {
67
+ kind: 'label',
68
+ id: 'label-id',
69
+ name: 'Synthetic label',
70
+ background: '#abcdef',
71
+ foreground: null,
72
+ },
73
+ ],
74
+ };
75
+ const props = (): ComponentProps<typeof ProviderColorPicker> => ({
76
+ wsId: 'workspace',
77
+ source: { id: 'source', provider: 'google', connectionId: 'owned' } as never,
78
+ value: 'BLUE',
79
+ onNativeChange: vi.fn(),
80
+ onProviderChange: vi.fn(),
81
+ });
82
+ beforeEach(() => {
83
+ vi.clearAllMocks();
84
+ mocks.query.mockReturnValue({ data: options });
85
+ });
86
+ it('keeps the legacy picker when capability is disabled', () => {
87
+ mocks.query.mockReturnValue({
88
+ data: { ...options, providerColorWrites: false },
89
+ });
90
+ render(<ProviderColorPicker {...props()} />);
91
+ expect(screen.getByText('Legacy palette')).toBeTruthy();
92
+ expect(screen.queryByRole('combobox')).toBeNull();
93
+ });
94
+ it('keeps another connection palette from supplying choices', () => {
95
+ mocks.query.mockReturnValue({
96
+ data: { ...options, connectionId: 'foreign' },
97
+ });
98
+ render(<ProviderColorPicker {...props()} />);
99
+ expect(screen.getByText('Legacy palette')).toBeTruthy();
100
+ });
101
+ it('sends exact provider namespace and ID for label and inherited choices', () => {
102
+ const input = props();
103
+ render(<ProviderColorPicker {...input} />);
104
+ fireEvent.change(screen.getByRole('combobox'), {
105
+ target: { value: 'label:label-id' },
106
+ });
107
+ expect(input.onProviderChange).toHaveBeenLastCalledWith({
108
+ connectionId: 'owned',
109
+ kind: 'label',
110
+ id: 'label-id',
111
+ });
112
+ fireEvent.change(screen.getByRole('combobox'), {
113
+ target: { value: 'inherit' },
114
+ });
115
+ expect(input.onProviderChange).toHaveBeenLastCalledWith({
116
+ connectionId: 'owned',
117
+ kind: 'inherit',
118
+ });
119
+ expect(input.onNativeChange).not.toHaveBeenCalled();
120
+ mocks.query.mock.calls[0]?.[0].queryFn();
121
+ expect(mocks.get).toHaveBeenCalledWith('workspace', 'owned');
122
+ });
123
+ it('initializes an imported label identity rather than the compatibility color', () => {
124
+ render(
125
+ <ProviderColorPicker
126
+ {...props()}
127
+ metadata={{
128
+ google_color: {
129
+ version: 1,
130
+ event_label_id: 'label-id',
131
+ inherited: false,
132
+ resolved_rgb: '#abcdef',
133
+ },
134
+ }}
135
+ />
136
+ );
137
+ expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
138
+ 'label:label-id'
139
+ );
140
+ });
141
+
142
+ it('keeps the legacy picker while query data is unavailable', () => {
143
+ mocks.query.mockReturnValue({ data: undefined });
144
+ render(<ProviderColorPicker {...props()} />);
145
+ expect(screen.getByText('Legacy palette')).toBeTruthy();
146
+ expect(screen.queryByRole('combobox')).toBeNull();
147
+ });
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import {
4
+ type CalendarSourceOption,
5
+ type GoogleProviderColorChoice,
6
+ getGoogleCalendarColorOptions,
7
+ } from '@tuturuuu/internal-api';
8
+ import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
9
+ import { Label } from '@tuturuuu/ui/label';
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@tuturuuu/ui/select';
17
+ import { readGoogleEventColor } from '@tuturuuu/utils/google-calendar-colors';
18
+ import { useTranslations } from 'next-intl';
19
+ import { EventColorPicker } from './event-form-components';
20
+
21
+ export function ProviderColorPicker({
22
+ wsId,
23
+ source,
24
+ value,
25
+ metadata,
26
+ choice,
27
+ onNativeChange,
28
+ onProviderChange,
29
+ }: {
30
+ wsId?: string;
31
+ source?: CalendarSourceOption;
32
+ value: SupportedColor;
33
+ metadata?: unknown;
34
+ choice?: GoogleProviderColorChoice;
35
+ onNativeChange: (value: SupportedColor) => void;
36
+ onProviderChange: (choice: GoogleProviderColorChoice) => void;
37
+ }) {
38
+ const t = useTranslations('calendar');
39
+ const connectionId =
40
+ source?.provider === 'google' ? source.connectionId : null;
41
+ const { data } = useQuery({
42
+ queryKey: ['google-calendar-color-options', wsId, connectionId],
43
+ enabled: !!wsId && !!connectionId,
44
+ queryFn: () => getGoogleCalendarColorOptions(wsId!, connectionId!),
45
+ });
46
+ if (
47
+ !connectionId ||
48
+ !data?.providerColorWrites ||
49
+ data.connectionId !== connectionId ||
50
+ !data.options.length
51
+ )
52
+ return <EventColorPicker value={value} onChange={onNativeChange} />;
53
+ const current = readGoogleEventColor(metadata);
54
+ const selected =
55
+ choice?.connectionId === connectionId
56
+ ? choice.kind === 'inherit'
57
+ ? 'inherit'
58
+ : `${choice.kind}:${choice.id}`
59
+ : current?.inherited
60
+ ? 'inherit'
61
+ : current?.event_label_id
62
+ ? `label:${current.event_label_id}`
63
+ : current?.color_id
64
+ ? `event:${current.color_id}`
65
+ : undefined;
66
+ return (
67
+ <div className="space-y-1">
68
+ <Label>{t('provider_color_label')}</Label>
69
+ <Select
70
+ value={selected}
71
+ onValueChange={(key) => {
72
+ const option = data.options.find(
73
+ (item) =>
74
+ (item.kind === 'inherit'
75
+ ? 'inherit'
76
+ : `${item.kind}:${item.id}`) === key
77
+ );
78
+ if (option)
79
+ onProviderChange(
80
+ option.kind === 'inherit'
81
+ ? { connectionId, kind: 'inherit' }
82
+ : { connectionId, kind: option.kind, id: option.id! }
83
+ );
84
+ }}
85
+ >
86
+ <SelectTrigger aria-label={t('provider_color_label')}>
87
+ <SelectValue placeholder={t('provider_color_label')} />
88
+ </SelectTrigger>
89
+ <SelectContent>
90
+ {data.options.map((option) => (
91
+ <SelectItem
92
+ key={`${option.kind}:${option.id}`}
93
+ value={
94
+ option.kind === 'inherit'
95
+ ? 'inherit'
96
+ : `${option.kind}:${option.id}`
97
+ }
98
+ >
99
+ <span className="flex items-center gap-2">
100
+ <span
101
+ className="size-3 rounded-full"
102
+ style={{ backgroundColor: option.background }}
103
+ />
104
+ {option.kind === 'inherit'
105
+ ? t('inherit_calendar_color')
106
+ : (option.name ??
107
+ t('google_event_color', { id: option.id ?? '' }))}
108
+ </span>
109
+ </SelectItem>
110
+ ))}
111
+ </SelectContent>
112
+ </Select>
113
+ </div>
114
+ );
115
+ }
@@ -1,6 +1,13 @@
1
1
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
2
  import { expect, it, vi } from 'vitest';
3
- import { saveCalendarEventDrafts } from './save-calendar-event-drafts';
3
+ import {
4
+ createCalendarCreationRequests,
5
+ newCalendarCreationRequestId,
6
+ } from '../../../../hooks/calendar-creation-request';
7
+ import {
8
+ prepareCalendarEventDrafts,
9
+ saveCalendarEventDrafts,
10
+ } from './save-calendar-event-drafts';
4
11
 
5
12
  it('retains rejected or unconfirmed drafts and retries only those events', async () => {
6
13
  const drafts = [
@@ -23,3 +30,30 @@ it('retains rejected or unconfirmed drafts and retries only those events', async
23
30
  'Unconfirmed',
24
31
  ]);
25
32
  });
33
+
34
+ it('retains distinct AI draft IDs and immutable payload across a lost-response batch retry', async () => {
35
+ const controller = createCalendarCreationRequests();
36
+ const manualId = newCalendarCreationRequestId();
37
+ controller.forAttempt({ title: 'Manual' }, manualId);
38
+ const drafts = prepareCalendarEventDrafts(
39
+ ['First AI', 'Second AI'].map((title) => ({ title }))
40
+ );
41
+ const attempts: { id: string; event: unknown }[] = [];
42
+ const addEvent = vi.fn(async (event, options) => {
43
+ attempts.push({
44
+ id: controller.forAttempt(event, options?.requestId),
45
+ event,
46
+ });
47
+ if (attempts.length <= 2) throw new Error('lost response');
48
+ return { id: 'confirmed' } as CalendarEvent;
49
+ });
50
+ const failed = await saveCalendarEventDrafts(drafts, addEvent, undefined);
51
+ expect(failed.failedEvents).toEqual(drafts);
52
+ await saveCalendarEventDrafts(failed.failedEvents, addEvent, undefined);
53
+ expect(new Set([manualId, attempts[0]!.id, attempts[1]!.id]).size).toBe(3);
54
+ expect(attempts[2]).toEqual(attempts[0]);
55
+ expect(attempts[3]).toEqual(attempts[1]);
56
+ expect(drafts.map((d) => d.requestId)).toEqual(
57
+ attempts.slice(0, 2).map((a) => a.id)
58
+ );
59
+ });
@@ -1,10 +1,12 @@
1
1
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { newCalendarCreationRequestId } from '../../../../hooks/calendar-creation-request';
2
3
 
3
4
  /** Keep only unconfirmed drafts after a batch so retry never resends saved events. */
4
5
  export async function saveCalendarEventDrafts(
5
6
  drafts: Partial<CalendarEvent>[],
6
7
  addEvent: (
7
- event: Omit<CalendarEvent, 'id'>
8
+ event: Omit<CalendarEvent, 'id'>,
9
+ options?: { requestId?: string }
8
10
  ) => Promise<CalendarEvent | undefined>,
9
11
  source: CalendarEvent['source']
10
12
  ) {
@@ -12,16 +14,19 @@ export async function saveCalendarEventDrafts(
12
14
  const failedEvents: Partial<CalendarEvent>[] = [];
13
15
  for (const draft of drafts) {
14
16
  try {
15
- const saved = await addEvent({
16
- title: draft.title || 'New Event',
17
- description: draft.description || '',
18
- start_at: draft.start_at || '',
19
- end_at: draft.end_at || '',
20
- color: draft.color || 'BLUE',
21
- location: draft.location || '',
22
- locked: draft.locked || false,
23
- source,
24
- });
17
+ const saved = await addEvent(
18
+ {
19
+ title: draft.title || 'New Event',
20
+ description: draft.description || '',
21
+ start_at: draft.start_at || '',
22
+ end_at: draft.end_at || '',
23
+ color: draft.color || 'BLUE',
24
+ location: draft.location || '',
25
+ locked: draft.locked || false,
26
+ source,
27
+ },
28
+ { requestId: draft.requestId }
29
+ );
25
30
  if (saved?.id) savedEvents.push(saved);
26
31
  else failedEvents.push(draft);
27
32
  } catch {
@@ -30,3 +35,10 @@ export async function saveCalendarEventDrafts(
30
35
  }
31
36
  return { savedEvents, failedEvents };
32
37
  }
38
+
39
+ export function prepareCalendarEventDrafts(drafts: Partial<CalendarEvent>[]) {
40
+ return drafts.map((draft) => ({
41
+ ...draft,
42
+ requestId: newCalendarCreationRequestId(),
43
+ }));
44
+ }