From 0ecbf69ebc6938bec39e2b56883de2ba4ea074bd Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:00:08 +0100 Subject: [PATCH] feat(contacts): integrate contacts into Todo and Calendar apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ContactSelector, ContactBadge, ContactAvatar to shared-ui - Add ContactsClient API service to shared-auth - Add ContactReference, ContactSummary types to shared-types - Todo: Add assignee and involvedContacts to tasks with UI in TaskEditModal - Todo: Display contacts in TaskItem and KanbanTaskCard - Calendar: Add AttendeeSelector with RSVP status support - Calendar: Integrate attendees in EventForm - Calendar: Add task drag-drop to calendar views (Day/Week/MultiDay) - Contacts: Add ContactTasks component to show related tasks - Backend: Add findByContact endpoint to Todo task service - UI polish: glassmorphism styling, keyboard navigation, auto-focus 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/calendar/apps/web/package.json | 1 + .../lib/components/calendar/DayView.svelte | 317 +++++++- .../components/calendar/MultiDayView.svelte | 402 +++++++++- .../lib/components/calendar/TaskBlock.svelte | 297 ++++++++ .../calendar/TodoSidebarSection.svelte | 3 + .../lib/components/calendar/WeekView.svelte | 425 ++++++++++- .../components/event/AttendeeSelector.svelte | 266 +++++++ .../src/lib/components/event/EventForm.svelte | 21 + .../components/todo/TodoDetailModal.svelte | 153 +++- .../src/lib/components/todo/TodoItem.svelte | 30 + .../apps/web/src/lib/composables/index.ts | 8 + .../lib/composables/useTaskDragDrop.svelte.ts | 306 ++++++++ .../apps/web/src/lib/i18n/locales/de.json | 25 + .../apps/web/src/lib/i18n/locales/en.json | 25 + .../web/src/lib/stores/contacts.svelte.ts | 175 +++++ .../apps/web/src/lib/stores/todos.svelte.ts | 85 ++- .../packages/shared/src/types/event.ts | 13 +- apps/contacts/apps/web/src/lib/api/todos.ts | 232 ++++++ .../lib/components/ContactDetailModal.svelte | 4 + .../src/lib/components/ContactTasks.svelte | 515 +++++++++++++ .../apps/web/src/lib/i18n/locales/de.json | 17 +- .../apps/web/src/lib/i18n/locales/en.json | 17 +- .../apps/web/src/lib/stores/todos.svelte.ts | 209 +++++ .../backend/src/db/schema/tasks.schema.ts | 7 + .../backend/src/task/dto/create-task.dto.ts | 25 + .../backend/src/task/dto/update-task.dto.ts | 30 + .../apps/backend/src/task/task.controller.ts | 14 + .../apps/backend/src/task/task.service.ts | 45 +- apps/todo/apps/web/package.json | 1 + .../src/lib/components/TaskEditModal.svelte | 63 ++ .../web/src/lib/components/TaskItem.svelte | 80 ++ .../web/src/lib/components/TaskList.svelte | 21 +- .../components/kanban/KanbanTaskCard.svelte | 81 +- .../web/src/lib/stores/contacts.svelte.ts | 175 +++++ apps/todo/packages/shared/package.json | 3 + apps/todo/packages/shared/src/types/task.ts | 20 + .../foundation-layer-improvements.md | 418 ++++++++++ packages/shared-auth/package.json | 1 + .../shared-auth/src/clients/contactsClient.ts | 204 +++++ packages/shared-auth/src/index.ts | 4 + packages/shared-types/src/contact.ts | 80 ++ packages/shared-types/src/index.ts | 3 + packages/shared-ui/package.json | 1 + packages/shared-ui/src/index.ts | 3 + .../molecules/contacts/ContactAvatar.svelte | 100 +++ .../molecules/contacts/ContactBadge.svelte | 185 +++++ .../molecules/contacts/ContactSelector.svelte | 711 ++++++++++++++++++ .../shared-ui/src/molecules/contacts/index.ts | 4 + packages/shared-ui/src/molecules/index.ts | 3 + pnpm-lock.yaml | 16 + 50 files changed, 5791 insertions(+), 53 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/components/calendar/TaskBlock.svelte create mode 100644 apps/calendar/apps/web/src/lib/components/event/AttendeeSelector.svelte create mode 100644 apps/calendar/apps/web/src/lib/composables/index.ts create mode 100644 apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts create mode 100644 apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts create mode 100644 apps/contacts/apps/web/src/lib/api/todos.ts create mode 100644 apps/contacts/apps/web/src/lib/components/ContactTasks.svelte create mode 100644 apps/contacts/apps/web/src/lib/stores/todos.svelte.ts create mode 100644 apps/todo/apps/web/src/lib/stores/contacts.svelte.ts create mode 100644 docs/optimizable/foundation-layer-improvements.md create mode 100644 packages/shared-auth/src/clients/contactsClient.ts create mode 100644 packages/shared-types/src/contact.ts create mode 100644 packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte create mode 100644 packages/shared-ui/src/molecules/contacts/ContactBadge.svelte create mode 100644 packages/shared-ui/src/molecules/contacts/ContactSelector.svelte create mode 100644 packages/shared-ui/src/molecules/contacts/index.ts diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index 535a04307..dfd8724e3 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -31,6 +31,7 @@ "dependencies": { "@calendar/shared": "workspace:*", "@manacore/shared-auth": "workspace:*", + "@manacore/shared-types": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index aaf62dd22..52c9f88bb 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -3,8 +3,9 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import { todosStore } from '$lib/stores/todos.svelte'; + import { todosStore, type Task } from '$lib/stores/todos.svelte'; import TodoRow from './TodoRow.svelte'; + import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { format, @@ -16,12 +17,15 @@ setMinutes, } from 'date-fns'; import { de } from 'date-fns/locale'; + import type { CalendarEvent } from '../../../../../../packages/shared/src/types/event'; interface Props { onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onEventClick?: (event: CalendarEvent) => void; + onTaskClick?: (task: Task) => void; } - let { onQuickCreate }: Props = $props(); + let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); // Constants const HOUR_HEIGHT = 60; // pixels per hour @@ -107,6 +111,21 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // ============================================================================ + // Task Drag & Drop State + // ============================================================================ + let isTaskDragging = $state(false); + let draggedTask = $state(null); + let taskDragPreviewTop = $state(0); + let taskDragPreviewHeight = $state(0); + + // Task Resize State + let isTaskResizing = $state(false); + let resizeTask = $state(null); + let taskResizeEdge = $state<'top' | 'bottom'>('bottom'); + let taskResizePreviewTop = $state(0); + let taskResizePreviewHeight = $state(0); + // ============================================================================ // Helper Functions // ============================================================================ @@ -321,13 +340,238 @@ document.removeEventListener('pointerup', handleDragEnd); document.removeEventListener('pointermove', handleResizeMove); document.removeEventListener('pointerup', handleResizeEnd); + // Task cleanup + isTaskDragging = false; + draggedTask = null; + isTaskResizing = false; + resizeTask = null; + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + } + + // ============================================================================ + // Task Drag & Drop + // ============================================================================ + function handleTaskDragStart(task: Task, e: PointerEvent) { + e.preventDefault(); + isTaskDragging = true; + draggedTask = task; + hasMoved = false; + + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskDragMove); + document.addEventListener('pointerup', handleTaskDragEnd); + } + + function handleTaskDragMove(e: PointerEvent) { + if (!isTaskDragging || !draggedTask || !dayColumnRef) return; + hasMoved = true; + + const rect = dayColumnRef.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + } + + async function handleTaskDragEnd() { + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + + if (!isTaskDragging || !draggedTask || !hasMoved) { + isTaskDragging = false; + draggedTask = null; + return; + } + + const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60); + const totalMinutes = firstVisibleHour * 60 + minutesFromStart; + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + const duration = draggedTask.estimatedDuration || 30; + const endTotalMinutes = totalMinutes + duration; + const endHours = Math.floor(endTotalMinutes / 60); + const endMins = Math.round(endTotalMinutes % 60); + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(draggedTask.id, { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + }); + + isTaskDragging = false; + draggedTask = null; + hasMoved = false; + } + + // ============================================================================ + // Task Resize + // ============================================================================ + function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + isTaskResizing = true; + resizeTask = task; + taskResizeEdge = edge; + hasMoved = false; + + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskResizeMove); + document.addEventListener('pointerup', handleTaskResizeEnd); + } + + function handleTaskResizeMove(e: PointerEvent) { + if (!isTaskResizing || !resizeTask || !dayColumnRef) return; + hasMoved = true; + + const rect = dayColumnRef.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + + if (taskResizeEdge === 'top') { + const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop); + } else { + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop); + } + } + + async function handleTaskResizeEnd() { + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + + if (!isTaskResizing || !resizeTask || !hasMoved) { + isTaskResizing = false; + resizeTask = null; + return; + } + + const startMinutes = + (taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + const endMinutes = + ((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) + + firstVisibleHour * 60; + + const startHours = Math.floor(startMinutes / 60); + const startMins = Math.round(startMinutes % 60); + const endHours = Math.floor(endMinutes / 60); + const endMins = Math.round(endMinutes % 60); + + const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newDuration = Math.round(endMinutes - startMinutes); + + await todosStore.updateTodo(resizeTask.id, { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + estimatedDuration: newDuration, + }); + + isTaskResizing = false; + resizeTask = null; + hasMoved = false; + } + + // ============================================================================ + // Sidebar Task Drop + // ============================================================================ + let isSidebarDropTarget = $state(false); + + function handleSidebarDragOver(e: DragEvent) { + e.preventDefault(); + if (!e.dataTransfer) return; + const types = e.dataTransfer.types; + if (!types.includes('application/json')) return; + e.dataTransfer.dropEffect = 'move'; + isSidebarDropTarget = true; + } + + function handleSidebarDragLeave(e: DragEvent) { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('.day-column')) { + isSidebarDropTarget = false; + } + } + + async function handleSidebarDrop(e: DragEvent) { + e.preventDefault(); + isSidebarDropTarget = false; + + if (!e.dataTransfer || !dayColumnRef) return; + + const jsonData = e.dataTransfer.getData('application/json'); + if (!jsonData) return; + + try { + const data = JSON.parse(jsonData); + if (data.type !== 'sidebar-task') return; + + const rect = dayColumnRef.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + const totalMinutes = firstVisibleHour * 60 + snappedMinutes; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + const duration = data.estimatedDuration || 30; + const endMinutes = totalMinutes + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(data.taskId, { + scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'), + scheduledStartTime: startTime, + scheduledEndTime: endTime, + estimatedDuration: duration, + }); + } catch (err) { + console.error('Failed to parse drop data:', err); + } } // ============================================================================ // Keyboard Handling // ============================================================================ function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && (isDragging || isResizing)) { + if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) { e.preventDefault(); cleanup(); } @@ -358,7 +602,36 @@ return `top: ${top}%; height: ${height}%; background-color: ${color};`; } - function handleEventClick(event: any, e: MouseEvent) { + /** + * Get style for a scheduled task (time-blocking) + */ + function getTaskStyle(task: Task): string { + if (!task.scheduledStartTime) return ''; + + const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = startHour * 60 + startMin; + + let duration = task.estimatedDuration || 30; + if (task.scheduledEndTime) { + const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number); + const endMinutes = endHour * 60 + endMin; + duration = endMinutes - startMinutes; + } + + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5); + + return `top: ${top}%; height: ${height}%;`; + } + + /** + * Get scheduled tasks for current day + */ + function getScheduledTasks(): Task[] { + return todosStore.getScheduledTasksForDay(viewStore.currentDate); + } + + function handleEventClick(event: CalendarEvent, e: MouseEvent) { // Don't navigate if dragging or resizing, or if we moved if (isDragging || isResizing || hasMoved) { e.preventDefault(); @@ -427,7 +700,16 @@ {/each} -
+ +
{#each hours as hour} + +
+ + {task.scheduledStartTime || ''} + {#if task.scheduledEndTime} + - {task.scheduledEndTime} + {/if} + + {task.title} +
+ + + {#if onResizeStart && !task.isCompleted} +
+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte index 2d2f28899..c747e3d72 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte @@ -27,6 +27,8 @@ // Fetch todos on mount await todosStore.fetchTodayTodos(); await todosStore.fetchUpcomingTodos(); + // Also fetch scheduled todos (including completed) for calendar display + await todosStore.fetchScheduledTodos(); }); function toggleExpanded() { @@ -114,6 +116,7 @@ {task} variant="compact" showProject={false} + draggable={!task.isCompleted} onclick={() => handleTaskClick(task)} /> {/each} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index 4b21db563..e58095e56 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -3,8 +3,9 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import { todosStore } from '$lib/stores/todos.svelte'; + import { todosStore, type Task } from '$lib/stores/todos.svelte'; import TodoRow from './TodoRow.svelte'; + import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { format, @@ -25,9 +26,11 @@ interface Props { onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onEventClick?: (event: CalendarEvent) => void; + onTaskClick?: (task: Task) => void; } - let { onQuickCreate }: Props = $props(); + let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); // Constants const HOUR_HEIGHT = 60; // px - should match CSS --hour-height @@ -112,6 +115,20 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // Task Drag & Drop State + let isTaskDragging = $state(false); + let draggedTask = $state(null); + let taskDragTargetDay = $state(null); + let taskDragPreviewTop = $state(0); + let taskDragPreviewHeight = $state(0); + + // Task Resize State + let isTaskResizing = $state(false); + let resizeTask = $state(null); + let taskResizeEdge = $state<'top' | 'bottom'>('bottom'); + let taskResizePreviewTop = $state(0); + let taskResizePreviewHeight = $state(0); + // Reference to the days container for position calculations let daysContainerEl: HTMLDivElement; @@ -157,6 +174,37 @@ return `top: ${top}%; height: ${height}%; background-color: ${color};`; } + /** + * Get style for a scheduled task (time-blocking) + */ + function getTaskStyle(task: Task): string { + if (!task.scheduledStartTime) return ''; + + // Parse HH:mm time + const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = startHour * 60 + startMin; + + // Calculate duration - use estimatedDuration or scheduledEndTime or default 30 min + let duration = task.estimatedDuration || 30; + if (task.scheduledEndTime) { + const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number); + const endMinutes = endHour * 60 + endMin; + duration = endMinutes - startMinutes; + } + + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); + + return `top: ${top}%; height: ${height}%;`; + } + + /** + * Get scheduled tasks for a specific day + */ + function getScheduledTasksForDay(day: Date): Task[] { + return todosStore.getScheduledTasksForDay(day); + } + function formatEventTime(date: Date | string): string { const d = typeof date === 'string' ? parseISO(date) : date; return settingsStore.formatTime(d); @@ -439,6 +487,263 @@ hasMoved = false; } + // ========== Task Drag & Drop ========== + + function handleTaskDragStart(task: Task, e: PointerEvent) { + e.preventDefault(); + isTaskDragging = true; + draggedTask = task; + hasMoved = false; + + // Initialize preview position + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskDragMove); + document.addEventListener('pointerup', handleTaskDragEnd); + } + + function handleTaskDragMove(e: PointerEvent) { + if (!isTaskDragging || !draggedTask) return; + hasMoved = true; + + // Find which day column we're over + const daysEl = daysContainerEl; + if (!daysEl) return; + + const dayColumns = daysEl.querySelectorAll('.day-column'); + for (let i = 0; i < dayColumns.length; i++) { + const col = dayColumns[i]; + const rect = col.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right) { + taskDragTargetDay = days[i]; + break; + } + } + + // Calculate vertical position + const targetColumn = daysEl.querySelector('.day-column'); + if (!targetColumn) return; + const rect = targetColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + // Snap to 15-minute intervals + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + } + + async function handleTaskDragEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + + if (!isTaskDragging || !draggedTask || !hasMoved) { + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + return; + } + + // Calculate new time from position + const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60); + const totalMinutes = firstVisibleHour * 60 + minutesFromStart; + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + // Calculate end time based on duration + const duration = draggedTask.estimatedDuration || 30; + const endTotalMinutes = totalMinutes + duration; + const endHours = Math.floor(endTotalMinutes / 60); + const endMins = Math.round(endTotalMinutes % 60); + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(draggedTask.id, { + scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined, + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + }); + + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + hasMoved = false; + } + + // ========== Task Resize ========== + + function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + isTaskResizing = true; + resizeTask = task; + taskResizeEdge = edge; + hasMoved = false; + + // Initialize preview position + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskResizeMove); + document.addEventListener('pointerup', handleTaskResizeEnd); + } + + function handleTaskResizeMove(e: PointerEvent) { + if (!isTaskResizing || !resizeTask) return; + hasMoved = true; + + const daysEl = daysContainerEl; + if (!daysEl) return; + + const targetColumn = daysEl.querySelector('.day-column'); + if (!targetColumn) return; + + const rect = targetColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + + if (taskResizeEdge === 'top') { + // Adjust start time, keep end fixed + const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop); + } else { + // Adjust end time, keep start fixed + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop); + } + } + + async function handleTaskResizeEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + + if (!isTaskResizing || !resizeTask || !hasMoved) { + isTaskResizing = false; + resizeTask = null; + return; + } + + // Calculate new times from position + const startMinutes = + (taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + const endMinutes = + ((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) + + firstVisibleHour * 60; + + const startHours = Math.floor(startMinutes / 60); + const startMins = Math.round(startMinutes % 60); + const endHours = Math.floor(endMinutes / 60); + const endMins = Math.round(endMinutes % 60); + + const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newDuration = Math.round(endMinutes - startMinutes); + + await todosStore.updateTodo(resizeTask.id, { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + estimatedDuration: newDuration, + }); + + isTaskResizing = false; + resizeTask = null; + hasMoved = false; + } + + // ========== Sidebar Task Drop ========== + let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null); + + function handleSidebarDragOver(e: DragEvent, day: Date) { + e.preventDefault(); + if (!e.dataTransfer) return; + + // Check if this is a sidebar task drag + const types = e.dataTransfer.types; + if (!types.includes('application/json')) return; + + e.dataTransfer.dropEffect = 'move'; + sidebarDropTarget = { day, y: e.clientY }; + } + + function handleSidebarDragLeave(e: DragEvent) { + // Only clear if leaving the column entirely + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('.day-column')) { + sidebarDropTarget = null; + } + } + + async function handleSidebarDrop(e: DragEvent, day: Date) { + e.preventDefault(); + sidebarDropTarget = null; + + if (!e.dataTransfer) return; + + const jsonData = e.dataTransfer.getData('application/json'); + if (!jsonData) return; + + try { + const data = JSON.parse(jsonData); + if (data.type !== 'sidebar-task') return; + + // Calculate drop time from Y position + const dayColumn = (e.target as HTMLElement).closest('.day-column'); + if (!dayColumn) return; + + const rect = dayColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + const totalMinutes = firstVisibleHour * 60 + snappedMinutes; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + // Calculate end time + const duration = data.estimatedDuration || 30; + const endMinutes = totalMinutes + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + // Update the task with scheduled time + await todosStore.updateTodo(data.taskId, { + scheduledDate: format(day, 'yyyy-MM-dd'), + scheduledStartTime: startTime, + scheduledEndTime: endTime, + estimatedDuration: duration, + }); + } catch (err) { + console.error('Failed to parse drop data:', err); + } + } + // ========== Keyboard Handling ========== function handleKeyDown(e: KeyboardEvent) { @@ -459,6 +764,20 @@ resizeOriginalEnd = null; hasMoved = false; } + // Cancel task drag/resize + if (isTaskDragging || isTaskResizing) { + e.preventDefault(); + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + isTaskResizing = false; + resizeTask = null; + hasMoved = false; + } } } @@ -538,7 +857,15 @@
{#each days as day, dayIndex} -
+ +
handleSidebarDragOver(e, day)} + ondragleave={handleSidebarDragLeave} + ondrop={(e) => handleSidebarDrop(e, day)} + > {#each hours as hour} + + {#if showStatusDropdown === attendee.email} +
+ {#each statusOptions as option (option.value)} + + {/each} +
+ {/if} +
+ + + +
+ {/each} +
+ {/if} + + + +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte index c8dcf2d70..b9537e8b4 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -4,12 +4,14 @@ import { settingsStore } from '$lib/stores/settings.svelte'; import { eventTagsStore } from '$lib/stores/event-tags.svelte'; import { TagSelector, type Tag } from '@manacore/shared-ui'; + import AttendeeSelector from './AttendeeSelector.svelte'; import type { CalendarEvent, CreateEventInput, UpdateEventInput, LocationDetails, EventTag, + EventAttendee, } from '@calendar/shared'; import { format, addMinutes, parseISO } from 'date-fns'; @@ -49,6 +51,9 @@ })) || [] ); + // Attendees state + let attendees = $state(event?.metadata?.attendees || []); + // Convert EventTag to Tag type for shared-ui components function eventTagToTag(tag: EventTag): Tag { return { @@ -167,6 +172,13 @@ delete metadata.locationDetails; } + // Add attendees + if (attendees.length > 0) { + metadata.attendees = attendees; + } else { + delete metadata.attendees; + } + // Only include metadata if it has properties const finalMetadata = Object.keys(metadata).length > 0 ? metadata : undefined; @@ -389,6 +401,15 @@ {/if} + +
+ Teilnehmer + (attendees = newAttendees)} + /> +
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
@@ -238,6 +317,29 @@
{/if} + + {#if task.scheduledDate} +
+ + + Geplant: {formatDisplayDate(task.scheduledDate)} + {#if task.scheduledStartTime} + um {task.scheduledStartTime} + {#if task.scheduledEndTime} + - {task.scheduledEndTime} + {/if} + {/if} + +
+ {/if} + + {#if task.estimatedDuration} +
+ + Geschätzte Dauer: {task.estimatedDuration} Min. +
+ {/if} +
@@ -423,6 +525,17 @@ flex-shrink: 0; } + .detail-item.scheduled { + background: hsl(var(--color-primary) / 0.1); + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + border-left: 3px solid hsl(var(--color-primary)); + } + + .detail-item.scheduled :global(svg) { + color: hsl(var(--color-primary)); + } + .labels-row { align-items: flex-start; } @@ -508,9 +621,35 @@ gap: 1rem; } + .form-section { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + background: hsl(var(--color-muted) / 0.3); + border-radius: var(--radius-md); + border: 1px solid hsl(var(--color-border) / 0.5); + } + + .section-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + color: hsl(var(--color-muted-foreground)); + } + + .section-label :global(svg) { + color: hsl(var(--color-primary)); + } + input[type='text'], input[type='date'], input[type='time'], + input[type='number'], textarea { padding: 0.5rem 0.75rem; border: 1px solid hsl(var(--color-border)); diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte index 0a24b386d..7d8a004ee 100644 --- a/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte @@ -13,6 +13,7 @@ showProject?: boolean; showDueDate?: boolean; showPriority?: boolean; + draggable?: boolean; onclick?: () => void; } @@ -22,6 +23,7 @@ showProject = true, showDueDate = true, showPriority = true, + draggable = false, onclick, }: Props = $props(); @@ -75,6 +77,22 @@ onclick(); } } + + function handleDragStart(e: DragEvent) { + if (!draggable || !e.dataTransfer) return; + // Store task data for drop target + e.dataTransfer.setData( + 'application/json', + JSON.stringify({ + type: 'sidebar-task', + taskId: task.id, + title: task.title, + priority: task.priority, + estimatedDuration: task.estimatedDuration || 30, + }) + ); + e.dataTransfer.effectAllowed = 'move'; + }
@@ -168,6 +189,15 @@ transform: translateX(2px); } + .todo-item.draggable-task { + cursor: grab; + } + + .todo-item.draggable-task:active { + cursor: grabbing; + opacity: 0.7; + } + .todo-item.completed { opacity: 0.6; } diff --git a/apps/calendar/apps/web/src/lib/composables/index.ts b/apps/calendar/apps/web/src/lib/composables/index.ts new file mode 100644 index 000000000..553f49eac --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/index.ts @@ -0,0 +1,8 @@ +/** + * Calendar Composables + * Reusable logic extracted from components + */ + +export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte'; +export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte'; +export { useTaskDragDrop } from './useTaskDragDrop.svelte'; diff --git a/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts new file mode 100644 index 000000000..f090319ec --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts @@ -0,0 +1,306 @@ +/** + * Composable for Task Drag & Drop in Calendar Views + * Handles dragging tasks to reschedule and resizing to change duration + */ + +import type { Task, UpdateTaskInput } from '$lib/api/todos'; +import { todosStore } from '$lib/stores/todos.svelte'; +import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns'; + +const SNAP_MINUTES = 15; + +interface UseTaskDragDropOptions { + /** Minimum snap interval in minutes */ + snapMinutes?: number; + /** Callback when task is updated */ + onTaskUpdate?: (task: Task) => void; +} + +export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) { + const snapMinutes = options.snapMinutes ?? SNAP_MINUTES; + + // Drag state + let isDragging = $state(false); + let draggedTask = $state(null); + let dragStartY = $state(0); + let dragTargetDay = $state(null); + let dragPreviewTop = $state(0); + let dragPreviewHeight = $state(0); + + // Resize state + let isResizing = $state(false); + let resizeTask = $state(null); + let resizeEdge = $state<'top' | 'bottom'>('bottom'); + let resizeStartY = $state(0); + let resizePreviewTop = $state(0); + let resizePreviewHeight = $state(0); + + // Track if we actually moved + let hasMoved = $state(false); + + /** + * Start dragging a task + */ + function startDrag( + task: Task, + e: PointerEvent, + gridElement: HTMLElement, + firstVisibleHour: number, + totalVisibleHours: number + ) { + e.preventDefault(); + isDragging = true; + draggedTask = task; + dragStartY = e.clientY; + hasMoved = false; + + // Calculate initial position + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + // Calculate height from duration + const duration = task.estimatedDuration || 30; + dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + // Capture pointer + (e.target as HTMLElement).setPointerCapture(e.pointerId); + } + + /** + * Handle drag move + */ + function onDragMove( + e: PointerEvent, + gridElement: HTMLElement, + day: Date, + firstVisibleHour: number, + totalVisibleHours: number + ) { + if (!isDragging || !draggedTask) return; + + hasMoved = true; + dragTargetDay = day; + + const rect = gridElement.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = (relativeY / rect.height) * 100; + + // Snap to intervals + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60; + const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; + + dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100; + } + + /** + * End drag and update task + */ + async function endDrag(firstVisibleHour: number, totalVisibleHours: number) { + if (!isDragging || !draggedTask || !hasMoved) { + isDragging = false; + draggedTask = null; + dragTargetDay = null; + return; + } + + // Calculate new time from position + const minutesFromMidnight = + (dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + const hours = Math.floor(minutesFromMidnight / 60); + const minutes = Math.round(minutesFromMidnight % 60); + + const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + // Calculate end time based on duration + const duration = draggedTask.estimatedDuration || 30; + const endMinutes = minutesFromMidnight + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = Math.round(endMinutes % 60); + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + const updateData: UpdateTaskInput = { + scheduledDate: dragTargetDay + ? format(dragTargetDay, 'yyyy-MM-dd') + : draggedTask.scheduledDate, + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + }; + + const result = await todosStore.updateTodo(draggedTask.id, updateData); + if (result.data) { + options.onTaskUpdate?.(result.data); + } + + isDragging = false; + draggedTask = null; + dragTargetDay = null; + hasMoved = false; + } + + /** + * Start resizing a task + */ + function startResize( + task: Task, + edge: 'top' | 'bottom', + e: PointerEvent, + firstVisibleHour: number, + totalVisibleHours: number + ) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + resizeTask = task; + resizeEdge = edge; + resizeStartY = e.clientY; + hasMoved = false; + + // Initialize preview position + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + (e.target as HTMLElement).setPointerCapture(e.pointerId); + } + + /** + * Handle resize move + */ + function onResizeMove( + e: PointerEvent, + gridElement: HTMLElement, + firstVisibleHour: number, + totalVisibleHours: number + ) { + if (!isResizing || !resizeTask) return; + + hasMoved = true; + + const rect = gridElement.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + + if (resizeEdge === 'top') { + // Adjust start time, keep end fixed + const originalEndPercent = resizePreviewTop + resizePreviewHeight; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; + resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop); + } else { + // Adjust end time, keep start fixed + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; + const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; + resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop); + } + } + + /** + * End resize and update task + */ + async function endResize(firstVisibleHour: number, totalVisibleHours: number) { + if (!isResizing || !resizeTask || !hasMoved) { + isResizing = false; + resizeTask = null; + return; + } + + // Calculate new times from position + const startMinutes = + (resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + const endMinutes = + ((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) + + firstVisibleHour * 60; + + const startHours = Math.floor(startMinutes / 60); + const startMins = Math.round(startMinutes % 60); + const endHours = Math.floor(endMinutes / 60); + const endMins = Math.round(endMinutes % 60); + + const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newDuration = Math.round(endMinutes - startMinutes); + + const updateData: UpdateTaskInput = { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + estimatedDuration: newDuration, + }; + + const result = await todosStore.updateTodo(resizeTask.id, updateData); + if (result.data) { + options.onTaskUpdate?.(result.data); + } + + isResizing = false; + resizeTask = null; + hasMoved = false; + } + + /** + * Cancel any ongoing drag/resize + */ + function cancel() { + isDragging = false; + isResizing = false; + draggedTask = null; + resizeTask = null; + dragTargetDay = null; + hasMoved = false; + } + + return { + // State getters + get isDragging() { + return isDragging; + }, + get draggedTask() { + return draggedTask; + }, + get dragTargetDay() { + return dragTargetDay; + }, + get dragPreviewTop() { + return dragPreviewTop; + }, + get dragPreviewHeight() { + return dragPreviewHeight; + }, + get isResizing() { + return isResizing; + }, + get resizeTask() { + return resizeTask; + }, + get resizePreviewTop() { + return resizePreviewTop; + }, + get resizePreviewHeight() { + return resizePreviewHeight; + }, + get hasMoved() { + return hasMoved; + }, + + // Methods + startDrag, + onDragMove, + endDrag, + startResize, + onResizeMove, + endResize, + cancel, + }; +} diff --git a/apps/calendar/apps/web/src/lib/i18n/locales/de.json b/apps/calendar/apps/web/src/lib/i18n/locales/de.json index c82da1737..8eff3b65a 100644 --- a/apps/calendar/apps/web/src/lib/i18n/locales/de.json +++ b/apps/calendar/apps/web/src/lib/i18n/locales/de.json @@ -86,5 +86,30 @@ "search": "Suchen", "error": "Fehler", "success": "Erfolgreich" + }, + "errors": { + "loadEvents": "Termine konnten nicht geladen werden", + "createEvent": "Termin konnte nicht erstellt werden", + "updateEvent": "Termin konnte nicht aktualisiert werden", + "deleteEvent": "Termin konnte nicht gelöscht werden" + }, + "success": { + "eventCreated": "Termin erstellt", + "eventDeleted": "Termin gelöscht" + }, + "priority": { + "urgent": "Dringend", + "high": "Wichtig", + "medium": "Normal", + "low": "Später" + }, + "todo": { + "task": "Aufgabe", + "markComplete": "Als erledigt markieren", + "markIncomplete": "Als unerledigt markieren" + }, + "a11y": { + "createEventOn": "Termin erstellen am {date}", + "slotTime": "{day} {time}" } } diff --git a/apps/calendar/apps/web/src/lib/i18n/locales/en.json b/apps/calendar/apps/web/src/lib/i18n/locales/en.json index 10af9d398..a092895dc 100644 --- a/apps/calendar/apps/web/src/lib/i18n/locales/en.json +++ b/apps/calendar/apps/web/src/lib/i18n/locales/en.json @@ -86,5 +86,30 @@ "search": "Search", "error": "Error", "success": "Success" + }, + "errors": { + "loadEvents": "Failed to load events", + "createEvent": "Failed to create event", + "updateEvent": "Failed to update event", + "deleteEvent": "Failed to delete event" + }, + "success": { + "eventCreated": "Event created", + "eventDeleted": "Event deleted" + }, + "priority": { + "urgent": "Urgent", + "high": "High", + "medium": "Normal", + "low": "Low" + }, + "todo": { + "task": "Task", + "markComplete": "Mark as complete", + "markIncomplete": "Mark as incomplete" + }, + "a11y": { + "createEventOn": "Create event on {date}", + "slotTime": "{day} {time}" } } diff --git a/apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts b/apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts new file mode 100644 index 000000000..35ddf5f7f --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts @@ -0,0 +1,175 @@ +/** + * Contacts Store for Calendar App + * + * Provides access to contacts from the Contacts app for event attendee management. + */ + +import { browser } from '$app/environment'; +import { createContactsClient, type ContactsClient } from '@manacore/shared-auth'; +import type { ContactSummary } from '@manacore/shared-types'; +import { authStore } from './auth.svelte'; + +// State +let client: ContactsClient | null = null; +let isAvailable = $state(null); +let isChecking = $state(false); +let lastCheck = $state(0); + +// Cache for recent search results +let searchCache = $state>(new Map()); +const CACHE_TTL = 60000; // 1 minute + +// Get contacts API URL dynamically +function getContactsApiUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string }) + .__PUBLIC_CONTACTS_API_URL__; + return injectedUrl || 'http://localhost:3015/api/v1'; + } + return 'http://localhost:3015/api/v1'; +} + +// Initialize client lazily +function getClient(): ContactsClient { + if (!client) { + client = createContactsClient({ + apiUrl: getContactsApiUrl(), + getAuthToken: async () => authStore.getAccessToken(), + timeout: 5000, + }); + } + return client; +} + +export const contactsStore = { + // Getters + get isAvailable() { + return isAvailable; + }, + get isChecking() { + return isChecking; + }, + + /** + * Check if the Contacts API is available + * Caches result for 30 seconds + */ + async checkAvailability(): Promise { + const now = Date.now(); + // Skip if checked recently + if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) { + return isAvailable; + } + + isChecking = true; + try { + const available = await getClient().isAvailable(); + isAvailable = available; + lastCheck = now; + return available; + } catch { + isAvailable = false; + lastCheck = now; + return false; + } finally { + isChecking = false; + } + }, + + /** + * Search contacts by query string + */ + async searchContacts(query: string): Promise { + // Check cache first + const cacheKey = query.toLowerCase().trim(); + const cached = searchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.results; + } + + // Check availability + if (isAvailable === null) { + await this.checkAvailability(); + } + + if (!isAvailable) { + return []; + } + + try { + const results = await getClient().searchContacts({ + query, + limit: 20, + excludeArchived: true, + }); + + // Cache results + searchCache.set(cacheKey, { + results, + timestamp: Date.now(), + }); + + return results; + } catch (error) { + console.error('[contactsStore] Search failed:', error); + return []; + } + }, + + /** + * Get a single contact by ID + */ + async getContact(id: string): Promise { + if (isAvailable === null) { + await this.checkAvailability(); + } + + if (!isAvailable) { + return null; + } + + try { + return await getClient().getContact(id); + } catch (error) { + console.error(`[contactsStore] Failed to get contact ${id}:`, error); + return null; + } + }, + + /** + * Get multiple contacts by IDs + */ + async getContacts(ids: string[]): Promise { + if (ids.length === 0) return []; + + if (isAvailable === null) { + await this.checkAvailability(); + } + + if (!isAvailable) { + return []; + } + + try { + return await getClient().getContacts(ids); + } catch (error) { + console.error('[contactsStore] Failed to get contacts:', error); + return []; + } + }, + + /** + * Clear the search cache + */ + clearCache() { + searchCache.clear(); + }, + + /** + * Reset availability check (force recheck on next call) + */ + resetAvailability() { + isAvailable = null; + lastCheck = 0; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts index 1311d0971..5c1f17957 100644 --- a/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts @@ -74,6 +74,50 @@ export const todosStore = { }); }, + /** + * Get scheduled tasks for a specific day (by scheduledDate - for time-blocking) + * Note: Includes completed tasks so they remain visible in the calendar + */ + getScheduledTasksForDay(date: Date): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => { + if (!task.scheduledDate) return false; + const scheduledDate = + typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate; + return isSameDay(scheduledDate, date); + }); + }, + + /** + * Get scheduled tasks within a date range (for time-blocking) + * Note: Includes completed tasks so they remain visible in the calendar + */ + getScheduledTasksInRange(start: Date, end: Date): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => { + if (!task.scheduledDate) return false; + const scheduledDate = + typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate; + return isWithinInterval(scheduledDate, { start, end }); + }); + }, + + /** + * Get unscheduled tasks (no scheduledDate - for sidebar drag source) + */ + get unscheduledForTimeBlocking(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos + .filter((task) => !task.isCompleted && !task.scheduledDate) + .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + }, + /** * Get todos within a date range */ @@ -202,14 +246,13 @@ export const todosStore = { /** * Fetch todos for a date range + * Note: Fetches both completed and uncompleted tasks so scheduled tasks remain visible */ async fetchTodos(startDate?: Date, endDate?: Date) { loading = true; error = null; - const query: TaskQuery = { - isCompleted: false, - }; + const query: TaskQuery = {}; if (startDate) { query.dueDateFrom = format(startDate, 'yyyy-MM-dd'); @@ -236,7 +279,7 @@ export const todosStore = { }, /** - * Fetch today's todos (shortcut) + * Fetch today's todos (shortcut) - only uncompleted tasks */ async fetchTodayTodos() { loading = true; @@ -260,6 +303,40 @@ export const todosStore = { return result; }, + /** + * Fetch all scheduled todos (including completed ones) + * Used for calendar time-blocking to keep completed tasks visible + */ + async fetchScheduledTodos() { + loading = true; + error = null; + + // Fetch all tasks without isCompleted filter - API will return all + const result = await api.getTasks({}); + + if (result.error) { + error = result.error.message; + serviceAvailable = false; + } else { + // Only keep tasks that have a scheduledDate (for time-blocking) + // Merge with existing todos (avoid duplicates) + const allTasks = result.data || []; + const scheduledTasks = allTasks.filter((t) => t.scheduledDate); + const existingIds = new Set(todos.map((t) => t.id)); + const uniqueNew = scheduledTasks.filter((t) => !existingIds.has(t.id)); + // Also update existing scheduled tasks (in case isCompleted changed) + todos = todos.map((existing) => { + const updated = scheduledTasks.find((t) => t.id === existing.id); + return updated || existing; + }); + todos = [...todos, ...uniqueNew]; + serviceAvailable = true; + } + + loading = false; + return result; + }, + /** * Fetch upcoming todos (shortcut) */ diff --git a/apps/calendar/packages/shared/src/types/event.ts b/apps/calendar/packages/shared/src/types/event.ts index 8260afb15..cadc0bc4e 100644 --- a/apps/calendar/packages/shared/src/types/event.ts +++ b/apps/calendar/packages/shared/src/types/event.ts @@ -1,10 +1,21 @@ +/** + * Event attendee RSVP status + */ +export type AttendeeStatus = 'accepted' | 'declined' | 'tentative' | 'pending'; + /** * Event attendee information */ export interface EventAttendee { email: string; name?: string; - status?: 'accepted' | 'declined' | 'tentative' | 'pending'; + status?: AttendeeStatus; + /** Contact reference for linked contacts */ + contactId?: string; + /** Cached photo URL from contact */ + photoUrl?: string; + /** Cached company from contact */ + company?: string; } /** diff --git a/apps/contacts/apps/web/src/lib/api/todos.ts b/apps/contacts/apps/web/src/lib/api/todos.ts new file mode 100644 index 000000000..2050068bd --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/todos.ts @@ -0,0 +1,232 @@ +/** + * Cross-App API Client for Todo Backend + * Allows Contacts app to fetch tasks related to a contact + */ + +import { browser } from '$app/environment'; +import { authStore } from '$lib/stores/auth.svelte'; + +// Types mirrored from @todo/shared +export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface Subtask { + id: string; + title: string; + isCompleted: boolean; + completedAt?: string | null; + order: number; +} + +export interface Label { + id: string; + userId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} + +export interface Project { + id: string; + userId: string; + name: string; + description?: string | null; + color: string; + icon?: string | null; + order: number; + isArchived: boolean; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ContactReference { + contactId: string; + displayName: string; + email?: string; + photoUrl?: string; + company?: string; + fetchedAt: string; +} + +export interface TaskMetadata { + notes?: string; + attachments?: string[]; + linkedCalendarEventId?: string | null; + storyPoints?: number | null; + effectiveDuration?: { + value: number; + unit: 'minutes' | 'hours' | 'days'; + } | null; + funRating?: number | null; + assignee?: ContactReference | null; + involvedContacts?: ContactReference[]; +} + +export interface Task { + id: string; + projectId?: string | null; + userId: string; + parentTaskId?: string | null; + title: string; + description?: string | null; + dueDate?: string | null; + dueTime?: string | null; + startDate?: string | null; + scheduledDate?: string | null; + scheduledStartTime?: string | null; + scheduledEndTime?: string | null; + estimatedDuration?: number | null; + priority: TaskPriority; + status: TaskStatus; + isCompleted: boolean; + completedAt?: string | null; + order: number; + columnId?: string | null; + columnOrder?: number; + recurrenceRule?: string | null; + recurrenceEndDate?: string | null; + lastOccurrence?: string | null; + subtasks?: Subtask[] | null; + metadata?: TaskMetadata | null; + labels?: Label[]; + project?: Project | null; + createdAt: string; + updatedAt: string; +} + +// API Configuration +function getTodoApiBase(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_TODO_BACKEND_URL__?: string }) + .__PUBLIC_TODO_BACKEND_URL__; + return injectedUrl || 'http://localhost:3018'; + } + return 'http://localhost:3018'; +} + +interface ApiResult { + data: T | null; + error: Error | null; +} + +async function fetchTodoApi(endpoint: string, options: RequestInit = {}): Promise> { + const token = await authStore.getAccessToken(); + const baseUrl = getTodoApiBase(); + + try { + const response = await fetch(`${baseUrl}/api/v1${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: new Error(errorData.message || `API error: ${response.status}`), + }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error('Unknown error'), + }; + } +} + +// API Functions + +/** + * Get tasks related to a specific contact (assigned or involved) + */ +export async function getTasksByContact( + contactId: string, + includeCompleted: boolean = false +): Promise> { + const params = new URLSearchParams(); + if (includeCompleted) { + params.set('includeCompleted', 'true'); + } + const query = params.toString(); + + const result = await fetchTodoApi<{ tasks: Task[] }>( + `/tasks/by-contact/${contactId}${query ? `?${query}` : ''}` + ); + + return { + data: result.data?.tasks || null, + error: result.error, + }; +} + +/** + * Complete a task + */ +export async function completeTask(taskId: string): Promise> { + const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/complete`, { + method: 'POST', + }); + + return { + data: result.data?.task || null, + error: result.error, + }; +} + +/** + * Uncomplete a task + */ +export async function uncompleteTask(taskId: string): Promise> { + const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/uncomplete`, { + method: 'POST', + }); + + return { + data: result.data?.task || null, + error: result.error, + }; +} + +/** + * Check if the Todo service is available + */ +export async function checkTodoServiceAvailable(): Promise { + try { + const baseUrl = getTodoApiBase(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch(`${baseUrl}/api/v1/health`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return response.ok; + } catch { + return false; + } +} + +// Priority styling helpers +export const PRIORITY_COLORS: Record = { + urgent: 'var(--color-danger, #ef4444)', + high: 'var(--color-warning, #f59e0b)', + medium: 'var(--color-accent, #3b82f6)', + low: 'var(--color-success, #22c55e)', +}; + +export const PRIORITY_LABELS: Record = { + urgent: { de: 'Dringend', en: 'Urgent' }, + high: { de: 'Wichtig', en: 'High' }, + medium: { de: 'Normal', en: 'Medium' }, + low: { de: 'Niedrig', en: 'Low' }, +}; diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index 569d28831..213178a9c 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -3,6 +3,7 @@ import { onMount } from 'svelte'; import { contactsApi, photoApi, type Contact } from '$lib/api/contacts'; import ContactNotes from './ContactNotes.svelte'; + import ContactTasks from './ContactTasks.svelte'; import { ContactDetailSkeleton } from '$lib/components/skeletons'; interface Props { @@ -841,6 +842,9 @@ + + +
{/if} {/if} diff --git a/apps/contacts/apps/web/src/lib/components/ContactTasks.svelte b/apps/contacts/apps/web/src/lib/components/ContactTasks.svelte new file mode 100644 index 000000000..f409150ae --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/ContactTasks.svelte @@ -0,0 +1,515 @@ + + +
+
+
+ + + +
+

{$_('contact.tasks.title')}

+ +
+ + {#if error} +
{error}
+ {/if} + + {#if loading} +
+ +
+ {:else if !todosStore.serviceAvailable} +
+ + + +

{$_('contact.tasks.serviceUnavailable')}

+
+ {:else if totalTasks === 0} +
+

{$_('contact.tasks.empty')}

+
+ {:else} + + {#if assignedTasks.length > 0} +
+
+ {$_('contact.tasks.assigned')} + {visibleAssigned.length} +
+
+ {#each visibleAssigned as task (task.id)} + {@const dueInfo = formatDueDate(task.dueDate)} +
+ +
+ {task.title} + {#if task.project} + {task.project.name} + {/if} +
+ {#if dueInfo.status !== 'none' && !task.isCompleted} + + {dueInfo.text} + + {/if} +
+ {/each} +
+
+ {/if} + + + {#if involvedTasks.length > 0} +
+
+ {$_('contact.tasks.involved')} + {visibleInvolved.length} +
+
+ {#each visibleInvolved as task (task.id)} + {@const dueInfo = formatDueDate(task.dueDate)} +
+ +
+ {task.title} + {#if task.project} + {task.project.name} + {/if} +
+ {#if dueInfo.status !== 'none' && !task.isCompleted} + + {dueInfo.text} + + {/if} +
+ {/each} +
+
+ {/if} + + + {#if hasMore} + + {/if} + {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/de.json b/apps/contacts/apps/web/src/lib/i18n/locales/de.json index 3fa3eea7b..ef9d60e39 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/de.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/de.json @@ -102,7 +102,22 @@ "country": "Land", "website": "Website", "birthday": "Geburtstag", - "notes": "Notizen" + "notes": "Notizen", + "tasks": { + "title": "Aufgaben", + "assigned": "Zugewiesen", + "involved": "Beteiligt", + "empty": "Keine Aufgaben für diesen Kontakt", + "serviceUnavailable": "Todo-Service nicht erreichbar", + "error": "Fehler beim Laden der Aufgaben", + "overdue": "Überfällig", + "dueToday": "Heute", + "tomorrow": "Morgen", + "showCompleted": "Erledigte", + "showMore": "{count} weitere anzeigen", + "markComplete": "Als erledigt markieren", + "markIncomplete": "Als unerledigt markieren" + } }, "groups": { "title": "Gruppen", diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/en.json b/apps/contacts/apps/web/src/lib/i18n/locales/en.json index 94a8426f5..99b0bb0b3 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/en.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/en.json @@ -102,7 +102,22 @@ "country": "Country", "website": "Website", "birthday": "Birthday", - "notes": "Notes" + "notes": "Notes", + "tasks": { + "title": "Tasks", + "assigned": "Assigned", + "involved": "Involved", + "empty": "No tasks for this contact", + "serviceUnavailable": "Todo service unavailable", + "error": "Failed to load tasks", + "overdue": "Overdue", + "dueToday": "Today", + "tomorrow": "Tomorrow", + "showCompleted": "Completed", + "showMore": "Show {count} more", + "markComplete": "Mark as complete", + "markIncomplete": "Mark as incomplete" + } }, "groups": { "title": "Groups", diff --git a/apps/contacts/apps/web/src/lib/stores/todos.svelte.ts b/apps/contacts/apps/web/src/lib/stores/todos.svelte.ts new file mode 100644 index 000000000..8645467d0 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/todos.svelte.ts @@ -0,0 +1,209 @@ +/** + * Todo Store for Contacts App + * Manages tasks related to contacts from the Todo service + */ + +import { browser } from '$app/environment'; +import { + getTasksByContact, + completeTask, + uncompleteTask, + checkTodoServiceAvailable, + type Task, +} from '$lib/api/todos'; + +// State +let tasksByContact = $state>(new Map()); +let loadingContacts = $state>(new Set()); +let serviceAvailable = $state(null); +let lastAvailabilityCheck = $state(0); + +// Cache TTL in milliseconds (5 minutes) +const CACHE_TTL = 5 * 60 * 1000; +// Availability check interval (30 seconds) +const AVAILABILITY_CHECK_INTERVAL = 30 * 1000; + +// Cache timestamps +const cacheTimestamps = new Map(); + +/** + * Check if cached data is still valid + */ +function isCacheValid(contactId: string): boolean { + const timestamp = cacheTimestamps.get(contactId); + if (!timestamp) return false; + return Date.now() - timestamp < CACHE_TTL; +} + +/** + * Check if the Todo service is available (with caching) + */ +async function checkAvailability(): Promise { + if (!browser) return false; + + const now = Date.now(); + if (serviceAvailable !== null && now - lastAvailabilityCheck < AVAILABILITY_CHECK_INTERVAL) { + return serviceAvailable; + } + + const available = await checkTodoServiceAvailable(); + serviceAvailable = available; + lastAvailabilityCheck = now; + return available; +} + +/** + * Load tasks for a specific contact + */ +async function loadTasksForContact( + contactId: string, + includeCompleted: boolean = false, + forceRefresh: boolean = false +): Promise { + if (!browser) return []; + + // Check cache first + if (!forceRefresh && isCacheValid(contactId)) { + return tasksByContact.get(contactId) || []; + } + + // Check service availability + const available = await checkAvailability(); + if (!available) { + return []; + } + + // Mark as loading + loadingContacts = new Set([...loadingContacts, contactId]); + + try { + const { data, error } = await getTasksByContact(contactId, includeCompleted); + + if (error) { + console.error(`Failed to load tasks for contact ${contactId}:`, error); + return []; + } + + const tasks = data || []; + + // Update cache + tasksByContact = new Map(tasksByContact).set(contactId, tasks); + cacheTimestamps.set(contactId, Date.now()); + + return tasks; + } finally { + // Remove from loading set + const newLoading = new Set(loadingContacts); + newLoading.delete(contactId); + loadingContacts = newLoading; + } +} + +/** + * Get cached tasks for a contact (does not fetch) + */ +function getTasksForContact(contactId: string): Task[] { + return tasksByContact.get(contactId) || []; +} + +/** + * Check if tasks are currently loading for a contact + */ +function isLoading(contactId: string): boolean { + return loadingContacts.has(contactId); +} + +/** + * Toggle task completion + */ +async function toggleTaskCompletion(taskId: string, contactId: string): Promise { + const tasks = tasksByContact.get(contactId) || []; + const task = tasks.find((t) => t.id === taskId); + + if (!task) return false; + + const { data, error } = task.isCompleted + ? await uncompleteTask(taskId) + : await completeTask(taskId); + + if (error || !data) { + console.error('Failed to toggle task completion:', error); + return false; + } + + // Update local state + const updatedTasks = tasks.map((t) => (t.id === taskId ? data : t)); + tasksByContact = new Map(tasksByContact).set(contactId, updatedTasks); + + return true; +} + +/** + * Clear cache for a specific contact + */ +function clearCacheForContact(contactId: string): void { + const newMap = new Map(tasksByContact); + newMap.delete(contactId); + tasksByContact = newMap; + cacheTimestamps.delete(contactId); +} + +/** + * Clear all cached data + */ +function clearCache(): void { + tasksByContact = new Map(); + cacheTimestamps.clear(); +} + +/** + * Categorize tasks by their relation to the contact + */ +function categorizeTasksForContact(contactId: string): { assigned: Task[]; involved: Task[] } { + const tasks = tasksByContact.get(contactId) || []; + + const assigned: Task[] = []; + const involved: Task[] = []; + + for (const task of tasks) { + const isAssignee = task.metadata?.assignee?.contactId === contactId; + const isInvolved = task.metadata?.involvedContacts?.some((c) => c.contactId === contactId); + + if (isAssignee) { + assigned.push(task); + } else if (isInvolved) { + involved.push(task); + } + } + + // Sort by due date (overdue first, then by date) + const sortByDueDate = (a: Task, b: Task): number => { + if (!a.dueDate && !b.dueDate) return 0; + if (!a.dueDate) return 1; + if (!b.dueDate) return -1; + return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(); + }; + + assigned.sort(sortByDueDate); + involved.sort(sortByDueDate); + + return { assigned, involved }; +} + +// Export store +export const todosStore = { + // Getters (reactive) + get serviceAvailable() { + return serviceAvailable; + }, + + // Methods + checkAvailability, + loadTasksForContact, + getTasksForContact, + isLoading, + toggleTaskCompletion, + clearCacheForContact, + clearCache, + categorizeTasksForContact, +}; diff --git a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts index 8d3596fa5..6d7df9b68 100644 --- a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts @@ -57,6 +57,12 @@ export const tasks = pgTable( dueTime: varchar('due_time', { length: 5 }), startDate: timestamp('start_date', { withTimezone: true }), + // Time-Blocking (for calendar integration) + scheduledDate: timestamp('scheduled_date', { withTimezone: true }), + scheduledStartTime: varchar('scheduled_start_time', { length: 5 }), // HH:mm + scheduledEndTime: varchar('scheduled_end_time', { length: 5 }), // HH:mm + estimatedDuration: integer('estimated_duration'), // in minutes + // Priority & Status priority: varchar('priority', { length: 10 }).default('medium').$type(), status: varchar('status', { length: 20 }).default('pending').$type(), @@ -90,6 +96,7 @@ export const tasks = pgTable( projectIdx: index('tasks_project_idx').on(table.projectId), userIdx: index('tasks_user_idx').on(table.userId), dueDateIdx: index('tasks_due_date_idx').on(table.dueDate), + scheduledDateIdx: index('tasks_scheduled_date_idx').on(table.scheduledDate), statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status), parentIdx: index('tasks_parent_idx').on(table.parentTaskId), orderIdx: index('tasks_order_idx').on(table.projectId, table.order), diff --git a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts index 2cebd0bdd..be3fb7e11 100644 --- a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts +++ b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts @@ -9,6 +9,10 @@ import { IsDateString, IsNotEmpty, ValidateNested, + IsInt, + Min, + Max, + Matches, } from 'class-validator'; import { Type } from 'class-transformer'; import type { TaskPriority } from '../../db/schema/tasks.schema'; @@ -47,6 +51,27 @@ export class CreateTaskDto { @IsDateString() startDate?: string | null; + // Time-Blocking fields + @IsOptional() + @IsDateString() + scheduledDate?: string | null; + + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' }) + scheduledStartTime?: string | null; + + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' }) + scheduledEndTime?: string | null; + + @IsOptional() + @IsInt() + @Min(1) + @Max(1440) // Max 24 hours in minutes + estimatedDuration?: number | null; + @IsOptional() @IsEnum(['low', 'medium', 'high', 'urgent']) priority?: TaskPriority; diff --git a/apps/todo/apps/backend/src/task/dto/update-task.dto.ts b/apps/todo/apps/backend/src/task/dto/update-task.dto.ts index 992f1b201..65ade2627 100644 --- a/apps/todo/apps/backend/src/task/dto/update-task.dto.ts +++ b/apps/todo/apps/backend/src/task/dto/update-task.dto.ts @@ -9,6 +9,10 @@ import { IsObject, MaxLength, IsDateString, + IsInt, + Min, + Max, + Matches, } from 'class-validator'; import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema'; @@ -43,6 +47,27 @@ export class UpdateTaskDto { @IsDateString() startDate?: string | null; + // Time-Blocking fields + @IsOptional() + @IsDateString() + scheduledDate?: string | null; + + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' }) + scheduledStartTime?: string | null; + + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' }) + scheduledEndTime?: string | null; + + @IsOptional() + @IsInt() + @Min(1) + @Max(1440) // Max 24 hours in minutes + estimatedDuration?: number | null; + @IsOptional() @IsEnum(['low', 'medium', 'high', 'urgent']) priority?: TaskPriority; @@ -74,4 +99,9 @@ export class UpdateTaskDto { @IsOptional() @IsObject() metadata?: TaskMetadata | null; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + labelIds?: string[]; } diff --git a/apps/todo/apps/backend/src/task/task.controller.ts b/apps/todo/apps/backend/src/task/task.controller.ts index 01060596a..86856c7af 100644 --- a/apps/todo/apps/backend/src/task/task.controller.ts +++ b/apps/todo/apps/backend/src/task/task.controller.ts @@ -42,6 +42,20 @@ export class TaskController { return result; } + @Get('by-contact/:contactId') + async getByContact( + @CurrentUser() user: CurrentUserData, + @Param('contactId') contactId: string, + @Query('includeCompleted') includeCompleted?: string + ) { + const tasks = await this.taskService.findByContact( + user.userId, + contactId, + includeCompleted === 'true' + ); + return { tasks }; + } + @Get(':id') async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { const task = await this.taskService.findByIdOrThrow(id, user.userId); diff --git a/apps/todo/apps/backend/src/task/task.service.ts b/apps/todo/apps/backend/src/task/task.service.ts index 747990a56..c5d681981 100644 --- a/apps/todo/apps/backend/src/task/task.service.ts +++ b/apps/todo/apps/backend/src/task/task.service.ts @@ -151,8 +151,11 @@ export class TaskService { await this.projectService.findByIdOrThrow(dto.projectId, userId); } + // Extract labelIds before spreading dto (it's not a db column) + const { labelIds, ...dtoWithoutLabels } = dto; + const updateData: Partial = { - ...dto, + ...dtoWithoutLabels, dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined, startDate: dto.startDate ? new Date(dto.startDate) @@ -181,6 +184,11 @@ export class TaskService { .where(and(eq(tasks.id, id), eq(tasks.userId, userId))) .returning(); + // Update labels if provided + if (labelIds !== undefined) { + await this.updateTaskLabels(id, userId, labelIds); + } + return this.loadTaskLabels(updated); } @@ -411,6 +419,41 @@ export class TaskService { return this.findAll(userId, { isCompleted: false }); } + /** + * Finds all tasks where the given contact is either the assignee or involved. + * Searches in metadata->assignee->contactId and metadata->involvedContacts array. + */ + async findByContact( + userId: string, + contactId: string, + includeCompleted: boolean = false + ): Promise { + // Build conditions for the query + const conditions: SQL[] = [eq(tasks.userId, userId)]; + + // Optionally exclude completed tasks + if (!includeCompleted) { + conditions.push(eq(tasks.isCompleted, false)); + } + + // Search for contactId in metadata->assignee->contactId OR in metadata->involvedContacts array + const contactCondition = or( + // Check if assignee.contactId matches + sql`${tasks.metadata}->>'assignee' IS NOT NULL AND ${tasks.metadata}->'assignee'->>'contactId' = ${contactId}`, + // Check if contactId exists in involvedContacts array + sql`${tasks.metadata}->'involvedContacts' @> ${JSON.stringify([{ contactId }])}::jsonb` + ); + + conditions.push(contactCondition as SQL); + + const result = await this.db.query.tasks.findMany({ + where: and(...conditions), + orderBy: [asc(tasks.dueDate), asc(tasks.order)], + }); + + return this.loadTaskLabelsBatch(result); + } + async getTodayTasks(userId: string): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); diff --git a/apps/todo/apps/web/package.json b/apps/todo/apps/web/package.json index 5a4f6211b..3f21ff091 100644 --- a/apps/todo/apps/web/package.json +++ b/apps/todo/apps/web/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", + "@manacore/shared-types": "workspace:*", "@manacore/shared-utils": "workspace:*", "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", diff --git a/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte b/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte index 6f9eaa758..1b370761c 100644 --- a/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte +++ b/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte @@ -7,8 +7,10 @@ EffectiveDuration, UpdateTaskInput, } from '@todo/shared'; + import type { ContactReference, ContactOrManual } from '@manacore/shared-types'; import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared'; import { projectsStore } from '$lib/stores/projects.svelte'; + import { contactsStore } from '$lib/stores/contacts.svelte'; import { format } from 'date-fns'; import SubtaskList from './SubtaskList.svelte'; import { @@ -18,6 +20,7 @@ FunRatingPicker, TagSelector, } from './form'; + import { ContactSelector } from '@manacore/shared-ui'; interface Props { task: Task; @@ -45,6 +48,10 @@ let storyPoints = $state(null); let effectiveDuration = $state(null); let funRating = $state(null); + // Contact associations + let assignee = $state([]); + let involvedContacts = $state([]); + let contactsAvailable = $state(null); // UI state let isLoading = $state(false); @@ -69,7 +76,15 @@ storyPoints = task.metadata?.storyPoints ?? null; effectiveDuration = task.metadata?.effectiveDuration ?? null; funRating = task.metadata?.funRating ?? null; + // Contact associations + assignee = task.metadata?.assignee ? [task.metadata.assignee] : []; + involvedContacts = task.metadata?.involvedContacts || []; showDeleteConfirm = false; + + // Check contacts availability + contactsStore.checkAvailability().then((available) => { + contactsAvailable = available; + }); } }); @@ -88,11 +103,26 @@ } } + // Extract ContactReference from ContactOrManual (filter out manual entries for now) + function toContactReference(contact: ContactOrManual): ContactReference | null { + if ('isManual' in contact && contact.isManual) { + return null; // Manual entries not stored as contacts + } + return contact as ContactReference; + } + async function handleSave() { if (!title.trim()) return; isLoading = true; try { + // Convert assignee array to single ContactReference + const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null; + // Convert involved contacts to array of ContactReferences + const involvedRefs = involvedContacts + .map(toContactReference) + .filter((c): c is ContactReference => c !== null); + const data: UpdateTaskInput = { title: title.trim(), description: description.trim() || null, @@ -110,6 +140,8 @@ storyPoints: storyPoints ?? undefined, effectiveDuration: effectiveDuration ?? undefined, funRating: funRating ?? undefined, + assignee: assigneeRef ?? undefined, + involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined, }, labelIds: selectedLabelIds, }; @@ -179,6 +211,37 @@ >
+ +
+ + (assignee = contacts)} + onSearch={(q) => contactsStore.searchContacts(q)} + singleSelect={true} + allowManualEntry={false} + placeholder="Person zuweisen..." + addLabel="Zuweisen" + searchPlaceholder="Name oder E-Mail..." + isAvailable={contactsAvailable ?? false} + /> +
+ + +
+ + (involvedContacts = contacts)} + onSearch={(q) => contactsStore.searchContacts(q)} + allowManualEntry={false} + placeholder="Personen hinzufügen..." + addLabel="Person hinzufügen" + searchPlaceholder="Name oder E-Mail..." + isAvailable={contactsAvailable ?? false} + /> +
+
diff --git a/apps/todo/apps/web/src/lib/components/TaskItem.svelte b/apps/todo/apps/web/src/lib/components/TaskItem.svelte index bfab460bd..1ad2a8779 100644 --- a/apps/todo/apps/web/src/lib/components/TaskItem.svelte +++ b/apps/todo/apps/web/src/lib/components/TaskItem.svelte @@ -3,6 +3,7 @@ import { format, isToday, isPast, isTomorrow } from 'date-fns'; import { de } from 'date-fns/locale'; import { projectsStore } from '$lib/stores/projects.svelte'; + import { ContactAvatar } from '@manacore/shared-ui'; interface Props { task: Task; @@ -165,6 +166,33 @@ {/if} + + {#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)} +
+ {#if task.metadata?.assignee} +
+ +
+ {/if} + {#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0} +
+ {#each task.metadata.involvedContacts.slice(0, 2) as contact} +
+ +
+ {/each} + {#if task.metadata.involvedContacts.length > 2} + +{task.metadata.involvedContacts.length - 2} + {/if} +
+ {/if} +
+ {/if} + {#if dueDateText()} (null); - // Create a stable key from task IDs to detect real changes - let lastTaskIds = ''; + // Create a stable key from task IDs and updatedAt to detect real changes + let lastTaskKey = ''; - // Sync items with tasks only when the set of task IDs changes + // Sync items with tasks when IDs change OR when tasks are updated $effect(() => { - const currentIds = tasks - .map((t) => t.id) + // Include updatedAt in the key to detect task updates + const currentKey = tasks + .map((t) => `${t.id}:${t.updatedAt || ''}`) .sort() .join(','); - if (currentIds !== lastTaskIds) { + if (currentKey !== lastTaskKey) { items = [...tasks]; - lastTaskIds = currentIds; + lastTaskKey = currentKey; } }); @@ -70,10 +71,10 @@ } } - // Update local state and sync lastTaskIds to prevent $effect from reverting + // Update local state and sync lastTaskKey to prevent $effect from reverting items = newItems; - lastTaskIds = newItems - .map((t) => t.id) + lastTaskKey = newItems + .map((t) => `${t.id}:${t.updatedAt || ''}`) .sort() .join(','); } diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte index bebc1a973..ae1774d77 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte @@ -2,7 +2,7 @@ import type { Task } from '@todo/shared'; import { format, isToday, isPast, isTomorrow } from 'date-fns'; import { de } from 'date-fns/locale'; - import { ConfirmationModal } from '@manacore/shared-ui'; + import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui'; import TaskEditModal from '../TaskEditModal.svelte'; interface Props { @@ -249,6 +249,33 @@
{/if}
+ + + {#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)} +
+ {#if task.metadata?.assignee} +
+ +
+ {/if} + {#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0} +
+ {#each task.metadata.involvedContacts.slice(0, 2) as contact} +
+ +
+ {/each} + {#if task.metadata.involvedContacts.length > 2} + +{task.metadata.involvedContacts.length - 2} + {/if} +
+ {/if} +
+ {/if} @@ -500,6 +527,58 @@ font-weight: 500; } + /* Contacts display */ + .contacts-display { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + } + + .assignee-avatar { + position: relative; + } + + .assignee-avatar::after { + content: ''; + position: absolute; + bottom: -1px; + right: -1px; + width: 6px; + height: 6px; + background: #8b5cf6; + border-radius: 50%; + border: 1px solid white; + } + + :global(.dark) .assignee-avatar::after { + border-color: rgba(30, 30, 30, 1); + } + + .involved-avatars { + display: flex; + align-items: center; + } + + .involved-avatar { + margin-left: -0.375rem; + } + + .involved-avatar:first-child { + margin-left: 0; + } + + .more-contacts { + font-size: 0.625rem; + color: #6b7280; + margin-left: 0.25rem; + font-weight: 500; + } + + :global(.dark) .more-contacts { + color: #9ca3af; + } + /* Context Menu */ .context-menu { position: fixed; diff --git a/apps/todo/apps/web/src/lib/stores/contacts.svelte.ts b/apps/todo/apps/web/src/lib/stores/contacts.svelte.ts new file mode 100644 index 000000000..7b0a8d40a --- /dev/null +++ b/apps/todo/apps/web/src/lib/stores/contacts.svelte.ts @@ -0,0 +1,175 @@ +/** + * Contacts Store for Todo App + * + * Provides access to contacts from the Contacts app for task assignment. + */ + +import { browser } from '$app/environment'; +import { createContactsClient, type ContactsClient } from '@manacore/shared-auth'; +import type { ContactSummary } from '@manacore/shared-types'; +import { authStore } from './auth.svelte'; + +// State +let client: ContactsClient | null = null; +let isAvailable = $state(null); +let isChecking = $state(false); +let lastCheck = $state(0); + +// Cache for recent search results +let searchCache = $state>(new Map()); +const CACHE_TTL = 60000; // 1 minute + +// Get contacts API URL dynamically +function getContactsApiUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string }) + .__PUBLIC_CONTACTS_API_URL__; + return injectedUrl || 'http://localhost:3015/api/v1'; + } + return 'http://localhost:3015/api/v1'; +} + +// Initialize client lazily +function getClient(): ContactsClient { + if (!client) { + client = createContactsClient({ + apiUrl: getContactsApiUrl(), + getAuthToken: async () => authStore.getAccessToken(), + timeout: 5000, + }); + } + return client; +} + +export const contactsStore = { + // Getters + get isAvailable() { + return isAvailable; + }, + get isChecking() { + return isChecking; + }, + + /** + * Check if the Contacts API is available + * Caches result for 30 seconds + */ + async checkAvailability(): Promise { + const now = Date.now(); + // Skip if checked recently + if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) { + return isAvailable; + } + + isChecking = true; + try { + const available = await getClient().isAvailable(); + isAvailable = available; + lastCheck = now; + return available; + } catch { + isAvailable = false; + lastCheck = now; + return false; + } finally { + isChecking = false; + } + }, + + /** + * Search contacts by query + */ + async searchContacts(query: string): Promise { + // Check cache first + const cacheKey = query.toLowerCase().trim(); + const cached = searchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.results; + } + + // Check availability + if (isAvailable === null) { + await this.checkAvailability(); + } + + if (!isAvailable) { + return []; + } + + try { + const results = await getClient().searchContacts({ + query, + limit: 20, + excludeArchived: true, + }); + + // Cache results + searchCache.set(cacheKey, { + results, + timestamp: Date.now(), + }); + + return results; + } catch (error) { + console.error('[contactsStore] Search failed:', error); + return []; + } + }, + + /** + * Get a single contact by ID + */ + async getContact(id: string): Promise { + if (isAvailable === null) { + await this.checkAvailability(); + } + + if (!isAvailable) { + return null; + } + + try { + return await getClient().getContact(id); + } catch (error) { + console.error(`[contactsStore] Failed to get contact ${id}:`, error); + return null; + } + }, + + /** + * Get multiple contacts by IDs + */ + async getContacts(ids: string[]): Promise { + if (ids.length === 0) return []; + + if (isAvailable === null) { + await this.checkAvailability(); + } + + if (!isAvailable) { + return []; + } + + try { + return await getClient().getContacts(ids); + } catch (error) { + console.error('[contactsStore] Failed to get contacts:', error); + return []; + } + }, + + /** + * Clear the search cache + */ + clearCache() { + searchCache.clear(); + }, + + /** + * Reset availability check (force recheck on next call) + */ + resetAvailability() { + isAvailable = null; + lastCheck = 0; + }, +}; diff --git a/apps/todo/packages/shared/package.json b/apps/todo/packages/shared/package.json index 40ec5e22a..e5dbcb266 100644 --- a/apps/todo/packages/shared/package.json +++ b/apps/todo/packages/shared/package.json @@ -14,6 +14,9 @@ "scripts": { "type-check": "tsc --noEmit" }, + "dependencies": { + "@manacore/shared-types": "workspace:*" + }, "devDependencies": { "typescript": "^5.9.3" } diff --git a/apps/todo/packages/shared/src/types/task.ts b/apps/todo/packages/shared/src/types/task.ts index 66ea523d4..0f2fb874b 100644 --- a/apps/todo/packages/shared/src/types/task.ts +++ b/apps/todo/packages/shared/src/types/task.ts @@ -1,4 +1,5 @@ import type { Label } from './label'; +import type { ContactReference } from '@manacore/shared-types'; export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; @@ -26,6 +27,9 @@ export interface TaskMetadata { storyPoints?: number | null; // Fibonacci: 1, 2, 3, 5, 8, 13, 21 effectiveDuration?: EffectiveDuration | null; // Actual time spent funRating?: number | null; // 1-10 scale + // Contact associations + assignee?: ContactReference | null; // Person responsible for the task + involvedContacts?: ContactReference[]; // Other people involved } export interface Task { @@ -43,6 +47,12 @@ export interface Task { dueTime?: string | null; // HH:mm format startDate?: Date | string | null; + // Time-Blocking (for calendar integration) + scheduledDate?: Date | string | null; // Date when task is scheduled + scheduledStartTime?: string | null; // HH:mm format - when to start + scheduledEndTime?: string | null; // HH:mm format - when to end + estimatedDuration?: number | null; // Duration in minutes + // Priority & Status priority: TaskPriority; status: TaskStatus; @@ -84,6 +94,11 @@ export interface CreateTaskInput { dueDate?: string | null; dueTime?: string | null; startDate?: string | null; + // Time-Blocking + scheduledDate?: string | null; + scheduledStartTime?: string | null; + scheduledEndTime?: string | null; + estimatedDuration?: number | null; priority?: TaskPriority; recurrenceRule?: string | null; recurrenceEndDate?: string | null; @@ -100,6 +115,11 @@ export interface UpdateTaskInput { dueDate?: string | null; dueTime?: string | null; startDate?: string | null; + // Time-Blocking + scheduledDate?: string | null; + scheduledStartTime?: string | null; + scheduledEndTime?: string | null; + estimatedDuration?: number | null; priority?: TaskPriority; status?: TaskStatus; isCompleted?: boolean; diff --git a/docs/optimizable/foundation-layer-improvements.md b/docs/optimizable/foundation-layer-improvements.md new file mode 100644 index 000000000..d88baba58 --- /dev/null +++ b/docs/optimizable/foundation-layer-improvements.md @@ -0,0 +1,418 @@ +# Foundation Layer - Verbesserungsvorschläge + +> **Stand:** Dezember 2024 +> **Betrifft:** Contacts, Todo, Calendar (Foundation Services) + +## Aktuelle Architektur (Gut!) + +Die drei Foundation Services sind korrekt als **separate Services mit eigenen Datenbanken** aufgesetzt: + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Contacts │ │ Todo │ │ Calendar │ +│ :3010 │ │ :3011 │ │ :3012 │ +│ │ │ │ │ │ +│ contacts DB │ │ todo DB │ │ calendar DB │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +**Warum das richtig ist:** +- Unabhängige Deployments +- Failure Isolation +- Unabhängige Skalierung +- Keine Schema-Konflikte zwischen Teams + +--- + +## Verbesserungsvorschläge + +### 1. Foundation Clients Package + +**Aufwand:** Mittel | **Priorität:** Hoch + +Einheitlicher API-Client für alle Consumer Apps (Chat, Picture, Clock, etc.). + +**Neues Package:** `packages/foundation-clients/` + +```typescript +// packages/foundation-clients/src/index.ts +export class FoundationClients { + contacts: ContactsClient; + todo: TodoClient; + calendar: CalendarClient; + + constructor(config: FoundationConfig) { + this.contacts = new ContactsClient(config); + this.todo = new TodoClient(config); + this.calendar = new CalendarClient(config); + } +} + +// packages/foundation-clients/src/contacts.client.ts +export class ContactsClient { + private baseUrl: string; + private cache: Map = new Map(); + + async get(id: string): Promise { + // Mit Caching + const cached = this.cache.get(id); + if (cached && !this.isStale(cached)) { + return cached.data; + } + + const response = await fetch(`${this.baseUrl}/contacts/${id}`, { + headers: { Authorization: `Bearer ${this.token}` } + }); + + if (!response.ok) return null; + const contact = await response.json(); + this.cache.set(id, { data: contact, fetchedAt: Date.now() }); + return contact; + } + + async search(query: string): Promise { + // Für Autocomplete in anderen Apps + } + + async getBulk(ids: string[]): Promise { + // Effizient für Listen + } +} +``` + +**Nutzung in Consumer Apps:** + +```typescript +// apps/chat/apps/backend/src/chat.service.ts +import { FoundationClients } from '@manacore/foundation-clients'; + +@Injectable() +export class ChatService { + private foundation: FoundationClients; + + constructor(configService: ConfigService) { + this.foundation = new FoundationClients({ + contactsUrl: configService.get('CONTACTS_API_URL'), + todoUrl: configService.get('TODO_API_URL'), + calendarUrl: configService.get('CALENDAR_API_URL'), + }); + } + + async getMessageWithContact(messageId: string) { + const message = await this.getMessage(messageId); + const sender = await this.foundation.contacts.get(message.senderId); + return { ...message, sender }; + } +} +``` + +--- + +### 2. Event Bus (Redis Pub/Sub) + +**Aufwand:** Mittel | **Priorität:** Mittel + +Ermöglicht reaktive Updates zwischen Services ohne Polling. + +**Events definieren:** + +```typescript +// packages/foundation-events/src/index.ts +export const FoundationEvents = { + // Contacts + CONTACT_CREATED: 'contact.created', + CONTACT_UPDATED: 'contact.updated', + CONTACT_DELETED: 'contact.deleted', + + // Todo + TASK_CREATED: 'task.created', + TASK_COMPLETED: 'task.completed', + TASK_DELETED: 'task.deleted', + + // Calendar + EVENT_CREATED: 'event.created', + EVENT_UPDATED: 'event.updated', + EVENT_DELETED: 'event.deleted', +} as const; + +export interface TaskCompletedEvent { + taskId: string; + userId: string; + completedAt: string; + linkedCalendarEventId?: string; +} +``` + +**Publisher (Todo Service):** + +```typescript +// apps/todo/apps/backend/src/task/task.service.ts +import { RedisService } from '@manacore/shared-redis'; +import { FoundationEvents } from '@manacore/foundation-events'; + +@Injectable() +export class TaskService { + constructor(private redis: RedisService) {} + + async completeTask(taskId: string, userId: string) { + const task = await this.markCompleted(taskId); + + // Event publizieren + await this.redis.publish(FoundationEvents.TASK_COMPLETED, { + taskId: task.id, + userId, + completedAt: new Date().toISOString(), + linkedCalendarEventId: task.metadata?.linkedCalendarEventId, + }); + + return task; + } +} +``` + +**Subscriber (Calendar Service):** + +```typescript +// apps/calendar/apps/backend/src/calendar.module.ts +import { FoundationEvents } from '@manacore/foundation-events'; + +@Injectable() +export class CalendarEventSubscriber implements OnModuleInit { + constructor( + private redis: RedisService, + private eventService: EventService + ) {} + + onModuleInit() { + this.redis.subscribe(FoundationEvents.TASK_COMPLETED, async (data) => { + if (data.linkedCalendarEventId) { + await this.eventService.markLinkedTaskCompleted( + data.linkedCalendarEventId + ); + } + }); + } +} +``` + +**Use Cases:** + +| Event | Reaktion | +|-------|----------| +| `task.completed` | Calendar markiert verknüpftes Event | +| `contact.updated` | Chat aktualisiert Sender-Anzeige | +| `event.deleted` | Todo entfernt `linkedCalendarEventId` | +| `contact.deleted` | Alle Apps entfernen Kontakt-Referenzen | + +--- + +### 3. Bulk-Endpoints + +**Aufwand:** Klein | **Priorität:** Hoch + +Reduziert N+1 API-Calls bei Listen-Ansichten. + +**Contacts Service:** + +```typescript +// apps/contacts/apps/backend/src/contact/contact.controller.ts +@Controller('contacts') +export class ContactController { + @Post('bulk') + async getBulk(@Body() body: { ids: string[] }): Promise { + return this.contactService.findByIds(body.ids); + } + + @Get('search') + async search( + @Query('q') query: string, + @Query('limit') limit = 10 + ): Promise { + return this.contactService.search(query, limit); + } +} +``` + +**Todo Service:** + +```typescript +// apps/todo/apps/backend/src/task/task.controller.ts +@Controller('tasks') +export class TaskController { + @Post('bulk') + async getBulk(@Body() body: { ids: string[] }): Promise { + return this.taskService.findByIds(body.ids); + } + + @Get('by-contact/:contactId') + async getByContact(@Param('contactId') contactId: string): Promise { + // Tasks die mit einem Kontakt verknüpft sind + return this.taskService.findByLinkedContact(contactId); + } +} +``` + +**Calendar Service:** + +```typescript +// apps/calendar/apps/backend/src/event/event.controller.ts +@Controller('events') +export class EventController { + @Post('bulk') + async getBulk(@Body() body: { ids: string[] }): Promise { + return this.eventService.findByIds(body.ids); + } + + @Get('by-attendee') + async getByAttendee(@Query('email') email: string): Promise { + return this.eventService.findByAttendeeEmail(email); + } +} +``` + +--- + +### 4. Caching-Layer + +**Aufwand:** Klein | **Priorität:** Mittel + +Kontakte ändern sich selten - perfekt für Caching. + +**In Foundation Clients (Client-Side Cache):** + +```typescript +// packages/foundation-clients/src/cache.ts +export class SimpleCache { + private cache = new Map(); + private ttlMs: number; + + constructor(ttlSeconds = 300) { + this.ttlMs = ttlSeconds * 1000; + } + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + return entry.data; + } + + set(key: string, data: T): void { + this.cache.set(key, { + data, + expiresAt: Date.now() + this.ttlMs, + }); + } + + invalidate(key: string): void { + this.cache.delete(key); + } +} +``` + +**Redis Cache (Server-Side):** + +```typescript +// apps/contacts/apps/backend/src/contact/contact.service.ts +@Injectable() +export class ContactService { + private readonly CACHE_TTL = 300; // 5 Minuten + + async findById(id: string): Promise { + // 1. Redis Cache prüfen + const cached = await this.redis.get(`contact:${id}`); + if (cached) return JSON.parse(cached); + + // 2. DB Query + const contact = await this.db + .select() + .from(contacts) + .where(eq(contacts.id, id)) + .limit(1); + + if (contact[0]) { + // 3. In Cache speichern + await this.redis.setex( + `contact:${id}`, + this.CACHE_TTL, + JSON.stringify(contact[0]) + ); + } + + return contact[0] || null; + } + + async update(id: string, data: UpdateContactDto): Promise { + const updated = await this.db + .update(contacts) + .set(data) + .where(eq(contacts.id, id)) + .returning(); + + // Cache invalidieren + await this.redis.del(`contact:${id}`); + + return updated[0]; + } +} +``` + +--- + +## Implementierungs-Reihenfolge + +| Phase | Task | Abhängigkeiten | +|-------|------|----------------| +| **1** | Bulk-Endpoints hinzufügen | Keine | +| **2** | Foundation Clients Package erstellen | Bulk-Endpoints | +| **3** | Client-Side Caching in Foundation Clients | Foundation Clients | +| **4** | Redis Cache in Services | Redis Setup | +| **5** | Event Bus Setup | Redis Setup | +| **6** | Event Publisher/Subscriber | Event Bus | + +--- + +## Neue Package-Struktur + +``` +packages/ +├── foundation-clients/ # NEU: API Clients +│ ├── src/ +│ │ ├── contacts.client.ts +│ │ ├── todo.client.ts +│ │ ├── calendar.client.ts +│ │ ├── cache.ts +│ │ └── index.ts +│ └── package.json +│ +├── foundation-events/ # NEU: Event Definitions +│ ├── src/ +│ │ ├── contact.events.ts +│ │ ├── task.events.ts +│ │ ├── calendar.events.ts +│ │ └── index.ts +│ └── package.json +│ +├── shared-types/ # Existiert bereits +│ └── src/ +│ ├── contact.ts # ContactReference, ContactSummary +│ └── ... +│ +└── shared-redis/ # NEU oder erweitern + └── src/ + ├── redis.service.ts + ├── pub-sub.ts + └── index.ts +``` + +--- + +## Offene Fragen + +- [ ] Welche Consumer Apps werden als erste integriert? +- [ ] Redis bereits im Stack oder neu einführen? +- [ ] Cache TTL pro Entity-Typ oder einheitlich? +- [ ] Event Bus: Redis Pub/Sub vs. dediziertes System (Bull, etc.)? diff --git a/packages/shared-auth/package.json b/packages/shared-auth/package.json index 41bd38b6b..661f49e4d 100644 --- a/packages/shared-auth/package.json +++ b/packages/shared-auth/package.json @@ -15,6 +15,7 @@ "lint": "eslint ." }, "dependencies": { + "@manacore/shared-types": "workspace:*", "base64-js": "^1.5.1" }, "devDependencies": { diff --git a/packages/shared-auth/src/clients/contactsClient.ts b/packages/shared-auth/src/clients/contactsClient.ts new file mode 100644 index 000000000..521d77699 --- /dev/null +++ b/packages/shared-auth/src/clients/contactsClient.ts @@ -0,0 +1,204 @@ +/** + * Contacts API Client for cross-app integration + * + * This client allows other apps (Todo, Calendar) to search and fetch contacts + * from the Contacts app backend. + */ + +import type { ContactSummary } from '@manacore/shared-types'; + +export interface ContactsClientConfig { + /** Base URL of the Contacts API (e.g., http://localhost:3015/api/v1) */ + apiUrl: string; + /** Function to get the current auth token */ + getAuthToken: () => Promise; + /** Request timeout in ms (default: 5000) */ + timeout?: number; +} + +export interface ContactSearchOptions { + /** Search query string */ + query?: string; + /** Maximum number of results */ + limit?: number; + /** Skip archived contacts */ + excludeArchived?: boolean; +} + +/** + * Client for accessing the Contacts API from other apps + */ +export class ContactsClient { + private config: ContactsClientConfig; + private available: boolean | null = null; + + constructor(config: ContactsClientConfig) { + this.config = { + timeout: 5000, + ...config, + }; + } + + /** + * Check if the Contacts API is available + */ + async isAvailable(): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + const response = await fetch(`${this.config.apiUrl}/health`, { + method: 'GET', + signal: controller.signal, + }); + + clearTimeout(timeoutId); + this.available = response.ok; + return this.available; + } catch { + this.available = false; + return false; + } + } + + /** + * Get cached availability status (call isAvailable() to refresh) + */ + getCachedAvailability(): boolean | null { + return this.available; + } + + /** + * Search contacts by query string + */ + async searchContacts(options: ContactSearchOptions = {}): Promise { + const { query = '', limit = 20, excludeArchived = true } = options; + + const params = new URLSearchParams(); + if (query) params.set('search', query); + if (limit) params.set('limit', String(limit)); + if (excludeArchived) params.set('isArchived', 'false'); + + try { + const response = (await this.fetchWithAuth(`/contacts?${params.toString()}`)) as { + contacts?: Record[]; + }; + return this.mapToContactSummaries(response.contacts || []); + } catch (error) { + console.error('[ContactsClient] Failed to search contacts:', error); + return []; + } + } + + /** + * Get a single contact by ID + */ + async getContact(id: string): Promise { + try { + const response = (await this.fetchWithAuth(`/contacts/${id}`)) as { + contact?: Record; + }; + if (response.contact) { + return this.mapToContactSummary(response.contact); + } + return null; + } catch (error) { + console.error(`[ContactsClient] Failed to get contact ${id}:`, error); + return null; + } + } + + /** + * Get multiple contacts by IDs (batch fetch) + */ + async getContacts(ids: string[]): Promise { + if (ids.length === 0) return []; + + // Contacts API doesn't have a batch endpoint, so we fetch individually + // but with Promise.allSettled to handle partial failures gracefully + const results = await Promise.allSettled(ids.map((id) => this.getContact(id))); + + return results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' && result.value !== null + ) + .map((result) => result.value as ContactSummary); + } + + /** + * Internal fetch with auth token + */ + private async fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise { + const token = await this.config.getAuthToken(); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + try { + const response = await fetch(`${this.config.apiUrl}${endpoint}`, { + ...options, + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timeout'); + } + throw error; + } + } + + /** + * Map API contact response to ContactSummary + */ + private mapToContactSummary(contact: Record): ContactSummary { + return { + id: contact.id as string, + displayName: + (contact.displayName as string) || + [contact.firstName, contact.lastName].filter(Boolean).join(' ') || + (contact.email as string) || + 'Unbekannt', + firstName: contact.firstName as string | undefined, + lastName: contact.lastName as string | undefined, + email: contact.email as string | undefined, + phone: (contact.phone as string) || (contact.mobile as string) || undefined, + company: contact.company as string | undefined, + photoUrl: contact.photoUrl as string | undefined, + }; + } + + /** + * Map array of contacts to ContactSummary[] + */ + private mapToContactSummaries(contacts: Record[]): ContactSummary[] { + return contacts.map((c) => this.mapToContactSummary(c)); + } +} + +/** + * Create a ContactsClient instance + */ +export function createContactsClient(config: ContactsClientConfig): ContactsClient { + return new ContactsClient(config); +} diff --git a/packages/shared-auth/src/index.ts b/packages/shared-auth/src/index.ts index ba9084964..ce6391bb3 100644 --- a/packages/shared-auth/src/index.ts +++ b/packages/shared-auth/src/index.ts @@ -70,6 +70,10 @@ export { } from './interceptors/fetchInterceptor'; export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor'; +// Contacts client for cross-app integration +export { ContactsClient, createContactsClient } from './clients/contactsClient'; +export type { ContactsClientConfig, ContactSearchOptions } from './clients/contactsClient'; + /** * Initialize auth service with all adapters for web * diff --git a/packages/shared-types/src/contact.ts b/packages/shared-types/src/contact.ts new file mode 100644 index 000000000..06ea5efb9 --- /dev/null +++ b/packages/shared-types/src/contact.ts @@ -0,0 +1,80 @@ +/** + * Contact-related types for cross-app integration + * + * These types are used when referencing contacts from the Contacts app + * in other apps like Todo and Calendar. + */ + +/** + * Reference to a contact with cached display data. + * Used for offline display when Contacts API is unavailable. + */ +export interface ContactReference { + /** Contact ID from Contacts app */ + contactId: string; + /** Cached display name */ + displayName: string; + /** Cached email */ + email?: string; + /** Cached photo URL */ + photoUrl?: string; + /** Cached company name */ + company?: string; + /** ISO timestamp when data was fetched (for cache invalidation) */ + fetchedAt: string; +} + +/** + * Summary of a contact from the Contacts API. + * Contains essential fields for display in selectors and lists. + */ +export interface ContactSummary { + id: string; + displayName: string; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + company?: string; + photoUrl?: string; +} + +/** + * Manual contact entry (when contact doesn't exist in Contacts app). + * Used for calendar attendees who aren't in the user's contacts. + */ +export interface ManualContactEntry { + /** Email address (required for manual entries) */ + email: string; + /** Display name (optional) */ + name?: string; + /** Indicates this is a manual entry, not from Contacts app */ + isManual: true; +} + +/** + * Union type for contact references that can be either + * a real contact or a manual entry. + */ +export type ContactOrManual = ContactReference | ManualContactEntry; + +/** + * Helper to check if a contact entry is manual + */ +export function isManualContact(contact: ContactOrManual): contact is ManualContactEntry { + return 'isManual' in contact && contact.isManual === true; +} + +/** + * Helper to create a ContactReference from a ContactSummary + */ +export function createContactReference(contact: ContactSummary): ContactReference { + return { + contactId: contact.id, + displayName: contact.displayName, + email: contact.email, + photoUrl: contact.photoUrl, + company: contact.company, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 36e08a07b..dd2f4de13 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -16,6 +16,9 @@ export * from './ui'; // Common utility types export * from './common'; +// Contact types for cross-app integration +export * from './contact'; + // API types export interface User { id: string; diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index f366d440f..e4b5665ae 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -38,6 +38,7 @@ "@manacore/shared-branding": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-theme": "workspace:*", + "@manacore/shared-types": "workspace:*", "d3-force": "^3.0.0", "d3-selection": "^3.0.0", "d3-transition": "^3.0.0", diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 9e3262b05..d8c032f24 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -39,6 +39,9 @@ export { // Feedback export { EmptyState } from './molecules'; +// Contacts +export { ContactAvatar, ContactBadge, ContactSelector } from './molecules'; + // Layout export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules'; diff --git a/packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte b/packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte new file mode 100644 index 000000000..07e3949e9 --- /dev/null +++ b/packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte @@ -0,0 +1,100 @@ + + +{#if photoUrl} + {name +{:else if initials} +
+ {initials} +
+{:else} +
+ +
+{/if} diff --git a/packages/shared-ui/src/molecules/contacts/ContactBadge.svelte b/packages/shared-ui/src/molecules/contacts/ContactBadge.svelte new file mode 100644 index 000000000..edc486731 --- /dev/null +++ b/packages/shared-ui/src/molecules/contacts/ContactBadge.svelte @@ -0,0 +1,185 @@ + + + + + + + {displayName} + {#if showEmail && email && email !== displayName} + {email} + {/if} + + + {#if removable} + + {/if} + + + diff --git a/packages/shared-ui/src/molecules/contacts/ContactSelector.svelte b/packages/shared-ui/src/molecules/contacts/ContactSelector.svelte new file mode 100644 index 000000000..40aadac06 --- /dev/null +++ b/packages/shared-ui/src/molecules/contacts/ContactSelector.svelte @@ -0,0 +1,711 @@ + + + + +
+ +
+ {#each selectedContacts as contact, index (index)} + handleRemoveContact(index)} /> + {/each} + + {#if canAddMore && !disabled} + + {/if} +
+ + + {#if isOpen} + + {/if} +
+ + diff --git a/packages/shared-ui/src/molecules/contacts/index.ts b/packages/shared-ui/src/molecules/contacts/index.ts new file mode 100644 index 000000000..b4f8c5628 --- /dev/null +++ b/packages/shared-ui/src/molecules/contacts/index.ts @@ -0,0 +1,4 @@ +// Contact selection and display components +export { default as ContactAvatar } from './ContactAvatar.svelte'; +export { default as ContactBadge } from './ContactBadge.svelte'; +export { default as ContactSelector } from './ContactSelector.svelte'; diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index 53aba560c..1a7f23173 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -39,6 +39,9 @@ export { // Feedback components export { EmptyState } from './feedback'; +// Contact components +export { ContactAvatar, ContactBadge, ContactSelector } from './contacts'; + // Layout components export { default as ModalFooter } from './ModalFooter.svelte'; export { default as DataCard } from './DataCard.svelte'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24a1a1fd2..cafd9bd8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: '@manacore/shared-theme-ui': specifier: workspace:* version: link:../../../../packages/shared-theme-ui + '@manacore/shared-types': + specifier: workspace:* + version: link:../../../../packages/shared-types '@manacore/shared-ui': specifier: workspace:* version: link:../../../../packages/shared-ui @@ -2655,6 +2658,9 @@ importers: '@manacore/shared-theme-ui': specifier: workspace:* version: link:../../../../packages/shared-theme-ui + '@manacore/shared-types': + specifier: workspace:* + version: link:../../../../packages/shared-types '@manacore/shared-ui': specifier: workspace:* version: link:../../../../packages/shared-ui @@ -2724,6 +2730,10 @@ importers: version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) apps/todo/packages/shared: + dependencies: + '@manacore/shared-types': + specifier: workspace:* + version: link:../../../../packages/shared-types devDependencies: typescript: specifier: ^5.9.3 @@ -3886,6 +3896,9 @@ importers: packages/shared-auth: dependencies: + '@manacore/shared-types': + specifier: workspace:* + version: link:../shared-types base64-js: specifier: ^1.5.1 version: 1.5.1 @@ -4289,6 +4302,9 @@ importers: '@manacore/shared-theme': specifier: workspace:* version: link:../shared-theme + '@manacore/shared-types': + specifier: workspace:* + version: link:../shared-types d3-force: specifier: ^3.0.0 version: 3.0.0