refactor: extract shared EventCardBase component

- Create EventCardBase with common layout, icons (calendar, clock, repeat), and formatting functions
- Refactor EventCard and ProposedEventCard to use EventCardBase
- Add event details to delete action responses for better UX
- Include event title in delete confirmation message
This commit is contained in:
2026-01-05 19:27:33 +01:00
parent 24ab6f0420
commit 8e58ab4249
5 changed files with 235 additions and 192 deletions

View File

@@ -1,7 +1,8 @@
import { View, Text, Pressable } from "react-native";
import { View, Pressable } from "react-native";
import { ExpandedEvent } from "@caldav/shared";
import { Feather } from "@expo/vector-icons";
import currentTheme from "../Themes";
import { EventCardBase } from "./EventCardBase";
type EventCardProps = {
event: ExpandedEvent;
@@ -9,117 +10,16 @@ type EventCardProps = {
onDelete: () => void;
};
function formatDate(date: Date): string {
const d = new Date(date);
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatTime(date: Date): string {
const d = new Date(date);
return d.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
}
function formatDuration(start: Date, end: Date): string {
const startDate = new Date(start);
const endDate = new Date(end);
const diffMs = endDate.getTime() - startDate.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
return `${diffMins} min`;
}
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (mins === 0) {
return hours === 1 ? "1 Std" : `${hours} Std`;
}
return `${hours} Std ${mins} min`;
}
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
return (
<View
className="rounded-xl overflow-hidden mb-3"
style={{ borderWidth: 2, borderColor: currentTheme.borderPrimary }}
>
{/* Header with title */}
<View
className="px-3 py-2"
style={{
backgroundColor: currentTheme.chatBot,
borderBottomWidth: 2,
borderBottomColor: currentTheme.borderPrimary,
}}
<View className="mb-3">
<EventCardBase
title={event.title}
startTime={event.occurrenceStart}
endTime={event.occurrenceEnd}
description={event.description}
isRecurring={event.isRecurring}
>
<Text className="font-bold text-base">{event.title}</Text>
</View>
{/* Content */}
<View className="px-3 py-2 bg-white">
{/* Date */}
<View className="flex-row items-center mb-1">
<Feather
name="calendar"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
{formatDate(event.occurrenceStart)}
</Text>
</View>
{/* Time with duration */}
<View className="flex-row items-center mb-1">
<Feather
name="clock"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
{formatTime(event.occurrenceStart)} -{" "}
{formatTime(event.occurrenceEnd)} (
{formatDuration(event.occurrenceStart, event.occurrenceEnd)})
</Text>
</View>
{/* Recurring indicator */}
{event.isRecurring && (
<View className="flex-row items-center mb-1">
<Feather
name="repeat"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
Wiederkehrend
</Text>
</View>
)}
{/* Description */}
{event.description && (
<Text
style={{ color: currentTheme.textPrimary }}
className="text-sm mt-1"
>
{event.description}
</Text>
)}
{/* Action buttons */}
<View className="flex-row justify-end mt-3 gap-3">
<Pressable
@@ -147,7 +47,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
/>
</Pressable>
</View>
</View>
</EventCardBase>
</View>
);
};

View File

@@ -0,0 +1,141 @@
import { View, Text } from "react-native";
import { Feather } from "@expo/vector-icons";
import { ReactNode } from "react";
import currentTheme from "../Themes";
type EventCardBaseProps = {
className?: string;
title: string;
startTime: Date;
endTime: Date;
description?: string;
isRecurring?: boolean;
children?: ReactNode;
};
function formatDate(date: Date): string {
const d = new Date(date);
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatTime(date: Date): string {
const d = new Date(date);
return d.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
}
function formatDuration(start: Date, end: Date): string {
const startDate = new Date(start);
const endDate = new Date(end);
const diffMs = endDate.getTime() - startDate.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
return `${diffMins} min`;
}
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (mins === 0) {
return hours === 1 ? "1 Std" : `${hours} Std`;
}
return `${hours} Std ${mins} min`;
}
export const EventCardBase = ({
className,
title,
startTime,
endTime,
description,
isRecurring,
children,
}: EventCardBaseProps) => {
return (
<View
className={`rounded-xl overflow-hidden ${className}`}
style={{ borderWidth: 2, borderColor: currentTheme.borderPrimary }}
>
{/* Header with title */}
<View
className="px-3 py-2"
style={{
backgroundColor: currentTheme.chatBot,
borderBottomWidth: 2,
borderBottomColor: currentTheme.borderPrimary,
}}
>
<Text className="font-bold text-base">{title}</Text>
</View>
{/* Content */}
<View className="px-3 py-2 bg-white">
{/* Date */}
<View className="flex-row items-center mb-1">
<Feather
name="calendar"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
{formatDate(startTime)}
</Text>
</View>
{/* Time with duration */}
<View className="flex-row items-center mb-1">
<Feather
name="clock"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)})
</Text>
</View>
{/* Recurring indicator */}
{isRecurring && (
<View className="flex-row items-center mb-1">
<Feather
name="repeat"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
Wiederkehrend
</Text>
</View>
)}
{/* Description */}
{description && (
<Text
style={{ color: currentTheme.textPrimary }}
className="text-sm mt-1"
>
{description}
</Text>
)}
{/* Action buttons slot */}
{children}
</View>
</View>
);
};
export default EventCardBase;

