implement chat messaging with event proposals
- Add functional chat with server communication and test responses - Add ProposedEventCard component for confirm/reject actions - Move Constants (Day, Month) from client to shared package - Add dateHelpers utility for weekday calculations - Extend Themes.tsx with button and text colors - Update CLAUDE.md with current implementation status - Add *.tsbuildinfo to .gitignore
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
42
CLAUDE.md
42
CLAUDE.md
@@ -78,7 +78,9 @@ src/
|
|||||||
│ ├── 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
|
│ ├── 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/
|
├── services/
|
||||||
│ ├── index.ts # Re-exports all services
|
│ ├── index.ts # Re-exports all services
|
||||||
│ ├── ApiClient.ts # HTTP client (get, post, put, delete)
|
│ ├── ApiClient.ts # HTTP client (get, post, put, delete)
|
||||||
@@ -159,13 +161,17 @@ src/
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── index.ts
|
├── 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
|
├── index.ts
|
||||||
├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse
|
└── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
||||||
├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO
|
|
||||||
└── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
|
|
||||||
# GetMessagesOptions, ChatResponse, ConversationSummary,
|
|
||||||
# ProposedEventChange, EventAction
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Types:**
|
**Key Types:**
|
||||||
@@ -177,6 +183,8 @@ src/
|
|||||||
- `CreateEventDTO`: Used for creating events AND for AI-proposed events
|
- `CreateEventDTO`: Used for creating events AND for AI-proposed events
|
||||||
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
||||||
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
||||||
|
- `Day`: "Monday" | "Tuesday" | ... | "Sunday"
|
||||||
|
- `Month`: "January" | "February" | ... | "December"
|
||||||
|
|
||||||
### Database Abstraction
|
### Database Abstraction
|
||||||
|
|
||||||
@@ -230,24 +238,30 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
|
|||||||
- `utils/password`: hash(), compare() using bcrypt
|
- `utils/password`: hash(), compare() using bcrypt
|
||||||
- `utils/jwt`: signToken() (verifyToken() pending)
|
- `utils/jwt`: signToken() (verifyToken() pending)
|
||||||
- `dotenv` integration for environment variables
|
- `dotenv` integration for environment variables
|
||||||
|
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
||||||
|
- `ChatService`: processMessage() with test responses, confirmEvent(), rejectEvent()
|
||||||
- **Stubbed (TODO):**
|
- **Stubbed (TODO):**
|
||||||
- `AuthMiddleware.authenticate()`: Currently uses fake user for testing
|
- `AuthMiddleware.authenticate()`: Currently uses fake user for testing
|
||||||
- `AuthController`: refresh(), logout()
|
- `AuthController`: refresh(), logout()
|
||||||
- `AuthService`: refreshToken()
|
- `AuthService`: refreshToken()
|
||||||
- All Chat and Event functionality
|
- `ChatController`: getConversations(), getConversation()
|
||||||
|
- `MongoChatRepository`: Database persistence for chat
|
||||||
|
- All Event functionality
|
||||||
- **Not started:**
|
- **Not started:**
|
||||||
- `ChatController`, `ChatService`, `MongoChatRepository`
|
|
||||||
- `EventController`, `EventService`, `MongoEventRepository`
|
- `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
|
- Tab navigation (Chat, Calendar) implemented with basic UI
|
||||||
- Calendar screen has month navigation and grid display (partially functional)
|
- 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
|
- 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')`
|
- Zustand stores (AuthStore, EventsStore) defined with `throw new Error('Not implemented')`
|
||||||
- Components (EventCard, EventConfirmDialog) exist as skeletons
|
- Components (EventCard, EventConfirmDialog) exist as skeletons
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ type Theme = {
|
|||||||
messageBorderBg: string,
|
messageBorderBg: string,
|
||||||
placeholderBg: string,
|
placeholderBg: string,
|
||||||
calenderBg: string,
|
calenderBg: string,
|
||||||
|
confirmButton: string,
|
||||||
|
rejectButton: string,
|
||||||
|
disabledButton: string,
|
||||||
|
buttonText: string,
|
||||||
|
textSecondary: string,
|
||||||
|
textMuted: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultLight: Theme = {
|
const defaultLight: Theme = {
|
||||||
@@ -14,6 +20,12 @@ const defaultLight: Theme = {
|
|||||||
messageBorderBg: "#FFFFFF",
|
messageBorderBg: "#FFFFFF",
|
||||||
placeholderBg: "#D9D9D9",
|
placeholderBg: "#D9D9D9",
|
||||||
calenderBg: "#FBD5B2",
|
calenderBg: "#FBD5B2",
|
||||||
|
confirmButton: "#22c55e",
|
||||||
|
rejectButton: "#ef4444",
|
||||||
|
disabledButton: "#ccc",
|
||||||
|
buttonText: "#fff",
|
||||||
|
textSecondary: "#666",
|
||||||
|
textMuted: "#888",
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentTheme: Theme = defaultLight;
|
let currentTheme: Theme = defaultLight;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Animated, Modal, Pressable, Text, View } from "react-native";
|
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 Header from "../../components/Header";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import currentTheme from "../../Themes";
|
import currentTheme from "../../Themes";
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { View, Text, TextInput } from "react-native";
|
import { View, Text, TextInput, Pressable } from "react-native";
|
||||||
import currentTheme from "../../Themes";
|
import currentTheme from "../../Themes";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Header from "../../components/Header";
|
import Header from "../../components/Header";
|
||||||
import BaseBackground from "../../components/BaseBackground";
|
import BaseBackground from "../../components/BaseBackground";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
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
|
// TODO: better shadows for everything
|
||||||
// (maybe with extra library because of differences between android and ios)
|
// (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
|
// TODO: create new messages
|
||||||
|
|
||||||
type BubbleSide = "left" | "right";
|
type BubbleSide = "left" | "right";
|
||||||
|
|
||||||
type ChatMessageProps = {
|
type ChatMessageProps = {
|
||||||
side: BubbleSide;
|
side: BubbleSide;
|
||||||
width: number;
|
content: string;
|
||||||
height: number;
|
proposedChange?: ProposedEventChange;
|
||||||
|
respondedAction?: "confirm" | "reject";
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onReject?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageData = {
|
type MessageData = ChatMessageProps & {
|
||||||
id: string;
|
id: string;
|
||||||
side: BubbleSide;
|
conversationId?: string;
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: only for testing
|
type ChatInputProps = {
|
||||||
const getRandomInt = (min: number, max: number) => {
|
onSend: (text: string) => void;
|
||||||
min = Math.ceil(min);
|
|
||||||
max = Math.floor(max);
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 Chat = () => {
|
||||||
|
const [messages, setMessages] = useState<MessageData[]>([]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<BaseBackground>
|
<BaseBackground>
|
||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
@@ -252,18 +107,20 @@ const Chat = () => {
|
|||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
side={item.side}
|
side={item.side}
|
||||||
width={item.width}
|
content={item.content}
|
||||||
height={item.height}
|
proposedChange={item.proposedChange}
|
||||||
|
respondedAction={item.respondedAction}
|
||||||
|
onConfirm={() =>
|
||||||
|
handleEventResponse("confirm", item.id, item.conversationId!)
|
||||||
|
}
|
||||||
|
onReject={() =>
|
||||||
|
handleEventResponse("reject", item.id, item.conversationId!)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
maintainVisibleContentPosition={{
|
|
||||||
autoscrollToBottomThreshold: 0.2,
|
|
||||||
startRenderingFromBottom: true,
|
|
||||||
}}
|
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
// extraData={selectedId} might need this later for re-rendering
|
|
||||||
/>
|
/>
|
||||||
<ChatInput />
|
<ChatInput onSend={handleSend} />
|
||||||
</BaseBackground>
|
</BaseBackground>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -297,51 +154,81 @@ const ChatHeader = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatInput = () => {
|
const MIN_INPUT_HEIGHT = 40;
|
||||||
const [text, onChangeText] = useState("Nachricht");
|
const MAX_INPUT_HEIGHT = 150;
|
||||||
|
|
||||||
|
const ChatInput = ({ onSend }: ChatInputProps) => {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (text.trim()) {
|
||||||
|
onSend(text.trim());
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row w-full h-8 my-2">
|
<View className="flex flex-row w-full items-end my-2 px-2">
|
||||||
<TextInput
|
<TextInput
|
||||||
className="w-4/5 h-full border border-solid rounded-2xl mx-2 px-2"
|
className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: currentTheme.messageBorderBg,
|
backgroundColor: currentTheme.messageBorderBg,
|
||||||
|
minHeight: MIN_INPUT_HEIGHT,
|
||||||
|
maxHeight: MAX_INPUT_HEIGHT,
|
||||||
|
textAlignVertical: "top",
|
||||||
}}
|
}}
|
||||||
onChangeText={onChangeText}
|
onChangeText={setText}
|
||||||
value={text}
|
value={text}
|
||||||
|
placeholder="Nachricht..."
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
multiline
|
||||||
/>
|
/>
|
||||||
|
<Pressable onPress={handleSend}>
|
||||||
<View
|
<View
|
||||||
className="w-8 h-full rounded-2xl"
|
className="w-10 h-10 rounded-full items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: currentTheme.placeholderBg,
|
backgroundColor: currentTheme.placeholderBg,
|
||||||
}}
|
}}
|
||||||
></View>
|
/>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatMessage = (props: ChatMessageProps) => {
|
const ChatMessage = ({
|
||||||
|
side,
|
||||||
|
content,
|
||||||
|
proposedChange,
|
||||||
|
respondedAction,
|
||||||
|
onConfirm,
|
||||||
|
onReject,
|
||||||
|
}: ChatMessageProps) => {
|
||||||
const borderColor =
|
const borderColor =
|
||||||
props.side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
|
side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
|
||||||
const selfSide =
|
const selfSide =
|
||||||
props.side === "left"
|
side === "left"
|
||||||
? "self-start ml-2 rounded-bl-sm"
|
? "self-start ml-2 rounded-bl-sm"
|
||||||
: "self-end mr-2 rounded-br-sm";
|
: "self-end mr-2 rounded-br-sm";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={
|
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
|
||||||
`bg-white border-2 border-solid rounded-xl my-2 ` + `${selfSide}`
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
width: props.width,
|
|
||||||
height: props.height,
|
|
||||||
borderColor: borderColor,
|
borderColor: borderColor,
|
||||||
|
maxWidth: "80%",
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="p-1">Lorem Ipsum Dolor sit amet</Text>
|
<Text className="p-2">{content}</Text>
|
||||||
|
|
||||||
|
{proposedChange && onConfirm && onReject && (
|
||||||
|
<ProposedEventCard
|
||||||
|
proposedChange={proposedChange}
|
||||||
|
respondedAction={respondedAction}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onReject={onReject}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
86
apps/client/src/components/ProposedEventCard.tsx
Normal file
86
apps/client/src/components/ProposedEventCard.tsx
Normal file
@@ -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 (
|
||||||
|
<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?.isRecurring && (
|
||||||
|
<Text style={{ color: currentTheme.textMuted }} className="text-sm">
|
||||||
|
Wiederkehrend
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View className="flex-row mt-3 gap-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={onConfirm}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="flex-1 py-2 rounded-lg items-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDisabled
|
||||||
|
? currentTheme.disabledButton
|
||||||
|
: currentTheme.confirmButton,
|
||||||
|
borderWidth: respondedAction === "confirm" ? 2 : 0,
|
||||||
|
borderColor: currentTheme.confirmButton,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
|
||||||
|
Annehmen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onReject}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="flex-1 py-2 rounded-lg items-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDisabled
|
||||||
|
? currentTheme.disabledButton
|
||||||
|
: currentTheme.rejectButton,
|
||||||
|
borderWidth: respondedAction === "reject" ? 2 : 0,
|
||||||
|
borderColor: currentTheme.rejectButton,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
|
||||||
|
Ablehnen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const API_BASE_URL =
|
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";
|
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
|
||||||
@@ -9,11 +9,24 @@ interface RequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
_method: HttpMethod,
|
method: HttpMethod,
|
||||||
_endpoint: string,
|
endpoint: string,
|
||||||
_options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
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 = {
|
export const ApiClient = {
|
||||||
|
|||||||
@@ -4,26 +4,26 @@ import {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
GetMessagesOptions,
|
GetMessagesOptions,
|
||||||
CalendarEvent,
|
} from "@caldav/shared";
|
||||||
} from '@caldav/shared';
|
import { ApiClient } from "./ApiClient";
|
||||||
|
|
||||||
export const ChatService = {
|
export const ChatService = {
|
||||||
sendMessage: async (_data: SendMessageDTO): Promise<ChatResponse> => {
|
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
|
||||||
throw new Error('Not implemented');
|
return ApiClient.post<ChatResponse>("/chat/message", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
confirmEvent: async (
|
confirmEvent: async (
|
||||||
_conversationId: string,
|
conversationId: string,
|
||||||
_messageId: string
|
messageId: string
|
||||||
): Promise<CalendarEvent> => {
|
): Promise<ChatResponse> => {
|
||||||
throw new Error('Not implemented');
|
return ApiClient.post<ChatResponse>(`/chat/confirm/${conversationId}/${messageId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
rejectEvent: async (
|
rejectEvent: async (
|
||||||
_conversationId: string,
|
conversationId: string,
|
||||||
_messageId: string
|
messageId: string
|
||||||
): Promise<void> => {
|
): Promise<ChatResponse> => {
|
||||||
throw new Error('Not implemented');
|
return ApiClient.post<ChatResponse>(`/chat/reject/${conversationId}/${messageId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
getConversations: async (): Promise<ConversationSummary[]> => {
|
getConversations: async (): Promise<ConversationSummary[]> => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
image: mongo-express:latest
|
image: mongo-express:latest
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "8083:8083"
|
||||||
environment:
|
environment:
|
||||||
ME_CONFIG_MONGODB_URL: mongodb://root:mongoose@mongo:27017/
|
ME_CONFIG_MONGODB_URL: mongodb://root:mongoose@mongo:27017/
|
||||||
ME_CONFIG_BASICAUTH_ENABLED: true
|
ME_CONFIG_BASICAUTH_ENABLED: true
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/caldav';
|
|||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
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
|
// Initialize repositories
|
||||||
const userRepo = new MongoUserRepository();
|
const userRepo = new MongoUserRepository();
|
||||||
const eventRepo = new MongoEventRepository();
|
const eventRepo = new MongoEventRepository();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { SendMessageDTO } from '@caldav/shared';
|
||||||
import { ChatService } from '../services';
|
import { ChatService } from '../services';
|
||||||
import { AuthenticatedRequest } from '../middleware';
|
import { AuthenticatedRequest } from '../middleware';
|
||||||
|
|
||||||
@@ -6,15 +7,36 @@ export class ChatController {
|
|||||||
constructor(private chatService: ChatService) {}
|
constructor(private chatService: ChatService) {}
|
||||||
|
|
||||||
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
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<void> {
|
async confirmEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
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<void> {
|
async rejectEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
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<void> {
|
async getConversations(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
|||||||
@@ -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';
|
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 {
|
export class ChatService {
|
||||||
constructor(
|
constructor(
|
||||||
private chatRepo: ChatRepository,
|
private chatRepo: ChatRepository,
|
||||||
@@ -9,15 +52,38 @@ export class ChatService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> {
|
async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> {
|
||||||
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<CalendarEvent> {
|
async confirmEvent(userId: string, conversationId: string, messageId: string): Promise<ChatResponse> {
|
||||||
throw new Error('Not implemented');
|
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<void> {
|
async rejectEvent(userId: string, conversationId: string, messageId: string): Promise<ChatResponse> {
|
||||||
throw new Error('Not implemented');
|
const message: ChatMessage = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
conversationId,
|
||||||
|
sender: 'assistant',
|
||||||
|
content: 'Der Vorschlag wurde abgelehnt.',
|
||||||
|
};
|
||||||
|
return { message, conversationId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConversations(userId: string): Promise<ConversationSummary[]> {
|
async getConversations(userId: string): Promise<ConversationSummary[]> {
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './models';
|
export * from './models';
|
||||||
|
export * from './utils';
|
||||||
|
|||||||
@@ -26,3 +26,14 @@ export const DAYS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Day = (typeof DAYS)[number];
|
export type Day = (typeof DAYS)[number];
|
||||||
|
|
||||||
|
// Mapping for Date.getDay() which returns 0=Sunday, 1=Monday, etc.
|
||||||
|
export const DAY_INDEX: Record<Day, number> = {
|
||||||
|
Sunday: 0,
|
||||||
|
Monday: 1,
|
||||||
|
Tuesday: 2,
|
||||||
|
Wednesday: 3,
|
||||||
|
Thursday: 4,
|
||||||
|
Friday: 5,
|
||||||
|
Saturday: 6,
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './User';
|
export * from './User';
|
||||||
export * from './CalendarEvent';
|
export * from './CalendarEvent';
|
||||||
export * from './ChatMessage';
|
export * from './ChatMessage';
|
||||||
|
export * from './Constants';
|
||||||
|
|||||||
31
packages/shared/src/utils/dateHelpers.ts
Normal file
31
packages/shared/src/utils/dateHelpers.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
1
packages/shared/src/utils/index.ts
Normal file
1
packages/shared/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './dateHelpers';
|
||||||
Reference in New Issue
Block a user