This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/frontend/modules/companion_js.py
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

2371 lines
89 KiB
Python

"""Companion Dashboard - JavaScript."""
def get_companion_js() -> str:
"""JavaScript für das Companion Dashboard."""
return """
// Companion Dashboard JavaScript
let companionData = null;
let currentMode = 'companion';
// Lesson Mode State
let lessonSession = null;
let lessonTimerInterval = null;
// ==================== WebSocket Real-time (Phase 6) ====================
let lessonWebSocket = null;
let wsReconnectAttempts = 0;
const WS_MAX_RECONNECT_ATTEMPTS = 5;
const WS_RECONNECT_DELAY = 2000;
let useWebSocket = true; // Fallback zu Polling wenn false
// ==================== Lesson Templates (Feature f37) ====================
let availableTemplates = [];
let selectedTemplate = null;
let currentPhaseDurations = {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5
};
// Templates beim Laden abrufen
async function loadLessonTemplates() {
try {
const response = await fetch('/api/classroom/templates?teacher_id=demo-teacher&include_system=true');
if (response.ok) {
const data = await response.json();
availableTemplates = data.templates || [];
renderTemplateOptions();
}
} catch (error) {
console.log('Templates konnten nicht geladen werden:', error);
// Fallback: Nur lokale System-Templates anzeigen
renderDefaultTemplateOptions();
}
}
function renderTemplateOptions() {
const systemGroup = document.getElementById('systemTemplatesGroup');
const myGroup = document.getElementById('myTemplatesGroup');
if (!systemGroup) return;
// System-Templates
const systemTemplates = availableTemplates.filter(t => t.is_system_template);
systemGroup.innerHTML = systemTemplates.map(t =>
`<option value="${t.template_id}" data-durations='${JSON.stringify(t.phase_durations)}' data-total="${t.total_duration_minutes}">${t.name} (${t.total_duration_minutes} Min)</option>`
).join('');
// Eigene Templates
const myTemplates = availableTemplates.filter(t => !t.is_system_template && t.teacher_id === 'demo-teacher');
if (myTemplates.length > 0 && myGroup) {
myGroup.style.display = '';
myGroup.innerHTML = myTemplates.map(t =>
`<option value="${t.template_id}" data-durations='${JSON.stringify(t.phase_durations)}' data-total="${t.total_duration_minutes}">${t.name} (${t.total_duration_minutes} Min)</option>`
).join('');
}
}
function renderDefaultTemplateOptions() {
const systemGroup = document.getElementById('systemTemplatesGroup');
if (!systemGroup) return;
// Hardcoded fallback wenn API nicht verfuegbar
systemGroup.innerHTML = `
<option value="system_standard_45" data-durations='{"einstieg":5,"erarbeitung":20,"sicherung":10,"transfer":5,"reflexion":5}' data-total="45">Standard 45 Min (45 Min)</option>
<option value="system_standard_90" data-durations='{"einstieg":10,"erarbeitung":40,"sicherung":20,"transfer":10,"reflexion":10}' data-total="90">Doppelstunde 90 Min (90 Min)</option>
<option value="system_workshop" data-durations='{"einstieg":5,"erarbeitung":30,"sicherung":5,"transfer":5,"reflexion":5}' data-total="50">Workshop-Stil (50 Min)</option>
<option value="system_discussion" data-durations='{"einstieg":8,"erarbeitung":15,"sicherung":7,"transfer":10,"reflexion":10}' data-total="50">Diskussion & Reflexion (50 Min)</option>
<option value="system_test_prep" data-durations='{"einstieg":3,"erarbeitung":25,"sicherung":12,"transfer":3,"reflexion":2}' data-total="45">Pruefungsvorbereitung (45 Min)</option>
`;
}
function applyLessonTemplate() {
const select = document.getElementById('lessonTemplate');
const option = select.options[select.selectedIndex];
const templateInfo = document.getElementById('templateInfo');
const templateDuration = document.getElementById('templateDuration');
if (!option.value) {
selectedTemplate = null;
if (templateInfo) templateInfo.style.display = 'none';
// Auf Standardwerte zuruecksetzen
currentPhaseDurations = {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5
};
} else {
selectedTemplate = option.value;
const durations = option.dataset.durations;
const total = option.dataset.total;
if (durations) {
currentPhaseDurations = JSON.parse(durations);
}
if (templateInfo && templateDuration) {
templateInfo.style.display = 'flex';
templateDuration.textContent = `Gesamtdauer: ${total} Minuten`;
}
// Fach aus Template uebernehmen wenn vorhanden
const template = availableTemplates.find(t => t.template_id === option.value);
if (template && template.subject) {
const subjectSelect = document.getElementById('lessonSubject');
for (let i = 0; i < subjectSelect.options.length; i++) {
if (subjectSelect.options[i].value === template.subject) {
subjectSelect.selectedIndex = i;
break;
}
}
}
// Thema aus Template uebernehmen wenn vorhanden und Feld leer
if (template && template.default_topic) {
const topicInput = document.getElementById('lessonTopic');
if (topicInput && !topicInput.value) {
topicInput.value = template.default_topic;
}
}
}
updatePhaseDurationsPreview();
}
function updatePhaseDurationsPreview() {
const previewPhases = document.getElementById('previewPhases');
const previewTotal = document.getElementById('previewTotal');
if (!previewPhases) return;
const phases = [
{ key: 'einstieg', label: 'E' },
{ key: 'erarbeitung', label: 'A' },
{ key: 'sicherung', label: 'S' },
{ key: 'transfer', label: 'T' },
{ key: 'reflexion', label: 'R' }
];
previewPhases.innerHTML = phases.map(p =>
`<span class="preview-phase" data-phase="${p.key}">${p.label}: ${currentPhaseDurations[p.key] || 0}</span>`
).join('');
const total = Object.values(currentPhaseDurations).reduce((sum, v) => sum + v, 0);
if (previewTotal) {
previewTotal.textContent = `Gesamt: ${total} Min`;
}
}
// ==================== Offline Timer Fallback (Feature f35) ====================
let offlineTimerInterval = null;
let lastKnownTimer = null;
let isOffline = false;
// Netzwerk-Status ueberwachen
window.addEventListener('online', () => {
isOffline = false;
showToast('Verbindung wiederhergestellt');
// Bei Reconnect sofort Timer vom Server holen
if (lessonSession && !lessonSession.is_ended) {
syncTimerFromServer();
}
});
window.addEventListener('offline', () => {
isOffline = true;
showToast('Offline - Timer laeuft lokal weiter');
startOfflineTimer();
});
function startOfflineTimer() {
if (offlineTimerInterval) clearInterval(offlineTimerInterval);
if (!lastKnownTimer || !lessonSession) return;
// Client-seitiger Timer-Countdown jede Sekunde
offlineTimerInterval = setInterval(() => {
if (!isOffline || !lastKnownTimer || lessonSession?.is_paused) {
return;
}
// Remaining Zeit decrementieren
if (lastKnownTimer.remaining_seconds > 0) {
lastKnownTimer.remaining_seconds--;
lastKnownTimer.elapsed_seconds++;
} else {
// Overtime
lastKnownTimer.overtime_seconds = (lastKnownTimer.overtime_seconds || 0) + 1;
lastKnownTimer.overtime = true;
}
// Prozente neu berechnen
if (lastKnownTimer.total_seconds > 0) {
lastKnownTimer.percentage = Math.round(
(lastKnownTimer.remaining_seconds / lastKnownTimer.total_seconds) * 100
);
lastKnownTimer.percentage_remaining = lastKnownTimer.percentage;
lastKnownTimer.percentage_elapsed = 100 - lastKnownTimer.percentage;
}
// Zeit formatieren
lastKnownTimer.remaining_formatted = formatSeconds(lastKnownTimer.remaining_seconds);
if (lastKnownTimer.overtime) {
lastKnownTimer.overtime_formatted = formatSeconds(lastKnownTimer.overtime_seconds);
}
// Warning-Status pruefen
lastKnownTimer.warning = lastKnownTimer.remaining_seconds <= 120 && !lastKnownTimer.overtime;
// UI aktualisieren
updateTimerUI(lastKnownTimer);
}, 1000);
}
function stopOfflineTimer() {
if (offlineTimerInterval) {
clearInterval(offlineTimerInterval);
offlineTimerInterval = null;
}
}
function formatSeconds(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function updateTimerUI(timer) {
document.getElementById('lessonTimerDisplay').textContent = timer.remaining_formatted;
document.getElementById('lessonProgressBar').style.width = timer.percentage_elapsed + '%';
updateVisualTimer(timer);
const progressEl = document.getElementById('lessonProgressBar');
progressEl.classList.remove('warning', 'overtime');
if (timer.overtime) {
progressEl.classList.add('overtime');
document.getElementById('lessonOvertimeBadge').style.display = 'inline-block';
document.getElementById('lessonOvertimeBadge').textContent = `+${timer.overtime_formatted} Overtime`;
} else if (timer.warning) {
progressEl.classList.add('warning');
document.getElementById('lessonOvertimeBadge').style.display = 'none';
} else {
document.getElementById('lessonOvertimeBadge').style.display = 'none';
}
}
async function syncTimerFromServer() {
if (!lessonSession) return;
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/timer`);
const timer = await res.json();
lastKnownTimer = timer;
lessonSession.timer = timer;
updateTimerUI(timer);
} catch (error) {
console.error('Timer sync error:', error);
}
}
async function loadCompanionDashboard() {
try {
const response = await fetch('/api/state/dashboard?teacher_id=demo-teacher');
companionData = await response.json();
renderDashboard();
} catch (error) {
console.error('Error loading dashboard:', error);
showError('Dashboard konnte nicht geladen werden');
}
}
// Kontext-Info aus /v1/context laden
async function loadContextInfo() {
try {
const response = await fetch('/api/classroom/v1/context?teacher_id=demo-teacher');
if (!response.ok) {
console.warn('Context API not available');
return;
}
const ctx = await response.json();
// Schuljahr
const yearEl = document.getElementById('ctxYear');
if (yearEl && ctx.school_year?.id) {
yearEl.textContent = ctx.school_year.id;
}
// Bundesland
const stateEl = document.getElementById('ctxState');
if (stateEl && ctx.school?.federal_state) {
const stateNames = {
'BY': 'Bayern', 'BW': 'Baden-Württemberg', 'BE': 'Berlin',
'BB': 'Brandenburg', 'HB': 'Bremen', 'HH': 'Hamburg',
'HE': 'Hessen', 'MV': 'Mecklenburg-Vorpommern', 'NI': 'Niedersachsen',
'NW': 'Nordrhein-Westfalen', 'RP': 'Rheinland-Pfalz', 'SL': 'Saarland',
'SN': 'Sachsen', 'ST': 'Sachsen-Anhalt', 'SH': 'Schleswig-Holstein', 'TH': 'Thüringen'
};
stateEl.textContent = stateNames[ctx.school.federal_state] || ctx.school.federal_state;
}
// Woche
const weekEl = document.getElementById('ctxWeek');
if (weekEl && ctx.school_year?.current_week) {
weekEl.textContent = ctx.school_year.current_week;
}
} catch (error) {
console.warn('Error loading context info:', error);
}
}
// Suggestions von /v1/suggestions API laden
async function loadSuggestionsFromAPI() {
try {
const response = await fetch('/api/classroom/v1/suggestions?teacher_id=demo-teacher');
if (!response.ok) {
console.warn('Suggestions API not available');
return;
}
const data = await response.json();
const container = document.getElementById('suggestionsList');
if (!container) return;
if (!data.suggestions || data.suggestions.length === 0) {
container.innerHTML = `
<div class="empty-state">
<span class="material-icons">check_circle</span>
<h3>Alles erledigt!</h3>
<p>Keine offenen Aufgaben. Gute Arbeit!</p>
</div>
`;
return;
}
// Tone zu Priority-Klasse mappen
const toneToClass = {
'hint': 'high',
'suggestion': 'medium',
'optional': 'low'
};
container.innerHTML = data.suggestions.map(s => `
<div class="suggestion-card" onclick="navigateTo('${s.action_url || '#'}')">
<div class="priority-bar ${toneToClass[s.tone] || 'medium'}"></div>
<div class="suggestion-icon">
<span class="material-icons">${s.icon || 'lightbulb'}</span>
</div>
<div class="suggestion-content">
<div class="suggestion-title">${s.title}</div>
<div class="suggestion-description">${s.description}</div>
${s.badge ? `<div class="suggestion-time">${s.badge}</div>` : ''}
</div>
<div class="suggestion-action">
Los <span class="material-icons" style="font-size: 18px;">arrow_forward</span>
</div>
</div>
`).join('');
} catch (error) {
console.warn('Error loading suggestions from API:', error);
}
}
// ==================== Onboarding Flow (Phase 8f) ====================
let onboardingStep = 1;
let onboardingData = {
federal_state: '',
school_type: ''
};
async function checkOnboardingNeeded() {
try {
const response = await fetch('/api/classroom/v1/context?teacher_id=demo-teacher');
if (!response.ok) return;
const ctx = await response.json();
// Wenn Onboarding nicht abgeschlossen, Modal zeigen
if (!ctx.flags?.onboarding_completed) {
showOnboardingModal();
}
} catch (error) {
console.warn('Could not check onboarding status:', error);
}
}
function showOnboardingModal() {
document.getElementById('onboardingModalOverlay').classList.add('active');
onboardingStep = 1;
updateOnboardingUI();
}
function hideOnboardingModal() {
document.getElementById('onboardingModalOverlay').classList.remove('active');
}
function updateOnboardingUI() {
// Steps anzeigen/verstecken
for (let i = 1; i <= 3; i++) {
const stepEl = document.getElementById(`onboardingStep${i}`);
const dotEl = document.getElementById(`onboardingDot${i}`);
if (stepEl) {
stepEl.classList.toggle('active', i === onboardingStep);
}
if (dotEl) {
dotEl.classList.remove('active', 'done');
if (i < onboardingStep) {
dotEl.classList.add('done');
} else if (i === onboardingStep) {
dotEl.classList.add('active');
}
}
}
}
function onboardingNext(currentStep) {
if (currentStep === 1) {
const state = document.getElementById('onboardingFederalState').value;
if (!state) {
alert('Bitte wählen Sie ein Bundesland.');
return;
}
onboardingData.federal_state = state;
onboardingStep = 2;
} else if (currentStep === 2) {
const type = document.getElementById('onboardingSchoolType').value;
if (!type) {
alert('Bitte wählen Sie eine Schulart.');
return;
}
onboardingData.school_type = type;
onboardingStep = 3;
// Zusammenfassung aktualisieren
const stateSelect = document.getElementById('onboardingFederalState');
const typeSelect = document.getElementById('onboardingSchoolType');
document.getElementById('onboardingSummaryState').textContent =
stateSelect.options[stateSelect.selectedIndex].text;
document.getElementById('onboardingSummaryType').textContent =
typeSelect.options[typeSelect.selectedIndex].text;
}
updateOnboardingUI();
}
function onboardingBack(currentStep) {
if (currentStep > 1) {
onboardingStep = currentStep - 1;
updateOnboardingUI();
}
}
async function completeOnboarding() {
try {
// 1. Kontext speichern (PUT statt PATCH)
const saveResponse = await fetch('/api/classroom/v1/context?teacher_id=demo-teacher', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
federal_state: onboardingData.federal_state,
school_type: onboardingData.school_type
})
});
if (!saveResponse.ok) {
throw new Error('Kontext konnte nicht gespeichert werden');
}
// 2. Onboarding abschließen
const completeResponse = await fetch('/api/classroom/v1/context/complete-onboarding?teacher_id=demo-teacher', {
method: 'POST'
});
if (!completeResponse.ok) {
throw new Error('Onboarding konnte nicht abgeschlossen werden');
}
// Modal schließen und Daten neu laden
hideOnboardingModal();
loadContextInfo();
loadSuggestionsFromAPI();
showToast('Einrichtung abgeschlossen!');
} catch (error) {
console.error('Onboarding error:', error);
alert('Fehler beim Speichern. Bitte versuchen Sie es erneut.');
}
}
function renderDashboard() {
if (!companionData) return;
// Phase Badge
document.getElementById('phaseBadge').textContent = companionData.context.phase_display_name;
// Phase Timeline
renderPhaseTimeline();
// Stats
document.getElementById('statClasses').textContent = companionData.stats.classes_count || 0;
document.getElementById('statStudents').textContent = companionData.stats.students_count || 0;
document.getElementById('statUnits').textContent = companionData.stats.learning_units_created || 0;
document.getElementById('statGrades').textContent = companionData.stats.grades_entered || 0;
// Progress
const progress = companionData.progress;
document.getElementById('progressPercent').textContent = Math.round(progress.percentage) + '%';
document.getElementById('progressBar').style.width = progress.percentage + '%';
document.getElementById('progressMilestones').textContent =
`${progress.completed} von ${progress.total} Meilensteinen erreicht`;
// Suggestions
renderSuggestions();
// Events
renderEvents();
}
function renderPhaseTimeline() {
const container = document.getElementById('phaseTimeline');
container.innerHTML = '';
companionData.phases.forEach(phase => {
const step = document.createElement('div');
step.className = 'phase-step';
step.onclick = () => console.log('Phase clicked:', phase.phase);
const dot = document.createElement('div');
dot.className = 'phase-dot';
if (phase.is_completed) {
dot.classList.add('completed');
dot.innerHTML = '<span class="material-icons">check</span>';
} else if (phase.is_current) {
dot.classList.add('current');
dot.innerHTML = '<span class="material-icons">circle</span>';
}
const label = document.createElement('div');
label.className = 'phase-label';
if (phase.is_current) label.classList.add('current');
label.textContent = phase.short_name;
step.appendChild(dot);
step.appendChild(label);
container.appendChild(step);
});
}
function renderSuggestions() {
const container = document.getElementById('suggestionsList');
if (!companionData.suggestions || companionData.suggestions.length === 0) {
container.innerHTML = `
<div class="empty-state">
<span class="material-icons">check_circle</span>
<h3>Alles erledigt!</h3>
<p>Keine offenen Aufgaben. Gute Arbeit!</p>
</div>
`;
return;
}
container.innerHTML = companionData.suggestions.map(s => `
<div class="suggestion-card" onclick="navigateTo('${s.action_target}')">
<div class="priority-bar ${s.priority.toLowerCase()}"></div>
<div class="suggestion-icon">
<span class="material-icons">${s.icon}</span>
</div>
<div class="suggestion-content">
<div class="suggestion-title">${s.title}</div>
<div class="suggestion-description">${s.description}</div>
<div class="suggestion-time">
<span class="material-icons" style="font-size: 14px; vertical-align: middle;">schedule</span>
ca. ${s.estimated_time} Min.
</div>
</div>
<div class="suggestion-action">
Los <span class="material-icons" style="font-size: 18px;">arrow_forward</span>
</div>
</div>
`).join('');
}
function renderEvents() {
const container = document.getElementById('eventsList');
const card = document.getElementById('eventsCard');
if (!companionData.upcoming_events || companionData.upcoming_events.length === 0) {
card.style.display = 'none';
return;
}
card.style.display = 'block';
const getEventIcon = (type) => {
const icons = {
'exam': 'quiz',
'parent_meeting': 'groups',
'deadline': 'alarm',
'default': 'event'
};
return icons[type] || icons.default;
};
container.innerHTML = companionData.upcoming_events.map(e => `
<div class="event-item">
<div class="event-icon">
<span class="material-icons">${getEventIcon(e.type)}</span>
</div>
<div class="event-info">
<div class="event-title">${e.title}</div>
<div class="event-date">${formatDate(e.date)}</div>
</div>
<div class="event-badge">
${e.in_days === 0 ? 'Heute' : `In ${e.in_days} Tagen`}
</div>
</div>
`).join('');
}
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short'
});
}
function navigateTo(target) {
console.log('Navigate to:', target);
// In echter App: window.location.href = target;
// Oder: router.push(target);
// Für Demo: Zeige Nachricht
showToast(`Navigiere zu: ${target}`);
}
function setMode(mode) {
currentMode = mode;
// Mode Buttons aktualisieren
document.getElementById('modeCompanion').classList.toggle('active', mode === 'companion');
document.getElementById('modeLesson').classList.toggle('active', mode === 'lesson');
document.getElementById('modeClassic').classList.toggle('active', mode === 'classic');
// companionDashboard immer sichtbar lassen (fuer Mode-Toggle)
document.getElementById('companionDashboard').style.display = 'block';
document.getElementById('lessonContainer').classList.toggle('active', mode === 'lesson');
// Companion-Inhalte (ausser Mode-Toggle) steuern
const companionContent = document.querySelectorAll('#companionDashboard > *:not(.mode-toggle)');
if (mode === 'lesson') {
// Nur Mode-Toggle sichtbar, Rest ausblenden
companionContent.forEach(el => el.style.display = 'none');
// Templates laden (Feature f37)
loadLessonTemplates();
} else if (mode === 'companion') {
// Alles im Companion anzeigen
companionContent.forEach(el => el.style.display = '');
} else if (mode === 'classic') {
// Klassisch: Zur Studio-Ansicht wechseln
companionContent.forEach(el => el.style.display = 'none');
showToast('Wechsle zum Studio...');
window.location.href = '/studio';
}
}
function showToast(message) {
// Einfache Toast-Nachricht
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #1a1a2e;
color: white;
padding: 12px 24px;
border-radius: 8px;
z-index: 1000;
animation: fadeIn 0.3s ease;
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
function showError(message) {
document.getElementById('suggestionsList').innerHTML = `
<div class="empty-state" style="background: #fef2f2;">
<span class="material-icons" style="color: #ef4444;">error</span>
<h3 style="color: #b91c1c;">Fehler</h3>
<p>${message}</p>
</div>
`;
}
// Milestone abschließen
async function completeMilestone(milestone) {
try {
const response = await fetch('/api/state/milestone?teacher_id=demo-teacher', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ milestone })
});
const result = await response.json();
if (result.success) {
showToast(`Meilenstein "${milestone}" abgeschlossen!`);
if (result.new_phase) {
showToast(`Neue Phase: ${result.new_phase}`);
}
loadCompanionDashboard(); // Reload
}
} catch (error) {
console.error('Error completing milestone:', error);
}
}
// ============================================
// LESSON MODE - Unterrichtsstunden-Steuerung
// ============================================
async function startNewLesson() {
const classId = document.getElementById('lessonClassId').value;
const subject = document.getElementById('lessonSubject').value;
const topic = document.getElementById('lessonTopic').value || null;
try {
// Session erstellen (mit Template-Phasendauern, Feature f37)
const createRes = await fetch('/api/classroom/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teacher_id: 'demo-teacher',
class_id: classId,
subject: subject,
topic: topic,
phase_durations: currentPhaseDurations
})
});
lessonSession = await createRes.json();
// Stunde starten (wechselt zu Einstieg)
const startRes = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/start`, {
method: 'POST'
});
lessonSession = await startRes.json();
// UI aktualisieren
showLessonActiveView();
startLessonTimerPolling();
renderLessonUI();
loadLessonSuggestions();
showToast('Stunde gestartet!');
} catch (error) {
console.error('Error starting lesson:', error);
showToast('Fehler beim Starten der Stunde');
}
}
function showLessonActiveView() {
document.getElementById('lessonStartView').style.display = 'none';
document.getElementById('lessonActiveView').style.display = 'block';
document.getElementById('lessonEndedView').style.display = 'none';
}
function showLessonStartView() {
document.getElementById('lessonStartView').style.display = 'block';
document.getElementById('lessonActiveView').style.display = 'none';
document.getElementById('lessonEndedView').style.display = 'none';
}
function showLessonEndedView() {
document.getElementById('lessonStartView').style.display = 'none';
document.getElementById('lessonActiveView').style.display = 'none';
document.getElementById('lessonEndedView').style.display = 'block';
}
function renderLessonUI() {
if (!lessonSession) return;
// Header mit Phasen-Farbe (Feature f25)
const headerEl = document.querySelector('.lesson-header');
const topicText = lessonSession.topic ? ` - ${lessonSession.topic}` : '';
document.getElementById('lessonSubjectDisplay').textContent =
`${lessonSession.subject} - Klasse ${lessonSession.class_id}${topicText}`;
// Phasen-Farbschema anwenden (Feature f25)
updatePhaseColors(lessonSession.current_phase);
// Timer
const timer = lessonSession.timer;
document.getElementById('lessonTimerDisplay').textContent = timer.remaining_formatted;
document.getElementById('lessonPhaseLabel').textContent = lessonSession.phase_display_name;
// Visual Pie Timer aktualisieren (Feature f21)
updateVisualTimer(timer);
// Legacy Timer Styling (fuer Progress Bar)
const progressEl = document.getElementById('lessonProgressBar');
progressEl.classList.remove('warning', 'overtime');
if (timer.overtime) {
progressEl.classList.add('overtime');
document.getElementById('lessonOvertimeBadge').style.display = 'inline-block';
document.getElementById('lessonOvertimeBadge').textContent = `+${timer.overtime_formatted} Overtime`;
} else if (timer.warning) {
progressEl.classList.add('warning');
document.getElementById('lessonOvertimeBadge').style.display = 'none';
} else {
document.getElementById('lessonOvertimeBadge').style.display = 'none';
}
// Progress Bar
progressEl.style.width = timer.percentage_elapsed + '%';
// Timeline
renderLessonTimeline();
// Pause Button Status (Feature f26)
updatePauseButton(lessonSession.is_paused);
// Next Phase Button
const btnNext = document.getElementById('btnNextPhase');
btnNext.disabled = lessonSession.is_ended;
}
function renderLessonTimeline() {
const container = document.getElementById('lessonTimeline');
container.innerHTML = '';
lessonSession.phases.forEach(phase => {
const step = document.createElement('div');
step.className = 'lesson-phase-step';
const dot = document.createElement('div');
dot.className = 'lesson-phase-dot';
// Phasen-Farbe hinzufuegen (Feature f25)
dot.classList.add(`phase-${phase.phase}`);
if (phase.is_completed) {
dot.classList.add('completed');
dot.innerHTML = '<span class="material-icons">check</span>';
} else if (phase.is_current) {
dot.classList.add('current');
dot.innerHTML = `<span class="material-icons">${phase.icon}</span>`;
} else {
dot.innerHTML = `<span class="material-icons">${phase.icon}</span>`;
}
const name = document.createElement('div');
name.className = 'lesson-phase-name';
if (phase.is_current) name.classList.add('current');
name.textContent = phase.display_name;
const duration = document.createElement('div');
duration.className = 'lesson-phase-duration';
duration.textContent = `${phase.duration_minutes} Min`;
step.appendChild(dot);
step.appendChild(name);
step.appendChild(duration);
container.appendChild(step);
});
}
async function loadLessonSuggestions() {
if (!lessonSession) return;
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/suggestions?limit=4`);
const data = await res.json();
const container = document.getElementById('lessonSuggestionsList');
if (!data.suggestions || data.suggestions.length === 0) {
container.innerHTML = '<p style="color: #6b7280; font-size: 13px;">Keine Vorschlaege fuer diese Phase.</p>';
return;
}
// Subject-Badge anzeigen wenn fachspezifisch (Feature f18)
const subjectBadge = (s) => {
if (s.subjects && s.subjects.length > 0 && data.subject) {
const normalizedSubject = data.subject.toLowerCase();
if (s.subjects.some(subj => subj.toLowerCase() === normalizedSubject)) {
return `<span class="subject-badge" title="Fachspezifischer Vorschlag">${data.subject}</span>`;
}
}
return '';
};
container.innerHTML = data.suggestions.map(s => `
<div class="lesson-suggestion-item${s.subjects ? ' subject-specific' : ''}">
<div class="lesson-suggestion-icon">
<span class="material-icons">${s.icon}</span>
</div>
<div class="lesson-suggestion-content">
<div class="lesson-suggestion-title">${s.title} ${subjectBadge(s)}</div>
<div class="lesson-suggestion-desc">${s.description}</div>
</div>
<div class="lesson-suggestion-time">${s.estimated_minutes} Min</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading suggestions:', error);
}
}
// ==================== Quick Actions (Feature f26) ====================
async function lessonExtendTime(minutes) {
if (!lessonSession) return;
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/extend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minutes: minutes })
});
if (!res.ok) {
throw new Error('Extend failed');
}
lessonSession = await res.json();
renderLessonUI();
showToast(`+${minutes} Min hinzugefuegt`);
} catch (error) {
console.error('Error extending time:', error);
showToast('Fehler beim Verlaengern');
}
}
async function lessonTogglePause() {
if (!lessonSession) return;
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/pause`, {
method: 'POST'
});
if (!res.ok) {
throw new Error('Pause toggle failed');
}
lessonSession = await res.json();
// UI fuer Pause-Status aktualisieren
updatePauseButton(lessonSession.is_paused);
renderLessonUI();
showToast(lessonSession.is_paused ? 'Pausiert' : 'Fortgesetzt');
} catch (error) {
console.error('Error toggling pause:', error);
showToast('Fehler bei Pause');
}
}
function updatePauseButton(isPaused) {
const btn = document.getElementById('btnPauseResume');
const icon = document.getElementById('pauseIcon');
const label = document.getElementById('pauseLabel');
if (!btn || !icon || !label) return;
if (isPaused) {
btn.classList.add('paused');
icon.textContent = 'play_arrow';
label.textContent = 'Weiter';
} else {
btn.classList.remove('paused');
icon.textContent = 'pause';
label.textContent = 'Pause';
}
}
// ==================== Keyboard Shortcuts (Feature f34) ====================
// Space = Pause/Resume, N = Next Phase, E = Extend +5min, H = High Contrast Toggle
document.addEventListener('keydown', function(e) {
// Nur wenn Lesson-Modus aktiv und keine Session beendet
if (!lessonSession || lessonSession.is_ended) return;
// Nicht reagieren wenn in Input-Feld
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
switch(e.code) {
case 'Space':
e.preventDefault();
lessonTogglePause();
break;
case 'KeyN':
e.preventDefault();
lessonNextPhase();
break;
case 'KeyE':
e.preventDefault();
lessonExtendTime(5);
break;
case 'KeyH':
e.preventDefault();
toggleHighContrast();
break;
case 'KeyA':
e.preventDefault();
toggleAudio();
break;
}
});
function toggleHighContrast() {
const container = document.querySelector('.companion-container');
if (container) {
container.classList.toggle('high-contrast');
const isHighContrast = container.classList.contains('high-contrast');
showToast(isHighContrast ? 'High Contrast aktiviert' : 'High Contrast deaktiviert');
}
}
// ==================== Audio Cues (Feature f33) ====================
// Sanfte Toene bei Phasenwechsel und Warnungen (keine harten Alarme)
let audioContext = null;
let audioEnabled = true;
function initAudioContext() {
if (!audioContext) {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.log('Web Audio API nicht verfuegbar');
audioEnabled = false;
}
}
return audioContext;
}
function playTone(frequency, duration, type = 'sine', volume = 0.3) {
if (!audioEnabled) return;
const ctx = initAudioContext();
if (!ctx) return;
// AudioContext aktivieren falls suspended
if (ctx.state === 'suspended') {
ctx.resume();
}
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
// Sanftes Ein- und Ausblenden
gainNode.gain.setValueAtTime(0, ctx.currentTime);
gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 0.05);
gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
}
function playPhaseChangeSound() {
// Sanfter aufsteigender Zwei-Ton (freundlich, nicht aufdringlich)
playTone(440, 0.15, 'sine', 0.2);
setTimeout(() => playTone(554, 0.2, 'sine', 0.2), 100);
}
function playWarningSound() {
// Sanfter einzelner Ton (hinweisend, nicht alarmierend)
playTone(392, 0.3, 'sine', 0.15);
}
function playEndSound() {
// Sanfte absteigende Tonfolge (abschliessend)
playTone(523, 0.15, 'sine', 0.2);
setTimeout(() => playTone(440, 0.15, 'sine', 0.2), 100);
setTimeout(() => playTone(349, 0.25, 'sine', 0.2), 200);
}
function toggleAudio() {
audioEnabled = !audioEnabled;
showToast(audioEnabled ? 'Audio aktiviert' : 'Audio deaktiviert');
}
async function lessonNextPhase() {
if (!lessonSession) return;
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/next-phase`, {
method: 'POST'
});
lessonSession = await res.json();
renderLessonUI();
loadLessonSuggestions();
showToast(`Phase: ${lessonSession.phase_display_name}`);
// Audio Cue abspielen (Feature f33)
if (lessonSession.is_ended) {
playEndSound();
stopLessonTimerPolling();
showLessonEndedWithSummary();
} else {
playPhaseChangeSound();
}
} catch (error) {
console.error('Error advancing phase:', error);
showToast('Fehler beim Phasenwechsel');
}
}
async function lessonEnd() {
if (!lessonSession) return;
if (!confirm('Stunde wirklich beenden?')) return;
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/end`, {
method: 'POST'
});
lessonSession = await res.json();
playEndSound(); // Audio Cue (Feature f33)
stopLessonTimerPolling();
showLessonEndedWithSummary();
showToast('Stunde beendet!');
} catch (error) {
console.error('Error ending lesson:', error);
showToast('Fehler beim Beenden');
}
}
function showLessonEndedWithSummary() {
showLessonEndedView();
const topicText = lessonSession.topic ? ` - ${lessonSession.topic}` : '';
document.getElementById('lessonEndedTopic').textContent =
`${lessonSession.subject} - Klasse ${lessonSession.class_id}${topicText}`;
// Dauer berechnen
if (lessonSession.lesson_started_at && lessonSession.lesson_ended_at) {
const start = new Date(lessonSession.lesson_started_at);
const end = new Date(lessonSession.lesson_ended_at);
const durationSec = Math.round((end - start) / 1000);
const mins = Math.floor(durationSec / 60);
const secs = durationSec % 60;
document.getElementById('summaryDuration').textContent =
`${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Phasen zaehlen
const completedPhases = lessonSession.phases.filter(p => p.is_completed).length;
document.getElementById('summaryPhases').textContent = `${completedPhases}/5`;
// Hausaufgaben und Materialien laden (Features f19, f20)
loadHomeworkList();
loadMaterialsList();
// Due Date auf morgen vorbelegen
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dueDateInput = document.getElementById('homeworkDueDate');
if (dueDateInput) {
dueDateInput.value = tomorrow.toISOString().split('T')[0];
}
// Analytics und Reflection laden (Phase 5)
loadSessionAnalytics();
loadExistingReflection();
// Reflection-Formular zuruecksetzen
currentReflectionRating = null;
const stars = document.querySelectorAll('.star-btn');
stars.forEach(star => star.classList.remove('active'));
const reflectionNotes = document.getElementById('reflectionNotes');
if (reflectionNotes) reflectionNotes.value = '';
const reflectionNext = document.getElementById('reflectionNextLesson');
if (reflectionNext) reflectionNext.value = '';
const saveBtn = document.querySelector('.reflection-save-btn');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<span class="material-icons" style="font-size: 18px;">save</span> Reflexion speichern';
}
}
function resetLesson() {
lessonSession = null;
stopLessonTimerPolling();
showLessonStartView();
// Formular zuruecksetzen
document.getElementById('lessonTopic').value = '';
}
// ==================== WebSocket Real-time Connection (Phase 6) ====================
function connectWebSocket() {
if (!lessonSession || !useWebSocket) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/classroom/ws/${lessonSession.session_id}`;
try {
lessonWebSocket = new WebSocket(wsUrl);
lessonWebSocket.onopen = () => {
console.log('WebSocket connected');
wsReconnectAttempts = 0;
isOffline = false;
stopOfflineTimer();
// Zeige Verbindungsstatus
updateConnectionStatus('connected');
};
lessonWebSocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (e) {
console.error('WebSocket message parse error:', e);
}
};
lessonWebSocket.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
lessonWebSocket = null;
if (lessonSession && !lessonSession.is_ended) {
// Versuche Reconnect
if (wsReconnectAttempts < WS_MAX_RECONNECT_ATTEMPTS) {
wsReconnectAttempts++;
updateConnectionStatus('reconnecting');
setTimeout(connectWebSocket, WS_RECONNECT_DELAY);
} else {
// Fallback zu Polling
console.log('WebSocket max reconnects reached, falling back to polling');
useWebSocket = false;
updateConnectionStatus('polling');
startLessonTimerPollingFallback();
}
}
};
lessonWebSocket.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus('error');
};
} catch (error) {
console.error('WebSocket connection failed:', error);
useWebSocket = false;
startLessonTimerPollingFallback();
}
}
function disconnectWebSocket() {
if (lessonWebSocket) {
lessonWebSocket.close();
lessonWebSocket = null;
}
wsReconnectAttempts = 0;
}
function handleWebSocketMessage(message) {
switch (message.type) {
case 'connected':
console.log('WebSocket initial data:', message.data);
if (message.data.timer) {
handleTimerUpdate(message.data.timer);
}
if (message.data.client_count > 1) {
showToast(`${message.data.client_count} Geraete verbunden`);
}
break;
case 'timer_update':
handleTimerUpdate(message.data);
break;
case 'phase_change':
handlePhaseChange(message.data);
break;
case 'session_ended':
handleSessionEnded(message.data);
break;
case 'pong':
// Keepalive response
break;
case 'error':
console.error('WebSocket server error:', message.data);
break;
default:
console.log('Unknown WebSocket message:', message);
}
}
function handleTimerUpdate(timerData) {
if (!lessonSession) return;
// Timer in Session und Backup speichern
lessonSession.timer = timerData;
lastKnownTimer = { ...timerData };
// Offline-Status zuruecksetzen
isOffline = false;
stopOfflineTimer();
// UI aktualisieren
updateTimerUI(timerData);
// Warning-Logik
if (timerData.warning && !timerData.overtime) {
if (!lessonSession._warningShown) {
showToast('Noch 2 Minuten in dieser Phase!');
playWarningSound();
lessonSession._warningShown = true;
}
} else if (!timerData.warning) {
lessonSession._warningShown = false;
}
}
function handlePhaseChange(phaseData) {
console.log('Phase changed:', phaseData);
showToast(`Phase: ${phaseData.phase_info?.phase_display_name || phaseData.new_phase}`);
playPhaseSound();
// Session-Daten neu laden
refreshLessonSession();
}
function handleSessionEnded(data) {
console.log('Session ended via WebSocket');
disconnectWebSocket();
refreshLessonSession();
}
function updateConnectionStatus(status) {
const statusIndicator = document.getElementById('wsConnectionStatus');
if (!statusIndicator) return;
const statusConfig = {
'connected': { icon: 'wifi', color: '#22c55e', text: 'Live' },
'reconnecting': { icon: 'sync', color: '#f59e0b', text: 'Reconnect...' },
'polling': { icon: 'schedule', color: '#6b7280', text: 'Polling' },
'error': { icon: 'wifi_off', color: '#ef4444', text: 'Offline' },
'offline': { icon: 'wifi_off', color: '#ef4444', text: 'Offline' }
};
const config = statusConfig[status] || statusConfig['offline'];
statusIndicator.innerHTML = `
<span class="material-icons" style="font-size: 14px; color: ${config.color};">${config.icon}</span>
<span style="font-size: 11px; color: ${config.color}; margin-left: 4px;">${config.text}</span>
`;
}
async function refreshLessonSession() {
if (!lessonSession) return;
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}`);
if (res.ok) {
lessonSession = await res.json();
renderLessonUI();
if (lessonSession.is_ended) {
showLessonEndedWithSummary();
}
}
} catch (error) {
console.error('Failed to refresh session:', error);
}
}
function sendWebSocketPing() {
if (lessonWebSocket && lessonWebSocket.readyState === WebSocket.OPEN) {
lessonWebSocket.send(JSON.stringify({ type: 'ping' }));
}
}
// WebSocket Keepalive alle 30 Sekunden
setInterval(sendWebSocketPing, 30000);
// ==================== Timer Polling (Fallback) ====================
function startLessonTimerPolling() {
// Phase 6: Versuche zuerst WebSocket
if (useWebSocket) {
connectWebSocket();
return;
}
// Fallback: Polling alle 5 Sekunden
startLessonTimerPollingFallback();
}
function startLessonTimerPollingFallback() {
// Timer alle 5 Sekunden aktualisieren
lessonTimerInterval = setInterval(async () => {
if (!lessonSession || lessonSession.is_ended) {
stopLessonTimerPolling();
return;
}
try {
const res = await fetch(`/api/classroom/sessions/${lessonSession.session_id}/timer`);
const timer = await res.json();
// Offline Timer stoppen wenn Server antwortet (Feature f35)
stopOfflineTimer();
isOffline = false;
// Timer in Session und Backup speichern (Feature f35)
lessonSession.timer = timer;
lastKnownTimer = { ...timer };
// UI aktualisieren
updateTimerUI(timer);
// Warning-Logik
if (timer.warning && !timer.overtime) {
if (!lessonSession._warningShown) {
showToast('Noch 2 Minuten in dieser Phase!');
playWarningSound(); // Audio Cue (Feature f33)
lessonSession._warningShown = true;
}
} else if (!timer.warning) {
lessonSession._warningShown = false;
}
} catch (error) {
console.error('Timer polling error:', error);
// Bei Verbindungsfehler: Offline Timer starten (Feature f35)
if (!isOffline && lastKnownTimer) {
isOffline = true;
showToast('Verbindung unterbrochen - Timer laeuft lokal');
startOfflineTimer();
}
}
}, 5000);
}
function stopLessonTimerPolling() {
// Phase 6: WebSocket trennen
disconnectWebSocket();
// Polling stoppen
if (lessonTimerInterval) {
clearInterval(lessonTimerInterval);
lessonTimerInterval = null;
}
stopOfflineTimer(); // Feature f35: Offline Timer auch stoppen
}
// ==================== PHASEN-FARBSCHEMA (Feature f25) ====================
const PHASE_COLORS = {
'einstieg': '#4A90E2', // Warmes Blau
'erarbeitung': '#F5A623', // Orange
'sicherung': '#7ED321', // Gruen
'transfer': '#9013FE', // Lila
'reflexion': '#6B7280', // Grau
'not_started': '#6C1B1B', // Default Weinrot
'ended': '#6C1B1B' // Default Weinrot
};
function updatePhaseColors(currentPhase) {
const headerEl = document.querySelector('.lesson-header');
if (!headerEl) return;
// Alle Phasen-Klassen entfernen
headerEl.classList.remove(
'phase-einstieg', 'phase-erarbeitung', 'phase-sicherung',
'phase-transfer', 'phase-reflexion', 'phase-not_started', 'phase-ended'
);
// Aktuelle Phasen-Klasse hinzufuegen
if (currentPhase) {
headerEl.classList.add(`phase-${currentPhase}`);
}
}
// ==================== VISUAL PIE TIMER (Feature f21) ====================
// Kreis-Umfang bei r=42: 2 * PI * 42 = 263.89
const TIMER_CIRCUMFERENCE = 263.89;
function updateVisualTimer(timer) {
const progressEl = document.getElementById('visualTimerProgress');
if (!progressEl) return;
// Prozent der verbleibenden Zeit berechnen
const percentRemaining = timer.percentage || 0;
const dashOffset = TIMER_CIRCUMFERENCE * (1 - percentRemaining / 100);
// SVG Stroke-Dashoffset setzen (animiert den Kreis)
progressEl.style.strokeDashoffset = dashOffset;
// Farbe basierend auf Zeit setzen
progressEl.classList.remove('time-plenty', 'time-warning', 'time-critical', 'time-overtime');
if (timer.overtime) {
progressEl.classList.add('time-overtime');
progressEl.style.strokeDashoffset = 0; // Voller Kreis bei Overtime
} else if (timer.remaining_seconds <= 120) {
// Kritisch: 2 Minuten oder weniger
progressEl.classList.add('time-critical');
} else if (timer.remaining_seconds <= 300) {
// Warnung: 5 Minuten oder weniger
progressEl.classList.add('time-warning');
} else {
// Normal: Mehr als 5 Minuten
progressEl.classList.add('time-plenty');
}
}
// ==================== HAUSAUFGABEN (Feature f20) ====================
async function addHomework() {
const titleInput = document.getElementById('homeworkInput');
const dueDateInput = document.getElementById('homeworkDueDate');
const title = titleInput.value.trim();
if (!title) {
showToast('Bitte Hausaufgabe eingeben');
return;
}
if (!lessonSession) {
showToast('Keine aktive Session');
return;
}
try {
const body = {
teacher_id: 'demo-teacher',
class_id: lessonSession.class_id,
title: title,
subject: lessonSession.subject,
session_id: lessonSession.session_id
};
// Due date nur hinzufuegen wenn gesetzt
if (dueDateInput.value) {
body.due_date = dueDateInput.value + 'T23:59:00';
}
const res = await fetch('/api/classroom/homework', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
throw new Error('Fehler beim Speichern');
}
// Eingabefelder leeren
titleInput.value = '';
dueDateInput.value = '';
showToast('Hausaufgabe gespeichert');
loadHomeworkList();
} catch (error) {
console.error('Error adding homework:', error);
showToast('Fehler beim Speichern der Hausaufgabe');
}
}
async function loadHomeworkList() {
if (!lessonSession) return;
const container = document.getElementById('homeworkList');
if (!container) return;
try {
const res = await fetch(`/api/classroom/homework?teacher_id=demo-teacher&class_id=${lessonSession.class_id}&limit=10`);
const data = await res.json();
if (!data.homework || data.homework.length === 0) {
container.innerHTML = '<div class="homework-empty">Keine Hausaufgaben fuer diese Klasse</div>';
return;
}
container.innerHTML = data.homework.map(hw => {
const isCompleted = hw.status === 'completed';
const dueText = hw.due_date ? formatDueDate(hw.due_date) : '';
return `
<div class="homework-item ${isCompleted ? 'completed' : ''}">
<input type="checkbox" class="homework-checkbox"
${isCompleted ? 'checked' : ''}
onchange="toggleHomeworkStatus('${hw.homework_id}', this.checked)">
<div class="homework-content">
<div class="homework-title">${hw.title}</div>
${dueText ? `<div class="homework-due">${dueText}</div>` : ''}
</div>
<button class="homework-delete-btn" onclick="deleteHomework('${hw.homework_id}')" title="Loeschen">
<span class="material-icons" style="font-size: 16px;">delete</span>
</button>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading homework:', error);
container.innerHTML = '<div class="homework-empty">Fehler beim Laden</div>';
}
}
function formatDueDate(isoString) {
const date = new Date(isoString);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.ceil((date - today) / (1000 * 60 * 60 * 24));
if (diff < 0) return 'Ueberfaellig';
if (diff === 0) return 'Heute';
if (diff === 1) return 'Morgen';
if (diff <= 7) return `In ${diff} Tagen`;
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
async function toggleHomeworkStatus(homeworkId, isCompleted) {
const newStatus = isCompleted ? 'completed' : 'assigned';
try {
const res = await fetch(`/api/classroom/homework/${homeworkId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (!res.ok) {
throw new Error('Fehler beim Aktualisieren');
}
loadHomeworkList();
} catch (error) {
console.error('Error updating homework status:', error);
showToast('Fehler beim Aktualisieren');
loadHomeworkList(); // Reload um Checkbox-Status zu korrigieren
}
}
async function deleteHomework(homeworkId) {
if (!confirm('Hausaufgabe wirklich loeschen?')) return;
try {
const res = await fetch(`/api/classroom/homework/${homeworkId}?teacher_id=demo-teacher`, {
method: 'DELETE'
});
if (!res.ok) {
throw new Error('Fehler beim Loeschen');
}
showToast('Hausaufgabe geloescht');
loadHomeworkList();
} catch (error) {
console.error('Error deleting homework:', error);
showToast('Fehler beim Loeschen');
}
}
// ==================== MATERIALIEN (Feature f19) ====================
async function showAddMaterialModal() {
// Einfaches Prompt fuer URL (spaeter: Modal mit mehr Optionen)
const url = prompt('Material-URL eingeben:', 'https://');
if (!url || url === 'https://') return;
const title = prompt('Titel des Materials:', '');
if (!title) return;
if (!lessonSession) {
showToast('Keine aktive Session');
return;
}
try {
// Material erstellen
const createRes = await fetch('/api/classroom/materials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teacher_id: 'demo-teacher',
title: title,
url: url,
material_type: 'link',
phase: lessonSession.current_phase,
subject: lessonSession.subject
})
});
if (!createRes.ok) {
throw new Error('Fehler beim Erstellen');
}
const material = await createRes.json();
// Material an Session anhaengen
const attachRes = await fetch(`/api/classroom/materials/${material.material_id}/attach/${lessonSession.session_id}`, {
method: 'POST'
});
if (!attachRes.ok) {
throw new Error('Fehler beim Anhaengen');
}
showToast('Material hinzugefuegt');
loadMaterialsList();
} catch (error) {
console.error('Error adding material:', error);
showToast('Fehler beim Hinzufuegen');
}
}
async function loadMaterialsList() {
if (!lessonSession) return;
const container = document.getElementById('materialsList');
if (!container) return;
try {
const res = await fetch(`/api/classroom/materials?teacher_id=demo-teacher&session_id=${lessonSession.session_id}&limit=20`);
const data = await res.json();
if (!data.materials || data.materials.length === 0) {
container.innerHTML = '<div class="materials-empty">Keine Materialien fuer diese Stunde</div>';
return;
}
const typeIcons = {
'document': 'description',
'link': 'link',
'video': 'play_circle',
'image': 'image',
'worksheet': 'assignment',
'presentation': 'slideshow',
'other': 'attach_file'
};
container.innerHTML = data.materials.map(m => `
<div class="material-item">
<span class="material-icons material-type-icon">${typeIcons[m.material_type] || 'attach_file'}</span>
<div class="material-content">
<div class="material-title">${m.title}</div>
${m.phase ? `<div class="material-phase">Phase: ${m.phase}</div>` : ''}
</div>
${m.url ? `
<a href="${m.url}" target="_blank" class="material-link-btn" title="Oeffnen">
<span class="material-icons" style="font-size: 18px;">open_in_new</span>
</a>
` : ''}
<button class="material-delete-btn" onclick="deleteMaterial('${m.material_id}')" title="Loeschen">
<span class="material-icons" style="font-size: 16px;">delete</span>
</button>
</div>
`).join('');
} catch (error) {
console.error('Error loading materials:', error);
container.innerHTML = '<div class="materials-empty">Fehler beim Laden</div>';
}
}
async function deleteMaterial(materialId) {
if (!confirm('Material wirklich loeschen?')) return;
try {
const res = await fetch(`/api/classroom/materials/${materialId}?teacher_id=demo-teacher`, {
method: 'DELETE'
});
if (!res.ok) {
throw new Error('Fehler beim Loeschen');
}
showToast('Material geloescht');
loadMaterialsList();
} catch (error) {
console.error('Error deleting material:', error);
showToast('Fehler beim Loeschen');
}
}
// ==================== ANALYTICS (Phase 5) ====================
async function loadSessionAnalytics() {
if (!lessonSession) return;
const grid = document.getElementById('analyticsGrid');
if (!grid) return;
try {
const res = await fetch(`/api/classroom/analytics/session/${lessonSession.session_id}`);
if (!res.ok) {
// Analytics nicht verfuegbar - zeige einfache Version
renderSimpleAnalytics();
return;
}
const data = await res.json();
renderAnalytics(data);
} catch (error) {
console.log('Analytics nicht verfuegbar:', error);
renderSimpleAnalytics();
}
}
function renderAnalytics(data) {
// Phase Bars
const barsContainer = document.getElementById('analyticsPhaseBars');
if (barsContainer && data.phase_statistics) {
const maxDuration = Math.max(
...data.phase_statistics.map(p => Math.max(p.planned_duration_seconds, p.actual_duration_seconds))
);
barsContainer.innerHTML = data.phase_statistics.map(phase => {
const plannedWidth = (phase.planned_duration_seconds / maxDuration) * 100;
const actualWidth = (phase.actual_duration_seconds / maxDuration) * 100;
// Farbklasse basierend auf Differenz
let colorClass = 'on-time';
const diff = phase.actual_duration_seconds - phase.planned_duration_seconds;
if (diff < -60) colorClass = 'under-time';
else if (diff > 180) colorClass = 'way-over';
else if (diff > 60) colorClass = 'over-time';
const diffText = phase.difference_formatted || formatDiffSeconds(diff);
return `
<div class="analytics-phase-bar">
<div class="analytics-phase-label">${phase.display_name}</div>
<div class="analytics-bar-container">
<div class="analytics-bar-planned" style="width: ${plannedWidth}%"></div>
<div class="analytics-bar-actual ${colorClass}" style="width: ${actualWidth}%"></div>
</div>
<div class="analytics-phase-time">${diffText}</div>
</div>
`;
}).join('');
}
// Overtime Summary
const overtimeValue = document.getElementById('analyticsOvertimeValue');
const overtimePhases = document.getElementById('analyticsOvertimePhases');
if (overtimeValue) {
overtimeValue.textContent = data.total_overtime_formatted || '00:00';
overtimeValue.style.color = data.total_overtime_seconds > 0 ? '#f59e0b' : '#10b981';
}
if (overtimePhases) {
overtimePhases.textContent = `${data.phases_with_overtime || 0} von ${data.total_phases} Phasen`;
}
}
function renderSimpleAnalytics() {
// Fallback wenn API nicht verfuegbar - berechne aus Session-Daten
if (!lessonSession || !lessonSession.phase_history) return;
const barsContainer = document.getElementById('analyticsPhaseBars');
if (!barsContainer) return;
const phaseNames = {
'einstieg': 'Einstieg',
'erarbeitung': 'Erarbeitung',
'sicherung': 'Sicherung',
'transfer': 'Transfer',
'reflexion': 'Reflexion'
};
let html = '';
let totalOvertime = 0;
let phasesWithOvertime = 0;
for (const entry of lessonSession.phase_history) {
const phase = entry.phase;
if (phase === 'not_started' || phase === 'ended') continue;
const planned = (lessonSession.phase_durations[phase] || 0) * 60;
const actual = entry.duration_seconds || 0;
const diff = actual - planned;
if (diff > 0) {
totalOvertime += diff;
phasesWithOvertime++;
}
let colorClass = 'on-time';
if (diff < -60) colorClass = 'under-time';
else if (diff > 180) colorClass = 'way-over';
else if (diff > 60) colorClass = 'over-time';
const maxSeconds = Math.max(planned, actual, 1);
const plannedWidth = (planned / maxSeconds) * 100;
const actualWidth = (actual / maxSeconds) * 100;
html += `
<div class="analytics-phase-bar">
<div class="analytics-phase-label">${phaseNames[phase] || phase}</div>
<div class="analytics-bar-container">
<div class="analytics-bar-planned" style="width: ${plannedWidth}%"></div>
<div class="analytics-bar-actual ${colorClass}" style="width: ${actualWidth}%"></div>
</div>
<div class="analytics-phase-time">${formatDiffSeconds(diff)}</div>
</div>
`;
}
barsContainer.innerHTML = html || '<div style="color: #6b7280; font-size: 13px;">Keine Daten</div>';
// Overtime Summary
const overtimeValue = document.getElementById('analyticsOvertimeValue');
const overtimePhases = document.getElementById('analyticsOvertimePhases');
if (overtimeValue) {
overtimeValue.textContent = formatSeconds(totalOvertime);
overtimeValue.style.color = totalOvertime > 0 ? '#f59e0b' : '#10b981';
}
if (overtimePhases) {
overtimePhases.textContent = `${phasesWithOvertime} von 5 Phasen`;
}
}
function formatDiffSeconds(seconds) {
const prefix = seconds >= 0 ? '+' : '';
const absSeconds = Math.abs(seconds);
const mins = Math.floor(absSeconds / 60);
const secs = absSeconds % 60;
return `${prefix}${mins}:${secs.toString().padStart(2, '0')}`;
}
// ==================== REFLECTION (Phase 5) ====================
let currentReflectionRating = null;
function setReflectionRating(rating) {
currentReflectionRating = rating;
// Sterne aktualisieren
const stars = document.querySelectorAll('.star-btn');
stars.forEach((star, index) => {
if (index < rating) {
star.classList.add('active');
} else {
star.classList.remove('active');
}
});
}
async function saveReflection() {
if (!lessonSession) {
showToast('Keine Session vorhanden');
return;
}
const notes = document.getElementById('reflectionNotes')?.value || '';
const nextLesson = document.getElementById('reflectionNextLesson')?.value || '';
// Mindestens Notizen oder Rating sollten vorhanden sein
if (!notes && !currentReflectionRating && !nextLesson) {
showToast('Bitte Notizen oder Bewertung eingeben');
return;
}
try {
const res = await fetch('/api/classroom/reflections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: lessonSession.session_id,
teacher_id: 'demo-teacher',
notes: notes,
overall_rating: currentReflectionRating,
what_worked: [], // Koennte spaeter erweitert werden
improvements: [],
notes_for_next_lesson: nextLesson
})
});
if (res.status === 409) {
// Bereits vorhanden - Update stattdessen
await updateExistingReflection(notes, nextLesson);
return;
}
if (!res.ok) {
throw new Error('Fehler beim Speichern');
}
showToast('Reflexion gespeichert');
// Button deaktivieren
const saveBtn = document.querySelector('.reflection-save-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="material-icons" style="font-size: 18px;">check</span> Gespeichert';
}
} catch (error) {
console.error('Error saving reflection:', error);
showToast('Fehler beim Speichern der Reflexion');
}
}
async function updateExistingReflection(notes, nextLesson) {
try {
// Zuerst existierende Reflection holen
const getRes = await fetch(`/api/classroom/reflections/session/${lessonSession.session_id}`);
if (!getRes.ok) {
throw new Error('Reflection nicht gefunden');
}
const existing = await getRes.json();
// Update durchfuehren
const updateRes = await fetch(`/api/classroom/reflections/${existing.reflection_id}?teacher_id=demo-teacher`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: notes,
overall_rating: currentReflectionRating,
notes_for_next_lesson: nextLesson
})
});
if (!updateRes.ok) {
throw new Error('Update fehlgeschlagen');
}
showToast('Reflexion aktualisiert');
const saveBtn = document.querySelector('.reflection-save-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="material-icons" style="font-size: 18px;">check</span> Gespeichert';
}
} catch (error) {
console.error('Error updating reflection:', error);
showToast('Fehler beim Aktualisieren');
}
}
async function loadExistingReflection() {
if (!lessonSession) return;
try {
const res = await fetch(`/api/classroom/reflections/session/${lessonSession.session_id}`);
if (!res.ok) return; // Keine bestehende Reflection
const data = await res.json();
// Formular befuellen
if (data.notes) {
document.getElementById('reflectionNotes').value = data.notes;
}
if (data.notes_for_next_lesson) {
document.getElementById('reflectionNextLesson').value = data.notes_for_next_lesson;
}
if (data.overall_rating) {
setReflectionRating(data.overall_rating);
}
// Button Text anpassen
const saveBtn = document.querySelector('.reflection-save-btn');
if (saveBtn) {
saveBtn.innerHTML = '<span class="material-icons" style="font-size: 18px;">save</span> Aktualisieren';
}
} catch (error) {
// Keine bestehende Reflection - OK
}
}
// ==================== EXPORT (Phase 5) ====================
function exportSessionPDF() {
if (!lessonSession) {
showToast('Keine Session zum Exportieren');
return;
}
// Neues Fenster mit Export-HTML oeffnen
const exportUrl = `/api/classroom/export/session/${lessonSession.session_id}`;
window.open(exportUrl, '_blank');
showToast('Export geoeffnet - Strg+P fuer PDF');
}
// ==================== Teacher Feedback (Phase 7) ====================
let selectedFeedbackType = 'improvement';
function openFeedbackModal() {
document.getElementById('feedbackModalOverlay').classList.add('active');
document.getElementById('feedbackForm').style.display = 'block';
document.getElementById('feedbackSuccess').style.display = 'none';
// Reset form
document.getElementById('feedbackTitle').value = '';
document.getElementById('feedbackDescription').value = '';
document.getElementById('feedbackName').value = '';
document.getElementById('feedbackEmail').value = '';
setFeedbackType('improvement');
}
function closeFeedbackModal(event) {
if (event && event.target !== event.currentTarget) return;
document.getElementById('feedbackModalOverlay').classList.remove('active');
}
function setFeedbackType(type) {
selectedFeedbackType = type;
document.querySelectorAll('.feedback-type-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === type);
});
}
async function submitFeedback() {
const title = document.getElementById('feedbackTitle').value.trim();
const description = document.getElementById('feedbackDescription').value.trim();
const name = document.getElementById('feedbackName').value.trim();
const email = document.getElementById('feedbackEmail').value.trim();
// Validierung
if (!title || title.length < 3) {
showToast('Bitte geben Sie einen Titel ein');
return;
}
if (!description || description.length < 10) {
showToast('Bitte beschreiben Sie Ihr Feedback genauer');
return;
}
const submitBtn = document.getElementById('feedbackSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet...';
try {
// Kontext sammeln
const contextPhase = lessonSession?.current_phase || '';
const contextSessionId = lessonSession?.session_id || null;
const response = await fetch('/api/classroom/feedback?teacher_id=demo-teacher', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
description: description,
feedback_type: selectedFeedbackType,
priority: 'medium',
teacher_name: name,
teacher_email: email,
context_url: window.location.href,
context_phase: contextPhase,
context_session_id: contextSessionId,
})
});
if (response.ok) {
document.getElementById('feedbackForm').style.display = 'none';
document.getElementById('feedbackSuccess').style.display = 'block';
console.log('Feedback submitted successfully');
} else {
const error = await response.json();
showToast('Fehler: ' + (error.detail || 'Unbekannter Fehler'));
}
} catch (error) {
console.error('Feedback submission error:', error);
showToast('Fehler beim Senden. Bitte versuchen Sie es spaeter.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Feedback senden';
}
}
// ==================== TEACHER SETTINGS (Feature f16) ====================
let teacherSettings = null;
const TEACHER_ID = 'demo-teacher'; // Wird spaeter durch Keycloak ersetzt
/**
* Laedt die Einstellungen des Lehrers vom Server.
*/
async function loadTeacherSettings() {
try {
const response = await fetch(`/api/classroom/settings/${TEACHER_ID}`);
if (response.ok) {
teacherSettings = await response.json();
applySettingsToUI();
applySettingsToSessionDefaults();
console.log('Teacher settings loaded:', teacherSettings);
}
} catch (error) {
console.log('Settings konnten nicht geladen werden, verwende Defaults:', error);
// Defaults werden schon im HTML gesetzt
}
}
/**
* Wendet die geladenen Einstellungen auf das Settings-Modal an.
*/
function applySettingsToUI() {
if (!teacherSettings) return;
const durations = teacherSettings.default_phase_durations || {};
if (durations.einstieg) document.getElementById('settingEinstieg').value = durations.einstieg;
if (durations.erarbeitung) document.getElementById('settingErarbeitung').value = durations.erarbeitung;
if (durations.sicherung) document.getElementById('settingSicherung').value = durations.sicherung;
if (durations.transfer) document.getElementById('settingTransfer').value = durations.transfer;
if (durations.reflexion) document.getElementById('settingReflexion').value = durations.reflexion;
updateTotalMinutes();
}
/**
* Wendet die Einstellungen auf die Session-Defaults an.
*/
function applySettingsToSessionDefaults() {
if (!teacherSettings || !teacherSettings.default_phase_durations) return;
const durations = teacherSettings.default_phase_durations;
currentPhaseDurations = {
einstieg: durations.einstieg || 8,
erarbeitung: durations.erarbeitung || 20,
sicherung: durations.sicherung || 10,
transfer: durations.transfer || 7,
reflexion: durations.reflexion || 5
};
}
/**
* Oeffnet das Einstellungen-Modal.
*/
function openSettingsModal() {
document.getElementById('settingsModalOverlay').classList.add('active');
updateTotalMinutes();
}
/**
* Schliesst das Einstellungen-Modal.
*/
function closeSettingsModal(event) {
if (event && event.target !== event.currentTarget) return;
document.getElementById('settingsModalOverlay').classList.remove('active');
}
/**
* Berechnet und zeigt die Gesamtminuten an.
*/
function updateTotalMinutes() {
const einstieg = parseInt(document.getElementById('settingEinstieg').value) || 0;
const erarbeitung = parseInt(document.getElementById('settingErarbeitung').value) || 0;
const sicherung = parseInt(document.getElementById('settingSicherung').value) || 0;
const transfer = parseInt(document.getElementById('settingTransfer').value) || 0;
const reflexion = parseInt(document.getElementById('settingReflexion').value) || 0;
const total = einstieg + erarbeitung + sicherung + transfer + reflexion;
document.getElementById('settingsTotalMinutes').textContent = total;
}
// Event Listeners fuer Phasen-Inputs
document.addEventListener('DOMContentLoaded', function() {
const inputs = ['settingEinstieg', 'settingErarbeitung', 'settingSicherung', 'settingTransfer', 'settingReflexion'];
inputs.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', updateTotalMinutes);
}
});
});
/**
* Speichert die Einstellungen auf dem Server.
*/
async function saveTeacherSettings() {
const saveBtn = document.getElementById('settingsSaveBtn');
saveBtn.disabled = true;
const durations = {
einstieg: parseInt(document.getElementById('settingEinstieg').value) || 8,
erarbeitung: parseInt(document.getElementById('settingErarbeitung').value) || 20,
sicherung: parseInt(document.getElementById('settingSicherung').value) || 10,
transfer: parseInt(document.getElementById('settingTransfer').value) || 7,
reflexion: parseInt(document.getElementById('settingReflexion').value) || 5
};
// Validierung
for (const [phase, value] of Object.entries(durations)) {
if (value < 1 || value > 120) {
showToast(`${phase}: Wert muss zwischen 1 und 120 liegen`);
saveBtn.disabled = false;
return;
}
}
try {
const response = await fetch(`/api/classroom/settings/${TEACHER_ID}/durations`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ durations })
});
if (response.ok) {
teacherSettings = await response.json();
applySettingsToSessionDefaults();
// Erfolgsmeldung
const toast = document.getElementById('settingsSavedToast');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
// Modal schliessen
setTimeout(() => closeSettingsModal(), 500);
} else {
const error = await response.json();
showToast('Fehler: ' + (error.detail || 'Konnte nicht speichern'));
}
} catch (error) {
console.error('Settings save error:', error);
showToast('Fehler beim Speichern. Bitte versuchen Sie es spaeter.');
} finally {
saveBtn.disabled = false;
}
}
/**
* Setzt die Einstellungen auf Standardwerte zurueck.
*/
function resetToDefaults() {
document.getElementById('settingEinstieg').value = 8;
document.getElementById('settingErarbeitung').value = 20;
document.getElementById('settingSicherung').value = 10;
document.getElementById('settingTransfer').value = 7;
document.getElementById('settingReflexion').value = 5;
updateTotalMinutes();
}
// Modul-Initialisierung (wird von loadModule('companion') aufgerufen)
function loadCompanionModule() {
console.log('Loading Companion Module...');
loadCompanionDashboard();
loadContextInfo(); // Kontext-Info Zeile laden
loadSuggestionsFromAPI(); // Suggestions von neuer API laden
loadTeacherSettings(); // Feature f16: Lehrer-Einstellungen laden
checkOnboardingNeeded(); // Onboarding-Status prüfen
}
"""