mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-02-13 17:35:44 +01:00
- 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
477 lines
12 KiB
TypeScript
477 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import type { Chart as ChartType } from "chart.js";
|
|
import dynamic from "next/dynamic";
|
|
import { useEffect, useRef } from "react";
|
|
import { getLocalizedLanguageName } from "../lib/localization";
|
|
|
|
interface SessionsData {
|
|
[date: string]: number;
|
|
}
|
|
|
|
interface CategoriesData {
|
|
[category: string]: number;
|
|
}
|
|
|
|
interface LanguageData {
|
|
[language: string]: number;
|
|
}
|
|
|
|
interface SessionsLineChartProps {
|
|
sessionsPerDay: SessionsData;
|
|
}
|
|
|
|
interface CategoriesBarChartProps {
|
|
categories: CategoriesData;
|
|
}
|
|
|
|
interface LanguagePieChartProps {
|
|
languages: LanguageData;
|
|
}
|
|
|
|
interface SentimentChartProps {
|
|
sentimentData: {
|
|
positive: number;
|
|
neutral: number;
|
|
negative: number;
|
|
};
|
|
}
|
|
|
|
interface TokenUsageChartProps {
|
|
tokenData: {
|
|
labels: string[];
|
|
values: number[];
|
|
costs: number[];
|
|
};
|
|
}
|
|
|
|
// 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 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Inner components that do the actual rendering
|
|
function SessionsLineChartInner({ sessionsPerDay }: SessionsLineChartProps) {
|
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
const chartRef = useRef<ChartType | null>(null);
|
|
|
|
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,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: { y: { beginAtZero: true } },
|
|
},
|
|
});
|
|
}
|
|
|
|
initChart();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
if (chartRef.current) {
|
|
chartRef.current.destroy();
|
|
chartRef.current = null;
|
|
}
|
|
};
|
|
}, [sessionsPerDay]);
|
|
|
|
return <canvas ref={ref} height={180} />;
|
|
}
|
|
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
cutout: "65%",
|
|
},
|
|
});
|
|
}
|
|
|
|
initChart();
|
|
|
|
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}`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
initChart();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
if (chartRef.current) {
|
|
chartRef.current.destroy();
|
|
chartRef.current = null;
|
|
}
|
|
};
|
|
}, [languages]);
|
|
|
|
return <canvas ref={ref} height={180} />;
|
|
}
|
|
|
|
function TokenUsageChartInner({ tokenData }: TokenUsageChartProps) {
|
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
const chartRef = useRef<ChartType | null>(null);
|
|
|
|
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",
|
|
},
|
|
{
|
|
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",
|
|
},
|
|
},
|
|
y1: {
|
|
beginAtZero: true,
|
|
position: "right",
|
|
grid: {
|
|
drawOnChartArea: false,
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: "Cost (EUR)",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
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} />,
|
|
}
|
|
);
|