mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 20:06:42 +02:00
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:
parent
307f1ae22e
commit
0ecbf69ebc
50 changed files with 5791 additions and 53 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
8
apps/calendar/apps/web/src/lib/composables/index.ts
Normal file
8
apps/calendar/apps/web/src/lib/composables/index.ts
Normal 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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
175
apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
175
apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue