@tuturuuu/ui 0.30.2 → 0.32.0

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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
  16. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  17. package/src/components/ui/custom/nav-link.tsx +7 -7
  18. package/src/components/ui/custom/nav.tsx +15 -63
  19. package/src/components/ui/custom/notification-card.tsx +361 -0
  20. package/src/components/ui/custom/notification-list.tsx +178 -0
  21. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  22. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  24. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  25. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  26. package/src/components/ui/custom/satellite-content.tsx +148 -0
  27. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  28. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  29. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  30. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  31. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  32. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  33. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  34. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  35. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  36. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  37. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  38. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  39. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  40. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  41. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  42. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  43. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  44. package/src/components/ui/custom/structure.tsx +14 -0
  45. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  46. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  47. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  48. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  49. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  50. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  51. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  53. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  54. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  55. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  56. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  57. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  58. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  59. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  61. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  62. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  63. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  65. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  66. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  67. package/src/components/ui/pwa-status.test.tsx +53 -0
  68. package/src/components/ui/pwa-status.tsx +113 -0
  69. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  70. package/src/components/ui/report-problem-dialog.tsx +6 -975
  71. package/src/components/ui/report-problem-model.ts +133 -0
  72. package/src/components/ui/report-problem-mutation.ts +122 -0
  73. package/src/components/ui/save-notification.test.tsx +36 -0
  74. package/src/components/ui/save-notification.tsx +17 -0
  75. package/src/components/ui/use-report-problem.ts +488 -0
  76. package/src/declarations.d.ts +2 -0
  77. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  78. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  79. package/src/hooks/calendar-date-utils.ts +11 -0
  80. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  81. package/src/hooks/calendar-event-lookup.ts +47 -0
  82. package/src/hooks/calendar-range-cache.ts +52 -0
  83. package/src/hooks/pwa-install-prompt.ts +44 -0
  84. package/src/hooks/use-calendar-sync.tsx +23 -100
  85. package/src/hooks/use-calendar.tsx +21 -77
  86. package/src/hooks/use-notifications.ts +9 -24
  87. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  88. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  89. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  90. package/src/hooks/use-workspace-presence.ts +0 -18
  91. package/src/lib/tasks-app-url.test.ts +25 -0
  92. package/src/lib/tasks-app-url.ts +8 -0
@@ -1,986 +1,295 @@
1
1
  'use client';
2
2
 
3
- import { Clock, Plus } from '@tuturuuu/icons';
3
+ 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
- import { Button } from '@tuturuuu/ui/button';
7
6
  import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
8
7
  import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
9
- import { usePopoverManager } from '@tuturuuu/ui/hooks/use-popover-manager';
10
8
  import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
11
- import {
12
- HoverCard,
13
- HoverCardContent,
14
- HoverCardTrigger,
15
- } from '@tuturuuu/ui/hover-card';
16
- import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
17
9
  import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
18
10
  import { cn } from '@tuturuuu/utils/format';
19
11
  import { getTimeFormatPattern } from '@tuturuuu/utils/time-helper';
20
12
  import {
13
+ addDays,
21
14
  eachDayOfInterval,
22
15
  endOfMonth,
23
- format,
24
- getDay,
16
+ endOfWeek,
25
17
  isSameMonth,
26
18
  isToday,
19
+ startOfDay,
27
20
  startOfMonth,
21
+ startOfWeek,
28
22
  } from 'date-fns';
29
23
  import dayjs from 'dayjs';
30
- import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
31
24
  import timezone from 'dayjs/plugin/timezone';
32
- import { useCallback, useEffect, useMemo, useState } from 'react';
33
- import {
34
- formatLunarDay,
35
- getLunarDate,
36
- getLunarHolidayName,
37
- isSpecialLunarDate,
38
- } from '../../../../lib/lunar-calendar';
25
+ import utc from 'dayjs/plugin/utc';
26
+ import { useTranslations } from 'next-intl';
27
+ import { useMemo } from 'react';
28
+ import { formatLunarDay, getLunarDate } from '../../../../lib/lunar-calendar';
29
+ import { Button } from '../../button';
39
30
  import { Popover, PopoverContent, PopoverTrigger } from '../../popover';
40
- import { getColorHighlight } from './color-highlights';
31
+ import { calendarEventTone } from './calendar-event-tone';
32
+ import { calendarDraftDate } from './calendar-period';
41
33
  import { useCalendarSettings } from './settings/settings-context';
42
34
 
35
+ dayjs.extend(utc);
43
36
  dayjs.extend(timezone);
44
- dayjs.extend(isSameOrAfter);
45
-
46
37
  interface MonthCalendarProps {
47
38
  date: Date;
39
+ readOnly?: boolean;
48
40
  workspace?: Workspace;
49
41
  visibleDates?: Date[];
50
42
  viewedMonth?: Date;
51
43
  locale?: string;
44
+ onDayClick?: (date: Date) => void;
52
45
  }
53
46
 
