@tuturuuu/ui 0.37.2 → 0.39.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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  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/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import type { ReactNode } from 'react';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ vi.mock('@tuturuuu/ui/dialog', () => ({
7
+ Dialog: ({ children }: { children: ReactNode }) => (
8
+ <div role="dialog">{children}</div>
9
+ ),
10
+ DialogContent: ({ children }: { children: ReactNode }) => (
11
+ <div>{children}</div>
12
+ ),
13
+ }));
14
+ vi.mock('next-intl', () => ({
15
+ useFormatter: () => ({ dateTime: (date: Date) => date.toISOString() }),
16
+ }));
17
+ vi.mock('./event-modal-header', () => ({
18
+ EventModalHeader: ({ event }: { event: CalendarEvent }) => (
19
+ <h1>{event.title}</h1>
20
+ ),
21
+ }));
22
+
23
+ import { ProviderReadonlyDialog } from './provider-readonly-dialog';
24
+
25
+ describe('unsupported provider recurrence readonly details', () => {
26
+ it('shows details without editable form, destructive actions or raw HTML injection', () => {
27
+ render(
28
+ <ProviderReadonlyDialog
29
+ open
30
+ onOpenChange={vi.fn()}
31
+ event={
32
+ {
33
+ id: 'instance',
34
+ title: 'Provider series',
35
+ start_at: '2026-10-06T09:00:00Z',
36
+ end_at: '2026-10-06T10:00:00Z',
37
+ location: 'Room',
38
+ description: '<script>private fixture</script>',
39
+ } as CalendarEvent
40
+ }
41
+ />
42
+ );
43
+ expect(
44
+ screen.getByRole('heading', { name: 'Provider series' })
45
+ ).toBeTruthy();
46
+ expect(screen.getByText('Room')).toBeTruthy();
47
+ expect(screen.getByText('<script>private fixture</script>')).toBeTruthy();
48
+ expect(screen.queryByRole('textbox')).toBeNull();
49
+ expect(screen.queryByRole('button')).toBeNull();
50
+ expect(document.querySelector('script')).toBeNull();
51
+ });
52
+ });
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import { Dialog, DialogContent } from '@tuturuuu/ui/dialog';
4
+ import { useFormatter } from 'next-intl';
5
+ import { EventModalHeader } from './event-modal-header';
6
+
7
+ /** Unsupported provider rules never mount the ordinary editable event form. */
8
+ export function ProviderReadonlyDialog({
9
+ event,
10
+ open,
11
+ onOpenChange,
12
+ }: {
13
+ event: CalendarEvent;
14
+ open: boolean;
15
+ onOpenChange: (open: boolean) => void;
16
+ }) {
17
+ const format = useFormatter();
18
+ return (
19
+ <Dialog open={open} onOpenChange={onOpenChange}>
20
+ <DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
21
+ <EventModalHeader event={event} isEditing />
22
+ <div className="space-y-4 px-6 py-5">
23
+ <p className="text-muted-foreground text-sm">
24
+ <time dateTime={event.start_at}>
25
+ {format.dateTime(new Date(event.start_at), {
26
+ dateStyle: 'medium',
27
+ timeStyle: 'short',
28
+ })}
29
+ </time>
30
+ {' – '}
31
+ <time dateTime={event.end_at}>
32
+ {format.dateTime(new Date(event.end_at), {
33
+ dateStyle: 'medium',
34
+ timeStyle: 'short',
35
+ })}
36
+ </time>
37
+ </p>
38
+ {event.location && <p className="text-sm">{event.location}</p>}
39
+ {event.description && (
40
+ <p className="max-h-64 overflow-auto whitespace-pre-wrap text-sm">
41
+ {event.description}
42
+ </p>
43
+ )}
44
+ </div>
45
+ </DialogContent>
46
+ </Dialog>
47
+ );
48
+ }
@@ -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
+ }
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
4
+ import { expect, it, vi } from 'vitest';
5
+ import { useCalendarDraftSave } from './use-calendar-draft-save';
6
+
7
+ const query = vi.hoisted(() => ({
8
+ data: { connectionId: 'owned', providerColorWrites: true },
9
+ }));
10
+ vi.mock('@tanstack/react-query', () => ({ useQuery: () => query }));
11
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
12
+ vi.mock('@tuturuuu/ui/button', () => ({
13
+ Button: (props: any) => <button {...props} />,
14
+ }));
15
+
16
+ function fixture() {
17
+ query.data = { connectionId: 'owned', providerColorWrites: true };
18
+ const draft = {
19
+ requestId: '01900000-0000-7000-8000-000000000001',
20
+ ws_id: 'workspace',
21
+ title: 'Original',
22
+ };
23
+ const args = {
24
+ draft,
25
+ wsId: 'workspace',
26
+ source: { provider: 'google' as const, connectionId: 'owned' } as any,
27
+ original: { id: 'new' } as CalendarEvent,
28
+ buildPayload: () => ({ title: args.draft.title }),
29
+ addEvent: vi.fn().mockRejectedValueOnce(new Error('lost response')),
30
+ updateEvent: vi.fn().mockResolvedValue(undefined),
31
+ closeModal: vi.fn(),
32
+ setIsSaving: vi.fn(),
33
+ onError: vi.fn(),
34
+ isSaving: false,
35
+ };
36
+ return args;
37
+ }
38
+
39
+ it('retries immutable uncertain contents and ID, preserves edits, then explicitly updates the confirmed ID', async () => {
40
+ const args = fixture();
41
+ const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
42
+ await act(() => result.current.save());
43
+ expect(result.current.recoveryNotice).not.toBeNull();
44
+ args.draft = { ...args.draft, title: 'Edited after uncertainty' };
45
+ rerender();
46
+ await act(() => result.current.save());
47
+ expect(args.addEvent).toHaveBeenCalledTimes(1);
48
+ args.addEvent.mockResolvedValueOnce({ id: 'confirmed', title: 'Original' });
49
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
50
+ .onClick;
51
+ await act(() => retry());
52
+ expect(args.addEvent).toHaveBeenLastCalledWith(
53
+ { title: 'Original' },
54
+ { requestId: args.draft.requestId }
55
+ );
56
+ expect(args.closeModal).not.toHaveBeenCalled();
57
+ expect(args.draft.title).toBe('Edited after uncertainty');
58
+ await act(() => result.current.save());
59
+ expect(args.addEvent).toHaveBeenCalledTimes(2);
60
+ expect(args.updateEvent).toHaveBeenCalledWith('confirmed', {
61
+ title: 'Edited after uncertainty',
62
+ });
63
+ expect(args.closeModal).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('keeps failed recovery uncertain without dispatching edited intent', async () => {
67
+ const args = fixture();
68
+ const { result } = renderHook(() => useCalendarDraftSave(args));
69
+ await act(() => result.current.save());
70
+ args.addEvent.mockRejectedValueOnce(new Error('still uncertain'));
71
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
72
+ .onClick;
73
+ await act(() => retry());
74
+ expect(result.current.recoveryNotice).not.toBeNull();
75
+ expect(args.updateEvent).not.toHaveBeenCalled();
76
+ expect(args.closeModal).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('does not close or recover another draft after a stale retry response', async () => {
80
+ const args = fixture();
81
+ const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
82
+ await act(() => result.current.save());
83
+ let resolve!: (event: unknown) => void;
84
+ args.addEvent.mockReturnValueOnce(
85
+ new Promise((r) => {
86
+ resolve = r;
87
+ })
88
+ );
89
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
90
+ .onClick;
91
+ let pending!: Promise<void>;
92
+ act(() => {
93
+ pending = retry();
94
+ });
95
+ args.draft = {
96
+ ...args.draft,
97
+ requestId: 'another-draft',
98
+ title: 'New draft',
99
+ };
100
+ rerender();
101
+ await act(async () => {
102
+ resolve({ id: 'old-confirmed', title: 'Original' });
103
+ await pending;
104
+ });
105
+ expect(args.closeModal).not.toHaveBeenCalled();
106
+ expect(result.current.recoveryNotice).toBeNull();
107
+ expect(args.updateEvent).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('keeps legacy unconfirmed creates out of automatic retry', async () => {
111
+ const args = fixture();
112
+ query.data.providerColorWrites = false;
113
+ const { result } = renderHook(() => useCalendarDraftSave(args));
114
+ await act(() => result.current.save());
115
+ expect(result.current.recoveryNotice!.props.children[1]).toBe(false);
116
+ await act(() => result.current.save());
117
+ expect(args.addEvent).toHaveBeenCalledTimes(1);
118
+ expect(args.updateEvent).not.toHaveBeenCalled();
119
+ });