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
parent a0306bab7d
commit 04b255c161
15 changed files with 2611 additions and 0 deletions

View file

@ -0,0 +1,270 @@
/**
* Calendar Statistics Store - Calculates calendar statistics using Svelte 5 runes
*/
import type { CalendarEvent, Calendar } from '@calendar/shared';
import {
startOfDay,
startOfWeek,
endOfWeek,
subDays,
format,
differenceInMinutes,
isToday,
isSameWeek,
parseISO,
eachDayOfInterval,
addDays,
} from 'date-fns';
import { de } from 'date-fns/locale';
import type {
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from '@manacore/shared-ui';
// Types
export interface EventStatusBreakdown {
status: 'confirmed' | 'tentative' | 'cancelled';
count: number;
percentage: number;
color: string;
}
const STATUS_COLORS: Record<string, string> = {
confirmed: '#10B981', // green
tentative: '#F59E0B', // orange
cancelled: '#EF4444', // red
};
const STATUS_LABELS: Record<string, string> = {
confirmed: 'Bestätigt',
tentative: 'Vorläufig',
cancelled: 'Abgesagt',
};
// State
let events = $state<CalendarEvent[]>([]);
let calendars = $state<Calendar[]>([]);
export const calendarStatisticsStore = {
// Setters
setEvents(newEvents: CalendarEvent[]) {
events = newEvents;
},
setCalendars(newCalendars: Calendar[]) {
calendars = newCalendars;
},
// Quick Stats
get totalEvents() {
return events.length;
},
get eventsToday() {
return events.filter((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
return isToday(startTime);
}).length;
},
get eventsThisWeek() {
const now = new Date();
return events.filter((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
return isSameWeek(startTime, now, { weekStartsOn: 1 });
}).length;
},
get upcomingEvents() {
const now = new Date();
const nextWeek = addDays(now, 7);
return events.filter((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
return startTime > now && startTime <= nextWeek;
}).length;
},
get busyHoursThisWeek() {
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
let totalMinutes = 0;
events.forEach((e) => {
if (e.isAllDay) return; // Skip all-day events
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime;
if (startTime >= weekStart && startTime <= weekEnd) {
totalMinutes += differenceInMinutes(endTime, startTime);
}
});
return Math.round((totalMinutes / 60) * 10) / 10; // Round to 1 decimal
},
get totalCalendars() {
return calendars.length;
},
get averageEventDuration() {
const timedEvents = events.filter((e) => !e.isAllDay);
if (timedEvents.length === 0) return 0;
const totalMinutes = timedEvents.reduce((sum, e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime;
return sum + differenceInMinutes(endTime, startTime);
}, 0);
return Math.round(totalMinutes / timedEvents.length);
},
// Activity Heatmap (last 6 months) - based on event creation
get activityHeatmap(): HeatmapDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 180);
// Count events per day based on start time
const eventMap = new Map<string, number>();
events.forEach((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
const dateKey = format(startTime, 'yyyy-MM-dd');
eventMap.set(dateKey, (eventMap.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: eventMap.get(dateKey) || 0,
dayOfWeek: day.getDay(),
};
});
},
// Weekly Trend (last 4 weeks)
get weeklyTrend(): TrendDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 27);
const eventMap = new Map<string, number>();
events.forEach((e) => {
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
if (startTime >= startDate && startTime <= endDate) {
const dateKey = format(startTime, 'yyyy-MM-dd');
eventMap.set(dateKey, (eventMap.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: eventMap.get(dateKey) || 0,
label: format(day, 'EEE', { locale: de }),
};
});
},
// Status Breakdown (Donut Chart)
get statusBreakdown(): DonutSegment[] {
const total = events.length;
if (total === 0) return [];
const counts: Record<string, number> = {
confirmed: 0,
tentative: 0,
cancelled: 0,
};
events.forEach((e) => {
const status = e.status || 'confirmed';
if (counts[status] !== undefined) {
counts[status]++;
}
});
return (['confirmed', 'tentative', 'cancelled'] as const).map((status) => ({
id: status,
label: STATUS_LABELS[status],
count: counts[status],
percentage: total > 0 ? Math.round((counts[status] / total) * 100) : 0,
color: STATUS_COLORS[status],
}));
},
// Calendar Activity (Progress Bars)
get calendarActivity(): ProgressItem[] {
const calendarMap = new Map<string, { total: number; thisWeek: number }>();
// Initialize with all calendars
calendars.forEach((c) => {
calendarMap.set(c.id, { total: 0, thisWeek: 0 });
});
const now = new Date();
// Count events per calendar
events.forEach((e) => {
const calendarId = e.calendarId;
const data = calendarMap.get(calendarId) || { total: 0, thisWeek: 0 };
data.total++;
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
if (isSameWeek(startTime, now, { weekStartsOn: 1 })) {
data.thisWeek++;
}
calendarMap.set(calendarId, data);
});
// Convert to array
const result: ProgressItem[] = [];
calendarMap.forEach((data, calendarId) => {
if (data.total === 0) return;
const calendar = calendars.find((c) => c.id === calendarId);
result.push({
id: calendarId,
name: calendar?.name || 'Unbekannt',
color: calendar?.color || '#6B7280',
total: data.total,
completed: data.thisWeek,
percentage: data.total > 0 ? Math.round((data.thisWeek / data.total) * 100) : 0,
});
});
// Sort by total events descending
return result.sort((a, b) => b.total - a.total);
},
// All-day vs Timed events ratio
get allDayRatio() {
const allDay = events.filter((e) => e.isAllDay).length;
const timed = events.filter((e) => !e.isAllDay).length;
return {
allDay,
timed,
allDayPercentage: events.length > 0 ? Math.round((allDay / events.length) * 100) : 0,
};
},
// Recurring events count
get recurringEventsCount() {
return events.filter((e) => e.recurrenceRule).length;
},
};

View file

@ -0,0 +1,287 @@
<script lang="ts">
import { onMount } from 'svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { calendarStatisticsStore } from '$lib/stores/statistics.svelte';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import {
BarChart3,
CalendarDays,
Calendar,
Clock,
CalendarCheck,
Hourglass,
} from 'lucide-svelte';
import { subDays, addDays } from 'date-fns';
let loading = $state(true);
// Update statistics when events change
$effect(() => {
calendarStatisticsStore.setEvents(eventsStore.events);
});
$effect(() => {
calendarStatisticsStore.setCalendars(calendarsStore.calendars);
});
// Build stats items for StatsGrid
let statsItems = $derived<StatItem[]>([
{
id: 'eventsToday',
label: 'Heute',
value: calendarStatisticsStore.eventsToday,
icon: CalendarDays,
variant: 'success',
},
{
id: 'eventsThisWeek',
label: 'Diese Woche',
value: calendarStatisticsStore.eventsThisWeek,
icon: Calendar,
variant: 'primary',
},
{
id: 'upcoming',
label: 'Anstehend (7 Tage)',
value: calendarStatisticsStore.upcomingEvents,
icon: CalendarCheck,
variant: 'info',
},
{
id: 'busyHours',
label: 'Stunden/Woche',
value: `${calendarStatisticsStore.busyHoursThisWeek}h`,
icon: Clock,
variant: 'neutral',
},
{
id: 'calendars',
label: 'Kalender',
value: calendarStatisticsStore.totalCalendars,
icon: Calendar,
variant: 'accent',
},
{
id: 'avgDuration',
label: 'Ø Dauer (Min)',
value: calendarStatisticsStore.averageEventDuration,
icon: Hourglass,
variant: 'info',
},
]);
onMount(async () => {
// Fetch events for the last 6 months + next month for statistics
const startDate = subDays(new Date(), 180);
const endDate = addDays(new Date(), 30);
await Promise.all([
eventsStore.fetchEvents(startDate, endDate),
calendarsStore.fetchCalendars(),
]);
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kalender</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">Dein Kalender im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={3} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={calendarStatisticsStore.activityHeatmap}
itemName="Event"
itemNamePlural="Events"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={calendarStatisticsStore.weeklyTrend}
itemName="Event"
itemNamePlural="Events"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={calendarStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Events"
centerValue={calendarStatisticsStore.totalEvents}
/>
</section>
</div>
<!-- Calendar Activity -->
<section class="chart-section calendars-section">
<ProgressBars
data={calendarStatisticsStore.calendarActivity}
title="Kalender-Aktivität"
emptyMessage="Keine Kalender mit Events"
/>
</section>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Ganztägige Events</span>
<span class="stat-value">
{calendarStatisticsStore.allDayRatio.allDay}
<span class="stat-percentage"
>({calendarStatisticsStore.allDayRatio.allDayPercentage}%)</span
>
</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Wiederkehrende Events</span>
<span class="stat-value">{calendarStatisticsStore.recurringEventsCount}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Events gesamt</span>
<span class="stat-value">{calendarStatisticsStore.totalEvents}</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: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 2fr 1fr;
}
}
.chart-section {
min-width: 0;
}
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.stat-card-small {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
:global(.dark) .stat-card-small {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-card-small .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-card-small .stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.stat-percentage {
font-size: 0.875rem;
font-weight: 400;
color: hsl(var(--muted-foreground));
}
</style>

View file

@ -0,0 +1,275 @@
/**
* Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes
*/
import type { Contact } from '$lib/api/contacts';
import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns';
import { de } from 'date-fns/locale';
import type {
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from '@manacore/shared-ui';
// Types
export interface ContactTag {
id: string;
name: string;
color: string;
}
// State
let contacts = $state<Contact[]>([]);
let tags = $state<ContactTag[]>([]);
export const contactsStatisticsStore = {
// Setters
setContacts(newContacts: Contact[]) {
contacts = newContacts;
},
setTags(newTags: ContactTag[]) {
tags = newTags;
},
// Quick Stats
get totalContacts() {
return contacts.length;
},
get favoriteContacts() {
return contacts.filter((c) => c.isFavorite).length;
},
get archivedContacts() {
return contacts.filter((c) => c.isArchived).length;
},
get activeContacts() {
return contacts.filter((c) => !c.isArchived).length;
},
get recentlyAdded() {
const weekAgo = subDays(new Date(), 7);
return contacts.filter((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
return createdAt >= weekAgo;
}).length;
},
get birthdaysThisMonth() {
const currentMonth = getMonth(new Date());
return contacts.filter((c) => {
if (!c.birthday) return false;
const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday);
return getMonth(birthday) === currentMonth;
}).length;
},
get contactsWithEmail() {
return contacts.filter((c) => c.email).length;
},
get contactsWithPhone() {
return contacts.filter((c) => c.phone || c.mobile).length;
},
// Completeness rate (contacts with email AND phone)
get completenessRate() {
if (contacts.length === 0) return 0;
const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length;
return Math.round((complete / contacts.length) * 100);
},
// Activity Heatmap (last 6 months) - based on contact creation
get activityHeatmap(): HeatmapDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 180);
// Count contacts created per day
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.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: creationMap.get(dateKey) || 0,
dayOfWeek: day.getDay(),
};
});
},
// Weekly Trend (last 4 weeks)
get weeklyTrend(): TrendDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 27);
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.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: creationMap.get(dateKey) || 0,
label: format(day, 'EEE', { locale: de }),
};
});
},
// Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived
get statusBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length;
const archived = contacts.filter((c) => c.isArchived).length;
const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length;
return [
{
id: 'favorites',
label: 'Favoriten',
count: favorites,
percentage: Math.round((favorites / total) * 100),
color: '#F59E0B', // amber
},
{
id: 'regular',
label: 'Aktiv',
count: regular,
percentage: Math.round((regular / total) * 100),
color: '#10B981', // green
},
{
id: 'archived',
label: 'Archiviert',
count: archived,
percentage: Math.round((archived / total) * 100),
color: '#6B7280', // gray
},
];
},
// Tags Progress (Progress Bars)
get tagProgress(): ProgressItem[] {
// Count contacts per tag
const tagCountMap = new Map<string, number>();
// This requires contacts to have a tags array - we'll estimate from the tag data
// For now, we'll show tags with placeholder counts
// In a real implementation, we'd need contactTags relation data
const result: ProgressItem[] = tags.map((tag) => ({
id: tag.id,
name: tag.name,
color: tag.color || '#6B7280',
total: contacts.length, // Total contacts as reference
completed: 0, // Would need contact-tag relation to calculate
percentage: 0,
}));
return result.sort((a, b) => b.completed - a.completed);
},
// Info completeness breakdown
get infoBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const withEmail = contacts.filter((c) => c.email).length;
const withPhone = contacts.filter((c) => c.phone || c.mobile).length;
const withCompany = contacts.filter((c) => c.company).length;
const withBirthday = contacts.filter((c) => c.birthday).length;
return [
{
id: 'email',
label: 'Mit E-Mail',
count: withEmail,
percentage: Math.round((withEmail / total) * 100),
color: '#3B82F6', // blue
},
{
id: 'phone',
label: 'Mit Telefon',
count: withPhone,
percentage: Math.round((withPhone / total) * 100),
color: '#10B981', // green
},
{
id: 'company',
label: 'Mit Firma',
count: withCompany,
percentage: Math.round((withCompany / total) * 100),
color: '#8B5CF6', // violet
},
{
id: 'birthday',
label: 'Mit Geburtstag',
count: withBirthday,
percentage: Math.round((withBirthday / total) * 100),
color: '#EC4899', // pink
},
];
},
// Country breakdown
get countryBreakdown(): ProgressItem[] {
const countryMap = new Map<string, number>();
contacts.forEach((c) => {
const country = c.country || 'Unbekannt';
countryMap.set(country, (countryMap.get(country) || 0) + 1);
});
const result: ProgressItem[] = [];
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280'];
let colorIndex = 0;
countryMap.forEach((count, country) => {
if (country !== 'Unbekannt' || count > 0) {
result.push({
id: country,
name: country,
color: colors[colorIndex % colors.length],
total: contacts.length,
completed: count,
percentage: Math.round((count / contacts.length) * 100),
});
colorIndex++;
}
});
return result.sort((a, b) => b.completed - a.completed).slice(0, 8);
},
// Total tags count
get totalTags() {
return tags.length;
},
};

