mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 10:52:08 +01:00
Refactor components and enhance metrics calculations:
- Update access denied messages to use HTML entities. - Add autoComplete attributes to forms for better user experience. - Improve trend calculations in sessionMetrics function. - Update MetricCard props to accept React nodes for icons. - Integrate Next.js Image component in Sidebar for optimization. - Adjust ESLint rules for better code quality. - Add new properties for trends in MetricsResult interface. - Bump version to 0.2.0 in package.json.
This commit is contained in:
@ -77,7 +77,7 @@ export default function CompanySettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
<p>You don't have permission to view company settings.</p>
|
<p>You don't have permission to view company settings.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -103,6 +103,7 @@ export default function CompanySettingsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSave();
|
handleSave();
|
||||||
}}
|
}}
|
||||||
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="font-medium text-gray-700">
|
<label className="font-medium text-gray-700">
|
||||||
@ -114,6 +115,7 @@ export default function CompanySettingsPage() {
|
|||||||
value={csvUrl}
|
value={csvUrl}
|
||||||
onChange={(e) => setCsvUrl(e.target.value)}
|
onChange={(e) => setCsvUrl(e.target.value)}
|
||||||
placeholder="https://example.com/data.csv"
|
placeholder="https://example.com/data.csv"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -125,6 +127,7 @@ export default function CompanySettingsPage() {
|
|||||||
value={csvUsername}
|
value={csvUsername}
|
||||||
onChange={(e) => setCsvUsername(e.target.value)}
|
onChange={(e) => setCsvUsername(e.target.value)}
|
||||||
placeholder="Username for CSV access (if needed)"
|
placeholder="Username for CSV access (if needed)"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -136,6 +139,7 @@ export default function CompanySettingsPage() {
|
|||||||
value={csvPassword}
|
value={csvPassword}
|
||||||
onChange={(e) => setCsvPassword(e.target.value)}
|
onChange={(e) => setCsvPassword(e.target.value)}
|
||||||
placeholder="Password will be updated only if provided"
|
placeholder="Password will be updated only if provided"
|
||||||
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Leave blank to keep current password
|
Leave blank to keep current password
|
||||||
@ -154,6 +158,7 @@ export default function CompanySettingsPage() {
|
|||||||
placeholder="Threshold value (0-100)"
|
placeholder="Threshold value (0-100)"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Percentage of negative sentiment sessions to trigger alert (0-100)
|
Percentage of negative sentiment sessions to trigger alert (0-100)
|
||||||
|
|||||||
@ -246,12 +246,12 @@ function DashboardContent() {
|
|||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.sessionTrend,
|
value: metrics.sessionTrend ?? 0,
|
||||||
label:
|
label:
|
||||||
metrics.sessionTrend > 0
|
(metrics.sessionTrend ?? 0) > 0
|
||||||
? `${metrics.sessionTrend}% increase`
|
? `${metrics.sessionTrend ?? 0}% increase`
|
||||||
: `${Math.abs(metrics.sessionTrend || 0)}% decrease`,
|
: `${Math.abs(metrics.sessionTrend ?? 0)}% decrease`,
|
||||||
positive: metrics.sessionTrend >= 0,
|
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@ -274,12 +274,12 @@ function DashboardContent() {
|
|||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.usersTrend,
|
value: metrics.usersTrend ?? 0,
|
||||||
label:
|
label:
|
||||||
metrics.usersTrend > 0
|
metrics.usersTrend > 0
|
||||||
? `${metrics.usersTrend}% increase`
|
? `${metrics.usersTrend}% increase`
|
||||||
: `${Math.abs(metrics.usersTrend || 0)}% decrease`,
|
: `${Math.abs(metrics.usersTrend || 0)}% decrease`,
|
||||||
positive: metrics.usersTrend >= 0,
|
isPositive: metrics.usersTrend >= 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@ -302,12 +302,12 @@ function DashboardContent() {
|
|||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.sessionTimeTrend,
|
value: metrics.sessionTimeTrend ?? 0,
|
||||||
label:
|
label:
|
||||||
metrics.sessionTimeTrend > 0
|
metrics.sessionTimeTrend > 0
|
||||||
? `${metrics.sessionTimeTrend}% increase`
|
? `${metrics.sessionTimeTrend}% increase`
|
||||||
: `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`,
|
: `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`,
|
||||||
positive: metrics.sessionTimeTrend >= 0,
|
isPositive: metrics.sessionTimeTrend >= 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@ -330,12 +330,12 @@ function DashboardContent() {
|
|||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
trend={{
|
trend={{
|
||||||
value: metrics.responseTimeTrend,
|
value: metrics.avgResponseTimeTrend ?? 0,
|
||||||
label:
|
label:
|
||||||
metrics.responseTimeTrend > 0
|
(metrics.avgResponseTimeTrend ?? 0) > 0
|
||||||
? `${metrics.responseTimeTrend}% increase`
|
? `${metrics.avgResponseTimeTrend ?? 0}% increase`
|
||||||
: `${Math.abs(metrics.responseTimeTrend || 0)}% decrease`,
|
: `${Math.abs(metrics.avgResponseTimeTrend ?? 0)}% decrease`,
|
||||||
positive: metrics.responseTimeTrend <= 0, // Lower response time is better
|
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -345,7 +345,7 @@ function DashboardContent() {
|
|||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
Sessions Over Time
|
Sessions Over Time
|
||||||
</h3>
|
</h3>
|
||||||
<SessionsLineChart sessions={metrics.sessionsByDate || {}} />
|
<SessionsLineChart sessionsPerDay={metrics.days} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export default function UserManagementPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
<p>You don't have permission to view user management.</p>
|
<p>You don't have permission to view user management.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -102,6 +102,7 @@ export default function UserManagementPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inviteUser();
|
inviteUser();
|
||||||
}}
|
}}
|
||||||
|
autoComplete="off" // Disable autofill for the form
|
||||||
>
|
>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="font-medium text-gray-700">Email</label>
|
<label className="font-medium text-gray-700">Email</label>
|
||||||
@ -112,6 +113,7 @@ export default function UserManagementPage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
autoComplete="off" // Disable autofill for this input
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,6 @@ const getCountryCoordinates = (): Record<string, [number, number]> => {
|
|||||||
|
|
||||||
return coordinates;
|
return coordinates;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Error loading country coordinates:", error);
|
console.error("Error loading country coordinates:", error);
|
||||||
return coordinates;
|
return coordinates;
|
||||||
}
|
}
|
||||||
@ -88,8 +87,6 @@ export default function GeographicMap({
|
|||||||
.filter(([code]) => {
|
.filter(([code]) => {
|
||||||
// If no coordinates found, log to help with debugging
|
// If no coordinates found, log to help with debugging
|
||||||
if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) {
|
if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`Missing coordinates for country code: ${code}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -102,14 +99,12 @@ export default function GeographicMap({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Log for debugging
|
// Log for debugging
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
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`
|
||||||
);
|
);
|
||||||
|
|
||||||
setCountryData(data);
|
setCountryData(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Error processing geographic data:", error);
|
console.error("Error processing geographic data:", error);
|
||||||
setCountryData([]);
|
setCountryData([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ interface MetricCardProps {
|
|||||||
title: string;
|
title: string;
|
||||||
value: string | number | null | undefined;
|
value: string | number | null | undefined;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: React.ReactNode;
|
||||||
trend?: {
|
trend?: {
|
||||||
value: number;
|
value: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image"; // Import the Next.js Image component
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
@ -190,10 +191,13 @@ export default function Sidebar() {
|
|||||||
<div
|
<div
|
||||||
className={`relative ${isExpanded ? "w-16" : "w-10"} aspect-square mb-1`}
|
className={`relative ${isExpanded ? "w-16" : "w-10"} aspect-square mb-1`}
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src="/favicon.svg"
|
src="/favicon.svg"
|
||||||
alt="LiveDash Logo"
|
alt="LiveDash Logo"
|
||||||
className="w-full h-full transition-all duration-300"
|
fill
|
||||||
|
style={{ objectFit: "contain" }}
|
||||||
|
className="transition-all duration-300"
|
||||||
|
priority // Added priority prop for LCP optimization
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
|
|||||||
@ -26,13 +26,13 @@ const eslintConfig = [
|
|||||||
"coverage/",
|
"coverage/",
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"@typescript-eslint/no-unused-vars": "warn",
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "warn",
|
||||||
"no-console": "warn",
|
"no-console": "off",
|
||||||
"no-trailing-spaces": "error",
|
"no-trailing-spaces": "warn",
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "warn",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
176
lib/metrics.ts
176
lib/metrics.ts
@ -314,7 +314,7 @@ export function sessionMetrics(
|
|||||||
const byDay: DayMetrics = {};
|
const byDay: DayMetrics = {};
|
||||||
const byCategory: CategoryMetrics = {};
|
const byCategory: CategoryMetrics = {};
|
||||||
const byLanguage: LanguageMetrics = {};
|
const byLanguage: LanguageMetrics = {};
|
||||||
const byCountry: CountryMetrics = {}; // Added for country data
|
const byCountry: CountryMetrics = {};
|
||||||
const tokensByDay: DayMetrics = {};
|
const tokensByDay: DayMetrics = {};
|
||||||
const tokensCostByDay: DayMetrics = {};
|
const tokensCostByDay: DayMetrics = {};
|
||||||
|
|
||||||
@ -322,49 +322,68 @@ export function sessionMetrics(
|
|||||||
forwarded = 0;
|
forwarded = 0;
|
||||||
let totalSentiment = 0,
|
let totalSentiment = 0,
|
||||||
sentimentCount = 0;
|
sentimentCount = 0;
|
||||||
let totalResponse = 0,
|
let totalResponseTimeCurrent = 0, // Renamed to avoid conflict
|
||||||
responseCount = 0;
|
responseCountCurrent = 0; // Renamed to avoid conflict
|
||||||
let totalTokens = 0,
|
let totalTokens = 0,
|
||||||
totalTokensEur = 0;
|
totalTokensEur = 0;
|
||||||
|
|
||||||
// For sentiment distribution
|
|
||||||
let sentimentPositive = 0,
|
let sentimentPositive = 0,
|
||||||
sentimentNegative = 0,
|
sentimentNegative = 0,
|
||||||
sentimentNeutral = 0;
|
sentimentNeutral = 0;
|
||||||
|
|
||||||
// Calculate total session duration in minutes
|
let totalDurationCurrent = 0, // Renamed to avoid conflict
|
||||||
let totalDuration = 0;
|
durationCountCurrent = 0; // Renamed to avoid conflict
|
||||||
let durationCount = 0;
|
|
||||||
|
|
||||||
const wordCounts: { [key: string]: number } = {}; // For WordCloud
|
const wordCounts: { [key: string]: number } = {};
|
||||||
|
const uniqueUserIdsCurrent = new Set<string>();
|
||||||
|
|
||||||
|
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) => {
|
sessions.forEach((s) => {
|
||||||
const day = s.startTime.toISOString().slice(0, 10);
|
const sessionDate = s.startTime;
|
||||||
|
const day = sessionDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Aggregate current period data
|
||||||
byDay[day] = (byDay[day] || 0) + 1;
|
byDay[day] = (byDay[day] || 0) + 1;
|
||||||
|
if (s.userId) {
|
||||||
|
uniqueUserIdsCurrent.add(s.userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
|
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
|
||||||
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
|
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
|
||||||
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1; // Populate byCountry
|
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1;
|
||||||
|
|
||||||
// Process token usage by day
|
|
||||||
if (s.tokens) {
|
if (s.tokens) {
|
||||||
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
|
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process token cost by day
|
|
||||||
if (s.tokensEur) {
|
if (s.tokensEur) {
|
||||||
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
|
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.endTime) {
|
if (s.endTime) {
|
||||||
const duration =
|
const duration =
|
||||||
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
|
(s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60); // minutes
|
||||||
|
const MAX_REASONABLE_DURATION = 24 * 60;
|
||||||
// Sanity check: Only include sessions with reasonable durations (less than 24 hours)
|
|
||||||
const MAX_REASONABLE_DURATION = 24 * 60; // 24 hours in minutes
|
|
||||||
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
|
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
|
||||||
totalDuration += duration;
|
totalDurationCurrent += duration;
|
||||||
durationCount++;
|
durationCountCurrent++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -374,45 +393,98 @@ export function sessionMetrics(
|
|||||||
if (s.sentiment != null) {
|
if (s.sentiment != null) {
|
||||||
totalSentiment += s.sentiment;
|
totalSentiment += s.sentiment;
|
||||||
sentimentCount++;
|
sentimentCount++;
|
||||||
|
if (s.sentiment > 0.3) sentimentPositive++;
|
||||||
// Classify sentiment
|
else if (s.sentiment < -0.3) sentimentNegative++;
|
||||||
if (s.sentiment > 0.3) {
|
else sentimentNeutral++;
|
||||||
sentimentPositive++;
|
|
||||||
} else if (s.sentiment < -0.3) {
|
|
||||||
sentimentNegative++;
|
|
||||||
} else {
|
|
||||||
sentimentNeutral++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.avgResponseTime != null) {
|
if (s.avgResponseTime != null) {
|
||||||
totalResponse += s.avgResponseTime;
|
totalResponseTimeCurrent += s.avgResponseTime;
|
||||||
responseCount++;
|
responseCountCurrent++;
|
||||||
}
|
}
|
||||||
|
|
||||||
totalTokens += s.tokens || 0;
|
totalTokens += s.tokens || 0;
|
||||||
totalTokensEur += s.tokensEur || 0;
|
totalTokensEur += s.tokensEur || 0;
|
||||||
|
|
||||||
// Process transcript for WordCloud
|
|
||||||
if (s.transcriptContent) {
|
if (s.transcriptContent) {
|
||||||
const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g); // Split into words, lowercase
|
const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g);
|
||||||
if (words) {
|
if (words) {
|
||||||
words.forEach((word) => {
|
words.forEach((word) => {
|
||||||
const cleanedWord = word.replace(/[^a-z0-9]/gi, ""); // Remove punctuation
|
const cleanedWord = word.replace(/[^a-z0-9]/gi, "");
|
||||||
if (
|
if (
|
||||||
cleanedWord &&
|
cleanedWord &&
|
||||||
!stopWords.has(cleanedWord) &&
|
!stopWords.has(cleanedWord) &&
|
||||||
cleanedWord.length > 2
|
cleanedWord.length > 2
|
||||||
) {
|
) {
|
||||||
// Check if not a stop word and length > 2
|
|
||||||
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
|
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aggregate previous period data (if session falls within the previous period range)
|
||||||
|
if (
|
||||||
|
sessionDate >= prevPeriodStartDate &&
|
||||||
|
sessionDate <= prevPeriodEndDate
|
||||||
|
) {
|
||||||
|
prevPeriodSessionsCount++;
|
||||||
|
if (s.userId) {
|
||||||
|
prevPeriodUniqueUserIds.add(s.userId);
|
||||||
|
}
|
||||||
|
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 (s.avgResponseTime != null) {
|
||||||
|
prevPeriodTotalResponseTime += s.avgResponseTime;
|
||||||
|
prevPeriodResponseCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now add sentiment alert logic:
|
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;
|
let belowThreshold = 0;
|
||||||
const threshold = companyConfig.sentimentAlert ?? null;
|
const threshold = companyConfig.sentimentAlert ?? null;
|
||||||
if (threshold != null) {
|
if (threshold != null) {
|
||||||
@ -421,45 +493,43 @@ export function sessionMetrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate average sessions per day
|
|
||||||
const dayCount = Object.keys(byDay).length;
|
const dayCount = Object.keys(byDay).length;
|
||||||
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
|
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
|
||||||
|
|
||||||
// Calculate average session length
|
|
||||||
const avgSessionLength =
|
|
||||||
durationCount > 0 ? totalDuration / durationCount : null;
|
|
||||||
|
|
||||||
// Prepare wordCloudData
|
|
||||||
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
|
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
|
||||||
.map(([text, value]) => ({ text, value }))
|
.map(([text, value]) => ({ text, value }))
|
||||||
.sort((a, b) => b.value - a.value)
|
.sort((a, b) => b.value - a.value)
|
||||||
.slice(0, 500); // Take top 500 words
|
.slice(0, 500);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSessions: total,
|
totalSessions: total,
|
||||||
avgSessionsPerDay,
|
avgSessionsPerDay: parseFloat(avgSessionsPerDay.toFixed(1)),
|
||||||
avgSessionLength,
|
avgSessionLength: parseFloat(avgSessionLengthCurrent.toFixed(1)),
|
||||||
days: byDay,
|
days: byDay,
|
||||||
languages: byLanguage,
|
languages: byLanguage,
|
||||||
categories: byCategory, // This will be empty if we are not using categories for word cloud
|
categories: byCategory,
|
||||||
countries: byCountry, // Added countries to the result
|
countries: byCountry,
|
||||||
belowThresholdCount: belowThreshold,
|
belowThresholdCount: belowThreshold,
|
||||||
// Additional metrics not in the interface - using type assertion
|
|
||||||
escalatedCount: escalated,
|
escalatedCount: escalated,
|
||||||
forwardedCount: forwarded,
|
forwardedCount: forwarded,
|
||||||
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : undefined,
|
avgSentiment: sentimentCount
|
||||||
avgResponseTime: responseCount ? totalResponse / responseCount : undefined,
|
? parseFloat((totalSentiment / sentimentCount).toFixed(2))
|
||||||
|
: undefined,
|
||||||
|
avgResponseTime: parseFloat(avgResponseTimeCurrentPeriod.toFixed(2)),
|
||||||
totalTokens,
|
totalTokens,
|
||||||
totalTokensEur,
|
totalTokensEur,
|
||||||
sentimentThreshold: threshold,
|
sentimentThreshold: threshold,
|
||||||
lastUpdated: Date.now(), // Add current timestamp
|
lastUpdated: Date.now(),
|
||||||
|
|
||||||
// New metrics for enhanced dashboard
|
|
||||||
sentimentPositiveCount: sentimentPositive,
|
sentimentPositiveCount: sentimentPositive,
|
||||||
sentimentNeutralCount: sentimentNeutral,
|
sentimentNeutralCount: sentimentNeutral,
|
||||||
sentimentNegativeCount: sentimentNegative,
|
sentimentNegativeCount: sentimentNegative,
|
||||||
tokensByDay,
|
tokensByDay,
|
||||||
tokensCostByDay,
|
tokensCostByDay,
|
||||||
wordCloudData, // Added word cloud data
|
wordCloudData,
|
||||||
|
uniqueUsers: uniqueUserIdsCurrent.size,
|
||||||
|
sessionTrend,
|
||||||
|
usersTrend,
|
||||||
|
avgSessionTimeTrend,
|
||||||
|
avgResponseTimeTrend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,6 +131,13 @@ export interface MetricsResult {
|
|||||||
tokensByDay?: DayMetrics;
|
tokensByDay?: DayMetrics;
|
||||||
tokensCostByDay?: DayMetrics;
|
tokensCostByDay?: DayMetrics;
|
||||||
wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud
|
wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud
|
||||||
|
|
||||||
|
// Properties for overview page cards and trends
|
||||||
|
uniqueUsers?: number;
|
||||||
|
sessionTrend?: number; // e.g., percentage change in totalSessions
|
||||||
|
usersTrend?: number; // e.g., percentage change in uniqueUsers
|
||||||
|
avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength
|
||||||
|
avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "livedash-node",
|
"name": "livedash-node",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -12,7 +12,8 @@
|
|||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "node prisma/seed.mjs"
|
"prisma:seed": "node prisma/seed.mjs",
|
||||||
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user