@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
@@ -1,60 +1,32 @@
1
1
  import { Calendar, ChevronDown, ChevronUp } from '@tuturuuu/icons';
2
2
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
3
  import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
4
+ import { useCalendarClock } from '@tuturuuu/ui/hooks/use-calendar-clock';
4
5
  import { useCalendarSync } from '@tuturuuu/ui/hooks/use-calendar-sync';
5
6
  import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
6
7
  import { getEventStyles } from '@tuturuuu/utils/color-helper';
7
8
  import { cn } from '@tuturuuu/utils/format';
8
9
  import dayjs from 'dayjs';
9
- import isBetween from 'dayjs/plugin/isBetween';
10
- import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
11
- import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
12
10
  import timezone from 'dayjs/plugin/timezone';
13
11
  import React, { useCallback, useMemo, useRef, useState } from 'react';
14
12
  import {
15
13
  calendarDayBoundary,
16
14
  calendarDayKey,
17
15
  } from '../../../../lib/calendar-day';
16
+ import {
17
+ calculateAllDayEventLayout,
18
+ type EventSpan,
19
+ MAX_EVENTS_DISPLAY,
20
+ } from './all-day-event-layout';
18
21
  import { MIN_COLUMN_WIDTH } from './config';
19
22
  import { CalendarEventProviderIcon } from './event-provider-display';
20
23
  import { LocationTimeline } from './location-timeline';
24
+ import { pastEventTreatment } from './past-event-treatment';
21
25
  import { useCalendarSettings } from './settings/settings-context';
22
26
  import { getEventLocationType } from './working-location';
23
27
 
24
- dayjs.extend(isBetween);
25
- dayjs.extend(isSameOrAfter);
26
- dayjs.extend(isSameOrBefore);
27
28
  dayjs.extend(timezone);
28
29
 
