mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 10:46:41 +02:00
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:
parent
0c2434bd1d
commit
d45a9db3fc
11 changed files with 2036 additions and 333 deletions
|
|
@ -28,7 +28,6 @@
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@todo/shared": "workspace:*",
|
|
||||||
"@manacore/shared-auth": "workspace:*",
|
"@manacore/shared-auth": "workspace:*",
|
||||||
"@manacore/shared-auth-ui": "workspace:*",
|
"@manacore/shared-auth-ui": "workspace:*",
|
||||||
"@manacore/shared-branding": "workspace:*",
|
"@manacore/shared-branding": "workspace:*",
|
||||||
|
|
@ -42,7 +41,9 @@
|
||||||
"@manacore/shared-theme": "workspace:*",
|
"@manacore/shared-theme": "workspace:*",
|
||||||
"@manacore/shared-theme-ui": "workspace:*",
|
"@manacore/shared-theme-ui": "workspace:*",
|
||||||
"@manacore/shared-ui": "workspace:*",
|
"@manacore/shared-ui": "workspace:*",
|
||||||
|
"@todo/shared": "workspace:*",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-svelte": "^0.556.0",
|
||||||
"svelte-dnd-action": "^0.9.68",
|
"svelte-dnd-action": "^0.9.68",
|
||||||
"svelte-i18n": "^4.0.1"
|
"svelte-i18n": "^4.0.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -3,4 +3,5 @@ export { projectsStore } from './projects.svelte';
|
||||||
export { tasksStore } from './tasks.svelte';
|
export { tasksStore } from './tasks.svelte';
|
||||||
export { labelsStore } from './labels.svelte';
|
export { labelsStore } from './labels.svelte';
|
||||||
export { viewStore } from './view.svelte';
|
export { viewStore } from './view.svelte';
|
||||||
|
export { statisticsStore } from './statistics.svelte';
|
||||||
export type { ViewType, SortBy, SortOrder } from './view.svelte';
|
export type { ViewType, SortBy, SortOrder } from './view.svelte';
|
||||||
|
|
|
||||||
361
apps/todo/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
361
apps/todo/apps/web/src/lib/stores/statistics.svelte.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
const navItems: PillNavItem[] = [
|
const navItems: PillNavItem[] = [
|
||||||
{ href: '/', label: 'Aufgaben', icon: 'list' },
|
{ href: '/', label: 'Aufgaben', icon: 'list' },
|
||||||
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
|
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
|
||||||
|
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
|
||||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||||
];
|
];
|
||||||
|
|
@ -157,6 +158,31 @@
|
||||||
isCollapsed = true;
|
isCollapsed = true;
|
||||||
collapsedStore.set(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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -202,7 +228,7 @@
|
||||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||||
class:floating-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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -238,15 +264,29 @@
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-wrapper.full-width {
|
||||||
|
max-width: none;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
.content-wrapper.full-width {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
.content-wrapper.full-width {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
249
apps/todo/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
249
apps/todo/apps/web/src/routes/(app)/statistics/+page.svelte
Normal 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
554
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue