Month cal changes

This commit is contained in:
Milan Paunovic
2025-01-23 02:16:07 +01:00
parent 231e99ff8f
commit 580104d052
8 changed files with 597 additions and 334 deletions

View File

@ -1,5 +1,5 @@
<resources>
<string name="app_name">"Cally "</string>
<string name="app_name">\"Cally \"</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">light</string>

View File

@ -1,4 +1,4 @@
import React, {memo} from "react";
import React, {memo, useState} from "react";
import {Button, Picker, PickerModes, SegmentedControl, Text, View} from "react-native-ui-lib";
import {MaterialIcons} from "@expo/vector-icons";
import {months} from "./constants";
@ -12,6 +12,7 @@ import {Mode} from "react-native-big-calendar";
export const CalendarHeader = memo(() => {
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const [mode, setMode] = useAtom(modeAtom);
const [tempIndex, setTempIndex] = useState<number | null>(null);
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const segments = isTablet
@ -26,9 +27,12 @@ export const CalendarHeader = memo(() => {
selectedMode = ["day", "3days", "month"][index] as Mode;
}
setTempIndex(index);
if (selectedMode) {
setTimeout(() => {
setMode(selectedMode as "day" | "week" | "month" | "3days");
setTempIndex(null);
}, 150);
}
};
@ -131,7 +135,7 @@ export const CalendarHeader = memo(() => {
outlineWidth={3}
segmentLabelStyle={styles.segmentslblStyle}
onChangeIndex={handleSegmentChange}
initialIndex={getInitialIndex()}
initialIndex={tempIndex ?? getInitialIndex()}
/>
</View>
</View>

View File

@ -1,92 +1,94 @@
import { Text, TouchableOpacity, View } from "react-native-ui-lib";
import React from "react";
import { StyleSheet } from "react-native";
import { useAtom } from "jotai";
import { isFamilyViewAtom } from "@/components/pages/calendar/atoms";
import {Text, TouchableOpacity, View} from "react-native-ui-lib";
import React, {useState, useCallback} from "react";
import {StyleSheet} from "react-native";
import {useAtom} from "jotai";
import {isFamilyViewAtom} from "@/components/pages/calendar/atoms";
const CalendarViewSwitch = () => {
const [isFamilyView, setIsFamilyView] = useAtom(isFamilyViewAtom);
const [isFamilyView, setIsFamilyView] = useAtom(isFamilyViewAtom);
const [localState, setLocalState] = useState(isFamilyView);
return (
<View
row
spread
style={{
position: "absolute",
bottom: 20,
left: 20,
borderRadius: 30,
backgroundColor: "white",
alignItems: "center",
justifyContent: "center",
// iOS shadow
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
// Android shadow (elevation)
elevation: 6,
}}
centerV
>
<TouchableOpacity
onPress={() => {
setIsFamilyView(true);
}}
>
<View
centerV
centerH
height={40}
paddingH-15
style={isFamilyView ? styles.switchBtnActive : styles.switchBtn}
>
<Text
color={isFamilyView ? "white" : "#a1a1a1"}
style={styles.switchTxt}
>
Family View
</Text>
</View>
</TouchableOpacity>
const handleViewChange = useCallback((newValue: boolean) => {
setLocalState(newValue);
setTimeout(() => {
setIsFamilyView(newValue);
}, 150);
}, [setIsFamilyView]);
<TouchableOpacity
onPress={() => {
setIsFamilyView(false);
}}
>
return (
<View
centerV
centerH
height={40}
paddingH-15
style={!isFamilyView ? styles.switchBtnActive : styles.switchBtn}
row
spread
style={{
position: "absolute",
bottom: 20,
left: 20,
borderRadius: 30,
backgroundColor: "white",
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 6,
}}
centerV
>
<Text
color={!isFamilyView ? "white" : "#a1a1a1"}
style={styles.switchTxt}
>
My View
</Text>
<TouchableOpacity
onPress={() => handleViewChange(true)}
>
<View
centerV
centerH
height={40}
paddingH-15
style={localState ? styles.switchBtnActive : styles.switchBtn}
>
<Text
color={localState ? "white" : "#a1a1a1"}
style={styles.switchTxt}
>
Family View
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleViewChange(false)}
>
<View
centerV
centerH
height={40}
paddingH-15
style={!localState ? styles.switchBtnActive : styles.switchBtn}
>
<Text
color={!localState ? "white" : "#a1a1a1"}
style={styles.switchTxt}
>
My View
</Text>
</View>
</TouchableOpacity>
</View>
</TouchableOpacity>
</View>
);
);
};
export default CalendarViewSwitch;
const styles = StyleSheet.create({
switchBtnActive: {
backgroundColor: "#a1a1a1",
borderRadius: 50,
},
switchBtn: {
backgroundColor: "white",
borderRadius: 50,
},
switchTxt: {
fontSize: 16,
fontFamily: "Manrope_600SemiBold",
},
});
switchBtnActive: {
backgroundColor: "#a1a1a1",
borderRadius: 50,
},
switchBtn: {
backgroundColor: "white",
borderRadius: 50,
},
switchTxt: {
fontSize: 16,
fontFamily: "Manrope_600SemiBold",
},
});

View File

@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useMemo, useRef} from "react";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {useAuthContext} from "@/contexts/AuthContext";
import {useAtomValue} from "jotai";
import {modeAtom, selectedDateAtom, selectedUserAtom} from "@/components/pages/calendar/atoms";
@ -9,8 +9,8 @@ import {useFormattedEvents} from "@/components/pages/calendar/useFormattedEvents
import {useCalendarControls} from "@/components/pages/calendar/useCalendarControls";
import {EventCell} from "@/components/pages/calendar/EventCell";
import {isToday} from "date-fns";
import { View } from "react-native-ui-lib";
import { DeviceType } from "expo-device";
import {View} from "react-native-ui-lib";
import {DeviceType} from "expo-device";
import * as Device from "expo-device"
import {useAtomCallback} from 'jotai/utils'
@ -21,7 +21,35 @@ interface EventCalendarProps {
onLoad?: () => void;
}
const MemoizedEventCell = React.memo(EventCell);
const HEADER_PROPS = {
dayBarHeight: 60,
headerBottomHeight: 20,
};
const BODY_PROPS = {
showNowIndicator: true,
hourFormat: "h:mm a"
};
const MODE_TO_DAYS = {
'week': 7,
'3days': 3,
'day': 1,
'month': 1
};
const getContainerProps = (selectedDate: Date) => ({
hourWidth: 70,
allowPinchToZoom: true,
useHaptic: true,
scrollToNow: true,
initialDate: selectedDate.toISOString(),
});
const MemoizedEventCell = React.memo(EventCell, (prev, next) => {
return prev.event.id === next.event.id &&
prev.event.lastModified === next.event.lastModified;
});
export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
{
@ -36,11 +64,16 @@ export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
const calendarRef = useRef<CalendarKitHandle>(null);
const {data: events} = useGetEvents();
const selectedUser = useAtomValue(selectedUserAtom);
const [customKey, setCustomKey] = useState("defaultKey");
const memoizedFamilyMembers = useMemo(() => familyMembers || [], [familyMembers]);
const containerProps = useMemo(() => getContainerProps(selectedDate), [selectedDate]);
const checkModeAndGoToDate = useAtomCallback(useCallback((get) => {
const currentMode = get(modeAtom);
if ((selectedDate && isToday(selectedDate)) || currentMode === "month") {
calendarRef?.current?.goToDate({date: selectedDate});
setCustomKey(selectedDate.toISOString())
}
}, [selectedDate]));
@ -55,35 +88,9 @@ export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
debouncedOnDateChanged
} = useCalendarControls(events ?? []);
const numberOfDays = useMemo(() => {
return mode === 'week' ? 7 : mode === '3days' ? 3 : 1;
}, [mode]);
const firstDay = useMemo(() => {
return profileData?.firstDayOfWeek === "Mondays" ? 1 : 0;
}, [profileData?.firstDayOfWeek]);
const headerProps = useMemo(() => ({
dayBarHeight: 60,
headerBottomHeight: 20,
}), []);
const bodyProps = useMemo(() => ({
showNowIndicator: true,
hourFormat: "h:mm a"
}), []);
const containerProps = useMemo(() => ({
hourWidth: 70,
allowPinchToZoom: true,
useHaptic: true,
scrollToNow: true,
initialDate: selectedDate.toISOString(),
}), [selectedDate]);
const getAttendees = useCallback((event: any) => {
return familyMembers?.filter(member => event?.attendees?.includes(member?.uid!)) || [];
}, [familyMembers]);
return memoizedFamilyMembers.filter(member => event?.attendees?.includes(member?.uid!));
}, [memoizedFamilyMembers]);
const renderEvent = useCallback((event: any) => {
const attendees = getAttendees(event);
@ -94,27 +101,30 @@ export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
attendees={attendees}
/>
);
}, [familyMembers, handlePressEvent, getAttendees]);
}, [getAttendees, handlePressEvent]);
return (
<CalendarContainer
ref={calendarRef}
{...containerProps}
numberOfDays={numberOfDays}
numberOfDays={MODE_TO_DAYS[mode]}
calendarWidth={calendarWidth}
onDateChanged={debouncedOnDateChanged}
firstDay={firstDay}
firstDay={profileData?.firstDayOfWeek === "Mondays" ? 1 : 0}
events={formattedEvents ?? []}
onPressEvent={handlePressEvent}
onPressBackground={handlePressCell}
onLoad={onLoad}
key={customKey}
>
<CalendarHeader {...headerProps} />
<CalendarHeader {...HEADER_PROPS} />
<CalendarBody
{...bodyProps}
{...BODY_PROPS}
renderEvent={renderEvent}
/>
{Device.deviceType === DeviceType.TABLET && <View style={{backgroundColor: 'white', height: '9%', width: '100%'}}/>}
{Device.deviceType === DeviceType.TABLET && (
<View style={{backgroundColor: 'white', height: '9%', width: '100%'}}/>
)}
</CalendarContainer>
);
});

