@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
@@ -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
- tz === 'auto' ? dayjs(visibleDates[0]) : dayjs(visibleDates[0]).tz(tz);
326
- const lastVisibleDate =
327
- tz === 'auto'
328
- ? dayjs(visibleDates[visibleDates.length - 1])
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
- // Use the assigned row directly instead of calculating it
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 opacity-30',
945
- // Normal styling
946
- bg,
947
- border,
948
- text,
934
+ isDraggedEvent && 'scale-95 outline outline-dashed',
949
935
  isPendingMutation &&
950
- 'opacity-60 outline outline-dashed outline-1 outline-primary/50',
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: { appearance: { firstDayOfWeek: 'monday' } },
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', () => ({ EventModal: () => null }));
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(externalState?.date || new Date());
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
- if (externalState?.setDate) {
151
- externalState.setDate(newDate);
152
- } else {
153
- setDate(newDate);
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
- // Update the date's hour and minute, every minute
410
+ // Settings bridge supplies the same resolved zone to the outer navigation and queries.
398
411
  useEffect(() => {
399
- const secondsToNextMinute = 60 - new Date().getSeconds();
400
-
401
- const timeout = setTimeout(() => {
402
- handleSetDate((date) => {
403
- const newDate = new Date(date);
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
+ });