mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +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-transition": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.468.0"
|
||||
},
|
||||
"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
|
||||
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:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
lucide-svelte:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(svelte@5.44.0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue