Round 12: WebSocket Collaboration, Curriculum Mapping, Attendance Analytics, Data Backup, Mobile Optimization, Performance

This commit is contained in:
Mustafa Cetin committed 2026-07-29 09:47:32 +00:00
1 parent 21310d3f62
commit ba55bd13e4
24 files changed
+6067 -947

No files matched your search

+257 -1
View File
@@ -725,7 +725,7 @@ export function fetchAuditLog(params?: {
/* ── Data Export (CSV) ────────────────────────────────────────────── */
export function downloadCsvExport(params: {
type: 'students' | 'progress' | 'assessments' | 'grades';
type: 'students' | 'progress' | 'assessments' | 'grades' | 'attendance';
classGroupId?: string;
schoolYearId?: string;
schoolId?: string;
@@ -1909,6 +1909,262 @@ export function duplicateRubric(id: string): Promise<Rubric> {
return apiPost<Rubric>(`/api/rubrics/${id}/duplicate`);
}
/* ── Rubric Templates & Grading ───────────────────────────────────── */
export interface RubricTemplate {
id: string;
title: string;
description: string;
subject: string;
type: 'ANALYTIC' | 'HOLISTIC';
maxPoints: number;
criteria: Array<{
name: string;
description: string;
weight: number;
maxPoints: number;
levels: Array<{ label: string; description: string; points: number }>;
}>;
}
export function fetchRubricTemplates(subject?: string): Promise<RubricTemplate[]> {
const sp = new URLSearchParams();
if (subject && subject !== 'all') sp.set('subject', subject);
return apiGet<RubricTemplate[]>(`/api/rubrics/templates?${sp.toString()}`);
}
export interface RubricGradeResult {
rubricId: string;
studentId: string;
finalScore: number;
maxPoints: number;
overallPercentage: number;
grade: number;
gradeLabel: string;
criterionBreakdown: Array<{
criterionId: string;
criterionName: string;
maxPoints: number;
earnedPoints: number;
selectedLevel: { id: string; label: string; description: string } | null;
percentage: number;
}>;
}
export function gradeWithRubric(rubricId: string, data: {
studentId: string;
scores: Array<{ criterionId: string; levelId: string; points: number }>;
note?: string;
}): Promise<RubricGradeResult> {
return apiPost<RubricGradeResult>(`/api/rubrics/${rubricId}/grade`, data);
}
export interface RubricAnalytics {
rubricId: string;
rubricTitle: string;
criteriaAnalytics: Array<{
criterionId: string;
criterionName: string;
maxPoints: number;
weight: number;
classAverage: number;
classMedian: number;
highestScore: number;
lowestScore: number;
levelDistribution: Array<{
levelId: string;
label: string;
points: number;
description: string;
percentage: number;
}>;
}>;
gradeDistribution: Array<{
grade: string;
range: string;
count: number;
color: string;
}>;
overallStats: {
averagePercentage: number;
totalAssessments: number;
totalStudents: number;
totalMaxPoints: number;
averageScore: number;
};
}
export function fetchRubricAnalytics(rubricId: string): Promise<RubricAnalytics> {
return apiGet<RubricAnalytics>(`/api/rubrics/${rubricId}/analytics`);
}
/* ── Curriculum Standards ─────────────────────────────────────────── */
export interface CurriculumStandard {
id: string;
schoolId: string;
subjectId: string | null;
code: string;
title: string;
description: string | null;
gradeLevel: number | null;
category: string | null;
source: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
subject: { id: string; name: string } | null;
competencyLinks: CurriculumStandardLink[];
}
export interface CurriculumStandardLink {
id: string;
standardId: string;
competencyId: string;
coverageLevel: string | null;
notes: string | null;
createdAt: string;
competency: {
id: string;
code: string;
title: string;
category: { id: string; name: string; color: string | null } | null;
};
}
export function fetchCurriculumStandards(schoolId: string, subjectId?: string, gradeLevel?: number): Promise<CurriculumStandard[]> {
const sp = new URLSearchParams();
sp.set('schoolId', schoolId);
if (subjectId && subjectId !== 'all') sp.set('subjectId', subjectId);
if (gradeLevel) sp.set('gradeLevel', String(gradeLevel));
return apiGet<CurriculumStandard[]>(`/api/curriculum-standards?${sp.toString()}`);
}
export function fetchCurriculumStandard(id: string): Promise<CurriculumStandard> {
return apiGet<CurriculumStandard>(`/api/curriculum-standards/${id}`);
}
export function createCurriculumStandard(data: {
schoolId: string;
subjectId?: string | null;
code: string;
title: string;
description?: string | null;
gradeLevel?: number | null;
category?: string | null;
source?: string | null;
isDemo?: boolean;
}): Promise<CurriculumStandard> {
return apiPost<CurriculumStandard>('/api/curriculum-standards', data);
}
export function updateCurriculumStandard(id: string, data: {
subjectId?: string | null;
code?: string;
title?: string;
description?: string | null;
gradeLevel?: number | null;
category?: string | null;
source?: string | null;
}): Promise<CurriculumStandard> {
return apiPut<CurriculumStandard>(`/api/curriculum-standards/${id}`, data);
}
export function deleteCurriculumStandard(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/curriculum-standards/${id}`);
}
export function linkCurriculumStandard(standardId: string, data: {
competencyId: string;
coverageLevel?: 'full' | 'partial' | 'related' | null;
notes?: string | null;
}): Promise<CurriculumStandardLink> {
return apiPost<CurriculumStandardLink>(`/api/curriculum-standards/${standardId}/links`, data);
}
export function unlinkCurriculumStandard(standardId: string, competencyId: string): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/curriculum-standards/${standardId}/links`, {
action: 'unlink',
competencyId,
});
}
export function fetchCurriculumStandardLinks(standardId: string): Promise<CurriculumStandardLink[]> {
return apiGet<CurriculumStandardLink[]>(`/api/curriculum-standards/${standardId}/links`);
}
/* ── Attendance Analytics ─────────────────────────────────────────── */
export interface AttendanceAnalyticsData {
trendData: Array<{
week: string;
attendanceRate: number;
absentRate: number;
present: number;
absent: number;
excused: number;
late: number;
total: number;
}>;
dayOfWeekAnalysis: Array<{
day: number;
dayName: string;
dayNameDe: string;
attendanceRate: number;
absentCount: number;
totalRecords: number;
}>;
absencePatterns: Array<{
studentId: string;
firstName: string;
lastName: string;
present: number;
absent: number;
excused: number;
late: number;
total: number;
absenceRate: number;
attendanceRate: number;
}>;
riskIndicators: Array<{
studentId: string;
firstName: string;
lastName: string;
absenceRate: number;
totalAbsences: number;
totalSessions: number;
riskLevel: 'low' | 'medium' | 'high' | 'critical';
}>;
classComparison: Array<{
classGroupId: string;
className: string;
gradeLevel: number;
present: number;
absent: number;
excused: number;
late: number;
total: number;
attendanceRate: number;
absenceRate: number;
}>;
statusDistribution: {
present: number;
absent: number;
excused: number;
late: number;
};
totalSessions: number;
totalRecords: number;
}
export function fetchAttendanceAnalytics(schoolId: string, classGroupId?: string): Promise<AttendanceAnalyticsData> {
const sp = new URLSearchParams();
sp.set('schoolId', schoolId);
if (classGroupId) sp.set('classGroupId', classGroupId);
return apiGet<AttendanceAnalyticsData>(`/api/attendance/analytics?${sp.toString()}`);
}
/* ── Comment Bank ────────────────────────────────────────────────── */
export interface CommentCategory {
+334
View File
@@ -2160,6 +2160,173 @@ const de: TranslationDict = {
'settings.default_password': 'Standardpasswort',
'settings.generate_email': 'E-Mail automatisch generieren',
'settings.link_student': 'Mit Schüler verknüpfen',
// ─── Collaboration & Real-Time ──────────────────────────────────────
'collab.online_users': '{count} online',
'collab.editing_by': '{name} bearbeitet...',
'collab.activity_title': 'Aktivität',
'collab.no_activity': 'Keine Aktivität',
'collab.started_editing': 'bearbeitet',
'collab.realtime_connected': 'Echtzeit verbunden',
'collab.realtime_disconnected': 'Echtzeit getrennt',
'collab.conflict_resolved': 'Konflikt gelöst (letzter Speichervorgang gewinnt)',
'collab.cursors_visible': 'Cursors anderer Nutzer sichtbar',
'collab.user_joined': '{name} ist beigetreten',
'collab.user_left': '{name} hat verlassen',
// ─── Notification Sound ────────────────────────────────────────────
'settings.notification_sound': 'Benachrichtigungston',
'settings.notification_sound_desc': 'Ton bei neuen Benachrichtigungen abspielen',
'settings.notification_sound_enabled': 'Ton aktiviert',
'settings.notification_sound_disabled': 'Ton deaktiviert',
// ── Rubric Enhancement ──
'rubrics.templates': 'Vorlagen',
'rubrics.templates_title': 'Bewertungsraster-Vorlagen',
'rubrics.templates_desc': 'Wählen Sie eine Vorlage als Ausgangspunkt für Ihr Bewertungsraster',
'rubrics.use_template': 'Vorlage verwenden',
'rubrics.template_math': 'Mathematik — Klassenarbeit',
'rubrics.template_german': 'Deutsch — Aufsichtsarbeit',
'rubrics.template_english': 'Englisch — Writing Task',
'rubrics.template_project': 'Allgemein — Projektarbeit',
'rubrics.template_oral': 'Allgemein — Mündliche Präsentation',
'rubrics.no_template': 'Ohne Vorlage starten',
'rubrics.grade': 'Bewerten',
'rubrics.grade_title': 'Rubrik-basierte Bewertung',
'rubrics.select_level': 'Niveau auswählen',
'rubrics.auto_grade': 'Automatische Notenberechnung',
'rubrics.final_score': 'Endpunktzahl',
'rubrics.grade_result': 'Note',
'rubrics.percentage': 'Prozent',
'rubrics.criterion_breakdown': 'Kriterien-Aufschlüsselung',
'rubrics.earned_points': 'Erreichte Punkte',
'rubrics.analytics': 'Analyse',
'rubrics.analytics_title': 'Rubrik-Analyse',
'rubrics.class_performance': 'Klassenleistung nach Kriterium',
'rubrics.grade_distribution': 'Notenverteilung',
'rubrics.average_score': 'Durchschnitt',
'rubrics.highest_score': 'Höchste Punktzahl',
'rubrics.lowest_score': 'Niedrigste Punktzahl',
'rubrics.export_pdf': 'Als PDF exportieren',
'rubrics.export_print': 'Drucken',
'rubrics.score_sheet': 'Bewertungsbogen',
'rubrics.select_student': 'Schüler auswählen',
'rubrics.select_rubric': 'Bewertungsraster auswählen',
// ── Curriculum Standards ──
'curriculum.title': 'Lehrplan-Mapping',
'curriculum.subtitle': 'Verknüpfen Sie Kompetenzen mit Lehrplanstandards',
'curriculum.standards': 'Lehrplanstandards',
'curriculum.create_standard': 'Standard erstellen',
'curriculum.edit_standard': 'Standard bearbeiten',
'curriculum.code': 'Code',
'curriculum.standard_title': 'Titel',
'curriculum.description': 'Beschreibung',
'curriculum.grade_level': 'Klassenstufe',
'curriculum.category': 'Kategorie',
'curriculum.source': 'Quelle',
'curriculum.subject': 'Fach',
'curriculum.coverage': 'Abdeckung',
'curriculum.coverage_visualization': 'Abdeckungsübersicht',
'curriculum.gap_analysis': 'Lückenanalyse',
'curriculum.gap_title': 'Standards ohne Kompetenzverknüpfung',
'curriculum.no_gaps': 'Alle Standards sind mit Kompetenzen verknüpft',
'curriculum.progress_tracking': 'Fortschrittsverfolgung',
'curriculum.heatmap': 'Abdeckungs-Heatmap',
'curriculum.link_competency': 'Kompetenz verknüpfen',
'curriculum.unlink_competency': 'Verknüpfung aufheben',
'curriculum.coverage_level': 'Abdeckungsgrad',
'curriculum.coverage_full': 'Vollständig',
'curriculum.coverage_partial': 'Teilweise',
'curriculum.coverage_related': 'Verwandt',
'curriculum.notes': 'Notizen',
'curriculum.linked_competencies': 'Verknüpfte Kompetenzen',
'curriculum.no_links': 'Keine Kompetenzen verknüpft',
'curriculum.standards_count': 'Standards',
'curriculum.linked_count': 'Verknüpft',
'curriculum.unlinked_count': 'Nicht verknüpft',
'curriculum.coverage_percent': 'Abdeckung %',
'curriculum.search_standards': 'Standards durchsuchen...',
'curriculum.all_subjects': 'Alle Fächer',
'curriculum.all_grades': 'Alle Klassenstufen',
'curriculum.all_sources': 'Alle Quellen',
'curriculum.delete_standard': 'Standard löschen',
'curriculum.delete_standard_desc': 'Möchten Sie diesen Standard wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
'curriculum.empty_title': 'Keine Standards vorhanden',
'curriculum.empty_desc': 'Erstellen Sie Ihren ersten Lehrplanstandard, um Kompetenzen zuzuordnen.',
'curriculum.select_competency': 'Kompetenz auswählen',
'curriculum.search_competency': 'Kompetenz suchen...',
'curriculum.mastery_progress': 'Beherrschungsfortschritt',
'curriculum.students_mastered': 'Schüler beherrschen diesen Standard',
// ── Attendance Analytics ──
'attendance.analytics': 'Analyse',
'attendance.analytics_title': 'Anwesenheits-Analyse',
'attendance.trends': 'Trends',
'attendance.weekly_trend': 'Wöchentlicher Trend',
'attendance.attendance_rate_over_time': 'Anwesenheitsrate im Zeitverlauf',
'attendance.absence_rate_over_time': 'Abwesenheitsrate im Zeitverlauf',
'attendance.day_of_week': 'Wochentagsanalyse',
'attendance.absences_by_day': 'Abwesenheiten nach Wochentag',
'attendance.chronic_absence': 'Chronische Abwesenheit',
'attendance.chronic_absence_desc': 'Schüler mit 10% oder höherer Abwesenheitsrate',
'attendance.risk_indicators': 'Risiko-Indikatoren',
'attendance.risk_low': 'Geringes Risiko',
'attendance.risk_medium': 'Mittleres Risiko',
'attendance.risk_high': 'Hohes Risiko',
'attendance.risk_critical': 'Kritisches Risiko',
'attendance.class_comparison': 'Klassenvergleich',
'attendance.attendance_by_class': 'Anwesenheit nach Klasse',
'attendance.status_distribution': 'Statusverteilung',
'attendance.export_csv': 'Als CSV exportieren',
'attendance.week': 'Woche',
'attendance.rate_percent': 'Rate %',
'attendance.absent_count_long': 'Abwesenheiten',
'attendance.total_sessions': 'Gesamtsitzungen',
'attendance.total_records': 'Gesamteinträge',
'attendance.no_analytics_data': 'Keine Analysedaten verfügbar',
'attendance.no_analytics_desc': 'Erstellen Sie Anwesenheitssitzungen, um Analysen zu sehen.',
// ── Backup (Task 10) ──
'backup.title': 'Datensicherung',
'backup.create': 'Sicherung erstellen',
'backup.restore': 'Sicherung wiederherstellen',
'backup.delete': 'Sicherung löschen',
'backup.auto': 'Automatische Sicherung',
'backup.daily': 'Täglich',
'backup.weekly': 'Wöchentlich',
'backup.last': 'Letzte Sicherung',
'backup.size': 'Größe',
'backup.status': 'Status',
'backup.completed': 'Abgeschlossen',
'backup.failed': 'Fehlgeschlagen',
'backup.pending': 'Ausstehend',
'backup.no_backups': 'Keine Sicherungen vorhanden',
'backup.no_backups_desc': 'Erstellen Sie Ihre erste Datensicherung.',
'backup.create_confirm': 'Sicherung erstellen?',
'backup.create_confirm_desc': 'Es wird eine vollständige Sicherung aller Daten erstellt.',
'backup.restore_confirm': 'Sicherung wiederherstellen?',
'backup.restore_confirm_desc': 'Die aktuellen Daten werden durch die Sicherung ersetzt. Diese Aktion kann nicht rückgängig gemacht werden.',
'backup.delete_confirm': 'Sicherung löschen?',
'backup.delete_confirm_desc': 'Diese Sicherung wird unwiderruflich gelöscht.',
'backup.creating': 'Sicherung wird erstellt...',
'backup.restoring': 'Sicherung wird wiederhergestellt...',
'backup.type': 'Typ',
'backup.type_full': 'Vollständig',
'backup.type_incremental': 'Inkrementell',
'backup.notes': 'Notizen',
'backup.download': 'Sicherung herunterladen',
'backup.created_success': 'Sicherung erfolgreich erstellt',
'backup.restored_success': 'Sicherung erfolgreich wiederhergestellt',
'backup.deleted_success': 'Sicherung gelöscht',
'backup.error_create': 'Fehler beim Erstellen der Sicherung',
'backup.error_restore': 'Fehler beim Wiederherstellen der Sicherung',
'backup.error_delete': 'Fehler beim Löschen der Sicherung',
// ── Performance (Task 10) ──
'performance.title': 'Leistung',
'performance.fps': 'Bilder pro Sekunde',
'mobile.optimized': 'Für mobile Geräte optimiert',
};
const en: TranslationDict = {
@@ -4319,6 +4486,173 @@ const en: TranslationDict = {
'settings.default_password': 'Default Password',
'settings.generate_email': 'Auto-generate Email',
'settings.link_student': 'Link to Student',
// ─── Collaboration & Real-Time ──────────────────────────────────────
'collab.online_users': '{count} online',
'collab.editing_by': '{name} is editing...',
'collab.activity_title': 'Activity',
'collab.no_activity': 'No activity',
'collab.started_editing': 'started editing',
'collab.realtime_connected': 'Real-time connected',
'collab.realtime_disconnected': 'Real-time disconnected',
'collab.conflict_resolved': 'Conflict resolved (last write wins)',
'collab.cursors_visible': 'Other users\' cursors visible',
'collab.user_joined': '{name} joined',
'collab.user_left': '{name} left',
// ─── Notification Sound ────────────────────────────────────────────
'settings.notification_sound': 'Notification Sound',
'settings.notification_sound_desc': 'Play sound for new notifications',
'settings.notification_sound_enabled': 'Sound enabled',
'settings.notification_sound_disabled': 'Sound disabled',
// ── Rubric Enhancement ──
'rubrics.templates': 'Templates',
'rubrics.templates_title': 'Rubric Templates',
'rubrics.templates_desc': 'Choose a template as a starting point for your rubric',
'rubrics.use_template': 'Use Template',
'rubrics.template_math': 'Math — Assessment',
'rubrics.template_german': 'German — Essay',
'rubrics.template_english': 'English — Writing Task',
'rubrics.template_project': 'General — Project Work',
'rubrics.template_oral': 'General — Oral Presentation',
'rubrics.no_template': 'Start from scratch',
'rubrics.grade': 'Grade',
'rubrics.grade_title': 'Rubric-Based Grading',
'rubrics.select_level': 'Select level',
'rubrics.auto_grade': 'Auto-Grade Calculation',
'rubrics.final_score': 'Final Score',
'rubrics.grade_result': 'Grade',
'rubrics.percentage': 'Percentage',
'rubrics.criterion_breakdown': 'Criterion Breakdown',
'rubrics.earned_points': 'Earned Points',
'rubrics.analytics': 'Analytics',
'rubrics.analytics_title': 'Rubric Analytics',
'rubrics.class_performance': 'Class Performance by Criterion',
'rubrics.grade_distribution': 'Grade Distribution',
'rubrics.average_score': 'Average',
'rubrics.highest_score': 'Highest Score',
'rubrics.lowest_score': 'Lowest Score',
'rubrics.export_pdf': 'Export as PDF',
'rubrics.export_print': 'Print',
'rubrics.score_sheet': 'Score Sheet',
'rubrics.select_student': 'Select Student',
'rubrics.select_rubric': 'Select Rubric',
// ── Curriculum Standards ──
'curriculum.title': 'Curriculum Mapping',
'curriculum.subtitle': 'Link competencies to curriculum standards',
'curriculum.standards': 'Curriculum Standards',
'curriculum.create_standard': 'Create Standard',
'curriculum.edit_standard': 'Edit Standard',
'curriculum.code': 'Code',
'curriculum.standard_title': 'Title',
'curriculum.description': 'Description',
'curriculum.grade_level': 'Grade Level',
'curriculum.category': 'Category',
'curriculum.source': 'Source',
'curriculum.subject': 'Subject',
'curriculum.coverage': 'Coverage',
'curriculum.coverage_visualization': 'Coverage Visualization',
'curriculum.gap_analysis': 'Gap Analysis',
'curriculum.gap_title': 'Standards without Competency Links',
'curriculum.no_gaps': 'All standards are linked to competencies',
'curriculum.progress_tracking': 'Progress Tracking',
'curriculum.heatmap': 'Coverage Heatmap',
'curriculum.link_competency': 'Link Competency',
'curriculum.unlink_competency': 'Unlink Competency',
'curriculum.coverage_level': 'Coverage Level',
'curriculum.coverage_full': 'Full',
'curriculum.coverage_partial': 'Partial',
'curriculum.coverage_related': 'Related',
'curriculum.notes': 'Notes',
'curriculum.linked_competencies': 'Linked Competencies',
'curriculum.no_links': 'No competencies linked',
'curriculum.standards_count': 'Standards',
'curriculum.linked_count': 'Linked',
'curriculum.unlinked_count': 'Unlinked',
'curriculum.coverage_percent': 'Coverage %',
'curriculum.search_standards': 'Search standards...',
'curriculum.all_subjects': 'All Subjects',
'curriculum.all_grades': 'All Grade Levels',
'curriculum.all_sources': 'All Sources',
'curriculum.delete_standard': 'Delete Standard',
'curriculum.delete_standard_desc': 'Are you sure you want to delete this standard? This action cannot be undone.',
'curriculum.empty_title': 'No Standards Yet',
'curriculum.empty_desc': 'Create your first curriculum standard to map competencies.',
'curriculum.select_competency': 'Select Competency',
'curriculum.search_competency': 'Search competency...',
'curriculum.mastery_progress': 'Mastery Progress',
'curriculum.students_mastered': 'Students have mastered this standard',
// ── Attendance Analytics ──
'attendance.analytics': 'Analytics',
'attendance.analytics_title': 'Attendance Analytics',
'attendance.trends': 'Trends',
'attendance.weekly_trend': 'Weekly Trend',
'attendance.attendance_rate_over_time': 'Attendance Rate Over Time',
'attendance.absence_rate_over_time': 'Absence Rate Over Time',
'attendance.day_of_week': 'Day of Week Analysis',
'attendance.absences_by_day': 'Absences by Day of Week',
'attendance.chronic_absence': 'Chronic Absence',
'attendance.chronic_absence_desc': 'Students with 10% or higher absence rate',
'attendance.risk_indicators': 'Risk Indicators',
'attendance.risk_low': 'Low Risk',
'attendance.risk_medium': 'Medium Risk',
'attendance.risk_high': 'High Risk',
'attendance.risk_critical': 'Critical Risk',
'attendance.class_comparison': 'Class Comparison',
'attendance.attendance_by_class': 'Attendance by Class',
'attendance.status_distribution': 'Status Distribution',
'attendance.export_csv': 'Export as CSV',
'attendance.week': 'Week',
'attendance.rate_percent': 'Rate %',
'attendance.absent_count_long': 'Absences',
'attendance.total_sessions': 'Total Sessions',
'attendance.total_records': 'Total Records',
'attendance.no_analytics_data': 'No Analytics Data Available',
'attendance.no_analytics_desc': 'Create attendance sessions to see analytics.',
// ── Backup (Task 10) ──
'backup.title': 'Datensicherung',
'backup.create': 'Sicherung erstellen',
'backup.restore': 'Sicherung wiederherstellen',
'backup.delete': 'Sicherung löschen',
'backup.auto': 'Automatische Sicherung',
'backup.daily': 'Täglich',
'backup.weekly': 'Wöchentlich',
'backup.last': 'Letzte Sicherung',
'backup.size': 'Größe',
'backup.status': 'Status',
'backup.completed': 'Abgeschlossen',
'backup.failed': 'Fehlgeschlagen',
'backup.pending': 'Ausstehend',
'backup.no_backups': 'Keine Sicherungen vorhanden',
'backup.no_backups_desc': 'Erstellen Sie Ihre erste Datensicherung.',
'backup.create_confirm': 'Sicherung erstellen?',
'backup.create_confirm_desc': 'Es wird eine vollständige Sicherung aller Daten erstellt.',
'backup.restore_confirm': 'Sicherung wiederherstellen?',
'backup.restore_confirm_desc': 'Die aktuellen Daten werden durch die Sicherung ersetzt. Diese Aktion kann nicht rückgängig gemacht werden.',
'backup.delete_confirm': 'Sicherung löschen?',
'backup.delete_confirm_desc': 'Diese Sicherung wird unwiderruflich gelöscht.',
'backup.creating': 'Sicherung wird erstellt...',
'backup.restoring': 'Sicherung wird wiederhergestellt...',
'backup.type': 'Typ',
'backup.type_full': 'Vollständig',
'backup.type_incremental': 'Inkrementell',
'backup.notes': 'Notizen',
'backup.download': 'Sicherung herunterladen',
'backup.created_success': 'Sicherung erfolgreich erstellt',
'backup.restored_success': 'Sicherung erfolgreich wiederhergestellt',
'backup.deleted_success': 'Sicherung gelöscht',
'backup.error_create': 'Fehler beim Erstellen der Sicherung',
'backup.error_restore': 'Fehler beim Wiederherstellen der Sicherung',
'backup.error_delete': 'Fehler beim Löschen der Sicherung',
// ── Performance (Task 10) ──
'performance.title': 'Leistung',
'performance.fps': 'Bilder pro Sekunde',
'mobile.optimized': 'Für mobile Geräte optimiert',
};
const translations: Record<string, TranslationDict> = { de, en };
+40 -2
View File
@@ -1,5 +1,6 @@
// CompetenceTrack — Notification Helper
// Server-side utility for creating notifications in the database
// Also pushes notifications via WebSocket service
import { db } from '@/lib/db';
@@ -13,9 +14,33 @@ export type NotificationType =
| 'REPORT_READY'
| 'GENERAL';
/**
* Push a notification to the WebSocket service so it arrives in real-time.
* This is a fire-and-forget call — failures are non-critical.
*/
async function pushNotificationToWS(params: {
userId: string;
type: string;
title: string;
message: string;
actionUrl?: string;
relatedId?: string;
}) {
try {
await fetch('http://localhost:3003/api/push-notification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
} catch {
// WebSocket push is non-critical — ignore errors
}
}
/**
* Create a notification for a specific user.
* This is a server-side function that writes directly to the database.
* Also pushes the notification via WebSocket for real-time delivery.
*/
export async function createNotification(params: {
schoolId: string;
@@ -28,7 +53,7 @@ export async function createNotification(params: {
}) {
const { schoolId, userId, type, title, message, actionUrl, relatedId } = params;
return db.notification.create({
const notification = await db.notification.create({
data: {
schoolId,
userId,
@@ -39,10 +64,16 @@ export async function createNotification(params: {
relatedId: relatedId ?? null,
},
});
// Push to WebSocket for real-time delivery (non-blocking)
pushNotificationToWS({ userId, type, title, message, actionUrl, relatedId }).catch(() => {});
return notification;
}
/**
* Create a notification for multiple users (e.g., all students in a class).
* Also pushes each notification via WebSocket.
*/
export async function createNotificationForUsers(params: {
schoolId: string;
@@ -55,7 +86,7 @@ export async function createNotificationForUsers(params: {
}) {
const { schoolId, userIds, type, title, message, actionUrl, relatedId } = params;
return db.notification.createMany({
const result = await db.notification.createMany({
data: userIds.map((userId) => ({
schoolId,
userId,
@@ -66,6 +97,13 @@ export async function createNotificationForUsers(params: {
relatedId: relatedId ?? null,
})),
});
// Push to WebSocket for each user (non-blocking)
for (const userId of userIds) {
pushNotificationToWS({ userId, type, title, message, actionUrl, relatedId }).catch(() => {});
}
return result;
}
/**
+365
View File
@@ -0,0 +1,365 @@
// CompetenceTrack — WebSocket Hook
// Real-time collaboration and push notifications via Socket.IO
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAppStore } from '@/lib/store';
// ─── Types ───────────────────────────────────────────────────────────
export interface CursorData {
userId: string;
userName: string;
pageId: string;
x: number;
y: number;
color: string;
}
export interface EditData {
userId: string;
userName: string;
pageId: string;
content: string;
timestamp: number;
}
export interface PresenceUser {
userId: string;
userName: string;
pageId: string;
color: string;
cursor: CursorData | null;
}
export interface NotificationPush {
type: string;
title: string;
message: string;
actionUrl?: string;
relatedId?: string;
timestamp: number;
}
export interface ActivityEvent {
userId: string;
userName: string;
pageId: string;
pageTitle: string;
timestamp: number;
}
// ─── Singleton Socket Manager ────────────────────────────────────────
let socketInstance: Socket | null = null;
let socketConnected = false;
function getSocket(): Socket | null {
return socketInstance;
}
function connectSocket(userId: string, userName: string, schoolId: string, role: string): Socket {
if (socketInstance && socketConnected) {
return socketInstance;
}
// Disconnect existing if any
if (socketInstance) {
socketInstance.disconnect();
}
const socket = io('/?XTransformPort=3003', {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 10,
timeout: 10000,
});
socket.on('connect', () => {
console.log('[CT-WS] Connected to WebSocket service');
socketConnected = true;
// Authenticate
socket.emit('auth', { userId, userName, schoolId, role });
});
socket.on('disconnect', () => {
console.log('[CT-WS] Disconnected from WebSocket service');
socketConnected = false;
});
socket.on('connect_error', (err) => {
console.warn('[CT-WS] Connection error:', err.message);
socketConnected = false;
});
socketInstance = socket;
return socket;
}
function disconnectSocket() {
if (socketInstance) {
socketInstance.disconnect();
socketInstance = null;
socketConnected = false;
}
}
// ─── Hook: useWebSocket ──────────────────────────────────────────────
export function useWebSocket() {
const currentUser = useAppStore((s) => s.currentUser);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!currentUser) {
disconnectSocket();
setConnected(false);
return;
}
const socket = connectSocket(
currentUser.id,
`${currentUser.firstName} ${currentUser.lastName}`,
currentUser.schoolId ?? '',
currentUser.role
);
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
// If already connected
if (socket.connected) {
setConnected(true);
}
return () => {
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [currentUser]);
return { connected, socket: getSocket() };
}
// ─── Hook: useNotebookCollaboration ──────────────────────────────────
export function useNotebookCollaboration(notebookId: string | null) {
const currentUser = useAppStore((s) => s.currentUser);
const [onlineUsers, setOnlineUsers] = useState<PresenceUser[]>([]);
const [cursors, setCursors] = useState<CursorData[]>([]);
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [lastEdit, setLastEdit] = useState<EditData | null>(null);
const [editingUsers, setEditingUsers] = useState<Map<string, { userName: string; pageId: string; timestamp: number }>>(new Map());
const activityListenersRef = useRef<((event: ActivityEvent) => void)[]>([]);
const editListenersRef = useRef<((edit: EditData) => void)[]>([]);
// Join/Leave notebook room
useEffect(() => {
const socket = getSocket();
if (!socket || !notebookId || !currentUser) return;
// Join notebook room
socket.emit('notebook:join', { notebookId });
// Listen for presence updates
const handlePresenceUpdate = (data: { notebookId: string; users: PresenceUser[] }) => {
if (data.notebookId === notebookId) {
setOnlineUsers(data.users.filter((u) => u.userId !== currentUser.id));
// Update cursors
setCursors(data.users.filter((u) => u.cursor && u.userId !== currentUser.id).map((u) => u.cursor!));
}
};
// Listen for cursor updates
const handleCursor = (cursor: CursorData) => {
if (cursor.userId !== currentUser.id) {
setCursors((prev) => {
const filtered = prev.filter((c) => c.userId !== cursor.userId);
return [...filtered, cursor];
});
}
};
// Listen for edits
const handleEdit = (edit: EditData) => {
if (edit.userId !== currentUser.id) {
setLastEdit(edit);
setEditingUsers((prev) => {
const next = new Map(prev);
next.set(edit.userId, { userName: edit.userName, pageId: edit.pageId, timestamp: edit.timestamp });
return next;
});
// Notify listeners
editListenersRef.current.forEach((fn) => fn(edit));
}
};
// Listen for activity events
const handleActivity = (event: ActivityEvent) => {
if (event.userId !== currentUser.id) {
setActivities((prev) => [event, ...prev].slice(0, 20));
activityListenersRef.current.forEach((fn) => fn(event));
}
};
socket.on('presence:update', handlePresenceUpdate);
socket.on('notebook:cursor', handleCursor);
socket.on('notebook:edit', handleEdit);
socket.on('activity:join', handleActivity);
return () => {
// Leave notebook room
socket.emit('notebook:leave', { notebookId });
socket.off('presence:update', handlePresenceUpdate);
socket.off('notebook:cursor', handleCursor);
socket.off('notebook:edit', handleEdit);
socket.off('activity:join', handleActivity);
setOnlineUsers([]);
setCursors([]);
setActivities([]);
setLastEdit(null);
setEditingUsers(new Map());
};
}, [notebookId, currentUser]);
// Broadcast cursor position
const broadcastCursor = useCallback((pageId: string, x: number, y: number) => {
const socket = getSocket();
if (!socket || !notebookId) return;
socket.emit('notebook:cursor', { notebookId, pageId, x, y });
}, [notebookId]);
// Broadcast edit
const broadcastEdit = useCallback((pageId: string, content: string) => {
const socket = getSocket();
if (!socket || !notebookId) return;
socket.emit('notebook:edit', { notebookId, pageId, content });
}, [notebookId]);
// Broadcast activity (user started editing a page)
const broadcastActivity = useCallback((pageId: string, pageTitle: string) => {
const socket = getSocket();
if (!socket || !notebookId) return;
socket.emit('activity:join', { notebookId, pageId, pageTitle });
}, [notebookId]);
// Add edit listener
const addEditListener = useCallback((fn: (edit: EditData) => void) => {
editListenersRef.current.push(fn);
return () => {
editListenersRef.current = editListenersRef.current.filter((f) => f !== fn);
};
}, []);
// Add activity listener
const addActivityListener = useCallback((fn: (event: ActivityEvent) => void) => {
activityListenersRef.current.push(fn);
return () => {
activityListenersRef.current = activityListenersRef.current.filter((f) => f !== fn);
};
}, []);
return {
onlineUsers,
cursors,
activities,
lastEdit,
editingUsers,
broadcastCursor,
broadcastEdit,
broadcastActivity,
addEditListener,
addActivityListener,
};
}
// ─── Hook: usePushNotifications ──────────────────────────────────────
export function usePushNotifications() {
const currentUser = useAppStore((s) => s.currentUser);
const [notification, setNotification] = useState<NotificationPush | null>(null);
useEffect(() => {
const socket = getSocket();
if (!socket || !currentUser) return;
const handleNewNotification = (data: NotificationPush) => {
setNotification(data);
// Auto-clear after 5 seconds
setTimeout(() => setNotification(null), 5000);
};
const handleNotificationRead = (data: { notificationId: string; userId: string }) => {
// Could be used to update UI in real-time
};
socket.on('notification:new', handleNewNotification);
socket.on('notification:read', handleNotificationRead);
return () => {
socket.off('notification:new', handleNewNotification);
socket.off('notification:read', handleNotificationRead);
};
}, [currentUser]);
return { notification };
}
// ─── Notification Sound Helper ───────────────────────────────────────
let audioContext: AudioContext | null = null;
export function playNotificationSound() {
try {
if (!audioContext) {
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
}
const ctx = audioContext;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.2);
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
} catch {
// Audio not supported
}
}
// ─── Notification Sound Preference ───────────────────────────────────
const NOTIF_SOUND_KEY = 'ct_notification_sound';
export function getNotificationSoundPref(): boolean {
try {
const stored = localStorage.getItem(NOTIF_SOUND_KEY);
if (stored !== null) return stored === 'true';
} catch {
// ignore
}
return true; // default on
}
export function setNotificationSoundPref(enabled: boolean) {
try {
localStorage.setItem(NOTIF_SOUND_KEY, String(enabled));
} catch {
// ignore
}
}