feat: Enhance dashboard metrics with new calculations and add Top Questions Chart component

This commit is contained in:
Max Kowalski
2025-06-26 12:04:51 +02:00
parent 0e5ac69d45
commit 8f3c1e0f7c
5 changed files with 254 additions and 2 deletions

View File

@ -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

View 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>
);
}

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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
}));