Baustein 10a: PWA, Offlinebetrieb, Druckansichten
Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt (scripts/symbole.ts, kein Bilddienst), Verknuepfungen zu Lernmodus, Berichtsheft und Handouts. Offline gilt eine Regel, und die steht im Dienstarbeiter: Nur das Lernmaterial liegt im Browserspeicher - Handouts, Merkkarten, Glossar, die eigene Woche. Lernzielkontrollen, Berichtsheft, Bewertungen, Feedbackgespraeche, Anhaenge und jedes PDF niemals. Das ist keine Sparsamkeit: Was im Browsercache liegt, ist an der serverseitigen Rechtepruefung vorbei. Bewertungen ohne Netz gehen in eine Warteschlange und werden nachgereicht. Die Schlange haengt an der Benutzerkennung, nicht am Geraet - auf einem geteilten Werkstattgeraet bekaeme sonst der Zweite die offenen Bewertungen des Ersten aufs Konto. Der Tag der Bewertung kommt vom Geraet und wird auf heute gedeckelt (tagDerBewertung, 4 Tests): Eine vorgestellte Uhr darf das Wiederholen nicht wegstellen. Druck: Wer die dunkle Fassung eingestellt hatte, druckte bisher schwarze Flaechen. Im Druck wird jetzt die komplette helle Palette gesetzt, dazu A4-Raender, keine Umbrueche mitten in Karten und Zeilen, aufgeklappte details. Druckknoepfe bei Handout, Glossar und Auswertung. Durchstich in scripts/pruefungen/baustein-10.mjs mit echtem Abschalten des Netzes: 33 Pruefungen, darunter der Nachweis, dass nichts Bewertungsrelevantes im Speicher landet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
be2a674c7a
commit
2babef9293
+2
-1
@@ -19,7 +19,8 @@
|
|||||||
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
|
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
|
||||||
"backup": "bash scripts/im-container/sicherung.sh",
|
"backup": "bash scripts/im-container/sicherung.sh",
|
||||||
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
||||||
"screenshots": "tsx scripts/screenshots.ts"
|
"screenshots": "tsx scripts/screenshots.ts",
|
||||||
|
"symbole": "tsx scripts/symbole.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Der Dienstarbeiter der Lern-Bar.
|
||||||
|
*
|
||||||
|
* Er hat genau eine Aufgabe: Der Azubi soll im Serverraum, im Keller oder im
|
||||||
|
* Zug weiterlernen können, wo kein Netz ist. Alles andere wäre Ballast.
|
||||||
|
*
|
||||||
|
* ─── Was offline liegen darf ──────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Nur das Lernmaterial: Handouts, Merkkarten, Glossar und die eigene Woche.
|
||||||
|
*
|
||||||
|
* **Nicht** zwischengespeichert werden Lernzielkontrollen, Berichtsheft,
|
||||||
|
* Bewertungen, Feedbackgespräche, Anhänge und jedes PDF. Der Grund ist keine
|
||||||
|
* Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft: Musterlösungen und
|
||||||
|
* Bewertungsdaten stehen hinter einer eigenen Prüfung. Was im Browsercache
|
||||||
|
* liegt, ist an dieser Prüfung vorbei – also liegt es dort nicht.
|
||||||
|
*
|
||||||
|
* ─── Wie zwischengespeichert wird ─────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Seiten: erst das Netz, dann der Speicher. So sieht man nach einer
|
||||||
|
* Aktualisierung sofort die neue Fassung und nur bei fehlendem Netz die alte.
|
||||||
|
*
|
||||||
|
* Dateien unter /_next/static/: erst der Speicher. Die Namen tragen eine
|
||||||
|
* Prüfsumme, ihr Inhalt ändert sich nie.
|
||||||
|
*
|
||||||
|
* Alles andere – Serveraktionen, Anmeldung, POST – geht immer ans Netz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FASSUNG = 'v1';
|
||||||
|
const SCHALE = `lernbar-schale-${FASSUNG}`;
|
||||||
|
const SEITEN = `lernbar-seiten-${FASSUNG}`;
|
||||||
|
const DATEIEN = `lernbar-dateien-${FASSUNG}`;
|
||||||
|
|
||||||
|
/** Was schon beim Einrichten vorliegen muss. */
|
||||||
|
const GRUNDVORRAT = ['/offline', '/symbol-192.png', '/lernbar.svg', '/manifest.webmanifest'];
|
||||||
|
|
||||||
|
/** Höchstens so viele Seiten liegen im Speicher – ein Handy ist kein Archiv. */
|
||||||
|
const HOECHSTZAHL_SEITEN = 60;
|
||||||
|
|
||||||
|
/** Nur diese Pfade dürfen offline vorliegen. Alles andere niemals. */
|
||||||
|
function darfOfflineLiegen(pfad) {
|
||||||
|
if (pfad === '/' || pfad === '/offline' || pfad === '/glossar') return true;
|
||||||
|
if (pfad === '/handouts' || pfad.startsWith('/handouts/')) return true;
|
||||||
|
if (pfad === '/karten' || pfad.startsWith('/karten/')) return true;
|
||||||
|
if (/^\/woche\/\d+$/.test(pfad)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SCHALE)
|
||||||
|
// Einzeln, damit ein fehlendes Stück nicht die ganze Einrichtung kippt.
|
||||||
|
.then((speicher) => Promise.all(GRUNDVORRAT.map((pfad) => speicher.add(pfad).catch(() => undefined))))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((namen) =>
|
||||||
|
Promise.all(
|
||||||
|
namen
|
||||||
|
.filter((name) => name.startsWith('lernbar-') && ![SCHALE, SEITEN, DATEIEN].includes(name))
|
||||||
|
.map((name) => caches.delete(name)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beim Abmelden wird alles Persönliche weggeräumt.
|
||||||
|
*
|
||||||
|
* Ein geteiltes Werkstattgerät soll nach dem Abmelden nicht noch die Woche des
|
||||||
|
* vorigen Azubis im Speicher haben.
|
||||||
|
*/
|
||||||
|
self.addEventListener('message', (ereignis) => {
|
||||||
|
if (ereignis.data && ereignis.data.typ === 'aufraeumen') {
|
||||||
|
ereignis.waitUntil(Promise.all([caches.delete(SEITEN), caches.delete(DATEIEN)]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function speicherKuerzen(name, hoechstzahl) {
|
||||||
|
const speicher = await caches.open(name);
|
||||||
|
const schluessel = await speicher.keys();
|
||||||
|
// Die ältesten zuerst – `keys()` liefert sie in der Reihenfolge des Ablegens.
|
||||||
|
for (const alt of schluessel.slice(0, Math.max(0, schluessel.length - hoechstzahl))) {
|
||||||
|
await speicher.delete(alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seiten: erst das Netz, bei Ausfall der Speicher, sonst die Offlineseite. */
|
||||||
|
async function seiteHolen(anfrage) {
|
||||||
|
const pfad = new URL(anfrage.url).pathname;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(anfrage);
|
||||||
|
|
||||||
|
if (antwort.ok && darfOfflineLiegen(pfad)) {
|
||||||
|
const speicher = await caches.open(SEITEN);
|
||||||
|
await speicher.put(anfrage, antwort.clone());
|
||||||
|
await speicherKuerzen(SEITEN, HOECHSTZAHL_SEITEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return antwort;
|
||||||
|
} catch (fehler) {
|
||||||
|
const gespeichert = await caches.match(anfrage, { ignoreSearch: false });
|
||||||
|
if (gespeichert) return gespeichert;
|
||||||
|
|
||||||
|
const offline = await caches.match('/offline');
|
||||||
|
if (offline) return offline;
|
||||||
|
|
||||||
|
return new Response('Kein Netz und nichts im Speicher.', {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unveränderliche Dateien: erst der Speicher. */
|
||||||
|
async function dateiHolen(anfrage) {
|
||||||
|
const gespeichert = await caches.match(anfrage);
|
||||||
|
if (gespeichert) return gespeichert;
|
||||||
|
|
||||||
|
const antwort = await fetch(anfrage);
|
||||||
|
if (antwort.ok) {
|
||||||
|
const speicher = await caches.open(DATEIEN);
|
||||||
|
await speicher.put(anfrage, antwort.clone());
|
||||||
|
}
|
||||||
|
return antwort;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (ereignis) => {
|
||||||
|
const anfrage = ereignis.request;
|
||||||
|
|
||||||
|
// Nur eigene Adressen, nur Lesezugriffe.
|
||||||
|
if (anfrage.method !== 'GET') return;
|
||||||
|
|
||||||
|
const adresse = new URL(anfrage.url);
|
||||||
|
if (adresse.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Serveraktionen und Datenschnittstellen gehen immer ans Netz – eine
|
||||||
|
// zwischengespeicherte Antwort wäre hier schlicht falsch.
|
||||||
|
if (adresse.pathname.startsWith('/api/') || anfrage.headers.has('Next-Action')) return;
|
||||||
|
|
||||||
|
// Auch die Teilnachladungen von Next (RSC) nicht: Sie gehören zu einer
|
||||||
|
// Sitzung und sollen nicht als Seite verwechselt werden.
|
||||||
|
if (adresse.searchParams.has('_rsc')) return;
|
||||||
|
|
||||||
|
if (anfrage.mode === 'navigate') {
|
||||||
|
ereignis.respondWith(seiteHolen(anfrage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adresse.pathname.startsWith('/_next/static/') || adresse.pathname.endsWith('.svg') || adresse.pathname.endsWith('.png')) {
|
||||||
|
ereignis.respondWith(dateiHolen(anfrage));
|
||||||
|
}
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,292 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck.
|
||||||
|
*
|
||||||
|
* Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz
|
||||||
|
* weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen.
|
||||||
|
* Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen
|
||||||
|
* Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den
|
||||||
|
* Browserspeicher.
|
||||||
|
*
|
||||||
|
* Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
|
||||||
|
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
|
||||||
|
|
||||||
|
let fehler = 0;
|
||||||
|
function pruefe(bedingung, text, zusatz = '') {
|
||||||
|
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
|
||||||
|
if (!bedingung) fehler += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const kontext = await browser.newContext({ locale: 'de-DE' });
|
||||||
|
const seite = await kontext.newPage();
|
||||||
|
|
||||||
|
async function anmelden() {
|
||||||
|
await seite.goto(`${BASIS}/anmelden`);
|
||||||
|
await seite.fill('#email', AZUBI.email);
|
||||||
|
await seite.fill('#passwort', AZUBI.pw);
|
||||||
|
await seite.click('button[type=submit]');
|
||||||
|
await seite.waitForURL(`${BASIS}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */
|
||||||
|
async function dienstarbeiterBereit() {
|
||||||
|
return seite.evaluate(async () => {
|
||||||
|
if (!('serviceWorker' in navigator)) return false;
|
||||||
|
const registrierung = await navigator.serviceWorker.ready;
|
||||||
|
// `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab.
|
||||||
|
if (navigator.serviceWorker.controller) return true;
|
||||||
|
await new Promise((fertig) => {
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true });
|
||||||
|
window.setTimeout(fertig, 5000);
|
||||||
|
});
|
||||||
|
return Boolean(navigator.serviceWorker.controller || registrierung.active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. Die Anwendung lässt sich installieren ------------------------------
|
||||||
|
|
||||||
|
console.log('\n1. Installierbar');
|
||||||
|
|
||||||
|
const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`);
|
||||||
|
pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`);
|
||||||
|
|
||||||
|
const manifestInhalt = await manifest.json().catch(() => null);
|
||||||
|
pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung');
|
||||||
|
pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung');
|
||||||
|
pruefe(
|
||||||
|
Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'),
|
||||||
|
'Es enthält ein Symbol für den Beschnitt unter Android',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) {
|
||||||
|
const antwort = await kontext.request.get(`${BASIS}${datei}`);
|
||||||
|
pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz
|
||||||
|
// ohne Internet.
|
||||||
|
await anmelden();
|
||||||
|
const fremdeQuellen = await seite.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('script[src], link[href], img[src]')]
|
||||||
|
.map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '')
|
||||||
|
.filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)),
|
||||||
|
);
|
||||||
|
pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine');
|
||||||
|
|
||||||
|
// --- 2. Der Dienstarbeiter übernimmt ---------------------------------------
|
||||||
|
|
||||||
|
console.log('\n2. Dienstarbeiter');
|
||||||
|
|
||||||
|
pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite');
|
||||||
|
|
||||||
|
// Lernmaterial einmal ansehen, damit es im Speicher landet.
|
||||||
|
await seite.goto(`${BASIS}/handouts/N3`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
await seite.goto(`${BASIS}/karten/lernen`);
|
||||||
|
await seite.waitForSelector('h1, .lernkarte');
|
||||||
|
|
||||||
|
// Und etwas, das **nicht** in den Speicher darf.
|
||||||
|
await seite.goto(`${BASIS}/kontrollen`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
|
||||||
|
const imSpeicher = await seite.evaluate(async () => {
|
||||||
|
const namen = await caches.keys();
|
||||||
|
const pfade = [];
|
||||||
|
for (const name of namen) {
|
||||||
|
const speicher = await caches.open(name);
|
||||||
|
for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname);
|
||||||
|
}
|
||||||
|
return pfade;
|
||||||
|
});
|
||||||
|
|
||||||
|
pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher');
|
||||||
|
pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher');
|
||||||
|
|
||||||
|
// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune.
|
||||||
|
const verboten = imSpeicher.filter((pfad) =>
|
||||||
|
/^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad),
|
||||||
|
);
|
||||||
|
pruefe(
|
||||||
|
verboten.length === 0,
|
||||||
|
'Nichts Bewertungsrelevantes liegt im Browserspeicher',
|
||||||
|
verboten.join(', ') || 'nichts',
|
||||||
|
);
|
||||||
|
pruefe(
|
||||||
|
!imSpeicher.some((pfad) => pfad.endsWith('/pdf')),
|
||||||
|
'Kein PDF liegt im Browserspeicher',
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 3. Ohne Netz --------------------------------------------------------
|
||||||
|
|
||||||
|
console.log('\n3. Ohne Netz');
|
||||||
|
|
||||||
|
await kontext.setOffline(true);
|
||||||
|
|
||||||
|
const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null);
|
||||||
|
pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz');
|
||||||
|
pruefe(
|
||||||
|
(await seite.locator('h1').innerText().catch(() => '')).length > 0,
|
||||||
|
'Es ist wirklich der Inhalt und keine Fehlerseite',
|
||||||
|
await seite.locator('h1').innerText().catch(() => '—'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null);
|
||||||
|
pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz');
|
||||||
|
|
||||||
|
// Die Netzleiste sagt Bescheid.
|
||||||
|
await seite.waitForTimeout(500);
|
||||||
|
const leiste = await seite.locator('.netzleiste').count();
|
||||||
|
pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint');
|
||||||
|
|
||||||
|
// Eine Seite, die nie im Speicher war, landet auf der Offlineseite.
|
||||||
|
const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null);
|
||||||
|
const unbekannterText = await seite.locator('body').innerText().catch(() => '');
|
||||||
|
pruefe(
|
||||||
|
unbekannterText.includes('Kein Netz'),
|
||||||
|
'Eine nicht gespeicherte Seite zeigt die Offlineseite',
|
||||||
|
`HTTP ${unbekannt?.status() ?? '—'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 4. Bewertungen gehen nicht verloren -----------------------------------
|
||||||
|
|
||||||
|
console.log('\n4. Bewertungen ohne Netz');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/karten/lernen`);
|
||||||
|
const hatKarte = (await seite.locator('.lernkarte').count()) > 0;
|
||||||
|
|
||||||
|
if (!hatKarte) {
|
||||||
|
console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen');
|
||||||
|
} else {
|
||||||
|
await seite.click('.lernkarte');
|
||||||
|
await seite.waitForSelector('.bewerten-knopf');
|
||||||
|
await seite.click('.bewerten-sitzt');
|
||||||
|
await seite.waitForTimeout(800);
|
||||||
|
|
||||||
|
const schlange = await seite.evaluate(() =>
|
||||||
|
Object.keys(localStorage)
|
||||||
|
.filter((s) => s.startsWith('lernbar-warteschlange-'))
|
||||||
|
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
|
||||||
|
);
|
||||||
|
pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`);
|
||||||
|
pruefe(
|
||||||
|
schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)),
|
||||||
|
'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung',
|
||||||
|
);
|
||||||
|
|
||||||
|
const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText();
|
||||||
|
pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim());
|
||||||
|
|
||||||
|
// --- Und wieder im Netz ---
|
||||||
|
console.log('\n5. Wieder im Netz');
|
||||||
|
|
||||||
|
// Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück,
|
||||||
|
// während der Azubi noch im Lernmodus steht – genau dort muss die
|
||||||
|
// Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie,
|
||||||
|
// weil die Schlange dann schon leer ist.
|
||||||
|
await kontext.setOffline(false);
|
||||||
|
|
||||||
|
const bestaetigung = await seite
|
||||||
|
.locator('.netzleiste')
|
||||||
|
.filter({ hasText: 'nachgereicht' })
|
||||||
|
.first()
|
||||||
|
.innerText({ timeout: 15000 })
|
||||||
|
.catch(() => '');
|
||||||
|
pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—');
|
||||||
|
|
||||||
|
const nachher = await seite.evaluate(() =>
|
||||||
|
Object.keys(localStorage)
|
||||||
|
.filter((s) => s.startsWith('lernbar-warteschlange-'))
|
||||||
|
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
|
||||||
|
);
|
||||||
|
pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`);
|
||||||
|
|
||||||
|
// Und die Bewertung ist wirklich angekommen, nicht nur verschwunden.
|
||||||
|
await seite.goto(`${BASIS}/karten`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
const uebersicht = await seite.locator('body').innerText();
|
||||||
|
pruefe(
|
||||||
|
!/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht),
|
||||||
|
'Die Übersicht kennt den neuen Lernstand',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await kontext.setOffline(false);
|
||||||
|
|
||||||
|
// --- 6. Die Schnittstelle prüft die Rechte ---------------------------------
|
||||||
|
|
||||||
|
console.log('\n6. Rechte an der Schnittstelle');
|
||||||
|
|
||||||
|
const ohneAnmeldung = await browser.newContext();
|
||||||
|
const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, {
|
||||||
|
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
|
||||||
|
});
|
||||||
|
pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`);
|
||||||
|
await ohneAnmeldung.close();
|
||||||
|
|
||||||
|
const alsAusbilder = await browser.newContext({ locale: 'de-DE' });
|
||||||
|
const ausbilderSeite = await alsAusbilder.newPage();
|
||||||
|
await ausbilderSeite.goto(`${BASIS}/anmelden`);
|
||||||
|
await ausbilderSeite.fill('#email', 'fw@wildeflorian.de');
|
||||||
|
await ausbilderSeite.fill('#passwort', 'ausbilder-start');
|
||||||
|
await ausbilderSeite.click('button[type=submit]');
|
||||||
|
await ausbilderSeite.waitForURL(`${BASIS}/`);
|
||||||
|
|
||||||
|
const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, {
|
||||||
|
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
|
||||||
|
});
|
||||||
|
pruefe(
|
||||||
|
alsAusbilderAbgewiesen.status() === 403,
|
||||||
|
'Der Ausbilder darf den Lernstand nicht führen',
|
||||||
|
`HTTP ${alsAusbilderAbgewiesen.status()}`,
|
||||||
|
);
|
||||||
|
await alsAusbilder.close();
|
||||||
|
|
||||||
|
// --- 7. Druck --------------------------------------------------------------
|
||||||
|
|
||||||
|
console.log('\n7. Druck');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/handouts/N3`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
|
||||||
|
// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem
|
||||||
|
// früher schwarze Flächen aufs Papier gekommen wären.
|
||||||
|
await seite.evaluate(() => {
|
||||||
|
document.documentElement.setAttribute('data-thema', 'dunkel');
|
||||||
|
localStorage.setItem('lernbar-thema', 'dunkel');
|
||||||
|
});
|
||||||
|
await seite.emulateMedia({ media: 'print' });
|
||||||
|
await seite.waitForTimeout(200);
|
||||||
|
|
||||||
|
const druckfarben = await seite.evaluate(() => {
|
||||||
|
const stil = getComputedStyle(document.body);
|
||||||
|
const wurzel = getComputedStyle(document.documentElement);
|
||||||
|
return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() };
|
||||||
|
});
|
||||||
|
pruefe(
|
||||||
|
druckfarben.grund === 'rgb(255, 255, 255)',
|
||||||
|
'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung',
|
||||||
|
druckfarben.grund,
|
||||||
|
);
|
||||||
|
pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text);
|
||||||
|
pruefe(
|
||||||
|
druckfarben.flaeche.toLowerCase() === '#f2f2f2',
|
||||||
|
'Auch die Flächen sind hell',
|
||||||
|
druckfarben.flaeche,
|
||||||
|
);
|
||||||
|
|
||||||
|
const versteckt = await seite.evaluate(() => {
|
||||||
|
const kopf = document.querySelector('.kopf');
|
||||||
|
return kopf ? getComputedStyle(kopf).display : 'keine';
|
||||||
|
});
|
||||||
|
pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt);
|
||||||
|
|
||||||
|
await seite.emulateMedia({ media: null });
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
|
||||||
|
process.exit(fehler === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst –
|
||||||
|
* ohne Bilddienst, ohne Netz.
|
||||||
|
*
|
||||||
|
* npm run symbole
|
||||||
|
*
|
||||||
|
* Zwei Fassungen, weil Android sie unterschiedlich behandelt:
|
||||||
|
*
|
||||||
|
* symbol-192.png / symbol-512.png das Zeichen, wie es ist
|
||||||
|
* symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf
|
||||||
|
* Petrol und nach innen gerückt. Android
|
||||||
|
* schneidet das Symbol in eine eigene
|
||||||
|
* Form – ein Kreis, ein Tropfen, was das
|
||||||
|
* Gerät gerade mag. Was zu weit außen
|
||||||
|
* liegt, ist dann weg.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const ZIEL = path.join(process.cwd(), 'public');
|
||||||
|
|
||||||
|
/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */
|
||||||
|
function zeichen(randAnteil: number, eckenRadius: number): string {
|
||||||
|
// Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten.
|
||||||
|
// `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern.
|
||||||
|
const innen = 64 * (1 - 2 * randAnteil);
|
||||||
|
const versatz = 64 * randAnteil;
|
||||||
|
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="${eckenRadius}" fill="#1F4E5F"/>
|
||||||
|
<g transform="translate(${versatz} ${versatz}) scale(${innen / 64})">
|
||||||
|
<rect x="14" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
|
||||||
|
<rect x="27" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
|
||||||
|
<rect x="40" y="16" width="10" height="32" rx="2" fill="#D98A2B"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Auftrag = { datei: string; groesse: number; svg: string };
|
||||||
|
|
||||||
|
const auftraege: Auftrag[] = [
|
||||||
|
{ datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) },
|
||||||
|
{ datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) },
|
||||||
|
// Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden.
|
||||||
|
{ datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
|
||||||
|
for (const auftrag of auftraege) {
|
||||||
|
const seite = await browser.newPage({
|
||||||
|
viewport: { width: auftrag.groesse, height: auftrag.groesse },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await seite.setContent(
|
||||||
|
`<!doctype html><style>html,body{margin:0;padding:0}svg{display:block;width:100vw;height:100vh}</style>${auftrag.svg}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const datei = path.join(ZIEL, auftrag.datei);
|
||||||
|
await seite.screenshot({ path: datei, omitBackground: false });
|
||||||
|
await seite.close();
|
||||||
|
|
||||||
|
console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Symbole erzeugt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((fehler) => {
|
||||||
|
console.error('Die Symbole konnten nicht erzeugt werden.');
|
||||||
|
console.error(fehler instanceof Error ? fehler.message : fehler);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { benutzerNachId } from '@/lib/repos/benutzer';
|
|||||||
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
|
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
|
||||||
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
|
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
|
||||||
import { verlauf } from '@/lib/kontrollen/bewertung';
|
import { verlauf } from '@/lib/kontrollen/bewertung';
|
||||||
|
import { Druckknopf } from '@/komponenten/Druckknopf';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Auswertung' };
|
export const metadata: Metadata = { title: 'Auswertung' };
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -72,6 +73,12 @@ export default async function AuswertungsSeite({ searchParams }: { searchParams:
|
|||||||
<Rahmenplan azubiId={azubiId} />
|
<Rahmenplan azubiId={azubiId} />
|
||||||
<Kontrollverlauf azubiId={azubiId} />
|
<Kontrollverlauf azubiId={azubiId} />
|
||||||
<Lernfelder azubiId={azubiId} />
|
<Lernfelder azubiId={azubiId} />
|
||||||
|
|
||||||
|
{/* Die Abdeckung ist die Ansicht, die man zum Gespräch mitnimmt – und zwar
|
||||||
|
auf Papier, weil dort niemand scrollt. */}
|
||||||
|
<div className="reihe nicht-drucken">
|
||||||
|
<Druckknopf beschriftung="Auswertung drucken" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte';
|
|||||||
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
|
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
|
||||||
import { normalisiere } from '@/lib/inhalte/laden';
|
import { normalisiere } from '@/lib/inhalte/laden';
|
||||||
import { GlossarSuche } from './GlossarSuche';
|
import { GlossarSuche } from './GlossarSuche';
|
||||||
|
import { Druckknopf } from '@/komponenten/Druckknopf';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Glossar' };
|
export const metadata: Metadata = { title: 'Glossar' };
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -67,6 +68,10 @@ export default async function GlossarSeite({ searchParams }: { searchParams: Pro
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="reihe nicht-drucken">
|
||||||
|
<Druckknopf beschriftung="Glossar drucken" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
|
|||||||
import { gelesenAm } from '@/lib/repos/fortschritt';
|
import { gelesenAm } from '@/lib/repos/fortschritt';
|
||||||
import { alsZeitpunktText } from '@/lib/zeit';
|
import { alsZeitpunktText } from '@/lib/zeit';
|
||||||
import { gelesenUmschalten } from './aktionen';
|
import { gelesenUmschalten } from './aktionen';
|
||||||
|
import { Druckknopf } from '@/komponenten/Druckknopf';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -159,6 +160,10 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
|
|||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="reihe nicht-drucken">
|
||||||
|
<Druckknopf beschriftung="Handout drucken" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
|
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
|
||||||
{vorher ? (
|
{vorher ? (
|
||||||
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
|
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { karteBewerten, type Bewertungsergebnis } from '../aktionen';
|
import { karteBewerten, type Bewertungsergebnis } from '../aktionen';
|
||||||
|
import { einreihen } from '@/lib/offline/warteschlange';
|
||||||
|
|
||||||
export type Lernkarte = {
|
export type Lernkarte = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,7 +31,15 @@ const knoepfe: { bewertung: Bewertung; text: string; taste: string; klasse: stri
|
|||||||
* Abkürzung, kein Überfahren mit der Maus. Am Rechner tun es zusätzlich die
|
* Abkürzung, kein Überfahren mit der Maus. Am Rechner tun es zusätzlich die
|
||||||
* Tasten 1, 2, 3 und die Leertaste.
|
* Tasten 1, 2, 3 und die Leertaste.
|
||||||
*/
|
*/
|
||||||
export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: string | null }) {
|
export function Lernmodus({
|
||||||
|
karten,
|
||||||
|
block,
|
||||||
|
benutzerId,
|
||||||
|
}: {
|
||||||
|
karten: Lernkarte[];
|
||||||
|
block: string | null;
|
||||||
|
benutzerId: string;
|
||||||
|
}) {
|
||||||
const [stapel, setzeStapel] = useState<Lernkarte[]>(karten);
|
const [stapel, setzeStapel] = useState<Lernkarte[]>(karten);
|
||||||
const [stelle, setzeStelle] = useState(0);
|
const [stelle, setzeStelle] = useState(0);
|
||||||
const [umgedreht, setzeUmgedreht] = useState(false);
|
const [umgedreht, setzeUmgedreht] = useState(false);
|
||||||
@@ -48,7 +57,7 @@ export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: strin
|
|||||||
if (!karte || laeuft) return;
|
if (!karte || laeuft) return;
|
||||||
setzeLaeuft(true);
|
setzeLaeuft(true);
|
||||||
|
|
||||||
const ergebnis = await karteBewerten(karte.id, bewertung);
|
const ergebnis = await bewertungAbsetzen(benutzerId, karte.id, bewertung);
|
||||||
setzeRueckmeldung(ergebnis);
|
setzeRueckmeldung(ergebnis);
|
||||||
setzeZaehler((z) => ({ ...z, [bewertung]: z[bewertung] + 1 }));
|
setzeZaehler((z) => ({ ...z, [bewertung]: z[bewertung] + 1 }));
|
||||||
setzeErledigt((e) => e + 1);
|
setzeErledigt((e) => e + 1);
|
||||||
@@ -63,7 +72,7 @@ export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: strin
|
|||||||
setzeStelle((s) => s + 1);
|
setzeStelle((s) => s + 1);
|
||||||
setzeLaeuft(false);
|
setzeLaeuft(false);
|
||||||
},
|
},
|
||||||
[karte, laeuft],
|
[karte, laeuft, benutzerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -239,3 +248,32 @@ export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: strin
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Bewertung absetzen – und wenn kein Netz da ist, sie merken.
|
||||||
|
*
|
||||||
|
* Ohne Netz scheitert die Serveraktion mit einem Netzfehler. Dann geht die
|
||||||
|
* Bewertung in die Warteschlange und wird nachgereicht, sobald die Verbindung
|
||||||
|
* wieder steht. Wann die Karte dann wiederkommt, sagt der Server; hier wird
|
||||||
|
* bewusst nichts ausgerechnet, was später anders ausfallen könnte.
|
||||||
|
*/
|
||||||
|
async function bewertungAbsetzen(
|
||||||
|
benutzerId: string,
|
||||||
|
karteId: string,
|
||||||
|
bewertung: Bewertung,
|
||||||
|
): Promise<Bewertungsergebnis> {
|
||||||
|
try {
|
||||||
|
return await karteBewerten(karteId, bewertung);
|
||||||
|
} catch {
|
||||||
|
einreihen(benutzerId, { karteId, bewertung, erfasstAm: heutigesDatum() });
|
||||||
|
window.dispatchEvent(new Event('lernbar-warteschlange'));
|
||||||
|
return { wiederAm: 'gemerkt – geht ab, sobald wieder Netz da ist' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der heutige Tag nach der Uhr des Geräts, als `JJJJ-MM-TT`. */
|
||||||
|
function heutigesDatum(): string {
|
||||||
|
const jetzt = new Date();
|
||||||
|
const zweistellig = (zahl: number) => String(zahl).padStart(2, '0');
|
||||||
|
return `${jetzt.getFullYear()}-${zweistellig(jetzt.getMonth() + 1)}-${zweistellig(jetzt.getDate())}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,5 +73,5 @@ export default async function LernSeite({ searchParams }: { searchParams: Promis
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Lernmodus karten={karten} block={gewaehlterBlock ?? null} />;
|
return <Lernmodus karten={karten} block={gewaehlterBlock ?? null} benutzerId={benutzer.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||||
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
|
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
|
||||||
|
import { Netzzustand } from '@/komponenten/Netzzustand';
|
||||||
import { abmelden } from '../anmelden/aktionen';
|
import { abmelden } from '../anmelden/aktionen';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -49,6 +50,7 @@ export default async function AngemeldetLayout({ children }: { children: React.R
|
|||||||
rolle={benutzer.rolle === 'ausbilder' ? 'Ausbilder' : 'Azubi'}
|
rolle={benutzer.rolle === 'ausbilder' ? 'Ausbilder' : 'Azubi'}
|
||||||
abmeldeAktion={abmelden}
|
abmeldeAktion={abmelden}
|
||||||
/>
|
/>
|
||||||
|
<Netzzustand benutzerId={benutzer.id} />
|
||||||
<main id="inhalt" className="huelle">
|
<main id="inhalt" className="huelle">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
|
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
|
||||||
import { AnmeldeFormular } from './AnmeldeFormular';
|
import { AnmeldeFormular } from './AnmeldeFormular';
|
||||||
|
import { Aufraeumen } from '@/komponenten/Aufraeumen';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Anmelden' };
|
export const metadata: Metadata = { title: 'Anmelden' };
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -11,6 +12,7 @@ export default async function AnmeldeSeite() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="anmeldung">
|
<main className="anmeldung">
|
||||||
|
<Aufraeumen />
|
||||||
<div className="anmeldung-karte stapel">
|
<div className="anmeldung-karte stapel">
|
||||||
<div>
|
<div>
|
||||||
<p className="leise" style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
<p className="leise" style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
|
||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import { bewerte } from '@/lib/repos/karten';
|
||||||
|
import { bewertungen, type Bewertung } from '@/lib/lernen/sm2';
|
||||||
|
import { heute } from '@/lib/zeit';
|
||||||
|
import { tagDerBewertung } from '@/lib/offline/nachtrag';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** Mehr nimmt eine Sendung nicht an – der Rest kommt beim nächsten Mal. */
|
||||||
|
const HOECHSTZAHL = 500;
|
||||||
|
|
||||||
|
type Eingang = { karteId?: unknown; bewertung?: unknown; erfasstAm?: unknown };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt Bewertungen entgegen, die ohne Netz entstanden sind.
|
||||||
|
*
|
||||||
|
* Diese Schnittstelle gibt es nur, weil eine Serveraktion sich nicht in eine
|
||||||
|
* Warteschlange legen lässt. Sie kann genau eines – Merkkarten bewerten – und
|
||||||
|
* prüft die Rechte wie jede andere Stelle auch: Nur der Azubi führt seinen
|
||||||
|
* eigenen Lernstand.
|
||||||
|
*
|
||||||
|
* Der Tag der Bewertung kommt vom Gerät, weil nur dort bekannt ist, wann
|
||||||
|
* tatsächlich gelernt wurde. Er wird auf heute gedeckelt: Ein Gerät mit
|
||||||
|
* falsch gestellter Uhr darf den nächsten Termin nicht in die Zukunft
|
||||||
|
* schieben und sich so das Wiederholen ersparen.
|
||||||
|
*/
|
||||||
|
export async function POST(anfrage: Request): Promise<Response> {
|
||||||
|
const benutzer = await angemeldeterBenutzer();
|
||||||
|
if (!benutzer) return Response.json({ fehler: 'Nicht angemeldet.' }, { status: 401 });
|
||||||
|
if (benutzer.rolle !== 'azubi') {
|
||||||
|
return Response.json({ fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let koerper: unknown;
|
||||||
|
try {
|
||||||
|
koerper = await anfrage.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ fehler: 'Unlesbare Sendung.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roh = (koerper as { eintraege?: unknown })?.eintraege;
|
||||||
|
if (!Array.isArray(roh)) return Response.json({ fehler: 'Keine Einträge.' }, { status: 400 });
|
||||||
|
|
||||||
|
const heuteDatum = heute();
|
||||||
|
const bekannt = inhalte().karteNach;
|
||||||
|
let uebernommen = 0;
|
||||||
|
let verworfen = 0;
|
||||||
|
|
||||||
|
for (const eintrag of roh.slice(0, HOECHSTZAHL) as Eingang[]) {
|
||||||
|
const karteId = typeof eintrag.karteId === 'string' ? eintrag.karteId : '';
|
||||||
|
const bewertung = typeof eintrag.bewertung === 'string' ? eintrag.bewertung : '';
|
||||||
|
const erfasstAm = typeof eintrag.erfasstAm === 'string' ? eintrag.erfasstAm : '';
|
||||||
|
|
||||||
|
if (!bekannt.has(karteId) || !bewertungen.includes(bewertung as Bewertung)) {
|
||||||
|
verworfen += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = tagDerBewertung(erfasstAm, heuteDatum);
|
||||||
|
bewerte(benutzer.id, karteId, bewertung as Bewertung, tag);
|
||||||
|
uebernommen += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ uebernommen, verworfen });
|
||||||
|
}
|
||||||
+93
-1
@@ -463,15 +463,87 @@ table.tabelle th {
|
|||||||
|
|
||||||
/* --- Druck --- */
|
/* --- Druck --- */
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 16mm 14mm;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
/* Wer die dunkle Fassung eingestellt hat, druckt sonst schwarze Flächen aus.
|
||||||
|
Deshalb hier die komplette helle Palette noch einmal – und zwar stärker
|
||||||
|
als `[data-thema="dunkel"]`, sonst gewinnt die Einstellung. */
|
||||||
|
:root,
|
||||||
|
:root[data-thema="dunkel"],
|
||||||
|
:root[data-thema="hell"] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--petrol: #1f4e5f;
|
||||||
|
--petrol-hell: #2c6b81;
|
||||||
|
--petrol-dunkel: #163a47;
|
||||||
|
--bernstein: #a9660f;
|
||||||
|
--bernstein-dunkel: #a9660f;
|
||||||
|
|
||||||
|
--grund: #ffffff;
|
||||||
|
--flaeche: #f2f2f2;
|
||||||
|
--flaeche-kraeftig: #e6e6e6;
|
||||||
|
--rand: #999999;
|
||||||
|
--rand-zart: #cccccc;
|
||||||
|
|
||||||
|
--text: #000000;
|
||||||
|
--text-leise: #444444;
|
||||||
|
--text-auf-petrol: #ffffff;
|
||||||
|
|
||||||
|
--gut: #1d6f4a;
|
||||||
|
--gut-flaeche: #ffffff;
|
||||||
|
--warnung: #8a5a00;
|
||||||
|
--warnung-flaeche: #ffffff;
|
||||||
|
--fehler: #9b2226;
|
||||||
|
--fehler-flaeche: #ffffff;
|
||||||
|
|
||||||
|
--schatten: none;
|
||||||
|
}
|
||||||
|
|
||||||
.kopf,
|
.kopf,
|
||||||
|
.netzleiste,
|
||||||
|
.sprung,
|
||||||
.nicht-drucken {
|
.nicht-drucken {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
body { background: #fff; color: #000; font-size: 11pt; }
|
|
||||||
|
body { background: #fff; color: #000; font-size: 10.5pt; }
|
||||||
.huelle { max-width: none; padding: 0; }
|
.huelle { max-width: none; padding: 0; }
|
||||||
.karte, .karte-grund { border: 1px solid #999; box-shadow: none; background: #fff; }
|
.karte, .karte-grund { border: 1px solid #999; box-shadow: none; background: #fff; }
|
||||||
|
|
||||||
|
/* Verweise auf dem Papier: Der Text zählt, die Adresse nützt niemandem.
|
||||||
|
Ausgenommen bleibt, was ohnehin nur als Text gedruckt wird. */
|
||||||
a { color: #000; text-decoration: none; }
|
a { color: #000; text-decoration: none; }
|
||||||
|
|
||||||
|
/* Nichts mitten in einer Karte oder Zeile umbrechen – ein halber
|
||||||
|
Checklistenschritt auf der nächsten Seite ist beim Abheften ärgerlich. */
|
||||||
|
.karte,
|
||||||
|
.karte-grund,
|
||||||
|
li,
|
||||||
|
tr,
|
||||||
|
figure,
|
||||||
|
blockquote,
|
||||||
|
pre {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kein Waisenkind und kein Hurenkind. */
|
||||||
|
p, li { orphans: 3; widows: 3; }
|
||||||
|
|
||||||
|
/* Aufgeklappt drucken: Was zugeklappt ist, fehlt sonst auf dem Papier,
|
||||||
|
ohne dass man es merkt. */
|
||||||
|
details { display: block !important; }
|
||||||
|
details > summary { display: block; font-weight: 650; }
|
||||||
|
details > *:not(summary) { display: revert !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nur für Bildschirmleser sichtbar */
|
/* Nur für Bildschirmleser sichtbar */
|
||||||
@@ -746,6 +818,26 @@ a.glossarbegriff:hover {
|
|||||||
color: var(--gut);
|
color: var(--gut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Netzzustand ------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Schmale Leiste unter der Kopfzeile. Sie schiebt den Inhalt herunter, statt
|
||||||
|
ihn zu überdecken – eine schwebende Meldung verdeckt auf dem Handy genau
|
||||||
|
die Zeile, die man gerade lesen will. */
|
||||||
|
.netzleiste {
|
||||||
|
background: var(--warnung-flaeche);
|
||||||
|
border-bottom: 1px solid var(--bernstein);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.netzleiste-gut {
|
||||||
|
color: var(--gut);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.wochentage {
|
.wochentage {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Angaben für die Installation auf dem Startbildschirm.
|
||||||
|
*
|
||||||
|
* `start_url` ist bewusst `/` und nicht `/anmelden`: Wer die Anwendung öffnet
|
||||||
|
* und noch angemeldet ist, soll auf seiner Woche landen. Ist die Sitzung
|
||||||
|
* abgelaufen, leitet der Server ohnehin zur Anmeldung.
|
||||||
|
*/
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'Lern-Bar – Betriebliche Ausbildung',
|
||||||
|
short_name: 'Lern-Bar',
|
||||||
|
description:
|
||||||
|
'Wochenplan, Handouts, Merkkarten, Aufgaben, Checklisten und Berichtsheft für die Ausbildung zum Fachinformatiker für Systemintegration.',
|
||||||
|
lang: 'de',
|
||||||
|
dir: 'ltr',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
theme_color: '#1f4e5f',
|
||||||
|
categories: ['education', 'productivity'],
|
||||||
|
icons: [
|
||||||
|
{ src: '/symbol-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/symbol-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/symbol-maskabel-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
|
{ name: 'Lernmodus', short_name: 'Lernen', url: '/karten/lernen' },
|
||||||
|
{ name: 'Berichtsheft', short_name: 'Bericht', url: '/berichtsheft' },
|
||||||
|
{ name: 'Handouts', short_name: 'Handouts', url: '/handouts' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Kein Netz' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diese Seite erscheint nur, wenn das Netz fehlt **und** die gewünschte Seite
|
||||||
|
* nicht im Speicher liegt. Sie wird beim Einrichten des Dienstarbeiters
|
||||||
|
* mitgenommen und muss deshalb ohne Server auskommen – also ohne Anmeldung,
|
||||||
|
* ohne Datenbank, ohne Inhalte.
|
||||||
|
*/
|
||||||
|
export default function OfflineSeite() {
|
||||||
|
return (
|
||||||
|
<main className="huelle">
|
||||||
|
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2.5rem 1.25rem' }}>
|
||||||
|
<p style={{ fontSize: '2.5rem', margin: 0 }} aria-hidden="true">
|
||||||
|
⚡
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 style={{ marginBottom: '0.2rem' }}>Kein Netz</h1>
|
||||||
|
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>
|
||||||
|
Diese Seite liegt nicht auf dem Gerät. Sobald wieder Netz da ist, geht es weiter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Bewusst gewöhnliche Verweise statt <Link>: Ohne Netz kann der Router
|
||||||
|
die Seite nicht nachladen. Ein echter Seitenwechsel geht dagegen an
|
||||||
|
den Dienstarbeiter – und der hat sie im Speicher. */}
|
||||||
|
{/* eslint-disable @next/next/no-html-link-for-pages */}
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
Ohne Netz funktionieren die <a href="/karten/lernen">Merkkarten</a> und die{' '}
|
||||||
|
<a href="/handouts">Handouts</a>, die du schon einmal geöffnet hattest. Deine Bewertungen
|
||||||
|
werden gemerkt und gehen ab, sobald du wieder verbunden bist.
|
||||||
|
</p>
|
||||||
|
{/* eslint-enable @next/next/no-html-link-for-pages */}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Räumt auf, was vom vorigen Benutzer noch auf dem Gerät liegt.
|
||||||
|
*
|
||||||
|
* Steht auf der Anmeldeseite, weil dort jeder landet, der sich abgemeldet hat
|
||||||
|
* oder dessen Sitzung abgelaufen ist. Der Dienstarbeiter legt Seiten für den
|
||||||
|
* Offlinebetrieb ab; auf einem geteilten Werkstattgerät hätte sonst der
|
||||||
|
* nächste die Woche des vorigen Azubis im Speicher.
|
||||||
|
*
|
||||||
|
* Die Warteschlange bleibt bewusst stehen: Sie gehört zur Anmeldung, nicht zum
|
||||||
|
* Gerät, und wird beim nächsten Anmelden nachgereicht. Verworfen würde damit
|
||||||
|
* eine Lernleistung, die jemand erbracht hat.
|
||||||
|
*/
|
||||||
|
export function Aufraeumen() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
void navigator.serviceWorker.ready
|
||||||
|
.then((registrierung) => registrierung.active?.postMessage({ typ: 'aufraeumen' }))
|
||||||
|
.catch(() => {
|
||||||
|
// Kein Dienstarbeiter eingerichtet: dann liegt auch nichts herum.
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Druckt die aktuelle Seite.
|
||||||
|
*
|
||||||
|
* Für Handouts, Glossar und Auswertung gibt es kein PDF – die brauchen keins,
|
||||||
|
* weil sie keine Unterschrift tragen. Was der Ausbilder davon braucht, ist der
|
||||||
|
* Ausdruck: ein Handout mit in den Serverraum, die Abdeckung mit ins Gespräch.
|
||||||
|
*
|
||||||
|
* Der Knopf selbst druckt sich nicht mit (`nicht-drucken`).
|
||||||
|
*/
|
||||||
|
export function Druckknopf({ beschriftung = 'Drucken' }: { beschriftung?: string }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className="knopf knopf-zweit nicht-drucken" onClick={() => window.print()}>
|
||||||
|
{beschriftung}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { offeneBewertungen, warteschlangeAbschicken } from '@/lib/offline/warteschlange';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Richtet den Dienstarbeiter ein und sagt, wenn das Netz fehlt.
|
||||||
|
*
|
||||||
|
* Das ist bewusst eine schmale Leiste und kein Fenster, das man wegklicken
|
||||||
|
* muss: Wer im Serverraum steht, weiß meist selbst, dass er kein Netz hat –
|
||||||
|
* er will nur wissen, ob seine Bewertungen angekommen sind.
|
||||||
|
*/
|
||||||
|
export function Netzzustand({ benutzerId }: { benutzerId: string }) {
|
||||||
|
const [imNetz, setzeImNetz] = useState(true);
|
||||||
|
const [offen, setzeOffen] = useState(0);
|
||||||
|
const [gerade, setzeGerade] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Erst hier, nicht im Anfangswert: Der Server kennt `navigator` nicht, und
|
||||||
|
// ein Unterschied zwischen Server- und Browserfassung bricht den Abgleich.
|
||||||
|
setzeImNetz(navigator.onLine);
|
||||||
|
setzeOffen(offeneBewertungen(benutzerId).length);
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||||
|
// Ohne Dienstarbeiter läuft alles weiter, nur eben nicht offline.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nachholen() {
|
||||||
|
if (offeneBewertungen(benutzerId).length === 0) return;
|
||||||
|
|
||||||
|
const uebernommen = await warteschlangeAbschicken(benutzerId);
|
||||||
|
if (uebernommen === null) return;
|
||||||
|
|
||||||
|
setzeOffen(offeneBewertungen(benutzerId).length);
|
||||||
|
if (uebernommen > 0) {
|
||||||
|
setzeGerade(`${uebernommen} ${uebernommen === 1 ? 'Bewertung' : 'Bewertungen'} nachgereicht.`);
|
||||||
|
window.setTimeout(() => setzeGerade(null), 6000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wiederDa() {
|
||||||
|
setzeImNetz(true);
|
||||||
|
void nachholen();
|
||||||
|
}
|
||||||
|
function weg() {
|
||||||
|
setzeImNetz(false);
|
||||||
|
setzeOffen(offeneBewertungen(benutzerId).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', wiederDa);
|
||||||
|
window.addEventListener('offline', weg);
|
||||||
|
|
||||||
|
// Auch beim ersten Laden: Der Browser war vielleicht schon wieder im Netz,
|
||||||
|
// bevor diese Seite überhaupt geöffnet wurde.
|
||||||
|
if (navigator.onLine) void nachholen();
|
||||||
|
|
||||||
|
// Der Lernmodus meldet sich, wenn etwas Neues in die Schlange gegangen ist.
|
||||||
|
function gemeldet() {
|
||||||
|
setzeOffen(offeneBewertungen(benutzerId).length);
|
||||||
|
}
|
||||||
|
window.addEventListener('lernbar-warteschlange', gemeldet);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', wiederDa);
|
||||||
|
window.removeEventListener('offline', weg);
|
||||||
|
window.removeEventListener('lernbar-warteschlange', gemeldet);
|
||||||
|
};
|
||||||
|
}, [benutzerId]);
|
||||||
|
|
||||||
|
if (imNetz && !gerade) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="netzleiste nicht-drucken" role="status" aria-live="polite">
|
||||||
|
{imNetz ? (
|
||||||
|
<span className="netzleiste-gut">✓ {gerade}</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<strong>Ohne Netz.</strong> Merkkarten und schon geöffnete Handouts gehen weiter.
|
||||||
|
{offen > 0
|
||||||
|
? ` ${offen} ${offen === 1 ? 'Bewertung wartet' : 'Bewertungen warten'} auf die Verbindung.`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { tagDerBewertung } from './nachtrag';
|
||||||
|
|
||||||
|
describe('Tag einer nachgereichten Bewertung', () => {
|
||||||
|
it('rechnet einen vergangenen Tag an', () => {
|
||||||
|
// Freitag im Keller gelernt, Montag wieder verbunden: der Freitag zählt.
|
||||||
|
expect(tagDerBewertung('2026-09-11', '2026-09-14')).toBe('2026-09-11');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt heute, wenn die Bewertung von heute ist', () => {
|
||||||
|
expect(tagDerBewertung('2026-09-14', '2026-09-14')).toBe('2026-09-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deckelt eine vorgestellte Uhr auf heute', () => {
|
||||||
|
// Sonst ließe sich das Wiederholen wegstellen, indem man vordatiert.
|
||||||
|
expect(tagDerBewertung('2027-01-01', '2026-09-14')).toBe('2026-09-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt heute, wenn das Gerät Unsinn meldet', () => {
|
||||||
|
expect(tagDerBewertung('', '2026-09-14')).toBe('2026-09-14');
|
||||||
|
expect(tagDerBewertung('gestern', '2026-09-14')).toBe('2026-09-14');
|
||||||
|
expect(tagDerBewertung('2026-9-4', '2026-09-14')).toBe('2026-09-14');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Regeln für Bewertungen, die nachgereicht werden.
|
||||||
|
*
|
||||||
|
* Steht getrennt von der Schnittstelle, weil es hier um eine Entscheidung geht
|
||||||
|
* und nicht um HTTP: Welchem Tag wird eine Bewertung zugerechnet, die das
|
||||||
|
* Gerät Stunden oder Tage später abliefert?
|
||||||
|
*/
|
||||||
|
|
||||||
|
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Tag, der einer nachgereichten Bewertung zugerechnet wird.
|
||||||
|
*
|
||||||
|
* Der Tag kommt vom Gerät, weil nur dort bekannt ist, wann tatsächlich gelernt
|
||||||
|
* wurde – wer am Freitag im Keller lernt und sich erst am Montag wieder
|
||||||
|
* verbindet, soll seinen Freitag angerechnet bekommen.
|
||||||
|
*
|
||||||
|
* Er wird auf heute gedeckelt: Eine falsch gestellte Uhr darf den nächsten
|
||||||
|
* Termin nicht in die Zukunft schieben; sonst ließe sich das Wiederholen
|
||||||
|
* wegstellen, indem man das Gerät vordatiert. Unlesbares gilt als heute.
|
||||||
|
*/
|
||||||
|
export function tagDerBewertung(gemeldet: string, heute: string): string {
|
||||||
|
if (!datumMuster.test(gemeldet)) return heute;
|
||||||
|
return gemeldet < heute ? gemeldet : heute;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Die Warteschlange für Bewertungen, die ohne Netz entstanden sind.
|
||||||
|
*
|
||||||
|
* Sie liegt im `localStorage` des Geräts und nicht in einer Datenbank: Es geht
|
||||||
|
* um eine Handvoll Einträge zwischen zwei Verbindungen, nicht um ein Archiv.
|
||||||
|
* Geht der Speicher verloren, ist eine Lernkarte falsch terminiert – mehr
|
||||||
|
* nicht. Dafür lohnt kein IndexedDB.
|
||||||
|
*
|
||||||
|
* Läuft nur im Browser. Auf dem Server sind alle Funktionen wirkungslos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Schlange gehört zur Anmeldung, nicht zum Gerät.
|
||||||
|
*
|
||||||
|
* Auf einem geteilten Werkstattgerät melden sich nacheinander verschiedene
|
||||||
|
* Leute an. Läge die Schlange unter einem festen Schlüssel, bekäme der Zweite
|
||||||
|
* die offenen Bewertungen des Ersten auf sein Konto geschrieben – und der
|
||||||
|
* Erste hätte sie nie.
|
||||||
|
*/
|
||||||
|
function schluessel(benutzerId: string): string {
|
||||||
|
return `lernbar-warteschlange-${benutzerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mehr als das passt nicht in eine sinnvolle Lernpause. */
|
||||||
|
const HOECHSTZAHL = 500;
|
||||||
|
|
||||||
|
export type OffeneBewertung = {
|
||||||
|
karteId: string;
|
||||||
|
bewertung: string;
|
||||||
|
/** Der Tag, an dem der Azubi bewertet hat – nicht der Tag des Absendens. */
|
||||||
|
erfasstAm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function verfuegbar(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offeneBewertungen(benutzerId: string): OffeneBewertung[] {
|
||||||
|
if (!verfuegbar()) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roh = window.localStorage.getItem(schluessel(benutzerId));
|
||||||
|
if (!roh) return [];
|
||||||
|
|
||||||
|
const gelesen: unknown = JSON.parse(roh);
|
||||||
|
if (!Array.isArray(gelesen)) return [];
|
||||||
|
|
||||||
|
return gelesen.filter(
|
||||||
|
(eintrag): eintrag is OffeneBewertung =>
|
||||||
|
typeof eintrag === 'object' &&
|
||||||
|
eintrag !== null &&
|
||||||
|
typeof (eintrag as OffeneBewertung).karteId === 'string' &&
|
||||||
|
typeof (eintrag as OffeneBewertung).bewertung === 'string' &&
|
||||||
|
typeof (eintrag as OffeneBewertung).erfasstAm === 'string',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Kaputter Speicher ist kein Grund, den Lernmodus zu verweigern.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schreibe(benutzerId: string, eintraege: OffeneBewertung[]): void {
|
||||||
|
if (!verfuegbar()) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(schluessel(benutzerId), JSON.stringify(eintraege.slice(-HOECHSTZAHL)));
|
||||||
|
} catch {
|
||||||
|
// Voller oder gesperrter Speicher: dann eben nicht.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function einreihen(benutzerId: string, eintrag: OffeneBewertung): void {
|
||||||
|
schreibe(benutzerId, [...offeneBewertungen(benutzerId), eintrag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function warteschlangeLeeren(benutzerId: string): void {
|
||||||
|
if (!verfuegbar()) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(schluessel(benutzerId));
|
||||||
|
} catch {
|
||||||
|
// siehe oben
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt ab, was aufgelaufen ist.
|
||||||
|
*
|
||||||
|
* Gibt die Zahl der übernommenen Bewertungen zurück, oder `null`, wenn es
|
||||||
|
* nicht geklappt hat. Bei einem Fehlschlag bleibt die Schlange stehen – lieber
|
||||||
|
* ein zweiter Versuch als eine verlorene Bewertung.
|
||||||
|
*/
|
||||||
|
export async function warteschlangeAbschicken(benutzerId: string): Promise<number | null> {
|
||||||
|
const eintraege = offeneBewertungen(benutzerId);
|
||||||
|
if (eintraege.length === 0) return 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const antwort = await fetch('/api/lernstand', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ eintraege }),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!antwort.ok) return null;
|
||||||
|
|
||||||
|
const ergebnis: unknown = await antwort.json();
|
||||||
|
warteschlangeLeeren(benutzerId);
|
||||||
|
|
||||||
|
return typeof ergebnis === 'object' && ergebnis !== null && typeof (ergebnis as { uebernommen?: unknown }).uebernommen === 'number'
|
||||||
|
? (ergebnis as { uebernommen: number }).uebernommen
|
||||||
|
: eintraege.length;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user