feat(analytics): implement learning analytics feature with data collection and display
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
This commit is contained in:
178
src/components/AnalyticsList.jsx
Normal file
178
src/components/AnalyticsList.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function AnalyticsList({ analytics, onRefresh }) {
|
||||
const [loading, setLoading] = useState({});
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDurationMs = (ms) => {
|
||||
if (!ms || ms <= 0) return '0m';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm(t('analytics.deleteConfirm'))) return;
|
||||
setLoading(prev => ({ ...prev, [id]: 'deleting' }));
|
||||
try {
|
||||
await api.delete(`/analytics/${id}`);
|
||||
toast.success(t('analytics.deleted'));
|
||||
onRefresh?.();
|
||||
} catch {
|
||||
toast.error(t('analytics.deleteFailed'));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [id]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
// Extract user summary from BBB learning analytics data
|
||||
const getUserSummary = (data) => {
|
||||
if (!data?.users) return [];
|
||||
const usersObj = typeof data.users === 'object' ? data.users : {};
|
||||
return Object.values(usersObj).map(u => ({
|
||||
name: u.name || '—',
|
||||
isModerator: u.isModerator || false,
|
||||
totalTime: u.registeredOn && u.leftOn ? u.leftOn - u.registeredOn : 0,
|
||||
talkTime: u.talk?.totalTime || 0,
|
||||
webcamTime: Array.isArray(u.webcams) ? u.webcams.reduce((acc, w) => acc + ((w.stoppedSharingAt || Date.now()) - (w.startedSharingAt || 0)), 0) : 0,
|
||||
messages: u.totalOfMessages || 0,
|
||||
emojis: Array.isArray(u.emojis) ? u.emojis.length : 0,
|
||||
raiseHand: Array.isArray(u.raiseHand) ? u.raiseHand.length : 0,
|
||||
}));
|
||||
};
|
||||
|
||||
if (!analytics || analytics.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<BarChart3 size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
||||
<p className="text-th-text-s text-sm">{t('analytics.noData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{analytics.map(entry => {
|
||||
const users = getUserSummary(entry.data);
|
||||
const isExpanded = expanded[entry.id];
|
||||
const totalParticipants = users.length;
|
||||
const totalMessages = users.reduce((sum, u) => sum + u.messages, 0);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="card p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-medium text-th-text truncate">
|
||||
{entry.meetingName || entry.meetingId}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-th-text-s">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{formatDate(entry.createdAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} />
|
||||
{totalParticipants} {t('analytics.participants')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={12} />
|
||||
{totalMessages} {t('analytics.messages')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => toggleExpand(entry.id)}
|
||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
||||
title={isExpanded ? t('analytics.collapse') : t('analytics.expand')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
disabled={loading[entry.id] === 'deleting'}
|
||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && users.length > 0 && (
|
||||
<div className="mt-4 border-t border-th-border pt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-th-text-s border-b border-th-border">
|
||||
<th className="pb-2 pr-4 font-medium">{t('analytics.userName')}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t('analytics.role')}</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
<span className="flex items-center gap-1"><Mic size={11} />{t('analytics.talkTime')}</span>
|
||||
</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
<span className="flex items-center gap-1"><Video size={11} />{t('analytics.webcamTime')}</span>
|
||||
</th>
|
||||
<th className="pb-2 pr-4 font-medium">
|
||||
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
|
||||
</th>
|
||||
<th className="pb-2 font-medium">{t('analytics.reactions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u, i) => (
|
||||
<tr key={i} className="border-b border-th-border/50 last:border-0">
|
||||
<td className="py-2 pr-4 text-th-text font-medium">{u.name}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
u.isModerator
|
||||
? 'bg-th-accent/15 text-th-accent'
|
||||
: 'bg-th-bg-s text-th-text-s'
|
||||
}`}>
|
||||
{u.isModerator ? t('analytics.moderator') : t('analytics.viewer')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-th-text-s">{formatDurationMs(u.talkTime)}</td>
|
||||
<td className="py-2 pr-4 text-th-text-s">{formatDurationMs(u.webcamTime)}</td>
|
||||
<td className="py-2 pr-4 text-th-text-s">{u.messages}</td>
|
||||
<td className="py-2 text-th-text-s">{u.emojis + u.raiseHand}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user