refactor: improve AI event handling and conflict display in chat

- AI fetches events on-demand via callbacks for better efficiency
- Add conflict detection with warning display when proposing overlapping events
- Improve event search and display in chat interface
- Load full chat history for display while limiting AI context
This commit is contained in:
2026-02-02 22:44:08 +01:00
parent 387bb2d1ee
commit 1092ff2648
19 changed files with 367 additions and 119 deletions

View File

@@ -22,9 +22,6 @@ import { EventService, ChatService } from "../services";
import { buildRRule, CreateEventDTO } from "@calchat/shared";
import { useChatStore } from "../stores";
// Direct store access for getting current state in callbacks
const getChatStoreState = () => useChatStore.getState();
type EditEventTextFieldProps = {
titel: string;
text?: string;
@@ -443,31 +440,25 @@ const EditEventScreen = () => {
: undefined,
};
// Chat mode: update proposal locally and on server
// Chat mode: update proposal on server and sync response to local store
if (mode === "chat" && proposalContext) {
try {
const context = JSON.parse(proposalContext) as ProposalContext;
// Update locally in ChatStore
const currentMessages = getChatStoreState().messages;
const message = currentMessages.find((m) => m.id === context.messageId);
if (message?.proposedChanges) {
const updatedProposals = message.proposedChanges.map((p) =>
p.id === context.proposalId ? { ...p, event: eventObject } : p,
);
updateMessage(context.messageId, {
proposedChanges: updatedProposals,
});
}
// Persist to server
await ChatService.updateProposalEvent(
// Persist to server - returns updated message with recalculated conflictingEvents
const updatedMessage = await ChatService.updateProposalEvent(
context.messageId,
context.proposalId,
eventObject,
);
// Update local ChatStore with server response (includes updated conflicts)
if (updatedMessage?.proposedChanges) {
updateMessage(context.messageId, {
proposedChanges: updatedMessage.proposedChanges,
});
}
router.back();
} catch (error) {
console.error("Failed to update proposal:", error);

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable } from "react-native";
import { Feather } from "@expo/vector-icons";
import { ProposedEventChange, formatDate } from "@calchat/shared";
import { Feather, Ionicons } from "@expo/vector-icons";
import { ProposedEventChange, formatDate, formatTime } from "@calchat/shared";
import { rrulestr } from "rrule";
import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase";
@@ -143,6 +143,29 @@ export const ProposedEventCard = ({
</Text>
</View>
)}
{/* Show conflicting events warning */}
{proposedChange.conflictingEvents &&
proposedChange.conflictingEvents.length > 0 && (
<View className="mb-2">
{proposedChange.conflictingEvents.map((conflict, index) => (
<View key={index} className="flex-row items-center mt-1">
<Ionicons
name="alert-circle"
size={16}
color={theme.rejectButton}
style={{ marginRight: 8 }}
/>
<Text
style={{ color: theme.rejectButton }}
className="text-sm flex-1"
>
Konflikt: {conflict.title} ({formatTime(conflict.startTime)}{" "}
- {formatTime(conflict.endTime)})
</Text>
</View>
))}
</View>
)}
<ActionButtons
isDisabled={isDisabled}
respondedAction={proposedChange.respondedAction}

View File

@@ -8,6 +8,11 @@ import {
ToolDefinition,
} from "./utils";
import { Logged } from "../logging";
import {
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
} from "openai/resources/chat/completions/completions";
/**
* Convert tool definitions to OpenAI format.
@@ -29,7 +34,7 @@ function toOpenAITools(
export class GPTAdapter implements AIProvider {
private client: OpenAI;
private model: string;
private tools: OpenAI.Chat.Completions.ChatCompletionTool[];
private tools: ChatCompletionTool[];
constructor(apiKey?: string, model: string = "gpt-5-mini") {
this.client = new OpenAI({
@@ -46,7 +51,7 @@ export class GPTAdapter implements AIProvider {
const systemPrompt = buildSystemPrompt(context);
// Build messages array with conversation history
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
const messages: ChatCompletionMessageParam[] = [
{ role: "developer", content: systemPrompt },
];
@@ -87,17 +92,21 @@ export class GPTAdapter implements AIProvider {
};
}
// Process tool calls
// Process all tool calls and collect results
const toolResults: Array<{
toolCall: ChatCompletionMessageToolCall;
content: string;
}> = [];
for (const toolCall of assistantMessage.tool_calls) {
// Skip non-function tool calls
if (toolCall.type !== "function") continue;
const { name, arguments: argsRaw } = toolCall.function;
const args = JSON.parse(argsRaw);
const result = executeToolCall(name, args, context);
const result = await executeToolCall(name, args, context);
// If the tool returned a proposedChange, add it to the array with unique ID
// Collect proposed changes
if (result.proposedChange) {
proposedChanges.push({
id: `proposal-${proposalIndex++}`,
@@ -105,17 +114,22 @@ export class GPTAdapter implements AIProvider {
});
}
// Add assistant message with tool call
messages.push({
role: "assistant",
tool_calls: [toolCall],
});
toolResults.push({ toolCall, content: result.content });
}
// Add tool result
// Add assistant message with ALL tool calls at once
messages.push({
role: "assistant",
tool_calls: assistantMessage.tool_calls,
content: assistantMessage.content,
});
// Add all tool results
for (const { toolCall, content } of toolResults) {
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result.content,
content,
});
}
}

View File

@@ -1,30 +1,4 @@
import {
CalendarEvent,
formatDate,
formatTime,
formatDateTime,
} from "@calchat/shared";
import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
// Re-export for backwards compatibility
// Re-export from shared package for use in toolExecutor
export { formatDate, formatTime, formatDateTime };
/**
* Format a list of events for display in the system prompt.
* Output is in German with date/time formatting.
*/
export function formatExistingEvents(events: CalendarEvent[]): string {
if (events.length === 0) {
return "Keine Termine vorhanden.";
}
return events
.map((e) => {
const start = new Date(e.startTime);
const end = new Date(e.endTime);
const timeStr = `${formatTime(start)} - ${formatTime(end)}`;
const recurring = e.isRecurring ? " (wiederkehrend)" : "";
const desc = e.description ? ` | ${e.description}` : "";
return `- ${e.title} (ID: ${e.id}) | ${formatDate(start)} ${timeStr}${recurring}${desc}`;
})
.join("\n");
}

View File

@@ -1,9 +1,4 @@
export {
formatExistingEvents,
formatDate,
formatTime,
formatDateTime,
} from "./eventFormatter";
export { formatDate, formatTime, formatDateTime } from "./eventFormatter";
export { buildSystemPrompt } from "./systemPrompt";
export {
TOOL_DEFINITIONS,

View File

@@ -1,5 +1,4 @@
import { AIContext } from "../../services/interfaces";
import { formatExistingEvents } from "./eventFormatter";
/**
* Build the system prompt for the AI assistant.
@@ -15,8 +14,6 @@ export function buildSystemPrompt(context: AIContext): string {
minute: "2-digit",
});
const eventsText = formatExistingEvents(context.existingEvents);
return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat".
Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen.
Antworte immer auf Deutsch.
@@ -29,8 +26,16 @@ Wichtige Regeln:
- Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID
- Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID
- Du kannst mehrere Event-Vorschläge in einer Antwort machen (z.B. für mehrere Termine auf einmal)
- Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst
- WICHTIG: Bei Terminen in der VERGANGENHEIT: Weise den Benutzer darauf hin und erstelle KEIN Event. Beispiel: "Das Datum liegt in der Vergangenheit. Meintest du vielleicht [nächstes Jahr]?"
- KRITISCH: Wenn ein Tool-Result eine ⚠️-Warnung enthält (z.B. "Zeitkonflikt mit..."), MUSST du diese dem Benutzer mitteilen! Ignoriere NIEMALS solche Warnungen! Beispiel: "An diesem Tag hast du bereits 'Jannes Geburtstag'. Soll ich den Termin trotzdem erstellen?"
WICHTIG - Event-Abfragen:
- Du hast KEINEN vorgeladenen Kalender-Kontext!
- Nutze IMMER getEventsInRange um Events zu laden, wenn der Benutzer nach Terminen fragt
- Nutze searchEvents um nach Terminen per Titel zu suchen (gibt auch die Event-ID zurück)
- Beispiel: "Was habe ich heute?" → getEventsInRange für heute
- Beispiel: "Was habe ich diese Woche?" → getEventsInRange für die Woche
- Beispiel: "Wann ist der Zahnarzt?" → searchEvents mit "Zahnarzt"
WICHTIG - Tool-Verwendung:
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen!
@@ -50,18 +55,21 @@ WICHTIG - Wiederkehrende Termine (RRULE):
- WICHTIG: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld!
WICHTIG - Antwortformat:
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal)
- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten
- Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
- Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
- Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:"
- Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:"
- Bei Übersichten: "Hier sind deine Termine:"
- WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform!
- Schlechte Beispiele: "Alles klar!" (zu unspezifisch), lange Listen mit Termin-Details im Text
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
Existierende Termine des Benutzers:
${eventsText}`;
WICHTIG - Unterscheide zwischen PROPOSALS und ABFRAGEN:
1. Bei PROPOSALS (proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent):
- Halte deine Textantworten SEHR KURZ (1-2 Sätze)
- Die Event-Details werden automatisch in Karten angezeigt
- Wiederhole NICHT die Details im Text
2. Bei ABFRAGEN (searchEvents, getEventsInRange, oder Fragen zu existierenden Terminen):
- Du MUSST die gefundenen Termine im Text nennen!
- Liste die relevanten Termine mit Titel, Datum und Uhrzeit auf
- NIEMALS Event-IDs dem Benutzer zeigen! Die IDs sind nur für dich intern
- Wenn keine Termine gefunden wurden, sage das explizit (z.B. "In diesem Zeitraum hast du keine Termine.")
- Beispiel: "Heute hast du: Zahnarzt um 10:00 Uhr, Meeting um 14:00 Uhr."`;
}

View File

@@ -179,4 +179,23 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
required: ["query"],
},
},
{
name: "getEventsInRange",
description:
"Load events from a specific date range. Use this when the user asks about a time period beyond the default 4 weeks (e.g., 'birthdays in the next 6 months', 'what do I have planned for summer').",
parameters: {
type: "object",
properties: {
startDate: {
type: "string",
description: "Start date as ISO string (YYYY-MM-DD)",
},
endDate: {
type: "string",
description: "End date as ISO string (YYYY-MM-DD)",
},
},
required: ["startDate", "endDate"],
},
},
];

View File

@@ -8,6 +8,18 @@ import {
import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
/**
* Check if two time ranges overlap.
*/
function hasTimeOverlap(
start1: Date,
end1: Date,
start2: Date,
end2: Date,
): boolean {
return start1 < end2 && end1 > start2;
}
/**
* Proposed change without ID - ID is added by GPTAdapter when collecting proposals
*/
@@ -24,12 +36,13 @@ export interface ToolResult {
/**
* Execute a tool call and return the result.
* This function is provider-agnostic and can be used with any LLM.
* Async to support tools that need to fetch data (e.g., getEventsInRange).
*/
export function executeToolCall(
export async function executeToolCall(
name: string,
args: Record<string, unknown>,
context: AIContext,
): ToolResult {
): Promise<ToolResult> {
switch (name) {
case "getDay": {
const date = getDay(
@@ -62,20 +75,52 @@ export function executeToolCall(
const dateStr = formatDate(event.startTime);
const startStr = formatTime(event.startTime);
const endStr = formatTime(event.endTime);
// Check for conflicts - fetch events for the specific day
const dayStart = new Date(event.startTime);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayStart.getDate() + 1);
const dayEvents = await context.fetchEventsInRange(dayStart, dayEnd);
// Use occurrenceStart/occurrenceEnd for expanded recurring events
const conflicts = dayEvents.filter((e) =>
hasTimeOverlap(
event.startTime,
event.endTime,
new Date(e.occurrenceStart),
new Date(e.occurrenceEnd),
),
);
// Build conflict warning if any
let conflictWarning = "";
if (conflicts.length > 0) {
const conflictNames = conflicts.map((c) => `"${c.title}"`).join(", ");
conflictWarning = `\n⚠ ACHTUNG: Zeitkonflikt mit ${conflictNames}!`;
}
return {
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr`,
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr${conflictWarning}`,
proposedChange: {
action: "create",
event,
conflictingEvents:
conflicts.length > 0
? conflicts.map((c) => ({
title: c.title,
startTime: new Date(c.occurrenceStart),
endTime: new Date(c.occurrenceEnd),
}))
: undefined,
},
};
}
case "proposeUpdateEvent": {
const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
const existingEvent = await context.fetchEventById(eventId);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
@@ -116,9 +161,7 @@ export function executeToolCall(
const eventId = args.eventId as string;
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
const occurrenceDate = args.occurrenceDate as string | undefined;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
const existingEvent = await context.fetchEventById(eventId);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
@@ -162,25 +205,46 @@ export function executeToolCall(
}
case "searchEvents": {
const query = (args.query as string).toLowerCase();
const matches = context.existingEvents.filter((e) =>
e.title.toLowerCase().includes(query),
);
const query = args.query as string;
const matches = await context.searchEvents(query);
if (matches.length === 0) {
return { content: `Keine Termine mit "${args.query}" gefunden.` };
return { content: `Keine Termine mit "${query}" gefunden.` };
}
const results = matches
.map((e) => {
const start = new Date(e.startTime);
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr`;
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
})
.join("\n");
return { content: `Gefundene Termine:\n${results}` };
}
case "getEventsInRange": {
const startDate = new Date(args.startDate as string);
const endDate = new Date(args.endDate as string);
const events = await context.fetchEventsInRange(startDate, endDate);
if (events.length === 0) {
return { content: "Keine Termine in diesem Zeitraum." };
}
const eventsText = events
.map((e) => {
const start = new Date(e.startTime);
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
})
.join("\n");
return {
content: `Termine von ${formatDate(startDate)} bis ${formatDate(endDate)}:\n${eventsText}`,
};
}
default:
return { content: `Unbekannte Funktion: ${name}` };
}

View File

@@ -96,8 +96,10 @@ app.post("/api/ai/test", async (req, res) => {
const result = await aiProvider.processMessage(message, {
userId: "test-user",
conversationHistory: [],
existingEvents: [],
currentDate: new Date(),
fetchEventsInRange: async () => [],
searchEvents: async () => [],
fetchEventById: async () => null,
});
res.json(result);
} catch (error) {

View File

@@ -4,7 +4,7 @@ import { createLogger } from "./logger";
* Summarize args for logging to avoid huge log entries.
* - Arrays: show length only
* - Long strings: truncate
* - Objects with conversationHistory/existingEvents: summarize
* - Objects with conversationHistory: summarize
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeArgs(args: any[]): any[] {
@@ -31,8 +31,6 @@ function summarizeValue(value: any, depth = 0): any {
for (const [key, val] of Object.entries(value)) {
if (key === "conversationHistory" && Array.isArray(val)) {
summarized[key] = `[${val.length} messages]`;
} else if (key === "existingEvents" && Array.isArray(val)) {
summarized[key] = `[${val.length} events]`;
} else if (key === "proposedChanges" && Array.isArray(val)) {
// Log full proposedChanges for debugging AI issues
summarized[key] = val.map((p) => summarizeValue(p, depth + 1));

View File

@@ -5,6 +5,7 @@ import {
CreateEventDTO,
GetMessagesOptions,
UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared";
import { ChatRepository } from "../../services/interfaces";
import { Logged } from "../../logging";
@@ -25,12 +26,20 @@ export class MongoChatRepository implements ChatRepository {
return conversation.toJSON() as unknown as Conversation;
}
async getConversationById(
conversationId: string,
): Promise<Conversation | null> {
const conversation = await ConversationModel.findById(conversationId);
return conversation
? (conversation.toJSON() as unknown as Conversation)
: null;
}
// Messages (cursor-based pagination)
async getMessages(
conversationId: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> {
const limit = options?.limit ?? 20;
const query: Record<string, unknown> = { conversationId };
// Cursor: load messages before this ID (for "load more" scrolling up)
@@ -39,9 +48,12 @@ export class MongoChatRepository implements ChatRepository {
}
// Fetch newest first, then reverse for chronological order
const docs = await ChatMessageModel.find(query)
.sort({ _id: -1 })
.limit(limit);
// Only apply limit if explicitly specified (no default - load all messages)
let queryBuilder = ChatMessageModel.find(query).sort({ _id: -1 });
if (options?.limit) {
queryBuilder = queryBuilder.limit(options.limit);
}
const docs = await queryBuilder;
return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage);
}
@@ -88,12 +100,28 @@ export class MongoChatRepository implements ChatRepository {
messageId: string,
proposalId: string,
event: CreateEventDTO,
conflictingEvents?: ConflictingEvent[],
): Promise<ChatMessage | null> {
// Always set both fields - use empty array when no conflicts
// (MongoDB has issues combining $set and $unset on positional operator)
const setFields: Record<string, unknown> = {
"proposedChanges.$.event": event,
"proposedChanges.$.conflictingEvents":
conflictingEvents && conflictingEvents.length > 0
? conflictingEvents
: [],
};
const doc = await ChatMessageModel.findOneAndUpdate(
{ _id: messageId, "proposedChanges.id": proposalId },
{ $set: { "proposedChanges.$.event": event } },
{ $set: setFields },
{ new: true },
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
async getMessageById(messageId: string): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findById(messageId);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
}

View File

@@ -28,6 +28,14 @@ export class MongoEventRepository implements EventRepository {
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
}
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
const events = await EventModel.find({
userId,
title: { $regex: query, $options: "i" },
}).sort({ startTime: 1 });
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
}
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
const event = await EventModel.create({ userId, ...data });
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field

View File

@@ -5,6 +5,7 @@ import {
CreateEventDTO,
UpdateEventDTO,
ProposedEventChange,
ConflictingEvent,
} from "@calchat/shared";
import { IdVirtual } from "./types";
@@ -41,6 +42,15 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
{ _id: false },
);
const ConflictingEventSchema = new Schema<ConflictingEvent>(
{
title: { type: String, required: true },
startTime: { type: Date, required: true },
endTime: { type: Date, required: true },
},
{ _id: false },
);
const ProposedChangeSchema = new Schema<ProposedEventChange>(
{
id: { type: String, required: true },
@@ -61,6 +71,7 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
enum: ["single", "future", "all"],
},
occurrenceDate: { type: String },
conflictingEvents: { type: [ConflictingEventSchema] },
},
{ _id: false },
);

View File

@@ -9,8 +9,8 @@ import {
CreateEventDTO,
UpdateEventDTO,
EventAction,
CreateMessageDTO,
RecurringDeleteMode,
ConflictingEvent,
} from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService";
@@ -570,7 +570,6 @@ export class ChatService {
responseIndex++;
} else {
// Production mode: use real AI
const events = await this.eventRepo.findByUserId(userId);
const history = await this.chatRepo.getMessages(conversationId, {
limit: 20,
});
@@ -578,8 +577,16 @@ export class ChatService {
response = await this.aiProvider.processMessage(data.content, {
userId,
conversationHistory: history,
existingEvents: events,
currentDate: new Date(),
fetchEventsInRange: async (start, end) => {
return this.eventService.getByDateRange(userId, start, end);
},
searchEvents: async (query) => {
return this.eventRepo.searchByTitle(userId, query);
},
fetchEventById: async (eventId) => {
return this.eventService.getById(eventId, userId);
},
});
}
@@ -713,6 +720,56 @@ export class ChatService {
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null> {
return this.chatRepo.updateProposalEvent(messageId, proposalId, event);
// Get the message to find the conversation
const message = await this.chatRepo.getMessageById(messageId);
if (!message) {
return null;
}
// Get the conversation to find the userId
const conversation = await this.chatRepo.getConversationById(
message.conversationId,
);
if (!conversation) {
return null;
}
const userId = conversation.userId;
// Get event times
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
// Get day range for conflict checking
const dayStart = new Date(eventStart);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayStart.getDate() + 1);
// Fetch events for the day
const dayEvents = await this.eventService.getByDateRange(
userId,
dayStart,
dayEnd,
);
// Check for time overlaps (use occurrenceStart/End for expanded recurring events)
const conflicts: ConflictingEvent[] = dayEvents
.filter(
(e) =>
new Date(e.occurrenceStart) < eventEnd &&
new Date(e.occurrenceEnd) > eventStart,
)
.map((e) => ({
title: e.title,
startTime: new Date(e.occurrenceStart),
endTime: new Date(e.occurrenceEnd),
}));
return this.chatRepo.updateProposalEvent(
messageId,
proposalId,
event,
conflicts.length > 0 ? conflicts : undefined,
);
}
}

View File

@@ -1,14 +1,21 @@
import {
CalendarEvent,
ChatMessage,
ProposedEventChange,
ExpandedEvent,
CalendarEvent,
} from "@calchat/shared";
export interface AIContext {
userId: string;
conversationHistory: ChatMessage[];
existingEvents: CalendarEvent[];
currentDate: Date;
// Callback to load events from a specific date range
// Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events
fetchEventsInRange: (startDate: Date, endDate: Date) => Promise<ExpandedEvent[]>;
// Callback to search events by title
searchEvents: (query: string) => Promise<CalendarEvent[]>;
// Callback to fetch a single event by ID
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
}
export interface AIResponse {

View File

@@ -5,11 +5,13 @@ import {
CreateEventDTO,
GetMessagesOptions,
UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared";
export interface ChatRepository {
// Conversations
getConversationsByUser(userId: string): Promise<Conversation[]>;
getConversationById(conversationId: string): Promise<Conversation | null>;
createConversation(userId: string): Promise<Conversation>;
// Messages (cursor-based pagination)
@@ -38,5 +40,8 @@ export interface ChatRepository {
messageId: string,
proposalId: string,
event: CreateEventDTO,
conflictingEvents?: ConflictingEvent[],
): Promise<ChatMessage | null>;
getMessageById(messageId: string): Promise<ChatMessage | null>;
}

View File

@@ -8,6 +8,7 @@ export interface EventRepository {
startDate: Date,
endDate: Date,
): Promise<CalendarEvent[]>;
searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>;
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
delete(id: string): Promise<boolean>;