feat: add EditEventScreen with calendar and chat mode support

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
This commit is contained in:
2026-01-31 18:46:31 +01:00
parent 617543a603
commit 6f0d172bf2
33 changed files with 1394 additions and 289 deletions

View File

@@ -94,10 +94,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string",
description: "Optional event description",
},
isRecurring: {
type: "boolean",
description: "Whether this is a recurring event",
},
recurrenceRule: {
type: "string",
description: "RRULE format string for recurring events",
@@ -133,10 +129,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string",
description: "New description (optional). NEVER put RRULE here!",
},
isRecurring: {
type: "boolean",
description: "Whether this is a recurring event (optional)",
},
recurrenceRule: {
type: "string",
description:

View File

@@ -57,7 +57,6 @@ export function executeToolCall(
startTime: new Date(args.startTime as string),
endTime: new Date(args.endTime as string),
description: args.description as string | undefined,
isRecurring: args.isRecurring as boolean | undefined,
recurrenceRule: args.recurrenceRule as string | undefined,
};
const dateStr = formatDate(event.startTime);
@@ -88,8 +87,6 @@ export function executeToolCall(
updates.startTime = new Date(args.startTime as string);
if (args.endTime) updates.endTime = new Date(args.endTime as string);
if (args.description) updates.description = args.description;
if (args.isRecurring !== undefined)
updates.isRecurring = args.isRecurring;
if (args.recurrenceRule) updates.recurrenceRule = args.recurrenceRule;
// Build event object for display (merge existing with updates)
@@ -99,8 +96,6 @@ export function executeToolCall(
endTime: (updates.endTime as Date) || existingEvent.endTime,
description:
(updates.description as string) || existingEvent.description,
isRecurring:
(updates.isRecurring as boolean) ?? existingEvent.isRecurring,
recurrenceRule:
(updates.recurrenceRule as string) || existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates,
@@ -131,7 +126,7 @@ export function executeToolCall(
// Build descriptive content based on delete mode
let modeDescription = "";
if (existingEvent.isRecurring) {
if (existingEvent.recurrenceRule) {
switch (deleteMode) {
case "single":
modeDescription = " (nur dieses Vorkommen)";
@@ -155,12 +150,11 @@ export function executeToolCall(
startTime: existingEvent.startTime,
endTime: existingEvent.endTime,
description: existingEvent.description,
isRecurring: existingEvent.isRecurring,
recurrenceRule: existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates,
},
deleteMode: existingEvent.isRecurring ? deleteMode : undefined,
occurrenceDate: existingEvent.isRecurring
deleteMode: existingEvent.recurrenceRule ? deleteMode : undefined,
occurrenceDate: existingEvent.recurrenceRule
? occurrenceDate
: undefined,
},

View File

@@ -150,4 +150,33 @@ export class ChatController {
}
}
}
async updateProposalEvent(
req: AuthenticatedRequest,
res: Response,
): Promise<void> {
try {
const { messageId } = req.params;
const { proposalId, event } = req.body as {
proposalId: string;
event: CreateEventDTO;
};
const message = await this.chatService.updateProposalEvent(
messageId,
proposalId,
event,
);
if (message) {
res.json(message);
} else {
res.status(404).json({ error: "Message or proposal not found" });
}
} catch (error) {
log.error(
{ error, messageId: req.params.messageId },
"Error updating proposal event",
);
res.status(500).json({ error: "Failed to update proposal event" });
}
}
}

View File

@@ -2,6 +2,7 @@ import {
ChatMessage,
Conversation,
CreateMessageDTO,
CreateEventDTO,
GetMessagesOptions,
UpdateMessageDTO,
} from "@calchat/shared";
@@ -82,4 +83,17 @@ export class MongoChatRepository implements ChatRepository {
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
async updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findOneAndUpdate(
{ _id: messageId, "proposedChanges.id": proposalId },
{ $set: { "proposedChanges.$.event": event } },
{ new: true },
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
}

View File

@@ -23,7 +23,6 @@ const EventSchema = new Schema<CreateEventDTO>(
startTime: { type: Date, required: true },
endTime: { type: Date, required: true },
note: { type: String },
isRecurring: { type: Boolean },
recurrenceRule: { type: String },
exceptionDates: { type: [String] },
},
@@ -37,7 +36,6 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
startTime: { type: Date },
endTime: { type: Date },
note: { type: String },
isRecurring: { type: Boolean },
recurrenceRule: { type: String },
},
{ _id: false },

View File

@@ -2,16 +2,22 @@ import mongoose, { Schema, Document, Model } from "mongoose";
import { CalendarEvent } from "@calchat/shared";
import { IdVirtual } from "./types";
export interface EventDocument extends Omit<CalendarEvent, "id">, Document {
interface EventVirtuals extends IdVirtual {
isRecurring: boolean;
}
export interface EventDocument
extends Omit<CalendarEvent, "id" | "isRecurring">,
Document {
toJSON(): CalendarEvent;
}
const EventSchema = new Schema<
EventDocument,
Model<EventDocument, {}, {}, IdVirtual>,
Model<EventDocument, {}, {}, EventVirtuals>,
{},
{},
IdVirtual
EventVirtuals
>(
{
userId: {
@@ -39,10 +45,6 @@ const EventSchema = new Schema<
note: {
type: String,
},
isRecurring: {
type: Boolean,
default: false,
},
recurrenceRule: {
type: String,
},
@@ -59,6 +61,11 @@ const EventSchema = new Schema<
return this._id.toString();
},
},
isRecurring: {
get() {
return !!this.recurrenceRule;
},
},
},
toJSON: {
virtuals: true,

View File

@@ -19,6 +19,9 @@ export function createChatRoutes(chatController: ChatController): Router {
router.get("/conversations/:id", (req, res) =>
chatController.getConversation(req, res),
);
router.put("/messages/:messageId/proposal", (req, res) =>
chatController.updateProposalEvent(req, res),
);
return router;
}

View File

@@ -41,7 +41,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Wednesday", 1, 18, 0),
endTime: getDay("Wednesday", 1, 19, 30),
description: "Wöchentliches Training",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=WE",
},
},
@@ -158,7 +157,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Monday", 1, 7, 0),
endTime: getDay("Monday", 1, 8, 0),
description: "Morgen-Yoga",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
},
},
@@ -170,7 +168,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Tuesday", 1, 18, 0),
endTime: getDay("Tuesday", 1, 19, 0),
description: "Abendlauf im Park",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH",
},
},
@@ -215,7 +212,6 @@ const staticResponses: TestResponse[] = [
title: "Badezimmer putzen",
startTime: getDay("Saturday", 1, 10, 0),
endTime: getDay("Saturday", 1, 11, 0),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
},
},
@@ -255,7 +251,6 @@ const staticResponses: TestResponse[] = [
title: "Mamas Geburtstag",
startTime: getDay("Thursday", 2, 0, 0),
endTime: getDay("Thursday", 2, 23, 59),
isRecurring: true,
recurrenceRule: "FREQ=YEARLY",
},
},
@@ -273,7 +268,6 @@ const staticResponses: TestResponse[] = [
title: "Fitnessstudio Probetraining",
startTime: getDay("Tuesday", 1, 18, 0),
endTime: getDay("Tuesday", 1, 19, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
},
},
@@ -327,7 +321,6 @@ const staticResponses: TestResponse[] = [
title: "Spanischkurs VHS",
startTime: getDay("Thursday", 1, 19, 0),
endTime: getDay("Thursday", 1, 20, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8",
},
},
@@ -373,7 +366,6 @@ async function getTestResponse(
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates,
},
@@ -412,7 +404,6 @@ async function getTestResponse(
startTime: sportEvent.startTime,
endTime: sportEvent.endTime,
description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: newRule,
exceptionDates: sportEvent.exceptionDates,
},
@@ -452,7 +443,6 @@ async function getTestResponse(
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates,
},
@@ -488,7 +478,6 @@ async function getTestResponse(
startTime: jensEvent.startTime,
endTime: jensEvent.endTime,
description: jensEvent.description,
isRecurring: jensEvent.isRecurring,
},
},
],
@@ -718,4 +707,12 @@ export class ChatService {
return this.chatRepo.getMessages(conversationId, options);
}
async updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null> {
return this.chatRepo.updateProposalEvent(messageId, proposalId, event);
}
}

