feat(shared-ui): add unified statistics components with theme support

- Add reusable chart components in shared-ui (StatsGrid, ActivityHeatmap, TrendLineChart, DonutChart, ProgressBars, StatisticsSkeleton)
- Use CSS variables (--primary) for consistent theme-based styling
- Add statistics pages to Calendar and Contacts apps
- Add statistics stores with app-specific metrics
- Fix PriorityDonutChart layout in Todo app (vertical layout with 2x2 legend grid)
- Add date-fns dependency to shared-ui

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 14:52:29 +01:00 committed by Wuesteon
parent 330b9907b0
commit 09e44a2f2f
15 changed files with 2611 additions and 0 deletions

View file

@ -42,6 +42,7 @@
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.468.0"
},
"devDependencies": {

View file

@ -0,0 +1,294 @@
<script lang="ts">
import { format, parseISO, getMonth } from 'date-fns';
import { de } from 'date-fns/locale';
import type { HeatmapDataPoint } from './types';
interface Props {
data: HeatmapDataPoint[];
title?: string;
/** Number of days to display (default: 180) */
daysCount?: number;
/** Custom tooltip formatter */
tooltipFormatter?: (point: HeatmapDataPoint) => string;
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
itemName?: string;
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
itemNamePlural?: string;
}
let {
data,
title = 'Aktivität',
daysCount = 180,
tooltipFormatter,
itemName = 'Aufgabe',
itemNamePlural = 'Aufgaben',
}: 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 (uses CSS variable --primary)
function getColorClass(count: number): string {
if (count === 0) return 'intensity-0';
const ratio = count / maxCount;
if (ratio <= 0.25) return 'intensity-1';
if (ratio <= 0.5) return 'intensity-2';
if (ratio <= 0.75) return 'intensity-3';
return 'intensity-4';
}
// Group data by weeks
let weeks = $derived.by(() => {
const result: HeatmapDataPoint[][] = [];
let currentWeek: HeatmapDataPoint[] = [];
// 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: HeatmapDataPoint): string {
if (!day.date) return '';
if (tooltipFormatter) return tooltipFormatter(day);
const date = format(parseISO(day.date), 'EEEE, d. MMMM yyyy', { locale: de });
const name = day.count === 1 ? itemName : itemNamePlural;
return `${day.count} ${name} am ${date}`;
}
</script>
<div class="heatmap-container">
<h3 class="heatmap-title">{title}</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 intensity-0"></div>
<div class="legend-cell intensity-1"></div>
<div class="legend-cell intensity-2"></div>
<div class="legend-cell intensity-3"></div>
<div class="legend-cell intensity-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;
}
:global(.dark) .cell.empty {
fill: transparent;
}
.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;
}
/* Intensity classes using theme primary color */
.intensity-0 {
fill: hsl(var(--muted) / 0.3);
background: hsl(var(--muted) / 0.3);
}
.intensity-1 {
fill: hsl(var(--primary) / 0.3);
background: hsl(var(--primary) / 0.3);
}
.intensity-2 {
fill: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.5);
}
.intensity-3 {
fill: hsl(var(--primary) / 0.7);
background: hsl(var(--primary) / 0.7);
}
.intensity-4 {
fill: hsl(var(--primary));
background: hsl(var(--primary));
}
</style>

View file

@ -0,0 +1,260 @@
<script lang="ts">
import type { DonutSegment } from './types';
interface Props {
data: DonutSegment[];
title?: string;
centerLabel?: string;
centerValue?: number | string;
showLegend?: boolean;
}
let {
data,
title = 'Verteilung',
centerLabel = 'Gesamt',
centerValue,
showLegend = true,
}: Props = $props();
// Chart settings
const SIZE = 200;
const CENTER = SIZE / 2;
const RADIUS = 80;
const INNER_RADIUS = 50;
// Total count
let total = $derived(centerValue ?? data.reduce((sum, d) => sum + d.count, 0));
// Generate arc paths
let arcs = $derived.by(() => {
const totalCount = data.reduce((sum, d) => sum + d.count, 0);
if (totalCount === 0) return [];
const result: Array<{
path: string;
color: string;
id: string;
label: string;
count: number;
percentage: number;
}> = [];
let currentAngle = -90; // Start at top
data.forEach((segment) => {
if (segment.count === 0) return;
const angle = (segment.count / totalCount) * 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,
id: segment.id,
label: segment.label,
count: segment.count,
percentage: segment.percentage,
});
currentAngle = endAngle;
});
return result;
});
// Hover state
let hoveredSegment = $state<string | null>(null);
</script>
<div class="donut-container">
<h3 class="donut-title">{title}</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.id}
onmouseenter={() => (hoveredSegment = arc.id)}
onmouseleave={() => (hoveredSegment = null)}
role="graphics-symbol"
aria-label="{arc.label}: {arc.count}"
>
<title>{arc.label}: {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">
{centerLabel}
</text>
</svg>
</div>
<!-- Legend -->
{#if showLegend}
<div class="donut-legend">
{#each data as item}
<div
class="legend-item"
class:active={hoveredSegment === item.id}
onmouseenter={() => (hoveredSegment = item.id)}
onmouseleave={() => (hoveredSegment = null)}
role="button"
tabindex="0"
>
<span class="legend-color" style="background-color: {item.color}"></span>
<span class="legend-label">{item.label}</span>
<span class="legend-count">{item.count}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.donut-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .donut-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.donut-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
}
.donut-content {
display: flex;
align-items: center;
gap: 1.5rem;
}
@media (max-width: 400px) {
.donut-content {
flex-direction: column;
}
}
.donut-chart {
flex-shrink: 0;
}
.donut-svg {
width: 140px;
height: 140px;
}
.arc-segment {
transition:
opacity 0.15s ease,
transform 0.15s ease;
transform-origin: center;
cursor: pointer;
}
.arc-segment:hover,
.arc-segment.hovered {
opacity: 0.85;
transform: scale(1.02);
}
.center-count {
font-size: 28px;
font-weight: 700;
fill: hsl(var(--foreground));
text-anchor: middle;
}
.center-label {
font-size: 12px;
fill: hsl(var(--muted-foreground));
text-anchor: middle;
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.legend-item:hover,
.legend-item.active {
background: hsl(var(--muted) / 0.3);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.875rem;
color: hsl(var(--foreground));
flex: 1;
}
.legend-count {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
</style>

View file

@ -0,0 +1,192 @@
<script lang="ts">
import type { ProgressItem } from './types';
interface Props {
data: ProgressItem[];
title?: string;
maxItems?: number;
emptyMessage?: string;
}
let {
data,
title = 'Fortschritt',
maxItems = 8,
emptyMessage = 'Keine Daten vorhanden',
}: Props = $props();
// Sort by total (descending) and limit to maxItems
let sortedData = $derived(data.slice(0, maxItems));
</script>
<div class="progress-container">
<h3 class="progress-title">{title}</h3>
{#if sortedData.length === 0}
<p class="no-data">{emptyMessage}</p>
{:else}
<div class="progress-list">
{#each sortedData as item (item.id)}
<div class="progress-row">
<div class="progress-header">
<div class="progress-name">
<span class="progress-dot" style="background-color: {item.color}"></span>
<span class="name-text">{item.name}</span>
</div>
<span class="progress-stats">
{item.completed}/{item.total}
</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar">
<!-- Completed segment -->
{#if item.completed > 0}
<div
class="progress-segment completed"
style="width: {(item.completed / item.total) *
100}%; background-color: {item.color}"
></div>
{/if}
<!-- In Progress segment -->
{#if item.inProgress && item.inProgress > 0}
<div
class="progress-segment in-progress"
style="width: {(item.inProgress / item.total) *
100}%; background-color: {item.color}; opacity: 0.4"
></div>
{/if}
</div>
<span class="percentage">{item.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;
}
.progress-row {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-name {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.progress-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;
}
.progress-stats {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: hsl(var(--muted) / 0.3);
border-radius: 4px;
overflow: hidden;
display: flex;
}
:global(.dark) .progress-bar {
background: rgba(255, 255, 255, 0.1);
}
.progress-segment {
height: 100%;
transition: width 0.3s ease;
}
.progress-segment.completed {
border-radius: 4px 0 0 4px;
}
.progress-segment.in-progress {
/* Striped pattern for in-progress */
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.3) 4px,
rgba(255, 255, 255, 0.3) 8px
);
}
.percentage {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
width: 36px;
text-align: right;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,272 @@
<script lang="ts">
/**
* StatisticsSkeleton - Skeleton for statistics page loading
*/
import { SkeletonBox } from '../molecules';
interface Props {
/** Number of stat cards to show (default: 6) */
statCards?: number;
/** Number of progress items to show (default: 4) */
progressItems?: number;
/** Number of legend items for donut chart (default: 4) */
legendItems?: number;
/** Show additional stats section (default: true) */
showAdditionalStats?: boolean;
}
let {
statCards = 6,
progressItems = 4,
legendItems = 4,
showAdditionalStats = true,
}: Props = $props();
</script>
<div class="statistics-skeleton" role="status" aria-label="Statistiken werden geladen...">
<!-- Stats Overview Cards -->
<div class="stats-overview">
{#each Array(statCards) as _, i}
<div class="stat-card" style="opacity: {Math.max(0.5, 1 - i * 0.08)};">
<SkeletonBox width="40px" height="40px" borderRadius="10px" />
<div class="stat-content">
<SkeletonBox width="48px" height="28px" />
<SkeletonBox width="80px" height="14px" />
</div>
</div>
{/each}
</div>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<div class="chart-card heatmap">
<div class="chart-header">
<SkeletonBox width="140px" height="20px" />
</div>
<div class="heatmap-grid">
{#each Array(7) as _}
<div class="heatmap-row">
{#each Array(12) as _}
<SkeletonBox width="16px" height="16px" borderRadius="3px" />
{/each}
</div>
{/each}
</div>
</div>
<!-- Charts Row -->
<div class="charts-row">
<!-- Weekly Trend Chart -->
<div class="chart-card trend">
<div class="chart-header">
<SkeletonBox width="120px" height="20px" />
</div>
<div class="trend-bars">
{#each Array(7) as _, i}
<div class="bar-wrapper">
<SkeletonBox width="32px" height="{40 + Math.random() * 60}px" borderRadius="4px" />
<SkeletonBox width="24px" height="12px" />
</div>
{/each}
</div>
</div>
<!-- Priority Donut Chart -->
<div class="chart-card donut">
<div class="chart-header">
<SkeletonBox width="100px" height="20px" />
</div>
<div class="donut-wrapper">
<SkeletonBox width="140px" height="140px" borderRadius="50%" />
</div>
<div class="legend">
{#each Array(legendItems) as _}
<div class="legend-item">
<SkeletonBox width="12px" height="12px" borderRadius="3px" />
<SkeletonBox width="60px" height="14px" />
</div>
{/each}
</div>
</div>
</div>
<!-- Project Progress -->
<div class="chart-card projects">
<div class="chart-header">
<SkeletonBox width="130px" height="20px" />
</div>
<div class="progress-bars">
{#each Array(progressItems) as _, i}
<div class="progress-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
<div class="progress-header">
<SkeletonBox width="{100 + i * 20}px" height="16px" />
<SkeletonBox width="40px" height="14px" />
</div>
<SkeletonBox width="100%" height="8px" borderRadius="4px" />
</div>
{/each}
</div>
</div>
</div>
<!-- Additional Stats -->
{#if showAdditionalStats}
<div class="additional-stats">
{#each Array(3) as _}
<div class="small-stat">
<SkeletonBox width="120px" height="12px" />
<SkeletonBox width="80px" height="18px" />
</div>
{/each}
</div>
{/if}
</div>
<style>
.statistics-skeleton {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Stats Overview */
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
}
.stat-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Charts Grid */
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.chart-card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
padding: 1.25rem;
}
.chart-header {
margin-bottom: 1rem;
}
/* Heatmap */
.heatmap-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.heatmap-row {
display: flex;
gap: 4px;
}
/* Charts Row */
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 2fr 1fr;
}
}
/* Trend Chart */
.trend-bars {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 120px;
padding-top: 1rem;
}
.bar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
/* Donut Chart */
.donut-wrapper {
display: flex;
justify-content: center;
padding: 1rem 0;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Project Progress */
.progress-bars {
display: flex;
flex-direction: column;
gap: 1rem;
}
.progress-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Additional Stats */
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.small-stat {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
}
</style>

View file

@ -0,0 +1,136 @@
<script lang="ts">
import type { StatItem } from './types';
import { STAT_VARIANT_COLORS } from './types';
interface Props {
items: StatItem[];
columns?: 2 | 3 | 4 | 6;
}
let { items, columns = 6 }: Props = $props();
// Filter items based on showCondition
let visibleItems = $derived(items.filter((item) => item.showCondition !== false));
</script>
<div
class="stats-grid"
class:cols-2={columns === 2}
class:cols-3={columns === 3}
class:cols-4={columns === 4}
class:cols-6={columns === 6}
>
{#each visibleItems as item (item.id)}
<div class="stat-card">
<div
class="stat-icon"
style="background-color: {STAT_VARIANT_COLORS[item.variant]
.bg}; color: {STAT_VARIANT_COLORS[item.variant].color}"
>
<item.icon size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{item.value}</span>
<span class="stat-label">{item.label}</span>
</div>
</div>
{/each}
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
/* Default responsive behavior for 6 columns */
.stats-grid.cols-6 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 640px) {
.stats-grid.cols-6 {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid.cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid.cols-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.stats-grid.cols-6 {
grid-template-columns: repeat(6, 1fr);
}
.stats-grid.cols-4 {
grid-template-columns: repeat(4, 1fr);
}
}
.stats-grid.cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.stats-grid.cols-3 {
grid-template-columns: repeat(2, 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;
}
</style>

View file

@ -0,0 +1,240 @@
<script lang="ts">
import type { TrendDataPoint } from './types';
interface Props {
data: TrendDataPoint[];
title?: string;
height?: number;
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
itemName?: string;
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
itemNamePlural?: string;
}
let {
data,
title = 'Trend (letzte 4 Wochen)',
height = 200,
itemName = 'Aufgabe',
itemNamePlural = 'Aufgaben',
}: Props = $props();
// Chart dimensions
const WIDTH = 600;
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 {
if (data.length <= 1) return PADDING.left;
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;
});
// Generate unique gradient ID
let gradientId = $derived(`areaGradient-${Math.random().toString(36).slice(2, 9)}`);
function formatTooltip(point: TrendDataPoint): string {
const name = point.count === 1 ? itemName : itemNamePlural;
return `${point.count} ${name} am ${point.date}`;
}
</script>
<div class="chart-container">
<h3 class="chart-title">{title}</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={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" class="gradient-start" />
<stop offset="100%" class="gradient-end" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#{gradientId})" 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>{formatTooltip(point)}</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;
}
.gradient-start {
stop-color: hsl(var(--primary));
stop-opacity: 0.3;
}
.gradient-end {
stop-color: hsl(var(--primary));
stop-opacity: 0.05;
}
.line-path {
fill: none;
stroke: hsl(var(--primary));
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.data-point {
fill: hsl(var(--primary));
stroke: white;
stroke-width: 2;
cursor: pointer;
transition: r 0.15s ease;
}
.data-point:hover {
r: 6;
}
:global(.dark) .data-point {
stroke: #1e1e1e;
}
.y-label {
font-size: 10px;
fill: hsl(var(--muted-foreground));
text-anchor: end;
}
.x-label {
font-size: 10px;
fill: hsl(var(--muted-foreground));
text-anchor: middle;
}
</style>

View file

@ -0,0 +1,20 @@
// Charts - Statistics Visualization Components
export { default as StatsGrid } from './StatsGrid.svelte';
export { default as ActivityHeatmap } from './ActivityHeatmap.svelte';
export { default as TrendLineChart } from './TrendLineChart.svelte';
export { default as DonutChart } from './DonutChart.svelte';
export { default as ProgressBars } from './ProgressBars.svelte';
export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte';
// Types
export type {
StatVariant,
StatItem,
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from './types';
// Constants
export { STAT_VARIANT_COLORS } from './types';

View file

@ -0,0 +1,62 @@
/**
* Shared Types for Chart Components
*/
import type { Component } from 'svelte';
// Stat card variant colors
export type StatVariant = 'success' | 'primary' | 'neutral' | 'danger' | 'info' | 'accent';
export const STAT_VARIANT_COLORS: Record<StatVariant, { bg: string; color: string }> = {
success: { bg: 'rgba(16, 185, 129, 0.15)', color: '#10B981' },
primary: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8B5CF6' },
neutral: { bg: 'rgba(107, 114, 128, 0.15)', color: '#6B7280' },
danger: { bg: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' },
info: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3B82F6' },
accent: { bg: 'rgba(236, 72, 153, 0.15)', color: '#EC4899' },
};
// StatsGrid types
export interface StatItem {
id: string;
label: string;
value: number | string;
icon: Component;
variant: StatVariant;
/** Optional: only show this stat if condition is true */
showCondition?: boolean;
}
// ActivityHeatmap types
export interface HeatmapDataPoint {
date: string; // YYYY-MM-DD format
count: number;
dayOfWeek: number; // 0-6 (Sunday-Saturday)
}
// TrendLineChart types
export interface TrendDataPoint {
date: string; // YYYY-MM-DD format
count: number;
label?: string;
}
// DonutChart types
export interface DonutSegment {
id: string;
label: string;
count: number;
percentage: number;
color: string;
}
// ProgressBars types
export interface ProgressItem {
id: string;
name: string;
color: string;
total: number;
completed: number;
inProgress?: number;
percentage: number;
}

View file

@ -110,3 +110,22 @@ export type { CommandBarItem, QuickAction, CreatePreview } from './command-bar';
// Pages
export { default as AppsPage } from './pages/AppsPage.svelte';
// Charts - Statistics Visualization
export {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
STAT_VARIANT_COLORS,
} from './charts';
export type {
StatVariant,
StatItem,
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from './charts';