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"]
+ ]
}