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>
2371 lines
89 KiB
Python
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
|
|
}
|
|
"""
|
|
|