mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
e5c63f65fb
commit
750a0c77ff
28 changed files with 40 additions and 4557 deletions
|
|
@ -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,
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue