feat(todo): add statistics page with visualizations

Add comprehensive statistics dashboard with:
- StatsOverview: Quick stats cards (completed today/week, active, overdue)
- ActivityHeatmap: GitHub-style contribution graph (6 months)
- WeeklyTrendChart: Line chart showing 4-week completion trend
- PriorityDonutChart: Interactive donut chart for priority breakdown
- ProjectProgressBars: Progress bars per project

Features:
- Custom SVG visualizations (no external chart libraries)
- Glass-pill design matching app aesthetic
- Dark mode support
- Responsive grid layout
- Statistics store with computed derivations
- Navigation link added to PillNav
- PWA service worker registration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 14:37:44 +01:00
parent 0c2434bd1d
commit d45a9db3fc
11 changed files with 2036 additions and 333 deletions

View file

@ -28,7 +28,6 @@
"vite": "^6.0.0"
},
"dependencies": {
"@todo/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
@ -42,7 +41,9 @@
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@todo/shared": "workspace:*",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.556.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1"
},

View file

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

View file

@ -0,0 +1,258 @@
<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: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
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>
<div class="donut-content">
<div class="donut-chart">
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
{#each arcs as arc}
<path
d={arc.path}
fill={arc.color}
class="arc-segment"
class:hovered={hoveredSegment === arc.priority}
onmouseenter={() => (hoveredSegment = arc.priority)}
onmouseleave={() => (hoveredSegment = null)}
role="graphics-symbol"
aria-label="{PRIORITY_LABELS[arc.priority]}: {arc.count}"
>
<title>{PRIORITY_LABELS[arc.priority]}: {arc.count} ({arc.percentage}%)</title>
</path>
{/each}
<!-- Center text -->
<text x={CENTER} y={CENTER - 8} class="center-count">
{total}
</text>
<text x={CENTER} y={CENTER + 12} class="center-label"> Aktiv </text>
</svg>
</div>
<!-- Legend -->
<div class="donut-legend">
{#each data as item}
<div
class="legend-item"
class:active={hoveredSegment === item.priority}
onmouseenter={() => (hoveredSegment = item.priority)}
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>
</div>
<style>
.donut-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .donut-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.donut-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
}
.donut-content {
display: flex;
align-items: center;
gap: 1.5rem;
}
@media (max-width: 400px) {
.donut-content {
flex-direction: column;
}
}
.donut-chart {
flex-shrink: 0;
}
.donut-svg {
width: 140px;
height: 140px;
}
.arc-segment {
transition:
opacity 0.15s ease,
transform 0.15s ease;
transform-origin: center;
cursor: pointer;
}
.arc-segment:hover,
.arc-segment.hovered {
opacity: 0.85;
transform: scale(1.02);
}
.center-count {
font-size: 28px;
font-weight: 700;
fill: hsl(var(--foreground));
text-anchor: middle;
}
.center-label {
font-size: 12px;
fill: hsl(var(--muted-foreground));
text-anchor: middle;
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.legend-item:hover,
.legend-item.active {
background: hsl(var(--muted) / 0.3);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.875rem;
color: hsl(var(--foreground));
flex: 1;
}
.legend-count {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
</style>

View file

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

View file

@ -0,0 +1,196 @@
<script lang="ts">
import { CheckCircle, Clock, AlertTriangle, TrendingUp, Target, Calendar } from 'lucide-svelte';
interface Props {
completedToday: number;
completedThisWeek: number;
activeTasks: number;
overdueTasks: number;
completionRate: number;
storyPointsThisWeek: number;
}
let {
completedToday,
completedThisWeek,
activeTasks,
overdueTasks,
completionRate,
storyPointsThisWeek,
}: Props = $props();
</script>
<div class="stats-grid">
<div class="stat-card stat-card-success">
<div class="stat-icon">
<CheckCircle size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{completedToday}</span>
<span class="stat-label">Heute erledigt</span>
</div>
</div>
<div class="stat-card stat-card-primary">
<div class="stat-icon">
<Calendar size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{completedThisWeek}</span>
<span class="stat-label">Diese Woche</span>
</div>
</div>
<div class="stat-card stat-card-neutral">
<div class="stat-icon">
<Clock size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{activeTasks}</span>
<span class="stat-label">Aktive Tasks</span>
</div>
</div>
<div
class="stat-card"
class:stat-card-danger={overdueTasks > 0}
class:stat-card-neutral={overdueTasks === 0}
>
<div class="stat-icon">
<AlertTriangle size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{overdueTasks}</span>
<span class="stat-label">Überfällig</span>
</div>
</div>
<div class="stat-card stat-card-info">
<div class="stat-icon">
<TrendingUp size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{completionRate}%</span>
<span class="stat-label">Abschlussrate</span>
</div>
</div>
{#if storyPointsThisWeek > 0}
<div class="stat-card stat-card-accent">
<div class="stat-icon">
<Target size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{storyPointsThisWeek}</span>
<span class="stat-label">Story Points</span>
</div>
</div>
{/if}
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(6, 1fr);
}
}
.stat-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
:global(.dark) .stat-card {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 0.75rem;
flex-shrink: 0;
}
.stat-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
color: hsl(var(--foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Color Variants */
.stat-card-success .stat-icon {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.stat-card-primary .stat-icon {
background: rgba(139, 92, 246, 0.15);
color: #8b5cf6;
}
.stat-card-neutral .stat-icon {
background: rgba(107, 114, 128, 0.15);
color: #6b7280;
}
.stat-card-danger .stat-icon {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.stat-card-info .stat-icon {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.stat-card-accent .stat-icon {
background: rgba(236, 72, 153, 0.15);
color: #ec4899;
}
</style>

View file

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

View file

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

View file

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

View file

@ -67,6 +67,7 @@
const navItems: PillNavItem[] = [
{ href: '/', label: 'Aufgaben', icon: 'list' },
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
@ -157,6 +158,31 @@
isCollapsed = true;
collapsedStore.set(true);
}
// Register Service Worker for PWA
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('Todo PWA: Service Worker registered', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
console.log('Todo PWA: New version available');
}
});
}
});
} catch (error) {
console.error('Todo PWA: Service Worker registration failed', error);
}
}
});
</script>
@ -202,7 +228,7 @@
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
<div class="content-wrapper" class:full-width={$page.url.pathname === '/kanban'}>
{@render children()}
</div>
</main>
@ -238,15 +264,29 @@
z-index: 0;
}
.content-wrapper.full-width {
max-width: none;
padding-left: 0;
padding-right: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
.content-wrapper.full-width {
padding-left: 0;
padding-right: 0;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
.content-wrapper.full-width {
padding-left: 0;
padding-right: 0;
}
}
</style>

View file

@ -0,0 +1,249 @@
<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 { 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}
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Lade Statistiken...</p>
</div>
{: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;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 2rem;
color: hsl(var(--muted-foreground));
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid hsl(var(--muted) / 0.3);
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.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>

554
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff