mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-02-13 16:35:44 +01:00
refactor: apply React best practices and migrate to ESLint CLI
- Dynamic import for WordCloud component (code splitting) - Fix waterfall in handleRefresh with Promise.all - Memoize data prep functions in overview page (7 useMemo hooks) - Replace useState+useEffect with useMemo in CountryDisplay/LanguageDisplay - Memoize navigationCards and class getters in dashboard page - Extract inline handlers with useCallback - Fix missing index parameter in TopQuestionsChart map - Migrate from next lint to ESLint CLI (Next.js 16 deprecation)
This commit is contained in:
@@ -14,9 +14,10 @@ import {
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -35,8 +36,17 @@ import GeographicMap from "../../../components/GeographicMap";
|
||||
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
||||
import MetricCard from "../../../components/ui/metric-card";
|
||||
import WordCloud from "../../../components/WordCloud";
|
||||
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
||||
import type { Company, MetricsResult } from "../../../lib/types";
|
||||
|
||||
// Dynamic import for heavy D3-based WordCloud component (~50KB)
|
||||
const WordCloud = dynamic(() => import("../../../components/WordCloud"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
// Safely wrapped component with useSession
|
||||
function DashboardContent() {
|
||||
@@ -93,35 +103,123 @@ function DashboardContent() {
|
||||
}
|
||||
}, [status, router, isInitialLoad, fetchMetrics]);
|
||||
|
||||
async function handleRefresh() {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (isAuditor) return;
|
||||
if (!company?.id) {
|
||||
alert("Cannot refresh: Company ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
setRefreshing(true);
|
||||
// Start both requests in parallel - metrics will be ready when refresh completes
|
||||
const [refreshRes, metricsRes] = await Promise.all([
|
||||
fetch("/api/admin/refresh-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
}),
|
||||
fetch("/api/dashboard/metrics"),
|
||||
]);
|
||||
|
||||
if (!company?.id) {
|
||||
setRefreshing(false);
|
||||
alert("Cannot refresh: Company ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/admin/refresh-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const metricsRes = await fetch("/api/dashboard/metrics");
|
||||
if (refreshRes.ok) {
|
||||
const data = await metricsRes.json();
|
||||
setMetrics(data.metrics);
|
||||
} else {
|
||||
const errorData = await res.json();
|
||||
const errorData = await refreshRes.json();
|
||||
alert(`Failed to refresh sessions: ${errorData.error}`);
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [isAuditor, company?.id]);
|
||||
|
||||
// Memoized data preparation - must be before any early returns (Rules of Hooks)
|
||||
const sentimentData = useMemo(() => {
|
||||
if (!metrics) return [];
|
||||
return [
|
||||
{
|
||||
name: "Positive",
|
||||
value: metrics.sentimentPositiveCount ?? 0,
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
{
|
||||
name: "Neutral",
|
||||
value: metrics.sentimentNeutralCount ?? 0,
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
{
|
||||
name: "Negative",
|
||||
value: metrics.sentimentNegativeCount ?? 0,
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
];
|
||||
}, [metrics]);
|
||||
|
||||
const sessionsOverTimeData = useMemo(() => {
|
||||
if (!metrics?.days) return [];
|
||||
return Object.entries(metrics.days).map(([date, value]) => ({
|
||||
date: new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
value: value as number,
|
||||
}));
|
||||
}, [metrics?.days]);
|
||||
|
||||
const categoriesData = useMemo(() => {
|
||||
if (!metrics?.categories) return [];
|
||||
return Object.entries(metrics.categories).map(([name, value]) => {
|
||||
const formattedName = formatEnumValue(name) || name;
|
||||
return {
|
||||
name:
|
||||
formattedName.length > 15
|
||||
? `${formattedName.substring(0, 15)}...`
|
||||
: formattedName,
|
||||
value: value as number,
|
||||
};
|
||||
});
|
||||
}, [metrics?.categories]);
|
||||
|
||||
const languagesData = useMemo(() => {
|
||||
if (!metrics?.languages) return [];
|
||||
return Object.entries(metrics.languages).map(([name, value]) => ({
|
||||
name,
|
||||
value: value as number,
|
||||
}));
|
||||
}, [metrics?.languages]);
|
||||
|
||||
const wordCloudData = useMemo(
|
||||
() => metrics?.wordCloudData ?? [],
|
||||
[metrics?.wordCloudData]
|
||||
);
|
||||
|
||||
const countryData = useMemo(() => {
|
||||
if (!metrics?.countries) return {};
|
||||
return Object.entries(metrics.countries).reduce(
|
||||
(acc, [code, count]) => {
|
||||
if (code && count) {
|
||||
acc[code] = count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
}, [metrics?.countries]);
|
||||
|
||||
// Use seeded random for stable response time distribution
|
||||
const responseTimeData = useMemo(() => {
|
||||
const avgTime = metrics?.avgResponseTime || 1.5;
|
||||
const seed = avgTime * 1000;
|
||||
const seededRandom = (i: number) => {
|
||||
const x = Math.sin(seed + i) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
return Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => avgTime * (0.5 + seededRandom(i))
|
||||
);
|
||||
}, [metrics?.avgResponseTime]);
|
||||
|
||||
// Show loading state while session status is being determined
|
||||
if (status === "loading") {
|
||||
@@ -211,101 +309,6 @@ function DashboardContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Data preparation functions
|
||||
const getSentimentData = () => {
|
||||
if (!metrics) return [];
|
||||
|
||||
const sentimentData = {
|
||||
positive: metrics.sentimentPositiveCount ?? 0,
|
||||
neutral: metrics.sentimentNeutralCount ?? 0,
|
||||
negative: metrics.sentimentNegativeCount ?? 0,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: "Positive",
|
||||
value: sentimentData.positive,
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
{
|
||||
name: "Neutral",
|
||||
value: sentimentData.neutral,
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
{
|
||||
name: "Negative",
|
||||
value: sentimentData.negative,
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getSessionsOverTimeData = () => {
|
||||
if (!metrics?.days) return [];
|
||||
|
||||
return Object.entries(metrics.days).map(([date, value]) => ({
|
||||
date: new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
value: value as number,
|
||||
}));
|
||||
};
|
||||
|
||||
const getCategoriesData = () => {
|
||||
if (!metrics?.categories) return [];
|
||||
|
||||
return Object.entries(metrics.categories).map(([name, value]) => {
|
||||
const formattedName = formatEnumValue(name) || name;
|
||||
return {
|
||||
name:
|
||||
formattedName.length > 15
|
||||
? `${formattedName.substring(0, 15)}...`
|
||||
: formattedName,
|
||||
value: value as number,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getLanguagesData = () => {
|
||||
if (!metrics?.languages) return [];
|
||||
|
||||
return Object.entries(metrics.languages).map(([name, value]) => ({
|
||||
name,
|
||||
value: value as number,
|
||||
}));
|
||||
};
|
||||
|
||||
const getWordCloudData = (): WordCloudWord[] => {
|
||||
if (!metrics?.wordCloudData) return [];
|
||||
return metrics.wordCloudData;
|
||||
};
|
||||
|
||||
const getCountryData = () => {
|
||||
if (!metrics?.countries) return {};
|
||||
return Object.entries(metrics.countries).reduce(
|
||||
(acc, [code, count]) => {
|
||||
if (code && count) {
|
||||
acc[code] = count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
};
|
||||
|
||||
const getResponseTimeData = () => {
|
||||
const avgTime = metrics.avgResponseTime || 1.5;
|
||||
const simulatedData: number[] = [];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const randomFactor = 0.5 + Math.random();
|
||||
simulatedData.push(avgTime * randomFactor);
|
||||
}
|
||||
|
||||
return simulatedData;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Modern Header */}
|
||||
@@ -475,14 +478,14 @@ function DashboardContent() {
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<ModernLineChart
|
||||
data={getSessionsOverTimeData()}
|
||||
data={sessionsOverTimeData}
|
||||
title="Sessions Over Time"
|
||||
className="lg:col-span-2"
|
||||
height={350}
|
||||
/>
|
||||
|
||||
<ModernDonutChart
|
||||
data={getSentimentData()}
|
||||
data={sentimentData}
|
||||
title="Conversation Sentiment"
|
||||
centerText={{
|
||||
title: "Total",
|
||||
@@ -494,13 +497,13 @@ function DashboardContent() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ModernBarChart
|
||||
data={getCategoriesData()}
|
||||
data={categoriesData}
|
||||
title="Sessions by Category"
|
||||
height={350}
|
||||
/>
|
||||
|
||||
<ModernDonutChart
|
||||
data={getLanguagesData()}
|
||||
data={languagesData}
|
||||
title="Languages Used"
|
||||
height={350}
|
||||
/>
|
||||
@@ -516,7 +519,7 @@ function DashboardContent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GeographicMap countries={getCountryData()} />
|
||||
<GeographicMap countries={countryData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -529,7 +532,7 @@ function DashboardContent() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<WordCloud words={getWordCloudData()} width={500} height={300} />
|
||||
<WordCloud words={wordCloudData} width={500} height={300} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -545,7 +548,7 @@ function DashboardContent() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponseTimeDistribution
|
||||
data={getResponseTimeData()}
|
||||
data={responseTimeData}
|
||||
average={metrics.avgResponseTime || 0}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user