feat(analytics): implement learning analytics feature with data collection and display
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s

This commit is contained in:
2026-03-13 09:46:15 +01:00
parent 530377272b
commit 7ef173c49e
9 changed files with 425 additions and 6 deletions

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

View File

@@ -255,7 +255,10 @@
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Freigabe fehlgeschlagen",
"shareRemove": "Freigabe entfernen",
"defaultWelcome": "Willkommen zum Meeting!"
"defaultWelcome": "Willkommen zum Meeting!",
"analytics": "Lernanalyse",
"enableAnalytics": "Lernanalyse aktivieren",
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting."
},
"recordings": {
"title": "Aufnahmen",
@@ -273,6 +276,24 @@
"publish": "Veröffentlichen",
"loadFailed": "Aufnahmen konnten nicht geladen werden"
},
"analytics": {
"title": "Lernanalyse",
"noData": "Keine Analysedaten vorhanden",
"participants": "Teilnehmer",
"messages": "Nachrichten",
"expand": "Details anzeigen",
"collapse": "Details ausblenden",
"deleteConfirm": "Analysedaten wirklich löschen?",
"deleted": "Analysedaten gelöscht",
"deleteFailed": "Fehler beim Löschen",
"userName": "Name",
"role": "Rolle",
"moderator": "Moderator",
"viewer": "Teilnehmer",
"talkTime": "Sprechzeit",
"webcamTime": "Webcam-Zeit",
"reactions": "Reaktionen"
},
"settings": {
"title": "Einstellungen",
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",

View File

@@ -255,7 +255,10 @@
"shareRemoved": "Share removed",
"shareFailed": "Share failed",
"shareRemove": "Remove share",
"defaultWelcome": "Welcome to the meeting!"
"defaultWelcome": "Welcome to the meeting!",
"analytics": "Learning Analytics",
"enableAnalytics": "Enable learning analytics",
"enableAnalyticsHint": "Collects participant engagement data after each meeting."
},
"recordings": {
"title": "Recordings",
@@ -273,6 +276,24 @@
"publish": "Publish",
"loadFailed": "Recordings could not be loaded"
},
"analytics": {
"title": "Learning Analytics",
"noData": "No analytics data available",
"participants": "Participants",
"messages": "Messages",
"expand": "Show details",
"collapse": "Hide details",
"deleteConfirm": "Really delete analytics data?",
"deleted": "Analytics data deleted",
"deleteFailed": "Error deleting data",
"userName": "Name",
"role": "Role",
"moderator": "Moderator",
"viewer": "Viewer",
"talkTime": "Talk time",
"webcamTime": "Webcam time",
"reactions": "Reactions"
},
"settings": {
"title": "Settings",
"subtitle": "Manage your profile and settings",

View File

@@ -4,13 +4,14 @@ import {
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
Shield, Save, UserPlus, X, Share2, Globe, Send,
FileText, Upload, Trash2, Link,
FileText, Upload, Trash2, Link, BarChart3,
} from 'lucide-react';
import Modal from '../components/Modal';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import RecordingList from '../components/RecordingList';
import AnalyticsList from '../components/AnalyticsList';
import toast from 'react-hot-toast';
export default function RoomDetail() {
@@ -22,6 +23,7 @@ export default function RoomDetail() {
const [room, setRoom] = useState(null);
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
const [recordings, setRecordings] = useState([]);
const [analytics, setAnalytics] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null);
const [activeTab, setActiveTab] = useState('overview');
@@ -93,10 +95,20 @@ export default function RoomDetail() {
}
};
const fetchAnalytics = async () => {
try {
const res = await api.get(`/analytics/room/${uid}`);
setAnalytics(res.data.analytics || []);
} catch {
// Ignore
}
};
useEffect(() => {
fetchRoom();
fetchStatus();
fetchRecordings();
fetchAnalytics();
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
}, [uid]);
@@ -183,6 +195,7 @@ export default function RoomDetail() {
record_meeting: !!editRoom.record_meeting,
guest_access: !!editRoom.guest_access,
moderator_code: editRoom.moderator_code,
learning_analytics: !!editRoom.learning_analytics,
});
setRoom(res.data.room);
setEditRoom(res.data.room);
@@ -331,6 +344,7 @@ export default function RoomDetail() {
const tabs = [
{ id: 'overview', label: t('room.overview'), icon: Play },
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length },
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
];
@@ -528,6 +542,10 @@ export default function RoomDetail() {
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
)}
{activeTab === 'analytics' && (
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} />
)}
{activeTab === 'settings' && isOwner && editRoom && (
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
<div>
@@ -621,6 +639,15 @@ export default function RoomDetail() {
/>
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!editRoom.learning_analytics}
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
</label>
</div>
{/* Guest access section */}