import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native'; import { useRouter, useFocusEffect } from 'expo-router'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Header } from '@/components/Header'; import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks'; import { useSubtasks } from '@/hooks/useSubtasks'; import { useTaskModals } from '@/hooks/useTaskModals'; import { useSettings } from '@/theme'; import { useCategories } from '@/hooks/useDatabase'; import { useICalImport } from '@/hooks/useICalImport'; import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions'; import { QuickAddBar } from '@/components/QuickAddBar'; import { OptionPickerModal } from '@/components/OptionPickerModal'; import { TaskItem } from '@/components/TaskItem'; import { SubtaskItem } from '@/components/SubtaskItem'; import Task from '@/models/Task'; import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns'; import Svg, { Path } from 'react-native-svg'; import type { ThemeColors } from '@/theme'; import type { TaskData, SubtaskData } from '@/types'; const WIDTH = Dimensions.get('window').width; const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so']; const PRIORITY_RANK: Record = { none: 0, low: 1, medium: 2, high: 3, critical: 4 }; const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; export default function CalendarScreen() { const router = useRouter(); const { theme, sortBy } = useSettings(); const styles = React.useMemo(() => createStyles(theme), [theme]); const categories = useCategories(); const categoryColors = useMemo(() => { const map = new Map(); for (const c of categories) { map.set(c.id, c.color); } return map; }, [categories]); const { modals, openTaskEdit } = useTaskModals(); const [visibleMonth, setVisibleMonth] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(() => new Date()); const [monthPickerVisible, setMonthPickerVisible] = useState(false); const [yearPickerVisible, setYearPickerVisible] = useState(false); const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks(); const visibleMonthRef = useRef(visibleMonth); const selectedDateRef = useRef(selectedDate); const translateX = useRef(new Animated.Value(0)); const animatingRef = useRef(false); const gridWidthRef = useRef(WIDTH); useEffect(() => { visibleMonthRef.current = visibleMonth; }, [visibleMonth]); useEffect(() => { selectedDateRef.current = selectedDate; }, [selectedDate]); const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate); const monthTasks = useTasksInMonth(visibleMonth); const refreshMonthTasks = monthTasks.refresh; const sortedDayTasks = useMemo(() => { const sorted = [...selectedDayTasks]; switch (sortBy) { case 'date': sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER)); break; case 'priority': sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]); break; case 'alpha': sorted.sort((a, b) => a.title.localeCompare(b.title)); break; case 'created': sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); break; } return sorted; }, [selectedDayTasks, sortBy]); useFocusEffect( useCallback(() => { refreshDayTasks(); refreshMonthTasks(); refreshSubtasks(); }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]) ); const weeks = useMemo(() => { const first = startOfMonth(visibleMonth); const offset = (first.getDay() + 6) % 7; // week starts Monday const gridStart = addDays(first, -offset); const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i)); const rows: Date[][] = []; for (let i = 0; i < 42; i += 7) rows.push(cells.slice(i, i + 7)); return rows; }, [visibleMonth]); const transitionTo = useCallback( (dir: 1 | -1, animate = true) => { if (animatingRef.current) return; const next = addMonths(visibleMonthRef.current, dir); if (!isSameMonth(selectedDateRef.current, next)) { setSelectedDate(startOfMonth(next)); } if (!animate) { setVisibleMonth(next); translateX.current.setValue(0); return; } animatingRef.current = true; const w = gridWidthRef.current || WIDTH; const target = dir === 1 ? -w : w; Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => { setVisibleMonth(next); translateX.current.setValue(-target); Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => { animatingRef.current = false; }); }); }, [translateX] ); const handleDayPress = useCallback( (day: Date) => { setSelectedDate(day); if (!isSameMonth(day, visibleMonthRef.current)) { transitionTo(day > visibleMonthRef.current ? 1 : -1, true); } else { router.push({ pathname: '/day-view', params: { date: day.toISOString() } }); } }, [router, transitionTo] ); const pan = useMemo( () => Gesture.Pan() .activeOffsetX([-16, 16]) .minDistance(6) .runOnJS(true) // eslint-disable-next-line react-hooks/refs .onUpdate((e) => { if (!animatingRef.current) translateX.current.setValue(e.translationX); }) // eslint-disable-next-line react-hooks/refs .onEnd((e) => { if (animatingRef.current) return; const w = gridWidthRef.current || WIDTH; const dx = e.translationX; if (dx <= -w / 4) { translateX.current.stopAnimation(); transitionTo(1, false); } else if (dx >= w / 4) { translateX.current.stopAnimation(); transitionTo(-1, false); } else { Animated.spring(translateX.current, { toValue: 0, useNativeDriver: false }).start(); } }), [translateX, transitionTo] ); const handleSelectMonth = useCallback((value: string | string[]) => { const monthIndex = parseInt(Array.isArray(value) ? value[0] : value, 10); setVisibleMonth(new Date(visibleMonthRef.current.getFullYear(), monthIndex, 1)); }, []); const handleSelectYear = useCallback((value: string | string[]) => { const year = parseInt(Array.isArray(value) ? value[0] : value, 10); setVisibleMonth(new Date(year, visibleMonthRef.current.getMonth(), 1)); }, []); const currentYear = new Date().getFullYear(); const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label })); const yearOptions = useMemo(() => { const options: { value: string; label: string }[] = []; for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) }); return options; }, [currentYear]); const handleToggleComplete = useCallback(async (taskId: string) => { await confirmAndToggleTaskComplete(taskId, () => { refreshDayTasks(); refreshMonthTasks(); refreshSubtasks(); }); }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]); const [expandedTasks, setExpandedTasks] = useState>(new Set()); const toggleExpand = useCallback((taskId: string) => { setExpandedTasks((prev) => { const next = new Set(prev); if (next.has(taskId)) { next.delete(taskId); } else { next.add(taskId); } return next; }); }, []); const handleSubtaskToggle = useCallback(async (subtaskId: string) => { await toggleSubtaskComplete(subtaskId); refreshDayTasks(); refreshMonthTasks(); refreshSubtasks(); }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]); return (
router.back()} style={styles.backButton} accessible={true} accessibilityLabel="Back" > } /> { gridWidthRef.current = e.nativeEvent.layout.width; }} // eslint-disable-next-line react-hooks/refs style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]} > transitionTo(-1)} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7} > setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}> {format(visibleMonth, 'MMMM')} setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}> {format(visibleMonth, 'yyyy')} transitionTo(1)} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7} > {GERMAN_WEEKDAYS.map((day, i) => ( {day} ))} {weeks.map((week, wi) => ( {week.map((day) => ( ))} ))} {format(selectedDate, 'EEEE, MMMM d')} {sortedDayTasks.length > 0 && ( {sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'} )} {sortedDayTasks.length === 0 ? ( No events on this day Tap a date or use the bar below ) : ( sortedDayTasks.map((task) => { const isExpanded = expandedTasks.has(task.id); const itemSubtasks = subtasksByTask.get(task.id) ?? []; return ( handleToggleComplete(task.id)} onPress={(id) => expandedTasks.has(id) ? router.push({ pathname: '/task-detail', params: { id } }) : toggleExpand(id) } onMenuOpen={(id) => openTaskEdit(id)} onSubtaskToggle={handleSubtaskToggle} /> ); }) )} {modals(() => {})} setMonthPickerVisible(false)} /> setYearPickerVisible(false)} /> ); } function byDayOf(byDay: Record, day: Date): Task[] { return byDay[day.getDate()] ?? []; } interface CalendarTaskRowProps { task: Task; expanded: boolean; subtasks: SubtaskData[]; categoryColor?: string; onToggle: (taskId: string) => void | Promise; onPress: (taskId: string) => void; onMenuOpen: (taskId: string) => void; onSubtaskToggle: (subtaskId: string) => void | Promise; } const CalendarTaskRow = React.memo(function CalendarTaskRow({ task, expanded, subtasks, categoryColor, onToggle, onPress, onMenuOpen, onSubtaskToggle, }: CalendarTaskRowProps) { const { theme } = useSettings(); const styles = React.useMemo(() => createStyles(theme), [theme]); const sortedSubtasks = useMemo( () => subtasks.slice().sort((a, b) => a.order - b.order), [subtasks] ); return ( onToggle(task.id)} onPress={() => onPress(task.id)} onMenuOpen={() => onMenuOpen(task.id)} expanded={expanded} categoryColor={categoryColor} /> {expanded && sortedSubtasks.length > 0 && ( {sortedSubtasks.map((sub) => ( onSubtaskToggle(sub.id)} /> ))} )} ); }); function cellTitle(tasks: Task[]): string | null { if (tasks.length === 0) return null; if (tasks.length === 1) return tasks[0].title; return `${tasks[0].title} +${tasks.length - 1}`; } function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string { if (tasks.length === 0) return theme.textMuted; return theme.accent; } interface DayCellProps { day: Date; label: string | null; dotColor: string; selected: boolean; today: boolean; inMonth: boolean; theme: ThemeColors; onPress: (day: Date) => void; } const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) { const styles = React.useMemo(() => createStyles(theme), [theme]); return ( onPress(day)} activeOpacity={0.7} > {format(day, 'd')} {label ? ( {label} ) : !inMonth ? ( ) : null} ); }); const createStyles = (theme: ThemeColors) => StyleSheet.create({ container: { flex: 1, }, scrollContent: { paddingBottom: 16, flexGrow: 1, }, scrollBody: { flex: 1, }, kbAvoid: { flex: 1, }, calendarArea: { paddingHorizontal: 12, paddingTop: 6, paddingBottom: 4, }, monthRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, paddingBottom: 14, }, monthNav: { width: 36, height: 36, borderRadius: 12, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, monthSelectorGroup: { alignItems: 'flex-start', flex: 1, }, monthButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingHorizontal: 10, }, monthLabel: { fontSize: 22, fontWeight: '700', }, yearButton: { marginTop: -2, paddingHorizontal: 6, }, yearLabel: { fontSize: 17, fontWeight: '700', }, weekdayRow: { flexDirection: 'row', marginBottom: 4, paddingHorizontal: 2, }, weekday: { flex: 1, textAlign: 'center', fontSize: 11, fontWeight: '600', textTransform: 'uppercase', }, weekRow: { flexDirection: 'row', gap: 6, marginBottom: 6, }, dayCell: { flex: 1, height: 56, borderRadius: 12, borderWidth: 1, borderColor: 'transparent', paddingVertical: 4, alignItems: 'center', }, dayNumber: { fontSize: 14, fontWeight: '600', }, dayNumberSelected: { color: theme.text, }, chip: { marginTop: 3, paddingHorizontal: 4, paddingVertical: 2, borderRadius: 5, maxWidth: '92%', }, chipText: { color: '#FFFFFF', fontSize: 8, fontWeight: '600', }, chipPlaceholder: { marginTop: 3, width: 6, height: 2, borderRadius: 1, opacity: 0.4, }, panelHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 18, paddingTop: 15, paddingBottom: 4, }, panelDate: { fontSize: 16, fontWeight: '700', }, panelCount: { fontSize: 13, }, emptyState: { alignItems: 'center', paddingVertical: 36, }, emptyText: { fontSize: 15, fontWeight: '600', marginBottom: 4, }, emptySubtext: { fontSize: 13, }, backButton: { padding: 8, }, eventCard: { marginHorizontal: 16, marginTop: 6, }, subtaskList: { paddingLeft: 8, paddingRight: 4, paddingTop: 4, }, });