diff --git a/COMMANDS.md b/COMMANDS.md index b64107c1f..5b5e201ee 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -6,7 +6,7 @@ pnpm docker:up:all pnpm docker:down -pnpm dev:calendar:app +pnpm dev:calendar:full pnpm dev:todo:full pnpm dev:contacts:full pnpm dev:clock:full diff --git a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts index 70a7fbee0..f1778fb84 100644 --- a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts +++ b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts @@ -12,8 +12,9 @@ import { import type { EventMetadata } from '../../db/schema/events.schema'; export class CreateEventDto { + @IsOptional() @IsUUID() - calendarId: string; + calendarId?: string; @IsString() @MaxLength(500) diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts index 91eeb3ac4..6e80e89cc 100644 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ b/apps/calendar/apps/backend/src/event/event.service.ts @@ -85,11 +85,20 @@ export class EventService { } async create(userId: string, dto: CreateEventDto): Promise { - // Verify user owns the calendar - const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId); + let calendarId = dto.calendarId; + let calendar; + + // If no calendarId provided, get or create default calendar + if (!calendarId) { + calendar = await this.calendarService.getOrCreateDefaultCalendar(userId); + calendarId = calendar.id; + } else { + // Verify user owns the specified calendar + calendar = await this.calendarService.findByIdOrThrow(calendarId, userId); + } const newEvent: NewEvent = { - calendarId: dto.calendarId, + calendarId, userId, title: dto.title, description: dto.description, 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/api/base-client.ts b/apps/calendar/apps/web/src/lib/api/base-client.ts new file mode 100644 index 000000000..06f9ba7c7 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/base-client.ts @@ -0,0 +1,117 @@ +/** + * Base API Client Factory + * Eliminates duplication between calendar and todo API clients + */ + +import { browser } from '$app/environment'; + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface FetchOptions { + method?: HttpMethod; + body?: unknown; + token?: string; + isFormData?: boolean; + timeout?: number; +} + +export interface ApiResult { + data: T | null; + error: Error | null; +} + +export interface ApiClientConfig { + baseUrl: string; + apiPrefix?: string; + getAuthToken?: () => string | null; + defaultTimeout?: number; +} + +/** + * Creates a configured API client for a specific backend + */ +export function createApiClient(config: ApiClientConfig) { + const { baseUrl, apiPrefix = '/api/v1', defaultTimeout = 30000 } = config; + + async function fetchApi(endpoint: string, options: FetchOptions = {}): Promise> { + const { method = 'GET', body, token, isFormData = false, timeout = defaultTimeout } = options; + + // Get auth token + let authToken = token; + if (!authToken && browser) { + authToken = config.getAuthToken?.() ?? localStorage.getItem('@auth/appToken') ?? undefined; + } + + // Setup abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const headers: Record = {}; + + // Don't set Content-Type for FormData - browser sets it automatically with boundary + if (!isFormData) { + headers['Content-Type'] = 'application/json'; + } + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, { + method, + headers, + body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: new Error(errorData.message || `API error: ${response.status}`), + }; + } + + // Handle empty responses (204 No Content) + if (response.status === 204) { + return { data: null, error: null }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + return { + data: null, + error: new Error('Request timed out'), + }; + } + + return { + data: null, + error: error instanceof Error ? error : new Error('Unknown error'), + }; + } + } + + return { fetchApi }; +} + +/** + * Helper to build query strings from object + */ +export function buildQueryString(params: Record): string { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +} diff --git a/apps/calendar/apps/web/src/lib/api/client.ts b/apps/calendar/apps/web/src/lib/api/client.ts index 91e1fa9d6..fe5dad53b 100644 --- a/apps/calendar/apps/web/src/lib/api/client.ts +++ b/apps/calendar/apps/web/src/lib/api/client.ts @@ -2,66 +2,22 @@ * API Client for Calendar Backend */ -import { browser } from '$app/environment'; import { env } from '$env/dynamic/public'; +import { createApiClient, type FetchOptions, type ApiResult } from './base-client'; const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014'; -type FetchOptions = { - method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - body?: unknown; - token?: string; - isFormData?: boolean; -}; +const calendarClient = createApiClient({ + baseUrl: API_BASE, + apiPrefix: '/api/v1', +}); export async function fetchApi( endpoint: string, options: FetchOptions = {} -): Promise<{ data: T | null; error: Error | null }> { - const { method = 'GET', body, token, isFormData = false } = options; - - let authToken = token; - if (!authToken && browser) { - authToken = localStorage.getItem('@auth/appToken') || undefined; - } - - try { - const headers: Record = {}; - - // Don't set Content-Type for FormData - browser sets it automatically with boundary - if (!isFormData) { - headers['Content-Type'] = 'application/json'; - } - - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${API_BASE}/api/v1${endpoint}`, { - method, - headers, - body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `API error: ${response.status}`), - }; - } - - // Handle empty responses (204 No Content) - if (response.status === 204) { - return { data: null, error: null }; - } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { - return { - data: null, - error: error instanceof Error ? error : new Error('Unknown error'), - }; - } +): Promise> { + return calendarClient.fetchApi(endpoint, options); } + +// Re-export types for backwards compatibility +export type { FetchOptions, ApiResult }; diff --git a/apps/calendar/apps/web/src/lib/api/todos.ts b/apps/calendar/apps/web/src/lib/api/todos.ts index fa649d829..db72aac5d 100644 --- a/apps/calendar/apps/web/src/lib/api/todos.ts +++ b/apps/calendar/apps/web/src/lib/api/todos.ts @@ -3,11 +3,16 @@ * Allows Calendar app to fetch/manage todos from the Todo service */ -import { browser } from '$app/environment'; import { env } from '$env/dynamic/public'; +import { createApiClient, buildQueryString } from './base-client'; const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018'; +const todoClient = createApiClient({ + baseUrl: TODO_API_BASE, + apiPrefix: '/api/v1', +}); + // ============================================ // Types (mirrored from @todo/shared for cross-app use) // ============================================ @@ -68,6 +73,11 @@ export interface Task { dueDate?: string | null; dueTime?: string | null; startDate?: string | null; + // Time-Blocking (for calendar integration) + scheduledDate?: string | null; + scheduledStartTime?: string | null; // HH:mm format + scheduledEndTime?: string | null; // HH:mm format + estimatedDuration?: number | null; // Duration in minutes priority: TaskPriority; status: TaskStatus; isCompleted: boolean; @@ -92,6 +102,11 @@ export interface CreateTaskInput { projectId?: string | null; dueDate?: string | null; dueTime?: string | null; + // Time-Blocking + scheduledDate?: string | null; + scheduledStartTime?: string | null; + scheduledEndTime?: string | null; + estimatedDuration?: number | null; priority?: TaskPriority; labelIds?: string[]; subtasks?: Omit[]; @@ -105,6 +120,11 @@ export interface UpdateTaskInput { projectId?: string | null; dueDate?: string | null; dueTime?: string | null; + // Time-Blocking + scheduledDate?: string | null; + scheduledStartTime?: string | null; + scheduledEndTime?: string | null; + estimatedDuration?: number | null; priority?: TaskPriority; status?: TaskStatus; isCompleted?: boolean; @@ -150,78 +170,10 @@ interface LabelsResponse { } // ============================================ -// API Client +// API Client (using shared base client) // ============================================ -type FetchOptions = { - method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - body?: unknown; - token?: string; -}; - -async function fetchTodoApi( - endpoint: string, - options: FetchOptions = {} -): Promise<{ data: T | null; error: Error | null }> { - const { method = 'GET', body, token } = options; - - let authToken = token; - if (!authToken && browser) { - authToken = localStorage.getItem('@auth/appToken') || undefined; - } - - try { - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `Todo API error: ${response.status}`), - }; - } - - // Handle empty responses (204 No Content) - if (response.status === 204) { - return { data: null, error: null }; - } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { - return { - data: null, - error: error instanceof Error ? error : new Error('Failed to connect to Todo service'), - }; - } -} - -// ============================================ -// Helper Functions -// ============================================ - -function buildQueryString(query: TaskQuery): string { - const params = new URLSearchParams(); - Object.entries(query).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - params.append(key, String(value)); - } - }); - const queryString = params.toString(); - return queryString ? `?${queryString}` : ''; -} +const fetchTodoApi = todoClient.fetchApi; // ============================================ // Task API Functions @@ -230,7 +182,7 @@ function buildQueryString(query: TaskQuery): string { export async function getTasks( query: TaskQuery = {} ): Promise<{ data: Task[] | null; error: Error | null }> { - const queryString = buildQueryString(query); + const queryString = buildQueryString(query as Record); const result = await fetchTodoApi(`/tasks${queryString}`); return { data: result.data?.tasks || null, diff --git a/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte b/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte index 733730d0b..8ef3a8637 100644 --- a/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte +++ b/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte @@ -1,16 +1,12 @@ + +
+ + {#if onResizeStart && !task.isCompleted} +
+ {/if} + +
+ + + +
+ + {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..48cc39fc4 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte @@ -5,7 +5,6 @@ import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte'; import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte'; import { ChevronDown, ChevronRight, Plus, CheckSquare, AlertTriangle } from 'lucide-svelte'; - import { goto } from '$app/navigation'; import { onMount } from 'svelte'; interface Props { @@ -18,8 +17,8 @@ let showQuickAdd = $state(false); let selectedTask = $state(null); - // Derived: combined overdue + today todos - const displayTodos = $derived(todosStore.getSidebarTodos(maxItems)); + // Derived: all active todos (overdue + today + upcoming) + const displayTodos = $derived(todosStore.getSidebarTodos()); const overdueCount = $derived(todosStore.overdueTodos.length); const totalActiveCount = $derived(todosStore.activeTodosCount); @@ -27,6 +26,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() { @@ -53,16 +54,12 @@ function handleQuickAddCancel() { showQuickAdd = false; } - - function goToAllTasks() { - goto('/tasks'); - }
-
+ - + {#if isExpanded} @@ -114,16 +111,11 @@ {task} variant="compact" showProject={false} + draggable={!task.isCompleted} onclick={() => handleTaskClick(task)} /> {/each} - - {#if totalActiveCount > maxItems} - - {/if} {/if} @@ -160,29 +152,31 @@ align-items: center; justify-content: space-between; width: 100%; - padding: 0.75rem 1rem; + padding: 0 0.5rem 0 0; + } + + .header-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + padding: 0.75rem 0.5rem 0.75rem 1rem; border: none; background: transparent; + color: hsl(var(--color-foreground)); cursor: pointer; transition: background 150ms ease; } - .section-header:hover { + .header-toggle:hover { background: hsl(var(--color-muted) / 0.3); } - .header-left { - display: flex; - align-items: center; - gap: 0.5rem; - color: hsl(var(--color-foreground)); - } - - .header-left :global(svg) { + .header-toggle :global(svg) { color: hsl(var(--color-muted-foreground)); } - .header-left :global(.section-icon) { + .header-toggle :global(.section-icon) { color: hsl(var(--color-primary)); } @@ -234,6 +228,8 @@ display: flex; flex-direction: column; gap: 0.25rem; + max-height: 300px; + overflow-y: auto; } .service-unavailable, @@ -267,24 +263,6 @@ } } - .show-all-button { - width: 100%; - padding: 0.5rem; - margin-top: 0.5rem; - border: none; - background: transparent; - color: hsl(var(--color-primary)); - font-size: 0.8125rem; - font-weight: 500; - cursor: pointer; - border-radius: var(--radius-md); - transition: background 150ms ease; - } - - .show-all-button:hover { - background: hsl(var(--color-primary) / 0.1); - } - .quick-add-wrapper { margin-top: 0.5rem; padding: 0 0.25rem; 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..61b59f64a 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, @@ -21,13 +22,17 @@ getWeek, } from 'date-fns'; import { de, enUS, fr, es, it } from 'date-fns/locale'; - import { locale } from 'svelte-i18n'; + import { locale, _ } from 'svelte-i18n'; + + import type { CalendarEvent } from '@calendar/shared'; 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 @@ -94,7 +99,7 @@ // Drag & Drop State let isDragging = $state(false); - let draggedEvent = $state(null); + let draggedEvent = $state(null); let dragOffsetMinutes = $state(0); let dragTargetDay = $state(null); let dragPreviewTop = $state(0); @@ -102,7 +107,7 @@ // Resize State let isResizing = $state(false); - let resizeEvent = $state(null); + let resizeEvent = $state(null); let resizeEdge = $state<'top' | 'bottom'>('bottom'); let resizeOriginalStart = $state(null); let resizeOriginalEnd = $state(null); @@ -112,6 +117,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; @@ -124,8 +143,11 @@ } // Get display mode for an event (per-event override takes precedence over global setting) - function getEventDisplayMode(event: any): 'header' | 'block' { - return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode; + function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' { + return ( + (event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode || + settingsStore.allDayDisplayMode + ); } // Split all-day events by display mode @@ -142,7 +164,7 @@ days.some((day) => getHeaderAllDayEventsForDay(day).length > 0) ); - function getEventStyle(event: any) { + function getEventStyle(event: CalendarEvent) { const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; @@ -157,12 +179,43 @@ 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); } - function handleEventClick(event: any, e: MouseEvent) { + function handleEventClick(event: CalendarEvent, e: MouseEvent) { // Don't navigate if we just finished dragging or resizing, or if we moved if (isDragging || isResizing || hasMoved) { e.preventDefault(); @@ -173,7 +226,11 @@ }, 100); return; } - goto(`/?event=${event.id}`); + if (onEventClick) { + onEventClick(event); + } else { + goto(`/?event=${event.id}`); + } } function handleSlotClick(day: Date, hour: number, e: MouseEvent) { @@ -220,7 +277,7 @@ return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; } - function startDrag(event: any, e: PointerEvent) { + function startDrag(event: CalendarEvent, e: PointerEvent) { e.preventDefault(); e.stopPropagation(); @@ -323,7 +380,7 @@ // ========== Resize Functions ========== - function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) { + function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) { e.preventDefault(); e.stopPropagation(); @@ -439,6 +496,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 +773,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; + } } } @@ -473,7 +801,8 @@ {#if settingsStore.showWeekNumbers}
- KW {weekNumber} + {$_('views.weekNumber')} + {weekNumber}
{/if} @@ -482,7 +811,7 @@
{#if settingsStore.showWeekNumbers} - KW {weekNumber} + {$_('views.weekNumber')} {weekNumber} {/if}
{#each days as day} @@ -538,7 +867,15 @@
{#each days as day, dayIndex} -
+ +
handleSidebarDragOver(e, day)} + ondragleave={handleSidebarDragLeave} + ondrop={(e) => handleSidebarDrop(e, day)} + > {#each hours as hour}
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)} - {event.title || (isDraft ? '(Neuer Termin)' : '')} + {event.title || (isDraft ? $_('calendar.draftEvent') : '')}
startResize(event, 'bottom', e)} role="slider" - aria-label="Endzeit ändern" + aria-label={$_('event.changeEndTime')} + aria-valuenow={0} tabindex="-1" >
{/each} - - {#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent.id)} + + {#each getScheduledTasksForDay(day) as task (task.id)} + {@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id} + {@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id} + {@const isTaskCrossDayDrag = + isTaskBeingDragged && + taskDragTargetDay !== null && + !isSameDay(day, taskDragTargetDay)} + + {/each} + + + {#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)} + + {/if} + + + {#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
void; + onEventClick?: (event: CalendarEvent) => void; } - let { onQuickCreate }: Props = $props(); + let { onQuickCreate, onEventClick }: Props = $props(); // Derived values let year = $derived(viewStore.currentDate.getFullYear()); diff --git a/apps/calendar/apps/web/src/lib/components/event/AttendeeSelector.svelte b/apps/calendar/apps/web/src/lib/components/event/AttendeeSelector.svelte new file mode 100644 index 000000000..678284f05 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/event/AttendeeSelector.svelte @@ -0,0 +1,266 @@ + + +
+ + {#if attendees.length > 0} +
+ {#each attendees as attendee (attendee.email)} +
+ + +
+
+ {attendee.name || attendee.email} +
+ {#if attendee.name && attendee.email} +
+ {attendee.email} +
+ {/if} + {#if attendee.company} +
+ {attendee.company} +
+ {/if} +
+ + +
+ + + {#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/EventDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte index 4ec2ee327..3f802dc68 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte @@ -145,8 +145,8 @@ - -