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>