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:
Florian Wilde
2026-09-11 15:47:06 +02:00
co-authored by Claude Opus 5
parent be2a674c7a
commit 2babef9293
24 changed files with 1126 additions and 6 deletions
+7
View File
@@ -6,6 +6,7 @@ import { benutzerNachId } from '@/lib/repos/benutzer';
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
import { verlauf } from '@/lib/kontrollen/bewertung';
import { Druckknopf } from '@/komponenten/Druckknopf';
export const metadata: Metadata = { title: 'Auswertung' };
export const dynamic = 'force-dynamic';
@@ -72,6 +73,12 @@ export default async function AuswertungsSeite({ searchParams }: { searchParams:
<Rahmenplan azubiId={azubiId} />
<Kontrollverlauf 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>
);
}
+5
View File
@@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte';
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
import { normalisiere } from '@/lib/inhalte/laden';
import { GlossarSuche } from './GlossarSuche';
import { Druckknopf } from '@/komponenten/Druckknopf';
export const metadata: Metadata = { title: 'Glossar' };
export const dynamic = 'force-dynamic';
@@ -67,6 +68,10 @@ export default async function GlossarSeite({ searchParams }: { searchParams: Pro
))}
</dl>
)}
<div className="reihe nicht-drucken">
<Druckknopf beschriftung="Glossar drucken" />
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen';
import { Druckknopf } from '@/komponenten/Druckknopf';
export const dynamic = 'force-dynamic';
@@ -159,6 +160,10 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
</form>
) : null}
<div className="reihe nicht-drucken">
<Druckknopf beschriftung="Handout drucken" />
</div>
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
{vorher ? (
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { karteBewerten, type Bewertungsergebnis } from '../aktionen';
import { einreihen } from '@/lib/offline/warteschlange';
export type Lernkarte = {
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
* 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 [stelle, setzeStelle] = useState(0);
const [umgedreht, setzeUmgedreht] = useState(false);
@@ -48,7 +57,7 @@ export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: strin
if (!karte || laeuft) return;
setzeLaeuft(true);
const ergebnis = await karteBewerten(karte.id, bewertung);
const ergebnis = await bewertungAbsetzen(benutzerId, karte.id, bewertung);
setzeRueckmeldung(ergebnis);
setzeZaehler((z) => ({ ...z, [bewertung]: z[bewertung] + 1 }));
setzeErledigt((e) => e + 1);
@@ -63,7 +72,7 @@ export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: strin
setzeStelle((s) => s + 1);
setzeLaeuft(false);
},
[karte, laeuft],
[karte, laeuft, benutzerId],
);
useEffect(() => {
@@ -239,3 +248,32 @@ export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: strin
</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())}`;
}
+1 -1
View File
@@ -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} />;
}
+2
View File
@@ -1,5 +1,6 @@
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
import { Netzzustand } from '@/komponenten/Netzzustand';
import { abmelden } from '../anmelden/aktionen';
export const dynamic = 'force-dynamic';
@@ -49,6 +50,7 @@ export default async function AngemeldetLayout({ children }: { children: React.R
rolle={benutzer.rolle === 'ausbilder' ? 'Ausbilder' : 'Azubi'}
abmeldeAktion={abmelden}
/>
<Netzzustand benutzerId={benutzer.id} />
<main id="inhalt" className="huelle">
{children}
</main>
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
import { AnmeldeFormular } from './AnmeldeFormular';
import { Aufraeumen } from '@/komponenten/Aufraeumen';
export const metadata: Metadata = { title: 'Anmelden' };
export const dynamic = 'force-dynamic';
@@ -11,6 +12,7 @@ export default async function AnmeldeSeite() {
return (
<main className="anmeldung">
<Aufraeumen />
<div className="anmeldung-karte stapel">
<div>
<p className="leise" style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
+66
View File
@@ -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
View File
@@ -463,15 +463,87 @@ table.tabelle th {
/* --- Druck --- */
@page {
size: A4;
margin: 16mm 14mm;
}
@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,
.netzleiste,
.sprung,
.nicht-drucken {
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; }
.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; }
/* 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 */
@@ -746,6 +818,26 @@ a.glossarbegriff:hover {
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 {
list-style: none;
margin: 0;
+36
View File
@@ -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' },
],
};
}
+38
View File
@@ -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>
);
}
+29
View File
@@ -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;
}
+18
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+24
View File
@@ -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');
});
});
+25
View File
@@ -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;
}
+114
View File
@@ -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;
}
}