@tuturuuu/ui 0.38.0 → 0.39.1

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 (98) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/custom/public-profile-menu-item.tsx +25 -0
  4. package/src/components/ui/custom/satellite-user-menu-items.test.tsx +61 -0
  5. package/src/components/ui/custom/satellite-user-menu-items.tsx +8 -0
  6. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  7. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  8. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  9. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  10. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  11. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  12. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  13. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  14. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  15. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  16. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  17. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  18. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  22. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  23. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  24. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  25. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  26. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  27. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  28. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  29. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  30. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  31. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +489 -0
  33. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  34. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  35. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  36. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  37. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  38. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  39. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  40. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  41. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  42. package/src/components/ui/finance/invoices/types.ts +3 -3
  43. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  44. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  45. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  46. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  47. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  48. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  49. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  50. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  51. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  52. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  53. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  54. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
  55. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  56. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  57. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  58. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  59. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  60. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  61. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  62. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  63. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  64. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  65. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  66. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  68. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  69. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  70. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  71. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  72. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
  73. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
  74. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  75. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  76. package/src/components/ui/public-link-button.test.tsx +116 -0
  77. package/src/components/ui/public-link-button.tsx +81 -0
  78. package/src/components/ui/text-editor/editor.tsx +3 -0
  79. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  80. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  81. package/src/components/ui/text-editor/tool-bar.tsx +18 -74
  82. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  83. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  84. package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
  85. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
  86. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  87. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  88. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
  89. package/src/hooks/calendar-connection-types.ts +49 -0
  90. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  91. package/src/hooks/calendar-event-write-payload.ts +33 -0
  92. package/src/hooks/collaboration-document-hydration.ts +35 -0
  93. package/src/hooks/supabase-provider.ts +39 -41
  94. package/src/hooks/use-calendar-sync.tsx +41 -62
  95. package/src/hooks/use-calendar.tsx +24 -38
  96. package/src/hooks/use-local-storage.ts +9 -11
  97. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  98. package/src/hooks/use-yjs-collaboration.ts +23 -0
