refactor(calendar): remove todo integration entirely

Remove all todo/task-related code from the calendar web app:
- Delete todo API client, store, and all todo components
- Remove TodoSidebarSection, TodoDayCell, TodoRow, TaskBlock
- Remove useTaskDragDrop and useSidebarDrop composables
- Remove "Aufgaben" tab from PillNav and keyboard shortcuts
- Remove "Todo-Service ist nicht erreichbar" error banner
- Remove todo toggle from AgendaFilters, todo type from AgendaItem
- Remove PUBLIC_TODO_BACKEND_URL from server hooks
- Remove showTasksInCalendar from settings store
- Clean up i18n keys (priority, todo sections)
- Clean up help config (task shortcuts section)

Build passes successfully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 14:25:34 +02:00
parent e5c63f65fb
commit 750a0c77ff
28 changed files with 40 additions and 4557 deletions

View file

@ -21,8 +21,7 @@ const PUBLIC_BACKEND_URL_CLIENT =
'http://localhost:3014';
const PUBLIC_STT_URL = process.env.PUBLIC_STT_URL || 'https://stt-api.mana.how';
// Cross-app integration URLs (for todo and contacts APIs)
const PUBLIC_TODO_BACKEND_URL = process.env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
// Cross-app integration URLs (for contacts API)
const PUBLIC_CONTACTS_API_URL = process.env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
@ -35,7 +34,6 @@ export const handle: Handle = async ({ event, resolve }) => {
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
window.__PUBLIC_STT_URL__ = ${JSON.stringify(PUBLIC_STT_URL)};
window.__PUBLIC_TODO_BACKEND_URL__ = ${JSON.stringify(PUBLIC_TODO_BACKEND_URL)};
window.__PUBLIC_CONTACTS_API_URL__ = ${JSON.stringify(PUBLIC_CONTACTS_API_URL)};
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
</script>`;
@ -48,7 +46,6 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
PUBLIC_MANA_CORE_AUTH_URL_CLIENT,
PUBLIC_BACKEND_URL_CLIENT,
PUBLIC_STT_URL,
PUBLIC_TODO_BACKEND_URL,
PUBLIC_CONTACTS_API_URL,
],
});

View file

@ -1,382 +0,0 @@
/**
* Cross-App API Client for Todo Backend
* 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, type ApiResult } from '@manacore/shared-api-client';
import { authStore } from '$lib/stores/auth.svelte';
// Get todo API base URL from injected window variable (browser) or env (SSR)
function getTodoApiBase(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_TODO_BACKEND_URL__?: string })
.__PUBLIC_TODO_BACKEND_URL__;
if (injectedUrl) return injectedUrl;
}
return env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
}
let _todoClient: ReturnType<typeof createApiClient> | null = null;
function getTodoClient() {
if (!_todoClient) {
_todoClient = createApiClient({
baseUrl: getTodoApiBase(),
apiPrefix: '/api/v1',
getAuthToken: () => authStore.getValidToken(),
timeout: 30000,
debug: import.meta.env.DEV,
useRuntimeUrl: false,
});
}
return _todoClient;
}
// For backwards compatibility
const todoClient = {
get: <T>(endpoint: string) => getTodoClient().get<T>(endpoint),
post: <T>(endpoint: string, body?: unknown) => getTodoClient().post<T>(endpoint, body),
put: <T>(endpoint: string, body?: unknown) => getTodoClient().put<T>(endpoint, body),
patch: <T>(endpoint: string, body?: unknown) => getTodoClient().patch<T>(endpoint, body),
delete: <T>(endpoint: string) => getTodoClient().delete<T>(endpoint),
};
// ============================================
// Types (mirrored from @todo/shared for cross-app use)
// ============================================
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 TaskMetadata {
notes?: string;
attachments?: string[];
linkedCalendarEventId?: string | null;
storyPoints?: number | null;
effectiveDuration?: {
value: number;
unit: 'minutes' | 'hours' | 'days';
} | null;
funRating?: number | null;
}
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;
// 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;
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;
}
export interface CreateTaskInput {
title: string;
description?: string;
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'>[];
recurrenceRule?: string | null;
metadata?: TaskMetadata;
}
export interface UpdateTaskInput {
title?: string;
description?: string | null;
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;
subtasks?: Subtask[] | null;
recurrenceRule?: string | null;
metadata?: TaskMetadata | null;
labelIds?: string[];
}
export interface TaskQuery {
projectId?: string;
labelId?: string;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
dueDateFrom?: string;
dueDateTo?: string;
search?: string;
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
sortOrder?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
// ============================================
// API Response Types
// ============================================
interface TasksResponse {
tasks: Task[];
}
interface TaskResponse {
task: Task;
}
interface ProjectsResponse {
projects: Project[];
}
interface LabelsResponse {
labels: Label[];
}
// ============================================
// API Client (using shared base client)
// ============================================
async function fetchTodoApi<T>(
endpoint: string,
options: { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown } = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body } = options;
let result: ApiResult<T>;
switch (method) {
case 'POST':
result = await todoClient.post<T>(endpoint, body);
break;
case 'PUT':
result = await todoClient.put<T>(endpoint, body);
break;
case 'PATCH':
result = await todoClient.patch<T>(endpoint, body);
break;
case 'DELETE':
result = await todoClient.delete<T>(endpoint);
break;
default:
result = await todoClient.get<T>(endpoint);
}
if (result.error) {
return { data: null, error: new Error(result.error.message) };
}
return { data: result.data, error: null };
}
// ============================================
// Task API Functions
// ============================================
export async function getTasks(
query: TaskQuery = {}
): Promise<{ data: Task[] | null; error: Error | null }> {
const queryString = buildQueryString(
query as Record<string, string | number | boolean | undefined>
);
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
return {
data: result.data?.tasks || null,
error: result.error,
};
}
export async function getTask(id: string): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`);
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function createTask(
data: CreateTaskInput
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>('/tasks', {
method: 'POST',
body: data,
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function updateTask(
id: string,
data: UpdateTaskInput
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`, {
method: 'PUT',
body: data,
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function deleteTask(id: string): Promise<{ error: Error | null }> {
const result = await fetchTodoApi(`/tasks/${id}`, {
method: 'DELETE',
});
return { error: result.error };
}
export async function completeTask(
id: string
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/complete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function uncompleteTask(
id: string
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/uncomplete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function getTodayTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
const result = await fetchTodoApi<TasksResponse>('/tasks/today');
return {
data: result.data?.tasks || null,
error: result.error,
};
}
export async function getUpcomingTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
const result = await fetchTodoApi<TasksResponse>('/tasks/upcoming');
return {
data: result.data?.tasks || null,
error: result.error,
};
}
// ============================================
// Project API Functions
// ============================================
export async function getProjects(): Promise<{ data: Project[] | null; error: Error | null }> {
const result = await fetchTodoApi<ProjectsResponse>('/projects');
return {
data: result.data?.projects || null,
error: result.error,
};
}
// ============================================
// Label API Functions
// ============================================
export async function getLabels(): Promise<{ data: Label[] | null; error: Error | null }> {
const result = await fetchTodoApi<LabelsResponse>('/labels');
return {
data: result.data?.labels || null,
error: result.error,
};
}
// ============================================
// Priority Colors Helper
// ============================================
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
urgent: 'hsl(var(--color-danger))',
high: 'hsl(var(--color-warning))',
medium: 'hsl(var(--color-accent))',
low: 'hsl(var(--color-success))',
};
export const PRIORITY_LABELS: Record<TaskPriority, string> = {
urgent: 'Dringend',
high: 'Wichtig',
medium: 'Normal',
low: 'Später',
};
export const PRIORITY_ORDER: Record<TaskPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
};

View file

@ -1,24 +1,13 @@
<script lang="ts">
import { Calendar, CheckSquare, Funnel } from '@manacore/shared-icons';
import { Funnel } from '@manacore/shared-icons';
import { FilterDropdown, type FilterDropdownOption } from '@manacore/shared-ui';
interface Props {
showEvents: boolean;
showTodos: boolean;
timeRange: '7' | '30' | 'all';
onToggleEvents?: () => void;
onToggleTodos?: () => void;
onRangeChange?: (range: '7' | '30' | 'all') => void;
}
let {
showEvents = true,
showTodos = true,
timeRange = '30',
onToggleEvents,
onToggleTodos,
onRangeChange,
}: Props = $props();
let { timeRange = '30', onRangeChange }: Props = $props();
const rangeOptions: FilterDropdownOption[] = [
{ value: '7', label: '7 Tage' },
@ -28,29 +17,6 @@
</script>
<div class="agenda-filters">
<div class="filter-group type-toggles">
<button
type="button"
class="filter-toggle"
class:active={showEvents}
onclick={onToggleEvents}
aria-pressed={showEvents}
>
<Calendar size={14} />
<span>Events</span>
</button>
<button
type="button"
class="filter-toggle"
class:active={showTodos}
onclick={onToggleTodos}
aria-pressed={showTodos}
>
<CheckSquare size={14} />
<span>Aufgaben</span>
</button>
</div>
<div class="filter-group">
<div class="range-selector">
<Funnel size={14} />
@ -69,65 +35,30 @@
.agenda-filters {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 1rem;
padding: 0.75rem 1rem;
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.type-toggles {
display: flex;
gap: 0.375rem;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.filter-toggle:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.filter-toggle.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.range-selector {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-muted-foreground));
}
@media (max-width: 480px) {
.agenda-filters {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.filter-group {
justify-content: center;
}

View file

@ -1,110 +1,46 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { CalendarEvent, Calendar as CalendarType } from '@calendar/shared';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
import { getCalendarColorWithBirthdays } from '$lib/data/queries';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte';
import { Calendar, MapPin, Clock } from '@manacore/shared-icons';
import { Calendar, MapPin } from '@manacore/shared-icons';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
type ItemType = 'event' | 'todo';
interface Props {
type: ItemType;
event?: CalendarEvent;
todo?: Task;
event: CalendarEvent;
onclick?: () => void;
}
let { type, event, todo, onclick }: Props = $props();
let { event, onclick }: Props = $props();
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: CalendarType[] } = getContext('calendars');
let isToggling = $state(false);
// Event helpers
const eventColor = $derived(
event ? getCalendarColorWithBirthdays(calendarsCtx.value, event.calendarId) : undefined
);
const eventColor = $derived(getCalendarColorWithBirthdays(calendarsCtx.value, event.calendarId));
const eventTimeLabel = $derived.by(() => {
if (!event) return '';
if (event.isAllDay) return 'Ganztägig';
const start = toDate(event.startTime);
const end = toDate(event.endTime);
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
});
// Todo helpers
const todoTimeLabel = $derived.by(() => {
if (!todo) return '';
if (todo.dueTime) return `Fällig: ${todo.dueTime}`;
return 'Heute fällig';
});
async function handleToggleTodo() {
if (!todo) return;
isToggling = true;
await todosStore.toggleComplete(todo.id);
isToggling = false;
}
</script>
{#if type === 'event' && event}
<button type="button" class="agenda-item event" style="--item-color: {eventColor};" {onclick}>
<div class="item-indicator">
<Calendar size={14} />
</div>
<div class="item-content">
<div class="item-header">
<span class="item-time">{eventTimeLabel}</span>
</div>
<span class="item-title">{event.title}</span>
{#if event.location}
<div class="item-meta">
<MapPin size={12} />
<span>{event.location}</span>
</div>
{/if}
</div>
</button>
{:else if type === 'todo' && todo}
<div
class="agenda-item todo"
class:completed={todo.isCompleted}
style="--item-color: {PRIORITY_COLORS[todo.priority]};"
>
<div class="item-checkbox">
<TodoCheckbox
checked={todo.isCompleted}
loading={isToggling}
size="md"
onchange={handleToggleTodo}
/>
</div>
<button type="button" class="item-content" {onclick}>
<div class="item-header">
<PriorityBadge priority={todo.priority} variant="dot" size="sm" />
<span class="item-time">{todoTimeLabel}</span>
</div>
<span class="item-title">{todo.title}</span>
{#if todo.project}
<div class="item-meta">
<span class="project-tag" style="color: {todo.project.color};">
{todo.project.name}
</span>
</div>
{/if}
</button>
<button type="button" class="agenda-item event" style="--item-color: {eventColor};" {onclick}>
<div class="item-indicator">
<Calendar size={14} />
</div>
{/if}
<div class="item-content">
<div class="item-header">
<span class="item-time">{eventTimeLabel}</span>
</div>
<span class="item-title">{event.title}</span>
{#if event.location}
<div class="item-meta">
<MapPin size={12} />
<span>{event.location}</span>
</div>
{/if}
</div>
</button>
<style>
.agenda-item {
@ -115,34 +51,16 @@
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
transition: all 150ms ease;
}
.agenda-item.event {
border: none;
cursor: pointer;
text-align: left;
width: 100%;
border-left: 4px solid var(--item-color);
}
.agenda-item.event:hover {
.agenda-item:hover {
background: hsl(var(--color-muted) / 0.5);
transform: translateX(4px);
}
.agenda-item.todo {
border-left: 3px solid var(--item-color);
}
.agenda-item.todo.completed {
opacity: 0.6;
}
.agenda-item.todo.completed .item-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.item-indicator {
display: flex;
align-items: center;
@ -154,12 +72,6 @@
color: white;
flex-shrink: 0;
}
.item-checkbox {
flex-shrink: 0;
padding-top: 2px;
}
.item-content {
flex: 1;
min-width: 0;
@ -167,31 +79,16 @@
flex-direction: column;
gap: 0.25rem;
}
.todo .item-content {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
text-align: left;
}
.todo .item-content:hover .item-title {
color: hsl(var(--color-primary));
}
.item-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-time {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.item-title {
font-size: 0.9375rem;
font-weight: 500;
@ -199,9 +96,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 150ms ease;
}
.item-meta {
display: flex;
align-items: center;
@ -209,16 +104,7 @@
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.item-meta :global(svg) {
flex-shrink: 0;
}
.project-tag {
font-size: 0.6875rem;
font-weight: 500;
background: color-mix(in srgb, currentColor 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
</style>

View file

@ -11,9 +11,7 @@
} from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
import TodoDayCell from './TodoDayCell.svelte';
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
import { useBirthdayPopover } from '$lib/composables';
import { goto } from '$app/navigation';
@ -327,11 +325,6 @@
</span>
</div>
<!-- Todos for this day -->
{#if todosStore.serviceAvailable}
<TodoDayCell date={day} maxVisible={2} />
{/if}
<div class="day-events">
{#each getEventsForDay(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}

View file

@ -1,297 +0,0 @@
<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 '@manacore/shared-icons';
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

@ -1,121 +0,0 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
interface Props {
date: Date;
maxVisible?: number;
}
let { date, maxVisible = 2 }: Props = $props();
let selectedTask = $state<Task | null>(null);
let togglingIds = $state<Set<string>>(new Set());
const todosForDay = $derived(todosStore.getTodosForDay(date));
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
async function handleToggle(task: Task, e: MouseEvent) {
e.stopPropagation();
togglingIds = new Set([...togglingIds, task.id]);
await todosStore.toggleComplete(task.id);
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function handleTaskClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
</script>
{#if todosForDay.length > 0}
<div class="todo-day-cell">
{#each visibleTodos as task (task.id)}
<button
type="button"
class="todo-cell-item"
class:completed={task.isCompleted}
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
onclick={() => handleTaskClick(task)}
>
<span class="priority-dot"></span>
<span class="todo-cell-title">{task.title}</span>
</button>
{/each}
{#if overflowCount > 0}
<span class="overflow-text">+{overflowCount} Aufgaben</span>
{/if}
</div>
{/if}
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-day-cell {
display: flex;
flex-direction: column;
gap: 1px;
margin-bottom: 2px;
}
.todo-cell-item {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 4px;
border-radius: 3px;
border: none;
background: hsl(var(--color-muted) / 0.3);
cursor: pointer;
transition: background 150ms ease;
text-align: left;
width: 100%;
}
.todo-cell-item:hover {
background: hsl(var(--color-muted) / 0.5);
}
.todo-cell-item.completed {
opacity: 0.5;
}
.todo-cell-item.completed .todo-cell-title {
text-decoration: line-through;
}
.priority-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--priority-color);
flex-shrink: 0;
}
.todo-cell-title {
font-size: 0.625rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.overflow-text {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
padding: 0 4px;
}
</style>

View file

@ -1,169 +0,0 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import { Check } from '@manacore/shared-icons';
interface Props {
date: Date;
maxVisible?: number;
}
let { date, maxVisible = 3 }: Props = $props();
let selectedTask = $state<Task | null>(null);
let togglingIds = $state<Set<string>>(new Set());
const todosForDay = $derived(todosStore.getTodosForDay(date));
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
async function handleToggle(task: Task) {
togglingIds = new Set([...togglingIds, task.id]);
await todosStore.toggleComplete(task.id);
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function handleTaskClick(task: Task, e: MouseEvent) {
// Don't open modal if clicking checkbox
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function handleShowAll() {
// Show first todo's modal, or navigate to tasks page
if (todosForDay.length > 0) {
selectedTask = todosForDay[0];
}
}
</script>
{#if todosForDay.length > 0}
<div class="todo-row">
<span class="todo-row-label">Aufgaben:</span>
<div class="todo-pills">
{#each visibleTodos as task (task.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<button
type="button"
class="todo-pill"
class:completed={task.isCompleted}
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
onclick={(e) => handleTaskClick(task, e)}
>
<TodoCheckbox
checked={task.isCompleted}
loading={togglingIds.has(task.id)}
size="sm"
onchange={() => handleToggle(task)}
/>
<span class="todo-pill-title">{task.title}</span>
</button>
{/each}
{#if overflowCount > 0}
<button type="button" class="overflow-badge" onclick={handleShowAll}>
+{overflowCount} mehr
</button>
{/if}
</div>
</div>
{/if}
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: hsl(var(--color-muted) / 0.2);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todo-row-label {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.todo-pills {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.todo-pills::-webkit-scrollbar {
display: none;
}
.todo-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: none;
background: hsl(var(--color-surface));
border-left: 2px solid var(--priority-color);
cursor: pointer;
transition: all 150ms ease;
flex-shrink: 0;
max-width: 150px;
}
.todo-pill:hover {
background: hsl(var(--color-muted) / 0.5);
}
.todo-pill.completed {
opacity: 0.6;
}
.todo-pill.completed .todo-pill-title {
text-decoration: line-through;
}
.todo-pill-title {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-badge {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: none;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: background 150ms ease;
}
.overflow-badge:hover {
background: hsl(var(--color-primary) / 0.2);
}
</style>

View file

@ -1,323 +0,0 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import TodoItem from '$lib/components/todo/TodoItem.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { CaretDown, CaretRight, Plus, CheckSquare, Warning } from '@manacore/shared-icons';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
interface Props {
maxItems?: number;
}
let { maxItems = 5 }: Props = $props();
let isExpanded = $state(true);
let showQuickAdd = $state(false);
let selectedTask = $state<Task | null>(null);
// Derived: combined overdue + today todos
const displayTodos = $derived(todosStore.getSidebarTodos(maxItems));
const overdueCount = $derived(todosStore.overdueTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
onMount(async () => {
// Fetch todos on mount
await todosStore.fetchTodayTodos();
await todosStore.fetchUpcomingTodos();
// Also fetch scheduled todos (including completed) for calendar display
await todosStore.fetchScheduledTodos();
});
function toggleExpanded() {
isExpanded = !isExpanded;
}
function handleAddClick(e: MouseEvent) {
e.stopPropagation();
showQuickAdd = true;
}
function handleTaskClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function handleQuickAddSubmit() {
// Keep quick add open for successive adds
}
function handleQuickAddCancel() {
showQuickAdd = false;
}
function goToAllTasks() {
goto('/tasks');
}
</script>
<div class="todo-sidebar-section">
<!-- Header -->
<div class="section-header">
<button type="button" class="header-toggle" onclick={toggleExpanded}>
<div class="header-left">
{#if isExpanded}
<CaretDown size={16} />
{:else}
<CaretRight size={16} />
{/if}
<CheckSquare size={16} class="section-icon" />
<span class="section-title">Aufgaben</span>
{#if totalActiveCount > 0}
<span class="count-badge">{totalActiveCount}</span>
{/if}
{#if overdueCount > 0}
<span class="overdue-badge" title="{overdueCount} überfällig">
<Warning size={12} />
</span>
{/if}
</div>
</button>
<button
type="button"
class="add-button"
onclick={handleAddClick}
aria-label="Aufgabe hinzufügen"
>
<Plus size={16} />
</button>
</div>
<!-- Content -->
{#if isExpanded}
<div class="section-content">
{#if !todosStore.serviceAvailable}
<div class="service-unavailable">
<Warning size={16} />
<span>Todo-Service nicht erreichbar</span>
</div>
{:else if todosStore.loading}
<div class="loading">
<div class="loading-spinner"></div>
<span>Laden...</span>
</div>
{:else if displayTodos.length === 0}
<div class="empty-state">
<CheckSquare size={20} />
<span>Keine offenen Aufgaben</span>
</div>
{:else}
<div class="todo-list">
{#each displayTodos as task (task.id)}
<TodoItem
{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 -->
{#if showQuickAdd}
<div class="quick-add-wrapper">
<QuickAddTodo
placeholder="Neue Aufgabe..."
autofocus
showButton={false}
onsubmit={handleQuickAddSubmit}
oncancel={handleQuickAddCancel}
/>
</div>
{/if}
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-sidebar-section {
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
}
/* Mobile: Full-bleed ohne Rundungen */
@media (max-width: 768px) {
.todo-sidebar-section {
border-radius: 0;
border: none;
border-top: 1px solid hsl(var(--color-border));
}
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 1rem 0 0;
}
.header-toggle {
flex: 1;
display: flex;
align-items: center;
padding: 0.75rem 0 0.75rem 1rem;
border: none;
background: transparent;
cursor: pointer;
transition: background 150ms ease;
}
.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) {
color: hsl(var(--color-muted-foreground));
}
.header-left :global(.section-icon) {
color: hsl(var(--color-primary));
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
}
.count-badge {
font-size: 0.6875rem;
font-weight: 600;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
padding: 1px 6px;
border-radius: 9999px;
}
.overdue-badge {
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-danger));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.add-button:hover {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.section-content {
padding: 0 0.5rem 0.5rem;
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.service-unavailable,
.loading,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
flex: 1;
}
.service-unavailable {
color: hsl(var(--color-danger));
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.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;
}
</style>

View file

@ -12,7 +12,6 @@
} from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
import {
@ -20,8 +19,6 @@
useCurrentTimeIndicator,
useBirthdayPopover,
useEventDragDrop,
useTaskDragDrop,
useSidebarDrop,
useDragToCreate,
useCalendarKeyboard,
} from '$lib/composables';
@ -34,7 +31,6 @@
type OverflowEvents,
} from '$lib/utils/eventFiltering';
import EventCard from './EventCard.svelte';
import TaskBlock from './TaskBlock.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { goto } from '$app/navigation';
import {
@ -58,10 +54,9 @@
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
let { date, onQuickCreate, onEventClick }: Props = $props();
// Get calendars and events from layout context (live queries)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
@ -150,18 +145,6 @@
minutesToPercent,
}));
const taskDragDrop = useTaskDragDrop(() => ({
containerEl: daysContainerEl,
days,
firstVisibleHour,
totalVisibleHours,
}));
const sidebarDrop = useSidebarDrop(() => ({
firstVisibleHour,
totalVisibleHours,
}));
const dragToCreate = useDragToCreate(() => ({
containerEl: daysContainerEl,
days,
@ -170,11 +153,7 @@
totalVisibleHours,
hourHeight: HOUR_HEIGHT,
minutesToPercent,
isOtherOperationActive: () =>
eventDragDrop.isDragging ||
eventDragDrop.isResizing ||
taskDragDrop.isTaskDragging ||
taskDragDrop.isTaskResizing,
isOtherOperationActive: () => eventDragDrop.isDragging || eventDragDrop.isResizing,
onCreateEnd: (startTime, endTime, position) => {
if (onQuickCreate) {
onQuickCreate(startTime, position, endTime);
@ -189,10 +168,6 @@
isActive: () => eventDragDrop.isDragging || eventDragDrop.isResizing,
cancel: eventDragDrop.cancel,
},
{
isActive: () => taskDragDrop.isTaskDragging || taskDragDrop.isTaskResizing,
cancel: taskDragDrop.cancel,
},
{ isActive: () => dragToCreate.isCreating, cancel: dragToCreate.cancel },
]);
@ -309,37 +284,6 @@
return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`;
}
/**
* 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 {
return settingsStore.formatTime(toDate(date));
}
@ -514,14 +458,10 @@
<div
class="day-column"
class:today={isToday(day)}
class:drop-target={sidebarDrop.dropTarget && isSameDay(day, sidebarDrop.dropTarget.day)}
class:creating={dragToCreate.isCreating &&
dragToCreate.createTargetDay &&
isSameDay(day, dragToCreate.createTargetDay)}
onpointerdown={dragToCreate.startCreate}
ondragover={(e) => sidebarDrop.handleDragOver(e, day)}
ondragleave={sidebarDrop.handleDragLeave}
ondrop={(e) => sidebarDrop.handleDrop(e, day)}
>
{#each hours as hour}
<div
@ -582,43 +522,6 @@
/>
{/each}
<!-- Scheduled Tasks (Time-Blocking) - only shown if enabled in settings -->
{#if settingsStore.showTasksInCalendar}
{#each getScheduledTasksForDay(day) as task (task.id)}
{@const isTaskBeingDragged =
taskDragDrop.isTaskDragging && taskDragDrop.draggedTask?.id === task.id}
{@const isTaskBeingResized =
taskDragDrop.isTaskResizing && taskDragDrop.resizeTask?.id === task.id}
{@const isTaskCrossDayDrag =
isTaskBeingDragged &&
taskDragDrop.taskDragTargetDay !== null &&
!isSameDay(day, taskDragDrop.taskDragTargetDay)}
<TaskBlock
{task}
style={isTaskBeingDragged && !isTaskCrossDayDrag
? `top: ${taskDragDrop.taskDragPreviewTop}%; height: ${taskDragDrop.taskDragPreviewHeight}%;`
: isTaskBeingResized
? `top: ${taskDragDrop.taskResizePreviewTop}%; height: ${taskDragDrop.taskResizePreviewHeight}%;`
: getTaskStyle(task)}
{onTaskClick}
onDragStart={taskDragDrop.startDrag}
onResizeStart={taskDragDrop.startResize}
isDragging={isTaskBeingDragged && !isTaskCrossDayDrag}
isResizing={isTaskBeingResized}
isDraggingSource={isTaskCrossDayDrag}
/>
{/each}
<!-- Task Drag preview (solid) for cross-day dragging -->
{#if taskDragDrop.isTaskDragging && taskDragDrop.draggedTask && taskDragDrop.taskDragTargetDay && isSameDay(day, taskDragDrop.taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === taskDragDrop.draggedTask!.id)}
<TaskBlock
task={taskDragDrop.draggedTask}
style="top: {taskDragDrop.taskDragPreviewTop}%; height: {taskDragDrop.taskDragPreviewHeight}%;"
isDragging={true}
/>
{/if}
{/if}
<!-- Drag preview (solid) for cross-day dragging -->
{#if eventDragDrop.isDragging && eventDragDrop.draggedEvent && eventDragDrop.dragTargetDay && isSameDay(day, eventDragDrop.dragTargetDay) && !getEventsForDay(day).some((e) => e.id === eventDragDrop.draggedEvent!.id)}
<EventCard

View file

@ -1,124 +0,0 @@
<script lang="ts">
import type { TaskPriority } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
interface Props {
priority: TaskPriority;
variant?: 'dot' | 'badge' | 'pill';
size?: 'sm' | 'md';
showLabel?: boolean;
}
let { priority, variant = 'dot', size = 'md', showLabel = false }: Props = $props();
const color = $derived(PRIORITY_COLORS[priority]);
const label = $derived(PRIORITY_LABELS[priority]);
</script>
{#if variant === 'dot'}
<span
class="priority-dot"
class:size-sm={size === 'sm'}
style="--priority-color: {color};"
title={label}
aria-label="Priorität: {label}"
></span>
{:else if variant === 'badge'}
<span
class="priority-badge"
class:size-sm={size === 'sm'}
style="--priority-color: {color};"
title={label}
>
{#if showLabel}
{label}
{:else}
{priority.charAt(0).toUpperCase()}
{/if}
</span>
{:else if variant === 'pill'}
<span class="priority-pill" class:size-sm={size === 'sm'} style="--priority-color: {color};">
<span class="pill-dot"></span>
{#if showLabel}
<span class="pill-label">{label}</span>
{/if}
</span>
{/if}
<style>
/* Dot variant */
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--priority-color);
flex-shrink: 0;
}
.priority-dot.size-sm {
width: 6px;
height: 6px;
}
/* Badge variant */
.priority-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 4px;
background: var(--priority-color);
color: white;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.priority-badge.size-sm {
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 0.625rem;
}
/* Pill variant */
.priority-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 9999px;
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--priority-color) 30%, transparent);
}
.priority-pill.size-sm {
gap: 4px;
padding: 1px 6px;
}
.pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--priority-color);
}
.priority-pill.size-sm .pill-dot {
width: 5px;
height: 5px;
}
.pill-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--priority-color);
}
.priority-pill.size-sm .pill-label {
font-size: 0.6875rem;
}
</style>

