Round 12: WebSocket Collaboration, Curriculum Mapping, Attendance Analytics, Data Backup, Mobile Optimization, Performance
This commit is contained in:
1 parent
21310d3f62
commit
ba55bd13e4
24 files changed
+6067
-947
No files matched your search
+257
-1
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user