View File

@@ -2,6 +2,7 @@ import {
ChatMessage,
Conversation,
CreateMessageDTO,
CreateEventDTO,
GetMessagesOptions,
UpdateMessageDTO,
} from "@calchat/shared";
@@ -32,4 +33,10 @@ export interface ChatRepository {
proposalId: string,
respondedAction: "confirm" | "reject",
): Promise<ChatMessage | null>;
updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null>;
}

View File

@@ -44,9 +44,13 @@ export function expandRecurringEvents(
const endTime = new Date(event.endTime);
const duration = endTime.getTime() - startTime.getTime();
// For multi-day events: adjust range start back by event duration
// to find events that start before rangeStart but extend into the range
const adjustedRangeStart = new Date(rangeStart.getTime() - duration);
if (!event.isRecurring || !event.recurrenceRule) {
// Non-recurring event: add as-is if within range
if (startTime >= rangeStart && startTime <= rangeEnd) {
// Non-recurring event: add if it overlaps with the range
if (endTime >= rangeStart && startTime <= rangeEnd) {
expanded.push({
...event,
occurrenceStart: startTime,
@@ -64,9 +68,11 @@ export function expandRecurringEvents(
`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`,
);
// Get occurrences within the range (using fake UTC dates)
// Get occurrences within the adjusted range (using fake UTC dates)
// Use adjustedRangeStart to catch multi-day events that start before
// rangeStart but still extend into the range
const occurrences = rule.between(
toRRuleDate(rangeStart),
toRRuleDate(adjustedRangeStart),
toRRuleDate(rangeEnd),
true, // inclusive
);
@@ -78,6 +84,11 @@ export function expandRecurringEvents(
const occurrenceStart = fromRRuleDate(occurrence);
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
// Only include if occurrence actually overlaps with the original range
if (occurrenceEnd < rangeStart || occurrenceStart > rangeEnd) {
continue;
}
// Skip if this occurrence is in the exception dates
const dateKey = formatDateKey(occurrenceStart);
if (exceptionSet.has(dateKey)) {