Add a unified event editor that works in two modes: - Calendar mode: Create/edit events directly via EventService API - Chat mode: Edit AI-proposed events before confirming them The chat mode allows users to modify proposed events (title, time, recurrence) and persists changes both locally and to the server. New components: DateTimePicker, ScrollableDropdown, useDropdownPosition New API: PUT /api/chat/messages/:messageId/proposal
655 lines
18 KiB
TypeScript
655 lines
18 KiB
TypeScript
import { Pressable, Text, View } from "react-native";
|
|
import {
|
|
DAYS,
|
|
MONTHS,
|
|
Month,
|
|
ExpandedEvent,
|
|
RecurringDeleteMode,
|
|
} from "@calchat/shared";
|
|
import Header from "../../components/Header";
|
|
import { EventCard } from "../../components/EventCard";
|
|
import { DeleteEventModal } from "../../components/DeleteEventModal";
|
|
import { ModalBase } from "../../components/ModalBase";
|
|
import { ScrollableDropdown } from "../../components/ScrollableDropdown";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { router, useFocusEffect } from "expo-router";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useThemeStore } from "../../stores/ThemeStore";
|
|
import BaseBackground from "../../components/BaseBackground";
|
|
import { EventService } from "../../services";
|
|
import { useEventsStore } from "../../stores";
|
|
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
|
|
|
|
// MonthSelector types and helpers
|
|
type MonthItem = {
|
|
id: string; // Format: "YYYY-MM"
|
|
year: number;
|
|
monthIndex: number; // 0-11
|
|
label: string; // e.g. "January 2024"
|
|
};
|
|
|
|
/**
|
|
* Formats a Date object to a string key in YYYY-MM-DD format.
|
|
* Used for grouping and looking up events by date.
|
|
*/
|
|
const getDateKey = (date: Date): string => {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
};
|
|
|
|
const generateMonths = (
|
|
centerYear: number,
|
|
centerMonth: number,
|
|
range: number,
|
|
): MonthItem[] => {
|
|
const months: MonthItem[] = [];
|
|
for (let offset = -range; offset <= range; offset++) {
|
|
let year = centerYear;
|
|
let month = centerMonth + offset;
|
|
|
|
while (month < 0) {
|
|
month += 12;
|
|
year--;
|
|
}
|
|
while (month > 11) {
|
|
month -= 12;
|
|
year++;
|
|
}
|
|
|
|
months.push({
|
|
id: `${year}-${String(month + 1).padStart(2, "0")}`,
|
|
year,
|
|
monthIndex: month,
|
|
label: `${MONTHS[month]} ${year}`,
|
|
});
|
|
}
|
|
return months;
|
|
};
|
|
|
|
const Calendar = () => {
|
|
const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
|
|
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
const [overlayVisible, setOverlayVisible] = useState(false);
|
|
|
|
// State for delete modal
|
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
const [eventToDelete, setEventToDelete] = useState<ExpandedEvent | null>(
|
|
null,
|
|
);
|
|
|
|
const { events, setEvents, deleteEvent } = useEventsStore();
|
|
|
|
// Function to load events for current view
|
|
const loadEvents = useCallback(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);
|
|
}
|
|
}, [monthIndex, currentYear, setEvents]);
|
|
|
|
// Load events when tab gains focus or month/year changes
|
|
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
|
|
// and useFocusEffect expects a sync function (optionally returning cleanup)
|
|
// Also re-open overlay if selectedDate exists (for back navigation from editEvent)
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
loadEvents();
|
|
if (selectedDate) {
|
|
setOverlayVisible(true);
|
|
}
|
|
}, [loadEvents, selectedDate]),
|
|
);
|
|
|
|
// Group events by date (YYYY-MM-DD format)
|
|
// Multi-day events are added to all days they span
|
|
const eventsByDate = useMemo(() => {
|
|
const map = new Map<string, ExpandedEvent[]>();
|
|
events.forEach((e) => {
|
|
const start = new Date(e.occurrenceStart);
|
|
const end = new Date(e.occurrenceEnd);
|
|
|
|
// Iterate through each day the event spans
|
|
const current = new Date(start);
|
|
current.setHours(0, 0, 0, 0);
|
|
while (current <= end) {
|
|
const key = getDateKey(current);
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(e);
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
});
|
|
return map;
|
|
}, [events]);
|
|
|
|
const changeMonth = (delta: number) => {
|
|
setMonthIndex((prev) => {
|
|
const newIndex = prev + delta;
|
|
if (newIndex > 11) {
|
|
setCurrentYear((y) => y + 1);
|
|
return 0;
|
|
}
|
|
if (newIndex < 0) {
|
|
setCurrentYear((y) => y - 1);
|
|
return 11;
|
|
}
|
|
return newIndex;
|
|
});
|
|
};
|
|
|
|
const handleDayPress = (date: Date) => {
|
|
setSelectedDate(date);
|
|
setOverlayVisible(true);
|
|
};
|
|
|
|
const handleCloseOverlay = () => {
|
|
setSelectedDate(null);
|
|
setOverlayVisible(false);
|
|
};
|
|
|
|
const handleCreateEvent = () => {
|
|
setOverlayVisible(false);
|
|
router.push({
|
|
pathname: "/editEvent",
|
|
params: { date: selectedDate?.toISOString() },
|
|
});
|
|
};
|
|
|
|
const handleEditEvent = (event?: ExpandedEvent) => {
|
|
router.push({
|
|
pathname: "/editEvent",
|
|
params: {
|
|
mode: "calendar",
|
|
id: event?.id,
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleDeleteEvent = (event: ExpandedEvent) => {
|
|
// Show delete modal for both recurring and non-recurring events
|
|
setEventToDelete(event);
|
|
setDeleteModalVisible(true);
|
|
};
|
|
|
|
const handleDeleteConfirm = async (mode: RecurringDeleteMode) => {
|
|
if (!eventToDelete) return;
|
|
|
|
setDeleteModalVisible(false);
|
|
const event = eventToDelete;
|
|
const occurrenceDate = getDateKey(new Date(event.occurrenceStart));
|
|
|
|
try {
|
|
if (event.isRecurring) {
|
|
// Recurring event: use mode and occurrenceDate
|
|
await EventService.delete(event.id, mode, occurrenceDate);
|
|
// Reload events to reflect changes
|
|
await loadEvents();
|
|
} else {
|
|
// Non-recurring event: simple delete
|
|
await EventService.delete(event.id);
|
|
deleteEvent(event.id);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to delete event:", error);
|
|
}
|
|
// Note: Don't clear eventToDelete here - it will be overwritten when opening a new modal.
|
|
// Clearing it during fade-out animation causes the modal content to flash from recurring to single.
|
|
};
|
|
|
|
const handleDeleteCancel = () => {
|
|
setDeleteModalVisible(false);
|
|
// Note: Don't clear eventToDelete - keeps modal content stable during fade-out animation
|
|
};
|
|
|
|
// Get events for selected date
|
|
const selectedDateEvents = useMemo(() => {
|
|
if (!selectedDate) return [];
|
|
const key = getDateKey(selectedDate);
|
|
return eventsByDate.get(key) || [];
|
|
}, [selectedDate, eventsByDate]);
|
|
|
|
return (
|
|
<BaseBackground>
|
|
<CalendarHeader
|
|
changeMonth={changeMonth}
|
|
monthIndex={monthIndex}
|
|
currentYear={currentYear}
|
|
setMonthIndex={setMonthIndex}
|
|
setYear={setCurrentYear}
|
|
/>
|
|
<WeekDaysLine />
|
|
<CalendarGrid
|
|
month={MONTHS[monthIndex]}
|
|
year={currentYear}
|
|
eventsByDate={eventsByDate}
|
|
onDayPress={handleDayPress}
|
|
/>
|
|
<EventOverlay
|
|
visible={overlayVisible && !deleteModalVisible}
|
|
date={selectedDate}
|
|
events={selectedDateEvents}
|
|
onClose={handleCloseOverlay}
|
|
onEditEvent={handleEditEvent}
|
|
onDeleteEvent={handleDeleteEvent}
|
|
onCreateEvent={handleCreateEvent}
|
|
/>
|
|
<DeleteEventModal
|
|
visible={deleteModalVisible}
|
|
eventTitle={eventToDelete?.title || ""}
|
|
isRecurring={eventToDelete?.isRecurring || false}
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={handleDeleteCancel}
|
|
/>
|
|
</BaseBackground>
|
|
);
|
|
};
|
|
|
|
type EventOverlayProps = {
|
|
visible: boolean;
|
|
date: Date | null;
|
|
events: ExpandedEvent[];
|
|
onClose: () => void;
|
|
onEditEvent: (event?: ExpandedEvent) => void;
|
|
onDeleteEvent: (event: ExpandedEvent) => void;
|
|
onCreateEvent: () => void;
|
|
};
|
|
|
|
const EventOverlay = ({
|
|
visible,
|
|
date,
|
|
events,
|
|
onClose,
|
|
onEditEvent,
|
|
onDeleteEvent,
|
|
onCreateEvent,
|
|
}: EventOverlayProps) => {
|
|
const { theme } = useThemeStore();
|
|
|
|
if (!date) return null;
|
|
|
|
const dateString = date.toLocaleDateString("de-DE", {
|
|
weekday: "long",
|
|
day: "2-digit",
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
|
|
const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`;
|
|
|
|
const addEventAttachment = (
|
|
<Pressable
|
|
className="flex flex-row justify-center items-center py-3"
|
|
style={{ backgroundColor: theme.confirmButton }}
|
|
onPress={onCreateEvent}
|
|
>
|
|
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
|
|
<Text style={{ color: theme.buttonText }} className="font-semibold ml-1">
|
|
Neuen Termin erstellen
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
|
|
return (
|
|
<ModalBase
|
|
visible={visible}
|
|
onClose={onClose}
|
|
title={dateString}
|
|
subtitle={subtitle}
|
|
attachment={addEventAttachment}
|
|
footer={{ label: "Schliessen", onPress: onClose }}
|
|
scrollable={true}
|
|
maxContentHeight={400}
|
|
>
|
|
{events.map((event, index) => (
|
|
<EventCard
|
|
key={`${event.id}-${index}`}
|
|
event={event}
|
|
onEdit={() => onEditEvent(event)}
|
|
onDelete={() => onDeleteEvent(event)}
|
|
/>
|
|
))}
|
|
</ModalBase>
|
|
);
|
|
};
|
|
|
|
type MonthSelectorProps = {
|
|
modalVisible: boolean;
|
|
onClose: () => void;
|
|
position: { top: number; left: number; width: number };
|
|
currentYear: number;
|
|
currentMonthIndex: number;
|
|
onSelectMonth: (year: number, monthIndex: number) => void;
|
|
};
|
|
|
|
const INITIAL_RANGE = 12; // 12 months before and after current
|
|
|
|
const MonthSelector = ({
|
|
modalVisible,
|
|
onClose,
|
|
position,
|
|
currentYear,
|
|
currentMonthIndex,
|
|
onSelectMonth,
|
|
}: MonthSelectorProps) => {
|
|
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
|
|
|
|
const appendMonths = useCallback(
|
|
(direction: "start" | "end", count: number) => {
|
|
setMonthSelectorData((prevData) => {
|
|
if (prevData.length === 0) return prevData;
|
|
|
|
const newMonths: MonthItem[] = [];
|
|
const referenceMonth =
|
|
direction === "start" ? prevData[0] : prevData[prevData.length - 1];
|
|
|
|
for (let i = 1; i <= count; i++) {
|
|
const offset = direction === "start" ? -i : i;
|
|
let year = referenceMonth.year;
|
|
let month = referenceMonth.monthIndex + offset;
|
|
|
|
while (month < 0) {
|
|
month += 12;
|
|
year--;
|
|
}
|
|
while (month > 11) {
|
|
month -= 12;
|
|
year++;
|
|
}
|
|
|
|
const newMonth: MonthItem = {
|
|
id: `${year}-${String(month + 1).padStart(2, "0")}`,
|
|
year,
|
|
monthIndex: month,
|
|
label: `${MONTHS[month]} ${year}`,
|
|
};
|
|
|
|
if (direction === "start") {
|
|
newMonths.unshift(newMonth);
|
|
} else {
|
|
newMonths.push(newMonth);
|
|
}
|
|
}
|
|
|
|
return direction === "start"
|
|
? [...newMonths, ...prevData]
|
|
: [...prevData, ...newMonths];
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Generate fresh data when modal opens, clear when closes
|
|
useEffect(() => {
|
|
if (modalVisible) {
|
|
setMonthSelectorData(
|
|
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
|
);
|
|
} else {
|
|
setMonthSelectorData([]);
|
|
}
|
|
}, [modalVisible, currentYear, currentMonthIndex]);
|
|
|
|
const handleSelect = useCallback(
|
|
(item: MonthItem) => {
|
|
onSelectMonth(item.year, item.monthIndex);
|
|
onClose();
|
|
},
|
|
[onSelectMonth, onClose],
|
|
);
|
|
|
|
return (
|
|
<ScrollableDropdown
|
|
visible={modalVisible}
|
|
onClose={onClose}
|
|
position={position}
|
|
data={monthSelectorData}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={(item, theme) => (
|
|
<View
|
|
className="w-full flex justify-center items-center py-2"
|
|
style={{
|
|
backgroundColor:
|
|
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
|
|
}}
|
|
>
|
|
<Text className="text-xl" style={{ color: theme.primeFg }}>
|
|
{item.label}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
onSelect={handleSelect}
|
|
height={200}
|
|
initialScrollIndex={INITIAL_RANGE}
|
|
onEndReached={() => appendMonths("end", 12)}
|
|
onStartReached={() => appendMonths("start", 12)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
type CalendarHeaderProps = {
|
|
changeMonth: (delta: number) => void;
|
|
monthIndex: number;
|
|
currentYear: number;
|
|
setMonthIndex: (index: number) => void;
|
|
setYear: (year: number) => void;
|
|
};
|
|
|
|
const CalendarHeader = (props: CalendarHeaderProps) => {
|
|
const { theme } = useThemeStore();
|
|
const dropdown = useDropdownPosition();
|
|
|
|
const prevMonth = () => props.changeMonth(-1);
|
|
const nextMonth = () => props.changeMonth(1);
|
|
|
|
return (
|
|
<Header className="flex flex-row items-center justify-between">
|
|
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
|
|
<View
|
|
ref={dropdown.ref}
|
|
className="relative flex flex-row items-center justify-around"
|
|
>
|
|
<Text className="text-4xl px-1" style={{ color: theme.textPrimary }}>
|
|
{MONTHS[props.monthIndex]} {props.currentYear}
|
|
</Text>
|
|
<Pressable
|
|
className="flex justify-center items-center w-12 h-12 border rounded-lg"
|
|
style={{
|
|
borderColor: theme.primeFg,
|
|
backgroundColor: theme.chatBot,
|
|
// iOS shadow
|
|
shadowColor: theme.shadowColor,
|
|
shadowOffset: { width: 0, height: 3 },
|
|
shadowOpacity: 0.35,
|
|
shadowRadius: 5,
|
|
// Android shadow
|
|
elevation: 6,
|
|
}}
|
|
onPress={dropdown.open}
|
|
>
|
|
<Ionicons name="chevron-down" size={28} color={theme.primeFg} />
|
|
</Pressable>
|
|
</View>
|
|
<MonthSelector
|
|
modalVisible={dropdown.visible}
|
|
onClose={dropdown.close}
|
|
position={dropdown.position}
|
|
currentYear={props.currentYear}
|
|
currentMonthIndex={props.monthIndex}
|
|
onSelectMonth={(year, month) => {
|
|
props.setYear(year);
|
|
props.setMonthIndex(month);
|
|
}}
|
|
/>
|
|
<ChangeMonthButton onPress={nextMonth} icon="chevron-forward" />
|
|
</Header>
|
|
);
|
|
};
|
|
|
|
type ChangeMonthButtonProps = {
|
|
onPress: () => void;
|
|
icon: "chevron-back" | "chevron-forward";
|
|
};
|
|
|
|
const ChangeMonthButton = (props: ChangeMonthButtonProps) => {
|
|
const { theme } = useThemeStore();
|
|
return (
|
|
<Pressable
|
|
onPress={props.onPress}
|
|
className="w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid"
|
|
style={{
|
|
backgroundColor: theme.chatBot,
|
|
borderColor: theme.primeFg,
|
|
// iOS shadow
|
|
shadowColor: theme.shadowColor,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 8,
|
|
// Android shadow
|
|
elevation: 6,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={props.icon}
|
|
size={48}
|
|
color={theme.primeFg}
|
|
style={{
|
|
marginLeft: props.icon === "chevron-forward" ? 4 : 0,
|
|
marginRight: props.icon === "chevron-back" ? 4 : 0,
|
|
}}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
const WeekDaysLine = () => {
|
|
const { theme } = useThemeStore();
|
|
return (
|
|
<View className="flex flex-row items-center justify-around px-2 gap-2">
|
|
{/* TODO: px and gap need fine tuning to perfectly align with the grid */}
|
|
{DAYS.map((day, i) => (
|
|
<Text key={i} style={{ color: theme.textPrimary }}>
|
|
{day.substring(0, 2).toUpperCase()}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type CalendarGridProps = {
|
|
month: Month;
|
|
year: number;
|
|
eventsByDate: Map<string, ExpandedEvent[]>;
|
|
onDayPress: (date: Date) => void;
|
|
};
|
|
|
|
const CalendarGrid = (props: CalendarGridProps) => {
|
|
const { theme } = useThemeStore();
|
|
const { baseDate, dateOffset } = useMemo(() => {
|
|
const monthIndex = MONTHS.indexOf(props.month);
|
|
const base = new Date(props.year, monthIndex, 1);
|
|
const offset = base.getDay() === 0 ? 6 : base.getDay() - 1;
|
|
return { baseDate: base, dateOffset: offset };
|
|
}, [props.month, props.year]);
|
|
|
|
// TODO: create array beforehand in a useMemo
|
|
const createDateFromOffset = (offset: number): Date => {
|
|
const date = new Date(baseDate);
|
|
date.setDate(date.getDate() + offset);
|
|
return date;
|
|
};
|
|
|
|
return (
|
|
<View
|
|
className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
|
|
style={{
|
|
backgroundColor: theme.calenderBg,
|
|
}}
|
|
>
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<View
|
|
key={i}
|
|
className="w-full flex-1 flex-row justify-around items-center gap-2"
|
|
>
|
|
{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)}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type SingleDayProps = {
|
|
date: Date;
|
|
month: Month;
|
|
hasEvents: boolean;
|
|
onPress: () => void;
|
|
};
|
|
|
|
const SingleDay = (props: SingleDayProps) => {
|
|
const { theme } = useThemeStore();
|
|
const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={props.onPress}
|
|
className="h-full flex-1 aspect-auto rounded-xl items-center justify-between py-1"
|
|
style={{
|
|
backgroundColor: theme.primeBg,
|
|
}}
|
|
>
|
|
<Text
|
|
className="text-xl"
|
|
style={{ color: theme.textPrimary, opacity: isSameMonth ? 1 : 0.5 }}
|
|
>
|
|
{props.date.getDate()}
|
|
</Text>
|
|
|
|
{/* Event indicator dot */}
|
|
{props.hasEvents && (
|
|
<View
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: theme.eventIndicator }}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
export default Calendar;
|