@@ -0,0 +1,340 @@
1
+ 'use client';
2
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import type {
4
+ CalendarProviderSeriesCapabilities,
5
+ CalendarProviderSeriesSource,
6
+ } from '@tuturuuu/internal-api/calendar-provider-series';
7
+ import {
8
+ createNativeCalendarSeries,
9
+ mutateNativeCalendarSeries,
10
+ type NativeCalendarSeries,
11
+ } from '@tuturuuu/internal-api/calendar-series';
12
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
13
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
14
+ import { inspectCalendarRecurrenceSlot } from '@tuturuuu/utils/calendar-recurrence';
15
+ import { useTranslations } from 'next-intl';
16
+ import { useCallback, useMemo, useRef, useState } from 'react';
17
+ import { nativeOccurrencesKey } from '../../../../hooks/use-native-calendar-occurrences';
18
+ import type { useWorkspaceActor } from '../../../../hooks/use-workspace-visibility';
19
+ import { Button } from '../../button';
20
+ import { DialogFooter } from '../../dialog';
21
+ import { Label } from '../../label';
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from '../../select';
29
+ import { NativeRecurrenceFields } from './native-recurrence-fields';
30
+ import {
31
+ newRecurrenceDraft,
32
+ type RecurrenceScope,
33
+ recurrenceDraftPayload,
34
+ recurrenceEditDraft,
35
+ recurrenceMutation,
36
+ recurrenceRequestIdentity,
37
+ } from './native-recurrence-model';
38
+ import { useProviderRecurrenceOperation } from './use-provider-recurrence-operation';
39
+ export function NativeRecurrenceForm({
40
+ wsId,
41
+ actor,
42
+ series,
43
+ occurrence,
44
+ timezone,
45
+ readOnly,
46
+ capabilities,
47
+ onDone,
48
+ onReload,
49
+ }: {
50
+ wsId: string;
51
+ actor: NonNullable<ReturnType<typeof useWorkspaceActor>>;
52
+ series?: NativeCalendarSeries;
53
+ occurrence?: CalendarEvent;
54
+ timezone?: string;
55
+ readOnly: boolean;
56
+ capabilities?: CalendarProviderSeriesCapabilities;
57
+ onDone: () => void;
58
+ onReload: () => void;
59
+ }) {
60
+ const t = useTranslations('calendar.recurrence');
61
+ const client = useQueryClient();
62
+ const [scope, setScope] = useState<RecurrenceScope>('this');
63
+ const createsFutureTail = useMemo(() => {
64
+ if (scope !== 'future' || !series || !occurrence?.originalStartLocal)
65
+ return false;
66
+ try {
67
+ return (
68
+ inspectCalendarRecurrenceSlot({
69
+ rule: series.rule,
70
+ anchor: series.anchor,
71
+ originalStartLocal: occurrence.originalStartLocal,
72
+ }).precedingCount > 0
73
+ );
74
+ } catch {
75
+ return false;
76
+ }
77
+ }, [scope, series, occurrence?.originalStartLocal]);
78
+ const [draft, setDraft] = useState(() =>
79
+ series && occurrence
80
+ ? recurrenceEditDraft(series, occurrence, 'this')
81
+ : newRecurrenceDraft(
82
+ timezone && timezone !== 'auto'
83
+ ? timezone
84
+ : Intl.DateTimeFormat().resolvedOptions().timeZone
85
+ )
86
+ );
87
+ const identity = useRef(recurrenceRequestIdentity());
88
+ const [invalid, setInvalid] = useState(false);
89
+ const [sourceId, setSourceId] = useState('native');
90
+ const selectedSource =
91
+ series?.providerSource ??
92
+ capabilities?.sources.find(
93
+ (source) => `${source.provider}:${source.connectionId}` === sourceId
94
+ );
95
+ const providerSource: CalendarProviderSeriesSource | undefined =
96
+ selectedSource && {
97
+ provider: selectedSource.provider,
98
+ connectionId: selectedSource.connectionId,
99
+ };
100
+ const providerAllowed =
101
+ !!providerSource &&
102
+ !!capabilities?.enabled &&
103
+ capabilities.sources.some(
104
+ (source) =>
105
+ source.provider === providerSource.provider &&
106
+ source.connectionId === providerSource.connectionId
107
+ );
108
+ const finish = useCallback(() => {
109
+ actor.assertActive();
110
+ void client.invalidateQueries({ queryKey: nativeOccurrencesKey(wsId) });
111
+ void client.invalidateQueries({
112
+ queryKey: ['native-calendar-series', wsId, actor.actorId],
113
+ });
114
+ onDone();
115
+ }, [actor, client, wsId, onDone]);
116
+ const finishProvider = useCallback(() => {
117
+ actor.assertActive();
118
+ // Calendar combines canonical occurrences with retained provider/database views.
119
+ // Revalidate those views only after provider publication, including a resumed creation.
120
+ void client.invalidateQueries({
121
+ queryKey: ['databaseCalendarEvents', wsId],
122
+ });
123
+ void client.invalidateQueries({ queryKey: ['googleCalendarEvents', wsId] });
124
+ finish();
125
+ }, [actor, client, wsId, finish]);
126
+ const providerOperation = useProviderRecurrenceOperation({
127
+ wsId,
128
+ actor,
129
+ identity: occurrence?.id ?? 'new',
130
+ onApplied: finishProvider,
131
+ });
132
+ const providerBlocked = !!series?.providerSource && !providerAllowed;
133
+ const frozen = readOnly || providerBlocked || providerOperation.pending;
134
+
135
+ const submission = useMutation({
136
+ mutationFn: async (action: 'update' | 'delete') => {
137
+ actor.assertActive();
138
+ if (frozen) throw new Error('Calendar is read only');
139
+ const actorKey = `${actor.actorId}:${wsId}:${providerSource ? `${providerSource.provider}:${providerSource.connectionId}` : 'native'}`;
140
+ if (series && occurrence) {
141
+ const payload = recurrenceMutation(
142
+ draft,
143
+ series,
144
+ occurrence,
145
+ scope,
146
+ 'placeholder',
147
+ action
148
+ );
149
+ const { requestId: _placeholder, ...intent } = payload;
150
+ const requestId = identity.current(actorKey, intent, action);
151
+ if (providerSource) {
152
+ if (!providerAllowed) throw new Error('Provider source unavailable');
153
+ providerOperation.submission.mutate({
154
+ ...payload,
155
+ requestId,
156
+ action,
157
+ source: providerSource,
158
+ seriesId: series.id,
159
+ });
160
+ return 'provider' as const;
161
+ }
162
+ await mutateNativeCalendarSeries(
163
+ wsId,
164
+ series.id,
165
+ { ...payload, requestId },
166
+ action
167
+ );
168
+ } else {
169
+ if (action === 'delete') throw new Error('No series selected');
170
+ const payload = recurrenceDraftPayload(draft);
171
+ const requestId = identity.current(actorKey, payload, 'create');
172
+ if (sourceId !== 'native') {
173
+ if (!providerSource || !providerAllowed)
174
+ throw new Error('Provider source unavailable');
175
+ providerOperation.submission.mutate({
176
+ ...payload,
177
+ requestId,
178
+ action: 'create',
179
+ source: providerSource,
180
+ });
181
+ return 'provider' as const;
182
+ }
183
+ await createNativeCalendarSeries(wsId, { ...payload, requestId });
184
+ }
185
+ actor.assertActive();
186
+ },
187
+ onSuccess: (result) => {
188
+ if (result !== 'provider') finish();
189
+ },
190
+ });
191
+ const submit = (action: 'update' | 'delete') => {
192
+ setInvalid(false);
193
+ try {
194
+ if (action === 'update')
195
+ recurrenceDraftPayload(draft, !!series && scope === 'this');
196
+ actor.assertActive();
197
+ if (!frozen) submission.mutate(action);
198
+ } catch {
199
+ setInvalid(true);
200
+ }
201
+ };
202
+ const conflict =
203
+ submission.error instanceof InternalApiError &&
204
+ submission.error.status === 409;
205
+ return (
206
+ <form
207
+ onSubmit={(event) => {
208
+ event.preventDefault();
209
+ submit('update');
210
+ }}
211
+ className="space-y-4"
212
+ >
213
+ {!series && (
214
+ <div className="space-y-1">
215
+ <Label>{t('provider_source')}</Label>
216
+ <Select
217
+ value={sourceId}
218
+ disabled={frozen || submission.isPending}
219
+ onValueChange={setSourceId}
220
+ >
221
+ <SelectTrigger aria-label={t('provider_source')}>
222
+ <SelectValue />
223
+ </SelectTrigger>
224
+ <SelectContent>
225
+ <SelectItem value="native">{t('native_source')}</SelectItem>
226
+ {capabilities?.enabled &&
227
+ capabilities.sources.map((source) => (
228
+ <SelectItem
229
+ key={`${source.provider}:${source.connectionId}`}
230
+ value={`${source.provider}:${source.connectionId}`}
231
+ >
232
+ {source.label} ·{' '}
233
+ {source.provider === 'google' ? 'Google' : 'Outlook'}
234
+ </SelectItem>
235
+ ))}
236
+ </SelectContent>
237
+ </Select>
238
+ </div>
239
+ )}
240
+ {providerBlocked && <p role="status">{t('provider_disabled')}</p>}
241
+ {providerOperation.pending && (
242
+ <div role="status" className="space-y-2">
243
+ <p>{t('provider_pending')}</p>
244
+ <Button
245
+ type="button"
246
+ variant="outline"
247
+ disabled={providerOperation.submission.isPending}
248
+ onClick={providerOperation.retry}
249
+ >
250
+ {t('provider_retry')}
251
+ </Button>
252
+ </div>
253
+ )}
254
+ {providerOperation.submission.isError && (
255
+ <p role="alert" className="text-destructive text-sm">
256
+ {t('failed')}
257
+ </p>
258
+ )}
259
+ {series && occurrence && (
260
+ <div className="space-y-1">
261
+ <Label>{t('scope')}</Label>
262
+ <Select
263
+ value={scope}
264
+ disabled={frozen || submission.isPending}
265
+ onValueChange={(value) => {
266
+ const next = value as RecurrenceScope;
267
+ setScope(next);
268
+ setDraft(recurrenceEditDraft(series, occurrence, next));
269
+ setInvalid(false);
270
+ submission.reset();
271
+ }}
272
+ >
273
+ <SelectTrigger aria-label={t('scope')}>
274
+ <SelectValue />
275
+ </SelectTrigger>
276
+ <SelectContent>
277
+ {(['this', 'all', 'future'] as const).map((value) => (
278
+ <SelectItem key={value} value={value}>
279
+ {t(value)}
280
+ </SelectItem>
281
+ ))}
282
+ </SelectContent>
283
+ </Select>
284
+ {createsFutureTail && (
285
+ <p className="text-muted-foreground text-xs">
286
+ {t('futureExceptionsReset')}
287
+ </p>
288
+ )}
289
+ </div>
290
+ )}
291
+ <NativeRecurrenceFields
292
+ draft={draft}
293
+ setDraft={(value) => {
294
+ setDraft(value);
295
+ setInvalid(false);
296
+ }}
297
+ nativeStorageHint={!series?.providerSource && sourceId === 'native'}
298
+ occurrenceOnly={!!series && scope === 'this'}
299
+ disabled={frozen || submission.isPending}
300
+ />
301
+ {(invalid || submission.isError) && (
302
+ <p role="alert" className="text-destructive text-sm">
303
+ {t(invalid ? 'invalid' : conflict ? 'conflict' : 'failed')}
304
+ </p>
305
+ )}
306
+ <DialogFooter>
307
+ {conflict && (
308
+ <Button type="button" variant="outline" onClick={onReload}>
309
+ {t('reload')}
310
+ </Button>
311
+ )}
312
+ {series && (
313
+ <Button
314
+ type="button"
315
+ variant="destructive"
316
+ disabled={frozen || submission.isPending}
317
+ onClick={() => submit('delete')}
318
+ >
319
+ {t('delete')}
320
+ </Button>
321
+ )}
322
+ <Button
323
+ type="button"
324
+ variant="outline"
325
+ onClick={onDone}
326
+ disabled={frozen || submission.isPending}
327
+ >
328
+ {t('cancel')}
329
+ </Button>
330
+ <Button type="submit" disabled={frozen || submission.isPending}>
331
+ {t(
332
+ submission.isPending || providerOperation.pending
333
+ ? 'saving'
334
+ : 'save'
335
+ )}
336
+ </Button>
337
+ </DialogFooter>
338
+ </form>
339
+ );
340
+ }
@@ -0,0 +1,234 @@
1
+ import type { NativeCalendarSeries } from '@tuturuuu/internal-api/calendar-series';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import { expandCalendarRecurrence } from '@tuturuuu/utils/calendar-recurrence';
4
+ import { describe, expect, it } from 'vitest';
5
+ import {
6
+ newRecurrenceDraft,
7
+ recurrenceAllDay,
8
+ recurrenceDraftPayload,
9
+ recurrenceEditDraft,
10
+ recurrenceMutation,
11
+ recurrenceRequestIdentity,
12
+ } from './native-recurrence-model';
13
+
14
+ const series: NativeCalendarSeries = {
15
+ id: 'series',
16
+ ws_id: 'ws',
17
+ revision: 7,
18
+ workspace_calendar_id: null,
19
+ rule: {
20
+ version: 1,
21
+ frequency: 'daily',
22
+ interval: 1,
23
+ timeZone: 'America/New_York',
24
+ end: { type: 'count', count: 5 },
25
+ },
26
+ anchor: {
27
+ startLocal: '2026-03-06T09:00:00',
28
+ endLocal: '2026-03-06T10:00:00',
29
+ allDay: false,
30
+ },
31
+ payload: { title: 'Daily' },
32
+ exceptions: [],
33
+ };
34
+ const event: CalendarEvent = {
35
+ id: 'occurrence',
36
+ seriesId: 'series',
37
+ seriesRevision: 6,
38
+ originalStartLocal: '2026-03-08T09:00:00',
39
+ start_at: '2026-03-08T13:00:00Z',
40
+ end_at: '2026-03-08T14:00:00Z',
41
+ title: 'Moved title',
42
+ };
43
+ function draft() {
44
+ return {
45
+ ...newRecurrenceDraft('America/New_York', new Date('2026-03-06T14:00:00Z')),
46
+ title: 'Planning',
47
+ frequency: 'daily' as const,
48
+ };
49
+ }
50
+ describe('native recurrence form contract', () => {
51
+ it('keeps wall-clock time through DST rather than fixed UTC intervals', () => {
52
+ const payload = recurrenceDraftPayload(draft());
53
+ const rows = expandCalendarRecurrence({
54
+ ...payload,
55
+ from: '2026-03-06T00:00:00Z',
56
+ to: '2026-03-10T00:00:00Z',
57
+ }).occurrences;
58
+ expect(rows.map((r) => r.start_at)).toEqual([
59
+ '2026-03-06T14:00:00Z',
60
+ '2026-03-07T14:00:00Z',
61
+ '2026-03-08T13:00:00Z',
62
+ '2026-03-09T13:00:00Z',
63
+ ]);
64
+ });
65
+ it('skips nonexistent spring-forward slots and uses the earlier fold instant', () => {
66
+ const spring = {
67
+ ...draft(),
68
+ startLocal: '2026-03-08T02:30',
69
+ endLocal: '2026-03-08T03:30',
70
+ };
71
+ expect(
72
+ expandCalendarRecurrence({
73
+ ...recurrenceDraftPayload(spring),
74
+ from: '2026-03-08T00:00:00Z',
75
+ to: '2026-03-10T00:00:00Z',
76
+ }).occurrences[0]?.originalStartLocal
77
+ ).toBe('2026-03-09T02:30:00');
78
+ const fold = {
79
+ ...draft(),
80
+ startLocal: '2026-11-01T01:30',
81
+ endLocal: '2026-11-01T02:30',
82
+ };
83
+ expect(
84
+ expandCalendarRecurrence({
85
+ ...recurrenceDraftPayload(fold),
86
+ from: '2026-11-01T00:00:00Z',
87
+ to: '2026-11-02T00:00:00Z',
88
+ }).occurrences[0]?.start_at
89
+ ).toBe('2026-11-01T05:30:00Z');
90
+ });
91
+ it.each([
92
+ { title: ' ' },
93
+ { interval: '0' },
94
+ { interval: '1.5' },
95
+ { timeZone: '+07:00' },
96
+ { endLocal: '2026-03-06T08:00' },
97
+ { frequency: 'weekly' as const, weekdays: [] },
98
+ ])('rejects invalid form data %o', (change) => {
99
+ expect(() => recurrenceDraftPayload({ ...draft(), ...change })).toThrow();
100
+ });
101
+ it('uses exclusive midnight boundaries for all-day creation', () => {
102
+ const payload = recurrenceDraftPayload(recurrenceAllDay(draft(), true));
103
+ expect(payload.anchor).toEqual({
104
+ startLocal: '2026-03-06T00:00:00',
105
+ endLocal: '2026-03-07T00:00:00',
106
+ allDay: true,
107
+ });
108
+ });
109
+ it('keeps the whole-series original anchor rather than the clicked occurrence date', () => {
110
+ expect(recurrenceEditDraft(series, event, 'all').startLocal).toBe(
111
+ '2026-03-06T09:00'
112
+ );
113
+ });
114
+ it('reduces future count by preceding slots without changing the selected original slot', () => {
115
+ const edited = recurrenceEditDraft(series, event, 'future');
116
+ expect(edited.endValue).toBe('3');
117
+ const result = recurrenceMutation(
118
+ edited,
119
+ series,
120
+ event,
121
+ 'future',
122
+ 'request',
123
+ 'update'
124
+ );
125
+ expect(result).toMatchObject({
126
+ scope: 'future',
127
+ expectedRevision: 7,
128
+ originalStartLocal: '2026-03-08T09:00:00',
129
+ rule: { end: { type: 'count', count: 3 } },
130
+ });
131
+ });
132
+ it('moves a single occurrence while preserving its immutable original identity', () => {
133
+ const edited = {
134
+ ...recurrenceEditDraft(series, event, 'this'),
135
+ startLocal: '2026-04-01T11:00',
136
+ endLocal: '2026-04-01T12:00',
137
+ };
138
+ const result = recurrenceMutation(
139
+ edited,
140
+ series,
141
+ event,
142
+ 'this',
143
+ 'request',
144
+ 'update'
145
+ );
146
+ expect(result.originalStartLocal).toBe(event.originalStartLocal);
147
+ expect(result.anchor?.startLocal).toBe('2026-04-01T11:00:00');
148
+ expect(result.rule).toBeUndefined();
149
+ expect(result.expectedRevision).toBe(7);
150
+ });
151
+ it.each(['this', 'all', 'future'] as const)(
152
+ 'deletes only the explicitly selected %s scope',
153
+ (scope) => {
154
+ const result = recurrenceMutation(
155
+ draft(),
156
+ series,
157
+ event,
158
+ scope,
159
+ 'request',
160
+ 'delete'
161
+ );
162
+ expect(result).toEqual({
163
+ requestId: 'request',
164
+ expectedRevision: 7,
165
+ scope,
166
+ originalStartLocal: event.originalStartLocal,
167
+ });
168
+ }
169
+ );
170
+ it('rejects an occurrence from another series or without a slot', () => {
171
+ expect(() =>
172
+ recurrenceMutation(
173
+ draft(),
174
+ series,
175
+ { ...event, seriesId: 'other' },
176
+ 'this',
177
+ 'request',
178
+ 'update'
179
+ )
180
+ ).toThrow();
181
+ expect(() =>
182
+ recurrenceEditDraft(
183
+ series,
184
+ { ...event, originalStartLocal: undefined },
185
+ 'this'
186
+ )
187
+ ).toThrow();
188
+ });
189
+ it('preserves advanced relative-weekday rules while editing event details', () => {
190
+ const advanced = {
191
+ ...series,
192
+ anchor: {
193
+ ...series.anchor,
194
+ startLocal: '2026-03-27T09:00:00',
195
+ endLocal: '2026-03-27T10:00:00',
196
+ },
197
+ rule: {
198
+ ...series.rule,
199
+ frequency: 'monthly' as const,
200
+ weekdays: ['FR' as const],
201
+ weekIndex: -1 as const,
202
+ },
203
+ };
204
+ expect(
205
+ recurrenceDraftPayload(recurrenceEditDraft(advanced, event, 'all')).rule
206
+ ).toEqual(advanced.rule);
207
+ });
208
+ it('reuses retry intent IDs and resets for actor, workspace, revision, scope and changed drafts', () => {
209
+ let count = 0;
210
+ const id = recurrenceRequestIdentity(() => String(++count));
211
+ const payload = recurrenceDraftPayload(draft());
212
+ expect(id('actor:ws', payload, 'create')).toBe('1');
213
+ expect(id('actor:ws', payload, 'create')).toBe('1');
214
+ expect(id('other:ws', payload, 'create')).toBe('2');
215
+ expect(id('other:next', payload, 'create')).toBe('3');
216
+ expect(
217
+ id('other:next', { ...payload, event: { title: 'Changed' } }, 'create')
218
+ ).toBe('4');
219
+ const edit = recurrenceMutation(
220
+ draft(),
221
+ series,
222
+ event,
223
+ 'this',
224
+ 'unused',
225
+ 'update'
226
+ );
227
+ const { requestId: _, ...intent } = edit;
228
+ expect(id('actor:ws', intent, 'update')).toBe('5');
229
+ expect(id('actor:ws', { ...intent, expectedRevision: 8 }, 'update')).toBe(
230
+ '6'
231
+ );
232
+ expect(id('actor:ws', { ...intent, scope: 'future' }, 'update')).toBe('7');
233
+ });
234
+ });