Skip to content

Commit c2d5cf2

Browse files
authored
Merge pull request #571 from objectstack-ai/copilot/optimize-calendar-view
2 parents facdf9f + efcf2df commit c2d5cf2

3 files changed

Lines changed: 218 additions & 7 deletions

File tree

packages/plugin-calendar/src/CalendarView.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "@object-ui/components"
2626

2727
const DEFAULT_EVENT_COLOR = "bg-blue-500 text-white"
28+
const STABLE_DEFAULT_DATE = new Date()
2829

2930
export interface CalendarEvent {
3031
id: string | number
@@ -53,7 +54,7 @@ export interface CalendarViewProps {
5354
function CalendarView({
5455
events = [],
5556
view = "month",
56-
currentDate = new Date(),
57+
currentDate = STABLE_DEFAULT_DATE,
5758
locale = "default",
5859
onEventClick,
5960
onDateClick,
@@ -368,12 +369,35 @@ interface MonthViewProps {
368369
}
369370

370371
function MonthView({ date, events, onEventClick, onDateClick, onEventDrop }: MonthViewProps) {
371-
const days = getMonthDays(date)
372-
const today = new Date()
372+
const days = React.useMemo(() => getMonthDays(date), [date.getFullYear(), date.getMonth()])
373+
const today = React.useMemo(() => new Date(), [])
373374
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
374375
const [draggedEventId, setDraggedEventId] = React.useState<string | number | null>(null)
375376
const [dropTargetIndex, setDropTargetIndex] = React.useState<number | null>(null)
376377

378+
// Pre-build event index by date key for O(1) lookup per cell instead of O(N)
379+
const eventsByDate = React.useMemo(() => {
380+
const map = new Map<string, CalendarEvent[]>()
381+
for (const event of events) {
382+
const eventStart = new Date(event.start)
383+
const eventEnd = event.end ? new Date(event.end) : new Date(eventStart)
384+
eventStart.setHours(0, 0, 0, 0)
385+
eventEnd.setHours(0, 0, 0, 0)
386+
const cursor = new Date(eventStart)
387+
while (cursor <= eventEnd) {
388+
const key = `${cursor.getFullYear()}-${cursor.getMonth()}-${cursor.getDate()}`
389+
const arr = map.get(key)
390+
if (arr) {
391+
arr.push(event)
392+
} else {
393+
map.set(key, [event])
394+
}
395+
cursor.setDate(cursor.getDate() + 1)
396+
}
397+
}
398+
return map
399+
}, [events])
400+
377401
const handleDragStart = (e: React.DragEvent, event: CalendarEvent) => {
378402
setDraggedEventId(event.id)
379403
e.dataTransfer.effectAllowed = "move"
@@ -447,7 +471,8 @@ function MonthView({ date, events, onEventClick, onDateClick, onEventDrop }: Mon
447471
{/* Calendar days */}
448472
<div role="grid" aria-label="Calendar grid" className="grid grid-cols-7 flex-1 auto-rows-fr">
449473
{days.map((day, index) => {
450-
const dayEvents = getEventsForDate(day, events)
474+
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`
475+
const dayEvents = eventsByDate.get(key) || []
451476
const isCurrentMonth = day.getMonth() === date.getMonth()
452477
const isToday = isSameDay(day, today)
453478

@@ -487,7 +512,7 @@ function MonthView({ date, events, onEventClick, onDateClick, onEventDrop }: Mon
487512
onDragEnd={handleDragEnd}
488513
className={cn(
489514
"text-xs px-2 py-1 rounded truncate cursor-pointer hover:opacity-80",
490-
event.color || DEFAULT_EVENT_COLOR,
515+
event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR),
491516
draggedEventId === event.id && "opacity-50"
492517
)}
493518
style={
@@ -600,7 +625,7 @@ function WeekView({ date, events, locale = "default", onEventClick, onDateClick
600625
aria-label={event.title}
601626
className={cn(
602627
"text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
603-
event.color || DEFAULT_EVENT_COLOR
628+
event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR)
604629
)}
605630
style={
606631
event.color && event.color.startsWith("#")
@@ -692,7 +717,7 @@ function DayView({ date, events, onEventClick, onDateClick }: DayViewProps) {
692717
aria-label={event.title}
693718
className={cn(
694719
"px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
695-
event.color || DEFAULT_EVENT_COLOR
720+
event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR)
696721
)}
697722
style={
698723
event.color && event.color.startsWith("#")

packages/plugin-calendar/src/ObjectCalendar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export interface ObjectCalendarProps {
5353
onDelete?: (record: any) => void;
5454
onNavigate?: (date: Date) => void;
5555
onViewChange?: (view: 'month' | 'week' | 'day') => void;
56+
onEventDrop?: (record: any, newStart: Date, newEnd?: Date) => void;
57+
locale?: string;
5658
}
5759

5860
/**
@@ -144,6 +146,8 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
144146
onDateClick,
145147
onNavigate,
146148
onViewChange,
149+
onEventDrop,
150+
locale,
147151
...rest
148152
}) => {
149153
const [data, setData] = useState<any[]>([]);
@@ -362,6 +366,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
362366
events={events}
363367
currentDate={currentDate}
364368
view={(schema as any).defaultView || 'month'}
369+
locale={locale}
365370
onEventClick={(event) => {
366371
navigation.handleClick(event.data);
367372
onEventClick?.(event.data);
@@ -376,6 +381,9 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
376381
onViewChange?.(v);
377382
}}
378383
onAddClick={handleCreate}
384+
onEventDrop={onEventDrop ? (event, newStart, newEnd) => {
385+
onEventDrop(event.data, newStart, newEnd);
386+
} : undefined}
379387
/>
380388
</div>
381389
{navigation.isOverlay && (
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* Tests for CalendarView optimizations:
9+
* - Event index (Map-based lookup instead of O(N) per cell)
10+
* - Stable default date reference
11+
* - HEX color text-white fix
12+
* - onEventDrop / locale passthrough from ObjectCalendar
13+
*/
14+
15+
import { describe, it, expect, vi } from 'vitest';
16+
import { render, screen } from '@testing-library/react';
17+
import '@testing-library/jest-dom';
18+
import React from 'react';
19+
import { CalendarView, type CalendarEvent } from '../CalendarView';
20+
21+
// Mock @object-ui/components
22+
vi.mock('@object-ui/components', () => ({
23+
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
24+
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
25+
Select: ({ children, value, onValueChange }: any) => <div data-testid="select">{children}</div>,
26+
SelectContent: ({ children }: any) => <div>{children}</div>,
27+
SelectItem: ({ children, value }: any) => <div data-value={value}>{children}</div>,
28+
SelectTrigger: ({ children }: any) => <div>{children}</div>,
29+
SelectValue: () => <span>Month</span>,
30+
Calendar: (props: any) => <div data-testid="calendar-picker">Calendar Picker</div>,
31+
Popover: ({ children }: any) => <div>{children}</div>,
32+
PopoverContent: ({ children }: any) => <div>{children}</div>,
33+
PopoverTrigger: ({ children }: any) => <div>{children}</div>,
34+
}));
35+
36+
// Mock lucide-react icons
37+
vi.mock('lucide-react', () => ({
38+
ChevronLeftIcon: (props: any) => <svg data-testid="chevron-left" {...props} />,
39+
ChevronRightIcon: (props: any) => <svg data-testid="chevron-right" {...props} />,
40+
CalendarIcon: (props: any) => <svg data-testid="calendar-icon" {...props} />,
41+
PlusIcon: (props: any) => <svg data-testid="plus-icon" {...props} />,
42+
}));
43+
44+
// Mock ResizeObserver
45+
class ResizeObserver {
46+
observe() {}
47+
unobserve() {}
48+
disconnect() {}
49+
}
50+
global.ResizeObserver = ResizeObserver;
51+
52+
const baseDate = new Date(2024, 0, 15); // Jan 15, 2024
53+
54+
describe('CalendarView optimizations', () => {
55+
describe('event index (Map-based lookup)', () => {
56+
it('renders single-day events correctly with the new index', () => {
57+
const events: CalendarEvent[] = [
58+
{ id: '1', title: 'Morning Meeting', start: new Date(2024, 0, 15, 9, 0), end: new Date(2024, 0, 15, 10, 0) },
59+
{ id: '2', title: 'Lunch', start: new Date(2024, 0, 15, 12, 0), end: new Date(2024, 0, 15, 13, 0) },
60+
];
61+
render(<CalendarView currentDate={baseDate} events={events} view="month" locale="en-US" />);
62+
expect(screen.getByText('Morning Meeting')).toBeInTheDocument();
63+
expect(screen.getByText('Lunch')).toBeInTheDocument();
64+
});
65+
66+
it('renders multi-day events on all spanned days', () => {
67+
const events: CalendarEvent[] = [
68+
{
69+
id: 'multi-1',
70+
title: 'Conference',
71+
start: new Date(2024, 0, 15, 9, 0),
72+
end: new Date(2024, 0, 17, 17, 0),
73+
allDay: true,
74+
},
75+
];
76+
const { container } = render(
77+
<CalendarView currentDate={baseDate} events={events} view="month" locale="en-US" />
78+
);
79+
// The multi-day event should appear on Jan 15, 16, and 17 — 3 cells
80+
const eventElements = container.querySelectorAll('[role="button"][aria-label="Conference"]');
81+
expect(eventElements.length).toBe(3);
82+
});
83+
84+
it('renders events with no end date on their start day only', () => {
85+
const events: CalendarEvent[] = [
86+
{ id: 'no-end', title: 'No End Event', start: new Date(2024, 0, 20, 14, 0) },
87+
];
88+
const { container } = render(
89+
<CalendarView currentDate={baseDate} events={events} view="month" locale="en-US" />
90+
);
91+
const eventElements = container.querySelectorAll('[role="button"][aria-label="No End Event"]');
92+
expect(eventElements.length).toBe(1);
93+
});
94+
});
95+
96+
describe('HEX color text-white fix', () => {
97+
it('applies text-white class when event color is a HEX value in month view', () => {
98+
const events: CalendarEvent[] = [
99+
{ id: 'hex-1', title: 'HEX Event', start: new Date(2024, 0, 15), color: '#3b82f6' },
100+
];
101+
const { container } = render(
102+
<CalendarView currentDate={baseDate} events={events} view="month" locale="en-US" />
103+
);
104+
const eventEl = container.querySelector('[aria-label="HEX Event"]');
105+
expect(eventEl).toBeInTheDocument();
106+
expect(eventEl!.className).toContain('text-white');
107+
expect((eventEl as HTMLElement).style.backgroundColor).toBe('#3b82f6');
108+
});
109+
110+
it('uses default color class when event color is a Tailwind class', () => {
111+
const events: CalendarEvent[] = [
112+
{ id: 'tw-1', title: 'Tailwind Event', start: new Date(2024, 0, 15), color: 'bg-red-500 text-white' },
113+
];
114+
const { container } = render(
115+
<CalendarView currentDate={baseDate} events={events} view="month" locale="en-US" />
116+
);
117+
const eventEl = container.querySelector('[aria-label="Tailwind Event"]');
118+
expect(eventEl).toBeInTheDocument();
119+
expect(eventEl!.className).toContain('bg-red-500');
120+
expect(eventEl!.className).toContain('text-white');
121+
});
122+
123+
it('falls back to DEFAULT_EVENT_COLOR when no color is specified', () => {
124+
const events: CalendarEvent[] = [
125+
{ id: 'no-color', title: 'No Color', start: new Date(2024, 0, 15) },
126+
];
127+
const { container } = render(
128+
<CalendarView currentDate={baseDate} events={events} view="month" locale="en-US" />
129+
);
130+
const eventEl = container.querySelector('[aria-label="No Color"]');
131+
expect(eventEl).toBeInTheDocument();
132+
expect(eventEl!.className).toContain('bg-blue-500');
133+
expect(eventEl!.className).toContain('text-white');
134+
});
135+
});
136+
137+
describe('stable default date', () => {
138+
it('renders without currentDate prop without errors', () => {
139+
const { container } = render(<CalendarView events={[]} locale="en-US" />);
140+
expect(container).toBeTruthy();
141+
});
142+
143+
it('does not trigger re-render loop when currentDate is not provided', () => {
144+
const onNavigate = vi.fn();
145+
const { rerender } = render(<CalendarView events={[]} locale="en-US" onNavigate={onNavigate} />);
146+
// Re-render the same component — should NOT trigger onNavigate from the effect
147+
rerender(<CalendarView events={[]} locale="en-US" onNavigate={onNavigate} />);
148+
expect(onNavigate).not.toHaveBeenCalled();
149+
});
150+
});
151+
152+
describe('drag-and-drop enablement', () => {
153+
it('events are draggable when onEventDrop is provided', () => {
154+
const events: CalendarEvent[] = [
155+
{ id: 'd-1', title: 'Drag Event', start: new Date(2024, 0, 15) },
156+
];
157+
const onEventDrop = vi.fn();
158+
const { container } = render(
159+
<CalendarView currentDate={baseDate} events={events} view="month" onEventDrop={onEventDrop} locale="en-US" />
160+
);
161+
const eventEl = container.querySelector('[aria-label="Drag Event"]');
162+
expect(eventEl).toBeInTheDocument();
163+
expect(eventEl!.getAttribute('draggable')).toBe('true');
164+
});
165+
166+
it('events are not draggable when onEventDrop is not provided', () => {
167+
const events: CalendarEvent[] = [
168+
{ id: 'nd-1', title: 'No Drag Event', start: new Date(2024, 0, 15) },
169+
];
170+
const { container } = render(
171+
<CalendarView currentDate={baseDate} events={events} view="month" locale="en-US" />
172+
);
173+
const eventEl = container.querySelector('[aria-label="No Drag Event"]');
174+
expect(eventEl).toBeInTheDocument();
175+
expect(eventEl!.getAttribute('draggable')).toBe('false');
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)