View File

@ -64,7 +64,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo((props) =>
return (
<View style={styles.root}>
{(isLoading || isSyncing) && (
{(isLoading || isSyncing) && mode !== 'month' && (
<Animated.View
exiting={FadeOut.duration(300)}
style={styles.loadingContainer}

View File

@ -1,7 +1,6 @@
import React, {useCallback, useMemo, useRef} from 'react';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {
Dimensions,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@ -16,16 +15,14 @@ import {
format,
isSameDay,
isSameMonth,
isWithinInterval,
startOfMonth,
subDays,
addMonths,
subMonths
addMonths, startOfWeek, isWithinInterval,
} from 'date-fns';
import {useGetEvents} from "@/hooks/firebase/useGetEvents";
import {useAtom, useSetAtom} from "jotai";
import {modeAtom, selectedDateAtom, selectedNewEventDateAtom} from "@/components/pages/calendar/atoms";
import {useAuthContext} from "@/contexts/AuthContext";
import {FlashList} from "@shopify/flash-list";
import * as Device from "expo-device";
interface CalendarEvent {
@ -41,176 +38,104 @@ interface CustomMonthCalendarProps {
}
const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MAX_VISIBLE_EVENTS = 3;
const CALENDAR_BUFFER_DAYS = 40;
const MAX_VISIBLE_EVENTS = 3 ;
const CENTER_MONTH_INDEX = 24;
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
const {data: rawEvents, isLoading} = useGetEvents();
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom);
const [mode, setMode] = useAtom(modeAtom);
const {profileData} = useAuthContext();
const scrollViewRef = useRef<ScrollView>(null);
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const screenWidth = isTablet ? Dimensions.get('window').width * 0.89 : Dimensions.get('window').width;
const dayWidth = (screenWidth - 32) / 7;
const isScrolling = useRef(false);
const currentScrollX = useRef(screenWidth);
const DayHeader = React.memo(({day, width}: { day: string; width: string }) => (
<View style={[styles.weekDay, {width}]}>
<Text style={styles.weekDayText}>{day}</Text>
</View>
));
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
const Event = React.memo(({event, onPress}: { event: CalendarEvent; onPress: () => void }) => (
<TouchableOpacity
style={[styles.event, {backgroundColor: event.color || '#6200ee'}]}
onPress={onPress}
>
<Text style={styles.eventText} numberOfLines={1}>
{event.title}
</Text>
</TouchableOpacity>
));
const events = useMemo(() => {
if (!rawEvents?.length) return [];
if (!selectedDate) return [];
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
color?: string;
weekPosition?: number;
}
const rangeStart = subDays(selectedDate, CALENDAR_BUFFER_DAYS);
const rangeEnd = addDays(selectedDate, CALENDAR_BUFFER_DAYS);
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.color || '#6200ee',
borderRadius: isStart || isEnd ? 4 : 0,
borderTopLeftRadius: isStart ? 4 : 0,
borderBottomLeftRadius: isStart ? 4 : 0,
borderTopRightRadius: isEnd ? 4 : 0,
borderBottomRightRadius: isEnd ? 4 : 0,
padding: 1,
zIndex: 1,
left: isStart ? 2 : 0,
right: isEnd ? 2 : 0,
top: event.weekPosition ? event.weekPosition * 20 : 0,
marginRight: !isEnd ? -1 : 0,
};
return rawEvents.filter((event) => {
if (!event?.start || !event?.end) {
return false;
}
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 false;
}
return startDate <= rangeEnd && endDate >= rangeStart;
});
}, [rawEvents, selectedDate]);
const onDayPress = useCallback(
(date: Date) => {
date && setSelectedDate(date);
setTimeout(() => {
setMode("day");
}, 100)
},
[mode, setSelectedNewEndDate, setSelectedDate]
);
const getMonthData = useCallback((date: Date) => {
const start = startOfMonth(date);
const end = endOfMonth(date);
const days = eachDayOfInterval({start, end});
// Add padding days at the start
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));
}
// Add padding days at the end
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 prevMonth = subMonths(selectedDate, 1);
const nextMonth = addMonths(selectedDate, 1);
return [
{
date: prevMonth,
days: getMonthData(prevMonth)
},
{
date: selectedDate,
days: getMonthData(selectedDate)
},
{
date: nextMonth,
days: getMonthData(nextMonth)
}
];
}, [selectedDate, getMonthData]);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isScrolling.current) {
currentScrollX.current = event.nativeEvent.contentOffset.x;
}
}, []);
const onMomentumScrollEnd = useCallback(() => {
if (isScrolling.current) return;
const pageWidth = screenWidth;
const currentX = currentScrollX.current;
if (currentX < pageWidth / 2) {
isScrolling.current = true;
setSelectedDate(prev => {
const newDate = subMonths(prev, 1);
// Immediately scroll back to center
scrollViewRef.current?.scrollTo({
x: pageWidth,
animated: false,
});
isScrolling.current = false;
currentScrollX.current = pageWidth;
return newDate;
});
} else if (currentX > pageWidth * 1.5) {
isScrolling.current = true;
setSelectedDate(prev => {
const newDate = addMonths(prev, 1);
// Immediately scroll back to center
scrollViewRef.current?.scrollTo({
x: pageWidth,
animated: false,
});
isScrolling.current = false;
currentScrollX.current = pageWidth;
return newDate;
});
}
}, [screenWidth, setSelectedDate]);
const getEventsForDay = useCallback((date: Date) => {
return events?.filter(event =>
isSameDay(new Date(event.start), date)
) ?? [];
}, [events]);
const renderEvent = useCallback((event: CalendarEvent) => {
return (
<TouchableOpacity
key={event.id}
style={[styles.event, {backgroundColor: event.color || '#6200ee'}]}
onPress={() => onDayPress(event.start)}
>
<Text style={styles.eventText} numberOfLines={1}>
return (
<TouchableOpacity style={style} onPress={onPress}>
{isStart && (
<Text style={[styles.eventText]} numberOfLines={1}>
{event.title}
</Text>
</TouchableOpacity>
);
}, [onDayPress]);
)}
</TouchableOpacity>
);
});
const renderDay = useCallback((date: Date, index: number) => {
const dayEvents = getEventsForDay(date);
const isCurrentMonth = isSameMonth(date, selectedDate);
const isToday = isSameDay(date, new Date());
const hasMoreEvents = dayEvents.length > MAX_VISIBLE_EVENTS;
const Day = React.memo(({
date,
selectedDate,
events,
multiDayEvents,
dayWidth,
onPress
}: {
date: Date;
selectedDate: Date;
events: CalendarEvent[];
multiDayEvents: CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
}) => {
const isCurrentMonth = isSameMonth(date, selectedDate);
const isToday = isSameDay(date, new Date());
return (
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;
return (
<View style={[styles.day, { width: dayWidth }]}>
<TouchableOpacity
key={index}
style={[styles.day, {width: dayWidth}]}
onPress={() => onDayPress(date)}
style={styles.dayContent}
onPress={() => onPress(date)}
>
<View style={[
styles.dateContainer,
@ -224,62 +149,389 @@ export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
{format(date, 'd')}
</Text>
</View>
<View style={styles.eventsContainer}>
{dayEvents.slice(0, MAX_VISIBLE_EVENTS).map(renderEvent)}
{hasMoreEvents && (
{/* Multi-day events first */}
{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)}
/>
))}
{/* Single-day events */}
{visibleSingleDayEvents.map(event => (
<Event
key={event.id}
event={event}
onPress={() => onPress(event.start)}
/>
))}
{/* Show total number of hidden events */}
{totalHiddenEvents > 0 && (
<Text style={styles.moreEvents}>
{dayEvents.length - MAX_VISIBLE_EVENTS} More
{totalHiddenEvents} More
</Text>
)}
</View>
</TouchableOpacity>
);
}, [dayWidth, selectedDate, getEventsForDay, onDayPress, renderEvent]);
</View>
);
});
const findFirstAvailablePosition = (usedPositions: number[]): number => {
let position = 0;
while (usedPositions.includes(position)) {
position++;
}
return position;
};
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
const {data: rawEvents} = useGetEvents();
const [selectedDate, setSelectedDate] = useAtom(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 - 32) / 7;
const centerMonth = useRef(selectedDate);
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
const events = useMemo(() => {
if (!rawEvents?.length) return new Map();
if (!selectedDate) return new Map();
const eventMap = new Map();
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 dateStr = format(startDate, 'yyyy-MM-dd');
const existing = eventMap.get(dateStr) || [];
eventMap.set(dateStr, [...existing, event]);
});
return eventMap;
}, [rawEvents, selectedDate]);
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]);
const processedEvents = useMemo(() => {
if (!rawEvents?.length || !selectedDate) 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, selectedDate]);
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];
const sortedDays = days.slice(weekStartsOn).concat(days.slice(0, weekStartsOn));
return sortedDays;
return days.slice(weekStartsOn).concat(days.slice(0, weekStartsOn));
}, [weekStartsOn]);
const renderMonth = useCallback(({item}: { item: MonthData }) => (
<Month
date={item.date}
days={item.days}
selectedDate={selectedDate}
getEventsForDay={getEventsForDay}
getMultiDayEventsForDay={getMultiDayEventsForDay}
dayWidth={dayWidth}
onPress={onDayPress}
screenWidth={screenWidth}
/>
), [getEventsForDay, getMultiDayEventsForDay, dayWidth, onDayPress, screenWidth, weekStartsOn]);
const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const currentMonthIndex = Math.round(event.nativeEvent.contentOffset.x / screenWidth);
const currentMonth = monthsToRender[currentMonthIndex];
if (currentMonth) {
setSelectedDate(currentMonth.date);
centerMonth.current = currentMonth.date;
}
}, [screenWidth, setSelectedDate, monthsToRender]);
return (
<View style={styles.container}>
<View style={styles.header}>
{sortedDaysOfWeek.map((day, index) => (
<View key={index} style={[styles.weekDay, {width: `${100 / 7}%`}]}>
<Text style={styles.weekDayText}>{day}</Text>
</View>
<DayHeader key={index} day={day} width={`${100 / 7}%`}/>
))}
</View>
<ScrollView
<FlashList
ref={scrollViewRef}
data={monthsToRender}
renderItem={renderMonth}
keyExtractor={keyExtractor}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
initialScrollIndex={CENTER_MONTH_INDEX}
onMomentumScrollEnd={onMomentumScrollEnd}
scrollEventThrottle={16}
contentOffset={{x: screenWidth, y: 0}}
style={{flex: 1}}
>
{monthsToRender.map(({date, days}, monthIndex) => (
<View
key={monthIndex}
style={[styles.scrollView, {width: screenWidth}]}
>
<View style={styles.daysGrid}>
{days.map((day, index) => renderDay(day, index))}
</View>
</View>
))}
</ScrollView>
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,
selectedDate,
getEventsForDay,
getMultiDayEventsForDay,
dayWidth,
onPress,
screenWidth
}: {
date: Date;
days: Date[];
selectedDate: Date;
getEventsForDay: (date: Date) => CalendarEvent[];
getMultiDayEventsForDay: (date: Date) => CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
screenWidth: number;
}) => {
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.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}
selectedDate={selectedDate}
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: 0,
left: 0,
right: 0,
zIndex: 1,
},
dayContent: {
flex: 1,
},
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: 4,
borderWidth: 0.5,
borderColor: '#eee',
position: 'relative',
},
container: {
flex: 1,
height: '100%',
@ -302,12 +554,6 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
justifyContent: 'center'
},
day: {
height: '15%', // 100% / 6 weeks
padding: 4,
borderWidth: 0.5,
borderColor: '#eee',
},
weekDay: {
alignItems: 'center',
justifyContent: 'center',
@ -342,19 +588,6 @@ const styles = StyleSheet.create({
outsideMonthText: {
color: '#ccc',
},
eventsContainer: {
flex: 1,
marginTop: 2,
},
event: {
borderRadius: 4,
padding: 2,
marginVertical: 1,
},
eventText: {
fontSize: 10,
color: '#fff',
},
moreEvents: {
fontSize: 10,
color: '#666',

View File

@ -52,6 +52,7 @@
"@react-native/assets-registry": "^0.76.3",
"@react-navigation/drawer": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@shopify/flash-list": "^1.7.2",
"@tanstack/query-async-storage-persister": "^5.62.7",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-persist-client": "^5.62.7",

View File

@ -3226,6 +3226,14 @@
component-type "^1.2.1"
join-component "^1.1.0"
"@shopify/flash-list@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.7.2.tgz#a862515a6aae912486d4515909761320d6a6e964"
integrity sha512-rnadpDht/mlJLDM02HBni49EJJQhc51zjJ3diGnTz3MV6U8vK9Hztou+2C5d6bNLb4oZvSG5f7NTWejkipyMLw==
dependencies:
recyclerlistview "4.2.1"
tslib "2.6.3"
"@sideway/address@^4.1.5":
version "4.1.5"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
@ -9974,7 +9982,7 @@ recast@^0.21.0:
source-map "~0.6.1"
tslib "^2.0.1"
recyclerlistview@^4.0.0:
recyclerlistview@4.2.1, recyclerlistview@^4.0.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/recyclerlistview/-/recyclerlistview-4.2.1.tgz#4537a0959400cdce1df1f38d26aab823786e9b13"
integrity sha512-NtVYjofwgUCt1rEsTp6jHQg/47TWjnO92TU2kTVgJ9wsc/ely4HnizHHa+f/dI7qaw4+zcSogElrLjhMltN2/g==
@ -11143,6 +11151,11 @@ ts-object-utils@0.0.5:
resolved "https://registry.yarnpkg.com/ts-object-utils/-/ts-object-utils-0.0.5.tgz#95361cdecd7e52167cfc5e634c76345e90a26077"
integrity sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==
tslib@2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0"
integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==
tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"