View file

@ -0,0 +1,280 @@
<script lang="ts">
import { onMount } from 'svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsStatisticsStore } from '$lib/stores/statistics.svelte';
import { tagsApi } from '$lib/api/tags';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CheckCircle } from 'lucide-svelte';
let loading = $state(true);
// Update statistics when contacts change
$effect(() => {
contactsStatisticsStore.setContacts(contactsStore.contacts);
});
// Build stats items for StatsGrid
let statsItems = $derived<StatItem[]>([
{
id: 'total',
label: 'Gesamt',
value: contactsStatisticsStore.totalContacts,
icon: Users,
variant: 'primary',
},
{
id: 'favorites',
label: 'Favoriten',
value: contactsStatisticsStore.favoriteContacts,
icon: Star,
variant: 'accent',
},
{
id: 'recentlyAdded',
label: 'Neu (7 Tage)',
value: contactsStatisticsStore.recentlyAdded,
icon: UserPlus,
variant: 'success',
},
{
id: 'birthdays',
label: 'Geburtstage',
value: contactsStatisticsStore.birthdaysThisMonth,
icon: Cake,
variant: 'info',
},
{
id: 'withEmail',
label: 'Mit E-Mail',
value: contactsStatisticsStore.contactsWithEmail,
icon: Mail,
variant: 'neutral',
},
{
id: 'completeness',
label: 'Vollständigkeit',
value: `${contactsStatisticsStore.completenessRate}%`,
icon: CheckCircle,
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
},
]);
onMount(async () => {
// Fetch all contacts (without filters for statistics)
await contactsStore.loadContacts({ isArchived: false });
// Also load archived for complete statistics
const allContacts = [...contactsStore.contacts];
// Fetch tags
try {
const tagsResult = await tagsApi.list();
contactsStatisticsStore.setTags(tagsResult);
} catch (e) {
console.error('Failed to load tags:', e);
}
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kontakte</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 Kontakte im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={4} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={contactsStatisticsStore.activityHeatmap}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={contactsStatisticsStore.weeklyTrend}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={contactsStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
</div>
<!-- Info Completeness -->
<div class="charts-row">
<section class="chart-section info-section">
<DonutChart
data={contactsStatisticsStore.infoBreakdown}
title="Informationen"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
<section class="chart-section country-section">
<ProgressBars
data={contactsStatisticsStore.countryBreakdown}
title="Nach Land"
emptyMessage="Keine Länder angegeben"
/>
</section>
</div>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Aktive Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.activeContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Archivierte Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.archivedContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Tags</span>
<span class="stat-value">{contactsStatisticsStore.totalTags}</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: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 1fr 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));
}
</style>

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';

3
pnpm-lock.yaml generated
View file

@ -4292,6 +4292,9 @@ importers:
d3-zoom:
specifier: ^3.0.0
version: 3.0.0
date-fns:
specifier: ^4.1.0
version: 4.1.0
lucide-svelte:
specifier: ^0.468.0
version: 0.468.0(svelte@5.44.0)