@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
@@ -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 { format } from 'date-fns';
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={{ backgroundColor: previewEvent._calendarColor || undefined }}
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
- {formatEventTime(previewEvent.start_at, previewEvent.end_at)}
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 getDayjsDate(visibleDates[startIndex] ?? new Date()).format(
673
- 'MMM D'
674
- );
671
+ return dayjs(visibleDates[startIndex] ?? new Date()).format('MMM D');
675
672
  }
676
- const start = getDayjsDate(visibleDates[startIndex] ?? new Date());
677
- const end = getDayjsDate(visibleDates[startIndex + span - 1] ?? new Date());
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
- styles.bg,
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: isDragging ? 0.7 : 1,
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 = getDayjsDate(visibleDates[dateIndex] ?? new Date());
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) < startOfDay(day);
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:brightness-110 focus-visible:outline-2 focus-visible:outline-ring sm:px-1.5 sm:text-xs',
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 opacity-70 lg:inline">
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
- isToday(day) && 'bg-primary/5'
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={isToday(day) ? 'date' : undefined}
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
- isToday(day) &&
211
+ calendarDayKey(day) === todayKey &&
202
212
  'bg-primary text-primary-foreground hover:bg-primary/90'
203
213
  )}
204
214
  >
@@ -1,42 +1,23 @@
1
1
  'use client';
2
2
 
3
- import { useQuery } from '@tanstack/react-query';
4
3
  import { Calendar, Download, RefreshCw } from '@tuturuuu/icons';
5
- import type { Workspace } from '@tuturuuu/types';
6
4
  import { Button } from '@tuturuuu/ui/button';
7
5
  import { AnalyticsCharts } from '@tuturuuu/ui/legacy/calendar/settings/analytics-charts';
8
6
  import { SummaryCards } from '@tuturuuu/ui/legacy/calendar/settings/summary-cards';
9
7
  import { SyncLogsTable } from '@tuturuuu/ui/legacy/calendar/settings/sync-logs-table';
10
8
  import { useMemo, useState } from 'react';
9
+ import { useVisibleWorkspaces } from '../../../../../hooks/use-visible-workspaces';
11
10
  import type { SyncLog } from './types';
12
11
 
13
- const getWorkspaces = async () => {
14
- const workspaces = await fetch('/api/workspaces');
15
- if (!workspaces.ok) {
16
- throw new Error(`Failed to fetch workspaces: ${workspaces.status}`);
17
- }
18
- return workspaces.json();
19
- };
20
-
21
12
  export function CalendarSyncDashboard({ syncLogs }: { syncLogs: SyncLog[] }) {
22
13
  const [filterType, setFilterType] = useState('all');
23
14
  const [filterWorkspace, setFilterWorkspace] = useState('all');
24
15
  const [searchTerm, setSearchTerm] = useState('');
25
16
 
26
- // Get workspaces from API
27
- const workspacesQuery = useQuery({
28
- queryKey: ['workspaces'],
29
- queryFn: () => getWorkspaces(),
30
- refetchInterval: 1000 * 60 * 5, // 5 minutes
17
+ const workspacesQuery = useVisibleWorkspaces(true, undefined, {
18
+ refetchInterval: 300_000,
31
19
  });
32
-
33
- // Fallback to mock workspaces if API fails
34
- const workspaces: Workspace[] = workspacesQuery.data || [
35
- { id: 'ws_1', name: 'Marketing Team' },
36
- { id: 'ws_2', name: 'Engineering' },
37
- { id: 'ws_3', name: 'Sales Department' },
38
- { id: 'ws_4', name: 'Executive Team' },
39
- ];
20
+ const workspaces = workspacesQuery.data ?? [];
40
21
 
41
22
  const filteredLogs = useMemo(() => {
42
23
  return syncLogs.filter((log) => {
@@ -44,7 +44,7 @@ import type { SyncLog } from './types';
44
44
 
45
45
  interface SyncLogsTableProps {
46
46
  syncLogs: SyncLog[];
47
- workspaces: Workspace[];
47
+ workspaces: Pick<Workspace, 'id' | 'name'>[];
48
48
  filterType: string;
49
49
  filterWorkspace: string;
50
50
  searchTerm: string;
@@ -221,11 +221,8 @@ export function SyncLogsTable({
221
221
  </SelectTrigger>
222
222
  <SelectContent>
223
223
  <SelectItem value="all">All Workspaces</SelectItem>
224
- {workspaces.map((workspace, index) => (
225
- <SelectItem
226
- key={`${workspace.id}-${index}`}
227
- value={workspace.id}
228
- >
224
+ {workspaces.map((workspace) => (
225
+ <SelectItem key={workspace.id} value={workspace.id}>
229
226
  <div className="flex items-center gap-2">
230
227
  <div className="h-2 w-2 rounded-full bg-blue-500" />
231
228
  {workspace.name}
@@ -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 [now, setNow] = useState(tz === 'auto' ? dayjs() : dayjs().tz(tz));
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 { isSameDay } from 'date-fns';
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 now = new Date();
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((date) => isSameDay(date, now));
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() === new Date().getMonth() &&
140
- monthDate.getFullYear() === new Date().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 = getEventDotColor(event.color || 'primary');
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 = isToday(day);
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 key={i} className={cn('h-1 w-1 rounded-full', color)} />
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 key={i} className={cn('h-1 w-1 rounded-full', color)} />
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
  )}
@@ -8,6 +8,7 @@ import {
8
8
  type GroupProps as GroupPropsOriginal,
9
9
  Panel,
10
10
  Separator,
11
+ usePanelRef,
11
12
  } from 'react-resizable-panels';
12
13
 
13
14
  interface ResizablePanelGroupProps
@@ -96,4 +97,4 @@ function ResizableHandle({
96
97
  );
97
98
  }
98
99
 
99
- export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
100
+ export { ResizableHandle, ResizablePanel, ResizablePanelGroup, usePanelRef };
@@ -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',