mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:52:09 +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 WelcomeBanner from "../../../components/WelcomeBanner";
|
||||
import DateRangePicker from "../../../components/DateRangePicker";
|
||||
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
||||
|
||||
// Safely wrapped component with useSession
|
||||
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
|
||||
title="Total Sessions"
|
||||
value={metrics.totalSessions}
|
||||
@ -365,6 +366,70 @@ function DashboardContent() {
|
||||
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 className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
@ -429,6 +494,9 @@ function DashboardContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Questions Chart */}
|
||||
<TopQuestionsChart data={metrics.topQuestions || []} />
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
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
|
||||
MetricsResult,
|
||||
WordCloudWord, // Added WordCloudWord
|
||||
TopQuestion, // Added TopQuestion
|
||||
} from "./types";
|
||||
|
||||
interface CompanyConfig {
|
||||
@ -349,7 +350,23 @@ export function sessionMetrics(
|
||||
const wordCounts: { [key: string]: number } = {};
|
||||
let alerts = 0;
|
||||
|
||||
// New metrics variables
|
||||
const hourlySessionCounts: { [hour: string]: number } = {};
|
||||
let resolvedChatsCount = 0;
|
||||
const questionCounts: { [question: string]: number } = {};
|
||||
|
||||
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
|
||||
let identifierAdded = false;
|
||||
if (session.ipAddress && session.ipAddress.trim() !== "") {
|
||||
@ -487,6 +504,51 @@ export function sessionMetrics(
|
||||
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)
|
||||
const processTextForWordCloud = (text: string | undefined | null) => {
|
||||
if (!text) return;
|
||||
@ -506,7 +568,8 @@ export function sessionMetrics(
|
||||
}
|
||||
};
|
||||
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;
|
||||
@ -547,6 +610,30 @@ export function sessionMetrics(
|
||||
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:", {
|
||||
// totalSessionDuration,
|
||||
// validSessionsForDuration,
|
||||
@ -585,5 +672,11 @@ export function sessionMetrics(
|
||||
lastUpdated: Date.now(),
|
||||
totalSessionDuration,
|
||||
validSessionsForDuration,
|
||||
|
||||
// New metrics
|
||||
avgDailyCosts,
|
||||
peakUsageTime,
|
||||
resolvedChatsPercentage,
|
||||
topQuestions,
|
||||
};
|
||||
}
|
||||
|
||||
11
lib/types.ts
11
lib/types.ts
@ -119,6 +119,11 @@ export interface WordCloudWord {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TopQuestion {
|
||||
question: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MetricsResult {
|
||||
totalSessions: number;
|
||||
avgSessionsPerDay: number;
|
||||
@ -153,6 +158,12 @@ export interface MetricsResult {
|
||||
avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength
|
||||
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
|
||||
totalSessionDuration?: number;
|
||||
validSessionsForDuration?: number;
|
||||
|
||||
@ -48,6 +48,9 @@ export default async function handler(
|
||||
|
||||
const prismaSessions = await prisma.session.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
messages: true, // Include messages for question extraction
|
||||
},
|
||||
});
|
||||
|
||||
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
||||
@ -74,6 +77,9 @@ export default async function handler(
|
||||
forwardedHr: ps.forwardedHr || false,
|
||||
initialMsg: ps.initialMsg || 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: undefined, // Or some other default/mapping if available
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user