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:
2026-01-20 07:05:27 +01:00
parent 5042a6c016
commit 8b3846539f
7 changed files with 298 additions and 288 deletions

View File

@@ -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>