@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
|
@@ -2,6 +2,7 @@ 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
4
|
import { useCalendarSync } from '@tuturuuu/ui/hooks/use-calendar-sync';
|
|
5
|
+
import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
|
|
5
6
|
import { getEventStyles } from '@tuturuuu/utils/color-helper';
|
|
6
7
|
import { cn } from '@tuturuuu/utils/format';
|
|
7
8
|
import dayjs from 'dayjs';
|
|
@@ -10,6 +11,10 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
|
|
10
11
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
|
11
12
|
import timezone from 'dayjs/plugin/timezone';
|
|
12
13
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
14
|
+
import {
|
|
15
|
+
calendarDayBoundary,
|
|
16
|
+
calendarDayKey,
|
|
17
|
+
} from '../../../../lib/calendar-day';
|
|
13
18
|
import { MIN_COLUMN_WIDTH } from './config';
|
|
14
19
|
import { CalendarEventProviderIcon } from './event-provider-display';
|
|
15
20
|
import { LocationTimeline } from './location-timeline';
|
|
@@ -21,6 +26,11 @@ dayjs.extend(isSameOrAfter);
|
|
|
21
26
|
dayjs.extend(isSameOrBefore);
|
|
22
27
|
dayjs.extend(timezone);
|
|
23
28
|
|
|
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
|
+
|
|
24
34
|
const MAX_EVENTS_DISPLAY = 2;
|
|
25
35
|
|
|
26
36
|
interface EventSpan {
|
|
@@ -28,13 +38,11 @@ interface EventSpan {
|
|
|
28
38
|
startIndex: number;
|
|
29
39
|
endIndex: number;
|
|
30
40
|
span: number;
|
|
31
|
-
// Add indicators for cut-off events
|
|
32
41
|
isCutOffStart: boolean; // Event starts before visible range
|
|
33
42
|
isCutOffEnd: boolean; // Event ends after visible range
|
|
34
43
|
actualStartDate: dayjs.Dayjs; // Actual start date of the event
|
|
35
44
|
actualEndDate: dayjs.Dayjs; // Actual end date of the event
|
|
36
45
|
row: number; // Add row property for proper stacking
|
|
37
|
-
// For merged events
|
|
38
46
|
isMerged?: boolean;
|
|
39
47
|
mergedEventIds?: string[];
|
|
40
48
|
}
|
|
@@ -122,8 +130,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
122
130
|
const visibleDates = showWeekends
|
|
123
131
|
? dates
|
|
124
132
|
: dates.filter((date) => {
|
|
125
|
-
const day =
|
|
126
|
-
tz === 'auto' ? dayjs(date).day() : dayjs(date).tz(tz).day();
|
|
133
|
+
const day = date.getDay();
|
|
127
134
|
return day !== 0 && day !== 6; // 0 = Sunday, 6 = Saturday
|
|
128
135
|
});
|
|
129
136
|
|
|
@@ -310,7 +317,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
310
317
|
const tempSpans: Omit<EventSpan, 'row'>[] = [];
|
|
311
318
|
|
|
312
319
|
// Process each all-day event
|
|
313
|
-
allDayEvents.forEach((event) => {
|
|
320
|
+
(visibleDates.length ? allDayEvents : []).forEach((event) => {
|
|
314
321
|
const eventStart =
|
|
315
322
|
tz === 'auto' ? dayjs(event.start_at) : dayjs(event.start_at).tz(tz);
|
|
316
323
|
const eventEnd =
|
|
@@ -321,12 +328,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
321
328
|
let endIndex = -1;
|
|
322
329
|
|
|
323
330
|
// First pass: find any overlap with visible dates
|
|
324
|
-
const firstVisibleDate =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
tz
|
|
328
|
-
|
|
329
|
-
: dayjs(visibleDates[visibleDates.length - 1]).tz(tz);
|
|
331
|
+
const firstVisibleDate = getZonedDay(visibleDates[0]!, tz);
|
|
332
|
+
const lastVisibleDate = getZonedDay(
|
|
333
|
+
visibleDates[visibleDates.length - 1]!,
|
|
334
|
+
tz
|
|
335
|
+
);
|
|
330
336
|
|
|
331
337
|
// Check if event overlaps with our visible date range at all
|
|
332
338
|
// Event overlaps if: event_start < visible_end AND event_end > visible_start
|
|
@@ -346,10 +352,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
346
352
|
} else {
|
|
347
353
|
// Find the first visible date that matches the event start
|
|
348
354
|
for (let i = 0; i < visibleDates.length; i++) {
|
|
349
|
-
const currentDate =
|
|
350
|
-
tz === 'auto'
|
|
351
|
-
? dayjs(visibleDates[i])
|
|
352
|
-
: dayjs(visibleDates[i]).tz(tz);
|
|
355
|
+
const currentDate = getZonedDay(visibleDates[i]!, tz);
|
|
353
356
|
if (
|
|
354
357
|
currentDate.isSameOrAfter(eventStart, 'day') &&
|
|
355
358
|
currentDate.isBefore(eventEnd, 'day')
|
|
@@ -367,10 +370,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
367
370
|
} else {
|
|
368
371
|
// Find the last visible date that the event covers
|
|
369
372
|
for (let i = visibleDates.length - 1; i >= 0; i--) {
|
|
370
|
-
const currentDate =
|
|
371
|
-
tz === 'auto'
|
|
372
|
-
? dayjs(visibleDates[i])
|
|
373
|
-
: dayjs(visibleDates[i]).tz(tz);
|
|
373
|
+
const currentDate = getZonedDay(visibleDates[i]!, tz);
|
|
374
374
|
if (
|
|
375
375
|
currentDate.isBefore(eventEnd, 'day') &&
|
|
376
376
|
currentDate.isSameOrAfter(eventStart, 'day')
|
|
@@ -567,10 +567,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
567
567
|
// Calculate max visible events per day for layout purposes
|
|
568
568
|
let maxVisibleEventsPerDay = 0;
|
|
569
569
|
eventsByDay.forEach((dayEvents, dayIndex) => {
|
|
570
|
-
const dateKey =
|
|
571
|
-
tz === 'auto'
|
|
572
|
-
? dayjs(visibleDates[dayIndex]).format('YYYY-MM-DD')
|
|
573
|
-
: dayjs(visibleDates[dayIndex]).tz(tz).format('YYYY-MM-DD');
|
|
570
|
+
const dateKey = calendarDayKey(visibleDates[dayIndex]!);
|
|
574
571
|
|
|
575
572
|
const shouldShowAll = dayEvents.length === MAX_EVENTS_DISPLAY + 1;
|
|
576
573
|
const isExpanded = expandedDates.includes(dateKey) || shouldShowAll;
|
|
@@ -775,10 +772,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
775
772
|
}}
|
|
776
773
|
>
|
|
777
774
|
{visibleDates.map((date, dateIndex) => {
|
|
778
|
-
const dateKey =
|
|
779
|
-
tz === 'auto'
|
|
780
|
-
? dayjs(date).format('YYYY-MM-DD')
|
|
781
|
-
: dayjs(date).tz(tz).format('YYYY-MM-DD');
|
|
775
|
+
const dateKey = calendarDayKey(date);
|
|
782
776
|
|
|
783
777
|
const dateEvents = getUniqueEventsForDate(dateIndex);
|
|
784
778
|
const shouldShowAll = dateEvents.length === MAX_EVENTS_DISPLAY + 1;
|
|
@@ -886,9 +880,8 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
886
880
|
{regularSpans.map((eventSpan) => {
|
|
887
881
|
const { event, startIndex, span, row, isCutOffStart, isCutOffEnd } =
|
|
888
882
|
eventSpan;
|
|
889
|
-
const { bg, border, text } = getEventStyles(event.color || 'BLUE');
|
|
890
883
|
|
|
891
|
-
|
|
884
|
+
const eventStyle = calendarEventStyle(event);
|
|
892
885
|
const eventRow = row;
|
|
893
886
|
|
|
894
887
|
// Check if this event should be visible based on expansion state
|
|
@@ -896,10 +889,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
896
889
|
if (dateIndex < startIndex || dateIndex > startIndex + span - 1)
|
|
897
890
|
return false;
|
|
898
891
|
|
|
899
|
-
const dateKey =
|
|
900
|
-
tz === 'auto'
|
|
901
|
-
? dayjs(date).format('YYYY-MM-DD')
|
|
902
|
-
: dayjs(date).tz(tz).format('YYYY-MM-DD');
|
|
892
|
+
const dateKey = calendarDayKey(date);
|
|
903
893
|
|
|
904
894
|
const dateEvents = getUniqueEventsForDate(dateIndex);
|
|
905
895
|
const shouldShowAll = dateEvents.length === MAX_EVENTS_DISPLAY + 1;
|
|
@@ -941,17 +931,15 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
941
931
|
? 'cursor-grabbing'
|
|
942
932
|
: 'cursor-grab hover:cursor-grab',
|
|
943
933
|
// Visual feedback for dragging
|
|
944
|
-
isDraggedEvent && 'scale-95
|
|
945
|
-
// Normal styling
|
|
946
|
-
bg,
|
|
947
|
-
border,
|
|
948
|
-
text,
|
|
934
|
+
isDraggedEvent && 'scale-95 outline outline-dashed',
|
|
949
935
|
isPendingMutation &&
|
|
950
|
-
'
|
|
936
|
+
'outline outline-dashed outline-1 outline-primary',
|
|
951
937
|
// Special styling for cut-off events
|
|
952
938
|
(isCutOffStart || isCutOffEnd) && 'border-dashed'
|
|
953
939
|
)}
|
|
954
940
|
style={{
|
|
941
|
+
...eventStyle,
|
|
942
|
+
borderColor: eventStyle.color,
|
|
955
943
|
left: `calc(${(startIndex * 100) / visibleDates.length}% + ${EVENT_LEFT_OFFSET}px)`,
|
|
956
944
|
width: `calc(${(span * 100) / visibleDates.length}% - ${EVENT_LEFT_OFFSET * 2}px)`,
|
|
957
945
|
top: `${eventRow * 1.6 + 0.25 + topOffset}rem`,
|
|
@@ -1001,10 +989,10 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
|
|
|
1001
989
|
'transform backdrop-blur-sm transition-none',
|
|
1002
990
|
getEventStyles(dragState.draggedEvent.color || 'BLUE').bg,
|
|
1003
991
|
getEventStyles(dragState.draggedEvent.color || 'BLUE').border,
|
|
1004
|
-
getEventStyles(dragState.draggedEvent.color || 'BLUE').text
|
|
1005
|
-
'opacity-90'
|
|
992
|
+
getEventStyles(dragState.draggedEvent.color || 'BLUE').text
|
|
1006
993
|
)}
|
|
1007
994
|
style={{
|
|
995
|
+
...calendarEventStyle(dragState.draggedEvent),
|
|
1008
996
|
left: `${dragState.currentX + 15}px`,
|
|
1009
997
|
top: `${dragState.currentY - 20}px`,
|
|
1010
998
|
height: '1.35rem',
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import utc from 'dayjs/plugin/utc';
|
|
4
|
+
import { expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
dayjs.extend(utc);
|
|
7
|
+
vi.mock('@tuturuuu/ui/hooks/use-calendar', () => ({
|
|
8
|
+
useCalendar: () => ({
|
|
9
|
+
addEmptyEvent: vi.fn(),
|
|
10
|
+
addEmptyEventWithDuration: vi.fn(),
|
|
11
|
+
isDragging: true,
|
|
12
|
+
setIsDragging: vi.fn(),
|
|
13
|
+
scheduleTaskAsEvent: vi.fn(),
|
|
14
|
+
readOnly: false,
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('./settings/settings-context', () => ({
|
|
18
|
+
useCalendarSettings: () => ({
|
|
19
|
+
settings: {
|
|
20
|
+
appearance: { timeFormat: '24h' },
|
|
21
|
+
timezone: { timezone: 'UTC' },
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { CalendarCell } from './calendar-cell';
|
|
27
|
+
|
|
28
|
+
it('gives the duration badge the same opaque contrast colors as its drag card', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<div data-testid="scroll">
|
|
31
|
+
<CalendarCell date="2026-09-07" hour={9} />
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
const parent = screen.getByTestId('scroll');
|
|
35
|
+
Object.defineProperties(parent, {
|
|
36
|
+
scrollHeight: { value: 1000 },
|
|
37
|
+
clientHeight: { value: 200 },
|
|
38
|
+
});
|
|
39
|
+
parent.getBoundingClientRect = () => ({
|
|
40
|
+
top: 0,
|
|
41
|
+
bottom: 200,
|
|
42
|
+
left: 0,
|
|
43
|
+
right: 200,
|
|
44
|
+
width: 200,
|
|
45
|
+
height: 200,
|
|
46
|
+
x: 0,
|
|
47
|
+
y: 0,
|
|
48
|
+
toJSON: () => ({}),
|
|
49
|
+
});
|
|
50
|
+
const cell = container.querySelector('.calendar-cell')!;
|
|
51
|
+
cell.getBoundingClientRect = () => ({
|
|
52
|
+
top: 0,
|
|
53
|
+
bottom: 80,
|
|
54
|
+
left: 0,
|
|
55
|
+
right: 200,
|
|
56
|
+
width: 200,
|
|
57
|
+
height: 80,
|
|
58
|
+
x: 0,
|
|
59
|
+
y: 0,
|
|
60
|
+
toJSON: () => ({}),
|
|
61
|
+
});
|
|
62
|
+
fireEvent.mouseDown(cell, { button: 0, clientY: 20 });
|
|
63
|
+
fireEvent.mouseMove(document, { clientY: 80 });
|
|
64
|
+
const badge = screen.getByText(/^(?:\d+h(?: \d+m)?|\d+m)$/);
|
|
65
|
+
expect(badge).toHaveStyle({ backgroundColor: '#2196f3', color: '#000000' });
|
|
66
|
+
expect(badge.closest('.border-l-2')).toHaveStyle({
|
|
67
|
+
backgroundColor: '#2196f3',
|
|
68
|
+
color: '#000000',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
|
|
2
|
+
import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
|
|
2
3
|
import { getEventStyles } from '@tuturuuu/utils/color-helper';
|
|
3
4
|
import { cn } from '@tuturuuu/utils/format';
|
|
4
5
|
import { format } from 'date-fns';
|
|
@@ -30,11 +31,9 @@ const DragPreview = ({
|
|
|
30
31
|
timeFormat = '12h',
|
|
31
32
|
}: DragPreviewProps) => {
|
|
32
33
|
const timePattern = timeFormat === '24h' ? 'HH:mm' : 'h:mm a';
|
|
33
|
-
// Calculate duration in minutes
|
|
34
34
|
const durationMs = endDate.getTime() - startDate.getTime();
|
|
35
35
|
const durationMinutes = Math.round(durationMs / (1000 * 60));
|
|
36
36
|
|
|
37
|
-
// Get color styles for the preview
|
|
38
37
|
const { bg, border, text } = getEventStyles(color);
|
|
39
38
|
|
|
40
39
|
return (
|
|
@@ -48,6 +47,7 @@ const DragPreview = ({
|
|
|
48
47
|
bg
|
|
49
48
|
)}
|
|
50
49
|
style={{
|
|
50
|
+
...calendarEventStyle({ color }),
|
|
51
51
|
top: `${top}px`,
|
|
52
52
|
height: `${Math.max(height, 20)}px`,
|
|
53
53
|
transition: 'none', // No transition during interaction
|
|
@@ -76,11 +76,10 @@ const DragPreview = ({
|
|
|
76
76
|
// More compact styling for short durations
|
|
77
77
|
height < 60
|
|
78
78
|
? 'top-1/2 right-1 -translate-y-1/2 px-1 py-0.5 text-[10px]'
|
|
79
|
-
: 'top-2 right-2 px-1.5 py-0.5'
|
|
80
|
-
text,
|
|
81
|
-
bg
|
|
79
|
+
: 'top-2 right-2 px-1.5 py-0.5'
|
|
82
80
|
)}
|
|
83
81
|
style={{
|
|
82
|
+
...calendarEventStyle({ color }),
|
|
84
83
|
maxWidth: 'calc(100% - 8px)', // Tighter max width
|
|
85
84
|
minWidth: 24, // Smaller min width for short durations
|
|
86
85
|
textAlign: 'right',
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import { useState } from 'react';
|
|
3
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import type { CalendarView } from '../../../../hooks/use-view-transition';
|
|
5
5
|
|
|
6
6
|
const mocks = vi.hoisted(() => ({
|
|
7
7
|
setDates: vi.fn(),
|
|
8
8
|
transition: (_view: string, action: () => void) => action(),
|
|
9
9
|
dates: [new Date(2026, 8, 7)],
|
|
10
|
-
settings: {
|
|
10
|
+
settings: {
|
|
11
|
+
appearance: { firstDayOfWeek: 'monday', timeFormat: '24h' },
|
|
12
|
+
timezone: { timezone: 'Asia/Ho_Chi_Minh' },
|
|
13
|
+
},
|
|
11
14
|
}));
|
|
12
15
|
vi.mock('@tuturuuu/ui/hooks/use-calendar-sync', () => ({
|
|
13
16
|
useCalendarSync: () => ({
|
|
@@ -42,7 +45,40 @@ vi.mock('./calendar-loading-skeleton', () => ({
|
|
|
42
45
|
vi.mock('./calendar-view-with-trail', () => ({
|
|
43
46
|
CalendarViewWithTrail: () => null,
|
|
44
47
|
}));
|
|
45
|
-
vi.mock('./event-modal', () =>
|
|
48
|
+
vi.mock('./event-modal', async () => {
|
|
49
|
+
const { EventDateTimePicker } = await import('./event-form-components.js');
|
|
50
|
+
const { createAllDayEvent } = await import('@tuturuuu/utils/calendar-utils');
|
|
51
|
+
return {
|
|
52
|
+
EventModal: () => {
|
|
53
|
+
const [date, setDate] = useState(new Date('2026-09-30T11:00:00Z'));
|
|
54
|
+
const [allDay, setAllDay] = useState(false);
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<EventDateTimePicker
|
|
58
|
+
label="Start"
|
|
59
|
+
value={date}
|
|
60
|
+
onChange={(next) => next && setDate(next)}
|
|
61
|
+
showTimeSelect={!allDay}
|
|
62
|
+
/>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={() => {
|
|
66
|
+
const range = createAllDayEvent(
|
|
67
|
+
new Date('2026-10-05T07:15:00Z'),
|
|
68
|
+
'Asia/Ho_Chi_Minh'
|
|
69
|
+
);
|
|
70
|
+
setDate(new Date(range.start_at));
|
|
71
|
+
setAllDay(true);
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
Select October 5 all day
|
|
75
|
+
</button>
|
|
76
|
+
<output aria-label="Stored instant">{date.toISOString()}</output>
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
46
82
|
vi.mock('./event-preview-popover', () => ({ EventPreviewPopover: () => null }));
|
|
47
83
|
vi.mock('./weekday-bar', () => ({ WeekdayBar: () => null }));
|
|
48
84
|
vi.mock('./month-calendar', () => ({
|
|
@@ -86,8 +122,51 @@ function ControlledCalendar({
|
|
|
86
122
|
beforeEach(() => {
|
|
87
123
|
localStorage.clear();
|
|
88
124
|
mocks.setDates.mockClear();
|
|
125
|
+
const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
|
|
126
|
+
vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(
|
|
127
|
+
function (this: Intl.DateTimeFormat) {
|
|
128
|
+
return { ...resolvedOptions.call(this), timeZone: 'America/Los_Angeles' };
|
|
129
|
+
}
|
|
130
|
+
);
|
|
89
131
|
});
|
|
132
|
+
afterEach(() => vi.restoreAllMocks());
|
|
90
133
|
describe('satellite controlled calendar views', () => {
|
|
134
|
+
it('uses the calendar timezone for event display and serializes edits as UTC', async () => {
|
|
135
|
+
render(
|
|
136
|
+
<CalendarContent
|
|
137
|
+
t={translate}
|
|
138
|
+
locale="en"
|
|
139
|
+
workspace={{ id: 'workspace-1' } as never}
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
fireEvent.click(screen.getByText('18:00'));
|
|
143
|
+
expect(await screen.findByText('Asia/Ho Chi Minh')).toBeVisible();
|
|
144
|
+
fireEvent.click(screen.getByTitle('Enter time manually'));
|
|
145
|
+
const input = screen.getByRole('textbox', {
|
|
146
|
+
name: 'Enter time manually in HH:MM',
|
|
147
|
+
});
|
|
148
|
+
fireEvent.change(input, { target: { value: '14:30' } });
|
|
149
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
150
|
+
expect(screen.getByLabelText('Stored instant')).toHaveTextContent(
|
|
151
|
+
'2026-09-30T07:30:00.000Z'
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
it('displays the configured all-day date rather than the browser previous day', () => {
|
|
155
|
+
render(
|
|
156
|
+
<CalendarContent
|
|
157
|
+
t={translate}
|
|
158
|
+
locale="en"
|
|
159
|
+
workspace={{ id: 'workspace-1' } as never}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
fireEvent.click(screen.getByText('Select October 5 all day'));
|
|
163
|
+
expect(
|
|
164
|
+
screen.getByRole('button', { name: 'Selected Oct 5, 2026 00:00' })
|
|
165
|
+
).toBeVisible();
|
|
166
|
+
expect(screen.getByLabelText('Stored instant')).toHaveTextContent(
|
|
167
|
+
'2026-10-04T17:00:00.000Z'
|
|
168
|
+
);
|
|
169
|
+
});
|
|
91
170
|
it('M updates the parent state, persists the month, and supports day drill-down', () => {
|
|
92
171
|
render(<ControlledCalendar />);
|
|
93
172
|
fireEvent.keyDown(window, { key: 'm' });
|
|
@@ -2,11 +2,14 @@ import type {
|
|
|
2
2
|
Workspace,
|
|
3
3
|
WorkspaceCalendarGoogleTokenClient,
|
|
4
4
|
} from '@tuturuuu/types';
|
|
5
|
+
import { useCalendarDayZone } from '@tuturuuu/ui/hooks/use-calendar-day-zone';
|
|
6
|
+
import { CalendarPreferencesProvider } from '@tuturuuu/ui/hooks/use-calendar-preferences';
|
|
5
7
|
import { useCalendarSync } from '@tuturuuu/ui/hooks/use-calendar-sync';
|
|
6
8
|
import type { CalendarView } from '@tuturuuu/ui/hooks/use-view-transition';
|
|
7
9
|
import { useViewTransition } from '@tuturuuu/ui/hooks/use-view-transition';
|
|
8
10
|
import { cn } from '@tuturuuu/utils/format';
|
|
9
11
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { calendarDay, calendarToday } from '../../../../lib/calendar-day';
|
|
10
13
|
import { AgendaView } from './agenda-view';
|
|
11
14
|
import { CalendarHeader } from './calendar-header';
|
|
12
15
|
import { CalendarLoadingSkeleton } from './calendar-loading-skeleton';
|
|
@@ -126,7 +129,11 @@ export const CalendarContent = ({
|
|
|
126
129
|
}) => {
|
|
127
130
|
const { transition } = useViewTransition();
|
|
128
131
|
const { settings } = useCalendarSettings();
|
|
129
|
-
const { dates, isLoading, setDates } = useCalendarSync();
|
|
132
|
+
const { dates, isLoading, setDates, setTimezone } = useCalendarSync();
|
|
133
|
+
const dayZone = useCalendarDayZone();
|
|
134
|
+
const timezone = settings?.timezone?.timezone;
|
|
135
|
+
const hasExternalDate = !!externalState?.date;
|
|
136
|
+
const followsToday = useRef(!hasExternalDate);
|
|
130
137
|
|
|
131
138
|
// Use ref to always have the latest settings without causing dependency cascades
|
|
132
139
|
const settingsRef = useRef(settings);
|
|
@@ -135,7 +142,11 @@ export const CalendarContent = ({
|
|
|
135
142
|
}, [settings]);
|
|
136
143
|
|
|
137
144
|
const [initialized, setInitialized] = useState(false);
|
|
138
|
-
const [date, setDate] = useState(
|
|
145
|
+
const [date, setDate] = useState(() =>
|
|
146
|
+
externalState?.date
|
|
147
|
+
? calendarDay(externalState.date)
|
|
148
|
+
: calendarToday(timezone)
|
|
149
|
+
);
|
|
139
150
|
const [view, setView] = useState<CalendarView>(externalState?.view || 'week');
|
|
140
151
|
const [availableViews, setAvailableViews] = useState<
|
|
141
152
|
{ value: string; label: string; disabled?: boolean }[]
|
|
@@ -147,11 +158,13 @@ export const CalendarContent = ({
|
|
|
147
158
|
// Use the external state handlers when provided
|
|
148
159
|
const handleSetDate = useCallback(
|
|
149
160
|
(newDate: Date | ((prevDate: Date) => Date)) => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
161
|
+
followsToday.current = false;
|
|
162
|
+
const update = (previous: Date) =>
|
|
163
|
+
calendarDay(
|
|
164
|
+
typeof newDate === 'function' ? newDate(previous) : newDate
|
|
165
|
+
);
|
|
166
|
+
if (externalState?.setDate) externalState.setDate(update);
|
|
167
|
+
else setDate(update);
|
|
155
168
|
},
|
|
156
169
|
[externalState]
|
|
157
170
|
);
|
|
@@ -394,32 +407,13 @@ export const CalendarContent = ({
|
|
|
394
407
|
}
|
|
395
408
|
}, [externalState?.view, view]);
|
|
396
409
|
|
|
397
|
-
//
|
|
410
|
+
// Settings bridge supplies the same resolved zone to the outer navigation and queries.
|
|
398
411
|
useEffect(() => {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
newDate.setHours(new Date().getHours());
|
|
405
|
-
newDate.setMinutes(new Date().getMinutes());
|
|
406
|
-
return newDate;
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
const interval = setInterval(() => {
|
|
410
|
-
handleSetDate((date) => {
|
|
411
|
-
const newDate = new Date(date);
|
|
412
|
-
newDate.setHours(new Date().getHours());
|
|
413
|
-
newDate.setMinutes(new Date().getMinutes());
|
|
414
|
-
return newDate;
|
|
415
|
-
});
|
|
416
|
-
}, 60000);
|
|
417
|
-
|
|
418
|
-
return () => clearInterval(interval);
|
|
419
|
-
}, secondsToNextMinute * 1000);
|
|
420
|
-
|
|
421
|
-
return () => clearTimeout(timeout);
|
|
422
|
-
}, [handleSetDate]);
|
|
412
|
+
setTimezone?.(timezone);
|
|
413
|
+
if (timezone) dayZone?.setTimezone(timezone);
|
|
414
|
+
if (!hasExternalDate && followsToday.current)
|
|
415
|
+
setDate(calendarToday(timezone));
|
|
416
|
+
}, [timezone, setTimezone, dayZone?.setTimezone, hasExternalDate]);
|
|
423
417
|
|
|
424
418
|
// Update the dates array when date changes while maintaining the same view
|
|
425
419
|
useEffect(() => {
|
|
@@ -636,10 +630,19 @@ export const CalendarContent = ({
|
|
|
636
630
|
{disabled || disableBuiltInEventUi
|
|
637
631
|
? null
|
|
638
632
|
: workspace && (
|
|
639
|
-
|
|
633
|
+
<CalendarPreferencesProvider
|
|
634
|
+
value={{
|
|
635
|
+
timezone: settings?.timezone?.timezone ?? 'auto',
|
|
636
|
+
timeFormat: settings?.appearance?.timeFormat ?? '12h',
|
|
637
|
+
weekStartsOn: getFirstDayOfWeekNumber(
|
|
638
|
+
settings?.appearance?.firstDayOfWeek,
|
|
639
|
+
locale
|
|
640
|
+
) as 0 | 1 | 6,
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
640
643
|
<EventPreviewPopover />
|
|
641
644
|
<EventModal />
|
|
642
|
-
|
|
645
|
+
</CalendarPreferencesProvider>
|
|
643
646
|
)}
|
|
644
647
|
</div>
|
|
645
648
|
);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { CalendarDayZoneBridgeProvider } from '../../../../hooks/use-calendar-day-zone';
|
|
4
|
+
import { calendarDayKey } from '../../../../lib/calendar-day';
|
|
5
|
+
|
|
6
|
+
const state = vi.hoisted(() => ({
|
|
7
|
+
zone: 'Asia/Tokyo',
|
|
8
|
+
setTimezone: vi.fn(),
|
|
9
|
+
setDates: vi.fn(),
|
|
10
|
+
outerZone: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@tuturuuu/ui/hooks/use-calendar-sync', () => ({
|
|
13
|
+
useCalendarSync: () => ({
|
|
14
|
+
dates: [new Date(2026, 0, 1)],
|
|
15
|
+
isLoading: false,
|
|
16
|
+
setDates: state.setDates,
|
|
17
|
+
setTimezone: state.setTimezone,
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@tuturuuu/ui/hooks/use-view-transition', () => ({
|
|
21
|
+
useViewTransition: () => ({
|
|
22
|
+
transition: (_: string, action: () => void) => action(),
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('./settings/settings-context', () => ({
|
|
26
|
+
useCalendarSettings: () => ({
|
|
27
|
+
settings: {
|
|
28
|
+
timezone: { timezone: state.zone },
|
|
29
|
+
appearance: { firstDayOfWeek: 'monday' },
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('./calendar-header', () => ({
|
|
34
|
+
CalendarHeader: ({ date }: { date: Date }) => (
|
|
35
|
+
<output data-testid="day">
|
|
36
|
+
{calendarDayKey(date)}:{date.getHours()}
|
|
37
|
+
</output>
|
|
38
|
+
),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock('./agenda-view', () => ({ AgendaView: () => null }));
|
|
41
|
+
vi.mock('./calendar-loading-skeleton', () => ({
|
|
42
|
+
CalendarLoadingSkeleton: () => null,
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('./calendar-view-with-trail', () => ({
|
|
45
|
+
CalendarViewWithTrail: () => null,
|
|
46
|
+
}));
|
|
47
|
+
vi.mock('./event-modal', () => ({ EventModal: () => null }));
|
|
48
|
+
vi.mock('./event-preview-popover', () => ({ EventPreviewPopover: () => null }));
|
|
49
|
+
vi.mock('./weekday-bar', () => ({ WeekdayBar: () => null }));
|
|
50
|
+
vi.mock('./month-calendar', () => ({ MonthCalendar: () => null }));
|
|
51
|
+
vi.mock('./year-calendar', () => ({ YearCalendar: () => null }));
|
|
52
|
+
|
|
53
|
+
import { CalendarContent } from './calendar-content';
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
vi.clearAllMocks();
|
|
58
|
+
localStorage.clear();
|
|
59
|
+
});
|
|
60
|
+
it('shared content projects default Today, bridges the settings zone and keeps selection through midnight', () => {
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
vi.setSystemTime(new Date('2025-12-31T16:00:00Z'));
|
|
63
|
+
state.zone = 'Asia/Tokyo';
|
|
64
|
+
const { rerender } = render(
|
|
65
|
+
<CalendarContent t={(key: string) => key} locale="en" />
|
|
66
|
+
);
|
|
67
|
+
expect(screen.getByTestId('day')).toHaveTextContent('2026-01-01:0');
|
|
68
|
+
expect(state.setTimezone).toHaveBeenLastCalledWith('Asia/Tokyo');
|
|
69
|
+
act(() => vi.advanceTimersByTime(24 * 60 * 60_000));
|
|
70
|
+
expect(screen.getByTestId('day')).toHaveTextContent('2026-01-01:0');
|
|
71
|
+
state.zone = 'America/New_York';
|
|
72
|
+
rerender(<CalendarContent t={(key: string) => key} locale="en" />);
|
|
73
|
+
expect(state.setTimezone).toHaveBeenLastCalledWith('America/New_York');
|
|
74
|
+
});
|
|
75
|
+
it('content forwards the zone to outer host navigation without reprojecting an explicit date', () => {
|
|
76
|
+
state.zone = 'Asia/Tokyo';
|
|
77
|
+
render(
|
|
78
|
+
<CalendarDayZoneBridgeProvider value={{ setTimezone: state.outerZone }}>
|
|
79
|
+
<CalendarContent
|
|
80
|
+
t={(key: string) => key}
|
|
81
|
+
locale="en"
|
|
82
|
+
externalState={{
|
|
83
|
+
date: new Date(2026, 0, 10),
|
|
84
|
+
setDate: vi.fn(),
|
|
85
|
+
view: 'day',
|
|
86
|
+
setView: vi.fn(),
|
|
87
|
+
availableViews: [],
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
</CalendarDayZoneBridgeProvider>
|
|
91
|
+
);
|
|
92
|
+
expect(state.outerZone).toHaveBeenCalledWith('Asia/Tokyo');
|
|
93
|
+
expect(screen.getByTestId('day')).toHaveTextContent('2026-01-10');
|
|
94
|
+
});
|