View File

@@ -1,6 +1,7 @@
import { View, Text, Pressable } from "react-native";
import { ProposedEventChange } from "@caldav/shared";
import currentTheme from "../Themes";
import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = {
proposedChange: ProposedEventChange;
@@ -9,17 +10,52 @@ type ProposedEventCardProps = {
onReject: () => void;
};
function formatDateTime(date?: Date): string {
if (!date) return "";
const d = new Date(date);
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
const ConfirmRejectButtons = ({
isDisabled,
respondedAction,
onConfirm,
onReject,
}: {
isDisabled: boolean;
respondedAction?: "confirm" | "reject";
onConfirm: () => void;
onReject: () => void;
}) => (
<View className="flex-row mt-3 gap-2">
<Pressable
onPress={onConfirm}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? currentTheme.disabledButton
: currentTheme.confirmButton,
borderWidth: respondedAction === "confirm" ? 2 : 0,
borderColor: currentTheme.confirmButton,
}}
>
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
Annehmen
</Text>
</Pressable>
<Pressable
onPress={onReject}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? currentTheme.disabledButton
: currentTheme.rejectButton,
borderWidth: respondedAction === "reject" ? 2 : 0,
borderColor: currentTheme.rejectButton,
}}
>
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
Ablehnen
</Text>
</Pressable>
</View>
);
export const ProposedEventCard = ({
proposedChange,
@@ -30,71 +66,27 @@ export const ProposedEventCard = ({
const event = proposedChange.event;
const isDisabled = !!respondedAction;
return (
<View
className="border-t p-2 mt-2"
style={{ borderTopColor: currentTheme.placeholderBg }}
>
{/* Event Details */}
<Text className="font-bold text-base">{event?.title}</Text>
<Text style={{ color: currentTheme.textSecondary }}>
{formatDateTime(event?.startTime)}
</Text>
{event?.description && (
<Text
style={{ color: currentTheme.textSecondary }}
className="text-sm mt-1"
>
{event.description}
</Text>
)}
{event?.isRecurring && (
<Text style={{ color: currentTheme.textMuted }} className="text-sm">
Wiederkehrend
</Text>
)}
if (!event) {
return null;
}
{/* Buttons */}
<View className="flex-row mt-3 gap-2">
<Pressable
onPress={onConfirm}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? currentTheme.disabledButton
: currentTheme.confirmButton,
borderWidth: respondedAction === "confirm" ? 2 : 0,
borderColor: currentTheme.confirmButton,
}}
>
<Text
style={{ color: currentTheme.buttonText }}
className="font-medium"
>
Annehmen
</Text>
</Pressable>
<Pressable
onPress={onReject}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? currentTheme.disabledButton
: currentTheme.rejectButton,
borderWidth: respondedAction === "reject" ? 2 : 0,
borderColor: currentTheme.rejectButton,
}}
>
<Text
style={{ color: currentTheme.buttonText }}
className="font-medium"
>
Ablehnen
</Text>
</Pressable>
</View>
return (
<View className="mt-2">
<EventCardBase
className="m-2"
title={event.title}
startTime={event.startTime}
endTime={event.endTime}
description={event.description}
isRecurring={event.isRecurring}
>
<ConfirmRejectButtons
isDisabled={isDisabled}
respondedAction={respondedAction}
onConfirm={onConfirm}
onReject={onReject}
/>
</EventCardBase>
</View>
);
};

View File

@@ -175,11 +175,17 @@ async function getTestResponse(
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
if (jensEvent) {
return {
content:
"Alles klar, ich lösche den Termin 'Meeting mit Jens' für dich:",
content: "Soll ich diesen Termin wirklich löschen?",
proposedChange: {
action: "delete",
eventId: jensEvent.id,
event: {
title: jensEvent.title,
startTime: jensEvent.startTime,
endTime: jensEvent.endTime,
description: jensEvent.description,
isRecurring: jensEvent.isRecurring,
},
},
};
}
@@ -285,7 +291,9 @@ export class ChatService {
: "Termin nicht gefunden.";
} else if (action === "delete" && eventId) {
await this.eventRepo.delete(eventId);
content = "Der Termin wurde gelöscht.";
content = event?.title
? `Der Termin "${event.title}" wurde gelöscht.`
: "Der Termin wurde gelöscht.";
} else {
content = "Ungültige Aktion.";
}