29
- function getZonedDay(date: Date, zone?: string) {
30
- const boundary = dayjs(calendarDayBoundary(date, zone));
31
- return zone && zone !== 'auto' ? boundary.tz(zone) : boundary;
32
- }
33
-
34
- const MAX_EVENTS_DISPLAY = 2;
35
-
36
- interface EventSpan {
37
- event: CalendarEvent;
38
- startIndex: number;
39
- endIndex: number;
40
- span: number;
41
- isCutOffStart: boolean; // Event starts before visible range
42
- isCutOffEnd: boolean; // Event ends after visible range
43
- actualStartDate: dayjs.Dayjs; // Actual start date of the event
44
- actualEndDate: dayjs.Dayjs; // Actual end date of the event
45
- row: number; // Add row property for proper stacking
46
- isMerged?: boolean;
47
- mergedEventIds?: string[];
48
- }
49
-
50
- interface EventLayout {
51
- spans: EventSpan[];
52
- maxVisibleEventsPerDay: number;
53
- eventsByDay: EventSpan[][];
54
- locationSpansFromMerge: EventSpan[];
55
- }
56
-
57
- // Drag state interface
58
30
  interface DragState {
59
31
  isDragging: boolean;
60
32
  draggedEvent: CalendarEvent | null;
@@ -65,7 +37,6 @@ interface DragState {
65
37
  currentY: number;
66
38
  targetDateIndex: number | null;
67
39
  originalDateIndex: number;
68
- // Add preview span for better visual feedback
69
40
  previewSpan: {
70
41
  startIndex: number;
71
42
  span: number;
@@ -73,7 +44,6 @@ interface DragState {
73
44
  } | null;
74
45
  }
75
46
 
76
- // 1. Extract EventContent component for shared rendering
77
47
  const EventContent = ({ event }: { event: CalendarEvent }) => (
78
48
  <>
79
49
  <CalendarEventProviderIcon
@@ -85,14 +55,20 @@ const EventContent = ({ event }: { event: CalendarEvent }) => (
85
55
  );
86
56
 
87
57
  export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
88
- const { openModal, updateEvent, addEvent, deleteEvent } = useCalendar();
58
+ const {
59
+ openModal,
60
+ updateEvent,
61
+ addEvent,
62
+ deleteEvent,
63
+ preservePastEventOpacity,
64
+ } = useCalendar();
89
65
  const { allDayEvents } = useCalendarSync();
66
+ const now = useCalendarClock();
90
67
  const { settings } = useCalendarSettings();
91
68
  const showWeekends = settings.appearance.showWeekends;
92
69
  const tz = settings?.timezone?.timezone;
93
70
  const [expandedDates, setExpandedDates] = useState<string[]>([]);
94
71
 
95
- // Drag and drop state
96
72
  const [dragState, setDragState] = useState<DragState>({
97
73
  isDragging: false,
98
74
  draggedEvent: null,
@@ -106,27 +82,22 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
106
82
  previewSpan: null,
107
83
  });
108
84
 
109
- // Use ref to store the current drag state for stable handlers
110
85
  const dragStateRef = useRef<DragState>(dragState);
111
86
  dragStateRef.current = dragState;
112
87
 
113
- // Refs for drag handling
114
88
  const containerRef = useRef<HTMLDivElement>(null);
115
89
  const dragPreviewRef = useRef<HTMLDivElement>(null);
116
90
 
117
- // Add refs for drag threshold and timer
118
91
  const dragStartPos = useRef<{ x: number; y: number } | null>(null);
119
92
  const dragInitiated = useRef(false);
120
93
  const longPressTimer = useRef<NodeJS.Timeout | null>(null);
121
94
  const DRAG_THRESHOLD = 5; // px
122
95
  const LONG_PRESS_DURATION = 250; // ms
123
96
 
124
- // Constants for layout
125
97
  const EVENT_LEFT_OFFSET = 4; // 4px offset from left edge
126
98
  /** Height of the LocationTimeline strip in rem. Used to offset regular events when location events are present. */
127
99
  const LOCATION_TIMELINE_HEIGHT_REM = 1.5;
128
100
 
129
- // Filter out weekend days if showWeekends is false
130
101
  const visibleDates = showWeekends
131
102
  ? dates
132
103
  : dates.filter((date) => {
@@ -134,11 +105,9 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
134
105
  return day !== 0 && day !== 6; // 0 = Sunday, 6 = Saturday
135
106
  });
136
107
 
137
- // Stable drag event handlers using refs
138
108
  const handleDragStart = useCallback(
139
109
  (e: React.MouseEvent, eventSpan: EventSpan) => {
140
- // Don't allow dragging if there are no visible dates or only one date
141
- if (visibleDates.length <= 1) return;
110
+ if (visibleDates.length <= 1 || eventSpan.event.seriesId) return;
142
111
 
143
112
  e.preventDefault();
144
113
  e.stopPropagation();
@@ -146,7 +115,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
146
115
  const rect = containerRef.current?.getBoundingClientRect();
147
116
  if (!rect) return;
148
117
 
149
- // Calculate initial preview span
150
118
  const previewSpan = {
151
119
  startIndex: eventSpan.startIndex,
152
120
  span: eventSpan.span,
@@ -166,14 +134,12 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
166
134
  previewSpan,
167
135
  });
168
136
 
169
- // Set cursor and prevent text selection
170
137
  document.body.style.cursor = 'grabbing';
171
138
  document.body.classList.add('select-none');
172
139
  },
173
140
  [visibleDates.length]
174
141
  );
175
142
 
176
- // Stable drag move handler
177
143
  const handleDragMove = useCallback(
178
144
  (e: MouseEvent) => {
179
145
  const currentDragState = dragStateRef.current;
@@ -187,7 +153,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
187
153
  const rect = containerRef.current.getBoundingClientRect();
188
154
  const relativeX = e.clientX - rect.left;
189
155
 
190
- // Calculate which date column we're over
191
156
  const columnWidth = rect.width / visibleDates.length;
192
157
  const targetDateIndex = Math.floor(relativeX / columnWidth);
193
158
  const clampedTargetIndex = Math.max(
@@ -195,7 +160,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
195
160
  Math.min(targetDateIndex, visibleDates.length - 1)
196
161
  );
197
162
 
198
- // Calculate new preview span based on target position
199
163
  const originalSpan = currentDragState.draggedEventSpan.span;
200
164
  const newStartIndex = clampedTargetIndex;
201
165
  const newEndIndex = Math.min(
@@ -221,7 +185,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
221
185
  [visibleDates.length]
222
186
  );
223
187
 
224
- // Stable drag end handler
225
188
  const handleDragEnd = useCallback(async () => {
226
189
  const currentDragState = dragStateRef.current;
227
190
  if (
@@ -234,17 +197,14 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
234
197
  const targetDateIndex = currentDragState.targetDateIndex;
235
198
  const originalDateIndex = currentDragState.originalDateIndex;
236
199
 
237
- // Only validate originalDateIndex (targetDateIndex is always clamped)
238
200
  if (originalDateIndex < 0 || originalDateIndex >= visibleDates.length) {
239
201
  console.warn('Invalid original date index for drag operation');
240
202
  return;
241
203
  }
242
204
 
243
- // Helper for dayjs + timezone
244
205
  const getDayjsDate = (d: string | Date) =>
245
206
  tz === 'auto' ? dayjs(d) : dayjs(d).tz(tz);
246
207
 
247
- // Reset drag state and cursor
248
208
  setDragState({
249
209
  isDragging: false,
250
210
  draggedEvent: null,
@@ -261,13 +221,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
261
221
  document.body.style.cursor = '';
262
222
  document.body.classList.remove('select-none');
263
223
 
264
- // If dropped on the same date or invalid target, do nothing
265
224
  if (targetDateIndex === null || targetDateIndex === originalDateIndex) {
266
225
  return;
267
226
  }
268
227
 
269
228
  try {
270
- // Use helper for all dayjs conversions
271
229
  const originalStartDate = getDayjsDate(
272
230
  visibleDates[originalDateIndex] ?? new Date()
273
231
  );
@@ -293,7 +251,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
293
251
  }
294
252
  }, [visibleDates, tz, updateEvent]);
295
253
 
296
- // Set up global mouse event listeners with stable handlers
297
254
  React.useEffect(() => {
298
255
  if (dragState.isDragging) {
299
256
  document.addEventListener('mousemove', handleDragMove);
@@ -306,302 +263,23 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
306
263
  }
307
264
  }, [dragState.isDragging, handleDragMove, handleDragEnd]);
308
265
 
309
- // Process events to determine their spans across visible dates
310
- const eventLayout = useMemo((): EventLayout => {
311
- const spans: EventSpan[] = [];
312
- const eventsByDay: EventSpan[][] = Array(visibleDates.length)
313
- .fill(null)
314
- .map(() => []);
315
-
316
- // First pass: create event spans without row assignment
317
- const tempSpans: Omit<EventSpan, 'row'>[] = [];
318
-
319
- // Process each all-day event
320
- (visibleDates.length ? allDayEvents : []).forEach((event) => {
321
- const eventStart =
322
- tz === 'auto' ? dayjs(event.start_at) : dayjs(event.start_at).tz(tz);
323
- const eventEnd =
324
- tz === 'auto' ? dayjs(event.end_at) : dayjs(event.end_at).tz(tz);
325
-
326
- // Find the start and end indices within our visible dates
327
- let startIndex = -1;
328
- let endIndex = -1;
329
-
330
- // First pass: find any overlap with visible dates
331
- const firstVisibleDate = getZonedDay(visibleDates[0]!, tz);
332
- const lastVisibleDate = getZonedDay(
333
- visibleDates[visibleDates.length - 1]!,
334
- tz
335
- );
336
-
337
- // Check if event overlaps with our visible date range at all
338
- // Event overlaps if: event_start < visible_end AND event_end > visible_start
339
- const eventOverlaps =
340
- eventStart.isBefore(lastVisibleDate.add(1, 'day'), 'day') &&
341
- eventEnd.isAfter(firstVisibleDate, 'day');
342
-
343
- if (!eventOverlaps) {
344
- return; // Skip this event if it doesn't overlap with visible dates
345
- }
346
-
347
- // Improved logic for multi-day events
348
- // Start index: first visible date that overlaps with the event
349
- if (eventStart.isSameOrBefore(firstVisibleDate, 'day')) {
350
- // Event starts before or on the first visible date
351
- startIndex = 0;
352
- } else {
353
- // Find the first visible date that matches the event start
354
- for (let i = 0; i < visibleDates.length; i++) {
355
- const currentDate = getZonedDay(visibleDates[i]!, tz);
356
- if (
357
- currentDate.isSameOrAfter(eventStart, 'day') &&
358
- currentDate.isBefore(eventEnd, 'day')
359
- ) {
360
- startIndex = i;
361
- break;
362
- }
363
- }
364
- }
365
-
366
- // End index: last visible date that overlaps with the event
367
- if (eventEnd.isAfter(lastVisibleDate.add(1, 'day'), 'day')) {
368
- // Event ends after the last visible date
369
- endIndex = visibleDates.length - 1;
370
- } else {
371
- // Find the last visible date that the event covers
372
- for (let i = visibleDates.length - 1; i >= 0; i--) {
373
- const currentDate = getZonedDay(visibleDates[i]!, tz);
374
- if (
375
- currentDate.isBefore(eventEnd, 'day') &&
376
- currentDate.isSameOrAfter(eventStart, 'day')
377
- ) {
378
- endIndex = i;
379
- break;
380
- }
381
- }
382
- }
383
-
384
- // Include events that have at least one day visible in our date range
385
- if (startIndex !== -1 && endIndex !== -1) {
386
- const span = endIndex - startIndex + 1;
387
-
388
- // Fix: Proper cut-off logic for all-day events
389
- // For all-day events, end_at is typically start of next day (exclusive)
390
- // So we need to check actual duration, not just date comparison
391
- const actualDurationDays = eventEnd.diff(eventStart, 'day');
392
- const isActuallyMultiDay = actualDurationDays > 1;
393
-
394
- // Only show cut-off indicators for events that actually span multiple days
395
- // AND are cut off by the visible range
396
- const isCutOffStart =
397
- isActuallyMultiDay && eventStart.isBefore(firstVisibleDate, 'day');
398
- const isCutOffEnd =
399
- isActuallyMultiDay &&
400
- eventEnd.isAfter(lastVisibleDate.add(1, 'day'), 'day');
401
-
402
- const eventSpan: Omit<EventSpan, 'row'> = {
403
- event,
404
- startIndex,
405
- endIndex,
406
- span,
407
- // Add indicators for cut-off events
408
- isCutOffStart,
409
- isCutOffEnd,
410
- actualStartDate: eventStart,
411
- actualEndDate: eventEnd,
412
- };
413
-
414
- tempSpans.push(eventSpan);
415
- }
416
- });
417
-
418
- // Second pass: Merge consecutive SINGLE-DAY events with same title and color
419
- // Optimized O(n log n) approach: group by mergeKey, sort groups, linear scan per group
420
- const mergedTempSpans: Omit<EventSpan, 'row'>[] = [];
421
-
422
- // Group single-day spans by mergeKey; multi-day events go directly to output
423
- const groups = new Map<string, Omit<EventSpan, 'row'>[]>();
424
-
425
- for (const span of tempSpans) {
426
- if (span.span !== 1) {
427
- // Multi-day events - don't merge, just add directly
428
- mergedTempSpans.push(span);
429
- continue;
430
- }
431
-
432
- const mergeKey = `${(span.event.title ?? '').toLowerCase().trim()}|${span.event.color ?? 'BLUE'}`;
433
- const group = groups.get(mergeKey) ?? [];
434
- group.push(span);
435
- groups.set(mergeKey, group);
436
- }
437
-
438
- // Process each group with linear merge
439
- for (const [, group] of groups) {
440
- // Sort by startIndex (O(k log k) per group, where k is group size)
441
- group.sort((a, b) => a.startIndex - b.startIndex);
442
-
443
- let currentMerged: Omit<EventSpan, 'row'> | null = null;
444
- let mergedIds: string[] = [];
445
-
446
- for (const span of group) {
447
- if (!currentMerged) {
448
- // Start a new potential merge chain
449
- currentMerged = { ...span };
450
- mergedIds = [span.event.id];
451
- } else if (span.startIndex === currentMerged.endIndex + 1) {
452
- // Adjacent - extend the merge
453
- currentMerged.endIndex = span.endIndex;
454
- currentMerged.span =
455
- currentMerged.endIndex - currentMerged.startIndex + 1;
456
- currentMerged.isCutOffEnd =
457
- currentMerged.isCutOffEnd || span.isCutOffEnd;
458
- currentMerged.actualEndDate = currentMerged.actualEndDate.isAfter(
459
- span.actualEndDate
460
- )
461
- ? currentMerged.actualEndDate
462
- : span.actualEndDate;
463
- mergedIds.push(span.event.id);
464
- } else {
465
- // Gap - push current merged span and start a new one
466
- if (mergedIds.length > 1) {
467
- currentMerged.isMerged = true;
468
- currentMerged.mergedEventIds = mergedIds;
469
- }
470
- mergedTempSpans.push(currentMerged);
471
- currentMerged = { ...span };
472
- mergedIds = [span.event.id];
473
- }
474
- }
475
-
476
- // Push the final merged span from this group
477
- if (currentMerged) {
478
- if (mergedIds.length > 1) {
479
- currentMerged.isMerged = true;
480
- currentMerged.mergedEventIds = mergedIds;
481
- }
482
- mergedTempSpans.push(currentMerged);
483
- }
484
- }
485
-
486
- // Sort final output by startIndex for consistent ordering
487
- mergedTempSpans.sort((a, b) => a.startIndex - b.startIndex);
488
-
489
- // Third pass: assign rows (ONLY for non-location events)
490
- // Location events will be rendered separately as a compact timeline strip
491
- // Extract location spans before filtering them out for row assignment
492
- const locationSpansFromMerge = mergedTempSpans
493
- .filter((span) => getEventLocationType(span.event) !== null)
494
- .map((span) => ({ ...span, row: 0 })); // Give them row 0 as placeholder
495
-
496
- const nonLocationSpans = mergedTempSpans.filter(
497
- (span) => getEventLocationType(span.event) === null
498
- );
499
-
500
- // Sort: 1) by span length (longer events first for better packing), 2) by start date
501
- const sortedTempSpans = nonLocationSpans.sort((a, b) => {
502
- // Longer events first (they need to claim their full row first)
503
- const spanDiff = b.span - a.span;
504
- if (spanDiff !== 0) return spanDiff;
505
-
506
- // Then by start date
507
- return a.actualStartDate.diff(b.actualStartDate);
508
- });
509
-
510
- // Track occupied rows for each day
511
- const occupiedRows: boolean[][] = Array(visibleDates.length)
512
- .fill(null)
513
- .map(() => []);
514
-
515
- // Assign rows to events
516
- sortedTempSpans.forEach((tempSpan) => {
517
- // Find the first available row that works for all days this event spans
518
- let row = 0;
519
- let rowFound = false;
520
-
521
- while (!rowFound) {
522
- // Check if this row is available for all days the event spans
523
- let canUseRow = true;
524
- for (
525
- let dayIndex = tempSpan.startIndex;
526
- dayIndex <= tempSpan.endIndex;
527
- dayIndex++
528
- ) {
529
- if (occupiedRows[dayIndex]?.[row]) {
530
- canUseRow = false;
531
- break;
532
- }
533
- }
534
-
535
- if (canUseRow) {
536
- // Mark this row as occupied for all days the event spans
537
- for (
538
- let dayIndex = tempSpan.startIndex;
539
- dayIndex <= tempSpan.endIndex;
540
- dayIndex++
541
- ) {
542
- if (!occupiedRows[dayIndex]) {
543
- occupiedRows[dayIndex] = [];
544
- }
545
- occupiedRows[dayIndex]![row] = true;
546
- }
547
- rowFound = true;
548
- } else {
549
- row++;
550
- }
551
- }
552
-
553
- // Create the final event span with row assignment
554
- const eventSpan: EventSpan = {
555
- ...tempSpan,
556
- row,
557
- };
558
-
559
- spans.push(eventSpan);
560
-
561
- // Add this event to each day it spans
562
- for (let i = tempSpan.startIndex; i <= tempSpan.endIndex; i++) {
563
- eventsByDay[i]?.push(eventSpan);
564
- }
565
- });
566
-
567
- // Calculate max visible events per day for layout purposes
568
- let maxVisibleEventsPerDay = 0;
569
- eventsByDay.forEach((dayEvents, dayIndex) => {
570
- const dateKey = calendarDayKey(visibleDates[dayIndex]!);
571
-
572
- const shouldShowAll = dayEvents.length === MAX_EVENTS_DISPLAY + 1;
573
- const isExpanded = expandedDates.includes(dateKey) || shouldShowAll;
574
- const visibleCount = isExpanded
575
- ? dayEvents.length
576
- : Math.min(dayEvents.length, MAX_EVENTS_DISPLAY);
577
-
578
- maxVisibleEventsPerDay = Math.max(maxVisibleEventsPerDay, visibleCount);
579
- });
580
-
581
- return {
582
- spans,
583
- maxVisibleEventsPerDay,
584
- eventsByDay,
585
- locationSpansFromMerge,
586
- };
587
- }, [allDayEvents, visibleDates, tz, expandedDates]);
266
+ const eventLayout = useMemo(
267
+ () =>
268
+ calculateAllDayEventLayout(allDayEvents, visibleDates, tz, expandedDates),
269
+ [allDayEvents, visibleDates, tz, expandedDates]
270
+ );
588
271
 
589
- // Extract location events (Home/Office) for compact timeline display
590
- // Deduplicate: only show one location event per day cell (first one wins)
591
272
  const locationSpans = useMemo(() => {
592
273
  const allLocationSpans = eventLayout.locationSpansFromMerge || [];
593
274
 
594
- // Track which day indices have been claimed by a location event
595
275
  const claimedDays = new Set<number>();
596
276
  const deduplicatedSpans: EventSpan[] = [];
597
277
 
598
- // Sort by start index to process in order
599
278
  const sortedSpans = [...allLocationSpans].sort(
600
279
  (a, b) => a.startIndex - b.startIndex
601
280
  );
602
281
 
603
282
  for (const span of sortedSpans) {
604
- // Check if any day in this span is already claimed
605
283
  let hasConflict = false;
606
284
  for (let day = span.startIndex; day <= span.endIndex; day++) {
607
285
  if (claimedDays.has(day)) {
@@ -611,7 +289,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
611
289
  }
612
290
 
613
291
  if (!hasConflict) {
614
- // Claim all days for this span
615
292
  for (let day = span.startIndex; day <= span.endIndex; day++) {
616
293
  claimedDays.add(day);
617
294
  }
@@ -622,19 +299,16 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
622
299
  return deduplicatedSpans;
623
300
  }, [eventLayout.locationSpansFromMerge]);
624
301
 
625
- // Get non-location spans for regular event display
626
302
  const regularSpans = useMemo(() => {
627
303
  return eventLayout.spans.filter(
628
304
  (span) => getEventLocationType(span.event) === null
629
305
  );
630
306
  }, [eventLayout.spans]);
631
307
 
632
- // Get unique events for a specific date (for expansion logic)
633
308
  const getUniqueEventsForDate = (dateIndex: number): EventSpan[] => {
634
309
  return eventLayout.eventsByDay[dateIndex] ?? [];
635
310
  };
636
311
 
637
- // Check if we have any all-day events to display
638
312
  if (eventLayout.spans.length === 0) {
639
313
  return null;
640
314
  }
@@ -647,7 +321,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
647
321
  );
648
322
  };
649
323
 
650
- // Calculate dynamic height based on visible events
651
324
  const locationTopOffset =
652
325
  locationSpans.length > 0 ? LOCATION_TIMELINE_HEIGHT_REM : 0;
653
326
  const regularRowsHeight =
@@ -656,9 +329,8 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
656
329
  : 0;
657
330
  const barHeight = Math.max(1.9, locationTopOffset + regularRowsHeight);
658
331
 
659
- // Enhanced mouse and touch handlers
660
332
  const handleEventMouseDown = (e: React.MouseEvent, eventSpan: EventSpan) => {
661
- if (visibleDates.length <= 1) return;
333
+ if (visibleDates.length <= 1 || eventSpan.event.seriesId) return;
662
334
  e.preventDefault();
663
335
  e.stopPropagation();
664
336
  dragStartPos.current = { x: e.clientX, y: e.clientY };
@@ -675,13 +347,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
675
347
  dragInitiated.current = true;
676
348
  handleDragStart(e, eventSpan);
677
349
  }
678
- // If drag already started, let handleDragMove do its job
679
350
  };
680
351
  const onMouseUp = () => {
681
352
  document.removeEventListener('mousemove', onMouseMove);
682
353
  document.removeEventListener('mouseup', onMouseUp);
683
354
  if (!dragInitiated.current) {
684
- // Treat as click (open modal)
685
355
  openModal(eventSpan.event.id, 'all-day');
686
356
  }
687
357
  dragStartPos.current = null;
@@ -692,13 +362,12 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
692
362
  };
693
363
 
694
364
  const handleEventTouchStart = (e: React.TouchEvent, eventSpan: EventSpan) => {
695
- if (visibleDates.length <= 1) return;
365
+ if (visibleDates.length <= 1 || eventSpan.event.seriesId) return;
696
366
  if (e.touches.length !== 1) return;
697
367
  const touch = e.touches[0];
698
368
  if (!touch) return;
699
369
  dragStartPos.current = { x: touch.clientX, y: touch.clientY };
700
370
  dragInitiated.current = false;
701
- // Start long-press timer
702
371
  longPressTimer.current = setTimeout(() => {
703
372
  dragInitiated.current = true;
704
373
  handleDragStart(
@@ -723,7 +392,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
723
392
  !dragInitiated.current &&
724
393
  Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD
725
394
  ) {
726
- // Cancel long-press, treat as scroll
727
395
  if (longPressTimer.current) clearTimeout(longPressTimer.current);
728
396
  longPressTimer.current = null;
729
397
  dragStartPos.current = null;
@@ -738,7 +406,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
738
406
  document.removeEventListener('touchmove', onTouchMove);
739
407
  document.removeEventListener('touchend', onTouchEnd);
740
408
  if (!dragInitiated.current) {
741
- // Treat as tap (open modal)
742
409
  openModal(eventSpan.event.id, 'all-day');
743
410
  }
744
411
  dragStartPos.current = null;
@@ -782,7 +449,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
782
449
  ? Math.max(0, dateEvents.length - MAX_EVENTS_DISPLAY)
783
450
  : 0;
784
451
 
785
- // Check if this column is a drop target
786
452
  const isDropTarget =
787
453
  dragState.isDragging && dragState.targetDateIndex === dateIndex;
788
454
  const isOriginalColumn =
@@ -793,13 +459,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
793
459
  key={`all-day-column-${dateKey}`}
794
460
  className={cn(
795
461
  'group flex h-full flex-col justify-start border-l transition-colors duration-200 last:border-r',
796
- // Drop zone visual feedback
797
462
  isDropTarget &&
798
463
  !isOriginalColumn &&
799
464
  'border-blue-300 bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30',
800
465
  isOriginalColumn &&
801
466
  'border-red-300 bg-red-100 dark:border-red-700 dark:bg-red-900/30',
802
- // Normal hover state (only when not dragging)
803
467
  !dragState.isDragging && 'hover:bg-muted/20'
804
468
  )}
805
469
  >
@@ -864,8 +528,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
864
528
  />
865
529
  )}
866
530
 
867
- {/* Absolute positioned spanning events */}
868
- {/* Location timeline strip at the top */}
869
531
  <LocationTimeline
870
532
  visibleDates={visibleDates}
871
533
  locationSpans={locationSpans}
@@ -876,7 +538,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
876
538
  deleteEvent={deleteEvent}
877
539
  />
878
540
 
879
- {/* Absolute positioned spanning events */}
880
541
  {regularSpans.map((eventSpan) => {
881
542
  const { event, startIndex, span, row, isCutOffStart, isCutOffEnd } =
882
543
  eventSpan;
@@ -884,7 +545,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
884
545
  const eventStyle = calendarEventStyle(event);
885
546
  const eventRow = row;
886
547
 
887
- // Check if this event should be visible based on expansion state
888
548
  const shouldHideEvent = visibleDates.some((date, dateIndex) => {
889
549
  if (dateIndex < startIndex || dateIndex > startIndex + span - 1)
890
550
  return false;
@@ -903,11 +563,9 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
903
563
 
904
564
  if (shouldHideEvent) return null;
905
565
 
906
- // Check if this event is currently being dragged
907
566
  const isDraggedEvent =
908
567
  dragState.isDragging && dragState.draggedEvent?.id === event.id;
909
568
 
910
- // Calculate top offset based on location strip presence
911
569
  const topOffset = locationTopOffset;
912
570
  const optimisticStatus = (
913
571
  event as CalendarEvent & {
@@ -926,15 +584,22 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
926
584
  key={`spanning-event-${event.id}`}
927
585
  className={cn(
928
586
  'absolute flex items-center rounded-sm border-l-2 px-2 py-1 font-semibold text-xs transition-all duration-200',
929
- // Cursor changes based on drag state
587
+ pastEventTreatment(
588
+ event,
589
+ preservePastEventOpacity,
590
+ isDraggedEvent,
591
+ now.getTime(),
592
+ calendarDayBoundary(
593
+ eventSpan.actualEndDate.toDate(),
594
+ tz
595
+ ).getTime()
596
+ ),
930
597
  dragState.isDragging
931
598
  ? 'cursor-grabbing'
932
599
  : 'cursor-grab hover:cursor-grab',
933
- // Visual feedback for dragging
934
600
  isDraggedEvent && 'scale-95 outline outline-dashed',
935
601
  isPendingMutation &&
936
602
  'outline outline-dashed outline-1 outline-primary',
937
- // Special styling for cut-off events
938
603
  (isCutOffStart || isCutOffEnd) && 'border-dashed'
939
604
  )}
940
605
  style={{
@@ -947,7 +612,6 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
947
612
  zIndex: isDraggedEvent ? 10 : 5,
948
613
  }}
949
614
  onClick={() => {
950
- // Only open modal if not dragging (locked events can still be clicked to edit)
951
615
  if (!dragState.isDragging) {
952
616
  openModal(event.id, 'all-day');
953
617
  }