mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 06:32:10 +01:00
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:
@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 80,
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg. Session Time"
|
||||
value={`${Math.round(metrics.avgSessionTime || 0)}m`}
|
||||
value={`${Math.round(metrics.avgSessionTimeTrend || 0)}m`}
|
||||
icon={
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
@ -302,12 +302,12 @@ function DashboardContent() {
|
||||
</svg>
|
||||
}
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<MetricCard
|
||||
|
||||
@ -25,7 +25,7 @@ export default function SessionViewPage() {
|
||||
|
||||
if (status === "authenticated" && id) {
|
||||
const fetchSession = async () => {
|
||||
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 (
|
||||
|
||||
@ -283,12 +283,12 @@ export default function SessionsPage() {
|
||||
Session ID: {session.sessionId || session.id}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-1">
|
||||
Start Time (Local):{" "}
|
||||
Start Time{/* (Local) */}:{" "}
|
||||
{new Date(session.startTime).toLocaleString()}
|
||||
</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()}
|
||||
</p>
|
||||
</p> */}
|
||||
{session.category && (
|
||||
<p className="text-sm text-gray-700">
|
||||
Category:{" "}
|
||||
|
||||
@ -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}%)`;
|
||||
},
|
||||
|
||||
@ -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<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]> = {
|
||||
// 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`
|
||||
);
|
||||
|
||||
@ -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(
|
||||
|
||||
394
lib/metrics.ts
394
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<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 uniqueUserIdsCurrent = new Set<string>();
|
||||
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<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);
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
55
package-lock.json
generated
55
package-lock.json
generated
@ -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",
|
||||
|
||||
39
package.json
39
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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"]
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user