mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-02-13 12:55:42 +01:00
fix: comprehensive TypeScript/build fixes and modernization
- Update tsconfig to ES2024 target and bundler moduleResolution - Add dynamic imports for chart.js and recharts (bundle optimization) - Consolidate 17 useState into useReducer in sessions page - Fix 18 .js extension imports across lib files - Add type declarations for @rapideditor/country-coder - Fix platform user types (PlatformUserRole enum) - Fix Calendar component prop types - Centralize next-auth type augmentation - Add force-dynamic to all API routes (prevent build-time prerender) - Fix Prisma JSON null handling with Prisma.DbNull - Fix various type mismatches (SessionMessage, ImportRecord, etc.) - Export ButtonProps from button component - Update next-themes import path - Replace JSX.Element with React.ReactElement - Remove obsolete debug scripts and pnpm lockfile - Downgrade eslint to v8 for next compatibility
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
import Chart from "chart.js/auto";
|
||||
|
||||
import type { Chart as ChartType } from "chart.js";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getLocalizedLanguageName } from "../lib/localization"; // Corrected import path
|
||||
import { getLocalizedLanguageName } from "../lib/localization";
|
||||
|
||||
interface SessionsData {
|
||||
[date: string]: number;
|
||||
@@ -43,266 +45,432 @@ interface TokenUsageChartProps {
|
||||
};
|
||||
}
|
||||
|
||||
// Basic line and bar chart for metrics. Extend as needed.
|
||||
export function SessionsLineChart({ sessionsPerDay }: SessionsLineChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current || !sessionsPerDay) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: Object.keys(sessionsPerDay),
|
||||
datasets: [
|
||||
{
|
||||
label: "Sessions",
|
||||
data: Object.values(sessionsPerDay),
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
},
|
||||
});
|
||||
return () => chart.destroy();
|
||||
}, [sessionsPerDay]);
|
||||
return <canvas ref={ref} height={180} />;
|
||||
// Loading skeleton for charts
|
||||
function ChartSkeleton({ height = 180 }: { height?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="animate-pulse bg-gray-200 dark:bg-gray-700 rounded"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoriesBarChart({ categories }: CategoriesBarChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current || !categories) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: Object.keys(categories),
|
||||
datasets: [
|
||||
{
|
||||
label: "Categories",
|
||||
data: Object.values(categories),
|
||||
backgroundColor: "rgba(59, 130, 246, 0.7)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
},
|
||||
});
|
||||
return () => chart.destroy();
|
||||
}, [categories]);
|
||||
return <canvas ref={ref} height={180} />;
|
||||
function DonutChartSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[180px]">
|
||||
<div className="animate-pulse flex items-center gap-6">
|
||||
<div className="w-28 h-28 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="w-16 h-3 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SentimentChart({ sentimentData }: SentimentChartProps) {
|
||||
// Inner components that do the actual rendering
|
||||
function SessionsLineChartInner({ sessionsPerDay }: SessionsLineChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current || !sentimentData) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const chartRef = useRef<ChartType | null>(null);
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: ["Positive", "Neutral", "Negative"],
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
sentimentData.positive,
|
||||
sentimentData.neutral,
|
||||
sentimentData.negative,
|
||||
],
|
||||
backgroundColor: [
|
||||
"rgba(34, 197, 94, 0.8)", // green
|
||||
"rgba(249, 115, 22, 0.8)", // orange
|
||||
"rgba(239, 68, 68, 0.8)", // red
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function initChart() {
|
||||
if (!ref.current || !sessionsPerDay) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { default: Chart } = await import("chart.js/auto");
|
||||
if (!isMounted) return;
|
||||
|
||||
if (chartRef.current) chartRef.current.destroy();
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: Object.keys(sessionsPerDay),
|
||||
datasets: [
|
||||
{
|
||||
label: "Sessions",
|
||||
data: Object.values(sessionsPerDay),
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cutout: "65%",
|
||||
},
|
||||
});
|
||||
return () => chart.destroy();
|
||||
}, [sentimentData]);
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
|
||||
export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current || !languages) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Get top 5 languages, combine others
|
||||
const entries = Object.entries(languages);
|
||||
const topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
|
||||
// Sum the count of all other languages
|
||||
const otherCount = entries
|
||||
.slice(5)
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
if (otherCount > 0) {
|
||||
topLanguages.push(["Other", otherCount]);
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Store original ISO codes for tooltip
|
||||
const isoCodes = topLanguages.map(([lang]) => lang);
|
||||
initChart();
|
||||
|
||||
const labels = topLanguages.map(([lang]) => {
|
||||
if (lang === "Other") {
|
||||
return "Other";
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
// Use getLocalizedLanguageName for robust name resolution
|
||||
// Pass "en" to maintain consistency with previous behavior if navigator.language is different
|
||||
return getLocalizedLanguageName(lang, "en");
|
||||
});
|
||||
};
|
||||
}, [sessionsPerDay]);
|
||||
|
||||
const data = topLanguages.map(([, count]) => count);
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "pie",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: [
|
||||
"rgba(59, 130, 246, 0.8)",
|
||||
"rgba(16, 185, 129, 0.8)",
|
||||
"rgba(249, 115, 22, 0.8)",
|
||||
"rgba(236, 72, 153, 0.8)",
|
||||
"rgba(139, 92, 246, 0.8)",
|
||||
"rgba(107, 114, 128, 0.8)",
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
function CategoriesBarChartInner({ categories }: CategoriesBarChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<ChartType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function initChart() {
|
||||
if (!ref.current || !categories) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { default: Chart } = await import("chart.js/auto");
|
||||
if (!isMounted) return;
|
||||
|
||||
if (chartRef.current) chartRef.current.destroy();
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: Object.keys(categories),
|
||||
datasets: [
|
||||
{
|
||||
label: "Categories",
|
||||
data: Object.values(categories),
|
||||
backgroundColor: "rgba(59, 130, 246, 0.7)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
initChart();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [categories]);
|
||||
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
|
||||
function SentimentChartInner({ sentimentData }: SentimentChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<ChartType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function initChart() {
|
||||
if (!ref.current || !sentimentData) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { default: Chart } = await import("chart.js/auto");
|
||||
if (!isMounted) return;
|
||||
|
||||
if (chartRef.current) chartRef.current.destroy();
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: ["Positive", "Neutral", "Negative"],
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
sentimentData.positive,
|
||||
sentimentData.neutral,
|
||||
sentimentData.negative,
|
||||
],
|
||||
backgroundColor: [
|
||||
"rgba(34, 197, 94, 0.8)",
|
||||
"rgba(249, 115, 22, 0.8)",
|
||||
"rgba(239, 68, 68, 0.8)",
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.label || "";
|
||||
const value = context.formattedValue || "";
|
||||
const index = context.dataIndex;
|
||||
const originalIsoCode = isoCodes[index]; // Get the original code
|
||||
cutout: "65%",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Only show ISO code if it's not "Other"
|
||||
// and it's a valid 2-letter code (check lowercase version)
|
||||
if (
|
||||
originalIsoCode &&
|
||||
originalIsoCode !== "Other" &&
|
||||
/^[a-z]{2}$/.test(originalIsoCode.toLowerCase())
|
||||
) {
|
||||
return `${label} (${originalIsoCode.toUpperCase()}): ${value}`;
|
||||
}
|
||||
initChart();
|
||||
|
||||
return `${label}: ${value}`;
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sentimentData]);
|
||||
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
|
||||
function LanguagePieChartInner({ languages }: LanguagePieChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<ChartType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function initChart() {
|
||||
if (!ref.current || !languages) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { default: Chart } = await import("chart.js/auto");
|
||||
if (!isMounted) return;
|
||||
|
||||
// Get top 5 languages, combine others
|
||||
const entries = Object.entries(languages);
|
||||
const topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
|
||||
const otherCount = entries
|
||||
.slice(5)
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
if (otherCount > 0) {
|
||||
topLanguages.push(["Other", otherCount]);
|
||||
}
|
||||
|
||||
const isoCodes = topLanguages.map(([lang]) => lang);
|
||||
|
||||
const labels = topLanguages.map(([lang]) => {
|
||||
if (lang === "Other") return "Other";
|
||||
return getLocalizedLanguageName(lang, "en");
|
||||
});
|
||||
|
||||
const data = topLanguages.map(([, count]) => count);
|
||||
|
||||
if (chartRef.current) chartRef.current.destroy();
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: "pie",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: [
|
||||
"rgba(59, 130, 246, 0.8)",
|
||||
"rgba(16, 185, 129, 0.8)",
|
||||
"rgba(249, 115, 22, 0.8)",
|
||||
"rgba(236, 72, 153, 0.8)",
|
||||
"rgba(139, 92, 246, 0.8)",
|
||||
"rgba(107, 114, 128, 0.8)",
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.label || "";
|
||||
const value = context.formattedValue || "";
|
||||
const index = context.dataIndex;
|
||||
const originalIsoCode = isoCodes[index];
|
||||
|
||||
if (
|
||||
originalIsoCode &&
|
||||
originalIsoCode !== "Other" &&
|
||||
/^[a-z]{2}$/.test(originalIsoCode.toLowerCase())
|
||||
) {
|
||||
return `${label} (${originalIsoCode.toUpperCase()}): ${value}`;
|
||||
}
|
||||
|
||||
return `${label}: ${value}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => chart.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
initChart();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [languages]);
|
||||
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
|
||||
export function TokenUsageChart({ tokenData }: TokenUsageChartProps) {
|
||||
function TokenUsageChartInner({ tokenData }: TokenUsageChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current || !tokenData) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const chartRef = useRef<ChartType | null>(null);
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: tokenData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Tokens",
|
||||
data: tokenData.values,
|
||||
backgroundColor: "rgba(59, 130, 246, 0.7)",
|
||||
borderWidth: 1,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "Cost (EUR)",
|
||||
data: tokenData.costs,
|
||||
backgroundColor: "rgba(16, 185, 129, 0.7)",
|
||||
borderWidth: 1,
|
||||
type: "line",
|
||||
yAxisID: "y1",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: true } },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
position: "left",
|
||||
title: {
|
||||
display: true,
|
||||
text: "Token Count",
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function initChart() {
|
||||
if (!ref.current || !tokenData) return;
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { default: Chart } = await import("chart.js/auto");
|
||||
if (!isMounted) return;
|
||||
|
||||
if (chartRef.current) chartRef.current.destroy();
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: tokenData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Tokens",
|
||||
data: tokenData.values,
|
||||
backgroundColor: "rgba(59, 130, 246, 0.7)",
|
||||
borderWidth: 1,
|
||||
yAxisID: "y",
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
beginAtZero: true,
|
||||
position: "right",
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
{
|
||||
label: "Cost (EUR)",
|
||||
data: tokenData.costs,
|
||||
backgroundColor: "rgba(16, 185, 129, 0.7)",
|
||||
borderWidth: 1,
|
||||
type: "line",
|
||||
yAxisID: "y1",
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "Cost (EUR)",
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: true } },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
position: "left",
|
||||
title: {
|
||||
display: true,
|
||||
text: "Token Count",
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
beginAtZero: true,
|
||||
position: "right",
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "Cost (EUR)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => chart.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
initChart();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [tokenData]);
|
||||
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
|
||||
// Dynamic exports with SSR disabled
|
||||
export const SessionsLineChart = dynamic(
|
||||
() => Promise.resolve(SessionsLineChartInner),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton height={180} />,
|
||||
}
|
||||
);
|
||||
|
||||
export const CategoriesBarChart = dynamic(
|
||||
() => Promise.resolve(CategoriesBarChartInner),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton height={180} />,
|
||||
}
|
||||
);
|
||||
|
||||
export const SentimentChart = dynamic(
|
||||
() => Promise.resolve(SentimentChartInner),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <DonutChartSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
export const LanguagePieChart = dynamic(
|
||||
() => Promise.resolve(LanguagePieChartInner),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <DonutChartSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
export const TokenUsageChart = dynamic(
|
||||
() => Promise.resolve(TokenUsageChartInner),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton height={180} />,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Chart, { type BubbleDataPoint, type Point } from "chart.js/auto";
|
||||
import type { BubbleDataPoint, Chart as ChartType, Point } from "chart.js";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface DonutChartProps {
|
||||
@@ -15,141 +16,180 @@ interface DonutChartProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default function DonutChart({ data, centerText }: DonutChartProps) {
|
||||
function DonutChartSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<div className="animate-pulse flex items-center gap-8">
|
||||
<div className="w-40 h-40 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="w-20 h-3 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DonutChartInner({ data, centerText }: DonutChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<ChartType<"doughnut"> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !data.values.length) return;
|
||||
let isMounted = true;
|
||||
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
async function initChart() {
|
||||
if (!ref.current || !data.values.length) return;
|
||||
|
||||
// Default colors if not provided
|
||||
const defaultColors: string[] = [
|
||||
"rgba(59, 130, 246, 0.8)", // blue
|
||||
"rgba(16, 185, 129, 0.8)", // green
|
||||
"rgba(249, 115, 22, 0.8)", // orange
|
||||
"rgba(236, 72, 153, 0.8)", // pink
|
||||
"rgba(139, 92, 246, 0.8)", // purple
|
||||
"rgba(107, 114, 128, 0.8)", // gray
|
||||
];
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const colors: string[] = data.colors || defaultColors;
|
||||
const { default: Chart } = await import("chart.js/auto");
|
||||
|
||||
// Helper to create an array of colors based on the data length
|
||||
const getColors = () => {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < data.values.length; i++) {
|
||||
result.push(colors[i % colors.length]);
|
||||
if (!isMounted) return;
|
||||
|
||||
// Default colors if not provided
|
||||
const defaultColors: string[] = [
|
||||
"rgba(59, 130, 246, 0.8)", // blue
|
||||
"rgba(16, 185, 129, 0.8)", // green
|
||||
"rgba(249, 115, 22, 0.8)", // orange
|
||||
"rgba(236, 72, 153, 0.8)", // pink
|
||||
"rgba(139, 92, 246, 0.8)", // purple
|
||||
"rgba(107, 114, 128, 0.8)", // gray
|
||||
];
|
||||
|
||||
const colors: string[] = data.colors || defaultColors;
|
||||
|
||||
// Helper to create an array of colors based on the data length
|
||||
const getColors = () => {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < data.values.length; i++) {
|
||||
result.push(colors[i % colors.length]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Destroy existing chart if any
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data.values,
|
||||
backgroundColor: getColors(),
|
||||
borderWidth: 1,
|
||||
hoverOffset: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: "70%",
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data.values,
|
||||
backgroundColor: getColors(),
|
||||
borderWidth: 1,
|
||||
hoverOffset: 5,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.label || "";
|
||||
const value = context.formattedValue;
|
||||
const total = context.chart.data.datasets[0].data.reduce(
|
||||
(
|
||||
a: number,
|
||||
b:
|
||||
| number
|
||||
| Point
|
||||
| [number, number]
|
||||
| BubbleDataPoint
|
||||
| null
|
||||
) => {
|
||||
if (typeof b === "number") {
|
||||
return a + b;
|
||||
}
|
||||
// Handle other types like Point, [number, number], BubbleDataPoint if necessary
|
||||
// For now, we'll assume they don't contribute to the sum or are handled elsewhere
|
||||
return a;
|
||||
},
|
||||
0
|
||||
) as number;
|
||||
const percentage = Math.round((context.parsed * 100) / total);
|
||||
return `${label}: ${value} (${percentage}%)`;
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: "70%",
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.label || "";
|
||||
const value = context.formattedValue;
|
||||
const total = context.chart.data.datasets[0].data.reduce(
|
||||
(
|
||||
a: number,
|
||||
b:
|
||||
| number
|
||||
| Point
|
||||
| [number, number]
|
||||
| BubbleDataPoint
|
||||
| null
|
||||
) => {
|
||||
if (typeof b === "number") {
|
||||
return a + b;
|
||||
}
|
||||
return a;
|
||||
},
|
||||
0
|
||||
) as number;
|
||||
const percentage = Math.round((context.parsed * 100) / total);
|
||||
return `${label}: ${value} (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: centerText
|
||||
? [
|
||||
{
|
||||
id: "centerText",
|
||||
beforeDraw: (chart: Chart<"doughnut">) => {
|
||||
const height = chart.height;
|
||||
const ctx = chart.ctx;
|
||||
ctx.restore();
|
||||
plugins: centerText
|
||||
? [
|
||||
{
|
||||
id: "centerText",
|
||||
beforeDraw: (chart: ChartType<"doughnut">) => {
|
||||
const height = chart.height;
|
||||
const ctx = chart.ctx;
|
||||
ctx.restore();
|
||||
|
||||
// Calculate the actual chart area width (excluding legend)
|
||||
// Legend is positioned on the right, so we adjust the center X coordinate
|
||||
const chartArea = chart.chartArea;
|
||||
const chartWidth = chartArea.right - chartArea.left;
|
||||
const chartArea = chart.chartArea;
|
||||
const chartWidth = chartArea.right - chartArea.left;
|
||||
const centerX = chartArea.left + chartWidth / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Get the center of just the chart area (not including the legend)
|
||||
const centerX = chartArea.left + chartWidth / 2;
|
||||
const centerY = height / 2;
|
||||
if (centerText.title) {
|
||||
ctx.font = "1rem sans-serif";
|
||||
ctx.fillStyle = "#6B7280";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(centerText.title, centerX, centerY - 10);
|
||||
}
|
||||
|
||||
// Title text
|
||||
if (centerText.title) {
|
||||
ctx.font = "1rem sans-serif"; // Consistent font
|
||||
ctx.fillStyle = "#6B7280"; // Tailwind gray-500
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle"; // Align vertically
|
||||
ctx.fillText(centerText.title, centerX, centerY - 10); // Adjust Y offset
|
||||
}
|
||||
|
||||
// Value text
|
||||
if (centerText.value !== undefined) {
|
||||
ctx.font = "bold 1.5rem sans-serif"; // Consistent font, larger
|
||||
ctx.fillStyle = "#1F2937"; // Tailwind gray-800
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle"; // Align vertically
|
||||
ctx.fillText(
|
||||
centerText.value.toString(),
|
||||
centerX,
|
||||
centerY + 15
|
||||
); // Adjust Y offset
|
||||
}
|
||||
ctx.save();
|
||||
if (centerText.value !== undefined) {
|
||||
ctx.font = "bold 1.5rem sans-serif";
|
||||
ctx.fillStyle = "#1F2937";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(
|
||||
centerText.value.toString(),
|
||||
centerX,
|
||||
centerY + 15
|
||||
);
|
||||
}
|
||||
ctx.save();
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
return () => chart.destroy();
|
||||
initChart();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [data, centerText]);
|
||||
|
||||
return <canvas ref={ref} height={300} />;
|
||||
}
|
||||
|
||||
const DonutChart = dynamic(() => Promise.resolve(DonutChartInner), {
|
||||
ssr: false,
|
||||
loading: () => <DonutChartSkeleton />,
|
||||
});
|
||||
|
||||
export default DonutChart;
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
interface ResponseTimeDistributionProps {
|
||||
data: number[];
|
||||
@@ -17,35 +8,35 @@ interface ResponseTimeDistributionProps {
|
||||
targetResponseTime?: number;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; payload: { label: string; count: number } }>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{payload[0].value}
|
||||
</span>{" "}
|
||||
responses
|
||||
</p>
|
||||
const ChartSkeleton = () => (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<div className="animate-pulse flex flex-col items-center gap-2">
|
||||
<div className="h-48 w-full bg-muted rounded-md flex items-end justify-around px-4 pb-4">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-muted-foreground/20 rounded-t"
|
||||
style={{ width: "8%", height: `${20 + Math.random() * 60}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ResponseTimeDistribution({
|
||||
data,
|
||||
average,
|
||||
targetResponseTime,
|
||||
}: ResponseTimeDistributionProps) {
|
||||
if (!data || !data.length) {
|
||||
const ResponseTimeDistributionChart = dynamic(
|
||||
() => import("./ResponseTimeDistributionChart"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
export default function ResponseTimeDistribution(
|
||||
props: ResponseTimeDistributionProps
|
||||
) {
|
||||
if (!props.data || !props.data.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No response time data available
|
||||
@@ -53,121 +44,5 @@ export default function ResponseTimeDistribution({
|
||||
);
|
||||
}
|
||||
|
||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||
const maxTime = Math.ceil(Math.max(...data));
|
||||
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||
|
||||
// Count responses in each bin
|
||||
data.forEach((time) => {
|
||||
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||
bins[binIndex]++;
|
||||
});
|
||||
|
||||
// Create chart data
|
||||
const chartData = bins.map((count, i) => {
|
||||
let label: string;
|
||||
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
||||
label = `${i}+ sec`;
|
||||
} else {
|
||||
label = `${i}-${i + 1} sec`;
|
||||
}
|
||||
|
||||
// Determine color based on response time
|
||||
let color: string;
|
||||
if (i <= 2)
|
||||
color = "hsl(var(--chart-1))"; // Green for fast
|
||||
else if (i <= 5)
|
||||
color = "hsl(var(--chart-4))"; // Yellow for medium
|
||||
else color = "hsl(var(--chart-3))"; // Red for slow
|
||||
|
||||
return {
|
||||
name: label,
|
||||
value: count,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{
|
||||
value: "Number of Responses",
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
style: { textAnchor: "middle" },
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={[4, 4, 0, 0]}
|
||||
fill="hsl(var(--chart-1))"
|
||||
maxBarSize={60}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Bar key={`cell-${entry.name}-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
|
||||
{/* Average line */}
|
||||
<ReferenceLine
|
||||
x={Math.floor(average)}
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${average.toFixed(1)}s`,
|
||||
position: "top" as const,
|
||||
style: {
|
||||
fill: "hsl(var(--primary))",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Target line (if provided) */}
|
||||
{targetResponseTime && (
|
||||
<ReferenceLine
|
||||
x={Math.floor(targetResponseTime)}
|
||||
stroke="hsl(var(--chart-2))"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: `Target: ${targetResponseTime}s`,
|
||||
position: "top" as const,
|
||||
style: {
|
||||
fill: "hsl(var(--chart-2))",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
return <ResponseTimeDistributionChart {...props} />;
|
||||
}
|
||||
|
||||
165
components/ResponseTimeDistributionChart.tsx
Normal file
165
components/ResponseTimeDistributionChart.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface ResponseTimeDistributionChartProps {
|
||||
data: number[];
|
||||
average: number;
|
||||
targetResponseTime?: number;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; payload: { label: string; count: number } }>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{payload[0].value}
|
||||
</span>{" "}
|
||||
responses
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function ResponseTimeDistributionChart({
|
||||
data,
|
||||
average,
|
||||
targetResponseTime,
|
||||
}: ResponseTimeDistributionChartProps) {
|
||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||
const maxTime = Math.ceil(Math.max(...data));
|
||||
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||
|
||||
// Count responses in each bin
|
||||
data.forEach((time) => {
|
||||
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||
bins[binIndex]++;
|
||||
});
|
||||
|
||||
// Create chart data
|
||||
const chartData = bins.map((count, i) => {
|
||||
let label: string;
|
||||
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
||||
label = `${i}+ sec`;
|
||||
} else {
|
||||
label = `${i}-${i + 1} sec`;
|
||||
}
|
||||
|
||||
// Determine color based on response time
|
||||
let color: string;
|
||||
if (i <= 2)
|
||||
color = "hsl(var(--chart-1))"; // Green for fast
|
||||
else if (i <= 5)
|
||||
color = "hsl(var(--chart-4))"; // Yellow for medium
|
||||
else color = "hsl(var(--chart-3))"; // Red for slow
|
||||
|
||||
return {
|
||||
name: label,
|
||||
value: count,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{
|
||||
value: "Number of Responses",
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
style: { textAnchor: "middle" },
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={[4, 4, 0, 0]}
|
||||
fill="hsl(var(--chart-1))"
|
||||
maxBarSize={60}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Bar key={`cell-${entry.name}-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
|
||||
{/* Average line */}
|
||||
<ReferenceLine
|
||||
x={Math.floor(average)}
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${average.toFixed(1)}s`,
|
||||
position: "top" as const,
|
||||
style: {
|
||||
fill: "hsl(var(--primary))",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Target line (if provided) */}
|
||||
{targetResponseTime && (
|
||||
<ReferenceLine
|
||||
x={Math.floor(targetResponseTime)}
|
||||
stroke="hsl(var(--chart-2))"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: `Target: ${targetResponseTime}s`,
|
||||
position: "top" as const,
|
||||
style: {
|
||||
fill: "hsl(var(--chart-2))",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
components/charts/bar-chart-inner.tsx
Normal file
97
components/charts/bar-chart-inner.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface BarChartData {
|
||||
name: string;
|
||||
value: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
interface BarChartInnerProps {
|
||||
data: BarChartData[];
|
||||
dataKey: string;
|
||||
colors: string[];
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name?: string }>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{payload[0].value}
|
||||
</span>{" "}
|
||||
sessions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function BarChartInner({
|
||||
data,
|
||||
dataKey,
|
||||
colors,
|
||||
height,
|
||||
}: BarChartInnerProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey={dataKey}
|
||||
radius={[4, 4, 0, 0]}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${entry.name}-${index}`}
|
||||
fill={colors[index % colors.length]}
|
||||
className="hover:opacity-80"
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface BarChartData {
|
||||
@@ -27,28 +18,24 @@ interface BarChartProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name?: string }>;
|
||||
label?: string;
|
||||
}
|
||||
const ChartSkeleton = ({ height = 300 }: { height?: number }) => (
|
||||
<div className="animate-pulse" style={{ height }}>
|
||||
<div className="h-full w-full bg-muted rounded-md flex items-end justify-around px-4 pb-8">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-muted-foreground/20 rounded-t"
|
||||
style={{ width: "12%", height: `${20 + Math.random() * 60}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{payload[0].value}
|
||||
</span>{" "}
|
||||
sessions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const BarChartInner = dynamic(() => import("./bar-chart-inner"), {
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton />,
|
||||
});
|
||||
|
||||
export default function ModernBarChart({
|
||||
data,
|
||||
@@ -72,48 +59,12 @@ export default function ModernBarChart({
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey={dataKey}
|
||||
radius={[4, 4, 0, 0]}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${entry.name}-${index}`}
|
||||
fill={colors[index % colors.length]}
|
||||
className="hover:opacity-80"
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<BarChartInner
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
colors={colors}
|
||||
height={height}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
146
components/charts/donut-chart-inner.tsx
Normal file
146
components/charts/donut-chart-inner.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
interface DonutChartInnerProps {
|
||||
data: Array<{ name: string; value: number; color?: string }>;
|
||||
title?: string;
|
||||
centerText?: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
};
|
||||
colors: string[];
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
payload: { total: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{data.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{data.value}</span>{" "}
|
||||
sessions ({((data.value / data.payload.total) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
interface LegendProps {
|
||||
payload?: Array<{
|
||||
value: string;
|
||||
color: string;
|
||||
type?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const CustomLegend = ({ payload }: LegendProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 mt-4">
|
||||
{payload?.map((entry, index) => (
|
||||
<div
|
||||
key={`legend-${entry.value}-${index}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CenterLabelProps {
|
||||
centerText?: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
const CenterLabel = ({ centerText }: CenterLabelProps) => {
|
||||
if (!centerText) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{centerText.value}</p>
|
||||
<p className="text-sm text-muted-foreground">{centerText.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DonutChartInner({
|
||||
data,
|
||||
title,
|
||||
centerText,
|
||||
colors,
|
||||
height,
|
||||
}: DonutChartInnerProps) {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||
const dataWithTotal = data.map((item) => ({ ...item, total }));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
role="img"
|
||||
aria-label={`${title || "Chart"} - ${data.length} segments`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={dataWithTotal}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
className="transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{dataWithTotal.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${entry.name}-${index}`}
|
||||
fill={entry.color || colors[index % colors.length]}
|
||||
className="hover:opacity-80 cursor-pointer focus:opacity-80"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<CenterLabel centerText={centerText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface DonutChartProps {
|
||||
@@ -22,78 +15,25 @@ interface DonutChartProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
payload: { total: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{data.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{data.value}</span>{" "}
|
||||
sessions ({((data.value / data.payload.total) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
interface LegendProps {
|
||||
payload?: Array<{
|
||||
value: string;
|
||||
color: string;
|
||||
type?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const CustomLegend = ({ payload }: LegendProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 mt-4">
|
||||
{payload?.map((entry, index) => (
|
||||
<div
|
||||
key={`legend-${entry.value}-${index}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
const ChartSkeleton = ({ height = 300 }: { height?: number }) => (
|
||||
<div className="animate-pulse relative" style={{ height }}>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="rounded-full bg-muted-foreground/20"
|
||||
style={{ width: "200px", height: "200px" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute rounded-full bg-background"
|
||||
style={{ width: "120px", height: "120px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CenterLabelProps {
|
||||
centerText?: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
};
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CenterLabel = ({ centerText }: CenterLabelProps) => {
|
||||
if (!centerText) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{centerText.value}</p>
|
||||
<p className="text-sm text-muted-foreground">{centerText.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const DonutChartInner = dynamic(() => import("./donut-chart-inner"), {
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton />,
|
||||
});
|
||||
|
||||
export default function ModernDonutChart({
|
||||
data,
|
||||
@@ -109,9 +49,6 @@ export default function ModernDonutChart({
|
||||
height = 300,
|
||||
className,
|
||||
}: DonutChartProps) {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||
const dataWithTotal = data.map((item) => ({ ...item, total }));
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
{title && (
|
||||
@@ -120,45 +57,13 @@ export default function ModernDonutChart({
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<div
|
||||
className="relative"
|
||||
role="img"
|
||||
aria-label={`${title || "Chart"} - ${data.length} segments`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={dataWithTotal}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
className="transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{dataWithTotal.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${entry.name}-${index}`}
|
||||
fill={entry.color || colors[index % colors.length]}
|
||||
className="hover:opacity-80 cursor-pointer focus:opacity-80"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<CenterLabel centerText={centerText} total={total} />
|
||||
</div>
|
||||
<DonutChartInner
|
||||
data={data}
|
||||
title={title}
|
||||
centerText={centerText}
|
||||
colors={colors}
|
||||
height={height}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
120
components/charts/line-chart-inner.tsx
Normal file
120
components/charts/line-chart-inner.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface LineChartData {
|
||||
date: string;
|
||||
value: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
interface LineChartInnerProps {
|
||||
data: LineChartData[];
|
||||
dataKey: string;
|
||||
color: string;
|
||||
gradient: boolean;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name?: string }>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{payload[0].value}
|
||||
</span>{" "}
|
||||
sessions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function LineChartInner({
|
||||
data,
|
||||
dataKey,
|
||||
color,
|
||||
gradient,
|
||||
height,
|
||||
}: LineChartInnerProps) {
|
||||
const gradientId = useId();
|
||||
const ChartComponent = gradient ? AreaChart : LineChart;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ChartComponent
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
{gradient && (
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
)}
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
{gradient ? (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
||||
/>
|
||||
)}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface LineChartData {
|
||||
@@ -30,28 +19,26 @@ interface LineChartProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name?: string }>;
|
||||
label?: string;
|
||||
}
|
||||
const ChartSkeleton = ({ height = 300 }: { height?: number }) => (
|
||||
<div className="animate-pulse" style={{ height }}>
|
||||
<div className="h-full w-full bg-muted rounded-md relative overflow-hidden">
|
||||
<svg className="w-full h-full" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,150 Q50,100 100,120 T200,80 T300,100 T400,60 T500,90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-muted-foreground/20"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{payload[0].value}
|
||||
</span>{" "}
|
||||
sessions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const LineChartInner = dynamic(() => import("./line-chart-inner"), {
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton />,
|
||||
});
|
||||
|
||||
export default function ModernLineChart({
|
||||
data,
|
||||
@@ -62,9 +49,6 @@ export default function ModernLineChart({
|
||||
height = 300,
|
||||
className,
|
||||
}: LineChartProps) {
|
||||
const gradientId = useId();
|
||||
const ChartComponent = gradient ? AreaChart : LineChart;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
{title && (
|
||||
@@ -73,61 +57,13 @@ export default function ModernLineChart({
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ChartComponent
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
{gradient && (
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
)}
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
{gradient ? (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
|
||||
/>
|
||||
)}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
<LineChartInner
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
color={color}
|
||||
gradient={gradient}
|
||||
height={height}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Pointer({
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: PointerProps): JSX.Element {
|
||||
}: PointerProps): React.ReactElement {
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ThemeProviderProps } from "next-themes";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@@ -56,4 +56,9 @@ function Button({
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
type ButtonProps = React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
|
||||
@@ -14,11 +14,9 @@ import {
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CalendarRootProps {
|
||||
className?: string;
|
||||
type CalendarRootProps = {
|
||||
rootRef?: React.Ref<HTMLDivElement>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const CalendarRoot = ({ className, rootRef, ...props }: CalendarRootProps) => {
|
||||
return (
|
||||
@@ -33,13 +31,14 @@ const CalendarRoot = ({ className, rootRef, ...props }: CalendarRootProps) => {
|
||||
|
||||
interface CalendarChevronProps {
|
||||
className?: string;
|
||||
orientation: "left" | "right" | "up" | "down";
|
||||
[key: string]: unknown;
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
orientation?: "left" | "right" | "up" | "down";
|
||||
}
|
||||
|
||||
const CalendarChevron = ({
|
||||
className,
|
||||
orientation,
|
||||
orientation = "down",
|
||||
...props
|
||||
}: CalendarChevronProps) => {
|
||||
if (orientation === "left") {
|
||||
@@ -59,19 +58,20 @@ const CalendarChevron = ({
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
};
|
||||
|
||||
interface CalendarWeekNumberProps {
|
||||
children: React.ReactNode;
|
||||
[key: string]: unknown;
|
||||
interface CalendarWeekNumberProps
|
||||
extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
||||
week: { weekNumber: number };
|
||||
}
|
||||
|
||||
const CalendarWeekNumber = ({
|
||||
week,
|
||||
children,
|
||||
...props
|
||||
}: CalendarWeekNumberProps) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-9 items-center justify-center p-0 text-sm">
|
||||
{children}
|
||||
{children ?? week.weekNumber}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user