@tuturuuu/ui 0.37.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +6 -4
  3. package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
  4. package/src/components/ui/date-time-picker-values.ts +205 -0
  5. package/src/components/ui/date-time-picker.tsx +70 -167
  6. package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
  7. package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
  8. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
  9. package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
  10. package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
  11. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
  12. package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
  13. package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
  14. package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
  15. package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
  16. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
  17. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
  18. package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
  19. package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
  20. package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
  21. package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
  22. package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
  23. package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
  24. package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
  25. package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
  26. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
  27. package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
  28. package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
  29. package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
  30. package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
  31. package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
  32. package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
  33. package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
  34. package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
  35. package/src/hooks/calendar-event-lookup.test.ts +39 -0
  36. package/src/hooks/calendar-event-lookup.ts +22 -8
  37. package/src/hooks/calendar-sync-range.test.ts +92 -0
  38. package/src/hooks/calendar-sync-range.ts +40 -0
  39. package/src/hooks/use-calendar-clock.ts +49 -0
  40. package/src/hooks/use-calendar-day-zone.tsx +12 -0
  41. package/src/hooks/use-calendar-sync.tsx +42 -80
  42. package/src/hooks/use-calendar.tsx +3 -3
  43. package/src/lib/calendar-day.ts +67 -0
@@ -1,12 +1,12 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
- import { fireEvent, render, screen } from '@testing-library/react';
4
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
5
5
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
6
6
  import dayjs from 'dayjs';
7
7
  import timezone from 'dayjs/plugin/timezone';
8
8
  import utc from 'dayjs/plugin/utc';
