mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 08:52:10 +01:00
feat: Enhance dashboard metrics with new calculations and add Top Questions Chart component
This commit is contained in:
@ -17,6 +17,7 @@ import GeographicMap from "../../../components/GeographicMap";
|
|||||||
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||||
import WelcomeBanner from "../../../components/WelcomeBanner";
|
import WelcomeBanner from "../../../components/WelcomeBanner";
|
||||||
import DateRangePicker from "../../../components/DateRangePicker";
|
import DateRangePicker from "../../../components/DateRangePicker";
|
||||||
|
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
||||||
|
|
||||||
// Safely wrapped component with useSession
|
// Safely wrapped component with useSession
|
||||||
function DashboardContent() {
|
function DashboardContent() {
|
||||||
@ -268,7 +269,7 @@ function DashboardContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7 gap-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Total Sessions"
|
title="Total Sessions"
|
||||||
value={metrics.totalSessions}
|
value={metrics.totalSessions}
|
||||||
@ -365,6 +366,70 @@ function DashboardContent() {
|
|||||||
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
|
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Avg. Daily Costs"
|
||||||
|
value={`€${metrics.avgDailyCosts?.toFixed(4) || '0.0000'}`}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Peak Usage Time"
|
||||||
|
value={metrics.peakUsageTime || 'N/A'}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Resolved Chats"
|
||||||
|
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || '0.0'}%`}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.resolvedChatsPercentage ?? 0,
|
||||||
|
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80, // 80%+ resolution rate is good
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
@ -429,6 +494,9 @@ function DashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top Questions Chart */}
|
||||||
|
<TopQuestionsChart data={metrics.topQuestions || []} />
|
||||||
|
|
||||||
<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">
|
||||||
Response Time Distribution
|
Response Time Distribution
|
||||||
|
|||||||
74
components/TopQuestionsChart.tsx
Normal file
74
components/TopQuestionsChart.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { TopQuestion } from '../lib/types';
|
||||||
|
|
||||||
|
interface TopQuestionsChartProps {
|
||||||
|
data: TopQuestion[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopQuestionsChart({ data, title = "Top 5 Asked Questions" }: TopQuestionsChartProps) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No questions data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the maximum count to calculate relative bar widths
|
||||||
|
const maxCount = Math.max(...data.map(q => q.count));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.map((question, index) => {
|
||||||
|
const percentage = maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="relative">
|
||||||
|
{/* Question text */}
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<p className="text-sm text-gray-700 font-medium leading-tight pr-4 flex-1">
|
||||||
|
{question.question}
|
||||||
|
</p>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 bg-gray-100 px-2 py-1 rounded-md whitespace-nowrap">
|
||||||
|
{question.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-in-out"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rank indicator */}
|
||||||
|
<div className="absolute -left-2 top-0 w-6 h-6 bg-blue-600 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>Total questions analyzed</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{data.reduce((sum, q) => sum + q.count, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
CountryMetrics, // Added CountryMetrics
|
CountryMetrics, // Added CountryMetrics
|
||||||
MetricsResult,
|
MetricsResult,
|
||||||
WordCloudWord, // Added WordCloudWord
|
WordCloudWord, // Added WordCloudWord
|
||||||
|
TopQuestion, // Added TopQuestion
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface CompanyConfig {
|
interface CompanyConfig {
|
||||||
@ -349,7 +350,23 @@ export function sessionMetrics(
|
|||||||
const wordCounts: { [key: string]: number } = {};
|
const wordCounts: { [key: string]: number } = {};
|
||||||
let alerts = 0;
|
let alerts = 0;
|
||||||
|
|
||||||
|
// New metrics variables
|
||||||
|
const hourlySessionCounts: { [hour: string]: number } = {};
|
||||||
|
let resolvedChatsCount = 0;
|
||||||
|
const questionCounts: { [question: string]: number } = {};
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
|
// Track hourly usage for peak time calculation
|
||||||
|
if (session.startTime) {
|
||||||
|
const hour = new Date(session.startTime).getHours();
|
||||||
|
const hourKey = `${hour.toString().padStart(2, '0')}:00`;
|
||||||
|
hourlySessionCounts[hourKey] = (hourlySessionCounts[hourKey] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count resolved chats (sessions that have ended and are not escalated)
|
||||||
|
if (session.endTime && !session.escalated) {
|
||||||
|
resolvedChatsCount++;
|
||||||
|
}
|
||||||
// Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
|
// Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
|
||||||
let identifierAdded = false;
|
let identifierAdded = false;
|
||||||
if (session.ipAddress && session.ipAddress.trim() !== "") {
|
if (session.ipAddress && session.ipAddress.trim() !== "") {
|
||||||
@ -487,6 +504,51 @@ export function sessionMetrics(
|
|||||||
byCountry[session.country] = (byCountry[session.country] || 0) + 1;
|
byCountry[session.country] = (byCountry[session.country] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract questions from session
|
||||||
|
const extractQuestions = () => {
|
||||||
|
// 1. Extract from questions JSON field
|
||||||
|
if (session.questions) {
|
||||||
|
try {
|
||||||
|
const questionsArray = JSON.parse(session.questions);
|
||||||
|
if (Array.isArray(questionsArray)) {
|
||||||
|
questionsArray.forEach((question: string) => {
|
||||||
|
if (question && question.trim().length > 0) {
|
||||||
|
const cleanQuestion = question.trim();
|
||||||
|
questionCounts[cleanQuestion] = (questionCounts[cleanQuestion] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[metrics] Failed to parse questions JSON for session ${session.id}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Extract questions from user messages (if available)
|
||||||
|
if (session.messages) {
|
||||||
|
session.messages
|
||||||
|
.filter(msg => msg.role === 'User')
|
||||||
|
.forEach(msg => {
|
||||||
|
const content = msg.content.trim();
|
||||||
|
// Simple heuristic: if message ends with ? or contains question words, treat as question
|
||||||
|
if (content.endsWith('?') ||
|
||||||
|
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(content)) {
|
||||||
|
questionCounts[content] = (questionCounts[content] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Extract questions from initial message as fallback
|
||||||
|
if (session.initialMsg) {
|
||||||
|
const content = session.initialMsg.trim();
|
||||||
|
if (content.endsWith('?') ||
|
||||||
|
/\b(what|when|where|why|how|who|which|can|could|would|will|is|are|do|does|did)\b/i.test(content)) {
|
||||||
|
questionCounts[content] = (questionCounts[content] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
extractQuestions();
|
||||||
|
|
||||||
// Word Cloud Data (from initial message and transcript content)
|
// Word Cloud Data (from initial message and transcript content)
|
||||||
const processTextForWordCloud = (text: string | undefined | null) => {
|
const processTextForWordCloud = (text: string | undefined | null) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@ -506,7 +568,8 @@ export function sessionMetrics(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
processTextForWordCloud(session.initialMsg);
|
processTextForWordCloud(session.initialMsg);
|
||||||
processTextForWordCloud(session.transcriptContent);
|
// Note: transcriptContent is not available in ChatSession type
|
||||||
|
// Could be added later if transcript parsing is implemented
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueUsers = uniqueUserIds.size;
|
const uniqueUsers = uniqueUserIds.size;
|
||||||
@ -547,6 +610,30 @@ export function sessionMetrics(
|
|||||||
mockPreviousPeriodData.avgResponseTime
|
mockPreviousPeriodData.avgResponseTime
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Calculate new metrics
|
||||||
|
|
||||||
|
// 1. Average Daily Costs (euros)
|
||||||
|
const avgDailyCosts = numDaysWithSessions > 0 ? totalTokensEur / numDaysWithSessions : 0;
|
||||||
|
|
||||||
|
// 2. Peak Usage Time
|
||||||
|
let peakUsageTime = "N/A";
|
||||||
|
if (Object.keys(hourlySessionCounts).length > 0) {
|
||||||
|
const peakHour = Object.entries(hourlySessionCounts)
|
||||||
|
.sort(([, a], [, b]) => b - a)[0][0];
|
||||||
|
const peakHourNum = parseInt(peakHour.split(':')[0]);
|
||||||
|
const endHour = (peakHourNum + 1) % 24;
|
||||||
|
peakUsageTime = `${peakHour}-${endHour.toString().padStart(2, '0')}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolved Chats Percentage
|
||||||
|
const resolvedChatsPercentage = totalSessions > 0 ? (resolvedChatsCount / totalSessions) * 100 : 0;
|
||||||
|
|
||||||
|
// 4. Top 5 Asked Questions
|
||||||
|
const topQuestions: TopQuestion[] = Object.entries(questionCounts)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 5) // Top 5 questions
|
||||||
|
.map(([question, count]) => ({ question, count }));
|
||||||
|
|
||||||
// console.log("Debug metrics calculation:", {
|
// console.log("Debug metrics calculation:", {
|
||||||
// totalSessionDuration,
|
// totalSessionDuration,
|
||||||
// validSessionsForDuration,
|
// validSessionsForDuration,
|
||||||
@ -585,5 +672,11 @@ export function sessionMetrics(
|
|||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
totalSessionDuration,
|
totalSessionDuration,
|
||||||
validSessionsForDuration,
|
validSessionsForDuration,
|
||||||
|
|
||||||
|
// New metrics
|
||||||
|
avgDailyCosts,
|
||||||
|
peakUsageTime,
|
||||||
|
resolvedChatsPercentage,
|
||||||
|
topQuestions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
11
lib/types.ts
11
lib/types.ts
@ -119,6 +119,11 @@ export interface WordCloudWord {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopQuestion {
|
||||||
|
question: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MetricsResult {
|
export interface MetricsResult {
|
||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
avgSessionsPerDay: number;
|
avgSessionsPerDay: number;
|
||||||
@ -153,6 +158,12 @@ export interface MetricsResult {
|
|||||||
avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength
|
avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength
|
||||||
avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime
|
avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime
|
||||||
|
|
||||||
|
// New metrics for enhanced dashboard
|
||||||
|
avgDailyCosts?: number; // Average daily costs in euros
|
||||||
|
peakUsageTime?: string; // Peak usage time (e.g., "14:00-15:00")
|
||||||
|
resolvedChatsPercentage?: number; // Percentage of resolved chats
|
||||||
|
topQuestions?: TopQuestion[]; // Top 5 most asked questions
|
||||||
|
|
||||||
// Debug properties
|
// Debug properties
|
||||||
totalSessionDuration?: number;
|
totalSessionDuration?: number;
|
||||||
validSessionsForDuration?: number;
|
validSessionsForDuration?: number;
|
||||||
|
|||||||
@ -48,6 +48,9 @@ export default async function handler(
|
|||||||
|
|
||||||
const prismaSessions = await prisma.session.findMany({
|
const prismaSessions = await prisma.session.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
messages: true, // Include messages for question extraction
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
||||||
@ -74,6 +77,9 @@ export default async function handler(
|
|||||||
forwardedHr: ps.forwardedHr || false,
|
forwardedHr: ps.forwardedHr || false,
|
||||||
initialMsg: ps.initialMsg || undefined,
|
initialMsg: ps.initialMsg || undefined,
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
||||||
|
questions: ps.questions || undefined, // Include questions field
|
||||||
|
summary: ps.summary || undefined, // Include summary field
|
||||||
|
messages: ps.messages || [], // Include messages for question extraction
|
||||||
// userId is missing in Prisma Session model, assuming it's not strictly needed for metrics or can be null
|
// userId is missing in Prisma Session model, assuming it's not strictly needed for metrics or can be null
|
||||||
userId: undefined, // Or some other default/mapping if available
|
userId: undefined, // Or some other default/mapping if available
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user