Merge branch 'till-dev' into till-dev-backup

This commit is contained in:
Till-JS 2025-12-11 16:12:26 +01:00
commit 876d81f788
99 changed files with 7637 additions and 891 deletions

View file

@ -6,7 +6,7 @@ pnpm docker:up:all
pnpm docker:down
pnpm dev:calendar:app
pnpm dev:calendar:full
pnpm dev:todo:full
pnpm dev:contacts:full
pnpm dev:clock:full

View file

@ -12,8 +12,9 @@ import {
import type { EventMetadata } from '../../db/schema/events.schema';
export class CreateEventDto {
@IsOptional()
@IsUUID()
calendarId: string;
calendarId?: string;
@IsString()
@MaxLength(500)

View file

@ -85,11 +85,20 @@ export class EventService {
}
async create(userId: string, dto: CreateEventDto): Promise<Event> {
// Verify user owns the calendar
const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
let calendarId = dto.calendarId;
let calendar;
// If no calendarId provided, get or create default calendar
if (!calendarId) {
calendar = await this.calendarService.getOrCreateDefaultCalendar(userId);
calendarId = calendar.id;
} else {
// Verify user owns the specified calendar
calendar = await this.calendarService.findByIdOrThrow(calendarId, userId);
}
const newEvent: NewEvent = {
calendarId: dto.calendarId,
calendarId,
userId,
title: dto.title,
description: dto.description,

View file

@ -31,6 +31,7 @@
"dependencies": {
"@calendar/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",

View file

@ -0,0 +1,117 @@
/**
* Base API Client Factory
* Eliminates duplication between calendar and todo API clients
*/
import { browser } from '$app/environment';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface FetchOptions {
method?: HttpMethod;
body?: unknown;
token?: string;
isFormData?: boolean;
timeout?: number;
}
export interface ApiResult<T> {
data: T | null;
error: Error | null;
}
export interface ApiClientConfig {
baseUrl: string;
apiPrefix?: string;
getAuthToken?: () => string | null;
defaultTimeout?: number;
}
/**
* Creates a configured API client for a specific backend
*/
export function createApiClient(config: ApiClientConfig) {
const { baseUrl, apiPrefix = '/api/v1', defaultTimeout = 30000 } = config;
async function fetchApi<T>(endpoint: string, options: FetchOptions = {}): Promise<ApiResult<T>> {
const { method = 'GET', body, token, isFormData = false, timeout = defaultTimeout } = options;
// Get auth token
let authToken = token;
if (!authToken && browser) {
authToken = config.getAuthToken?.() ?? localStorage.getItem('@auth/appToken') ?? undefined;
}
// Setup abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const headers: Record<string, string> = {};
// Don't set Content-Type for FormData - browser sets it automatically with boundary
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
return {
data: null,
error: new Error('Request timed out'),
};
}
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
return { fetchApi };
}
/**
* Helper to build query strings from object
*/
export function buildQueryString(params: Record<string, unknown>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}

View file

@ -2,66 +2,22 @@
* API Client for Calendar Backend
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { createApiClient, type FetchOptions, type ApiResult } from './base-client';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
isFormData?: boolean;
};
const calendarClient = createApiClient({
baseUrl: API_BASE,
apiPrefix: '/api/v1',
});
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token, isFormData = false } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {};
// Don't set Content-Type for FormData - browser sets it automatically with boundary
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
): Promise<ApiResult<T>> {
return calendarClient.fetchApi<T>(endpoint, options);
}
// Re-export types for backwards compatibility
export type { FetchOptions, ApiResult };

View file

@ -3,11 +3,16 @@
* Allows Calendar app to fetch/manage todos from the Todo service
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { createApiClient, buildQueryString } from './base-client';
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
const todoClient = createApiClient({
baseUrl: TODO_API_BASE,
apiPrefix: '/api/v1',
});
// ============================================
// Types (mirrored from @todo/shared for cross-app use)
// ============================================
@ -68,6 +73,11 @@ export interface Task {
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
// Time-Blocking (for calendar integration)
scheduledDate?: string | null;
scheduledStartTime?: string | null; // HH:mm format
scheduledEndTime?: string | null; // HH:mm format
estimatedDuration?: number | null; // Duration in minutes
priority: TaskPriority;
status: TaskStatus;
isCompleted: boolean;
@ -92,6 +102,11 @@ export interface CreateTaskInput {
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
labelIds?: string[];
subtasks?: Omit<Subtask, 'id'>[];
@ -105,6 +120,11 @@ export interface UpdateTaskInput {
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
@ -150,78 +170,10 @@ interface LabelsResponse {
}
// ============================================
// API Client
// API Client (using shared base client)
// ============================================
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
};
async function fetchTodoApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `Todo API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Failed to connect to Todo service'),
};
}
}
// ============================================
// Helper Functions
// ============================================
function buildQueryString(query: TaskQuery): string {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, String(value));
}
});
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
const fetchTodoApi = todoClient.fetchApi;
// ============================================
// Task API Functions
@ -230,7 +182,7 @@ function buildQueryString(query: TaskQuery): string {
export async function getTasks(
query: TaskQuery = {}
): Promise<{ data: Task[] | null; error: Error | null }> {
const queryString = buildQueryString(query);
const queryString = buildQueryString(query as Record<string, unknown>);
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
return {
data: result.data?.tasks || null,

View file

@ -1,16 +1,12 @@
<script lang="ts">
import { toast } from '$lib/stores/toast';
import type { Toast } from '$lib/stores/toast';
import { toastStore, type Toast } from '$lib/stores/toast.svelte';
import { fly } from 'svelte/transition';
let toasts = $state<Toast[]>([]);
toast.subscribe((value) => {
toasts = value;
});
// Reactive getter from the runes-based store
let toasts = $derived(toastStore.toasts);
function handleClose(id: string) {
toast.remove(id);
toastStore.remove(id);
}
function getIcon(type: Toast['type']) {

View file

@ -3,8 +3,9 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -17,11 +18,15 @@
} from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { onQuickCreate }: Props = $props();
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Constants
const HOUR_HEIGHT = 60; // pixels per hour
@ -72,8 +77,11 @@
);
// Get display mode for an event (per-event override takes precedence over global setting)
function getEventDisplayMode(event: any): 'header' | 'block' {
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
return (
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
settingsStore.allDayDisplayMode
);
}
// Split all-day events by display mode
@ -87,7 +95,7 @@
// Drag & Drop State
// ============================================================================
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
@ -97,7 +105,7 @@
// Resize State
// ============================================================================
let isResizing = $state(false);
let resizeEvent = $state<any>(null);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
@ -107,6 +115,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
// ============================================================================
@ -129,7 +152,7 @@
// ============================================================================
// Drag Handlers
// ============================================================================
function startDrag(event: any, e: PointerEvent) {
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -215,7 +238,7 @@
// ============================================================================
// Resize Handlers
// ============================================================================
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -321,13 +344,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();
}
@ -342,7 +590,7 @@
// ============================================================================
// Event Styling
// ============================================================================
function getEventStyle(event: any) {
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
@ -358,7 +606,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();
@ -368,7 +645,11 @@
}, 100);
return;
}
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleSlotClick(hour: number, e: MouseEvent) {
@ -427,7 +708,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"
@ -452,6 +742,7 @@
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-card"
class:dragging={isBeingDragged}
@ -474,6 +765,7 @@
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
@ -498,11 +790,31 @@
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
</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 +936,12 @@
background: hsl(var(--color-primary) / 0.05);
}
.day-column.drop-target {
background: hsl(var(--color-primary) / 0.15);
outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px;
}
.event-card {
position: absolute;
left: 4px;

View file

@ -28,12 +28,16 @@
setMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { _ } from 'svelte-i18n';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate }: Props = $props();
let { onQuickCreate, onEventClick }: Props = $props();
// Get all days to display in the month grid (including days from prev/next months)
let allCalendarDays = $derived.by(() => {
@ -76,7 +80,7 @@
// Drag & Drop State
// ============================================================================
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragTargetDay = $state<Date | null>(null);
let monthViewRef = $state<HTMLElement | null>(null);
@ -219,7 +223,7 @@
}
}
function handleEventClick(event: any, e: MouseEvent) {
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
// Don't navigate if dragging
if (isDragging) {
e.preventDefault();
@ -227,7 +231,11 @@
return;
}
e.stopPropagation();
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleMoreClick(day: Date, e: MouseEvent) {
@ -251,7 +259,6 @@
<div class="week-row">
{#each week as day}
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-cell"
class:other-month={!isSameMonth(day, viewStore.currentDate)}
@ -262,6 +269,9 @@
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day, e as unknown as MouseEvent)}
role="button"
tabindex="0"
aria-label={$_('a11y.createEventOn', {
values: { date: format(day, 'EEEE, d. MMMM', { locale: de }) },
})}
>
<span class="day-number" class:today={isToday(day)}>
{format(day, 'd')}
@ -276,6 +286,7 @@
{#each getEventsForDay(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-pill"
class:dragging={isBeingDragged}
@ -300,14 +311,17 @@
)}</span
>
{/if}
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span
<span class="event-title"
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
>
</div>
{/each}
{#if eventsStore.getEventsForDay(day).length > 3}
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
+{eventsStore.getEventsForDay(day).length - 3} mehr
{$_('views.moreEvents', {
values: { count: eventsStore.getEventsForDay(day).length - 3 },
})}
</button>
{/if}
</div>

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -23,12 +25,16 @@
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
import type { CalendarEvent } from '@calendar/shared';
// Props
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 };
@ -93,7 +99,7 @@
// ========== Drag & Drop State ==========
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
@ -101,7 +107,7 @@
// ========== Resize State ==========
let isResizing = $state(false);
let resizeEvent = $state<any>(null);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
@ -111,6 +117,20 @@
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
// Task Drag & Drop State
let isTaskDragging = $state(false);
let draggedTask = $state<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;
@ -123,8 +143,11 @@
}
// Get display mode for an event (per-event override takes precedence over global setting)
function getEventDisplayMode(event: any): 'header' | 'block' {
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
return (
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
settingsStore.allDayDisplayMode
);
}
// Split all-day events by display mode
@ -141,7 +164,7 @@
days.some((day) => getHeaderAllDayEventsForDay(day).length > 0)
);
function getEventStyle(event: any) {
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
@ -156,12 +179,41 @@
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);
}
function handleEventClick(event: any, e: MouseEvent) {
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
// Don't navigate if we just finished dragging or resizing, or if we moved
if (isDragging || isResizing || hasMoved) {
e.preventDefault();
@ -171,7 +223,11 @@
}, 100);
return;
}
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
@ -218,7 +274,7 @@
return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
}
function startDrag(event: any, e: PointerEvent) {
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -321,7 +377,7 @@
// ========== Resize Functions ==========
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -437,15 +493,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 +752,11 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = null;
isTaskResizing = false;
resizeTask = null;
hasMoved = false;
}
}
@ -516,7 +820,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 +854,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)};`
@ -566,6 +881,7 @@
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
@ -582,15 +898,49 @@
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
</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 +1224,51 @@
}
}
.event-card.drag-ghost {
opacity: 0.6;
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
border: 2px dashed white;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time {
opacity: 0.5;
}
/* Solid preview at target position during cross-day drag */
.event-card.drag-preview {
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Task drag ghost */
.task-drag-ghost {
position: absolute;
left: 2px;
right: 2px;
padding: 4px 6px;
background: hsl(var(--color-surface) / 0.8);
border: 2px dashed hsl(var(--color-primary));
border-radius: var(--radius-sm);
opacity: 0.7;
pointer-events: none;
z-index: 50;
overflow: hidden;
}
.task-drag-ghost .task-title {
font-size: 0.7rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
/* Sidebar task drop target */
.day-column.drop-target {
background: hsl(var(--color-primary) / 0.15);
outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px;
}
.compact .event-card,

View file

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

View file

@ -5,7 +5,6 @@
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { ChevronDown, ChevronRight, Plus, CheckSquare, AlertTriangle } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
interface Props {
@ -18,8 +17,8 @@
let showQuickAdd = $state(false);
let selectedTask = $state<Task | null>(null);
// Derived: combined overdue + today todos
const displayTodos = $derived(todosStore.getSidebarTodos(maxItems));
// Derived: all active todos (overdue + today + upcoming)
const displayTodos = $derived(todosStore.getSidebarTodos());
const overdueCount = $derived(todosStore.overdueTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
@ -27,6 +26,8 @@
// Fetch todos on mount
await todosStore.fetchTodayTodos();
await todosStore.fetchUpcomingTodos();
// Also fetch scheduled todos (including completed) for calendar display
await todosStore.fetchScheduledTodos();
});
function toggleExpanded() {
@ -53,16 +54,12 @@
function handleQuickAddCancel() {
showQuickAdd = false;
}
function goToAllTasks() {
goto('/tasks');
}
</script>
<div class="todo-sidebar-section">
<!-- Header -->
<button type="button" class="section-header" onclick={toggleExpanded}>
<div class="header-left">
<div class="section-header">
<button type="button" class="header-toggle" onclick={toggleExpanded}>
{#if isExpanded}
<ChevronDown size={16} />
{:else}
@ -78,7 +75,7 @@
<AlertTriangle size={12} />
</span>
{/if}
</div>
</button>
<button
type="button"
class="add-button"
@ -87,7 +84,7 @@
>
<Plus size={16} />
</button>
</button>
</div>
<!-- Content -->
{#if isExpanded}
@ -114,16 +111,11 @@
{task}
variant="compact"
showProject={false}
draggable={!task.isCompleted}
onclick={() => handleTaskClick(task)}
/>
{/each}
</div>
{#if totalActiveCount > maxItems}
<button type="button" class="show-all-button" onclick={goToAllTasks}>
Alle {totalActiveCount} anzeigen
</button>
{/if}
{/if}
<!-- Quick Add -->
@ -160,29 +152,31 @@
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
padding: 0 0.5rem 0 0;
}
.header-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
padding: 0.75rem 0.5rem 0.75rem 1rem;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: background 150ms ease;
}
.section-header:hover {
.header-toggle:hover {
background: hsl(var(--color-muted) / 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-foreground));
}
.header-left :global(svg) {
.header-toggle :global(svg) {
color: hsl(var(--color-muted-foreground));
}
.header-left :global(.section-icon) {
.header-toggle :global(.section-icon) {
color: hsl(var(--color-primary));
}
@ -234,6 +228,8 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 300px;
overflow-y: auto;
}
.service-unavailable,
@ -267,24 +263,6 @@
}
}
.show-all-button {
width: 100%;
padding: 0.5rem;
margin-top: 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-primary));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-md);
transition: background 150ms ease;
}
.show-all-button:hover {
background: hsl(var(--color-primary) / 0.1);
}
.quick-add-wrapper {
margin-top: 0.5rem;
padding: 0 0.25rem;

View file

@ -3,8 +3,9 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -21,13 +22,17 @@
getWeek,
} from 'date-fns';
import { de, enUS, fr, es, it } from 'date-fns/locale';
import { locale } from 'svelte-i18n';
import { locale, _ } from 'svelte-i18n';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { onQuickCreate }: Props = $props();
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Constants
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
@ -94,7 +99,7 @@
// Drag & Drop State
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
@ -102,7 +107,7 @@
// Resize State
let isResizing = $state(false);
let resizeEvent = $state<any>(null);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
@ -112,6 +117,20 @@
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
// Task Drag & Drop State
let isTaskDragging = $state(false);
let draggedTask = $state<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;
@ -124,8 +143,11 @@
}
// Get display mode for an event (per-event override takes precedence over global setting)
function getEventDisplayMode(event: any): 'header' | 'block' {
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
return (
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
settingsStore.allDayDisplayMode
);
}
// Split all-day events by display mode
@ -142,7 +164,7 @@
days.some((day) => getHeaderAllDayEventsForDay(day).length > 0)
);
function getEventStyle(event: any) {
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
@ -157,12 +179,43 @@
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
}
/**
* Get style for a scheduled task (time-blocking)
*/
function getTaskStyle(task: Task): string {
if (!task.scheduledStartTime) return '';
// Parse HH:mm time
const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
// Calculate duration - use estimatedDuration or scheduledEndTime or default 30 min
let duration = task.estimatedDuration || 30;
if (task.scheduledEndTime) {
const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number);
const endMinutes = endHour * 60 + endMin;
duration = endMinutes - startMinutes;
}
const top = minutesToPercent(startMinutes);
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2);
return `top: ${top}%; height: ${height}%;`;
}
/**
* Get scheduled tasks for a specific day
*/
function getScheduledTasksForDay(day: Date): Task[] {
return todosStore.getScheduledTasksForDay(day);
}
function formatEventTime(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return settingsStore.formatTime(d);
}
function handleEventClick(event: any, e: MouseEvent) {
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
// Don't navigate if we just finished dragging or resizing, or if we moved
if (isDragging || isResizing || hasMoved) {
e.preventDefault();
@ -173,7 +226,11 @@
}, 100);
return;
}
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
@ -220,7 +277,7 @@
return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
}
function startDrag(event: any, e: PointerEvent) {
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -323,7 +380,7 @@
// ========== Resize Functions ==========
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -439,6 +496,263 @@
hasMoved = false;
}
// ========== Task Drag & Drop ==========
function handleTaskDragStart(task: Task, e: PointerEvent) {
e.preventDefault();
isTaskDragging = true;
draggedTask = task;
hasMoved = false;
// Initialize preview position
if (task.scheduledStartTime) {
const [h, m] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = h * 60 + m - firstVisibleHour * 60;
taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
}
const duration = task.estimatedDuration || 30;
taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
document.addEventListener('pointermove', handleTaskDragMove);
document.addEventListener('pointerup', handleTaskDragEnd);
}
function handleTaskDragMove(e: PointerEvent) {
if (!isTaskDragging || !draggedTask) return;
hasMoved = true;
// Find which day column we're over
const daysEl = daysContainerEl;
if (!daysEl) return;
const dayColumns = daysEl.querySelectorAll('.day-column');
for (let i = 0; i < dayColumns.length; i++) {
const col = dayColumns[i];
const rect = col.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
taskDragTargetDay = days[i];
break;
}
}
// Calculate vertical position
const targetColumn = daysEl.querySelector('.day-column');
if (!targetColumn) return;
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
// Snap to 15-minute intervals
const minutesPerPercent = (totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
}
async function handleTaskDragEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleTaskDragMove);
document.removeEventListener('pointerup', handleTaskDragEnd);
if (!isTaskDragging || !draggedTask || !hasMoved) {
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = null;
return;
}
// Calculate new time from position
const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60);
const totalMinutes = firstVisibleHour * 60 + minutesFromStart;
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes % 60);
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
// Calculate end time based on duration
const duration = draggedTask.estimatedDuration || 30;
const endTotalMinutes = totalMinutes + duration;
const endHours = Math.floor(endTotalMinutes / 60);
const endMins = Math.round(endTotalMinutes % 60);
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
await todosStore.updateTodo(draggedTask.id, {
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
});
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = null;
hasMoved = false;
}
// ========== Task Resize ==========
function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
isTaskResizing = true;
resizeTask = task;
taskResizeEdge = edge;
hasMoved = false;
// Initialize preview position
if (task.scheduledStartTime) {
const [h, m] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = h * 60 + m - firstVisibleHour * 60;
taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
}
const duration = task.estimatedDuration || 30;
taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
document.addEventListener('pointermove', handleTaskResizeMove);
document.addEventListener('pointerup', handleTaskResizeEnd);
}
function handleTaskResizeMove(e: PointerEvent) {
if (!isTaskResizing || !resizeTask) return;
hasMoved = true;
const daysEl = daysContainerEl;
if (!daysEl) return;
const targetColumn = daysEl.querySelector('.day-column');
if (!targetColumn) return;
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
const minutesPerPercent = (totalVisibleHours * 60) / 100;
if (taskResizeEdge === 'top') {
// Adjust start time, keep end fixed
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
} else {
// Adjust end time, keep start fixed
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
}
}
async function handleTaskResizeEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleTaskResizeMove);
document.removeEventListener('pointerup', handleTaskResizeEnd);
if (!isTaskResizing || !resizeTask || !hasMoved) {
isTaskResizing = false;
resizeTask = null;
return;
}
// Calculate new times from position
const startMinutes =
(taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
const endMinutes =
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) +
firstVisibleHour * 60;
const startHours = Math.floor(startMinutes / 60);
const startMins = Math.round(startMinutes % 60);
const endHours = Math.floor(endMinutes / 60);
const endMins = Math.round(endMinutes % 60);
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
const newDuration = Math.round(endMinutes - startMinutes);
await todosStore.updateTodo(resizeTask.id, {
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
estimatedDuration: newDuration,
});
isTaskResizing = false;
resizeTask = null;
hasMoved = false;
}
// ========== Sidebar Task Drop ==========
let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null);
function handleSidebarDragOver(e: DragEvent, day: Date) {
e.preventDefault();
if (!e.dataTransfer) return;
// Check if this is a sidebar task drag
const types = e.dataTransfer.types;
if (!types.includes('application/json')) return;
e.dataTransfer.dropEffect = 'move';
sidebarDropTarget = { day, y: e.clientY };
}
function handleSidebarDragLeave(e: DragEvent) {
// Only clear if leaving the column entirely
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget?.closest('.day-column')) {
sidebarDropTarget = null;
}
}
async function handleSidebarDrop(e: DragEvent, day: Date) {
e.preventDefault();
sidebarDropTarget = null;
if (!e.dataTransfer) return;
const jsonData = e.dataTransfer.getData('application/json');
if (!jsonData) return;
try {
const data = JSON.parse(jsonData);
if (data.type !== 'sidebar-task') return;
// Calculate drop time from Y position
const dayColumn = (e.target as HTMLElement).closest('.day-column');
if (!dayColumn) return;
const rect = dayColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
const minutesPerPercent = (totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
const totalMinutes = firstVisibleHour * 60 + snappedMinutes;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
// Calculate end time
const duration = data.estimatedDuration || 30;
const endMinutes = totalMinutes + duration;
const endHours = Math.floor(endMinutes / 60);
const endMins = endMinutes % 60;
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
// Update the task with scheduled time
await todosStore.updateTodo(data.taskId, {
scheduledDate: format(day, 'yyyy-MM-dd'),
scheduledStartTime: startTime,
scheduledEndTime: endTime,
estimatedDuration: duration,
});
} catch (err) {
console.error('Failed to parse drop data:', err);
}
}
// ========== Keyboard Handling ==========
function handleKeyDown(e: KeyboardEvent) {
@ -459,6 +773,20 @@
resizeOriginalEnd = null;
hasMoved = false;
}
// Cancel task drag/resize
if (isTaskDragging || isTaskResizing) {
e.preventDefault();
document.removeEventListener('pointermove', handleTaskDragMove);
document.removeEventListener('pointerup', handleTaskDragEnd);
document.removeEventListener('pointermove', handleTaskResizeMove);
document.removeEventListener('pointerup', handleTaskResizeEnd);
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = null;
isTaskResizing = false;
resizeTask = null;
hasMoved = false;
}
}
}
@ -473,7 +801,8 @@
<!-- Week number indicator (if enabled) -->
{#if settingsStore.showWeekNumbers}
<div class="week-number-indicator">
KW {weekNumber}
{$_('views.weekNumber')}
{weekNumber}
</div>
{/if}
@ -482,7 +811,7 @@
<div class="all-day-row">
<div class="time-gutter">
{#if settingsStore.showWeekNumbers}
<span class="week-label">KW {weekNumber}</span>
<span class="week-label">{$_('views.weekNumber')} {weekNumber}</span>
{/if}
</div>
{#each days as day}
@ -538,7 +867,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,19 +900,23 @@
{@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)};`
: getEventStyle(event)}
role="button"
tabindex="0"
aria-label={event.title || $_('calendar.draftEvent')}
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
@ -585,30 +926,67 @@
class="resize-handle top"
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label="Startzeit ändern"
aria-label={$_('event.changeStartTime')}
aria-valuenow={0}
tabindex="-1"
></div>
<span class="event-time">
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
</span>
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
<span class="event-title"
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
>
<!-- Bottom resize handle -->
<div
class="resize-handle bottom"
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label="Endzeit ändern"
aria-label={$_('event.changeEndTime')}
aria-valuenow={0}
tabindex="-1"
></div>
</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 +1174,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 +1231,44 @@
outline-offset: -2px;
}
.event-card.drag-ghost {
opacity: 0.6;
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
border: 2px dashed white;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time {
opacity: 0.5;
}
/* Solid preview at target position during cross-day drag */
.event-card.drag-preview {
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Task drag ghost */
.task-drag-ghost {
position: absolute;
left: 2px;
right: 2px;
padding: 4px 6px;
background: hsl(var(--color-surface) / 0.8);
border: 2px dashed hsl(var(--color-primary));
border-radius: var(--radius-sm);
opacity: 0.7;
pointer-events: none;
z-index: 50;
overflow: hidden;
}
.task-drag-ghost .task-title {
font-size: 0.7rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.event-card.draft {

View file

@ -16,13 +16,14 @@
setMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarViewType } from '@calendar/shared';
import type { CalendarViewType, CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate }: Props = $props();
let { onQuickCreate, onEventClick }: Props = $props();
// Derived values
let year = $derived(viewStore.currentDate.getFullYear());

View file

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

View file

@ -145,8 +145,8 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="button" tabindex="-1">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
{#if loading}
<EventDetailSkeleton />

View file

@ -4,12 +4,14 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { TagSelector, type Tag } from '@manacore/shared-ui';
import AttendeeSelector from './AttendeeSelector.svelte';
import type {
CalendarEvent,
CreateEventInput,
UpdateEventInput,
LocationDetails,
EventTag,
EventAttendee,
} from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
@ -49,6 +51,9 @@
})) || []
);
// Attendees state
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
return {
@ -131,7 +136,7 @@
e.preventDefault();
if (!title.trim()) return;
if (!calendarId) return;
// calendarId is now optional - backend will use/create default calendar if not provided
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
@ -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;
@ -177,7 +189,8 @@
isAllDay,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
calendarId,
// Only include calendarId if set - backend will use default if not provided
...(calendarId ? { calendarId } : {}),
metadata: finalMetadata,
tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined,
};
@ -202,15 +215,19 @@
<div class="flex flex-col gap-2">
<label for="calendar" class="text-sm font-medium text-foreground">Kalender</label>
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{#if calendarsStore.calendars.length > 0}
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{:else}
<p class="text-sm text-muted-foreground italic">Standardkalender wird automatisch erstellt</p>
{/if}
</div>
<div class="flex flex-col gap-2">
@ -378,7 +395,7 @@
<!-- Tags -->
{#if availableTags.length > 0 || eventTagsStore.loading}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">Tags</label>
<span class="text-sm font-medium text-foreground">Tags</span>
<TagSelector
tags={availableTags}
{selectedTags}
@ -389,6 +406,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"
@ -400,7 +426,7 @@
<button
type="submit"
class="px-4 py-2 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={submitting || !title.trim() || !calendarId}
disabled={submitting || !title.trim()}
>
{mode === 'create' ? 'Erstellen' : 'Speichern'}
</button>

View file

@ -2,18 +2,25 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { LocationDetails } from '@calendar/shared';
import { toast } from '$lib/stores/toast';
import type { LocationDetails, CalendarEvent } from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
import { tick, onMount, onDestroy } from 'svelte';
interface Props {
startTime: Date;
startTime?: Date;
event?: CalendarEvent;
onClose: () => void;
onCreated?: () => void;
onUpdated?: () => void;
onDeleted?: () => void;
}
let { startTime, onClose, onCreated }: Props = $props();
let { startTime, event, onClose, onCreated, onUpdated, onDeleted }: Props = $props();
// Mode: create or edit
let isEditMode = $derived(!!event);
// Input ref for programmatic focus
let titleInputRef = $state<HTMLInputElement | null>(null);
@ -25,12 +32,17 @@
// Track when draft event was last modified (to ignore clicks after drag/resize)
let lastDraftUpdateTime = $state(0);
// Calculate position relative to draft event element
// Calculate position relative to draft event element or existing event
function updatePosition() {
if (typeof window === 'undefined') return;
const draftElement = document.querySelector('[data-event-id="__draft__"]');
if (!draftElement) {
// In edit mode, position relative to the existing event element
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
: '[data-event-id="__draft__"]';
const eventElement = document.querySelector(eventSelector);
if (!eventElement) {
// Fallback: center in viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
@ -42,7 +54,7 @@
return;
}
const rect = draftElement.getBoundingClientRect();
const rect = eventElement.getBoundingClientRect();
const overlayWidth = 380;
const maxOverlayHeight = 450;
const margin = 16;
@ -79,7 +91,7 @@
positionInitialized = true;
}
// Handle clicks outside overlay (but allow clicks on draft event)
// Handle clicks outside overlay (but allow clicks on event)
function handleDocumentClick(e: MouseEvent) {
// Ignore clicks within 250ms of draft event update (drag/resize just ended)
if (Date.now() - lastDraftUpdateTime < 250) {
@ -88,10 +100,13 @@
const target = e.target as HTMLElement;
const overlay = document.querySelector('.quick-event-overlay');
const draftEvent = document.querySelector('[data-event-id="__draft__"]');
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
: '[data-event-id="__draft__"]';
const eventElement = document.querySelector(eventSelector);
// Don't close if clicking on overlay or draft event
if (overlay?.contains(target) || draftEvent?.contains(target)) {
// Don't close if clicking on overlay or event element
if (overlay?.contains(target) || eventElement?.contains(target)) {
return;
}
@ -115,18 +130,19 @@
document.removeEventListener('click', handleDocumentClick);
});
// Update position when draft event changes (user dragged it)
// Also track the update time to prevent closing overlay after drag/resize
// Update position when draft event changes (user dragged it) - only in create mode
$effect(() => {
const draft = eventsStore.draftEvent;
if (draft && positionInitialized) {
// Track when draft was updated (for click ignore logic)
lastDraftUpdateTime = Date.now();
if (!isEditMode) {
const draft = eventsStore.draftEvent;
if (draft && positionInitialized) {
// Track when draft was updated (for click ignore logic)
lastDraftUpdateTime = Date.now();
// Use requestAnimationFrame to wait for DOM update
requestAnimationFrame(() => {
updatePosition();
});
// Use requestAnimationFrame to wait for DOM update
requestAnimationFrame(() => {
updatePosition();
});
}
}
});
@ -135,11 +151,15 @@
if (titleInputRef) {
tick().then(() => {
titleInputRef?.focus();
// Select all text in edit mode for easy replacement
if (isEditMode) {
titleInputRef?.select();
}
});
}
});
// Form state - initialize from draft event
// Form state - initialize from event (edit mode) or draft event (create mode)
let title = $state('');
let calendarId = $state('');
let description = $state('');
@ -155,82 +175,132 @@
let locationCountry = $state('');
let submitting = $state(false);
// Date/time fields - derive from draft event
// Editable date/time strings (for form inputs)
let startDateStr = $state('');
let startTimeStr = $state('');
let endDateStr = $state('');
let endTimeStr = $state('');
// Initialize form state from event in edit mode
$effect(() => {
if (isEditMode && event) {
title = event.title || '';
calendarId = event.calendarId || '';
description = event.description || '';
location = event.location || '';
isAllDay = event.isAllDay || false;
allDayDisplayMode =
(event.metadata?.allDayDisplayMode as 'default' | 'header' | 'block') || 'default';
// Initialize location details
const loc = event.metadata?.locationDetails;
if (loc) {
showLocationDetails = true;
locationStreet = loc.street || '';
locationPostalCode = loc.postalCode || '';
locationCity = loc.city || '';
locationCountry = loc.country || '';
}
// Initialize time fields
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
startDateStr = format(eventStart, 'yyyy-MM-dd');
startTimeStr = format(eventStart, 'HH:mm');
endDateStr = format(eventEnd, 'yyyy-MM-dd');
endTimeStr = format(eventEnd, 'HH:mm');
}
});
// Date/time fields - derive from draft event (create mode) or event (edit mode)
let draftStart = $derived(() => {
if (isEditMode && event) {
return typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.startTime === 'string' ? parseISO(draft.startTime) : draft.startTime;
}
return startTime;
return startTime || new Date();
});
let draftEnd = $derived(() => {
if (isEditMode && event) {
return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.endTime === 'string' ? parseISO(draft.endTime) : draft.endTime;
}
return addMinutes(startTime, settingsStore.defaultEventDuration);
return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration);
});
// Display date/time - derived from draft event
// Display date/time - derived from draft event or event
let displayStartDate = $derived(format(draftStart(), 'yyyy-MM-dd'));
let displayStartTime = $derived(format(draftStart(), 'HH:mm'));
let displayEndDate = $derived(format(draftEnd(), 'yyyy-MM-dd'));
let displayEndTime = $derived(format(draftEnd(), 'HH:mm'));
// Editable date/time strings (for form inputs)
let startDateStr = $state(format(startTime, 'yyyy-MM-dd'));
let startTimeStr = $state(format(startTime, 'HH:mm'));
let endDateStr = $state('');
let endTimeStr = $state('');
// Sync form fields from draft event when it changes (e.g., user drags it)
// Sync form fields from draft event when it changes (e.g., user drags it) - only in create mode
$effect(() => {
startDateStr = displayStartDate;
startTimeStr = displayStartTime;
endDateStr = displayEndDate;
endTimeStr = displayEndTime;
if (!isEditMode) {
startDateStr = displayStartDate;
startTimeStr = displayStartTime;
endDateStr = displayEndDate;
endTimeStr = displayEndTime;
}
});
// Set default calendar
// Set default calendar - only in create mode
$effect(() => {
if (!calendarId && calendarsStore.defaultCalendar?.id) {
if (!isEditMode && !calendarId && calendarsStore.defaultCalendar?.id) {
calendarId = calendarsStore.defaultCalendar.id;
// Update draft event with calendar
eventsStore.updateDraftEvent({ calendarId });
}
});
// Update draft event when title changes
// Update draft event when title changes - only in create mode
function handleTitleChange(e: Event) {
const target = e.target as HTMLInputElement;
title = target.value;
eventsStore.updateDraftEvent({ title: target.value });
if (!isEditMode) {
eventsStore.updateDraftEvent({ title: target.value });
}
}
// Update draft event when time fields change
function handleStartDateChange(e: Event) {
const target = e.target as HTMLInputElement;
startDateStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function handleStartTimeChange(e: Event) {
const target = e.target as HTMLInputElement;
startTimeStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function handleEndDateChange(e: Event) {
const target = e.target as HTMLInputElement;
endDateStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function handleEndTimeChange(e: Event) {
const target = e.target as HTMLInputElement;
endTimeStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function updateDraftTimes() {
@ -252,13 +322,17 @@
function handleCalendarChange(e: Event) {
const target = e.target as HTMLSelectElement;
calendarId = target.value;
eventsStore.updateDraftEvent({ calendarId: target.value });
if (!isEditMode) {
eventsStore.updateDraftEvent({ calendarId: target.value });
}
}
// Update draft when all-day changes
function handleAllDayToggle() {
isAllDay = !isAllDay;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
// Overlay style
@ -292,18 +366,32 @@
}
: undefined;
// Build metadata
let metadata: Record<string, unknown> | undefined = undefined;
// Build metadata - preserve existing metadata in edit mode
let metadata: Record<string, unknown> | undefined = isEditMode
? { ...(event?.metadata || {}) }
: undefined;
if (isAllDay && allDayDisplayMode !== 'default') {
metadata = { allDayDisplayMode: allDayDisplayMode as 'header' | 'block' };
metadata = {
...(metadata || {}),
allDayDisplayMode: allDayDisplayMode as 'header' | 'block',
};
} else if (metadata) {
delete metadata.allDayDisplayMode;
}
if (locationDetails) {
metadata = { ...(metadata || {}), locationDetails };
} else if (metadata) {
delete metadata.locationDetails;
}
await eventsStore.createEvent({
// Clean up empty metadata
if (metadata && Object.keys(metadata).length === 0) {
metadata = undefined;
}
const eventData = {
title: title.trim(),
calendarId,
startTime: startDateTime.toISOString(),
@ -312,12 +400,56 @@
description: description.trim() || undefined,
location: location.trim() || undefined,
metadata,
});
};
if (isEditMode && event) {
// Update existing event
const result = await eventsStore.updateEvent(event.id, eventData);
if (result.error) {
toast.error(`Fehler beim Speichern: ${result.error.message}`);
return;
}
toast.success('Termin aktualisiert');
onUpdated?.();
} else {
// Create new event
await eventsStore.createEvent(eventData);
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
onCreated?.();
}
onCreated?.();
onClose();
} catch (error) {
console.error('Failed to create event:', error);
console.error('Failed to save event:', error);
toast.error('Fehler beim Speichern');
} finally {
submitting = false;
}
}
async function handleDelete() {
if (!event) return;
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
return;
}
submitting = true;
try {
const result = await eventsStore.deleteEvent(event.id);
if (result.error) {
toast.error(`Fehler beim Löschen: ${result.error.message}`);
return;
}
toast.success('Termin gelöscht');
onDeleted?.();
onClose();
} catch (error) {
console.error('Failed to delete event:', error);
toast.error('Fehler beim Löschen');
} finally {
submitting = false;
}
@ -338,22 +470,42 @@
style={overlayStyle}
role="dialog"
aria-modal="true"
aria-label="Termin erstellen"
aria-label={isEditMode ? 'Termin bearbeiten' : 'Termin erstellen'}
>
<form onsubmit={handleSubmit}>
<!-- Header -->
<div class="overlay-header">
<span class="header-title">Neuer Termin</span>
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<span class="header-title">{isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'}</span>
<div class="header-actions">
{#if isEditMode}
<button
type="button"
class="delete-btn"
onclick={handleDelete}
disabled={submitting}
aria-label="Löschen"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Scrollable content -->
@ -399,17 +551,22 @@
></div>
</div>
<div class="row-content">
<label class="field-label">Kalender</label>
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
<span class="field-label">Kalender</span>
{#if calendarsStore.calendars.length > 0}
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{:else}
<span class="field-placeholder">Standardkalender wird erstellt</span>
{/if}
</div>
</div>
<!-- All day toggle -->
<div class="form-row clickable" onclick={handleAllDayToggle}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="form-row clickable" onclick={handleAllDayToggle} role="button" tabindex="0">
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -437,7 +594,7 @@
<div class="form-row sub-row">
<div class="row-icon"></div>
<div class="row-content">
<label class="field-label">Anzeigeart</label>
<span class="field-label">Anzeigeart</span>
<select class="field-select" bind:value={allDayDisplayMode}>
<option value="default">Standard (aus Einstellungen)</option>
<option value="header">In Kopfzeile</option>
@ -461,7 +618,7 @@
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<label class="field-label">Beginn</label>
<span class="field-label">Beginn</span>
<input
type="date"
class="field-input"
@ -471,7 +628,7 @@
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<label class="field-label">Uhrzeit</label>
<span class="field-label">Uhrzeit</span>
<input
type="time"
class="field-input"
@ -497,7 +654,7 @@
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<label class="field-label">Ende</label>
<span class="field-label">Ende</span>
<input
type="date"
class="field-input"
@ -507,7 +664,7 @@
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<label class="field-label">Uhrzeit</label>
<span class="field-label">Uhrzeit</span>
<input
type="time"
class="field-input"
@ -575,7 +732,7 @@
<div class="row-icon"></div>
<div class="row-content address-details-form">
<div class="address-field">
<label class="field-label">Straße</label>
<span class="field-label">Straße</span>
<input
type="text"
class="field-input"
@ -585,7 +742,7 @@
</div>
<div class="address-row">
<div class="address-field postal">
<label class="field-label">PLZ</label>
<span class="field-label">PLZ</span>
<input
type="text"
class="field-input"
@ -594,7 +751,7 @@
/>
</div>
<div class="address-field city">
<label class="field-label">Stadt</label>
<span class="field-label">Stadt</span>
<input
type="text"
class="field-input"
@ -604,7 +761,7 @@
</div>
</div>
<div class="address-field">
<label class="field-label">Land</label>
<span class="field-label">Land</span>
<input
type="text"
class="field-input"
@ -664,14 +821,14 @@
display: flex;
flex-direction: column;
animation: slideIn 150ms ease-out;
overflow: hidden; /* Prevent any content from overflowing */
overflow: hidden;
}
.quick-event-overlay form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0; /* Allow form to shrink below content size */
min-height: 0;
height: 100%;
}
@ -701,7 +858,14 @@
color: hsl(var(--color-foreground));
}
.close-btn {
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.close-btn,
.delete-btn {
padding: 0.375rem;
border: none;
background: transparent;
@ -716,11 +880,21 @@
color: hsl(var(--color-foreground));
}
.delete-btn:hover {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.overlay-content {
flex: 1;
min-height: 0; /* Important for flex scroll */
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain; /* Prevent scroll chaining to background */
overscroll-behavior: contain;
padding: 0.75rem 0;
}
@ -841,6 +1015,14 @@
border-color: hsl(var(--color-primary));
}
.field-placeholder {
display: block;
padding: 0.5rem 0.625rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.field-input.full {
padding: 0.625rem;
}

View file

@ -83,6 +83,7 @@
</button>
{:else}
<form class="quick-add-form" onsubmit={handleSubmit}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={inputRef}
bind:value={title}

View file

@ -5,7 +5,18 @@
import { toast } from '$lib/stores/toast';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import { X, Calendar, Clock, Folder, Tag, Trash2, CheckSquare, AlertCircle } from 'lucide-svelte';
import {
X,
Calendar,
Clock,
Folder,
Tag,
Trash2,
CheckSquare,
AlertCircle,
CalendarClock,
Timer,
} from 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
@ -23,12 +34,34 @@
let isDeleting = $state(false);
let isToggling = $state(false);
// Form state
let title = $state(task.title);
let description = $state(task.description || '');
let dueDate = $state(task.dueDate ? formatDateForInput(task.dueDate) : '');
let dueTime = $state(task.dueTime || '');
let priority = $state<TaskPriority>(task.priority);
// Form state - initialized with derived values
let title = $state(initialTask.title);
let description = $state(initialTask.description || '');
let dueDate = $state(initialTask.dueDate ? formatDateForInput(initialTask.dueDate) : '');
let dueTime = $state(initialTask.dueTime || '');
let priority = $state<TaskPriority>(initialTask.priority);
// Time-Blocking fields
let scheduledDate = $state(
initialTask.scheduledDate ? formatDateForInput(initialTask.scheduledDate) : ''
);
let scheduledStartTime = $state(initialTask.scheduledStartTime || '');
let scheduledEndTime = $state(initialTask.scheduledEndTime || '');
let estimatedDuration = $state(initialTask.estimatedDuration?.toString() || '');
// Sync form state when task changes
$effect(() => {
title = task.title;
description = task.description || '';
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
dueTime = task.dueTime || '';
priority = task.priority;
// Time-Blocking
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
scheduledStartTime = task.scheduledStartTime || '';
scheduledEndTime = task.scheduledEndTime || '';
estimatedDuration = task.estimatedDuration?.toString() || '';
});
function formatDateForInput(date: string | Date | null | undefined): string {
if (!date) return '';
@ -67,6 +100,11 @@
dueDate: dueDate || null,
dueTime: dueTime || null,
priority,
// Time-Blocking
scheduledDate: scheduledDate || null,
scheduledStartTime: scheduledStartTime || null,
scheduledEndTime: scheduledEndTime || null,
estimatedDuration: estimatedDuration ? parseInt(estimatedDuration, 10) : null,
};
const result = await todosStore.updateTodo(task.id, updateData);
@ -106,6 +144,11 @@
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
dueTime = task.dueTime || '';
priority = task.priority;
// Time-Blocking
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
scheduledStartTime = task.scheduledStartTime || '';
scheduledEndTime = task.scheduledEndTime || '';
estimatedDuration = task.estimatedDuration?.toString() || '';
isEditing = true;
}
@ -132,8 +175,8 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="button" tabindex="-1">
<div class="modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<!-- Header -->
<div class="modal-header">
@ -168,14 +211,7 @@
>
<div class="form-group">
<label for="title">Titel</label>
<input
id="title"
type="text"
bind:value={title}
placeholder="Aufgabentitel"
required
autofocus
/>
<input id="title" type="text" bind:value={title} placeholder="Aufgabentitel" required />
</div>
<div class="form-group">
@ -200,8 +236,44 @@
</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>
<span class="label-text">Priorität</span>
<div class="priority-options">
{#each Object.entries(PRIORITY_LABELS) as [key, label]}
<button
@ -238,6 +310,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 +518,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 +614,35 @@
gap: 1rem;
}
.form-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border) / 0.5);
}
.section-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: hsl(var(--color-muted-foreground));
}
.section-label :global(svg) {
color: hsl(var(--color-primary));
}
input[type='text'],
input[type='date'],
input[type='time'],
input[type='number'],
textarea {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));

View file

@ -13,6 +13,7 @@
showProject?: boolean;
showDueDate?: boolean;
showPriority?: boolean;
draggable?: boolean;
onclick?: () => void;
}
@ -22,6 +23,7 @@
showProject = true,
showDueDate = true,
showPriority = true,
draggable = false,
onclick,
}: Props = $props();
@ -75,8 +77,25 @@
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>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="todo-item"
class:completed={task.isCompleted}
@ -84,9 +103,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 +190,15 @@
transform: translateX(2px);
}
.todo-item.draggable-task {
cursor: grab;
}
.todo-item.draggable-task:active {
cursor: grabbing;
opacity: 0.7;
}
.todo-item.completed {
opacity: 0.6;
}

View file

@ -0,0 +1,8 @@
/**
* Calendar Composables
* Reusable logic extracted from components
*/
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';
export { useTaskDragDrop } from './useTaskDragDrop.svelte';

View file

@ -0,0 +1,243 @@
/**
* Drag & Drop Composable for Calendar Events
* Extracts drag logic from WeekView/DayView for reusability
*/
import type { CalendarEvent } from '@calendar/shared';
import { parseISO, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { eventsStore } from '$lib/stores/events.svelte';
export interface DragDropConfig {
/** Reference to the container element for position calculations */
containerEl: HTMLElement | null;
/** Array of visible days */
days: Date[];
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Last visible hour (for filtered hours mode) */
lastVisibleHour: number;
/** Height of one hour in pixels */
hourHeight: number;
/** Minutes per snap interval */
snapMinutes?: number;
}
export interface DragState {
isDragging: boolean;
draggedEvent: CalendarEvent | null;
dragTargetDay: Date | null;
dragPreviewTop: number;
dragPreviewHeight: number;
hasMoved: boolean;
}
export function useDragDrop(getConfig: () => DragDropConfig) {
// State
let isDragging = $state(false);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
let hasMoved = $state(false);
// Derived values
const totalVisibleHours = $derived(() => {
const config = getConfig();
return config.lastVisibleHour - config.firstVisibleHour;
});
/**
* Convert minutes to percentage position (accounting for hidden hours)
*/
function minutesToPercent(minutes: number): number {
const config = getConfig();
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
}
/**
* Get day from X coordinate
*/
function getDayFromX(clientX: number): Date | null {
const config = getConfig();
if (!config.containerEl) return null;
const rect = config.containerEl.getBoundingClientRect();
const relativeX = clientX - rect.left;
const dayWidth = rect.width / config.days.length;
const dayIndex = Math.floor(relativeX / dayWidth);
if (dayIndex >= 0 && dayIndex < config.days.length) {
return config.days[dayIndex];
}
return null;
}
/**
* Get minutes from Y coordinate
*/
function getMinutesFromY(clientY: number): number {
const config = getConfig();
if (!config.containerEl) return 0;
const rect = config.containerEl.getBoundingClientRect();
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
// Account for hidden early hours
const visibleMinutes =
(relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
// Snap to interval
const snapMinutes = config.snapMinutes ?? 15;
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
}
/**
* Start dragging an event
*/
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
const config = getConfig();
isDragging = true;
draggedEvent = event;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const duration = differenceInMinutes(end, start);
// Calculate initial preview position
const startMinutes = start.getHours() * 60 + start.getMinutes();
dragPreviewTop = minutesToPercent(startMinutes);
dragPreviewHeight = (duration / (totalVisibleHours() * 60)) * 100;
dragTargetDay = start;
// Calculate offset from event start to click position
const clickMinutes = getMinutesFromY(e.clientY);
dragOffsetMinutes = clickMinutes - startMinutes;
document.addEventListener('pointermove', handleDragMove);
document.addEventListener('pointerup', handleDragEnd);
}
function handleDragMove(e: PointerEvent) {
if (!isDragging || !draggedEvent) return;
const config = getConfig();
hasMoved = true;
// Calculate new position
const newDay = getDayFromX(e.clientX);
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
// Clamp to valid range
const clampedMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(config.lastVisibleHour * 60 - 15, newMinutes)
);
// Update preview
dragPreviewTop = minutesToPercent(clampedMinutes);
if (newDay) {
dragTargetDay = newDay;
}
}
async function handleDragEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
cleanup();
return;
}
const config = getConfig();
const start =
typeof draggedEvent.startTime === 'string'
? parseISO(draggedEvent.startTime)
: draggedEvent.startTime;
const end =
typeof draggedEvent.endTime === 'string'
? parseISO(draggedEvent.endTime)
: draggedEvent.endTime;
const duration = differenceInMinutes(end, start);
// Calculate new start time
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
const newHours = Math.floor(clampedMinutes / 60);
const newMins = clampedMinutes % 60;
let newStart = new Date(dragTargetDay);
newStart = setHours(newStart, newHours);
newStart = setMinutes(newStart, newMins);
const newEnd = addMinutes(newStart, duration);
// Update event via store
if (eventsStore.isDraftEvent(draggedEvent.id)) {
eventsStore.updateDraftEvent({
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
} else {
await eventsStore.updateEvent(draggedEvent.id, {
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
}
cleanup();
}
function cleanup() {
isDragging = false;
draggedEvent = null;
dragTargetDay = null;
hasMoved = false;
}
/**
* Cancel drag operation (e.g., on Escape key)
*/
function cancelDrag() {
if (isDragging) {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
cleanup();
}
}
return {
// State (reactive getters)
get isDragging() {
return isDragging;
},
get draggedEvent() {
return draggedEvent;
},
get dragTargetDay() {
return dragTargetDay;
},
get dragPreviewTop() {
return dragPreviewTop;
},
get dragPreviewHeight() {
return dragPreviewHeight;
},
get hasMoved() {
return hasMoved;
},
// Methods
startDrag,
cancelDrag,
minutesToPercent,
};
}

View file

@ -0,0 +1,235 @@
/**
* Resize Composable for Calendar Events
* Extracts resize logic from WeekView/DayView for reusability
*/
import type { CalendarEvent } from '@calendar/shared';
import { parseISO, differenceInMinutes, setHours, setMinutes } from 'date-fns';
import { eventsStore } from '$lib/stores/events.svelte';
export interface ResizeConfig {
/** Reference to the container element for position calculations */
containerEl: HTMLElement | null;
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Last visible hour (for filtered hours mode) */
lastVisibleHour: number;
/** Height of one hour in pixels */
hourHeight: number;
/** Minutes per snap interval */
snapMinutes?: number;
}
export interface ResizeState {
isResizing: boolean;
resizeEvent: CalendarEvent | null;
resizeEdge: 'top' | 'bottom';
resizePreviewTop: number;
resizePreviewHeight: number;
hasMoved: boolean;
}
export function useResize(getConfig: () => ResizeConfig) {
// State
let isResizing = $state(false);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let hasMoved = $state(false);
// Derived values
const totalVisibleHours = $derived(() => {
const config = getConfig();
return config.lastVisibleHour - config.firstVisibleHour;
});
/**
* Convert minutes to percentage position
*/
function minutesToPercent(minutes: number): number {
const config = getConfig();
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
}
/**
* Get minutes from Y coordinate
*/
function getMinutesFromY(clientY: number): number {
const config = getConfig();
if (!config.containerEl) return 0;
const rect = config.containerEl.getBoundingClientRect();
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
const visibleMinutes =
(relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
const snapMinutes = config.snapMinutes ?? 15;
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
}
/**
* Start resizing an event
*/
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeEvent = event;
resizeEdge = edge;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
resizeOriginalStart = start;
resizeOriginalEnd = end;
// Set initial preview
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
resizePreviewTop = minutesToPercent(startMinutes);
resizePreviewHeight = (duration / (totalVisibleHours() * 60)) * 100;
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
function handleResizeMove(e: PointerEvent) {
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
const config = getConfig();
hasMoved = true;
const currentMinutes = getMinutesFromY(e.clientY);
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
if (resizeEdge === 'bottom') {
// Resize from bottom - change end time
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, currentMinutes)
);
const newDuration = newEndMinutes - originalStartMinutes;
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
} else {
// Resize from top - change start time
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
);
const newDuration = originalEndMinutes - newStartMinutes;
resizePreviewTop = minutesToPercent(newStartMinutes);
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
}
}
async function handleResizeEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
cleanup();
return;
}
const config = getConfig();
const currentMinutes = getMinutesFromY(e.clientY);
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
let newStart = resizeOriginalStart;
let newEnd = resizeOriginalEnd;
if (resizeEdge === 'bottom') {
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, currentMinutes)
);
const newHours = Math.floor(newEndMinutes / 60);
const newMins = newEndMinutes % 60;
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
newEnd = setMinutes(newEnd, newMins);
} else {
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
);
const newHours = Math.floor(newStartMinutes / 60);
const newMins = newStartMinutes % 60;
newStart = setHours(new Date(resizeOriginalStart), newHours);
newStart = setMinutes(newStart, newMins);
}
// Update event via store
if (eventsStore.isDraftEvent(resizeEvent.id)) {
eventsStore.updateDraftEvent({
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
} else {
await eventsStore.updateEvent(resizeEvent.id, {
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
}
cleanup();
}
function cleanup() {
isResizing = false;
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
hasMoved = false;
}
/**
* Cancel resize operation
*/
function cancelResize() {
if (isResizing) {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
cleanup();
}
}
return {
// State (reactive getters)
get isResizing() {
return isResizing;
},
get resizeEvent() {
return resizeEvent;
},
get resizeEdge() {
return resizeEdge;
},
get resizePreviewTop() {
return resizePreviewTop;
},
get resizePreviewHeight() {
return resizePreviewHeight;
},
get hasMoved() {
return hasMoved;
},
// Methods
startResize,
cancelResize,
minutesToPercent,
};
}

View file

@ -0,0 +1,306 @@
/**
* Composable for Task Drag & Drop in Calendar Views
* Handles dragging tasks to reschedule and resizing to change duration
*/
import type { Task, UpdateTaskInput } from '$lib/api/todos';
import { todosStore } from '$lib/stores/todos.svelte';
import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns';
const SNAP_MINUTES = 15;
interface UseTaskDragDropOptions {
/** Minimum snap interval in minutes */
snapMinutes?: number;
/** Callback when task is updated */
onTaskUpdate?: (task: Task) => void;
}
export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) {
const snapMinutes = options.snapMinutes ?? SNAP_MINUTES;
// Drag state
let isDragging = $state(false);
let draggedTask = $state<Task | null>(null);
let dragStartY = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
// Resize state
let isResizing = $state(false);
let resizeTask = $state<Task | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeStartY = $state(0);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
// Track if we actually moved
let hasMoved = $state(false);
/**
* Start dragging a task
*/
function startDrag(
task: Task,
e: PointerEvent,
gridElement: HTMLElement,
firstVisibleHour: number,
totalVisibleHours: number
) {
e.preventDefault();
isDragging = true;
draggedTask = task;
dragStartY = e.clientY;
hasMoved = false;
// Calculate initial position
if (task.scheduledStartTime) {
const [h, m] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = h * 60 + m - firstVisibleHour * 60;
dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
}
// Calculate height from duration
const duration = task.estimatedDuration || 30;
dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
// Capture pointer
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
/**
* Handle drag move
*/
function onDragMove(
e: PointerEvent,
gridElement: HTMLElement,
day: Date,
firstVisibleHour: number,
totalVisibleHours: number
) {
if (!isDragging || !draggedTask) return;
hasMoved = true;
dragTargetDay = day;
const rect = gridElement.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = (relativeY / rect.height) * 100;
// Snap to intervals
const minutesPerPercent = (totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60;
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100;
}
/**
* End drag and update task
*/
async function endDrag(firstVisibleHour: number, totalVisibleHours: number) {
if (!isDragging || !draggedTask || !hasMoved) {
isDragging = false;
draggedTask = null;
dragTargetDay = null;
return;
}
// Calculate new time from position
const minutesFromMidnight =
(dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
const hours = Math.floor(minutesFromMidnight / 60);
const minutes = Math.round(minutesFromMidnight % 60);
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
// Calculate end time based on duration
const duration = draggedTask.estimatedDuration || 30;
const endMinutes = minutesFromMidnight + duration;
const endHours = Math.floor(endMinutes / 60);
const endMins = Math.round(endMinutes % 60);
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
const updateData: UpdateTaskInput = {
scheduledDate: dragTargetDay
? format(dragTargetDay, 'yyyy-MM-dd')
: draggedTask.scheduledDate,
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
};
const result = await todosStore.updateTodo(draggedTask.id, updateData);
if (result.data) {
options.onTaskUpdate?.(result.data);
}
isDragging = false;
draggedTask = null;
dragTargetDay = null;
hasMoved = false;
}
/**
* Start resizing a task
*/
function startResize(
task: Task,
edge: 'top' | 'bottom',
e: PointerEvent,
firstVisibleHour: number,
totalVisibleHours: number
) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeTask = task;
resizeEdge = edge;
resizeStartY = e.clientY;
hasMoved = false;
// Initialize preview position
if (task.scheduledStartTime) {
const [h, m] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = h * 60 + m - firstVisibleHour * 60;
resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
}
const duration = task.estimatedDuration || 30;
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
/**
* Handle resize move
*/
function onResizeMove(
e: PointerEvent,
gridElement: HTMLElement,
firstVisibleHour: number,
totalVisibleHours: number
) {
if (!isResizing || !resizeTask) return;
hasMoved = true;
const rect = gridElement.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
const minutesPerPercent = (totalVisibleHours * 60) / 100;
if (resizeEdge === 'top') {
// Adjust start time, keep end fixed
const originalEndPercent = resizePreviewTop + resizePreviewHeight;
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop);
} else {
// Adjust end time, keep start fixed
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop);
}
}
/**
* End resize and update task
*/
async function endResize(firstVisibleHour: number, totalVisibleHours: number) {
if (!isResizing || !resizeTask || !hasMoved) {
isResizing = false;
resizeTask = null;
return;
}
// Calculate new times from position
const startMinutes =
(resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
const endMinutes =
((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) +
firstVisibleHour * 60;
const startHours = Math.floor(startMinutes / 60);
const startMins = Math.round(startMinutes % 60);
const endHours = Math.floor(endMinutes / 60);
const endMins = Math.round(endMinutes % 60);
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
const newDuration = Math.round(endMinutes - startMinutes);
const updateData: UpdateTaskInput = {
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
estimatedDuration: newDuration,
};
const result = await todosStore.updateTodo(resizeTask.id, updateData);
if (result.data) {
options.onTaskUpdate?.(result.data);
}
isResizing = false;
resizeTask = null;
hasMoved = false;
}
/**
* Cancel any ongoing drag/resize
*/
function cancel() {
isDragging = false;
isResizing = false;
draggedTask = null;
resizeTask = null;
dragTargetDay = null;
hasMoved = false;
}
return {
// State getters
get isDragging() {
return isDragging;
},
get draggedTask() {
return draggedTask;
},
get dragTargetDay() {
return dragTargetDay;
},
get dragPreviewTop() {
return dragPreviewTop;
},
get dragPreviewHeight() {
return dragPreviewHeight;
},
get isResizing() {
return isResizing;
},
get resizeTask() {
return resizeTask;
},
get resizePreviewTop() {
return resizePreviewTop;
},
get resizePreviewHeight() {
return resizePreviewHeight;
},
get hasMoved() {
return hasMoved;
},
// Methods
startDrag,
onDragMove,
endDrag,
startResize,
onResizeMove,
endResize,
cancel,
};
}

View file

@ -35,11 +35,17 @@ function getInitialLocale(): SupportedLocale {
}
// Initialize i18n at module scope (required for SSR)
// Always set initialLocale to ensure it's never undefined
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
initialLocale: browser ? getInitialLocale() : defaultLocale,
});
// On browser, also explicitly set locale to ensure it's loaded
if (browser) {
locale.set(getInitialLocale());
}
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);

View file

@ -19,7 +19,9 @@
"month": "Monat",
"year": "Jahr",
"agenda": "Agenda",
"weekdaysOnly": "Nur Wochentage"
"weekdaysOnly": "Nur Wochentage",
"weekNumber": "KW",
"moreEvents": "+{count} mehr"
},
"calendar": {
"today": "Heute",
@ -27,7 +29,10 @@
"noEvents": "Keine Termine",
"allDay": "Ganztägig",
"myCalendars": "Meine Kalender",
"sharedCalendars": "Geteilte Kalender"
"sharedCalendars": "Geteilte Kalender",
"draftEvent": "(Neuer Termin)",
"hideSidebar": "Sidebar ausblenden",
"showSidebar": "Sidebar einblenden"
},
"event": {
"title": "Titel",
@ -41,7 +46,9 @@
"calendar": "Kalender",
"save": "Speichern",
"delete": "Löschen",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"changeStartTime": "Startzeit ändern",
"changeEndTime": "Endzeit ändern"
},
"repeat": {
"none": "Nicht wiederholen",
@ -86,5 +93,30 @@
"search": "Suchen",
"error": "Fehler",
"success": "Erfolgreich"
},
"errors": {
"loadEvents": "Termine konnten nicht geladen werden",
"createEvent": "Termin konnte nicht erstellt werden",
"updateEvent": "Termin konnte nicht aktualisiert werden",
"deleteEvent": "Termin konnte nicht gelöscht werden"
},
"success": {
"eventCreated": "Termin erstellt",
"eventDeleted": "Termin gelöscht"
},
"priority": {
"urgent": "Dringend",
"high": "Wichtig",
"medium": "Normal",
"low": "Später"
},
"todo": {
"task": "Aufgabe",
"markComplete": "Als erledigt markieren",
"markIncomplete": "Als unerledigt markieren"
},
"a11y": {
"createEventOn": "Termin erstellen am {date}",
"slotTime": "{day} {time}"
}
}

View file

@ -19,7 +19,9 @@
"month": "Month",
"year": "Year",
"agenda": "Agenda",
"weekdaysOnly": "Weekdays only"
"weekdaysOnly": "Weekdays only",
"weekNumber": "W",
"moreEvents": "+{count} more"
},
"calendar": {
"today": "Today",
@ -27,7 +29,10 @@
"noEvents": "No events",
"allDay": "All day",
"myCalendars": "My Calendars",
"sharedCalendars": "Shared Calendars"
"sharedCalendars": "Shared Calendars",
"draftEvent": "(New Event)",
"hideSidebar": "Hide sidebar",
"showSidebar": "Show sidebar"
},
"event": {
"title": "Title",
@ -41,7 +46,9 @@
"calendar": "Calendar",
"save": "Save",
"delete": "Delete",
"cancel": "Cancel"
"cancel": "Cancel",
"changeStartTime": "Change start time",
"changeEndTime": "Change end time"
},
"repeat": {
"none": "Don't repeat",
@ -86,5 +93,30 @@
"search": "Search",
"error": "Error",
"success": "Success"
},
"errors": {
"loadEvents": "Failed to load events",
"createEvent": "Failed to create event",
"updateEvent": "Failed to update event",
"deleteEvent": "Failed to delete event"
},
"success": {
"eventCreated": "Event created",
"eventDeleted": "Event deleted"
},
"priority": {
"urgent": "Urgent",
"high": "High",
"medium": "Normal",
"low": "Low"
},
"todo": {
"task": "Task",
"markComplete": "Mark as complete",
"markIncomplete": "Mark as incomplete"
},
"a11y": {
"createEventOn": "Create event on {date}",
"slotTime": "{day} {time}"
}
}

View file

@ -0,0 +1,175 @@
/**
* Contacts Store for Calendar App
*
* Provides access to contacts from the Contacts app for event attendee management.
*/
import { browser } from '$app/environment';
import { createContactsClient, type ContactsClient } from '@manacore/shared-auth';
import type { ContactSummary } from '@manacore/shared-types';
import { authStore } from './auth.svelte';
// State
let client: ContactsClient | null = null;
let isAvailable = $state<boolean | null>(null);
let isChecking = $state(false);
let lastCheck = $state<number>(0);
// Cache for recent search results
let searchCache = $state<Map<string, { results: ContactSummary[]; timestamp: number }>>(new Map());
const CACHE_TTL = 60000; // 1 minute
// Get contacts API URL dynamically
function getContactsApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
.__PUBLIC_CONTACTS_API_URL__;
return injectedUrl || 'http://localhost:3015/api/v1';
}
return 'http://localhost:3015/api/v1';
}
// Initialize client lazily
function getClient(): ContactsClient {
if (!client) {
client = createContactsClient({
apiUrl: getContactsApiUrl(),
getAuthToken: async () => authStore.getAccessToken(),
timeout: 5000,
});
}
return client;
}
export const contactsStore = {
// Getters
get isAvailable() {
return isAvailable;
},
get isChecking() {
return isChecking;
},
/**
* Check if the Contacts API is available
* Caches result for 30 seconds
*/
async checkAvailability(): Promise<boolean> {
const now = Date.now();
// Skip if checked recently
if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) {
return isAvailable;
}
isChecking = true;
try {
const available = await getClient().isAvailable();
isAvailable = available;
lastCheck = now;
return available;
} catch {
isAvailable = false;
lastCheck = now;
return false;
} finally {
isChecking = false;
}
},
/**
* Search contacts by query string
*/
async searchContacts(query: string): Promise<ContactSummary[]> {
// Check cache first
const cacheKey = query.toLowerCase().trim();
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
}
// Check availability
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return [];
}
try {
const results = await getClient().searchContacts({
query,
limit: 20,
excludeArchived: true,
});
// Cache results
searchCache.set(cacheKey, {
results,
timestamp: Date.now(),
});
return results;
} catch (error) {
console.error('[contactsStore] Search failed:', error);
return [];
}
},
/**
* Get a single contact by ID
*/
async getContact(id: string): Promise<ContactSummary | null> {
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return null;
}
try {
return await getClient().getContact(id);
} catch (error) {
console.error(`[contactsStore] Failed to get contact ${id}:`, error);
return null;
}
},
/**
* Get multiple contacts by IDs
*/
async getContacts(ids: string[]): Promise<ContactSummary[]> {
if (ids.length === 0) return [];
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return [];
}
try {
return await getClient().getContacts(ids);
} catch (error) {
console.error('[contactsStore] Failed to get contacts:', error);
return [];
}
},
/**
* Clear the search cache
*/
clearCache() {
searchCache.clear();
},
/**
* Reset availability check (force recheck on next call)
*/
resetAvailability() {
isAvailable = null;
lastCheck = 0;
},
};

View file

@ -5,6 +5,7 @@
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
import * as api from '$lib/api/events';
import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns';
import { toastStore } from './toast.svelte';
// State
let events = $state<CalendarEvent[]>([]);
@ -45,6 +46,7 @@ export const eventsStore = {
if (result.error) {
error = result.error.message;
toastStore.error(`Termine konnten nicht geladen werden: ${result.error.message}`);
} else {
// API returns { events: [...] }
const data = result.data as { events: CalendarEvent[] } | null;
@ -119,8 +121,11 @@ export const eventsStore = {
async createEvent(data: CreateEventInput) {
const result = await api.createEvent(data);
if (result.data) {
if (result.error) {
toastStore.error(`Termin konnte nicht erstellt werden: ${result.error.message}`);
} else if (result.data) {
events = [...events, result.data];
toastStore.success('Termin erstellt');
}
return result;
@ -132,7 +137,9 @@ export const eventsStore = {
async updateEvent(id: string, data: UpdateEventInput) {
const result = await api.updateEvent(id, data);
if (result.data) {
if (result.error) {
toastStore.error(`Termin konnte nicht aktualisiert werden: ${result.error.message}`);
} else if (result.data) {
events = events.map((e) => (e.id === id ? result.data! : e));
}
@ -140,13 +147,23 @@ export const eventsStore = {
},
/**
* Delete an event
* Delete an event (optimistic update)
*/
async deleteEvent(id: string) {
// Optimistic: remove event immediately
const eventToDelete = events.find((e) => e.id === id);
events = events.filter((e) => e.id !== id);
const result = await api.deleteEvent(id);
if (!result.error) {
events = events.filter((e) => e.id !== id);
if (result.error) {
// Rollback: restore the event on error
if (eventToDelete) {
events = [...events, eventToDelete];
}
toastStore.error(`Termin konnte nicht gelöscht werden: ${result.error.message}`);
} else {
toastStore.success('Termin gelöscht');
}
return result;

View file

@ -0,0 +1,57 @@
/**
* Toast Store - Svelte 5 Runes version
* Manages toast notifications
*/
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
// State
let toasts = $state<Toast[]>([]);
function add(message: string, type: ToastType = 'info', duration: number = 4000): string {
const id = crypto.randomUUID();
const toast: Toast = { id, type, message, duration };
toasts = [...toasts, toast];
if (duration > 0) {
setTimeout(() => {
remove(id);
}, duration);
}
return id;
}
function remove(id: string) {
toasts = toasts.filter((t) => t.id !== id);
}
function clear() {
toasts = [];
}
export const toastStore = {
get toasts() {
return toasts;
},
add,
remove,
clear,
success: (message: string, duration?: number) => add(message, 'success', duration),
error: (message: string, duration?: number) => add(message, 'error', duration ?? 6000),
warning: (message: string, duration?: number) => add(message, 'warning', duration),
info: (message: string, duration?: number) => add(message, 'info', duration),
};
// Keep old export for backwards compatibility
export const toast = toastStore;

View file

@ -61,7 +61,7 @@ export const todosStore = {
// ========== Derived Getters ==========
/**
* Get todos for a specific day
* Get todos for a specific day (by dueDate)
*/
getTodosForDay(date: Date): Task[] {
const currentTodos = todos ?? [];
@ -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
*/
@ -175,17 +219,15 @@ export const todosStore = {
},
/**
* Get combined sidebar todos (overdue + today, sorted by priority)
* Limited to show in sidebar
* Get combined sidebar todos (overdue + today + upcoming, sorted by date then priority)
*/
getSidebarTodos(limit = 5): Task[] {
getSidebarTodos(): Task[] {
const overdue = this.overdueTodos;
const today = this.todaysTodos;
const upcoming = this.upcomingTodos;
// Combine and sort: overdue first, then today, both by priority
const combined = [...overdue, ...today];
return combined.slice(0, limit);
// Combine: overdue first, then today, then upcoming
return [...overdue, ...today, ...upcoming];
},
/**
@ -202,14 +244,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 +277,7 @@ export const todosStore = {
},
/**
* Fetch today's todos (shortcut)
* Fetch today's todos (shortcut) - only uncompleted tasks
*/
async fetchTodayTodos() {
loading = true;
@ -260,6 +301,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)
*/
@ -271,7 +346,11 @@ export const todosStore = {
if (result.error) {
error = result.error.message;
serviceAvailable = false;
// Only set serviceAvailable to false if we have no todos yet
// (if fetchTodayTodos succeeded, we should still show the service as available)
if (todos.length === 0) {
serviceAvailable = false;
}
} else {
// Merge with existing todos (avoid duplicates)
const newTodos = result.data || [];
@ -338,13 +417,20 @@ export const todosStore = {
},
/**
* Delete a todo
* Delete a todo (optimistic update)
*/
async deleteTodo(id: string) {
// Optimistic: remove todo immediately
const todoToDelete = todos.find((t) => t.id === id);
todos = todos.filter((t) => t.id !== id);
const result = await api.deleteTask(id);
if (!result.error) {
todos = todos.filter((t) => t.id !== id);
if (result.error) {
// Rollback: restore the todo on error
if (todoToDelete) {
todos = [...todos, todoToDelete];
}
}
return result;

View file

@ -0,0 +1,41 @@
/**
* Event Date Helpers
* Utilities for consistent date handling across the calendar app
*/
import { parseISO } from 'date-fns';
/**
* Convert a date value that may be either a string or Date to a Date object
* This handles the common pattern where API returns ISO strings but we need Date objects
*/
export function toDate(value: string | Date): Date {
return typeof value === 'string' ? parseISO(value) : value;
}
/**
* Get the start time of an event as a Date object
*/
export function getEventStart(event: { startTime: string | Date }): Date {
return toDate(event.startTime);
}
/**
* Get the end time of an event as a Date object
*/
export function getEventEnd(event: { endTime: string | Date }): Date {
return toDate(event.endTime);
}
/**
* Get both start and end times of an event as Date objects
*/
export function getEventTimes(event: { startTime: string | Date; endTime: string | Date }): {
start: Date;
end: Date;
} {
return {
start: toDate(event.startTime),
end: toDate(event.endTime),
};
}

View file

@ -104,12 +104,6 @@
const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
const resolved = resolveEventIds(parsed, calendars, tags);
// Ensure we have a calendar
if (!resolved.calendarId) {
console.error('No calendar available');
return;
}
// Ensure we have start and end times
if (!resolved.startTime) {
// Default to now + 1 hour
@ -119,8 +113,10 @@
resolved.endTime = end.toISOString();
}
// Create event - calendarId is now optional, backend will use/create default if not provided
await eventsStore.createEvent({
calendarId: resolved.calendarId,
// Only include calendarId if resolved (from command or default calendar)
...(resolved.calendarId ? { calendarId: resolved.calendarId } : {}),
title: resolved.title,
startTime: resolved.startTime,
endTime: resolved.endTime || resolved.startTime,
@ -128,6 +124,11 @@
location: resolved.location,
tagIds: resolved.tagIds,
});
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
}
let isSidebarMode = $state(false);

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
@ -18,24 +19,24 @@
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
import { format, addMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarEvent } from '@calendar/shared';
import { addMinutes } from 'date-fns';
let initialized = $state(false);
// Quick event overlay state
let showQuickCreate = $state(false);
// Quick event overlay state - for both create and edit
let showQuickOverlay = $state(false);
let quickCreateDate = $state<Date>(new Date());
let editingEvent = $state<CalendarEvent | null>(null);
// Event modal state (local state for reactivity)
let selectedEventId = $state<string | null>(null);
// Derive modal open state from URL
let modalEventId = $derived($page.url.searchParams.get('event'));
// Generate a unique key for the overlay to force remount
let overlayKey = $state(0);
function handleQuickCreate(date: Date, position: { x: number; y: number }) {
// Close any existing overlay first
editingEvent = null;
quickCreateDate = date;
// Create draft event immediately so it appears in the grid
@ -50,11 +51,22 @@
isAllDay: false,
});
showQuickCreate = true;
overlayKey++;
showQuickOverlay = true;
}
function handleQuickCreateClose() {
showQuickCreate = false;
function handleEventClick(event: CalendarEvent) {
// Close any existing overlay/draft first
eventsStore.clearDraftEvent();
editingEvent = event;
overlayKey++;
showQuickOverlay = true;
}
function handleQuickOverlayClose() {
showQuickOverlay = false;
editingEvent = null;
eventsStore.clearDraftEvent();
}
@ -63,6 +75,14 @@
eventsStore.clearDraftEvent();
}
function handleEventUpdated() {
// Event is automatically updated in store
}
function handleEventDeleted() {
// Event is automatically removed from store
}
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
@ -74,11 +94,6 @@
initialized = true;
});
function handleEventModalClose() {
// Remove event param from URL
goto('/', { replaceState: true });
}
// Refetch events when view changes
$effect(() => {
if (initialized && authStore.isAuthenticated) {
@ -96,7 +111,7 @@
</script>
<svelte:head>
<title>Kalender</title>
<title>{$_('app.name')}</title>
</svelte:head>
<div class="calendar-layout">
@ -106,7 +121,7 @@
<button
class="sidebar-collapse-btn"
onclick={() => settingsStore.toggleSidebar()}
title="Sidebar ausblenden"
title={$_('calendar.hideSidebar')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -125,7 +140,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neuer Termin
{$_('calendar.newEvent')}
</button>
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
@ -141,7 +156,7 @@
<button
class="fab-expand"
onclick={() => settingsStore.toggleSidebar()}
title="Sidebar einblenden"
title={$_('calendar.showSidebar')}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -152,7 +167,7 @@
/>
</svg>
</button>
<button class="fab-new-event" onclick={handleNewEvent} title="Neuer Termin">
<button class="fab-new-event" onclick={handleNewEvent} title={$_('calendar.newEvent')}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@ -173,37 +188,49 @@
{#if !initialized}
<CalendarViewSkeleton />
{:else if viewStore.viewType === 'day'}
<DayView onQuickCreate={handleQuickCreate} />
<DayView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} onQuickCreate={handleQuickCreate} />
<MultiDayView
dayCount={5}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'week'}
<WeekView onQuickCreate={handleQuickCreate} />
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} onQuickCreate={handleQuickCreate} />
<MultiDayView
dayCount={10}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} onQuickCreate={handleQuickCreate} />
<MultiDayView
dayCount={14}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'month'}
<MonthView onQuickCreate={handleQuickCreate} />
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === 'year'}
<YearView onQuickCreate={handleQuickCreate} />
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else}
<WeekView onQuickCreate={handleQuickCreate} />
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{/if}
</div>
</div>
<!-- Quick Event Overlay -->
{#if showQuickCreate}
<QuickEventOverlay
startTime={quickCreateDate}
onClose={handleQuickCreateClose}
onCreated={handleEventCreated}
/>
{/if}
<!-- Event Detail Modal -->
{#if modalEventId}
<EventDetailModal eventId={modalEventId} onClose={handleEventModalClose} />
<!-- Quick Event Overlay (for both create and edit) -->
{#if showQuickOverlay}
{#key overlayKey}
<QuickEventOverlay
startTime={editingEvent ? undefined : quickCreateDate}
event={editingEvent ?? undefined}
onClose={handleQuickOverlayClose}
onCreated={handleEventCreated}
onUpdated={handleEventUpdated}
onDeleted={handleEventDeleted}
/>
{/key}
{/if}
</div>

View file

@ -34,6 +34,11 @@
return;
}
// Refresh calendars in case a default calendar was created
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
toast.success('Termin erstellt');
goto('/');
}

View file

@ -5,7 +5,7 @@
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import '$lib/i18n';
let graphComponent: NetworkGraph;
let graphComponent = $state<NetworkGraph | null>(null);
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
@ -172,7 +172,11 @@
<div class="info-panel">
<div class="info-header">
<h3>{networkStore.selectedNode.name}</h3>
<button class="close-btn" onclick={() => networkStore.selectNode(null)}>
<button
class="close-btn"
onclick={() => networkStore.selectNode(null)}
aria-label="Schließen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"

View file

@ -516,105 +516,6 @@
color: hsl(var(--color-muted-foreground));
}
/* Language options */
.locale-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.locale-option {
padding: 0.5rem 1rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.locale-option:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.locale-option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
/* Theme options */
.theme-options {
display: flex;
gap: 0.5rem;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.theme-option:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.theme-option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.theme-option .icon {
width: 1.25rem;
height: 1.25rem;
}
/* Variant grid */
.variant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
}
.variant-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.75rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
cursor: pointer;
transition: all 150ms ease;
}
.variant-option:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.variant-option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.variant-icon {
font-size: 1.5rem;
}
.variant-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
/* Select input */
.select-input {
width: 100%;

View file

@ -1,5 +1,7 @@
<script lang="ts">
import '../app.css';
// Initialize i18n early - must be imported before any component that uses $_
import { waitLocale } from '$lib/i18n';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
@ -11,6 +13,9 @@
let loading = $state(true);
onMount(async () => {
// Wait for i18n locale to be loaded
await waitLocale();
// Initialize theme
theme.initialize();

View file

@ -1,10 +1,21 @@
/**
* Event attendee RSVP status
*/
export type AttendeeStatus = 'accepted' | 'declined' | 'tentative' | 'pending';
/**
* Event attendee information
*/
export interface EventAttendee {
email: string;
name?: string;
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
status?: AttendeeStatus;
/** Contact reference for linked contacts */
contactId?: string;
/** Cached photo URL from contact */
photoUrl?: string;
/** Cached company from contact */
company?: string;
}
/**
@ -126,7 +137,8 @@ export interface CalendarEventWithCalendar extends CalendarEvent {
* Data required to create a new event
*/
export interface CreateEventInput {
calendarId: string;
/** Calendar ID. If not provided, the default calendar will be used (or created if none exists) */
calendarId?: string;
title: string;
description?: string;
location?: string;

View file

@ -0,0 +1,232 @@
/**
* Cross-App API Client for Todo Backend
* Allows Contacts app to fetch tasks related to a contact
*/
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
// Types mirrored from @todo/shared
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export interface Subtask {
id: string;
title: string;
isCompleted: boolean;
completedAt?: string | null;
order: number;
}
export interface Label {
id: string;
userId: string;
name: string;
color: string;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
userId: string;
name: string;
description?: string | null;
color: string;
icon?: string | null;
order: number;
isArchived: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface ContactReference {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
fetchedAt: string;
}
export interface TaskMetadata {
notes?: string;
attachments?: string[];
linkedCalendarEventId?: string | null;
storyPoints?: number | null;
effectiveDuration?: {
value: number;
unit: 'minutes' | 'hours' | 'days';
} | null;
funRating?: number | null;
assignee?: ContactReference | null;
involvedContacts?: ContactReference[];
}
export interface Task {
id: string;
projectId?: string | null;
userId: string;
parentTaskId?: string | null;
title: string;
description?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority: TaskPriority;
status: TaskStatus;
isCompleted: boolean;
completedAt?: string | null;
order: number;
columnId?: string | null;
columnOrder?: number;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
lastOccurrence?: string | null;
subtasks?: Subtask[] | null;
metadata?: TaskMetadata | null;
labels?: Label[];
project?: Project | null;
createdAt: string;
updatedAt: string;
}
// API Configuration
function getTodoApiBase(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_TODO_BACKEND_URL__?: string })
.__PUBLIC_TODO_BACKEND_URL__;
return injectedUrl || 'http://localhost:3018';
}
return 'http://localhost:3018';
}
interface ApiResult<T> {
data: T | null;
error: Error | null;
}
async function fetchTodoApi<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResult<T>> {
const token = await authStore.getAccessToken();
const baseUrl = getTodoApiBase();
try {
const response = await fetch(`${baseUrl}/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
// API Functions
/**
* Get tasks related to a specific contact (assigned or involved)
*/
export async function getTasksByContact(
contactId: string,
includeCompleted: boolean = false
): Promise<ApiResult<Task[]>> {
const params = new URLSearchParams();
if (includeCompleted) {
params.set('includeCompleted', 'true');
}
const query = params.toString();
const result = await fetchTodoApi<{ tasks: Task[] }>(
`/tasks/by-contact/${contactId}${query ? `?${query}` : ''}`
);
return {
data: result.data?.tasks || null,
error: result.error,
};
}
/**
* Complete a task
*/
export async function completeTask(taskId: string): Promise<ApiResult<Task>> {
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/complete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
/**
* Uncomplete a task
*/
export async function uncompleteTask(taskId: string): Promise<ApiResult<Task>> {
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/uncomplete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
/**
* Check if the Todo service is available
*/
export async function checkTodoServiceAvailable(): Promise<boolean> {
try {
const baseUrl = getTodoApiBase();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${baseUrl}/api/v1/health`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok;
} catch {
return false;
}
}
// Priority styling helpers
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
urgent: 'var(--color-danger, #ef4444)',
high: 'var(--color-warning, #f59e0b)',
medium: 'var(--color-accent, #3b82f6)',
low: 'var(--color-success, #22c55e)',
};
export const PRIORITY_LABELS: Record<TaskPriority, { de: string; en: string }> = {
urgent: { de: 'Dringend', en: 'Urgent' },
high: { de: 'Wichtig', en: 'High' },
medium: { de: 'Normal', en: 'Medium' },
low: { de: 'Niedrig', en: 'Low' },
};

View file

@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
import ContactNotes from './ContactNotes.svelte';
import ContactTasks from './ContactTasks.svelte';
import { ContactDetailSkeleton } from '$lib/components/skeletons';
interface Props {
@ -841,6 +842,9 @@
<!-- Contact Notes (separate from contact.notes field) -->
<ContactNotes {contactId} />
<!-- Tasks related to this contact -->
<ContactTasks {contactId} />
</div>
{/if}
{/if}

View file

@ -0,0 +1,515 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { todosStore } from '$lib/stores/todos.svelte';
import { PRIORITY_COLORS, type Task } from '$lib/api/todos';
interface Props {
contactId: string;
}
let { contactId }: Props = $props();
let loading = $state(true);
let error = $state<string | null>(null);
let showCompleted = $state(false);
let displayLimit = $state(10);
// Categorized tasks
let assignedTasks = $state<Task[]>([]);
let involvedTasks = $state<Task[]>([]);
// Derived values
const visibleAssigned = $derived.by(() => {
const filtered = showCompleted ? assignedTasks : assignedTasks.filter((t) => !t.isCompleted);
return filtered.slice(0, displayLimit);
});
const visibleInvolved = $derived.by(() => {
const filtered = showCompleted ? involvedTasks : involvedTasks.filter((t) => !t.isCompleted);
return filtered.slice(0, displayLimit);
});
const totalVisible = $derived(visibleAssigned.length + visibleInvolved.length);
const totalTasks = $derived(assignedTasks.length + involvedTasks.length);
const hasMore = $derived(totalVisible < totalTasks);
async function loadTasks() {
loading = true;
error = null;
try {
// Always load with completed to get full list, filter in UI
await todosStore.loadTasksForContact(contactId, true);
const categorized = todosStore.categorizeTasksForContact(contactId);
assignedTasks = categorized.assigned;
involvedTasks = categorized.involved;
} catch (e) {
error = e instanceof Error ? e.message : $_('contact.tasks.error');
} finally {
loading = false;
}
}
async function handleToggleComplete(task: Task) {
const success = await todosStore.toggleTaskCompletion(task.id, contactId);
if (success) {
// Refresh categorization
const categorized = todosStore.categorizeTasksForContact(contactId);
assignedTasks = categorized.assigned;
involvedTasks = categorized.involved;
}
}
function formatDueDate(dueDate: string | null | undefined): {
text: string;
status: 'overdue' | 'today' | 'upcoming' | 'none';
} {
if (!dueDate) return { text: '', status: 'none' };
const due = new Date(dueDate);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
const diffDays = Math.floor((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { text: $_('contact.tasks.overdue'), status: 'overdue' };
} else if (diffDays === 0) {
return { text: $_('contact.tasks.dueToday'), status: 'today' };
} else if (diffDays === 1) {
return { text: $_('contact.tasks.tomorrow'), status: 'upcoming' };
} else if (diffDays < 7) {
return { text: due.toLocaleDateString('de-DE', { weekday: 'short' }), status: 'upcoming' };
} else {
return {
text: due.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' }),
status: 'upcoming',
};
}
}
function showMore() {
displayLimit += 10;
}
onMount(loadTasks);
</script>
<section class="tasks-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</div>
<h3 class="section-title">{$_('contact.tasks.title')}</h3>
<label class="show-completed-toggle">
<input type="checkbox" bind:checked={showCompleted} />
<span>{$_('contact.tasks.showCompleted')}</span>
</label>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<div class="loading">
<span class="spinner"></span>
</div>
{:else if !todosStore.serviceAvailable}
<div class="service-unavailable">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p>{$_('contact.tasks.serviceUnavailable')}</p>
</div>
{:else if totalTasks === 0}
<div class="empty-tasks">
<p>{$_('contact.tasks.empty')}</p>
</div>
{:else}
<!-- Assigned Tasks -->
{#if assignedTasks.length > 0}
<div class="task-group">
<div class="group-header">
<span class="group-title">{$_('contact.tasks.assigned')}</span>
<span class="group-count">{visibleAssigned.length}</span>
</div>
<div class="tasks-list">
{#each visibleAssigned as task (task.id)}
{@const dueInfo = formatDueDate(task.dueDate)}
<div class="task-item" class:completed={task.isCompleted}>
<button
class="task-checkbox"
onclick={() => handleToggleComplete(task)}
aria-label={task.isCompleted
? $_('contact.tasks.markIncomplete')
: $_('contact.tasks.markComplete')}
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
>
{#if task.isCompleted}
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
{/if}
</button>
<div class="task-content">
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
{#if task.project}
<span class="task-project" style="--project-color: {task.project.color}"
>{task.project.name}</span
>
{/if}
</div>
{#if dueInfo.status !== 'none' && !task.isCompleted}
<span
class="task-due"
class:overdue={dueInfo.status === 'overdue'}
class:today={dueInfo.status === 'today'}
>
{dueInfo.text}
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Involved Tasks -->
{#if involvedTasks.length > 0}
<div class="task-group">
<div class="group-header">
<span class="group-title">{$_('contact.tasks.involved')}</span>
<span class="group-count">{visibleInvolved.length}</span>
</div>
<div class="tasks-list">
{#each visibleInvolved as task (task.id)}
{@const dueInfo = formatDueDate(task.dueDate)}
<div class="task-item" class:completed={task.isCompleted}>
<button
class="task-checkbox"
onclick={() => handleToggleComplete(task)}
aria-label={task.isCompleted
? $_('contact.tasks.markIncomplete')
: $_('contact.tasks.markComplete')}
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
>
{#if task.isCompleted}
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
{/if}
</button>
<div class="task-content">
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
{#if task.project}
<span class="task-project" style="--project-color: {task.project.color}"
>{task.project.name}</span
>
{/if}
</div>
{#if dueInfo.status !== 'none' && !task.isCompleted}
<span
class="task-due"
class:overdue={dueInfo.status === 'overdue'}
class:today={dueInfo.status === 'today'}
>
{dueInfo.text}
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Show More Button -->
{#if hasMore}
<button class="show-more-btn" onclick={showMore}>
{$_('contact.tasks.showMore', { values: { count: totalTasks - totalVisible } })}
</button>
{/if}
{/if}
</section>
<style>
.tasks-section {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
padding: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.625rem;
padding-bottom: 0.625rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0.75rem;
}
.section-icon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-accent) / 0.1);
color: hsl(var(--color-accent));
display: flex;
align-items: center;
justify-content: center;
}
.section-icon svg {
width: 1rem;
height: 1rem;
}
.section-title {
flex: 1;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.show-completed-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.show-completed-toggle input {
width: 0.875rem;
height: 0.875rem;
accent-color: hsl(var(--color-primary));
}
.error-message {
padding: 0.5rem 0.75rem;
background: hsl(var(--color-error) / 0.1);
border-radius: 0.5rem;
color: hsl(var(--color-error));
font-size: 0.8125rem;
margin-bottom: 0.75rem;
}
.loading {
display: flex;
justify-content: center;
padding: 1.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-accent));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.service-unavailable {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
}
.service-unavailable svg {
width: 1.5rem;
height: 1.5rem;
color: hsl(var(--color-warning));
}
.service-unavailable p {
font-size: 0.8125rem;
text-align: center;
}
.empty-tasks {
text-align: center;
padding: 1.5rem 1rem;
}
.empty-tasks p {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
/* Task Groups */
.task-group {
margin-bottom: 0.75rem;
}
.task-group:last-of-type {
margin-bottom: 0;
}
.group-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.group-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.group-count {
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
/* Tasks List */
.tasks-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.task-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.task-item:hover {
background: hsl(var(--color-muted) / 0.5);
}
.task-item.completed {
opacity: 0.6;
}
.task-checkbox {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
border: 2px solid var(--priority-color, hsl(var(--color-border)));
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
}
.task-checkbox:hover {
background: hsl(var(--color-muted));
}
.task-item.completed .task-checkbox {
background: var(--priority-color, hsl(var(--color-primary)));
border-color: var(--priority-color, hsl(var(--color-primary)));
}
.task-checkbox svg {
width: 0.75rem;
height: 0.75rem;
color: white;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.task-title {
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-title.completed {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.task-project {
font-size: 0.6875rem;
color: var(--project-color, hsl(var(--color-muted-foreground)));
}
.task-due {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
flex-shrink: 0;
}
.task-due.overdue {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.task-due.today {
background: hsl(var(--color-warning) / 0.1);
color: hsl(var(--color-warning));
}
/* Show More Button */
.show-more-btn {
width: 100%;
padding: 0.5rem;
margin-top: 0.5rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.show-more-btn:hover {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
</style>

View file

@ -102,7 +102,22 @@
"country": "Land",
"website": "Website",
"birthday": "Geburtstag",
"notes": "Notizen"
"notes": "Notizen",
"tasks": {
"title": "Aufgaben",
"assigned": "Zugewiesen",
"involved": "Beteiligt",
"empty": "Keine Aufgaben für diesen Kontakt",
"serviceUnavailable": "Todo-Service nicht erreichbar",
"error": "Fehler beim Laden der Aufgaben",
"overdue": "Überfällig",
"dueToday": "Heute",
"tomorrow": "Morgen",
"showCompleted": "Erledigte",
"showMore": "{count} weitere anzeigen",
"markComplete": "Als erledigt markieren",
"markIncomplete": "Als unerledigt markieren"
}
},
"groups": {
"title": "Gruppen",

View file

@ -102,7 +102,22 @@
"country": "Country",
"website": "Website",
"birthday": "Birthday",
"notes": "Notes"
"notes": "Notes",
"tasks": {
"title": "Tasks",
"assigned": "Assigned",
"involved": "Involved",
"empty": "No tasks for this contact",
"serviceUnavailable": "Todo service unavailable",
"error": "Failed to load tasks",
"overdue": "Overdue",
"dueToday": "Today",
"tomorrow": "Tomorrow",
"showCompleted": "Completed",
"showMore": "Show {count} more",
"markComplete": "Mark as complete",
"markIncomplete": "Mark as incomplete"
}
},
"groups": {
"title": "Groups",

View file

@ -0,0 +1,209 @@
/**
* Todo Store for Contacts App
* Manages tasks related to contacts from the Todo service
*/
import { browser } from '$app/environment';
import {
getTasksByContact,
completeTask,
uncompleteTask,
checkTodoServiceAvailable,
type Task,
} from '$lib/api/todos';
// State
let tasksByContact = $state<Map<string, Task[]>>(new Map());
let loadingContacts = $state<Set<string>>(new Set());
let serviceAvailable = $state<boolean | null>(null);
let lastAvailabilityCheck = $state<number>(0);
// Cache TTL in milliseconds (5 minutes)
const CACHE_TTL = 5 * 60 * 1000;
// Availability check interval (30 seconds)
const AVAILABILITY_CHECK_INTERVAL = 30 * 1000;
// Cache timestamps
const cacheTimestamps = new Map<string, number>();
/**
* Check if cached data is still valid
*/
function isCacheValid(contactId: string): boolean {
const timestamp = cacheTimestamps.get(contactId);
if (!timestamp) return false;
return Date.now() - timestamp < CACHE_TTL;
}
/**
* Check if the Todo service is available (with caching)
*/
async function checkAvailability(): Promise<boolean> {
if (!browser) return false;
const now = Date.now();
if (serviceAvailable !== null && now - lastAvailabilityCheck < AVAILABILITY_CHECK_INTERVAL) {
return serviceAvailable;
}
const available = await checkTodoServiceAvailable();
serviceAvailable = available;
lastAvailabilityCheck = now;
return available;
}
/**
* Load tasks for a specific contact
*/
async function loadTasksForContact(
contactId: string,
includeCompleted: boolean = false,
forceRefresh: boolean = false
): Promise<Task[]> {
if (!browser) return [];
// Check cache first
if (!forceRefresh && isCacheValid(contactId)) {
return tasksByContact.get(contactId) || [];
}
// Check service availability
const available = await checkAvailability();
if (!available) {
return [];
}
// Mark as loading
loadingContacts = new Set([...loadingContacts, contactId]);
try {
const { data, error } = await getTasksByContact(contactId, includeCompleted);
if (error) {
console.error(`Failed to load tasks for contact ${contactId}:`, error);
return [];
}
const tasks = data || [];
// Update cache
tasksByContact = new Map(tasksByContact).set(contactId, tasks);
cacheTimestamps.set(contactId, Date.now());
return tasks;
} finally {
// Remove from loading set
const newLoading = new Set(loadingContacts);
newLoading.delete(contactId);
loadingContacts = newLoading;
}
}
/**
* Get cached tasks for a contact (does not fetch)
*/
function getTasksForContact(contactId: string): Task[] {
return tasksByContact.get(contactId) || [];
}
/**
* Check if tasks are currently loading for a contact
*/
function isLoading(contactId: string): boolean {
return loadingContacts.has(contactId);
}
/**
* Toggle task completion
*/
async function toggleTaskCompletion(taskId: string, contactId: string): Promise<boolean> {
const tasks = tasksByContact.get(contactId) || [];
const task = tasks.find((t) => t.id === taskId);
if (!task) return false;
const { data, error } = task.isCompleted
? await uncompleteTask(taskId)
: await completeTask(taskId);
if (error || !data) {
console.error('Failed to toggle task completion:', error);
return false;
}
// Update local state
const updatedTasks = tasks.map((t) => (t.id === taskId ? data : t));
tasksByContact = new Map(tasksByContact).set(contactId, updatedTasks);
return true;
}
/**
* Clear cache for a specific contact
*/
function clearCacheForContact(contactId: string): void {
const newMap = new Map(tasksByContact);
newMap.delete(contactId);
tasksByContact = newMap;
cacheTimestamps.delete(contactId);
}
/**
* Clear all cached data
*/
function clearCache(): void {
tasksByContact = new Map();
cacheTimestamps.clear();
}
/**
* Categorize tasks by their relation to the contact
*/
function categorizeTasksForContact(contactId: string): { assigned: Task[]; involved: Task[] } {
const tasks = tasksByContact.get(contactId) || [];
const assigned: Task[] = [];
const involved: Task[] = [];
for (const task of tasks) {
const isAssignee = task.metadata?.assignee?.contactId === contactId;
const isInvolved = task.metadata?.involvedContacts?.some((c) => c.contactId === contactId);
if (isAssignee) {
assigned.push(task);
} else if (isInvolved) {
involved.push(task);
}
}
// Sort by due date (overdue first, then by date)
const sortByDueDate = (a: Task, b: Task): number => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
};
assigned.sort(sortByDueDate);
involved.sort(sortByDueDate);
return { assigned, involved };
}
// Export store
export const todosStore = {
// Getters (reactive)
get serviceAvailable() {
return serviceAvailable;
},
// Methods
checkAvailability,
loadTasksForContact,
getTasksForContact,
isLoading,
toggleTaskCompletion,
clearCacheForContact,
clearCache,
categorizeTasksForContact,
};

View file

@ -57,6 +57,12 @@ export const tasks = pgTable(
dueTime: varchar('due_time', { length: 5 }),
startDate: timestamp('start_date', { withTimezone: true }),
// Time-Blocking (for calendar integration)
scheduledDate: timestamp('scheduled_date', { withTimezone: true }),
scheduledStartTime: varchar('scheduled_start_time', { length: 5 }), // HH:mm
scheduledEndTime: varchar('scheduled_end_time', { length: 5 }), // HH:mm
estimatedDuration: integer('estimated_duration'), // in minutes
// Priority & Status
priority: varchar('priority', { length: 10 }).default('medium').$type<TaskPriority>(),
status: varchar('status', { length: 20 }).default('pending').$type<TaskStatus>(),
@ -90,6 +96,7 @@ export const tasks = pgTable(
projectIdx: index('tasks_project_idx').on(table.projectId),
userIdx: index('tasks_user_idx').on(table.userId),
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
scheduledDateIdx: index('tasks_scheduled_date_idx').on(table.scheduledDate),
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),

View file

@ -9,6 +9,10 @@ import {
IsDateString,
IsNotEmpty,
ValidateNested,
IsInt,
Min,
Max,
Matches,
} from 'class-validator';
import { Type } from 'class-transformer';
import type { TaskPriority } from '../../db/schema/tasks.schema';
@ -47,6 +51,27 @@ export class CreateTaskDto {
@IsDateString()
startDate?: string | null;
// Time-Blocking fields
@IsOptional()
@IsDateString()
scheduledDate?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
scheduledStartTime?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
scheduledEndTime?: string | null;
@IsOptional()
@IsInt()
@Min(1)
@Max(1440) // Max 24 hours in minutes
estimatedDuration?: number | null;
@IsOptional()
@IsEnum(['low', 'medium', 'high', 'urgent'])
priority?: TaskPriority;

View file

@ -9,6 +9,10 @@ import {
IsObject,
MaxLength,
IsDateString,
IsInt,
Min,
Max,
Matches,
} from 'class-validator';
import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
@ -43,6 +47,27 @@ export class UpdateTaskDto {
@IsDateString()
startDate?: string | null;
// Time-Blocking fields
@IsOptional()
@IsDateString()
scheduledDate?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
scheduledStartTime?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
scheduledEndTime?: string | null;
@IsOptional()
@IsInt()
@Min(1)
@Max(1440) // Max 24 hours in minutes
estimatedDuration?: number | null;
@IsOptional()
@IsEnum(['low', 'medium', 'high', 'urgent'])
priority?: TaskPriority;
@ -74,4 +99,9 @@ export class UpdateTaskDto {
@IsOptional()
@IsObject()
metadata?: TaskMetadata | null;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
labelIds?: string[];
}

View file

@ -42,6 +42,20 @@ export class TaskController {
return result;
}
@Get('by-contact/:contactId')
async getByContact(
@CurrentUser() user: CurrentUserData,
@Param('contactId') contactId: string,
@Query('includeCompleted') includeCompleted?: string
) {
const tasks = await this.taskService.findByContact(
user.userId,
contactId,
includeCompleted === 'true'
);
return { tasks };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const task = await this.taskService.findByIdOrThrow(id, user.userId);

View file

@ -1,5 +1,5 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql, inArray } from 'drizzle-orm';
import { RRule, RRuleSet, rrulestr } from 'rrule';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
@ -125,6 +125,11 @@ export class TaskService {
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
dueTime: dto.dueTime,
startDate: dto.startDate ? new Date(dto.startDate) : null,
// Time-Blocking fields
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
scheduledStartTime: dto.scheduledStartTime,
scheduledEndTime: dto.scheduledEndTime,
estimatedDuration: dto.estimatedDuration,
priority: dto.priority ?? 'medium',
recurrenceRule: dto.recurrenceRule,
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : null,
@ -151,14 +156,23 @@ export class TaskService {
await this.projectService.findByIdOrThrow(dto.projectId, userId);
}
// Extract labelIds before spreading dto (it's not a db column)
const { labelIds, ...dtoWithoutLabels } = dto;
const updateData: Partial<NewTask> = {
...dto,
...dtoWithoutLabels,
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
startDate: dto.startDate
? new Date(dto.startDate)
: dto.startDate === null
? null
: undefined,
// Time-Blocking fields
scheduledDate: dto.scheduledDate
? new Date(dto.scheduledDate)
: dto.scheduledDate === null
? null
: undefined,
recurrenceEndDate: dto.recurrenceEndDate
? new Date(dto.recurrenceEndDate)
: dto.recurrenceEndDate === null
@ -181,6 +195,11 @@ export class TaskService {
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.returning();
// Update labels if provided
if (labelIds !== undefined) {
await this.updateTaskLabels(id, userId, labelIds);
}
return this.loadTaskLabels(updated);
}
@ -411,6 +430,41 @@ export class TaskService {
return this.findAll(userId, { isCompleted: false });
}
/**
* Finds all tasks where the given contact is either the assignee or involved.
* Searches in metadata->assignee->contactId and metadata->involvedContacts array.
*/
async findByContact(
userId: string,
contactId: string,
includeCompleted: boolean = false
): Promise<TaskWithLabels[]> {
// Build conditions for the query
const conditions: SQL[] = [eq(tasks.userId, userId)];
// Optionally exclude completed tasks
if (!includeCompleted) {
conditions.push(eq(tasks.isCompleted, false));
}
// Search for contactId in metadata->assignee->contactId OR in metadata->involvedContacts array
const contactCondition = or(
// Check if assignee.contactId matches
sql`${tasks.metadata}->>'assignee' IS NOT NULL AND ${tasks.metadata}->'assignee'->>'contactId' = ${contactId}`,
// Check if contactId exists in involvedContacts array
sql`${tasks.metadata}->'involvedContacts' @> ${JSON.stringify([{ contactId }])}::jsonb`
);
conditions.push(contactCondition as SQL);
const result = await this.db.query.tasks.findMany({
where: and(...conditions),
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return this.loadTaskLabelsBatch(result);
}
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
@ -434,10 +488,13 @@ export class TaskService {
}
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
// Ensure days is a valid number
const daysNum = typeof days === 'number' && !isNaN(days) ? days : 7;
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + days);
const endDate = new Date(today.getTime());
endDate.setDate(endDate.getDate() + daysNum);
const result = await this.db.query.tasks.findMany({
where: and(
@ -525,10 +582,11 @@ export class TaskService {
const taskIds = taskList.map((t) => t.id);
// Single query to get all task-label relationships
const allTaskLabels = await this.db.query.taskLabels.findMany({
where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))),
});
// Single query to get all task-label relationships using inArray
const allTaskLabels = await this.db
.select()
.from(taskLabels)
.where(inArray(taskLabels.taskId, taskIds));
if (allTaskLabels.length === 0) {
// No labels for any task - return tasks with empty labels array
@ -538,10 +596,8 @@ export class TaskService {
// Get unique label IDs
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
// Single query to get all labels
const allLabels = await this.db.query.labels.findMany({
where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))),
});
// Single query to get all labels using inArray
const allLabels = await this.db.select().from(labels).where(inArray(labels.id, uniqueLabelIds));
// Create a map of labelId -> label for fast lookup
const labelMap = new Map(allLabels.map((l) => [l.id, l]));

View file

@ -30,6 +30,7 @@
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",

View file

@ -14,13 +14,21 @@ interface CreateTaskDto {
interface UpdateTaskDto {
title?: string;
description?: string;
description?: string | null;
projectId?: string | null;
parentTaskId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
subtasks?: Subtask[];
isCompleted?: boolean;
order?: number;
subtasks?: Subtask[] | null;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
metadata?: Record<string, unknown> | null;
labelIds?: string[];
}
interface TaskQuery {

View file

@ -7,8 +7,10 @@
EffectiveDuration,
UpdateTaskInput,
} from '@todo/shared';
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { projectsStore } from '$lib/stores/projects.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { format } from 'date-fns';
import SubtaskList from './SubtaskList.svelte';
import {
@ -18,6 +20,7 @@
FunRatingPicker,
TagSelector,
} from './form';
import { ContactSelector } from '@manacore/shared-ui';
interface Props {
task: Task;
@ -45,6 +48,10 @@
let storyPoints = $state<number | null>(null);
let effectiveDuration = $state<EffectiveDuration | null>(null);
let funRating = $state<number | null>(null);
// Contact associations
let assignee = $state<ContactOrManual[]>([]);
let involvedContacts = $state<ContactOrManual[]>([]);
let contactsAvailable = $state<boolean | null>(null);
// UI state
let isLoading = $state(false);
@ -69,7 +76,15 @@
storyPoints = task.metadata?.storyPoints ?? null;
effectiveDuration = task.metadata?.effectiveDuration ?? null;
funRating = task.metadata?.funRating ?? null;
// Contact associations
assignee = task.metadata?.assignee ? [task.metadata.assignee] : [];
involvedContacts = task.metadata?.involvedContacts || [];
showDeleteConfirm = false;
// Check contacts availability
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
}
});
@ -88,11 +103,26 @@
}
}
// Extract ContactReference from ContactOrManual (filter out manual entries for now)
function toContactReference(contact: ContactOrManual): ContactReference | null {
if ('isManual' in contact && contact.isManual) {
return null; // Manual entries not stored as contacts
}
return contact as ContactReference;
}
async function handleSave() {
if (!title.trim()) return;
isLoading = true;
try {
// Convert assignee array to single ContactReference
const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null;
// Convert involved contacts to array of ContactReferences
const involvedRefs = involvedContacts
.map(toContactReference)
.filter((c): c is ContactReference => c !== null);
const data: UpdateTaskInput = {
title: title.trim(),
description: description.trim() || null,
@ -110,6 +140,8 @@
storyPoints: storyPoints ?? undefined,
effectiveDuration: effectiveDuration ?? undefined,
funRating: funRating ?? undefined,
assignee: assigneeRef ?? undefined,
involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined,
},
labelIds: selectedLabelIds,
};
@ -179,6 +211,37 @@
></textarea>
</div>
<!-- Zuständige Person -->
<div class="form-section">
<label class="form-label">Zuständig</label>
<ContactSelector
selectedContacts={assignee}
onContactsChange={(contacts) => (assignee = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
singleSelect={true}
allowManualEntry={false}
placeholder="Person zuweisen..."
addLabel="Zuweisen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
/>
</div>
<!-- Beteiligte Personen -->
<div class="form-section">
<label class="form-label">Beteiligte</label>
<ContactSelector
selectedContacts={involvedContacts}
onContactsChange={(contacts) => (involvedContacts = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
allowManualEntry={false}
placeholder="Personen hinzufügen..."
addLabel="Person hinzufügen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
/>
</div>
<!-- Zeitplanung -->
<div class="form-section">
<label class="form-label">Zeitplanung</label>

View file

@ -3,6 +3,7 @@
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { projectsStore } from '$lib/stores/projects.svelte';
import { ContactAvatar } from '@manacore/shared-ui';
interface Props {
task: Task;
@ -165,6 +166,33 @@
{/if}
</button>
<!-- Assignee and involved contacts -->
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
<div class="contacts-display">
{#if task.metadata?.assignee}
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
<ContactAvatar
name={task.metadata.assignee.displayName}
photoUrl={task.metadata.assignee.photoUrl}
size="xs"
/>
</div>
{/if}
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
<div class="involved-avatars">
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
</div>
{/each}
{#if task.metadata.involvedContacts.length > 2}
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Due date (always on the right) -->
{#if dueDateText()}
<span
@ -424,6 +452,58 @@
font-weight: 500;
}
/* Contacts display */
.contacts-display {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.assignee-avatar {
position: relative;
}
.assignee-avatar::after {
content: '';
position: absolute;
bottom: -1px;
right: -1px;
width: 6px;
height: 6px;
background: #8b5cf6;
border-radius: 50%;
border: 1px solid white;
}
:global(.dark) .assignee-avatar::after {
border-color: rgba(30, 30, 30, 1);
}
.involved-avatars {
display: flex;
align-items: center;
}
.involved-avatar {
margin-left: -0.375rem;
}
.involved-avatar:first-child {
margin-left: 0;
}
.more-contacts {
font-size: 0.625rem;
color: #6b7280;
margin-left: 0.25rem;
font-weight: 500;
}
:global(.dark) .more-contacts {
color: #9ca3af;
}
/* Due date */
.due-date {
font-size: 0.75rem;

View file

@ -28,18 +28,19 @@
// Track which task is being animated for completion
let animatingTaskId = $state<string | null>(null);
// Create a stable key from task IDs to detect real changes
let lastTaskIds = '';
// Create a stable key from task IDs and updatedAt to detect real changes
let lastTaskKey = '';
// Sync items with tasks only when the set of task IDs changes
// Sync items with tasks when IDs change OR when tasks are updated
$effect(() => {
const currentIds = tasks
.map((t) => t.id)
// Include updatedAt in the key to detect task updates
const currentKey = tasks
.map((t) => `${t.id}:${t.updatedAt || ''}`)
.sort()
.join(',');
if (currentIds !== lastTaskIds) {
if (currentKey !== lastTaskKey) {
items = [...tasks];
lastTaskIds = currentIds;
lastTaskKey = currentKey;
}
});
@ -70,10 +71,10 @@
}
}
// Update local state and sync lastTaskIds to prevent $effect from reverting
// Update local state and sync lastTaskKey to prevent $effect from reverting
items = newItems;
lastTaskIds = newItems
.map((t) => t.id)
lastTaskKey = newItems
.map((t) => `${t.id}:${t.updatedAt || ''}`)
.sort()
.join(',');
}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { KanbanColumn, Task, UpdateTaskInput } from '@todo/shared';
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
import type { KanbanColumn, Task } from '@todo/shared';
import KanbanTaskCard from './KanbanTaskCard.svelte';
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
@ -36,13 +36,11 @@
const flipDurationMs = 200;
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
function handleDndConsider(e: CustomEvent<DndEvent<Task>>) {
localTasks = e.detail.items;
}
function handleDndFinalize(
e: CustomEvent<{ items: Task[]; info: { id: string; source: { items: Task[] } } }>
) {
function handleDndFinalize(e: CustomEvent<DndEvent<Task>>) {
const newItems = e.detail.items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID);
const movedTaskId = e.detail.info.id;
@ -71,20 +69,21 @@
}
}
async function handleSaveTask(task: Task, data: UpdateTaskInput) {
// Transform data to match updateTask API (convert null to undefined)
const updateData: UpdateTaskInput = {};
async function handleSaveTask(task: Task, data: Partial<Task>) {
// Transform Partial<Task> to updateTask format
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description ?? undefined;
if (data.description !== undefined) updateData.description = data.description;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ?? undefined;
if (data.dueDate !== undefined) {
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
}
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.status !== undefined) updateData.status = data.status;
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks ?? undefined;
if (data.recurrenceRule !== undefined)
updateData.recurrenceRule = data.recurrenceRule ?? undefined;
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks;
if (data.recurrenceRule !== undefined) updateData.recurrenceRule = data.recurrenceRule;
if (data.metadata !== undefined) updateData.metadata = data.metadata;
if (data.labelIds !== undefined) updateData.labelIds = data.labelIds;
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
await tasksStore.updateTask(task.id, updateData);
}

View file

@ -2,7 +2,7 @@
import type { Task } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { ConfirmationModal } from '@manacore/shared-ui';
import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui';
import TaskEditModal from '../TaskEditModal.svelte';
interface Props {
@ -249,6 +249,33 @@
</div>
{/if}
</div>
<!-- Contacts display -->
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
<div class="contacts-display">
{#if task.metadata?.assignee}
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
<ContactAvatar
name={task.metadata.assignee.displayName}
photoUrl={task.metadata.assignee.photoUrl}
size="xs"
/>
</div>
{/if}
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
<div class="involved-avatars">
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
</div>
{/each}
{#if task.metadata.involvedContacts.length > 2}
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Context Menu -->
@ -500,6 +527,58 @@
font-weight: 500;
}
/* Contacts display */
.contacts-display {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.assignee-avatar {
position: relative;
}
.assignee-avatar::after {
content: '';
position: absolute;
bottom: -1px;
right: -1px;
width: 6px;
height: 6px;
background: #8b5cf6;
border-radius: 50%;
border: 1px solid white;
}
:global(.dark) .assignee-avatar::after {
border-color: rgba(30, 30, 30, 1);
}
.involved-avatars {
display: flex;
align-items: center;
}
.involved-avatar {
margin-left: -0.375rem;
}
.involved-avatar:first-child {
margin-left: 0;
}
.more-contacts {
font-size: 0.625rem;
color: #6b7280;
margin-left: 0.25rem;
font-weight: 500;
}
:global(.dark) .more-contacts {
color: #9ca3af;
}
/* Context Menu */
.context-menu {
position: fixed;

View file

@ -0,0 +1,23 @@
/**
* Feedback Service Instance for Todo Web App
*/
import { browser } from '$app/environment';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const feedbackService = createFeedbackService({
apiUrl: getAuthUrl(),
appId: 'todo',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,175 @@
/**
* Contacts Store for Todo App
*
* Provides access to contacts from the Contacts app for task assignment.
*/
import { browser } from '$app/environment';
import { createContactsClient, type ContactsClient } from '@manacore/shared-auth';
import type { ContactSummary } from '@manacore/shared-types';
import { authStore } from './auth.svelte';
// State
let client: ContactsClient | null = null;
let isAvailable = $state<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
*/
async searchContacts(query: string): Promise<ContactSummary[]> {
// Check cache first
const cacheKey = query.toLowerCase().trim();
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
}
// Check availability
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return [];
}
try {
const results = await getClient().searchContacts({
query,
limit: 20,
excludeArchived: true,
});
// Cache results
searchCache.set(cacheKey, {
results,
timestamp: Date.now(),
});
return results;
} catch (error) {
console.error('[contactsStore] Search failed:', error);
return [];
}
},
/**
* Get a single contact by ID
*/
async getContact(id: string): Promise<ContactSummary | null> {
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return null;
}
try {
return await getClient().getContact(id);
} catch (error) {
console.error(`[contactsStore] Failed to get contact ${id}:`, error);
return null;
}
},
/**
* Get multiple contacts by IDs
*/
async getContacts(ids: string[]): Promise<ContactSummary[]> {
if (ids.length === 0) return [];
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return [];
}
try {
return await getClient().getContacts(ids);
} catch (error) {
console.error('[contactsStore] Failed to get contacts:', error);
return [];
}
},
/**
* Clear the search cache
*/
clearCache() {
searchCache.clear();
},
/**
* Reset availability check (force recheck on next call)
*/
resetAvailability() {
isAvailable = null;
lastCheck = 0;
},
};

View file

@ -217,13 +217,21 @@ export const tasksStore = {
id: string,
data: {
title?: string;
description?: string;
description?: string | null;
projectId?: string | null;
parentTaskId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
subtasks?: Subtask[];
isCompleted?: boolean;
order?: number;
subtasks?: Subtask[] | null;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
metadata?: { [key: string]: unknown } | null;
labelIds?: string[];
}
) {
error = null;

View file

@ -100,8 +100,12 @@
if (!editingTask) return;
try {
// Update task
await tasksStore.updateTask(editingTask.id, data);
// Update task - cast metadata to be compatible with store type
const updateData = {
...data,
metadata: data.metadata as { [key: string]: unknown } | null | undefined,
};
await tasksStore.updateTask(editingTask.id, updateData);
// Update labels if provided
if (data.labelIds !== undefined) {

View file

@ -1,22 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
}
});
import { feedbackService } from '$lib/services/feedback';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<svelte:head>
<title>Feedback | Todo</title>
</svelte:head>
<FeedbackPage
appName="Todo"
userEmail={authStore.user?.email || undefined}
primaryColor="#8b5cf6"
/>
<FeedbackPage {feedbackService} appName="Todo" currentUserId={authStore.user?.id} />

View file

@ -11,20 +11,20 @@
// Get translations based on current locale
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleResetPassword(email: string) {
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<svelte:head>
<title>{translations.title} | Todo</title>
<title>{translations.titleForm} | Todo</title>
</svelte:head>
<ForgotPasswordPage
appName="Todo"
logo={TodoLogo}
primaryColor="#8b5cf6"
onResetPassword={handleResetPassword}
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#f3e8ff"

View file

@ -26,8 +26,6 @@
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect="/"
loginPath="/login"
lightBackground="#f3e8ff"

View file

@ -14,6 +14,9 @@
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.9.3"
}

View file

@ -1,4 +1,5 @@
import type { Label } from './label';
import type { ContactReference } from '@manacore/shared-types';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
@ -26,6 +27,9 @@ export interface TaskMetadata {
storyPoints?: number | null; // Fibonacci: 1, 2, 3, 5, 8, 13, 21
effectiveDuration?: EffectiveDuration | null; // Actual time spent
funRating?: number | null; // 1-10 scale
// Contact associations
assignee?: ContactReference | null; // Person responsible for the task
involvedContacts?: ContactReference[]; // Other people involved
}
export interface Task {
@ -43,6 +47,12 @@ export interface Task {
dueTime?: string | null; // HH:mm format
startDate?: Date | string | null;
// Time-Blocking (for calendar integration)
scheduledDate?: Date | string | null; // Date when task is scheduled
scheduledStartTime?: string | null; // HH:mm format - when to start
scheduledEndTime?: string | null; // HH:mm format - when to end
estimatedDuration?: number | null; // Duration in minutes
// Priority & Status
priority: TaskPriority;
status: TaskStatus;
@ -84,6 +94,11 @@ export interface CreateTaskInput {
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
@ -100,6 +115,11 @@ export interface UpdateTaskInput {
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;

View file

@ -0,0 +1,418 @@
# Foundation Layer - Verbesserungsvorschläge
> **Stand:** Dezember 2024
> **Betrifft:** Contacts, Todo, Calendar (Foundation Services)
## Aktuelle Architektur (Gut!)
Die drei Foundation Services sind korrekt als **separate Services mit eigenen Datenbanken** aufgesetzt:
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Contacts │ │ Todo │ │ Calendar │
│ :3010 │ │ :3011 │ │ :3012 │
│ │ │ │ │ │
│ contacts DB │ │ todo DB │ │ calendar DB │
└─────────────┘ └─────────────┘ └─────────────┘
```
**Warum das richtig ist:**
- Unabhängige Deployments
- Failure Isolation
- Unabhängige Skalierung
- Keine Schema-Konflikte zwischen Teams
---
## Verbesserungsvorschläge
### 1. Foundation Clients Package
**Aufwand:** Mittel | **Priorität:** Hoch
Einheitlicher API-Client für alle Consumer Apps (Chat, Picture, Clock, etc.).
**Neues Package:** `packages/foundation-clients/`
```typescript
// packages/foundation-clients/src/index.ts
export class FoundationClients {
contacts: ContactsClient;
todo: TodoClient;
calendar: CalendarClient;
constructor(config: FoundationConfig) {
this.contacts = new ContactsClient(config);
this.todo = new TodoClient(config);
this.calendar = new CalendarClient(config);
}
}
// packages/foundation-clients/src/contacts.client.ts
export class ContactsClient {
private baseUrl: string;
private cache: Map<string, CachedContact> = new Map();
async get(id: string): Promise<Contact | null> {
// Mit Caching
const cached = this.cache.get(id);
if (cached && !this.isStale(cached)) {
return cached.data;
}
const response = await fetch(`${this.baseUrl}/contacts/${id}`, {
headers: { Authorization: `Bearer ${this.token}` }
});
if (!response.ok) return null;
const contact = await response.json();
this.cache.set(id, { data: contact, fetchedAt: Date.now() });
return contact;
}
async search(query: string): Promise<ContactSummary[]> {
// Für Autocomplete in anderen Apps
}
async getBulk(ids: string[]): Promise<Contact[]> {
// Effizient für Listen
}
}
```
**Nutzung in Consumer Apps:**
```typescript
// apps/chat/apps/backend/src/chat.service.ts
import { FoundationClients } from '@manacore/foundation-clients';
@Injectable()
export class ChatService {
private foundation: FoundationClients;
constructor(configService: ConfigService) {
this.foundation = new FoundationClients({
contactsUrl: configService.get('CONTACTS_API_URL'),
todoUrl: configService.get('TODO_API_URL'),
calendarUrl: configService.get('CALENDAR_API_URL'),
});
}
async getMessageWithContact(messageId: string) {
const message = await this.getMessage(messageId);
const sender = await this.foundation.contacts.get(message.senderId);
return { ...message, sender };
}
}
```
---
### 2. Event Bus (Redis Pub/Sub)
**Aufwand:** Mittel | **Priorität:** Mittel
Ermöglicht reaktive Updates zwischen Services ohne Polling.
**Events definieren:**
```typescript
// packages/foundation-events/src/index.ts
export const FoundationEvents = {
// Contacts
CONTACT_CREATED: 'contact.created',
CONTACT_UPDATED: 'contact.updated',
CONTACT_DELETED: 'contact.deleted',
// Todo
TASK_CREATED: 'task.created',
TASK_COMPLETED: 'task.completed',
TASK_DELETED: 'task.deleted',
// Calendar
EVENT_CREATED: 'event.created',
EVENT_UPDATED: 'event.updated',
EVENT_DELETED: 'event.deleted',
} as const;
export interface TaskCompletedEvent {
taskId: string;
userId: string;
completedAt: string;
linkedCalendarEventId?: string;
}
```
**Publisher (Todo Service):**
```typescript
// apps/todo/apps/backend/src/task/task.service.ts
import { RedisService } from '@manacore/shared-redis';
import { FoundationEvents } from '@manacore/foundation-events';
@Injectable()
export class TaskService {
constructor(private redis: RedisService) {}
async completeTask(taskId: string, userId: string) {
const task = await this.markCompleted(taskId);
// Event publizieren
await this.redis.publish(FoundationEvents.TASK_COMPLETED, {
taskId: task.id,
userId,
completedAt: new Date().toISOString(),
linkedCalendarEventId: task.metadata?.linkedCalendarEventId,
});
return task;
}
}
```
**Subscriber (Calendar Service):**
```typescript
// apps/calendar/apps/backend/src/calendar.module.ts
import { FoundationEvents } from '@manacore/foundation-events';
@Injectable()
export class CalendarEventSubscriber implements OnModuleInit {
constructor(
private redis: RedisService,
private eventService: EventService
) {}
onModuleInit() {
this.redis.subscribe(FoundationEvents.TASK_COMPLETED, async (data) => {
if (data.linkedCalendarEventId) {
await this.eventService.markLinkedTaskCompleted(
data.linkedCalendarEventId
);
}
});
}
}
```
**Use Cases:**
| Event | Reaktion |
|-------|----------|
| `task.completed` | Calendar markiert verknüpftes Event |
| `contact.updated` | Chat aktualisiert Sender-Anzeige |
| `event.deleted` | Todo entfernt `linkedCalendarEventId` |
| `contact.deleted` | Alle Apps entfernen Kontakt-Referenzen |
---
### 3. Bulk-Endpoints
**Aufwand:** Klein | **Priorität:** Hoch
Reduziert N+1 API-Calls bei Listen-Ansichten.
**Contacts Service:**
```typescript
// apps/contacts/apps/backend/src/contact/contact.controller.ts
@Controller('contacts')
export class ContactController {
@Post('bulk')
async getBulk(@Body() body: { ids: string[] }): Promise<Contact[]> {
return this.contactService.findByIds(body.ids);
}
@Get('search')
async search(
@Query('q') query: string,
@Query('limit') limit = 10
): Promise<ContactSummary[]> {
return this.contactService.search(query, limit);
}
}
```
**Todo Service:**
```typescript
// apps/todo/apps/backend/src/task/task.controller.ts
@Controller('tasks')
export class TaskController {
@Post('bulk')
async getBulk(@Body() body: { ids: string[] }): Promise<Task[]> {
return this.taskService.findByIds(body.ids);
}
@Get('by-contact/:contactId')
async getByContact(@Param('contactId') contactId: string): Promise<Task[]> {
// Tasks die mit einem Kontakt verknüpft sind
return this.taskService.findByLinkedContact(contactId);
}
}
```
**Calendar Service:**
```typescript
// apps/calendar/apps/backend/src/event/event.controller.ts
@Controller('events')
export class EventController {
@Post('bulk')
async getBulk(@Body() body: { ids: string[] }): Promise<Event[]> {
return this.eventService.findByIds(body.ids);
}
@Get('by-attendee')
async getByAttendee(@Query('email') email: string): Promise<Event[]> {
return this.eventService.findByAttendeeEmail(email);
}
}
```
---
### 4. Caching-Layer
**Aufwand:** Klein | **Priorität:** Mittel
Kontakte ändern sich selten - perfekt für Caching.
**In Foundation Clients (Client-Side Cache):**
```typescript
// packages/foundation-clients/src/cache.ts
export class SimpleCache<T> {
private cache = new Map<string, { data: T; expiresAt: number }>();
private ttlMs: number;
constructor(ttlSeconds = 300) {
this.ttlMs = ttlSeconds * 1000;
}
get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set(key: string, data: T): void {
this.cache.set(key, {
data,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(key: string): void {
this.cache.delete(key);
}
}
```
**Redis Cache (Server-Side):**
```typescript
// apps/contacts/apps/backend/src/contact/contact.service.ts
@Injectable()
export class ContactService {
private readonly CACHE_TTL = 300; // 5 Minuten
async findById(id: string): Promise<Contact | null> {
// 1. Redis Cache prüfen
const cached = await this.redis.get(`contact:${id}`);
if (cached) return JSON.parse(cached);
// 2. DB Query
const contact = await this.db
.select()
.from(contacts)
.where(eq(contacts.id, id))
.limit(1);
if (contact[0]) {
// 3. In Cache speichern
await this.redis.setex(
`contact:${id}`,
this.CACHE_TTL,
JSON.stringify(contact[0])
);
}
return contact[0] || null;
}
async update(id: string, data: UpdateContactDto): Promise<Contact> {
const updated = await this.db
.update(contacts)
.set(data)
.where(eq(contacts.id, id))
.returning();
// Cache invalidieren
await this.redis.del(`contact:${id}`);
return updated[0];
}
}
```
---
## Implementierungs-Reihenfolge
| Phase | Task | Abhängigkeiten |
|-------|------|----------------|
| **1** | Bulk-Endpoints hinzufügen | Keine |
| **2** | Foundation Clients Package erstellen | Bulk-Endpoints |
| **3** | Client-Side Caching in Foundation Clients | Foundation Clients |
| **4** | Redis Cache in Services | Redis Setup |
| **5** | Event Bus Setup | Redis Setup |
| **6** | Event Publisher/Subscriber | Event Bus |
---
## Neue Package-Struktur
```
packages/
├── foundation-clients/ # NEU: API Clients
│ ├── src/
│ │ ├── contacts.client.ts
│ │ ├── todo.client.ts
│ │ ├── calendar.client.ts
│ │ ├── cache.ts
│ │ └── index.ts
│ └── package.json
├── foundation-events/ # NEU: Event Definitions
│ ├── src/
│ │ ├── contact.events.ts
│ │ ├── task.events.ts
│ │ ├── calendar.events.ts
│ │ └── index.ts
│ └── package.json
├── shared-types/ # Existiert bereits
│ └── src/
│ ├── contact.ts # ContactReference, ContactSummary
│ └── ...
└── shared-redis/ # NEU oder erweitern
└── src/
├── redis.service.ts
├── pub-sub.ts
└── index.ts
```
---
## Offene Fragen
- [ ] Welche Consumer Apps werden als erste integriert?
- [ ] Redis bereits im Stack oder neu einführen?
- [ ] Cache TTL pro Entity-Typ oder einheitlich?
- [ ] Event Bus: Redis Pub/Sub vs. dediziertes System (Bull, etc.)?

View file

@ -15,6 +15,7 @@
"lint": "eslint ."
},
"dependencies": {
"@manacore/shared-types": "workspace:*",
"base64-js": "^1.5.1"
},
"devDependencies": {

View file

@ -0,0 +1,204 @@
/**
* Contacts API Client for cross-app integration
*
* This client allows other apps (Todo, Calendar) to search and fetch contacts
* from the Contacts app backend.
*/
import type { ContactSummary } from '@manacore/shared-types';
export interface ContactsClientConfig {
/** Base URL of the Contacts API (e.g., http://localhost:3015/api/v1) */
apiUrl: string;
/** Function to get the current auth token */
getAuthToken: () => Promise<string | null>;
/** Request timeout in ms (default: 5000) */
timeout?: number;
}
export interface ContactSearchOptions {
/** Search query string */
query?: string;
/** Maximum number of results */
limit?: number;
/** Skip archived contacts */
excludeArchived?: boolean;
}
/**
* Client for accessing the Contacts API from other apps
*/
export class ContactsClient {
private config: ContactsClientConfig;
private available: boolean | null = null;
constructor(config: ContactsClientConfig) {
this.config = {
timeout: 5000,
...config,
};
}
/**
* Check if the Contacts API is available
*/
async isAvailable(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const response = await fetch(`${this.config.apiUrl}/health`, {
method: 'GET',
signal: controller.signal,
});
clearTimeout(timeoutId);
this.available = response.ok;
return this.available;
} catch {
this.available = false;
return false;
}
}
/**
* Get cached availability status (call isAvailable() to refresh)
*/
getCachedAvailability(): boolean | null {
return this.available;
}
/**
* Search contacts by query string
*/
async searchContacts(options: ContactSearchOptions = {}): Promise<ContactSummary[]> {
const { query = '', limit = 20, excludeArchived = true } = options;
const params = new URLSearchParams();
if (query) params.set('search', query);
if (limit) params.set('limit', String(limit));
if (excludeArchived) params.set('isArchived', 'false');
try {
const response = (await this.fetchWithAuth(`/contacts?${params.toString()}`)) as {
contacts?: Record<string, unknown>[];
};
return this.mapToContactSummaries(response.contacts || []);
} catch (error) {
console.error('[ContactsClient] Failed to search contacts:', error);
return [];
}
}
/**
* Get a single contact by ID
*/
async getContact(id: string): Promise<ContactSummary | null> {
try {
const response = (await this.fetchWithAuth(`/contacts/${id}`)) as {
contact?: Record<string, unknown>;
};
if (response.contact) {
return this.mapToContactSummary(response.contact);
}
return null;
} catch (error) {
console.error(`[ContactsClient] Failed to get contact ${id}:`, error);
return null;
}
}
/**
* Get multiple contacts by IDs (batch fetch)
*/
async getContacts(ids: string[]): Promise<ContactSummary[]> {
if (ids.length === 0) return [];
// Contacts API doesn't have a batch endpoint, so we fetch individually
// but with Promise.allSettled to handle partial failures gracefully
const results = await Promise.allSettled(ids.map((id) => this.getContact(id)));
return results
.filter(
(result): result is PromiseFulfilledResult<ContactSummary | null> =>
result.status === 'fulfilled' && result.value !== null
)
.map((result) => result.value as ContactSummary);
}
/**
* Internal fetch with auth token
*/
private async fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise<unknown> {
const token = await this.config.getAuthToken();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(`${this.config.apiUrl}${endpoint}`, {
...options,
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Request timeout');
}
throw error;
}
}
/**
* Map API contact response to ContactSummary
*/
private mapToContactSummary(contact: Record<string, unknown>): ContactSummary {
return {
id: contact.id as string,
displayName:
(contact.displayName as string) ||
[contact.firstName, contact.lastName].filter(Boolean).join(' ') ||
(contact.email as string) ||
'Unbekannt',
firstName: contact.firstName as string | undefined,
lastName: contact.lastName as string | undefined,
email: contact.email as string | undefined,
phone: (contact.phone as string) || (contact.mobile as string) || undefined,
company: contact.company as string | undefined,
photoUrl: contact.photoUrl as string | undefined,
};
}
/**
* Map array of contacts to ContactSummary[]
*/
private mapToContactSummaries(contacts: Record<string, unknown>[]): ContactSummary[] {
return contacts.map((c) => this.mapToContactSummary(c));
}
}
/**
* Create a ContactsClient instance
*/
export function createContactsClient(config: ContactsClientConfig): ContactsClient {
return new ContactsClient(config);
}

View file

@ -70,6 +70,10 @@ export {
} from './interceptors/fetchInterceptor';
export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
// Contacts client for cross-app integration
export { ContactsClient, createContactsClient } from './clients/contactsClient';
export type { ContactsClientConfig, ContactSearchOptions } from './clients/contactsClient';
/**
* Initialize auth service with all adapters for web
*

View file

@ -110,8 +110,6 @@
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-form__input,
.feedback-form__select,
.feedback-form__textarea {
padding: 0.75rem;
border: 1px solid hsl(var(--color-border, 0 0% 90%));
@ -126,8 +124,6 @@
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
.feedback-form__input:focus,
.feedback-form__select:focus,
.feedback-form__textarea:focus {
outline: none;
border-color: hsl(var(--color-primary, 47 95% 58%));

View file

@ -0,0 +1,80 @@
/**
* Contact-related types for cross-app integration
*
* These types are used when referencing contacts from the Contacts app
* in other apps like Todo and Calendar.
*/
/**
* Reference to a contact with cached display data.
* Used for offline display when Contacts API is unavailable.
*/
export interface ContactReference {
/** Contact ID from Contacts app */
contactId: string;
/** Cached display name */
displayName: string;
/** Cached email */
email?: string;
/** Cached photo URL */
photoUrl?: string;
/** Cached company name */
company?: string;
/** ISO timestamp when data was fetched (for cache invalidation) */
fetchedAt: string;
}
/**
* Summary of a contact from the Contacts API.
* Contains essential fields for display in selectors and lists.
*/
export interface ContactSummary {
id: string;
displayName: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
company?: string;
photoUrl?: string;
}
/**
* Manual contact entry (when contact doesn't exist in Contacts app).
* Used for calendar attendees who aren't in the user's contacts.
*/
export interface ManualContactEntry {
/** Email address (required for manual entries) */
email: string;
/** Display name (optional) */
name?: string;
/** Indicates this is a manual entry, not from Contacts app */
isManual: true;
}
/**
* Union type for contact references that can be either
* a real contact or a manual entry.
*/
export type ContactOrManual = ContactReference | ManualContactEntry;
/**
* Helper to check if a contact entry is manual
*/
export function isManualContact(contact: ContactOrManual): contact is ManualContactEntry {
return 'isManual' in contact && contact.isManual === true;
}
/**
* Helper to create a ContactReference from a ContactSummary
*/
export function createContactReference(contact: ContactSummary): ContactReference {
return {
contactId: contact.id,
displayName: contact.displayName,
email: contact.email,
photoUrl: contact.photoUrl,
company: contact.company,
fetchedAt: new Date().toISOString(),
};
}

View file

@ -16,6 +16,9 @@ export * from './ui';
// Common utility types
export * from './common';
// Contact types for cross-app integration
export * from './contact';
// API types
export interface User {
id: string;

View file

@ -38,6 +38,7 @@
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-types": "workspace:*",
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.0",

View file

@ -164,10 +164,7 @@
border-bottom: none;
}
/* Body */
.card__body {
/* Padding applied via variant classes above */
}
/* Body - padding applied via variant classes above */
/* Footer */
.card__footer {

View file

@ -2,8 +2,6 @@
* Shared Types for Chart Components
*/
import type { Component } from 'svelte';
// Stat card variant colors
export type StatVariant = 'success' | 'primary' | 'neutral' | 'danger' | 'info' | 'accent';
@ -17,11 +15,13 @@ export const STAT_VARIANT_COLORS: Record<StatVariant, { bg: string; color: strin
};
// StatsGrid types
export interface StatItem {
id: string;
label: string;
value: number | string;
icon: Component;
/** Svelte component to render as icon (e.g., lucide-svelte icon) */
icon: any;
variant: StatVariant;
/** Optional: only show this stat if condition is true */
showCondition?: boolean;

View file

@ -102,7 +102,7 @@
let creating = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
let inputElement: HTMLInputElement;
let inputElement = $state<HTMLInputElement | null>(null);
// Computed create preview
let createPreview = $derived(
@ -260,6 +260,7 @@
role="dialog"
aria-modal="true"
aria-label="Suchen"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>

View file

@ -39,6 +39,9 @@ export {
// Feedback
export { EmptyState } from './molecules';
// Contacts
export { ContactAvatar, ContactBadge, ContactSelector } from './molecules';
// Layout
export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules';

View file

@ -80,6 +80,7 @@
const isClickable = $derived(interactive || !!onclick);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="data-card rounded-xl p-4 transition-colors {variantClasses[variant]} {isClickable
? 'cursor-pointer hover:bg-menu-hover'
@ -153,6 +154,7 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View file

@ -5,6 +5,7 @@
value: string;
oninput?: (value: string) => void;
onchange?: (value: string) => void;
onkeydown?: (e: KeyboardEvent) => void;
label?: string;
placeholder?: string;
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
@ -21,6 +22,7 @@
value = $bindable(),
oninput,
onchange,
onkeydown,
label,
placeholder,
type = 'text',
@ -66,6 +68,7 @@
autocomplete={autocomplete as HTMLInputAttributes['autocomplete']}
oninput={handleInput}
onchange={handleChange}
{onkeydown}
class="w-full rounded-lg border px-4 py-2.5 text-theme bg-content transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed {error
? 'border-red-500 focus:ring-red-500/50'
: 'border-theme'}"

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { User } from '@manacore/shared-icons';
interface Props {
/** Photo URL */
photoUrl?: string | null;
/** Display name (for initials fallback) */
name?: string;
/** Size in pixels */
size?: 'xs' | 'sm' | 'md' | 'lg';
/** Custom class */
class?: string;
}
let { photoUrl, name = '', size = 'md', class: className = '' }: Props = $props();
const sizeClasses = {
xs: 'w-5 h-5 text-[10px]',
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base',
};
const iconSizes = {
xs: 10,
sm: 12,
md: 16,
lg: 20,
};
// Generate initials from name
const initials = $derived.by(() => {
if (!name) return '';
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
});
// Generate a consistent background color based on the name
const bgColor = $derived.by(() => {
if (!name) return 'bg-gray-400';
const colors = [
'bg-violet-500',
'bg-blue-500',
'bg-cyan-500',
'bg-teal-500',
'bg-green-500',
'bg-amber-500',
'bg-orange-500',
'bg-rose-500',
'bg-pink-500',
'bg-indigo-500',
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
});
</script>
{#if photoUrl}
<img
src={photoUrl}
alt={name || 'Kontakt'}
class="
{sizeClasses[size]}
rounded-full object-cover
{className}
"
/>
{:else if initials}
<div
class="
{sizeClasses[size]}
{bgColor}
rounded-full
flex items-center justify-center
text-white font-medium
{className}
"
>
{initials}
</div>
{:else}
<div
class="
{sizeClasses[size]}
bg-gray-300 dark:bg-gray-600
rounded-full
flex items-center justify-center
text-gray-500 dark:text-gray-400
{className}
"
>
<User size={iconSizes[size]} />
</div>
{/if}

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { X } from '@manacore/shared-icons';
import ContactAvatar from './ContactAvatar.svelte';
import type {
ContactReference,
ManualContactEntry,
ContactOrManual,
} from '@manacore/shared-types';
interface Props {
/** Contact to display */
contact: ContactOrManual;
/** Show remove button */
removable?: boolean;
/** Called when remove is clicked */
onRemove?: () => void;
/** Size variant */
size?: 'sm' | 'md';
/** Show email under name */
showEmail?: boolean;
}
let { contact, removable = false, onRemove, size = 'md', showEmail = false }: Props = $props();
// Check if this is a manual entry
const isManual = $derived('isManual' in contact && contact.isManual === true);
// Get display values
const displayName = $derived(
isManual
? (contact as ManualContactEntry).name || (contact as ManualContactEntry).email
: (contact as ContactReference).displayName
);
const email = $derived(
isManual ? (contact as ManualContactEntry).email : (contact as ContactReference).email
);
const photoUrl = $derived(isManual ? undefined : (contact as ContactReference).photoUrl);
const avatarSizes = {
sm: 'xs' as const,
md: 'sm' as const,
};
</script>
<span
class="contact-badge"
class:size-sm={size === 'sm'}
class:size-md={size === 'md'}
class:manual={isManual}
>
<ContactAvatar {photoUrl} name={displayName} size={avatarSizes[size]} />
<span class="contact-info">
<span class="contact-name">{displayName}</span>
{#if showEmail && email && email !== displayName}
<span class="contact-email">{email}</span>
{/if}
</span>
{#if removable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onRemove?.();
}}
class="remove-btn"
aria-label="Entfernen"
>
<X size={12} />
</button>
{/if}
</span>
<style>
.contact-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 9999px;
transition: all 0.15s;
}
:global(.dark) .contact-badge {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.25);
}
.contact-badge:hover {
background: rgba(139, 92, 246, 0.18);
border-color: rgba(139, 92, 246, 0.3);
}
:global(.dark) .contact-badge:hover {
background: rgba(139, 92, 246, 0.22);
border-color: rgba(139, 92, 246, 0.35);
}
/* Manual entry variant (dashed border) */
.contact-badge.manual {
background: rgba(107, 114, 128, 0.1);
border: 1px dashed rgba(107, 114, 128, 0.3);
}
:global(.dark) .contact-badge.manual {
background: rgba(156, 163, 175, 0.12);
border-color: rgba(156, 163, 175, 0.3);
}
/* Size variants */
.size-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.size-md {
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
}
.contact-info {
display: flex;
flex-direction: column;
line-height: 1.2;
min-width: 0;
}
.contact-name {
color: #374151;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
:global(.dark) .contact-name {
color: #f3f4f6;
}
.contact-email {
font-size: 0.625rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
:global(.dark) .contact-email {
color: #9ca3af;
}
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.125rem;
padding: 0.25rem;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
border-radius: 9999px;
transition: all 0.15s;
}
:global(.dark) .remove-btn {
color: #9ca3af;
}
.remove-btn:hover {
background: rgba(0, 0, 0, 0.08);
color: #374151;
}
:global(.dark) .remove-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
</style>

View file

@ -0,0 +1,711 @@
<script lang="ts">
import { Plus, MagnifyingGlass, User, Envelope } from '@manacore/shared-icons';
import ContactBadge from './ContactBadge.svelte';
import ContactAvatar from './ContactAvatar.svelte';
import type {
ContactReference,
ContactSummary,
ManualContactEntry,
ContactOrManual,
createContactReference,
} from '@manacore/shared-types';
interface Props {
/** Currently selected contacts */
selectedContacts: ContactOrManual[];
/** Called when selection changes */
onContactsChange: (contacts: ContactOrManual[]) => void;
/** Function to search contacts (async) */
onSearch: (query: string) => Promise<ContactSummary[]>;
/** Allow manual email entry (for contacts not in system) */
allowManualEntry?: boolean;
/** Maximum contacts that can be selected */
maxContacts?: number;
/** Single select mode (only one contact allowed) */
singleSelect?: boolean;
/** Placeholder text */
placeholder?: string;
/** Add button label */
addLabel?: string;
/** Search placeholder */
searchPlaceholder?: string;
/** Loading state */
loading?: boolean;
/** Disabled state */
disabled?: boolean;
/** Show "not available" message when contacts API is down */
unavailableMessage?: string;
/** Is contacts API available */
isAvailable?: boolean;
}
let {
selectedContacts,
onContactsChange,
onSearch,
allowManualEntry = false,
maxContacts,
singleSelect = false,
placeholder = 'Kontakt hinzufügen...',
addLabel = 'Kontakt hinzufügen',
searchPlaceholder = 'Name oder E-Mail suchen...',
loading = false,
disabled = false,
unavailableMessage = 'Kontakte nicht verfügbar',
isAvailable = true,
}: Props = $props();
let isOpen = $state(false);
let searchQuery = $state('');
let searchResults = $state<ContactSummary[]>([]);
let isSearching = $state(false);
let showManualEntry = $state(false);
let manualEmail = $state('');
let manualName = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let searchInputRef = $state<HTMLInputElement | null>(null);
let highlightedIndex = $state(-1);
// Focus search input when dropdown opens
$effect(() => {
if (isOpen && searchInputRef) {
setTimeout(() => searchInputRef?.focus(), 0);
highlightedIndex = -1;
}
});
// Reset highlighted index when results change
$effect(() => {
if (searchResults.length > 0) {
highlightedIndex = -1;
}
});
const effectiveMax = $derived(singleSelect ? 1 : maxContacts);
const canAddMore = $derived(!effectiveMax || selectedContacts.length < effectiveMax);
// Check if an email looks valid
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Debounced search
async function handleSearchInput(query: string) {
searchQuery = query;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (!query.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
if (!isAvailable) return;
isSearching = true;
try {
const results = await onSearch(query);
// Filter out already selected contacts
const selectedIds = new Set(
selectedContacts
.filter((c): c is ContactReference => 'contactId' in c)
.map((c) => c.contactId)
);
searchResults = results.filter((r) => !selectedIds.has(r.id));
} catch (error) {
console.error('Contact search failed:', error);
searchResults = [];
} finally {
isSearching = false;
}
}, 300);
}
function handleSelectContact(contact: ContactSummary) {
if (!canAddMore) return;
const reference: ContactReference = {
contactId: contact.id,
displayName: contact.displayName,
email: contact.email,
photoUrl: contact.photoUrl,
company: contact.company,
fetchedAt: new Date().toISOString(),
};
if (singleSelect) {
onContactsChange([reference]);
} else {
onContactsChange([...selectedContacts, reference]);
}
searchQuery = '';
searchResults = [];
isOpen = false;
}
function handleRemoveContact(index: number) {
onContactsChange(selectedContacts.filter((_, i) => i !== index));
}
function handleAddManualEntry() {
if (!manualEmail.trim() || !isValidEmail(manualEmail)) return;
const entry: ManualContactEntry = {
email: manualEmail.trim(),
name: manualName.trim() || undefined,
isManual: true,
};
if (singleSelect) {
onContactsChange([entry]);
} else {
onContactsChange([...selectedContacts, entry]);
}
manualEmail = '';
manualName = '';
showManualEntry = false;
isOpen = false;
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.contact-selector-container')) {
isOpen = false;
showManualEntry = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen = false;
showManualEntry = false;
}
}
function handleSearchKeyDown(e: KeyboardEvent) {
if (!isOpen || searchResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, searchResults.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, -1);
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
e.preventDefault();
handleSelectContact(searchResults[highlightedIndex]);
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
<div class="contact-selector-container">
<!-- Selected Contacts Display -->
<div class="selected-contacts">
{#each selectedContacts as contact, index (index)}
<ContactBadge {contact} removable onRemove={() => handleRemoveContact(index)} />
{/each}
{#if canAddMore && !disabled}
<button type="button" onclick={() => (isOpen = !isOpen)} class="add-button" {disabled}>
<Plus size={14} weight="bold" />
<span>{addLabel}</span>
</button>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen}
<div class="dropdown">
{#if !isAvailable}
<!-- Unavailable State -->
<div class="unavailable-state">
<User size={24} />
<p>{unavailableMessage}</p>
{#if allowManualEntry}
<button type="button" onclick={() => (showManualEntry = true)} class="manual-link">
Manuell hinzufügen
</button>
{/if}
</div>
{:else}
<!-- Search Input -->
<div class="search-section">
<div class="search-input-wrapper">
<MagnifyingGlass size={16} class="search-icon" />
<input
bind:this={searchInputRef}
type="text"
value={searchQuery}
oninput={(e) => handleSearchInput(e.currentTarget.value)}
onkeydown={handleSearchKeyDown}
placeholder={searchPlaceholder}
class="search-input"
/>
</div>
</div>
<!-- Results List -->
<div class="results-list">
{#if isSearching || loading}
<div class="empty-state">Suche...</div>
{:else if searchResults.length > 0}
{#each searchResults as contact, index (contact.id)}
<button
type="button"
onclick={() => handleSelectContact(contact)}
class="result-item"
class:highlighted={index === highlightedIndex}
>
<ContactAvatar photoUrl={contact.photoUrl} name={contact.displayName} size="md" />
<div class="result-info">
<div class="result-name">{contact.displayName}</div>
{#if contact.email}
<div class="result-detail">{contact.email}</div>
{/if}
{#if contact.company}
<div class="result-detail">{contact.company}</div>
{/if}
</div>
</button>
{/each}
{:else if searchQuery.trim()}
<div class="empty-state">Kein Kontakt gefunden</div>
{:else}
<div class="empty-state">Name oder E-Mail eingeben...</div>
{/if}
</div>
<!-- Manual Entry Option -->
{#if allowManualEntry}
<div class="manual-section">
{#if showManualEntry}
<div class="manual-form">
<div class="input-with-icon">
<Envelope size={14} />
<input
type="email"
bind:value={manualEmail}
placeholder="E-Mail-Adresse *"
class="manual-input"
/>
</div>
<div class="input-with-icon">
<User size={14} />
<input
type="text"
bind:value={manualName}
placeholder="Name (optional)"
class="manual-input"
onkeydown={(e) => e.key === 'Enter' && handleAddManualEntry()}
/>
</div>
<div class="manual-actions">
<button
type="button"
onclick={() => (showManualEntry = false)}
class="btn-cancel"
>
Abbrechen
</button>
<button
type="button"
onclick={handleAddManualEntry}
disabled={!manualEmail.trim() || !isValidEmail(manualEmail)}
class="btn-add"
>
Hinzufügen
</button>
</div>
</div>
{:else}
<button type="button" onclick={() => (showManualEntry = true)} class="manual-trigger">
<Envelope size={14} />
<span>E-Mail manuell hinzufügen</span>
</button>
{/if}
</div>
{/if}
{/if}
</div>
{/if}
</div>
<style>
.contact-selector-container {
position: relative;
}
.selected-contacts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.add-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
color: #6b7280;
background: transparent;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .add-button {
color: #9ca3af;
border-color: rgba(255, 255, 255, 0.2);
}
.add-button:hover:not(:disabled) {
color: #374151;
border-color: rgba(0, 0, 0, 0.3);
background: rgba(0, 0, 0, 0.03);
}
:global(.dark) .add-button:hover:not(:disabled) {
color: #e5e7eb;
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.05);
}
.add-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Dropdown */
.dropdown {
position: absolute;
z-index: 50;
margin-top: 0.25rem;
width: 100%;
min-width: 320px;
background: rgba(255, 255, 255, 1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 1rem;
box-shadow:
0 12px 28px -5px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
:global(.dark) .dropdown {
background: rgba(45, 45, 45, 1);
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
0 12px 28px -5px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* Search Section */
.search-section {
padding: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .search-section {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.search-input-wrapper {
position: relative;
}
.search-input-wrapper :global(.search-icon) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: #6b7280;
}
:global(.dark) .search-input-wrapper :global(.search-icon) {
color: #9ca3af;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
font-size: 0.875rem;
color: #374151;
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.75rem;
outline: none;
transition: all 0.15s;
}
:global(.dark) .search-input {
color: #f3f4f6;
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.search-input:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
}
/* Results List */
.results-list {
max-height: 14rem;
overflow-y: auto;
}
.empty-state {
padding: 1rem;
text-align: center;
font-size: 0.875rem;
color: #6b7280;
}
:global(.dark) .empty-state {
color: #9ca3af;
}
.result-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 1rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.result-item:hover,
.result-item.highlighted {
background: rgba(139, 92, 246, 0.08);
}
:global(.dark) .result-item:hover,
:global(.dark) .result-item.highlighted {
background: rgba(139, 92, 246, 0.15);
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .result-name {
color: #f3f4f6;
}
.result-detail {
font-size: 0.75rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .result-detail {
color: #9ca3af;
}
/* Manual Entry Section */
.manual-section {
padding: 0.75rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .manual-section {
border-top-color: rgba(255, 255, 255, 0.08);
}
.manual-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-with-icon {
position: relative;
display: flex;
align-items: center;
}
.input-with-icon > :global(svg) {
position: absolute;
left: 0.75rem;
color: #6b7280;
}
:global(.dark) .input-with-icon > :global(svg) {
color: #9ca3af;
}
.manual-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
font-size: 0.875rem;
color: #374151;
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.75rem;
outline: none;
transition: all 0.15s;
}
:global(.dark) .manual-input {
color: #f3f4f6;
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.manual-input:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.manual-input::placeholder {
color: #9ca3af;
}
.manual-actions {
display: flex;
gap: 0.5rem;
}
.btn-cancel {
flex: 1;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
:global(.dark) .btn-cancel {
color: #9ca3af;
}
.btn-cancel:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
}
.btn-add {
flex: 1;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: white;
background: #8b5cf6;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-add:hover:not(:disabled) {
opacity: 0.9;
}
.btn-add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.manual-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .manual-trigger {
color: #9ca3af;
}
.manual-trigger:hover {
color: #374151;
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .manual-trigger:hover {
color: #e5e7eb;
background: rgba(255, 255, 255, 0.08);
}
/* Unavailable State */
.unavailable-state {
padding: 1.5rem;
text-align: center;
color: #6b7280;
}
:global(.dark) .unavailable-state {
color: #9ca3af;
}
.unavailable-state > :global(svg) {
margin: 0 auto 0.5rem;
opacity: 0.5;
}
.unavailable-state p {
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.manual-link {
font-size: 0.875rem;
color: #8b5cf6;
background: none;
border: none;
cursor: pointer;
text-decoration: none;
}
.manual-link:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,4 @@
// Contact selection and display components
export { default as ContactAvatar } from './ContactAvatar.svelte';
export { default as ContactBadge } from './ContactBadge.svelte';
export { default as ContactSelector } from './ContactSelector.svelte';

View file

@ -39,6 +39,9 @@ export {
// Feedback components
export { EmptyState } from './feedback';
// Contact components
export { ContactAvatar, ContactBadge, ContactSelector } from './contacts';
// Layout components
export { default as ModalFooter } from './ModalFooter.svelte';
export { default as DataCard } from './DataCard.svelte';

View file

@ -10,7 +10,7 @@
/** Alternative name field (for compatibility) */
text?: string;
/** Tag color (hex) */
color?: string;
color?: string | null;
/** Nested style object with color */
style?: { color?: string };
}
@ -55,36 +55,64 @@
}
</script>
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-all"
class:cursor-pointer={clickable}
class:hover:scale-105={clickable}
style="background-color: {tagColor}20; color: {tagColor}"
onclick={handleClick}
onkeydown={handleKeyDown}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : undefined}
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
{#if clickable}
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-all cursor-pointer hover:scale-105"
style="background-color: {tagColor}20; color: {tagColor}"
onclick={handleClick}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
<span>{tagName}</span>
<span>{tagName}</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
type="button"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
type="button"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</span>
{:else}
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium"
style="background-color: {tagColor}20; color: {tagColor}"
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
<span>{tagName}</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
type="button"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</span>
{/if}

View file

@ -79,22 +79,22 @@
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={name} placeholder={namePlaceholder} onkeydown={handleKeyDown} autofocus />
<Input bind:value={name} placeholder={namePlaceholder} onkeydown={handleKeyDown} />
</div>
<!-- Color Picker -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
<span class="block text-sm font-medium text-muted-foreground mb-3">
{colorLabel}
</label>
</span>
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
</div>
<!-- Preview -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
<span class="block text-sm font-medium text-muted-foreground mb-3">
{previewLabel}
</label>
</span>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>

View file

@ -83,6 +83,7 @@
<div class={layout === 'grid' ? gridClasses : listClasses}>
{#each tags as tag (tag.id)}
{@const color = getTagColor(tag)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="
group relative flex items-center gap-3 p-4

View file

@ -32,7 +32,7 @@ export interface Tag {
export interface TagData {
name?: string;
text?: string;
color?: string;
color?: string | null;
style?: { color?: string };
}

View file

@ -148,7 +148,11 @@
{#if open}
<!-- Backdrop -->
<button class="menu-backdrop" onclick={close} onkeydown={(e) => e.key === 'Escape' && close()}
<button
class="menu-backdrop"
onclick={close}
onkeydown={(e) => e.key === 'Escape' && close()}
aria-label="Close dropdown"
></button>
<!-- Dropdown items -->

View file

@ -467,7 +467,8 @@
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
<svelte:component this={phosphorIcons[item.icon]} size={18} class="pill-icon" />
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -500,7 +501,8 @@
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
{#if phosphorIcons[element.icon]}
<svelte:component this={phosphorIcons[element.icon]} size={18} class="pill-icon" />
{@const IconComponent = phosphorIcons[element.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -1206,9 +1208,7 @@
min-height: 1rem;
}
.sidebar-container .toggle-pill {
margin-top: auto;
}
/* Note: .toggle-pill class may be applied dynamically */
/* Segmented control */
.segmented-control {

View file

@ -84,6 +84,7 @@
</script>
<Modal {visible} {onClose} {title} {icon} {maxWidth}>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form onsubmit={handleSubmit} onkeydown={handleKeydown} class="space-y-4">
<!-- Error message -->
{#if error}

View file

@ -256,6 +256,7 @@
export { resetZoom, zoomIn, zoomOut, focusOnSelectedNode };
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={containerElement}
class="network-graph-container"
@ -265,11 +266,14 @@
role="application"
aria-label="Network Graph"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<svg
bind:this={svgElement}
class="network-graph-svg"
style="width: 100%; height: 100%;"
onclick={handleBackgroundClick}
role="img"
aria-label="Network graph visualization"
>
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
@ -280,6 +284,7 @@
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
{@const isHighlighted =
selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Invisible wider line for easier hover -->
<line
x1={coords.x1}

View file

@ -156,6 +156,7 @@
<!-- Modal -->
{#if selectedAppIndex !== null}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-overlay" onclick={closeModal} role="dialog" aria-modal="true" tabindex="-1">
<button onclick={closeModal} class="modal-close-btn" aria-label="Close modal">
<svg
@ -383,6 +384,7 @@
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View file

@ -209,6 +209,7 @@
: 'bg-gray-200 dark:bg-gray-700'}"
onclick={() =>
handleSidebarChange(!userSettings.globalSettings.nav.sidebarCollapsed)}
aria-label="Toggle sidebar collapsed state"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {userSettings
@ -416,6 +417,7 @@
? 'bg-[hsl(var(--primary))]'
: 'bg-gray-200 dark:bg-gray-700'}"
onclick={() => handleSoundsChange(!(userSettings.general?.soundsEnabled ?? true))}
aria-label="Toggle sound effects"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {(userSettings

View file

@ -28,10 +28,8 @@
// Check if there are any routes to configure
const hasRoutes = $derived(hideableItems.length > 0);
function isRouteHidden(href: string): boolean {
const hidden = userSettings.getHiddenNavItemsForApp(appId);
return hidden.includes(href);
}
// Reactive: get hidden items from nav settings (triggers re-render when hiddenNavItems changes)
const hiddenItems = $derived(userSettings.nav.hiddenNavItems?.[appId] || []);
async function handleToggle(href: string): Promise<void> {
await userSettings.toggleNavItemVisibility(appId, href);
@ -129,7 +127,7 @@
<div class="space-y-1">
{#each hideableItems as item (item.href)}
{@const hidden = isRouteHidden(item.href)}
{@const hidden = hiddenItems.includes(item.href)}
{@const iconPath = item.icon ? getIconPath(item.icon) : ''}
<label
class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-[hsl(var(--muted))]/50 cursor-pointer transition-colors border border-transparent hover:border-[hsl(var(--border))]"

558
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff