diff --git a/.gitignore b/.gitignore index 3c3629e..559e515 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md index 0bd8d51..b52be64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,9 @@ src/ │ ├── BaseBackground.tsx # Common screen wrapper │ ├── Header.tsx # Header component │ ├── EventCard.tsx # Event card for calendar display -│ └── EventConfirmDialog.tsx # AI-proposed event confirmation modal +│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal +│ └── ProposedEventCard.tsx # Inline event proposal with confirm/reject buttons +├── Themes.tsx # Centralized color/theme definitions ├── services/ │ ├── index.ts # Re-exports all services │ ├── ApiClient.ts # HTTP client (get, post, put, delete) @@ -159,13 +161,17 @@ src/ ``` src/ ├── index.ts -└── models/ +├── models/ +│ ├── index.ts +│ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse +│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO +│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, +│ │ # GetMessagesOptions, ChatResponse, ConversationSummary, +│ │ # ProposedEventChange, EventAction +│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX +└── utils/ ├── index.ts - ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse - ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO - └── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, - # GetMessagesOptions, ChatResponse, ConversationSummary, - # ProposedEventChange, EventAction + └── dateHelpers.ts # getDay() - get date for specific weekday relative to today ``` **Key Types:** @@ -177,6 +183,8 @@ src/ - `CreateEventDTO`: Used for creating events AND for AI-proposed events - `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number` - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) +- `Day`: "Monday" | "Tuesday" | ... | "Sunday" +- `Month`: "January" | "February" | ... | "December" ### Database Abstraction @@ -230,24 +238,30 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - `utils/password`: hash(), compare() using bcrypt - `utils/jwt`: signToken() (verifyToken() pending) - `dotenv` integration for environment variables + - `ChatController`: sendMessage(), confirmEvent(), rejectEvent() + - `ChatService`: processMessage() with test responses, confirmEvent(), rejectEvent() - **Stubbed (TODO):** - `AuthMiddleware.authenticate()`: Currently uses fake user for testing - `AuthController`: refresh(), logout() - `AuthService`: refreshToken() - - All Chat and Event functionality + - `ChatController`: getConversations(), getConversation() + - `MongoChatRepository`: Database persistence for chat + - All Event functionality - **Not started:** - - `ChatController`, `ChatService`, `MongoChatRepository` - `EventController`, `EventService`, `MongoEventRepository` - - `ClaudeAdapter` (AI integration) + - `ClaudeAdapter` (AI integration - currently using test responses) -**Shared:** Types and DTOs defined and exported. +**Shared:** Types, DTOs, constants (Day, Month), and date utilities defined and exported. -**Frontend:** Skeleton complete with file-based routing structure: +**Frontend:** - Tab navigation (Chat, Calendar) implemented with basic UI - Calendar screen has month navigation and grid display (partially functional) -- Chat screen has message list UI with FlashList (mock data only) +- Chat screen functional with FlashList, message sending, and event confirm/reject +- `ApiClient`: get(), post() implemented +- `ChatService`: sendMessage(), confirmEvent(), rejectEvent() implemented +- `ProposedEventCard`: Displays proposed events with confirm/reject buttons, theming support +- `Themes.tsx`: Centralized color definitions including button colors - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons -- Services (ApiClient, AuthService, EventService, ChatService) defined with `throw new Error('Not implemented')` - Zustand stores (AuthStore, EventsStore) defined with `throw new Error('Not implemented')` - Components (EventCard, EventConfirmDialog) exist as skeletons diff --git a/apps/client/src/Themes.tsx b/apps/client/src/Themes.tsx index 8ef55fb..e98c1db 100644 --- a/apps/client/src/Themes.tsx +++ b/apps/client/src/Themes.tsx @@ -5,6 +5,12 @@ type Theme = { messageBorderBg: string, placeholderBg: string, calenderBg: string, + confirmButton: string, + rejectButton: string, + disabledButton: string, + buttonText: string, + textSecondary: string, + textMuted: string, } const defaultLight: Theme = { @@ -14,6 +20,12 @@ const defaultLight: Theme = { messageBorderBg: "#FFFFFF", placeholderBg: "#D9D9D9", calenderBg: "#FBD5B2", + confirmButton: "#22c55e", + rejectButton: "#ef4444", + disabledButton: "#ccc", + buttonText: "#fff", + textSecondary: "#666", + textMuted: "#888", } let currentTheme: Theme = defaultLight; diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index 7a7603d..45d9574 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -1,5 +1,5 @@ import { Animated, Modal, Pressable, Text, View } from "react-native"; -import { DAYS, MONTHS, Month } from "../../Constants"; +import { DAYS, MONTHS, Month } from "@caldav/shared"; import Header from "../../components/Header"; import React, { useEffect, useMemo, useRef, useState } from "react"; import currentTheme from "../../Themes"; diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index ff4684c..66b261b 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -1,9 +1,12 @@ -import { View, Text, TextInput } from "react-native"; +import { View, Text, TextInput, Pressable } from "react-native"; import currentTheme from "../../Themes"; import { useState } from "react"; import Header from "../../components/Header"; import BaseBackground from "../../components/BaseBackground"; import { FlashList } from "@shopify/flash-list"; +import { ChatService } from "../../services"; +import { ProposedEventChange } from "@caldav/shared"; +import { ProposedEventCard } from "../../components/ProposedEventCard"; // TODO: better shadows for everything // (maybe with extra library because of differences between android and ios) @@ -11,239 +14,91 @@ import { FlashList } from "@shopify/flash-list"; // TODO: create new messages type BubbleSide = "left" | "right"; + type ChatMessageProps = { side: BubbleSide; - width: number; - height: number; + content: string; + proposedChange?: ProposedEventChange; + respondedAction?: "confirm" | "reject"; + onConfirm?: () => void; + onReject?: () => void; }; -type MessageData = { +type MessageData = ChatMessageProps & { id: string; - side: BubbleSide; - width: number; - height: number; + conversationId?: string; }; -// NOTE: only for testing -const getRandomInt = (min: number, max: number) => { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(Math.random() * (max - min + 1)) + min; +type ChatInputProps = { + onSend: (text: string) => void; }; -const randomWidth = () => getRandomInt(100, 400); -const randomHeight = () => getRandomInt(50, 100); - -const messages: MessageData[] = [ - // {{{ - { - id: "1", - side: "left", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "2", - side: "right", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "3", - side: "left", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "4", - side: "right", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "5", - side: "left", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "6", - side: "right", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "7", - side: "left", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "8", - side: "right", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "9", - side: "left", - width: randomWidth(), - height: randomHeight(), - }, - { - id: "10", - side: "right", - width: randomWidth(), - height: randomHeight(), - }, - // { - // id: "11", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "12", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "13", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "14", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "15", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "16", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "17", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "18", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "19", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "20", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "21", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "22", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "23", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "24", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "25", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "26", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "27", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "28", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "29", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "30", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "31", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "32", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "33", - // side: "left", - // width: randomWidth(), - // height: randomHeight(), - // }, - // { - // id: "34", - // side: "right", - // width: randomWidth(), - // height: randomHeight(), - // }, - //, width: randomWidth, height: getRandomInt(50, 500) }}} -]; - const Chat = () => { + const [messages, setMessages] = useState([]); + + const handleEventResponse = async ( + action: "confirm" | "reject", + messageId: string, + conversationId: string + ) => { + // Mark message as responded (optimistic update) + setMessages((prev) => + prev.map((msg) => + msg.id === messageId ? { ...msg, respondedAction: action } : msg + ) + ); + + try { + const response = + action === "confirm" + ? await ChatService.confirmEvent(conversationId, messageId) + : await ChatService.rejectEvent(conversationId, messageId); + + const botMessage: MessageData = { + id: response.message.id, + side: "left", + content: response.message.content, + conversationId: response.conversationId, + }; + setMessages((prev) => [...prev, botMessage]); + } catch (error) { + console.error(`Failed to ${action} event:`, error); + // Revert on error + setMessages((prev) => + prev.map((msg) => + msg.id === messageId ? { ...msg, respondedAction: undefined } : msg + ) + ); + } + }; + + const handleSend = async (text: string) => { + // Show user message immediately + const userMessage: MessageData = { + id: Date.now().toString(), + side: "right", + content: text, + }; + setMessages((prev) => [...prev, userMessage]); + + try { + // Fetch server response + const response = await ChatService.sendMessage({ content: text }); + + // Show bot response + const botMessage: MessageData = { + id: response.message.id, + side: "left", + content: response.message.content, + proposedChange: response.message.proposedChange, + conversationId: response.conversationId, + }; + setMessages((prev) => [...prev, botMessage]); + } catch (error) { + console.error("Failed to send message:", error); + } + }; + return ( @@ -252,18 +107,20 @@ const Chat = () => { renderItem={({ item }) => ( + handleEventResponse("confirm", item.id, item.conversationId!) + } + onReject={() => + handleEventResponse("reject", item.id, item.conversationId!) + } /> )} - maintainVisibleContentPosition={{ - autoscrollToBottomThreshold: 0.2, - startRenderingFromBottom: true, - }} keyExtractor={(item) => item.id} - // extraData={selectedId} might need this later for re-rendering /> - + ); }; @@ -297,51 +154,81 @@ const ChatHeader = () => { ); }; -const ChatInput = () => { - const [text, onChangeText] = useState("Nachricht"); +const MIN_INPUT_HEIGHT = 40; +const MAX_INPUT_HEIGHT = 150; + +const ChatInput = ({ onSend }: ChatInputProps) => { + const [text, setText] = useState(""); + + const handleSend = () => { + if (text.trim()) { + onSend(text.trim()); + setText(""); + } + }; return ( - + - + + + ); }; -const ChatMessage = (props: ChatMessageProps) => { +const ChatMessage = ({ + side, + content, + proposedChange, + respondedAction, + onConfirm, + onReject, +}: ChatMessageProps) => { const borderColor = - props.side === "left" ? currentTheme.chatBot : currentTheme.primeFg; + side === "left" ? currentTheme.chatBot : currentTheme.primeFg; const selfSide = - props.side === "left" + side === "left" ? "self-start ml-2 rounded-bl-sm" : "self-end mr-2 rounded-br-sm"; return ( - Lorem Ipsum Dolor sit amet + {content} + + {proposedChange && onConfirm && onReject && ( + + )} ); }; diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx new file mode 100644 index 0000000..68efb8a --- /dev/null +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -0,0 +1,86 @@ +import { View, Text, Pressable } from "react-native"; +import { ProposedEventChange } from "@caldav/shared"; +import currentTheme from "../Themes"; + +type ProposedEventCardProps = { + proposedChange: ProposedEventChange; + respondedAction?: "confirm" | "reject"; + onConfirm: () => void; + 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", + }); +} + +export const ProposedEventCard = ({ + proposedChange, + respondedAction, + onConfirm, + onReject, +}: ProposedEventCardProps) => { + const event = proposedChange.event; + const isDisabled = !!respondedAction; + + return ( + + {/* Event Details */} + {event?.title} + + {formatDateTime(event?.startTime)} + + {event?.isRecurring && ( + + Wiederkehrend + + )} + + {/* Buttons */} + + + + Annehmen + + + + + Ablehnen + + + + + ); +}; diff --git a/apps/client/src/services/ApiClient.ts b/apps/client/src/services/ApiClient.ts index 19e56fe..8628f45 100644 --- a/apps/client/src/services/ApiClient.ts +++ b/apps/client/src/services/ApiClient.ts @@ -1,5 +1,5 @@ const API_BASE_URL = - process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000/api"; + process.env.EXPO_PUBLIC_API_URL || "http://localhost:3001/api"; type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; @@ -9,11 +9,24 @@ interface RequestOptions { } async function request( - _method: HttpMethod, - _endpoint: string, - _options?: RequestOptions, + method: HttpMethod, + endpoint: string, + options?: RequestOptions, ): Promise { - throw new Error("Not implemented"); + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return response.json(); } export const ApiClient = { diff --git a/apps/client/src/services/ChatService.ts b/apps/client/src/services/ChatService.ts index 7dda752..bafa44b 100644 --- a/apps/client/src/services/ChatService.ts +++ b/apps/client/src/services/ChatService.ts @@ -4,26 +4,26 @@ import { ChatMessage, ConversationSummary, GetMessagesOptions, - CalendarEvent, -} from '@caldav/shared'; +} from "@caldav/shared"; +import { ApiClient } from "./ApiClient"; export const ChatService = { - sendMessage: async (_data: SendMessageDTO): Promise => { - throw new Error('Not implemented'); + sendMessage: async (data: SendMessageDTO): Promise => { + return ApiClient.post("/chat/message", data); }, confirmEvent: async ( - _conversationId: string, - _messageId: string - ): Promise => { - throw new Error('Not implemented'); + conversationId: string, + messageId: string + ): Promise => { + return ApiClient.post(`/chat/confirm/${conversationId}/${messageId}`); }, rejectEvent: async ( - _conversationId: string, - _messageId: string - ): Promise => { - throw new Error('Not implemented'); + conversationId: string, + messageId: string + ): Promise => { + return ApiClient.post(`/chat/reject/${conversationId}/${messageId}`); }, getConversations: async (): Promise => { diff --git a/apps/server/docker/mongo/compose.yml b/apps/server/docker/mongo/compose.yml index 4afdf4a..bc3d9cb 100644 --- a/apps/server/docker/mongo/compose.yml +++ b/apps/server/docker/mongo/compose.yml @@ -19,7 +19,7 @@ services: image: mongo-express:latest restart: always ports: - - "8081:8081" + - "8083:8083" environment: ME_CONFIG_MONGODB_URL: mongodb://root:mongoose@mongo:27017/ ME_CONFIG_BASICAUTH_ENABLED: true diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b1a5ea4..d690e34 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -15,6 +15,21 @@ const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/caldav'; // Middleware app.use(express.json()); +// CORS - only needed for web browser development +// Native mobile apps don't send Origin headers and aren't affected by CORS +if (process.env.NODE_ENV !== 'production') { + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + if (req.method === 'OPTIONS') { + res.sendStatus(200); + return; + } + next(); + }); +} + // Initialize repositories const userRepo = new MongoUserRepository(); const eventRepo = new MongoEventRepository(); diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index aac7d78..2f0712e 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -1,4 +1,5 @@ import { Response } from 'express'; +import { SendMessageDTO } from '@caldav/shared'; import { ChatService } from '../services'; import { AuthenticatedRequest } from '../middleware'; @@ -6,15 +7,36 @@ export class ChatController { constructor(private chatService: ChatService) {} async sendMessage(req: AuthenticatedRequest, res: Response): Promise { - throw new Error('Not implemented'); + try { + const userId = req.user!.userId; + const data: SendMessageDTO = req.body; + const response = await this.chatService.processMessage(userId, data); + res.json(response); + } catch (error) { + res.status(500).json({ error: 'Failed to process message' }); + } } async confirmEvent(req: AuthenticatedRequest, res: Response): Promise { - throw new Error('Not implemented'); + try { + const userId = req.user!.userId; + const { conversationId, messageId } = req.params; + const response = await this.chatService.confirmEvent(userId, conversationId, messageId); + res.json(response); + } catch (error) { + res.status(500).json({ error: 'Failed to confirm event' }); + } } async rejectEvent(req: AuthenticatedRequest, res: Response): Promise { - throw new Error('Not implemented'); + try { + const userId = req.user!.userId; + const { conversationId, messageId } = req.params; + const response = await this.chatService.rejectEvent(userId, conversationId, messageId); + res.json(response); + } catch (error) { + res.status(500).json({ error: 'Failed to reject event' }); + } } async getConversations(req: AuthenticatedRequest, res: Response): Promise { diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 5adc9bd..e6b9a6e 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -1,6 +1,49 @@ -import { ChatMessage, ChatResponse, SendMessageDTO, CalendarEvent, ConversationSummary, GetMessagesOptions } from '@caldav/shared'; +import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay } from '@caldav/shared'; import { ChatRepository, EventRepository, AIProvider } from './interfaces'; +// Test responses array (cycles through responses) +let responseIndex = 0; + +const testResponses: Array<{ content: string; proposedChange?: ProposedEventChange }> = [ + // {{{ + // 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:", + proposedChange: { + action: 'create', + event: { + title: "Meeting mit Jens", + startTime: getDay('Friday', 1, 14, 0), + endTime: getDay('Friday', 1, 15, 0), + description: "Arbeitstreffen", + } + } + }, + // Response 2: Recurring event - every Saturday 10:00 + { + content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:", + proposedChange: { + action: 'create', + event: { + title: "Badezimmer putzen", + startTime: getDay('Saturday', 1, 10, 0), + endTime: getDay('Saturday', 1, 11, 0), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", + } + } + }, + // Response 3: Calendar overview (text only, no proposedChange) + { + content: "Hier sind deine Termine für die nächsten 2 Wochen:\n\n" + + "Freitag, 10.01. - 14:00 Uhr: Meeting mit Jens\n" + + "Samstag, 11.01. - 10:00 Uhr: Badezimmer putzen\n" + + "Samstag, 18.01. - 10:00 Uhr: Badezimmer putzen\n\n" + + "Insgesamt 3 Termine.", + }, + // }}} +]; + export class ChatService { constructor( private chatRepo: ChatRepository, @@ -9,15 +52,38 @@ export class ChatService { ) {} async processMessage(userId: string, data: SendMessageDTO): Promise { - throw new Error('Not implemented'); + const response = testResponses[responseIndex % testResponses.length]; + responseIndex++; + + const message: ChatMessage = { + id: Date.now().toString(), + conversationId: data.conversationId || 'temp-conv-id', + sender: 'assistant', + content: response.content, + proposedChange: response.proposedChange, + }; + + return { message, conversationId: message.conversationId }; } - async confirmEvent(userId: string, conversationId: string, messageId: string): Promise { - throw new Error('Not implemented'); + async confirmEvent(userId: string, conversationId: string, messageId: string): Promise { + const message: ChatMessage = { + id: Date.now().toString(), + conversationId, + sender: 'assistant', + content: 'Der Vorschlag wurde angenommen.', + }; + return { message, conversationId }; } - async rejectEvent(conversationId: string, messageId: string): Promise { - throw new Error('Not implemented'); + async rejectEvent(userId: string, conversationId: string, messageId: string): Promise { + const message: ChatMessage = { + id: Date.now().toString(), + conversationId, + sender: 'assistant', + content: 'Der Vorschlag wurde abgelehnt.', + }; + return { message, conversationId }; } async getConversations(userId: string): Promise { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e9644da..0e1c231 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,2 @@ export * from './models'; +export * from './utils'; diff --git a/apps/client/src/Constants.tsx b/packages/shared/src/models/Constants.ts similarity index 64% rename from apps/client/src/Constants.tsx rename to packages/shared/src/models/Constants.ts index 961ded4..a78ec3b 100644 --- a/apps/client/src/Constants.tsx +++ b/packages/shared/src/models/Constants.ts @@ -26,3 +26,14 @@ export const DAYS = [ ] as const; export type Day = (typeof DAYS)[number]; + +// Mapping for Date.getDay() which returns 0=Sunday, 1=Monday, etc. +export const DAY_INDEX: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}; diff --git a/packages/shared/src/models/index.ts b/packages/shared/src/models/index.ts index f00dc4c..f3c584f 100644 --- a/packages/shared/src/models/index.ts +++ b/packages/shared/src/models/index.ts @@ -1,3 +1,4 @@ export * from './User'; export * from './CalendarEvent'; export * from './ChatMessage'; +export * from './Constants'; diff --git a/packages/shared/src/utils/dateHelpers.ts b/packages/shared/src/utils/dateHelpers.ts new file mode 100644 index 0000000..17533a4 --- /dev/null +++ b/packages/shared/src/utils/dateHelpers.ts @@ -0,0 +1,31 @@ +import { Day, DAY_INDEX } from '../models/Constants'; + +/** + * Get a date for a specific weekday relative to today. + * @param day - The target day (e.g., "Friday") + * @param offset - 1 = next occurrence, 2 = the one after, -1 = last occurrence, etc. + * @param hour - Hour of day (0-23) + * @param minute - Minute (0-59) + */ +export function getDay(day: Day, offset: number, hour: number, minute: number): Date { + const today = new Date(); + const currentDay = today.getDay(); + const targetDay = DAY_INDEX[day]; + + let daysUntil = targetDay - currentDay; + + if (offset > 0) { + // Future: if target is today or past, move to next week + if (daysUntil <= 0) daysUntil += 7; + daysUntil += (offset - 1) * 7; + } else if (offset < 0) { + // Past: if target is today or future, move to last week + if (daysUntil >= 0) daysUntil -= 7; + daysUntil += (offset + 1) * 7; + } + + const result = new Date(today); + result.setDate(today.getDate() + daysUntil); + result.setHours(hour, minute, 0, 0); + return result; +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts new file mode 100644 index 0000000..159aee7 --- /dev/null +++ b/packages/shared/src/utils/index.ts @@ -0,0 +1 @@ +export * from './dateHelpers';