@tuturuuu/ui 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +7 -5
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +181 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  6. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  7. package/src/components/ui/custom/structure.tsx +37 -5
  8. package/src/components/ui/custom/user-filters.tsx +2 -1
  9. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  10. package/src/components/ui/custom/workspace-picker-content.tsx +293 -0
  11. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  12. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  13. package/src/components/ui/custom/workspace-select.tsx +87 -359
  14. package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
  15. package/src/components/ui/date-time-picker-values.ts +205 -0
  16. package/src/components/ui/date-time-picker.tsx +70 -167
  17. package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
  18. package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
  19. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
  20. package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
  21. package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
  22. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
  23. package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
  24. package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
  25. package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
  26. package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
  27. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
  28. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
  29. package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
  30. package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
  31. package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
  32. package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
  33. package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
  34. package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
  35. package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
  36. package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
  37. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
  38. package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
  39. package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
  40. package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
  41. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  42. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  43. package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
  44. package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
  45. package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
  46. package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
  47. package/src/components/ui/resizable.tsx +2 -1
  48. package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
  49. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  52. package/src/hooks/calendar-event-lookup.test.ts +39 -0
  53. package/src/hooks/calendar-event-lookup.ts +22 -8
  54. package/src/hooks/calendar-sync-range.test.ts +92 -0
  55. package/src/hooks/calendar-sync-range.ts +40 -0
  56. package/src/hooks/use-calendar-clock.ts +49 -0
  57. package/src/hooks/use-calendar-day-zone.tsx +12 -0
  58. package/src/hooks/use-calendar-sync.tsx +42 -80
  59. package/src/hooks/use-calendar.tsx +3 -3
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-user.ts +15 -3
  62. package/src/hooks/use-workspace-visibility.ts +234 -0
  63. 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 = () => setDate(new Date());
88
- const isTodaySelected = () => dayjs(date).isSame(dayjs(), 'day');
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() === new Date().getMonth() &&
92
- date.getFullYear() === new Date().getFullYear();
100
+ date.getMonth() === today.getMonth() &&
101
+ date.getFullYear() === today.getFullYear();
93
102
  const isCurrentYear = () =>
94
- view === 'year' && date.getFullYear() === new 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={dayjs(dates[index]!).format('YYYY-MM-DD')}
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((date) => getDayKeyFromDate(date, tz))),
201
- [dates, tz]
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.events.filter(
16
- (event) =>
17
- new Date(event.start_at).toDateString() === day.toDateString()
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: 'America/New_York' },
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 today = tz === 'auto' ? dayjs() : dayjs().tz(tz);
31
- const dayjsDate = tz === 'auto' ? dayjs(date) : dayjs(date).tz(tz);
32
- const isToday = dayjsDate.isSame(today, 'day');
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 { getEventStyles } from '@tuturuuu/utils/color-helper';
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 firstEventEnd = events?.[0]?.end_at
57
- ? moment(events[0].end_at).toDate()
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
- if (endAt && firstEventEnd !== endAt) {
84
- setEndAt(firstEventEnd);
85
- setCurrentCycle(1);
86
- setStartAt(null);
87
- setTime(0);
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 color =
162
- (isUpcoming ? upcomingEvent?.color : events?.[0]?.color) ?? 'BLUE';
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 ${bg} ${text} ${hidden ? 'opacity-0' : 'opacity-100'} ${
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 orientation="vertical" className={bg} />
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={`aspect-square h-fit justify-self-end rounded-lg border p-1 ${bg} transition`}
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" />