mirror of
https://github.com/urosran/cally.git
synced 2025-07-10 07:07:16 +00:00
572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
import React, {useCallback, useMemo, useRef} from 'react';
|
|
import {Dimensions, StyleSheet, Text, TouchableOpacity, View,} from 'react-native';
|
|
import {
|
|
addDays,
|
|
addMonths,
|
|
eachDayOfInterval,
|
|
endOfMonth,
|
|
format,
|
|
isSameDay,
|
|
isSameMonth,
|
|
isWithinInterval,
|
|
startOfMonth,
|
|
} from 'date-fns';
|
|
import {useGetEvents} from "@/hooks/firebase/useGetEvents";
|
|
import {useSetAtom} from "jotai";
|
|
import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
|
|
import {useAuthContext} from "@/contexts/AuthContext";
|
|
import {FlashList} from "@shopify/flash-list";
|
|
import * as Device from "expo-device";
|
|
import {CalendarController} from "@/components/pages/calendar/CalendarController";
|
|
|
|
interface CalendarEvent {
|
|
id: string;
|
|
title: string;
|
|
start: Date;
|
|
end: Date;
|
|
color?: string;
|
|
}
|
|
|
|
interface CustomMonthCalendarProps {
|
|
weekStartsOn?: 0 | 1;
|
|
}
|
|
|
|
const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
const MAX_VISIBLE_EVENTS = 3;
|
|
const CENTER_MONTH_INDEX = 12;
|
|
|
|
|
|
const Event = React.memo(({event, onPress}: { event: CalendarEvent; onPress: () => void }) => (
|
|
<TouchableOpacity
|
|
style={[styles.event, {backgroundColor: event?.eventColor || '#6200ee'}]}
|
|
onPress={onPress}
|
|
>
|
|
<Text style={styles.eventText} numberOfLines={1}>
|
|
{event.title}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
));
|
|
|
|
interface CalendarEvent {
|
|
id: string;
|
|
title: string;
|
|
start: Date;
|
|
end: Date;
|
|
color?: string;
|
|
weekPosition?: number;
|
|
}
|
|
|
|
const MultiDayEvent = React.memo(({
|
|
event,
|
|
isStart,
|
|
isEnd,
|
|
onPress,
|
|
}: {
|
|
event: CalendarEvent;
|
|
dayWidth: number;
|
|
isStart: boolean;
|
|
isEnd: boolean;
|
|
onPress: () => void;
|
|
}) => {
|
|
const style = {
|
|
position: 'absolute' as const,
|
|
height: 14,
|
|
backgroundColor: event?.eventColor || '#6200ee',
|
|
padding: 2,
|
|
zIndex: 1,
|
|
left: isStart ? 4 : -0.5, // Extend slightly into the border
|
|
right: isEnd ? 4 : -0.5, // Extend slightly into the border
|
|
top: event.weekPosition ? event.weekPosition * 24 : 0,
|
|
borderRadius: 4,
|
|
borderTopLeftRadius: isStart ? 4 : 0,
|
|
borderBottomLeftRadius: isStart ? 4 : 0,
|
|
borderTopRightRadius: isEnd ? 4 : 0,
|
|
borderBottomRightRadius: isEnd ? 4 : 0,
|
|
justifyContent: 'center',
|
|
};
|
|
|
|
return (
|
|
<TouchableOpacity style={style} onPress={onPress}>
|
|
{isStart && (
|
|
<Text style={[styles.eventText]} numberOfLines={1}>
|
|
{event.title}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
});
|
|
|
|
const Day = React.memo((
|
|
{
|
|
date,
|
|
events,
|
|
multiDayEvents,
|
|
dayWidth,
|
|
onPress
|
|
}: {
|
|
date: Date;
|
|
events: CalendarEvent[];
|
|
multiDayEvents: CalendarEvent[];
|
|
dayWidth: number;
|
|
onPress: (date: Date) => void;
|
|
}) => {
|
|
const isCurrentMonth = isSameMonth(date, new Date());
|
|
const isToday = isSameDay(date, new Date());
|
|
|
|
const remainingSlots = Math.max(0, MAX_VISIBLE_EVENTS - multiDayEvents.length);
|
|
const singleDayEvents = events.filter(event => !event.isMultiDay);
|
|
const visibleSingleDayEvents = singleDayEvents.slice(0, remainingSlots);
|
|
const totalHiddenEvents = singleDayEvents.length - remainingSlots;
|
|
|
|
const maxMultiDayPosition = multiDayEvents.length > 0
|
|
? Math.max(...multiDayEvents.map(e => e.weekPosition || 0)) + 1
|
|
: 0;
|
|
const multiDayEventsHeight = maxMultiDayPosition * 16; // Height for multi-day events
|
|
|
|
return (
|
|
<View style={[styles.day, {width: dayWidth}]}>
|
|
<TouchableOpacity
|
|
style={styles.dayContent}
|
|
onPress={() => onPress(date)}
|
|
>
|
|
<View style={[
|
|
styles.dateContainer,
|
|
isToday && {backgroundColor: events?.[0]?.eventColor},
|
|
]}>
|
|
<Text style={[
|
|
styles.dateText,
|
|
!isCurrentMonth && styles.outsideMonthText,
|
|
isToday && styles.todayText,
|
|
]}>
|
|
{format(date, 'd')}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Multi-day events container */}
|
|
<View style={[styles.multiDayContainer, {height: multiDayEventsHeight}]}>
|
|
{multiDayEvents.map(event => (
|
|
<MultiDayEvent
|
|
key={event.id}
|
|
event={event}
|
|
dayWidth={dayWidth}
|
|
isStart={isSameDay(date, event.start)}
|
|
isEnd={isSameDay(date, event.end)}
|
|
onPress={() => onPress(event.start)}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
{/* Single-day events container */}
|
|
<View style={[styles.singleDayContainer, {marginTop: multiDayEventsHeight}]}>
|
|
{visibleSingleDayEvents.map(event => (
|
|
<Event
|
|
key={event.id}
|
|
event={event}
|
|
onPress={() => onPress(event.start)}
|
|
/>
|
|
))}
|
|
{totalHiddenEvents > 0 && (
|
|
<Text style={styles.moreEvents}>
|
|
{totalHiddenEvents} More
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
|
|
const {data: rawEvents} = useGetEvents();
|
|
const setSelectedDate = useSetAtom(selectedDateAtom);
|
|
const setMode = useSetAtom(modeAtom);
|
|
const {profileData} = useAuthContext();
|
|
|
|
const scrollViewRef = useRef<FlashList<any>>(null);
|
|
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
|
|
const screenWidth = isTablet ? Dimensions.get('window').width * 0.89 : Dimensions.get('window').width;
|
|
const screenHeight = isTablet ? Dimensions.get('window').height * 0.89 : Dimensions.get('window').height;
|
|
const dayWidth = screenWidth / 7;
|
|
const centerMonth = useRef(new Date());
|
|
|
|
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
|
|
|
|
const onDayPress = useCallback(
|
|
(date: Date) => {
|
|
date && setSelectedDate(date);
|
|
setTimeout(() => {
|
|
setMode("day");
|
|
}, 100)
|
|
},
|
|
[setSelectedDate, setMode]
|
|
);
|
|
|
|
const getMonthData = useCallback((date: Date) => {
|
|
const start = startOfMonth(date);
|
|
const end = endOfMonth(date);
|
|
const days = eachDayOfInterval({start, end});
|
|
|
|
const firstDay = days[0];
|
|
const startPadding = [];
|
|
let startDay = firstDay.getDay();
|
|
while (startDay !== weekStartsOn) {
|
|
startDay = (startDay - 1 + 7) % 7;
|
|
startPadding.unshift(addDays(firstDay, -startPadding.length - 1));
|
|
}
|
|
|
|
const lastDay = days[days.length - 1];
|
|
const endPadding = [];
|
|
let endDay = lastDay.getDay();
|
|
while (endDay !== (weekStartsOn + 6) % 7) {
|
|
endDay = (endDay + 1) % 7;
|
|
endPadding.push(addDays(lastDay, endPadding.length + 1));
|
|
}
|
|
|
|
return [...startPadding, ...days, ...endPadding];
|
|
}, [weekStartsOn]);
|
|
|
|
const monthsToRender = useMemo(() => {
|
|
const months = [];
|
|
for (let i = -CENTER_MONTH_INDEX; i <= CENTER_MONTH_INDEX; i++) {
|
|
const monthDate = addMonths(centerMonth.current, i);
|
|
months.push({
|
|
date: monthDate,
|
|
days: getMonthData(monthDate)
|
|
});
|
|
}
|
|
return months;
|
|
}, [getMonthData, rawEvents]);
|
|
|
|
const processedEvents = useMemo(() => {
|
|
if (!rawEvents?.length) return {
|
|
eventMap: new Map(),
|
|
multiDayEvents: []
|
|
};
|
|
|
|
const eventMap = new Map();
|
|
const multiDayEvents: CalendarEvent[] = [];
|
|
|
|
rawEvents.forEach((event) => {
|
|
if (!event?.start || !event?.end) return;
|
|
|
|
const startDate = event.start instanceof Date ? event.start : new Date(event.start);
|
|
const endDate = event.end instanceof Date ? event.end : new Date(event.end);
|
|
|
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return;
|
|
|
|
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
if (duration > 1) {
|
|
multiDayEvents.push({
|
|
...event,
|
|
isMultiDay: true,
|
|
start: startDate,
|
|
end: endDate
|
|
});
|
|
} else {
|
|
const dateStr = format(startDate, 'yyyy-MM-dd');
|
|
const existing = eventMap.get(dateStr) || [];
|
|
eventMap.set(dateStr, [...existing, {...event, start: startDate, end: endDate}]);
|
|
}
|
|
});
|
|
|
|
multiDayEvents.sort((a, b) => {
|
|
if (!a.start || !b.start || !a.end || !b.end) return 0;
|
|
const durationA = a.end.getTime() - a.start.getTime();
|
|
const durationB = b.end.getTime() - b.start.getTime();
|
|
return durationB - durationA;
|
|
});
|
|
|
|
return {eventMap, multiDayEvents};
|
|
}, [rawEvents]);
|
|
|
|
const getMultiDayEventsForDay = useCallback((date: Date) => {
|
|
return processedEvents.multiDayEvents.filter(event => {
|
|
if (!event.start || !event.end) return false;
|
|
return isWithinInterval(date, {
|
|
start: event.start,
|
|
end: event.end
|
|
});
|
|
});
|
|
}, [processedEvents.multiDayEvents]);
|
|
|
|
const getEventsForDay = useCallback((date: Date) => {
|
|
const dateStr = format(date, 'yyyy-MM-dd');
|
|
return processedEvents.eventMap.get(dateStr) || [];
|
|
}, [processedEvents.eventMap]);
|
|
|
|
const sortedDaysOfWeek = useMemo(() => {
|
|
const days = [...DAYS_OF_WEEK];
|
|
return days.slice(weekStartsOn).concat(days.slice(0, weekStartsOn));
|
|
}, [weekStartsOn]);
|
|
|
|
const renderMonth = useCallback(({item}: { item: MonthData }) => (
|
|
<Month
|
|
date={item.date}
|
|
days={item.days}
|
|
getEventsForDay={getEventsForDay}
|
|
getMultiDayEventsForDay={getMultiDayEventsForDay}
|
|
dayWidth={dayWidth}
|
|
onPress={onDayPress}
|
|
screenWidth={screenWidth}
|
|
sortedDaysOfWeek={sortedDaysOfWeek}
|
|
/>
|
|
), [getEventsForDay, getMultiDayEventsForDay, dayWidth, onDayPress, screenWidth, weekStartsOn]);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<CalendarController
|
|
scrollViewRef={scrollViewRef}
|
|
centerMonthIndex={CENTER_MONTH_INDEX}
|
|
/>
|
|
<FlashList
|
|
ref={scrollViewRef}
|
|
data={monthsToRender}
|
|
renderItem={renderMonth}
|
|
keyExtractor={keyExtractor}
|
|
horizontal
|
|
pagingEnabled
|
|
showsHorizontalScrollIndicator={false}
|
|
initialScrollIndex={CENTER_MONTH_INDEX}
|
|
removeClippedSubviews={true}
|
|
estimatedItemSize={screenWidth}
|
|
estimatedListSize={{width: screenWidth, height: screenHeight * 0.9}}
|
|
maintainVisibleContentPosition={{
|
|
minIndexForVisible: 0,
|
|
autoscrollToTopThreshold: 10,
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type MonthData = {
|
|
date: Date;
|
|
days: Date[];
|
|
};
|
|
|
|
const keyExtractor = (item: MonthData, index: number) => `month-${index}`;
|
|
|
|
const Month = React.memo(({
|
|
date,
|
|
days,
|
|
getEventsForDay,
|
|
getMultiDayEventsForDay,
|
|
dayWidth,
|
|
onPress,
|
|
screenWidth,
|
|
sortedDaysOfWeek
|
|
}: {
|
|
date: Date;
|
|
days: Date[];
|
|
getEventsForDay: (date: Date) => CalendarEvent[];
|
|
getMultiDayEventsForDay: (date: Date) => CalendarEvent[];
|
|
dayWidth: number;
|
|
onPress: (date: Date) => void;
|
|
screenWidth: number;
|
|
sortedDaysOfWeek: string[];
|
|
}) => {
|
|
const weeks = useMemo(() => {
|
|
const result = [];
|
|
for (let i = 0; i < days.length; i += 7) {
|
|
result.push(days.slice(i, i + 7));
|
|
}
|
|
return result;
|
|
}, [days]);
|
|
|
|
const eventPositions = useMemo(() => {
|
|
const positions = new Map<string, number>();
|
|
const weekTracking = new Map<number, Set<string>>();
|
|
|
|
weeks.forEach((week, weekIndex) => {
|
|
const activeEvents = new Set<string>();
|
|
|
|
week.forEach(day => {
|
|
const events = getMultiDayEventsForDay(day);
|
|
events.forEach(event => {
|
|
activeEvents.add(event.id);
|
|
});
|
|
});
|
|
|
|
weekTracking.set(weekIndex, activeEvents);
|
|
|
|
activeEvents.forEach(eventId => {
|
|
if (!positions.has(eventId)) {
|
|
const prevWeekEvents = weekIndex > 0 ? weekTracking.get(weekIndex - 1) : new Set<string>();
|
|
const usedPositions = new Set<number>();
|
|
|
|
if (prevWeekEvents) {
|
|
prevWeekEvents.forEach(prevEventId => {
|
|
if (activeEvents.has(prevEventId)) {
|
|
usedPositions.add(positions.get(prevEventId) || 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
let position = 0;
|
|
while (usedPositions.has(position)) {
|
|
position++;
|
|
}
|
|
positions.set(eventId, position);
|
|
}
|
|
});
|
|
});
|
|
|
|
return positions;
|
|
}, [weeks, getMultiDayEventsForDay]);
|
|
|
|
return (
|
|
<View style={[styles.scrollView, {width: screenWidth}]}>
|
|
<View style={styles.monthHeader}>
|
|
<Text style={styles.monthText}>{format(date, 'MMMM yyyy')}</Text>
|
|
<View style={styles.weekDayRow}>
|
|
{sortedDaysOfWeek.map((day, index) => (
|
|
<Text key={index} style={styles.weekDayText}>{day}</Text>
|
|
))}
|
|
</View>
|
|
</View>
|
|
<View style={styles.daysGrid}>
|
|
{weeks.map((week, weekIndex) => (
|
|
<React.Fragment key={weekIndex}>
|
|
{week.map((date, dayIndex) => {
|
|
const multiDayEvents = getMultiDayEventsForDay(date).map(event => ({
|
|
...event,
|
|
weekPosition: eventPositions.get(event.id) || 0
|
|
}));
|
|
|
|
return (
|
|
<Day
|
|
key={`${weekIndex}-${dayIndex}`}
|
|
date={date}
|
|
events={getEventsForDay(date)}
|
|
multiDayEvents={multiDayEvents}
|
|
dayWidth={dayWidth}
|
|
onPress={onPress}
|
|
/>
|
|
);
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
const HEADER_HEIGHT = 40;
|
|
|
|
const styles = StyleSheet.create({
|
|
multiDayContainer: {
|
|
position: 'absolute',
|
|
top: 29,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
zIndex: 1,
|
|
},
|
|
dayContent: {
|
|
flex: 1,
|
|
padding: 4, // Move padding here instead
|
|
},
|
|
eventsContainer: {
|
|
flex: 1,
|
|
marginTop: 2,
|
|
position: 'relative',
|
|
},
|
|
event: {
|
|
borderRadius: 4,
|
|
padding: 1,
|
|
marginVertical: 1,
|
|
height: 14,
|
|
},
|
|
eventText: {
|
|
fontSize: 10,
|
|
color: '#fff',
|
|
fontWeight: '500',
|
|
},
|
|
day: {
|
|
height: '14%',
|
|
padding: 0,
|
|
borderWidth: 0.5,
|
|
borderColor: '#eee',
|
|
position: 'relative',
|
|
overflow: 'visible',
|
|
},
|
|
container: {
|
|
flex: 1,
|
|
height: '100%',
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
monthContainer: {
|
|
flex: 1,
|
|
},
|
|
daysGrid: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
// justifyContent: 'center'
|
|
},
|
|
weekDay: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: HEADER_HEIGHT,
|
|
},
|
|
scrollContent: {
|
|
flex: 1,
|
|
},
|
|
weekDayText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: '#666',
|
|
},
|
|
dateContainer: {
|
|
minWidth: 20,
|
|
height: 24,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: 12,
|
|
},
|
|
todayContainer: {
|
|
backgroundColor: '#6200ee',
|
|
},
|
|
dateText: {
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
color: '#333',
|
|
},
|
|
todayText: {
|
|
color: '#fff',
|
|
},
|
|
outsideMonthText: {
|
|
color: '#ccc',
|
|
},
|
|
moreEvents: {
|
|
fontSize: 10,
|
|
color: '#666',
|
|
textAlign: 'center',
|
|
},
|
|
monthHeader: {
|
|
paddingVertical: 12,
|
|
},
|
|
monthText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
color: '#333',
|
|
textAlign: 'center',
|
|
marginBottom: 8,
|
|
},
|
|
weekDayRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-around',
|
|
paddingHorizontal: 0,
|
|
},
|
|
});
|
|
|
|
export default MonthCalendar; |