54
- interface MultiDayEventSegment {
47
+ function MonthEvent({
48
+ event,
49
+ day,
50
+ timePattern,
51
+ zone,
52
+ onOpen,
53
+ }: {
55
54
  event: CalendarEvent;
56
- startCol: number;
57
- span: number;
58
- rowIndex: number;
59
- position: number; // Position in the all-day stack (0 = first, 1 = second, etc.)
60
- }
61
-
62
- const normalizeColor = (color: string): string => {
63
- if (!color) return 'primary';
64
- const normalized = color.trim().toLowerCase();
65
-
66
- // Map specific values to standardized names
67
- if (normalized === '#6b7280' || normalized === 'grey') return 'gray';
68
-
69
- return normalized;
70
- };
71
-
72
- const getDominantEventColor = (events: any[]): string => {
73
- if (events.length === 0) return 'primary';
74
- if (events.length === 1) return normalizeColor(events[0].color || 'primary');
75
-
76
- const colorCount = new Map<string, number>();
77
- for (const event of events) {
78
- const normalizedColor = normalizeColor(event.color || 'primary');
79
- colorCount.set(normalizedColor, (colorCount.get(normalizedColor) || 0) + 1);
80
- }
81
-
82
- let dominantColor = 'primary';
83
- let maxCount = -1;
84
- for (const [color, count] of colorCount) {
85
- if (count > maxCount) {
86
- dominantColor = color;
87
- maxCount = count;
88
- }
89
- }
90
- return dominantColor;
91
- };
92
-
93
- // Utility function for scroll shadow classes
94
- const getScrollShadowClasses = (
95
- scrollState: { top: boolean; bottom: boolean } | undefined
96
- ) => {
97
- return cn(
98
- scrollState?.top &&
99
- 'before:pointer-events-none before:absolute before:top-0 before:right-0 before:left-0 before:h-3 before:bg-linear-to-b before:from-muted/80 before:to-transparent',
100
- scrollState?.bottom &&
101
- 'after:pointer-events-none after:absolute after:right-0 after:bottom-0 after:left-0 after:h-3 after:bg-linear-to-t after:from-muted/80 after:to-transparent'
55
+ day: Date;
56
+ timePattern: string;
57
+ zone?: string;
58
+ onOpen: (id: string) => void;
59
+ }) {
60
+ const t = useTranslations('calendar');
61
+ const title = event.title || t('views.untitled_event');
62
+ const allDay = isAllDayEvent(event);
63
+ const continued = new Date(event.start_at) < startOfDay(day);
64
+ return (
65
+ <button
66
+ type="button"
67
+ onClick={() => onOpen(event.id)}
68
+ title={title}
69
+ 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),
72
+ allDay && 'font-medium'
73
+ )}
74
+ >
75
+ {continued ? (
76
+ <ArrowLeft className="size-2.5 shrink-0" />
77
+ ) : (
78
+ !allDay && (
79
+ <span className="hidden shrink-0 tabular-nums opacity-70 lg:inline">
80
+ {(zone && zone !== 'auto'
81
+ ? dayjs(event.start_at).tz(zone)
82
+ : dayjs(event.start_at)
83
+ ).format(timePattern)}
84
+ </span>
85
+ )
86
+ )}
87
+ <span className="truncate">{title}</span>
88
+ </button>
102
89
  );
103
- };
90
+ }
104
91
 
