From efb5261c7df4398696eb8801e98f833e98eb038e Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 14:44:28 +0200 Subject: [PATCH] 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. --- app/dashboard/company/page.tsx | 7 +- app/dashboard/overview/page.tsx | 30 +++--- app/dashboard/users/page.tsx | 4 +- components/GeographicMap.tsx | 5 - components/MetricCard.tsx | 2 +- components/Sidebar.tsx | 14 ++- eslint.config.js | 10 +- lib/metrics.ts | 176 ++++++++++++++++++++++---------- lib/types.ts | 7 ++ package.json | 5 +- 10 files changed, 172 insertions(+), 88 deletions(-) diff --git a/app/dashboard/company/page.tsx b/app/dashboard/company/page.tsx index bb2f7e6..c04fea1 100644 --- a/app/dashboard/company/page.tsx +++ b/app/dashboard/company/page.tsx @@ -77,7 +77,7 @@ export default function CompanySettingsPage() { return (

Access Denied

-

You don't have permission to view company settings.

+

You don't have permission to view company settings.

); } @@ -103,6 +103,7 @@ export default function CompanySettingsPage() { e.preventDefault(); handleSave(); }} + autoComplete="off" >
@@ -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" /> @@ -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" />

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" />

Percentage of negative sentiment sessions to trigger alert (0-100) diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx index 83fe7bd..3906f83 100644 --- a/app/dashboard/overview/page.tsx +++ b/app/dashboard/overview/page.tsx @@ -246,12 +246,12 @@ function DashboardContent() { } 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, }} /> } 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, }} /> } 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, }} /> } 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 }} /> @@ -345,7 +345,7 @@ function DashboardContent() {

Sessions Over Time

- +

diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx index 87a2a0e..c1addcc 100644 --- a/app/dashboard/users/page.tsx +++ b/app/dashboard/users/page.tsx @@ -74,7 +74,7 @@ export default function UserManagementPage() { return (

Access Denied

-

You don't have permission to view user management.

+

You don't have permission to view user management.

); } @@ -102,6 +102,7 @@ export default function UserManagementPage() { e.preventDefault(); inviteUser(); }} + autoComplete="off" // Disable autofill for the form >
@@ -112,6 +113,7 @@ export default function UserManagementPage() { value={email} onChange={(e) => setEmail(e.target.value)} required + autoComplete="off" // Disable autofill for this input />
diff --git a/components/GeographicMap.tsx b/components/GeographicMap.tsx index ca767ea..a2828b0 100644 --- a/components/GeographicMap.tsx +++ b/components/GeographicMap.tsx @@ -44,7 +44,6 @@ const getCountryCoordinates = (): Record => { 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([]); } diff --git a/components/MetricCard.tsx b/components/MetricCard.tsx index 9388599..a3fe0f2 100644 --- a/components/MetricCard.tsx +++ b/components/MetricCard.tsx @@ -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; diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index b50f828..45b83c3 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -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() {
- LiveDash Logo
{isExpanded && ( @@ -213,7 +217,7 @@ export default function Sidebar() {
{isExpanded ? "Collapse sidebar" : "Expand sidebar"} @@ -292,8 +296,8 @@ export default function Sidebar() { ) : (
Logout diff --git a/eslint.config.js b/eslint.config.js index b01b0f6..c033397 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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", }, }, ]; diff --git a/lib/metrics.ts b/lib/metrics.ts index 56eeac9..d8b07b6 100644 --- a/lib/metrics.ts +++ b/lib/metrics.ts @@ -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(); + + 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(); + 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, }; } diff --git a/lib/types.ts b/lib/types.ts index e55da4f..de4ca14 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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 { diff --git a/package.json b/package.json index 94ff784..c1cbc4a 100644 --- a/package.json +++ b/package.json @@ -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",