mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:41:08 +02:00
♻️ 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:
parent
836b341b3e
commit
99fdf1d17f
23 changed files with 431 additions and 2900 deletions
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
187
apps/todo/apps/web/src/lib/data/demo-tasks.ts
Normal file
187
apps/todo/apps/web/src/lib/data/demo-tasks.ts
Normal 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_');
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
116
apps/todo/docs/CLEANUP_PLAN.md
Normal file
116
apps/todo/docs/CLEANUP_PLAN.md
Normal 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 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue