mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 12:06:41 +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>
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"d3-selection": "^3.0.0",
|
"d3-selection": "^3.0.0",
|
||||||
"d3-transition": "^3.0.0",
|
"d3-transition": "^3.0.0",
|
||||||
"d3-zoom": "^3.0.0",
|
"d3-zoom": "^3.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-svelte": "^0.468.0"
|
"lucide-svelte": "^0.468.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
294
packages/shared-ui/src/charts/ActivityHeatmap.svelte
Normal file
294
packages/shared-ui/src/charts/ActivityHeatmap.svelte
Normal 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>
|
||||||
260
packages/shared-ui/src/charts/DonutChart.svelte
Normal file
260
packages/shared-ui/src/charts/DonutChart.svelte
Normal 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>
|
||||||
192
packages/shared-ui/src/charts/ProgressBars.svelte
Normal file
192
packages/shared-ui/src/charts/ProgressBars.svelte
Normal 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>
|
||||||
272
packages/shared-ui/src/charts/StatisticsSkeleton.svelte
Normal file
272
packages/shared-ui/src/charts/StatisticsSkeleton.svelte
Normal 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>
|
||||||
136
packages/shared-ui/src/charts/StatsGrid.svelte
Normal file
136
packages/shared-ui/src/charts/StatsGrid.svelte
Normal 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>
|
||||||
240
packages/shared-ui/src/charts/TrendLineChart.svelte
Normal file
240
packages/shared-ui/src/charts/TrendLineChart.svelte
Normal 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>
|
||||||
20
packages/shared-ui/src/charts/index.ts
Normal file
20
packages/shared-ui/src/charts/index.ts
Normal 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';
|
||||||
62
packages/shared-ui/src/charts/types.ts
Normal file
62
packages/shared-ui/src/charts/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -110,3 +110,22 @@ export type { CommandBarItem, QuickAction, CreatePreview } from './command-bar';
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
export { default as AppsPage } from './pages/AppsPage.svelte';
|
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
3
pnpm-lock.yaml
generated
|
|
@ -4298,6 +4298,9 @@ importers:
|
||||||
d3-zoom:
|
d3-zoom:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
lucide-svelte:
|
lucide-svelte:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(svelte@5.44.0)
|
version: 0.468.0(svelte@5.44.0)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue