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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user