View file

@ -1,227 +0,0 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import { Plus, X } from '@manacore/shared-icons';
interface Props {
placeholder?: string;
onsubmit?: () => void;
oncancel?: () => void;
autofocus?: boolean;
showButton?: boolean;
}
let {
placeholder = 'Neue Aufgabe...',
onsubmit,
oncancel,
autofocus = false,
showButton = true,
}: Props = $props();
let title = $state('');
let isExpanded = $state(!showButton);
let isSubmitting = $state(false);
let inputRef: HTMLInputElement | undefined = $state();
function expand() {
isExpanded = true;
// Focus input after DOM update
setTimeout(() => inputRef?.focus(), 0);
}
function collapse() {
isExpanded = false;
title = '';
oncancel?.();
}
async function handleSubmit(e?: Event) {
e?.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle || isSubmitting) return;
isSubmitting = true;
const result = await todosStore.createTodo({
title: trimmedTitle,
priority: 'medium',
});
isSubmitting = false;
if (!result.error) {
title = '';
onsubmit?.();
// Keep input focused for quick successive adds
inputRef?.focus();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
collapse();
}
}
function handleBlur() {
// Only collapse if empty and showButton is true
if (showButton && !title.trim()) {
collapse();
}
}
</script>
{#if showButton && !isExpanded}
<button type="button" class="add-button" onclick={expand}>
<Plus size={16} />
<span>Aufgabe hinzufügen</span>
</button>
{:else}
<form class="quick-add-form" onsubmit={handleSubmit}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={inputRef}
bind:value={title}
type="text"
class="quick-add-input"
{placeholder}
disabled={isSubmitting}
onkeydown={handleKeydown}
onblur={handleBlur}
autofocus={autofocus || isExpanded}
/>
{#if showButton}
<button type="button" class="cancel-button" onclick={collapse} disabled={isSubmitting}>
<X size={14} />
</button>
{/if}
<button
type="submit"
class="submit-button"
disabled={!title.trim() || isSubmitting}
aria-label="Aufgabe erstellen"
>
{#if isSubmitting}
<span class="spinner"></span>
{:else}
<Plus size={14} />
{/if}
</button>
</form>
{/if}
<style>
.add-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: all 150ms ease;
}
.add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.quick-add-form {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
transition: border-color 150ms ease;
}
.quick-add-form:focus-within {
border-color: hsl(var(--color-primary));
}
.quick-add-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
}
.quick-add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.quick-add-input:disabled {
opacity: 0.5;
}
.cancel-button,
.submit-button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.cancel-button {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.cancel-button:hover:not(:disabled) {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.submit-button {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.submit-button:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid hsl(var(--color-primary-foreground) / 0.3);
border-top-color: hsl(var(--color-primary-foreground));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,130 +0,0 @@
<script lang="ts">
import { Check } from '@manacore/shared-icons';
interface Props {
checked: boolean;
loading?: boolean;
size?: 'sm' | 'md' | 'lg';
onchange?: (checked: boolean) => void;
}
let { checked, loading = false, size = 'md', onchange }: Props = $props();
const sizes = {
sm: { box: 14, icon: 10 },
md: { box: 18, icon: 12 },
lg: { box: 22, icon: 16 },
};
function handleClick(e: MouseEvent) {
e.stopPropagation();
if (!loading && onchange) {
onchange(!checked);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (!loading && onchange) {
onchange(!checked);
}
}
}
</script>
<button
type="button"
class="todo-checkbox"
class:checked
class:loading
class:size-sm={size === 'sm'}
class:size-md={size === 'md'}
class:size-lg={size === 'lg'}
style="--box-size: {sizes[size].box}px; --icon-size: {sizes[size].icon}px;"
onclick={handleClick}
onkeydown={handleKeydown}
disabled={loading}
aria-checked={checked}
aria-label={checked ? 'Als unerledigt markieren' : 'Als erledigt markieren'}
role="checkbox"
>
{#if loading}
<span class="spinner"></span>
{:else if checked}
<Check size={sizes[size].icon} weight="bold" />
{/if}
</button>
<style>
.todo-checkbox {
width: var(--box-size);
height: var(--box-size);
min-width: var(--box-size);
border-radius: 4px;
border: 2px solid hsl(var(--color-border));
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 150ms ease;
padding: 0;
}
.todo-checkbox:hover:not(:disabled) {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.todo-checkbox:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.todo-checkbox.checked {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.todo-checkbox.checked:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.8);
border-color: hsl(var(--color-primary) / 0.8);
}
.todo-checkbox:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.todo-checkbox.loading {
cursor: wait;
}
.spinner {
width: calc(var(--icon-size) - 2px);
height: calc(var(--icon-size) - 2px);
border: 2px solid hsl(var(--color-muted-foreground) / 0.3);
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Size variants */
.size-sm {
border-radius: 3px;
border-width: 1.5px;
}
.size-lg {
border-radius: 5px;
}
</style>

View file

@ -1,763 +0,0 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task, UpdateTaskInput, TaskPriority } from '$lib/api/todos';
import { PRIORITY_LABELS, PRIORITY_COLORS } from '$lib/api/todos';
import { toastStore as toast, focusTrap } from '@manacore/shared-ui';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import {
X,
Calendar,
Clock,
Folder,
Tag,
Trash,
CheckSquare,
WarningCircle,
CalendarCheck,
Timer,
} from '@manacore/shared-icons';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
task: Task;
onClose: () => void;
}
let { task: initialTask, onClose }: Props = $props();
// Local editable state
let task = $state<Task>({ ...initialTask });
let isEditing = $state(false);
let isSaving = $state(false);
let isDeleting = $state(false);
let isToggling = $state(false);
// 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 '';
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'yyyy-MM-dd');
}
function formatDisplayDate(date: string | Date | null | undefined): string {
if (!date) return 'Kein Datum';
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'EEEE, d. MMMM yyyy', { locale: de });
}
async function handleToggleComplete() {
isToggling = true;
const result = await todosStore.toggleComplete(task.id);
if (result.data) {
task = result.data;
} else if (result.error) {
toast.error(`Fehler: ${result.error.message}`);
}
isToggling = false;
}
async function handleSave() {
if (!title.trim()) {
toast.error('Titel darf nicht leer sein');
return;
}
isSaving = true;
const updateData: UpdateTaskInput = {
title: title.trim(),
description: description.trim() || null,
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);
if (result.error) {
toast.error(`Fehler beim Speichern: ${result.error.message}`);
} else if (result.data) {
task = result.data;
toast.success('Aufgabe aktualisiert');
isEditing = false;
}
isSaving = false;
}
async function handleDelete() {
if (!confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
return;
}
isDeleting = true;
const result = await todosStore.deleteTodo(task.id);
if (result.error) {
toast.error(`Fehler beim Löschen: ${result.error.message}`);
isDeleting = false;
} else {
toast.success('Aufgabe gelöscht');
onClose();
}
}
function startEditing() {
// Reset form state to current task values
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() || '';
isEditing = true;
}
function cancelEditing() {
isEditing = false;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (isEditing) {
cancelEditing();
} else {
onClose();
}
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="modal" role="dialog" aria-labelledby="modal-title" aria-modal="true" use:focusTrap>
<!-- Header -->
<div class="modal-header">
<div class="header-left">
<TodoCheckbox
checked={task.isCompleted}
loading={isToggling}
size="lg"
onchange={handleToggleComplete}
/>
{#if !isEditing}
<h2 id="modal-title" class="modal-title" class:completed={task.isCompleted}>
{task.title}
</h2>
{/if}
</div>
<button type="button" class="close-button" onclick={onClose} aria-label="Schließen">
<X size={20} />
</button>
</div>
<!-- Content -->
<div class="modal-content">
{#if isEditing}
<!-- Edit Mode -->
<form
class="edit-form"
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div class="form-group">
<label for="title">Titel</label>
<input id="title" type="text" bind:value={title} placeholder="Aufgabentitel" required />
</div>
<div class="form-group">
<label for="description">Beschreibung</label>
<textarea
id="description"
bind:value={description}
placeholder="Beschreibung hinzufügen..."
rows="3"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="dueDate">Fälligkeitsdatum</label>
<input id="dueDate" type="date" bind:value={dueDate} />
</div>
<div class="form-group">
<label for="dueTime">Uhrzeit</label>
<input id="dueTime" type="time" bind:value={dueTime} />
</div>
</div>
<!-- Time-Blocking Section -->
<div class="form-section">
<span class="section-label">
<CalendarCheck 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">
<span class="label-text">Priorität</span>
<div class="priority-options">
{#each Object.entries(PRIORITY_LABELS) as [key, label]}
<button
type="button"
class="priority-option"
class:selected={priority === key}
style="--priority-color: {PRIORITY_COLORS[key as TaskPriority]};"
onclick={() => (priority = key as TaskPriority)}
>
<span class="priority-dot"></span>
{label}
</button>
{/each}
</div>
</div>
</form>
{:else}
<!-- View Mode -->
<div class="detail-section">
{#if task.description}
<p class="description">{task.description}</p>
{/if}
<div class="detail-list">
<div class="detail-item">
<Calendar size={16} />
<span>{formatDisplayDate(task.dueDate)}</span>
</div>
{#if task.dueTime}
<div class="detail-item">
<Clock size={16} />
<span>{task.dueTime} Uhr</span>
</div>
{/if}
<!-- Time-Blocking Display -->
{#if task.scheduledDate}
<div class="detail-item scheduled">
<CalendarCheck 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">
<WarningCircle size={16} />
<PriorityBadge {priority} variant="pill" showLabel />
</div>
{#if task.project}
<div class="detail-item">
<Folder size={16} />
<span class="project-name" style="color: {task.project.color};">
{task.project.name}
</span>
</div>
{/if}
{#if task.labels && task.labels.length > 0}
<div class="detail-item labels-row">
<Tag size={16} />
<div class="labels">
{#each task.labels as label}
<span class="label-tag" style="--label-color: {label.color};">
{label.name}
</span>
{/each}
</div>
</div>
{/if}
</div>
{#if task.subtasks && task.subtasks.length > 0}
<div class="subtasks-section">
<h3>
<CheckSquare size={16} />
Unteraufgaben ({task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks
.length})
</h3>
<ul class="subtask-list">
{#each task.subtasks as subtask}
<li class:completed={subtask.isCompleted}>
<span class="subtask-check">{subtask.isCompleted ? '☑' : '☐'}</span>
{subtask.title}
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="modal-footer">
{#if isEditing}
<button type="button" class="btn btn-secondary" onclick={cancelEditing} disabled={isSaving}>
Abbrechen
</button>
<button type="button" class="btn btn-primary" onclick={handleSave} disabled={isSaving}>
{#if isSaving}
Speichern...
{:else}
Speichern
{/if}
</button>
{:else}
<button type="button" class="btn btn-danger" onclick={handleDelete} disabled={isDeleting}>
<Trash size={16} />
{#if isDeleting}
Löschen...
{:else}
Löschen
{/if}
</button>
<button type="button" class="btn btn-primary" onclick={startEditing}> Bearbeiten </button>
{/if}
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
width: 100%;
max-width: 500px;
max-height: 90vh;
background: hsl(var(--color-background));
border-radius: var(--radius-lg);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-title.completed {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.close-button:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* View Mode */
.description {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.6;
margin: 0 0 1rem;
white-space: pre-wrap;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.detail-item :global(svg) {
color: hsl(var(--color-muted-foreground));
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;
}
.labels {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.label-tag {
font-size: 0.75rem;
color: var(--label-color);
background: color-mix(in srgb, var(--label-color) 15%, transparent);
padding: 2px 8px;
border-radius: 9999px;
}
.subtasks-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--color-border));
}
.subtasks-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 0.75rem;
}
.subtask-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.subtask-list li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.subtask-list li.completed {
color: hsl(var(--color-muted-foreground));
text-decoration: line-through;
}
.subtask-check {
font-size: 0.875rem;
}
/* Edit Mode */
.edit-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
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));
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color 150ms ease;
}
input:focus,
textarea:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
textarea {
resize: vertical;
min-height: 80px;
}
.label-text {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.priority-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.priority-option {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: all 150ms ease;
}
.priority-option:hover {
border-color: var(--priority-color);
}
.priority-option.selected {
border-color: var(--priority-color);
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
}
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--priority-color);
}
/* Footer */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid hsl(var(--color-border));
}
.btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.btn-secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.8);
}
.btn-danger {
background: hsl(var(--color-danger) / 0.1);
color: hsl(var(--color-danger));
}
.btn-danger:hover:not(:disabled) {
background: hsl(var(--color-danger));
color: white;
}
</style>

View file

@ -1,318 +0,0 @@
<script lang="ts">
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import { format, parseISO, isToday, isTomorrow, isPast, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
task: Task;
variant?: 'default' | 'compact' | 'minimal';
showProject?: boolean;
showDueDate?: boolean;
showPriority?: boolean;
draggable?: boolean;
onclick?: () => void;
}
let {
task,
variant = 'default',
showProject = true,
showDueDate = true,
showPriority = true,
draggable = false,
onclick,
}: Props = $props();
let isToggling = $state(false);
const priorityColor = $derived(PRIORITY_COLORS[task.priority]);
const dueDateLabel = $derived.by(() => {
if (!task.dueDate) return null;
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
if (isToday(date)) {
return task.dueTime ? `Heute, ${task.dueTime}` : 'Heute';
}
if (isTomorrow(date)) {
return task.dueTime ? `Morgen, ${task.dueTime}` : 'Morgen';
}
if (isPast(startOfDay(date)) && !task.isCompleted) {
return format(date, 'd. MMM', { locale: de });
}
return format(date, 'd. MMM', { locale: de });
});
const isOverdue = $derived.by(() => {
if (!task.dueDate || task.isCompleted) return false;
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isPast(startOfDay(date)) && !isToday(date);
});
const subtaskProgress = $derived.by(() => {
if (!task.subtasks || task.subtasks.length === 0) return null;
const completed = task.subtasks.filter((s) => s.isCompleted).length;
return { completed, total: task.subtasks.length };
});
async function handleToggle(checked: boolean) {
isToggling = true;
await todosStore.toggleComplete(task.id);
isToggling = false;
}
function handleClick(e: MouseEvent) {
// Don't trigger onclick when clicking checkbox
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
onclick?.();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && onclick) {
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}
class:overdue={isOverdue}
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}
>
<TodoCheckbox
checked={task.isCompleted}
loading={isToggling}
size={variant === 'minimal' ? 'sm' : 'md'}
onchange={handleToggle}
/>
<div class="todo-content">
<div class="todo-main">
{#if showPriority && variant !== 'minimal'}
<PriorityBadge
priority={task.priority}
variant="dot"
size={variant === 'compact' ? 'sm' : 'md'}
/>
{/if}
<span class="todo-title">{task.title}</span>
{#if subtaskProgress && variant === 'default'}
<span class="subtask-count">
{subtaskProgress.completed}/{subtaskProgress.total}
</span>
{/if}
</div>
{#if variant !== 'minimal'}
<div class="todo-meta">
{#if showDueDate && dueDateLabel}
<span class="due-date" class:overdue={isOverdue}>
{dueDateLabel}
</span>
{/if}
{#if showProject && task.project}
<span class="project" style="--project-color: {task.project.color};">
{task.project.name}
</span>
{/if}
{#if task.labels && task.labels.length > 0 && variant === 'default'}
<div class="labels">
{#each task.labels.slice(0, 2) as label}
<span class="label" style="--label-color: {label.color};">
{label.name}
</span>
{/each}
{#if task.labels.length > 2}
<span class="label-more">+{task.labels.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.todo-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
border-left: 3px solid var(--priority-color);
transition: all 150ms ease;
}
.todo-item.clickable {
cursor: pointer;
}
.todo-item.clickable:hover {
background: hsl(var(--color-muted) / 0.5);
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;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.todo-item.overdue {
background: hsl(var(--color-danger) / 0.05);
}
/* Compact variant */
.todo-item.compact {
padding: 0.5rem 0.625rem;
gap: 0.5rem;
border-left-width: 2px;
}
/* Minimal variant */
.todo-item.minimal {
padding: 0.375rem 0.5rem;
gap: 0.375rem;
border-left-width: 2px;
background: transparent;
}
.todo-item.minimal:hover {
background: hsl(var(--color-muted) / 0.3);
}
.todo-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.todo-main {
display: flex;
align-items: center;
gap: 0.5rem;
}
.todo-title {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact .todo-title {
font-size: 0.8125rem;
}
.minimal .todo-title {
font-size: 0.75rem;
}
.subtask-count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-muted) / 0.5);
padding: 1px 6px;
border-radius: 9999px;
flex-shrink: 0;
}
.todo-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.due-date.overdue {
color: hsl(var(--color-danger));
font-weight: 500;
}
.project {
font-size: 0.6875rem;
color: var(--project-color);
background: color-mix(in srgb, var(--project-color) 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
.labels {
display: flex;
align-items: center;
gap: 0.25rem;
}
.label {
font-size: 0.625rem;
color: var(--label-color);
background: color-mix(in srgb, var(--label-color) 15%, transparent);
padding: 1px 4px;
border-radius: 3px;
}
.label-more {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -14,12 +14,6 @@ export {
type EventResizeState,
} from './useEventDragDrop.svelte';
// Task drag/drop and resize
export { useTaskDragDrop, type TaskDragDropConfig } from './useTaskDragDrop.svelte';
// Sidebar task drop handling
export { useSidebarDrop, type SidebarDropConfig } from './useSidebarDrop.svelte';
// Drag-to-create
export { useDragToCreate, type DragToCreateConfig } from './useDragToCreate.svelte';

View file

@ -1,123 +0,0 @@
/**
* Sidebar Task Drop Composable
* Handles dropping tasks from sidebar into calendar day columns
*/
import { todosStore } from '$lib/stores/todos.svelte';
import { format } from 'date-fns';
import { formatTime, getSnapMinutes } from '$lib/utils/drag-helpers';
export interface SidebarDropConfig {
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Total visible hours */
totalVisibleHours: number;
/** Minutes per snap interval (default: 15) */
snapMinutes?: number;
}
export function useSidebarDrop(getConfig: () => SidebarDropConfig) {
// Track active drop target (for visual feedback)
let dropTarget = $state<{ day: Date; y: number } | null>(null);
/**
* Handle dragover event on a day column
*/
function handleDragOver(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';
dropTarget = { day, y: e.clientY };
}
/**
* Handle dragleave event
*/
function handleDragLeave(e: DragEvent) {
// Only clear if leaving the column entirely
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget?.closest('.day-column')) {
dropTarget = null;
}
}
/**
* Handle drop event on a day column
*/
async function handleDrop(e: DragEvent, day: Date) {
e.preventDefault();
dropTarget = 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 config = getConfig();
// 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 = (config.totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent;
const snapMinutes = getSnapMinutes(getConfig().snapMinutes);
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const startTime = formatTime(hours, minutes);
// Calculate end time
const duration = data.estimatedDuration || 30;
const endMinutes = totalMinutes + duration;
const endHours = Math.floor(endMinutes / 60);
const endMins = endMinutes % 60;
const endTime = formatTime(endHours, endMins);
// 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);
}
}
/**
* Clear drop target (use when component unmounts or for manual cleanup)
*/
function clearDropTarget() {
dropTarget = null;
}
return {
// State (reactive getter)
get dropTarget() {
return dropTarget;
},
// Methods
handleDragOver,
handleDragLeave,
handleDrop,
clearDropTarget,
};
}

View file

@ -1,313 +0,0 @@
/**
* Task Drag & Drop + Resize Composable
* Extracts duplicated task drag/resize logic from WeekView, DayView, MultiDayView
*
* Uses document-level event listeners for smooth drag operations across the entire screen.
*/
import type { Task } from '$lib/stores/todos.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { format } from 'date-fns';
import { formatTime, getSnapMinutes } from '$lib/utils/drag-helpers';
export interface TaskDragDropConfig {
/** Reference to the container element for position calculations */
containerEl: HTMLElement | null;
/** Array of visible days (for multi-day views) or single day (for day view) */
days: Date[];
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Total visible hours */
totalVisibleHours: number;
/** Minutes per snap interval (default: 15) */
snapMinutes?: number;
}
export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) {
// ========== Drag 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);
// ========== 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);
// Track if we actually moved during drag/resize
let hasMoved = $state(false);
// ========== Helper Functions ==========
// ========== Drag Functions ==========
function startDrag(task: Task, e: PointerEvent) {
e.preventDefault();
const config = getConfig();
isTaskDragging = true;
draggedTask = task;
hasMoved = false;
// Initialize preview position from task's current time
if (task.scheduledStartTime) {
const [h, m] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = h * 60 + m - config.firstVisibleHour * 60;
taskDragPreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
}
const duration = task.estimatedDuration || 30;
taskDragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
document.addEventListener('pointermove', handleDragMove);
document.addEventListener('pointerup', handleDragEnd);
}
function handleDragMove(e: PointerEvent) {
if (!isTaskDragging || !draggedTask) return;
const config = getConfig();
hasMoved = true;
// Find which day column we're over
if (config.containerEl) {
const dayColumns = config.containerEl.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 = config.days[i];
break;
}
}
}
// Calculate vertical position
const targetColumn = config.containerEl?.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 intervals
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent;
const snapMinutes = getSnapMinutes(getConfig().snapMinutes);
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
}
async function handleDragEnd() {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
if (!isTaskDragging || !draggedTask || !hasMoved) {
cleanupDrag();
return;
}
const config = getConfig();
// Calculate new time from position
const minutesFromStart = (taskDragPreviewTop / 100) * (config.totalVisibleHours * 60);
const totalMinutes = config.firstVisibleHour * 60 + minutesFromStart;
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes % 60);
const newStartTime = formatTime(hours, minutes);
// 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 = formatTime(endHours, endMins);
await todosStore.updateTodo(draggedTask.id, {
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
});
cleanupDrag();
}
function cleanupDrag() {
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = null;
hasMoved = false;
}
// ========== Resize Functions ==========
function startResize(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
const config = getConfig();
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 - config.firstVisibleHour * 60;
taskResizePreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
}
const duration = task.estimatedDuration || 30;
taskResizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
function handleResizeMove(e: PointerEvent) {
if (!isTaskResizing || !resizeTask) return;
const config = getConfig();
hasMoved = true;
const targetColumn = config.containerEl?.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 = (config.totalVisibleHours * 60) / 100;
const snapMinutes = getSnapMinutes(getConfig().snapMinutes);
if (taskResizeEdge === 'top') {
// Adjust start time, keep end fixed
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
taskResizePreviewTop = (snappedMinutes / (config.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 / snapMinutes) * snapMinutes;
const newBottom = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
}
}
async function handleResizeEnd() {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
if (!isTaskResizing || !resizeTask || !hasMoved) {
cleanupResize();
return;
}
const config = getConfig();
// Calculate new times from position
const startMinutes =
(taskResizePreviewTop / 100) * (config.totalVisibleHours * 60) + config.firstVisibleHour * 60;
const endMinutes =
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (config.totalVisibleHours * 60) +
config.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 = formatTime(startHours, startMins);
const newEndTime = formatTime(endHours, endMins);
const newDuration = Math.round(endMinutes - startMinutes);
await todosStore.updateTodo(resizeTask.id, {
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
estimatedDuration: newDuration,
});
cleanupResize();
}
function cleanupResize() {
isTaskResizing = false;
resizeTask = null;
hasMoved = false;
}
// ========== Combined Cleanup ==========
function cleanup() {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
cleanupDrag();
cleanupResize();
}
/**
* Cancel any active drag/resize operation
*/
function cancel() {
if (isTaskDragging || isTaskResizing) {
cleanup();
}
}
return {
// Drag state (reactive getters)
get isTaskDragging() {
return isTaskDragging;
},
get draggedTask() {
return draggedTask;
},
get taskDragTargetDay() {
return taskDragTargetDay;
},
get taskDragPreviewTop() {
return taskDragPreviewTop;
},
get taskDragPreviewHeight() {
return taskDragPreviewHeight;
},
// Resize state (reactive getters)
get isTaskResizing() {
return isTaskResizing;
},
get resizeTask() {
return resizeTask;
},
get taskResizeEdge() {
return taskResizeEdge;
},
get taskResizePreviewTop() {
return taskResizePreviewTop;
},
get taskResizePreviewHeight() {
return taskResizePreviewHeight;
},
// Shared state
get hasMoved() {
return hasMoved;
},
// Methods
startDrag,
startResize,
cancel,
cleanup,
};
}

View file

@ -1,134 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
// Polyfill PointerEvent for jsdom
if (typeof globalThis.PointerEvent === 'undefined') {
globalThis.PointerEvent = class PointerEvent extends MouseEvent {
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
}
} as unknown as typeof PointerEvent;
}
vi.mock('$lib/stores/todos.svelte', () => ({
todosStore: {
updateTodo: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('$lib/utils/calendarConstants', () => ({
SNAP_INTERVAL_MINUTES: 15,
}));
import { useTaskDragDrop } from './useTaskDragDrop.svelte';
function createMockContainer() {
const el = {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 700,
bottom: 960,
width: 700,
height: 960,
}),
querySelectorAll: () => [],
querySelector: () => null,
parentElement: { scrollTop: 0 },
} as unknown as HTMLElement;
return el;
}
function makeDays(): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date('2026-03-02');
d.setDate(d.getDate() + i);
return d;
});
}
describe('useTaskDragDrop', () => {
function createInstance() {
return useTaskDragDrop(() => ({
containerEl: createMockContainer(),
days: makeDays(),
firstVisibleHour: 0,
totalVisibleHours: 24,
}));
}
it('should start in idle state', () => {
const td = createInstance();
expect(td.isTaskDragging).toBe(false);
expect(td.isTaskResizing).toBe(false);
expect(td.draggedTask).toBeNull();
expect(td.resizeTask).toBeNull();
expect(td.hasMoved).toBe(false);
});
it('should set isTaskDragging when startDrag is called', () => {
const td = createInstance();
const task = {
id: 'task-1',
scheduledStartTime: '10:00',
estimatedDuration: 30,
};
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
td.startDrag(task as any, event);
expect(td.isTaskDragging).toBe(true);
expect(td.draggedTask).toBeTruthy();
expect(td.draggedTask!.id).toBe('task-1');
td.cancel();
});
it('should set isTaskResizing when startResize is called', () => {
const td = createInstance();
const task = {
id: 'task-1',
scheduledStartTime: '10:00',
estimatedDuration: 30,
};
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
td.startResize(task as any, 'bottom', event);
expect(td.isTaskResizing).toBe(true);
expect(td.resizeTask).toBeTruthy();
td.cancel();
});
it('should reset state on cancel', () => {
const td = createInstance();
const task = { id: 'task-1', scheduledStartTime: '10:00', estimatedDuration: 30 };
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
td.startDrag(task as any, event);
expect(td.isTaskDragging).toBe(true);
td.cancel();
expect(td.isTaskDragging).toBe(false);
expect(td.draggedTask).toBeNull();
});
it('should calculate preview position from task time', () => {
const td = createInstance();
const task = {
id: 'task-1',
scheduledStartTime: '12:00', // noon
estimatedDuration: 60,
};
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 480 });
td.startDrag(task as any, event);
// Preview top should be around 50% (12:00 / 24:00)
expect(td.taskDragPreviewTop).toBeCloseTo(50, 0);
// Height should be ~4.17% (60 min / 1440 min)
expect(td.taskDragPreviewHeight).toBeCloseTo(4.17, 0);
td.cancel();
});
});

View file

@ -1,4 +1,4 @@
import { NavigationArrow, CalendarBlank, ListChecks } from '@manacore/shared-icons';
import { NavigationArrow, CalendarBlank } from '@manacore/shared-icons';
import {
COMMON_SHORTCUTS,
COMMON_SYNTAX,
@ -26,12 +26,6 @@ const CALENDAR_SHORTCUTS: ShortcutCategory[] = [
{
keys: ['Cmd', '2'],
altKeys: ['Ctrl', '2'],
description: 'Aufgaben öffnen',
category: 'navigation',
},
{
keys: ['Cmd', '3'],
altKeys: ['Ctrl', '3'],
description: 'Einstellungen öffnen',
category: 'navigation',
},
@ -59,23 +53,6 @@ const CALENDAR_SHORTCUTS: ShortcutCategory[] = [
},
],
},
{
id: 'tasks',
title: 'Aufgaben',
icon: ListChecks,
shortcuts: [
{
keys: ['Enter'],
description: 'Aufgabe öffnen',
category: 'tasks',
},
{
keys: ['Space'],
description: 'Aufgabe abhaken',
category: 'tasks',
},
],
},
];
/**

View file

@ -169,10 +169,6 @@ export function getCalendarHelpContent(locale: string): HelpContent {
},
{
shortcut: 'Cmd/Ctrl + 2',
action: t('Aufgaben öffnen', 'Open Tasks'),
},
{
shortcut: 'Cmd/Ctrl + 3',
action: t('Einstellungen öffnen', 'Open Settings'),
},
],

View file

@ -202,17 +202,6 @@
"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

@ -202,17 +202,6 @@
"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

@ -1,6 +1,6 @@
/**
* Birthdays Store - Manages contact birthdays for calendar display
* Cross-app integration with Contacts Backend (similar to todosStore)
* Cross-app integration with Contacts Backend
*/
import { browser } from '$app/environment';

View file

@ -48,7 +48,6 @@ let _dateStripCollapsed = $state(false);
let _tagStripCollapsed = $state(true);
let _selectedTagIds = $state<string[]>([]);
let _immersiveModeEnabled = $state(false);
let _showTasksInCalendar = $state(false);
let _sidebarCollapsed = $state(true);
const DEFAULT_SETTINGS: CalendarAppSettings = {
@ -234,9 +233,6 @@ export const settingsStore = {
get immersiveModeEnabled() {
return _immersiveModeEnabled;
},
get showTasksInCalendar() {
return _showTasksInCalendar;
},
get sidebarCollapsed() {
return _sidebarCollapsed;
},
@ -283,10 +279,6 @@ export const settingsStore = {
_selectedTagIds = [];
},
toggleTasksInCalendar() {
_showTasksInCalendar = !_showTasksInCalendar;
},
toggleImmersiveMode() {
_immersiveModeEnabled = !_immersiveModeEnabled;
},

View file

@ -1,493 +0,0 @@
/**
* Todos Store - Manages todos from Todo-App using Svelte 5 runes
* Cross-app integration with Todo Backend
*/
import * as api from '$lib/api/todos';
import type {
Task,
TaskPriority,
CreateTaskInput,
UpdateTaskInput,
TaskQuery,
Project,
Label,
} from '$lib/api/todos';
import { PRIORITY_ORDER } from '$lib/api/todos';
import {
format,
parseISO,
isSameDay,
isToday,
isBefore,
startOfDay,
addDays,
isWithinInterval,
} from 'date-fns';
// Re-export types for convenience
export type { Task, TaskPriority, CreateTaskInput, UpdateTaskInput, Project, Label };
// State
let todos = $state<Task[]>([]);
let projects = $state<Project[]>([]);
let labels = $state<Label[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let loadedRange = $state<{ start: Date; end: Date } | null>(null);
let serviceAvailable = $state(true);
export const todosStore = {
// ========== Getters ==========
get todos() {
return todos ?? [];
},
get projects() {
return projects ?? [];
},
get labels() {
return labels ?? [];
},
get loading() {
return loading;
},
get error() {
return error;
},
get serviceAvailable() {
return serviceAvailable;
},
// ========== Derived Getters ==========
/**
* Get todos for a specific day (by dueDate)
*/
getTodosForDay(date: Date): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => {
if (!task.dueDate || task.isCompleted) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isSameDay(dueDate, date);
});
},
/**
* 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
*/
getTodosInRange(start: Date, end: Date): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => {
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isWithinInterval(dueDate, { start, end });
});
},
/**
* Get today's uncompleted todos
*/
get todaysTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos
.filter((task) => {
if (task.isCompleted) return false;
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isToday(dueDate);
})
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
},
/**
* Get overdue todos (due before today, not completed)
*/
get overdueTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
const today = startOfDay(new Date());
return currentTodos
.filter((task) => {
if (task.isCompleted) return false;
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isBefore(startOfDay(dueDate), today);
})
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
},
/**
* Get upcoming todos (next 7 days, not including today)
*/
get upcomingTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
const tomorrow = startOfDay(addDays(new Date(), 1));
const weekFromNow = startOfDay(addDays(new Date(), 7));
return currentTodos
.filter((task) => {
if (task.isCompleted) return false;
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isWithinInterval(startOfDay(dueDate), { start: tomorrow, end: weekFromNow });
})
.sort((a, b) => {
// First sort by date
const dateA = a.dueDate ? parseISO(a.dueDate as string) : new Date();
const dateB = b.dueDate ? parseISO(b.dueDate as string) : new Date();
const dateDiff = dateA.getTime() - dateB.getTime();
if (dateDiff !== 0) return dateDiff;
// Then by priority
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
});
},
/**
* Get todos without due date
*/
get unscheduledTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos
.filter((task) => !task.isCompleted && !task.dueDate)
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
},
/**
* Get completed todos
*/
get completedTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => task.isCompleted);
},
/**
* Get combined sidebar todos (overdue + today, sorted by priority)
* Limited to show in sidebar
*/
getSidebarTodos(limit = 5): Task[] {
const overdue = this.overdueTodos;
const today = this.todaysTodos;
// Combine and sort: overdue first, then today, both by priority
const combined = [...overdue, ...today];
return combined.slice(0, limit);
},
/**
* Get total count of active todos (not completed)
*/
get activeTodosCount(): number {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return 0;
return currentTodos.filter((task) => !task.isCompleted).length;
},
// ========== API Methods ==========
/**
* 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 = {};
if (startDate) {
query.dueDateFrom = format(startDate, 'yyyy-MM-dd');
}
if (endDate) {
query.dueDateTo = format(endDate, 'yyyy-MM-dd');
}
const result = await api.getTasks(query);
if (result.error) {
error = result.error.message;
serviceAvailable = false;
} else {
todos = result.data || [];
serviceAvailable = true;
if (startDate && endDate) {
loadedRange = { start: startDate, end: endDate };
}
}
loading = false;
return result;
},
/**
* Fetch today's todos (shortcut) - only uncompleted tasks
*/
async fetchTodayTodos() {
loading = true;
error = null;
const result = await api.getTodayTasks();
if (result.error) {
error = result.error.message;
serviceAvailable = false;
} else {
// Merge with existing todos (avoid duplicates)
const newTodos = result.data || [];
const existingIds = new Set(todos.map((t) => t.id));
const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id));
todos = [...todos, ...uniqueNew];
serviceAvailable = true;
}
loading = false;
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)
*/
async fetchUpcomingTodos() {
loading = true;
error = null;
const result = await api.getUpcomingTasks();
if (result.error) {
error = result.error.message;
// 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 || [];
const existingIds = new Set(todos.map((t) => t.id));
const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id));
todos = [...todos, ...uniqueNew];
serviceAvailable = true;
}
loading = false;
return result;
},
/**
* Fetch projects
*/
async fetchProjects() {
const result = await api.getProjects();
if (!result.error && result.data) {
projects = result.data;
}
return result;
},
/**
* Fetch labels
*/
async fetchLabels() {
const result = await api.getLabels();
if (!result.error && result.data) {
labels = result.data;
}
return result;
},
/**
* Create a new todo
*/
async createTodo(data: CreateTaskInput) {
const result = await api.createTask(data);
if (result.data) {
todos = [...todos, result.data];
}
return result;
},
/**
* Update a todo
*/
async updateTodo(id: string, data: UpdateTaskInput) {
const result = await api.updateTask(id, data);
if (result.data) {
todos = todos.map((t) => (t.id === id ? result.data! : t));
}
return result;
},
/**
* 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) {
// Rollback: restore the todo on error
if (todoToDelete) {
todos = [...todos, todoToDelete];
}
}
return result;
},
/**
* Toggle todo completion
*/
async toggleComplete(id: string) {
const todo = todos.find((t) => t.id === id);
if (!todo) return { data: null, error: new Error('Todo not found') };
const result = todo.isCompleted ? await api.uncompleteTask(id) : await api.completeTask(id);
if (result.data) {
todos = todos.map((t) => (t.id === id ? result.data! : t));
}
return result;
},
/**
* Get todo by ID
*/
getById(id: string): Task | undefined {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return undefined;
return currentTodos.find((t) => t.id === id);
},
/**
* Get project by ID
*/
getProjectById(id: string): Project | undefined {
const currentProjects = projects ?? [];
if (!Array.isArray(currentProjects)) return undefined;
return currentProjects.find((p) => p.id === id);
},
/**
* Clear todos cache
*/
clear() {
todos = [];
loadedRange = null;
},
/**
* Check if Todo service is available
*/
async checkServiceHealth(): Promise<boolean> {
const result = await api.getTasks({ limit: 1 });
serviceAvailable = !result.error;
return serviceAvailable;
},
};

View file

@ -14,7 +14,6 @@
PillNavItem,
PillDropdownItem,
QuickInputItem,
PillTabGroupConfig,
PillTagSelectorConfig,
PillNavElement,
} from '@manacore/shared-ui';
@ -268,8 +267,7 @@
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group)
// Tags are now in the tag-selector dropdown in prependElements
// Base navigation items for Calendar
let baseNavItems = $derived<PillNavItem[]>([
{
href: '/',
@ -284,20 +282,6 @@
filterHiddenNavItems('calendar', baseNavItems, userSettings.nav?.hiddenNavItems || {})
);
// Active tab based on sidebar state: 'tasks' when sidebar is open, 'calendar' when closed
let activeTab = $derived(settingsStore.sidebarCollapsed ? 'calendar' : 'tasks');
// Tab group for Kalender/Aufgaben
let calendarTasksTabGroup = $derived<PillTabGroupConfig>({
type: 'tabs',
options: [
{ id: 'calendar', icon: 'calendar', label: 'Kalender', title: 'Kalender anzeigen' },
{ id: 'tasks', icon: 'check-square', label: 'Aufgaben', title: 'Aufgaben-Sidebar öffnen' },
],
value: activeTab,
onChange: handleTabChange,
});
// Tag selector config for PillNavigation
let tagSelectorConfig = $derived<PillTagSelectorConfig>({
type: 'tag-selector',
@ -309,33 +293,10 @@
label: 'Tags',
});
// Prepended elements (tab groups at the start of navigation)
// Note: View switcher moved to ViewsBar component
let prependElements = $derived<PillNavElement[]>(
showCalendarToolbar
? [calendarTasksTabGroup, { type: 'divider' }, tagSelectorConfig]
: [calendarTasksTabGroup]
);
// Prepended elements
let prependElements = $derived<PillNavElement[]>(showCalendarToolbar ? [tagSelectorConfig] : []);
// Handle tab change: toggle sidebar for tasks, close for calendar
function handleTabChange(tabId: string) {
// Always navigate to main calendar page if not there
if ($page.url.pathname !== '/') {
goto('/');
}
if (tabId === 'tasks') {
// Toggle behavior: if sidebar is already open, close it
settingsStore.toggleSidebar();
} else if (tabId === 'calendar') {
// Kalender-Tab: close sidebar if open
if (!settingsStore.sidebarCollapsed) {
settingsStore.toggleSidebar();
}
}
}
// Navigation shortcuts (Ctrl+1 = Kalender, Ctrl+2 = Aufgaben toggle, Ctrl+3+ = other nav items)
// Navigation shortcuts
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
@ -346,17 +307,11 @@
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num === 1) {
// Ctrl+1: Kalender (close sidebar)
event.preventDefault();
handleTabChange('calendar');
} else if (num === 2) {
// Ctrl+2: Aufgaben (toggle sidebar)
goto('/');
} else if (num >= 2 && num <= baseNavItems.length + 1) {
event.preventDefault();
handleTabChange('tasks');
} else if (num >= 3 && num <= baseNavItems.length + 2) {
// Ctrl+3+: other nav items (offset by 2 for the tab group)
event.preventDefault();
const route = baseNavItems[num - 3]?.href;
const route = baseNavItems[num - 2]?.href;
if (route) {
goto(route);
}
@ -595,7 +550,7 @@
>
<div
class="content-wrapper"
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
class:calendar-expanded={$page.url.pathname === '/'}
class:immersive={settingsStore.immersiveModeEnabled}
>
{@render children()}

View file

@ -4,40 +4,28 @@
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import { getDefaultCalendar } from '$lib/data/queries';
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import ServiceStatusBanner from '$lib/components/ServiceStatusBanner.svelte';
import type { CalendarEvent, Calendar } from '@calendar/shared';
import { addMinutes } from 'date-fns';
import { browser } from '$app/environment';
import { CaretDoubleLeft } from '@manacore/shared-icons';
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// 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);
// Generate a unique key for the overlay to force remount
let overlayKey = $state(0);
function handleQuickCreate(date: Date, position: { x: number; y: number }, endDate?: Date) {
// Close any existing overlay first
editingEvent = null;
quickCreateDate = date;
// Create draft event immediately so it appears in the grid
const defaultCalendar = getDefaultCalendar(calendarsCtx.value);
// Use provided endDate or calculate from default duration
const endTime = endDate ?? addMinutes(date, settingsStore.defaultEventDuration);
eventsStore.createDraftEvent({
calendarId: defaultCalendar?.id || '',
title: '',
@ -45,15 +33,12 @@
endTime: endTime.toISOString(),
isAllDay: false,
});
overlayKey++;
showQuickOverlay = true;
}
function handleEventClick(event: CalendarEvent) {
// Close any existing overlay/draft first
eventsStore.clearDraftEvent();
editingEvent = event;
overlayKey++;
showQuickOverlay = true;
@ -66,19 +51,12 @@
}
function handleEventCreated() {
// Event is automatically added to store, draft is cleared
eventsStore.clearDraftEvent();
}
function handleEventUpdated() {
// Event is automatically updated in store
}
function handleEventUpdated() {}
function handleEventDeleted() {}
function handleEventDeleted() {
// Event is automatically removed from store
}
// Voice event creation handler
interface VoiceEventData {
title: string;
startTime?: Date;
@ -92,16 +70,10 @@
function handleVoiceEventCreate(event: CustomEvent<VoiceEventData>) {
const data = event.detail;
// Close any existing overlay first
editingEvent = null;
eventsStore.clearDraftEvent();
// Determine start time - use parsed time or default to now
const startTime = data.startTime || new Date();
quickCreateDate = startTime;
// Calculate end time
let endTime: Date;
if (data.endTime) {
endTime = data.endTime;
@ -111,11 +83,7 @@
} else {
endTime = addMinutes(startTime, settingsStore.defaultEventDuration);
}
// Get default calendar
const defaultCalendar = getDefaultCalendar(calendarsCtx.value);
// Create draft event with voice transcription data
eventsStore.createDraftEvent({
calendarId: defaultCalendar?.id || '',
title: data.title,
@ -125,12 +93,10 @@
location: data.location,
description: data.description ? `Sprachnotiz: ${data.description}` : undefined,
});
overlayKey++;
showQuickOverlay = true;
}
// Listen for voice event creation from layout
$effect(() => {
if (browser) {
const handler = (e: Event) => handleVoiceEventCreate(e as CustomEvent<VoiceEventData>);
@ -162,12 +128,6 @@
/>
<div class="service-banners">
<ServiceStatusBanner
serviceName="Todo-Service"
available={todosStore.serviceAvailable}
error={todosStore.error}
onRetry={() => todosStore.fetchTodos()}
/>
{#if settingsStore.showBirthdays}
<ServiceStatusBanner
serviceName="Geburtstage (Kontakte)"
@ -179,36 +139,12 @@
</div>
<div class="calendar-layout">
<!-- Desktop: Left Sidebar -->
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
<!-- Collapse button at top -->
<button
class="sidebar-collapse-btn"
onclick={() => settingsStore.toggleSidebar()}
title={$_('calendar.hideSidebar')}
>
<CaretDoubleLeft size={16} />
</button>
<TodoSidebarSection maxItems={5} />
</aside>
<!-- Main Calendar Area -->
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
<div class="calendar-main">
<div class="calendar-content">
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
</div>
</div>
<!-- Mobile: Bottom Todo Section -->
<aside
class="calendar-sidebar-mobile mobile-only"
class:collapsed={settingsStore.sidebarCollapsed}
>
<TodoSidebarSection maxItems={3} />
</aside>
<!-- Quick Event Overlay (for both create and edit) -->
{#if showQuickOverlay}
{#key overlayKey}
<QuickEventOverlay
@ -226,69 +162,12 @@
<style>
.calendar-layout {
display: flex;
gap: 1.5rem;
width: 100%;
flex: 1;
min-height: 0;
position: relative;
}
/* Desktop only elements */
.desktop-only {
display: flex;
}
/* Mobile only elements - hidden by default */
.mobile-only {
display: none;
}
.calendar-sidebar {
width: 260px;
flex-shrink: 0;
flex-direction: column;
gap: 1rem;
position: relative;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: left top;
}
.calendar-sidebar.collapsed {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
padding: 0;
margin: 0;
}
.calendar-layout:has(.calendar-sidebar.collapsed) {
gap: 0;
}
.sidebar-collapse-btn {
position: absolute;
top: 0;
right: -12px;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: var(--color-surface);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 150ms ease;
color: var(--color-muted-foreground);
}
.sidebar-collapse-btn:hover {
background: var(--color-muted);
color: var(--color-foreground);
}
.calendar-main {
flex: 1;
display: flex;
@ -296,15 +175,6 @@
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.calendar-main.expanded {
border-radius: 0;
border: none;
}
.calendar-content {
@ -313,27 +183,6 @@
overflow: hidden;
}
/* Mobile: Bottom Todo Section */
.calendar-sidebar-mobile {
width: 100%;
flex-direction: column;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 0.75rem;
overflow-y: auto;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.calendar-sidebar-mobile.collapsed {
height: 0;
flex: 0;
padding: 0;
opacity: 0;
overflow: hidden;
border: none;
}
/* Mobile Layout - 50/50 Splitscreen */
@media (max-width: 768px) {
.calendar-layout {
flex-direction: column;
@ -344,27 +193,7 @@
overflow: hidden;
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: flex;
}
.calendar-main {
border-radius: 0;
border: none;
min-height: 0;
overflow: hidden;
}
.calendar-layout:has(.calendar-sidebar-mobile:not(.collapsed)) .calendar-main {
flex: 0 0 50%;
height: 50%;
}
.calendar-layout:has(.calendar-sidebar-mobile.collapsed) .calendar-main {
flex: 1;
height: 100%;
}
@ -373,37 +202,6 @@
height: 100%;
overflow-y: auto;
}
.calendar-sidebar-mobile {
display: flex;
flex-direction: column;
flex: 0 0 50%;
height: 50%;
max-height: none;
border-radius: 0;
margin-bottom: 0;
padding: 0;
border-top: none;
overflow: hidden;
}
.calendar-sidebar-mobile > :global(*) {
flex: 1;
min-height: 0;
}
.calendar-sidebar-mobile.collapsed {
flex: 0;
height: 0;
padding: 0;
border: none;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.calendar-sidebar {
width: 220px;
}
}
.service-banners {