Files
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

399 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GeoGraSim V2 — TelemetryClient
*
* ESM-Bibliothek für V2-Module. Übernimmt:
* - URL-Parameter-Parsing (session_token, mode, difficulty, lang …)
* - Profil-Abruf (/api/student/me)
* - Runtime-Config (/api/runtime-config)
* - Heartbeat-Loop mit Plattform-Frequenz (clamped 1030s default)
* - Stuck-Detection (Default 90s, aus runtime-config)
* - Milestone- und Accessibility-Blocker-Events
* - State-Save (/api/module/state PUT)
* - Result-POST am Ende
* - Tab-Close via navigator.sendBeacon
* - Mock-Erkennung (?mock=1 in URL)
*
* Token-Refresh: Stub vorhanden, echte Implementation folgt mit V2-Login.
* Bei 401 → Lib redirected zu /v2beta/ (Login).
*
* Verwendung im Modul (siehe v2-modules/hallo-welt/public/game.html):
*
* import { TelemetryClient } from '../../../v2-platform/lib/telemetry-client.js';
*
* const tc = new TelemetryClient({
* moduleSlug: 'hallo-welt',
* moduleVersion: '1.0.0',
* specVersion: '1.0',
* });
* await tc.init();
*
* tc.setPhase('welcome', 'Begrüßung');
* tc.milestone('welcome-shown', 'Begrüßung gesehen', 1);
* tc.updateScore(score, 100);
*
* await tc.complete({
* completed: true,
* lehrplanCoverage: { 'AT-GW-1.4.2': 'full' },
* });
*/
export class TelemetryClient {
/**
* @param {object} options
* @param {string} options.moduleSlug — z.B. "hallo-welt"
* @param {string} options.moduleVersion — z.B. "1.0.0"
* @param {string} options.specVersion — z.B. "1.0"
* @param {number} [options.heartbeatPreferenceS=15] — Modul-Wunsch, wird auf runtime-config-Grenzen geclampt
* @param {(profile:object) => void} [options.onProfileLoaded]
* @param {(err:Error) => void} [options.onError]
* @param {(reason:string) => void} [options.onTokenExpired] — wenn null, default: redirect zu /v2beta/
* @param {(info:{idleS:number,phase:string}) => void} [options.onStuck] — UI-Hook: Banner einblenden
* @param {() => void} [options.onUnstuck] — UI-Hook: Banner ausblenden, wenn wieder Aktivität
*/
constructor(options) {
if (!options?.moduleSlug || !options?.moduleVersion || !options?.specVersion) {
throw new Error('TelemetryClient: moduleSlug, moduleVersion, specVersion sind Pflicht.');
}
this.opts = {
heartbeatPreferenceS: 15,
...options,
};
// URL-Parameter parsen
const params = new URLSearchParams(location.search);
this.session = {
sessionToken: params.get('session_token') || '',
studentId: parseInt(params.get('student_id') || '0', 10),
classId: parseInt(params.get('class_id') || '0', 10),
mode: params.get('mode') || 'free',
difficulty: params.get('difficulty') || 'L1',
lang: params.get('lang') || 'de-AT-standard',
resume: params.get('resume') === '1',
isMock: params.get('mock') === '1',
};
if (!this.session.sessionToken) {
throw new Error('TelemetryClient: kein session_token in URL.');
}
// API-Base: Mock oder Echt
// Pfad: von v2-modules/<slug>/public/game.html drei Ebenen hoch nach Repo-Root
this.apiBase = this.session.isMock
? '../../../v2-platform/mock/api'
: '../../../v2-platform/php/api';
// Runtime-State
this.profile = null;
this.config = null;
this.phase = null;
this.phaseLabel = null;
this.step = null;
this.score = 0;
this.scoreMax = 0;
this.startTs = Date.now();
this.phaseStartTs = Date.now();
this.lastActivityTs = Date.now();
this._heartbeatTimer = null;
this._stuckTimer = null;
this._stuckActive = false;
this._activityHandlers = null;
this._destroyed = false;
}
// ─── INIT ──────────────────────────────────────────────────────────
async init() {
// 1) runtime-config holen
this.config = await this._fetch('runtime-config.php', { method: 'GET' });
// 2) Profil holen
const profileRes = await this._fetch('student/me.php', { method: 'GET' });
this.profile = profileRes;
this.opts.onProfileLoaded?.(profileRes);
// 3) Activity-Tracking starten
this._activityHandlers = {
activity: () => { this.lastActivityTs = Date.now(); this._clearStuck(); },
visibility: () => {
if (document.visibilityState === 'visible') {
this.lastActivityTs = Date.now();
}
},
};
['click', 'touchstart', 'keydown'].forEach(e =>
document.addEventListener(e, this._activityHandlers.activity, { passive: true })
);
document.addEventListener('visibilitychange', this._activityHandlers.visibility);
// 4) Tab-Close-Beacon registrieren
addEventListener('beforeunload', () => this._sendFinalBeacon());
// 5) Heartbeat-Loop starten
this._startHeartbeat();
// 6) Stuck-Check starten
this._startStuckChecker();
return this;
}
// ─── PHASE / SCORE ─────────────────────────────────────────────────
setPhase(phase, phaseLabel = null, step = null) {
this.phase = phase;
this.phaseLabel = phaseLabel || phase;
this.step = step;
this.phaseStartTs = Date.now();
this.lastActivityTs = Date.now();
this._clearStuck();
// sofort Heartbeat senden, damit Lehrperson den Wechsel sieht
this._sendHeartbeat();
}
updateScore(score, scoreMax = null) {
this.score = score;
if (scoreMax !== null) this.scoreMax = scoreMax;
}
// ─── EVENTS ────────────────────────────────────────────────────────
/**
* Milestone emittieren.
* @param {string} id — muss in manifest.telemetryMilestones deklariert sein
* @param {string} label — sichtbarer Text
* @param {number} [scoreDelta=0]
*/
milestone(id, label, scoreDelta = 0) {
this.score += scoreDelta;
return this._post('telemetry.php', {
type: 'milestone',
milestoneId: id,
milestoneLabel: label,
scoreDelta,
currentScore: this.score,
phase: this.phase,
});
}
/**
* Accessibility-Blocker — wenn Modul für aktuelle Schüler*in nicht weiterspielbar ist.
* @param {object} info
* @param {'visual-required'|'audio-required'|'mouse-required'|'motor-required'} info.blockerType
* @param {string} info.hint
*/
accessibilityBlocker({ blockerType, hint }) {
return this._post('telemetry.php', {
type: 'accessibility-blocker',
blockerType,
hint,
phase: this.phase,
phaseLabel: this.phaseLabel,
});
}
// ─── STATE (Resume-Daten) ──────────────────────────────────────────
async saveState(state, summary = '') {
return this._post('module/state.php', {
moduleSlug: this.opts.moduleSlug,
state,
summary,
}, 'PUT');
}
// ─── ABSCHLUSS ─────────────────────────────────────────────────────
/**
* Ergebnis bei Modul-Abschluss senden + Cleanup.
* Modul ruft das EINMAL pro Sitzung, danach destroy().
*/
async complete(result) {
const body = {
moduleSlug: this.opts.moduleSlug,
moduleVersion: this.opts.moduleVersion,
specVersion: this.opts.specVersion,
completed: true,
abandoned: false,
score: this.score,
scoreMax: this.scoreMax || 100,
durationS: this.elapsedTotalS(),
difficulty: this.session.difficulty,
mode: this.session.mode,
milestonesReached: [],
lehrplanCoverage: {},
ts: new Date().toISOString(),
...result,
};
const res = await this._post('result.php', body);
return res;
}
/**
* Cleanup: Timer stoppen, Event-Listener entfernen.
* Wird von complete() / exit() automatisch aufgerufen.
*/
destroy() {
if (this._destroyed) return;
this._destroyed = true;
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
if (this._stuckTimer) clearInterval(this._stuckTimer);
if (this._activityHandlers) {
['click', 'touchstart', 'keydown'].forEach(e =>
document.removeEventListener(e, this._activityHandlers.activity)
);
document.removeEventListener('visibilitychange', this._activityHandlers.visibility);
}
}
/**
* Modul-Abbruch (z.B. User klickt ✕).
*/
exit(reason = 'user-exit') {
this._post('telemetry.php', {
type: 'milestone',
milestoneId: 'module-aborted',
milestoneLabel: `Modul abgebrochen (${reason})`,
phase: this.phase,
});
this.destroy();
}
// ─── UTILS für Modul-Code ──────────────────────────────────────────
elapsedTotalS() { return Math.floor((Date.now() - this.startTs) / 1000); }
elapsedPhaseS() { return Math.floor((Date.now() - this.phaseStartTs) / 1000); }
// ─── INTERN ────────────────────────────────────────────────────────
_startHeartbeat() {
const min = this.config?.heartbeat?.minIntervalS || 10;
const max = this.config?.heartbeat?.maxIntervalS || 30;
const clamped = Math.min(max, Math.max(min, this.opts.heartbeatPreferenceS));
this._heartbeatTimer = setInterval(() => {
// Bei Inaktivität > 60s nichts senden (User wahrscheinlich AFK)
if (Date.now() - this.lastActivityTs > 60_000) return;
this._sendHeartbeat();
}, clamped * 1000);
// Initial-Heartbeat
setTimeout(() => this._sendHeartbeat(), 100);
}
_sendHeartbeat() {
return this._post('telemetry.php', {
type: 'heartbeat',
phase: this.phase || 'init',
phaseLabel: this.phaseLabel || 'Initialisierung',
step: this.step,
currentScore: this.score || undefined,
scoreMax: this.scoreMax || undefined,
timeInPhaseS: this.elapsedPhaseS(),
totalTimeS: this.elapsedTotalS(),
});
}
_startStuckChecker() {
const threshold = this.config?.stuck?.thresholdS || 90;
this._stuckTimer = setInterval(() => {
if (this._stuckActive) return;
if (document.visibilityState !== 'visible') return;
const idleS = Math.floor((Date.now() - this.lastActivityTs) / 1000);
if (idleS >= threshold) {
this._stuckActive = true;
const info = { idleS, phase: this.phase };
this._post('telemetry.php', {
type: 'stuck',
phase: this.phase,
phaseLabel: this.phaseLabel,
step: this.step,
idleS,
hint: `Inaktiv in Phase '${this.phase}' seit ${idleS}s`,
});
this.opts.onStuck?.(info);
}
}, 10_000);
}
_clearStuck() {
if (this._stuckActive) {
this._stuckActive = false;
this.opts.onUnstuck?.();
}
}
// ─── HTTP ──────────────────────────────────────────────────────────
async _fetch(endpoint, opts = {}) {
const url = `${this.apiBase}/${endpoint}`;
const res = await fetch(url, {
method: opts.method || 'GET',
headers: {
Authorization: `Bearer ${this.session.sessionToken}`,
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) {
this._handleTokenExpired('401-on-' + endpoint);
throw new Error(`401 Unauthorized auf ${endpoint}`);
}
if (!res.ok) {
const err = new Error(`HTTP ${res.status} auf ${endpoint}`);
this.opts.onError?.(err);
throw err;
}
return res.json();
}
/**
* Telemetry-Post mit milder Fehlertoleranz (silent retry max 1×, dann verwerfen).
*/
async _post(endpoint, body, method = 'POST') {
const full = {
moduleSlug: this.opts.moduleSlug,
moduleVersion: this.opts.moduleVersion,
ts: new Date().toISOString(),
...body,
};
try {
return await this._fetch(endpoint, { method, body: full });
} catch (e) {
// Telemetry blockiert Modul nicht — Fehler still verschluckt
return null;
}
}
_handleTokenExpired(reason) {
if (this.opts.onTokenExpired) {
this.opts.onTokenExpired(reason);
} else {
// Default: zurück zur V2-Login-Seite
// Aber: nur wenn nicht in iframe (Mock-Test-Harness lädt im iframe)
if (window.parent === window) {
location.href = '/v2beta/';
} else {
console.warn(`[TelemetryClient] Token expired (${reason}). Im iframe — kein Auto-Redirect.`);
}
}
}
_sendFinalBeacon() {
if (!navigator.sendBeacon || this._destroyed) return;
const body = JSON.stringify({
type: 'heartbeat',
moduleSlug: this.opts.moduleSlug,
moduleVersion: this.opts.moduleVersion,
phase: 'tab-closed',
phaseLabel: 'Tab geschlossen',
currentScore: this.score || undefined,
scoreMax: this.scoreMax || undefined,
timeInPhaseS: this.elapsedPhaseS(),
totalTimeS: this.elapsedTotalS(),
ts: new Date().toISOString(),
});
navigator.sendBeacon(
`${this.apiBase}/telemetry.php?session_token=${encodeURIComponent(this.session.sessionToken)}`,
new Blob([body], { type: 'application/json' })
);
}
}