implement calendar event display with day indicators and overlay
- Add ExpandedEvent type to shared package for recurring event instances - Implement EventController and EventService with full CRUD operations - Server-side recurring event expansion via recurrenceExpander - Calendar grid shows orange dot indicator for days with events - Tap on day opens modal overlay with EventCards - EventCard component with Feather icons (calendar, clock, repeat, edit, trash) - EventsStore with Zustand for client-side event state management - Load events for visible grid range including adjacent month days - Add textPrimary, borderPrimary, eventIndicator to theme - Update test responses for multiple events on Saturdays
This commit is contained in:
@@ -1,16 +1,74 @@
|
||||
import { Animated, Modal, Pressable, Text, View } from "react-native";
|
||||
import { DAYS, MONTHS, Month } from "@caldav/shared";
|
||||
import {
|
||||
Animated,
|
||||
Modal,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { DAYS, MONTHS, Month, ExpandedEvent } from "@caldav/shared";
|
||||
import Header from "../../components/Header";
|
||||
import { EventCard } from "../../components/EventCard";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import currentTheme from "../../Themes";
|
||||
import BaseBackground from "../../components/BaseBackground";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { EventService } from "../../services";
|
||||
import { useEventsStore } from "../../stores";
|
||||
|
||||
// TODO: month selection dropdown menu
|
||||
|
||||
const Calendar = () => {
|
||||
const [monthIndex, setMonthIndex] = useState(0);
|
||||
const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
|
||||
const { events, setEvents, deleteEvent } = useEventsStore();
|
||||
|
||||
// Load events when month/year changes
|
||||
// Include days from prev/next month that are visible in the grid
|
||||
useEffect(() => {
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
// Calculate first visible day (up to 6 days before month start)
|
||||
const firstOfMonth = new Date(currentYear, monthIndex, 1);
|
||||
const dayOfWeek = firstOfMonth.getDay();
|
||||
const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const startDate = new Date(
|
||||
currentYear,
|
||||
monthIndex,
|
||||
1 - daysFromPrevMonth,
|
||||
);
|
||||
|
||||
// Calculate last visible day (6 weeks * 7 days = 42 days total)
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(startDate.getDate() + 41);
|
||||
endDate.setHours(23, 59, 59);
|
||||
|
||||
const loadedEvents = await EventService.getByDateRange(
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
setEvents(loadedEvents);
|
||||
} catch (error) {
|
||||
console.error("Failed to load events:", error);
|
||||
}
|
||||
};
|
||||
loadEvents();
|
||||
}, [monthIndex, currentYear, setEvents]);
|
||||
|
||||
// Group events by date (YYYY-MM-DD format)
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map = new Map<string, ExpandedEvent[]>();
|
||||
events.forEach((e) => {
|
||||
const date = new Date(e.occurrenceStart);
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(e);
|
||||
});
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const changeMonth = (delta: number) => {
|
||||
setMonthIndex((prev) => {
|
||||
@@ -27,6 +85,55 @@ const Calendar = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDayPress = (date: Date, hasEvents: boolean) => {
|
||||
if (hasEvents) {
|
||||
setSelectedDate(date);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseOverlay = () => {
|
||||
setSelectedDate(null);
|
||||
};
|
||||
|
||||
const handleEditEvent = (event: ExpandedEvent) => {
|
||||
console.log("Edit event:", event.id);
|
||||
// TODO: Navigate to event edit screen
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (event: ExpandedEvent) => {
|
||||
Alert.alert("Event löschen", `"${event.title}" wirklich löschen?`, [
|
||||
{ text: "Abbrechen", style: "cancel" },
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await EventService.delete(event.id);
|
||||
deleteEvent(event.id);
|
||||
// Close overlay if no more events for this date
|
||||
if (selectedDate) {
|
||||
const dateKey = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}`;
|
||||
const remainingEvents = eventsByDate.get(dateKey) || [];
|
||||
if (remainingEvents.length <= 1) {
|
||||
setSelectedDate(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete event:", error);
|
||||
Alert.alert("Fehler", "Event konnte nicht gelöscht werden");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Get events for selected date
|
||||
const selectedDateEvents = useMemo(() => {
|
||||
if (!selectedDate) return [];
|
||||
const key = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}`;
|
||||
return eventsByDate.get(key) || [];
|
||||
}, [selectedDate, eventsByDate]);
|
||||
|
||||
return (
|
||||
<BaseBackground>
|
||||
<CalendarHeader
|
||||
@@ -35,11 +142,119 @@ const Calendar = () => {
|
||||
currentYear={currentYear}
|
||||
/>
|
||||
<WeekDaysLine />
|
||||
<CalendarGrid month={MONTHS[monthIndex]} year={currentYear} />
|
||||
<CalendarGrid
|
||||
month={MONTHS[monthIndex]}
|
||||
year={currentYear}
|
||||
eventsByDate={eventsByDate}
|
||||
onDayPress={handleDayPress}
|
||||
/>
|
||||
|
||||
{/* Event Overlay Modal */}
|
||||
<EventOverlay
|
||||
visible={selectedDate !== null}
|
||||
date={selectedDate}
|
||||
events={selectedDateEvents}
|
||||
onClose={handleCloseOverlay}
|
||||
onEditEvent={handleEditEvent}
|
||||
onDeleteEvent={handleDeleteEvent}
|
||||
/>
|
||||
</BaseBackground>
|
||||
);
|
||||
};
|
||||
|
||||
type EventOverlayProps = {
|
||||
visible: boolean;
|
||||
date: Date | null;
|
||||
events: ExpandedEvent[];
|
||||
onClose: () => void;
|
||||
onEditEvent: (event: ExpandedEvent) => void;
|
||||
onDeleteEvent: (event: ExpandedEvent) => void;
|
||||
};
|
||||
|
||||
const EventOverlay = ({
|
||||
visible,
|
||||
date,
|
||||
events,
|
||||
onClose,
|
||||
onEditEvent,
|
||||
onDeleteEvent,
|
||||
}: EventOverlayProps) => {
|
||||
if (!date) return null;
|
||||
|
||||
const dateString = date.toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
className="flex-1 justify-center items-center"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
className="w-11/12 max-h-3/4 rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: currentTheme.primeBg,
|
||||
borderWidth: 4,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
className="px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: currentTheme.chatBot,
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: currentTheme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold text-lg">{dateString}</Text>
|
||||
<Text>
|
||||
{events.length} {events.length === 1 ? "Termin" : "Termine"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Events List */}
|
||||
<ScrollView className="p-4" style={{ maxHeight: 400 }}>
|
||||
{events.map((event, index) => (
|
||||
<EventCard
|
||||
key={`${event.id}-${index}`}
|
||||
event={event}
|
||||
onEdit={() => onEditEvent(event)}
|
||||
onDelete={() => onDeleteEvent(event)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Close button */}
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
className="py-3 items-center"
|
||||
style={{
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: currentTheme.placeholderBg,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: currentTheme.primeFg }} className="font-bold">
|
||||
Schließen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type MonthSelectorProps = {
|
||||
modalVisible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -239,6 +454,8 @@ const WeekDaysLine = () => (
|
||||
type CalendarGridProps = {
|
||||
month: Month;
|
||||
year: number;
|
||||
eventsByDate: Map<string, ExpandedEvent[]>;
|
||||
onDayPress: (date: Date, hasEvents: boolean) => void;
|
||||
};
|
||||
|
||||
const CalendarGrid = (props: CalendarGridProps) => {
|
||||
@@ -256,6 +473,10 @@ const CalendarGrid = (props: CalendarGridProps) => {
|
||||
return date;
|
||||
};
|
||||
|
||||
const getDateKey = (date: Date): string => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
|
||||
@@ -268,13 +489,20 @@ const CalendarGrid = (props: CalendarGridProps) => {
|
||||
key={i}
|
||||
className="w-full flex-1 flex-row justify-around items-center gap-2"
|
||||
>
|
||||
{Array.from({ length: 7 }).map((_, j) => (
|
||||
<SingleDay
|
||||
key={j}
|
||||
date={createDateFromOffset(i * 7 + j - dateOffset)}
|
||||
month={props.month}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: 7 }).map((_, j) => {
|
||||
const date = createDateFromOffset(i * 7 + j - dateOffset);
|
||||
const dateKey = getDateKey(date);
|
||||
const hasEvents = props.eventsByDate.has(dateKey);
|
||||
return (
|
||||
<SingleDay
|
||||
key={j}
|
||||
date={date}
|
||||
month={props.month}
|
||||
hasEvents={hasEvents}
|
||||
onPress={() => props.onDayPress(date, hasEvents)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -284,14 +512,17 @@ const CalendarGrid = (props: CalendarGridProps) => {
|
||||
type SingleDayProps = {
|
||||
date: Date;
|
||||
month: Month;
|
||||
hasEvents: boolean;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const SingleDay = (props: SingleDayProps) => {
|
||||
const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="h-full flex-1 aspect-auto rounded-xl items-center"
|
||||
<Pressable
|
||||
onPress={props.onPress}
|
||||
className="h-full flex-1 aspect-auto rounded-xl items-center justify-between py-1"
|
||||
style={{
|
||||
backgroundColor: currentTheme.primeBg,
|
||||
}}
|
||||
@@ -301,7 +532,15 @@ const SingleDay = (props: SingleDayProps) => {
|
||||
>
|
||||
{props.date.getDate()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Event indicator dot */}
|
||||
{props.hasEvents && (
|
||||
<View
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: currentTheme.eventIndicator }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user