import React, {
useState,
useRef,
useMemo,
useCallback,
useEffect,
} from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
LayoutAnimation,
Dimensions,
Platform,
} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideInLeft,
SlideOutLeft,
SlideOutRight,
} from 'react-native-reanimated';
import { ms } from './utils';
import { CustomCalendarProps } from './types';
import { MonthView } from './CalendarViews/MonthView';
import { WeekView } from './CalendarViews/WeekView';
import { DayView } from './CalendarViews/DayView';
import { DateStrip } from './DateStrip';
import { formatDate } from './utils';
import { createDefaultCalendarStyles } from './defaultStyles';
const HOUR_HEIGHT = ms(60);
const DefaultEventCard: React.FC<{
event: any;
viewMode: 'day' | 'week' | 'month';
styles: any;
theme: any;
isSelected?: boolean;
}> = ({ event, viewMode, styles, theme, isSelected }) => {
const formatDuration = (duration: number) => {
if (duration < 1) {
return `${Math.round(duration * 60)}min`;
}
return `${duration}h`;
};
return (
{event.title || 'Untitled Event'}
{event.duration && (
Duration: {formatDuration(event.duration)}
)}
{event.status && (
Status: {event.status}
)}
);
};
export * from './types';
export const CustomCalendar: React.FC = ({
events = [],
initialDate,
viewMode: propViewMode,
onViewChange,
theme,
styles: propStyles = {},
labels = {},
renderEventCard,
renderEventMarkers,
onEventPress,
onDateChange,
legendItems,
getEventContainerStyle,
timeSlots,
startHour = 1,
headerLayout = 'column',
legendPosition = 'right',
renderHeaderRight,
renderTimeLabel,
}) => {
const styles = useMemo(
() => ({ ...createDefaultCalendarStyles(theme), ...propStyles }),
[theme, propStyles]
);
const [internalViewMode, setInternalViewMode] = useState<
'day' | 'week' | 'month'
>('day');
const viewMode = propViewMode || internalViewMode;
const [currentDisplayDate, setCurrentDisplayDate] = useState(
initialDate || new Date()
);
const [selectedDate, setSelectedDate] = useState(initialDate || new Date());
const [daySelectedEventId, setDaySelectedEventId] = useState(
null
);
const [monthViewHeight, setMonthViewHeight] = useState(0);
const [isManualDateSelect, setIsManualDateSelect] = useState(false);
const dayScrollRef = useRef(null);
const monthScrollRef = useRef(null);
const lightenColor = (hex: string, amount: number = 0.85) => {
const cleanHex = hex.replace('#', '').substring(0, 6);
// Parse hex color
const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(cleanHex);
if (!result) return '#f0f0f0';
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
const newR = Math.round(r + (255 - r) * amount);
const newG = Math.round(g + (255 - g) * amount);
const newB = Math.round(b + (255 - b) * amount);
return `#${newR.toString(16).padStart(2, '0')}${newG
.toString(16)
.padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
};
const autoGetEventContainerStyle = useCallback(
(event: any) => {
if (getEventContainerStyle) {
return getEventContainerStyle(event);
}
// 1. Check for direct event color
if (event.color) {
const bgColor = lightenColor(event.color, 0.85);
return {
backgroundColor: bgColor,
borderLeftColor: event.color,
borderLeftWidth: 3,
};
}
// 2. Check legend if available
if (legendItems && legendItems.length > 0) {
const eventType = (event.type || event.status || '').toLowerCase();
const matchedLegend = legendItems.find(
(item) => item.label.toLowerCase() === eventType
);
if (matchedLegend) {
const bgColor = lightenColor(matchedLegend.color, 0.85);
return {
backgroundColor: bgColor,
borderLeftColor: matchedLegend.color,
borderLeftWidth: 3,
};
}
}
// 3. Default fallback
const defaultColor = theme?.colors?.primary || '#3498db';
const defaultBgColor = lightenColor(defaultColor, 0.85);
return {
backgroundColor: defaultBgColor,
borderLeftColor: defaultColor,
borderLeftWidth: 3,
};
},
[legendItems, getEventContainerStyle]
);
const autoRenderEventMarkers = useCallback(
(_date: Date, events: any[]) => {
if (!events || events.length === 0) return null;
const limitedEvents = events.slice(0, 3);
return (
{limitedEvents.map((e, index) => {
let markerColor = theme?.colors?.mainText || '#000';
if (e.color) {
markerColor = e.color;
} else if (legendItems && legendItems.length > 0) {
const eventType = (e.type || e.status || '').toLowerCase();
const matchedLegend = legendItems.find(
(item) => item.label.toLowerCase() === eventType
);
if (matchedLegend) {
markerColor = matchedLegend.color;
}
}
return (
);
})}
);
},
[legendItems, theme]
);
const effectiveRenderEventCard = useMemo(
() =>
renderEventCard ||
((
event: any,
viewMode: any,
isSelected: boolean,
toggleSelection: any
) => (
)),
[renderEventCard, styles, theme]
);
// Helper: Date Logic
const handlePrev = useCallback(() => {
setCurrentDisplayDate((d) => {
const n = new Date(d);
n.setMonth(d.getMonth() - 1);
const newSelected = new Date(n);
newSelected.setDate(1);
setIsManualDateSelect(true);
setSelectedDate(newSelected);
if (onDateChange) onDateChange(newSelected);
return n;
});
}, [onDateChange]);
const handleNext = useCallback(() => {
setCurrentDisplayDate((d) => {
const n = new Date(d);
n.setMonth(d.getMonth() + 1);
const newSelected = new Date(n);
newSelected.setDate(1);
setIsManualDateSelect(true);
setSelectedDate(newSelected);
if (onDateChange) onDateChange(newSelected);
return n;
});
}, [onDateChange]);
const monthYearString = useMemo(
() =>
currentDisplayDate.toLocaleString('default', {
month: 'long',
year: 'numeric',
}),
[currentDisplayDate]
);
const daysInMonth = useMemo(() => {
const days = [];
const year = currentDisplayDate.getFullYear();
const month = currentDisplayDate.getMonth();
const numDays = new Date(year, month + 1, 0).getDate();
for (let i = 1; i <= numDays; i++) {
days.push(formatDate(new Date(year, month, i)));
}
return days;
}, [currentDisplayDate]);
// Auto-scroll DayView to first event
useEffect(() => {
if (viewMode === 'day' && dayScrollRef.current && events.length > 0) {
const targetDate = selectedDate;
const dayStart = new Date(targetDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(targetDate);
dayEnd.setHours(23, 59, 59, 999);
const dayEvents = events.filter(
(e) => e.start >= dayStart && e.start <= dayEnd
);
if (dayEvents.length > 0) {
const earliestMinutes = Math.min(
...dayEvents.map(
(e) => e.start.getHours() * 60 + e.start.getMinutes()
)
);
const earliestHour = earliestMinutes / 60;
const offsetY = (earliestHour - startHour) * HOUR_HEIGHT - ms(20);
setTimeout(() => {
dayScrollRef.current?.scrollTo({
y: Math.max(0, offsetY),
animated: true,
});
}, 100);
}
}
}, [viewMode, selectedDate, events, startHour]);
const handleViewChange = (mode: 'day' | 'week' | 'month') => {
let newDate = selectedDate;
if (mode === 'week') {
// Find first event in current week
const currentDay = selectedDate.getDay();
const startOfWeek = new Date(selectedDate);
startOfWeek.setDate(selectedDate.getDate() - currentDay);
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
const weekEvents = events
.filter((e) => e.start >= startOfWeek && e.start <= endOfWeek)
.sort((a, b) => a.start.getTime() - b.start.getTime());
if (weekEvents.length > 0) {
newDate = new Date(weekEvents[0].start);
}
}
if (newDate.getTime() !== selectedDate.getTime()) {
setSelectedDate(newDate);
if (onDateChange) onDateChange(newDate);
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setIsManualDateSelect(false);
if (onViewChange) onViewChange(mode);
// If not controlled (propViewMode is undefined), update internal state
if (!propViewMode) {
setInternalViewMode(mode);
}
};
useEffect(() => {
if (viewMode === 'month' && isManualDateSelect) {
const dayEvents = events.filter(
(e) =>
e.start.getDate() === selectedDate.getDate() &&
e.start.getMonth() === selectedDate.getMonth() &&
e.start.getFullYear() === selectedDate.getFullYear()
);
if (dayEvents.length > 0) {
const earliestMinutes = dayEvents.reduce((min, e) => {
const start = e.start.getHours() * 60 + e.start.getMinutes();
return start < min ? start : min;
}, 24 * 60);
const earliestHour = earliestMinutes / 60;
const offset = Math.max(
0,
(earliestHour - startHour) * HOUR_HEIGHT - ms(20)
);
if (monthViewHeight > 0) {
setTimeout(() => {
monthScrollRef.current?.scrollTo({
y: monthViewHeight + offset,
animated: true,
});
setIsManualDateSelect(false);
}, 100);
}
}
}
}, [
viewMode,
selectedDate,
events,
monthViewHeight,
isManualDateSelect,
startHour,
]);
// Pager Logic
const [pagerWidth, setPagerWidth] = useState(Dimensions.get('window').width);
const pagerRef = useRef(null);
const isPagingRef = useRef(false);
useEffect(() => {
if (viewMode === 'month' && pagerRef.current) {
if (Platform.OS === 'android') {
setTimeout(() => {
pagerRef.current?.scrollTo({ x: pagerWidth, animated: false });
isPagingRef.current = false;
}, 50);
} else {
pagerRef.current.scrollTo({ x: pagerWidth, animated: false });
isPagingRef.current = false;
}
}
}, [currentDisplayDate, pagerWidth, viewMode]);
const handleMomentumScrollEnd = (e: any) => {
if (isPagingRef.current) return;
const offsetX = e.nativeEvent.contentOffset.x;
const page = Math.round(offsetX / pagerWidth);
if (page === 0) {
isPagingRef.current = true;
handlePrev();
} else if (page === 2) {
isPagingRef.current = true;
handleNext();
}
};
return (
{['Day', 'Week', 'Month'].map((mode) => (
handleViewChange(mode.toLowerCase() as any)}
>
{mode}
))}
{headerLayout === 'row' && legendPosition === 'left' && legendItems && (
{legendItems.map((item, index) => (
{item.label}
))}
)}
{monthYearString}
{renderHeaderRight && (
{renderHeaderRight()}
)}
{(headerLayout === 'column' || legendPosition === 'right') &&
legendItems && (
{legendItems.map((item, index) => (
{item.label}
))}
)}
{/* @ts-ignore - Reanimated type definitions issue */}
{viewMode === 'day' && (
<>
{
setSelectedDate(d);
if (onDateChange) onDateChange(d);
}}
events={events}
currentDisplayDate={currentDisplayDate}
theme={theme}
styles={styles}
renderEventMarkers={renderEventMarkers || autoRenderEventMarkers}
/>
>
)}
{viewMode === 'week' && (
{
setSelectedDate(d);
if (onDateChange) onDateChange(d);
}}
timeSlots={timeSlots}
startHour={startHour}
getEventContainerStyle={autoGetEventContainerStyle}
renderTimeLabel={renderTimeLabel}
/>
)}
{viewMode === 'month' && (
{
setMonthViewHeight(event.nativeEvent.layout.height);
setPagerWidth(event.nativeEvent.layout.width);
}}
style={{ width: '100%' }}
>
{[
new Date(
currentDisplayDate.getFullYear(),
currentDisplayDate.getMonth() - 1,
1
),
currentDisplayDate,
new Date(
currentDisplayDate.getFullYear(),
currentDisplayDate.getMonth() + 1,
1
),
].map((date, index) => (
{
setIsManualDateSelect(true);
setSelectedDate(d);
if (onDateChange) onDateChange(d);
}}
events={events}
theme={theme}
styles={styles}
renderEventMarkers={
renderEventMarkers || autoRenderEventMarkers
}
/>
))}
)}
);
};