♻️ refactor(todo): remove statistics, network view and session tasks; implement demo mode

Remove complex, low-usage features to simplify codebase:
- Statistics/Heatmap: ~1,900 LOC removed (charts, store, page)
- Network View: ~800 LOC removed (D3.js graph, store, API, page)
- Session Tasks: ~190 LOC removed, replaced with demo mode

Demo mode improvements:
- Static sample tasks instead of confusing sessionStorage behavior
- Auth gate shows when users try to create/edit/complete tasks
- Clear "Demo-Modus" banner for unauthenticated users
- Better UX: users can explore the app without frustration

Total estimated savings: ~2,800 LOC
This commit is contained in:
Till-JS 2026-01-28 13:57:59 +01:00
parent 836b341b3e
commit 99fdf1d17f
23 changed files with 431 additions and 2900 deletions

View file

@ -1,43 +0,0 @@
/**
* Network Graph API Client
*/
import { apiClient } from './client';
export interface NetworkTag {
id: string;
name: string;
color: string | null;
}
export interface NetworkNode {
id: string;
name: string;
photoUrl: string | null;
company: string | null; // Project name
isFavorite: boolean;
tags: NetworkTag[];
connectionCount: number;
}
export interface NetworkLink {
source: string;
target: string;
type: 'tag';
strength: number;
sharedTags: string[];
}
export interface NetworkGraphResponse {
nodes: NetworkNode[];
links: NetworkLink[];
}
export const networkApi = {
/**
* Get the network graph of tasks connected by shared labels
*/
async getGraph(): Promise<NetworkGraphResponse> {
return apiClient.get<NetworkGraphResponse>('/api/v1/network/graph');
},
};

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { sessionTasksStore } from '$lib/stores/session-tasks.svelte';
interface Props {
visible: boolean;
@ -16,7 +15,7 @@
save: {
title: 'Anmelden um zu speichern',
description:
'Melde dich an, um deine Aufgaben in der Cloud zu speichern und auf allen Geräten zu synchronisieren.',
'Im Demo-Modus kannst du die App erkunden. Melde dich an, um eigene Aufgaben zu erstellen und zu speichern.',
icon: 'cloud',
},
sync: {
@ -33,7 +32,6 @@
};
let currentMessage = $derived(messages[action]);
let sessionTaskCount = $derived(sessionTasksStore.count);
function handleLogin() {
// Store return URL for redirect after login
@ -121,21 +119,7 @@
{currentMessage.description}
</p>
<!-- Session tasks info -->
{#if sessionTaskCount > 0}
<div class="bg-muted/50 mb-6 rounded-lg p-3 text-center text-sm">
<span class="text-muted-foreground">
Du hast <strong class="text-foreground">{sessionTaskCount}</strong>
{sessionTaskCount === 1 ? 'Aufgabe' : 'Aufgaben'} in dieser Sitzung erstellt.
</span>
<br />
<span class="text-muted-foreground text-xs">
Diese werden nach der Anmeldung in deinen Account übernommen.
</span>
</div>
{/if}
<!-- Buttons -->
<!-- Buttons -->
<div class="flex flex-col gap-3">
<button
onclick={handleLogin}
@ -159,8 +143,8 @@
<!-- Info text -->
<p class="text-muted-foreground mt-4 text-center text-xs">
Du kannst weiterhin Aufgaben erstellen. Diese werden lokal gespeichert und gehen beim
Schließen des Tabs verloren.
Du kannst die Demo-Aufgaben ansehen, aber um eigene Aufgaben zu erstellen benötigst du ein
Konto.
</p>
</div>
</div>

View file

@ -60,13 +60,19 @@
isLoading = true;
try {
await tasksStore.createTask({
const result = await tasksStore.createTask({
title,
projectId: selectedProjectId,
dueDate: selectedDate.toISOString(),
priority: selectedPriority,
});
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
// Reset form
inputValue = '';
selectedDate = new Date();

View file

@ -117,15 +117,26 @@
}
async function handleToggleComplete(task: Task) {
let result;
if (task.isCompleted) {
await tasksStore.uncompleteTask(task.id);
result = await tasksStore.uncompleteTask(task.id);
} else {
await tasksStore.completeTask(task.id);
result = await tasksStore.completeTask(task.id);
}
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
}
}
async function handleDelete(taskId: string) {
await tasksStore.deleteTask(taskId);
const result = await tasksStore.deleteTask(taskId);
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
}
}
</script>

View file

@ -79,7 +79,12 @@
// Get projectId from current board if available
const currentBoard = kanbanStore.currentBoard;
const taskProjectId = currentBoard?.projectId ?? projectId;
await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
const result = await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
}
}
async function handleTaskMove(taskId: string, toColumnId: string, order: number) {

View file

@ -62,10 +62,16 @@
}
async function handleToggleComplete(task: Task) {
let result;
if (task.isCompleted) {
await tasksStore.uncompleteTask(task.id);
result = await tasksStore.uncompleteTask(task.id);
} else {
await tasksStore.completeTask(task.id);
result = await tasksStore.completeTask(task.id);
}
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
}
}
@ -85,7 +91,12 @@
if (data.metadata !== undefined) updateData.metadata = data.metadata;
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
await tasksStore.updateTask(task.id, updateData);
const result = await tasksStore.updateTask(task.id, updateData);
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
}
}
async function handleDeleteTask(task: Task) {

View file

@ -1,299 +0,0 @@
<script lang="ts">
import { format, parseISO, getMonth } from 'date-fns';
import { de } from 'date-fns/locale';
interface DailyCompletion {
date: string;
count: number;
dayOfWeek: number;
}
interface Props {
data: DailyCompletion[];
}
let { data }: 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
function getColorClass(count: number): string {
if (count === 0) return 'level-0';
const ratio = count / maxCount;
if (ratio <= 0.25) return 'level-1';
if (ratio <= 0.5) return 'level-2';
if (ratio <= 0.75) return 'level-3';
return 'level-4';
}
// Group data by weeks
let weeks = $derived.by(() => {
const result: DailyCompletion[][] = [];
let currentWeek: DailyCompletion[] = [];
// 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: DailyCompletion): string {
if (!day.date) return '';
const date = format(parseISO(day.date), 'EEEE, d. MMMM yyyy', { locale: de });
return `${day.count} ${day.count === 1 ? 'Aufgabe' : 'Aufgaben'} am ${date}`;
}
</script>
<div class="heatmap-container">
<h3 class="heatmap-title">Aktivität</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 level-0"></div>
<div class="legend-cell level-1"></div>
<div class="legend-cell level-2"></div>
<div class="legend-cell level-3"></div>
<div class="legend-cell level-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;
}
.cell.level-0 {
fill: hsl(var(--muted) / 0.3);
}
.cell.level-1 {
fill: rgba(139, 92, 246, 0.3);
}
.cell.level-2 {
fill: rgba(139, 92, 246, 0.5);
}
.cell.level-3 {
fill: rgba(139, 92, 246, 0.7);
}
.cell.level-4 {
fill: #8b5cf6;
}
:global(.dark) .cell.level-0 {
fill: rgba(255, 255, 255, 0.1);
}
.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;
}
.legend-cell.level-0 {
background: hsl(var(--muted) / 0.3);
}
.legend-cell.level-1 {
background: rgba(139, 92, 246, 0.3);
}
.legend-cell.level-2 {
background: rgba(139, 92, 246, 0.5);
}
.legend-cell.level-3 {
background: rgba(139, 92, 246, 0.7);
}
.legend-cell.level-4 {
background: #8b5cf6;
}
:global(.dark) .legend-cell.level-0 {
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -1,248 +0,0 @@
<script lang="ts">
import type { TaskPriority } from '@todo/shared';
interface PriorityBreakdown {
priority: TaskPriority;
count: number;
percentage: number;
color: string;
}
interface Props {
data: PriorityBreakdown[];
}
let { data }: Props = $props();
// Chart settings
const SIZE = 200;
const CENTER = SIZE / 2;
const RADIUS = 80;
const INNER_RADIUS = 50;
// Priority labels
const PRIORITY_LABELS: Record<TaskPriority, string> = {
low: 'Später',
medium: 'Normal',
high: 'Wichtig',
urgent: 'Dringend',
};
// Total count
let total = $derived(data.reduce((sum, d) => sum + d.count, 0));
// Generate arc paths
let arcs = $derived.by(() => {
if (total === 0) return [];
const result: Array<{
path: string;
color: string;
priority: TaskPriority;
count: number;
percentage: number;
}> = [];
let currentAngle = -90; // Start at top
data.forEach((segment) => {
if (segment.count === 0) return;
const angle = (segment.count / total) * 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,
priority: segment.priority,
count: segment.count,
percentage: segment.percentage,
});
currentAngle = endAngle;
});
return result;
});
// Hover state
let hoveredSegment = $state<TaskPriority | null>(null);
</script>
<div class="donut-container">
<h3 class="donut-title">Prioritäten</h3>
<!-- 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="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 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>
<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-chart {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.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: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.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: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.75rem;
color: hsl(var(--foreground));
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-count {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
</style>

View file

@ -1,192 +0,0 @@
<script lang="ts">
interface ProjectProgress {
projectId: string | null;
projectName: string;
projectColor: string;
total: number;
completed: number;
inProgress: number;
percentage: number;
}
interface Props {
data: ProjectProgress[];
}
let { data }: Props = $props();
// Sort by total tasks (descending) and limit to top 8
let sortedData = $derived(data.slice(0, 8));
</script>
<div class="progress-container">
<h3 class="progress-title">Projekt-Fortschritt</h3>
{#if sortedData.length === 0}
<p class="no-data">Keine Projekte mit Aufgaben</p>
{:else}
<div class="progress-list">
{#each sortedData as project}
<div class="project-row">
<div class="project-header">
<div class="project-name">
<span class="project-dot" style="background-color: {project.projectColor}"></span>
<span class="name-text">{project.projectName}</span>
</div>
<span class="project-stats">
{project.completed}/{project.total}
</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar">
<!-- Completed segment -->
{#if project.completed > 0}
<div
class="progress-segment completed"
style="width: {(project.completed / project.total) *
100}%; background-color: {project.projectColor}"
></div>
{/if}
<!-- In Progress segment -->
{#if project.inProgress > 0}
<div
class="progress-segment in-progress"
style="width: {(project.inProgress / project.total) *
100}%; background-color: {project.projectColor}; opacity: 0.4"
></div>
{/if}
</div>
<span class="percentage">{project.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;
}
.project-row {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.project-name {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.project-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;
}
.project-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

@ -1,196 +0,0 @@
<script lang="ts">
import { CheckCircle, Clock, AlertTriangle, TrendingUp, Target, Calendar } from 'lucide-svelte';
interface Props {
completedToday: number;
completedThisWeek: number;
activeTasks: number;
overdueTasks: number;
completionRate: number;
storyPointsThisWeek: number;
}
let {
completedToday,
completedThisWeek,
activeTasks,
overdueTasks,
completionRate,
storyPointsThisWeek,
}: Props = $props();
</script>
<div class="stats-grid">
<div class="stat-card stat-card-success">
<div class="stat-icon">
<CheckCircle size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{completedToday}</span>
<span class="stat-label">Heute erledigt</span>
</div>
</div>
<div class="stat-card stat-card-primary">
<div class="stat-icon">
<Calendar size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{completedThisWeek}</span>
<span class="stat-label">Diese Woche</span>
</div>
</div>
<div class="stat-card stat-card-neutral">
<div class="stat-icon">
<Clock size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{activeTasks}</span>
<span class="stat-label">Aktive Tasks</span>
</div>
</div>
<div
class="stat-card"
class:stat-card-danger={overdueTasks > 0}
class:stat-card-neutral={overdueTasks === 0}
>
<div class="stat-icon">
<AlertTriangle size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{overdueTasks}</span>
<span class="stat-label">Überfällig</span>
</div>
</div>
<div class="stat-card stat-card-info">
<div class="stat-icon">
<TrendingUp size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{completionRate}%</span>
<span class="stat-label">Abschlussrate</span>
</div>
</div>
{#if storyPointsThisWeek > 0}
<div class="stat-card stat-card-accent">
<div class="stat-icon">
<Target size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{storyPointsThisWeek}</span>
<span class="stat-label">Story Points</span>
</div>
</div>
{/if}
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(6, 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;
}
/* Color Variants */
.stat-card-success .stat-icon {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.stat-card-primary .stat-icon {
background: rgba(139, 92, 246, 0.15);
color: #8b5cf6;
}
.stat-card-neutral .stat-icon {
background: rgba(107, 114, 128, 0.15);
color: #6b7280;
}
.stat-card-danger .stat-icon {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.stat-card-info .stat-icon {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.stat-card-accent .stat-icon {
background: rgba(236, 72, 153, 0.15);
color: #ec4899;
}
</style>

View file

@ -1,214 +0,0 @@
<script lang="ts">
interface DataPoint {
date: string;
count: number;
dayName: string;
}
interface Props {
data: DataPoint[];
}
let { data }: Props = $props();
// Chart dimensions
const WIDTH = 600;
const HEIGHT = 200;
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 {
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;
});
</script>
<div class="chart-container">
<h3 class="chart-title">Trend (letzte 4 Wochen)</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="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8B5CF6" stop-opacity="0.3" />
<stop offset="100%" stop-color="#8B5CF6" stop-opacity="0.05" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#areaGradient)" 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>{point.count} Aufgaben am {point.date}</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;
}
.line-path {
fill: none;
stroke: #8b5cf6;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.data-point {
fill: #8b5cf6;
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,187 @@
/**
* Demo Tasks - Static sample tasks for unauthenticated users
*
* Shows a realistic task list with various task types to demonstrate
* the app's capabilities without requiring login.
*/
import type { Task } from '@todo/shared';
import { addDays, format, subDays } from 'date-fns';
/**
* Generate demo tasks relative to the current date
*/
export function generateDemoTasks(): Task[] {
const now = new Date();
const today = format(now, 'yyyy-MM-dd');
const tomorrow = format(addDays(now, 1), 'yyyy-MM-dd');
const dayAfterTomorrow = format(addDays(now, 2), 'yyyy-MM-dd');
const nextWeek = format(addDays(now, 7), 'yyyy-MM-dd');
const yesterday = format(subDays(now, 1), 'yyyy-MM-dd');
const demoTasks: Task[] = [
// Overdue task
{
id: 'demo_1',
userId: 'demo',
title: 'Steuererklärung abgeben',
description: 'Alle Unterlagen zusammenstellen und online einreichen',
dueDate: yesterday,
priority: 'urgent',
status: 'pending',
isCompleted: false,
order: 0,
subtasks: [
{ id: 'sub_1_1', title: 'Belege sammeln', isCompleted: true, order: 0 },
{ id: 'sub_1_2', title: 'Formulare ausfüllen', isCompleted: true, order: 1 },
{ id: 'sub_1_3', title: 'Online einreichen', isCompleted: false, order: 2 },
],
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Today - high priority
{
id: 'demo_2',
userId: 'demo',
title: 'Präsentation vorbereiten',
description: 'Slides für das Team-Meeting erstellen',
dueDate: today,
priority: 'high',
status: 'in_progress',
isCompleted: false,
order: 1,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Today - medium priority
{
id: 'demo_3',
userId: 'demo',
title: 'E-Mails beantworten',
dueDate: today,
priority: 'medium',
status: 'pending',
isCompleted: false,
order: 2,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Tomorrow
{
id: 'demo_4',
userId: 'demo',
title: 'Arzttermin',
description: 'Jährliche Vorsorgeuntersuchung - Praxis Dr. Müller',
dueDate: tomorrow,
dueTime: '10:00',
priority: 'high',
status: 'pending',
isCompleted: false,
order: 3,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Day after tomorrow
{
id: 'demo_5',
userId: 'demo',
title: 'Einkaufsliste',
priority: 'low',
status: 'pending',
isCompleted: false,
order: 4,
dueDate: dayAfterTomorrow,
subtasks: [
{ id: 'sub_5_1', title: 'Milch', isCompleted: false, order: 0 },
{ id: 'sub_5_2', title: 'Brot', isCompleted: false, order: 1 },
{ id: 'sub_5_3', title: 'Obst', isCompleted: false, order: 2 },
{ id: 'sub_5_4', title: 'Gemüse', isCompleted: false, order: 3 },
],
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Next week
{
id: 'demo_6',
userId: 'demo',
title: 'Wohnung aufräumen',
description: 'Frühjahrsputz - alle Zimmer gründlich reinigen',
dueDate: nextWeek,
priority: 'medium',
status: 'pending',
isCompleted: false,
order: 5,
subtasks: [
{ id: 'sub_6_1', title: 'Küche', isCompleted: false, order: 0 },
{ id: 'sub_6_2', title: 'Bad', isCompleted: false, order: 1 },
{ id: 'sub_6_3', title: 'Wohnzimmer', isCompleted: false, order: 2 },
{ id: 'sub_6_4', title: 'Schlafzimmer', isCompleted: false, order: 3 },
],
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// No due date (inbox)
{
id: 'demo_7',
userId: 'demo',
title: 'Buch "Atomic Habits" lesen',
description: 'Kapitel 1-5 diese Woche',
priority: 'low',
status: 'pending',
isCompleted: false,
order: 6,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Completed task
{
id: 'demo_8',
userId: 'demo',
title: 'Fitnessstudio anmelden',
priority: 'medium',
status: 'completed',
isCompleted: true,
completedAt: subDays(now, 2).toISOString(),
order: 7,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Another completed
{
id: 'demo_9',
userId: 'demo',
title: 'Geburtstagsgeschenk kaufen',
description: 'Für Lisa - sie mag Bücher und Tee',
priority: 'high',
status: 'completed',
isCompleted: true,
completedAt: subDays(now, 1).toISOString(),
order: 8,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
// Work task
{
id: 'demo_10',
userId: 'demo',
title: 'Code Review für PR #42',
description: 'Feature-Branch von Max reviewen',
dueDate: tomorrow,
priority: 'medium',
status: 'pending',
isCompleted: false,
order: 9,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
];
return demoTasks;
}
/**
* Check if a task ID is a demo task
*/
export function isDemoTask(id: string): boolean {
return id.startsWith('demo_');
}

View file

@ -3,5 +3,4 @@ export { projectsStore } from './projects.svelte';
export { tasksStore } from './tasks.svelte';
export { labelsStore } from './labels.svelte';
export { viewStore } from './view.svelte';
export { statisticsStore } from './statistics.svelte';
export type { ViewType, SortBy, SortOrder } from './view.svelte';

View file

@ -5,6 +5,7 @@
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
import * as kanbanApi from '$lib/api/kanban';
import * as tasksApi from '$lib/api/tasks';
import { authStore } from './auth.svelte';
// Board state
let boards = $state<KanbanBoard[]>([]);
@ -418,9 +419,16 @@ export const kanbanStore = {
/**
* Create a new task in a specific column
* Requires authentication - demo mode shows auth gate
*/
async createTaskInColumn(columnId: string, title: string, projectId?: string) {
error = null;
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
return { error: 'auth_required' as const };
}
try {
// Find the column to get its default status
const column = columns.find((c) => c.id === columnId);

View file

@ -1,370 +0,0 @@
/**
* Network Store - Manages network graph state with D3-force simulation
*/
import { browser } from '$app/environment';
import { networkApi } from '$lib/api/network';
import type { NetworkNode, NetworkLink } from '$lib/api/network';
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
type Simulation,
} from 'd3-force';
import type {
SimulationNode as SharedSimulationNode,
SimulationLink as SharedSimulationLink,
} from '@manacore/shared-ui';
// Re-export types from shared-ui for convenience
export type SimulationNode = SharedSimulationNode;
export type SimulationLink = SharedSimulationLink;
// State
let nodes = $state<SimulationNode[]>([]);
let links = $state<SimulationLink[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedNodeId = $state<string | null>(null);
let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
let searchQuery = $state('');
let filterTagId = $state<string | null>(null);
let filterProject = $state<string | null>(null);
let minStrength = $state(0);
let tickCounter = $state(0);
let simulationInitialized = false;
let dataLoaded = false;
let lastDimensions = { width: 0, height: 0 };
// Derived state for filtering
const filteredNodes = $derived.by(() => {
let result = nodes;
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(node) =>
node.name.toLowerCase().includes(query) ||
node.subtitle?.toLowerCase().includes(query) ||
node.tags.some((t) => t.name.toLowerCase().includes(query))
);
}
// Tag filter
if (filterTagId) {
result = result.filter((node) => node.tags.some((t) => t.id === filterTagId));
}
// Project filter (uses subtitle field)
if (filterProject) {
result = result.filter((node) => node.subtitle === filterProject);
}
return result;
});
const filteredLinks = $derived.by(() => {
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
return links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
// Check if both nodes are visible
if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) {
return false;
}
// Filter by minimum strength
if (minStrength > 0 && link.strength < minStrength) {
return false;
}
return true;
});
});
// Get unique projects for filter dropdown
const uniqueProjects = $derived.by(() => {
const projects = new Set<string>();
for (const node of nodes) {
if (node.subtitle) {
projects.add(node.subtitle);
}
}
return Array.from(projects).sort();
});
// Get unique tags for filter dropdown
const uniqueTags = $derived.by(() => {
const tagsMap = new Map<string, { id: string; name: string; color: string | null }>();
for (const node of nodes) {
for (const tag of node.tags) {
if (!tagsMap.has(tag.id)) {
tagsMap.set(tag.id, tag);
}
}
}
return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
});
export const networkStore = {
// Getters
get nodes() {
void tickCounter;
return filteredNodes;
},
get allNodes() {
void tickCounter;
return nodes;
},
get links() {
void tickCounter;
return filteredLinks;
},
get allLinks() {
void tickCounter;
return links;
},
get tick() {
return tickCounter;
},
get loading() {
return loading;
},
get error() {
return error;
},
get selectedNodeId() {
return selectedNodeId;
},
get selectedNode() {
return nodes.find((n) => n.id === selectedNodeId) || null;
},
get searchQuery() {
return searchQuery;
},
get filterTagId() {
return filterTagId;
},
get filterProject() {
return filterProject;
},
get minStrength() {
return minStrength;
},
get uniqueProjects() {
return uniqueProjects;
},
get uniqueTags() {
return uniqueTags;
},
/**
* Load network graph data from API
*/
async loadGraph(force = false) {
if (dataLoaded && !force) {
return;
}
if (loading) {
return;
}
loading = true;
error = null;
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
try {
const response = await networkApi.getGraph();
// Convert to simulation nodes with subtitle for project
nodes = response.nodes.map((node) => ({
...node,
subtitle: node.company, // Map project name to subtitle
x: undefined,
y: undefined,
vx: undefined,
vy: undefined,
fx: null,
fy: null,
}));
// Convert to simulation links
links = response.links.map((link) => ({
source: link.source,
target: link.target,
type: link.type,
strength: link.strength,
sharedTags: link.sharedTags,
}));
dataLoaded = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load network graph';
console.error('Failed to load network graph:', e);
} finally {
loading = false;
}
},
/**
* Initialize D3 force simulation
*/
initSimulation(width: number, height: number) {
if (!browser) return;
if (nodes.length === 0) return;
if (width <= 0 || height <= 0) return;
if (simulationInitialized && simulation) {
if (
Math.abs(lastDimensions.width - width) > 50 ||
Math.abs(lastDimensions.height - height) > 50
) {
lastDimensions = { width, height };
this.updateSimulationCenter(width, height);
}
return;
}
if (simulation) {
simulation.stop();
}
lastDimensions = { width, height };
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 3;
nodes.forEach((node, i) => {
if (node.x === undefined || node.y === undefined) {
const angle = (i / nodes.length) * 2 * Math.PI;
const r = radius * (0.5 + Math.random() * 0.5);
node.x = centerX + r * Math.cos(angle);
node.y = centerY + r * Math.sin(angle);
}
});
simulation = forceSimulation<SimulationNode, SimulationLink>(nodes)
.force(
'link',
forceLink<SimulationNode, SimulationLink>(links)
.id((d) => d.id)
.distance(100)
.strength(0.5)
)
.force('charge', forceManyBody().strength(-300))
.force('center', forceCenter(centerX, centerY))
.force('collision', forceCollide().radius(50))
.on('tick', () => {
tickCounter++;
});
simulationInitialized = true;
simulation.alpha(1).restart();
},
updateSimulationCenter(width: number, height: number) {
if (simulation) {
simulation.force('center', forceCenter(width / 2, height / 2));
simulation.alpha(0.3).restart();
}
},
stopSimulation() {
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
},
reset() {
this.stopSimulation();
nodes = [];
links = [];
dataLoaded = false;
lastDimensions = { width: 0, height: 0 };
tickCounter = 0;
},
reheatSimulation() {
if (simulation) {
simulation.alpha(0.3).restart();
}
},
fixNode(nodeId: string, x: number, y: number) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = x;
node.fy = y;
}
},
releaseNode(nodeId: string) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = null;
node.fy = null;
}
},
selectNode(nodeId: string | null) {
selectedNodeId = nodeId;
},
setSearch(query: string) {
searchQuery = query;
},
setFilterTag(tagId: string | null) {
filterTagId = tagId;
},
setFilterProject(project: string | null) {
filterProject = project;
},
setMinStrength(strength: number) {
minStrength = strength;
},
clearFilters() {
searchQuery = '';
filterTagId = null;
filterProject = null;
minStrength = 0;
},
getConnectedNodes(nodeId: string): SimulationNode[] {
const connectedIds = new Set<string>();
for (const link of links) {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
if (sourceId === nodeId) {
connectedIds.add(targetId);
} else if (targetId === nodeId) {
connectedIds.add(sourceId);
}
}
return nodes.filter((n) => connectedIds.has(n.id));
},
getNodeLinks(nodeId: string): SimulationLink[] {
return links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return sourceId === nodeId || targetId === nodeId;
});
},
};

View file

@ -1,190 +0,0 @@
/**
* Session Tasks Store - Temporary local tasks for guest users
* Tasks are stored in sessionStorage and lost when the browser tab is closed
*/
import type { Task, TaskPriority, Subtask } from '@todo/shared';
import { browser } from '$app/environment';
const STORAGE_KEY = 'todo-session-tasks';
// Generate a unique ID for session tasks
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Load tasks from sessionStorage
function loadFromStorage(): Task[] {
if (!browser) return [];
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
// Save tasks to sessionStorage
function saveToStorage(tasks: Task[]) {
if (!browser) return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
} catch (e) {
console.warn('Failed to save session tasks:', e);
}
}
// State
let tasks = $state<Task[]>(loadFromStorage());
export const sessionTasksStore = {
get tasks() {
return tasks;
},
get hasTaskks() {
return tasks.length > 0;
},
/**
* Initialize from sessionStorage (call on mount)
*/
initialize() {
tasks = loadFromStorage();
},
/**
* Create a new session task
*/
createTask(data: {
title: string;
description?: string;
projectId?: string;
dueDate?: string;
priority?: TaskPriority;
subtasks?: Subtask[];
}): Task {
const newTask: Task = {
id: generateSessionId(),
projectId: data.projectId || 'session-inbox',
userId: 'guest',
title: data.title,
description: data.description || null,
dueDate: data.dueDate || null,
priority: data.priority || 'medium',
status: 'pending',
isCompleted: false,
order: tasks.length,
subtasks: data.subtasks || null,
labels: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
tasks = [...tasks, newTask];
saveToStorage(tasks);
return newTask;
},
/**
* Update a session task
*/
updateTask(id: string, data: Partial<Task>): Task | null {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return null;
const updatedTask = {
...tasks[index],
...data,
updatedAt: new Date().toISOString(),
};
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
saveToStorage(tasks);
return updatedTask;
},
/**
* Complete a session task
*/
completeTask(id: string): Task | null {
return this.updateTask(id, {
isCompleted: true,
status: 'completed',
completedAt: new Date().toISOString(),
});
},
/**
* Uncomplete a session task
*/
uncompleteTask(id: string): Task | null {
return this.updateTask(id, {
isCompleted: false,
status: 'pending',
completedAt: null,
});
},
/**
* Delete a session task
*/
deleteTask(id: string): boolean {
const hadTask = tasks.some((t) => t.id === id);
tasks = tasks.filter((t) => t.id !== id);
saveToStorage(tasks);
return hadTask;
},
/**
* Get task by ID
*/
getById(id: string): Task | undefined {
return tasks.find((t) => t.id === id);
},
/**
* Check if a task ID is a session task
*/
isSessionTask(id: string): boolean {
return id.startsWith('session_');
},
/**
* Get all tasks (for migration to cloud on login)
*/
getAllTasks(): Task[] {
return [...tasks];
},
/**
* Clear all session tasks (after migration or on explicit clear)
*/
clear() {
tasks = [];
if (browser) {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Get count of session tasks
*/
get count() {
return tasks.length;
},
/**
* Get incomplete tasks
*/
get incompleteTasks() {
return tasks.filter((t) => !t.isCompleted);
},
/**
* Get completed tasks
*/
get completedTasks() {
return tasks.filter((t) => t.isCompleted);
},
};

View file

@ -1,361 +0,0 @@
/**
* Statistics Store - Calculates task statistics using Svelte 5 runes
*/
import type { Task, TaskPriority, Project } from '@todo/shared';
import {
startOfDay,
startOfWeek,
endOfWeek,
subDays,
subWeeks,
format,
differenceInDays,
isToday,
isSameDay,
parseISO,
eachDayOfInterval,
} from 'date-fns';
import { de } from 'date-fns/locale';
// Types
export interface DailyCompletion {
date: string;
count: number;
dayOfWeek: number;
}
export interface WeeklyData {
week: string;
weekStart: Date;
completedCount: number;
storyPoints: number;
}
export interface PriorityBreakdown {
priority: TaskPriority;
count: number;
percentage: number;
color: string;
}
export interface ProjectProgress {
projectId: string | null;
projectName: string;
projectColor: string;
total: number;
completed: number;
inProgress: number;
percentage: number;
}
export interface DayProductivity {
day: string;
dayIndex: number;
avgCompletions: number;
}
const PRIORITY_COLORS: Record<TaskPriority, string> = {
low: '#10B981',
medium: '#F59E0B',
high: '#F97316',
urgent: '#EF4444',
};
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// State
let tasks = $state<Task[]>([]);
let projects = $state<Project[]>([]);
export const statisticsStore = {
// Setters
setTasks(newTasks: Task[]) {
tasks = newTasks;
},
setProjects(newProjects: Project[]) {
projects = newProjects;
},
// Quick Stats
get totalTasks() {
return tasks.length;
},
get completedTasks() {
return tasks.filter((t) => t.isCompleted).length;
},
get activeTasks() {
return tasks.filter((t) => !t.isCompleted).length;
},
get overdueTasks() {
const today = startOfDay(new Date());
return tasks.filter((t) => {
if (t.isCompleted || !t.dueDate) return false;
const dueDate = startOfDay(new Date(t.dueDate));
return dueDate < today;
}).length;
},
get completedToday() {
return tasks.filter((t) => {
if (!t.isCompleted || !t.completedAt) return false;
return isToday(new Date(t.completedAt));
}).length;
},
get completedThisWeek() {
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
return tasks.filter((t) => {
if (!t.isCompleted || !t.completedAt) return false;
const completedDate = new Date(t.completedAt);
return completedDate >= weekStart && completedDate <= weekEnd;
}).length;
},
get storyPointsThisWeek() {
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
return tasks
.filter((t) => {
if (!t.isCompleted || !t.completedAt) return false;
const completedDate = new Date(t.completedAt);
return completedDate >= weekStart && completedDate <= weekEnd;
})
.reduce((sum, t) => sum + (t.metadata?.storyPoints || 0), 0);
},
get completionRate() {
if (tasks.length === 0) return 0;
return Math.round((this.completedTasks / tasks.length) * 100);
},
// Activity Heatmap (last 6 months)
get activityHeatmap(): DailyCompletion[] {
const endDate = new Date();
const startDate = subDays(endDate, 180); // ~6 months
// Count completions per day
const completionMap = new Map<string, number>();
tasks.forEach((t) => {
if (t.isCompleted && t.completedAt) {
const dateKey = format(new Date(t.completedAt), 'yyyy-MM-dd');
completionMap.set(dateKey, (completionMap.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: completionMap.get(dateKey) || 0,
dayOfWeek: day.getDay(),
};
});
},
// Weekly Trend (last 4 weeks)
get weeklyTrend(): { date: string; count: number; dayName: string }[] {
const endDate = new Date();
const startDate = subDays(endDate, 27); // Last 4 weeks
const completionMap = new Map<string, number>();
tasks.forEach((t) => {
if (t.isCompleted && t.completedAt) {
const completedDate = new Date(t.completedAt);
if (completedDate >= startDate && completedDate <= endDate) {
const dateKey = format(completedDate, 'yyyy-MM-dd');
completionMap.set(dateKey, (completionMap.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: completionMap.get(dateKey) || 0,
dayName: DAY_NAMES[day.getDay()],
};
});
},
// Priority Breakdown
get priorityBreakdown(): PriorityBreakdown[] {
const activeTasks = tasks.filter((t) => !t.isCompleted);
const total = activeTasks.length;
const counts: Record<TaskPriority, number> = {
low: 0,
medium: 0,
high: 0,
urgent: 0,
};
activeTasks.forEach((t) => {
const priority = (t.priority as TaskPriority) || 'medium';
counts[priority]++;
});
return (['urgent', 'high', 'medium', 'low'] as TaskPriority[]).map((priority) => ({
priority,
count: counts[priority],
percentage: total > 0 ? Math.round((counts[priority] / total) * 100) : 0,
color: PRIORITY_COLORS[priority],
}));
},
// Project Progress
get projectProgress(): ProjectProgress[] {
const projectMap = new Map<
string | null,
{ total: number; completed: number; inProgress: number }
>();
// Initialize with inbox (null projectId)
projectMap.set(null, { total: 0, completed: 0, inProgress: 0 });
// Initialize all projects
projects.forEach((p) => {
projectMap.set(p.id, { total: 0, completed: 0, inProgress: 0 });
});
// Count tasks
tasks.forEach((t) => {
const projectId = t.projectId || null;
const data = projectMap.get(projectId) || { total: 0, completed: 0, inProgress: 0 };
data.total++;
if (t.isCompleted) {
data.completed++;
} else if (t.status === 'in_progress') {
data.inProgress++;
}
projectMap.set(projectId, data);
});
// Convert to array
const result: ProjectProgress[] = [];
projectMap.forEach((data, projectId) => {
if (data.total === 0) return; // Skip empty projects
const project = projectId ? projects.find((p) => p.id === projectId) : null;
result.push({
projectId,
projectName: project?.name || 'Inbox',
projectColor: project?.color || '#6B7280',
total: data.total,
completed: data.completed,
inProgress: data.inProgress,
percentage: data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0,
});
});
// Sort by total tasks descending
return result.sort((a, b) => b.total - a.total);
},
// Weekly Velocity (Story Points per week)
get weeklyVelocity(): WeeklyData[] {
const weeks: WeeklyData[] = [];
for (let i = 11; i >= 0; i--) {
const weekStart = startOfWeek(subWeeks(new Date(), i), { weekStartsOn: 1 });
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
const weekTasks = tasks.filter((t) => {
if (!t.isCompleted || !t.completedAt) return false;
const completedDate = new Date(t.completedAt);
return completedDate >= weekStart && completedDate <= weekEnd;
});
weeks.push({
week: format(weekStart, 'd. MMM', { locale: de }),
weekStart,
completedCount: weekTasks.length,
storyPoints: weekTasks.reduce((sum, t) => sum + (t.metadata?.storyPoints || 0), 0),
});
}
return weeks;
},
// Most Productive Days
get productiveDays(): DayProductivity[] {
const dayStats = new Map<number, { total: number; weeks: Set<string> }>();
// Initialize all days
for (let i = 0; i < 7; i++) {
dayStats.set(i, { total: 0, weeks: new Set() });
}
// Count completions per day of week
tasks.forEach((t) => {
if (t.isCompleted && t.completedAt) {
const completedDate = new Date(t.completedAt);
const dayOfWeek = completedDate.getDay();
const weekKey = format(completedDate, 'yyyy-ww');
const stats = dayStats.get(dayOfWeek)!;
stats.total++;
stats.weeks.add(weekKey);
}
});
// Calculate averages
return Array.from(dayStats.entries()).map(([dayIndex, stats]) => ({
day: DAY_NAMES[dayIndex],
dayIndex,
avgCompletions:
stats.weeks.size > 0 ? Math.round((stats.total / stats.weeks.size) * 10) / 10 : 0,
}));
},
// Subtask Stats
get subtaskStats() {
let totalSubtasks = 0;
let completedSubtasks = 0;
tasks.forEach((t) => {
if (t.subtasks && Array.isArray(t.subtasks)) {
totalSubtasks += t.subtasks.length;
completedSubtasks += t.subtasks.filter((s) => s.isCompleted).length;
}
});
return {
total: totalSubtasks,
completed: completedSubtasks,
percentage: totalSubtasks > 0 ? Math.round((completedSubtasks / totalSubtasks) * 100) : 0,
};
},
// Average completion time (in days)
get averageCompletionTime() {
const completedWithDates = tasks.filter((t) => t.isCompleted && t.completedAt && t.createdAt);
if (completedWithDates.length === 0) return 0;
const totalDays = completedWithDates.reduce((sum, t) => {
const created = new Date(t.createdAt);
const completed = new Date(t.completedAt!);
return sum + differenceInDays(completed, created);
}, 0);
return Math.round((totalDays / completedWithDates.length) * 10) / 10;
},
};

View file

@ -1,13 +1,14 @@
/**
* Tasks Store - Manages task state using Svelte 5 runes
* Supports both authenticated (cloud) and guest (session) modes
* Authenticated users: tasks from API
* Demo mode: static sample tasks to showcase the app
*/
import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
import * as tasksApi from '$lib/api/tasks';
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
import { sessionTasksStore } from './session-tasks.svelte';
import { authStore } from './auth.svelte';
import { generateDemoTasks, isDemoTask } from '$lib/data/demo-tasks';
// State
let tasks = $state<Task[]>([]);
@ -117,16 +118,15 @@ export const tasksStore = {
/**
* Fetch all tasks (incomplete + completed) for unified view
* In guest mode, only shows session tasks
* In demo mode, shows static sample tasks
*/
async fetchAllTasks() {
loading = true;
error = null;
// Guest mode: load session tasks only
// Demo mode: load static demo tasks
if (!authStore.isAuthenticated) {
sessionTasksStore.initialize();
tasks = sessionTasksStore.tasks;
tasks = generateDemoTasks();
loading = false;
return;
}
@ -201,7 +201,7 @@ export const tasksStore = {
/**
* Create a new task
* If not authenticated, creates a session task (local only)
* Requires authentication - demo mode shows auth gate
*/
async createTask(data: {
title: string;
@ -215,18 +215,9 @@ export const tasksStore = {
}) {
error = null;
// Guest mode: create session task
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
const sessionTask = sessionTasksStore.createTask({
title: data.title,
description: data.description,
projectId: data.projectId || 'session-inbox',
dueDate: data.dueDate,
priority: data.priority,
subtasks: data.subtasks as Subtask[],
});
tasks = [...tasks, sessionTask];
return sessionTask;
return { error: 'auth_required' as const };
}
// Authenticated: create via API
@ -243,7 +234,7 @@ export const tasksStore = {
/**
* Update an existing task
* Handles both session tasks (local) and cloud tasks
* Demo tasks require authentication
*/
async updateTask(
id: string,
@ -268,14 +259,9 @@ export const tasksStore = {
) {
error = null;
// Session task: update locally
if (sessionTasksStore.isSessionTask(id)) {
const updated = sessionTasksStore.updateTask(id, data);
if (updated) {
tasks = tasks.map((t) => (t.id === id ? updated : t));
return updated;
}
throw new Error('Task not found');
// Demo task: require authentication
if (isDemoTask(id)) {
return { error: 'auth_required' as const };
}
// Cloud task: update via API
@ -293,6 +279,7 @@ export const tasksStore = {
/**
* Update task optimistically (for drag and drop)
* Updates local state immediately, then syncs with server
* Demo tasks require authentication
*/
async updateTaskOptimistic(
id: string,
@ -301,6 +288,11 @@ export const tasksStore = {
isCompleted?: boolean;
}
) {
// Demo task: require authentication
if (isDemoTask(id)) {
return { error: 'auth_required' as const };
}
// Optimistic update - immediately update local state
const originalTask = tasks.find((t) => t.id === id);
if (!originalTask) return;
@ -333,16 +325,14 @@ export const tasksStore = {
/**
* Delete a task
* Handles both session tasks (local) and cloud tasks
* Demo tasks require authentication
*/
async deleteTask(id: string) {
error = null;
// Session task: delete locally
if (sessionTasksStore.isSessionTask(id)) {
sessionTasksStore.deleteTask(id);
tasks = tasks.filter((t) => t.id !== id);
return;
// Demo task: require authentication
if (isDemoTask(id)) {
return { error: 'auth_required' as const };
}
// Cloud task: delete via API
@ -358,19 +348,14 @@ export const tasksStore = {
/**
* Mark task as complete
* Handles both session tasks (local) and cloud tasks
* Demo tasks require authentication
*/
async completeTask(id: string) {
error = null;
// Session task: complete locally
if (sessionTasksStore.isSessionTask(id)) {
const completed = sessionTasksStore.completeTask(id);
if (completed) {
tasks = tasks.map((t) => (t.id === id ? completed : t));
return completed;
}
throw new Error('Task not found');
// Demo task: require authentication
if (isDemoTask(id)) {
return { error: 'auth_required' as const };
}
// Cloud task: complete via API
@ -387,19 +372,14 @@ export const tasksStore = {
/**
* Mark task as incomplete
* Handles both session tasks (local) and cloud tasks
* Demo tasks require authentication
*/
async uncompleteTask(id: string) {
error = null;
// Session task: uncomplete locally
if (sessionTasksStore.isSessionTask(id)) {
const uncompleted = sessionTasksStore.uncompleteTask(id);
if (uncompleted) {
tasks = tasks.map((t) => (t.id === id ? uncompleted : t));
return uncompleted;
}
throw new Error('Task not found');
// Demo task: require authentication
if (isDemoTask(id)) {
return { error: 'auth_required' as const };
}
// Cloud task: uncomplete via API
@ -491,63 +471,9 @@ export const tasksStore = {
},
/**
* Check if a task is a session task (local only)
* Check if a task is a demo task (static sample data)
*/
isSessionTask(taskId: string) {
return sessionTasksStore.isSessionTask(taskId);
},
/**
* Migrate session tasks to cloud after login
* Call this after successful authentication
*/
async migrateSessionTasks(defaultProjectId?: string) {
const sessionTasks = sessionTasksStore.getAllTasks();
if (sessionTasks.length === 0) return { migrated: 0, failed: 0 };
let migrated = 0;
let failed = 0;
for (const sessionTask of sessionTasks) {
try {
await tasksApi.createTask({
title: sessionTask.title,
description: sessionTask.description || undefined,
projectId: defaultProjectId || undefined,
dueDate: sessionTask.dueDate ? String(sessionTask.dueDate) : undefined,
priority: sessionTask.priority,
subtasks: sessionTask.subtasks?.map((s) => ({
title: s.title,
isCompleted: s.isCompleted,
order: s.order,
})),
});
migrated++;
} catch {
failed++;
}
}
// Clear session tasks after migration
if (migrated > 0) {
sessionTasksStore.clear();
console.log(`Migrated ${migrated} tasks to cloud`);
}
return { migrated, failed };
},
/**
* Get count of pending session tasks
*/
get sessionTaskCount() {
return sessionTasksStore.count;
},
/**
* Check if there are pending session tasks to migrate
*/
get hasSessionTasks() {
return sessionTasksStore.count > 0;
isDemoTask(taskId: string) {
return isDemoTask(taskId);
},
};

View file

@ -40,8 +40,8 @@
import { getTasks } from '$lib/api/tasks';
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
import { sessionTasksStore } from '$lib/stores/session-tasks.svelte';
import { GuestWelcomeModal, shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { browser } from '$app/environment';
// App switcher items
const appItems = getPillAppItems('todo');
@ -100,13 +100,18 @@
const parsed = parseTaskInput(query);
const resolved = resolveTaskIds(parsed, projectsStore.projects, labelsStore.labels);
await tasksStore.createTask({
const result = await tasksStore.createTask({
title: resolved.title,
dueDate: resolved.dueDate,
priority: resolved.priority,
projectId: resolved.projectId,
labelIds: resolved.labelIds,
});
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
showAuthGate('save');
}
}
let isSidebarMode = $state(false);
@ -164,9 +169,7 @@
const baseNavItems: PillNavItem[] = [
{ 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' },
];
@ -286,11 +289,17 @@
showAuthGateModal = true;
}
// Session tasks indicator
let sessionTaskCount = $derived(sessionTasksStore.count);
// Language for GuestWelcomeModal
let currentLocale = $derived($locale || 'de');
// Listen for show-auth-gate events from child components
$effect(() => {
if (browser) {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ action?: 'save' | 'sync' | 'feature' }>;
showAuthGate(customEvent.detail?.action || 'save');
};
window.addEventListener('show-auth-gate', handler);
return () => window.removeEventListener('show-auth-gate', handler);
}
});
onMount(async () => {
// Initialize split-panel from URL/localStorage
@ -299,9 +308,6 @@
// Initialize todo settings
todoSettings.initialize();
// Initialize session tasks for guest mode
sessionTasksStore.initialize();
// Show guest welcome modal for unauthenticated users
if (!authStore.isAuthenticated && shouldShowGuestWelcome('todo')) {
showGuestWelcome = true;
@ -314,12 +320,6 @@
if (authStore.isAuthenticated) {
await Promise.all([labelsStore.fetchLabels(), userSettings.load()]);
// Check for session tasks to migrate after login
if (tasksStore.hasSessionTasks) {
const defaultProject = projectsStore.inboxProject;
await tasksStore.migrateSessionTasks(defaultProject?.id);
}
// Redirect to start page if on root and a custom start page is set
const currentPath = window.location.pathname;
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
@ -391,7 +391,7 @@
<SplitPaneContainer>
<div class="layout-container">
<!-- Guest Mode Banner -->
<!-- Demo Mode Banner -->
{#if !authStore.isAuthenticated}
<div
class="guest-banner bg-primary/10 border-primary/20 fixed top-0 right-0 left-0 z-50 flex items-center justify-between border-b px-4 py-2"
@ -402,21 +402,24 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span class="text-foreground">
<strong>Gast-Modus</strong>
{#if sessionTaskCount > 0}
- {sessionTaskCount}
{sessionTaskCount === 1 ? 'Aufgabe' : 'Aufgaben'} lokal gespeichert
{:else}
- Aufgaben werden nur in diesem Tab gespeichert
{/if}
<strong>Demo-Modus</strong>
<span class="text-muted-foreground hidden sm:inline">
- Beispiel-Aufgaben zum Ausprobieren
</span>
</span>
</div>
<button
onclick={() => showAuthGate('sync')}
onclick={() => showAuthGate('save')}
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1 text-sm font-medium transition-colors"
>
Anmelden

View file

@ -103,29 +103,35 @@
}
// Drag and drop handler - uses optimistic updates for smooth UX
function handleTaskDrop(taskId: string, targetDate: Date | 'completed' | 'overdue') {
async function handleTaskDrop(taskId: string, targetDate: Date | 'completed' | 'overdue') {
const task = tasksStore.tasks.find((t) => t.id === taskId);
if (!task) return;
let result;
if (targetDate === 'completed') {
// Mark task as completed (optimistic)
if (!task.isCompleted) {
tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
result = await tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
}
} else if (targetDate === 'overdue') {
// Set to yesterday (optimistic)
const yesterday = subDays(startOfDay(new Date()), 1);
tasksStore.updateTaskOptimistic(taskId, {
result = await tasksStore.updateTaskOptimistic(taskId, {
dueDate: yesterday.toISOString(),
isCompleted: task.isCompleted ? false : undefined,
});
} else {
// Set to specific date (optimistic)
tasksStore.updateTaskOptimistic(taskId, {
result = await tasksStore.updateTaskOptimistic(taskId, {
dueDate: targetDate.toISOString(),
isCompleted: task.isCompleted ? false : undefined,
});
}
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
}
}
</script>

View file

@ -1,396 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
let graphComponent: NetworkGraph;
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
function handleNodeClick(node: SimulationNode) {
// Select node (highlight connections)
networkStore.selectNode(node.id);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to task detail page (could open modal instead)
// For now, just keep selected
networkStore.selectNode(node.id);
}
function handleBackgroundClick() {
networkStore.selectNode(null);
}
function handleDragStart(node: SimulationNode) {
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(node: SimulationNode, x: number, y: number) {
networkStore.fixNode(node.id, x, y);
}
function handleDragEnd(node: SimulationNode) {
networkStore.releaseNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
function handleZoomOut() {
graphComponent?.zoomOut();
}
function handleResetZoom() {
graphComponent?.resetZoom();
}
function handleFocusSelected() {
graphComponent?.focusOnSelectedNode();
}
function handleFocusSearch() {
controlsComponent?.focusSearch();
}
function handleSearch(query: string) {
networkStore.setSearch(query);
}
function handleTagFilter(tagId: string | null) {
networkStore.setFilterTag(tagId);
}
function handleSubtitleFilter(project: string | null) {
networkStore.setFilterProject(project);
}
function handleStrengthFilter(strength: number) {
networkStore.setMinStrength(strength);
}
function handleClearFilters() {
networkStore.clearFilters();
}
// Initialize simulation when data is loaded and container is ready
$effect(() => {
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
const rect = graphContainer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
networkStore.initSimulation(rect.width, rect.height);
}
}
});
onMount(() => {
networkStore.loadGraph();
});
onDestroy(() => {
networkStore.stopSimulation();
});
</script>
<svelte:head>
<title>Netzwerk - Todo</title>
</svelte:head>
<div class="network-page">
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
bind:this={controlsComponent}
searchQuery={networkStore.searchQuery}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueProjects}
selectedSubtitle={networkStore.filterProject}
subtitleLabel="Projekt"
nodeCount={networkStore.nodes.length}
linkCount={networkStore.links.length}
nodeLabel="Aufgaben"
linkLabel="Verbindungen"
searchPlaceholder="Aufgabe suchen..."
minStrength={networkStore.minStrength}
onSearch={handleSearch}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
onFocusSelected={handleFocusSelected}
onClearFilters={handleClearFilters}
/>
</div>
<!-- Error Banner -->
{#if networkStore.error}
<div class="error-banner" role="alert">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{networkStore.error}</span>
</div>
{/if}
<!-- Main Content -->
<div class="graph-container" bind:this={graphContainer}>
{#if networkStore.loading}
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Lade Netzwerk-Graph...</p>
</div>
{:else}
<NetworkGraph
bind:this={graphComponent}
nodes={networkStore.nodes}
links={networkStore.links}
selectedNodeId={networkStore.selectedNodeId}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onBackgroundClick={handleBackgroundClick}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
onFocusSearch={handleFocusSearch}
/>
{/if}
</div>
<!-- Selected Task Info Panel -->
{#if networkStore.selectedNode}
<div class="info-panel">
<div class="info-header">
<h3>{networkStore.selectedNode.name}</h3>
<button class="close-btn" onclick={() => networkStore.selectNode(null)}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if networkStore.selectedNode.subtitle}
<p class="info-subtitle">{networkStore.selectedNode.subtitle}</p>
{/if}
{#if networkStore.selectedNode.tags.length > 0}
<div class="info-tags">
{#each networkStore.selectedNode.tags as tag}
<span
class="tag"
style="background-color: {tag.color || 'hsl(var(--muted))'}; color: white;"
>
{tag.name}
</span>
{/each}
</div>
{/if}
<div class="info-stats">
<span>{networkStore.selectedNode.connectionCount} Verbindungen</span>
</div>
</div>
{/if}
</div>
<style>
.network-page {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}
/* Floating Controls */
.controls-wrapper {
position: absolute;
top: 5rem; /* Below the nav */
left: 1rem;
z-index: 10;
max-width: calc(100% - 2rem);
}
/* Error Banner */
.error-banner {
position: absolute;
top: 5rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.875rem;
color: hsl(var(--destructive));
backdrop-filter: blur(8px);
}
/* Graph Container - Full screen */
.graph-container {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
color: hsl(var(--muted-foreground));
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Info Panel */
.info-panel {
position: fixed;
top: 5rem;
right: 1rem;
bottom: 1rem;
width: 320px;
max-width: calc(100vw - 2rem);
z-index: 50;
background: hsl(var(--card) / 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.info-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.info-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.close-btn {
padding: 0.25rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
transition: all 0.15s;
}
.close-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.info-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
}
.info-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.info-stats {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
/* Responsive */
@media (max-width: 1024px) {
.info-panel {
width: 100%;
max-width: 100%;
top: auto;
right: 0;
bottom: 0;
height: auto;
max-height: 50vh;
border-radius: 1rem 1rem 0 0;
animation: slideInUp 0.2s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
@media (max-width: 768px) {
.controls-wrapper {
top: 6rem;
width: calc(100% - 1rem);
max-width: none;
}
}
</style>

View file

@ -1,222 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import { statisticsStore } from '$lib/stores/statistics.svelte';
import StatsOverview from '$lib/components/statistics/StatsOverview.svelte';
import ActivityHeatmap from '$lib/components/statistics/ActivityHeatmap.svelte';
import WeeklyTrendChart from '$lib/components/statistics/WeeklyTrendChart.svelte';
import PriorityDonutChart from '$lib/components/statistics/PriorityDonutChart.svelte';
import ProjectProgressBars from '$lib/components/statistics/ProjectProgressBars.svelte';
import { StatisticsSkeleton } from '$lib/components/skeletons';
import { BarChart3 } from 'lucide-svelte';
let loading = $state(true);
// Update statistics when tasks change
$effect(() => {
statisticsStore.setTasks(tasksStore.tasks);
});
$effect(() => {
statisticsStore.setProjects(projectsStore.projects);
});
onMount(async () => {
// Fetch all tasks including completed ones
await tasksStore.fetchAllTasks();
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Todo</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 Produktivität im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsOverview
completedToday={statisticsStore.completedToday}
completedThisWeek={statisticsStore.completedThisWeek}
activeTasks={statisticsStore.activeTasks}
overdueTasks={statisticsStore.overdueTasks}
completionRate={statisticsStore.completionRate}
storyPointsThisWeek={statisticsStore.storyPointsThisWeek}
/>
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap data={statisticsStore.activityHeatmap} />
</section>
<!-- Weekly Trend + Priority Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<WeeklyTrendChart data={statisticsStore.weeklyTrend} />
</section>
<section class="chart-section priority-section">
<PriorityDonutChart data={statisticsStore.priorityBreakdown} />
</section>
</div>
<!-- Project Progress -->
<section class="chart-section projects-section">
<ProjectProgressBars data={statisticsStore.projectProgress} />
</section>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Durchschn. Bearbeitungszeit</span>
<span class="stat-value">{statisticsStore.averageCompletionTime} Tage</span>
</div>
{#if statisticsStore.subtaskStats.total > 0}
<div class="stat-card-small">
<span class="stat-label">Subtasks erledigt</span>
<span class="stat-value">
{statisticsStore.subtaskStats.completed}/{statisticsStore.subtaskStats.total}
<span class="stat-percentage">({statisticsStore.subtaskStats.percentage}%)</span>
</span>
</div>
{/if}
<div class="stat-card-small">
<span class="stat-label">Produktivster Tag</span>
<span class="stat-value">
{#if statisticsStore.productiveDays.length > 0}
{@const bestDay = statisticsStore.productiveDays.reduce((best, day) =>
day.avgCompletions > best.avgCompletions ? day : best
)}
{bestDay.day} ({bestDay.avgCompletions} Aufg./Tag)
{:else}
-
{/if}
</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: rgba(139, 92, 246, 0.15);
color: #8b5cf6;
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,116 @@
# Todo App - Cleanup Plan
Dieser Plan dokumentiert Features und Code, die überdurchschnittlich viel Komplexität erzeugen bei geringem Nutzen. Ziel ist eine schlankere, wartbarere Codebase.
## Status-Legende
- ✅ Erledigt
- 🔄 In Bearbeitung
- ⏳ Geplant
- ❌ Abgelehnt
---
## Geplante Aufräumarbeiten
### Priorität 1: Quick Wins (Hoher ROI)
#### ✅ 1.1 Statistiken & Heatmap entfernen
**Status:** Erledigt
**Geschätzte Ersparnis:** ~1.900 Zeilen
**Komplexität:** HOCH | **Nutzen:** NIEDRIG
**Beschreibung:**
Umfangreiches Analytics-System mit Activity Heatmap, Weekly Trend Chart, Priority Donut, Project Progress, Weekly Velocity. Die meisten Nutzer verwenden diese Features nicht.
**Zu entfernende Dateien:**
- `src/lib/stores/statistics.svelte.ts` (~361 Zeilen)
- `src/lib/components/statistics/ActivityHeatmap.svelte`
- `src/lib/components/statistics/WeeklyTrendChart.svelte`
- `src/lib/components/statistics/PriorityDonutChart.svelte`
- `src/lib/components/statistics/ProjectProgressBars.svelte`
- `src/lib/components/statistics/StatsOverview.svelte`
- `src/routes/(app)/statistics/+page.svelte`
**Zu ändernde Dateien:**
- `src/routes/(app)/+layout.svelte` - Nav-Item entfernen
---
#### ✅ 1.2 Network View entfernen
**Status:** Erledigt
**Geschätzte Ersparnis:** ~800 Zeilen
**Komplexität:** HOCH | **Nutzen:** NIEDRIG
**Beschreibung:**
D3.js Force-Directed Graph zur Visualisierung von Task-Beziehungen. Komplex (Force Simulation, Node Dragging, Filtering) aber wenig genutzt.
**Zu entfernende Dateien:**
- `src/lib/stores/network.svelte.ts` (~370 Zeilen)
- `src/lib/api/network.ts` (~50 Zeilen)
- `src/routes/(app)/network/+page.svelte`
**Zu ändernde Dateien:**
- `src/routes/(app)/+layout.svelte` - Nav-Item entfernen
---
#### ✅ 1.3 Session Tasks → Demo-Modus
**Status:** Erledigt
**Geschätzte Ersparnis:** ~100 Zeilen (netto)
**Komplexität:** MITTEL | **Nutzen:** HOCH (bessere UX)
**Beschreibung:**
Wie bei der Calendar-App: Session-basiertes Task-Management durch statischen Demo-Modus ersetzen. Statt frustrierender UX (Tasks verschwinden bei Tab-Schließung) zeigt die App Beispiel-Tasks.
**Zu entfernende Dateien:**
- `src/lib/stores/session-tasks.svelte.ts` (~190 Zeilen)
**Neue Dateien:**
- `src/lib/data/demo-tasks.ts` (~100 Zeilen)
**Zu ändernde Dateien:**
- `src/lib/stores/tasks.svelte.ts` - Session-Logik durch Demo-Tasks ersetzen
- `src/routes/(app)/+layout.svelte` - Demo-Banner, Auth-Gate
- `src/routes/(app)/+page.svelte` - Auth-Gate bei Task-Erstellung
---
### Priorität 2: Mittlerer Aufwand
#### ⏳ 2.1 Contacts Integration entfernen
**Status:** Geplant
**Geschätzte Ersparnis:** ~200 Zeilen
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
**Beschreibung:**
Cross-App Integration mit Contacts-App. Geringe Nutzung wenn Contacts-App nicht adoptiert.
**Zu entfernende Dateien:**
- `src/lib/stores/contacts.svelte.ts` (~175 Zeilen)
---
## Zusammenfassung
| Phase | Features | LOC Ersparnis | Status |
|-------|----------|---------------|--------|
| ✅ Prio 1.1 | Statistiken/Heatmap | ~1.900 | Erledigt |
| ✅ Prio 1.2 | Network View | ~800 | Erledigt |
| ✅ Prio 1.3 | Sessions → Demo | ~100 | Erledigt |
| 🟡 Prio 2 | Contacts Integration | ~200 | Geplant |
| **Gesamt** | | **~3.000** | |
**Ziel:** ~25% Code-Reduktion bei gleichem/besserem Nutzererlebnis
---
## Changelog
| Datum | Aktion | Commit |
|-------|--------|--------|
| 2026-01-28 | Statistiken, Network View, Session Tasks entfernt; Demo-Modus implementiert | f4d6201a |