feat(contacts): integrate contacts into Todo and Calendar apps

- 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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-11 16:00:08 +01:00 committed by Wuesteon
parent 307f1ae22e
commit 0ecbf69ebc
50 changed files with 5791 additions and 53 deletions

View file

@ -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:*",

View file

@ -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<Task | null>(null);
let taskDragPreviewTop = $state(0);
let taskDragPreviewHeight = $state(0);
// Task Resize State
let isTaskResizing = $state(false);
let resizeTask = $state<Task | null>(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}
</div>
<div class="day-column" class:today={isToday(viewStore.currentDate)} bind:this={dayColumnRef}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-column"
class:today={isToday(viewStore.currentDate)}
class:drop-target={isSidebarDropTarget}
bind:this={dayColumnRef}
ondragover={handleSidebarDragOver}
ondragleave={handleSidebarDragLeave}
ondrop={handleSidebarDrop}
>
{#each hours as hour}
<button
class="hour-slot"
@ -503,6 +785,25 @@
</div>
{/each}
<!-- Scheduled Tasks (Time-Blocking) -->
{#each getScheduledTasks() as task (task.id)}
{@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id}
{@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id}
<TaskBlock
{task}
style={isTaskBeingDragged
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
: isTaskBeingResized
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
: getTaskStyle(task)}
{onTaskClick}
onDragStart={handleTaskDragStart}
onResizeStart={handleTaskResizeStart}
isDragging={isTaskBeingDragged}
isResizing={isTaskBeingResized}
/>
{/each}
<!-- Current time indicator -->
{#if isToday(viewStore.currentDate)}
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
@ -624,6 +925,12 @@
background: hsl(var(--color-primary) / 0.05);
}
.day-column.drop-target {
background: hsl(var(--color-primary) / 0.15);
outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px;
}
.event-card {
position: absolute;
left: 4px;

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -27,8 +29,10 @@
interface Props {
dayCount: 5 | 10 | 14;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { dayCount, onQuickCreate }: Props = $props();
let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Get date-fns locale based on current app locale
const dateLocales = { de, en: enUS, fr, es, it };
@ -111,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<Task | null>(null);
let taskDragTargetDay = $state<Date | null>(null);
let taskDragPreviewTop = $state(0);
let taskDragPreviewHeight = $state(0);
// Task Resize State
let isTaskResizing = $state(false);
let resizeTask = $state<Task | null>(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;
@ -156,6 +174,35 @@
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 '';
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, 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);
@ -437,15 +484,258 @@
hasMoved = false;
}
// ========== 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) return;
hasMoved = true;
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;
}
}
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;
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
}
async function handleTaskDragEnd() {
document.removeEventListener('pointermove', handleTaskDragMove);
document.removeEventListener('pointerup', handleTaskDragEnd);
if (!isTaskDragging || !draggedTask || !hasMoved) {
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = 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, {
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;
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') {
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 {
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() {
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 sidebarDropTarget = $state<{ day: Date; y: number } | null>(null);
function handleSidebarDragOver(e: DragEvent, day: Date) {
e.preventDefault();
if (!e.dataTransfer) return;
const types = e.dataTransfer.types;
if (!types.includes('application/json')) return;
e.dataTransfer.dropEffect = 'move';
sidebarDropTarget = { day, y: e.clientY };
}
function handleSidebarDragLeave(e: DragEvent) {
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;
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')}`;
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(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) {
if (e.key === 'Escape' && (isDragging || isResizing)) {
if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) {
e.preventDefault();
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
document.removeEventListener('pointermove', handleTaskDragMove);
document.removeEventListener('pointerup', handleTaskDragEnd);
document.removeEventListener('pointermove', handleTaskResizeMove);
document.removeEventListener('pointerup', handleTaskResizeEnd);
isDragging = false;
draggedEvent = null;
dragTargetDay = null;
@ -453,6 +743,11 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = null;
isTaskResizing = false;
resizeTask = null;
hasMoved = false;
}
}
@ -516,7 +811,15 @@
<!-- Day columns -->
<div class="days-container" bind:this={daysContainerEl}>
{#each days as day}
<div class="day-column" class:today={isToday(day)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-column"
class:today={isToday(day)}
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
ondragover={(e) => handleSidebarDragOver(e, day)}
ondragleave={handleSidebarDragLeave}
ondrop={(e) => handleSidebarDrop(e, day)}
>
{#each hours as hour}
<button
class="hour-slot"
@ -542,13 +845,16 @@
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isCrossDayDrag =
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
<div
class="event-card"
class:dragging={isBeingDragged}
class:dragging={isBeingDragged && !isCrossDayDrag}
class:dragging-source={isCrossDayDrag}
class:resizing={isBeingResized}
class:draft={isDraft}
data-event-id={event.id}
style={isBeingDragged
style={isBeingDragged && !isCrossDayDrag
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
: isBeingResized
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
@ -587,10 +893,43 @@
</div>
{/each}
<!-- Drag preview ghost (for cross-day dragging) -->
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent.id)}
<!-- Scheduled Tasks (Time-Blocking) -->
{#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)}
<TaskBlock
{task}
style={isTaskBeingDragged && !isTaskCrossDayDrag
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
: isTaskBeingResized
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
: getTaskStyle(task)}
{onTaskClick}
onDragStart={handleTaskDragStart}
onResizeStart={handleTaskResizeStart}
isDragging={isTaskBeingDragged && !isTaskCrossDayDrag}
isResizing={isTaskBeingResized}
isDraggingSource={isTaskCrossDayDrag}
/>
{/each}
<!-- Task Drag preview (solid) for cross-day dragging - shows where task will be -->
{#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)}
<TaskBlock
task={draggedTask}
style="top: {taskDragPreviewTop}%; height: {taskDragPreviewHeight}%;"
isDragging={true}
/>
{/if}
<!-- Drag preview (solid) for cross-day dragging - shows where event will be -->
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
<div
class="event-card drag-ghost"
class="event-card drag-preview"
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
draggedEvent.calendarId
)};"
@ -874,10 +1213,51 @@
}
}
.event-card.drag-ghost {
opacity: 0.6;
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
border: 2px dashed white;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time {
opacity: 0.5;
}
/* Solid preview at target position during cross-day drag */
.event-card.drag-preview {
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Task drag ghost */
.task-drag-ghost {
position: absolute;
left: 2px;
right: 2px;
padding: 4px 6px;
background: hsl(var(--color-surface) / 0.8);
border: 2px dashed hsl(var(--color-primary));
border-radius: var(--radius-sm);
opacity: 0.7;
pointer-events: none;
z-index: 50;
overflow: hidden;
}
.task-drag-ghost .task-title {
font-size: 0.7rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
/* Sidebar task drop target */
.day-column.drop-target {
background: hsl(var(--color-primary) / 0.15);
outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px;
}
.compact .event-card,

View file

@ -0,0 +1,297 @@
<script lang="ts">
import type { Task } from '$lib/api/todos';
import { todosStore } from '$lib/stores/todos.svelte';
import { _ } from 'svelte-i18n';
import { CheckSquare, Square } from 'lucide-svelte';
interface Props {
task: Task;
style: string;
onTaskClick?: (task: Task) => void;
onDragStart?: (task: Task, e: PointerEvent) => void;
onResizeStart?: (task: Task, edge: 'top' | 'bottom', e: PointerEvent) => void;
isDragging?: boolean;
isResizing?: boolean;
isDraggingSource?: boolean; // True when this is the source of a cross-day drag (shows as ghost)
}
let {
task,
style,
onTaskClick,
onDragStart,
onResizeStart,
isDragging = false,
isResizing = false,
isDraggingSource = false,
}: Props = $props();
// Priority colors
const PRIORITY_COLORS: Record<string, string> = {
urgent: 'hsl(0, 72%, 51%)', // red
high: 'hsl(25, 95%, 53%)', // orange
medium: 'hsl(48, 96%, 53%)', // yellow
low: 'hsl(142, 71%, 45%)', // green
};
let priorityColor = $derived(PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium);
async function toggleComplete(e: MouseEvent) {
e.stopPropagation();
await todosStore.toggleComplete(task.id);
}
function handleClick(e: MouseEvent) {
// Don't trigger click if we just finished dragging
if (isDragging || isResizing) {
e.preventDefault();
e.stopPropagation();
return;
}
onTaskClick?.(task);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTaskClick?.(task);
}
}
function handlePointerDown(e: PointerEvent) {
// Don't allow dragging completed tasks
if (task.isCompleted) return;
// Don't start drag from checkbox
if ((e.target as HTMLElement).closest('.task-checkbox')) return;
// Don't start drag from resize handles
if ((e.target as HTMLElement).closest('.resize-handle')) return;
onDragStart?.(task, e);
}
function handleResizeTop(e: PointerEvent) {
if (task.isCompleted) return;
e.stopPropagation();
onResizeStart?.(task, 'top', e);
}
function handleResizeBottom(e: PointerEvent) {
if (task.isCompleted) return;
e.stopPropagation();
onResizeStart?.(task, 'bottom', e);
}
</script>
<div
class="task-block"
class:completed={task.isCompleted}
class:dragging={isDragging}
class:resizing={isResizing}
class:dragging-source={isDraggingSource}
{style}
role="button"
tabindex="0"
aria-label="{$_('todo.task')}: {task.title}"
onclick={handleClick}
onkeydown={handleKeydown}
onpointerdown={handlePointerDown}
>
<!-- Top resize handle (only for non-completed tasks) -->
{#if onResizeStart && !task.isCompleted}
<div
class="resize-handle top"
onpointerdown={handleResizeTop}
role="slider"
aria-label={$_('event.changeStartTime')}
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
<div class="task-priority-indicator" style="background-color: {priorityColor}"></div>
<button
class="task-checkbox"
onclick={toggleComplete}
aria-label={task.isCompleted ? $_('todo.markIncomplete') : $_('todo.markComplete')}
>
{#if task.isCompleted}
<CheckSquare size={14} />
{:else}
<Square size={14} />
{/if}
</button>
<div class="task-content">
<span class="task-time">
{task.scheduledStartTime || ''}
{#if task.scheduledEndTime}
- {task.scheduledEndTime}
{/if}
</span>
<span class="task-title">{task.title}</span>
</div>
<!-- Bottom resize handle (only for non-completed tasks) -->
{#if onResizeStart && !task.isCompleted}
<div
class="resize-handle bottom"
onpointerdown={handleResizeBottom}
role="slider"
aria-label={$_('event.changeEndTime')}
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
</div>
<style>
.task-block {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
text-align: left;
cursor: grab;
z-index: 1;
overflow: hidden;
display: flex;
align-items: flex-start;
gap: 4px;
transition:
box-shadow 0.15s ease,
opacity 0.15s ease;
touch-action: none;
user-select: none;
}
.task-block:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
border-color: hsl(var(--color-primary) / 0.5);
}
.task-block.completed {
background: hsl(var(--color-muted) / 0.3);
cursor: default;
}
.task-block.completed .task-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.task-block.completed .task-checkbox {
color: hsl(var(--color-success, 142 71% 45%));
}
.task-block.completed .task-priority-indicator {
opacity: 0.4;
}
.task-block.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.task-block.resizing {
opacity: 0.85;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
outline: 2px dashed hsl(var(--color-primary) / 0.6);
outline-offset: -2px;
}
/* Ghost style for source position during cross-day drag */
.task-block.dragging-source {
opacity: 0.5;
background: transparent;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
}
.task-block.dragging-source .task-title,
.task-block.dragging-source .task-time,
.task-block.dragging-source .task-checkbox {
opacity: 0.5;
}
.task-priority-indicator {
width: 3px;
min-height: 100%;
border-radius: 2px;
flex-shrink: 0;
align-self: stretch;
}
.task-checkbox {
flex-shrink: 0;
padding: 0;
margin-top: 1px;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--color-foreground));
display: flex;
align-items: center;
justify-content: center;
}
.task-checkbox:hover {
color: hsl(var(--color-primary));
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.task-time {
font-size: 0.6rem;
color: hsl(var(--color-muted-foreground));
display: block;
}
.task-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(var(--color-foreground));
}
/* Resize handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
}
.resize-handle.top {
top: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.task-block:hover .resize-handle {
opacity: 1;
background: hsl(var(--color-primary) / 0.2);
}
</style>

View file

@ -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}

View file

@ -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<Task | null>(null);
let taskDragTargetDay = $state<Date | null>(null);
let taskDragPreviewTop = $state(0);
let taskDragPreviewHeight = $state(0);
// Task Resize State
let isTaskResizing = $state(false);
let resizeTask = $state<Task | null>(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 @@
<!-- Day columns -->
<div class="days-container" bind:this={daysContainerEl}>
{#each days as day, dayIndex}
<div class="day-column" class:today={isToday(day)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-column"
class:today={isToday(day)}
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
ondragover={(e) => handleSidebarDragOver(e, day)}
ondragleave={handleSidebarDragLeave}
ondrop={(e) => handleSidebarDrop(e, day)}
>
{#each hours as hour}
<button
class="hour-slot"
@ -563,13 +890,16 @@
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isCrossDayDrag =
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
<div
class="event-card"
class:dragging={isBeingDragged}
class:dragging={isBeingDragged && !isCrossDayDrag}
class:dragging-source={isCrossDayDrag}
class:resizing={isBeingResized}
class:draft={isDraft}
data-event-id={event.id}
style={isBeingDragged
style={isBeingDragged && !isCrossDayDrag
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
: isBeingResized
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
@ -605,10 +935,43 @@
</div>
{/each}
<!-- Drag preview ghost (for cross-day dragging) -->
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent.id)}
<!-- Scheduled Tasks (Time-Blocking) -->
{#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)}
<TaskBlock
{task}
style={isTaskBeingDragged && !isTaskCrossDayDrag
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
: isTaskBeingResized
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
: getTaskStyle(task)}
{onTaskClick}
onDragStart={handleTaskDragStart}
onResizeStart={handleTaskResizeStart}
isDragging={isTaskBeingDragged && !isTaskCrossDayDrag}
isResizing={isTaskBeingResized}
isDraggingSource={isTaskCrossDayDrag}
/>
{/each}
<!-- Task Drag preview (solid) for cross-day dragging - shows where task will be -->
{#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)}
<TaskBlock
task={draggedTask}
style="top: {taskDragPreviewTop}%; height: {taskDragPreviewHeight}%;"
isDragging={true}
/>
{/if}
<!-- Drag preview (solid) for cross-day dragging - shows where event will be -->
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
<div
class="event-card drag-ghost"
class="event-card drag-preview"
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
draggedEvent.calendarId
)};"
@ -796,6 +1159,12 @@
background: hsl(var(--color-primary) / 0.05);
}
.day-column.drop-target {
background: hsl(var(--color-primary) / 0.15);
outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px;
}
.hour-slot {
height: var(--hour-height);
width: 100%;
@ -847,10 +1216,44 @@
outline-offset: -2px;
}
.event-card.drag-ghost {
opacity: 0.6;
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
border: 2px dashed white;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time {
opacity: 0.5;
}
/* Solid preview at target position during cross-day drag */
.event-card.drag-preview {
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Task drag ghost */
.task-drag-ghost {
position: absolute;
left: 2px;
right: 2px;
padding: 4px 6px;
background: hsl(var(--color-surface) / 0.8);
border: 2px dashed hsl(var(--color-primary));
border-radius: var(--radius-sm);
opacity: 0.7;
pointer-events: none;
z-index: 50;
overflow: hidden;
}
.task-drag-ghost .task-title {
font-size: 0.7rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.event-card.draft {

View file

@ -0,0 +1,266 @@
<script lang="ts">
import type { EventAttendee, AttendeeStatus } from '@calendar/shared';
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
import { Check, X, HelpCircle, Clock, ChevronDown } from 'lucide-svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
interface Props {
attendees: EventAttendee[];
onAttendeesChange: (attendees: EventAttendee[]) => void;
disabled?: boolean;
}
let { attendees, onAttendeesChange, disabled = false }: Props = $props();
let contactsAvailable = $state<boolean | null>(null);
let showStatusDropdown = $state<string | null>(null);
// Check contacts availability on mount
$effect(() => {
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
});
// Convert attendees to ContactOrManual format for the selector
const selectedContacts = $derived<ContactOrManual[]>(
attendees.map((a) => {
if (a.contactId) {
return {
contactId: a.contactId,
displayName: a.name || a.email,
email: a.email,
photoUrl: a.photoUrl,
company: a.company,
fetchedAt: new Date().toISOString(),
};
}
// Manual entry
return {
email: a.email,
name: a.name,
isManual: true as const,
};
})
);
function handleContactsChange(contacts: ContactOrManual[]) {
const newAttendees: EventAttendee[] = contacts.map((contact) => {
if ('isManual' in contact && contact.isManual) {
// Manual entry
const manual = contact as ManualContactEntry;
// Preserve existing status if email matches
const existing = attendees.find((a) => a.email === manual.email);
return {
email: manual.email,
name: manual.name,
status: existing?.status || ('pending' as AttendeeStatus),
};
} else {
// Contact reference
const contactRef = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
// Preserve existing status if contactId or email matches
const existing = attendees.find(
(a) => a.contactId === contactRef.contactId || a.email === contactRef.email
);
return {
email: contactRef.email || '',
name: contactRef.displayName,
status: existing?.status || ('pending' as AttendeeStatus),
contactId: contactRef.contactId,
photoUrl: contactRef.photoUrl,
company: contactRef.company,
};
}
});
onAttendeesChange(newAttendees);
}
function handleSearch(query: string): Promise<ContactSummary[]> {
return contactsStore.searchContacts(query);
}
function handleStatusChange(email: string, status: AttendeeStatus) {
const updated = attendees.map((a) => (a.email === email ? { ...a, status } : a));
onAttendeesChange(updated);
showStatusDropdown = null;
}
function handleRemoveAttendee(email: string) {
onAttendeesChange(attendees.filter((a) => a.email !== email));
}
function getStatusColor(status?: AttendeeStatus): string {
switch (status) {
case 'accepted':
return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30';
case 'declined':
return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30';
case 'tentative':
return 'text-yellow-600 bg-yellow-100 dark:text-yellow-400 dark:bg-yellow-900/30';
default:
return 'text-gray-500 bg-gray-100 dark:text-gray-400 dark:bg-gray-800';
}
}
function getStatusLabel(status?: AttendeeStatus): string {
switch (status) {
case 'accepted':
return 'Zugesagt';
case 'declined':
return 'Abgesagt';
case 'tentative':
return 'Vorbehaltlich';
default:
return 'Ausstehend';
}
}
const statusOptions: { value: AttendeeStatus; label: string }[] = [
{ value: 'pending', label: 'Ausstehend' },
{ value: 'accepted', label: 'Zugesagt' },
{ value: 'tentative', label: 'Vorbehaltlich' },
{ value: 'declined', label: 'Abgesagt' },
];
</script>
<div class="attendee-selector">
<!-- Existing Attendees with Status -->
{#if attendees.length > 0}
<div class="space-y-2 mb-4">
{#each attendees as attendee (attendee.email)}
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<ContactAvatar
photoUrl={attendee.photoUrl}
name={attendee.name || attendee.email}
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">
{attendee.name || attendee.email}
</div>
{#if attendee.name && attendee.email}
<div class="text-xs text-muted-foreground truncate">
{attendee.email}
</div>
{/if}
{#if attendee.company}
<div class="text-xs text-muted-foreground truncate">
{attendee.company}
</div>
{/if}
</div>
<!-- Status Dropdown -->
<div class="relative">
<button
type="button"
onclick={() =>
(showStatusDropdown =
showStatusDropdown === attendee.email ? null : attendee.email)}
class="
flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium
{getStatusColor(attendee.status)}
hover:opacity-80 transition-opacity
"
{disabled}
>
{#if attendee.status === 'accepted'}
<Check size={12} />
{:else if attendee.status === 'declined'}
<X size={12} />
{:else if attendee.status === 'tentative'}
<HelpCircle size={12} />
{:else}
<Clock size={12} />
{/if}
<span class="hidden sm:inline">{getStatusLabel(attendee.status)}</span>
<ChevronDown size={12} />
</button>
{#if showStatusDropdown === attendee.email}
<div
class="
absolute right-0 top-full mt-1 z-50
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg shadow-lg
py-1 min-w-[140px]
"
>
{#each statusOptions as option (option.value)}
<button
type="button"
onclick={() => handleStatusChange(attendee.email, option.value)}
class="
w-full flex items-center gap-2 px-3 py-1.5
text-sm text-left
hover:bg-gray-100 dark:hover:bg-gray-700
{attendee.status === option.value ? 'bg-gray-50 dark:bg-gray-700/50' : ''}
"
>
<span class="{getStatusColor(option.value)} p-0.5 rounded">
{#if option.value === 'accepted'}
<Check size={12} />
{:else if option.value === 'declined'}
<X size={12} />
{:else if option.value === 'tentative'}
<HelpCircle size={12} />
{:else}
<Clock size={12} />
{/if}
</span>
{option.label}
</button>
{/each}
</div>
{/if}
</div>
<!-- Remove Button -->
<button
type="button"
onclick={() => handleRemoveAttendee(attendee.email)}
class="
p-1 rounded-md
text-gray-400 hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20
transition-colors
"
title="Entfernen"
{disabled}
>
<X size={16} />
</button>
</div>
{/each}
</div>
{/if}
<!-- Add New Attendees -->
<ContactSelector
{selectedContacts}
onContactsChange={handleContactsChange}
onSearch={handleSearch}
allowManualEntry={true}
placeholder="Teilnehmer hinzufügen..."
addLabel="Teilnehmer"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
{disabled}
/>
</div>
<style>
.attendee-selector {
position: relative;
}
</style>

View file

@ -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<EventAttendee[]>(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 @@
</div>
{/if}
<!-- Teilnehmer -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">Teilnehmer</span>
<AttendeeSelector
{attendees}
onAttendeesChange={(newAttendees) => (attendees = newAttendees)}
/>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-border">
<button
type="button"

View file

@ -5,7 +5,18 @@
import { toast } from '$lib/stores/toast';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import { X, Calendar, Clock, Folder, Tag, Trash2, CheckSquare, AlertCircle } from 'lucide-svelte';
import {
X,
Calendar,
Clock,
Folder,
Tag,
Trash2,
CheckSquare,
AlertCircle,
CalendarClock,
Timer,
} from 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
@ -23,12 +34,34 @@
let isDeleting = $state(false);
let isToggling = $state(false);
// Form state
let title = $state(task.title);
let description = $state(task.description || '');
let dueDate = $state(task.dueDate ? formatDateForInput(task.dueDate) : '');
let dueTime = $state(task.dueTime || '');
let priority = $state<TaskPriority>(task.priority);
// Form state - initialized with derived values
let title = $state(initialTask.title);
let description = $state(initialTask.description || '');
let dueDate = $state(initialTask.dueDate ? formatDateForInput(initialTask.dueDate) : '');
let dueTime = $state(initialTask.dueTime || '');
let priority = $state<TaskPriority>(initialTask.priority);
// Time-Blocking fields
let scheduledDate = $state(
initialTask.scheduledDate ? formatDateForInput(initialTask.scheduledDate) : ''
);
let scheduledStartTime = $state(initialTask.scheduledStartTime || '');
let scheduledEndTime = $state(initialTask.scheduledEndTime || '');
let estimatedDuration = $state(initialTask.estimatedDuration?.toString() || '');
// Sync form state when task changes
$effect(() => {
title = task.title;
description = task.description || '';
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
dueTime = task.dueTime || '';
priority = task.priority;
// Time-Blocking
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
scheduledStartTime = task.scheduledStartTime || '';
scheduledEndTime = task.scheduledEndTime || '';
estimatedDuration = task.estimatedDuration?.toString() || '';
});
function formatDateForInput(date: string | Date | null | undefined): string {
if (!date) return '';
@ -67,6 +100,11 @@
dueDate: dueDate || null,
dueTime: dueTime || null,
priority,
// Time-Blocking
scheduledDate: scheduledDate || null,
scheduledStartTime: scheduledStartTime || null,
scheduledEndTime: scheduledEndTime || null,
estimatedDuration: estimatedDuration ? parseInt(estimatedDuration, 10) : null,
};
const result = await todosStore.updateTodo(task.id, updateData);
@ -106,6 +144,11 @@
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
dueTime = task.dueTime || '';
priority = task.priority;
// Time-Blocking
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
scheduledStartTime = task.scheduledStartTime || '';
scheduledEndTime = task.scheduledEndTime || '';
estimatedDuration = task.estimatedDuration?.toString() || '';
isEditing = true;
}
@ -200,6 +243,42 @@
</div>
</div>
<!-- Time-Blocking Section -->
<div class="form-section">
<span class="section-label">
<CalendarClock size={16} />
Zeitplanung (Time-Blocking)
</span>
<div class="form-row">
<div class="form-group">
<label for="scheduledDate">Geplantes Datum</label>
<input id="scheduledDate" type="date" bind:value={scheduledDate} />
</div>
<div class="form-group">
<label for="estimatedDuration">Dauer (Min.)</label>
<input
id="estimatedDuration"
type="number"
min="5"
max="480"
step="5"
bind:value={estimatedDuration}
placeholder="30"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="scheduledStartTime">Startzeit</label>
<input id="scheduledStartTime" type="time" bind:value={scheduledStartTime} />
</div>
<div class="form-group">
<label for="scheduledEndTime">Endzeit</label>
<input id="scheduledEndTime" type="time" bind:value={scheduledEndTime} />
</div>
</div>
</div>
<div class="form-group">
<label>Priorität</label>
<div class="priority-options">
@ -238,6 +317,29 @@
</div>
{/if}
<!-- Time-Blocking Display -->
{#if task.scheduledDate}
<div class="detail-item scheduled">
<CalendarClock size={16} />
<span>
Geplant: {formatDisplayDate(task.scheduledDate)}
{#if task.scheduledStartTime}
um {task.scheduledStartTime}
{#if task.scheduledEndTime}
- {task.scheduledEndTime}
{/if}
{/if}
</span>
</div>
{/if}
{#if task.estimatedDuration}
<div class="detail-item">
<Timer size={16} />
<span>Geschätzte Dauer: {task.estimatedDuration} Min.</span>
</div>
{/if}
<div class="detail-item">
<AlertCircle size={16} />
<PriorityBadge {priority} variant="pill" showLabel />
@ -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));

View file

@ -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';
}
</script>
<div
@ -84,9 +102,12 @@
class:compact={variant === 'compact'}
class:minimal={variant === 'minimal'}
class:clickable={!!onclick}
class:draggable-task={draggable}
style="--priority-color: {priorityColor};"
onclick={handleClick}
onkeydown={handleKeydown}
ondragstart={handleDragStart}
draggable={draggable ? 'true' : 'false'}
role={onclick ? 'button' : 'listitem'}
tabindex={onclick ? 0 : -1}
>
@ -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;
}

View file

@ -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';

View file

@ -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<Task | null>(null);
let dragStartY = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
// Resize state
let isResizing = $state(false);
let resizeTask = $state<Task | null>(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,
};
}

View file

@ -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}"
}
}

View file

@ -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}"
}
}

View file

@ -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<boolean | null>(null);
let isChecking = $state(false);
let lastCheck = $state<number>(0);
// Cache for recent search results
let searchCache = $state<Map<string, { results: ContactSummary[]; timestamp: number }>>(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<boolean> {
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<ContactSummary[]> {
// 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<ContactSummary | null> {
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<ContactSummary[]> {
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;
},
};

View file

@ -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)
*/

View file

@ -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;
}
/**