mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
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:
parent
330b9907b0
commit
09e44a2f2f
15 changed files with 2611 additions and 0 deletions
270
apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
270
apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
287
apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
287
apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte
Normal 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>
|
||||
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue