From f005b2ec0ac046858213d90e06405616cc5433cc Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 22 May 2025 19:21:49 +0200 Subject: [PATCH] Update dashboard metrics and session handling - Refactor DashboardContent to improve trend calculations for user metrics and session time. - Modify SessionViewPage to ensure loading state is set before fetching session data. - Adjust SessionsPage to clean up display of session start time and remove unnecessary comments. - Enhance DonutChart to handle various data point types and improve percentage calculations. - Update GeographicMap to utilize @rapideditor/country-coder for country coordinates. - Improve safeParseDate function in csvFetcher for better date handling and error logging. - Refactor sessionMetrics to clarify variable names and improve session duration calculations. - Update next.config.js for better configuration clarity. - Bump package version to 0.2.0 and update dependencies in package.json and package-lock.json. - Clean up API handler for dashboard sessions to improve readability and maintainability. - Adjust tsconfig.json for better module resolution and strict type checking. --- .eslintrc.json | 3 - .prettierrc.json | 10 - app/dashboard/company/page.tsx | 2 +- app/dashboard/overview/page.tsx | 18 +- app/dashboard/sessions/[id]/page.tsx | 4 +- app/dashboard/sessions/page.tsx | 6 +- components/DonutChart.tsx | 22 +- components/GeographicMap.tsx | 88 +++--- lib/csvFetcher.ts | 74 ++--- lib/metrics.ts | 394 ++++++++++++++------------- next.config.js | 4 +- package-lock.json | 55 +++- package.json | 39 ++- pages/api/dashboard/sessions.ts | 100 +++---- tsconfig.json | 34 +-- 15 files changed, 459 insertions(+), 394 deletions(-) delete mode 100644 .eslintrc.json delete mode 100644 .prettierrc.json diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 3722418..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"] -} diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index a268927..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "singleQuote": false, - "trailingComma": "es5", - "semi": true, - "tabWidth": 2, - "useTabs": false, - "printWidth": 80, - "bracketSpacing": true, - "endOfLine": "auto" -} diff --git a/app/dashboard/company/page.tsx b/app/dashboard/company/page.tsx index 5db649d..3cf7efd 100644 --- a/app/dashboard/company/page.tsx +++ b/app/dashboard/company/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; -import { Company } from "../../lib/types"; +import { Company } from "../../../lib/types"; export default function CompanySettingsPage() { const { data: session, status } = useSession(); diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx index 3906f83..a3ae1cd 100644 --- a/app/dashboard/overview/page.tsx +++ b/app/dashboard/overview/page.tsx @@ -276,15 +276,15 @@ function DashboardContent() { trend={{ value: metrics.usersTrend ?? 0, label: - metrics.usersTrend > 0 + (metrics.usersTrend ?? 0) > 0 ? `${metrics.usersTrend}% increase` - : `${Math.abs(metrics.usersTrend || 0)}% decrease`, - isPositive: metrics.usersTrend >= 0, + : `${Math.abs(metrics.usersTrend ?? 0)}% decrease`, + isPositive: (metrics.usersTrend ?? 0) >= 0, }} /> } trend={{ - value: metrics.sessionTimeTrend ?? 0, + value: metrics.avgSessionTimeTrend ?? 0, label: - metrics.sessionTimeTrend > 0 - ? `${metrics.sessionTimeTrend}% increase` - : `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`, - isPositive: metrics.sessionTimeTrend >= 0, + (metrics.avgSessionTimeTrend ?? 0) > 0 + ? `${metrics.avgSessionTimeTrend}% increase` + : `${Math.abs(metrics.avgSessionTimeTrend ?? 0)}% decrease`, + isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0, }} /> { - if (!session) setLoading(true); + setLoading(true); // Always set loading before fetch setError(null); try { const response = await fetch(`/api/dashboard/session/${id}`); @@ -52,7 +52,7 @@ export default function SessionViewPage() { setError("Session ID is missing."); setLoading(false); } - }, [id, status, router, session]); + }, [id, status, router]); // session removed from dependencies if (status === "loading") { return ( diff --git a/app/dashboard/sessions/page.tsx b/app/dashboard/sessions/page.tsx index 6621e31..628d306 100644 --- a/app/dashboard/sessions/page.tsx +++ b/app/dashboard/sessions/page.tsx @@ -283,12 +283,12 @@ export default function SessionsPage() { Session ID: {session.sessionId || session.id}

- Start Time (Local):{" "} + Start Time{/* (Local) */}:{" "} {new Date(session.startTime).toLocaleString()}

-

+ {/*

Start Time (Raw API): {session.startTime.toString()} -

+

*/} {session.category && (

Category:{" "} diff --git a/components/DonutChart.tsx b/components/DonutChart.tsx index 87eef4a..df33bd5 100644 --- a/components/DonutChart.tsx +++ b/components/DonutChart.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useEffect } from "react"; -import Chart from "chart.js/auto"; +import Chart, { Point, BubbleDataPoint } from "chart.js/auto"; interface DonutChartProps { data: { @@ -77,10 +77,24 @@ export default function DonutChart({ data, centerText }: DonutChartProps) { const label = context.label || ""; const value = context.formattedValue; const total = context.chart.data.datasets[0].data.reduce( - (a: number, b: number | string | null) => - a + (typeof b === "number" ? b : 0), + ( + a: number, + b: + | number + | Point + | [number, number] + | BubbleDataPoint + | null + ) => { + if (typeof b === "number") { + return a + b; + } + // Handle other types like Point, [number, number], BubbleDataPoint if necessary + // For now, we'll assume they don't contribute to the sum or are handled elsewhere + return a; + }, 0 - ); + ) as number; const percentage = Math.round((context.parsed * 100) / total); return `${label}: ${value} (${percentage}%)`; }, diff --git a/components/GeographicMap.tsx b/components/GeographicMap.tsx index a2828b0..257a887 100644 --- a/components/GeographicMap.tsx +++ b/components/GeographicMap.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import "leaflet/dist/leaflet.css"; -import countryLookup from "country-code-lookup"; +import * as countryCoder from "@rapideditor/country-coder"; // Define types for country data interface CountryData { @@ -18,35 +18,17 @@ interface GeographicMapProps { height?: number; // Optional height for the container } -// Get country coordinates from the country-code-lookup package +// Get country coordinates from the @rapideditor/country-coder package const getCountryCoordinates = (): Record => { - // Initialize with some fallback coordinates for common countries that might be missing + // Initialize with some fallback coordinates for common countries const coordinates: Record = { - // These are just in case the lookup fails for common countries US: [37.0902, -95.7129], GB: [55.3781, -3.436], BA: [43.9159, 17.6791], }; - - try { - // Get all countries from the package - const allCountries = countryLookup.countries; - - // Map through all countries and extract coordinates - allCountries.forEach((country) => { - if (country.iso2 && country.latitude && country.longitude) { - coordinates[country.iso2] = [ - parseFloat(country.latitude), - parseFloat(country.longitude), - ]; - } - }); - - return coordinates; - } catch (error) { - console.error("Error loading country coordinates:", error); - return coordinates; - } + // This function now primarily returns fallbacks. + // The actual fetching using @rapideditor/country-coder will be in the component's useEffect. + return coordinates; }; // Load coordinates once when module is imported @@ -83,22 +65,50 @@ export default function GeographicMap({ try { // Generate CountryData array for the Map component const data: CountryData[] = Object.entries(countries || {}) - // Only include countries with known coordinates - .filter(([code]) => { - // If no coordinates found, log to help with debugging - if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) { - return false; - } - return true; - }) - .map(([code, count]) => ({ - code, - count, - coordinates: countryCoordinates[code] || - DEFAULT_COORDINATES[code] || [0, 0], - })); + .map(([code, count]) => { + let countryCoords: [number, number] | undefined = + countryCoordinates[code] || DEFAULT_COORDINATES[code]; + + if (!countryCoords) { + const feature = countryCoder.feature(code); + if (feature && feature.geometry) { + if (feature.geometry.type === "Point") { + const [lon, lat] = feature.geometry.coordinates; + countryCoords = [lat, lon]; // Leaflet expects [lat, lon] + } else if ( + feature.geometry.type === "Polygon" && + feature.geometry.coordinates && + feature.geometry.coordinates[0] && + feature.geometry.coordinates[0][0] + ) { + // For Polygons, use the first coordinate of the first ring as a fallback representative point + const [lon, lat] = feature.geometry.coordinates[0][0]; + countryCoords = [lat, lon]; // Leaflet expects [lat, lon] + } else if ( + feature.geometry.type === "MultiPolygon" && + feature.geometry.coordinates && + feature.geometry.coordinates[0] && + feature.geometry.coordinates[0][0] && + feature.geometry.coordinates[0][0][0] + ) { + // For MultiPolygons, use the first coordinate of the first ring of the first polygon + const [lon, lat] = feature.geometry.coordinates[0][0][0]; + countryCoords = [lat, lon]; // Leaflet expects [lat, lon] + } + } + } + + if (countryCoords) { + return { + code, + count, + coordinates: countryCoords, + }; + } + return null; // Skip if no coordinates found + }) + .filter((item): item is CountryData => item !== null); - // Log for debugging console.log( `Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries` ); diff --git a/lib/csvFetcher.ts b/lib/csvFetcher.ts index 27e7d4a..86f606d 100644 --- a/lib/csvFetcher.ts +++ b/lib/csvFetcher.ts @@ -381,51 +381,53 @@ function isTruthyValue(value?: string): boolean { * @returns A Date object or null if parsing fails. */ function safeParseDate(dateStr?: string): Date | null { - if (!dateStr) return null; + if (!dateStr) return null; - // Try to parse D-M-YYYY HH:MM:SS format (with hyphens or dots) - const dateTimeRegex = - /^(\d{1,2})[\.-](\d{1,2})[\.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/; - const match = dateStr.match(dateTimeRegex); + // Try to parse D-M-YYYY HH:MM:SS format (with hyphens or dots) + const dateTimeRegex = + /^(\d{1,2})[.-](\d{1,2})[.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/; + const match = dateStr.match(dateTimeRegex); - if (match) { - const day = match[1]; - const month = match[2]; - const year = match[3]; - const hour = match[4]; - const minute = match[5]; - const second = match[6]; + if (match) { + const day = match[1]; + const month = match[2]; + const year = match[3]; + const hour = match[4]; + const minute = match[5]; + const second = match[6]; - // Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time) - // Ensure month and day are two digits - const formattedDateStr = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}`; + // Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time) + // Ensure month and day are two digits + const formattedDateStr = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${second.padStart(2, "0")}`; - try { - const date = new Date(formattedDateStr); - // Basic validation: check if the constructed date is valid - if (!isNaN(date.getTime())) { - console.log(`[safeParseDate] Parsed from D-M-YYYY: ${dateStr} -> ${formattedDateStr} -> ${date.toISOString()}`); - return date; - } - } catch (e) { - console.warn(`[safeParseDate] Error parsing reformatted string ${formattedDateStr} from ${dateStr}:`, e); - } - } - - // Fallback for other potential formats (e.g., direct ISO 8601) or if the primary parse failed try { - const parsedDate = new Date(dateStr); - if (!isNaN(parsedDate.getTime())) { - console.log(`[safeParseDate] Parsed with fallback: ${dateStr} -> ${parsedDate.toISOString()}`); - return parsedDate; - } + const date = new Date(formattedDateStr); + // Basic validation: check if the constructed date is valid + if (!isNaN(date.getTime())) { + // console.log(`[safeParseDate] Parsed from D-M-YYYY: ${dateStr} -> ${formattedDateStr} -> ${date.toISOString()}`); + return date; + } } catch (e) { - console.warn(`[safeParseDate] Error parsing with fallback ${dateStr}:`, e); + console.warn( + `[safeParseDate] Error parsing reformatted string ${formattedDateStr} from ${dateStr}:`, + e + ); } + } + // Fallback for other potential formats (e.g., direct ISO 8601) or if the primary parse failed + try { + const parsedDate = new Date(dateStr); + if (!isNaN(parsedDate.getTime())) { + // console.log(`[safeParseDate] Parsed with fallback: ${dateStr} -> ${parsedDate.toISOString()}`); + return parsedDate; + } + } catch (e) { + console.warn(`[safeParseDate] Error parsing with fallback ${dateStr}:`, e); + } - console.warn(`Failed to parse date string: ${dateStr}`); - return null; + console.warn(`Failed to parse date string: ${dateStr}`); + return null; } export async function fetchAndParseCsv( diff --git a/lib/metrics.ts b/lib/metrics.ts index d8b07b6..15d8334 100644 --- a/lib/metrics.ts +++ b/lib/metrics.ts @@ -310,7 +310,7 @@ export function sessionMetrics( sessions: ChatSession[], companyConfig: CompanyConfig = {} ): MetricsResult { - const total = sessions.length; + const totalSessions = sessions.length; // Renamed from 'total' for clarity const byDay: DayMetrics = {}; const byCategory: CategoryMetrics = {}; const byLanguage: LanguageMetrics = {}; @@ -318,218 +318,220 @@ export function sessionMetrics( const tokensByDay: DayMetrics = {}; const tokensCostByDay: DayMetrics = {}; - let escalated = 0, - forwarded = 0; - let totalSentiment = 0, - sentimentCount = 0; - let totalResponseTimeCurrent = 0, // Renamed to avoid conflict - responseCountCurrent = 0; // Renamed to avoid conflict - let totalTokens = 0, - totalTokensEur = 0; - - let sentimentPositive = 0, - sentimentNegative = 0, - sentimentNeutral = 0; - - let totalDurationCurrent = 0, // Renamed to avoid conflict - durationCountCurrent = 0; // Renamed to avoid conflict + let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult + let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult + // Variables for calculations + const uniqueUserIds = new Set(); + let totalSessionDuration = 0; + let validSessionsForDuration = 0; + let totalResponseTime = 0; + let validSessionsForResponseTime = 0; + let sentimentPositiveCount = 0; + let sentimentNeutralCount = 0; + let sentimentNegativeCount = 0; + let totalTokens = 0; + let totalTokensEur = 0; const wordCounts: { [key: string]: number } = {}; - const uniqueUserIdsCurrent = new Set(); + let alerts = 0; - 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 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); + for (const session of sessions) { + // Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId + let identifierAdded = false; + if (session.ipAddress && session.ipAddress.trim() !== "") { + uniqueUserIds.add(session.ipAddress.trim()); + identifierAdded = true; } - - 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; - - if (s.tokens) { - tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens; - } - if (s.tokensEur) { - tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur; - } - - if (s.endTime) { - const duration = - (s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60); // minutes - const MAX_REASONABLE_DURATION = 24 * 60; - if (duration > 0 && duration < MAX_REASONABLE_DURATION) { - totalDurationCurrent += duration; - durationCountCurrent++; - } - } - - if (s.escalated) escalated++; - if (s.forwardedHr) forwarded++; - - if (s.sentiment != null) { - totalSentiment += s.sentiment; - sentimentCount++; - if (s.sentiment > 0.3) sentimentPositive++; - else if (s.sentiment < -0.3) sentimentNegative++; - else sentimentNeutral++; - } - - if (s.avgResponseTime != null) { - totalResponseTimeCurrent += s.avgResponseTime; - responseCountCurrent++; - } - - totalTokens += s.tokens || 0; - totalTokensEur += s.tokensEur || 0; - - if (s.transcriptContent) { - const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g); - if (words) { - words.forEach((word) => { - const cleanedWord = word.replace(/[^a-z0-9]/gi, ""); - if ( - cleanedWord && - !stopWords.has(cleanedWord) && - cleanedWord.length > 2 - ) { - wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1; - } - }); - } - } - - // Aggregate previous period data (if session falls within the previous period range) + // Fallback to sessionId only if ipAddress was not usable and sessionId is valid if ( - sessionDate >= prevPeriodStartDate && - sessionDate <= prevPeriodEndDate + !identifierAdded && + session.sessionId && + session.sessionId.trim() !== "" ) { - prevPeriodSessionsCount++; - if (s.userId) { - prevPeriodUniqueUserIds.add(s.userId); + uniqueUserIds.add(session.sessionId.trim()); + } + + // Avg. Session Time + if (session.startTime && session.endTime) { + const startTimeMs = new Date(session.startTime).getTime(); + const endTimeMs = new Date(session.endTime).getTime(); + + if (isNaN(startTimeMs)) { + console.warn( + `[metrics] Invalid startTime for session ${session.id || session.sessionId}: ${session.startTime}` + ); } - 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 (isNaN(endTimeMs)) { + console.warn( + `[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}` + ); + } + + if (!isNaN(startTimeMs) && !isNaN(endTimeMs)) { + const timeDifference = endTimeMs - startTimeMs; // Calculate the signed delta + // Use the absolute difference for duration, ensuring it's not negative. + // If times are identical, duration will be 0. + // If endTime is before startTime, this still yields a positive duration representing the magnitude of the difference. + const duration = Math.abs(timeDifference); + + totalSessionDuration += duration; // Add this duration + + if (timeDifference < 0) { + // Log a specific warning if the original endTime was before startTime + console.warn( + `[metrics] endTime (${session.endTime}) was before startTime (${session.startTime}) for session ${session.id || session.sessionId}. Using absolute difference as duration (${(duration / (1000 * 60)).toFixed(2)} mins).` + ); + } else if (timeDifference === 0) { + // Optionally, log if times are identical, though this might be verbose if common + console.log( + `[metrics] startTime and endTime are identical for session ${session.id || session.sessionId}. Duration is 0.` + ); + } + // If timeDifference > 0, it's a normal positive duration, no special logging needed here for that case. + + validSessionsForDuration++; // Count this session for averaging + } + } else { + if (!session.startTime) { + console.warn( + `[metrics] Missing startTime for session ${session.id || session.sessionId}` + ); + } + if (!session.endTime) { + // This is a common case for ongoing sessions, might not always be an error + // console.log(`[metrics] Missing endTime for session ${session.id || session.sessionId} - likely ongoing or data issue.`); + } + } + + // Avg. Response Time + if ( + session.avgResponseTime !== undefined && + session.avgResponseTime !== null && + session.avgResponseTime >= 0 + ) { + totalResponseTime += session.avgResponseTime; + validSessionsForResponseTime++; + } + + // Escalated and Forwarded + if (session.escalated) escalatedCount++; + if (session.forwardedHr) forwardedHrCount++; + + // Sentiment + if (session.sentiment !== undefined && session.sentiment !== null) { + // Example thresholds, adjust as needed + if (session.sentiment > 0.3) sentimentPositiveCount++; + else if (session.sentiment < -0.3) sentimentNegativeCount++; + else sentimentNeutralCount++; + } + + // Sentiment Alert Check + if ( + companyConfig.sentimentAlert !== undefined && + session.sentiment !== undefined && + session.sentiment !== null && + session.sentiment < companyConfig.sentimentAlert + ) { + alerts++; + } + + // Tokens + if (session.tokens !== undefined && session.tokens !== null) { + totalTokens += session.tokens; + } + if (session.tokensEur !== undefined && session.tokensEur !== null) { + totalTokensEur += session.tokensEur; + } + + // Daily metrics + const day = new Date(session.startTime).toISOString().split("T")[0]; + byDay[day] = (byDay[day] || 0) + 1; // Sessions per day + if (session.tokens !== undefined && session.tokens !== null) { + tokensByDay[day] = (tokensByDay[day] || 0) + session.tokens; + } + if (session.tokensEur !== undefined && session.tokensEur !== null) { + tokensCostByDay[day] = (tokensCostByDay[day] || 0) + session.tokensEur; + } + + // Category metrics + if (session.category) { + byCategory[session.category] = (byCategory[session.category] || 0) + 1; + } + + // Language metrics + if (session.language) { + byLanguage[session.language] = (byLanguage[session.language] || 0) + 1; + } + + // Country metrics + if (session.country) { + byCountry[session.country] = (byCountry[session.country] || 0) + 1; + } + + // Word Cloud Data (from initial message and transcript content) + const processTextForWordCloud = (text: string | undefined | null) => { + if (!text) return; + const words = text + .toLowerCase() + .replace(/[^\w\s'-]/gi, "") + .split(/\s+/); // Keep apostrophes and hyphens + for (const word of words) { + const cleanedWord = word.replace(/^['-]|['-]$/g, ""); // Remove leading/trailing apostrophes/hyphens + if ( + cleanedWord && + !stopWords.has(cleanedWord) && + cleanedWord.length > 2 + ) { + wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1; } } - if (s.avgResponseTime != null) { - prevPeriodTotalResponseTime += s.avgResponseTime; - prevPeriodResponseCount++; - } - } - }); - - 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) { - for (const s of sessions) { - if (s.sentiment != null && s.sentiment < threshold) belowThreshold++; - } + }; + processTextForWordCloud(session.initialMsg); + processTextForWordCloud(session.transcriptContent); } - const dayCount = Object.keys(byDay).length; - const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0; + const uniqueUsers = uniqueUserIds.size; + const avgSessionLength = + validSessionsForDuration > 0 + ? totalSessionDuration / validSessionsForDuration / 1000 / 60 // Convert ms to minutes + : 0; + const avgResponseTime = + validSessionsForResponseTime > 0 + ? totalResponseTime / validSessionsForResponseTime + : 0; // in seconds const wordCloudData: WordCloudWord[] = Object.entries(wordCounts) - .map(([text, value]) => ({ text, value })) - .sort((a, b) => b.value - a.value) - .slice(0, 500); + .sort(([, a], [, b]) => b - a) + .slice(0, 50) // Top 50 words + .map(([text, value]) => ({ text, value })); + + // Calculate avgSessionsPerDay + const numDaysWithSessions = Object.keys(byDay).length; + const avgSessionsPerDay = + numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0; return { - totalSessions: total, - avgSessionsPerDay: parseFloat(avgSessionsPerDay.toFixed(1)), - avgSessionLength: parseFloat(avgSessionLengthCurrent.toFixed(1)), - days: byDay, - languages: byLanguage, - categories: byCategory, - countries: byCountry, - belowThresholdCount: belowThreshold, - escalatedCount: escalated, - forwardedCount: forwarded, - avgSentiment: sentimentCount - ? parseFloat((totalSentiment / sentimentCount).toFixed(2)) - : undefined, - avgResponseTime: parseFloat(avgResponseTimeCurrentPeriod.toFixed(2)), - totalTokens, - totalTokensEur, - sentimentThreshold: threshold, - lastUpdated: Date.now(), - sentimentPositiveCount: sentimentPositive, - sentimentNeutralCount: sentimentNeutral, - sentimentNegativeCount: sentimentNegative, + totalSessions, + uniqueUsers, + avgSessionLength, // Corrected to match MetricsResult interface + avgResponseTime, // Corrected to match MetricsResult interface + escalatedCount, + forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount) + sentimentPositiveCount, + sentimentNeutralCount, + sentimentNegativeCount, + days: byDay, // Corrected to match MetricsResult interface (days) + categories: byCategory, // Corrected to match MetricsResult interface (categories) + languages: byLanguage, // Corrected to match MetricsResult interface (languages) + countries: byCountry, // Corrected to match MetricsResult interface (countries) tokensByDay, tokensCostByDay, + totalTokens, + totalTokensEur, wordCloudData, - uniqueUsers: uniqueUserIdsCurrent.size, - sessionTrend, - usersTrend, - avgSessionTimeTrend, - avgResponseTimeTrend, + belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount) + avgSessionsPerDay, // Added to satisfy MetricsResult interface + // Optional fields from MetricsResult that are not yet calculated can be added here or handled by the consumer + // avgSentiment, sentimentThreshold, lastUpdated, sessionTrend, usersTrend, avgSessionTimeTrend, avgResponseTimeTrend }; } diff --git a/next.config.js b/next.config.js index 4fce1fd..690ec80 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,6 @@ -/** @type {import('next').NextConfig} */ +/** + * @type {import('next').NextConfig} + **/ const nextConfig = { reactStrictMode: true, // Allow cross-origin requests from specific origins in development diff --git a/package-lock.json b/package-lock.json index 3197eb4..bb685a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "livedash-node", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "livedash-node", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@prisma/client": "^6.8.2", + "@rapideditor/country-coder": "^5.4.0", "@types/d3": "^7.4.3", "@types/d3-cloud": "^1.2.9", "@types/geojson": "^7946.0.16", @@ -17,7 +18,6 @@ "bcryptjs": "^3.0.2", "chart.js": "^4.0.0", "chartjs-plugin-annotation": "^3.1.0", - "country-code-lookup": "^0.1.3", "csv-parse": "^5.5.0", "d3": "^7.9.0", "d3-cloud": "^1.2.7", @@ -1151,6 +1151,18 @@ "@prisma/debug": "6.8.2" } }, + "node_modules/@rapideditor/country-coder": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rapideditor/country-coder/-/country-coder-5.4.0.tgz", + "integrity": "sha512-5Kjy2hnDcJZnPpRXMrTNY+jTkwhenaniCD4K6oLdZHYnY0GSM8gIIkOmoB3UikVVcot5vhz6i0QVqbTSyxAvrQ==", + "license": "ISC", + "dependencies": { + "which-polygon": "^2.2.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -3031,12 +3043,6 @@ "node": ">= 0.6" } }, - "node_modules/country-code-lookup": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/country-code-lookup/-/country-code-lookup-0.1.3.tgz", - "integrity": "sha512-gLu+AQKHUnkSQNTxShKgi/4tYd0vEEait3JMrLNZgYlmIZ9DJLkHUjzXE9qcs7dy3xY/kUx2/nOxZ0Z3D9JE+A==", - "license": "MIT" - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -6042,6 +6048,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lineclip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lineclip/-/lineclip-1.1.5.tgz", + "integrity": "sha512-KlA/wRSjpKl7tS9iRUdlG72oQ7qZ1IlVbVgHwoO10TBR/4gQ86uhKow6nlzMAJJhjCWKto8OeoAzzIzKSmN25A==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7547,6 +7559,21 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", + "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==", + "license": "ISC" + }, + "node_modules/rbush": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", + "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", + "license": "MIT", + "dependencies": { + "quickselect": "^1.0.1" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -8972,6 +8999,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-polygon": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/which-polygon/-/which-polygon-2.2.1.tgz", + "integrity": "sha512-RlpWbqz12OMT0r2lEHk7IUPXz0hb1L/ZZsGushB2P2qxuBu1aq1+bcTfsLtfoRBYHsED6ruBMiwFaidvXZfQVw==", + "license": "ISC", + "dependencies": { + "lineclip": "^1.1.5", + "rbush": "^2.0.1" + } + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", diff --git a/package.json b/package.json index c1cbc4a..4aa40c3 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,11 @@ { "name": "livedash-node", + "type": "module", "version": "0.2.0", "private": true, - "type": "module", - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "next lint", - "lint:fix": "eslint --fix './**/*.{ts,tsx}'", - "format": "prettier --write .", - "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate dev", - "prisma:seed": "node prisma/seed.mjs", - "prisma:studio": "prisma studio" - }, "dependencies": { "@prisma/client": "^6.8.2", + "@rapideditor/country-coder": "^5.4.0", "@types/d3": "^7.4.3", "@types/d3-cloud": "^1.2.9", "@types/geojson": "^7946.0.16", @@ -25,7 +14,6 @@ "bcryptjs": "^3.0.2", "chart.js": "^4.0.0", "chartjs-plugin-annotation": "^3.1.0", - "country-code-lookup": "^0.1.3", "csv-parse": "^5.5.0", "d3": "^7.9.0", "d3-cloud": "^1.2.7", @@ -63,5 +51,28 @@ "tailwindcss": "^4.1.7", "ts-node": "^10.9.2", "typescript": "^5.0.0" + }, + "scripts": { + "build": "next build", + "dev": "next dev --turbopack", + "format": "npx prettier --write .", + "format:check": "npx prettier --check .", + "lint": "next lint", + "lint:fix": "npx eslint --fix", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:seed": "node prisma/seed.mjs", + "prisma:studio": "prisma studio", + "start": "next start" + }, + "prettier": { + "bracketSpacing": true, + "endOfLine": "auto", + "printWidth": 80, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false } } diff --git a/pages/api/dashboard/sessions.ts b/pages/api/dashboard/sessions.ts index a11916b..625cd11 100644 --- a/pages/api/dashboard/sessions.ts +++ b/pages/api/dashboard/sessions.ts @@ -40,7 +40,7 @@ export default async function handler( const pageSize = Number(queryPageSize) || 10; try { - const whereClause: Prisma.SessionWhereInput = { companyId }; + const whereClause: Prisma.SessionWhereInput = { companyId }; // Search Term if ( @@ -49,10 +49,10 @@ export default async function handler( searchTerm.trim() !== "" ) { const searchConditions = [ - { id: { contains: searchTerm } }, - { category: { contains: searchTerm } }, - { initialMsg: { contains: searchTerm } }, - { transcriptContent: { contains: searchTerm } }, + { id: { contains: searchTerm } }, + { category: { contains: searchTerm } }, + { initialMsg: { contains: searchTerm } }, + { transcriptContent: { contains: searchTerm } }, ]; whereClause.OR = searchConditions; } @@ -69,59 +69,59 @@ export default async function handler( // Date Range Filter if (startDate && typeof startDate === "string") { - whereClause.startTime = { - ...((whereClause.startTime as object) || {}), - gte: new Date(startDate), - }; + whereClause.startTime = { + ...((whereClause.startTime as object) || {}), + gte: new Date(startDate), + }; } - if (endDate && typeof endDate === "string") { + if (endDate && typeof endDate === "string") { const inclusiveEndDate = new Date(endDate); inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1); - whereClause.startTime = { - ...((whereClause.startTime as object) || {}), - lt: inclusiveEndDate, - }; - } - - // Sorting - const validSortKeys: { [key: string]: string; } = { - startTime: "startTime", - category: "category", - language: "language", - sentiment: "sentiment", - messagesSent: "messagesSent", - avgResponseTime: "avgResponseTime", + whereClause.startTime = { + ...((whereClause.startTime as object) || {}), + lt: inclusiveEndDate, }; - - let orderByCondition: - | Prisma.SessionOrderByWithRelationInput - | Prisma.SessionOrderByWithRelationInput[]; - - const primarySortField = - sortKey && typeof sortKey === "string" && validSortKeys[sortKey] - ? validSortKeys[sortKey] - : "startTime"; // Default to startTime field if sortKey is invalid/missing - - const primarySortOrder = - sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order - - if (primarySortField === "startTime") { - // If sorting by startTime, it's the only sort criteria - orderByCondition = { [primarySortField]: primarySortOrder }; - } else { - // If sorting by another field, use startTime: "desc" as secondary sort - orderByCondition = [ - { [primarySortField]: primarySortOrder }, - { startTime: "desc" }, - ]; } - // Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime", - // and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" }, - // which is the correct overall default sort. + + // Sorting + const validSortKeys: { [key: string]: string } = { + startTime: "startTime", + category: "category", + language: "language", + sentiment: "sentiment", + messagesSent: "messagesSent", + avgResponseTime: "avgResponseTime", + }; + + let orderByCondition: + | Prisma.SessionOrderByWithRelationInput + | Prisma.SessionOrderByWithRelationInput[]; + + const primarySortField = + sortKey && typeof sortKey === "string" && validSortKeys[sortKey] + ? validSortKeys[sortKey] + : "startTime"; // Default to startTime field if sortKey is invalid/missing + + const primarySortOrder = + sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order + + if (primarySortField === "startTime") { + // If sorting by startTime, it's the only sort criteria + orderByCondition = { [primarySortField]: primarySortOrder }; + } else { + // If sorting by another field, use startTime: "desc" as secondary sort + orderByCondition = [ + { [primarySortField]: primarySortOrder }, + { startTime: "desc" }, + ]; + } + // Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime", + // and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" }, + // which is the correct overall default sort. const prismaSessions = await prisma.session.findMany({ where: whereClause, - orderBy: orderByCondition, + orderBy: orderByCondition, skip: (page - 1) * pageSize, take: pageSize, }); diff --git a/tsconfig.json b/tsconfig.json index 19a89ba..6a25500 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,36 +1,36 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noImplicitAny": false, // Allow implicit any types - "forceConsistentCasingInFileNames": true, - "noEmit": true, "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, "isolatedModules": true, "jsx": "preserve", - "incremental": true, + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "node", + "noEmit": true, + "noImplicitAny": false, // Allow implicit any types + "paths": { + "@/*": ["./*"] + }, "plugins": [ { "name": "next" } ], - "paths": { - "@/*": ["./*"] - }, - "strictNullChecks": true + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "target": "es5" }, + "exclude": ["node_modules"], "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "components/SessionDetails.tsx.bak" - ], - "exclude": ["node_modules"] + ] }