@tuturuuu/ui 0.36.1 → 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.
- package/CHANGELOG.md +31 -0
- package/package.json +7 -5
- package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
- package/src/components/ui/date-time-picker-values.ts +205 -0
- package/src/components/ui/date-time-picker.tsx +70 -167
- package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
- package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
- package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
- package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
- package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
- package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
- package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
- package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
- package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
- package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
- package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
- package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
- package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
- package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
- package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
- package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
- package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
- package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
- package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
- package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
- package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
- package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
- package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
- package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
- package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
- package/src/hooks/calendar-event-lookup.test.ts +39 -0
- package/src/hooks/calendar-event-lookup.ts +22 -8
- package/src/hooks/calendar-sync-range.test.ts +92 -0
- package/src/hooks/calendar-sync-range.ts +40 -0
- package/src/hooks/use-calendar-clock.ts +49 -0
- package/src/hooks/use-calendar-day-zone.tsx +12 -0
- package/src/hooks/use-calendar-sync.tsx +42 -80
- package/src/hooks/use-calendar.tsx +3 -3
- 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:
|
|
38
|
-
hoveredEventColumn:
|
|
40
|
+
hoveredBaseEventId: calendarMocks.hoveredBaseEventId,
|
|
41
|
+
hoveredEventColumn: calendarMocks.hoveredEventColumn,
|
|
39
42
|
isEventReadOnly: calendarMocks.isEventReadOnly,
|
|
40
43
|
openModal: calendarMocks.openModal,
|
|
41
|
-
preservePastEventOpacity:
|
|
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.
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
const
|
|
942
|
-
|
|
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
|
-
'
|
|
1054
|
+
'line-through':
|
|
1074
1055
|
isPastEvent &&
|
|
1075
1056
|
!isAffectedByPreview &&
|
|
1076
|
-
!preservePastEventOpacity, //
|
|
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
|
-
'
|
|
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
|
-
'
|
|
1087
|
-
isOptimisticallyMutating,
|
|
1088
|
-
'
|
|
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
|
-
|
|
1106
|
-
borderLeftColor: _calendarColor
|
|
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
|
-
//
|
|
1111
|
-
opacity:
|
|
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=
|
|
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=
|
|
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
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
+
});
|