@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
|
@@ -13,23 +13,12 @@ import {
|
|
|
13
13
|
import { Badge } from '@tuturuuu/ui/badge';
|
|
14
14
|
import { Button } from '@tuturuuu/ui/button';
|
|
15
15
|
import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
|
|
16
|
+
import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
|
|
17
|
+
import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
|
|
16
18
|
import { cn } from '@tuturuuu/utils/format';
|
|
17
|
-
import {
|
|
19
|
+
import { useLocale } from 'next-intl';
|
|
18
20
|
import { useEffect, useRef } from 'react';
|
|
19
|
-
|
|
20
|
-
function formatEventTime(startAt?: string, endAt?: string) {
|
|
21
|
-
if (!startAt || !endAt) return '';
|
|
22
|
-
|
|
23
|
-
const start = new Date(startAt);
|
|
24
|
-
const end = new Date(endAt);
|
|
25
|
-
const sameDay = start.toDateString() === end.toDateString();
|
|
26
|
-
|
|
27
|
-
if (sameDay) {
|
|
28
|
-
return `${format(start, 'EEE, MMM d')} - ${format(start, 'p')} - ${format(end, 'p')}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return `${format(start, 'MMM d, p')} - ${format(end, 'MMM d, p')}`;
|
|
32
|
-
}
|
|
21
|
+
import { formatEventPreviewTime } from './event-preview-time';
|
|
33
22
|
|
|
34
23
|
function getSourceLabel(event: {
|
|
35
24
|
provider?: string | null;
|
|
@@ -48,6 +37,8 @@ function getSourceLabel(event: {
|
|
|
48
37
|
}
|
|
49
38
|
|
|
50
39
|
export function EventPreviewPopover() {
|
|
40
|
+
const preferences = useCalendarPreferences();
|
|
41
|
+
const locale = useLocale();
|
|
51
42
|
const {
|
|
52
43
|
previewEvent,
|
|
53
44
|
isPreviewOpen,
|
|
@@ -102,7 +93,7 @@ export function EventPreviewPopover() {
|
|
|
102
93
|
<div className="flex items-start gap-3">
|
|
103
94
|
<div
|
|
104
95
|
className="mt-1 h-3 w-3 shrink-0 rounded-full"
|
|
105
|
-
style={
|
|
96
|
+
style={calendarEventStyle(previewEvent)}
|
|
106
97
|
/>
|
|
107
98
|
<div className="min-w-0 flex-1 space-y-3">
|
|
108
99
|
<div className="flex items-start justify-between gap-3">
|
|
@@ -111,7 +102,12 @@ export function EventPreviewPopover() {
|
|
|
111
102
|
{previewEvent.title || 'Untitled event'}
|
|
112
103
|
</h3>
|
|
113
104
|
<p className="text-muted-foreground text-sm">
|
|
114
|
-
{
|
|
105
|
+
{formatEventPreviewTime(
|
|
106
|
+
previewEvent.start_at,
|
|
107
|
+
previewEvent.end_at,
|
|
108
|
+
preferences,
|
|
109
|
+
locale
|
|
110
|
+
)}
|
|
115
111
|
</p>
|
|
116
112
|
</div>
|
|
117
113
|
<Button
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CalendarPreferences } from '../../../../hooks/use-calendar-preferences';
|
|
2
|
+
|
|
3
|
+
/** Compare and format event instants in the calendar's resolved display zone. */
|
|
4
|
+
export function formatEventPreviewTime(
|
|
5
|
+
startAt: string | undefined,
|
|
6
|
+
endAt: string | undefined,
|
|
7
|
+
preferences: CalendarPreferences,
|
|
8
|
+
locale: string
|
|
9
|
+
): string {
|
|
10
|
+
if (!startAt || !endAt) return '';
|
|
11
|
+
const start = new Date(startAt);
|
|
12
|
+
const end = new Date(endAt);
|
|
13
|
+
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()))
|
|
14
|
+
return '';
|
|
15
|
+
|
|
16
|
+
const timeZone =
|
|
17
|
+
preferences.timezone && preferences.timezone !== 'auto'
|
|
18
|
+
? preferences.timezone
|
|
19
|
+
: undefined;
|
|
20
|
+
const dateKey = new Intl.DateTimeFormat('en-CA', {
|
|
21
|
+
timeZone,
|
|
22
|
+
year: 'numeric',
|
|
23
|
+
month: '2-digit',
|
|
24
|
+
day: '2-digit',
|
|
25
|
+
});
|
|
26
|
+
const date = new Intl.DateTimeFormat(locale, {
|
|
27
|
+
timeZone,
|
|
28
|
+
weekday: 'short',
|
|
29
|
+
month: 'short',
|
|
30
|
+
day: 'numeric',
|
|
31
|
+
});
|
|
32
|
+
const time = new Intl.DateTimeFormat(locale, {
|
|
33
|
+
timeZone,
|
|
34
|
+
hour: '2-digit',
|
|
35
|
+
minute: '2-digit',
|
|
36
|
+
hourCycle: preferences.timeFormat === '24h' ? 'h23' : 'h12',
|
|
37
|
+
});
|
|
38
|
+
return dateKey.format(start) === dateKey.format(end)
|
|
39
|
+
? `${date.format(start)} - ${time.format(start)} - ${time.format(end)}`
|
|
40
|
+
: `${date.format(start)}, ${time.format(start)} - ${date.format(end)}, ${time.format(end)}`;
|
|
41
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '@tuturuuu/icons';
|
|
16
16
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
17
17
|
import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
|
|
18
|
+
import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
|
|
18
19
|
import { cn } from '@tuturuuu/utils/format';
|
|
19
20
|
import dayjs from 'dayjs';
|
|
20
21
|
import isBetween from 'dayjs/plugin/isBetween';
|
|
@@ -29,11 +30,9 @@ dayjs.extend(isBetween);
|
|
|
29
30
|
dayjs.extend(isSameOrAfter);
|
|
30
31
|
dayjs.extend(isSameOrBefore);
|
|
31
32
|
|
|
32
|
-
// Module-level utility for timezone-aware dayjs conversion
|
|
33
33
|
const createDayjsDate = (d: Date | string, tz?: string) =>
|
|
34
34
|
tz === 'auto' ? dayjs(d) : dayjs(d).tz(tz);
|
|
35
35
|
|
|
36
|
-
// Types
|
|
37
36
|
interface EventSpan {
|
|
38
37
|
event: CalendarEvent;
|
|
39
38
|
startIndex: number;
|
|
@@ -669,12 +668,10 @@ const LocationPill = ({
|
|
|
669
668
|
// Get the label showing the span info
|
|
670
669
|
const getSpanLabel = () => {
|
|
671
670
|
if (isSingleDayEvent) {
|
|
672
|
-
return
|
|
673
|
-
'MMM D'
|
|
674
|
-
);
|
|
671
|
+
return dayjs(visibleDates[startIndex] ?? new Date()).format('MMM D');
|
|
675
672
|
}
|
|
676
|
-
const start =
|
|
677
|
-
const end =
|
|
673
|
+
const start = dayjs(visibleDates[startIndex] ?? new Date());
|
|
674
|
+
const end = dayjs(visibleDates[startIndex + span - 1] ?? new Date());
|
|
678
675
|
return `${start.format('MMM D')} - ${end.format('MMM D')}`;
|
|
679
676
|
};
|
|
680
677
|
|
|
@@ -851,16 +848,14 @@ const LocationPill = ({
|
|
|
851
848
|
<div
|
|
852
849
|
className={cn(
|
|
853
850
|
'group absolute flex h-4 items-center justify-center gap-1 rounded-full px-2 font-medium text-[10px] shadow-sm transition-all',
|
|
854
|
-
|
|
855
|
-
styles.text,
|
|
856
|
-
styles.hover,
|
|
857
|
-
'hover:shadow-md',
|
|
851
|
+
'hover:shadow-md hover:ring-1 hover:ring-current',
|
|
858
852
|
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
|
859
853
|
)}
|
|
860
854
|
style={{
|
|
855
|
+
...calendarEventStyle(event),
|
|
861
856
|
left: `calc(${(displayStartIndex * 100) / visibleDates.length}% + 4px)`,
|
|
862
857
|
width: `calc(${(displaySpan * 100) / visibleDates.length}% - 8px)`,
|
|
863
|
-
opacity:
|
|
858
|
+
opacity: 1,
|
|
864
859
|
}}
|
|
865
860
|
title={`${event.title}${displaySpan > 1 ? ` (${displaySpan} days)` : ''}`}
|
|
866
861
|
>
|
|
@@ -1175,7 +1170,7 @@ const LocationPicker = ({
|
|
|
1175
1170
|
}
|
|
1176
1171
|
};
|
|
1177
1172
|
|
|
1178
|
-
const displayDate =
|
|
1173
|
+
const displayDate = dayjs(visibleDates[dateIndex] ?? new Date());
|
|
1179
1174
|
|
|
1180
1175
|
return (
|
|
1181
1176
|
<Popover
|
|
@@ -1353,10 +1348,7 @@ export const LocationTimeline = ({
|
|
|
1353
1348
|
>
|
|
1354
1349
|
{/* Clickable day cells for adding locations */}
|
|
1355
1350
|
{visibleDates.map((date, dateIndex) => {
|
|
1356
|
-
const dateKey =
|
|
1357
|
-
tz === 'auto'
|
|
1358
|
-
? dayjs(date).format('YYYY-MM-DD')
|
|
1359
|
-
: dayjs(date).tz(tz).format('YYYY-MM-DD');
|
|
1351
|
+
const dateKey = dayjs(date).format('YYYY-MM-DD');
|
|
1360
1352
|
|
|
1361
1353
|
// Check if this day already has a location event
|
|
1362
1354
|
const hasLocationEvent = locationSpans.some(
|
|
@@ -4,8 +4,10 @@ import { ArrowLeft, Ellipsis, Plus } from '@tuturuuu/icons';
|
|
|
4
4
|
import type { Workspace } from '@tuturuuu/types';
|
|
5
5
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
6
6
|
import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
|
|
7
|
+
import { useCalendarClock } from '@tuturuuu/ui/hooks/use-calendar-clock';
|
|
7
8
|
import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
|
|
8
9
|
import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
|
|
10
|
+
import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
|
|
9
11
|
import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
|
|
10
12
|
import { cn } from '@tuturuuu/utils/format';
|
|
11
13
|
import { getTimeFormatPattern } from '@tuturuuu/utils/time-helper';
|
|
@@ -15,8 +17,6 @@ import {
|
|
|
15
17
|
endOfMonth,
|
|
16
18
|
endOfWeek,
|
|
17
19
|
isSameMonth,
|
|
18
|
-
isToday,
|
|
19
|
-
startOfDay,
|
|
20
20
|
startOfMonth,
|
|
21
21
|
startOfWeek,
|
|
22
22
|
} from 'date-fns';
|
|
@@ -25,10 +25,14 @@ import timezone from 'dayjs/plugin/timezone';
|
|
|
25
25
|
import utc from 'dayjs/plugin/utc';
|
|
26
26
|
import { useTranslations } from 'next-intl';
|
|
27
27
|
import { useMemo } from 'react';
|
|
28
|
+
import {
|
|
29
|
+
calendarDayBoundary,
|
|
30
|
+
calendarDayKey,
|
|
31
|
+
calendarToday,
|
|
32
|
+
} from '../../../../lib/calendar-day';
|
|
28
33
|
import { formatLunarDay, getLunarDate } from '../../../../lib/lunar-calendar';
|
|
29
34
|
import { Button } from '../../button';
|
|
30
35
|
import { Popover, PopoverContent, PopoverTrigger } from '../../popover';
|
|
31
|
-
import { calendarEventTone } from './calendar-event-tone';
|
|
32
36
|
import { calendarDraftDate } from './calendar-period';
|
|
33
37
|
import { useCalendarSettings } from './settings/settings-context';
|
|
34
38
|
|
|
@@ -60,15 +64,15 @@ function MonthEvent({
|
|
|
60
64
|
const t = useTranslations('calendar');
|
|
61
65
|
const title = event.title || t('views.untitled_event');
|
|
62
66
|
const allDay = isAllDayEvent(event);
|
|
63
|
-
const continued = new Date(event.start_at) <
|
|
67
|
+
const continued = new Date(event.start_at) < calendarDayBoundary(day, zone);
|
|
64
68
|
return (
|
|
65
69
|
<button
|
|
66
70
|
type="button"
|
|
67
71
|
onClick={() => onOpen(event.id)}
|
|
68
72
|
title={title}
|
|
73
|
+
style={calendarEventStyle(event)}
|
|
69
74
|
className={cn(
|
|
70
|
-
'flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-[10px] leading-tight transition-colors hover:
|
|
71
|
-
calendarEventTone(event.color),
|
|
75
|
+
'flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-[10px] leading-tight transition-colors hover:ring-1 hover:ring-current focus-visible:outline-2 focus-visible:outline-ring sm:px-1.5 sm:text-xs',
|
|
72
76
|
allDay && 'font-medium'
|
|
73
77
|
)}
|
|
74
78
|
>
|
|
@@ -76,7 +80,7 @@ function MonthEvent({
|
|
|
76
80
|
<ArrowLeft className="size-2.5 shrink-0" />
|
|
77
81
|
) : (
|
|
78
82
|
!allDay && (
|
|
79
|
-
<span className="hidden shrink-0 tabular-nums
|
|
83
|
+
<span className="hidden shrink-0 tabular-nums lg:inline">
|
|
80
84
|
{(zone && zone !== 'auto'
|
|
81
85
|
? dayjs(event.start_at).tz(zone)
|
|
82
86
|
: dayjs(event.start_at)
|
|
@@ -106,6 +110,10 @@ export function MonthCalendar({
|
|
|
106
110
|
} = useCalendar();
|
|
107
111
|
const cannotCreate = readOnly || providerReadOnly;
|
|
108
112
|
const { settings } = useCalendarSettings();
|
|
113
|
+
const now = useCalendarClock();
|
|
114
|
+
const todayKey = calendarDayKey(
|
|
115
|
+
calendarToday(settings?.timezone?.timezone, now)
|
|
116
|
+
);
|
|
109
117
|
const { timeFormat, weekStartsOn } = useCalendarPreferences();
|
|
110
118
|
const { value: showLunar } = useUserBooleanConfig(
|
|
111
119
|
'SHOW_LUNAR_CALENDAR',
|
|
@@ -187,18 +195,20 @@ export function MonthCalendar({
|
|
|
187
195
|
className={cn(
|
|
188
196
|
'group min-w-0 border-r border-b p-1 sm:p-2',
|
|
189
197
|
!currentMonth && 'bg-muted/35 text-muted-foreground',
|
|
190
|
-
|
|
198
|
+
calendarDayKey(day) === todayKey && 'bg-primary/5'
|
|
191
199
|
)}
|
|
192
200
|
>
|
|
193
201
|
<div className="mb-1 flex min-h-7 items-center justify-between gap-1">
|
|
194
202
|
<button
|
|
195
203
|
type="button"
|
|
196
204
|
aria-label={label}
|
|
197
|
-
aria-current={
|
|
205
|
+
aria-current={
|
|
206
|
+
calendarDayKey(day) === todayKey ? 'date' : undefined
|
|
207
|
+
}
|
|
198
208
|
onClick={() => onDayClick?.(day)}
|
|
199
209
|
className={cn(
|
|
200
210
|
'flex size-7 shrink-0 items-center justify-center rounded-full font-medium text-xs tabular-nums hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring',
|
|
201
|
-
|
|
211
|
+
calendarDayKey(day) === todayKey &&
|
|
202
212
|
'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
203
213
|
)}
|
|
204
214
|
>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { useCalendarClock } from '@tuturuuu/ui/hooks/use-calendar-clock';
|
|
1
2
|
import dayjs from 'dayjs';
|
|
2
3
|
import timezone from 'dayjs/plugin/timezone';
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
4
4
|
import { HOUR_HEIGHT } from './config';
|
|
5
5
|
import { useCalendarSettings } from './settings/settings-context';
|
|
6
6
|
|
|
@@ -15,25 +15,8 @@ export const TimeIndicatorLine = ({
|
|
|
15
15
|
}) => {
|
|
16
16
|
const { settings } = useCalendarSettings();
|
|
17
17
|
const tz = settings?.timezone?.timezone;
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
// Update the time every minute
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const updateTime = () => {
|
|
23
|
-
setNow(tz === 'auto' ? dayjs() : dayjs().tz(tz));
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// Update immediately
|
|
27
|
-
updateTime();
|
|
28
|
-
|
|
29
|
-
// Then update every minute
|
|
30
|
-
const interval = setInterval(updateTime, 60000);
|
|
31
|
-
|
|
32
|
-
return () => clearInterval(interval);
|
|
33
|
-
}, [tz]);
|
|
34
|
-
|
|
35
|
-
// Use selected timezone
|
|
36
|
-
const nowTz = tz === 'auto' ? now : now.tz(tz);
|
|
18
|
+
const now = dayjs(useCalendarClock());
|
|
19
|
+
const nowTz = tz && tz !== 'auto' ? now.tz(tz) : now;
|
|
37
20
|
const hours = nowTz.hour();
|
|
38
21
|
const minutes = nowTz.minute();
|
|
39
22
|
const seconds = nowTz.second();
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCalendarClock } from '@tuturuuu/ui/hooks/use-calendar-clock';
|
|
2
|
+
import { calendarDayKey, calendarToday } from '../../../../lib/calendar-day';
|
|
3
|
+
import { useCalendarSettings } from './settings/settings-context';
|
|
2
4
|
import { TimeIndicatorLine } from './time-indicator-line';
|
|
3
5
|
|
|
4
6
|
export const TimeIndicator = ({ dates }: { dates: Date[] }) => {
|
|
5
|
-
const
|
|
7
|
+
const { settings } = useCalendarSettings();
|
|
8
|
+
const now = useCalendarClock();
|
|
9
|
+
const todayKey = calendarDayKey(
|
|
10
|
+
calendarToday(settings?.timezone?.timezone, now)
|
|
11
|
+
);
|
|
6
12
|
|
|
7
13
|
// Find the index of today's date in the dates array
|
|
8
|
-
const todayIndex = dates.findIndex(
|
|
14
|
+
const todayIndex = dates.findIndex(
|
|
15
|
+
(date) => calendarDayKey(date) === todayKey
|
|
16
|
+
);
|
|
9
17
|
|
|
10
18
|
// Only render if today is in the visible dates
|
|
11
19
|
if (todayIndex === -1) return null;
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import { Clock } from '@tuturuuu/icons';
|
|
2
2
|
import { cn } from '@tuturuuu/utils/format';
|
|
3
3
|
import dayjs from 'dayjs';
|
|
4
|
-
import timezone from 'dayjs/plugin/timezone';
|
|
5
4
|
import type { CalendarView } from '../../../../hooks/use-view-transition';
|
|
6
5
|
import { AllDayEventBar } from './all-day-event-bar';
|
|
7
6
|
import { MIN_COLUMN_WIDTH } from './config';
|
|
8
7
|
import { DayTitle } from './day-title';
|
|
9
8
|
import { useCalendarSettings } from './settings/settings-context';
|
|
10
9
|
|
|
11
|
-
dayjs.extend(timezone);
|
|
12
|
-
|
|
13
10
|
export const WeekdayBar = ({
|
|
14
11
|
locale,
|
|
15
12
|
view,
|
|
@@ -21,14 +18,12 @@ export const WeekdayBar = ({
|
|
|
21
18
|
}) => {
|
|
22
19
|
const { settings } = useCalendarSettings();
|
|
23
20
|
const showWeekends = settings.appearance.showWeekends;
|
|
24
|
-
const tz = settings?.timezone?.timezone;
|
|
25
21
|
|
|
26
22
|
// Filter out weekend days if showWeekends is false
|
|
27
23
|
const visibleDates = showWeekends
|
|
28
24
|
? dates
|
|
29
25
|
: dates.filter((date) => {
|
|
30
|
-
const day =
|
|
31
|
-
tz === 'auto' ? dayjs(date).day() : dayjs(date).tz(tz).day();
|
|
26
|
+
const day = date.getDay();
|
|
32
27
|
return day !== 0 && day !== 6; // 0 = Sunday, 6 = Saturday
|
|
33
28
|
});
|
|
34
29
|
|
|
@@ -50,8 +45,7 @@ export const WeekdayBar = ({
|
|
|
50
45
|
}}
|
|
51
46
|
>
|
|
52
47
|
{visibleDates.map((weekday) => {
|
|
53
|
-
const dayjsDate =
|
|
54
|
-
tz === 'auto' ? dayjs(weekday) : dayjs(weekday).tz(tz);
|
|
48
|
+
const dayjsDate = dayjs(weekday);
|
|
55
49
|
return (
|
|
56
50
|
<div
|
|
57
51
|
key={`date-${dayjsDate.format('YYYY-MM-DD')}`}
|
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
|
|
4
|
+
import { useCalendarClock } from '@tuturuuu/ui/hooks/use-calendar-clock';
|
|
4
5
|
import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
|
|
5
6
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
|
|
7
|
+
import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
|
|
6
8
|
import { cn } from '@tuturuuu/utils/format';
|
|
7
|
-
import {
|
|
8
|
-
eachDayOfInterval,
|
|
9
|
-
endOfMonth,
|
|
10
|
-
getDay,
|
|
11
|
-
isToday,
|
|
12
|
-
startOfMonth,
|
|
13
|
-
} from 'date-fns';
|
|
9
|
+
import { eachDayOfInterval, endOfMonth, getDay, startOfMonth } from 'date-fns';
|
|
14
10
|
import { useTranslations } from 'next-intl';
|
|
15
11
|
import { useMemo } from 'react';
|
|
12
|
+
import { calendarDayKey, calendarToday } from '../../../../lib/calendar-day';
|
|
16
13
|
import {
|
|
17
14
|
formatLunarDay,
|
|
18
15
|
getLunarDate,
|
|
19
16
|
getLunarHolidayName,
|
|
20
17
|
isSpecialLunarDate,
|
|
21
18
|
} from '../../../../lib/lunar-calendar';
|
|
19
|
+
import { useCalendarSettings } from './settings/settings-context';
|
|
22
20
|
|
|
23
21
|
interface YearCalendarProps {
|
|
24
22
|
year: number;
|
|
@@ -29,32 +27,6 @@ interface YearCalendarProps {
|
|
|
29
27
|
onMonthClick?: (date: Date) => void;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
const normalizeColor = (color: string): string => {
|
|
33
|
-
if (!color) return 'primary';
|
|
34
|
-
const normalized = color.trim().toLowerCase();
|
|
35
|
-
if (normalized === '#6b7280' || normalized === 'grey') return 'gray';
|
|
36
|
-
return normalized;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const EVENT_DOT_COLORS: Record<string, string> = {
|
|
40
|
-
blue: 'bg-dynamic-blue',
|
|
41
|
-
red: 'bg-dynamic-red',
|
|
42
|
-
green: 'bg-dynamic-green',
|
|
43
|
-
purple: 'bg-dynamic-purple',
|
|
44
|
-
yellow: 'bg-dynamic-yellow',
|
|
45
|
-
orange: 'bg-dynamic-orange',
|
|
46
|
-
pink: 'bg-dynamic-pink',
|
|
47
|
-
cyan: 'bg-dynamic-cyan',
|
|
48
|
-
indigo: 'bg-dynamic-indigo',
|
|
49
|
-
gray: 'bg-dynamic-gray',
|
|
50
|
-
primary: 'bg-primary',
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
function getEventDotColor(color: string): string {
|
|
54
|
-
const normalized = normalizeColor(color);
|
|
55
|
-
return EVENT_DOT_COLORS[normalized] || 'bg-primary';
|
|
56
|
-
}
|
|
57
|
-
|
|
58
30
|
function getWeekdayLabels(
|
|
59
31
|
firstDayOfWeek: number,
|
|
60
32
|
showWeekends: boolean,
|
|
@@ -98,6 +70,9 @@ function MiniMonth({
|
|
|
98
70
|
}) {
|
|
99
71
|
const t = useTranslations('calendar');
|
|
100
72
|
const { getCurrentEvents } = useCalendar();
|
|
73
|
+
const { settings } = useCalendarSettings();
|
|
74
|
+
const now = useCalendarClock();
|
|
75
|
+
const today = calendarToday(settings?.timezone?.timezone, now);
|
|
101
76
|
const monthStart = startOfMonth(monthDate);
|
|
102
77
|
const monthEnd = endOfMonth(monthDate);
|
|
103
78
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
|
@@ -136,8 +111,8 @@ function MiniMonth({
|
|
|
136
111
|
}, [monthStart, days, firstDayOfWeek, showWeekends, numCols]);
|
|
137
112
|
|
|
138
113
|
const isCurrentMonth =
|
|
139
|
-
monthDate.getMonth() ===
|
|
140
|
-
monthDate.getFullYear() ===
|
|
114
|
+
monthDate.getMonth() === today.getMonth() &&
|
|
115
|
+
monthDate.getFullYear() === today.getFullYear();
|
|
141
116
|
|
|
142
117
|
// Pre-compute event counts for all days in the month to avoid per-cell context calls
|
|
143
118
|
const dayEventData = useMemo(() => {
|
|
@@ -148,7 +123,7 @@ function MiniMonth({
|
|
|
148
123
|
const seenColors = new Set<string>();
|
|
149
124
|
const dots: string[] = [];
|
|
150
125
|
for (const event of events) {
|
|
151
|
-
const color =
|
|
126
|
+
const color = calendarEventStyle(event).backgroundColor;
|
|
152
127
|
if (!seenColors.has(color) && dots.length < 3) {
|
|
153
128
|
seenColors.add(color);
|
|
154
129
|
dots.push(color);
|
|
@@ -229,11 +204,16 @@ function DayCell({
|
|
|
229
204
|
dayEventData?: { count: number; dots: string[] };
|
|
230
205
|
}) {
|
|
231
206
|
const t = useTranslations('calendar');
|
|
207
|
+
const { settings } = useCalendarSettings();
|
|
208
|
+
const now = useCalendarClock();
|
|
209
|
+
const todayKey = calendarDayKey(
|
|
210
|
+
calendarToday(settings?.timezone?.timezone, now)
|
|
211
|
+
);
|
|
232
212
|
if (!day) {
|
|
233
213
|
return <div className={cn('h-9', showLunar && 'h-11')} />;
|
|
234
214
|
}
|
|
235
215
|
|
|
236
|
-
const today =
|
|
216
|
+
const today = calendarDayKey(day) === todayKey;
|
|
237
217
|
const hasEvents = !!dayEventData && dayEventData.count > 0;
|
|
238
218
|
const eventDots = dayEventData?.dots || [];
|
|
239
219
|
|
|
@@ -282,7 +262,11 @@ function DayCell({
|
|
|
282
262
|
{hasEvents && !showLunar && (
|
|
283
263
|
<div className="absolute -bottom-0.5 flex gap-px">
|
|
284
264
|
{eventDots.map((color, i) => (
|
|
285
|
-
<div
|
|
265
|
+
<div
|
|
266
|
+
key={i}
|
|
267
|
+
className="h-1 w-1 rounded-full"
|
|
268
|
+
style={{ backgroundColor: color }}
|
|
269
|
+
/>
|
|
286
270
|
))}
|
|
287
271
|
</div>
|
|
288
272
|
)}
|
|
@@ -290,7 +274,11 @@ function DayCell({
|
|
|
290
274
|
{hasEvents && showLunar && (
|
|
291
275
|
<div className="absolute top-0 right-0 flex gap-px">
|
|
292
276
|
{eventDots.slice(0, 1).map((color, i) => (
|
|
293
|
-
<div
|
|
277
|
+
<div
|
|
278
|
+
key={i}
|
|
279
|
+
className="h-1 w-1 rounded-full"
|
|
280
|
+
style={{ backgroundColor: color }}
|
|
281
|
+
/>
|
|
294
282
|
))}
|
|
295
283
|
</div>
|
|
296
284
|
)}
|
|
@@ -154,6 +154,51 @@ describe('CalendarSyncProvider optimistic visible events', () => {
|
|
|
154
154
|
vi.stubGlobal('fetch', vi.fn());
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
it('requests both event APIs at zoned DST boundaries and refetches when the zone changes', async () => {
|
|
158
|
+
const fetchMock = mockCalendarFetch([]);
|
|
159
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
160
|
+
const { result, queryClient } = renderCalendarSync();
|
|
161
|
+
const requestsFor = (path: string) =>
|
|
162
|
+
fetchMock.mock.calls
|
|
163
|
+
.map(([input]) => new URL(String(input), 'http://localhost'))
|
|
164
|
+
.filter((url) => url.pathname.endsWith(path));
|
|
165
|
+
act(() => {
|
|
166
|
+
result.current.setTimezone('America/New_York');
|
|
167
|
+
result.current.setDates([new Date(2026, 2, 8, 18)]);
|
|
168
|
+
});
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
for (const path of ['/calendar/events', '/calendar/habit-events']) {
|
|
171
|
+
const url = requestsFor(path).at(-1);
|
|
172
|
+
expect(url?.searchParams.get('start_at')).toBe(
|
|
173
|
+
'2026-03-08T05:00:00.000Z'
|
|
174
|
+
);
|
|
175
|
+
expect(url?.searchParams.get('end_at')).toBe(
|
|
176
|
+
'2026-03-09T04:00:00.000Z'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
act(() => result.current.setTimezone('Asia/Tokyo'));
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
for (const path of ['/calendar/events', '/calendar/habit-events']) {
|
|
183
|
+
const url = requestsFor(path).at(-1);
|
|
184
|
+
expect(url?.searchParams.get('start_at')).toBe(
|
|
185
|
+
'2026-03-07T15:00:00.000Z'
|
|
186
|
+
);
|
|
187
|
+
expect(url?.searchParams.get('end_at')).toBe(
|
|
188
|
+
'2026-03-08T15:00:00.000Z'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
const keys = queryClient
|
|
193
|
+
.getQueryCache()
|
|
194
|
+
.findAll({ queryKey: ['databaseCalendarEvents'] })
|
|
195
|
+
.map((query) => query.queryKey[2]);
|
|
196
|
+
expect(keys).toContain(
|
|
197
|
+
'workspace-1:America/New_York:2026-03-08-2026-03-08'
|
|
198
|
+
);
|
|
199
|
+
expect(keys).toContain('workspace-1:Asia/Tokyo:2026-03-08-2026-03-08');
|
|
200
|
+
});
|
|
201
|
+
|
|
157
202
|
it('reports a partial HTTP 200 sync as an error without a success callback', async () => {
|
|
158
203
|
vi.stubGlobal(
|
|
159
204
|
'fetch',
|
|
@@ -12,6 +12,45 @@ const event = (id: string, start: string, end: string): CalendarEvent => ({
|
|
|
12
12
|
end_at: end,
|
|
13
13
|
});
|
|
14
14
|
describe('indexed calendar lookup', () => {
|
|
15
|
+
it.each([
|
|
16
|
+
['Asia/Tokyo', 'America/Los_Angeles', '2026-01-01T07:30:00Z', 2025, 11, 31],
|
|
17
|
+
['America/Los_Angeles', 'Asia/Tokyo', '2025-12-31T15:30:00Z', 2026, 0, 1],
|
|
18
|
+
] as const)(
|
|
19
|
+
'groups timed instants in %s browser with %s calendar days',
|
|
20
|
+
(browserZone, calendarZone, instant, year, month, day) => {
|
|
21
|
+
vi.stubEnv('TZ', browserZone);
|
|
22
|
+
try {
|
|
23
|
+
const end = new Date(
|
|
24
|
+
new Date(instant).getTime() + 15 * 60_000
|
|
25
|
+
).toISOString();
|
|
26
|
+
const lookup = createCalendarEventLookup(
|
|
27
|
+
[event('timed', instant, end)],
|
|
28
|
+
calendarZone
|
|
29
|
+
);
|
|
30
|
+
expect(
|
|
31
|
+
lookup(new Date(year, month, day, 18)).map((item) => item.id)
|
|
32
|
+
).toEqual(['timed']);
|
|
33
|
+
expect(lookup(new Date(year, month, day + 1))).toEqual([]);
|
|
34
|
+
} finally {
|
|
35
|
+
vi.unstubAllEnvs();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
it('preserves the existing all-day carrier identity when the calendar zone changes', () => {
|
|
40
|
+
vi.stubEnv('TZ', 'UTC');
|
|
41
|
+
try {
|
|
42
|
+
const events = [
|
|
43
|
+
event('all-day', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'),
|
|
44
|
+
];
|
|
45
|
+
for (const zone of ['America/Los_Angeles', 'Asia/Tokyo']) {
|
|
46
|
+
const lookup = createCalendarEventLookup(events, zone);
|
|
47
|
+
expect(lookup(new Date(2026, 0, 1))).toHaveLength(1);
|
|
48
|
+
expect(lookup(new Date(2026, 0, 2))).toHaveLength(0);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
vi.unstubAllEnvs();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
15
54
|
it('preserves event order and exclusive all-day ends across a year boundary', () => {
|
|
16
55
|
const events = [
|
|
17
56
|
event('timed', '2026-12-31T23:00:00', '2027-01-01T01:00:00'),
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
2
|
import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
|
|
3
|
+
import { calendarInstantDay, calendarToday } from '../lib/calendar-day';
|
|
3
4
|
|
|
4
5
|
const dayStamp = (date: Date) =>
|
|
5
6
|
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
6
7
|
/** Parse once, binary-search starts, and prune intervals that already ended. */
|
|
7
|
-
export function createCalendarEventLookup(
|
|
8
|
+
export function createCalendarEventLookup(
|
|
9
|
+
events: CalendarEvent[],
|
|
10
|
+
timezone?: string
|
|
11
|
+
) {
|
|
8
12
|
const intervals = events
|
|
9
|
-
.map((event, order) =>
|
|
10
|
-
event
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
.map((event, order) => {
|
|
14
|
+
const allDay = isAllDayEvent(event);
|
|
15
|
+
const eventDay = (value: string) => {
|
|
16
|
+
const instant = new Date(value);
|
|
17
|
+
if (!Number.isFinite(instant.getTime())) return NaN;
|
|
18
|
+
return dayStamp(
|
|
19
|
+
allDay ? instant : calendarInstantDay(instant, timezone)
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
event,
|
|
24
|
+
order,
|
|
25
|
+
start: eventDay(event.start_at),
|
|
26
|
+
end: eventDay(event.end_at) - (allDay ? 1 : 0),
|
|
27
|
+
};
|
|
28
|
+
})
|
|
15
29
|
.filter((item) => Number.isFinite(item.start) && Number.isFinite(item.end))
|
|
16
30
|
.sort((a, b) => a.start - b.start);
|
|
17
31
|
let max = -Infinity;
|
|
18
32
|
const latestEnd = intervals.map((item) => (max = Math.max(max, item.end)));
|
|
19
33
|
const cache = new Map<number, CalendarEvent[]>();
|
|
20
|
-
return (date =
|
|
34
|
+
return (date = calendarToday(timezone)) => {
|
|
21
35
|
const target = dayStamp(date);
|
|
22
36
|
const cached = cache.get(target);
|
|
23
37
|
if (cached) return cached;
|