Files
klatab/screens/Calendar.tsx
2024-10-14 20:04:51 +02:00

465 lines
17 KiB
TypeScript

import { addMonths, addDays, format, isSameDay, isWithinInterval, parseISO, getDay, parse, differenceInDays } from 'date-fns';
import React, { useContext, useEffect, useState, useMemo, useCallback } from 'react';
import { FlatList, ScrollView, StyleSheet, View, Text, TouchableOpacity, Modal } from 'react-native';
import ErrorBoundary from 'react-native-error-boundary';
import { RefreshControl } from 'react-native-web-refresh-control';
import { InterfaceContext } from '../Interface';
import { SettingsContext } from '../SettingsProvider';
import ErrorDisplay from '../common/ErrorDisplay';
import { ThemeButton, ThemeText } from '../common/ThemeTypes';
import { useColors, useDayColors } from '../common/colors';
import DatePicker from '../common/DatePicker';
const dayTypeNames = { schoolday: 'Schultag', weekend: 'Wochenende', holiday: 'Feiertag', bridgeday: 'Brückentag', vacation: 'Ferien', examday: 'Prüftag' }
const dayNames = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
const dayNamesShort = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
let errorObject;
const Calendar = () => {
const colors = useColors();
const dayColors = useDayColors();
const [calendarData, setCalendarData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const { fetchAppointments, fetchTests, fetchDayTypes, refreshInterface, userInformation } = useContext(InterfaceContext);
const { settings } = useContext(SettingsContext);
const [days, setDays] = useState([]);
const [selectedView, setSelectedView] = useState('days');
const [showDayModal, setShowDayModal] = useState(false);
const [dayModalDate, setDayModalDate] = useState(new Date());
const [startDate, setStartDate] = useState(new Date(format(new Date(), 'yyyy-MM-dd')));
const fetchData = async () => {
try {
setLoading(true);
if (!userInformation) {
refreshInterface()
throw new Error("UserInformation is Null");
}
let type = userInformation.usertype == 'pupil' ? 'class' : 'teacher';
let value = userInformation.usertype == 'pupil' ? userInformation.classid : userInformation.id;
const endDate = addMonths(startDate, 12);
const [appointments, tests, dayTypes] = await Promise.all([
fetchAppointments(startDate),
fetchTests(type, value, startDate),
fetchDayTypes(startDate, endDate)
]);
if (!appointments || !tests || !dayTypes) throw new Error("Data returned is Null");
setCalendarData({ appointments, tests, dayTypes });
setDays(eachDayOfInterval({ start: startDate, end: endDate }));
setError(false);
} catch (error) {
console.error(error)
errorObject = error;
setError(true);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [settings, userInformation, startDate]);
const refresh = () => {
fetchData();
};
const openDayModal = useCallback((date) => {
setDayModalDate(date);
setShowDayModal(true);
}, []);
const renderContent = () => {
if (loading && !calendarData) {
return null;
}
if (error) {
return (
<ErrorDisplay message={errorObject?.message} onRefresh={refresh} />
)
}
try {
if (selectedView === 'days') {
return (
<FlatList
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} colors={[colors.primary]} />}
data={days}
keyExtractor={(item) => item.toString()}
renderItem={({ item }) => (
<Day
onClick={() => openDayModal(item)}
date={item}
calendarData={calendarData}
dayColors={dayColors}
colors={colors}
/>
)}
maxToRenderPerBatch={20}
updateCellsBatchingPeriod={0}
getItemLayout={(data, index) => (
{ length: 70, offset: 70 * index, index }
)}
/>
);
} else if (selectedView === 'tests') {
return (
<FlatList
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} colors={[colors.primary]} />}
data={calendarData.tests}
keyExtractor={(item) => item.date + item.hourFrom + item.hourUntil + item.subject}
renderItem={({ item }) => (
<View style={{ marginLeft: 5, marginBottom: 5, borderBottomWidth: 1, borderColor: '#ccc' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 3 }}>
<ThemeText style={{ fontWeight: 'bold', marginRight: 5 }}>{format(item.date, 'dd.MM.yyyy')}</ThemeText>
<ThemeText>{`${item.timeFrom}-${item.timeUntil}`}</ThemeText>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 5 }}>
<ThemeText style={{ marginRight: 5 }}>{item.subject}</ThemeText>
<ThemeText style={{ marginRight: 5 }}>{item.type}</ThemeText>
{userInformation.usertype === 'teacher' && (
<ThemeText style={{ marginRight: 5 }}>{item.class}</ThemeText>
)}
{userInformation.usertype === 'pupil' && (
<ThemeText style={{ marginRight: 5 }}>{item.teacher}</ThemeText>
)}
<ThemeText style={{ marginRight: 5 }}>{item.room}</ThemeText>
<ThemeText style={{ marginRight: 5 }}>{`${item.hourFrom||'n'}-${item.hourUntil||'n'}`}</ThemeText>
</View>
</View>
)}
getItemLayout={(data, index) => (
{ length: 70, offset: 70 * index, index }
)}
/>
);
} else if (selectedView === 'appointments') {
// Group appointments by day
const groupedAppointments = calendarData.appointments.reduce((acc, appointment) => {
if (!appointment.from) {
return acc;
}
const dateKey = format(appointment.from, 'yyyy-MM-dd');
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(appointment);
return acc;
}, {});
// Convert object to array for rendering
const groupedAppointmentsArray = Object.keys(groupedAppointments).map((date) => ({
date: parseISO(date),
appointments: groupedAppointments[date]
}));
return (
<FlatList
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} colors={[colors.primary]} />}
data={groupedAppointmentsArray}
keyExtractor={(item) => format(item.date, 'yyyy-MM-dd')}
renderItem={({ item }) => (
<View style={{ marginTop: 5, borderBottomWidth: 1, borderColor: '#ccc' }}>
<ThemeText style={{ fontSize: 18, marginBottom: 10, marginLeft: 20 }}>{format(item.date, 'dd.MM.yyyy')}</ThemeText>
{item.appointments.map((appointment, index) => (
<View key={index} style={{ flexDirection: 'row', alignItems: 'center', marginLeft: 5, marginBottom: 10 }}>
<View style={{ flex: 1 }}>
<>
<ThemeText>{`${format(appointment.from, 'HH:mm')} - ${endsNextDayMidnight(appointment.from, appointment.until) ? '24:00' : format(appointment.until, 'HH:mm')}`}</ThemeText>
{!endsNextDayMidnight(appointment.from, appointment.until) && <ThemeText>{`bis ${format(appointment.until, 'dd.MM.yyyy')}`}</ThemeText>}
</>
</View>
<View style={{ flex: 2 }}>
<ThemeText>{appointment.description}</ThemeText>
</View>
</View>
))}
</View>
)}
getItemLayout={(data, index) => (
{ length: 40, offset: 40 * index, index }
)}
/>
);
}
} catch (error) {
console.error(error)
return (
<View>
<ThemeText>Fehler beim rendern des Kalenders</ThemeText>
<ThemeText>{JSON.stringify(calendarData.times)}</ThemeText>
<ThemeText>{JSON.stringify(calendarData.dayTypes)}</ThemeText>
</View>
);
}
}
return (
// @ts-expect-error - Für Web, TS kennt userSelect nicht
<View style={{ flex: 1, backgroundColor: colors.backPrimary, userSelect: 'none' }}>
<View style={styles.container}>
<DatePicker
date={startDate}
setDate={(date) => {
setStartDate(date)
}}
/>
<ThemeButton onPress={() => setSelectedView('days')} color={selectedView === 'days' ? colors.accent : null}><Text>Tage</Text></ThemeButton>
<ThemeButton onPress={() => setSelectedView('tests')} color={selectedView === 'tests' ? colors.accent : null}><Text>Prüfungen</Text></ThemeButton>
<ThemeButton onPress={() => setSelectedView('appointments')} color={selectedView === 'appointments' ? colors.accent : null}><Text>Termine</Text></ThemeButton>
</View >
<ErrorBoundary>
{renderContent()}
{showDayModal && <DayModal date={dayModalDate} calendarData={calendarData} colors={colors} dayColors={dayColors} onClose={() => setShowDayModal(false)} userInformation={userInformation} />}
</ErrorBoundary>
</View>
);
};
export default Calendar;
const styles = StyleSheet.create({
dayBox: {
paddingLeft: 10,
height: 40,
display: 'flex',
justifyContent: 'center',
borderBottomWidth: 1,
borderColor: '#ccc',
},
container: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
paddingRight: 10,
gap: 10,
},
rowContainer: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
}
});
const Day = React.memo(({ date, calendarData, dayColors, onClick }: any) => {
let dayType = useMemo(() => getDayType(date, calendarData), [date, calendarData]);
const tests = useMemo(() => getTests(date, calendarData), [date, calendarData]);
const appointments = useMemo(() => getAppointments(date, calendarData), [date, calendarData]);
let testText;
if (tests.length > 0) {
dayType = 'examday';
if (tests.length === 1) {
testText = ` Prüfung: ${tests[0].subject} ${tests[0].type} `;
} else if (tests.length > 1) {
testText = ` ${tests.length} Prüfungen `;
}
}
return (
<TouchableOpacity onPress={onClick} style={[styles.dayBox, styles.rowContainer, { backgroundColor: dayColors[dayType] }]}>
<ThemeText style={{ width: 90 }}>{format(date, 'dd.MM.yyyy')}</ThemeText>
<ThemeText style={{ width: 30 }}>{dayNamesShort[getDay(date)]}</ThemeText>
<ThemeText>{testText}{appointments.length > 0 ? ` Termine: ${appointments.length}` : ""}</ThemeText>
</TouchableOpacity>
);
}, (prevProps, nextProps) => {
const res = prevProps.date === nextProps.date &&
prevProps.calendarData === nextProps.calendarData &&
prevProps.dayColors === nextProps.dayColors;
return res
});
const DayModal = React.memo(({ date, calendarData, onClose, dayColors, colors, userInformation }: any) => {
let dayType = useMemo(() => getDayType(date, calendarData), [date, calendarData]);
const tests = useMemo(() => getTests(date, calendarData), [date, calendarData]);
const appointments = useMemo(() => getAppointments(date, calendarData), [date, calendarData]);
if (tests.length > 0) {
dayType = 'examday';
}
const modalStyles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: 22,
},
modalView: {
margin: 20,
backgroundColor: colors.backSecondary,
borderRadius: 20,
width: '80%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
borderWidth: 5,
borderColor: colors.backSecondary,
maxWidth: 500,
},
modalHeader: {
backgroundColor: dayColors[dayType],
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
padding: 20,
alignItems: 'center',
},
modalContent: {
padding: 20,
},
dayBox: {
padding: 10,
borderBottomWidth: 1,
borderColor: '#ccc',
backgroundColor: colors.backPrimary,
},
titleText: {
fontWeight: 'bold',
fontSize: 20,
},
sectionTitle: {
fontSize: 18,
marginTop: 15,
marginBottom: 5,
},
});
return (
<Modal
transparent={true}
visible={true}
onRequestClose={onClose}
>
<View style={modalStyles.centeredView}>
<View style={modalStyles.modalView}>
<View style={modalStyles.modalHeader}>
<ThemeText style={{ fontSize: 16 }}>{dayNames[getDay(date)]}</ThemeText>
<ThemeText style={modalStyles.titleText}>{format(date, 'dd.MM.yyyy')}</ThemeText>
<ThemeText style={{ fontSize: 16 }}>{dayTypeNames[dayType]}</ThemeText>
</View>
<View style={modalStyles.modalContent}>
<ThemeText style={modalStyles.sectionTitle}>Tests:</ThemeText>
{tests.length > 0 ? (
tests.map((test, index) => (
<View key={index} style={modalStyles.dayBox}>
<ThemeText>{`${test.subject} ${test.type} ${test.room} ${test.hourFrom}-${test.hourUntil} ${test.timeFrom}-${test.timeUntil} ${userInformation.usertype == 'pupil' ? test.teacher : test.class}`}</ThemeText>
</View>
))
) : (
<ThemeText>Keine Tests</ThemeText>
)}
<ThemeText style={modalStyles.sectionTitle}>Termine:</ThemeText>
{appointments.length > 0 ? (
appointments.map((appointment, index) => (
<View key={index} style={modalStyles.dayBox}>
<ThemeText style={{ fontWeight: 'bold' }}>
{`${format(appointment.from, 'dd.MM.yyyy HH:mm')} - `}
{`${endsSameDay(appointment.from, appointment.until) ? '' : format(appointment.until, 'dd.MM.yyyy') + ' '}`}
{`${endsNextDayMidnight(appointment.from, appointment.until) ? '24:00' : format(appointment.until, 'HH:mm')}`}
</ThemeText>
<ThemeText>{appointment.description}</ThemeText>
</View>
))
) : (
<ThemeText>Keine Termine</ThemeText>
)}
<ThemeButton onPress={onClose} style={{ marginTop: 20 }}><Text>Schließen</Text></ThemeButton>
</View>
</View>
</View>
</Modal>
);
});
const getDayType = (date, calendarData) => {
// Set the date to midnight to avoid timezone issues
const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dayType = calendarData.dayTypes.find((d) => {
// Create midnight dates for comparison
const fromDate = new Date(d.from);
const untilDate = new Date(d.until);
// Set both dates to midnight
fromDate.setHours(0, 0, 0, 0);
untilDate.setHours(0, 0, 0, 0);
return fromDate <= targetDate && targetDate <= untilDate;
});
console.log(targetDate, dayType);
return dayType ? dayType.type : "schultag";
};
const getTests = (date, calendarData) => {
return calendarData.tests.filter((test) => isSameDay(parseISO(test.date), date));
};
const getAppointments = (date, calendarData) => {
return calendarData?.appointments?.filter((app) => {
if (!app.from || !app.until) {
return false; // Skip appointments without a valid start or end date
}
const appointmentStart = parseISO(app.from);
const appointmentEnd = parseISO(app.until);
// Check if the date falls within the appointment range
if (isWithinInterval(date, { start: appointmentStart, end: appointmentEnd })) {
return true;
}
// Check if the appointment spans multiple days
if (isSameDay(appointmentStart, appointmentEnd) && isSameDay(date, appointmentStart)) {
return true; // Single day appointment
}
return false;
});
};
function eachDayOfInterval({ start, end = new Date(Date.now()), }: { start: Date, end?: Date }) {
const days: Date[] = []
while (end >= start) {
days.push(start)
start = addDays(start, 1)
}
return days
}
function endsSameDay(start, end) {
const startD = new Date(start)
const endD = new Date(end)
const daysDifference = differenceInDays(startD, endD)
return daysDifference == 0 || endsNextDayMidnight(startD, endD)
}
function endsNextDayMidnight(start, end) {
const startD = new Date(start)
const endD = new Date(end)
const daysDifference = differenceInDays(startD, endD)
const endTimeIsMidnight = endD.getHours() === 0 && endD.getMinutes() === 0 && endD.getSeconds() === 0;
return (daysDifference == 1 || daysDifference == -1) && endTimeIsMidnight
}