Merge pull request #15 from Memo-2023/till-dev

feat: Calendar todo integration, CommandBar improvements, and statistics components
This commit is contained in:
Nils Weiser 2025-12-10 21:42:38 +01:00 committed by GitHub
commit 15a29aae5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 11957 additions and 1835 deletions

View file

@ -228,6 +228,7 @@ CLOCK_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock
# ============================================
TODO_BACKEND_PORT=3018
TODO_BACKEND_URL=http://localhost:3018
TODO_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo
# ============================================

View file

@ -31,7 +31,6 @@
"dependencies": {
"@calendar/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
@ -40,13 +39,16 @@
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@neodrag/svelte": "^2.3.3",
"d3-force": "^3.0.0",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.559.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1"
},

View file

@ -0,0 +1,370 @@
/**
* 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';
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
// ============================================
// 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;
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;
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;
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
// ============================================
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
};
async function fetchTodoApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `Todo API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Failed to connect to Todo service'),
};
}
}
// ============================================
// Helper Functions
// ============================================
function buildQueryString(query: TaskQuery): string {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, String(value));
}
});
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
// ============================================
// Task API Functions
// ============================================
export async function getTasks(
query: TaskQuery = {}
): Promise<{ data: Task[] | null; error: Error | null }> {
const queryString = buildQueryString(query);
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
return {
data: result.data?.tasks || null,
error: result.error,
};
}
export async function getTask(id: string): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`);
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function createTask(
data: CreateTaskInput
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>('/tasks', {
method: 'POST',
body: data,
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function updateTask(
id: string,
data: UpdateTaskInput
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`, {
method: 'PUT',
body: data,
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function deleteTask(id: string): Promise<{ error: Error | null }> {
const result = await fetchTodoApi(`/tasks/${id}`, {
method: 'DELETE',
});
return { error: result.error };
}
export async function completeTask(
id: string
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/complete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function uncompleteTask(
id: string
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/uncomplete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function getTodayTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
const result = await fetchTodoApi<TasksResponse>('/tasks/today');
return {
data: result.data?.tasks || null,
error: result.error,
};
}
export async function getUpcomingTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
const result = await fetchTodoApi<TasksResponse>('/tasks/upcoming');
return {
data: result.data?.tasks || null,
error: result.error,
};
}
// ============================================
// Project API Functions
// ============================================
export async function getProjects(): Promise<{ data: Project[] | null; error: Error | null }> {
const result = await fetchTodoApi<ProjectsResponse>('/projects');
return {
data: result.data?.projects || null,
error: result.error,
};
}
// ============================================
// Label API Functions
// ============================================
export async function getLabels(): Promise<{ data: Label[] | null; error: Error | null }> {
const result = await fetchTodoApi<LabelsResponse>('/labels');
return {
data: result.data?.labels || null,
error: result.error,
};
}
// ============================================
// Priority Colors Helper
// ============================================
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
urgent: 'hsl(var(--color-danger))',
high: 'hsl(var(--color-warning))',
medium: 'hsl(var(--color-accent))',
low: 'hsl(var(--color-success))',
};
export const PRIORITY_LABELS: Record<TaskPriority, string> = {
urgent: 'Dringend',
high: 'Wichtig',
medium: 'Normal',
low: 'Später',
};
export const PRIORITY_ORDER: Record<TaskPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
};

View file

@ -0,0 +1,151 @@
<script lang="ts">
import { Calendar, CheckSquare, Filter } from 'lucide-svelte';
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();
const rangeOptions = [
{ value: '7' as const, label: '7 Tage' },
{ value: '30' as const, label: '30 Tage' },
{ value: 'all' as const, label: 'Alle' },
];
</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">
<Filter size={14} />
<select
value={timeRange}
onchange={(e) =>
onRangeChange?.((e.target as HTMLSelectElement).value as '7' | '30' | 'all')}
>
{#each rangeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
</div>
<style>
.agenda-filters {
display: flex;
align-items: center;
justify-content: space-between;
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));
}
.range-selector select {
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.range-selector select:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
@media (max-width: 480px) {
.agenda-filters {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.filter-group {
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,217 @@
<script lang="ts">
import type { CalendarEvent } from '@calendar/shared';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
import { calendarsStore } from '$lib/stores/calendars.svelte';
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 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
type ItemType = 'event' | 'todo';
interface Props {
type: ItemType;
event?: CalendarEvent;
todo?: Task;
onclick?: () => void;
}
let { type, event, todo, onclick }: Props = $props();
let isToggling = $state(false);
// Event helpers
const eventColor = $derived(event ? calendarsStore.getColor(event.calendarId) : undefined);
const eventTimeLabel = $derived.by(() => {
if (!event) return '';
if (event.isAllDay) return 'Ganztägig';
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : 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>
</div>
{/if}
<style>
.agenda-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
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 {
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;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
background: var(--item-color);
color: white;
flex-shrink: 0;
}
.item-checkbox {
flex-shrink: 0;
padding-top: 2px;
}
.item-content {
flex: 1;
min-width: 0;
display: flex;
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;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 150ms ease;
}
.item-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.item-meta :global(svg) {
flex-shrink: 0;
}
.project-tag {
font-size: 0.6875rem;
font-weight: 500;
background: color-mix(in srgb, currentColor 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
</style>

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -405,6 +407,16 @@
</div>
{/if}
<!-- Todos section -->
{#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0}
<div class="todos-section">
<div class="time-gutter"></div>
<div class="todos-content">
<TodoRow date={viewStore.currentDate} maxVisible={4} />
</div>
</div>
{/if}
<!-- Time grid -->
<div class="time-grid scrollbar-thin">
<div class="time-column">
@ -533,6 +545,16 @@
cursor: pointer;
}
/* Todos section */
.todos-section {
display: flex;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todos-content {
flex: 1;
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event {
position: absolute;

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoDayCell from './TodoDayCell.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -265,6 +267,11 @@
{format(day, 'd')}
</span>
<!-- Todos for this day -->
{#if todosStore.serviceAvailable}
<TodoDayCell date={day} maxVisible={2} />
{/if}
<div class="day-events">
{#each getEventsForDay(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}

View file

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

View file

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

View file

@ -0,0 +1,292 @@
<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 { ChevronDown, ChevronRight, Plus, CheckSquare, AlertTriangle } from 'lucide-svelte';
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();
});
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 -->
<button type="button" class="section-header" onclick={toggleExpanded}>
<div class="header-left">
{#if isExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight 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">
<AlertTriangle size={12} />
</span>
{/if}
</div>
<button
type="button"
class="add-button"
onclick={handleAddClick}
aria-label="Aufgabe hinzufügen"
>
<Plus size={16} />
</button>
</button>
<!-- Content -->
{#if isExpanded}
<div class="section-content">
{#if !todosStore.serviceAvailable}
<div class="service-unavailable">
<AlertTriangle 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}
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;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
cursor: pointer;
transition: background 150ms ease;
}
.section-header: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;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.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;
}
.service-unavailable {
color: hsl(var(--color-danger));
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.show-all-button {
width: 100%;
padding: 0.5rem;
margin-top: 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-primary));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-md);
transition: background 150ms ease;
}
.show-all-button:hover {
background: hsl(var(--color-primary) / 0.1);
}
.quick-add-wrapper {
margin-top: 0.5rem;
padding: 0 0.25rem;
}
</style>

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -499,6 +501,18 @@
</div>
{/if}
<!-- Todos row (shown per day, below all-day events) -->
{#if todosStore.serviceAvailable}
<div class="todos-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="todos-cell">
<TodoRow date={day} maxVisible={2} />
</div>
{/each}
</div>
{/if}
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
@ -651,6 +665,18 @@
cursor: pointer;
}
/* Todos row */
.todos-row {
display: flex;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todos-cell {
flex: 1;
border-left: 1px solid hsl(var(--color-border));
min-height: 0;
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event {
position: absolute;

View file

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

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import { Plus, X } from 'lucide-svelte';
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}>
<input
bind:this={inputRef}
bind:value={title}
type="text"
class="quick-add-input"
{placeholder}
disabled={isSubmitting}
onkeydown={handleKeydown}
onblur={handleBlur}
autofocus={autofocus || isExpanded}
/>
{#if showButton}
<button type="button" class="cancel-button" onclick={collapse} disabled={isSubmitting}>
<X size={14} />
</button>
{/if}
<button
type="submit"
class="submit-button"
disabled={!title.trim() || isSubmitting}
aria-label="Aufgabe erstellen"
>
{#if isSubmitting}
<span class="spinner"></span>
{:else}
<Plus size={14} />
{/if}
</button>
</form>
{/if}
<style>
.add-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: all 150ms ease;
}
.add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.quick-add-form {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
transition: border-color 150ms ease;
}
.quick-add-form:focus-within {
border-color: hsl(var(--color-primary));
}
.quick-add-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
}
.quick-add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.quick-add-input:disabled {
opacity: 0.5;
}
.cancel-button,
.submit-button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.cancel-button {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.cancel-button:hover:not(:disabled) {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.submit-button {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.submit-button:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid hsl(var(--color-primary-foreground) / 0.3);
border-top-color: hsl(var(--color-primary-foreground));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { Check } from 'lucide-svelte';
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} strokeWidth={3} />
{/if}
</button>
<style>
.todo-checkbox {
width: var(--box-size);
height: var(--box-size);
min-width: var(--box-size);
border-radius: 4px;
border: 2px solid hsl(var(--color-border));
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 150ms ease;
padding: 0;
}
.todo-checkbox:hover:not(:disabled) {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.todo-checkbox:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.todo-checkbox.checked {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.todo-checkbox.checked:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.8);
border-color: hsl(var(--color-primary) / 0.8);
}
.todo-checkbox:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.todo-checkbox.loading {
cursor: wait;
}
.spinner {
width: calc(var(--icon-size) - 2px);
height: calc(var(--icon-size) - 2px);
border: 2px solid hsl(var(--color-muted-foreground) / 0.3);
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Size variants */
.size-sm {
border-radius: 3px;
border-width: 1.5px;
}
.size-lg {
border-radius: 5px;
}
</style>

View file

@ -0,0 +1,625 @@
<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 { toast } from '$lib/stores/toast';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import { X, Calendar, Clock, Folder, Tag, Trash2, CheckSquare, AlertCircle } from 'lucide-svelte';
import { 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
let title = $state(task.title);
let description = $state(task.description || '');
let dueDate = $state(task.dueDate ? formatDateForInput(task.dueDate) : '');
let dueTime = $state(task.dueTime || '');
let priority = $state<TaskPriority>(task.priority);
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,
};
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;
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 a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<!-- 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
autofocus
/>
</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>
<div class="form-group">
<label>Priorität</label>
<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}
<div class="detail-item">
<AlertCircle 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}>
<Trash2 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;
}
.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;
}
input[type='text'],
input[type='date'],
input[type='time'],
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;
}
.priority-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.priority-option {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: all 150ms ease;
}
.priority-option:hover {
border-color: var(--priority-color);
}
.priority-option.selected {
border-color: var(--priority-color);
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
}
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--priority-color);
}
/* Footer */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid hsl(var(--color-border));
}
.btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.btn-secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.8);
}
.btn-danger {
background: hsl(var(--color-danger) / 0.1);
color: hsl(var(--color-danger));
}
.btn-danger:hover:not(:disabled) {
background: hsl(var(--color-danger));
color: white;
}
</style>

View file

@ -0,0 +1,287 @@
<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;
onclick?: () => void;
}
let {
task,
variant = 'default',
showProject = true,
showDueDate = true,
showPriority = true,
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();
}
}
</script>
<div
class="todo-item"
class:completed={task.isCompleted}
class:overdue={isOverdue}
class:compact={variant === 'compact'}
class:minimal={variant === 'minimal'}
class:clickable={!!onclick}
style="--priority-color: {priorityColor};"
onclick={handleClick}
onkeydown={handleKeydown}
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.completed {
opacity: 0.6;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.todo-item.overdue {
background: hsl(var(--color-danger) / 0.05);
}
/* Compact variant */
.todo-item.compact {
padding: 0.5rem 0.625rem;
gap: 0.5rem;
border-left-width: 2px;
}
/* Minimal variant */
.todo-item.minimal {
padding: 0.375rem 0.5rem;
gap: 0.375rem;
border-left-width: 2px;
background: transparent;
}
.todo-item.minimal:hover {
background: hsl(var(--color-muted) / 0.3);
}
.todo-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.todo-main {
display: flex;
align-items: center;
gap: 0.5rem;
}
.todo-title {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact .todo-title {
font-size: 0.8125rem;
}
.minimal .todo-title {
font-size: 0.75rem;
}
.subtask-count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-muted) / 0.5);
padding: 1px 6px;
border-radius: 9999px;
flex-shrink: 0;
}
.todo-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.due-date.overdue {
color: hsl(var(--color-danger));
font-weight: 500;
}
.project {
font-size: 0.6875rem;
color: var(--project-color);
background: color-mix(in srgb, var(--project-color) 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
.labels {
display: flex;
align-items: center;
gap: 0.25rem;
}
.label {
font-size: 0.625rem;
color: var(--label-color);
background: color-mix(in srgb, var(--label-color) 15%, transparent);
padding: 1px 4px;
border-radius: 3px;
}
.label-more {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,270 @@
/**
* Calendar Statistics Store - Calculates calendar statistics using Svelte 5 runes
*/
import type { CalendarEvent, Calendar } from '@calendar/shared';
import {
startOfDay,
startOfWeek,
endOfWeek,
subDays,
format,
differenceInMinutes,
isToday,
isSameWeek,
parseISO,
eachDayOfInterval,
addDays,
} from 'date-fns';
import { de } from 'date-fns/locale';
import type {
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from '@manacore/shared-ui';
// Types
export interface EventStatusBreakdown {
status: 'confirmed' | 'tentative' | 'cancelled';
count: number;
percentage: number;
color: string;
}
const STATUS_COLORS: Record<string, string> = {
confirmed: '#10B981', // green
tentative: '#F59E0B', // orange
cancelled: '#EF4444', // red
};
const STATUS_LABELS: Record<string, string> = {
confirmed: 'Bestätigt',
tentative: 'Vorläufig',
cancelled: 'Abgesagt',
};
// State
let events = $state<CalendarEvent[]>([]);
let calendars = $state<Calendar[]>([]);
export const calendarStatisticsStore = {
// Setters
setEvents(newEvents: CalendarEvent[]) {
events = newEvents;
},
setCalendars(newCalendars: Calendar[]) {
calendars = newCalendars;
},
// Quick Stats
get totalEvents() {
return events.length;
},
get eventsToday() {
return events.filter((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
return isToday(startTime);
}).length;
},
get eventsThisWeek() {
const now = new Date();
return events.filter((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
return isSameWeek(startTime, now, { weekStartsOn: 1 });
}).length;
},
get upcomingEvents() {
const now = new Date();
const nextWeek = addDays(now, 7);
return events.filter((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
return startTime > now && startTime <= nextWeek;
}).length;
},
get busyHoursThisWeek() {
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
let totalMinutes = 0;
events.forEach((e) => {
if (e.isAllDay) return; // Skip all-day events
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime;
if (startTime >= weekStart && startTime <= weekEnd) {
totalMinutes += differenceInMinutes(endTime, startTime);
}
});
return Math.round((totalMinutes / 60) * 10) / 10; // Round to 1 decimal
},
get totalCalendars() {
return calendars.length;
},
get averageEventDuration() {
const timedEvents = events.filter((e) => !e.isAllDay);
if (timedEvents.length === 0) return 0;
const totalMinutes = timedEvents.reduce((sum, e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime;
return sum + differenceInMinutes(endTime, startTime);
}, 0);
return Math.round(totalMinutes / timedEvents.length);
},
// Activity Heatmap (last 6 months) - based on event creation
get activityHeatmap(): HeatmapDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 180);
// Count events per day based on start time
const eventMap = new Map<string, number>();
events.forEach((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
const dateKey = format(startTime, 'yyyy-MM-dd');
eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1);
});
// Generate all days
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: eventMap.get(dateKey) || 0,
dayOfWeek: day.getDay(),
};
});
},
// Weekly Trend (last 4 weeks)
get weeklyTrend(): TrendDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 27);
const eventMap = new Map<string, number>();
events.forEach((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
if (startTime >= startDate && startTime <= endDate) {
const dateKey = format(startTime, 'yyyy-MM-dd');
eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1);
}
});
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: eventMap.get(dateKey) || 0,
label: format(day, 'EEE', { locale: de }),
};
});
},
// Status Breakdown (Donut Chart)
get statusBreakdown(): DonutSegment[] {
const total = events.length;
if (total === 0) return [];
const counts: Record<string, number> = {
confirmed: 0,
tentative: 0,
cancelled: 0,
};
events.forEach((e) => {
const status = e.status || 'confirmed';
if (counts[status] !== undefined) {
counts[status]++;
}
});
return (['confirmed', 'tentative', 'cancelled'] as const).map((status) => ({
id: status,
label: STATUS_LABELS[status],
count: counts[status],
percentage: total > 0 ? Math.round((counts[status] / total) * 100) : 0,
color: STATUS_COLORS[status],
}));
},
// Calendar Activity (Progress Bars)
get calendarActivity(): ProgressItem[] {
const calendarMap = new Map<string, { total: number; thisWeek: number }>();
// Initialize with all calendars
calendars.forEach((c) => {
calendarMap.set(c.id, { total: 0, thisWeek: 0 });
});
const now = new Date();
// Count events per calendar
events.forEach((e) => {
const calendarId = e.calendarId;
const data = calendarMap.get(calendarId) || { total: 0, thisWeek: 0 };
data.total++;
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
if (isSameWeek(startTime, now, { weekStartsOn: 1 })) {
data.thisWeek++;
}
calendarMap.set(calendarId, data);
});
// Convert to array
const result: ProgressItem[] = [];
calendarMap.forEach((data, calendarId) => {
if (data.total === 0) return;
const calendar = calendars.find((c) => c.id === calendarId);
result.push({
id: calendarId,
name: calendar?.name || 'Unbekannt',
color: calendar?.color || '#6B7280',
total: data.total,
completed: data.thisWeek,
percentage: data.total > 0 ? Math.round((data.thisWeek / data.total) * 100) : 0,
});
});
// Sort by total events descending
return result.sort((a, b) => b.total - a.total);
},
// All-day vs Timed events ratio
get allDayRatio() {
const allDay = events.filter((e) => e.isAllDay).length;
const timed = events.filter((e) => !e.isAllDay).length;
return {
allDay,
timed,
allDayPercentage: events.length > 0 ? Math.round((allDay / events.length) * 100) : 0,
};
},
// Recurring events count
get recurringEventsCount() {
return events.filter((e) => e.recurrenceRule).length;
},
};

View file

@ -0,0 +1,405 @@
/**
* 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
*/
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 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
*/
async fetchTodos(startDate?: Date, endDate?: Date) {
loading = true;
error = null;
const query: TaskQuery = {
isCompleted: false,
};
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)
*/
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 upcoming todos (shortcut)
*/
async fetchUpcomingTodos() {
loading = true;
error = null;
const result = await api.getUpcomingTasks();
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 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
*/
async deleteTodo(id: string) {
const result = await api.deleteTask(id);
if (!result.error) {
todos = todos.filter((t) => t.id !== id);
}
return result;
},
/**
* Toggle todo completion
*/
async toggleComplete(id: string) {
const todo = todos.find((t) => t.id === id);
if (!todo) return { data: null, error: new Error('Todo not found') };
const result = todo.isCompleted ? await api.uncompleteTask(id) : await api.completeTask(id);
if (result.data) {
todos = todos.map((t) => (t.id === id ? result.data! : t));
}
return result;
},
/**
* Get todo by ID
*/
getById(id: string): Task | undefined {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return undefined;
return currentTodos.find((t) => t.id === id);
},
/**
* Get project by ID
*/
getProjectById(id: string): Project | undefined {
const currentProjects = projects ?? [];
if (!Array.isArray(currentProjects)) return undefined;
return currentProjects.find((p) => p.id === id);
},
/**
* Clear todos cache
*/
clear() {
todos = [];
loadedRange = null;
},
/**
* Check if Todo service is available
*/
async checkServiceHealth(): Promise<boolean> {
const result = await api.getTasks({ limit: 1 });
serviceAvailable = !result.error;
return serviceAvailable;
},
};

View file

