diff --git a/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts b/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts new file mode 100644 index 000000000..20f739c87 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts @@ -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 = { + confirmed: '#10B981', // green + tentative: '#F59E0B', // orange + cancelled: '#EF4444', // red +}; + +const STATUS_LABELS: Record = { + confirmed: 'Bestätigt', + tentative: 'Vorläufig', + cancelled: 'Abgesagt', +}; + +// State +let events = $state([]); +let calendars = $state([]); + +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(); + + 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(); + + 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 = { + 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(); + + // 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; + }, +}; diff --git a/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte new file mode 100644 index 000000000..231dcd38c --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte @@ -0,0 +1,287 @@ + + + + Statistiken - Kalender + + +
+ + + {#if loading} + + {:else} + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+ +
+
+ + +
+
+ Ganztägige Events + + {calendarStatisticsStore.allDayRatio.allDay} + ({calendarStatisticsStore.allDayRatio.allDayPercentage}%) + +
+ +
+ Wiederkehrende Events + {calendarStatisticsStore.recurringEventsCount} +
+ +
+ Events gesamt + {calendarStatisticsStore.totalEvents} +
+
+ {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts b/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts new file mode 100644 index 000000000..4c2a112f6 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts @@ -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([]); +let tags = $state([]); + +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(); + + 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(); + + 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(); + + // 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(); + + 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; + }, +}; diff --git a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte new file mode 100644 index 000000000..f288dbd02 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte @@ -0,0 +1,280 @@ + + + + Statistiken - Kontakte + + +
+ + + {#if loading} + + {:else} + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+
+
+ + +
+
+ Aktive Kontakte + {contactsStatisticsStore.activeContacts} +
+ +
+ Archivierte Kontakte + {contactsStatisticsStore.archivedContacts} +
+ +
+ Tags + {contactsStatisticsStore.totalTags} +
+
+ {/if} +
+ + diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index d43549996..f366d440f 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -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": { diff --git a/packages/shared-ui/src/charts/ActivityHeatmap.svelte b/packages/shared-ui/src/charts/ActivityHeatmap.svelte new file mode 100644 index 000000000..852539046 --- /dev/null +++ b/packages/shared-ui/src/charts/ActivityHeatmap.svelte @@ -0,0 +1,294 @@ + + +
+

{title}

+ +
+ + + {#each monthLabels as label} + + {label.month} + + {/each} + + + {#each DAY_LABELS as label, i} + {#if label} + + {label} + + {/if} + {/each} + + + {#each weeks as week, weekIndex} + {#each week as day, dayIndex} + {#if day.date} + + {formatTooltip(day)} + + {:else} + + {/if} + {/each} + {/each} + +
+ + +
+ Weniger +
+
+
+
+
+
+
+ Mehr +
+
+ + diff --git a/packages/shared-ui/src/charts/DonutChart.svelte b/packages/shared-ui/src/charts/DonutChart.svelte new file mode 100644 index 000000000..643db2d61 --- /dev/null +++ b/packages/shared-ui/src/charts/DonutChart.svelte @@ -0,0 +1,260 @@ + + +
+

{title}

+ +
+
+ + {#each arcs as arc} + (hoveredSegment = arc.id)} + onmouseleave={() => (hoveredSegment = null)} + role="graphics-symbol" + aria-label="{arc.label}: {arc.count}" + > + {arc.label}: {arc.count} ({arc.percentage}%) + + {/each} + + + + {total} + + + {centerLabel} + + +
+ + + {#if showLegend} +
+ {#each data as item} +
(hoveredSegment = item.id)} + onmouseleave={() => (hoveredSegment = null)} + role="button" + tabindex="0" + > + + {item.label} + {item.count} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/packages/shared-ui/src/charts/ProgressBars.svelte b/packages/shared-ui/src/charts/ProgressBars.svelte new file mode 100644 index 000000000..21692ec04 --- /dev/null +++ b/packages/shared-ui/src/charts/ProgressBars.svelte @@ -0,0 +1,192 @@ + + +
+

{title}

+ + {#if sortedData.length === 0} +

{emptyMessage}

+ {:else} +
+ {#each sortedData as item (item.id)} +
+
+
+ + {item.name} +
+ + {item.completed}/{item.total} + +
+ +
+
+ + {#if item.completed > 0} +
+ {/if} + + + {#if item.inProgress && item.inProgress > 0} +
+ {/if} +
+ + {item.percentage}% +
+
+ {/each} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/charts/StatisticsSkeleton.svelte b/packages/shared-ui/src/charts/StatisticsSkeleton.svelte new file mode 100644 index 000000000..e50ea20dd --- /dev/null +++ b/packages/shared-ui/src/charts/StatisticsSkeleton.svelte @@ -0,0 +1,272 @@ + + +
+ +
+ {#each Array(statCards) as _, i} +
+ +
+ + +
+
+ {/each} +
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _} +
+ {#each Array(12) as _} + + {/each} +
+ {/each} +
+
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _, i} +
+ + +
+ {/each} +
+
+ + +
+
+ +
+
+ +
+
+ {#each Array(legendItems) as _} +
+ + +
+ {/each} +
+
+
+ + +
+
+ +
+
+ {#each Array(progressItems) as _, i} +
+
+ + +
+ +
+ {/each} +
+
+
+ + + {#if showAdditionalStats} +
+ {#each Array(3) as _} +
+ + +
+ {/each} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/charts/StatsGrid.svelte b/packages/shared-ui/src/charts/StatsGrid.svelte new file mode 100644 index 000000000..49d1ffea9 --- /dev/null +++ b/packages/shared-ui/src/charts/StatsGrid.svelte @@ -0,0 +1,136 @@ + + +
+ {#each visibleItems as item (item.id)} +
+
+ +
+
+ {item.value} + {item.label} +
+
+ {/each} +
+ + diff --git a/packages/shared-ui/src/charts/TrendLineChart.svelte b/packages/shared-ui/src/charts/TrendLineChart.svelte new file mode 100644 index 000000000..0615c490b --- /dev/null +++ b/packages/shared-ui/src/charts/TrendLineChart.svelte @@ -0,0 +1,240 @@ + + +
+

{title}

+ + + + {#each yTicks as tick} + + {/each} + + + + + + + + + + + + + + + + {#each data as point, i} + + {formatTooltip(point)} + + {/each} + + + {#each yTicks as tick} + + {tick} + + {/each} + + + {#each xLabels as label} + + {label.label} + + {/each} + +
+ + diff --git a/packages/shared-ui/src/charts/index.ts b/packages/shared-ui/src/charts/index.ts new file mode 100644 index 000000000..6246fd8a8 --- /dev/null +++ b/packages/shared-ui/src/charts/index.ts @@ -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'; diff --git a/packages/shared-ui/src/charts/types.ts b/packages/shared-ui/src/charts/types.ts new file mode 100644 index 000000000..774b0d993 --- /dev/null +++ b/packages/shared-ui/src/charts/types.ts @@ -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 = { + 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; +} diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 55bd31ed3..9e3262b05 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6457e53cf..b14c5088a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4292,6 +4292,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)