mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21: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
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue