@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const state = vi.hoisted(() => ({
|
|
5
|
+
zone: 'America/New_York',
|
|
6
|
+
showWeekends: false,
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('./settings/settings-context', () => ({
|
|
9
|
+
useCalendarSettings: () => ({
|
|
10
|
+
settings: {
|
|
11
|
+
timezone: { timezone: state.zone },
|
|
12
|
+
appearance: { showWeekends: state.showWeekends },
|
|
13
|
+
},
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('@tuturuuu/ui/hooks/use-user-config', () => ({
|
|
17
|
+
useUserBooleanConfig: () => ({ value: false }),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('./all-day-event-bar', () => ({ AllDayEventBar: () => null }));
|
|
20
|
+
vi.mock('./time-indicator-line', () => ({
|
|
21
|
+
TimeIndicatorLine: ({ columnIndex }: { columnIndex: number }) => (
|
|
22
|
+
<div data-testid="indicator">{columnIndex}</div>
|
|
23
|
+
),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { DayTitle } from './day-title';
|
|
27
|
+
import { TimeIndicator } from './time-indicator';
|
|
28
|
+
import { WeekdayBar } from './weekday-bar';
|
|
29
|
+
|
|
30
|
+
afterEach(() => vi.useRealTimers());
|
|
31
|
+
describe('calendar day display', () => {
|
|
32
|
+
it('keeps Monday carrier headings and weekday filtering in a zone behind the browser', () => {
|
|
33
|
+
state.zone = 'America/New_York';
|
|
34
|
+
render(
|
|
35
|
+
<WeekdayBar
|
|
36
|
+
locale="en"
|
|
37
|
+
view="week"
|
|
38
|
+
dates={[
|
|
39
|
+
new Date(2026, 8, 6),
|
|
40
|
+
new Date(2026, 8, 7),
|
|
41
|
+
new Date(2026, 8, 8),
|
|
42
|
+
]}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
expect(screen.queryByText('Sun')).toBeNull();
|
|
46
|
+
expect(screen.getByText('Mon')).toBeVisible();
|
|
47
|
+
expect(screen.getByText('7')).toBeVisible();
|
|
48
|
+
expect(screen.getByText('Tue')).toBeVisible();
|
|
49
|
+
});
|
|
50
|
+
it.each([
|
|
51
|
+
['America/New_York', '2026-09-08T01:00:00Z', 7, 0],
|
|
52
|
+
['Asia/Tokyo', '2026-09-07T23:00:00Z', 8, 1],
|
|
53
|
+
])(
|
|
54
|
+
'aligns the heading and indicator Today for %s',
|
|
55
|
+
(zone, now, day, column) => {
|
|
56
|
+
state.zone = zone;
|
|
57
|
+
vi.useFakeTimers();
|
|
58
|
+
vi.setSystemTime(new Date(now));
|
|
59
|
+
render(
|
|
60
|
+
<>
|
|
61
|
+
<DayTitle
|
|
62
|
+
view="day"
|
|
63
|
+
date={new Date(2026, 8, day)}
|
|
64
|
+
weekday="Heading"
|
|
65
|
+
/>
|
|
66
|
+
<TimeIndicator dates={[new Date(2026, 8, 7), new Date(2026, 8, 8)]} />
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByText(String(day))).toHaveClass('bg-primary');
|
|
70
|
+
expect(screen.getByTestId('indicator')).toHaveTextContent(String(column));
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
it('advances Today and the indicator at calendar midnight while preserving view days', () => {
|
|
74
|
+
state.zone = 'America/New_York';
|
|
75
|
+
vi.useFakeTimers();
|
|
76
|
+
vi.setSystemTime(new Date('2026-09-08T03:59:30Z'));
|
|
77
|
+
const dates = [new Date(2026, 8, 7), new Date(2026, 8, 8)];
|
|
78
|
+
const { unmount } = render(
|
|
79
|
+
<>
|
|
80
|
+
{dates.map((date) => (
|
|
81
|
+
<DayTitle
|
|
82
|
+
key={date.getDate()}
|
|
83
|
+
view="day"
|
|
84
|
+
date={date}
|
|
85
|
+
weekday="Heading"
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
<TimeIndicator dates={dates} />
|
|
89
|
+
</>
|
|
90
|
+
);
|
|
91
|
+
expect(screen.getByText('7')).toHaveClass('bg-primary');
|
|
92
|
+
expect(screen.getByTestId('indicator')).toHaveTextContent('0');
|
|
93
|
+
// Every heading and indicator subscribes to a single shared clock timer.
|
|
94
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
95
|
+
act(() => vi.advanceTimersByTime(30_000));
|
|
96
|
+
expect(screen.getByText('7')).not.toHaveClass('bg-primary');
|
|
97
|
+
expect(screen.getByText('8')).toHaveClass('bg-primary');
|
|
98
|
+
expect(screen.getByTestId('indicator')).toHaveTextContent('1');
|
|
99
|
+
expect(dates.map((date) => [date.getDate(), date.getHours()])).toEqual([
|
|
100
|
+
[7, 0],
|
|
101
|
+
[8, 0],
|
|
102
|
+
]);
|
|
103
|
+
unmount();
|
|
104
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
} from '@tuturuuu/ui/select';
|
|
20
20
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
|
|
21
21
|
import dayjs from 'dayjs';
|
|
22
|
+
import { useCalendarClock } from '../../../../hooks/use-calendar-clock';
|
|
23
|
+
import { useCalendarDayZone } from '../../../../hooks/use-calendar-day-zone';
|
|
22
24
|
import type { CalendarView } from '../../../../hooks/use-view-transition';
|
|
25
|
+
import { calendarToday } from '../../../../lib/calendar-day';
|
|
26
|
+
import { useCalendarSettings } from './settings/settings-context';
|
|
23
27
|
|
|
24
28
|
export function CalendarHeader({
|
|
25
29
|
t,
|
|
@@ -43,6 +47,10 @@ export function CalendarHeader({
|
|
|
43
47
|
onViewChange: (view: CalendarView) => void;
|
|
44
48
|
extras?: React.ReactNode;
|
|
45
49
|
}) {
|
|
50
|
+
const { settings } = useCalendarSettings();
|
|
51
|
+
const zone = settings.timezone.timezone;
|
|
52
|
+
const dayZone = useCalendarDayZone();
|
|
53
|
+
const today = calendarToday(zone, useCalendarClock());
|
|
46
54
|
const views = availableViews.filter((view) => view?.disabled !== true);
|
|
47
55
|
const { value: showLunar, toggle: toggleLunar } = useUserBooleanConfig(
|
|
48
56
|
'SHOW_LUNAR_CALENDAR',
|
|
@@ -84,14 +92,15 @@ export function CalendarHeader({
|
|
|
84
92
|
});
|
|
85
93
|
|
|
86
94
|
const { syncStatus } = useCalendarSync();
|
|
87
|
-
const selectToday = () =>
|
|
88
|
-
|
|
95
|
+
const selectToday = () =>
|
|
96
|
+
dayZone?.goToToday ? dayZone.goToToday() : setDate(today);
|
|
97
|
+
const isTodaySelected = () => dayjs(date).isSame(dayjs(today), 'day');
|
|
89
98
|
const isCurrentMonth = () =>
|
|
90
99
|
view === 'month' &&
|
|
91
|
-
date.getMonth() ===
|
|
92
|
-
date.getFullYear() ===
|
|
100
|
+
date.getMonth() === today.getMonth() &&
|
|
101
|
+
date.getFullYear() === today.getFullYear();
|
|
93
102
|
const isCurrentYear = () =>
|
|
94
|
-
view === 'year' && date.getFullYear() ===
|
|
103
|
+
view === 'year' && date.getFullYear() === today.getFullYear();
|
|
95
104
|
|
|
96
105
|
const isCurrentPeriod =
|
|
97
106
|
isTodaySelected() || isCurrentMonth() || isCurrentYear();
|
|
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
|
|
5
5
|
import timezone from 'dayjs/plugin/timezone';
|
|
6
6
|
import { useParams } from 'next/navigation';
|
|
7
7
|
import { useMemo } from 'react';
|
|
8
|
+
import { calendarDayKey } from '../../../../lib/calendar-day';
|
|
8
9
|
import { CalendarColumn } from './calendar-column';
|
|
9
10
|
import { DAY_HEIGHT, MAX_LEVEL } from './config';
|
|
10
11
|
import { EventCard } from './event-card';
|
|
@@ -19,12 +20,6 @@ type LayoutCalendarEvent = CalendarEvent & {
|
|
|
19
20
|
_startMs: number;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
function getDayKeyFromDate(date: Date, tz?: string) {
|
|
23
|
-
return (tz === 'auto' ? dayjs(date) : dayjs(date).tz(tz)).format(
|
|
24
|
-
'YYYY-MM-DD'
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
function getDayKeyFromIso(value: string, tz?: string) {
|
|
29
24
|
return (tz === 'auto' ? dayjs(value) : dayjs(value).tz(tz)).format(
|
|
30
25
|
'YYYY-MM-DD'
|
|
@@ -179,7 +174,7 @@ export const CalendarBaseMatrix = ({ dates }: { dates: Date[] }) => {
|
|
|
179
174
|
{dates.map((_, index) => (
|
|
180
175
|
<CalendarColumn
|
|
181
176
|
key={`cal-col-${index}`}
|
|
182
|
-
date={
|
|
177
|
+
date={calendarDayKey(dates[index]!)}
|
|
183
178
|
last={index === dates.length - 1}
|
|
184
179
|
/>
|
|
185
180
|
))}
|
|
@@ -197,8 +192,8 @@ export const CalendarEventMatrix = ({ dates }: { dates: Date[] }) => {
|
|
|
197
192
|
const tz = settings?.timezone?.timezone;
|
|
198
193
|
|
|
199
194
|
const visibleDayKeys = useMemo(
|
|
200
|
-
() => new Set(dates.map(
|
|
201
|
-
[dates
|
|
195
|
+
() => new Set(dates.map(calendarDayKey)),
|
|
196
|
+
[dates]
|
|
202
197
|
);
|
|
203
198
|
|
|
204
199
|
const filteredRealEvents = useMemo(
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
2
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
3
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createCalendarEventLookup } from '../../../../hooks/calendar-event-lookup';
|
|
4
5
|
|
|
5
6
|
const state = vi.hoisted(() => ({
|
|
6
7
|
openModal: vi.fn(),
|
|
7
8
|
addEmptyEvent: vi.fn(),
|
|
8
9
|
events: [] as CalendarEvent[],
|
|
9
10
|
weekStartsOn: 0,
|
|
11
|
+
zone: 'America/New_York',
|
|
12
|
+
useZonedLookup: false,
|
|
10
13
|
}));
|
|
11
14
|
vi.mock('@tuturuuu/ui/hooks/use-calendar', () => ({
|
|
12
15
|
useCalendar: () => ({
|
|
13
16
|
...state,
|
|
14
17
|
getCurrentEvents: (day: Date) =>
|
|
15
|
-
state.
|
|
16
|
-
(
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
state.useZonedLookup
|
|
19
|
+
? createCalendarEventLookup(state.events, state.zone)(day)
|
|
20
|
+
: state.events.filter(
|
|
21
|
+
(event) =>
|
|
22
|
+
new Date(event.start_at).toDateString() === day.toDateString()
|
|
23
|
+
),
|
|
19
24
|
}),
|
|
20
25
|
}));
|
|
21
26
|
vi.mock('@tuturuuu/ui/hooks/use-calendar-preferences', () => ({
|
|
@@ -31,7 +36,7 @@ vi.mock('./settings/settings-context', () => ({
|
|
|
31
36
|
useCalendarSettings: () => ({
|
|
32
37
|
settings: {
|
|
33
38
|
appearance: { showWeekends: true },
|
|
34
|
-
timezone: { timezone:
|
|
39
|
+
timezone: { timezone: state.zone },
|
|
35
40
|
},
|
|
36
41
|
}),
|
|
37
42
|
}));
|
|
@@ -47,6 +52,8 @@ import { YearCalendar } from './year-calendar';
|
|
|
47
52
|
const date = new Date(2026, 8, 7);
|
|
48
53
|
beforeEach(() => {
|
|
49
54
|
state.weekStartsOn = 0;
|
|
55
|
+
state.zone = 'America/New_York';
|
|
56
|
+
state.useZonedLookup = false;
|
|
50
57
|
state.openModal.mockClear();
|
|
51
58
|
state.addEmptyEvent.mockClear();
|
|
52
59
|
state.events = Array.from(
|
|
@@ -62,6 +69,8 @@ beforeEach(() => {
|
|
|
62
69
|
}) as CalendarEvent
|
|
63
70
|
);
|
|
64
71
|
});
|
|
72
|
+
afterEach(() => vi.useRealTimers());
|
|
73
|
+
|
|
65
74
|
describe('calendar view interactions', () => {
|
|
66
75
|
it('month overflow opens every event and creates on the chosen calendar date and timezone', () => {
|
|
67
76
|
render(<MonthCalendar date={date} viewedMonth={date} locale="en" />);
|
|
@@ -166,4 +175,109 @@ describe('calendar view interactions', () => {
|
|
|
166
175
|
);
|
|
167
176
|
expect(onDayClick.mock.calls[0]?.[0].getDate()).toBe(7);
|
|
168
177
|
});
|
|
178
|
+
it.each([
|
|
179
|
+
['America/New_York', '2026-09-08T01:00:00Z', 7],
|
|
180
|
+
['Asia/Tokyo', '2026-09-07T23:00:00Z', 8],
|
|
181
|
+
])('marks the calendar Today in month and year for %s', (zone, now, day) => {
|
|
182
|
+
vi.useFakeTimers();
|
|
183
|
+
vi.setSystemTime(new Date(now));
|
|
184
|
+
state.zone = zone;
|
|
185
|
+
const { container, unmount } = render(
|
|
186
|
+
<MonthCalendar date={date} locale="en" />
|
|
187
|
+
);
|
|
188
|
+
expect(container.querySelector('[aria-current="date"]')).toHaveTextContent(
|
|
189
|
+
String(day)
|
|
190
|
+
);
|
|
191
|
+
unmount();
|
|
192
|
+
const year = render(<YearCalendar year={2026} locale="en" />);
|
|
193
|
+
expect(
|
|
194
|
+
year.container.querySelector('[aria-current="date"]')
|
|
195
|
+
).toHaveAccessibleName(new RegExp(`September ${day}, 2026`));
|
|
196
|
+
});
|
|
197
|
+
it('uses the calendar Today and event clock in agenda', () => {
|
|
198
|
+
state.useZonedLookup = true;
|
|
199
|
+
vi.useFakeTimers();
|
|
200
|
+
vi.setSystemTime(new Date('2026-09-08T01:00:00Z'));
|
|
201
|
+
state.events = [
|
|
202
|
+
{
|
|
203
|
+
id: 'evening',
|
|
204
|
+
title: 'Evening',
|
|
205
|
+
start_at: '2026-09-07T23:00:00Z',
|
|
206
|
+
end_at: '2026-09-08T00:00:00Z',
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
render(<AgendaView startDate={date} daysToShow={1} locale="en" />);
|
|
210
|
+
expect(screen.getByText('today')).toBeVisible();
|
|
211
|
+
expect(screen.getByRole('button', { name: /Evening/ })).toHaveTextContent(
|
|
212
|
+
'19:00'
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('calendar view opaque effective colors', () => {
|
|
218
|
+
it('preserves borderline provider contrast with a visible month hover ring', () => {
|
|
219
|
+
state.events = [
|
|
220
|
+
{
|
|
221
|
+
id: 'borderline-rgb',
|
|
222
|
+
title: 'Borderline RGB',
|
|
223
|
+
color: 'BLUE',
|
|
224
|
+
start_at: new Date(2026, 8, 7, 10).toISOString(),
|
|
225
|
+
end_at: new Date(2026, 8, 7, 11).toISOString(),
|
|
226
|
+
scheduling_metadata: {
|
|
227
|
+
google_color: { version: 1, inherited: false, background: '#757575' },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
render(<MonthCalendar date={date} viewedMonth={date} locale="en" />);
|
|
232
|
+
const card = screen.getByRole('button', { name: /Borderline RGB/ });
|
|
233
|
+
expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
|
|
234
|
+
expect(card.style.color).toBe('rgb(255, 255, 255)');
|
|
235
|
+
fireEvent.mouseEnter(card);
|
|
236
|
+
expect(card).toHaveClass('hover:ring-1', 'hover:ring-current');
|
|
237
|
+
expect(card.className).not.toMatch(/(?:brightness|opacity)-/);
|
|
238
|
+
expect(card.style.filter).toBe('');
|
|
239
|
+
expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
|
|
240
|
+
expect(card.style.color).toBe('rgb(255, 255, 255)');
|
|
241
|
+
const luminance = ((117 / 255 + 0.055) / 1.055) ** 2.4;
|
|
242
|
+
expect(1.05 / (luminance + 0.05)).toBeGreaterThanOrEqual(4.5);
|
|
243
|
+
fireEvent.click(card);
|
|
244
|
+
expect(state.openModal).toHaveBeenCalledWith('borderline-rgb');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it.each([false, true])(
|
|
248
|
+
'uses provider RGB in month and agenda, inherited=%s',
|
|
249
|
+
(inherited) => {
|
|
250
|
+
state.events = [
|
|
251
|
+
{
|
|
252
|
+
id: 'rgb',
|
|
253
|
+
title: 'RGB event',
|
|
254
|
+
color: 'BLUE',
|
|
255
|
+
start_at: new Date(2026, 8, 7, 10).toISOString(),
|
|
256
|
+
end_at: new Date(2026, 8, 7, 11).toISOString(),
|
|
257
|
+
_calendarColor: '#ff80ab',
|
|
258
|
+
scheduling_metadata: {
|
|
259
|
+
google_color: { version: 1, inherited, background: '#00ff88' },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
const rgb = inherited ? 'rgb(255, 128, 171)' : 'rgb(0, 255, 136)';
|
|
264
|
+
const month = render(
|
|
265
|
+
<MonthCalendar date={date} viewedMonth={date} locale="en" />
|
|
266
|
+
);
|
|
267
|
+
expect(
|
|
268
|
+
screen.getByRole('button', { name: /RGB event/ }).style.backgroundColor
|
|
269
|
+
).toBe(rgb);
|
|
270
|
+
expect(
|
|
271
|
+
screen.getByRole('button', { name: /RGB event/ }).style.color
|
|
272
|
+
).toBe('rgb(0, 0, 0)');
|
|
273
|
+
month.unmount();
|
|
274
|
+
render(<AgendaView startDate={date} locale="en" daysToShow={1} />);
|
|
275
|
+
expect(
|
|
276
|
+
screen.getByRole('button', { name: /RGB event/ }).style.backgroundColor
|
|
277
|
+
).toBe(rgb);
|
|
278
|
+
expect(
|
|
279
|
+
screen.getByRole('button', { name: /RGB event/ }).style.color
|
|
280
|
+
).toBe('rgb(0, 0, 0)');
|
|
281
|
+
}
|
|
282
|
+
);
|
|
169
283
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { useCalendarClock } from '@tuturuuu/ui/hooks/use-calendar-clock';
|
|
1
2
|
import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
|
|
2
3
|
import { cn } from '@tuturuuu/utils/format';
|
|
3
4
|
import dayjs from 'dayjs';
|
|
4
|
-
import timezone from 'dayjs/plugin/timezone';
|
|
5
5
|
import type { CalendarView } from '../../../../hooks/use-view-transition';
|
|
6
|
+
import { calendarDayKey, calendarToday } from '../../../../lib/calendar-day';
|
|
6
7
|
import {
|
|
7
8
|
formatLunarDay,
|
|
8
9
|
getLunarDate,
|
|
@@ -11,8 +12,6 @@ import {
|
|
|
11
12
|
} from '../../../../lib/lunar-calendar';
|
|
12
13
|
import { useCalendarSettings } from './settings/settings-context';
|
|
13
14
|
|
|
14
|
-
dayjs.extend(timezone);
|
|
15
|
-
|
|
16
15
|
interface DayTitleProps {
|
|
17
16
|
view: CalendarView;
|
|
18
17
|
date: Date;
|
|
@@ -27,9 +26,10 @@ export function DayTitle({ date, weekday, locale = 'en' }: DayTitleProps) {
|
|
|
27
26
|
'SHOW_LUNAR_CALENDAR',
|
|
28
27
|
locale.startsWith('vi')
|
|
29
28
|
);
|
|
30
|
-
const
|
|
31
|
-
const dayjsDate =
|
|
32
|
-
const isToday =
|
|
29
|
+
const now = useCalendarClock();
|
|
30
|
+
const dayjsDate = dayjs(date);
|
|
31
|
+
const isToday =
|
|
32
|
+
calendarDayKey(date) === calendarDayKey(calendarToday(tz, now));
|
|
33
33
|
|
|
34
34
|
const lunar = showLunar ? getLunarDate(dayjsDate.toDate()) : null;
|
|
35
35
|
const holidayName = lunar ? getLunarHolidayName(lunar, locale) : null;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const state = vi.hoisted(() => ({
|
|
5
|
+
current: false,
|
|
6
|
+
id: 'first',
|
|
7
|
+
end: '2026-10-01T11:00:00Z',
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('@tuturuuu/ui/hooks/use-calendar', () => ({
|
|
10
|
+
useCalendar: () => ({
|
|
11
|
+
getCurrentEvents: () =>
|
|
12
|
+
state.current
|
|
13
|
+
? [
|
|
14
|
+
{
|
|
15
|
+
id: state.id,
|
|
16
|
+
title: 'Current RGB',
|
|
17
|
+
start_at: '2026-10-01T09:00:00Z',
|
|
18
|
+
end_at: state.end,
|
|
19
|
+
color: 'BLUE',
|
|
20
|
+
scheduling_metadata: {
|
|
21
|
+
google_color: {
|
|
22
|
+
version: 1,
|
|
23
|
+
inherited: false,
|
|
24
|
+
background: '#fbd75b',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
: [],
|
|
30
|
+
getUpcomingEvent: () => ({
|
|
31
|
+
title: 'Upcoming provider event',
|
|
32
|
+
start_at: '2026-10-01T10:00:00Z',
|
|
33
|
+
color: 'BLUE',
|
|
34
|
+
scheduling_metadata: {
|
|
35
|
+
google_color: {
|
|
36
|
+
version: 1,
|
|
37
|
+
calendar_id: 'source',
|
|
38
|
+
color_id: '4',
|
|
39
|
+
event_label_id: null,
|
|
40
|
+
inherited: false,
|
|
41
|
+
background: '#fbd75b',
|
|
42
|
+
resolution: 'event',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
isEditing: () => false,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import { DynamicIsland } from './dynamic-island';
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
state.current = false;
|
|
54
|
+
state.id = 'first';
|
|
55
|
+
state.end = '2026-10-01T11:00:00Z';
|
|
56
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
57
|
+
vi.setSystemTime(new Date('2026-10-01T09:30:00Z'));
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => vi.useRealTimers());
|
|
60
|
+
it('renders the upcoming provider color as an opaque fill', () => {
|
|
61
|
+
render(<DynamicIsland />);
|
|
62
|
+
const card = screen.getByText('Upcoming provider event').closest('.border');
|
|
63
|
+
expect(card).toHaveStyle({ backgroundColor: '#fbd75b', color: '#000000' });
|
|
64
|
+
expect(card).toHaveClass('opacity-100');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses matching provider fill and contrast on pomodoro controls and edge', () => {
|
|
68
|
+
state.current = true;
|
|
69
|
+
const { container } = render(<DynamicIsland />);
|
|
70
|
+
const card = screen.getByText('Current RGB').closest('.border');
|
|
71
|
+
expect(card).toHaveStyle({
|
|
72
|
+
backgroundColor: '#fbd75b',
|
|
73
|
+
borderColor: '#000000',
|
|
74
|
+
});
|
|
75
|
+
expect(screen.getByRole('button')).toHaveStyle({
|
|
76
|
+
backgroundColor: '#fbd75b',
|
|
77
|
+
color: '#000000',
|
|
78
|
+
borderColor: '#000000',
|
|
79
|
+
});
|
|
80
|
+
expect(container.querySelector('[data-orientation="vertical"]')).toHaveStyle({
|
|
81
|
+
backgroundColor: '#000000',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('starts, ticks, stops and restarts the same event, and cleans up on unmount', () => {
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
state.current = true;
|
|
88
|
+
const view = render(<DynamicIsland />);
|
|
89
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
90
|
+
fireEvent.click(screen.getByRole('button'));
|
|
91
|
+
expect(screen.getByText('30m')).toBeInTheDocument();
|
|
92
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
93
|
+
expect(screen.getByText('29m 59s')).toBeInTheDocument();
|
|
94
|
+
fireEvent.click(screen.getByRole('button'));
|
|
95
|
+
expect(screen.getByText('Focused work')).toBeInTheDocument();
|
|
96
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
97
|
+
fireEvent.click(screen.getByRole('button'));
|
|
98
|
+
expect(screen.getByText('30m')).toBeInTheDocument();
|
|
99
|
+
view.unmount();
|
|
100
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('resets when event identity or deadline changes and removes stale state when events end', () => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
state.current = true;
|
|
106
|
+
const view = render(<DynamicIsland />);
|
|
107
|
+
fireEvent.click(screen.getByRole('button'));
|
|
108
|
+
state.id = 'second';
|
|
109
|
+
view.rerender(<DynamicIsland />);
|
|
110
|
+
expect(screen.getByText('Focused work')).toBeInTheDocument();
|
|
111
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
112
|
+
fireEvent.click(screen.getByRole('button'));
|
|
113
|
+
state.end = '2026-10-01T09:40:00Z';
|
|
114
|
+
view.rerender(<DynamicIsland />);
|
|
115
|
+
expect(screen.getByText('1 cycles')).toBeInTheDocument();
|
|
116
|
+
state.current = false;
|
|
117
|
+
view.rerender(<DynamicIsland />);
|
|
118
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
119
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('finishes the last cycle once without leaving an interval running', () => {
|
|
123
|
+
vi.useFakeTimers();
|
|
124
|
+
state.current = true;
|
|
125
|
+
state.end = '2026-10-01T09:30:01Z';
|
|
126
|
+
const play = vi.fn().mockResolvedValue(undefined);
|
|
127
|
+
vi.stubGlobal(
|
|
128
|
+
'Audio',
|
|
129
|
+
class {
|
|
130
|
+
play = play;
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
render(<DynamicIsland />);
|
|
134
|
+
fireEvent.click(screen.getByRole('button'));
|
|
135
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
136
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(screen.queryByText(/Cycle #/)).not.toBeInTheDocument();
|
|
138
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
139
|
+
vi.unstubAllGlobals();
|
|
140
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Play, StopCircle } from '@tuturuuu/icons';
|
|
2
2
|
import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
|
|
3
3
|
import { Separator } from '@tuturuuu/ui/separator';
|
|
4
|
-
import {
|
|
4
|
+
import { calendarEventStyle } from '@tuturuuu/utils/calendar-event-colors';
|
|
5
5
|
import moment from 'moment';
|
|
6
|
-
import { useEffect, useState } from 'react';
|
|
6
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
7
7
|
|
|
8
8
|
export const DynamicIsland = () => {
|
|
9
9
|
const { getCurrentEvents, getUpcomingEvent, isEditing } = useCalendar();
|
|
@@ -53,18 +53,16 @@ export const DynamicIsland = () => {
|
|
|
53
53
|
return `${hoursString}${minutesString}${secondsString}`.trimEnd();
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
: null
|
|
56
|
+
const firstEventEndAt = events?.[0]?.end_at;
|
|
57
|
+
const firstEventEnd = useMemo(
|
|
58
|
+
() => (firstEventEndAt ? moment(firstEventEndAt).toDate() : null),
|
|
59
|
+
[firstEventEndAt]
|
|
60
|
+
);
|
|
59
61
|
const timeLeft = firstEventEnd ? getTimeLeft(firstEventEnd) : 0;
|
|
60
62
|
|
|
61
63
|
const [startAt, setStartAt] = useState<Date | null>(null);
|
|
62
64
|
const [endAt, setEndAt] = useState<Date | null>(null);
|
|
63
65
|
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (events?.[0]?.end_at) setEndAt(firstEventEnd);
|
|
66
|
-
}, [events, firstEventEnd]);
|
|
67
|
-
|
|
68
66
|
const focusMinutes = 25;
|
|
69
67
|
const breakMinutes = 5;
|
|
70
68
|
|
|
@@ -79,20 +77,19 @@ export const DynamicIsland = () => {
|
|
|
79
77
|
const [currentCycle, setCurrentCycle] = useState(1);
|
|
80
78
|
const [time, setTime] = useState(0);
|
|
81
79
|
|
|
80
|
+
const firstEventId = events?.[0]?.id;
|
|
81
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: A different event with the same deadline starts a new session.
|
|
82
82
|
useEffect(() => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
}, [endAt, firstEventEnd]);
|
|
83
|
+
setEndAt(firstEventEnd);
|
|
84
|
+
setCurrentCycle(1);
|
|
85
|
+
setStartAt(null);
|
|
86
|
+
setTime(0);
|
|
87
|
+
}, [firstEventId, firstEventEnd]);
|
|
90
88
|
|
|
91
89
|
const startTimer = () => {
|
|
92
90
|
if (startAt) {
|
|
93
91
|
setCurrentCycle(1);
|
|
94
92
|
setStartAt(null);
|
|
95
|
-
setEndAt(null);
|
|
96
93
|
setTime(0);
|
|
97
94
|
return;
|
|
98
95
|
}
|
|
@@ -130,13 +127,14 @@ export const DynamicIsland = () => {
|
|
|
130
127
|
// });
|
|
131
128
|
|
|
132
129
|
setStartAt(null);
|
|
133
|
-
setEndAt(null);
|
|
134
130
|
setTime(0);
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
return;
|
|
138
134
|
}
|
|
139
135
|
|
|
136
|
+
if (!startAt || !endAt || time <= 0) return;
|
|
137
|
+
|
|
140
138
|
const interval = setInterval(() => {
|
|
141
139
|
setTime(time - 1);
|
|
142
140
|
}, 1000);
|
|
@@ -158,9 +156,8 @@ export const DynamicIsland = () => {
|
|
|
158
156
|
const hasEvents = events?.length > 0 || !!upcomingEvent;
|
|
159
157
|
const hidden = isEditing() || !hasEvents;
|
|
160
158
|
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
const { bg, text } = getEventStyles(color);
|
|
159
|
+
const activeEvent = isUpcoming ? upcomingEvent : events?.[0];
|
|
160
|
+
const eventStyle = calendarEventStyle(activeEvent ?? {});
|
|
164
161
|
|
|
165
162
|
return (
|
|
166
163
|
<div
|
|
@@ -169,7 +166,7 @@ export const DynamicIsland = () => {
|
|
|
169
166
|
}`}
|
|
170
167
|
>
|
|
171
168
|
<div
|
|
172
|
-
className={`flex max-w-4xl items-center gap-4 rounded-lg border px-8 py-2 shadow-xl backdrop-blur-xl ${
|
|
169
|
+
className={`flex max-w-4xl items-center gap-4 rounded-lg border px-8 py-2 shadow-xl backdrop-blur-xl ${hidden ? 'opacity-0' : 'opacity-100'} ${
|
|
173
170
|
isUpcoming
|
|
174
171
|
? 'w-[calc(min(20rem,100%))] justify-center text-center'
|
|
175
172
|
: isRunning
|
|
@@ -177,6 +174,8 @@ export const DynamicIsland = () => {
|
|
|
177
174
|
: 'w-full justify-between'
|
|
178
175
|
} duration-300`}
|
|
179
176
|
style={{
|
|
177
|
+
...eventStyle,
|
|
178
|
+
borderColor: eventStyle.color,
|
|
180
179
|
transition: 'width 1s, opacity 300ms',
|
|
181
180
|
}}
|
|
182
181
|
>
|
|
@@ -202,7 +201,10 @@ export const DynamicIsland = () => {
|
|
|
202
201
|
{pomodoroCycles > 0 && (
|
|
203
202
|
<>
|
|
204
203
|
{!isRunning && (
|
|
205
|
-
<Separator
|
|
204
|
+
<Separator
|
|
205
|
+
orientation="vertical"
|
|
206
|
+
style={{ backgroundColor: eventStyle.color }}
|
|
207
|
+
/>
|
|
206
208
|
)}
|
|
207
209
|
|
|
208
210
|
<div>
|
|
@@ -230,7 +232,8 @@ export const DynamicIsland = () => {
|
|
|
230
232
|
<button
|
|
231
233
|
type="button"
|
|
232
234
|
onClick={startTimer}
|
|
233
|
-
className=
|
|
235
|
+
className="aspect-square h-fit justify-self-end rounded-lg border p-1 transition hover:ring-1 hover:ring-current"
|
|
236
|
+
style={{ ...eventStyle, borderColor: eventStyle.color }}
|
|
234
237
|
>
|
|
235
238
|
{startAt ? (
|
|
236
239
|
<StopCircle className="h-6 w-6" />
|