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.
This commit is contained in:
2025-05-22 19:21:49 +02:00
parent ed6e5b0c36
commit f005b2ec0a
15 changed files with 459 additions and 394 deletions

View File

@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

View File

@ -1,10 +0,0 @@
{
"singleQuote": false,
"trailingComma": "es5",
"semi": true,
"tabWidth": 2,
"useTabs": false,
"printWidth": 80,
"bracketSpacing": true,
"endOfLine": "auto"
}

View File

@ -2,7 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Company } from "../../lib/types"; import { Company } from "../../../lib/types";
export default function CompanySettingsPage() { export default function CompanySettingsPage() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();

View File

@ -276,15 +276,15 @@ function DashboardContent() {
trend={{ trend={{
value: metrics.usersTrend ?? 0, value: metrics.usersTrend ?? 0,
label: label:
metrics.usersTrend > 0 (metrics.usersTrend ?? 0) > 0
? `${metrics.usersTrend}% increase` ? `${metrics.usersTrend}% increase`
: `${Math.abs(metrics.usersTrend || 0)}% decrease`, : `${Math.abs(metrics.usersTrend ?? 0)}% decrease`,
isPositive: metrics.usersTrend >= 0, isPositive: (metrics.usersTrend ?? 0) >= 0,
}} }}
/> />
<MetricCard <MetricCard
title="Avg. Session Time" title="Avg. Session Time"
value={`${Math.round(metrics.avgSessionTime || 0)}m`} value={`${Math.round(metrics.avgSessionTimeTrend || 0)}m`}
icon={ icon={
<svg <svg
className="h-5 w-5" className="h-5 w-5"
@ -302,12 +302,12 @@ function DashboardContent() {
</svg> </svg>
} }
trend={{ trend={{
value: metrics.sessionTimeTrend ?? 0, value: metrics.avgSessionTimeTrend ?? 0,
label: label:
metrics.sessionTimeTrend > 0 (metrics.avgSessionTimeTrend ?? 0) > 0
? `${metrics.sessionTimeTrend}% increase` ? `${metrics.avgSessionTimeTrend}% increase`
: `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`, : `${Math.abs(metrics.avgSessionTimeTrend ?? 0)}% decrease`,
isPositive: metrics.sessionTimeTrend >= 0, isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
}} }}
/> />
<MetricCard <MetricCard

View File

@ -25,7 +25,7 @@ export default function SessionViewPage() {
if (status === "authenticated" && id) { if (status === "authenticated" && id) {
const fetchSession = async () => { const fetchSession = async () => {
if (!session) setLoading(true); setLoading(true); // Always set loading before fetch
setError(null); setError(null);
try { try {
const response = await fetch(`/api/dashboard/session/${id}`); const response = await fetch(`/api/dashboard/session/${id}`);
@ -52,7 +52,7 @@ export default function SessionViewPage() {
setError("Session ID is missing."); setError("Session ID is missing.");
setLoading(false); setLoading(false);
} }
}, [id, status, router, session]); }, [id, status, router]); // session removed from dependencies
if (status === "loading") { if (status === "loading") {
return ( return (

View File

@ -283,12 +283,12 @@ export default function SessionsPage() {
Session ID: {session.sessionId || session.id} Session ID: {session.sessionId || session.id}
</h2> </h2>
<p className="text-sm text-gray-500 mb-1"> <p className="text-sm text-gray-500 mb-1">
Start Time (Local):{" "} Start Time{/* (Local) */}:{" "}
{new Date(session.startTime).toLocaleString()} {new Date(session.startTime).toLocaleString()}
</p> </p>
<p className="text-xs text-gray-400 mb-1"> {/* <p className="text-xs text-gray-400 mb-1">
Start Time (Raw API): {session.startTime.toString()} Start Time (Raw API): {session.startTime.toString()}
</p> </p> */}
{session.category && ( {session.category && (
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
Category:{" "} Category:{" "}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import Chart from "chart.js/auto"; import Chart, { Point, BubbleDataPoint } from "chart.js/auto";
interface DonutChartProps { interface DonutChartProps {
data: { data: {
@ -77,10 +77,24 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
const label = context.label || ""; const label = context.label || "";
const value = context.formattedValue; const value = context.formattedValue;
const total = context.chart.data.datasets[0].data.reduce( 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 0
); ) as number;
const percentage = Math.round((context.parsed * 100) / total); const percentage = Math.round((context.parsed * 100) / total);
return `${label}: ${value} (${percentage}%)`; return `${label}: ${value} (${percentage}%)`;
}, },

View File

@ -3,7 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import countryLookup from "country-code-lookup"; import * as countryCoder from "@rapideditor/country-coder";
// Define types for country data // Define types for country data
interface CountryData { interface CountryData {
@ -18,35 +18,17 @@ interface GeographicMapProps {
height?: number; // Optional height for the container 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<string, [number, number]> => { const getCountryCoordinates = (): Record<string, [number, number]> => {
// Initialize with some fallback coordinates for common countries that might be missing // Initialize with some fallback coordinates for common countries
const coordinates: Record<string, [number, number]> = { const coordinates: Record<string, [number, number]> = {
// These are just in case the lookup fails for common countries
US: [37.0902, -95.7129], US: [37.0902, -95.7129],
GB: [55.3781, -3.436], GB: [55.3781, -3.436],
BA: [43.9159, 17.6791], BA: [43.9159, 17.6791],
}; };
// This function now primarily returns fallbacks.
try { // The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
// Get all countries from the package return coordinates;
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;
}
}; };
// Load coordinates once when module is imported // Load coordinates once when module is imported
@ -83,22 +65,50 @@ export default function GeographicMap({
try { try {
// Generate CountryData array for the Map component // Generate CountryData array for the Map component
const data: CountryData[] = Object.entries(countries || {}) const data: CountryData[] = Object.entries(countries || {})
// Only include countries with known coordinates .map(([code, count]) => {
.filter(([code]) => { let countryCoords: [number, number] | undefined =
// If no coordinates found, log to help with debugging countryCoordinates[code] || DEFAULT_COORDINATES[code];
if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) {
return false; if (!countryCoords) {
} const feature = countryCoder.feature(code);
return true; if (feature && feature.geometry) {
}) if (feature.geometry.type === "Point") {
.map(([code, count]) => ({ const [lon, lat] = feature.geometry.coordinates;
code, countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
count, } else if (
coordinates: countryCoordinates[code] || feature.geometry.type === "Polygon" &&
DEFAULT_COORDINATES[code] || [0, 0], 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( console.log(
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries` `Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
); );

View File

@ -381,51 +381,53 @@ function isTruthyValue(value?: string): boolean {
* @returns A Date object or null if parsing fails. * @returns A Date object or null if parsing fails.
*/ */
function safeParseDate(dateStr?: string): Date | null { 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) // Try to parse D-M-YYYY HH:MM:SS format (with hyphens or dots)
const dateTimeRegex = const dateTimeRegex =
/^(\d{1,2})[\.-](\d{1,2})[\.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/; /^(\d{1,2})[.-](\d{1,2})[.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/;
const match = dateStr.match(dateTimeRegex); const match = dateStr.match(dateTimeRegex);
if (match) { if (match) {
const day = match[1]; const day = match[1];
const month = match[2]; const month = match[2];
const year = match[3]; const year = match[3];
const hour = match[4]; const hour = match[4];
const minute = match[5]; const minute = match[5];
const second = match[6]; const second = match[6];
// Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time) // Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time)
// Ensure month and day are two digits // 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')}`; 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 { try {
const parsedDate = new Date(dateStr); const date = new Date(formattedDateStr);
if (!isNaN(parsedDate.getTime())) { // Basic validation: check if the constructed date is valid
console.log(`[safeParseDate] Parsed with fallback: ${dateStr} -> ${parsedDate.toISOString()}`); if (!isNaN(date.getTime())) {
return parsedDate; // console.log(`[safeParseDate] Parsed from D-M-YYYY: ${dateStr} -> ${formattedDateStr} -> ${date.toISOString()}`);
} return date;
}
} catch (e) { } 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}`); console.warn(`Failed to parse date string: ${dateStr}`);
return null; return null;
} }
export async function fetchAndParseCsv( export async function fetchAndParseCsv(

View File

@ -310,7 +310,7 @@ export function sessionMetrics(
sessions: ChatSession[], sessions: ChatSession[],
companyConfig: CompanyConfig = {} companyConfig: CompanyConfig = {}
): MetricsResult { ): MetricsResult {
const total = sessions.length; const totalSessions = sessions.length; // Renamed from 'total' for clarity
const byDay: DayMetrics = {}; const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {}; const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {}; const byLanguage: LanguageMetrics = {};
@ -318,218 +318,220 @@ export function sessionMetrics(
const tokensByDay: DayMetrics = {}; const tokensByDay: DayMetrics = {};
const tokensCostByDay: DayMetrics = {}; const tokensCostByDay: DayMetrics = {};
let escalated = 0, let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult
forwarded = 0; let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult
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
// Variables for calculations
const uniqueUserIds = new Set<string>();
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 wordCounts: { [key: string]: number } = {};
const uniqueUserIdsCurrent = new Set<string>(); let alerts = 0;
let minDateCurrentPeriod = new Date(); for (const session of sessions) {
if (sessions.length > 0) { // Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
minDateCurrentPeriod = new Date( let identifierAdded = false;
Math.min(...sessions.map((s) => s.startTime.getTime())) if (session.ipAddress && session.ipAddress.trim() !== "") {
); uniqueUserIds.add(session.ipAddress.trim());
} identifierAdded = true;
const prevPeriodEndDate = new Date(minDateCurrentPeriod);
prevPeriodEndDate.setDate(prevPeriodEndDate.getDate() - 1);
const prevPeriodStartDate = new Date(prevPeriodEndDate);
prevPeriodStartDate.setDate(prevPeriodStartDate.getDate() - 6); // 7-day previous period
let prevPeriodSessionsCount = 0;
const prevPeriodUniqueUserIds = new Set<string>();
let prevPeriodTotalDuration = 0;
let prevPeriodDurationCount = 0;
let prevPeriodTotalResponseTime = 0;
let prevPeriodResponseCount = 0;
sessions.forEach((s) => {
const 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);
} }
// Fallback to sessionId only if ipAddress was not usable and sessionId is valid
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)
if ( if (
sessionDate >= prevPeriodStartDate && !identifierAdded &&
sessionDate <= prevPeriodEndDate session.sessionId &&
session.sessionId.trim() !== ""
) { ) {
prevPeriodSessionsCount++; uniqueUserIds.add(session.sessionId.trim());
if (s.userId) { }
prevPeriodUniqueUserIds.add(s.userId);
// 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) { if (isNaN(endTimeMs)) {
const duration = console.warn(
(s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60); `[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}`
const MAX_REASONABLE_DURATION = 24 * 60; );
if (duration > 0 && duration < MAX_REASONABLE_DURATION) { }
prevPeriodTotalDuration += duration;
prevPeriodDurationCount++; 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; processTextForWordCloud(session.initialMsg);
prevPeriodResponseCount++; processTextForWordCloud(session.transcriptContent);
}
}
});
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++;
}
} }
const dayCount = Object.keys(byDay).length; const uniqueUsers = uniqueUserIds.size;
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0; 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) const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
.map(([text, value]) => ({ text, value })) .sort(([, a], [, b]) => b - a)
.sort((a, b) => b.value - a.value) .slice(0, 50) // Top 50 words
.slice(0, 500); .map(([text, value]) => ({ text, value }));
// Calculate avgSessionsPerDay
const numDaysWithSessions = Object.keys(byDay).length;
const avgSessionsPerDay =
numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0;
return { return {
totalSessions: total, totalSessions,
avgSessionsPerDay: parseFloat(avgSessionsPerDay.toFixed(1)), uniqueUsers,
avgSessionLength: parseFloat(avgSessionLengthCurrent.toFixed(1)), avgSessionLength, // Corrected to match MetricsResult interface
days: byDay, avgResponseTime, // Corrected to match MetricsResult interface
languages: byLanguage, escalatedCount,
categories: byCategory, forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount)
countries: byCountry, sentimentPositiveCount,
belowThresholdCount: belowThreshold, sentimentNeutralCount,
escalatedCount: escalated, sentimentNegativeCount,
forwardedCount: forwarded, days: byDay, // Corrected to match MetricsResult interface (days)
avgSentiment: sentimentCount categories: byCategory, // Corrected to match MetricsResult interface (categories)
? parseFloat((totalSentiment / sentimentCount).toFixed(2)) languages: byLanguage, // Corrected to match MetricsResult interface (languages)
: undefined, countries: byCountry, // Corrected to match MetricsResult interface (countries)
avgResponseTime: parseFloat(avgResponseTimeCurrentPeriod.toFixed(2)),
totalTokens,
totalTokensEur,
sentimentThreshold: threshold,
lastUpdated: Date.now(),
sentimentPositiveCount: sentimentPositive,
sentimentNeutralCount: sentimentNeutral,
sentimentNegativeCount: sentimentNegative,
tokensByDay, tokensByDay,
tokensCostByDay, tokensCostByDay,
totalTokens,
totalTokensEur,
wordCloudData, wordCloudData,
uniqueUsers: uniqueUserIdsCurrent.size, belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount)
sessionTrend, avgSessionsPerDay, // Added to satisfy MetricsResult interface
usersTrend, // Optional fields from MetricsResult that are not yet calculated can be added here or handled by the consumer
avgSessionTimeTrend, // avgSentiment, sentimentThreshold, lastUpdated, sessionTrend, usersTrend, avgSessionTimeTrend, avgResponseTimeTrend
avgResponseTimeTrend,
}; };
} }

View File

@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */ /**
* @type {import('next').NextConfig}
**/
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
// Allow cross-origin requests from specific origins in development // Allow cross-origin requests from specific origins in development

55
package-lock.json generated
View File

@ -1,14 +1,15 @@
{ {
"name": "livedash-node", "name": "livedash-node",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "livedash-node", "name": "livedash-node",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@prisma/client": "^6.8.2", "@prisma/client": "^6.8.2",
"@rapideditor/country-coder": "^5.4.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/d3-cloud": "^1.2.9", "@types/d3-cloud": "^1.2.9",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
@ -17,7 +18,6 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chart.js": "^4.0.0", "chart.js": "^4.0.0",
"chartjs-plugin-annotation": "^3.1.0", "chartjs-plugin-annotation": "^3.1.0",
"country-code-lookup": "^0.1.3",
"csv-parse": "^5.5.0", "csv-parse": "^5.5.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"d3-cloud": "^1.2.7", "d3-cloud": "^1.2.7",
@ -1151,6 +1151,18 @@
"@prisma/debug": "6.8.2" "@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": { "node_modules/@react-leaflet/core": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@ -3031,12 +3043,6 @@
"node": ">= 0.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": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@ -6042,6 +6048,12 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -7547,6 +7559,21 @@
], ],
"license": "MIT" "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": { "node_modules/react": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@ -8972,6 +8999,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/which-typed-array": {
"version": "1.1.19", "version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",

View File

@ -1,22 +1,11 @@
{ {
"name": "livedash-node", "name": "livedash-node",
"type": "module",
"version": "0.2.0", "version": "0.2.0",
"private": true, "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": { "dependencies": {
"@prisma/client": "^6.8.2", "@prisma/client": "^6.8.2",
"@rapideditor/country-coder": "^5.4.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/d3-cloud": "^1.2.9", "@types/d3-cloud": "^1.2.9",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
@ -25,7 +14,6 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chart.js": "^4.0.0", "chart.js": "^4.0.0",
"chartjs-plugin-annotation": "^3.1.0", "chartjs-plugin-annotation": "^3.1.0",
"country-code-lookup": "^0.1.3",
"csv-parse": "^5.5.0", "csv-parse": "^5.5.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"d3-cloud": "^1.2.7", "d3-cloud": "^1.2.7",
@ -63,5 +51,28 @@
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.0.0" "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
} }
} }

View File

@ -40,7 +40,7 @@ export default async function handler(
const pageSize = Number(queryPageSize) || 10; const pageSize = Number(queryPageSize) || 10;
try { try {
const whereClause: Prisma.SessionWhereInput = { companyId }; const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term // Search Term
if ( if (
@ -49,10 +49,10 @@ export default async function handler(
searchTerm.trim() !== "" searchTerm.trim() !== ""
) { ) {
const searchConditions = [ const searchConditions = [
{ id: { contains: searchTerm } }, { id: { contains: searchTerm } },
{ category: { contains: searchTerm } }, { category: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } }, { initialMsg: { contains: searchTerm } },
{ transcriptContent: { contains: searchTerm } }, { transcriptContent: { contains: searchTerm } },
]; ];
whereClause.OR = searchConditions; whereClause.OR = searchConditions;
} }
@ -69,59 +69,59 @@ export default async function handler(
// Date Range Filter // Date Range Filter
if (startDate && typeof startDate === "string") { if (startDate && typeof startDate === "string") {
whereClause.startTime = { whereClause.startTime = {
...((whereClause.startTime as object) || {}), ...((whereClause.startTime as object) || {}),
gte: new Date(startDate), gte: new Date(startDate),
}; };
} }
if (endDate && typeof endDate === "string") { if (endDate && typeof endDate === "string") {
const inclusiveEndDate = new Date(endDate); const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1); inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = { whereClause.startTime = {
...((whereClause.startTime as object) || {}), ...((whereClause.startTime as object) || {}),
lt: inclusiveEndDate, lt: inclusiveEndDate,
};
}
// 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" }, // Sorting
// which is the correct overall default sort. 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({ const prismaSessions = await prisma.session.findMany({
where: whereClause, where: whereClause,
orderBy: orderByCondition, orderBy: orderByCondition,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}); });

View File

@ -1,36 +1,36 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": false, // Allow implicit any types
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "forceConsistentCasingInFileNames": true,
"moduleResolution": "node", "incremental": true,
"resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"noImplicitAny": false, // Allow implicit any types
"paths": {
"@/*": ["./*"]
},
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "resolveJsonModule": true,
"@/*": ["./*"] "skipLibCheck": true,
}, "strict": true,
"strictNullChecks": true "strictNullChecks": true,
"target": "es5"
}, },
"exclude": ["node_modules"],
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
"components/SessionDetails.tsx.bak" "components/SessionDetails.tsx.bak"
], ]
"exclude": ["node_modules"]
} }