Compare commits

...

2 Commits

Author SHA1 Message Date
8e58ab4249 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
2026-01-05 19:27:33 +01:00
24ab6f0420 move help response to first position in test responses 2026-01-05 12:55:36 +01:00
5 changed files with 255 additions and 212 deletions

View File

@@ -77,9 +77,10 @@ src/
├── components/
│ ├── BaseBackground.tsx # Common screen wrapper
│ ├── Header.tsx # Header component
│ ├── EventCard.tsx # Event card for calendar display
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal
│ └── ProposedEventCard.tsx # Inline event proposal with confirm/reject buttons
│ └── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons)
├── Themes.tsx # Centralized color/theme definitions
├── services/
│ ├── index.ts # Re-exports all services
@@ -305,8 +306,9 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- `ApiClient`: get(), post(), put(), delete() implemented
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions
- `EventCard`: Displays event details (title, date, time, duration, recurring indicator) with Feather icons and edit/delete buttons
- `ProposedEventCard`: Displays proposed events (title, date, description, recurring indicator) with confirm/reject buttons
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions)
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
- `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches

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

@@ -21,7 +21,16 @@ let responseIndex = 0;
// Static test responses (event proposals)
const staticResponses: TestResponse[] = [
// {{{
// Response 0: Meeting mit Jens - next Friday 14:00
// Response 0: Help response (text only)
{
content:
"Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" +
'• "Erstelle einen Termin für morgen um 15 Uhr"\n' +
'• "Was habe ich nächste Woche vor?"\n' +
'• "Verschiebe das Meeting auf Donnerstag"\n\n' +
"Wie kann ich dir helfen?",
},
// Response 1: Meeting mit Jens - next Friday 14:00
{
content:
"Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
@@ -35,7 +44,7 @@ const staticResponses: TestResponse[] = [
},
},
},
// Response 1: Recurring event - every Saturday 10:00
// Response 2: Recurring event - every Saturday 10:00
{
content:
"Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
@@ -50,11 +59,11 @@ const staticResponses: TestResponse[] = [
},
},
},
// Response 2: 2-week overview (DYNAMIC - placeholder)
// Response 3: 2-week overview (DYNAMIC - placeholder)
{ content: "" },
// Response 3: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
// Response 4: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
{ content: "" },
// Response 4: Doctor appointment with description
// Response 5: Doctor appointment with description
{
content:
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
@@ -68,7 +77,7 @@ const staticResponses: TestResponse[] = [
},
},
},
// Response 5: Birthday - yearly recurring
// Response 6: Birthday - yearly recurring
{
content:
"Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:",
@@ -83,7 +92,7 @@ const staticResponses: TestResponse[] = [
},
},
},
// Response 6: Gym - recurring for 2 months (8 weeks)
// Response 7: Gym - recurring for 2 months (8 weeks)
{
content:
"Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:",
@@ -98,17 +107,8 @@ const staticResponses: TestResponse[] = [
},
},
},
// Response 7: 1-week overview (DYNAMIC - placeholder)
// Response 8: 1-week overview (DYNAMIC - placeholder)
{ content: "" },
// Response 8: Help response (text only)
{
content:
"Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" +
'• "Erstelle einen Termin für morgen um 15 Uhr"\n' +
'• "Was habe ich nächste Woche vor?"\n' +
'• "Verschiebe das Meeting auf Donnerstag"\n\n' +
"Wie kann ich dir helfen?",
},
// Response 9: Phone call - short appointment (Wednesday, so +2 days = Friday)
{
content:
@@ -165,28 +165,34 @@ async function getTestResponse(
const responseIdx = index % staticResponses.length;
// Dynamic responses: fetch events from DB and format
if (responseIdx === 2) {
if (responseIdx === 3) {
return { content: await getWeeksOverview(eventRepo, userId, 2) };
}
if (responseIdx === 3) {
if (responseIdx === 4) {
// Delete "Meeting mit Jens"
const events = await eventRepo.findByUserId(userId);
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,
},
},
};
}
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
}
if (responseIdx === 7) {
if (responseIdx === 8) {
return { content: await getWeeksOverview(eventRepo, userId, 1) };
}
@@ -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.";
}