Refactor components and enhance metrics calculations:

- Update access denied messages to use HTML entities.
- Add autoComplete attributes to forms for better user experience.
- Improve trend calculations in sessionMetrics function.
- Update MetricCard props to accept React nodes for icons.
- Integrate Next.js Image component in Sidebar for optimization.
- Adjust ESLint rules for better code quality.
- Add new properties for trends in MetricsResult interface.
- Bump version to 0.2.0 in package.json.
This commit is contained in:
2025-05-22 14:44:28 +02:00
parent e3134aa451
commit efb5261c7d
10 changed files with 172 additions and 88 deletions

View File

@ -77,7 +77,7 @@ export default function CompanySettingsPage() {
return (
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
<p>You don't have permission to view company settings.</p>
<p>You don&apos;t have permission to view company settings.</p>
</div>
);
}
@ -103,6 +103,7 @@ export default function CompanySettingsPage() {
e.preventDefault();
handleSave();
}}
autoComplete="off"
>
<div className="grid gap-2">
<label className="font-medium text-gray-700">
@ -114,6 +115,7 @@ export default function CompanySettingsPage() {
value={csvUrl}
onChange={(e) => setCsvUrl(e.target.value)}
placeholder="https://example.com/data.csv"
autoComplete="off"
/>
</div>
@ -125,6 +127,7 @@ export default function CompanySettingsPage() {
value={csvUsername}
onChange={(e) => setCsvUsername(e.target.value)}
placeholder="Username for CSV access (if needed)"
autoComplete="off"
/>
</div>
@ -136,6 +139,7 @@ export default function CompanySettingsPage() {
value={csvPassword}
onChange={(e) => setCsvPassword(e.target.value)}
placeholder="Password will be updated only if provided"
autoComplete="new-password"
/>
<p className="text-sm text-gray-500">
Leave blank to keep current password
@ -154,6 +158,7 @@ export default function CompanySettingsPage() {
placeholder="Threshold value (0-100)"
min="0"
max="100"
autoComplete="off"
/>
<p className="text-sm text-gray-500">
Percentage of negative sentiment sessions to trigger alert (0-100)

View File

@ -246,12 +246,12 @@ function DashboardContent() {
</svg>
}
trend={{
value: metrics.sessionTrend,
value: metrics.sessionTrend ?? 0,
label:
metrics.sessionTrend > 0
? `${metrics.sessionTrend}% increase`
: `${Math.abs(metrics.sessionTrend || 0)}% decrease`,
positive: metrics.sessionTrend >= 0,
(metrics.sessionTrend ?? 0) > 0
? `${metrics.sessionTrend ?? 0}% increase`
: `${Math.abs(metrics.sessionTrend ?? 0)}% decrease`,
isPositive: (metrics.sessionTrend ?? 0) >= 0,
}}
/>
<MetricCard
@ -274,12 +274,12 @@ function DashboardContent() {
</svg>
}
trend={{
value: metrics.usersTrend,
value: metrics.usersTrend ?? 0,
label:
metrics.usersTrend > 0
? `${metrics.usersTrend}% increase`
: `${Math.abs(metrics.usersTrend || 0)}% decrease`,
positive: metrics.usersTrend >= 0,
isPositive: metrics.usersTrend >= 0,
}}
/>
<MetricCard
@ -302,12 +302,12 @@ function DashboardContent() {
</svg>
}
trend={{
value: metrics.sessionTimeTrend,
value: metrics.sessionTimeTrend ?? 0,
label:
metrics.sessionTimeTrend > 0
? `${metrics.sessionTimeTrend}% increase`
: `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`,
positive: metrics.sessionTimeTrend >= 0,
isPositive: metrics.sessionTimeTrend >= 0,
}}
/>
<MetricCard
@ -330,12 +330,12 @@ function DashboardContent() {
</svg>
}
trend={{
value: metrics.responseTimeTrend,
value: metrics.avgResponseTimeTrend ?? 0,
label:
metrics.responseTimeTrend > 0
? `${metrics.responseTimeTrend}% increase`
: `${Math.abs(metrics.responseTimeTrend || 0)}% decrease`,
positive: metrics.responseTimeTrend <= 0, // Lower response time is better
(metrics.avgResponseTimeTrend ?? 0) > 0
? `${metrics.avgResponseTimeTrend ?? 0}% increase`
: `${Math.abs(metrics.avgResponseTimeTrend ?? 0)}% decrease`,
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
}}
/>
</div>
@ -345,7 +345,7 @@ function DashboardContent() {
<h3 className="font-bold text-lg text-gray-800 mb-4">
Sessions Over Time
</h3>
<SessionsLineChart sessions={metrics.sessionsByDate || {}} />
<SessionsLineChart sessionsPerDay={metrics.days} />
</div>
<div className="bg-white p-6 rounded-xl shadow">
<h3 className="font-bold text-lg text-gray-800 mb-4">

View File

@ -74,7 +74,7 @@ export default function UserManagementPage() {
return (
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
<p>You don't have permission to view user management.</p>
<p>You don&apos;t have permission to view user management.</p>
</div>
);
}
@ -102,6 +102,7 @@ export default function UserManagementPage() {
e.preventDefault();
inviteUser();
}}
autoComplete="off" // Disable autofill for the form
>
<div className="grid gap-2">
<label className="font-medium text-gray-700">Email</label>
@ -112,6 +113,7 @@ export default function UserManagementPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="off" // Disable autofill for this input
/>
</div>

View File

@ -44,7 +44,6 @@ const getCountryCoordinates = (): Record<string, [number, number]> => {
return coordinates;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error loading country coordinates:", error);
return coordinates;
}
@ -88,8 +87,6 @@ export default function GeographicMap({
.filter(([code]) => {
// If no coordinates found, log to help with debugging
if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) {
// eslint-disable-next-line no-console
console.warn(`Missing coordinates for country code: ${code}`);
return false;
}
return true;
@ -102,14 +99,12 @@ export default function GeographicMap({
}));
// Log for debugging
// eslint-disable-next-line no-console
console.log(
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
);
setCountryData(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error processing geographic data:", error);
setCountryData([]);
}

View File

@ -4,7 +4,7 @@ interface MetricCardProps {
title: string;
value: string | number | null | undefined;
description?: string;
icon?: string;
icon?: React.ReactNode;
trend?: {
value: number;
label?: string;

View File

@ -2,6 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import Image from "next/image"; // Import the Next.js Image component
import { usePathname } from "next/navigation";
import { signOut } from "next-auth/react";
@ -190,10 +191,13 @@ export default function Sidebar() {
<div
className={`relative ${isExpanded ? "w-16" : "w-10"} aspect-square mb-1`}
>
<img
<Image
src="/favicon.svg"
alt="LiveDash Logo"
className="w-full h-full transition-all duration-300"
fill
style={{ objectFit: "contain" }}
className="transition-all duration-300"
priority // Added priority prop for LCP optimization
/>
</div>
{isExpanded && (
@ -213,7 +217,7 @@ export default function Sidebar() {
<div
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
text-white bg-gray-800 z-50
invisible opacity-0 -translate-x-3 transition-all
invisible opacity-0 -translate-x-3 transition-all
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
>
{isExpanded ? "Collapse sidebar" : "Expand sidebar"}
@ -292,8 +296,8 @@ export default function Sidebar() {
) : (
<div
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
text-white bg-gray-800 z-50
invisible opacity-0 -translate-x-3 transition-all
text-white bg-gray-800 z-50
invisible opacity-0 -translate-x-3 transition-all
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
>
Logout

View File

@ -26,13 +26,13 @@ const eslintConfig = [
"coverage/",
],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": "warn",
"react/no-unescaped-entities": "off",
"no-console": "warn",
"no-trailing-spaces": "error",
"react/no-unescaped-entities": "warn",
"no-console": "off",
"no-trailing-spaces": "warn",
"prefer-const": "error",
"no-unused-vars": "off",
"no-unused-vars": "warn",
},
},
];

View File

@ -314,7 +314,7 @@ export function sessionMetrics(
const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {};
const byCountry: CountryMetrics = {}; // Added for country data
const byCountry: CountryMetrics = {};
const tokensByDay: DayMetrics = {};
const tokensCostByDay: DayMetrics = {};
@ -322,49 +322,68 @@ export function sessionMetrics(
forwarded = 0;
let totalSentiment = 0,
sentimentCount = 0;
let totalResponse = 0,
responseCount = 0;
let totalResponseTimeCurrent = 0, // Renamed to avoid conflict
responseCountCurrent = 0; // Renamed to avoid conflict
let totalTokens = 0,
totalTokensEur = 0;
// For sentiment distribution
let sentimentPositive = 0,
sentimentNegative = 0,
sentimentNeutral = 0;
// Calculate total session duration in minutes
let totalDuration = 0;
let durationCount = 0;
let totalDurationCurrent = 0, // Renamed to avoid conflict
durationCountCurrent = 0; // Renamed to avoid conflict
const wordCounts: { [key: string]: number } = {}; // For WordCloud
const wordCounts: { [key: string]: number } = {};
const uniqueUserIdsCurrent = new Set<string>();
let minDateCurrentPeriod = new Date();
if (sessions.length > 0) {
minDateCurrentPeriod = new Date(
Math.min(...sessions.map((s) => s.startTime.getTime()))
);
}
const prevPeriodEndDate = new Date(minDateCurrentPeriod);
prevPeriodEndDate.setDate(prevPeriodEndDate.getDate() - 1);
const prevPeriodStartDate = new Date(prevPeriodEndDate);
prevPeriodStartDate.setDate(prevPeriodStartDate.getDate() - 6); // 7-day previous period
let prevPeriodSessionsCount = 0;
const prevPeriodUniqueUserIds = new Set<string>();
let prevPeriodTotalDuration = 0;
let prevPeriodDurationCount = 0;
let prevPeriodTotalResponseTime = 0;
let prevPeriodResponseCount = 0;
sessions.forEach((s) => {
const day = s.startTime.toISOString().slice(0, 10);
const sessionDate = s.startTime;
const day = sessionDate.toISOString().slice(0, 10);
// Aggregate current period data
byDay[day] = (byDay[day] || 0) + 1;
if (s.userId) {
uniqueUserIdsCurrent.add(s.userId);
}
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1; // Populate byCountry
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1;
// Process token usage by day
if (s.tokens) {
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
}
// Process token cost by day
if (s.tokensEur) {
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
}
if (s.endTime) {
const duration =
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
// Sanity check: Only include sessions with reasonable durations (less than 24 hours)
const MAX_REASONABLE_DURATION = 24 * 60; // 24 hours in minutes
(s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60); // minutes
const MAX_REASONABLE_DURATION = 24 * 60;
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
totalDuration += duration;
durationCount++;
totalDurationCurrent += duration;
durationCountCurrent++;
}
}
@ -374,45 +393,98 @@ export function sessionMetrics(
if (s.sentiment != null) {
totalSentiment += s.sentiment;
sentimentCount++;
// Classify sentiment
if (s.sentiment > 0.3) {
sentimentPositive++;
} else if (s.sentiment < -0.3) {
sentimentNegative++;
} else {
sentimentNeutral++;
}
if (s.sentiment > 0.3) sentimentPositive++;
else if (s.sentiment < -0.3) sentimentNegative++;
else sentimentNeutral++;
}
if (s.avgResponseTime != null) {
totalResponse += s.avgResponseTime;
responseCount++;
totalResponseTimeCurrent += s.avgResponseTime;
responseCountCurrent++;
}
totalTokens += s.tokens || 0;
totalTokensEur += s.tokensEur || 0;
// Process transcript for WordCloud
if (s.transcriptContent) {
const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g); // Split into words, lowercase
const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g);
if (words) {
words.forEach((word) => {
const cleanedWord = word.replace(/[^a-z0-9]/gi, ""); // Remove punctuation
const cleanedWord = word.replace(/[^a-z0-9]/gi, "");
if (
cleanedWord &&
!stopWords.has(cleanedWord) &&
cleanedWord.length > 2
) {
// Check if not a stop word and length > 2
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
}
});
}
}
// Aggregate previous period data (if session falls within the previous period range)
if (
sessionDate >= prevPeriodStartDate &&
sessionDate <= prevPeriodEndDate
) {
prevPeriodSessionsCount++;
if (s.userId) {
prevPeriodUniqueUserIds.add(s.userId);
}
if (s.endTime) {
const duration =
(s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60);
const MAX_REASONABLE_DURATION = 24 * 60;
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
prevPeriodTotalDuration += duration;
prevPeriodDurationCount++;
}
}
if (s.avgResponseTime != null) {
prevPeriodTotalResponseTime += s.avgResponseTime;
prevPeriodResponseCount++;
}
}
});
// Now add sentiment alert logic:
const calculateTrend = (current: number, previous: number): number => {
if (previous === 0) {
return current > 0 ? 100 : 0;
}
const trend = ((current - previous) / previous) * 100;
return parseFloat(trend.toFixed(1));
};
const sessionTrend = calculateTrend(total, prevPeriodSessionsCount);
const usersTrend = calculateTrend(
uniqueUserIdsCurrent.size,
prevPeriodUniqueUserIds.size
);
const avgSessionLengthCurrent =
durationCountCurrent > 0 ? totalDurationCurrent / durationCountCurrent : 0;
const avgSessionLengthPrevious =
prevPeriodDurationCount > 0
? prevPeriodTotalDuration / prevPeriodDurationCount
: 0;
const avgSessionTimeTrend = calculateTrend(
avgSessionLengthCurrent,
avgSessionLengthPrevious
);
const avgResponseTimeCurrentPeriod =
responseCountCurrent > 0
? totalResponseTimeCurrent / responseCountCurrent
: 0;
const avgResponseTimePreviousPeriod =
prevPeriodResponseCount > 0
? prevPeriodTotalResponseTime / prevPeriodResponseCount
: 0;
const avgResponseTimeTrend = calculateTrend(
avgResponseTimeCurrentPeriod,
avgResponseTimePreviousPeriod
);
let belowThreshold = 0;
const threshold = companyConfig.sentimentAlert ?? null;
if (threshold != null) {
@ -421,45 +493,43 @@ export function sessionMetrics(
}
}
// Calculate average sessions per day
const dayCount = Object.keys(byDay).length;
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
// Calculate average session length
const avgSessionLength =
durationCount > 0 ? totalDuration / durationCount : null;
// Prepare wordCloudData
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
.map(([text, value]) => ({ text, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 500); // Take top 500 words
.slice(0, 500);
return {
totalSessions: total,
avgSessionsPerDay,
avgSessionLength,
avgSessionsPerDay: parseFloat(avgSessionsPerDay.toFixed(1)),
avgSessionLength: parseFloat(avgSessionLengthCurrent.toFixed(1)),
days: byDay,
languages: byLanguage,
categories: byCategory, // This will be empty if we are not using categories for word cloud
countries: byCountry, // Added countries to the result
categories: byCategory,
countries: byCountry,
belowThresholdCount: belowThreshold,
// Additional metrics not in the interface - using type assertion
escalatedCount: escalated,
forwardedCount: forwarded,
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : undefined,
avgResponseTime: responseCount ? totalResponse / responseCount : undefined,
avgSentiment: sentimentCount
? parseFloat((totalSentiment / sentimentCount).toFixed(2))
: undefined,
avgResponseTime: parseFloat(avgResponseTimeCurrentPeriod.toFixed(2)),
totalTokens,
totalTokensEur,
sentimentThreshold: threshold,
lastUpdated: Date.now(), // Add current timestamp
// New metrics for enhanced dashboard
lastUpdated: Date.now(),
sentimentPositiveCount: sentimentPositive,
sentimentNeutralCount: sentimentNeutral,
sentimentNegativeCount: sentimentNegative,
tokensByDay,
tokensCostByDay,
wordCloudData, // Added word cloud data
wordCloudData,
uniqueUsers: uniqueUserIdsCurrent.size,
sessionTrend,
usersTrend,
avgSessionTimeTrend,
avgResponseTimeTrend,
};
}

View File

@ -131,6 +131,13 @@ export interface MetricsResult {
tokensByDay?: DayMetrics;
tokensCostByDay?: DayMetrics;
wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud
// Properties for overview page cards and trends
uniqueUsers?: number;
sessionTrend?: number; // e.g., percentage change in totalSessions
usersTrend?: number; // e.g., percentage change in uniqueUsers
avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength
avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime
}
export interface ApiResponse<T> {

View File

@ -1,6 +1,6 @@
{
"name": "livedash-node",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
@ -12,7 +12,8 @@
"format": "prettier --write .",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "node prisma/seed.mjs"
"prisma:seed": "node prisma/seed.mjs",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^6.8.2",