9
- import { beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
10
  import { EventCard } from './event-card';
11
11
 
12
12
  dayjs.extend(utc);
@@ -19,6 +19,9 @@ class ResizeObserverMock {
19
19
  }
20
20
 
21
21
  const calendarMocks = vi.hoisted(() => ({
22
+ hoveredBaseEventId: null as string | null,
23
+ hoveredEventColumn: null as number | null,
24
+ preservePastEventOpacity: true,
22
25
  deleteEvent: vi.fn(),
23
26
  hideModal: vi.fn(),
24
27
  isEventReadOnly: vi.fn(() => true),
@@ -34,11 +37,11 @@ vi.mock('@tuturuuu/ui/hooks/use-calendar', () => ({
34
37
  deleteEvent: calendarMocks.deleteEvent,
35
38
  disableBuiltInEventUi: true,
36
39
  hideModal: calendarMocks.hideModal,
37
- hoveredBaseEventId: null,
38
- hoveredEventColumn: null,
40
+ hoveredBaseEventId: calendarMocks.hoveredBaseEventId,
41
+ hoveredEventColumn: calendarMocks.hoveredEventColumn,
39
42
  isEventReadOnly: calendarMocks.isEventReadOnly,
40
43
  openModal: calendarMocks.openModal,
41
- preservePastEventOpacity: true,
44
+ preservePastEventOpacity: calendarMocks.preservePastEventOpacity,
42
45
  readOnly: false,
43
46
  renderEventContextMenu: undefined,
44
47
  setHoveredBaseEventId: calendarMocks.setHoveredBaseEventId,
@@ -77,9 +80,15 @@ function renderEventCard(event: CalendarEvent) {
77
80
  );
78
81
  }
79
82
 
83
+ afterEach(() => vi.useRealTimers());
80
84
  describe('EventCard read-only adapter events', () => {
81
85
  beforeEach(() => {
82
86
  vi.clearAllMocks();
87
+ vi.useFakeTimers({ toFake: ['Date'] });
88
+ vi.setSystemTime(new Date('2026-06-28T00:00:00Z'));
89
+ calendarMocks.hoveredBaseEventId = null;
90
+ calendarMocks.hoveredEventColumn = null;
91
+ calendarMocks.preservePastEventOpacity = true;
83
92
  vi.stubGlobal('ResizeObserver', ResizeObserverMock);
84
93
  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
85
94
  callback(0);
@@ -87,6 +96,197 @@ describe('EventCard read-only adapter events', () => {
87
96
  });
88
97
  });
89
98
 
99
+ it('renders opaque RGB and contrast text on a timed event', () => {
100
+ renderEventCard({
101
+ id: 'rgb',
102
+ title: 'RGB event',
103
+ color: 'BLUE',
104
+ start_at: '2026-06-26T08:30:00.000Z',
105
+ end_at: '2026-06-26T09:30:00.000Z',
106
+ scheduling_metadata: {
107
+ google_color: { version: 1, inherited: false, background: '#00ff88' },
108
+ },
109
+ });
110
+ const card = screen.getByTestId('calendar-event-rgb');
111
+ expect(card.style.backgroundColor).toBe('rgb(0, 255, 136)');
112
+ expect(card.style.color).toBe('rgb(0, 0, 0)');
113
+ expect(card.style.opacity).toBe('1');
114
+ });
115
+
116
+ it('updates the same mounted card when only provider metadata changes', () => {
117
+ const event: CalendarEvent = {
118
+ id: 'metadata-rerender',
119
+ title: 'Provider event',
120
+ color: 'BLUE',
121
+ start_at: '2026-06-26T08:30:00.000Z',
122
+ end_at: '2026-06-26T09:30:00.000Z',
123
+ scheduling_metadata: {
124
+ google_color: { version: 1, inherited: false, background: '#00ff88' },
125
+ },
126
+ };
127
+ const client = new QueryClient();
128
+ const dates = [new Date('2026-06-26T00:00:00.000Z')];
129
+ const card = (value: CalendarEvent) => (
130
+ <QueryClientProvider client={client}>
131
+ <EventCard dates={dates} event={value} wsId="workspace-1" />
132
+ </QueryClientProvider>
133
+ );
134
+ const view = render(card(event));
135
+ const element = screen.getByTestId('calendar-event-metadata-rerender');
136
+ expect(element.style.backgroundColor).toBe('rgb(0, 255, 136)');
137
+ view.rerender(
138
+ card({
139
+ ...event,
140
+ scheduling_metadata: {
141
+ google_color: { version: 1, inherited: false, background: '#000044' },
142
+ },
143
+ })
144
+ );
145
+ expect(screen.getByTestId('calendar-event-metadata-rerender')).toBe(
146
+ element
147
+ );
148
+ expect(element.style.backgroundColor).toBe('rgb(0, 0, 68)');
149
+ expect(element.style.color).toBe('rgb(255, 255, 255)');
150
+ expect(element.style.opacity).toBe('1');
151
+ view.rerender(card({ ...event, scheduling_metadata: null }));
152
+ expect(element.style.backgroundColor).toBe('rgb(33, 150, 243)');
153
+ });
154
+
155
+ it('keeps pending and past fills opaque, and reveals lower stacks by hiding upper cards', () => {
156
+ calendarMocks.preservePastEventOpacity = false;
157
+ calendarMocks.hoveredBaseEventId = 'base';
158
+ calendarMocks.hoveredEventColumn = 0;
159
+ const base = {
160
+ id: 'stack',
161
+ title: 'Stack',
162
+ color: 'BLUE' as const,
163
+ start_at: '2026-06-26T08:30:00.000Z',
164
+ end_at: '2026-06-26T09:30:00.000Z',
165
+ _column: 1,
166
+ _overlapCount: 2,
167
+ _overlapGroup: ['base', 'stack'],
168
+ _optimisticStatus: 'updating',
169
+ scheduling_metadata: {
170
+ google_color: { version: 1, inherited: false, background: '#00ff88' },
171
+ },
172
+ };
173
+ const rendered = renderEventCard(base);
174
+ const card = screen.getByTestId('calendar-event-stack');
175
+ expect(card.style.opacity).toBe('1');
176
+ expect(card.style.visibility).toBe('hidden');
177
+ expect(card.style.backgroundColor).toBe('rgb(0, 255, 136)');
178
+ expect(card.className).not.toMatch(/opacity-(?:30|50|60|80)/);
179
+ expect(card).toHaveClass('line-through');
180
+ rendered.unmount();
181
+ calendarMocks.hoveredBaseEventId = null;
182
+ calendarMocks.hoveredEventColumn = null;
183
+ renderEventCard(base);
184
+ expect(screen.getByTestId('calendar-event-stack').style.visibility).toBe(
185
+ 'visible'
186
+ );
187
+ expect(screen.getByTestId('calendar-event-stack').style.opacity).toBe('1');
188
+ });
189
+
190
+ it.each(['drag', 'resize'] as const)(
191
+ 'keeps provider fill and text opaque during %s pickup',
192
+ (interaction) => {
193
+ calendarMocks.isEventReadOnly.mockReturnValueOnce(false);
194
+ const { container } = renderEventCard({
195
+ id: 'interaction',
196
+ title: 'Interactive RGB',
197
+ color: 'BLUE',
198
+ start_at: '2026-06-26T08:30:00.000Z',
199
+ end_at: '2026-06-26T09:30:00.000Z',
200
+ scheduling_metadata: {
201
+ google_color: { version: 1, inherited: false, background: '#00ff88' },
202
+ },
203
+ });
204
+ const card = screen.getByTestId('calendar-event-interaction');
205
+ const handle =
206
+ interaction === 'resize'
207
+ ? container.querySelector('.cursor-s-resize')!
208
+ : screen.getByText('Interactive RGB');
209
+ fireEvent.mouseDown(handle, { button: 0, clientX: 20, clientY: 20 });
210
+ expect(card).toHaveClass('shadow-md');
211
+ expect(card.style.backgroundColor).toBe('rgb(0, 255, 136)');
212
+ expect(card.style.color).toBe('rgb(0, 0, 0)');
213
+ expect(card.style.opacity).toBe('1');
214
+ expect(card.className).not.toMatch(/opacity-(?:30|50|60|80)/);
215
+ fireEvent.mouseUp(window, { clientX: 20, clientY: 20 });
216
+ expect(calendarMocks.updateEvent).not.toHaveBeenCalled();
217
+ }
218
+ );
219
+
220
+ it('uses an opaque readable error fill after a failed resize', async () => {
221
+ calendarMocks.isEventReadOnly.mockReturnValueOnce(false);
222
+ calendarMocks.updateEvent.mockRejectedValueOnce(
223
+ new Error('Synthetic update failure')
224
+ );
225
+ const { container } = renderEventCard({
226
+ id: 'failed',
227
+ title: 'Failed resize',
228
+ color: 'BLUE',
229
+ start_at: '2026-06-26T08:30:00Z',
230
+ end_at: '2026-06-26T09:30:00Z',
231
+ scheduling_metadata: {
232
+ google_color: { version: 1, inherited: false, background: '#00ff88' },
233
+ },
234
+ });
235
+ fireEvent.mouseDown(container.querySelector('.cursor-s-resize')!, {
236
+ button: 0,
237
+ clientY: 20,
238
+ });
239
+ fireEvent.mouseMove(document, { clientY: 80 });
240
+ fireEvent.mouseUp(document, { clientY: 80 });
241
+ await waitFor(() =>
242
+ expect(screen.getByTestId('calendar-event-failed')).toHaveStyle({
243
+ backgroundColor: '#f44336',
244
+ color: '#000000',
245
+ opacity: '1',
246
+ })
247
+ );
248
+ });
249
+
250
+ it.each([
251
+ {
252
+ position: 'start',
253
+ icon: 'arrow-right',
254
+ background: '#a6bff2',
255
+ foreground: '#000000',
256
+ },
257
+ {
258
+ position: 'end',
259
+ icon: 'arrow-left',
260
+ background: '#3670e2',
261
+ foreground: '#ffffff',
262
+ },
263
+ ] as const)(
264
+ 'continuation arrows inherit resolved contrast for custom RGB at $position',
265
+ ({ position, icon, background, foreground }) => {
266
+ const { container } = renderEventCard({
267
+ id: 'continuation',
268
+ title: 'Custom Google continuation',
269
+ color: 'BLUE',
270
+ start_at: '2026-06-26T08:30:00.000Z',
271
+ end_at: '2026-06-27T09:30:00.000Z',
272
+ _isMultiDay: true,
273
+ _dayPosition: position,
274
+ scheduling_metadata: {
275
+ google_color: { version: 1, inherited: false, background },
276
+ },
277
+ });
278
+ const card = screen.getByTestId('calendar-event-continuation');
279
+ const arrow = container.querySelector(`.lucide-${icon}`);
280
+ expect(arrow).not.toBeNull();
281
+ expect(card).toHaveStyle({
282
+ backgroundColor: background,
283
+ color: foreground,
284
+ });
285
+ expect(arrow).toHaveClass('text-inherit');
286
+ expect(arrow).not.toHaveClass('text-dynamic-light-blue');
287
+ }
288
+ );
289
+
90
290
  it('opens read-only events but hides resize controls', () => {
91
291
  const { container } = renderEventCard({
92
292
  id: 'event-1',
@@ -170,8 +370,7 @@ describe('EventCard read-only adapter events', () => {
170
370
  } as CalendarEvent & { _optimisticStatus: 'updating' });
171
371
 
172
372
  expect(screen.getByTestId('calendar-event-event-updating')).toHaveClass(
173
- 'outline-dashed',
174
- 'opacity-60'
373
+ 'outline-dashed'
175
374
  );
176
375
  });
177
376
  });
@@ -17,8 +17,10 @@ import {
17
17
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
18
18
  import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
19
19
  import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
20
+ import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
20
21
  import { getEventStyles } from '@tuturuuu/utils/color-helper';
21
22
  import { cn } from '@tuturuuu/utils/format';
23
+ import { opaqueGoogleColor } from '@tuturuuu/utils/google-calendar-colors';
22
24
  import { containsHtml, sanitizeHtml } from '@tuturuuu/utils/html-sanitizer';
23
25
  import dayjs from 'dayjs';
24
26
  import timezone from 'dayjs/plugin/timezone';
@@ -346,7 +348,7 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
346
348
  });
347
349
 
348
350
  if (dateIdx === -1) {
349
- cardEl.style.opacity = '0';
351
+ cardEl.style.visibility = 'hidden';
350
352
  cardEl.style.pointerEvents = 'none';
351
353
  return;
352
354
  }
@@ -438,9 +440,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
438
440
 
439
441
  observer.observe(cellEl);
440
442
 
441
- // Check if the event is in the past
442
- const isPastEvent = endDate.isBefore(dayjs());
443
-
444
443
  // Check if this event should be transparent due to hover state
445
444
  // An event is transparent if:
446
445
  // 1. There are overlaps
@@ -454,17 +453,9 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
454
453
  columnIndex > hoveredEventColumn &&
455
454
  overlapGroup.includes(hoveredBaseEventId);
456
455
 
457
- // Set opacity based on transparency state, then past event state
458
- if (shouldBeTransparent) {
459
- cardEl.style.opacity = '0.05';
460
- cardEl.style.pointerEvents = 'none';
461
- } else if (isPastEvent && !preservePastEventOpacity) {
462
- cardEl.style.opacity = '0.5';
463
- cardEl.style.pointerEvents = 'all';
464
- } else {
465
- cardEl.style.opacity = '1';
466
- cardEl.style.pointerEvents = 'all';
467
- }
456
+ cardEl.style.opacity = '1';
457
+ cardEl.style.visibility = shouldBeTransparent ? 'hidden' : 'visible';
458
+ cardEl.style.pointerEvents = shouldBeTransparent ? 'none' : 'all';
468
459
 
469
460
  return () => observer.disconnect();
470
461
  }, [
@@ -481,8 +472,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
481
472
  overlapGroup,
482
473
  hoveredBaseEventId,
483
474
  hoveredEventColumn,
484
- endDate,
485
- preservePastEventOpacity,
486
475
  ]);
487
476
 
488
477
  // Event resizing - only enable for non-multi-day events or the start/end segments
@@ -936,19 +925,11 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
936
925
  isReadOnlyEvent,
937
926
  ]);
938
927
 
939
- // Color styles based on event color
940
-
941
- const { bg, border, text, dragBg, syncingBg, successBg, errorBg } =
942
- getEventStyles(color);
943
-
944
- // Get the appropriate background based on event state
945
- const getBackgroundStyle = () => {
946
- if (updateStatus === 'syncing') return syncingBg;
947
- if (updateStatus === 'success') return successBg;
948
- if (updateStatus === 'error') return errorBg;
949
- if (visualState.isDragging) return dragBg;
950
- return bg;
951
- };
928
+ const { border } = getEventStyles(color);
929
+ // Error feedback stays opaque and uses contrast computed for its red fill.
930
+ const eventStyle = calendarEventStyle(
931
+ updateStatus === 'error' ? { color: 'RED' } : event
932
+ );
952
933
 
953
934
  // Use the visual state for UI rendering
954
935
  const { isDragging, isResizing } = visualState;
@@ -1070,28 +1051,24 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1070
1051
  'transform shadow-md': isDragging || isResizing, // Subtle transform during interaction
1071
1052
  'shadow-sm': hasOverlaps && !isDragging && !isResizing, // Subtle shadow for stacked events
1072
1053
  'hover:shadow-md': hasOverlaps, // Enhanced shadow on hover for stacked events
1073
- 'opacity-50':
1054
+ 'line-through':
1074
1055
  isPastEvent &&
1075
1056
  !isAffectedByPreview &&
1076
- !preservePastEventOpacity, // Lower opacity for past events
1077
- 'opacity-30 grayscale transition-all duration-500':
1078
- isAffectedByPreview, // Dim affected events during preview
1057
+ !preservePastEventOpacity, // Mark past events in the text
1079
1058
  'rounded-l-none border-l-4': showStartIndicator, // Special styling for continuation from previous day
1080
1059
  'rounded-r-none border-r-4': showEndIndicator, // Special styling for continuation to next day
1081
1060
  'border-l-[3px]': hasCalendarInfo, // Thicker border for calendar events
1082
1061
  // Habit-specific styling (icon only, no dashed border)
1083
- 'opacity-60': _isHabit && _habitCompleted, // Dimmed for completed habits
1062
+ 'line-through decoration-double': _isHabit && _habitCompleted, // Mark completed habits
1084
1063
  // Preview-specific styling - dashed border only for NEW/MOVED events (not reused)
1085
1064
  'border-2 border-dashed': _isPreview && !_isReused,
1086
- 'opacity-60 outline outline-dashed outline-1 outline-primary/50':
1087
- isOptimisticallyMutating,
1088
- 'opacity-80 ring-1 ring-primary/30':
1065
+ 'outline outline-dashed outline-1 outline-primary':
1066
+ isOptimisticallyMutating || isAffectedByPreview,
1067
+ 'ring-1 ring-primary':
1089
1068
  isOptimisticallyPending && !isOptimisticallyMutating,
1090
1069
  },
1091
1070
  level ? 'border border-l-2' : 'border-l-2',
1092
- border,
1093
- text,
1094
- getBackgroundStyle() // Use dynamic background based on status
1071
+ border
1095
1072
  )}
1096
1073
  style={{
1097
1074
  transition:
@@ -1102,13 +1079,14 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1102
1079
  willChange:
1103
1080
  isDragging || isResizing ? 'transform, top, left' : 'auto', // GPU acceleration
1104
1081
  transform: isDragging || isResizing ? 'translateZ(0)' : 'none', // Force GPU acceleration during interaction
1105
- // Add calendar color accent if available
1106
- borderLeftColor: _calendarColor || undefined,
1082
+ ...eventStyle,
1083
+ borderLeftColor: opaqueGoogleColor(_calendarColor) ?? undefined,
1107
1084
  // Enhanced border for shorter events (likely on top)
1108
1085
  borderLeftWidth:
1109
1086
  hasOverlaps && isLikelyTopEvent ? '3px' : undefined,
1110
- // Significantly lowered opacity for stacked events when base is hovered
1111
- opacity: shouldBeTransparent ? 0.05 : 1,
1087
+ // Hide upper cards to reveal the hovered lower card without blending fills
1088
+ opacity: 1,
1089
+ visibility: shouldBeTransparent ? 'hidden' : 'visible',
1112
1090
  // Maintain pointer events even when transparent
1113
1091
  pointerEvents: shouldBeTransparent ? 'none' : undefined,
1114
1092
  }}
