@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,205 @@
1
+ import type { NativeCalendarSeries } from '@tuturuuu/internal-api/calendar-series';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import type { CalendarRecurrenceRule } from '@tuturuuu/types/primitives/calendar-recurrence';
4
+ import {
5
+ toGoogleCalendarRecurrence,
6
+ toGraphCalendarRecurrence,
7
+ } from '@tuturuuu/utils/calendar-recurrence-providers';
8
+ import { describe, expect, it } from 'vitest';
9
+ import {
10
+ recurrenceDraftPayload,
11
+ recurrenceEditDraft,
12
+ recurrenceMutation,
13
+ } from './native-recurrence-model';
14
+
15
+ const event: CalendarEvent = {
16
+ id: 'slot',
17
+ seriesId: 'series',
18
+ originalStartLocal: '2026-03-27T09:00:00',
19
+ start_at: '2026-03-27T13:00:00Z',
20
+ end_at: '2026-03-27T14:00:00Z',
21
+ title: 'Monthly review',
22
+ };
23
+ function series(
24
+ rule: Partial<CalendarRecurrenceRule>,
25
+ day = '27'
26
+ ): NativeCalendarSeries {
27
+ return {
28
+ id: 'series',
29
+ ws_id: 'ws',
30
+ revision: 3,
31
+ workspace_calendar_id: null,
32
+ rule: {
33
+ version: 1,
34
+ frequency: 'monthly',
35
+ interval: 1,
36
+ timeZone: 'America/New_York',
37
+ weekdays: ['FR'],
38
+ weekIndex: -1,
39
+ end: { type: 'count', count: 8 },
40
+ ...rule,
41
+ },
42
+ anchor: {
43
+ startLocal: `2026-03-${day}T09:00:00`,
44
+ endLocal: `2026-03-${day}T10:00:00`,
45
+ allDay: false,
46
+ },
47
+ payload: { title: 'Monthly review' },
48
+ exceptions: [],
49
+ };
50
+ }
51
+ describe('editable relative recurrence provider parity', () => {
52
+ it.each([
53
+ { index: 1, day: '06' },
54
+ { index: 2, day: '13' },
55
+ { index: 3, day: '20' },
56
+ { index: 4, day: '27' },
57
+ { index: -1, day: '27' },
58
+ ] as const)(
59
+ 'retains ordinal $index through details edits and provider serialization',
60
+ ({ index, day }) => {
61
+ for (const frequency of ['monthly', 'yearly'] as const) {
62
+ const original = series(
63
+ {
64
+ frequency,
65
+ weekIndex: index,
66
+ ...(frequency === 'yearly' ? { month: 3 } : {}),
67
+ },
68
+ day
69
+ );
70
+ const draft = recurrenceEditDraft(original, event, 'all');
71
+ expect(draft.retainedRule).toBeUndefined();
72
+ expect(draft.monthPattern).toBe('weekday');
73
+ const payload = recurrenceDraftPayload({
74
+ ...draft,
75
+ title: 'Updated title',
76
+ });
77
+ expect(payload.rule).toEqual(original.rule);
78
+ expect(toGraphCalendarRecurrence(payload.rule, payload.anchor)).toEqual(
79
+ toGraphCalendarRecurrence(original.rule, original.anchor)
80
+ );
81
+ expect(
82
+ toGoogleCalendarRecurrence(payload.rule, payload.anchor)
83
+ ).toEqual(toGoogleCalendarRecurrence(original.rule, original.anchor));
84
+ }
85
+ }
86
+ );
87
+ it('retains multi-weekday earliest-slot semantics and inclusive until dates', () => {
88
+ const original = series(
89
+ {
90
+ weekdays: ['TH', 'FR'],
91
+ weekIndex: 1,
92
+ end: { type: 'until', date: '2026-12-31' },
93
+ },
94
+ '05'
95
+ );
96
+ const payload = recurrenceDraftPayload(
97
+ recurrenceEditDraft(original, event, 'all')
98
+ );
99
+ expect(payload.rule).toEqual(original.rule);
100
+ expect(
101
+ toGraphCalendarRecurrence(payload.rule, payload.anchor).pattern.daysOfWeek
102
+ ).toEqual(['thursday', 'friday']);
103
+ expect(toGoogleCalendarRecurrence(payload.rule, payload.anchor)).toEqual(
104
+ toGoogleCalendarRecurrence(original.rule, original.anchor)
105
+ );
106
+ });
107
+ it('preserves Sunday week boundaries on biweekly imported series', () => {
108
+ const original = series({
109
+ frequency: 'weekly',
110
+ interval: 2,
111
+ weekIndex: undefined,
112
+ weekdays: ['FR'],
113
+ weekStartsOn: 'SU',
114
+ });
115
+ const payload = recurrenceDraftPayload(
116
+ recurrenceEditDraft(original, event, 'all')
117
+ );
118
+ expect(payload.rule.weekStartsOn).toBe('SU');
119
+ expect(
120
+ toGraphCalendarRecurrence(payload.rule, payload.anchor).pattern
121
+ .firstDayOfWeek
122
+ ).toBe('sunday');
123
+ });
124
+ it('preserves explicit month-end clamping while editing details', () => {
125
+ const original = series(
126
+ {
127
+ weekIndex: undefined,
128
+ weekdays: undefined,
129
+ monthDay: 31,
130
+ monthDayOverflow: 'last-day',
131
+ },
132
+ '31'
133
+ );
134
+ const payload = recurrenceDraftPayload(
135
+ recurrenceEditDraft(original, event, 'all')
136
+ );
137
+ expect(payload.rule).toEqual(original.rule);
138
+ expect(
139
+ toGraphCalendarRecurrence(payload.rule, payload.anchor).pattern.dayOfMonth
140
+ ).toBe(31);
141
+ });
142
+ it('does not add optional normalization that could reset existing exceptions', () => {
143
+ const original = series({
144
+ weekIndex: undefined,
145
+ weekdays: undefined,
146
+ monthDay: 27,
147
+ });
148
+ expect(
149
+ recurrenceDraftPayload(recurrenceEditDraft(original, event, 'all')).rule
150
+ ).toEqual(original.rule);
151
+ });
152
+ it.each(['0', '5', 'invalid'])('rejects invalid ordinal %s', (weekIndex) => {
153
+ expect(() =>
154
+ recurrenceDraftPayload({
155
+ ...recurrenceEditDraft(series({}), event, 'all'),
156
+ weekIndex,
157
+ })
158
+ ).toThrow();
159
+ });
160
+ it('rejects empty weekdays, invalid yearly months and mismatched anchors', () => {
161
+ const draft = recurrenceEditDraft(series({}), event, 'all');
162
+ expect(() => recurrenceDraftPayload({ ...draft, weekdays: [] })).toThrow();
163
+ for (const month of ['0', '13'])
164
+ expect(() =>
165
+ recurrenceDraftPayload({ ...draft, frequency: 'yearly', month })
166
+ ).toThrow();
167
+ expect(() =>
168
+ recurrenceDraftPayload({ ...draft, weekIndex: '1' })
169
+ ).toThrow();
170
+ });
171
+ it('keeps occurrence edits independent of the series pattern', () => {
172
+ const original = series({});
173
+ const draft = recurrenceEditDraft(original, event, 'this');
174
+ expect(
175
+ recurrenceMutation(
176
+ { ...draft, weekIndex: 'invalid', month: '0' },
177
+ original,
178
+ event,
179
+ 'this',
180
+ 'request',
181
+ 'update'
182
+ ).rule
183
+ ).toBeUndefined();
184
+ });
185
+ it('uses the remaining count for a future relative split', () => {
186
+ const original = series({});
187
+ const later = {
188
+ ...event,
189
+ originalStartLocal: '2026-04-24T09:00:00',
190
+ start_at: '2026-04-24T13:00:00Z',
191
+ end_at: '2026-04-24T14:00:00Z',
192
+ };
193
+ const payload = recurrenceMutation(
194
+ recurrenceEditDraft(original, later, 'future'),
195
+ original,
196
+ later,
197
+ 'future',
198
+ 'request',
199
+ 'update'
200
+ );
201
+ expect(payload.rule?.end).toEqual({ type: 'count', count: 7 });
202
+ expect(payload.rule?.weekIndex).toBe(-1);
203
+ expect(payload.originalStartLocal).toBe(later.originalStartLocal);
204
+ });
205
+ });
@@ -0,0 +1,255 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from '@testing-library/react';
10
+ import type { PropsWithChildren } from 'react';
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+
13
+ const mocks = vi.hoisted(() => ({
14
+ getSeries: vi.fn(),
15
+ nativeMutate: vi.fn(),
16
+ nativeCreate: vi.fn(),
17
+ capabilities: vi.fn(),
18
+ providerSubmit: vi.fn(),
19
+ pending: false,
20
+ applied: (() => {}) as () => void,
21
+ }));
22
+ vi.mock('@tuturuuu/internal-api/calendar-series', () => ({
23
+ getNativeCalendarSeries: mocks.getSeries,
24
+ mutateNativeCalendarSeries: mocks.nativeMutate,
25
+ createNativeCalendarSeries: mocks.nativeCreate,
26
+ }));
27
+ vi.mock('@tuturuuu/internal-api/calendar-provider-series', () => ({
28
+ getCalendarProviderSeriesCapabilities: mocks.capabilities,
29
+ }));
30
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
31
+ vi.mock('../../../../hooks/use-workspace-visibility', () => ({
32
+ useWorkspaceActor: () => ({ actorId: 'actor', assertActive: () => {} }),
33
+ }));
34
+ vi.mock('../../../../hooks/use-native-calendar-occurrences', () => ({
35
+ nativeOccurrencesKey: (wsId: string) => ['occurrences', wsId],
36
+ }));
37
+ vi.mock('./use-provider-recurrence-operation', () => ({
38
+ useProviderRecurrenceOperation: ({
39
+ onApplied,
40
+ }: {
41
+ onApplied: () => void;
42
+ }) => {
43
+ mocks.applied = onApplied;
44
+ return {
45
+ pending: mocks.pending,
46
+ submission: {
47
+ mutate: mocks.providerSubmit,
48
+ isPending: false,
49
+ isError: false,
50
+ },
51
+ retry: vi.fn(),
52
+ };
53
+ },
54
+ }));
55
+ vi.mock('../../dialog', () =>
56
+ Object.fromEntries(
57
+ [
58
+ 'Dialog',
59
+ 'DialogContent',
60
+ 'DialogDescription',
61
+ 'DialogFooter',
62
+ 'DialogHeader',
63
+ 'DialogTitle',
64
+ ].map((name) => [
65
+ name,
66
+ ({ children }: PropsWithChildren) => <div>{children}</div>,
67
+ ])
68
+ )
69
+ );
70
+ vi.mock('../../select', () =>
71
+ Object.fromEntries(
72
+ [
73
+ 'Select',
74
+ 'SelectContent',
75
+ 'SelectItem',
76
+ 'SelectTrigger',
77
+ 'SelectValue',
78
+ ].map((name) => [
79
+ name,
80
+ ({ children }: PropsWithChildren) => <div>{children}</div>,
81
+ ])
82
+ )
83
+ );
84
+ vi.mock('../../label', () => ({
85
+ Label: ({ children }: PropsWithChildren) => <label>{children}</label>,
86
+ }));
87
+
88
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
89
+ import { NativeRecurrenceDialog } from './native-recurrence-dialog';
90
+
91
+ const series = {
92
+ id: 'series',
93
+ ws_id: 'workspace',
94
+ revision: 2,
95
+ workspace_calendar_id: null,
96
+ providerSource: { provider: 'google', connectionId: 'connection' },
97
+ rule: {
98
+ version: 1,
99
+ frequency: 'daily',
100
+ interval: 1,
101
+ timeZone: 'UTC',
102
+ end: { type: 'never' },
103
+ },
104
+ anchor: {
105
+ startLocal: '2026-10-06T09:00:00',
106
+ endLocal: '2026-10-06T10:00:00',
107
+ allDay: false,
108
+ },
109
+ payload: { title: 'Test series' },
110
+ exceptions: [],
111
+ };
112
+ const occurrence = {
113
+ id: 'instance',
114
+ seriesId: 'series',
115
+ originalStartLocal: '2026-10-06T09:00:00',
116
+ start_at: '2026-10-06T09:00:00Z',
117
+ end_at: '2026-10-06T10:00:00Z',
118
+ title: 'Test series',
119
+ } as CalendarEvent;
120
+ const clients: QueryClient[] = [];
121
+ function mount() {
122
+ const client = new QueryClient({
123
+ defaultOptions: { queries: { retry: false } },
124
+ });
125
+ clients.push(client);
126
+ const view = render(
127
+ <QueryClientProvider client={client}>
128
+ <NativeRecurrenceDialog
129
+ wsId="workspace"
130
+ open
131
+ onOpenChange={vi.fn()}
132
+ occurrence={occurrence}
133
+ />
134
+ </QueryClientProvider>
135
+ );
136
+ return { ...view, client };
137
+ }
138
+ beforeEach(() => {
139
+ vi.clearAllMocks();
140
+ vi.stubGlobal(
141
+ 'ResizeObserver',
142
+ class {
143
+ observe() {}
144
+ unobserve() {}
145
+ disconnect() {}
146
+ }
147
+ );
148
+ mocks.pending = false;
149
+ mocks.getSeries.mockResolvedValue(series);
150
+ mocks.capabilities.mockResolvedValue({
151
+ enabled: true,
152
+ sources: [
153
+ {
154
+ provider: 'google',
155
+ connectionId: 'connection',
156
+ label: 'Test Calendar',
157
+ },
158
+ ],
159
+ });
160
+ });
161
+ afterEach(() => {
162
+ cleanup();
163
+ vi.unstubAllGlobals();
164
+ for (const client of clients.splice(0)) client.clear();
165
+ });
166
+ describe('imported recurrence mutation routing', () => {
167
+ it('uses the provider operation contract and never the native mutation API', async () => {
168
+ mount();
169
+ await waitFor(() =>
170
+ expect(screen.getByRole('button', { name: 'save' })).not.toBeDisabled()
171
+ );
172
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
173
+ await waitFor(() => expect(mocks.providerSubmit).toHaveBeenCalledTimes(1));
174
+ expect(mocks.providerSubmit).toHaveBeenCalledWith(
175
+ expect.objectContaining({
176
+ action: 'update',
177
+ seriesId: 'series',
178
+ scope: 'this',
179
+ source: { provider: 'google', connectionId: 'connection' },
180
+ })
181
+ );
182
+ expect(mocks.nativeMutate).not.toHaveBeenCalled();
183
+ expect(mocks.nativeCreate).not.toHaveBeenCalled();
184
+ expect(screen.queryByText('nativeOnly')).not.toBeInTheDocument();
185
+ });
186
+ it.each([
187
+ { enabled: false, sources: [] },
188
+ { enabled: true, sources: [] },
189
+ ])(
190
+ 'blocks imported writes when admission or actor-owned source is unavailable',
191
+ async (caps) => {
192
+ mocks.capabilities.mockResolvedValue(caps);
193
+ mount();
194
+ await waitFor(() =>
195
+ expect(screen.getByText('provider_disabled')).toBeInTheDocument()
196
+ );
197
+ expect(screen.queryByText('nativeOnly')).not.toBeInTheDocument();
198
+ expect(screen.getByRole('button', { name: 'save' })).toBeDisabled();
199
+ expect(screen.getByRole('button', { name: 'delete' })).toBeDisabled();
200
+ expect(mocks.providerSubmit).not.toHaveBeenCalled();
201
+ expect(mocks.nativeMutate).not.toHaveBeenCalled();
202
+ }
203
+ );
204
+ it('keeps edits frozen while an ambiguous or pending provider operation is retained', async () => {
205
+ mocks.pending = true;
206
+ mount();
207
+ await waitFor(() =>
208
+ expect(screen.getByText('provider_pending')).toBeInTheDocument()
209
+ );
210
+ expect(screen.getByRole('button', { name: 'saving' })).toBeDisabled();
211
+ expect(mocks.nativeMutate).not.toHaveBeenCalled();
212
+ });
213
+ });
214
+
215
+ it('revalidates all provider projections only after applied and only in the affected workspace', async () => {
216
+ const h = mount();
217
+ const ownKeys = [
218
+ ['databaseCalendarEvents', 'workspace', 'dates'],
219
+ ['googleCalendarEvents', 'workspace', 'dates'],
220
+ ];
221
+ const other = ['googleCalendarEvents', 'other-workspace', 'dates'];
222
+ for (const key of [...ownKeys, other])
223
+ h.client.setQueryData(key, [{ id: 'retained' }]);
224
+ await waitFor(() =>
225
+ expect(screen.getByRole('button', { name: 'save' })).not.toBeDisabled()
226
+ );
227
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
228
+ await waitFor(() => expect(mocks.providerSubmit).toHaveBeenCalled());
229
+ for (const key of ownKeys)
230
+ expect(h.client.getQueryState(key)?.isInvalidated).toBe(false);
231
+ mocks.applied();
232
+ for (const key of ownKeys)
233
+ expect(h.client.getQueryState(key)?.isInvalidated).toBe(true);
234
+ expect(h.client.getQueryState(other)?.isInvalidated).toBe(false);
235
+ });
236
+
237
+ it.each(['google', 'microsoft'])(
238
+ 'does not claim native storage for an imported %s series',
239
+ async (provider) => {
240
+ mocks.getSeries.mockResolvedValue({
241
+ ...series,
242
+ providerSource: { provider, connectionId: 'connection' },
243
+ });
244
+ mocks.capabilities.mockResolvedValue({ enabled: false, sources: [] });
245
+ mount();
246
+ await screen.findByText('provider_disabled');
247
+ expect(screen.queryByText('nativeOnly')).not.toBeInTheDocument();
248
+ }
249
+ );
250
+ it('retains the native storage hint for canonical native events', async () => {
251
+ const { providerSource: _provider, ...nativeSeries } = series;
252
+ mocks.getSeries.mockResolvedValue(nativeSeries);
253
+ mount();
254
+ expect(await screen.findByText('nativeOnly')).toBeInTheDocument();
255
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calendarDayBoundary } from '../../../../lib/calendar-day';
3
+ import { calculateAllDayEventLayout } from './all-day-event-layout';
4
+ import { pastEventTreatment } from './past-event-treatment';
5
+
6
+ const event = {
7
+ id: 'past',
8
+ start_at: '2026-01-01T00:00:00Z',
9
+ end_at: '2026-01-01T01:00:00Z',
10
+ };
11
+ const now = Date.parse('2026-01-02T00:00:00Z');
12
+ describe('past calendar treatment', () => {
13
+ it('fades native events with an overlay above the opaque card', () => {
14
+ expect(pastEventTreatment(event, false, false, now)).toContain(
15
+ 'after:bg-background/50'
16
+ );
17
+ expect(
18
+ pastEventTreatment({ ...event, provider: 'tuturuuu' }, false, false, now)
19
+ ).toContain('hover:after:opacity-0');
20
+ });
21
+ it('fades past events across imported providers', () => {
22
+ for (const value of [
23
+ { ...event, provider: 'google' as const },
24
+ { ...event, provider: 'microsoft' as const },
25
+ { ...event, google_event_id: 'synthetic-provider' },
26
+ ]) {
27
+ expect(pastEventTreatment(value, false, false, now)).toContain(
28
+ 'after:bg-background/50'
29
+ );
30
+ }
31
+ });
32
+ it('preserves explicit adapters and interaction/preview states', () => {
33
+ expect(pastEventTreatment(event, true, false, now)).toBeUndefined();
34
+ expect(pastEventTreatment(event, false, true, now)).toBeUndefined();
35
+ for (const value of [
36
+ { ...event, _isPreview: true },
37
+ { ...event, _optimisticStatus: 'updating' },
38
+ ]) {
39
+ expect(pastEventTreatment(value, false, false, now)).toBeUndefined();
40
+ }
41
+ expect(
42
+ pastEventTreatment(event, false, false, Date.parse(event.end_at))
43
+ ).toBeUndefined();
44
+ });
45
+ });
46
+
47
+ it('all-day fading follows displayed exclusive civil end rather than legacy UTC midnight', () => {
48
+ const event = {
49
+ id: 'synthetic-date',
50
+ start_at: '2030-01-15T00:00:00Z',
51
+ end_at: '2030-01-17T00:00:00Z',
52
+ };
53
+ const civilEnd = calendarDayBoundary(
54
+ new Date(2030, 0, 17),
55
+ 'America/Los_Angeles'
56
+ ).getTime();
57
+ expect(
58
+ pastEventTreatment(
59
+ event,
60
+ false,
61
+ false,
62
+ Date.parse('2030-01-17T04:00:00Z'),
63
+ civilEnd
64
+ )
65
+ ).toBeUndefined();
66
+ expect(
67
+ pastEventTreatment(event, false, false, civilEnd, civilEnd)
68
+ ).toBeUndefined();
69
+ expect(
70
+ pastEventTreatment(event, false, false, civilEnd + 1, civilEnd)
71
+ ).toContain('after:bg-background/50');
72
+ });
73
+
74
+ it.each([
75
+ ['2026-03-08T05:00:00Z', '2026-03-09T04:00:00Z', new Date(2026, 2, 8)],
76
+ ['2026-11-01T04:00:00Z', '2026-11-02T05:00:00Z', new Date(2026, 10, 1)],
77
+ ])(
78
+ 'all-day DST fading respects the actual exclusive midnight (%s)',
79
+ (start_at, end_at, date) => {
80
+ const event = {
81
+ id: 'synthetic-dst',
82
+ start_at: start_at as string,
83
+ end_at: end_at as string,
84
+ };
85
+ const span = calculateAllDayEventLayout(
86
+ [event],
87
+ [date as Date],
88
+ 'America/New_York',
89
+ []
90
+ ).spans[0]!;
91
+ const end = calendarDayBoundary(
92
+ span.actualEndDate.toDate(),
93
+ 'America/New_York'
94
+ ).getTime();
95
+ expect(end).toBe(Date.parse(event.end_at));
96
+ expect(pastEventTreatment(event, false, false, end, end)).toBeUndefined();
97
+ expect(pastEventTreatment(event, false, false, end + 1, end)).toContain(
98
+ 'after:bg-background/50'
99
+ );
100
+ }
101
+ );
102
+ it('merged all-day span stays unfaded through its last civil day', () => {
103
+ const events = [
104
+ {
105
+ id: 'first',
106
+ title: 'Synthetic merged',
107
+ start_at: '2030-01-15T00:00:00Z',
108
+ end_at: '2030-01-16T00:00:00Z',
109
+ },
110
+ {
111
+ id: 'second',
112
+ title: 'Synthetic merged',
113
+ start_at: '2030-01-16T00:00:00Z',
114
+ end_at: '2030-01-17T00:00:00Z',
115
+ },
116
+ ];
117
+ const span = calculateAllDayEventLayout(
118
+ events,
119
+ [new Date(2030, 0, 15), new Date(2030, 0, 16)],
120
+ 'America/Los_Angeles',
121
+ []
122
+ ).spans[0]!;
123
+ expect(span.isMerged).toBe(true);
124
+ const end = calendarDayBoundary(
125
+ span.actualEndDate.toDate(),
126
+ 'America/Los_Angeles'
127
+ ).getTime();
128
+ expect(
129
+ pastEventTreatment(
130
+ span.event,
131
+ false,
132
+ false,
133
+ Date.parse('2030-01-16T20:00:00Z'),
134
+ end
135
+ )
136
+ ).toBeUndefined();
137
+ });
@@ -0,0 +1,25 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+
3
+ // Fade over an opaque fill, never through it: the grid beneath stays occluded.
4
+ const PAST_EVENT_TREATMENT =
5
+ 'isolate overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:z-30 after:bg-background/50 after:transition-opacity after:duration-200 hover:after:opacity-0 focus-visible:after:opacity-0';
6
+
7
+ export function pastEventTreatment(
8
+ event: CalendarEvent,
9
+ preserve = false,
10
+ interacting = false,
11
+ now = Date.now(),
12
+ displayEndAt = Date.parse(event.end_at)
13
+ ): string | undefined {
14
+ const status = (event as CalendarEvent & { _optimisticStatus?: string })
15
+ ._optimisticStatus;
16
+ if (
17
+ preserve ||
18
+ interacting ||
19
+ event._isPreview ||
20
+ status ||
21
+ !(displayEndAt < now)
22
+ )
23
+ return undefined;
24
+ return PAST_EVENT_TREATMENT;
25
+ }