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/ ├── components/
│ ├── BaseBackground.tsx # Common screen wrapper │ ├── BaseBackground.tsx # Common screen wrapper
│ ├── Header.tsx # Header component │ ├── 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 │ ├── 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 ├── Themes.tsx # Centralized color/theme definitions
├── services/ ├── services/
│ ├── index.ts # Re-exports all 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 - `ApiClient`: get(), post(), put(), delete() implemented
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully 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 - `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 - `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
- `ProposedEventCard`: Displays proposed events (title, date, description, recurring indicator) with confirm/reject buttons - `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 - `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
- `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches - `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 { ExpandedEvent } from "@caldav/shared";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import currentTheme from "../Themes"; import currentTheme from "../Themes";
import { EventCardBase } from "./EventCardBase";
type EventCardProps = { type EventCardProps = {
event: ExpandedEvent; event: ExpandedEvent;
@@ -9,117 +10,16 @@ type EventCardProps = {
onDelete: () => void; 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) => { export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
return ( return (
<View <View className="mb-3">
className="rounded-xl overflow-hidden mb-3" <EventCardBase
style={{ borderWidth: 2, borderColor: currentTheme.borderPrimary }} title={event.title}
startTime={event.occurrenceStart}
endTime={event.occurrenceEnd}
description={event.description}
isRecurring={event.isRecurring}
> >
{/* Header with title */}
<View
className="px-3 py-2"
style={{
backgroundColor: currentTheme.chatBot,
borderBottomWidth: 2,
borderBottomColor: currentTheme.borderPrimary,
}}
>
<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 */} {/* Action buttons */}
<View className="flex-row justify-end mt-3 gap-3"> <View className="flex-row justify-end mt-3 gap-3">
<Pressable <Pressable
@@ -147,7 +47,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
/> />
</Pressable> </Pressable>
</View> </View>
</View> </EventCardBase>
</View> </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 { View, Text, Pressable } from "react-native";
import { ProposedEventChange } from "@caldav/shared"; import { ProposedEventChange } from "@caldav/shared";
import currentTheme from "../Themes"; import currentTheme from "../Themes";
import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = { type ProposedEventCardProps = {
proposedChange: ProposedEventChange; proposedChange: ProposedEventChange;
@@ -9,52 +10,17 @@ type ProposedEventCardProps = {
onReject: () => void; onReject: () => void;
}; };
function formatDateTime(date?: Date): string { const ConfirmRejectButtons = ({
if (!date) return ""; isDisabled,
const d = new Date(date);
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export const ProposedEventCard = ({
proposedChange,
respondedAction, respondedAction,
onConfirm, onConfirm,
onReject, onReject,
}: ProposedEventCardProps) => { }: {
const event = proposedChange.event; isDisabled: boolean;
const isDisabled = !!respondedAction; respondedAction?: "confirm" | "reject";
onConfirm: () => void;
return ( onReject: () => void;
<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>
)}
{/* Buttons */}
<View className="flex-row mt-3 gap-2"> <View className="flex-row mt-3 gap-2">
<Pressable <Pressable
onPress={onConfirm} onPress={onConfirm}
@@ -68,10 +34,7 @@ export const ProposedEventCard = ({
borderColor: currentTheme.confirmButton, borderColor: currentTheme.confirmButton,
}} }}
> >
<Text <Text style={{ color: currentTheme.buttonText }} className="font-medium">
style={{ color: currentTheme.buttonText }}
className="font-medium"
>
Annehmen Annehmen
</Text> </Text>
</Pressable> </Pressable>
@@ -87,14 +50,43 @@ export const ProposedEventCard = ({
borderColor: currentTheme.rejectButton, borderColor: currentTheme.rejectButton,
}} }}
> >
<Text <Text style={{ color: currentTheme.buttonText }} className="font-medium">
style={{ color: currentTheme.buttonText }}
className="font-medium"
>
Ablehnen Ablehnen
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
);
export const ProposedEventCard = ({
proposedChange,
respondedAction,
onConfirm,
onReject,
}: ProposedEventCardProps) => {
const event = proposedChange.event;
const isDisabled = !!respondedAction;
if (!event) {
return null;
}
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>
); );
}; };

View File

@@ -21,7 +21,16 @@ let responseIndex = 0;
// Static test responses (event proposals) // Static test responses (event proposals)
const staticResponses: TestResponse[] = [ 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: content:
"Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:", "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: content:
"Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:", "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: "" }, { content: "" },
// Response 3: Delete "Meeting mit Jens" (DYNAMIC - placeholder) // Response 4: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
{ content: "" }, { content: "" },
// Response 4: Doctor appointment with description // Response 5: Doctor appointment with description
{ {
content: content:
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!", "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: content:
"Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:", "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: content:
"Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:", "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: "" }, { 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) // Response 9: Phone call - short appointment (Wednesday, so +2 days = Friday)
{ {
content: content:
@@ -165,28 +165,34 @@ async function getTestResponse(
const responseIdx = index % staticResponses.length; const responseIdx = index % staticResponses.length;
// Dynamic responses: fetch events from DB and format // Dynamic responses: fetch events from DB and format
if (responseIdx === 2) { if (responseIdx === 3) {
return { content: await getWeeksOverview(eventRepo, userId, 2) }; return { content: await getWeeksOverview(eventRepo, userId, 2) };
} }
if (responseIdx === 3) { if (responseIdx === 4) {
// Delete "Meeting mit Jens" // Delete "Meeting mit Jens"
const events = await eventRepo.findByUserId(userId); const events = await eventRepo.findByUserId(userId);
const jensEvent = events.find((e) => e.title === "Meeting mit Jens"); const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
if (jensEvent) { if (jensEvent) {
return { return {
content: content: "Soll ich diesen Termin wirklich löschen?",
"Alles klar, ich lösche den Termin 'Meeting mit Jens' für dich:",
proposedChange: { proposedChange: {
action: "delete", action: "delete",
eventId: jensEvent.id, 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." }; return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
} }
if (responseIdx === 7) { if (responseIdx === 8) {
return { content: await getWeeksOverview(eventRepo, userId, 1) }; return { content: await getWeeksOverview(eventRepo, userId, 1) };
} }
@@ -285,7 +291,9 @@ export class ChatService {
: "Termin nicht gefunden."; : "Termin nicht gefunden.";
} else if (action === "delete" && eventId) { } else if (action === "delete" && eventId) {
await this.eventRepo.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 { } else {
content = "Ungültige Aktion."; content = "Ungültige Aktion.";
} }