@@ -1150,13 +1128,13 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1150
1128
  {/* Continuation indicators for multi-day events */}
1151
1129
  {showStartIndicator && (
1152
1130
  <div className="absolute top-1/2 left-2 -translate-x-1 -translate-y-1/2">
1153
- <ArrowLeft className={`h-3 w-3 ${text}`} />
1131
+ <ArrowLeft className="h-3 w-3 text-inherit" />
1154
1132
  </div>
1155
1133
  )}
1156
1134
 
1157
1135
  {showEndIndicator && (
1158
1136
  <div className="absolute top-1/2 right-2 translate-x-1 -translate-y-1/2">
1159
- <ArrowRight className={`h-3 w-3 ${text}`} />
1137
+ <ArrowRight className="h-3 w-3 text-inherit" />
1160
1138
  </div>
1161
1139
  )}
1162
1140
 
@@ -1302,7 +1280,7 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
1302
1280
  {((!_isMultiDay && duration > 0.5) ||
1303
1281
  (_isMultiDay &&
1304
1282
  (_dayPosition === 'start' || _dayPosition === 'end'))) && (
1305
- <div className="mt-1 flex items-center text-xs opacity-80">
1283
+ <div className="mt-1 flex items-center text-xs">
1306
1284
  {_isMultiDay ? (
1307
1285
  _dayPosition === 'start' ? (
1308
1286
  <span>Starts {formatEventTime(startDate)}</span>
@@ -1479,7 +1457,6 @@ function eventCardPropsEqual(previous: EventCardProps, next: EventCardProps) {
1479
1457
  }
1480
1458
 
1481
1459
  if (!datesEqual(previous.dates, next.dates)) return false;
1482
-
1483
1460
  const left = previous.event as CalendarEvent & {
1484
1461
  _optimisticStatus?: string;
1485
1462
  _warning?: string;
@@ -1496,6 +1473,7 @@ function eventCardPropsEqual(previous: EventCardProps, next: EventCardProps) {
1496
1473
  left.start_at === right.start_at &&
1497
1474
  left.end_at === right.end_at &&
1498
1475
  left.color === right.color &&
1476
+ left.scheduling_metadata === right.scheduling_metadata &&
1499
1477
  left.locked === right.locked &&
1500
1478
  left.provider === right.provider &&
1501
1479
  left.external_event_id === right.external_event_id &&
@@ -91,6 +91,7 @@ import {
91
91
  OverlapWarning,
92
92
  } from './event-form-components';
93
93
  import { EventModalHeader } from './event-modal-header';
94
+ import { eventEndPickerBounds } from './event-picker-bounds';
94
95
  import { saveCalendarEventDrafts } from './save-calendar-event-drafts';
95
96
  import { useCalendarSettings } from './settings/settings-context';
96
97
  import { useEventDraftSession } from './use-event-draft-session';
@@ -1056,32 +1057,11 @@ export function EventModal() {
1056
1057
  })()}
1057
1058
  onChange={handleEndDateChange}
1058
1059
  showTimeSelect={!isAllDay}
1059
- minDate={(() => {
1060
- // Allow selecting the same day as the start date
1061
- const start = new Date(
1062
- event.start_at || new Date()
1063
- );
1064
- return new Date(
1065
- start.getFullYear(),
1066
- start.getMonth(),
1067
- start.getDate()
1068
- );
1069
- })()}
1070
- minTime={(() => {
1071
- const start = new Date(
1072
- event.start_at || new Date()
1073
- );
1074
- const end = new Date(event.end_at || new Date());
1075
- // Only apply minTime if start and end are on the same day
1076
- if (
1077
- start.getFullYear() === end.getFullYear() &&
1078
- start.getMonth() === end.getMonth() &&
1079
- start.getDate() === end.getDate()
1080
- ) {
1081
- return `${start.getHours().toString().padStart(2, '0')}:${start.getMinutes().toString().padStart(2, '0')}`;
1082
- }
1083
- return undefined;
1084
- })()}
1060
+ {...eventEndPickerBounds(
1061
+ event.start_at || new Date().toISOString(),
1062
+ tz,
1063
+ isAllDay
1064
+ )}
1085
1065
  scrollIntoViewOnOpen={true}
1086
1066
  pickerButtonRef={endPickerRef}
1087
1067
  />
@@ -0,0 +1,16 @@
1
+ import { pickerTimeValue } from '../../date-time-picker-values';
2
+
3
+ /** Bounds stay instants; their clock labels use the calendar's resolved zone. */
4
+ export function eventEndPickerBounds(
5
+ startAt: string,
6
+ zone: string | undefined,
7
+ allDay: boolean
8
+ ) {
9
+ const start = new Date(startAt);
10
+ return {
11
+ minDate: start,
12
+ minTime: allDay
13
+ ? undefined
14
+ : pickerTimeValue(start, zone && zone !== 'auto' ? zone : null),
15
+ };
16
+ }
@@ -0,0 +1,139 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { CalendarPreferencesProvider } from '@tuturuuu/ui/hooks/use-calendar-preferences';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { EventPreviewPopover } from './event-preview-popover';
5
+ import { formatEventPreviewTime } from './event-preview-time';
6
+
7
+ const colorState = vi.hoisted(() => ({
8
+ metadata: undefined as unknown,
9
+ source: undefined as string | undefined,
10
+ }));
11
+ vi.mock('@tuturuuu/ui/hooks/use-calendar', () => ({
12
+ useCalendar: () => ({
13
+ previewEvent: {
14
+ id: 'synthetic-event',
15
+ title: 'Synthetic timezone regression',
16
+ color: 'BLUE',
17
+ scheduling_metadata: colorState.metadata,
18
+ _calendarColor: colorState.source,
19
+ start_at: '2026-09-30T11:00:00Z',
20
+ end_at: '2026-09-30T13:00:00Z',
21
+ },
22
+ isPreviewOpen: true,
23
+ closePreview: vi.fn(),
24
+ openEventEditor: vi.fn(),
25
+ deleteEvent: vi.fn(),
26
+ readOnly: true,
27
+ }),
28
+ }));
29
+ vi.mock('next-intl', () => ({ useLocale: () => 'en-US' }));
30
+
31
+ beforeEach(() => {
32
+ colorState.metadata = undefined;
33
+ colorState.source = undefined;
34
+ });
35
+ it.each([false, true])(
36
+ 'preview dot selects provider/inherited intent: %s',
37
+ (inherited) => {
38
+ colorState.source = '#ff80ab';
39
+ colorState.metadata = {
40
+ google_color: { version: 1, inherited, background: '#00ff88' },
41
+ };
42
+ const { container } = render(
43
+ <CalendarPreferencesProvider
44
+ value={{ timezone: 'UTC', timeFormat: '24h' }}
45
+ >
46
+ <EventPreviewPopover />
47
+ </CalendarPreferencesProvider>
48
+ );
49
+ expect(container.querySelector('.rounded-full')).toHaveStyle({
50
+ backgroundColor: inherited ? '#ff80ab' : '#00ff88',
51
+ });
52
+ }
53
+ );
54
+
55
+ describe('calendar quick preview timezone', () => {
56
+ it('renders the actual preview in the calendar zone and 24-hour preference', () => {
57
+ render(
58
+ <CalendarPreferencesProvider
59
+ value={{ timezone: 'Asia/Ho_Chi_Minh', timeFormat: '24h' }}
60
+ >
61
+ <EventPreviewPopover />
62
+ </CalendarPreferencesProvider>
63
+ );
64
+ expect(screen.getByText('Wed, Sep 30 - 18:00 - 20:00')).toBeTruthy();
65
+ });
66
+
67
+ it('compares calendar dates in the selected zone across midnight', () => {
68
+ expect(
69
+ formatEventPreviewTime(
70
+ '2026-09-30T16:30:00Z',
71
+ '2026-09-30T18:30:00Z',
72
+ { timezone: 'Asia/Ho_Chi_Minh', timeFormat: '24h' },
73
+ 'en-US'
74
+ )
75
+ ).toBe('Wed, Sep 30, 23:30 - Thu, Oct 1, 01:30');
76
+ });
77
+
78
+ it('projects each instant independently across a DST gap', () => {
79
+ expect(
80
+ formatEventPreviewTime(
81
+ '2026-03-08T06:30:00Z',
82
+ '2026-03-08T07:30:00Z',
83
+ { timezone: 'America/New_York', timeFormat: '24h' },
84
+ 'en-US'
85
+ )
86
+ ).toBe('Sun, Mar 8 - 01:30 - 03:30');
87
+ });
88
+
89
+ it('accepts Unicode whitespace in Intl twelve-hour labels', () => {
90
+ const original:
91
+ | TypedPropertyDescriptor<Intl.DateTimeFormat['format']>
92
+ | undefined = Object.getOwnPropertyDescriptor(
93
+ Intl.DateTimeFormat.prototype,
94
+ 'format'
95
+ );
96
+ if (!original?.get) throw new Error('Intl formatter getter unavailable');
97
+ const getFormat = original.get;
98
+ Object.defineProperty(Intl.DateTimeFormat.prototype, 'format', {
99
+ ...original,
100
+ get(this: Intl.DateTimeFormat): Intl.DateTimeFormat['format'] {
101
+ const formatter = getFormat.call(this);
102
+ const unicodeFormat: Intl.DateTimeFormat['format'] = (date) =>
103
+ formatter(date).replace(/\s(?=[AP]M)/gu, '\u202f');
104
+ return unicodeFormat;
105
+ },
106
+ });
107
+ try {
108
+ const value = formatEventPreviewTime(
109
+ '2026-09-30T11:00:00Z',
110
+ '2026-09-30T13:00:00Z',
111
+ { timezone: 'Asia/Ho_Chi_Minh', timeFormat: '12h' },
112
+ 'en-US'
113
+ );
114
+ expect(value).toContain('06:00\u202fPM');
115
+ expect(value).toMatch(/06:00\s+PM/u);
116
+ expect(value).toMatch(/08:00\s+PM/u);
117
+ } finally {
118
+ Object.defineProperty(Intl.DateTimeFormat.prototype, 'format', original);
119
+ }
120
+ });
121
+ it('uses the supported locale and twelve-hour clock', () => {
122
+ const value = formatEventPreviewTime(
123
+ '2026-09-30T11:00:00Z',
124
+ '2026-09-30T13:00:00Z',
125
+ { timezone: 'Asia/Ho_Chi_Minh', timeFormat: '12h' },
126
+ 'en-US'
127
+ );
128
+ expect(value).toMatch(/06:00\s+PM/u);
129
+ expect(value).toMatch(/08:00\s+PM/u);
130
+ expect(
131
+ formatEventPreviewTime(
132
+ '2026-09-30T11:00:00Z',
133
+ '2026-09-30T13:00:00Z',
134
+ { timezone: 'Asia/Ho_Chi_Minh', timeFormat: '24h' },
135
+ 'vi'
136
+ )
137
+ ).toContain('18:00');
138
+ });
139
+ });