@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,94 @@
1
+ import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { calculateAllDayEventLayout } from './all-day-event-layout';
4
+
5
+ const date = (day: number) => new Date(2030, 0, day);
6
+ describe('all-day civil spans', () => {
7
+ it.each([
8
+ 'America/Los_Angeles',
9
+ 'Pacific/Honolulu',
10
+ 'Asia/Ho_Chi_Minh',
11
+ 'Pacific/Kiritimati',
12
+ ])('retains UTC date-only import civil dates in %s', (zone) => {
13
+ const layout = calculateAllDayEventLayout(
14
+ [
15
+ {
16
+ id: 'synthetic',
17
+ title: 'Synthetic span',
18
+ start_at: '2030-01-15T00:00:00Z',
19
+ end_at: '2030-01-17T00:00:00Z',
20
+ },
21
+ ],
22
+ [date(14), date(15), date(16), date(17)],
23
+ zone,
24
+ []
25
+ );
26
+ expect(
27
+ layout.spans.map(({ startIndex, endIndex }) => [startIndex, endIndex])
28
+ ).toEqual([[1, 2]]);
29
+ });
30
+ });
31
+
32
+ it.each([
33
+ ['2026-03-08T05:00:00Z', '2026-03-09T04:00:00Z', 8],
34
+ ['2026-11-01T04:00:00Z', '2026-11-02T05:00:00Z', 1],
35
+ ])(
36
+ 'renders DST all-day midnights without fixed 24-hour arithmetic (%s)',
37
+ (start_at, end_at, day) => {
38
+ const event = {
39
+ id: 'synthetic-dst',
40
+ title: 'Synthetic DST',
41
+ start_at: start_at as string,
42
+ end_at: end_at as string,
43
+ };
44
+ const month = day === 8 ? 2 : 10;
45
+ const dates = [
46
+ new Date(2026, month, Number(day) - 1),
47
+ new Date(2026, month, Number(day)),
48
+ new Date(2026, month, Number(day) + 1),
49
+ ];
50
+ expect(isAllDayEvent(event, 'America/New_York')).toBe(true);
51
+ expect(
52
+ calculateAllDayEventLayout(
53
+ [event],
54
+ dates,
55
+ 'America/New_York',
56
+ []
57
+ ).spans.map(({ startIndex, endIndex }) => [startIndex, endIndex])
58
+ ).toEqual([[1, 1]]);
59
+ }
60
+ );
61
+ it('clips a long span with an exclusive end and retains cutoff flags', () => {
62
+ const layout = calculateAllDayEventLayout(
63
+ [
64
+ {
65
+ id: 'synthetic-long',
66
+ title: 'Synthetic long',
67
+ start_at: '2029-12-01T00:00:00Z',
68
+ end_at: '2030-02-01T00:00:00Z',
69
+ },
70
+ ],
71
+ [date(15), date(16), date(17)],
72
+ 'America/Los_Angeles',
73
+ []
74
+ );
75
+ expect(layout.spans[0]).toMatchObject({
76
+ startIndex: 0,
77
+ endIndex: 2,
78
+ isCutOffStart: true,
79
+ isCutOffEnd: true,
80
+ });
81
+ });
82
+ it('ignores invalid ranges and exclusive-end-only contact', () => {
83
+ const events = [
84
+ { id: 'invalid', start_at: 'invalid', end_at: 'invalid' },
85
+ {
86
+ id: 'ended',
87
+ start_at: '2030-01-14T00:00:00Z',
88
+ end_at: '2030-01-15T00:00:00Z',
89
+ },
90
+ ];
91
+ expect(
92
+ calculateAllDayEventLayout(events, [date(15)], 'Pacific/Honolulu', []).spans
93
+ ).toEqual([]);
94
+ });
@@ -0,0 +1,322 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { allDayEventCivilDates } from '@tuturuuu/utils/calendar-utils';
3
+ import dayjs from 'dayjs';
4
+ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
5
+ import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
6
+ import timezone from 'dayjs/plugin/timezone';
7
+ import utc from 'dayjs/plugin/utc';
8
+ import { calendarDayKey } from '../../../../lib/calendar-day';
9
+ import { getEventLocationType } from './working-location';
10
+
11
+ dayjs.extend(utc);
12
+ dayjs.extend(timezone);
13
+ dayjs.extend(isSameOrAfter);
14
+ dayjs.extend(isSameOrBefore);
15
+ export const MAX_EVENTS_DISPLAY = 2;
16
+ function getZonedDay(date: Date, _zone?: string) {
17
+ return dayjs(date).startOf('day');
18
+ }
19
+ export interface EventSpan {
20
+ event: CalendarEvent;
21
+ startIndex: number;
22
+ endIndex: number;
23
+ span: number;
24
+ isCutOffStart: boolean; // Event starts before visible range
25
+ isCutOffEnd: boolean; // Event ends after visible range
26
+ actualStartDate: dayjs.Dayjs; // Actual start date of the event
27
+ actualEndDate: dayjs.Dayjs; // Actual end date of the event
28
+ row: number; // Add row property for proper stacking
29
+ isMerged?: boolean;
30
+ mergedEventIds?: string[];
31
+ }
32
+
33
+ export interface EventLayout {
34
+ spans: EventSpan[];
35
+ maxVisibleEventsPerDay: number;
36
+ eventsByDay: EventSpan[][];
37
+ locationSpansFromMerge: EventSpan[];
38
+ }
39
+
40
+ export function calculateAllDayEventLayout(
41
+ allDayEvents: CalendarEvent[],
42
+ visibleDates: Date[],
43
+ tz: string | undefined,
44
+ expandedDates: string[]
45
+ ): EventLayout {
46
+ const spans: EventSpan[] = [];
47
+ const eventsByDay: EventSpan[][] = Array(visibleDates.length)
48
+ .fill(null)
49
+ .map(() => []);
50
+
51
+ // First pass: create event spans without row assignment
52
+ const tempSpans: Omit<EventSpan, 'row'>[] = [];
53
+
54
+ // Process each all-day event
55
+ (visibleDates.length ? allDayEvents : []).forEach((event) => {
56
+ const civilDates = allDayEventCivilDates(event, tz);
57
+ if (!civilDates) return;
58
+ const eventStart = dayjs(civilDates.start);
59
+ const eventEnd = dayjs(civilDates.end);
60
+
61
+ // Find the start and end indices within our visible dates
62
+ let startIndex = -1;
63
+ let endIndex = -1;
64
+
65
+ // First pass: find any overlap with visible dates
66
+ const firstVisibleDate = getZonedDay(visibleDates[0]!, tz);
67
+ const lastVisibleDate = getZonedDay(
68
+ visibleDates[visibleDates.length - 1]!,
69
+ tz
70
+ );
71
+
72
+ // Check if event overlaps with our visible date range at all
73
+ // Event overlaps if: event_start < visible_end AND event_end > visible_start
74
+ const eventOverlaps =
75
+ eventStart.isBefore(lastVisibleDate.add(1, 'day'), 'day') &&
76
+ eventEnd.isAfter(firstVisibleDate, 'day');
77
+
78
+ if (!eventOverlaps) {
79
+ return; // Skip this event if it doesn't overlap with visible dates
80
+ }
81
+
82
+ // Improved logic for multi-day events
83
+ // Start index: first visible date that overlaps with the event
84
+ if (eventStart.isSameOrBefore(firstVisibleDate, 'day')) {
85
+ // Event starts before or on the first visible date
86
+ startIndex = 0;
87
+ } else {
88
+ // Find the first visible date that matches the event start
89
+ for (let i = 0; i < visibleDates.length; i++) {
90
+ const currentDate = getZonedDay(visibleDates[i]!, tz);
91
+ if (
92
+ currentDate.isSameOrAfter(eventStart, 'day') &&
93
+ currentDate.isBefore(eventEnd, 'day')
94
+ ) {
95
+ startIndex = i;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+
101
+ // End index: last visible date that overlaps with the event
102
+ if (eventEnd.isAfter(lastVisibleDate.add(1, 'day'), 'day')) {
103
+ // Event ends after the last visible date
104
+ endIndex = visibleDates.length - 1;
105
+ } else {
106
+ // Find the last visible date that the event covers
107
+ for (let i = visibleDates.length - 1; i >= 0; i--) {
108
+ const currentDate = getZonedDay(visibleDates[i]!, tz);
109
+ if (
110
+ currentDate.isBefore(eventEnd, 'day') &&
111
+ currentDate.isSameOrAfter(eventStart, 'day')
112
+ ) {
113
+ endIndex = i;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+
119
+ // Include events that have at least one day visible in our date range
120
+ if (startIndex !== -1 && endIndex !== -1) {
121
+ const span = endIndex - startIndex + 1;
122
+
123
+ // Fix: Proper cut-off logic for all-day events
124
+ // For all-day events, end_at is typically start of next day (exclusive)
125
+ // So we need to check actual duration, not just date comparison
126
+ const actualDurationDays = eventEnd.diff(eventStart, 'day');
127
+ const isActuallyMultiDay = actualDurationDays > 1;
128
+
129
+ // Only show cut-off indicators for events that actually span multiple days
130
+ // AND are cut off by the visible range
131
+ const isCutOffStart =
132
+ isActuallyMultiDay && eventStart.isBefore(firstVisibleDate, 'day');
133
+ const isCutOffEnd =
134
+ isActuallyMultiDay &&
135
+ eventEnd.isAfter(lastVisibleDate.add(1, 'day'), 'day');
136
+
137
+ const eventSpan: Omit<EventSpan, 'row'> = {
138
+ event,
139
+ startIndex,
140
+ endIndex,
141
+ span,
142
+ // Add indicators for cut-off events
143
+ isCutOffStart,
144
+ isCutOffEnd,
145
+ actualStartDate: eventStart,
146
+ actualEndDate: eventEnd,
147
+ };
148
+
149
+ tempSpans.push(eventSpan);
150
+ }
151
+ });
152
+
153
+ // Second pass: Merge consecutive SINGLE-DAY events with same title and color
154
+ // Optimized O(n log n) approach: group by mergeKey, sort groups, linear scan per group
155
+ const mergedTempSpans: Omit<EventSpan, 'row'>[] = [];
156
+
157
+ // Group single-day spans by mergeKey; multi-day events go directly to output
158
+ const groups = new Map<string, Omit<EventSpan, 'row'>[]>();
159
+
160
+ for (const span of tempSpans) {
161
+ if (span.span !== 1) {
162
+ // Multi-day events - don't merge, just add directly
163
+ mergedTempSpans.push(span);
164
+ continue;
165
+ }
166
+
167
+ const mergeKey = `${(span.event.title ?? '').toLowerCase().trim()}|${span.event.color ?? 'BLUE'}`;
168
+ const group = groups.get(mergeKey) ?? [];
169
+ group.push(span);
170
+ groups.set(mergeKey, group);
171
+ }
172
+
173
+ // Process each group with linear merge
174
+ for (const [, group] of groups) {
175
+ // Sort by startIndex (O(k log k) per group, where k is group size)
176
+ group.sort((a, b) => a.startIndex - b.startIndex);
177
+
178
+ let currentMerged: Omit<EventSpan, 'row'> | null = null;
179
+ let mergedIds: string[] = [];
180
+
181
+ for (const span of group) {
182
+ if (!currentMerged) {
183
+ // Start a new potential merge chain
184
+ currentMerged = { ...span };
185
+ mergedIds = [span.event.id];
186
+ } else if (span.startIndex === currentMerged.endIndex + 1) {
187
+ // Adjacent - extend the merge
188
+ currentMerged.endIndex = span.endIndex;
189
+ currentMerged.span =
190
+ currentMerged.endIndex - currentMerged.startIndex + 1;
191
+ currentMerged.isCutOffEnd =
192
+ currentMerged.isCutOffEnd || span.isCutOffEnd;
193
+ currentMerged.actualEndDate = currentMerged.actualEndDate.isAfter(
194
+ span.actualEndDate
195
+ )
196
+ ? currentMerged.actualEndDate
197
+ : span.actualEndDate;
198
+ mergedIds.push(span.event.id);
199
+ } else {
200
+ // Gap - push current merged span and start a new one
201
+ if (mergedIds.length > 1) {
202
+ currentMerged.isMerged = true;
203
+ currentMerged.mergedEventIds = mergedIds;
204
+ }
205
+ mergedTempSpans.push(currentMerged);
206
+ currentMerged = { ...span };
207
+ mergedIds = [span.event.id];
208
+ }
209
+ }
210
+
211
+ // Push the final merged span from this group
212
+ if (currentMerged) {
213
+ if (mergedIds.length > 1) {
214
+ currentMerged.isMerged = true;
215
+ currentMerged.mergedEventIds = mergedIds;
216
+ }
217
+ mergedTempSpans.push(currentMerged);
218
+ }
219
+ }
220
+
221
+ // Sort final output by startIndex for consistent ordering
222
+ mergedTempSpans.sort((a, b) => a.startIndex - b.startIndex);
223
+
224
+ // Third pass: assign rows (ONLY for non-location events)
225
+ // Location events will be rendered separately as a compact timeline strip
226
+ // Extract location spans before filtering them out for row assignment
227
+ const locationSpansFromMerge = mergedTempSpans
228
+ .filter((span) => getEventLocationType(span.event) !== null)
229
+ .map((span) => ({ ...span, row: 0 })); // Give them row 0 as placeholder
230
+
231
+ const nonLocationSpans = mergedTempSpans.filter(
232
+ (span) => getEventLocationType(span.event) === null
233
+ );
234
+
235
+ // Sort: 1) by span length (longer events first for better packing), 2) by start date
236
+ const sortedTempSpans = nonLocationSpans.sort((a, b) => {
237
+ // Longer events first (they need to claim their full row first)
238
+ const spanDiff = b.span - a.span;
239
+ if (spanDiff !== 0) return spanDiff;
240
+
241
+ // Then by start date
242
+ return a.actualStartDate.diff(b.actualStartDate);
243
+ });
244
+
245
+ // Track occupied rows for each day
246
+ const occupiedRows: boolean[][] = Array(visibleDates.length)
247
+ .fill(null)
248
+ .map(() => []);
249
+
250
+ // Assign rows to events
251
+ sortedTempSpans.forEach((tempSpan) => {
252
+ // Find the first available row that works for all days this event spans
253
+ let row = 0;
254
+ let rowFound = false;
255
+
256
+ while (!rowFound) {
257
+ // Check if this row is available for all days the event spans
258
+ let canUseRow = true;
259
+ for (
260
+ let dayIndex = tempSpan.startIndex;
261
+ dayIndex <= tempSpan.endIndex;
262
+ dayIndex++
263
+ ) {
264
+ if (occupiedRows[dayIndex]?.[row]) {
265
+ canUseRow = false;
266
+ break;
267
+ }
268
+ }
269
+
270
+ if (canUseRow) {
271
+ // Mark this row as occupied for all days the event spans
272
+ for (
273
+ let dayIndex = tempSpan.startIndex;
274
+ dayIndex <= tempSpan.endIndex;
275
+ dayIndex++
276
+ ) {
277
+ if (!occupiedRows[dayIndex]) {
278
+ occupiedRows[dayIndex] = [];
279
+ }
280
+ occupiedRows[dayIndex]![row] = true;
281
+ }
282
+ rowFound = true;
283
+ } else {
284
+ row++;
285
+ }
286
+ }
287
+
288
+ // Create the final event span with row assignment
289
+ const eventSpan: EventSpan = {
290
+ ...tempSpan,
291
+ row,
292
+ };
293
+
294
+ spans.push(eventSpan);
295
+
296
+ // Add this event to each day it spans
297
+ for (let i = tempSpan.startIndex; i <= tempSpan.endIndex; i++) {
298
+ eventsByDay[i]?.push(eventSpan);
299
+ }
300
+ });
301
+
302
+ // Calculate max visible events per day for layout purposes
303
+ let maxVisibleEventsPerDay = 0;
304
+ eventsByDay.forEach((dayEvents, dayIndex) => {
305
+ const dateKey = calendarDayKey(visibleDates[dayIndex]!);
306
+
307
+ const shouldShowAll = dayEvents.length === MAX_EVENTS_DISPLAY + 1;
308
+ const isExpanded = expandedDates.includes(dateKey) || shouldShowAll;
309
+ const visibleCount = isExpanded
310
+ ? dayEvents.length
311
+ : Math.min(dayEvents.length, MAX_EVENTS_DISPLAY);
312
+
313
+ maxVisibleEventsPerDay = Math.max(maxVisibleEventsPerDay, visibleCount);
314
+ });
315
+
316
+ return {
317
+ spans,
318
+ maxVisibleEventsPerDay,
319
+ eventsByDay,
320
+ locationSpansFromMerge,
321
+ };
322
+ }
@@ -0,0 +1,34 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+
3
+ export function createCalendarDraftRecovery() {
4
+ let submitted:
5
+ | {
6
+ payload: Partial<CalendarEvent>;
7
+ requestId: string;
8
+ recoverable: boolean;
9
+ }
10
+ | undefined;
11
+ let recovered: CalendarEvent | undefined;
12
+ return {
13
+ original: () => recovered,
14
+ submitted: () => submitted,
15
+ capture(
16
+ payload: Partial<CalendarEvent>,
17
+ requestId: string,
18
+ recoverable: boolean
19
+ ) {
20
+ // Snapshot once. An uncertain request never changes its ID or contents.
21
+ submitted ??= {
22
+ payload: structuredClone(payload),
23
+ requestId,
24
+ recoverable,
25
+ };
26
+ return submitted;
27
+ },
28
+ complete(event: CalendarEvent) {
29
+ if (!event.id || event.id === 'new')
30
+ throw new Error('Unconfirmed creation');
31
+ recovered = event;
32
+ },
33
+ };
34
+ }
@@ -215,34 +215,65 @@ describe('calendar view interactions', () => {
215
215
  });
216
216
 
217
217
  describe('calendar view opaque effective colors', () => {
218
- it('preserves borderline provider contrast with a visible month hover ring', () => {
219
- state.events = [
220
- {
221
- id: 'borderline-rgb',
222
- title: 'Borderline RGB',
223
- color: 'BLUE',
224
- start_at: new Date(2026, 8, 7, 10).toISOString(),
225
- end_at: new Date(2026, 8, 7, 11).toISOString(),
226
- scheduling_metadata: {
227
- google_color: { version: 1, inherited: false, background: '#757575' },
218
+ it.each([false, true])(
219
+ 'preserves provider contrast and accessible month treatment, past=%s',
220
+ (past) => {
221
+ vi.useFakeTimers({ toFake: ['Date'] });
222
+ vi.setSystemTime(new Date(2026, 8, past ? 8 : 7, 9));
223
+ state.events = [
224
+ {
225
+ id: 'borderline-rgb',
226
+ title: 'Borderline RGB',
227
+ color: 'BLUE',
228
+ start_at: new Date(2026, 8, 7, 10).toISOString(),
229
+ end_at: new Date(2026, 8, 7, 11).toISOString(),
230
+ scheduling_metadata: {
231
+ google_color: {
232
+ version: 1,
233
+ inherited: false,
234
+ background: '#757575',
235
+ },
236
+ },
228
237
  },
229
- },
230
- ];
231
- render(<MonthCalendar date={date} viewedMonth={date} locale="en" />);
232
- const card = screen.getByRole('button', { name: /Borderline RGB/ });
233
- expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
234
- expect(card.style.color).toBe('rgb(255, 255, 255)');
235
- fireEvent.mouseEnter(card);
236
- expect(card).toHaveClass('hover:ring-1', 'hover:ring-current');
237
- expect(card.className).not.toMatch(/(?:brightness|opacity)-/);
238
- expect(card.style.filter).toBe('');
239
- expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
240
- expect(card.style.color).toBe('rgb(255, 255, 255)');
241
- const luminance = ((117 / 255 + 0.055) / 1.055) ** 2.4;
242
- expect(1.05 / (luminance + 0.05)).toBeGreaterThanOrEqual(4.5);
243
- fireEvent.click(card);
244
- expect(state.openModal).toHaveBeenCalledWith('borderline-rgb');
245
- });
238
+ ];
239
+ render(<MonthCalendar date={date} viewedMonth={date} locale="en" />);
240
+ const card = screen.getByRole('button', { name: /Borderline RGB/ });
241
+ expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
242
+ expect(card.style.color).toBe('rgb(255, 255, 255)');
243
+ fireEvent.mouseEnter(card);
244
+ expect(card).toHaveClass('hover:ring-1', 'hover:ring-current');
245
+ const wholeCardDimming = card.className.split(/\s+/).filter((token) => {
246
+ const variants = token.split(':');
247
+ return (
248
+ !variants.includes('after') &&
249
+ /^(?:brightness|opacity)-/.test(variants.at(-1) ?? '')
250
+ );
251
+ });
252
+ expect(wholeCardDimming).toEqual([]);
253
+ expect(card.style.opacity).toBe('');
254
+ expect(card).toHaveClass(
255
+ 'focus-visible:outline-2',
256
+ 'focus-visible:outline-ring'
257
+ );
258
+ if (past) {
259
+ expect(card).toHaveClass(
260
+ 'after:bg-background/50',
261
+ 'after:pointer-events-none',
262
+ 'hover:after:opacity-0',
263
+ 'focus-visible:after:opacity-0'
264
+ );
265
+ } else {
266
+ expect(card).not.toHaveClass('after:bg-background/50');
267
+ }
268
+ expect(card.style.filter).toBe('');
269
+ expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
270
+ expect(card.style.color).toBe('rgb(255, 255, 255)');
271
+ const luminance = ((117 / 255 + 0.055) / 1.055) ** 2.4;
272
+ expect(1.05 / (luminance + 0.05)).toBeGreaterThanOrEqual(4.5);
273
+ fireEvent.click(card);
274
+ expect(state.openModal).toHaveBeenCalledWith('borderline-rgb');
275
+ }
276
+ );
246
277
 
247
278
  it.each([false, true])(
248
279
  'uses provider RGB in month and agenda, inherited=%s',
@@ -113,6 +113,30 @@ describe('EventCard read-only adapter events', () => {
113
113
  expect(card.style.opacity).toBe('1');
114
114
  });
115
115
 
116
+ it('fades past first-party cards above an opaque fill and restores on hover/focus', () => {
117
+ calendarMocks.preservePastEventOpacity = false;
118
+ renderEventCard({
119
+ id: 'past-native',
120
+ title: 'Past event',
121
+ color: 'BLUE',
122
+ start_at: '2026-06-26T08:30:00Z',
123
+ end_at: '2026-06-26T09:30:00Z',
124
+ });
125
+ const card = screen.getByTestId('calendar-event-past-native');
126
+ expect(card.style.opacity).toBe('1');
127
+ expect(card.style.backgroundColor).toBe('rgb(33, 150, 243)');
128
+ expect(card).toHaveClass(
129
+ 'after:bg-background/50',
130
+ 'hover:after:opacity-0',
131
+ 'focus-visible:after:opacity-0'
132
+ );
133
+ fireEvent.mouseEnter(card);
134
+ expect(card.style.opacity).toBe('1');
135
+ expect(card).toHaveClass('hover:after:opacity-0');
136
+ fireEvent.mouseLeave(card);
137
+ expect(card).toHaveClass('after:bg-background/50');
138
+ });
139
+
116
140
  it('updates the same mounted card when only provider metadata changes', () => {
117
141
  const event: CalendarEvent = {
118
142
  id: 'metadata-rerender',
@@ -176,7 +200,7 @@ describe('EventCard read-only adapter events', () => {
176
200
  expect(card.style.visibility).toBe('hidden');
177
201
  expect(card.style.backgroundColor).toBe('rgb(0, 255, 136)');
178
202
  expect(card.className).not.toMatch(/opacity-(?:30|50|60|80)/);
179
- expect(card).toHaveClass('line-through');
203
+ expect(card).not.toHaveClass('line-through');
180
204
  rendered.unmount();
181
205
  calendarMocks.hoveredBaseEventId = null;
182
206
  calendarMocks.hoveredEventColumn = null;
@@ -37,6 +37,7 @@ import {
37
37
  } from '../../context-menu';
38
38
  import { GRID_SNAP, HOUR_HEIGHT, MAX_HOURS, MIN_EVENT_HEIGHT } from './config';
39
39
  import { CalendarEventProviderIcon } from './event-provider-display';
40
+ import { pastEventTreatment } from './past-event-treatment';
40
41
  import { useCalendarSettings } from './settings/settings-context';
41
42
 
42
43
  dayjs.extend(timezone);
@@ -67,7 +68,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
67
68
  title,
68
69
  description,
69
70
  // start_at,
70
- end_at,
71
71
  color = 'BLUE',
72
72
  locked = false,
73
73
  _isMultiDay,
@@ -957,9 +957,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
957
957
  return d.format(timeFormat === '24h' ? 'HH:mm' : 'h:mm a');
958
958
  };
959
959
 
960
- // Check if the event is in the past
961
- const isPastEvent = new Date(end_at) < new Date();
962
-
963
960
  // Check if this event is affected by preview (will be modified/deleted)
964
961
  const isAffectedByPreview =
965
962
  affectedEventIds?.has(id) || affectedEventIds?.has(event._originalId || '');
@@ -1005,7 +1002,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1005
1002
  });
1006
1003
  };
1007
1004
 
1008
- // Determine if this event has calendar source info
1009
1005
  const hasCalendarInfo =
1010
1006
  google_calendar_id && google_calendar_id !== 'primary';
1011
1007
  const calendarDisplayName =
@@ -1014,8 +1010,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1014
1010
  ? 'Primary Calendar'
1015
1011
  : google_calendar_id);
1016
1012
 
1017
- // Calculate stacking position for visual effects
1018
- // Note: Actual column-based positioning is handled in the useEffect
1019
1013
  const hasOverlaps = overlapCount > 1;
1020
1014
 
1021
1015
  // Check if an event in the same group is being hovered
@@ -1051,10 +1045,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1051
1045
  'transform shadow-md': isDragging || isResizing, // Subtle transform during interaction
1052
1046
  'shadow-sm': hasOverlaps && !isDragging && !isResizing, // Subtle shadow for stacked events
1053
1047
  'hover:shadow-md': hasOverlaps, // Enhanced shadow on hover for stacked events
1054
- 'line-through':
1055
- isPastEvent &&
1056
- !isAffectedByPreview &&
1057
- !preservePastEventOpacity, // Mark past events in the text
1058
1048
  'rounded-l-none border-l-4': showStartIndicator, // Special styling for continuation from previous day
1059
1049
  'rounded-r-none border-r-4': showEndIndicator, // Special styling for continuation to next day
1060
1050
  'border-l-[3px]': hasCalendarInfo, // Thicker border for calendar events
@@ -1068,7 +1058,15 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1068
1058
  isOptimisticallyPending && !isOptimisticallyMutating,
1069
1059
  },
1070
1060
  level ? 'border border-l-2' : 'border-l-2',
1071
- border
1061
+ border,
1062
+ pastEventTreatment(
1063
+ event,
1064
+ preservePastEventOpacity,
1065
+ isDragging ||
1066
+ isResizing ||
1067
+ !!isAffectedByPreview ||
1068
+ updateStatus === 'error'
1069
+ )
1072
1070
  )}
1073
1071
  style={{
1074
1072
  transition: