@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,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
+ });