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

@ -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>