mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 { labelsStore } from './labels.svelte';
|
||||
export { viewStore } from './view.svelte';
|
||||
export { statisticsStore } from './statistics.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[] = [
|
||||
{ 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>
|
||||
|
|
|
|||
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