@ -0,0 +1,261 @@
/**
* Event Parser for Calendar App
*
* Extends the base parser with event-specific patterns:
* - Calendar: @CalendarName
* - Duration: für 2 Stunden, 30 min
* - Location: in Berlin, bei Firma XY
*/
import {
parseBaseInput,
extractAtReference,
combineDateAndTime,
formatDatePreview,
formatTimePreview,
} from '@manacore/shared-utils';
export interface ParsedEvent {
title: string;
startTime?: Date;
endTime?: Date;
calendarName?: string;
location?: string;
tagNames: string[];
isAllDay: boolean;
}
interface Calendar {
id: string;
name: string;
}
interface Tag {
id: string;
name: string;
}
export interface ParsedEventWithIds {
title: string;
startTime?: string;
endTime?: string;
calendarId?: string;
tagIds: string[];
location?: string;
isAllDay: boolean;
}
// Duration patterns (event-specific)
const DURATION_PATTERNS: { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] = [
// "für X Stunden" or "X Stunden"
{
pattern: /(?:für\s+)?(\d+(?:[.,]\d+)?)\s*(?:stunde?n?|h)\b/i,
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
},
// "für X Minuten" or "X min"
{
pattern: /(?:für\s+)?(\d+)\s*(?:minuten?|min)\b/i,
getMinutes: (match) => parseInt(match[1], 10),
},
// "1,5h" or "1.5h"
{
pattern: /(\d+[.,]\d+)\s*h\b/i,
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
},
];
// Location patterns (event-specific)
const LOCATION_PATTERNS: RegExp[] = [
/\bin\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
/\bbei\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
];
/**
* Extract duration from text
*/
function extractDuration(text: string): { minutes?: number; remaining: string } {
for (const { pattern, getMinutes } of DURATION_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
minutes: getMinutes(match),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { minutes: undefined, remaining: text };
}
/**
* Extract location from text
*/
function extractLocation(text: string): { location?: string; remaining: string } {
for (const pattern of LOCATION_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
location: match[1].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { location: undefined, remaining: text };
}
/**
* Parse natural language event input
*
* Examples:
* - "Meeting morgen 14 Uhr für 1 Stunde @Arbeit in Büro #wichtig"
* - "Arzttermin Montag 10:30 30 min bei Dr. Müller"
* - "Geburtstag 15.12. ganztägig #privat"
*/
export function parseEventInput(input: string): ParsedEvent {
let text = input.trim();
// Check for all-day indicator first
const allDayPattern = /\bganztägig\b|\ball[- ]?day\b/i;
const isAllDay = allDayPattern.test(text);
text = text.replace(allDayPattern, '').trim();
// Extract calendar (@CalendarName) - event-specific
const calendarResult = extractAtReference(text);
text = calendarResult.remaining;
const calendarName = calendarResult.value;
// Extract duration first (before base parser)
const durationResult = extractDuration(text);
text = durationResult.remaining;
const durationMinutes = durationResult.minutes;
// Extract location (before base parser to avoid conflicts)
const locationResult = extractLocation(text);
text = locationResult.remaining;
const location = locationResult.location;
// Use base parser for common patterns (date, time, tags)
const base = parseBaseInput(text);
// Combine date and time for start
const startTime = combineDateAndTime(base.date, base.time);
// Calculate end time based on duration (default 1 hour)
let endTime: Date | undefined;
if (startTime && !isAllDay) {
const duration = durationMinutes || 60; // Default 1 hour
endTime = new Date(startTime.getTime() + duration * 60 * 1000);
} else if (startTime && isAllDay) {
// All-day events: end time is end of day
endTime = new Date(startTime);
endTime.setHours(23, 59, 59, 999);
}
return {
title: base.title,
startTime,
endTime,
calendarName,
location,
tagNames: base.tagNames,
isAllDay,
};
}
/**
* Resolve calendar and tag names to IDs
*/
export function resolveEventIds(
parsed: ParsedEvent,
calendars: Calendar[],
tags: Tag[]
): ParsedEventWithIds {
let calendarId: string | undefined;
const tagIds: string[] = [];
// Find calendar by name (case-insensitive)
if (parsed.calendarName) {
const calendar = calendars.find(
(c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase()
);
if (calendar) {
calendarId = calendar.id;
}
}
// Use default calendar if none specified
if (!calendarId && calendars.length > 0) {
const defaultCalendar = calendars.find((c: any) => c.isDefault) || calendars[0];
calendarId = defaultCalendar.id;
}
// Find tags by name (case-insensitive)
for (const tagName of parsed.tagNames) {
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) {
tagIds.push(tag.id);
}
}
return {
title: parsed.title,
startTime: parsed.startTime?.toISOString(),
endTime: parsed.endTime?.toISOString(),
calendarId,
tagIds,
location: parsed.location,
isAllDay: parsed.isAllDay,
};
}
/**
* Format parsed event for preview display
*/
export function formatParsedEventPreview(parsed: ParsedEvent): string {
const parts: string[] = [];
if (parsed.startTime) {
let dateStr = `📅 ${formatDatePreview(parsed.startTime)}`;
if (!parsed.isAllDay && parsed.startTime.getHours() !== 0) {
dateStr += ` ${formatTimePreview({
hours: parsed.startTime.getHours(),
minutes: parsed.startTime.getMinutes(),
})}`;
// Add duration if end time differs
if (parsed.endTime) {
const durationMs = parsed.endTime.getTime() - parsed.startTime.getTime();
const durationMins = Math.round(durationMs / 60000);
if (durationMins > 0 && durationMins !== 60) {
if (durationMins >= 60) {
const hours = Math.floor(durationMins / 60);
const mins = durationMins % 60;
dateStr += mins > 0 ? ` (${hours}h ${mins}min)` : ` (${hours}h)`;
} else {
dateStr += ` (${durationMins}min)`;
}
}
}
}
if (parsed.isAllDay) {
dateStr += ' (Ganztägig)';
}
parts.push(dateStr);
}
if (parsed.location) {
parts.push(`📍 ${parsed.location}`);
}
if (parsed.calendarName) {
parts.push(`📆 ${parsed.calendarName}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -9,12 +9,15 @@
PillDropdownItem,
CommandBarItem,
QuickAction,
CreatePreview,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import {
THEME_DEFINITIONS,
@ -22,6 +25,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -32,6 +36,11 @@
import { searchEvents } from '$lib/api/events';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import {
parseEventInput,
resolveEventIds,
formatParsedEventPreview,
} from '$lib/utils/event-parser';
// App switcher items
const appItems = getPillAppItems('calendar');
@ -51,6 +60,7 @@
onclick: () => viewStore.goToToday(),
},
{ id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' },
{ id: 'tasks', label: 'Aufgaben anzeigen', icon: 'check-square', href: '/tasks' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
@ -72,6 +82,54 @@
goto(`/event/${item.id}`);
}
// CommandBar Quick-Create handlers
function handleCommandBarParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseEventInput(query);
if (!parsed.title) return null;
return {
title: parsed.title,
subtitle: formatParsedEventPreview(parsed),
};
}
async function handleCommandBarCreate(query: string): Promise<void> {
const parsed = parseEventInput(query);
if (!parsed.title) return;
// Resolve calendar and tag names to IDs
const calendars = calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name }));
const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
const resolved = resolveEventIds(parsed, calendars, tags);
// Ensure we have a calendar
if (!resolved.calendarId) {
console.error('No calendar available');
return;
}
// Ensure we have start and end times
if (!resolved.startTime) {
// Default to now + 1 hour
const now = new Date();
resolved.startTime = now.toISOString();
const end = new Date(now.getTime() + 60 * 60 * 1000);
resolved.endTime = end.toISOString();
}
await eventsStore.createEvent({
calendarId: resolved.calendarId,
title: resolved.title,
startTime: resolved.startTime,
endTime: resolved.endTime || resolved.startTime,
isAllDay: resolved.isAllDay,
location: resolved.location,
tagIds: resolved.tagIds,
});
}
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
@ -122,18 +180,25 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Calendar
const navItems: PillNavItem[] = [
// Base navigation items for Calendar
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/agenda', label: 'Agenda', icon: 'list' },
{ href: '/tasks', label: 'Aufgaben', icon: 'check-square' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-4)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
@ -200,8 +265,9 @@
// Initialize view state
viewStore.initialize();
// Load calendars and user settings
// Load calendars, tags, and user settings
await calendarsStore.fetchCalendars();
await eventTagsStore.fetchTags();
await userSettings.load();
// Redirect to start page if on root and a custom start page is set
@ -283,9 +349,13 @@
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Termin suchen..."
placeholder="Termin suchen oder erstellen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
onCreate={handleCommandBarCreate}
onParseCreate={handleCommandBarParseCreate}
createText="Als Termin erstellen"
createShortcut="⌘↵"
/>
</div>

View file

@ -16,6 +16,7 @@
import YearView from '$lib/components/calendar/YearView.svelte';
import MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte';
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
@ -130,6 +131,8 @@
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
<CalendarSidebar />
<TodoSidebarSection maxItems={5} />
</aside>
<!-- FAB when sidebar is collapsed -->

View file

@ -0,0 +1,287 @@
<script lang="ts">
import { onMount } from 'svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { calendarStatisticsStore } from '$lib/stores/statistics.svelte';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import {
BarChart3,
CalendarDays,
Calendar,
Clock,
CalendarCheck,
Hourglass,
} from 'lucide-svelte';
import { subDays, addDays } from 'date-fns';
let loading = $state(true);
// Update statistics when events change
$effect(() => {
calendarStatisticsStore.setEvents(eventsStore.events);
});
$effect(() => {
calendarStatisticsStore.setCalendars(calendarsStore.calendars);
});
// Build stats items for StatsGrid
let statsItems = $derived<StatItem[]>([
{
id: 'eventsToday',
label: 'Heute',
value: calendarStatisticsStore.eventsToday,
icon: CalendarDays,
variant: 'success',
},
{
id: 'eventsThisWeek',
label: 'Diese Woche',
value: calendarStatisticsStore.eventsThisWeek,
icon: Calendar,
variant: 'primary',
},
{
id: 'upcoming',
label: 'Anstehend (7 Tage)',
value: calendarStatisticsStore.upcomingEvents,
icon: CalendarCheck,
variant: 'info',
},
{
id: 'busyHours',
label: 'Stunden/Woche',
value: `${calendarStatisticsStore.busyHoursThisWeek}h`,
icon: Clock,
variant: 'neutral',
},
{
id: 'calendars',
label: 'Kalender',
value: calendarStatisticsStore.totalCalendars,
icon: Calendar,
variant: 'accent',
},
{
id: 'avgDuration',
label: 'Ø Dauer (Min)',
value: calendarStatisticsStore.averageEventDuration,
icon: Hourglass,
variant: 'info',
},
]);
onMount(async () => {
// Fetch events for the last 6 months + next month for statistics
const startDate = subDays(new Date(), 180);
const endDate = addDays(new Date(), 30);
await Promise.all([
eventsStore.fetchEvents(startDate, endDate),
calendarsStore.fetchCalendars(),
]);
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kalender</title>
</svelte:head>
<div class="statistics-page">
<header class="page-header">
<div class="header-icon">
<BarChart3 size={28} />
</div>
<div class="header-content">
<h1>Statistiken</h1>
<p class="header-subtitle">Dein Kalender im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={3} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={calendarStatisticsStore.activityHeatmap}
itemName="Event"
itemNamePlural="Events"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={calendarStatisticsStore.weeklyTrend}
itemName="Event"
itemNamePlural="Events"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={calendarStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Events"
centerValue={calendarStatisticsStore.totalEvents}
/>
</section>
</div>
<!-- Calendar Activity -->
<section class="chart-section calendars-section">
<ProgressBars
data={calendarStatisticsStore.calendarActivity}
title="Kalender-Aktivität"
emptyMessage="Keine Kalender mit Events"
/>
</section>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Ganztägige Events</span>
<span class="stat-value">
{calendarStatisticsStore.allDayRatio.allDay}
<span class="stat-percentage"
>({calendarStatisticsStore.allDayRatio.allDayPercentage}%)</span
>
</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Wiederkehrende Events</span>
<span class="stat-value">{calendarStatisticsStore.recurringEventsCount}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Events gesamt</span>
<span class="stat-value">{calendarStatisticsStore.totalEvents}</span>
</div>
</div>
{/if}
</div>
<style>
.statistics-page {
padding-bottom: 6rem;
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 2fr 1fr;
}
}
.chart-section {
min-width: 0;
}
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.stat-card-small {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
:global(.dark) .stat-card-small {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-card-small .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-card-small .stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.stat-percentage {
font-size: 0.875rem;
font-weight: 400;
color: hsl(var(--muted-foreground));
}
</style>

View file

@ -0,0 +1,486 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import type { CalendarEvent } from '@calendar/shared';
import AgendaItem from '$lib/components/agenda/AgendaItem.svelte';
import AgendaFilters from '$lib/components/agenda/AgendaFilters.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { AgendaSkeleton } from '$lib/components/skeletons';
import {
format,
parseISO,
isToday,
isTomorrow,
addDays,
startOfDay,
endOfDay,
isBefore,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { CheckSquare, AlertTriangle, Plus } from 'lucide-svelte';
// State
let loading = $state(true);
let showEvents = $state(true);
let showTodos = $state(true);
let timeRange = $state<'7' | '30' | 'all'>('30');
let selectedTask = $state<Task | null>(null);
let showQuickAdd = $state(false);
// Combined and grouped items
type AgendaGroup = {
date: Date;
items: Array<{ type: 'event' | 'todo'; event?: CalendarEvent; todo?: Task }>;
};
let groupedItems = $derived.by(() => {
const groups = new Map<string, AgendaGroup['items']>();
const today = startOfDay(new Date());
// Add events
if (showEvents) {
const currentEvents = eventsStore.events ?? [];
if (Array.isArray(currentEvents)) {
for (const event of currentEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'event', event });
}
}
}
// Add todos
if (showTodos) {
const currentTodos = todosStore.todos ?? [];
if (Array.isArray(currentTodos)) {
for (const todo of currentTodos) {
if (todo.isCompleted) continue; // Skip completed todos
let dateKey: string;
if (todo.dueDate) {
const dueDate =
typeof todo.dueDate === 'string' ? parseISO(todo.dueDate) : todo.dueDate;
// Group overdue todos under today
if (isBefore(startOfDay(dueDate), today)) {
dateKey = format(today, 'yyyy-MM-dd');
} else {
dateKey = format(dueDate, 'yyyy-MM-dd');
}
} else {
// Todos without due date go under today
dateKey = format(today, 'yyyy-MM-dd');
}
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'todo', todo });
}
}
}
// Sort groups by date and items within each group
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, items]) => ({
date: parseISO(dateKey),
items: items.sort((a, b) => {
// Todos before events
if (a.type !== b.type) return a.type === 'todo' ? -1 : 1;
// Sort events by time
if (a.type === 'event' && b.type === 'event' && a.event && b.event) {
const aStart =
typeof a.event.startTime === 'string'
? parseISO(a.event.startTime)
: a.event.startTime;
const bStart =
typeof b.event.startTime === 'string'
? parseISO(b.event.startTime)
: b.event.startTime;
return aStart.getTime() - bStart.getTime();
}
// Sort todos by priority
if (a.type === 'todo' && b.type === 'todo' && a.todo && b.todo) {
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
return priorityOrder[a.todo.priority] - priorityOrder[b.todo.priority];
}
return 0;
}),
}));
});
// Stats
const overdueCount = $derived(todosStore.overdueTodos.length);
const todayCount = $derived(todosStore.todaysTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch data based on time range
await fetchData();
loading = false;
});
async function fetchData() {
const start = startOfDay(new Date());
const days = timeRange === '7' ? 7 : timeRange === '30' ? 30 : 90;
const end = endOfDay(addDays(start, days));
await Promise.all([
eventsStore.fetchEvents(start, end),
todosStore.fetchTodos(start, end),
todosStore.fetchTodayTodos(),
]);
}
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(eventId: string) {
goto(`/?event=${eventId}`);
}
function handleTodoClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function toggleEvents() {
showEvents = !showEvents;
}
function toggleTodos() {
showTodos = !showTodos;
}
function handleRangeChange(range: '7' | '30' | 'all') {
timeRange = range;
loading = true;
fetchData().then(() => (loading = false));
}
</script>
<svelte:head>
<title>Aufgaben | Kalender</title>
</svelte:head>
<div class="tasks-page">
<header class="page-header">
<div class="header-content">
<div class="header-icon">
<CheckSquare size={24} />
</div>
<div>
<h1>Aufgaben</h1>
<p class="subtitle">Ihre Termine und Aufgaben auf einen Blick</p>
</div>
</div>
<!-- Stats -->
<div class="stats">
{#if overdueCount > 0}
<span class="stat overdue">
<AlertTriangle size={14} />
{overdueCount} überfällig
</span>
{/if}
<span class="stat">{todayCount} heute</span>
<span class="stat">{totalActiveCount} gesamt</span>
</div>
</header>
<!-- Filters -->
<AgendaFilters
{showEvents}
{showTodos}
{timeRange}
onToggleEvents={toggleEvents}
onToggleTodos={toggleTodos}
onRangeChange={handleRangeChange}
/>
<!-- Quick Add -->
<div class="quick-add-section">
{#if showQuickAdd}
<QuickAddTodo
placeholder="Neue Aufgabe hinzufügen..."
autofocus
showButton={false}
onsubmit={() => (showQuickAdd = false)}
oncancel={() => (showQuickAdd = false)}
/>
{:else}
<button type="button" class="quick-add-button" onclick={() => (showQuickAdd = true)}>
<Plus size={16} />
<span>Neue Aufgabe</span>
</button>
{/if}
</div>
<!-- Content -->
{#if loading}
<AgendaSkeleton />
{:else if !todosStore.serviceAvailable}
<div class="error-state card">
<AlertTriangle size={24} />
<p>Todo-Service ist nicht erreichbar</p>
<p class="hint">Bitte versuchen Sie es später erneut</p>
</div>
{:else if groupedItems.length === 0}
<div class="empty-state card">
<CheckSquare size={32} />
<p>Keine Einträge gefunden</p>
<p class="hint">
{#if !showEvents && !showTodos}
Aktivieren Sie mindestens einen Filter
{:else}
Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum
{/if}
</p>
</div>
{:else}
<div class="item-list">
{#each groupedItems as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
<span class="item-count">({group.items.length})</span>
</h2>
<div class="items">
{#each group.items as item}
{#if item.type === 'event' && item.event}
<AgendaItem
type="event"
event={item.event}
onclick={() => handleEventClick(item.event!.id)}
/>
{:else if item.type === 'todo' && item.todo}
<AgendaItem
type="todo"
todo={item.todo}
onclick={() => handleTodoClick(item.todo!)}
/>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.tasks-page {
max-width: 700px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.subtitle {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0.25rem 0 0;
}
.stats {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0.5rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-sm);
}
.stat.overdue {
color: hsl(var(--color-danger));
background: hsl(var(--color-danger) / 0.1);
}
.quick-add-section {
margin-bottom: 0.5rem;
}
.quick-add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
border-radius: var(--radius-lg);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.quick-add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.item-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.date-header.today {
color: hsl(var(--color-primary));
}
.item-count {
font-weight: 400;
font-size: 0.75rem;
}
.items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 1.5rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.empty-state :global(svg),
.error-state :global(svg) {
opacity: 0.5;
}
.error-state {
color: hsl(var(--color-danger));
}
.hint {
font-size: 0.8125rem;
opacity: 0.7;
margin: 0;
}
.card {
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
}
@media (max-width: 640px) {
.tasks-page {
padding: 1rem;
}
.page-header {
flex-direction: column;
align-items: stretch;
}
.stats {
justify-content: flex-start;
}
}
</style>

View file

@ -12,6 +12,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -78,8 +79,8 @@
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// Navigation items for Chat (settings moved to user dropdown)
const navItems: PillNavItem[] = [
// Base navigation items for Chat (settings moved to user dropdown)
const baseNavItems: PillNavItem[] = [
{ href: '/chat', label: 'Chat', icon: 'home' },
{ href: '/templates', label: 'Templates', icon: 'document' },
{ href: '/spaces', label: 'Spaces', icon: 'building' },
@ -88,14 +89,19 @@
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('chat', baseNavItems, userSettings.nav.hiddenNavItems)
);
// User email for user dropdown
let userEmail = $derived(authStore.user?.email);
// Check if current page is a chat page (needs full-width layout)
let isChatPage = $derived($page.url.pathname.startsWith('/chat'));
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
// Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -19,6 +19,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -161,8 +162,8 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Clock
const navItems: PillNavItem[] = [
// Base navigation items for Clock
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Übersicht', icon: 'home' },
{ href: '/alarms', label: 'Wecker', icon: 'bell' },
{ href: '/timers', label: 'Timer', icon: 'timer' },
@ -174,8 +175,13 @@
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-9)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('clock', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-9) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -0,0 +1,275 @@
/**
* Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes
*/
import type { Contact } from '$lib/api/contacts';
import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns';
import { de } from 'date-fns/locale';
import type {
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from '@manacore/shared-ui';
// Types
export interface ContactTag {
id: string;
name: string;
color: string;
}
// State
let contacts = $state<Contact[]>([]);
let tags = $state<ContactTag[]>([]);
export const contactsStatisticsStore = {
// Setters
setContacts(newContacts: Contact[]) {
contacts = newContacts;
},
setTags(newTags: ContactTag[]) {
tags = newTags;
},
// Quick Stats
get totalContacts() {
return contacts.length;
},
get favoriteContacts() {
return contacts.filter((c) => c.isFavorite).length;
},
get archivedContacts() {
return contacts.filter((c) => c.isArchived).length;
},
get activeContacts() {
return contacts.filter((c) => !c.isArchived).length;
},
get recentlyAdded() {
const weekAgo = subDays(new Date(), 7);
return contacts.filter((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
return createdAt >= weekAgo;
}).length;
},
get birthdaysThisMonth() {
const currentMonth = getMonth(new Date());
return contacts.filter((c) => {
if (!c.birthday) return false;
const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday);
return getMonth(birthday) === currentMonth;
}).length;
},
get contactsWithEmail() {
return contacts.filter((c) => c.email).length;
},
get contactsWithPhone() {
return contacts.filter((c) => c.phone || c.mobile).length;
},
// Completeness rate (contacts with email AND phone)
get completenessRate() {
if (contacts.length === 0) return 0;
const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length;
return Math.round((complete / contacts.length) * 100);
},
// Activity Heatmap (last 6 months) - based on contact creation
get activityHeatmap(): HeatmapDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 180);
// Count contacts created per day
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
}
});
// Generate all days
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: creationMap.get(dateKey) || 0,
dayOfWeek: day.getDay(),
};
});
},
// Weekly Trend (last 4 weeks)
get weeklyTrend(): TrendDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 27);
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
}
});
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: creationMap.get(dateKey) || 0,
label: format(day, 'EEE', { locale: de }),
};
});
},
// Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived
get statusBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length;
const archived = contacts.filter((c) => c.isArchived).length;
const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length;
return [
{
id: 'favorites',
label: 'Favoriten',
count: favorites,
percentage: Math.round((favorites / total) * 100),
color: '#F59E0B', // amber
},
{
id: 'regular',
label: 'Aktiv',
count: regular,
percentage: Math.round((regular / total) * 100),
color: '#10B981', // green
},
{
id: 'archived',
label: 'Archiviert',
count: archived,
percentage: Math.round((archived / total) * 100),
color: '#6B7280', // gray
},
];
},
// Tags Progress (Progress Bars)
get tagProgress(): ProgressItem[] {
// Count contacts per tag
const tagCountMap = new Map<string, number>();
// This requires contacts to have a tags array - we'll estimate from the tag data
// For now, we'll show tags with placeholder counts
// In a real implementation, we'd need contactTags relation data
const result: ProgressItem[] = tags.map((tag) => ({
id: tag.id,
name: tag.name,
color: tag.color || '#6B7280',
total: contacts.length, // Total contacts as reference
completed: 0, // Would need contact-tag relation to calculate
percentage: 0,
}));
return result.sort((a, b) => b.completed - a.completed);
},
// Info completeness breakdown
get infoBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const withEmail = contacts.filter((c) => c.email).length;
const withPhone = contacts.filter((c) => c.phone || c.mobile).length;
const withCompany = contacts.filter((c) => c.company).length;
const withBirthday = contacts.filter((c) => c.birthday).length;
return [
{
id: 'email',
label: 'Mit E-Mail',
count: withEmail,
percentage: Math.round((withEmail / total) * 100),
color: '#3B82F6', // blue
},
{
id: 'phone',
label: 'Mit Telefon',
count: withPhone,
percentage: Math.round((withPhone / total) * 100),
color: '#10B981', // green
},
{
id: 'company',
label: 'Mit Firma',
count: withCompany,
percentage: Math.round((withCompany / total) * 100),
color: '#8B5CF6', // violet
},
{
id: 'birthday',
label: 'Mit Geburtstag',
count: withBirthday,
percentage: Math.round((withBirthday / total) * 100),
color: '#EC4899', // pink
},
];
},
// Country breakdown
get countryBreakdown(): ProgressItem[] {
const countryMap = new Map<string, number>();
contacts.forEach((c) => {
const country = c.country || 'Unbekannt';
countryMap.set(country, (countryMap.get(country) || 0) + 1);
});
const result: ProgressItem[] = [];
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280'];
let colorIndex = 0;
countryMap.forEach((count, country) => {
if (country !== 'Unbekannt' || count > 0) {
result.push({
id: country,
name: country,
color: colors[colorIndex % colors.length],
total: contacts.length,
completed: count,
percentage: Math.round((count / contacts.length) * 100),
});
colorIndex++;
}
});
return result.sort((a, b) => b.completed - a.completed).slice(0, 8);
},
// Total tags count
get totalTags() {
return tags.length;
},
};

View file

@ -0,0 +1,227 @@
/**
* Contact Parser for Contacts App
*
* Extends the base parser with contact-specific patterns:
* - Company: @CompanyName or bei CompanyName
* - Email: Recognizes email addresses
* - Phone: Recognizes phone numbers
* - Name: First and last name extraction
*/
import { extractTags, extractAtReference } from '@manacore/shared-utils';
export interface ParsedContact {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagNames: string[];
}
interface Tag {
id: string;
name: string;
}
export interface ParsedContactWithIds {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagIds: string[];
}
// Email pattern
const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/;
// Phone patterns (various formats)
const PHONE_PATTERNS: RegExp[] = [
// International format: +49 123 456789, +49-123-456789
/\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/,
// German format: 0123 456789, 0123/456789
/\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/,
// Simple format: 123456789 (at least 6 digits)
/\b\d{6,}\b/,
];
// Company patterns (alternative to @company)
const COMPANY_PATTERNS: RegExp[] = [
/\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
/\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
];
/**
* Extract email from text
*/
function extractEmail(text: string): { email?: string; remaining: string } {
const match = text.match(EMAIL_PATTERN);
if (match) {
return {
email: match[1],
remaining: text.replace(EMAIL_PATTERN, '').trim(),
};
}
return { email: undefined, remaining: text };
}
/**
* Extract phone number from text
*/
function extractPhone(text: string): { phone?: string; remaining: string } {
for (const pattern of PHONE_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
phone: match[0].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { phone: undefined, remaining: text };
}
/**
* Extract company from text (bei/von patterns)
*/
function extractCompanyPattern(text: string): { company?: string; remaining: string } {
for (const pattern of COMPANY_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
company: match[1].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { company: undefined, remaining: text };
}
/**
* Extract first and last name from display name
*/
function parseNames(displayName: string): { firstName?: string; lastName?: string } {
const parts = displayName.trim().split(/\s+/);
if (parts.length === 0) {
return {};
}
if (parts.length === 1) {
return { firstName: parts[0] };
}
// First part is first name, rest is last name
return {
firstName: parts[0],
lastName: parts.slice(1).join(' '),
};
}
/**
* Parse natural language contact input
*
* Examples:
* - "Max Mustermann @ACME Corp max@example.com #kunde #wichtig"
* - "Anna Schmidt bei Google +49 123 456789"
* - "Peter Müller peter@mail.de #privat"
*/
export function parseContactInput(input: string): ParsedContact {
let text = input.trim();
// Extract tags first (#tag1 #tag2)
const tagsResult = extractTags(text);
text = tagsResult.remaining;
const tagNames = tagsResult.value || [];
// Extract company via @CompanyName
const atRefResult = extractAtReference(text);
text = atRefResult.remaining;
let company = atRefResult.value;
// If no @company, try bei/von patterns
if (!company) {
const companyPatternResult = extractCompanyPattern(text);
text = companyPatternResult.remaining;
company = companyPatternResult.company;
}
// Extract email
const emailResult = extractEmail(text);
text = emailResult.remaining;
const email = emailResult.email;
// Extract phone
const phoneResult = extractPhone(text);
text = phoneResult.remaining;
const phone = phoneResult.phone;
// Clean up multiple spaces and get display name
const displayName = text.replace(/\s+/g, ' ').trim();
// Parse first and last name
const { firstName, lastName } = parseNames(displayName);
return {
displayName,
firstName,
lastName,
company,
email,
phone,
tagNames,
};
}
/**
* Resolve tag names to IDs
*/
export function resolveContactIds(parsed: ParsedContact, tags: Tag[]): ParsedContactWithIds {
const tagIds: string[] = [];
// Find tags by name (case-insensitive)
for (const tagName of parsed.tagNames) {
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) {
tagIds.push(tag.id);
}
}
return {
displayName: parsed.displayName,
firstName: parsed.firstName,
lastName: parsed.lastName,
company: parsed.company,
email: parsed.email,
phone: parsed.phone,
tagIds,
};
}
/**
* Format parsed contact for preview display
*/
export function formatParsedContactPreview(parsed: ParsedContact): string {
const parts: string[] = [];
if (parsed.company) {
parts.push(`🏢 ${parsed.company}`);
}
if (parsed.email) {
parts.push(`📧 ${parsed.email}`);
}
if (parsed.phone) {
parts.push(`📞 ${parsed.phone}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -9,6 +9,7 @@
PillDropdownItem,
CommandBarItem,
QuickAction,
CreatePreview,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
@ -19,6 +20,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -28,13 +30,21 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsApi } from '$lib/api/contacts';
import { contactsApi, tagsApi } from '$lib/api/contacts';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsSettings } from '$lib/stores/settings.svelte';
import {
parseContactInput,
resolveContactIds,
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
// Search modal state
let searchModalOpen = $state(false);
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
// Check if we're on a contact detail route
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
const showContactModal = $derived(!!contactDetailMatch);
@ -97,19 +107,25 @@
// User email for user dropdown (fallback to 'Menü' when not logged in)
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Contacts
const navItems: PillNavItem[] = [
// Base navigation items for Contacts
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
];
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('contacts', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
@ -193,6 +209,47 @@
goto(`/contacts/${item.id}`);
}
// CommandBar Quick-Create handlers
function handleCommandBarParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseContactInput(query);
if (!parsed.displayName) return null;
return {
title: parsed.displayName,
subtitle: formatParsedContactPreview(parsed),
};
}
async function handleCommandBarCreate(query: string): Promise<void> {
const parsed = parseContactInput(query);
if (!parsed.displayName) return;
// Resolve tag names to IDs
const resolved = resolveContactIds(parsed, availableTags);
try {
const contact = await contactsStore.createContact({
displayName: resolved.displayName,
firstName: resolved.firstName,
lastName: resolved.lastName,
company: resolved.company,
email: resolved.email,
phone: resolved.phone,
});
// Add tags to the created contact
if (resolved.tagIds.length > 0 && contact) {
for (const tagId of resolved.tagIds) {
await tagsApi.addToContact(tagId, contact.id);
}
}
} catch (e) {
console.error('Failed to create contact:', e);
}
}
// CommandBar quick actions
const commandBarQuickActions: QuickAction[] = [
{
@ -214,9 +271,17 @@
return;
}
// Load user settings
// Load user settings and tags
await userSettings.load();
// Load tags for Quick-Create
try {
const tagsResult = await tagsApi.list();
availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name }));
} catch (e) {
console.error('Failed to load tags:', e);
}
// Initialize contacts settings and view mode
contactsSettings.initialize();
viewModeStore.initialize();
@ -302,9 +367,13 @@
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Kontakt suchen..."
placeholder="Kontakt suchen oder erstellen..."
emptyText="Keine Kontakte gefunden"
searchingText="Suche..."
onCreate={handleCommandBarCreate}
onParseCreate={handleCommandBarParseCreate}
createText="Als Kontakt erstellen"
createShortcut="⌘↵"
/>
</div>

View file

@ -0,0 +1,280 @@
<script lang="ts">
import { onMount } from 'svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsStatisticsStore } from '$lib/stores/statistics.svelte';
import { tagsApi } from '$lib/api/tags';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CheckCircle } from 'lucide-svelte';
let loading = $state(true);
// Update statistics when contacts change
$effect(() => {
contactsStatisticsStore.setContacts(contactsStore.contacts);
});
// Build stats items for StatsGrid
let statsItems = $derived<StatItem[]>([
{
id: 'total',
label: 'Gesamt',
value: contactsStatisticsStore.totalContacts,
icon: Users,
variant: 'primary',
},
{
id: 'favorites',
label: 'Favoriten',
value: contactsStatisticsStore.favoriteContacts,
icon: Star,
variant: 'accent',
},
{
id: 'recentlyAdded',
label: 'Neu (7 Tage)',
value: contactsStatisticsStore.recentlyAdded,
icon: UserPlus,
variant: 'success',
},
{
id: 'birthdays',
label: 'Geburtstage',
value: contactsStatisticsStore.birthdaysThisMonth,
icon: Cake,
variant: 'info',
},
{
id: 'withEmail',
label: 'Mit E-Mail',
value: contactsStatisticsStore.contactsWithEmail,
icon: Mail,
variant: 'neutral',
},
{
id: 'completeness',
label: 'Vollständigkeit',
value: `${contactsStatisticsStore.completenessRate}%`,
icon: CheckCircle,
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
},
]);
onMount(async () => {
// Fetch all contacts (without filters for statistics)
await contactsStore.loadContacts({ isArchived: false });
// Also load archived for complete statistics
const allContacts = [...contactsStore.contacts];
// Fetch tags
try {
const tagsResult = await tagsApi.list();
contactsStatisticsStore.setTags(tagsResult);
} catch (e) {
console.error('Failed to load tags:', e);
}
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kontakte</title>
</svelte:head>
<div class="statistics-page">
<header class="page-header">
<div class="header-icon">
<BarChart3 size={28} />
</div>
<div class="header-content">
<h1>Statistiken</h1>
<p class="header-subtitle">Deine Kontakte im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={4} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={contactsStatisticsStore.activityHeatmap}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={contactsStatisticsStore.weeklyTrend}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={contactsStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
</div>
<!-- Info Completeness -->
<div class="charts-row">
<section class="chart-section info-section">
<DonutChart
data={contactsStatisticsStore.infoBreakdown}
title="Informationen"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
<section class="chart-section country-section">
<ProgressBars
data={contactsStatisticsStore.countryBreakdown}
title="Nach Land"
emptyMessage="Keine Länder angegeben"
/>
</section>
</div>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Aktive Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.activeContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Archivierte Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.archivedContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Tags</span>
<span class="stat-value">{contactsStatisticsStore.totalTags}</span>
</div>
</div>
{/if}
</div>
<style>
.statistics-page {
padding-bottom: 6rem;
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 1fr 1fr;
}
}
.chart-section {
min-width: 0;
}
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.stat-card-small {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
:global(.dark) .stat-card-small {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-card-small .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-card-small .stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
</style>

View file

@ -18,6 +18,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
@ -33,13 +34,18 @@
// Get theme state
let isDark = $derived(theme.isDark);
// Navigation items for ManaDeck (Mana and Profile are in user dropdown)
const navItems: PillNavItem[] = [
// Base navigation items for ManaDeck (Mana and Profile are in user dropdown)
const baseNavItems: PillNavItem[] = [
{ href: '/decks', label: 'Decks', icon: 'archive' },
{ href: '/explore', label: 'Explore', icon: 'search' },
{ href: '/progress', label: 'Progress', icon: 'chart' },
];
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('manadeck', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Get pinned themes from user settings (extended themes only)
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>

View file

@ -12,6 +12,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
@ -93,8 +94,8 @@
}
});
// Navigation items (Mana is in user dropdown via manaHref)
const navItems: PillNavItem[] = [
// Base navigation items (Mana is in user dropdown via manaHref)
const baseNavItems: PillNavItem[] = [
{ href: '/app/gallery', label: 'Galerie', icon: 'home' },
{ href: '/app/board', label: 'Moodboards', icon: 'grid' },
{ href: '/app/explore', label: 'Entdecken', icon: 'search' },
@ -104,6 +105,11 @@
{ href: '/app/archive', label: 'Archiv', icon: 'archive' },
];
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('picture', baseNavItems, userSettings.nav.hiddenNavItems)
);
// View mode options for tab group
const viewModeOptions = [
{ id: 'single', icon: 'list', title: 'Liste (1)' },

View file

@ -0,0 +1,16 @@
/** @type {import('jest').Config} */
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@todo/shared$': '<rootDir>/../../packages/shared/src',
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
},
};

View file

@ -9,33 +9,41 @@
"start": "nest start",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@todo/shared": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.9",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.9",
"@nestjs/platform-express": "^10.4.9",
"@nestjs/schedule": "^4.1.2",
"@todo/shared": "workspace:*",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rrule": "^2.8.1",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^11.1.9",
"@types/express": "^5.0.1",
"@types/jest": "^30.0.0",
"@types/node": "^22.15.21",
"drizzle-kit": "^0.30.2",
"jest": "^30.2.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.4",
"typescript": "^5.9.3"
}

View file

@ -1,8 +1,10 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { IsString, IsOptional, MaxLength, MinLength, IsNotEmpty } from 'class-validator';
export class CreateLabelDto {
@IsString()
@MaxLength(100)
@IsNotEmpty({ message: 'Name darf nicht leer sein' })
@MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' })
@MaxLength(100, { message: 'Name darf maximal 100 Zeichen haben' })
name: string;
@IsOptional()

View file

@ -1,5 +1,5 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { tasks, labels, taskLabels, projects } from '../db/schema';
@ -54,21 +54,32 @@ export class NetworkService {
const projectMap = new Map(userProjects.map((p) => [p.id, p.name]));
// 3. Get labels for each task
// 3. Get all labels for all tasks in a single batch query (fix N+1)
const taskIds = userTasks.map(({ task }) => task.id);
const taskLabelsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
for (const { task } of userTasks) {
const taskLabelRows = await this.db
if (taskIds.length > 0) {
const allTaskLabels = await this.db
.select({
id: labels.id,
name: labels.name,
color: labels.color,
taskId: taskLabels.taskId,
labelId: labels.id,
labelName: labels.name,
labelColor: labels.color,
})
.from(taskLabels)
.innerJoin(labels, eq(taskLabels.labelId, labels.id))
.where(eq(taskLabels.taskId, task.id));
.where(inArray(taskLabels.taskId, taskIds));
taskLabelsMap.set(task.id, taskLabelRows);
// Group labels by taskId
for (const row of allTaskLabels) {
const existing = taskLabelsMap.get(row.taskId) || [];
existing.push({
id: row.labelId,
name: row.labelName,
color: row.labelColor,
});
taskLabelsMap.set(row.taskId, existing);
}
}
// 4. Filter tasks that have at least one label

View file

@ -1,9 +1,19 @@
import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator';
import {
IsString,
IsOptional,
IsBoolean,
MaxLength,
MinLength,
IsObject,
IsNotEmpty,
} from 'class-validator';
import type { ProjectSettings } from '../../db/schema/projects.schema';
export class CreateProjectDto {
@IsString()
@MaxLength(255)
@IsNotEmpty({ message: 'Name darf nicht leer sein' })
@MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' })
@MaxLength(255, { message: 'Name darf maximal 255 Zeichen haben' })
name: string;
@IsOptional()

View file

@ -0,0 +1,515 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { TaskService } from '../task.service';
import { ProjectService } from '../../project/project.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
// Mock database
const mockSelectFrom = jest.fn().mockReturnThis();
const mockSelectWhere = jest.fn();
const mockDb = {
query: {
tasks: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
taskLabels: {
findMany: jest.fn(),
},
labels: {
findMany: jest.fn(),
},
},
select: jest.fn().mockReturnValue({
from: mockSelectFrom,
where: mockSelectWhere,
}),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn(),
};
// Mock ProjectService
const mockProjectService = {
findByIdOrThrow: jest.fn(),
};
describe('TaskService', () => {
let service: TaskService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TaskService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
{
provide: ProjectService,
useValue: mockProjectService,
},
],
}).compile();
service = module.get<TaskService>(TaskService);
// Reset all mocks before each test
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should return all tasks for a user', async () => {
const userId = 'user-123';
const mockTasks = [
{ id: 'task-1', title: 'Task 1', userId },
{ id: 'task-2', title: 'Task 2', userId },
];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.findAll(userId);
expect(result).toHaveLength(2);
expect(result[0].labels).toEqual([]);
expect(result[1].labels).toEqual([]);
});
it('should filter by projectId when provided', async () => {
const userId = 'user-123';
const projectId = 'project-1';
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
await service.findAll(userId, { projectId });
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
});
it('should filter by priority when provided', async () => {
const userId = 'user-123';
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
await service.findAll(userId, { priority: 'high' });
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
});
});
describe('findById', () => {
it('should return a task when found', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const mockTask = { id: taskId, title: 'Test Task', userId };
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.findById(taskId, userId);
expect(result).toBeDefined();
expect(result?.id).toBe(taskId);
expect(result?.labels).toEqual([]);
});
it('should return null when task not found', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
const result = await service.findById('non-existent', 'user-123');
expect(result).toBeNull();
});
});
describe('findByIdOrThrow', () => {
it('should return a task when found', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const mockTask = { id: taskId, title: 'Test Task', userId };
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.findByIdOrThrow(taskId, userId);
expect(result.id).toBe(taskId);
});
it('should throw NotFoundException when task not found', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('should create a task with basic fields', async () => {
const userId = 'user-123';
const dto = { title: 'New Task' };
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 0 };
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([createdTask]);
const result = await service.create(userId, dto);
expect(result.title).toBe('New Task');
expect(mockDb.insert).toHaveBeenCalled();
});
it('should verify project belongs to user when projectId is provided', async () => {
const userId = 'user-123';
const projectId = 'project-1';
const dto = { title: 'New Task', projectId };
const createdTask = { id: 'task-new', title: 'New Task', userId, projectId, order: 0 };
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: projectId, userId });
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([createdTask]);
await service.create(userId, dto);
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(projectId, userId);
});
it('should calculate order based on existing tasks', async () => {
const userId = 'user-123';
const dto = { title: 'New Task' };
const existingTasks = [
{ id: 'task-1', order: 0 },
{ id: 'task-2', order: 1 },
{ id: 'task-3', order: 2 },
];
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 3 };
mockDb.query.tasks.findMany.mockResolvedValue(existingTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([createdTask]);
const result = await service.create(userId, dto);
expect(result.order).toBe(3);
});
});
describe('update', () => {
it('should update a task', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const dto = { title: 'Updated Title' };
const existingTask = { id: taskId, title: 'Original', userId };
const updatedTask = { id: taskId, title: 'Updated Title', userId };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([updatedTask]);
const result = await service.update(taskId, userId, dto);
expect(result.title).toBe('Updated Title');
});
it('should throw when task does not exist', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
await expect(service.update('non-existent', 'user-123', { title: 'Test' })).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete a task', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, userId };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
await service.delete(taskId, userId);
expect(mockDb.delete).toHaveBeenCalled();
});
it('should throw when task does not exist', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException);
});
});
describe('complete', () => {
it('should mark a task as completed', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, title: 'Test', userId, recurrenceRule: null };
const completedTask = { ...existingTask, isCompleted: true, status: 'completed' };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([completedTask]);
const result = await service.complete(taskId, userId);
expect(result.isCompleted).toBe(true);
expect(result.status).toBe('completed');
});
it('should create next occurrence for recurring task', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const existingTask = {
id: taskId,
title: 'Daily Task',
userId,
recurrenceRule: 'FREQ=DAILY',
dueDate: new Date(),
labels: [],
};
const completedTask = {
...existingTask,
isCompleted: true,
status: 'completed',
completedAt: new Date(),
lastOccurrence: new Date(),
};
const newTask = {
id: 'task-new',
title: 'Daily Task',
userId,
recurrenceRule: 'FREQ=DAILY',
dueDate: tomorrow,
isCompleted: false,
status: 'pending',
};
// First call for findByIdOrThrow
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
// For completing the task
mockDb.returning
.mockResolvedValueOnce([newTask]) // For creating new occurrence
.mockResolvedValueOnce([completedTask]); // For completing original
const result = await service.complete(taskId, userId);
expect(result.isCompleted).toBe(true);
// Verify that a new task was created
expect(mockDb.insert).toHaveBeenCalled();
});
});
describe('uncomplete', () => {
it('should mark a task as not completed', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, title: 'Test', userId, isCompleted: true };
const uncompletedTask = { ...existingTask, isCompleted: false, status: 'pending' };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([uncompletedTask]);
const result = await service.uncomplete(taskId, userId);
expect(result.isCompleted).toBe(false);
expect(result.status).toBe('pending');
});
});
describe('move', () => {
it('should move a task to a different project', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const newProjectId = 'project-2';
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
const movedTask = { ...existingTask, projectId: newProjectId };
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: newProjectId, userId });
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([movedTask]);
const result = await service.move(taskId, userId, newProjectId);
expect(result.projectId).toBe(newProjectId);
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(newProjectId, userId);
});
it('should move a task to inbox (null project)', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
const movedTask = { ...existingTask, projectId: null };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([movedTask]);
const result = await service.move(taskId, userId, null);
expect(result.projectId).toBeNull();
expect(mockProjectService.findByIdOrThrow).not.toHaveBeenCalled();
});
});
describe('getInboxTasks', () => {
it('should return incomplete tasks', async () => {
const userId = 'user-123';
const mockTasks = [
{ id: 'task-1', title: 'Task 1', userId, isCompleted: false },
{ id: 'task-2', title: 'Task 2', userId, isCompleted: false },
];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.getInboxTasks(userId);
expect(result).toHaveLength(2);
expect(result.every((t) => t.isCompleted === false)).toBe(true);
});
});
describe('getTodayTasks', () => {
it('should return tasks due today', async () => {
const userId = 'user-123';
const today = new Date();
const mockTasks = [{ id: 'task-1', title: 'Today Task', userId, dueDate: today }];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.getTodayTasks(userId);
expect(result).toHaveLength(1);
});
});
describe('getCompletedTasks', () => {
it('should return completed tasks with pagination info', async () => {
const userId = 'user-123';
const mockTasks = Array(50)
.fill(null)
.map((_, i) => ({
id: `task-${i}`,
title: `Task ${i}`,
userId,
isCompleted: true,
}));
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockSelectWhere.mockResolvedValue([{ count: 75 }]);
const result = await service.getCompletedTasks(userId);
expect(result.tasks).toHaveLength(50);
expect(result.total).toBe(75);
expect(result.hasMore).toBe(true);
});
it('should respect custom limit and offset', async () => {
const userId = 'user-123';
const mockTasks = Array(10)
.fill(null)
.map((_, i) => ({
id: `task-${i}`,
title: `Task ${i}`,
userId,
isCompleted: true,
}));
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockSelectWhere.mockResolvedValue([{ count: 25 }]);
const result = await service.getCompletedTasks(userId, 10, 10);
expect(result.tasks).toHaveLength(10);
expect(result.total).toBe(25);
expect(result.hasMore).toBe(true); // offset 10 + limit 10 = 20 < 25
});
it('should enforce max limit of 100', async () => {
const userId = 'user-123';
const mockTasks = Array(100)
.fill(null)
.map((_, i) => ({
id: `task-${i}`,
title: `Task ${i}`,
userId,
isCompleted: true,
}));
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockSelectWhere.mockResolvedValue([{ count: 200 }]);
// Request 500 tasks, should be capped at 100
const result = await service.getCompletedTasks(userId, 500, 0);
expect(result.tasks).toHaveLength(100);
expect(result.hasMore).toBe(true);
});
});
describe('loadTaskLabelsBatch', () => {
it('should batch load labels for multiple tasks', async () => {
const userId = 'user-123';
const mockTasks = [
{ id: 'task-1', title: 'Task 1', userId },
{ id: 'task-2', title: 'Task 2', userId },
];
const mockTaskLabels = [
{ taskId: 'task-1', labelId: 'label-1' },
{ taskId: 'task-1', labelId: 'label-2' },
{ taskId: 'task-2', labelId: 'label-1' },
];
const mockLabels = [
{ id: 'label-1', name: 'Important', color: '#ff0000' },
{ id: 'label-2', name: 'Work', color: '#0000ff' },
];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue(mockTaskLabels);
mockDb.query.labels.findMany.mockResolvedValue(mockLabels);
const result = await service.findAll(userId);
expect(result[0].labels).toHaveLength(2);
expect(result[1].labels).toHaveLength(1);
// Should only make 2 queries for labels (taskLabels + labels), not N+1
expect(mockDb.query.taskLabels.findMany).toHaveBeenCalledTimes(1);
expect(mockDb.query.labels.findMany).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -4,15 +4,22 @@ import {
IsUUID,
IsEnum,
IsArray,
IsObject,
MaxLength,
MinLength,
IsDateString,
IsNotEmpty,
ValidateNested,
} from 'class-validator';
import type { TaskPriority, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
import { Type } from 'class-transformer';
import type { TaskPriority } from '../../db/schema/tasks.schema';
import { CreateSubtaskDto } from './subtask.dto';
import { TaskMetadataDto } from './metadata.dto';
export class CreateTaskDto {
@IsString()
@MaxLength(500)
@IsNotEmpty({ message: 'Titel darf nicht leer sein' })
@MinLength(1, { message: 'Titel muss mindestens 1 Zeichen haben' })
@MaxLength(500, { message: 'Titel darf maximal 500 Zeichen haben' })
title: string;
@IsOptional()
@ -54,7 +61,9 @@ export class CreateTaskDto {
@IsOptional()
@IsArray()
subtasks?: Omit<Subtask, 'id'>[];
@ValidateNested({ each: true })
@Type(() => CreateSubtaskDto)
subtasks?: CreateSubtaskDto[];
@IsOptional()
@IsArray()
@ -62,6 +71,7 @@ export class CreateTaskDto {
labelIds?: string[];
@IsOptional()
@IsObject()
metadata?: TaskMetadata;
@ValidateNested()
@Type(() => TaskMetadataDto)
metadata?: TaskMetadataDto;
}

View file

@ -0,0 +1,58 @@
import {
IsString,
IsOptional,
IsNumber,
IsArray,
IsUUID,
IsEnum,
Min,
Max,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class EffectiveDurationDto {
@IsNumber()
@Min(1, { message: 'Dauer muss mindestens 1 sein' })
@Max(9999, { message: 'Dauer darf maximal 9999 sein' })
value: number;
@IsEnum(['minutes', 'hours', 'days'], { message: 'Ungültige Zeiteinheit' })
unit: 'minutes' | 'hours' | 'days';
}
export class TaskMetadataDto {
@IsOptional()
@IsString()
@MaxLength(10000, { message: 'Notizen dürfen maximal 10000 Zeichen haben' })
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(500, { each: true })
attachments?: string[];
@IsOptional()
@IsUUID()
linkedCalendarEventId?: string | null;
@IsOptional()
@IsNumber()
@IsEnum([1, 2, 3, 5, 8, 13, 21], {
message: 'Storypoints müssen Fibonacci-Zahlen sein (1,2,3,5,8,13,21)',
})
storyPoints?: number | null;
@IsOptional()
@ValidateNested()
@Type(() => EffectiveDurationDto)
effectiveDuration?: EffectiveDurationDto | null;
@IsOptional()
@IsNumber()
@Min(1, { message: 'Spaß-Faktor muss mindestens 1 sein' })
@Max(10, { message: 'Spaß-Faktor darf maximal 10 sein' })
funRating?: number | null;
}

View file

@ -0,0 +1,48 @@
import {
IsString,
IsBoolean,
IsOptional,
IsNumber,
MinLength,
MaxLength,
Min,
IsDateString,
} from 'class-validator';
export class SubtaskDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
@MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' })
@MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' })
title: string;
@IsBoolean()
isCompleted: boolean;
@IsOptional()
@IsDateString()
completedAt?: string | null;
@IsNumber()
@Min(0)
order: number;
}
export class CreateSubtaskDto {
@IsString()
@MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' })
@MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' })
title: string;
@IsOptional()
@IsBoolean()
isCompleted?: boolean;
@IsOptional()
@IsNumber()
@Min(0)
order?: number;
}

View file

@ -33,9 +33,13 @@ export class TaskController {
}
@Get('completed')
async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50);
return { tasks };
async getCompleted(
@CurrentUser() user: CurrentUserData,
@Query('limit') limit?: number,
@Query('offset') offset?: number
) {
const result = await this.taskService.getCompletedTasks(user.userId, limit ?? 50, offset ?? 0);
return result;
}
@Get(':id')

View file

@ -1,11 +1,15 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm';
import { RRule, RRuleSet, rrulestr } from 'rrule';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema';
import { ProjectService } from '../project/project.service';
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
// Extended Task type that includes labels (populated after loading from DB)
type TaskWithLabels = Task & { labels: (typeof labels.$inferSelect)[] };
@Injectable()
export class TaskService {
constructor(
@ -13,7 +17,7 @@ export class TaskService {
private projectService: ProjectService
) {}
async findAll(userId: string, query: QueryTasksDto = {}): Promise<Task[]> {
async findAll(userId: string, query: QueryTasksDto = {}): Promise<TaskWithLabels[]> {
const conditions: SQL[] = [eq(tasks.userId, userId)];
if (query.projectId) {
@ -73,11 +77,11 @@ export class TaskService {
offset: query.offset,
});
// Load labels for each task
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
// Batch load labels for all tasks (2 queries instead of N+1)
return this.loadTaskLabelsBatch(result);
}
async findById(id: string, userId: string): Promise<Task | null> {
async findById(id: string, userId: string): Promise<TaskWithLabels | null> {
const result = await this.db.query.tasks.findFirst({
where: and(eq(tasks.id, id), eq(tasks.userId, userId)),
});
@ -86,7 +90,7 @@ export class TaskService {
return this.loadTaskLabels(result);
}
async findByIdOrThrow(id: string, userId: string): Promise<Task> {
async findByIdOrThrow(id: string, userId: string): Promise<TaskWithLabels> {
const task = await this.findById(id, userId);
if (!task) {
throw new NotFoundException(`Task with id ${id} not found`);
@ -94,7 +98,7 @@ export class TaskService {
return task;
}
async create(userId: string, dto: CreateTaskDto): Promise<Task> {
async create(userId: string, dto: CreateTaskDto): Promise<TaskWithLabels> {
// Verify project belongs to user if provided
if (dto.projectId) {
await this.projectService.findByIdOrThrow(dto.projectId, userId);
@ -139,7 +143,7 @@ export class TaskService {
return this.loadTaskLabels(created);
}
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<Task> {
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<TaskWithLabels> {
await this.findByIdOrThrow(id, userId);
// Verify project belongs to user if changing project
@ -185,13 +189,28 @@ export class TaskService {
await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
}
async complete(id: string, userId: string): Promise<Task> {
async complete(id: string, userId: string): Promise<TaskWithLabels> {
const task = await this.findByIdOrThrow(id, userId);
// If task has recurrence, create next occurrence instead of completing
if (task.recurrenceRule) {
// TODO: Implement recurrence handling
// For now, just mark as complete
const nextOccurrence = await this.createNextOccurrence(task, userId);
if (nextOccurrence) {
// Mark current task as completed and update lastOccurrence
const [completed] = await this.db
.update(tasks)
.set({
isCompleted: true,
status: 'completed',
completedAt: new Date(),
lastOccurrence: new Date(),
updatedAt: new Date(),
})
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.returning();
return this.loadTaskLabels(completed);
}
}
return this.update(id, userId, {
@ -200,14 +219,155 @@ export class TaskService {
});
}
async uncomplete(id: string, userId: string): Promise<Task> {
/**
* Validates an RRULE string to prevent abuse (DoS, excessive occurrences).
* Returns true if valid, false if invalid or too complex.
*/
private validateRRule(rruleString: string): boolean {
// Basic length check
if (!rruleString || rruleString.length > 500) {
return false;
}
try {
const rule = rrulestr(rruleString);
// Get occurrences for the next 10 years with a limit
// Daily tasks = ~3650/10yrs, hourly would be ~87600 (reject)
const maxOccurrences = 5000;
const tenYearsFromNow = new Date();
tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);
const occurrences = rule.between(new Date(), tenYearsFromNow, true, (_, count) => {
// Stop iteration early if we exceed limit
return count < maxOccurrences;
});
// Reject if too many occurrences (prevents hourly/minutely abuse)
if (occurrences.length >= maxOccurrences) {
console.warn(`RRULE rejected: too many occurrences (${occurrences.length})`);
return false;
}
return true;
} catch {
return false;
}
}
/**
* Creates the next occurrence of a recurring task based on its RRULE.
* Returns the newly created task, or null if no more occurrences should be created.
*/
private async createNextOccurrence(
task: TaskWithLabels,
userId: string
): Promise<TaskWithLabels | null> {
if (!task.recurrenceRule) return null;
// Validate RRULE complexity before parsing
if (!this.validateRRule(task.recurrenceRule)) {
console.warn(`Invalid or too complex RRULE for task ${task.id}`);
return null;
}
try {
// Parse the RRULE string
const rule = rrulestr(task.recurrenceRule);
const now = new Date();
// Get the next occurrence after now
const nextDate = rule.after(now, false);
// Check if we've exceeded the recurrence end date
if (task.recurrenceEndDate) {
const endDate = new Date(task.recurrenceEndDate);
if (!nextDate || nextDate > endDate) {
return null; // No more occurrences
}
}
if (!nextDate) {
return null; // No more occurrences according to RRULE
}
// Reset subtasks (mark all as incomplete)
const resetSubtasks: Subtask[] | undefined = task.subtasks?.map((s) => ({
...s,
isCompleted: false,
completedAt: null,
}));
// Create new task for the next occurrence
const newTask: NewTask = {
userId,
projectId: task.projectId,
parentTaskId: task.parentTaskId,
title: task.title,
description: task.description,
dueDate: nextDate,
dueTime: task.dueTime,
startDate: task.startDate
? this.calculateNextStartDate(task.startDate, task.dueDate, nextDate)
: null,
priority: task.priority ?? 'medium',
status: 'pending',
isCompleted: false,
recurrenceRule: task.recurrenceRule,
recurrenceEndDate: task.recurrenceEndDate,
subtasks: resetSubtasks,
metadata: task.metadata,
order: task.order,
columnId: task.columnId,
columnOrder: task.columnOrder,
};
const [created] = await this.db.insert(tasks).values(newTask).returning();
// Copy labels from original task
if (task.labels && task.labels.length > 0) {
await this.db.insert(taskLabels).values(
task.labels.map((label) => ({
taskId: created.id,
labelId: label.id,
}))
);
}
return this.loadTaskLabels(created);
} catch (error) {
// If RRULE parsing fails, log and return null
console.error('Failed to parse recurrence rule:', error);
return null;
}
}
/**
* Calculates the new start date based on the offset between original start and due dates.
*/
private calculateNextStartDate(
originalStartDate: Date | string | null,
originalDueDate: Date | string | null,
nextDueDate: Date
): Date | null {
if (!originalStartDate || !originalDueDate) return null;
const start = new Date(originalStartDate);
const due = new Date(originalDueDate);
const diffMs = due.getTime() - start.getTime();
// New start date maintains the same offset from the new due date
return new Date(nextDueDate.getTime() - diffMs);
}
async uncomplete(id: string, userId: string): Promise<TaskWithLabels> {
return this.update(id, userId, {
isCompleted: false,
status: 'pending',
});
}
async move(id: string, userId: string, projectId: string | null): Promise<Task> {
async move(id: string, userId: string, projectId: string | null): Promise<TaskWithLabels> {
// Verify new project if provided
if (projectId) {
await this.projectService.findByIdOrThrow(projectId, userId);
@ -247,11 +407,11 @@ export class TaskService {
}
}
async getInboxTasks(userId: string): Promise<Task[]> {
async getInboxTasks(userId: string): Promise<TaskWithLabels[]> {
return this.findAll(userId, { isCompleted: false });
}
async getTodayTasks(userId: string): Promise<Task[]> {
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
@ -270,10 +430,10 @@ export class TaskService {
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
return this.loadTaskLabelsBatch(result);
}
async getUpcomingTasks(userId: string, days: number = 7): Promise<Task[]> {
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
@ -289,20 +449,45 @@ export class TaskService {
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
return this.loadTaskLabelsBatch(result);
}
async getCompletedTasks(userId: string, limit: number = 50): Promise<Task[]> {
const result = await this.db.query.tasks.findMany({
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
orderBy: [desc(tasks.completedAt)],
limit,
});
async getCompletedTasks(
userId: string,
limit: number = 50,
offset: number = 0
): Promise<{ tasks: TaskWithLabels[]; total: number; hasMore: boolean }> {
// Enforce max limit to prevent abuse
const safeLimit = Math.min(limit, 100);
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
const [result, countResult] = await Promise.all([
this.db.query.tasks.findMany({
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
orderBy: [desc(tasks.completedAt)],
limit: safeLimit,
offset,
}),
this.db
.select({ count: sql<number>`count(*)::int` })
.from(tasks)
.where(and(eq(tasks.userId, userId), eq(tasks.isCompleted, true))),
]);
const total = countResult[0]?.count ?? 0;
const tasksWithLabels = await this.loadTaskLabelsBatch(result);
return {
tasks: tasksWithLabels,
total,
hasMore: offset + safeLimit < total,
};
}
async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise<Task[]> {
async reorder(
userId: string,
taskIds: string[],
projectId?: string | null
): Promise<TaskWithLabels[]> {
// Update order for each task
const updates = taskIds.map((id, index) =>
this.db
@ -316,22 +501,66 @@ export class TaskService {
return this.findAll(userId, { projectId: projectId ?? undefined });
}
/**
* Loads labels for a single task (used for single task operations).
* For multiple tasks, use loadTaskLabelsBatch instead.
*/
private async loadTaskLabels(
task: Task
): Promise<Task & { labels: (typeof labels.$inferSelect)[] }> {
const taskLabelRows = await this.db.query.taskLabels.findMany({
where: eq(taskLabels.taskId, task.id),
});
const [result] = await this.loadTaskLabelsBatch([task]);
return result;
}
if (taskLabelRows.length === 0) {
return { ...task, labels: [] };
/**
* Batch loads labels for multiple tasks in just 2 queries (instead of N+1).
* This significantly improves performance when loading task lists.
*/
private async loadTaskLabelsBatch(
taskList: Task[]
): Promise<(Task & { labels: (typeof labels.$inferSelect)[] })[]> {
if (taskList.length === 0) {
return [];
}
const labelIds = taskLabelRows.map((tl) => tl.labelId);
const taskLabelsData = await this.db.query.labels.findMany({
where: or(...labelIds.map((id) => eq(labels.id, id))),
const taskIds = taskList.map((t) => t.id);
// Single query to get all task-label relationships
const allTaskLabels = await this.db.query.taskLabels.findMany({
where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))),
});
return { ...task, labels: taskLabelsData };
if (allTaskLabels.length === 0) {
// No labels for any task - return tasks with empty labels array
return taskList.map((task) => ({ ...task, labels: [] }));
}
// Get unique label IDs
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
// Single query to get all labels
const allLabels = await this.db.query.labels.findMany({
where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))),
});
// Create a map of labelId -> label for fast lookup
const labelMap = new Map(allLabels.map((l) => [l.id, l]));
// Create a map of taskId -> labelIds for fast lookup
const taskLabelMap = new Map<string, string[]>();
for (const tl of allTaskLabels) {
const existing = taskLabelMap.get(tl.taskId) || [];
existing.push(tl.labelId);
taskLabelMap.set(tl.taskId, existing);
}
// Combine tasks with their labels
return taskList.map((task) => {
const labelIds = taskLabelMap.get(task.id) || [];
const taskLabelsData = labelIds
.map((id) => labelMap.get(id))
.filter((l): l is typeof labels.$inferSelect => l !== undefined);
return { ...task, labels: taskLabelsData };
});
}
}

View file

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

View file

@ -15,8 +15,8 @@
<meta name="theme-color" content="#8b5cf6" />
<meta name="msapplication-TileColor" content="#8b5cf6" />
<!-- Apple iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- PWA -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Todo" />
<link rel="apple-touch-icon" href="/icons/icon.svg" />

View file

@ -39,7 +39,7 @@
<button
type="button"
onclick={() => (isOpen = !isOpen)}
class="section-header glass-pill w-full flex items-center gap-3 px-4 py-3 rounded-full cursor-pointer transition-all duration-200"
class="section-header w-full flex items-center gap-2 py-2 cursor-pointer transition-all duration-200"
>
<!-- Icon -->
<span class="icon-wrapper {iconColors[variant]}">
@ -77,36 +77,8 @@
</div>
<style>
/* Glass pill effect matching PillNavigation */
.glass-pill {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
position: relative;
z-index: 1;
}
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-pill:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-pill:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
.section-header:hover {
opacity: 0.7;
}
.icon-wrapper {

View file

@ -4,6 +4,7 @@
import { viewStore } from '$lib/stores/view.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import { format, addDays } from 'date-fns';
import { de } from 'date-fns/locale';
@ -21,14 +22,6 @@
let showPriorityPicker = $state(false);
let showProjectPicker = $state(false);
// Priority options
const priorities: { value: TaskPriority; label: string; color: string }[] = [
{ value: 'low', label: 'Niedrig', color: '#22c55e' },
{ value: 'medium', label: 'Mittel', color: '#eab308' },
{ value: 'high', label: 'Hoch', color: '#f97316' },
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
];
// Quick date options
const dateOptions = [
{ label: 'Heute', date: new Date() },
@ -38,7 +31,7 @@
];
// Derived values
let currentPriority = $derived(priorities.find((p) => p.value === selectedPriority)!);
let currentPriority = $derived(PRIORITY_OPTIONS.find((p) => p.value === selectedPriority)!);
let selectedProject = $derived(
selectedProjectId ? projectsStore.getById(selectedProjectId) : undefined
);
@ -81,11 +74,14 @@
if (viewStore.currentView !== 'project') {
selectedProjectId = undefined;
}
inputRef?.focus();
} catch (error) {
console.error('Failed to create task:', error);
} finally {
isLoading = false;
// Focus after isLoading is reset (input is no longer disabled)
requestAnimationFrame(() => {
inputRef?.focus();
});
}
}
@ -232,7 +228,7 @@
{#if showPriorityPicker}
<div class="dropdown" onclick={(e) => e.stopPropagation()} role="menu">
{#each priorities as priority}
{#each PRIORITY_OPTIONS as priority}
<button
type="button"
class="dropdown-item"

View file

@ -4,19 +4,26 @@
Subtask,
TaskPriority,
TaskStatus,
DurationUnit,
EffectiveDuration,
UpdateTaskInput,
} from '@todo/shared';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { projectsStore } from '$lib/stores/projects.svelte';
import { labelsStore } from '$lib/stores/labels.svelte';
import { format } from 'date-fns';
import SubtaskList from './SubtaskList.svelte';
import {
PrioritySelector,
StorypointsSelector,
DurationPicker,
FunRatingPicker,
TagSelector,
} from './form';
interface Props {
task: Task;
open: boolean;
onClose: () => void;
onSave: (data: Partial<Task>) => void;
onSave: (data: UpdateTaskInput) => void;
onDelete: (taskId: string) => void;
}
@ -36,70 +43,13 @@
let recurrenceRule = $state('');
let notes = $state('');
let storyPoints = $state<number | null>(null);
let effectiveDurationValue = $state<number | null>(null);
let effectiveDurationUnit = $state<DurationUnit>('hours');
let effectiveDuration = $state<EffectiveDuration | null>(null);
let funRating = $state<number | null>(null);
let showCustomDuration = $state(false);
// UI state
let showLabelDropdown = $state(false);
let isLoading = $state(false);
let showDeleteConfirm = $state(false);
// Priority options
const priorities: { value: TaskPriority; label: string; color: string }[] = [
{ value: 'low', label: 'Niedrig', color: '#22c55e' },
{ value: 'medium', label: 'Mittel', color: '#eab308' },
{ value: 'high', label: 'Hoch', color: '#f97316' },
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
];
// Status options
const statuses: { value: TaskStatus; label: string }[] = [
{ value: 'pending', label: 'Ausstehend' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'completed', label: 'Abgeschlossen' },
{ value: 'cancelled', label: 'Abgebrochen' },
];
// Recurrence options
const recurrenceOptions = [
{ value: '', label: 'Keine Wiederholung' },
{ value: 'FREQ=DAILY', label: 'Täglich' },
{ value: 'FREQ=WEEKLY', label: 'Wöchentlich' },
{ value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Alle 2 Wochen' },
{ value: 'FREQ=MONTHLY', label: 'Monatlich' },
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
];
// Storypoints options (Fibonacci)
const storyPointOptions = [1, 2, 3, 5, 8, 13, 21];
// Quick duration options
const durationOptions: { label: string; value: number; unit: DurationUnit }[] = [
{ label: '15m', value: 15, unit: 'minutes' },
{ label: '30m', value: 30, unit: 'minutes' },
{ label: '1h', value: 1, unit: 'hours' },
{ label: '2h', value: 2, unit: 'hours' },
{ label: '4h', value: 4, unit: 'hours' },
{ label: '1d', value: 1, unit: 'days' },
{ label: '2d', value: 2, unit: 'days' },
];
// Duration unit options
const durationUnitOptions: { value: DurationUnit; label: string }[] = [
{ value: 'minutes', label: 'Minuten' },
{ value: 'hours', label: 'Stunden' },
{ value: 'days', label: 'Tage' },
];
// Fun rating color helper
function getFunRatingColor(rating: number): string {
if (rating <= 3) return '#ef4444'; // red
if (rating <= 6) return '#eab308'; // yellow
return '#22c55e'; // green
}
// Initialize form when task changes or modal opens
$effect(() => {
if (open && task) {
@ -115,23 +65,9 @@
subtasks = task.subtasks ? [...task.subtasks] : [];
recurrenceRule = task.recurrenceRule || '';
notes = task.metadata?.notes || '';
// New metadata fields
// Metadata fields
storyPoints = task.metadata?.storyPoints ?? null;
if (task.metadata?.effectiveDuration) {
effectiveDurationValue = task.metadata.effectiveDuration.value;
effectiveDurationUnit = task.metadata.effectiveDuration.unit;
// Check if it's a custom value not in quick options
const isQuickOption = durationOptions.some(
(opt) =>
opt.value === task.metadata?.effectiveDuration?.value &&
opt.unit === task.metadata?.effectiveDuration?.unit
);
showCustomDuration = !isQuickOption;
} else {
effectiveDurationValue = null;
effectiveDurationUnit = 'hours';
showCustomDuration = false;
}
effectiveDuration = task.metadata?.effectiveDuration ?? null;
funRating = task.metadata?.funRating ?? null;
showDeleteConfirm = false;
}
@ -157,16 +93,7 @@
isLoading = true;
try {
// Build effective duration object
let effectiveDuration: EffectiveDuration | null = null;
if (effectiveDurationValue !== null && effectiveDurationValue > 0) {
effectiveDuration = {
value: effectiveDurationValue,
unit: effectiveDurationUnit,
};
}
const data: Partial<Task> = {
const data: UpdateTaskInput = {
title: title.trim(),
description: description.trim() || null,
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
@ -184,11 +111,9 @@
effectiveDuration: effectiveDuration ?? undefined,
funRating: funRating ?? undefined,
},
labelIds: selectedLabelIds,
};
// Include labelIds for the update
(data as any).labelIds = selectedLabelIds;
onSave(data);
} finally {
isLoading = false;
@ -203,37 +128,9 @@
}
}
function toggleLabel(labelId: string) {
if (selectedLabelIds.includes(labelId)) {
selectedLabelIds = selectedLabelIds.filter((id) => id !== labelId);
} else {
selectedLabelIds = [...selectedLabelIds, labelId];
}
}
function handleSubtasksChange(newSubtasks: Subtask[]) {
subtasks = newSubtasks;
}
function selectQuickDuration(opt: { value: number; unit: DurationUnit }) {
effectiveDurationValue = opt.value;
effectiveDurationUnit = opt.unit;
showCustomDuration = false;
}
function isQuickDurationSelected(opt: { value: number; unit: DurationUnit }): boolean {
return (
effectiveDurationValue === opt.value &&
effectiveDurationUnit === opt.unit &&
!showCustomDuration
);
}
function clearDuration() {
effectiveDurationValue = null;
effectiveDurationUnit = 'hours';
showCustomDuration = false;
}
</script>
<svelte:window onkeydown={handleKeydown} />
@ -304,27 +201,14 @@
<!-- Priorität -->
<div class="form-section">
<label class="form-label">Priorität</label>
<div class="priority-buttons">
{#each priorities as p}
<button
type="button"
class="priority-btn"
class:selected={priority === p.value}
style="--priority-color: {p.color}"
onclick={() => (priority = p.value)}
>
<span class="priority-dot" style="background-color: {p.color}"></span>
{p.label}
</button>
{/each}
</div>
<PrioritySelector value={priority} onChange={(p) => (priority = p)} />
</div>
<!-- Status -->
<div class="form-section">
<label class="form-label" for="task-status">Status</label>
<select id="task-status" class="form-select" bind:value={status}>
{#each statuses as s}
{#each STATUS_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
@ -343,71 +227,13 @@
</select>
</div>
<!-- Labels -->
<!-- Tags -->
<div class="form-section">
<label class="form-label">Labels</label>
<div class="label-selector">
<button
type="button"
class="label-trigger"
onclick={() => (showLabelDropdown = !showLabelDropdown)}
>
{#if selectedLabelIds.length === 0}
<span class="text-muted">Labels auswählen...</span>
{:else}
<div class="selected-labels">
{#each selectedLabelIds.slice(0, 3) as labelId}
{@const label = labelsStore.getById(labelId)}
{#if label}
<span class="label-tag" style="--label-color: {label.color}">
{label.name}
</span>
{/if}
{/each}
{#if selectedLabelIds.length > 3}
<span class="label-more">+{selectedLabelIds.length - 3}</span>
{/if}
</div>
{/if}
<svg class="dropdown-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if showLabelDropdown}
<div class="label-dropdown">
{#each labelsStore.labels as label}
<button
type="button"
class="label-option"
class:selected={selectedLabelIds.includes(label.id)}
onclick={() => toggleLabel(label.id)}
>
<span class="label-dot" style="background-color: {label.color}"></span>
<span class="label-name">{label.name}</span>
{#if selectedLabelIds.includes(label.id)}
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
{/each}
{#if labelsStore.labels.length === 0}
<div class="no-labels">Keine Labels vorhanden</div>
{/if}
</div>
{/if}
</div>
<label class="form-label">Tags</label>
<TagSelector
selectedIds={selectedLabelIds}
onChange={(ids) => (selectedLabelIds = ids)}
/>
</div>
<!-- Subtasks -->
@ -420,7 +246,7 @@
<div class="form-section">
<label class="form-label" for="task-recurrence">Wiederholung</label>
<select id="task-recurrence" class="form-select" bind:value={recurrenceRule}>
{#each recurrenceOptions as option}
{#each RECURRENCE_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
@ -441,140 +267,22 @@
<!-- Storypoints -->
<div class="form-section">
<label class="form-label">Storypoints</label>
<div class="storypoint-buttons">
{#each storyPointOptions as sp}
<button
type="button"
class="storypoint-btn"
class:selected={storyPoints === sp}
onclick={() => (storyPoints = storyPoints === sp ? null : sp)}
>
{sp}
</button>
{/each}
{#if storyPoints !== null}
<button
type="button"
class="storypoint-clear"
onclick={() => (storyPoints = null)}
title="Zurücksetzen"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<StorypointsSelector value={storyPoints} onChange={(v) => (storyPoints = v)} />
</div>
<!-- Effektive Dauer -->
<div class="form-section">
<label class="form-label">Effektive Dauer</label>
<div class="duration-buttons">
{#each durationOptions as opt}
<button
type="button"
class="duration-btn"
class:selected={isQuickDurationSelected(opt)}
onclick={() => selectQuickDuration(opt)}
>
{opt.label}
</button>
{/each}
<button
type="button"
class="duration-btn"
class:selected={showCustomDuration}
onclick={() => (showCustomDuration = !showCustomDuration)}
>
...
</button>
{#if effectiveDurationValue !== null}
<button
type="button"
class="duration-clear"
onclick={clearDuration}
title="Zurücksetzen"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if showCustomDuration}
<div class="duration-custom">
<input
type="number"
class="form-input-sm duration-input"
bind:value={effectiveDurationValue}
placeholder="Wert"
min="1"
/>
<select class="form-select duration-unit" bind:value={effectiveDurationUnit}>
{#each durationUnitOptions as unit}
<option value={unit.value}>{unit.label}</option>
{/each}
</select>
</div>
{/if}
<DurationPicker value={effectiveDuration} onChange={(v) => (effectiveDuration = v)} />
</div>
<!-- Spaß-Faktor -->
<div class="form-section">
<label class="form-label">
Spaß-Faktor{#if funRating !== null}: <span
class="fun-rating-value"
style="color: {getFunRatingColor(funRating)}">{funRating}</span
Spaß-Faktor{#if funRating !== null}: <span class="fun-rating-value">{funRating}</span
>{/if}
</label>
<div class="fun-rating">
{#each Array(10) as _, i}
{@const rating = i + 1}
<button
type="button"
class="fun-rating-dot"
class:filled={funRating !== null && rating <= funRating}
style="--dot-color: {getFunRatingColor(rating)}"
onclick={() => (funRating = funRating === rating ? null : rating)}
title={rating}
>
<span class="dot"></span>
</button>
{/each}
{#if funRating !== null}
<button
type="button"
class="fun-rating-clear"
onclick={() => (funRating = null)}
title="Zurücksetzen"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<div class="fun-rating-labels">
<span>1</span>
<span>5</span>
<span>10</span>
</div>
<FunRatingPicker value={funRating} onChange={(v) => (funRating = v)} />
</div>
</div>
@ -788,184 +496,6 @@
min-height: 80px;
}
/* Priority buttons */
.priority-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.priority-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.8);
font-size: 0.8125rem;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .priority-btn {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #e5e7eb;
}
.priority-btn:hover {
border-color: var(--priority-color);
}
.priority-btn.selected {
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
border-color: var(--priority-color);
color: var(--priority-color);
}
.priority-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
}
/* Label selector */
.label-selector {
position: relative;
}
.label-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .label-trigger {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.label-trigger:hover {
border-color: rgba(0, 0, 0, 0.25);
}
.text-muted {
color: #9ca3af;
font-size: 0.875rem;
}
.selected-labels {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.label-tag {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--label-color) 15%, transparent);
color: var(--label-color);
font-weight: 500;
}
.label-more {
font-size: 0.75rem;
color: #6b7280;
}
.dropdown-arrow {
width: 1rem;
height: 1rem;
color: #9ca3af;
}
.label-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
padding: 0.375rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 10;
}
:global(.dark) .label-dropdown {
background: rgba(40, 40, 40, 0.95);
border-color: rgba(255, 255, 255, 0.15);
}
.label-option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.label-option:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .label-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.label-option.selected {
background: rgba(139, 92, 246, 0.1);
}
.label-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
flex-shrink: 0;
}
.label-name {
flex: 1;
font-size: 0.875rem;
color: #374151;
}
:global(.dark) .label-name {
color: #e5e7eb;
}
.check-icon {
width: 1rem;
height: 1rem;
color: #8b5cf6;
}
.no-labels {
padding: 0.75rem;
text-align: center;
font-size: 0.875rem;
color: #9ca3af;
}
/* Footer */
.modal-footer {
display: flex;
@ -1056,164 +586,6 @@
}
}
/* Storypoints */
.storypoint-buttons {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
align-items: center;
}
.storypoint-btn {
min-width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.8);
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .storypoint-btn {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #e5e7eb;
}
.storypoint-btn:hover {
border-color: #8b5cf6;
}
.storypoint-btn.selected {
background: rgba(139, 92, 246, 0.15);
border-color: #8b5cf6;
color: #8b5cf6;
}
.storypoint-clear,
.duration-clear,
.fun-rating-clear {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
cursor: pointer;
transition: all 0.15s;
}
.storypoint-clear:hover,
.duration-clear:hover,
.fun-rating-clear:hover {
background: rgba(239, 68, 68, 0.2);
}
/* Duration */
.duration-buttons {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
align-items: center;
}
.duration-btn {
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.8);
font-size: 0.8125rem;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .duration-btn {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #e5e7eb;
}
.duration-btn:hover {
border-color: #8b5cf6;
}
.duration-btn.selected {
background: rgba(139, 92, 246, 0.15);
border-color: #8b5cf6;
color: #8b5cf6;
}
.duration-custom {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.duration-input {
width: 80px;
}
.duration-unit {
width: 120px;
}
/* Fun Rating */
.fun-rating {
display: flex;
gap: 0.25rem;
align-items: center;
}
.fun-rating-dot {
padding: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
transition: transform 0.15s;
}
.fun-rating-dot:hover {
transform: scale(1.2);
}
.fun-rating-dot .dot {
display: block;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.1);
transition: all 0.15s;
}
:global(.dark) .fun-rating-dot .dot {
background: rgba(255, 255, 255, 0.15);
}
.fun-rating-dot.filled .dot {
background: var(--dot-color);
}
.fun-rating-labels {
display: flex;
justify-content: space-between;
padding: 0 0.25rem;
margin-top: 0.25rem;
font-size: 0.6875rem;
color: #9ca3af;
}
.fun-rating-value {
font-weight: 600;
}

View file

@ -7,12 +7,44 @@
interface Props {
task: Task;
showCompleted?: boolean;
animateComplete?: boolean;
onToggleComplete: () => void;
onDelete: () => void;
onEdit?: () => void;
}
let { task, showCompleted = false, onToggleComplete, onDelete, onEdit }: Props = $props();
let {
task,
showCompleted = false,
animateComplete = false,
onToggleComplete,
onDelete,
onEdit,
}: Props = $props();
// Animation state for completing
let isAnimatingComplete = $state(false);
// External animation trigger
$effect(() => {
if (animateComplete && !task.isCompleted) {
isAnimatingComplete = true;
}
});
function handleToggleClick() {
if (!task.isCompleted) {
// Animate before completing
isAnimatingComplete = true;
setTimeout(() => {
isAnimatingComplete = false;
onToggleComplete();
}, 500);
} else {
// Uncomplete immediately
onToggleComplete();
}
}
function handleContentClick() {
if (onEdit) {
@ -58,7 +90,18 @@
});
</script>
<div class="task-item group" class:completed={task.isCompleted}>
<div
class="task-item group"
class:completed={task.isCompleted}
class:completing={isAnimatingComplete}
>
<!-- Drag handle -->
<div class="drag-handle">
<svg class="drag-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
</svg>
</div>
<!-- Priority indicator -->
<div
class="priority-dot"
@ -66,9 +109,20 @@
></div>
<!-- Checkbox -->
<button class="task-checkbox" class:checked={task.isCompleted} onclick={onToggleComplete}>
{#if task.isCompleted}
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<button
class="task-checkbox"
class:checked={task.isCompleted}
class:animating={isAnimatingComplete}
onclick={handleToggleClick}
>
{#if task.isCompleted || isAnimatingComplete}
<svg
class="check-icon"
class:animate-check={isAnimatingComplete}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
@ -80,27 +134,9 @@
{task.title}
</span>
<!-- Meta info inline -->
{#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)}
<!-- Labels and subtasks below title -->
{#if subtaskProgress() || (task.labels && task.labels.length > 0)}
<div class="task-meta">
{#if dueDateText()}
<span
class="meta-item date"
class:overdue={isOverdue()}
class:today={isToday(new Date(task.dueDate || 0))}
>
<svg class="meta-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{dueDateText()}
</span>
{/if}
{#if subtaskProgress()}
<span class="meta-item">
<svg class="meta-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -129,6 +165,17 @@
{/if}
</button>
<!-- Due date (always on the right) -->
{#if dueDateText()}
<span
class="due-date"
class:overdue={isOverdue()}
class:today={task.dueDate && isToday(new Date(task.dueDate))}
>
{dueDateText()}
</span>
{/if}
<!-- Project indicator -->
{#if projectColor()}
<div class="project-dot" style="background-color: {projectColor()}"></div>
@ -151,18 +198,16 @@
.task-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: 9999px;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
margin-bottom: 0.5rem;
margin-bottom: 0.375rem;
}
:global(.dark) .task-item {
@ -172,11 +217,8 @@
.task-item:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
:global(.dark) .task-item:hover {
@ -188,6 +230,54 @@
opacity: 0.6;
}
/* Completing animation */
.task-item.completing {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.3);
}
:global(.dark) .task-item.completing {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.4);
}
/* Drag handle */
.drag-handle {
cursor: grab;
opacity: 0;
transition: opacity 0.15s;
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0.125rem;
margin-left: -0.25rem;
}
.task-item:hover .drag-handle {
opacity: 0.4;
}
.drag-handle:hover {
opacity: 0.7 !important;
}
.drag-handle:active {
cursor: grabbing;
}
.drag-icon {
width: 1rem;
height: 1rem;
color: currentColor;
}
/* During drag, disable pointer events on interactive elements */
:global([aria-grabbed='true']) .task-checkbox,
:global([aria-grabbed='true']) .task-content,
:global([aria-grabbed='true']) .delete-btn {
pointer-events: none;
}
/* Priority dot */
.priority-dot {
width: 0.5rem;
@ -226,12 +316,48 @@
border-color: #8b5cf6;
}
.task-checkbox.animating {
background: #22c55e;
border-color: #22c55e;
transform: scale(1.2);
}
.check-icon {
width: 0.75rem;
height: 0.75rem;
color: white;
}
.check-icon.animate-check {
animation: drawCheck 0.3s ease-out forwards;
}
.check-icon.animate-check path {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: drawPath 0.3s ease-out forwards;
}
@keyframes drawPath {
to {
stroke-dashoffset: 0;
}
}
@keyframes drawCheck {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Content */
.task-content {
flex: 1;
@ -284,14 +410,6 @@
color: #9ca3af;
}
.meta-item.date.overdue {
color: #ef4444;
}
.meta-item.date.today {
color: #f97316;
}
.meta-icon {
width: 0.75rem;
height: 0.75rem;
@ -306,6 +424,26 @@
font-weight: 500;
}
/* Due date */
.due-date {
font-size: 0.75rem;
color: #6b7280;
flex-shrink: 0;
white-space: nowrap;
}
:global(.dark) .due-date {
color: #9ca3af;
}
.due-date.overdue {
color: #ef4444;
}
.due-date.today {
color: #f97316;
}
/* Project dot */
.project-dot {
width: 0.5rem;

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { Task } from '@todo/shared';
import TaskItem from './TaskItem.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
@ -6,10 +7,76 @@
interface Props {
tasks: Task[];
showCompleted?: boolean;
enableDragDrop?: boolean;
dropTargetDate?: Date | 'completed' | 'overdue';
onEditTask?: (task: Task) => void;
onTaskDrop?: (taskId: string, targetDate: Date | 'completed' | 'overdue') => void;
}
let { tasks, showCompleted = false, onEditTask }: Props = $props();
let {
tasks,
showCompleted = false,
enableDragDrop = false,
dropTargetDate,
onEditTask,
onTaskDrop,
}: Props = $props();
// Local mutable state for dnd-zone
let items = $state<Task[]>([]);
// Track which task is being animated for completion
let animatingTaskId = $state<string | null>(null);
// Create a stable key from task IDs to detect real changes
let lastTaskIds = '';
// Sync items with tasks only when the set of task IDs changes
$effect(() => {
const currentIds = tasks
.map((t) => t.id)
.sort()
.join(',');
if (currentIds !== lastTaskIds) {
items = [...tasks];
lastTaskIds = currentIds;
}
});
const flipDurationMs = 200;
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
items = e.detail.items;
}
function handleDndFinalize(e: CustomEvent<{ items: Task[]; info: { id: string } }>) {
const newItems = e.detail.items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID);
const movedTaskId = e.detail.info.id;
// Check if this task came from another list (dropped INTO this list)
const wasInThisList = tasks.some((t) => t.id === movedTaskId);
if (!wasInThisList && dropTargetDate && onTaskDrop) {
// If dropping into completed section, animate first
if (dropTargetDate === 'completed') {
animatingTaskId = movedTaskId;
setTimeout(() => {
animatingTaskId = null;
onTaskDrop(movedTaskId, dropTargetDate);
}, 500);
} else {
// Task moved FROM another section TO this section
onTaskDrop(movedTaskId, dropTargetDate);
}
}
// Update local state and sync lastTaskIds to prevent $effect from reverting
items = newItems;
lastTaskIds = newItems
.map((t) => t.id)
.sort()
.join(',');
}
async function handleToggleComplete(task: Task) {
if (task.isCompleted) {
@ -24,14 +91,89 @@
}
</script>
<div class="task-list">
{#each tasks as task (task.id)}
<TaskItem
{task}
{showCompleted}
onToggleComplete={() => handleToggleComplete(task)}
onDelete={() => handleDelete(task.id)}
onEdit={onEditTask ? () => onEditTask(task) : undefined}
/>
{/each}
</div>
{#if enableDragDrop}
<div
class="task-list"
class:empty={items.length === 0}
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: {},
dropTargetClasses: ['task-drop-target'],
type: 'homepage-tasks',
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
>
{#each items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)}
<TaskItem
{task}
{showCompleted}
animateComplete={animatingTaskId === task.id}
onToggleComplete={() => handleToggleComplete(task)}
onDelete={() => handleDelete(task.id)}
onEdit={onEditTask ? () => onEditTask(task) : undefined}
/>
{/each}
{#if items.length === 0}
<div class="empty-placeholder">
<span>Aufgabe hierher ziehen</span>
</div>
{/if}
</div>
{:else}
<div class="task-list">
{#each tasks as task (task.id)}
<TaskItem
{task}
{showCompleted}
animateComplete={animatingTaskId === task.id}
onToggleComplete={() => handleToggleComplete(task)}
onDelete={() => handleDelete(task.id)}
onEdit={onEditTask ? () => onEditTask(task) : undefined}
/>
{/each}
</div>
{/if}
<style>
.task-list {
min-height: 60px;
padding: 0.25rem;
border-radius: 0.5rem;
transition: background-color 0.15s ease;
}
.task-list.empty {
border: 2px dashed rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark) .task-list.empty {
border-color: rgba(255, 255, 255, 0.2);
}
.empty-placeholder {
color: var(--color-muted-foreground, #9ca3af);
font-size: 0.875rem;
padding: 1rem;
text-align: center;
opacity: 0.5;
}
:global(.task-drop-target) {
outline: 2px dashed #8b5cf6 !important;
outline-offset: -2px;
background: rgba(139, 92, 246, 0.08) !important;
}
:global(.task-drop-target) .empty-placeholder {
opacity: 0;
}
:global(.dark .task-drop-target) {
background: rgba(139, 92, 246, 0.15) !important;
}
</style>

View file

@ -0,0 +1,238 @@
<script lang="ts">
import type { DurationUnit, EffectiveDuration } from '@todo/shared';
interface Props {
value: EffectiveDuration | null;
onChange: (value: EffectiveDuration | null) => void;
}
let { value, onChange }: Props = $props();
let showCustom = $state(false);
let customValue = $state<number | null>(null);
let customUnit = $state<DurationUnit>('hours');
// Quick duration options
const quickOptions: { label: string; value: number; unit: DurationUnit }[] = [
{ label: '15m', value: 15, unit: 'minutes' },
{ label: '30m', value: 30, unit: 'minutes' },
{ label: '1h', value: 1, unit: 'hours' },
{ label: '2h', value: 2, unit: 'hours' },
{ label: '4h', value: 4, unit: 'hours' },
{ label: '1d', value: 1, unit: 'days' },
{ label: '2d', value: 2, unit: 'days' },
];
const unitOptions: { value: DurationUnit; label: string }[] = [
{ value: 'minutes', label: 'Minuten' },
{ value: 'hours', label: 'Stunden' },
{ value: 'days', label: 'Tage' },
];
// Sync custom inputs with value prop
$effect(() => {
if (value) {
const isQuickOption = quickOptions.some(
(opt) => opt.value === value.value && opt.unit === value.unit
);
if (!isQuickOption) {
showCustom = true;
customValue = value.value;
customUnit = value.unit;
} else {
showCustom = false;
}
} else {
showCustom = false;
customValue = null;
customUnit = 'hours';
}
});
function selectQuick(opt: { value: number; unit: DurationUnit }) {
showCustom = false;
onChange({ value: opt.value, unit: opt.unit });
}
function isQuickSelected(opt: { value: number; unit: DurationUnit }): boolean {
return value !== null && value.value === opt.value && value.unit === opt.unit && !showCustom;
}
function toggleCustom() {
showCustom = !showCustom;
if (showCustom && customValue && customValue > 0) {
onChange({ value: customValue, unit: customUnit });
}
}
function handleCustomChange() {
if (customValue && customValue > 0) {
onChange({ value: customValue, unit: customUnit });
}
}
function clear() {
showCustom = false;
customValue = null;
customUnit = 'hours';
onChange(null);
}
</script>
<div class="duration-picker">
<div class="duration-buttons">
{#each quickOptions as opt}
<button
type="button"
class="duration-btn"
class:selected={isQuickSelected(opt)}
onclick={() => selectQuick(opt)}
>
{opt.label}
</button>
{/each}
<button type="button" class="duration-btn" class:selected={showCustom} onclick={toggleCustom}>
...
</button>
{#if value !== null}
<button type="button" class="duration-clear" onclick={clear} title="Zurücksetzen">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if showCustom}
<div class="duration-custom">
<input
type="number"
class="duration-input"
bind:value={customValue}
oninput={handleCustomChange}
placeholder="Wert"
min="1"
/>
<select class="duration-unit" bind:value={customUnit} onchange={handleCustomChange}>
{#each unitOptions as unit}
<option value={unit.value}>{unit.label}</option>
{/each}
</select>
</div>
{/if}
</div>
<style>
.duration-picker {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.duration-buttons {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
align-items: center;
}
.duration-btn {
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.8);
font-size: 0.8125rem;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .duration-btn {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #e5e7eb;
}
.duration-btn:hover {
border-color: #8b5cf6;
}
.duration-btn.selected {
background: rgba(139, 92, 246, 0.15);
border-color: #8b5cf6;
color: #8b5cf6;
}
.duration-clear {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
cursor: pointer;
transition: all 0.15s;
}
.duration-clear:hover {
background: rgba(239, 68, 68, 0.2);
}
.duration-custom {
display: flex;
gap: 0.5rem;
}
.duration-input {
width: 80px;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
color: #374151;
}
:global(.dark) .duration-input {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
.duration-input:focus {
outline: none;
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.duration-unit {
width: 120px;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
color: #374151;
}
:global(.dark) .duration-unit {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
.duration-unit:focus {
outline: none;
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
</style>

View file

@ -0,0 +1,127 @@
<script lang="ts">
interface Props {
value: number | null;
onChange: (value: number | null) => void;
}
let { value, onChange }: Props = $props();
function getRatingColor(rating: number): string {
if (rating <= 3) return '#ef4444'; // red
if (rating <= 6) return '#eab308'; // yellow
return '#22c55e'; // green
}
function handleSelect(rating: number) {
onChange(value === rating ? null : rating);
}
function handleClear() {
onChange(null);
}
</script>
<div class="fun-rating-picker">
<div class="fun-rating">
{#each Array(10) as _, i}
{@const rating = i + 1}
<button
type="button"
class="fun-rating-dot"
class:filled={value !== null && rating <= value}
style="--dot-color: {getRatingColor(rating)}"
onclick={() => handleSelect(rating)}
title={String(rating)}
>
<span class="dot"></span>
</button>
{/each}
{#if value !== null}
<button type="button" class="fun-rating-clear" onclick={handleClear} title="Zurücksetzen">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<div class="fun-rating-labels">
<span>1</span>
<span>5</span>
<span>10</span>
</div>
</div>
<style>
.fun-rating-picker {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.fun-rating {
display: flex;
gap: 0.25rem;
align-items: center;
}
.fun-rating-dot {
padding: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
transition: transform 0.15s;
}
.fun-rating-dot:hover {
transform: scale(1.2);
}
.fun-rating-dot .dot {
display: block;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.1);
transition: all 0.15s;
}
:global(.dark) .fun-rating-dot .dot {
background: rgba(255, 255, 255, 0.15);
}
.fun-rating-dot.filled .dot {
background: var(--dot-color);
}
.fun-rating-labels {
display: flex;
justify-content: space-between;
padding: 0 0.25rem;
font-size: 0.6875rem;
color: #9ca3af;
}
.fun-rating-clear {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
cursor: pointer;
transition: all 0.15s;
}
.fun-rating-clear:hover {
background: rgba(239, 68, 68, 0.2);
}
</style>

View file

@ -0,0 +1,70 @@
<script lang="ts">
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
interface Props {
value: TaskPriority;
onChange: (priority: TaskPriority) => void;
}
let { value, onChange }: Props = $props();
</script>
<div class="priority-buttons">
{#each PRIORITY_OPTIONS as p}
<button
type="button"
class="priority-btn"
class:selected={value === p.value}
style="--priority-color: {p.color}"
onclick={() => onChange(p.value)}
>
<span class="priority-dot" style="background-color: {p.color}"></span>
{p.label}
</button>
{/each}
</div>
<style>
.priority-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.priority-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.8);
font-size: 0.8125rem;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .priority-btn {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #e5e7eb;
}
.priority-btn:hover {
border-color: var(--priority-color);
}
.priority-btn.selected {
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
border-color: var(--priority-color);
color: var(--priority-color);
}
.priority-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
interface Props {
value: number | null;
onChange: (value: number | null) => void;
}
let { value, onChange }: Props = $props();
// Fibonacci sequence for story points
const options = [1, 2, 3, 5, 8, 13, 21];
function handleSelect(sp: number) {
onChange(value === sp ? null : sp);
}
function handleClear() {
onChange(null);
}
</script>
<div class="storypoint-buttons">
{#each options as sp}
<button
type="button"
class="storypoint-btn"
class:selected={value === sp}
onclick={() => handleSelect(sp)}
>
{sp}
</button>
{/each}
{#if value !== null}
<button type="button" class="storypoint-clear" onclick={handleClear} title="Zurücksetzen">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<style>
.storypoint-buttons {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
align-items: center;
}
.storypoint-btn {
min-width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.8);
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .storypoint-btn {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
color: #e5e7eb;
}
.storypoint-btn:hover {
border-color: #8b5cf6;
}
.storypoint-btn.selected {
background: rgba(139, 92, 246, 0.15);
border-color: #8b5cf6;
color: #8b5cf6;
}
.storypoint-clear {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
cursor: pointer;
transition: all 0.15s;
}
.storypoint-clear:hover {
background: rgba(239, 68, 68, 0.2);
}
</style>

View file

@ -0,0 +1,223 @@
<script lang="ts">
import { labelsStore } from '$lib/stores/labels.svelte';
interface Props {
selectedIds: string[];
onChange: (ids: string[]) => void;
}
let { selectedIds, onChange }: Props = $props();
let showDropdown = $state(false);
function toggleTag(tagId: string) {
if (selectedIds.includes(tagId)) {
onChange(selectedIds.filter((id) => id !== tagId));
} else {
onChange([...selectedIds, tagId]);
}
}
function handleTriggerClick(e: MouseEvent) {
e.stopPropagation();
showDropdown = !showDropdown;
}
function handleWindowClick() {
showDropdown = false;
}
</script>
<svelte:window onclick={handleWindowClick} />
<div class="tag-selector">
<button type="button" class="tag-trigger" onclick={handleTriggerClick}>
{#if selectedIds.length === 0}
<span class="text-muted">Tags auswählen...</span>
{:else}
<div class="selected-tags">
{#each selectedIds.slice(0, 3) as tagId}
{@const tag = labelsStore.getById(tagId)}
{#if tag}
<span class="tag-chip" style="--tag-color: {tag.color}">
{tag.name}
</span>
{/if}
{/each}
{#if selectedIds.length > 3}
<span class="tag-more">+{selectedIds.length - 3}</span>
{/if}
</div>
{/if}
<svg class="dropdown-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showDropdown}
<div class="tag-dropdown" onclick={(e) => e.stopPropagation()} role="listbox">
{#each labelsStore.labels as tag}
<button
type="button"
class="tag-option"
class:selected={selectedIds.includes(tag.id)}
onclick={() => toggleTag(tag.id)}
role="option"
aria-selected={selectedIds.includes(tag.id)}
>
<span class="tag-dot" style="background-color: {tag.color}"></span>
<span class="tag-name">{tag.name}</span>
{#if selectedIds.includes(tag.id)}
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
{/each}
{#if labelsStore.labels.length === 0}
<div class="no-tags">Keine Tags vorhanden</div>
{/if}
</div>
{/if}
</div>
<style>
.tag-selector {
position: relative;
}
.tag-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .tag-trigger {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.tag-trigger:hover {
border-color: rgba(0, 0, 0, 0.25);
}
.text-muted {
color: #9ca3af;
font-size: 0.875rem;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tag-chip {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--tag-color) 15%, transparent);
color: var(--tag-color);
font-weight: 500;
}
.tag-more {
font-size: 0.75rem;
color: #6b7280;
}
.dropdown-arrow {
width: 1rem;
height: 1rem;
color: #9ca3af;
}
.tag-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
padding: 0.375rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 10;
}
:global(.dark) .tag-dropdown {
background: rgba(40, 40, 40, 0.95);
border-color: rgba(255, 255, 255, 0.15);
}
.tag-option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.tag-option:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .tag-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.tag-option.selected {
background: rgba(139, 92, 246, 0.1);
}
.tag-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
flex-shrink: 0;
}
.tag-name {
flex: 1;
font-size: 0.875rem;
color: #374151;
}
:global(.dark) .tag-name {
color: #e5e7eb;
}
.check-icon {
width: 1rem;
height: 1rem;
color: #8b5cf6;
}
.no-tags {
padding: 0.75rem;
text-align: center;
font-size: 0.875rem;
color: #9ca3af;
}
</style>

View file

@ -0,0 +1,5 @@
export { default as PrioritySelector } from './PrioritySelector.svelte';
export { default as StorypointsSelector } from './StorypointsSelector.svelte';
export { default as DurationPicker } from './DurationPicker.svelte';
export { default as FunRatingPicker } from './FunRatingPicker.svelte';
export { default as TagSelector } from './TagSelector.svelte';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { KanbanColumn, Task, TaskPriority } from '@todo/shared';
import { ConfirmationModal } from '@manacore/shared-ui';
import KanbanColumnComponent from './KanbanColumn.svelte';
import AddColumnButton from './AddColumnButton.svelte';
import { kanbanStore } from '$lib/stores/kanban.svelte';
@ -24,6 +25,8 @@
// Local columns state for drag and drop
let localColumns = $state<KanbanColumn[]>([]);
let showDeleteConfirm = $state(false);
let columnToDelete = $state<string | null>(null);
// Sync with store
$effect(() => {
@ -55,10 +58,17 @@
await kanbanStore.updateColumn(columnId, data);
}
async function handleDeleteColumn(columnId: string) {
if (confirm('Spalte wirklich löschen? Alle Aufgaben werden in die erste Spalte verschoben.')) {
await kanbanStore.deleteColumn(columnId);
function handleDeleteColumn(columnId: string) {
columnToDelete = columnId;
showDeleteConfirm = true;
}
async function confirmDeleteColumn() {
if (columnToDelete) {
await kanbanStore.deleteColumn(columnToDelete);
}
showDeleteConfirm = false;
columnToDelete = null;
}
async function handleTasksReorder(columnId: string, taskIds: string[]) {
@ -167,6 +177,21 @@
{/if}
</div>
<!-- Delete column confirmation modal -->
<ConfirmationModal
visible={showDeleteConfirm}
onClose={() => {
showDeleteConfirm = false;
columnToDelete = null;
}}
onConfirm={confirmDeleteColumn}
variant="danger"
title="Spalte löschen?"
message="Alle Aufgaben dieser Spalte werden in die erste Spalte verschoben."
confirmLabel="Löschen"
cancelLabel="Abbrechen"
/>
<style>
.kanban-board {
min-height: 400px;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { KanbanColumn, Task } from '@todo/shared';
import type { KanbanColumn, Task, UpdateTaskInput } from '@todo/shared';
import KanbanTaskCard from './KanbanTaskCard.svelte';
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
@ -71,9 +71,9 @@
}
}
async function handleSaveTask(task: Task, data: Partial<Task>) {
async function handleSaveTask(task: Task, data: UpdateTaskInput) {
// Transform data to match updateTask API (convert null to undefined)
const updateData: Parameters<typeof tasksStore.updateTask>[1] = {};
const updateData: UpdateTaskInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description ?? undefined;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
@ -84,7 +84,7 @@
if (data.recurrenceRule !== undefined)
updateData.recurrenceRule = data.recurrenceRule ?? undefined;
if (data.metadata !== undefined) updateData.metadata = data.metadata;
if ((data as any).labelIds !== undefined) (updateData as any).labelIds = (data as any).labelIds;
if (data.labelIds !== undefined) updateData.labelIds = data.labelIds;
await tasksStore.updateTask(task.id, updateData);
}

View file

@ -36,19 +36,19 @@
},
{
value: 'high',
label: 'Hoch',
label: 'Wichtig',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-500',
},
{
value: 'medium',
label: 'Mittel',
label: 'Normal',
color: 'text-yellow-600 dark:text-yellow-400',
bgColor: 'bg-yellow-500',
},
{
value: 'low',
label: 'Niedrig',
label: 'Später',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500',
},
@ -184,10 +184,9 @@
<div class="h-6 w-px bg-border hidden sm:block"></div>
<!-- Labels filter -->
<!-- Tags filter -->
<div class="filter-group flex items-center gap-2 relative">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Labels</span
>
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tags</span>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-background border border-border rounded-lg hover:border-primary/50 hover:bg-muted/50 transition-all"
onclick={() => (showLabelsDropdown = !showLabelsDropdown)}
@ -234,7 +233,7 @@
class="absolute top-full left-0 mt-2 z-50 min-w-[220px] bg-popover border border-border rounded-xl shadow-lg p-2 animate-in fade-in slide-in-from-top-2 duration-150"
>
{#if labelsStore.labels.length === 0}
<p class="text-sm text-muted-foreground p-3 text-center">Keine Labels vorhanden</p>
<p class="text-sm text-muted-foreground p-3 text-center">Keine Tags vorhanden</p>
{:else}
<div class="max-h-[200px] overflow-y-auto">
{#each labelsStore.labels as label}

View file

@ -2,6 +2,7 @@
import type { Task } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { ConfirmationModal } from '@manacore/shared-ui';
import TaskEditModal from '../TaskEditModal.svelte';
interface Props {
@ -15,6 +16,7 @@
// Modal state
let showModal = $state(false);
let showDeleteConfirm = $state(false);
// Inline edit state
let isEditingTitle = $state(false);
@ -129,9 +131,12 @@
function handleContextDelete() {
showContextMenu = false;
if (confirm('Aufgabe wirklich löschen?')) {
onDelete?.();
}
showDeleteConfirm = true;
}
function confirmDelete() {
showDeleteConfirm = false;
onDelete?.();
}
// Modal handlers
@ -308,6 +313,18 @@
onDelete={handleModalDelete}
/>
<!-- Delete confirmation modal -->
<ConfirmationModal
visible={showDeleteConfirm}
onClose={() => (showDeleteConfirm = false)}
onConfirm={confirmDelete}
variant="danger"
title="Aufgabe löschen?"
message="Diese Aufgabe wird unwiderruflich gelöscht."
confirmLabel="Löschen"
cancelLabel="Abbrechen"
/>
<style>
.kanban-card {
display: flex;

View file

@ -22,9 +22,9 @@
// Priority labels
const PRIORITY_LABELS: Record<TaskPriority, string> = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
low: 'Später',
medium: 'Normal',
high: 'Wichtig',
urgent: 'Dringend',
};
@ -98,49 +98,48 @@
<div class="donut-container">
<h3 class="donut-title">Prioritäten</h3>
<div class="donut-content">
<div class="donut-chart">
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
{#each arcs as arc}
<path
d={arc.path}
fill={arc.color}
class="arc-segment"
class:hovered={hoveredSegment === arc.priority}
onmouseenter={() => (hoveredSegment = arc.priority)}
onmouseleave={() => (hoveredSegment = null)}
role="graphics-symbol"
aria-label="{PRIORITY_LABELS[arc.priority]}: {arc.count}"
>
<title>{PRIORITY_LABELS[arc.priority]}: {arc.count} ({arc.percentage}%)</title>
</path>
{/each}
<!-- Center text -->
<text x={CENTER} y={CENTER - 8} class="center-count">
{total}
</text>
<text x={CENTER} y={CENTER + 12} class="center-label"> Aktiv </text>
</svg>
</div>
<!-- Legend -->
<div class="donut-legend">
{#each data as item}
<div
class="legend-item"
class:active={hoveredSegment === item.priority}
onmouseenter={() => (hoveredSegment = item.priority)}
<!-- Chart centered -->
<div class="donut-chart">
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
{#each arcs as arc}
<path
d={arc.path}
fill={arc.color}
class="arc-segment"
class:hovered={hoveredSegment === arc.priority}
onmouseenter={() => (hoveredSegment = arc.priority)}
onmouseleave={() => (hoveredSegment = null)}
role="button"
tabindex="0"
role="graphics-symbol"
aria-label="{PRIORITY_LABELS[arc.priority]}: {arc.count}"
>
<span class="legend-color" style="background-color: {item.color}"></span>
<span class="legend-label">{PRIORITY_LABELS[item.priority]}</span>
<span class="legend-count">{item.count}</span>
</div>
<title>{PRIORITY_LABELS[arc.priority]}: {arc.count} ({arc.percentage}%)</title>
</path>
{/each}
</div>
<!-- Center text -->
<text x={CENTER} y={CENTER - 8} class="center-count">
{total}
</text>
<text x={CENTER} y={CENTER + 12} class="center-label"> Aktiv </text>
</svg>
</div>
<!-- Legend as horizontal grid below -->
<div class="donut-legend">
{#each data as item}
<div
class="legend-item"
class:active={hoveredSegment === item.priority}
onmouseenter={() => (hoveredSegment = item.priority)}
onmouseleave={() => (hoveredSegment = null)}
role="button"
tabindex="0"
>
<span class="legend-color" style="background-color: {item.color}"></span>
<span class="legend-label">{PRIORITY_LABELS[item.priority]}</span>
<span class="legend-count">{item.count}</span>
</div>
{/each}
</div>
</div>
@ -166,20 +165,10 @@
margin: 0 0 1rem 0;
}
.donut-content {
display: flex;
align-items: center;
gap: 1.5rem;
}
@media (max-width: 400px) {
.donut-content {
flex-direction: column;
}
}
.donut-chart {
flex-shrink: 0;
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.donut-svg {
@ -215,11 +204,9 @@
}
.donut-legend {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.legend-item {
@ -238,20 +225,23 @@
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.875rem;
font-size: 0.75rem;
color: hsl(var(--foreground));
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-count {
font-size: 0.875rem;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
}

View file

@ -121,7 +121,6 @@ export const tasksStore = {
try {
// Fetch all tasks without filter - let frontend handle filtering
const allTasks = await tasksApi.getTasks({});
console.log('API response - all tasks:', allTasks.length);
tasks = allTasks;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch all tasks';
@ -239,6 +238,47 @@ export const tasksStore = {
}
},
/**
* Update task optimistically (for drag and drop)
* Updates local state immediately, then syncs with server
*/
async updateTaskOptimistic(
id: string,
data: {
dueDate?: string | null;
isCompleted?: boolean;
}
) {
// Optimistic update - immediately update local state
const originalTask = tasks.find((t) => t.id === id);
if (!originalTask) return;
tasks = tasks.map((t) => (t.id === id ? { ...t, ...data } : t));
try {
// Handle completion state change first
if (data.isCompleted !== undefined && data.isCompleted !== originalTask.isCompleted) {
if (data.isCompleted) {
const updatedTask = await tasksApi.completeTask(id);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
} else {
const updatedTask = await tasksApi.uncompleteTask(id);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
}
}
// Handle due date change
if (data.dueDate !== undefined) {
const updatedTask = await tasksApi.updateTask(id, { dueDate: data.dueDate });
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
}
} catch (e) {
// Rollback on error
console.error('Failed to update task:', e);
tasks = tasks.map((t) => (t.id === id ? originalTask : t));
}
},
/**
* Delete a task
*/

View file

@ -0,0 +1,181 @@
/**
* Task Parser for Todo App
*
* Extends the base parser with task-specific patterns:
* - Priority: !hoch, !!, !!!, !dringend
* - Project: @ProjectName
*/
import {
parseBaseInput,
extractAtReference,
combineDateAndTime,
formatDatePreview,
formatTimePreview,
} from '@manacore/shared-utils';
import type { TaskPriority } from '@todo/shared';
export interface ParsedTask {
title: string;
dueDate?: Date;
priority?: TaskPriority;
projectName?: string;
labelNames: string[];
}
interface Project {
id: string;
name: string;
}
interface Label {
id: string;
name: string;
}
export interface ParsedTaskWithIds {
title: string;
dueDate?: string;
priority?: TaskPriority;
projectId?: string;
labelIds: string[];
}
// Priority patterns (task-specific)
// Supports: später, normal, wichtig, dringend (with or without !) and shortcuts !, !!, !!!
const PRIORITY_PATTERNS: { pattern: RegExp; priority: TaskPriority }[] = [
{ pattern: /!{3,}|!?dringend\b/i, priority: 'urgent' },
{ pattern: /!{2}|!?wichtig\b/i, priority: 'high' },
{ pattern: /!?normal\b/i, priority: 'medium' },
{ pattern: /!?sp[aä]ter\b/i, priority: 'low' },
];
/**
* Extract priority from text
*/
function extractPriority(text: string): { priority?: TaskPriority; remaining: string } {
for (const { pattern, priority } of PRIORITY_PATTERNS) {
if (pattern.test(text)) {
return {
priority,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { priority: undefined, remaining: text };
}
/**
* Parse natural language task input
*
* Examples:
* - "Meeting morgen 14 Uhr !hoch @Arbeit #wichtig"
* - "Einkaufen heute #privat"
* - "Report in 3 Tagen !!"
*/
export function parseTaskInput(input: string): ParsedTask {
let text = input.trim();
// Extract priority first (task-specific)
const priorityResult = extractPriority(text);
text = priorityResult.remaining;
const priority = priorityResult.priority;
// Extract project (@ProjectName) - task-specific
const projectResult = extractAtReference(text);
text = projectResult.remaining;
const projectName = projectResult.value;
// Use base parser for common patterns (date, time, tags)
const base = parseBaseInput(text);
// Combine date and time
const dueDate = combineDateAndTime(base.date, base.time);
return {
title: base.title,
dueDate,
priority,
projectName,
labelNames: base.tagNames,
};
}
/**
* Resolve project and label names to IDs
*/
export function resolveTaskIds(
parsed: ParsedTask,
projects: Project[],
labels: Label[]
): ParsedTaskWithIds {
let projectId: string | undefined;
const labelIds: string[] = [];
// Find project by name (case-insensitive)
if (parsed.projectName) {
const project = projects.find(
(p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase()
);
if (project) {
projectId = project.id;
}
}
// Find labels by name (case-insensitive)
for (const labelName of parsed.labelNames) {
const label = labels.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
if (label) {
labelIds.push(label.id);
}
}
return {
title: parsed.title,
dueDate: parsed.dueDate?.toISOString(),
priority: parsed.priority,
projectId,
labelIds,
};
}
/**
* Format parsed task for preview display
*/
export function formatParsedTaskPreview(parsed: ParsedTask): string {
const parts: string[] = [];
if (parsed.dueDate) {
let dateStr = `📅 ${formatDatePreview(parsed.dueDate)}`;
// Add time if not midnight
if (parsed.dueDate.getHours() !== 0 || parsed.dueDate.getMinutes() !== 0) {
dateStr += ` ${formatTimePreview({
hours: parsed.dueDate.getHours(),
minutes: parsed.dueDate.getMinutes(),
})}`;
}
parts.push(dateStr);
}
if (parsed.priority) {
const priorityLabels: Record<TaskPriority, string> = {
low: '🟢 Später',
medium: '🟡 Normal',
high: '🟠 Wichtig',
urgent: '🔴 Dringend',
};
parts.push(priorityLabels[parsed.priority]);
}
if (parsed.projectName) {
parts.push(`📁 ${parsed.projectName}`);
}
if (parsed.labelNames.length > 0) {
parts.push(`🏷️ ${parsed.labelNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -9,11 +9,13 @@
PillDropdownItem,
CommandBarItem,
QuickAction,
CreatePreview,
} from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import { labelsStore } from '$lib/stores/labels.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { theme } from '$lib/stores/theme';
import {
isSidebarMode as sidebarModeStore,
@ -25,9 +27,11 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { getTasks } from '$lib/api/tasks';
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
// App switcher items
const appItems = getPillAppItems('todo');
@ -69,6 +73,35 @@
goto(`/task/${item.id}`);
}
// CommandBar create - parse input and show preview
function handleCommandBarParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseTaskInput(query);
const preview = formatParsedTaskPreview(parsed);
return {
title: `"${parsed.title}" als Aufgabe erstellen`,
subtitle: preview || 'Neue Aufgabe',
};
}
// CommandBar create - actually create the task
async function handleCommandBarCreate(query: string): Promise<void> {
if (!query.trim()) return;
const parsed = parseTaskInput(query);
const resolved = resolveTaskIds(parsed, projectsStore.projects, labelsStore.labels);
await tasksStore.createTask({
title: resolved.title,
dueDate: resolved.dueDate,
priority: resolved.priority,
projectId: resolved.projectId,
labelIds: resolved.labelIds,
});
}
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
@ -124,31 +157,33 @@
{ href: '/', label: 'Aufgaben', icon: 'list' },
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
{ href: '/labels', label: 'Labels', icon: 'tag' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation items (base items + dynamic label items in sidebar mode)
// Navigation items (base items + dynamic label items in sidebar mode, filtered by visibility settings)
const navItems = $derived.by(() => {
// In sidebar mode, add labels as sub-items if available
// Start with base items, filter out hidden ones
let items = filterHiddenNavItems('todo', baseNavItems, userSettings.nav.hiddenNavItems);
// In sidebar mode, add tags as sub-items if available
if (isSidebarMode && labelsStore.labels.length > 0) {
const labelItems: PillNavItem[] = labelsStore.labels.slice(0, 5).map((label) => ({
href: `/label/${label.id}`,
const tagItems: PillNavItem[] = labelsStore.labels.slice(0, 5).map((label) => ({
href: `/tag/${label.id}`,
label: label.name,
icon: 'tag',
}));
// Insert label items after "Labels" nav item
const items = [...baseNavItems];
const labelsIndex = items.findIndex((i) => i.href === '/labels');
if (labelsIndex !== -1 && labelItems.length > 0) {
items.splice(labelsIndex + 1, 0, ...labelItems);
// Insert tag items after "Tags" nav item
const tagsIndex = items.findIndex((i) => i.href === '/tags');
if (tagsIndex !== -1 && tagItems.length > 0) {
items = [...items];
items.splice(tagsIndex + 1, 0, ...tagItems);
}
return items;
}
return baseNavItems;
return items;
});
// Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts
@ -183,16 +218,20 @@
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('todo-nav-sidebar', String(isSidebar));
try {
localStorage?.setItem('todo-nav-sidebar', String(isSidebar));
} catch {
// localStorage not available or quota exceeded
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('todo-nav-collapsed', String(collapsed));
try {
localStorage?.setItem('todo-nav-collapsed', String(collapsed));
} catch {
// localStorage not available or quota exceeded
}
}
@ -231,18 +270,26 @@
goto(userSettings.startPage, { replaceState: true });
}
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('todo-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
// Initialize sidebar mode from localStorage (with error handling for private browsing)
try {
const savedSidebar = localStorage?.getItem('todo-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
} catch {
// localStorage not available (private browsing, quota exceeded, etc.)
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('todo-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
try {
const savedCollapsed = localStorage?.getItem('todo-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
} catch {
// localStorage not available
}
// Register Service Worker for PWA
@ -326,9 +373,13 @@
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Aufgabe suchen..."
placeholder="Aufgabe suchen oder erstellen..."
emptyText="Keine Aufgaben gefunden"
searchingText="Suche..."
onCreate={handleCommandBarCreate}
onParseCreate={handleCommandBarParseCreate}
createText="Als Aufgabe erstellen"
createShortcut="⌘↵"
/>
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { format, addDays, startOfDay } from 'date-fns';
import { format, addDays, subDays, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { ListChecks } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
@ -12,7 +12,7 @@
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
import TaskEditModal from '$lib/components/TaskEditModal.svelte';
import { TaskListSkeleton } from '$lib/components/skeletons';
import type { Task } from '@todo/shared';
import type { Task, UpdateTaskInput } from '@todo/shared';
let isLoading = $state(true);
let editingTask = $state<Task | null>(null);
@ -39,13 +39,24 @@
let todayTasks = $derived(tasksStore.todayTasks);
let completedTasks = $derived(tasksStore.completedTasks);
// Group upcoming tasks by day
// Tomorrow's tasks
let tomorrowDate = $derived(addDays(startOfDay(new Date()), 1));
let dayAfterTomorrowDate = $derived(addDays(startOfDay(new Date()), 2));
let tomorrowTasks = $derived(
tasksStore.tasks.filter((task) => {
if (!task.dueDate || task.isCompleted) return false;
const taskDate = startOfDay(new Date(task.dueDate));
return taskDate.getTime() === tomorrowDate.getTime();
})
);
// Group upcoming tasks by day (starting from day after tomorrow)
let groupedUpcomingTasks = $derived(() => {
const groups: { date: Date; label: string; tasks: Task[] }[] = [];
const today = startOfDay(new Date());
// Start from tomorrow (day 1) through day 7
for (let i = 1; i <= 7; i++) {
// Start from day after tomorrow (day 2) through day 7
for (let i = 2; i <= 7; i++) {
const date = addDays(today, i);
const dayTasks = tasksStore.tasks.filter((task) => {
if (!task.dueDate || task.isCompleted) return false;
@ -54,13 +65,7 @@
});
if (dayTasks.length > 0) {
let label: string;
if (i === 1) {
label = 'Morgen';
} else {
label = format(date, 'EEEE, d. MMMM', { locale: de });
}
const label = format(date, 'EEEE, d. MMMM', { locale: de });
groups.push({ date, label, tasks: dayTasks });
}
}
@ -68,7 +73,7 @@
return groups;
});
// Total upcoming count
// Total upcoming count (excluding tomorrow)
let upcomingCount = $derived(
groupedUpcomingTasks().reduce((sum, group) => sum + group.tasks.length, 0)
);
@ -77,6 +82,7 @@
let allEmpty = $derived(
overdueTasks.length === 0 &&
todayTasks.length === 0 &&
tomorrowTasks.length === 0 &&
upcomingCount === 0 &&
completedTasks.length === 0
);
@ -90,7 +96,7 @@
editingTask = null;
}
async function handleSaveTask(data: Partial<Task>) {
async function handleSaveTask(data: UpdateTaskInput) {
if (!editingTask) return;
try {
@ -98,8 +104,8 @@
await tasksStore.updateTask(editingTask.id, data);
// Update labels if provided
if ('labelIds' in data) {
await tasksStore.updateLabels(editingTask.id, (data as any).labelIds);
if (data.labelIds !== undefined) {
await tasksStore.updateLabels(editingTask.id, data.labelIds);
}
closeEditModal();
@ -116,6 +122,32 @@
console.error('Failed to delete task:', error);
}
}
// Drag and drop handler - uses optimistic updates for smooth UX
function handleTaskDrop(taskId: string, targetDate: Date | 'completed' | 'overdue') {
const task = tasksStore.tasks.find((t) => t.id === taskId);
if (!task) return;
if (targetDate === 'completed') {
// Mark task as completed (optimistic)
if (!task.isCompleted) {
tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
}
} else if (targetDate === 'overdue') {
// Set to yesterday (optimistic)
const yesterday = subDays(startOfDay(new Date()), 1);
tasksStore.updateTaskOptimistic(taskId, {
dueDate: yesterday.toISOString(),
isCompleted: task.isCompleted ? false : undefined,
});
} else {
// Set to specific date (optimistic)
tasksStore.updateTaskOptimistic(taskId, {
dueDate: targetDate.toISOString(),
isCompleted: task.isCompleted ? false : undefined,
});
}
}
</script>
<svelte:head>
@ -155,7 +187,13 @@
variant="warning"
defaultOpen={true}
>
<TaskList tasks={overdueTasks} onEditTask={openEditModal} />
<TaskList
tasks={overdueTasks}
enableDragDrop
dropTargetDate="overdue"
onTaskDrop={handleTaskDrop}
onEditTask={openEditModal}
/>
</CollapsibleSection>
{/if}
@ -167,13 +205,30 @@
variant="default"
defaultOpen={true}
>
{#if todayTasks.length === 0}
<div class="text-center py-6 text-muted-foreground">
<p>Keine Aufgaben für heute</p>
</div>
{:else}
<TaskList tasks={todayTasks} onEditTask={openEditModal} />
{/if}
<TaskList
tasks={todayTasks}
enableDragDrop
dropTargetDate={startOfDay(new Date())}
onTaskDrop={handleTaskDrop}
onEditTask={openEditModal}
/>
</CollapsibleSection>
<!-- Tomorrow Section -->
<CollapsibleSection
title="Morgen"
count={tomorrowTasks.length}
icon="upcoming"
variant="default"
defaultOpen={true}
>
<TaskList
tasks={tomorrowTasks}
enableDragDrop
dropTargetDate={tomorrowDate}
onTaskDrop={handleTaskDrop}
onEditTask={openEditModal}
/>
</CollapsibleSection>
<!-- Upcoming Section -->
@ -184,39 +239,49 @@
variant="default"
defaultOpen={true}
>
{#if upcomingCount === 0}
<div class="text-center py-6 text-muted-foreground">
<p>Keine anstehenden Aufgaben</p>
</div>
{:else}
<div class="space-y-4">
{#each groupedUpcomingTasks() as group}
<div>
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
{group.label} ({group.tasks.length})
</h3>
<TaskList tasks={group.tasks} onEditTask={openEditModal} />
</div>
{/each}
</div>
{/if}
<div class="space-y-4">
{#each groupedUpcomingTasks() as group}
<div>
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
{group.label} ({group.tasks.length})
</h3>
<TaskList
tasks={group.tasks}
enableDragDrop
dropTargetDate={group.date}
onTaskDrop={handleTaskDrop}
onEditTask={openEditModal}
/>
</div>
{/each}
{#if upcomingCount === 0}
<!-- Empty drop zone for day after tomorrow -->
<TaskList
tasks={[]}
enableDragDrop
dropTargetDate={dayAfterTomorrowDate}
onTaskDrop={handleTaskDrop}
/>
{/if}
</div>
</CollapsibleSection>
<!-- Completed Section - collapsed by default -->
<!-- Completed Section -->
<CollapsibleSection
title="Erledigt"
count={completedTasks.length}
icon="completed"
variant="success"
defaultOpen={false}
defaultOpen={true}
>
{#if completedTasks.length === 0}
<div class="text-center py-6 text-muted-foreground">
<p>Noch keine erledigten Aufgaben</p>
</div>
{:else}
<TaskList tasks={completedTasks} showCompleted onEditTask={openEditModal} />
{/if}
<TaskList
tasks={completedTasks}
enableDragDrop
dropTargetDate="completed"
onTaskDrop={handleTaskDrop}
showCompleted
onEditTask={openEditModal}
/>
</CollapsibleSection>
</div>
{/if}

View file

@ -1,314 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { labelsStore } from '$lib/stores/labels.svelte';
import type { Label } from '@todo/shared';
let searchQuery = $state('');
let showModal = $state(false);
let editingLabel = $state<Label | null>(null);
const filteredLabels = $derived.by(() => {
if (!searchQuery.trim()) return labelsStore.labels;
const query = searchQuery.toLowerCase();
return labelsStore.labels.filter((l) => l.name.toLowerCase().includes(query));
});
// Convert Label to Tag type for shared-ui components
function labelToTag(label: Label): Tag {
return {
id: label.id,
name: label.name,
color: label.color,
};
}
function openCreateModal() {
editingLabel = null;
showModal = true;
}
function openEditModal(tag: Tag) {
const label = labelsStore.labels.find((l) => l.id === tag.id);
if (label) {
editingLabel = label;
showModal = true;
}
}
function closeModal() {
showModal = false;
editingLabel = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingLabel) {
await labelsStore.updateLabel(editingLabel.id, { name, color });
} else {
await labelsStore.createLabel({ name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save label:', e);
}
}
async function handleDelete() {
if (!editingLabel) return;
try {
await labelsStore.deleteLabel(editingLabel.id);
closeModal();
} catch (e) {
console.error('Failed to delete label:', e);
}
}
async function handleDeleteFromList(tag: Tag) {
if (!confirm(`Label "${tag.name}" wirklich löschen?`)) return;
try {
await labelsStore.deleteLabel(tag.id);
} catch (e) {
console.error('Failed to delete label:', e);
}
}
function handleLabelClick(tag: Tag) {
goto(`/label/${tag.id}`);
}
onMount(() => {
if (labelsStore.labels.length === 0) {
labelsStore.fetchLabels();
}
});
</script>
<svelte:head>
<title>Labels - Todo</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label="Zurück">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Labels</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neues Label">
<Plus size={20} weight="bold" />
</button>
</header>
<!-- Search -->
<div class="search-wrapper">
<MagnifyingGlass size={20} class="search-icon" />
<input
type="text"
placeholder="Labels durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
</div>
{#if labelsStore.error}
<div class="error-banner" role="alert">
<span>{labelsStore.error}</span>
</div>
{/if}
<!-- Label List using shared component -->
<TagList
tags={filteredLabels.map(labelToTag)}
loading={labelsStore.loading}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
onClick={handleLabelClick}
emptyMessage={searchQuery ? 'Keine Labels gefunden' : 'Keine Labels vorhanden'}
emptyDescription={searchQuery
? `Kein Label für "${searchQuery}" gefunden`
: 'Erstelle dein erstes Label'}
/>
{#if !labelsStore.loading && labelsStore.labels.length > 0}
<p class="labels-count">
{labelsStore.labels.length}
{labelsStore.labels.length === 1 ? 'Label' : 'Labels'}
</p>
{/if}
{#if !labelsStore.loading && labelsStore.labels.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neues Label
</button>
</div>
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingLabel ? labelToTag(editingLabel) : null}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingLabel ? handleDelete : undefined}
title={editingLabel ? 'Label bearbeiten' : 'Neues Label'}
saveLabel={editingLabel ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Label Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Label "${editingLabel?.name || ''}" wirklich löschen?`}
/>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
/* Search */
.search-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.875rem 1rem 0.875rem 3rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1.5rem;
}
/* Count */
.labels-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
/* Empty CTA */
.empty-cta {
display: flex;
justify-content: center;
margin-top: 1rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
</style>

View file

@ -6,6 +6,7 @@
import { todoSettings, type TodoView, type KanbanCardSize } from '$lib/stores/settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import {
SettingsPage,
SettingsSection,
@ -20,13 +21,8 @@
GlobalSettingsSection,
} from '@manacore/shared-ui';
// Options for selects
const priorityOptions = [
{ value: 'low', label: 'Niedrig' },
{ value: 'medium', label: 'Mittel' },
{ value: 'high', label: 'Hoch' },
{ value: 'urgent', label: 'Dringend' },
];
// Use shared priority options (without color)
const priorityOptions = PRIORITY_OPTIONS.map((p) => ({ value: p.value, label: p.label }));
const viewOptions = [
{ value: 'inbox', label: 'Inbox' },
@ -129,7 +125,20 @@
</SettingsSection>
<!-- Global Settings Section (synced across all apps) -->
<GlobalSettingsSection {userSettings} appId="todo" />
<GlobalSettingsSection
{userSettings}
appId="todo"
navItems={[
{ href: '/', label: 'Aufgaben', icon: 'list' },
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
]}
alwaysVisibleHrefs={['/', '/settings']}
/>
<!-- Task Behavior Section -->
<SettingsSection title="Task-Verhalten">

View file

@ -15,16 +15,16 @@
let editingTask = $state<Task | null>(null);
let showEditModal = $state(false);
// Get label ID from URL
const labelId = $derived($page.params.id ?? '');
// Get tag ID from URL
const tagId = $derived($page.params.id ?? '');
// Get label from store
const label = $derived(labelsStore.getById(labelId));
// Get tag from store
const tag = $derived(labelsStore.getById(tagId));
// Get tasks with this label
const labelTasks = $derived(labelId ? tasksStore.getTasksByLabel(labelId) : []);
const incompleteTasks = $derived(labelTasks.filter((t) => !t.isCompleted));
const completedTasks = $derived(labelTasks.filter((t) => t.isCompleted));
// Get tasks with this tag
const tagTasks = $derived(tagId ? tasksStore.getTasksByLabel(tagId) : []);
const incompleteTasks = $derived(tagTasks.filter((t) => !t.isCompleted));
const completedTasks = $derived(tagTasks.filter((t) => t.isCompleted));
onMount(async () => {
if (!authStore.isAuthenticated) {
@ -33,12 +33,12 @@
}
try {
// Ensure labels are loaded
// Ensure tags are loaded
if (labelsStore.labels.length === 0) {
await labelsStore.fetchLabels();
}
// Fetch all tasks to filter by label
// Fetch all tasks to filter by tag
await tasksStore.fetchAllTasks();
} catch (error) {
console.error('Failed to load data:', error);
@ -88,7 +88,7 @@
</script>
<svelte:head>
<title>{label?.name || 'Label'} - Todo</title>
<title>{tag?.name || 'Tag'} - Todo</title>
</svelte:head>
<div class="page-container">
@ -98,39 +98,39 @@
<CaretLeft size={20} weight="bold" />
</a>
<div class="header-content">
{#if label}
<div class="label-icon" style="background-color: {label.color}20">
<div class="label-dot" style="background-color: {label.color}"></div>
{#if tag}
<div class="tag-icon" style="background-color: {tag.color}20">
<div class="tag-dot" style="background-color: {tag.color}"></div>
</div>
<h1 class="title">{label.name}</h1>
<h1 class="title">{tag.name}</h1>
{:else}
<h1 class="title">Label</h1>
<h1 class="title">Tag</h1>
{/if}
</div>
<a href="/labels" class="manage-button" aria-label="Labels verwalten">
<a href="/tags" class="manage-button" aria-label="Tags verwalten">
<Tag size={20} weight="bold" />
</a>
</header>
{#if isLoading}
<TaskListSkeleton />
{:else if !label}
{:else if !tag}
<div class="empty-state">
<div class="empty-icon">
<Tag size={40} weight="light" />
</div>
<h2 class="empty-title">Label nicht gefunden</h2>
<p class="empty-description">Dieses Label existiert nicht mehr.</p>
<a href="/labels" class="btn btn-primary">Zu den Labels</a>
<h2 class="empty-title">Tag nicht gefunden</h2>
<p class="empty-description">Dieser Tag existiert nicht mehr.</p>
<a href="/tags" class="btn btn-primary">Zu den Tags</a>
</div>
{:else if labelTasks.length === 0}
{:else if tagTasks.length === 0}
<div class="empty-state">
<div class="empty-icon" style="background-color: {label.color}20">
<Tag size={40} weight="light" style="color: {label.color}" />
<div class="empty-icon" style="background-color: {tag.color}20">
<Tag size={40} weight="light" style="color: {tag.color}" />
</div>
<h2 class="empty-title">Keine Aufgaben</h2>
<p class="empty-description">
Es gibt keine Aufgaben mit dem Label "{label.name}".
Es gibt keine Aufgaben mit dem Tag "{tag.name}".
</p>
<a href="/" class="btn btn-primary">Aufgabe erstellen</a>
</div>
@ -156,8 +156,8 @@
{/if}
<p class="task-count">
{labelTasks.length}
{labelTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'}
{tagTasks.length}
{tagTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'}
</p>
{/if}
</div>
@ -196,7 +196,7 @@
gap: 0.75rem;
}
.label-icon {
.tag-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
@ -205,7 +205,7 @@
justify-content: center;
}
.label-dot {
.tag-dot {
width: 1rem;
height: 1rem;
border-radius: 50%;

View file

@ -0,0 +1,502 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, ConfirmationModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft, Check } from '@manacore/shared-icons';
import { labelsStore } from '$lib/stores/labels.svelte';
import type { Label } from '@todo/shared';
let searchQuery = $state('');
let showModal = $state(false);
let editingLabel = $state<Label | null>(null);
let showDeleteConfirm = $state(false);
let labelToDelete = $state<Tag | null>(null);
// Inline create state
let newTagName = $state('');
let newTagColor = $state('#8b5cf6');
let isCreating = $state(false);
let newTagInputRef = $state<HTMLInputElement | null>(null);
// Predefined color palette
const colorPalette = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#6b7280', // gray
];
const filteredLabels = $derived.by(() => {
if (!searchQuery.trim()) return labelsStore.labels;
const query = searchQuery.toLowerCase();
return labelsStore.labels.filter((l) => l.name.toLowerCase().includes(query));
});
// Convert Label to Tag type for shared-ui components
function labelToTag(label: Label): Tag {
return {
id: label.id,
name: label.name,
color: label.color,
};
}
// Inline create handlers
async function handleInlineCreate() {
if (!newTagName.trim() || isCreating) return;
isCreating = true;
try {
await labelsStore.createLabel({ name: newTagName.trim(), color: newTagColor });
newTagName = '';
newTagColor = '#8b5cf6';
} catch (e) {
console.error('Failed to create tag:', e);
} finally {
isCreating = false;
}
}
function handleInlineKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && newTagName.trim()) {
e.preventDefault();
handleInlineCreate();
}
}
function openEditModal(tag: Tag) {
const label = labelsStore.labels.find((l) => l.id === tag.id);
if (label) {
editingLabel = label;
showModal = true;
}
}
function closeModal() {
showModal = false;
editingLabel = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingLabel) {
await labelsStore.updateLabel(editingLabel.id, { name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save label:', e);
}
}
async function handleDelete() {
if (!editingLabel) return;
try {
await labelsStore.deleteLabel(editingLabel.id);
closeModal();
} catch (e) {
console.error('Failed to delete label:', e);
}
}
function handleDeleteFromList(tag: Tag) {
labelToDelete = tag;
showDeleteConfirm = true;
}
async function confirmDeleteLabel() {
if (!labelToDelete) return;
try {
await labelsStore.deleteLabel(labelToDelete.id);
} catch (e) {
console.error('Failed to delete label:', e);
} finally {
showDeleteConfirm = false;
labelToDelete = null;
}
}
function handleTagClick(tag: Tag) {
goto(`/tag/${tag.id}`);
}
onMount(() => {
if (labelsStore.labels.length === 0) {
labelsStore.fetchLabels();
}
});
</script>
<svelte:head>
<title>Tags - Todo</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label="Zurück">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>
</header>
<!-- Inline Create Form -->
<div class="inline-create-form">
<div class="create-input-row">
<!-- Name input -->
<input
bind:this={newTagInputRef}
type="text"
placeholder="Neuer Tag..."
bind:value={newTagName}
onkeydown={handleInlineKeydown}
class="create-input"
disabled={isCreating}
/>
<!-- Submit button -->
<button
type="button"
class="create-button"
onclick={handleInlineCreate}
disabled={!newTagName.trim() || isCreating}
aria-label="Tag erstellen"
>
{#if isCreating}
<div class="spinner"></div>
{:else}
<Plus size={18} weight="bold" />
{/if}
</button>
</div>
<!-- Color palette (always visible) -->
<div class="color-row">
<div class="color-palette">
{#each colorPalette as color}
<button
type="button"
class="color-option"
class:active={newTagColor === color}
style="background-color: {color}"
onclick={() => (newTagColor = color)}
aria-label="Farbe {color}"
></button>
{/each}
</div>
<!-- Preview -->
{#if newTagName.trim()}
<div class="create-preview">
<span class="preview-tag" style="--tag-color: {newTagColor}">
{newTagName}
</span>
</div>
{/if}
</div>
</div>
<!-- Search -->
<div class="search-wrapper">
<MagnifyingGlass size={20} class="search-icon" />
<input
type="text"
placeholder="Tags durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
</div>
{#if labelsStore.error}
<div class="error-banner" role="alert">
<span>{labelsStore.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredLabels.map(labelToTag)}
loading={labelsStore.loading}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
onClick={handleTagClick}
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
emptyDescription={searchQuery
? `Kein Tag für "${searchQuery}" gefunden`
: 'Erstelle deinen ersten Tag'}
/>
{#if !labelsStore.loading && labelsStore.labels.length > 0}
<p class="tags-count">
{labelsStore.labels.length}
{labelsStore.labels.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingLabel ? labelToTag(editingLabel) : null}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingLabel ? handleDelete : undefined}
title={editingLabel ? 'Tag bearbeiten' : 'Neuer Tag'}
saveLabel={editingLabel ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Tag Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Tag "${editingLabel?.name || ''}" wirklich löschen?`}
/>
<!-- Delete confirmation modal -->
<ConfirmationModal
visible={showDeleteConfirm}
onClose={() => {
showDeleteConfirm = false;
labelToDelete = null;
}}
onConfirm={confirmDeleteLabel}
variant="danger"
title="Tag löschen?"
message={`Der Tag "${labelToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`}
confirmLabel="Löschen"
cancelLabel="Abbrechen"
/>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
/* Inline Create Form */
.inline-create-form {
margin-bottom: 1.5rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
}
.create-input-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Color Row */
.color-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.75rem;
}
.color-palette {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.color-option {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.color-option:hover {
transform: scale(1.15);
}
.color-option.active {
border-color: hsl(var(--foreground));
box-shadow: 0 0 0 2px hsl(var(--background));
}
/* Create Input */
.create-input {
flex: 1;
padding: 0.625rem 1rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.create-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
.create-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.create-input::placeholder {
color: hsl(var(--muted-foreground));
}
/* Create Button */
.create-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.create-button:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
.create-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Spinner */
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid hsl(var(--primary-foreground) / 0.3);
border-top-color: hsl(var(--primary-foreground));
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Preview */
.create-preview {
display: flex;
align-items: center;
flex-shrink: 0;
}
.preview-tag {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 9999px;
background: var(--tag-color);
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Search */
.search-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.875rem 1rem 0.875rem 3rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1.5rem;
}
/* Count */
.tags-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
</style>

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'todo-v1';
const CACHE_NAME = 'todo-v2';
const OFFLINE_URL = '/offline.html';
// Assets, die immer gecacht werden sollen
@ -8,23 +8,16 @@ const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.j
const CACHE_STRATEGIES = {
// Netzwerk zuerst, dann Cache (für HTML/Navigation)
networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/],
// Cache zuerst, dann Netzwerk (für Assets)
// Cache zuerst, dann Netzwerk (für Assets) - nur für gebaute Assets, nicht /src/
cacheFirst: [
/\.css$/,
/\.js$/,
/\/_app\//, // SvelteKit gebaute Assets
/\.woff2?$/,
/\.ttf$/,
/\.otf$/,
/\.svg$/,
/\.png$/,
/\.jpg$/,
/\.jpeg$/,
/\.webp$/,
/\.ico$/,
/\/_app\//,
],
// Nur Netzwerk (für API-Calls)
networkOnly: [/\/api\//, /localhost:3018/],
// Nur Netzwerk (für API-Calls und Dev-Server)
networkOnly: [/\/api\//, /localhost:3018/, /^\/src\//, /^\/@/, /^\/node_modules\//],
};
// Service Worker Installation

View file

@ -69,6 +69,9 @@ export const REMINDER_PRESETS = [
{ label: '1 week before', minutes: 10080 },
] as const;
// Re-export task-specific constants (German localized versions)
export * from './task';
// View types
export type ViewType =
| 'inbox'

View file

@ -0,0 +1,55 @@
import type { TaskPriority, TaskStatus } from '../types/task';
export interface PriorityOption {
value: TaskPriority;
label: string;
color: string;
}
export interface StatusOption {
value: TaskStatus;
label: string;
}
export interface RecurrenceOption {
value: string;
label: string;
}
export const PRIORITY_OPTIONS: PriorityOption[] = [
{ value: 'low', label: 'Später', color: '#22c55e' },
{ value: 'medium', label: 'Normal', color: '#eab308' },
{ value: 'high', label: 'Wichtig', color: '#f97316' },
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
];
export const STATUS_OPTIONS: StatusOption[] = [
{ value: 'pending', label: 'Offen' },
{ value: 'in_progress', label: 'In Arbeit' },
{ value: 'completed', label: 'Erledigt' },
{ value: 'cancelled', label: 'Abgebrochen' },
];
export const RECURRENCE_OPTIONS: RecurrenceOption[] = [
{ value: '', label: 'Keine Wiederholung' },
{ value: 'FREQ=DAILY', label: 'Täglich' },
{ value: 'FREQ=WEEKLY', label: 'Wöchentlich' },
{ value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Alle 2 Wochen' },
{ value: 'FREQ=MONTHLY', label: 'Monatlich' },
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
];
// Fibonacci sequence for story points
export const STORYPOINT_OPTIONS = [1, 2, 3, 5, 8, 13, 21] as const;
// Helper to get priority label
export function getPriorityLabel(priority: TaskPriority): string {
const option = PRIORITY_OPTIONS.find((p) => p.value === priority);
return option?.label ?? priority;
}
// Helper to get status label
export function getStatusLabel(status: TaskStatus): string {
const option = STATUS_OPTIONS.find((s) => s.value === status);
return option?.label ?? status;
}

View file

@ -108,6 +108,7 @@ export interface UpdateTaskInput {
recurrenceEndDate?: string | null;
subtasks?: Subtask[] | null;
metadata?: TaskMetadata | null;
labelIds?: string[];
}
export interface QueryTasksInput {

View file

@ -14,6 +14,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -77,8 +78,8 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Zitare
const navItems: PillNavItem[] = [
// Base navigation items for Zitare
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Zitate', icon: 'document' },
{ href: '/search', label: 'Suche', icon: 'search' },
{ href: '/authors', label: 'Autoren', icon: 'users' },
@ -87,8 +88,13 @@
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-6)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('zitare', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -4,11 +4,11 @@ Dokumentation des Git-Workflows für das ManaCore Monorepo.
## Branch-Struktur
| Branch | Zweck |
|--------|-------|
| `main` | Produktion - stabile Releases |
| `dev` | Entwicklung - Integration aller Features |
| `till-dev`, `{name}-dev` | Persönliche Entwicklungs-Branches |
| Branch | Zweck |
| ------------------------------ | ---------------------------------------- |
| `main` | Produktion - stabile Releases |
| `dev` | Entwicklung - Integration aller Features |
| `till-dev`, `{name}-dev` | Persönliche Entwicklungs-Branches |
## Workflow-Übersicht
@ -19,7 +19,7 @@ main (Produktion)
dev (Integration)
│ PR (squashed)
│ PR (einzelne Commits behalten)
till-dev (Feature-Entwicklung)
```
@ -32,37 +32,16 @@ till-dev (Feature-Entwicklung)
# Sicherstellen, dass du auf deinem Branch bist
git checkout till-dev
# Änderungen committen (viele kleine Commits sind OK)
# Änderungen committen - kleine, aussagekräftige Commits
git add .
git commit -m "feat(app): add feature X"
git commit -m "fix(app): fix bug Y"
git commit -m "refactor(app): cleanup Z"
```
### 2. Vor dem PR: Commits squashen
### 2. Regelmäßig mit dev synchronisieren
Wenn viele Commits angesammelt sind, sollten diese vor dem PR gequasht werden, um:
- Merge-Konflikte zu minimieren
- Die Git-History sauber zu halten
- Code-Reviews zu vereinfachen
```bash
# Anzahl der Commits seit dev zählen
git log --oneline origin/dev..HEAD | wc -l
# Alle Commits seit dev zu einem squashen
git reset --soft origin/dev
# Einen neuen, zusammengefassten Commit erstellen
git commit -m "feat: descriptive summary of all changes"
# Force-Push zum Remote (überschreibt alte Commits)
git push --force-with-lease
```
### 3. Rebase mit dev (falls nötig)
Falls `dev` sich geändert hat, muss rebased werden:
Halte deinen Branch aktuell, um große Konflikte zu vermeiden:
```bash
# Neuesten Stand von dev holen
@ -71,16 +50,17 @@ git fetch origin dev
# Rebase durchführen
git rebase origin/dev
# Bei Konflikten:
# 1. Konflikte lösen
# Bei Konflikten: Jeden Commit einzeln lösen
# 1. Konflikte in den angezeigten Dateien lösen
# 2. git add <resolved-files>
# 3. git rebase --continue
# 4. Wiederholen bis alle Commits durchlaufen sind
# Nach erfolgreichem Rebase pushen
git push --force-with-lease
```
### 4. Pull Request erstellen
### 3. Pull Request erstellen
```bash
gh pr create --base dev --head till-dev \
@ -94,45 +74,37 @@ gh pr create --base dev --head till-dev \
- [ ] Test case 2"
```
## Squash-Strategie
## Konflikt-Lösung beim Rebase
### Wann squashen?
### Allgemeiner Ablauf
| Situation | Aktion |
|-----------|--------|
| Vor jedem PR | Immer squashen |
| Bei vielen kleinen Commits (10+) | Squashen empfohlen |
| Bei Rebase-Konflikten | Erst squashen, dann rebasen |
| Tägliche Arbeit | Kleine Commits OK |
Bei einem Rebase werden Commits einzeln auf den neuen Base-Branch angewendet. Konflikte müssen für jeden Commit separat gelöst werden - das gibt mehr Kontext und macht die Lösung einfacher.
### Squash-Commit-Message Format
```bash
# Rebase starten
git rebase origin/dev
# Bei Konflikt: Status prüfen
git status # Zeigt konfliktbehaftete Dateien
# Konflikte lösen, dann:
git add <resolved-files>
git rebase --continue
# Nächster Commit wird angewendet...
# Wiederholen bis fertig
```
feat: kurze Zusammenfassung (max 50 Zeichen)
## Neue Features
- Feature 1: Beschreibung
- Feature 2: Beschreibung
## Bug Fixes
- Fix 1: Beschreibung
## Breaking Changes (falls vorhanden)
- Breaking Change 1
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
## Konflikt-Lösung
### Einfache Konflikte
```bash
# Konflikt in einer Datei
git checkout --ours path/to/file # Unsere Version behalten
git checkout --theirs path/to/file # Ihre Version behalten
# Unsere Version behalten (die aus dem Feature-Branch)
git checkout --ours path/to/file
# Ihre Version behalten (die aus dev)
git checkout --theirs path/to/file
# Nach der Wahl:
git add path/to/file
git rebase --continue
```
@ -142,7 +114,7 @@ git rebase --continue
Diese Datei sollte nie manuell gemerged werden:
```bash
# Ihre Version nehmen und neu installieren
# Version aus dev nehmen und neu installieren
git checkout --theirs pnpm-lock.yaml
pnpm install --frozen-lockfile=false
git add pnpm-lock.yaml
@ -169,15 +141,15 @@ git rebase --abort # Zurück zum Zustand vor dem Rebase
Verwende [Conventional Commits](https://www.conventionalcommits.org/):
| Prefix | Verwendung |
|--------|------------|
| `feat` | Neue Features |
| `fix` | Bug Fixes |
| `docs` | Dokumentation |
| `style` | Formatting (kein Code-Change) |
| `refactor` | Code-Refactoring |
| `test` | Tests hinzufügen/ändern |
| `chore` | Build, CI, Dependencies |
| Prefix | Verwendung |
| ---------- | ----------------------------- |
| `feat` | Neue Features |
| `fix` | Bug Fixes |
| `docs` | Dokumentation |
| `style` | Formatting (kein Code-Change) |
| `refactor` | Code-Refactoring |
| `test` | Tests hinzufügen/ändern |
| `chore` | Build, CI, Dependencies |
### Scope (optional)
@ -187,6 +159,13 @@ fix(calendar): fix event drag and drop
docs(readme): update installation guide
```
### Kleine, fokussierte Commits
- Ein Commit = eine logische Änderung
- Aussagekräftige Commit Messages
- Leichter zu reviewen und bei Problemen zu debuggen
- Einzelne Commits können bei Bedarf reverted werden
### Branch-Hygiene
```bash
@ -202,23 +181,23 @@ git fetch --prune
```bash
# 1. Feature entwickeln
git checkout till-dev
# ... viele Commits über mehrere Tage ...
git commit -m "feat(network): add D3 force simulation"
git commit -m "feat(network): add zoom and pan controls"
git commit -m "fix(network): fix node positioning on load"
git commit -m "docs(network): add keyboard shortcuts help"
# 2. Vor PR: Status prüfen
# 2. Vor PR: Mit dev synchronisieren
git fetch origin dev
git log --oneline origin/dev..HEAD # 54 commits
git rebase origin/dev
# Konflikte einzeln lösen falls nötig...
# 3. Squashen
git reset --soft origin/dev
git commit -m "feat: major update with network graphs, themes, and more"
# 4. Pushen
# 3. Pushen
git push --force-with-lease
# 5. PR erstellen
gh pr create --base dev --head till-dev --title "feat: major update"
# 4. PR erstellen
gh pr create --base dev --head till-dev --title "feat(network): add network graph visualization"
# 6. Nach Merge: Branch aktualisieren
# 5. Nach Merge: Branch aktualisieren
git fetch origin dev
git checkout till-dev
git reset --hard origin/dev
@ -301,6 +280,14 @@ git reflog
git reset --hard HEAD@{2}
```
### Viele Konflikte beim Rebase
Wenn zu viele Konflikte auftreten:
1. `git rebase --abort` - Rebase abbrechen
2. Regelmäßiger rebasen (täglich/wöchentlich)
3. Bei sehr alten Branches: Mit dem Team absprechen
---
*Zuletzt aktualisiert: 10.12.2025*

View file

@ -0,0 +1,272 @@
# Code Review: PR #14
**Title:** feat: major update with network graphs, themes, todo extensions, and more
**Author:** Till-JS
**Date:** 2025-12-10
**Status:** OPEN
**URL:** https://github.com/Memo-2023/manacore-monorepo/pull/14
---
## Summary
| Metric | Value |
|--------|-------|
| Files Changed | 382 |
| Additions | +39,514 |
| Deletions | -6,251 |
---
## Overview
This is a **major feature release** introducing:
1. **Network Graph Visualization** - D3.js force-directed graphs for Contacts, Calendar, and Todo apps
2. **Central Tags API** - Unified tagging system in mana-core-auth
3. **Custom Themes System** - Theme editor, community gallery, and sharing
4. **Todo App Extensions** - Kanban boards, statistics, settings page, PWA support
5. **Contacts App Features** - Duplicate detection, photo upload, batch operations, favorites views
6. **Help System** - Shared packages for content, UI, and types (`shared-help-content`, `shared-help-ui`, `shared-help-types`, `shared-help-mobile`)
7. **Skeleton Loaders** - Better loading states across apps
8. **CommandBar** - Global search (Cmd+K)
9. **Bug Fixes** - Network graph simulation fixes, database schema TEXT for user_id
---
## Code Quality Analysis
### Strengths
#### 1. Excellent Architecture
- Clean separation of concerns with shared packages (`shared-ui`, `shared-theme`, `shared-tags`, `shared-help-*`)
- Proper Svelte 5 runes usage (`$state`, `$derived`, `$effect`)
- Good TypeScript typing throughout
#### 2. NetworkGraph Component (`packages/shared-ui/src/organisms/network/`)
- Well-structured D3.js integration with `d3-zoom` and `d3-selection`
- Proper zoom/pan handling
- Keyboard shortcuts implemented:
- `+`/`-` for zoom in/out
- `0` to reset zoom
- `Esc` to deselect
- `F` to focus on selected node
- `/` to focus search
- Accessible with `role="button"`, `aria-label`, `tabindex`
- Efficient re-rendering with proper state management
#### 3. Tags Service (`services/mana-core-auth/src/tags/`)
- Proper validation (duplicate name check before create/update)
- Good use of Drizzle's `returning()` for immediate results
- User-scoped queries with proper authorization (`userId` checks)
- Default tags created for new users
#### 4. Custom Themes Store (`packages/shared-theme/src/custom-themes-store.svelte.ts`)
- Clean API design with factory function pattern
- Proper state management with Svelte 5 runes
- Good separation of public/authenticated API calls
- CSS variable application for runtime theming
---
### Suggestions for Improvement
#### 1. Hardcoded German Strings
**Location:** `packages/shared-ui/src/organisms/network/NetworkGraph.svelte:440-442`
```svelte
<p class="empty-title">Keine Verbindungen gefunden</p>
<p class="empty-description">Elemente werden verbunden, wenn sie gemeinsame Tags haben.</p>
```
**Recommendation:** Use i18n for user-facing strings to maintain consistency across the monorepo.
---
#### 2. Default Tags in German Only
**Location:** `services/mana-core-auth/src/tags/tags.service.ts:10-15`
```typescript
const DEFAULT_TAGS = [
{ name: 'Arbeit', color: '#3B82F6', icon: 'Briefcase' },
{ name: 'Persönlich', color: '#10B981', icon: 'User' },
{ name: 'Familie', color: '#EC4899', icon: 'Heart' },
{ name: 'Wichtig', color: '#EF4444', icon: 'Star' },
];
```
**Recommendation:** Consider locale-aware default tags or use English defaults that users can customize.
---
#### 3. Database Connection Pattern
**Location:** `services/mana-core-auth/src/tags/tags.service.ts:21-24`
```typescript
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
```
**Issue:** Using `!` assertion is less safe.
**Recommendation:** Inject the database connection via NestJS dependency injection instead of calling `getDb()` on every method call.
---
#### 4. Missing Error Boundary Handling
The NetworkGraph component handles empty states but doesn't have explicit error handling for malformed node/link data.
**Recommendation:** Add defensive checks for invalid data structures.
---
### Potential Issues
#### 1. PR Size
- 382 files is extremely large for a single PR
- Makes code review difficult and increases risk
- Consider splitting into feature branches for easier review and rollback
#### 2. Database Schema Consistency
**Location:** `services/mana-core-auth/src/db/schema/tags.schema.ts:11`
```typescript
userId: text('user_id').notNull(),
```
This uses `TEXT` type for user_id. Verify this aligns with how user IDs are stored in other tables (some use `UUID`).
#### 3. Missing Test Coverage
This major PR adds significant functionality without visible test changes. Consider adding:
- Unit tests for `TagsService`
- Component tests for `NetworkGraph`
- Integration tests for the themes API
---
### Security Considerations
#### Authorization Checks ✅
- Tag operations properly scope by `userId`
- Custom themes store requires authentication for write operations
- Community theme browsing allows public access (appropriate)
#### Input Validation
- DTOs should be reviewed for proper validation (max lengths, format checks)
- Tag color field accepts any 7-char string - consider validating hex format (`/^#[0-9A-Fa-f]{6}$/`)
#### Environment Files ✅
- `.env.development` only removes 6 lines, no secrets added
- No credentials exposed in the diff
---
## New Shared Packages
| Package | Purpose |
|---------|---------|
| `@manacore/shared-tags` | Client for central tags API |
| `@manacore/shared-help-content` | Markdown content loader and search |
| `@manacore/shared-help-ui` | Svelte help page components |
| `@manacore/shared-help-types` | TypeScript types for help system |
| `@manacore/shared-help-mobile` | React Native help components |
| `@manacore/shared-theme-ui` | Theme editor and community gallery |
---
## Files Changed by Category
| Category | Count | Notable Changes |
|----------|-------|-----------------|
| Contacts App | ~40 | Duplicates, batch ops, network, favorites |
| Todo App | ~30 | Kanban, statistics, settings, PWA |
| Calendar App | ~25 | Event tags, network graph |
| Shared UI | ~30 | NetworkGraph, skeleton loaders, tags |
| Shared Theme | ~15 | Custom themes store, editor |
| Shared Help | ~35 | Content, UI, types, mobile |
| mana-core-auth | ~15 | Tags API, themes API |
| Archived Apps | ~100 | Documentation cleanup |
---
## Recommendations
### 1. Split Future PRs
Consider creating separate PRs for major features:
- Network Graph feature
- Central Tags API
- Custom Themes System
- Help System packages
### 2. Add i18n
Replace hardcoded German strings in shared components.
### 3. Add Tests
At minimum, add unit tests for:
- `TagsService` (create, update, delete, defaults)
- `ThemesService` (publish, download, rate)
- `NetworkGraph` (props, events, accessibility)
### 4. Database Migration Plan
Ensure `tags` and `themes` table migrations are coordinated across environments.
### 5. Documentation Updates
The CLAUDE.md files are helpful. Ensure README updates for new packages.
---
## Rating Summary
| Aspect | Rating | Notes |
|--------|--------|-------|
| Code Quality | ⭐⭐⭐⭐ | Clean, well-structured code |
| Architecture | ⭐⭐⭐⭐⭐ | Excellent use of shared packages |
| Test Coverage | ⭐⭐ | Missing tests for new features |
| PR Size | ⭐⭐ | Too large for single review |
| Security | ⭐⭐⭐⭐ | Good authorization patterns |
| i18n | ⭐⭐⭐ | Some hardcoded German strings |
---
## Conclusion
This is solid, well-architected code with good separation of concerns. The main concerns are:
1. **PR size** - Makes review difficult
2. **Missing tests** - New features lack test coverage
3. **Hardcoded strings** - Some German text in shared components
Consider splitting future releases of this scale into smaller, focused PRs.
---
## Test Plan Checklist
From the PR description:
- [ ] Verify network graph loads correctly in Contacts, Calendar, Todo
- [ ] Test theme editor and community themes page
- [ ] Check Todo app new features (kanban, statistics, settings)
- [ ] Verify contacts duplicate detection and batch operations
- [ ] Test skeleton loaders appear during loading states
---
*Review by Claude Code - 2025-12-10*

View file

@ -25,7 +25,8 @@
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"lint": "eslint ."
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",

View file

@ -6,15 +6,17 @@
*/
/**
* Route definition with i18n label
* Route definition with label
*/
export interface AppRoute {
/** Route path (e.g., '/stopwatch') */
path: string;
/** i18n key for the label (e.g., 'nav.stopwatch') */
labelKey: string;
/** Display label for the route (e.g., 'Stoppuhr') */
label: string;
/** Optional icon name */
icon?: string;
/** If true, this route cannot be hidden (e.g., Settings, Home) */
alwaysVisible?: boolean;
}
/**
@ -37,13 +39,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'clock',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.dashboard', icon: 'home' },
{ path: '/alarms', labelKey: 'nav.alarms', icon: 'alarm' },
{ path: '/timers', labelKey: 'nav.timers', icon: 'timer' },
{ path: '/stopwatch', labelKey: 'nav.stopwatch', icon: 'stopwatch' },
{ path: '/pomodoro', labelKey: 'nav.pomodoro', icon: 'target' },
{ path: '/world-clock', labelKey: 'nav.worldClock', icon: 'globe' },
{ path: '/life', labelKey: 'nav.lifeClock', icon: 'heart' },
{ path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true },
{ path: '/alarms', label: 'Wecker', icon: 'alarm' },
{ path: '/timers', label: 'Timer', icon: 'timer' },
{ path: '/stopwatch', label: 'Stoppuhr', icon: 'stopwatch' },
{ path: '/pomodoro', label: 'Pomodoro', icon: 'target' },
{ path: '/world-clock', label: 'Weltuhr', icon: 'globe' },
{ path: '/life', label: 'Lebenszeit', icon: 'heart' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -51,8 +54,11 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'calendar',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.month', icon: 'calendar' },
{ path: '/agenda', labelKey: 'nav.agenda', icon: 'list' },
{ path: '/', label: 'Kalender', icon: 'calendar', alwaysVisible: true },
{ path: '/agenda', label: 'Agenda', icon: 'list' },
{ path: '/tags', label: 'Tags', icon: 'tag' },
{ path: '/network', label: 'Netzwerk', icon: 'share' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -60,20 +66,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'contacts',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.contacts', icon: 'users' },
{ path: '/groups', labelKey: 'nav.groups', icon: 'folder' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
],
},
mail: {
appId: 'mail',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.inbox', icon: 'inbox' },
{ path: '/sent', labelKey: 'nav.sent', icon: 'send' },
{ path: '/drafts', labelKey: 'nav.drafts', icon: 'file' },
{ path: '/starred', labelKey: 'nav.starred', icon: 'star' },
{ path: '/', label: 'Kontakte', icon: 'users', alwaysVisible: true },
{ path: '/favorites', label: 'Favoriten', icon: 'star' },
{ path: '/tags', label: 'Tags', icon: 'tag' },
{ path: '/archive', label: 'Archiv', icon: 'archive' },
{ path: '/duplicates', label: 'Duplikate', icon: 'copy' },
{ path: '/data', label: 'Import/Export', icon: 'download' },
{ path: '/network', label: 'Netzwerk', icon: 'share' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -81,21 +81,12 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'todo',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.all', icon: 'list' },
{ path: '/today', labelKey: 'nav.today', icon: 'calendar' },
{ path: '/upcoming', labelKey: 'nav.upcoming', icon: 'clock' },
{ path: '/completed', labelKey: 'nav.completed', icon: 'check' },
],
},
storage: {
appId: 'storage',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.home', icon: 'home' },
{ path: '/files', labelKey: 'nav.files', icon: 'folder' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
{ path: '/shared', labelKey: 'nav.shared', icon: 'share' },
{ path: '/', label: 'Aufgaben', icon: 'list', alwaysVisible: true },
{ path: '/kanban', label: 'Kanban', icon: 'grid' },
{ path: '/labels', label: 'Labels', icon: 'tag' },
{ path: '/statistics', label: 'Statistiken', icon: 'chart' },
{ path: '/network', label: 'Netzwerk', icon: 'share' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -103,10 +94,13 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'chat',
defaultRoute: '/chat',
availableRoutes: [
{ path: '/chat', labelKey: 'nav.chat', icon: 'message' },
{ path: '/spaces', labelKey: 'nav.spaces', icon: 'folder' },
{ path: '/templates', labelKey: 'nav.templates', icon: 'file' },
{ path: '/documents', labelKey: 'nav.documents', icon: 'document' },
{ path: '/chat', label: 'Chat', icon: 'message', alwaysVisible: true },
{ path: '/templates', label: 'Vorlagen', icon: 'file' },
{ path: '/spaces', label: 'Spaces', icon: 'folder' },
{ path: '/documents', label: 'Dokumente', icon: 'document' },
{ path: '/archive', label: 'Archiv', icon: 'archive' },
{ path: '/feedback', label: 'Feedback', icon: 'chat' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -114,10 +108,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'picture',
defaultRoute: '/app/gallery',
availableRoutes: [
{ path: '/app/gallery', labelKey: 'nav.gallery', icon: 'image' },
{ path: '/app/generate', labelKey: 'nav.generate', icon: 'sparkle' },
{ path: '/app/board', labelKey: 'nav.board', icon: 'grid' },
{ path: '/app/explore', labelKey: 'nav.explore', icon: 'compass' },
{ path: '/app/gallery', label: 'Galerie', icon: 'image', alwaysVisible: true },
{ path: '/app/board', label: 'Moodboards', icon: 'grid' },
{ path: '/app/explore', label: 'Entdecken', icon: 'compass' },
{ path: '/app/generate', label: 'Generieren', icon: 'sparkle' },
{ path: '/app/upload', label: 'Upload', icon: 'upload' },
{ path: '/app/tags', label: 'Tags', icon: 'tag' },
{ path: '/app/archive', label: 'Archiv', icon: 'archive' },
{ path: '/app/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -125,9 +123,10 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'manadeck',
defaultRoute: '/decks',
availableRoutes: [
{ path: '/decks', labelKey: 'nav.decks', icon: 'layers' },
{ path: '/explore', labelKey: 'nav.explore', icon: 'compass' },
{ path: '/progress', labelKey: 'nav.progress', icon: 'trending' },
{ path: '/decks', label: 'Decks', icon: 'layers', alwaysVisible: true },
{ path: '/explore', label: 'Entdecken', icon: 'compass' },
{ path: '/progress', label: 'Fortschritt', icon: 'trending' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -135,24 +134,23 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'zitare',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.home', icon: 'home' },
{ path: '/quotes', labelKey: 'nav.quotes', icon: 'quote' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
{ path: '/authors', labelKey: 'nav.authors', icon: 'users' },
{ path: '/lists', labelKey: 'nav.lists', icon: 'list' },
{ path: '/', label: 'Zitate', icon: 'quote', alwaysVisible: true },
{ path: '/search', label: 'Suche', icon: 'search' },
{ path: '/authors', label: 'Autoren', icon: 'users' },
{ path: '/favorites', label: 'Favoriten', icon: 'star' },
{ path: '/lists', label: 'Listen', icon: 'list' },
{ path: '/feedback', label: 'Feedback', icon: 'chat' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
presi: {
appId: 'presi',
defaultRoute: '/',
availableRoutes: [{ path: '/', labelKey: 'nav.home', icon: 'home' }],
},
manacore: {
appId: 'manacore',
defaultRoute: '/',
availableRoutes: [{ path: '/', labelKey: 'nav.dashboard', icon: 'home' }],
availableRoutes: [
{ path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
};
@ -199,3 +197,46 @@ export function getAvailableRoutes(appId: string): AppRoute[] {
export function getDefaultRoute(appId: string): string {
return APP_ROUTES[appId]?.defaultRoute ?? '/';
}
/**
* Filter hidden navigation items from a list of nav items
* @param appId The app identifier
* @param items Array of nav items with href property
* @param hiddenNavItems Hidden items config (appId -> hidden paths)
* @returns Filtered array with hidden items removed
*/
export function filterHiddenNavItems<T extends { href: string }>(
appId: string,
items: T[],
hiddenNavItems: Record<string, string[]> = {}
): T[] {
const hidden = hiddenNavItems[appId] || [];
return items.filter((item) => !hidden.includes(item.href));
}
/**
* Get routes that can be hidden for a specific app
* (excludes routes marked as alwaysVisible)
* @param appId The app identifier
* @returns Array of routes that can be hidden
*/
export function getHideableRoutes(appId: string): AppRoute[] {
const config = APP_ROUTES[appId];
return config?.availableRoutes.filter((r) => !r.alwaysVisible) || [];
}
/**
* Check if a route is hidden for a specific app
* @param appId The app identifier
* @param path The route path
* @param hiddenNavItems Hidden items config
* @returns True if the route is hidden
*/
export function isRouteHidden(
appId: string,
path: string,
hiddenNavItems: Record<string, string[]> = {}
): boolean {
const hidden = hiddenNavItems[appId] || [];
return hidden.includes(path);
}

View file

@ -117,4 +117,12 @@ export {
// App Routes
export type { AppRoute, AppRouteConfig } from './app-routes';
export { APP_ROUTES, getStartPage, getAvailableRoutes, getDefaultRoute } from './app-routes';
export {
APP_ROUTES,
getStartPage,
getAvailableRoutes,
getDefaultRoute,
filterHiddenNavItems,
getHideableRoutes,
isRouteHidden,
} from './app-routes';

View file

@ -240,6 +240,8 @@ export interface NavSettings {
desktopPosition: NavPosition;
/** Whether sidebar is collapsed */
sidebarCollapsed: boolean;
/** Hidden navigation items per app (appId -> list of hidden paths) */
hiddenNavItems?: Record<string, string[]>;
}
/**
@ -323,7 +325,7 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
* Default global settings
*/
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false },
nav: { desktopPosition: 'top', sidebarCollapsed: false, hiddenNavItems: {} },
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
locale: 'de',
general: DEFAULT_GENERAL_SETTINGS,
@ -364,6 +366,12 @@ export interface UserSettingsStore {
setStartPage: (appId: string, path: string) => Promise<void>;
/** Update general settings */
updateGeneral: (settings: Partial<GeneralSettings>) => Promise<void>;
/** Get hidden nav items for a specific app */
getHiddenNavItemsForApp: (appId: string) => string[];
/** Toggle visibility of a navigation item */
toggleNavItemVisibility: (appId: string, href: string) => Promise<void>;
/** Set hidden nav items for an app */
setHiddenNavItems: (appId: string, hiddenHrefs: string[]) => Promise<void>;
}
/**

View file

@ -314,6 +314,46 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
}
}
/**
* Get hidden nav items for a specific app
*/
function getHiddenNavItemsForApp(targetAppId: string): string[] {
return globalSettings.nav.hiddenNavItems?.[targetAppId] || [];
}
/**
* Toggle visibility of a navigation item for an app
*/
async function toggleNavItemVisibility(targetAppId: string, href: string): Promise<void> {
const currentHidden = getHiddenNavItemsForApp(targetAppId);
const isHidden = currentHidden.includes(href);
const newHidden = isHidden ? currentHidden.filter((h) => h !== href) : [...currentHidden, href];
await setHiddenNavItems(targetAppId, newHidden);
}
/**
* Set hidden nav items for an app
*/
async function setHiddenNavItems(targetAppId: string, hiddenHrefs: string[]): Promise<void> {
const newHiddenNavItems = {
...globalSettings.nav.hiddenNavItems,
[targetAppId]: hiddenHrefs,
};
// Remove empty arrays
if (hiddenHrefs.length === 0) {
delete newHiddenNavItems[targetAppId];
}
await updateGlobal({
nav: {
hiddenNavItems: newHiddenNavItems,
},
} as Partial<GlobalSettings>);
}
return {
get nav() {
return nav;
@ -349,5 +389,8 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
removeAppOverride,
setStartPage,
updateGeneral,
getHiddenNavItemsForApp,
toggleNavItemVisibility,
setHiddenNavItems,
};
}

View file

@ -42,6 +42,7 @@
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.468.0"
},
"devDependencies": {

View file

@ -0,0 +1,294 @@
<script lang="ts">
import { format, parseISO, getMonth } from 'date-fns';
import { de } from 'date-fns/locale';
import type { HeatmapDataPoint } from './types';
interface Props {
data: HeatmapDataPoint[];
title?: string;
/** Number of days to display (default: 180) */
daysCount?: number;
/** Custom tooltip formatter */
tooltipFormatter?: (point: HeatmapDataPoint) => string;
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
itemName?: string;
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
itemNamePlural?: string;
}
let {
data,
title = 'Aktivität',
daysCount = 180,
tooltipFormatter,
itemName = 'Aufgabe',
itemNamePlural = 'Aufgaben',
}: Props = $props();
// Constants
const CELL_SIZE = 12;
const CELL_GAP = 3;
const DAY_LABELS = ['Mo', '', 'Mi', '', 'Fr', '', 'So'];
// Calculate max for color scaling
let maxCount = $derived(Math.max(...data.map((d) => d.count), 1));
// Get color intensity based on count (uses CSS variable --primary)
function getColorClass(count: number): string {
if (count === 0) return 'intensity-0';
const ratio = count / maxCount;
if (ratio <= 0.25) return 'intensity-1';
if (ratio <= 0.5) return 'intensity-2';
if (ratio <= 0.75) return 'intensity-3';
return 'intensity-4';
}
// Group data by weeks
let weeks = $derived.by(() => {
const result: HeatmapDataPoint[][] = [];
let currentWeek: HeatmapDataPoint[] = [];
// Adjust for Monday start
const adjustedData = [...data];
// Fill initial gap if first day isn't Monday
if (adjustedData.length > 0) {
const firstDay = adjustedData[0];
// Convert Sunday (0) to 6, Monday (1) to 0, etc.
const adjustedDayOfWeek = firstDay.dayOfWeek === 0 ? 6 : firstDay.dayOfWeek - 1;
for (let i = 0; i < adjustedDayOfWeek; i++) {
currentWeek.push({ date: '', count: 0, dayOfWeek: i });
}
}
adjustedData.forEach((day) => {
// Convert to Monday-based index
const adjustedDayOfWeek = day.dayOfWeek === 0 ? 6 : day.dayOfWeek - 1;
if (adjustedDayOfWeek === 0 && currentWeek.length > 0) {
result.push(currentWeek);
currentWeek = [];
}
currentWeek.push({ ...day, dayOfWeek: adjustedDayOfWeek });
});
if (currentWeek.length > 0) {
result.push(currentWeek);
}
return result;
});
// Calculate month labels
let monthLabels = $derived.by(() => {
const labels: { month: string; weekIndex: number }[] = [];
let lastMonth = -1;
weeks.forEach((week, weekIndex) => {
const validDay = week.find((d) => d.date);
if (validDay) {
const date = parseISO(validDay.date);
const month = getMonth(date);
if (month !== lastMonth) {
labels.push({
month: format(date, 'MMM', { locale: de }),
weekIndex,
});
lastMonth = month;
}
}
});
return labels;
});
// Calculate SVG dimensions
let svgWidth = $derived(weeks.length * (CELL_SIZE + CELL_GAP) + 30);
let svgHeight = 7 * (CELL_SIZE + CELL_GAP) + 30;
function formatTooltip(day: HeatmapDataPoint): string {
if (!day.date) return '';
if (tooltipFormatter) return tooltipFormatter(day);
const date = format(parseISO(day.date), 'EEEE, d. MMMM yyyy', { locale: de });
const name = day.count === 1 ? itemName : itemNamePlural;
return `${day.count} ${name} am ${date}`;
}
</script>
<div class="heatmap-container">
<h3 class="heatmap-title">{title}</h3>
<div class="heatmap-scroll">
<svg
width={svgWidth}
height={svgHeight}
viewBox="0 0 {svgWidth} {svgHeight}"
class="heatmap-svg"
>
<!-- Month labels -->
{#each monthLabels as label}
<text x={30 + label.weekIndex * (CELL_SIZE + CELL_GAP)} y={10} class="month-label">
{label.month}
</text>
{/each}
<!-- Day labels -->
{#each DAY_LABELS as label, i}
{#if label}
<text x={0} y={22 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE / 2 + 4} class="day-label">
{label}
</text>
{/if}
{/each}
<!-- Cells -->
{#each weeks as week, weekIndex}
{#each week as day, dayIndex}
{#if day.date}
<rect
x={30 + weekIndex * (CELL_SIZE + CELL_GAP)}
y={20 + dayIndex * (CELL_SIZE + CELL_GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
class="cell {getColorClass(day.count)}"
>
<title>{formatTooltip(day)}</title>
</rect>
{:else}
<rect
x={30 + weekIndex * (CELL_SIZE + CELL_GAP)}
y={20 + dayIndex * (CELL_SIZE + CELL_GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
class="cell empty"
/>
{/if}
{/each}
{/each}
</svg>
</div>
<!-- Legend -->
<div class="legend">
<span class="legend-label">Weniger</span>
<div class="legend-cells">
<div class="legend-cell intensity-0"></div>
<div class="legend-cell intensity-1"></div>
<div class="legend-cell intensity-2"></div>
<div class="legend-cell intensity-3"></div>
<div class="legend-cell intensity-4"></div>
</div>
<span class="legend-label">Mehr</span>
</div>
</div>
<style>
.heatmap-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .heatmap-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.heatmap-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
}
.heatmap-scroll {
overflow-x: auto;
padding-bottom: 0.5rem;
}
.heatmap-svg {
display: block;
}
.month-label {
font-size: 10px;
fill: hsl(var(--muted-foreground));
}
.day-label {
font-size: 10px;
fill: hsl(var(--muted-foreground));
}
.cell {
transition: opacity 0.15s ease;
}
.cell:hover {
opacity: 0.8;
}
.cell.empty {
fill: transparent;
}
:global(.dark) .cell.empty {
fill: transparent;
}
.legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.legend-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.legend-cells {
display: flex;
gap: 3px;
}
.legend-cell {
width: 12px;
height: 12px;
border-radius: 2px;
}
/* Intensity classes using theme primary color */
.intensity-0 {
fill: hsl(var(--muted) / 0.3);
background: hsl(var(--muted) / 0.3);
}
.intensity-1 {
fill: hsl(var(--primary) / 0.3);
background: hsl(var(--primary) / 0.3);
}
.intensity-2 {
fill: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.5);
}
.intensity-3 {
fill: hsl(var(--primary) / 0.7);
background: hsl(var(--primary) / 0.7);
}
.intensity-4 {
fill: hsl(var(--primary));
background: hsl(var(--primary));
}
</style>

View file

@ -0,0 +1,260 @@
<script lang="ts">
import type { DonutSegment } from './types';
interface Props {
data: DonutSegment[];
title?: string;
centerLabel?: string;
centerValue?: number | string;
showLegend?: boolean;
}
let {
data,
title = 'Verteilung',
centerLabel = 'Gesamt',
centerValue,
showLegend = true,
}: Props = $props();
// Chart settings
const SIZE = 200;
const CENTER = SIZE / 2;
const RADIUS = 80;
const INNER_RADIUS = 50;
// Total count
let total = $derived(centerValue ?? data.reduce((sum, d) => sum + d.count, 0));
// Generate arc paths
let arcs = $derived.by(() => {
const totalCount = data.reduce((sum, d) => sum + d.count, 0);
if (totalCount === 0) return [];
const result: Array<{
path: string;
color: string;
id: string;
label: string;
count: number;
percentage: number;
}> = [];
let currentAngle = -90; // Start at top
data.forEach((segment) => {
if (segment.count === 0) return;
const angle = (segment.count / totalCount) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
// Convert angles to radians
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
// Calculate points
const x1 = CENTER + RADIUS * Math.cos(startRad);
const y1 = CENTER + RADIUS * Math.sin(startRad);
const x2 = CENTER + RADIUS * Math.cos(endRad);
const y2 = CENTER + RADIUS * Math.sin(endRad);
const x3 = CENTER + INNER_RADIUS * Math.cos(endRad);
const y3 = CENTER + INNER_RADIUS * Math.sin(endRad);
const x4 = CENTER + INNER_RADIUS * Math.cos(startRad);
const y4 = CENTER + INNER_RADIUS * Math.sin(startRad);
const largeArc = angle > 180 ? 1 : 0;
// Create arc path
const path = [
`M ${x1} ${y1}`,
`A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${x2} ${y2}`,
`L ${x3} ${y3}`,
`A ${INNER_RADIUS} ${INNER_RADIUS} 0 ${largeArc} 0 ${x4} ${y4}`,
'Z',
].join(' ');
result.push({
path,
color: segment.color,
id: segment.id,
label: segment.label,
count: segment.count,
percentage: segment.percentage,
});
currentAngle = endAngle;
});
return result;
});
// Hover state
let hoveredSegment = $state<string | null>(null);
</script>
<div class="donut-container">
<h3 class="donut-title">{title}</h3>
<div class="donut-content">
<div class="donut-chart">
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
{#each arcs as arc}
<path
d={arc.path}
fill={arc.color}
class="arc-segment"
class:hovered={hoveredSegment === arc.id}
onmouseenter={() => (hoveredSegment = arc.id)}
onmouseleave={() => (hoveredSegment = null)}
role="graphics-symbol"
aria-label="{arc.label}: {arc.count}"
>
<title>{arc.label}: {arc.count} ({arc.percentage}%)</title>
</path>
{/each}
<!-- Center text -->
<text x={CENTER} y={CENTER - 8} class="center-count">
{total}
</text>
<text x={CENTER} y={CENTER + 12} class="center-label">
{centerLabel}
</text>
</svg>
</div>
<!-- Legend -->
{#if showLegend}
<div class="donut-legend">
{#each data as item}
<div
class="legend-item"
class:active={hoveredSegment === item.id}
onmouseenter={() => (hoveredSegment = item.id)}
onmouseleave={() => (hoveredSegment = null)}
role="button"
tabindex="0"
>
<span class="legend-color" style="background-color: {item.color}"></span>
<span class="legend-label">{item.label}</span>
<span class="legend-count">{item.count}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.donut-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .donut-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.donut-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
}
.donut-content {
display: flex;
align-items: center;
gap: 1.5rem;
}
@media (max-width: 400px) {
.donut-content {
flex-direction: column;
}
}
.donut-chart {
flex-shrink: 0;
}
.donut-svg {
width: 140px;
height: 140px;
}
.arc-segment {
transition:
opacity 0.15s ease,
transform 0.15s ease;
transform-origin: center;
cursor: pointer;
}
.arc-segment:hover,
.arc-segment.hovered {
opacity: 0.85;
transform: scale(1.02);
}
.center-count {
font-size: 28px;
font-weight: 700;
fill: hsl(var(--foreground));
text-anchor: middle;
}
.center-label {
font-size: 12px;
fill: hsl(var(--muted-foreground));
text-anchor: middle;
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.legend-item:hover,
.legend-item.active {
background: hsl(var(--muted) / 0.3);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.875rem;
color: hsl(var(--foreground));
flex: 1;
}
.legend-count {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
</style>

View file

@ -0,0 +1,192 @@
<script lang="ts">
import type { ProgressItem } from './types';
interface Props {
data: ProgressItem[];
title?: string;
maxItems?: number;
emptyMessage?: string;
}
let {
data,
title = 'Fortschritt',
maxItems = 8,
emptyMessage = 'Keine Daten vorhanden',
}: Props = $props();
// Sort by total (descending) and limit to maxItems
let sortedData = $derived(data.slice(0, maxItems));
</script>
<div class="progress-container">
<h3 class="progress-title">{title}</h3>
{#if sortedData.length === 0}
<p class="no-data">{emptyMessage}</p>
{:else}
<div class="progress-list">
{#each sortedData as item (item.id)}
<div class="progress-row">
<div class="progress-header">
<div class="progress-name">
<span class="progress-dot" style="background-color: {item.color}"></span>
<span class="name-text">{item.name}</span>
</div>
<span class="progress-stats">
{item.completed}/{item.total}
</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar">
<!-- Completed segment -->
{#if item.completed > 0}
<div
class="progress-segment completed"
style="width: {(item.completed / item.total) *
100}%; background-color: {item.color}"
></div>
{/if}
<!-- In Progress segment -->
{#if item.inProgress && item.inProgress > 0}
<div
class="progress-segment in-progress"
style="width: {(item.inProgress / item.total) *
100}%; background-color: {item.color}; opacity: 0.4"
></div>
{/if}
</div>
<span class="percentage">{item.percentage}%</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.progress-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .progress-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.progress-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
}
.no-data {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
text-align: center;
padding: 2rem;
}
.progress-list {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.progress-row {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-name {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.progress-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.name-text {
font-size: 0.875rem;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-stats {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: hsl(var(--muted) / 0.3);
border-radius: 4px;
overflow: hidden;
display: flex;
}
:global(.dark) .progress-bar {
background: rgba(255, 255, 255, 0.1);
}
.progress-segment {
height: 100%;
transition: width 0.3s ease;
}
.progress-segment.completed {
border-radius: 4px 0 0 4px;
}
.progress-segment.in-progress {
/* Striped pattern for in-progress */
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.3) 4px,
rgba(255, 255, 255, 0.3) 8px
);
}
.percentage {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
width: 36px;
text-align: right;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,272 @@
<script lang="ts">
/**
* StatisticsSkeleton - Skeleton for statistics page loading
*/
import { SkeletonBox } from '../molecules';
interface Props {
/** Number of stat cards to show (default: 6) */
statCards?: number;
/** Number of progress items to show (default: 4) */
progressItems?: number;
/** Number of legend items for donut chart (default: 4) */
legendItems?: number;
/** Show additional stats section (default: true) */
showAdditionalStats?: boolean;
}
let {
statCards = 6,
progressItems = 4,
legendItems = 4,
showAdditionalStats = true,
}: Props = $props();
</script>
<div class="statistics-skeleton" role="status" aria-label="Statistiken werden geladen...">
<!-- Stats Overview Cards -->
<div class="stats-overview">
{#each Array(statCards) as _, i}
<div class="stat-card" style="opacity: {Math.max(0.5, 1 - i * 0.08)};">
<SkeletonBox width="40px" height="40px" borderRadius="10px" />
<div class="stat-content">
<SkeletonBox width="48px" height="28px" />
<SkeletonBox width="80px" height="14px" />
</div>
</div>
{/each}
</div>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<div class="chart-card heatmap">
<div class="chart-header">
<SkeletonBox width="140px" height="20px" />
</div>
<div class="heatmap-grid">
{#each Array(7) as _}
<div class="heatmap-row">
{#each Array(12) as _}
<SkeletonBox width="16px" height="16px" borderRadius="3px" />
{/each}
</div>
{/each}
</div>
</div>
<!-- Charts Row -->
<div class="charts-row">
<!-- Weekly Trend Chart -->
<div class="chart-card trend">
<div class="chart-header">
<SkeletonBox width="120px" height="20px" />
</div>
<div class="trend-bars">
{#each Array(7) as _, i}
<div class="bar-wrapper">
<SkeletonBox width="32px" height="{40 + Math.random() * 60}px" borderRadius="4px" />
<SkeletonBox width="24px" height="12px" />
</div>
{/each}
</div>
</div>
<!-- Priority Donut Chart -->
<div class="chart-card donut">
<div class="chart-header">
<SkeletonBox width="100px" height="20px" />
</div>
<div class="donut-wrapper">
<SkeletonBox width="140px" height="140px" borderRadius="50%" />
</div>
<div class="legend">
{#each Array(legendItems) as _}
<div class="legend-item">
<SkeletonBox width="12px" height="12px" borderRadius="3px" />
<SkeletonBox width="60px" height="14px" />
</div>
{/each}
</div>
</div>
</div>
<!-- Project Progress -->
<div class="chart-card projects">
<div class="chart-header">
<SkeletonBox width="130px" height="20px" />
</div>
<div class="progress-bars">
{#each Array(progressItems) as _, i}
<div class="progress-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
<div class="progress-header">
<SkeletonBox width="{100 + i * 20}px" height="16px" />
<SkeletonBox width="40px" height="14px" />
</div>
<SkeletonBox width="100%" height="8px" borderRadius="4px" />
</div>
{/each}
</div>
</div>
</div>
<!-- Additional Stats -->
{#if showAdditionalStats}
<div class="additional-stats">
{#each Array(3) as _}
<div class="small-stat">
<SkeletonBox width="120px" height="12px" />
<SkeletonBox width="80px" height="18px" />
</div>
{/each}
</div>
{/if}
</div>
<style>
.statistics-skeleton {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Stats Overview */
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
}
.stat-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Charts Grid */
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.chart-card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
padding: 1.25rem;
}
.chart-header {
margin-bottom: 1rem;
}
/* Heatmap */
.heatmap-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.heatmap-row {
display: flex;
gap: 4px;
}
/* Charts Row */
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 2fr 1fr;
}
}
/* Trend Chart */
.trend-bars {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 120px;
padding-top: 1rem;
}
.bar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
/* Donut Chart */
.donut-wrapper {
display: flex;
justify-content: center;
padding: 1rem 0;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Project Progress */
.progress-bars {
display: flex;
flex-direction: column;
gap: 1rem;
}
.progress-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Additional Stats */
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.small-stat {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
}
</style>

View file

@ -0,0 +1,136 @@
<script lang="ts">
import type { StatItem } from './types';
import { STAT_VARIANT_COLORS } from './types';
interface Props {
items: StatItem[];
columns?: 2 | 3 | 4 | 6;
}
let { items, columns = 6 }: Props = $props();
// Filter items based on showCondition
let visibleItems = $derived(items.filter((item) => item.showCondition !== false));
</script>
<div
class="stats-grid"
class:cols-2={columns === 2}
class:cols-3={columns === 3}
class:cols-4={columns === 4}
class:cols-6={columns === 6}
>
{#each visibleItems as item (item.id)}
<div class="stat-card">
<div
class="stat-icon"
style="background-color: {STAT_VARIANT_COLORS[item.variant]
.bg}; color: {STAT_VARIANT_COLORS[item.variant].color}"
>
<item.icon size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{item.value}</span>
<span class="stat-label">{item.label}</span>
</div>
</div>
{/each}
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
/* Default responsive behavior for 6 columns */
.stats-grid.cols-6 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 640px) {
.stats-grid.cols-6 {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid.cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid.cols-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.stats-grid.cols-6 {
grid-template-columns: repeat(6, 1fr);
}
.stats-grid.cols-4 {
grid-template-columns: repeat(4, 1fr);
}
}
.stats-grid.cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.stats-grid.cols-3 {
grid-template-columns: repeat(2, 1fr);
}
.stat-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
:global(.dark) .stat-card {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 0.75rem;
flex-shrink: 0;
}
.stat-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
color: hsl(var(--foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,240 @@
<script lang="ts">
import type { TrendDataPoint } from './types';
interface Props {
data: TrendDataPoint[];
title?: string;
height?: number;
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
itemName?: string;
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
itemNamePlural?: string;
}
let {
data,
title = 'Trend (letzte 4 Wochen)',
height = 200,
itemName = 'Aufgabe',
itemNamePlural = 'Aufgaben',
}: Props = $props();
// Chart dimensions
const WIDTH = 600;
const PADDING = { top: 20, right: 20, bottom: 30, left: 40 };
let chartWidth = WIDTH - PADDING.left - PADDING.right;
let chartHeight = height - PADDING.top - PADDING.bottom;
// Calculate max for scaling
let maxCount = $derived(Math.max(...data.map((d) => d.count), 1));
// Scale functions
function scaleX(index: number): number {
if (data.length <= 1) return PADDING.left;
return PADDING.left + (index / (data.length - 1)) * chartWidth;
}
function scaleY(value: number): number {
return PADDING.top + chartHeight - (value / maxCount) * chartHeight;
}
// Generate path for the line
let linePath = $derived.by(() => {
if (data.length === 0) return '';
const points = data.map((d, i) => ({
x: scaleX(i),
y: scaleY(d.count),
}));
// Create smooth curve using cubic bezier
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const cpX = (prev.x + curr.x) / 2;
path += ` C ${cpX} ${prev.y}, ${cpX} ${curr.y}, ${curr.x} ${curr.y}`;
}
return path;
});
// Generate path for the area fill
let areaPath = $derived.by(() => {
if (data.length === 0) return '';
const baseline = PADDING.top + chartHeight;
return `${linePath} L ${scaleX(data.length - 1)} ${baseline} L ${scaleX(0)} ${baseline} Z`;
});
// Y-axis ticks
let yTicks = $derived.by(() => {
const tickCount = 4;
const step = maxCount / tickCount;
return Array.from({ length: tickCount + 1 }, (_, i) => Math.round(i * step));
});
// X-axis labels (show every 7th day for weekly labels)
let xLabels = $derived.by(() => {
const labels: { index: number; label: string }[] = [];
const step = Math.max(1, Math.floor(data.length / 4));
for (let i = 0; i < data.length; i += step) {
if (data[i]) {
labels.push({ index: i, label: data[i].date.slice(5) }); // MM-DD format
}
}
return labels;
});
// Generate unique gradient ID
let gradientId = $derived(`areaGradient-${Math.random().toString(36).slice(2, 9)}`);
function formatTooltip(point: TrendDataPoint): string {
const name = point.count === 1 ? itemName : itemNamePlural;
return `${point.count} ${name} am ${point.date}`;
}
</script>
<div class="chart-container">
<h3 class="chart-title">{title}</h3>
<svg viewBox="0 0 {WIDTH} {height}" class="chart-svg" preserveAspectRatio="xMidYMid meet">
<!-- Grid lines -->
{#each yTicks as tick}
<line
x1={PADDING.left}
y1={scaleY(tick)}
x2={WIDTH - PADDING.right}
y2={scaleY(tick)}
class="grid-line"
/>
{/each}
<!-- Area fill with gradient -->
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" class="gradient-start" />
<stop offset="100%" class="gradient-end" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#{gradientId})" class="area-path" />
<!-- Line -->
<path d={linePath} class="line-path" />
<!-- Data points -->
{#each data as point, i}
<circle cx={scaleX(i)} cy={scaleY(point.count)} r={4} class="data-point">
<title>{formatTooltip(point)}</title>
</circle>
{/each}
<!-- Y-axis labels -->
{#each yTicks as tick}
<text x={PADDING.left - 8} y={scaleY(tick) + 4} class="y-label">
{tick}
</text>
{/each}
<!-- X-axis labels -->
{#each xLabels as label}
<text x={scaleX(label.index)} y={height - 8} class="x-label">
{label.label}
</text>
{/each}
</svg>
</div>
<style>
.chart-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .chart-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.chart-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
}
.chart-svg {
width: 100%;
height: auto;
max-height: 200px;
}
.grid-line {
stroke: hsl(var(--muted) / 0.3);
stroke-width: 1;
stroke-dasharray: 4 4;
}
:global(.dark) .grid-line {
stroke: rgba(255, 255, 255, 0.1);
}
.area-path {
transition: opacity 0.3s ease;
}
.gradient-start {
stop-color: hsl(var(--primary));
stop-opacity: 0.3;
}
.gradient-end {
stop-color: hsl(var(--primary));
stop-opacity: 0.05;
}
.line-path {
fill: none;
stroke: hsl(var(--primary));
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.data-point {
fill: hsl(var(--primary));
stroke: white;
stroke-width: 2;
cursor: pointer;
transition: r 0.15s ease;
}
.data-point:hover {
r: 6;
}
:global(.dark) .data-point {
stroke: #1e1e1e;
}
.y-label {
font-size: 10px;
fill: hsl(var(--muted-foreground));
text-anchor: end;
}
.x-label {
font-size: 10px;
fill: hsl(var(--muted-foreground));
text-anchor: middle;
}
</style>

View file

@ -0,0 +1,20 @@
// Charts - Statistics Visualization Components
export { default as StatsGrid } from './StatsGrid.svelte';
export { default as ActivityHeatmap } from './ActivityHeatmap.svelte';
export { default as TrendLineChart } from './TrendLineChart.svelte';
export { default as DonutChart } from './DonutChart.svelte';
export { default as ProgressBars } from './ProgressBars.svelte';
export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte';
// Types
export type {
StatVariant,
StatItem,
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from './types';
// Constants
export { STAT_VARIANT_COLORS } from './types';

View file

@ -0,0 +1,62 @@
/**
* Shared Types for Chart Components
*/
import type { Component } from 'svelte';
// Stat card variant colors
export type StatVariant = 'success' | 'primary' | 'neutral' | 'danger' | 'info' | 'accent';
export const STAT_VARIANT_COLORS: Record<StatVariant, { bg: string; color: string }> = {
success: { bg: 'rgba(16, 185, 129, 0.15)', color: '#10B981' },
primary: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8B5CF6' },
neutral: { bg: 'rgba(107, 114, 128, 0.15)', color: '#6B7280' },
danger: { bg: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' },
info: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3B82F6' },
accent: { bg: 'rgba(236, 72, 153, 0.15)', color: '#EC4899' },
};
// StatsGrid types
export interface StatItem {
id: string;
label: string;
value: number | string;
icon: Component;
variant: StatVariant;
/** Optional: only show this stat if condition is true */
showCondition?: boolean;
}
// ActivityHeatmap types
export interface HeatmapDataPoint {
date: string; // YYYY-MM-DD format
count: number;
dayOfWeek: number; // 0-6 (Sunday-Saturday)
}
// TrendLineChart types
export interface TrendDataPoint {
date: string; // YYYY-MM-DD format
count: number;
label?: string;
}
// DonutChart types
export interface DonutSegment {
id: string;
label: string;
count: number;
percentage: number;
color: string;
}
// ProgressBars types
export interface ProgressItem {
id: string;
name: string;
color: string;
total: number;
completed: number;
inProgress?: number;
percentage: number;
}

View file

@ -1,6 +1,47 @@
<script lang="ts">
import { goto } from '$app/navigation';
// Syntax highlighting patterns for command keywords
interface HighlightPattern {
pattern: RegExp;
className: string;
}
const HIGHLIGHT_PATTERNS: HighlightPattern[] = [
// Priority keywords (Todo) - with specific colors per level
{ pattern: /(!{3,}|!?dringend)\b/gi, className: 'hl-priority-urgent' },
{ pattern: /(!{2}|!?wichtig)\b/gi, className: 'hl-priority-high' },
{ pattern: /!?normal\b/gi, className: 'hl-priority-medium' },
{ pattern: /!?sp[aä]ter\b/gi, className: 'hl-priority-low' },
// Tags
{ pattern: /#\w+/g, className: 'hl-tag' },
// Projects/Calendars/Companies (@reference)
{ pattern: /@\w+/g, className: 'hl-reference' },
// Date keywords
{
pattern:
/\b(heute|morgen|übermorgen|montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag|nächsten?\s+\w+|in\s+\d+\s+tagen?)\b/gi,
className: 'hl-date',
},
// Time patterns
{ pattern: /\b(\d{1,2}:\d{2}|um\s+\d{1,2}(\s*uhr)?|\d{1,2}\s*uhr)\b/gi, className: 'hl-time' },
];
function highlightText(text: string): string {
if (!text) return '';
let result = text;
// Escape HTML first
result = result.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Apply highlights (process in order, avoiding double-highlighting)
for (const { pattern, className } of HIGHLIGHT_PATTERNS) {
result = result.replace(pattern, (match) => `<span class="${className}">${match}</span>`);
}
return result;
}
export interface CommandBarItem {
id: string;
title: string;
@ -19,6 +60,11 @@
onclick?: () => void;
}
export interface CreatePreview {
title: string;
subtitle: string;
}
interface Props {
open: boolean;
onClose: () => void;
@ -28,6 +74,11 @@
placeholder?: string;
emptyText?: string;
searchingText?: string;
// New: Task creation support
onCreate?: (query: string) => Promise<void>;
onParseCreate?: (query: string) => CreatePreview | null;
createText?: string;
createShortcut?: string;
}
let {
@ -39,21 +90,38 @@
placeholder = 'Suchen...',
emptyText = 'Keine Ergebnisse gefunden',
searchingText = 'Suche...',
onCreate,
onParseCreate,
createText = 'Als Eintrag erstellen',
createShortcut = '⌘↵',
}: Props = $props();
let searchQuery = $state('');
let results = $state<CommandBarItem[]>([]);
let loading = $state(false);
let creating = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
let inputElement: HTMLInputElement;
// Computed create preview
let createPreview = $derived(
searchQuery.trim() && onParseCreate ? onParseCreate(searchQuery) : null
);
// Highlighted text for overlay
let highlightedQuery = $derived(highlightText(searchQuery));
// Check if create option is selected (it's always first when available)
let isCreateSelected = $derived(selectedIndex === 0 && createPreview !== null);
// Reset state when modal opens
$effect(() => {
if (open) {
searchQuery = '';
results = [];
selectedIndex = 0;
creating = false;
setTimeout(() => inputElement?.focus(), 50);
}
});
@ -82,6 +150,20 @@
}, 150);
}
async function handleCreate() {
if (!onCreate || !searchQuery.trim() || creating) return;
creating = true;
try {
await onCreate(searchQuery);
onClose();
} catch (error) {
console.error('Create error:', error);
} finally {
creating = false;
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
@ -89,10 +171,23 @@
return;
}
// Cmd/Ctrl+Enter to create directly
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
if (onCreate && searchQuery.trim()) {
handleCreate();
}
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
const maxIndex = searchQuery.trim() ? results.length - 1 : quickActions.length - 1;
selectedIndex = Math.min(selectedIndex + 1, maxIndex);
// Calculate max index including create option
const hasCreate = createPreview !== null;
const maxIndex = searchQuery.trim()
? (hasCreate ? 1 : 0) + results.length - 1
: quickActions.length - 1;
selectedIndex = Math.min(selectedIndex + 1, Math.max(0, maxIndex));
return;
}
@ -104,8 +199,17 @@
if (event.key === 'Enter') {
event.preventDefault();
if (searchQuery.trim() && results.length > 0) {
selectItem(results[selectedIndex]);
if (searchQuery.trim()) {
// If create option is selected
if (isCreateSelected && onCreate) {
handleCreate();
} else if (results.length > 0) {
// Adjust index for results (subtract 1 if create option exists)
const resultIndex = createPreview !== null ? selectedIndex - 1 : selectedIndex;
if (resultIndex >= 0 && resultIndex < results.length) {
selectItem(results[resultIndex]);
}
}
} else if (!searchQuery.trim() && quickActions.length > 0) {
const action = quickActions[selectedIndex];
if (action.href) {
@ -160,7 +264,7 @@
onkeydown={handleKeydown}
>
<div class="command-modal">
<!-- Search Input -->
<!-- Search Input with Syntax Highlighting -->
<div class="command-input-wrapper">
<svg class="command-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -170,37 +274,84 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:this={inputElement}
type="text"
{placeholder}
bind:value={searchQuery}
oninput={handleSearch}
class="command-input"
/>
<div class="input-highlight-container">
<!-- Highlight backdrop (shows colored keywords) -->
<div class="input-highlight-backdrop" aria-hidden="true">
{@html highlightedQuery}&nbsp;
</div>
<!-- Actual input (transparent text, visible caret) -->
<input
bind:this={inputElement}
type="text"
{placeholder}
bind:value={searchQuery}
oninput={handleSearch}
class="command-input"
/>
</div>
<kbd class="command-shortcut">ESC</kbd>
</div>
<!-- Results -->
{#if searchQuery.trim()}
<div class="command-results">
<!-- Create option (always first when available) -->
{#if createPreview && onCreate}
<button
type="button"
class="command-result create-option"
class:selected={selectedIndex === 0}
onclick={handleCreate}
onmouseenter={() => (selectedIndex = 0)}
disabled={creating}
>
<div class="result-avatar create-avatar">
{#if creating}
<div class="loading-spinner-small"></div>
{:else}
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{/if}
</div>
<div class="result-info">
<div class="result-name">{createPreview.title}</div>
{#if createPreview.subtitle}
<div class="result-details">
<span>{createPreview.subtitle}</span>
</div>
{/if}
</div>
<kbd class="create-shortcut">{createShortcut}</kbd>
</button>
{/if}
{#if loading}
<div class="command-loading">
<div class="loading-spinner"></div>
<span>{searchingText}</span>
</div>
{:else if results.length === 0}
{:else if results.length === 0 && !createPreview}
<div class="command-empty">
<span>{emptyText}</span>
</div>
{:else}
{:else if results.length > 0}
<div class="results-divider">
<span>Suchergebnisse</span>
</div>
{#each results as item, index (item.id)}
{@const adjustedIndex = createPreview ? index + 1 : index}
<button
type="button"
class="command-result"
class:selected={index === selectedIndex}
class:selected={adjustedIndex === selectedIndex}
onclick={() => selectItem(item)}
onmouseenter={() => (selectedIndex = index)}
onmouseenter={() => (selectedIndex = adjustedIndex)}
>
<div class="result-avatar">
{#if item.imageUrl}
@ -329,6 +480,9 @@
<div class="footer-hints">
<span><kbd>↑↓</kbd> Navigation</span>
<span><kbd></kbd> Öffnen</span>
{#if onCreate}
<span><kbd>{createShortcut}</kbd> Erstellen</span>
{/if}
<span><kbd>ESC</kbd> Schließen</span>
</div>
</div>
@ -345,9 +499,9 @@
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
background: hsl(var(--color-background) / 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
animation: fadeIn 0.15s ease;
}
@ -364,15 +518,15 @@
width: 100%;
max-width: 560px;
margin: 0 1rem;
background: #1a1a1a;
border: 1px solid #333;
background: hsl(var(--color-surface-elevated));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.1);
0 25px 50px -12px hsl(var(--color-background) / 0.5),
0 0 0 1px hsl(var(--color-border) / 0.5);
overflow: hidden;
animation: slideIn 0.2s ease;
color: #e5e5e5;
color: hsl(var(--color-foreground));
}
@keyframes slideIn {
@ -391,37 +545,103 @@
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #333;
border-bottom: 1px solid hsl(var(--color-border));
}
.command-icon {
width: 1.25rem;
height: 1.25rem;
color: #888;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.command-input {
/* Input with syntax highlighting overlay */
.input-highlight-container {
position: relative;
flex: 1;
min-width: 0;
}
.input-highlight-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
font-size: 1rem;
font-family: inherit;
white-space: pre;
pointer-events: none;
color: hsl(var(--color-foreground));
overflow: hidden;
}
.command-input {
position: relative;
width: 100%;
border: none;
background: transparent;
font-size: 1rem;
color: #fff;
font-family: inherit;
color: transparent;
caret-color: hsl(var(--color-foreground));
outline: none;
z-index: 1;
}
.command-input::placeholder {
color: #666;
color: hsl(var(--color-muted-foreground));
}
/* Syntax highlighting colors - Priority levels with matching UI colors */
.input-highlight-backdrop :global(.hl-priority-urgent) {
color: #ef4444; /* red - Dringend */
font-weight: 600;
}
.input-highlight-backdrop :global(.hl-priority-high) {
color: #f97316; /* orange - Wichtig */
font-weight: 600;
}
.input-highlight-backdrop :global(.hl-priority-medium) {
color: #eab308; /* yellow - Normal */
font-weight: 600;
}
.input-highlight-backdrop :global(.hl-priority-low) {
color: #22c55e; /* green - Später */
font-weight: 600;
}
.input-highlight-backdrop :global(.hl-tag) {
color: hsl(var(--color-primary));
font-weight: 500;
}
.input-highlight-backdrop :global(.hl-reference) {
color: hsl(var(--color-success));
font-weight: 500;
}
.input-highlight-backdrop :global(.hl-date) {
color: hsl(262 83% 58%);
font-weight: 500;
}
.input-highlight-backdrop :global(.hl-time) {
color: hsl(262 83% 58%);
font-weight: 500;
}
.command-shortcut {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 4px;
color: #888;
color: hsl(var(--color-muted-foreground));
}
.command-results {
@ -436,15 +656,24 @@
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: #888;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #333;
border-top-color: #3b82f6;
border: 2px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner-small {
width: 1rem;
height: 1rem;
border: 2px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-success));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@ -455,6 +684,40 @@
}
}
/* Create option styles */
.create-option {
border-bottom: 1px solid hsl(var(--color-border));
}
.create-option.selected,
.create-option:hover {
background: hsl(var(--color-success) / 0.1);
}
.create-avatar {
background: hsl(var(--color-success));
}
.create-shortcut {
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-family: inherit;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 4px;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.results-divider {
padding: 0.5rem 1.25rem 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.command-result {
display: flex;
align-items: center;
@ -466,12 +729,12 @@
cursor: pointer;
text-align: left;
transition: background 0.1s ease;
color: #e5e5e5;
color: hsl(var(--color-foreground));
}
.command-result:hover,
.command-result.selected {
background: #2a2a2a;
background: hsl(var(--color-surface-hover));
}
.result-avatar {
@ -479,8 +742,8 @@
height: 40px;
min-width: 40px;
border-radius: 9999px;
background: #3b82f6;
color: #fff;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;
justify-content: center;
@ -495,7 +758,7 @@
.result-name {
font-weight: 500;
color: #fff;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -505,7 +768,7 @@
display: flex;
gap: 0.5rem;
font-size: 0.8125rem;
color: #888;
color: hsl(var(--color-muted-foreground));
}
.result-details span {
@ -517,7 +780,7 @@
.result-favorite {
width: 1rem;
height: 1rem;
color: #ef4444;
color: hsl(var(--color-error));
flex-shrink: 0;
}
@ -532,7 +795,7 @@
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
color: #e5e5e5;
color: hsl(var(--color-foreground));
background: transparent;
border: none;
cursor: pointer;
@ -542,13 +805,13 @@
.quick-action:hover,
.quick-action.selected {
background: #2a2a2a;
background: hsl(var(--color-surface-hover));
}
.quick-action-icon {
width: 1.25rem;
height: 1.25rem;
color: #888;
color: hsl(var(--color-muted-foreground));
}
.quick-action span {
@ -560,30 +823,30 @@
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 4px;
color: #888;
color: hsl(var(--color-muted-foreground));
}
.command-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid #333;
background: #141414;
border-top: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
}
.footer-hints {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #666;
color: hsl(var(--color-muted-foreground));
}
.footer-hints kbd {
padding: 0.125rem 0.25rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
background: hsl(var(--color-surface-elevated));
border: 1px solid hsl(var(--color-border));
border-radius: 3px;
margin-right: 0.25rem;
}

View file

@ -1,2 +1,2 @@
export { default as CommandBar } from './CommandBar.svelte';
export type { CommandBarItem, QuickAction } from './CommandBar.svelte';
export type { CommandBarItem, QuickAction, CreatePreview } from './CommandBar.svelte';

View file

@ -106,7 +106,26 @@ export {
// Command Bar
export { CommandBar } from './command-bar';
export type { CommandBarItem, QuickAction } from './command-bar';
export type { CommandBarItem, QuickAction, CreatePreview } from './command-bar';
// Pages
export { default as AppsPage } from './pages/AppsPage.svelte';
// Charts - Statistics Visualization
export {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
STAT_VARIANT_COLORS,
} from './charts';
export type {
StatVariant,
StatItem,
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from './charts';

View file

@ -8,14 +8,27 @@
import { getAvailableRoutes, getDefaultRoute } from '@manacore/shared-theme';
import SettingsSection from './SettingsSection.svelte';
import SettingsCard from './SettingsCard.svelte';
import NavVisibilitySettings from './NavVisibilitySettings.svelte';
interface NavItem {
href: string;
label: string;
icon?: string;
}
interface Props {
/** User settings store instance */
userSettings: UserSettingsStore;
/** App ID for start page selection */
appId?: string;
/** Navigation items for visibility settings */
navItems?: NavItem[];
/** Items that should always be visible (e.g., home route) */
alwaysVisibleHrefs?: string[];
/** Whether to show navigation settings */
showNavigation?: boolean;
/** Whether to show nav visibility settings */
showNavVisibility?: boolean;
/** Whether to show theme settings */
showTheme?: boolean;
/** Whether to show language settings */
@ -33,7 +46,10 @@
let {
userSettings,
appId,
navItems = [],
alwaysVisibleHrefs = [],
showNavigation = true,
showNavVisibility = true,
showTheme = true,
showLanguage = true,
showGeneral = true,
@ -205,10 +221,21 @@
</div>
{/if}
{#if showNavVisibility && appId && navItems.length > 0}
<!-- Navigation Visibility Settings -->
<div
class="space-y-4 {showNavigation ? 'pt-4 border-t border-[hsl(var(--border))]' : ''}"
>
<NavVisibilitySettings {userSettings} {appId} {navItems} {alwaysVisibleHrefs} />
</div>
{/if}
{#if showTheme}
<!-- Theme Settings -->
<div
class="space-y-4 {showNavigation ? 'pt-4 border-t border-[hsl(var(--border))]' : ''}"
class="space-y-4 {showNavigation || (showNavVisibility && appId)
? 'pt-4 border-t border-[hsl(var(--border))]'
: ''}"
>
<h3
class="text-xs font-semibold text-[hsl(var(--muted-foreground))] uppercase tracking-wider"
@ -266,7 +293,7 @@
{#if showLanguage}
<!-- Language Settings -->
<div
class="space-y-4 {showTheme || showNavigation
class="space-y-4 {showTheme || showNavigation || (showNavVisibility && appId)
? 'pt-4 border-t border-[hsl(var(--border))]'
: ''}"
>
@ -303,7 +330,10 @@
{#if showGeneral}
<!-- General Settings -->
<div
class="space-y-4 {showLanguage || showTheme || showNavigation
class="space-y-4 {showLanguage ||
showTheme ||
showNavigation ||
(showNavVisibility && appId)
? 'pt-4 border-t border-[hsl(var(--border))]'
: ''}"
>
@ -329,7 +359,7 @@
>
{#each availableRoutes as route}
<option value={route.path}>
{t(route.labelKey)}
{route.label}
</option>
{/each}
</select>

View file

@ -0,0 +1,175 @@
<script lang="ts">
import type { UserSettingsStore } from '@manacore/shared-theme';
interface NavItem {
href: string;
label: string;
icon?: string;
}
interface Props {
/** User settings store instance */
userSettings: UserSettingsStore;
/** Current app ID */
appId: string;
/** Navigation items from the app layout */
navItems: NavItem[];
/** Items that should always be visible (e.g., home route) */
alwaysVisibleHrefs?: string[];
}
let { userSettings, appId, navItems, alwaysVisibleHrefs = [] }: Props = $props();
// Filter to only show hideable items (exclude always visible)
const hideableItems = $derived(
navItems.filter((item) => !alwaysVisibleHrefs.includes(item.href))
);
// Check if there are any routes to configure
const hasRoutes = $derived(hideableItems.length > 0);
function isRouteHidden(href: string): boolean {
const hidden = userSettings.getHiddenNavItemsForApp(appId);
return hidden.includes(href);
}
async function handleToggle(href: string): Promise<void> {
await userSettings.toggleNavItemVisibility(appId, href);
}
// Icon SVG paths (same as PillNavigation)
const icons: Record<string, string> = {
// Clock app icons
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
timer: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
stopwatch: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0zM9 3h6m-3-1v2',
activity: 'M13 10V3L4 14h7v7l9-11h-7z',
target:
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
globe:
'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
// Todo app icons
inbox:
'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4',
check: 'M5 13l4 4L19 7',
checkCircle: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
plus: 'M12 4v16m8-8H4',
columns:
'M9 4H5a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1zM19 4h-4a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1z',
kanban:
'M9 4H5a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1zM19 4h-4a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1z',
// Common icons
mic: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z',
calendar:
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
folder: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
upload: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12',
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
document:
'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
chart:
'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
settings:
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
users:
'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
building:
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
heart:
'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
compass:
'M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM16.24 7.76L14.12 14.12L7.76 16.24L9.88 9.88L16.24 7.76Z',
moon: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
sun: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z',
palette:
'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
chat: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
'share-2':
'M18 8a3 3 0 100-6 3 3 0 000 6zM6 15a3 3 0 100-6 3 3 0 000 6zM18 22a3 3 0 100-6 3 3 0 000 6zM8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98',
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
mana: 'M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z',
quote:
'M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z',
image:
'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
layers:
'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
trending: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
sparkle:
'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z',
};
function getIconPath(name: string): string {
return icons[name] || '';
}
</script>
{#if hasRoutes}
<div class="space-y-4">
<div>
<h3
class="text-xs font-semibold text-[hsl(var(--muted-foreground))] uppercase tracking-wider"
>
Navigation anpassen
</h3>
<p class="text-sm text-[hsl(var(--muted-foreground))] mt-1">
Versteckte Seiten bleiben über die URL erreichbar
</p>
</div>
<div class="space-y-1">
{#each hideableItems as item (item.href)}
{@const hidden = isRouteHidden(item.href)}
{@const iconPath = item.icon ? getIconPath(item.icon) : ''}
<label
class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-[hsl(var(--muted))]/50 cursor-pointer transition-colors border border-transparent hover:border-[hsl(var(--border))]"
>
<span
class="flex items-center gap-3 text-sm {hidden
? 'text-[hsl(var(--muted-foreground))]'
: 'text-[hsl(var(--foreground))]'}"
>
{#if iconPath}
<svg
class="w-4 h-4 flex-shrink-0 {hidden ? 'opacity-50' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconPath}></path>
</svg>
{/if}
<span class={hidden ? 'line-through' : ''}>{item.label}</span>
</span>
<button
type="button"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {!hidden
? 'bg-[hsl(var(--primary))]'
: 'bg-gray-200 dark:bg-gray-700'}"
onclick={() => handleToggle(item.href)}
aria-label={hidden ? 'Einblenden' : 'Ausblenden'}
>
<span
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform shadow-sm {!hidden
? 'translate-x-5'
: 'translate-x-0.5'}"
></span>
</button>
</label>
{/each}
</div>
</div>
{/if}

View file

@ -10,3 +10,4 @@ export { default as SettingsTimeInput } from './SettingsTimeInput.svelte';
export { default as SettingsDangerZone } from './SettingsDangerZone.svelte';
export { default as SettingsDangerButton } from './SettingsDangerButton.svelte';
export { default as GlobalSettingsSection } from './GlobalSettingsSection.svelte';
export { default as NavVisibilitySettings } from './NavVisibilitySettings.svelte';

View file

@ -22,3 +22,6 @@ export * from './keyboard';
// IndexedDB Cache
export * from './cache';
// Natural Language Parsers
export * from './parsers';

View file

@ -0,0 +1,320 @@
/**
* Base Natural Language Parser
*
* Shared parsing utilities for date, time, and tags across all apps.
* App-specific parsers (task-parser, event-parser, contact-parser) extend this.
*/
import {
addDays,
nextMonday,
nextTuesday,
nextWednesday,
nextThursday,
nextFriday,
nextSaturday,
nextSunday,
setHours,
setMinutes,
} from 'date-fns';
export interface BaseParsedInput {
title: string;
date?: Date;
time?: { hours: number; minutes: number };
tagNames: string[];
rawInput: string;
}
export interface ExtractResult<T> {
value: T | undefined;
remaining: string;
}
// ============================================================================
// Date Extraction
// ============================================================================
interface DatePattern {
pattern: RegExp;
getDate: (match?: RegExpMatchArray) => Date;
}
const DATE_PATTERNS: DatePattern[] = [
{ pattern: /\bheute\b/i, getDate: () => new Date() },
{ pattern: /\bmorgen\b/i, getDate: () => addDays(new Date(), 1) },
{ pattern: /\bübermorgen\b/i, getDate: () => addDays(new Date(), 2) },
{ pattern: /\bnächste[nr]?\s*woche\b/i, getDate: () => addDays(new Date(), 7) },
{ pattern: /\bnächste[nr]?\s*montag\b/i, getDate: () => nextMonday(new Date()) },
{ pattern: /\bnächste[nr]?\s*dienstag\b/i, getDate: () => nextTuesday(new Date()) },
{ pattern: /\bnächste[nr]?\s*mittwoch\b/i, getDate: () => nextWednesday(new Date()) },
{ pattern: /\bnächste[nr]?\s*donnerstag\b/i, getDate: () => nextThursday(new Date()) },
{ pattern: /\bnächste[nr]?\s*freitag\b/i, getDate: () => nextFriday(new Date()) },
{ pattern: /\bnächste[nr]?\s*samstag\b/i, getDate: () => nextSaturday(new Date()) },
{ pattern: /\bnächste[nr]?\s*sonntag\b/i, getDate: () => nextSunday(new Date()) },
{ pattern: /\bmontag\b/i, getDate: () => nextMonday(new Date()) },
{ pattern: /\bdienstag\b/i, getDate: () => nextTuesday(new Date()) },
{ pattern: /\bmittwoch\b/i, getDate: () => nextWednesday(new Date()) },
{ pattern: /\bdonnerstag\b/i, getDate: () => nextThursday(new Date()) },
{ pattern: /\bfreitag\b/i, getDate: () => nextFriday(new Date()) },
{ pattern: /\bsamstag\b/i, getDate: () => nextSaturday(new Date()) },
{ pattern: /\bsonntag\b/i, getDate: () => nextSunday(new Date()) },
];
// Pattern for "in X Tagen"
const IN_DAYS_PATTERN = /\bin\s*(\d+)\s*tage?n?\b/i;
// Pattern for specific date (DD.MM. or DD.MM.YYYY)
const SPECIFIC_DATE_PATTERN = /\b(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\b/;
/**
* Extract date from text
*/
export function extractDate(text: string): ExtractResult<Date> {
let remaining = text;
let date: Date | undefined;
// Try "in X Tagen" pattern first
const inDaysMatch = remaining.match(IN_DAYS_PATTERN);
if (inDaysMatch) {
const days = parseInt(inDaysMatch[1], 10);
date = addDays(new Date(), days);
remaining = remaining.replace(IN_DAYS_PATTERN, '').trim();
return { value: date, remaining };
}
// Try specific date (DD.MM. or DD.MM.YYYY)
const specificDateMatch = remaining.match(SPECIFIC_DATE_PATTERN);
if (specificDateMatch) {
const day = parseInt(specificDateMatch[1], 10);
const month = parseInt(specificDateMatch[2], 10) - 1;
const year = specificDateMatch[3]
? parseInt(specificDateMatch[3], 10) < 100
? 2000 + parseInt(specificDateMatch[3], 10)
: parseInt(specificDateMatch[3], 10)
: new Date().getFullYear();
date = new Date(year, month, day);
remaining = remaining.replace(SPECIFIC_DATE_PATTERN, '').trim();
return { value: date, remaining };
}
// Try relative date patterns
for (const { pattern, getDate } of DATE_PATTERNS) {
if (pattern.test(remaining)) {
date = getDate();
remaining = remaining.replace(pattern, '').trim();
return { value: date, remaining };
}
}
return { value: undefined, remaining };
}
// ============================================================================
// Time Extraction
// ============================================================================
// Pattern for time (um 14 Uhr, 14:00, etc.)
const TIME_PATTERN = /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
/**
* Extract time from text
*/
export function extractTime(text: string): ExtractResult<{ hours: number; minutes: number }> {
const match = text.match(TIME_PATTERN);
if (match) {
const hours = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
// Validate time
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
const remaining = text.replace(TIME_PATTERN, '').trim();
return { value: { hours, minutes }, remaining };
}
}
return { value: undefined, remaining: text };
}
// ============================================================================
// Tag Extraction
// ============================================================================
/**
* Extract tags (#tag1 #tag2) from text
*/
export function extractTags(text: string): ExtractResult<string[]> {
const tags: string[] = [];
const tagRegex = /#(\S+)/g;
let match;
while ((match = tagRegex.exec(text)) !== null) {
tags.push(match[1]);
}
const remaining = text.replace(/#\S+/g, '').trim();
return { value: tags, remaining };
}
// ============================================================================
// @ Reference Extraction (Projects, Calendars, Companies)
// ============================================================================
/**
* Extract @reference from text
*/
export function extractAtReference(text: string): ExtractResult<string> {
const match = text.match(/@(\S+)/);
if (match) {
const remaining = text.replace(/@\S+/, '').trim();
return { value: match[1], remaining };
}
return { value: undefined, remaining: text };
}
// ============================================================================
// Combined Date + Time
// ============================================================================
/**
* Combine date and time into a single Date object
*/
export function combineDateAndTime(
date?: Date,
time?: { hours: number; minutes: number }
): Date | undefined {
if (!date) return undefined;
if (time) {
return setHours(setMinutes(date, time.minutes), time.hours);
}
return date;
}
// ============================================================================
// Preview Formatting
// ============================================================================
/**
* Format date for preview display
*/
export function formatDatePreview(date: Date): string {
const now = new Date();
const tomorrow = addDays(now, 1);
if (date.toDateString() === now.toDateString()) {
return 'Heute';
}
if (date.toDateString() === tomorrow.toDateString()) {
return 'Morgen';
}
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
/**
* Format time for preview display
*/
export function formatTimePreview(time: { hours: number; minutes: number }): string {
return `${time.hours.toString().padStart(2, '0')}:${time.minutes.toString().padStart(2, '0')}`;
}
/**
* Format date and time for preview
*/
export function formatDateTimePreview(
date?: Date,
time?: { hours: number; minutes: number }
): string {
if (!date) return '';
let result = formatDatePreview(date);
if (time) {
result += ` ${formatTimePreview(time)}`;
}
return result;
}
// ============================================================================
// Main Parser Function
// ============================================================================
/**
* Parse base input - extracts common patterns (date, time, tags, @reference)
*
* App-specific parsers should call this first, then extract their own patterns.
*/
export function parseBaseInput(input: string): BaseParsedInput {
let text = input.trim();
const rawInput = text;
// Extract tags first (they're clearly delimited)
const tagsResult = extractTags(text);
text = tagsResult.remaining;
const tagNames = tagsResult.value || [];
// Extract date
const dateResult = extractDate(text);
text = dateResult.remaining;
const date = dateResult.value;
// Extract time
const timeResult = extractTime(text);
text = timeResult.remaining;
const time = timeResult.value;
// If we got time but no date, assume today
const finalDate = time && !date ? new Date() : date;
// Clean up multiple spaces
const title = text.replace(/\s+/g, ' ').trim();
return {
title,
date: finalDate,
time,
tagNames,
rawInput,
};
}
// ============================================================================
// Utility: Clean title from all patterns
// ============================================================================
/**
* Remove all recognized patterns from text to get clean title
*/
export function cleanTitle(text: string): string {
let result = text;
// Remove tags
result = result.replace(/#\S+/g, '');
// Remove @references
result = result.replace(/@\S+/g, '');
// Remove dates
result = result.replace(IN_DAYS_PATTERN, '');
result = result.replace(SPECIFIC_DATE_PATTERN, '');
for (const { pattern } of DATE_PATTERNS) {
result = result.replace(pattern, '');
}
// Remove time
result = result.replace(TIME_PATTERN, '');
// Clean up
return result.replace(/\s+/g, ' ').trim();
}

View file

@ -0,0 +1,26 @@
/**
* Natural Language Parsers
*
* Base parser with common patterns, extended by app-specific parsers.
*/
export {
// Types
type BaseParsedInput,
type ExtractResult,
// Extraction functions
extractDate,
extractTime,
extractTags,
extractAtReference,
// Combination
combineDateAndTime,
// Preview formatting
formatDatePreview,
formatTimePreview,
formatDateTimePreview,
// Main parser
parseBaseInput,
// Utilities
cleanTitle,
} from './base-parser';

603
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -426,6 +426,8 @@ const APP_CONFIGS = [
vars: {
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
PUBLIC_TODO_BACKEND_URL: (env) =>
env.TODO_BACKEND_URL || `http://localhost:${env.TODO_BACKEND_PORT || '3018'}`,
},
},

Some files were not shown because too many files have changed in this diff Show more