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 { 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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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:{" "}
|
||||||
|
|||||||
@ -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}%)`;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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`
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
394
lib/metrics.ts
394
lib/metrics.ts
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
55
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
39
package.json
39
package.json
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user