105
- export const MonthCalendar = ({
92
+ export function MonthCalendar({
106
93
  date,
94
+ readOnly = false,
95
+ viewedMonth = date,
107
96
  visibleDates,
108
- viewedMonth,
109
97
  locale = 'en',
110
- }: MonthCalendarProps) => {
111
- const { getCurrentEvents, addEmptyEvent, openModal } = useCalendar();
98
+ onDayClick,
99
+ }: MonthCalendarProps) {
100
+ const t = useTranslations('calendar');
101
+ const {
102
+ getCurrentEvents,
103
+ addEmptyEvent,
104
+ openModal,
105
+ readOnly: providerReadOnly,
106
+ } = useCalendar();
107
+ const cannotCreate = readOnly || providerReadOnly;
112
108
  const { settings } = useCalendarSettings();
109
+ const { timeFormat, weekStartsOn } = useCalendarPreferences();
113
110
  const { value: showLunar } = useUserBooleanConfig(
114
111
  'SHOW_LUNAR_CALENDAR',
115
112
  locale.startsWith('vi')
116
113
  );
117
- const { timeFormat } = useCalendarPreferences();
118
114
  const timePattern = getTimeFormatPattern(timeFormat);
119
- const [currDate, setCurrDate] = useState(date);
120
- const [hoveredDay, setHoveredDay] = useState<Date | null>(null);
121
- const tz = settings?.timezone?.timezone;
122
-
123
- // Layout constants for calendar dimensions and positioning
124
- const LAYOUT_CONSTANTS = {
125
- DAY_HEADER_HEIGHT: 28, // Height of day number and plus button area (h-7 + padding)
126
- CONTAINER_PADDING: 6, // p-1.5 = 6px padding
127
- EVENT_HEIGHT: 26, // Height of each event (increased for better readability)
128
- EVENT_SPACING: 4, // gap-1 = 4px spacing between events
129
- MARGIN_TOP: 6, // mt-1.5 = 6px margin top for events container
130
- MIN_DAY_HEIGHT: 140, // min-h-[140px] - increased for better event visibility
131
- } as const;
132
-
133
- // Layout constants for event display
134
- const MAX_ALL_DAY_VISIBLE = 2; // Maximum all-day events visible before overflow
135
- const MAX_TIMED_VISIBLE = 2; // Maximum timed events visible before overflow
136
-
137
- // Update currDate when date prop changes
138
- useEffect(() => {
139
- setCurrDate(date);
140
- }, [date]);
141
-
142
- // Get first day of week from settings or infer from visibleDates
143
- const firstDayOfWeek = useMemo(() => {
144
- const settingValue = settings?.appearance?.firstDayOfWeek;
145
-
146
- // If we have visibleDates, infer first day from the first date (most reliable)
147
- // The visibleDates from calendar-content are already calculated with the correct first day
148
- if (visibleDates && visibleDates.length > 0 && visibleDates[0]) {
149
- return visibleDates[0].getDay();
150
- }
151
-
152
- // Fallback to settings
153
- if (settingValue === 'sunday') return 0;
154
- if (settingValue === 'saturday') return 6;
155
- if (settingValue === 'monday') return 1;
156
-
157
- // 'auto' or undefined - default to Monday (locale detection happens in calendar-content)
158
- return 1;
159
- }, [settings?.appearance?.firstDayOfWeek, visibleDates]);
160
-
161
- // Get weekday labels based on first day of week
162
- const weekdayLabels = useMemo(() => {
163
- const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
164
- const reorderedDays = [...days];
165
-
166
- // Reorder days based on first day of week
167
- for (let i = 0; i < firstDayOfWeek; i++) {
168
- const day = reorderedDays.shift();
169
- if (day) reorderedDays.push(day);
170
- }
171
-
172
- return reorderedDays;
173
- }, [firstDayOfWeek]);
174
-
175
- // Get days in month
176
- const monthStart = startOfMonth(currDate);
177
- const monthEnd = endOfMonth(currDate);
178
- const monthDays = eachDayOfInterval({ start: monthStart, end: monthEnd });
179
-
180
- // Calculate days needed to fill the calendar grid (weeks)
181
- // Adjust startDay calculation based on first day of week
182
- const startDay = (getDay(monthStart) - firstDayOfWeek + 7) % 7;
183
- const endDay = 6 - ((getDay(monthEnd) - firstDayOfWeek + 7) % 7);
184
-
185
- // Get days from previous month to fill first week
186
- const prevMonthDays = [];
187
- for (let i = startDay - 1; i >= 0; i--) {
188
- const day =
189
- tz === 'auto'
190
- ? dayjs(monthStart)
191
- .subtract(i + 1, 'day')
192
- .toDate()
193
- : dayjs(monthStart)
194
- .tz(tz)
195
- .subtract(i + 1, 'day')
196
- .toDate();
197
- prevMonthDays.push(day);
198
- }
199
-
200
- // Get days from next month to fill last week
201
- const nextMonthDays = [];
202
- for (let i = 0; i < endDay; i++) {
203
- const day =
204
- tz === 'auto'
205
- ? dayjs(monthEnd)
206
- .add(i + 1, 'day')
207
- .toDate()
208
- : dayjs(monthEnd)
209
- .tz(tz)
210
- .add(i + 1, 'day')
211
- .toDate();
212
- nextMonthDays.push(day);
213
- }
214
-
215
- // Use visibleDates if provided, otherwise fallback to old logic
216
- const calendarDays = visibleDates ?? [
217
- ...prevMonthDays,
218
- ...monthDays,
219
- ...nextMonthDays,
220
- ];
221
-
222
- // Create weeks (group by 7 days)
223
- const weeks = useMemo(() => {
224
- const result: Date[][] = [];
225
- for (let i = 0; i < calendarDays.length; i += 7) {
226
- result.push(calendarDays.slice(i, i + 7));
227
- }
228
- return result;
229
- }, [calendarDays]);
230
-
231
- // Calculate multi-day event segments for rendering as spanning bars
232
- const multiDaySegments = useMemo(() => {
233
- const segments: MultiDayEventSegment[] = [];
234
- const processedInWeek = new Map<number, Set<string>>(); // week index -> set of event IDs
235
-
236
- weeks.forEach((week, rowIndex) => {
237
- const eventsInWeek = new Set<string>();
238
- const weekSegments: Array<{
239
- event: CalendarEvent;
240
- startCol: number;
241
- span: number;
242
- }> = [];
243
-
244
- // Find all multi-day events that overlap with this week
245
- week.forEach((day) => {
246
- const dayEvents = getCurrentEvents(day);
247
-
248
- dayEvents.forEach((event) => {
249
- if (!isAllDayEvent(event)) return;
250
- if (eventsInWeek.has(event.id)) return;
251
-
252
- const eventStart = dayjs(event.start_at);
253
- const eventEnd = dayjs(event.end_at);
254
- const durationDays = eventEnd.diff(eventStart, 'day');
255
-
256
- // Only process multi-day events (spanning 2+ days)
257
- if (durationDays <= 1) return;
258
-
259
- // Find which columns this event spans in this week
260
- let startCol = -1;
261
- let endCol = -1;
262
-
263
- week.forEach((weekDay, weekCol) => {
264
- const weekDayStart = dayjs(weekDay).startOf('day');
265
- const isInRange =
266
- weekDayStart.isSameOrAfter(eventStart, 'day') &&
267
- weekDayStart.isBefore(eventEnd, 'day');
268
-
269
- if (isInRange) {
270
- if (startCol === -1) startCol = weekCol;
271
- endCol = weekCol;
272
- }
273
- });
274
-
275
- if (startCol !== -1 && endCol !== -1 && endCol >= startCol) {
276
- weekSegments.push({
277
- event,
278
- startCol,
279
- span: endCol - startCol + 1,
280
- });
281
- eventsInWeek.add(event.id);
282
- }
283
- });
284
- });
285
-
286
- // Assign vertical positions to avoid overlaps
287
- weekSegments.sort((a, b) => {
288
- // Sort by start column, then by duration (longer first)
289
- if (a.startCol !== b.startCol) return a.startCol - b.startCol;
290
- return b.span - a.span;
291
- });
292
-
293
- const positions: number[] = [];
294
- weekSegments.forEach((seg, idx) => {
295
- // Simple greedy assignment - find first available position
296
- let position = 0;
297
- const usedPositions = new Set(
298
- weekSegments.slice(0, idx).map((_, i) => {
299
- const otherSeg = weekSegments[i];
300
- const overlaps =
301
- (otherSeg?.startCol || 0) < seg.startCol + seg.span &&
302
- (otherSeg?.startCol || 0) + (otherSeg?.span || 0) > seg.startCol;
303
- return overlaps ? positions[i] : -1;
304
- })
305
- );
306
-
307
- while (usedPositions.has(position)) {
308
- position++;
309
- }
310
-
311
- positions.push(position);
312
- segments.push({
313
- ...seg,
314
- rowIndex,
315
- position,
316
- });
317
- });
318
-
319
- processedInWeek.set(rowIndex, eventsInWeek);
320
- });
321
-
322
- return segments;
323
- }, [getCurrentEvents, weeks]);
324
-
325
- // Get single-day all-day events for a day (multi-day events rendered separately as bars)
326
- const getSingleDayAllDayEvents = useCallback(
327
- (day: Date) => {
328
- const dayEvents = getCurrentEvents(day);
329
- const allDayEvents: CalendarEvent[] = [];
330
-
331
- dayEvents.forEach((event) => {
332
- if (isAllDayEvent(event)) {
333
- const eventStart = dayjs(event.start_at);
334
- const eventEnd = dayjs(event.end_at);
335
- const durationDays = eventEnd.diff(eventStart, 'day');
336
-
337
- // Only include single-day all-day events
338
- if (durationDays <= 1) {
339
- allDayEvents.push(event);
340
- }
341
- }
342
- });
343
-
344
- // Sort by start time
345
- allDayEvents.sort((a, b) => {
346
- const aStart = new Date(a.start_at).getTime();
347
- const bStart = new Date(b.start_at).getTime();
348
- return aStart - bStart;
349
- });
350
-
351
- return allDayEvents;
352
- },
353
- [getCurrentEvents]
115
+ const firstDay = (visibleDates?.[0]?.getDay() ?? weekStartsOn) as
116
+ | 0
117
+ | 1
118
+ | 2
119
+ | 3
120
+ | 4
121
+ | 5
122
+ | 6;
123
+ const days = useMemo(
124
+ () =>
125
+ visibleDates?.length
126
+ ? visibleDates
127
+ : eachDayOfInterval({
128
+ start: startOfWeek(startOfMonth(viewedMonth), {
129
+ weekStartsOn: firstDay,
130
+ }),
131
+ end: endOfWeek(endOfMonth(viewedMonth), { weekStartsOn: firstDay }),
132
+ }),
133
+ [visibleDates, viewedMonth, firstDay]
354
134
  );
355
-
356
- // Get timed events for a day
357
- const getTimedEventsForDay = useCallback(
358
- (day: Date) => {
359
- const dayEvents = getCurrentEvents(day);
360
- const timedEvents: CalendarEvent[] = [];
361
-
362
- dayEvents.forEach((event) => {
363
- if (!isAllDayEvent(event)) {
364
- timedEvents.push(event);
365
- }
366
- });
367
-
368
- // Sort by start time
369
- timedEvents.sort((a, b) => {
370
- const aStart = new Date(a.start_at).getTime();
371
- const bStart = new Date(b.start_at).getTime();
372
- return aStart - bStart;
373
- });
374
-
375
- return timedEvents;
376
- },
377
- [getCurrentEvents]
135
+ const weekdays = Array.from({ length: 7 }, (_, index) =>
136
+ addDays(days[0]!, index)
378
137
  );
379
-
380
- // Handle adding a new event
381
- const handleAddEvent = (day: Date) => {
382
- // Create event at 9:00 AM on the selected day
383
- const eventDate =
384
- tz === 'auto'
385
- ? dayjs(day).hour(9).minute(0).second(0).millisecond(0).toDate()
386
- : dayjs(day).tz(tz).hour(9).minute(0).second(0).millisecond(0).toDate();
387
- addEmptyEvent(eventDate);
388
- };
389
-
390
- const formatEventTime = (event: any) => {
391
- try {
392
- const start = new Date(event.start_at);
393
- const end = new Date(event.end_at);
394
- return `${format(start, timePattern)} - ${format(end, timePattern)}`;
395
- } catch (_) {
396
- return '';
397
- }
398
- };
399
-
400
- // Get color styles for an event using dynamic color tokens
401
- const getEventStyles = (
402
- event: any
403
- ): { bg: string; text: string; border: string } => {
404
- const colorMap: Record<
405
- string,
406
- { bg: string; text: string; border: string }
407
- > = {
408
- blue: {
409
- bg: 'bg-dynamic-blue/10',
410
- text: 'text-dynamic-blue',
411
- border: 'border-dynamic-blue/30',
412
- },
413
- red: {
414
- bg: 'bg-dynamic-red/10',
415
- text: 'text-dynamic-red',
416
- border: 'border-dynamic-red/30',
417
- },
418
- green: {
419
- bg: 'bg-dynamic-green/10',
420
- text: 'text-dynamic-green',
421
- border: 'border-dynamic-green/30',
422
- },
423
- purple: {
424
- bg: 'bg-dynamic-purple/10',
425
- text: 'text-dynamic-purple',
426
- border: 'border-dynamic-purple/30',
427
- },
428
- yellow: {
429
- bg: 'bg-dynamic-yellow/10',
430
- text: 'text-dynamic-yellow',
431
- border: 'border-dynamic-yellow/30',
432
- },
433
- orange: {
434
- bg: 'bg-dynamic-orange/10',
435
- text: 'text-dynamic-orange',
436
- border: 'border-dynamic-orange/30',
437
- },
438
- pink: {
439
- bg: 'bg-dynamic-pink/10',
440
- text: 'text-dynamic-pink',
441
- border: 'border-dynamic-pink/30',
442
- },
443
- cyan: {
444
- bg: 'bg-dynamic-cyan/10',
445
- text: 'text-dynamic-cyan',
446
- border: 'border-dynamic-cyan/30',
447
- },
448
- indigo: {
449
- bg: 'bg-dynamic-indigo/10',
450
- text: 'text-dynamic-indigo',
451
- border: 'border-dynamic-indigo/30',
452
- },
453
- gray: {
454
- bg: 'bg-dynamic-gray/10',
455
- text: 'text-dynamic-gray',
456
- border: 'border-dynamic-gray/30',
457
- },
458
- };
459
- const normalizedColor = normalizeColor(event.color || 'blue');
460
- return (
461
- normalizedColor && colorMap[normalizedColor]
462
- ? colorMap[normalizedColor]
463
- : colorMap.blue
464
- ) as {
465
- bg: string;
466
- text: string;
467
- border: string;
468
- };
138
+ const showWeekends = settings?.appearance?.showWeekends !== false;
139
+ const visibleDays = days.filter(
140
+ (day) => showWeekends || ![0, 6].includes(day.getDay())
141
+ );
142
+ const columns = showWeekends ? 7 : 5;
143
+ const rows = Math.ceil(visibleDays.length / columns);
144
+ const addEvent = (day: Date) => {
145
+ if (cannotCreate) return;
146
+ addEmptyEvent(calendarDraftDate(day, settings?.timezone?.timezone));
469
147
  };
470
-
471
- // Memoize dominant color for each day
472
- const dominantColorForDay = useMemo(() => {
473
- const map: Record<string, string> = {};
474
- for (const day of calendarDays) {
475
- const events = getCurrentEvents(day);
476
- map[day.toISOString()] = getDominantEventColor(events);
477
- }
478
- return map;
479
- }, [calendarDays, getCurrentEvents]);
480
-
481
- // Use the custom popover manager hook
482
- const {
483
- moreButtonRefs,
484
- popoverContentRefs,
485
- openPopoverIdx,
486
- setOpenPopoverIdx,
487
- scrollStates,
488
- setPopoverHovered,
489
- handlePopoverScroll,
490
- } = usePopoverManager();
491
-
492
148
  return (
493
- <div className="flex-1 overflow-auto rounded-md border bg-background shadow-sm">
494
- <div className="grid grid-cols-7 divide-x divide-y border-b text-center">
495
- {weekdayLabels.map((day) => (
496
- <div
497
- key={day}
498
- className={cn(
499
- 'py-2 font-medium text-sm',
500
- (day === 'Sun' || day === 'Sat') &&
501
- !settings.appearance.showWeekends
502
- ? 'text-muted-foreground/50'
503
- : 'text-muted-foreground'
504
- )}
505
- >
506
- {day}
507
- </div>
508
- ))}
509
- </div>
510
-
511
- <div className="relative">
512
- {weeks.map((week, weekIndex) => (
513
- <div key={`week-${weekIndex}`} className="relative">
514
- {/* Multi-day event bars for this week */}
515
- <div className="pointer-events-none absolute inset-0 z-10">
516
- {multiDaySegments
517
- .filter((seg) => seg.rowIndex === weekIndex)
518
- .map((segment) => {
519
- const { event, startCol, span, position } = segment;
520
- const { bg, text, border } = getEventStyles(event);
521
-
522
- const eventHeight = LAYOUT_CONSTANTS.EVENT_HEIGHT;
523
- const dayHeaderHeight = LAYOUT_CONSTANTS.DAY_HEADER_HEIGHT;
524
- const containerPadding = LAYOUT_CONSTANTS.CONTAINER_PADDING;
525
- const marginTop = LAYOUT_CONSTANTS.MARGIN_TOP;
526
- const eventSpacing = LAYOUT_CONSTANTS.EVENT_SPACING;
527
-
528
- const topOffset =
529
- dayHeaderHeight +
530
- containerPadding +
531
- marginTop +
532
- position * (eventHeight + eventSpacing);
533
-
534
- const leftPercent = (startCol / 7) * 100;
535
- const widthPercent = (span / 7) * 100;
536
-
537
- return (
538
- <HoverCard
539
- key={`segment-${event.id}-${weekIndex}`}
540
- openDelay={200}
541
- closeDelay={100}
542
- >
543
- <HoverCardTrigger asChild>
544
- <div
545
- className={cn(
546
- 'pointer-events-auto absolute cursor-pointer truncate rounded-md border border-l-2 px-2 py-1 font-medium text-xs transition-all hover:shadow-sm',
547
- bg,
548
- text,
549
- border
550
- )}
551
- style={{
552
- left: `calc(${leftPercent}% + ${containerPadding}px)`,
553
- width: `calc(${widthPercent}% - ${containerPadding * 2}px)`,
554
- top: `${topOffset}px`,
555
- height: `${eventHeight}px`,
556
- lineHeight: `${eventHeight - 8}px`,
557
- }}
558
- onClick={() => openModal(event.id)}
559
- >
560
- {event.title || 'Untitled event'}
561
- </div>
562
- </HoverCardTrigger>
563
- <HoverCardContent
564
- side="right"
565
- align="start"
566
- className="w-80"
567
- >
568
- <div className="space-y-2">
569
- <h4 className="wrap-break-word line-clamp-2 font-semibold">
570
- {event.title || 'Untitled event'}
571
- </h4>
572
- {event.description && (
573
- <div
574
- className="text-muted-foreground text-sm [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-primary/80"
575
- {...{
576
- dangerouslySetInnerHTML: {
577
- __html: event.description,
578
- },
579
- }}
580
- />
581
- )}
582
- <div className="flex items-center text-muted-foreground text-xs">
583
- <Clock className="mr-1.5 h-3.5 w-3.5" />
584
- <span>
585
- All day • {dayjs(event.start_at).format('MMM D')}{' '}
586
- -{' '}
587
- {dayjs(event.end_at)
588
- .subtract(1, 'day')
589
- .format('MMM D')}
590
- </span>
591
- </div>
592
- </div>
593
- </HoverCardContent>
594
- </HoverCard>
595
- );
596
- })}
149
+ <div
150
+ className="flex h-full min-h-0 flex-col bg-background"
151
+ data-calendar-view="month"
152
+ >
153
+ <div
154
+ className="grid shrink-0 border-b bg-muted/30"
155
+ style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
156
+ >
157
+ {weekdays
158
+ .filter((day) => showWeekends || ![0, 6].includes(day.getDay()))
159
+ .map((day) => (
160
+ <div
161
+ key={day.getDay()}
162
+ className="px-2 py-3 text-center font-medium text-muted-foreground text-xs"
163
+ >
164
+ {day.toLocaleDateString(locale, { weekday: 'short' })}
597
165
  </div>
598
-
599
- {/* Week row grid */}
600
- <div className="grid grid-cols-7 divide-x divide-y">
601
- {week.map((day, dayIdx) => {
602
- const globalDayIdx = weekIndex * 7 + dayIdx;
603
- const dominantColor =
604
- dominantColorForDay[day.toISOString()] || 'primary';
605
- const highlightClass = isToday(day)
606
- ? `${getColorHighlight(dominantColor)} z-10`
607
- : '';
608
-
609
- const isCurrentMonth = isSameMonth(
610
- day,
611
- viewedMonth ?? currDate
612
- );
613
- const isTodayDate = isToday(day);
614
- const isWeekend = day.getDay() === 0 || day.getDay() === 6;
615
- const isHidden = isWeekend && !settings.appearance.showWeekends;
616
- const isHovered =
617
- hoveredDay &&
618
- hoveredDay.getDate() === day.getDate() &&
619
- hoveredDay.getMonth() === day.getMonth() &&
620
- hoveredDay.getFullYear() === day.getFullYear();
621
-
622
- return (
623
- <div
624
- key={day.toString()}
625
- className={cn(
626
- 'group relative min-h-35 p-1.5 transition-colors',
627
- !isCurrentMonth && 'bg-muted/50',
628
- highlightClass,
629
- isHovered && 'bg-muted/30',
630
- isHidden && 'bg-muted/10'
631
- )}
632
- onMouseEnter={() => setHoveredDay(day)}
633
- onMouseLeave={() => setHoveredDay(null)}
166
+ ))}
167
+ </div>
168
+ <div
169
+ className="grid min-h-0 flex-1 overflow-auto"
170
+ style={{
171
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
172
+ gridTemplateRows: `repeat(${rows}, minmax(160px, 1fr))`,
173
+ }}
174
+ >
175
+ {visibleDays.map((day) => {
176
+ const events = [...getCurrentEvents(day)].sort(
177
+ (a, b) =>
178
+ Number(isAllDayEvent(b)) - Number(isAllDayEvent(a)) ||
179
+ +new Date(a.start_at) - +new Date(b.start_at)
180
+ );
181
+ const currentMonth = isSameMonth(day, viewedMonth);
182
+ const label = day.toLocaleDateString(locale, { dateStyle: 'full' });
183
+ return (
184
+ <section
185
+ key={day.toISOString()}
186
+ aria-label={label}
187
+ className={cn(
188
+ 'group min-w-0 border-r border-b p-1 sm:p-2',
189
+ !currentMonth && 'bg-muted/35 text-muted-foreground',
190
+ isToday(day) && 'bg-primary/5'
191
+ )}
192
+ >
193
+ <div className="mb-1 flex min-h-7 items-center justify-between gap-1">
194
+ <button
195
+ type="button"
196
+ aria-label={label}
197
+ aria-current={isToday(day) ? 'date' : undefined}
198
+ onClick={() => onDayClick?.(day)}
199
+ className={cn(
200
+ '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) &&
202
+ 'bg-primary text-primary-foreground hover:bg-primary/90'
203
+ )}
204
+ >
205
+ {day.getDate()}
206
+ </button>
207
+ {showLunar && (
208
+ <span className="hidden text-[10px] text-muted-foreground sm:inline">
209
+ {formatLunarDay(getLunarDate(day))}
210
+ </span>
211
+ )}
212
+ <button
213
+ type="button"
214
+ disabled={cannotCreate}
215
+ aria-label={t('views.create_on_date', { date: label })}
216
+ onClick={() => addEvent(day)}
217
+ className="hidden size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent focus-visible:opacity-100 sm:flex sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"
218
+ >
219
+ <Plus className="size-3" />
220
+ </button>
221
+ </div>
222
+ <div className="space-y-1">
223
+ {events.slice(0, 3).map((event) => (
224
+ <MonthEvent
225
+ key={event.id}
226
+ event={event}
227
+ day={day}
228
+ timePattern={timePattern}
229
+ zone={settings?.timezone?.timezone}
230
+ onOpen={openModal}
231
+ />
232
+ ))}
233
+ </div>
234
+ <Popover>
235
+ <PopoverTrigger asChild>
236
+ <button
237
+ type="button"
238
+ className="mt-1 w-full truncate rounded px-1 py-1 text-left text-[10px] text-muted-foreground hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring sm:text-xs"
239
+ aria-label={t('views.open_day', { date: label })}
634
240
  >
635
- <div className="flex items-center justify-between">
636
- <div className="flex items-center gap-1">
637
- <span
638
- className={cn(
639
- 'flex h-7 w-7 items-center justify-center text-sm',
640
- isTodayDate &&
641
- 'rounded-full bg-primary font-medium text-primary-foreground',
642
- !isCurrentMonth && 'text-muted-foreground',
643
- isHidden && 'text-muted-foreground/50'
644
- )}
645
- >
646
- {format(day, 'd')}
647
- </span>
648
- {showLunar &&
649
- (() => {
650
- const lunar = getLunarDate(day);
651
- const isSpecial = isSpecialLunarDate(lunar);
652
- const holidayName = getLunarHolidayName(
653
- lunar,
654
- locale
655
- );
656
- return (
657
- <span
658
- className={cn(
659
- 'text-[10px] leading-none',
660
- isSpecial
661
- ? 'font-semibold text-dynamic-orange'
662
- : 'text-muted-foreground',
663
- !isCurrentMonth && 'opacity-50'
664
- )}
665
- title={holidayName ?? undefined}
666
- >
667
- {formatLunarDay(lunar)}
668
- </span>
669
- );
670
- })()}
671
- </div>
672
- <Tooltip>
673
- <TooltipTrigger asChild>
674
- <Button
675
- variant="ghost"
676
- size="icon"
677
- className={cn(
678
- 'h-6 w-6 opacity-0 hover:bg-primary/10 hover:opacity-100 focus:opacity-100 group-hover:opacity-100',
679
- isHidden && 'opacity-0 group-hover:opacity-50'
680
- )}
681
- onClick={() => handleAddEvent(day)}
682
- disabled={isHidden}
683
- >
684
- <Plus className="h-3 w-3" />
685
- </Button>
686
- </TooltipTrigger>
687
- <TooltipContent>Add event</TooltipContent>
688
- </Tooltip>
689
- </div>
690
-
691
- {/* Events container with separated sections */}
692
- <div className="mt-1.5 flex flex-col gap-1">
693
- {/* Reserve space for multi-day event segments */}
694
- {multiDaySegments
695
- .filter((seg) => {
696
- const week = weeks[seg.rowIndex];
697
- if (!week) return false;
698
- const isInRange =
699
- dayIdx >= seg.startCol &&
700
- dayIdx < seg.startCol + seg.span;
701
- return seg.rowIndex === weekIndex && isInRange;
702
- })
703
- .sort((a, b) => a.position - b.position)
704
- .map((seg) => (
705
- <div
706
- key={`placeholder-${seg.event.id}-${dayIdx}`}
707
- className="pointer-events-none opacity-0"
708
- style={{
709
- height: `${LAYOUT_CONSTANTS.EVENT_HEIGHT}px`,
710
- }}
711
- aria-hidden="true"
712
- />
713
- ))}
714
-
715
- {/* All-day events section */}
716
- {getSingleDayAllDayEvents(day)
717
- .slice(0, MAX_ALL_DAY_VISIBLE)
718
- .map((event) => {
719
- const { bg, text, border } = getEventStyles(event);
720
-
721
- return (
722
- <HoverCard
723
- key={event.id}
724
- openDelay={200}
725
- closeDelay={100}
726
- >
727
- <HoverCardTrigger asChild>
728
- <div
729
- className={cn(
730
- 'group/event cursor-pointer truncate rounded-md border px-2 py-1 font-medium text-xs transition-all hover:shadow-sm',
731
- bg,
732
- text,
733
- border,
734
- !isCurrentMonth && 'opacity-50'
735
- )}
736
- onClick={() => openModal(event.id)}
737
- >
738
- {event.title || 'Untitled'}
739
- </div>
740
- </HoverCardTrigger>
741
- <HoverCardContent
742
- side="right"
743
- align="start"
744
- className="w-80"
745
- >
746
- <div className="space-y-2">
747
- <h4 className="wrap-break-word line-clamp-2 font-semibold">
748
- {event.title || 'Untitled event'}
749
- </h4>
750
- {event.description && (
751
- <div
752
- className="text-muted-foreground text-sm [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-primary/80"
753
- {...{
754
- dangerouslySetInnerHTML: {
755
- __html: event.description,
756
- },
757
- }}
758
- />
759
- )}
760
- <div className="flex items-center text-muted-foreground text-xs">
761
- <Clock className="mr-1.5 h-3.5 w-3.5" />
762
- <span>All day</span>
763
- </div>
764
- </div>
765
- </HoverCardContent>
766
- </HoverCard>
767
- );
768
- })}
769
-
770
- {/* Separator between all-day and timed events */}
771
- {getSingleDayAllDayEvents(day).length > 0 &&
772
- getTimedEventsForDay(day).length > 0 && (
773
- <div className="my-0.5 border-border/50 border-t" />
774
- )}
775
-
776
- {/* Timed events section */}
777
- {getTimedEventsForDay(day)
778
- .slice(0, MAX_TIMED_VISIBLE)
779
- .map((event) => {
780
- const { bg, text, border } = getEventStyles(event);
781
- const eventTime = format(
782
- new Date(event.start_at),
783
- 'HH:mm'
784
- );
785
-
786
- return (
787
- <HoverCard
788
- key={event.id}
789
- openDelay={200}
790
- closeDelay={100}
791
- >
792
- <HoverCardTrigger asChild>
793
- <div
794
- className={cn(
795
- 'group/event flex cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-xs transition-all',
796
- bg,
797
- text,
798
- border,
799
- !isCurrentMonth && 'opacity-50',
800
- 'hover:shadow-sm'
801
- )}
802
- onClick={() => openModal(event.id)}
803
- >
804
- <span className="shrink-0 font-medium opacity-70">
805
- {eventTime}
806
- </span>
807
- <span className="min-w-0 flex-1 truncate font-medium">
808
- {event.title || 'Untitled'}
809
- </span>
810
- </div>
811
- </HoverCardTrigger>
812
- <HoverCardContent
813
- side="right"
814
- align="start"
815
- className="w-80"
816
- >
817
- <div className="space-y-2">
818
- <h4 className="wrap-break-word line-clamp-2 font-semibold">
819
- {event.title || 'Untitled event'}
820
- </h4>
821
- {event.description && (
822
- <div
823
- className="text-muted-foreground text-sm [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-primary/80"
824
- {...{
825
- dangerouslySetInnerHTML: {
826
- __html: event.description,
827
- },
828
- }}
829
- />
830
- )}
831
- <div className="flex items-center text-muted-foreground text-xs">
832
- <Clock className="mr-1.5 h-3.5 w-3.5" />
833
- <span>{formatEventTime(event)}</span>
834
- </div>
835
- </div>
836
- </HoverCardContent>
837
- </HoverCard>
838
- );
839
- })}
840
-
841
- {/* Overflow indicator */}
842
- {(() => {
843
- const allDayEvents = getSingleDayAllDayEvents(day);
844
- const timedEvents = getTimedEventsForDay(day);
845
- const visibleCount =
846
- Math.min(allDayEvents.length, MAX_ALL_DAY_VISIBLE) +
847
- Math.min(timedEvents.length, MAX_TIMED_VISIBLE);
848
- const totalCount =
849
- allDayEvents.length + timedEvents.length;
850
- const remainingCount = totalCount - visibleCount;
851
-
852
- if (remainingCount <= 0) return null;
853
-
854
- return (
855
- <Popover
856
- open={openPopoverIdx === globalDayIdx}
857
- onOpenChange={(open) =>
858
- setOpenPopoverIdx(open ? globalDayIdx : null)
859
- }
860
- >
861
- <PopoverTrigger asChild>
862
- <button
863
- type="button"
864
- ref={(el) => {
865
- moreButtonRefs.current[globalDayIdx] = el;
866
- }}
867
- className={cn(
868
- 'w-full rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs transition-colors hover:bg-muted/80',
869
- !isCurrentMonth && 'opacity-50'
870
- )}
871
- onClick={() => setOpenPopoverIdx(globalDayIdx)}
872
- >
873
- +{remainingCount} more
874
- </button>
875
- </PopoverTrigger>
876
- <PopoverContent
877
- align="start"
878
- className={cn(
879
- 'relative max-h-64 overflow-y-auto p-2 transition-none!',
880
- getScrollShadowClasses(
881
- scrollStates[globalDayIdx]
882
- )
883
- )}
884
- style={{
885
- width:
886
- moreButtonRefs.current[globalDayIdx]
887
- ?.offsetWidth || undefined,
888
- }}
889
- >
890
- <div
891
- className="flex flex-col gap-1.5"
892
- onScroll={(e) =>
893
- handlePopoverScroll(e, globalDayIdx)
894
- }
895
- ref={(el) => {
896
- popoverContentRefs.current[globalDayIdx] = el;
897
- }}
898
- onMouseEnter={() =>
899
- setPopoverHovered((prev) => ({
900
- ...prev,
901
- [globalDayIdx]: true,
902
- }))
903
- }
904
- onMouseLeave={() =>
905
- setPopoverHovered((prev) => ({
906
- ...prev,
907
- [globalDayIdx]: false,
908
- }))
909
- }
910
- >
911
- {/* Overflow all-day events */}
912
- {allDayEvents
913
- .slice(MAX_ALL_DAY_VISIBLE)
914
- .map((event) => {
915
- const { bg, text, border } =
916
- getEventStyles(event);
917
- return (
918
- <div
919
- key={event.id}
920
- className={cn(
921
- 'cursor-pointer truncate rounded-md border px-2 py-1 font-medium text-xs',
922
- bg,
923
- text,
924
- border,
925
- !isCurrentMonth && 'opacity-50'
926
- )}
927
- onClick={() => openModal(event.id)}
928
- >
929
- {event.title || 'Untitled'}
930
- </div>
931
- );
932
- })}
933
-
934
- {/* Separator in popover if needed */}
935
- {allDayEvents.length > MAX_ALL_DAY_VISIBLE &&
936
- timedEvents.length > MAX_TIMED_VISIBLE && (
937
- <div className="my-0.5 border-border/50 border-t" />
938
- )}
939
-
940
- {/* Overflow timed events */}
941
- {timedEvents
942
- .slice(MAX_TIMED_VISIBLE)
943
- .map((event) => {
944
- const { bg, text, border } =
945
- getEventStyles(event);
946
- const eventTime = format(
947
- new Date(event.start_at),
948
- 'HH:mm'
949
- );
950
- return (
951
- <div
952
- key={event.id}
953
- className={cn(
954
- 'flex cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-xs',
955
- bg,
956
- text,
957
- border,
958
- !isCurrentMonth && 'opacity-50'
959
- )}
960
- onClick={() => openModal(event.id)}
961
- >
962
- <span className="shrink-0 font-medium opacity-70">
963
- {eventTime}
964
- </span>
965
- <span className="min-w-0 flex-1 truncate font-medium">
966
- {event.title || 'Untitled'}
967
- </span>
968
- </div>
969
- );
970
- })}
971
- </div>
972
- </PopoverContent>
973
- </Popover>
974
- );
975
- })()}
976
- </div>
241
+ <span className="sm:hidden" aria-hidden="true">
242
+ {events.length > 3 ? (
243
+ `+${events.length - 3}`
244
+ ) : (
245
+ <Ellipsis className="size-3.5" />
246
+ )}
247
+ </span>
248
+ <span className="hidden sm:inline">
249
+ {events.length > 3
250
+ ? t('views.more_events', { count: events.length - 3 })
251
+ : t('views.open_day_short')}
252
+ </span>
253
+ </button>
254
+ </PopoverTrigger>
255
+ <PopoverContent
256
+ className="max-h-[70dvh] w-80 max-w-[calc(100vw-2rem)] space-y-3 overflow-auto"
257
+ align="start"
258
+ >
259
+ <div className="flex items-center justify-between gap-2">
260
+ <h3 className="font-semibold text-sm">{label}</h3>
261
+ <Button
262
+ size="icon"
263
+ variant="ghost"
264
+ disabled={cannotCreate}
265
+ aria-label={t('views.create_on_date', { date: label })}
266
+ onClick={() => addEvent(day)}
267
+ >
268
+ <Plus className="size-4" />
269
+ </Button>
977
270
  </div>
978
- );
979
- })}
980
- </div>
981
- </div>
982
- ))}
271
+ {events.length ? (
272
+ events.map((event) => (
273
+ <MonthEvent
274
+ key={event.id}
275
+ event={event}
276
+ day={day}
277
+ timePattern={timePattern}
278
+ zone={settings?.timezone?.timezone}
279
+ onOpen={openModal}
280
+ />
281
+ ))
282
+ ) : (
283
+ <p className="text-muted-foreground text-sm">
284
+ {t('views.no_events')}
285
+ </p>
286
+ )}
287
+ </PopoverContent>
288
+ </Popover>
289
+ </section>
290
+ );
291
+ })}
983
292
  </div>
984
293
  </div>
985
294
  );
986
- };
295
+ }