Baustein 3: Wochenplan als Startseite, Fortschritt, Wochen verschieben

- Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fuenf Tage
  mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist
- Ausbilder sieht je Azubi die laufende Woche mit Stand und offenen Punkten
- /woche/<nr> fuer jede Woche, Ausbilder waehlt ueber ?azubi= den Stand;
  Zugriff wird serverseitig bei jedem Aufruf geprueft
- Tabelle wochenplanung mit geplantem und tatsaechlichem Datum, Zustand
  und Notiz fuer den Azubi
- Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen
- Reihenfolge per Ziehen und Ablegen sowie per Tastatur (Alt + Pfeil)
- Warnung bei sich ueberlappenden Terminen
- Plan entsteht automatisch beim Anlegen eines Azubikontos
- Planungs- und Fortschrittslogik als reine Funktionen, 36 Tests dafuer
- Insgesamt 104 Tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 09:04:39 +02:00
co-authored by Claude Opus 5
parent 1af5613e9b
commit 103a603eb4
29 changed files with 2158 additions and 47 deletions
+40
View File
@@ -26,6 +26,45 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.3.0] – 2026-09-10
Der Wochenplan wird zur Startseite, und der Ausbilder kann ihn umplanen.
### Hinzugefügt
- **Wochenplan als Startseite des Azubis**: Woche, Thema, Leitfrage, Wochenziel,
die fünf Tage mit Theorie- und Praxisteil, hervorgehobener heutiger Tag,
Fortschrittsbalken und eine Liste dessen, was noch offen ist
- Der Ausbilder sieht auf seiner Startseite je Azubi die laufende Woche mit
Fortschritt und offenen Punkten, mit Sprung in die Wocheneinzelansicht
- `/woche/<nr>` zeigt jede beliebige Woche; der Ausbilder wählt über `?azubi=`,
wessen Stand er sieht – geprüft wird das serverseitig bei jedem Aufruf
- Tabelle `wochenplanung` mit geplantem und tatsächlichem Datum, Zustand
(`geplant` / `laufend` / `abgeschlossen`) und einer Notiz für den Azubi
- Wochenplanseite für den Ausbilder:
- Plan aus den Wochen des Repositorys erzeugen und fehlende Wochen anhängen
- Woche auf ein neues Datum verlegen, wahlweise mit allen folgenden Wochen;
ein beliebiges Datum wird auf den Montag seiner Woche gerundet
- Reihenfolge per Ziehen und Ablegen ändern – die Terminfolge bleibt fest,
nur die Zuordnung wechselt, damit der Plan lückenlos bleibt
- Dasselbe mit der Tastatur über Alt + Pfeil hoch/runter, damit die Funktion
nicht auf die Maus angewiesen ist
- Zustand je Woche setzen; beim Start wird das tatsächliche Datum vermerkt
- Notiz je Woche, die der Azubi in seiner Wochenansicht sieht
- Warnung, wenn sich nach einem Einzelverschieben Termine überlappen
- Der Wochenplan entsteht automatisch, sobald ein Azubikonto angelegt wird
- Fortschrittsrechnung als reine Funktion über Zählwerte, damit sie mit den
weiteren Bausteinen wachsen kann, ohne sich zu verzweigen
- 36 Tests für Planerzeugung, Verschieben, Umsortieren, „welche Woche ist dran"
und die Fortschrittsrechnung
### Angemerkt
- Praxisaufgaben, Checklisten und Lernzielkontrollen erscheinen im
Fortschritt bereits mit ihrer Sollzahl, lassen sich aber erst mit den
Bausteinen 5 bis 8 abhaken. Das ist Absicht: Die Woche zeigt, was ansteht,
auch wenn die Anwendung es noch nicht mitzählen kann
## [0.2.0] – 2026-09-10 ## [0.2.0] – 2026-09-10
Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien
@@ -105,5 +144,6 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto - `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht [Unveröffentlicht]: #unveröffentlicht
[0.3.0]: #030--2026-09-10
[0.2.0]: #020--2026-09-10 [0.2.0]: #020--2026-09-10
[0.1.0]: #010--2026-09-10 [0.1.0]: #010--2026-09-10
+32
View File
@@ -7,6 +7,38 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
--- ---
## 2026-09-10 · Baustein 3 – Wochenplan
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 09:05 | 09:20 | 0:15 | Datenmodell der Wochenplanung, Migration, Drizzle-Schema |
| 09:20 | 09:45 | 0:25 | Planungslogik als reine Funktionen: erzeugen, verschieben, umsortieren, „welche Woche ist dran", Überschneidungen; dazu die Fortschrittsrechnung |
| 09:45 | 09:55 | 0:10 | 36 Tests dafür – liefen beim ersten Anlauf durch |
| 09:55 | 10:10 | 0:15 | Repository-Schicht, Wochensicht aus Inhalt + Planung + Fortschritt |
| 10:10 | 10:35 | 0:25 | Wochenansicht, Startseiten für beide Rollen, Wocheneinzelansicht |
| 10:35 | 10:55 | 0:20 | Wochenplanseite: verlegen, umsortieren mit Ziehen und Tastatur, Zustand, Notizen |
| 10:55 | 11:10 | 0:15 | Durchstich im Browser, Screenshots, Dokumente nachgezogen |
**Summe Baustein 3: rund 2 h 05 min**
### Was aufgefallen ist
- Beim Umsortieren war die erste Idee, die Termine mitzuziehen. Falsch: Dann
entstehen Lücken und Überschneidungen. Richtig ist, die Folge der
Kalenderwochen als festen Rahmen zu behandeln und nur die Zuordnung zu
ändern – der Plan bleibt so immer lückenlos.
- Der Lint-Fehler „azubiId is defined but never used" im Verlegen-Formular war
kein Stilproblem, sondern ein echter Bug: Dem Formular fehlte das versteckte
Feld, das die Serveraktion ausliest. Ein Lauf hätte den Fehler zwar auch
gezeigt, aber erst nach dem Klick.
- Ziehen und Ablegen allein reicht nicht. Wer mit der Tastatur arbeitet, kommt
damit nicht weiter, deshalb tun es die Pfeilknöpfe und Alt + Pfeil genauso.
- Die Erfolgsmeldung nach „Wochen einplanen" ist nicht zu sehen, weil das
Formular danach verschwindet – die erscheinende Planliste ist die
Rückmeldung. Kein Fehler, aber es hat mich im Testskript zunächst gekostet.
---
## 2026-09-10 · Baustein 2 – Inhalte ## 2026-09-10 · Baustein 2 – Inhalte
| Von | Bis | Dauer | Tätigkeit | | Von | Bis | Dauer | Tätigkeit |
+9 -1
View File
@@ -35,7 +35,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## 0.3 – Woche und Wiederholung ## 0.3 – Woche und Wiederholung
- [ ] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben** - [x] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
- [x] Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fünf Tage
mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist
- [x] Ausbilder sieht dieselbe Woche plus den Stand seiner Azubis
- [x] Geplantes und tatsächliches Datum je Woche
- [x] Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen
- [x] Reihenfolge per Ziehen und Ablegen **und** per Tastatur
- [x] Zustand je Woche (geplant / läuft / abgeschlossen) und Notiz für den Azubi
- [x] Warnung bei sich überlappenden Terminen
- [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)** - [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
## 0.4 – Nachweise ## 0.4 – Nachweise
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+5 -1
View File
@@ -74,11 +74,15 @@ async function main() {
await seite.waitForSelector('#neu-name'); await seite.waitForSelector('#neu-name');
await bild(seite, '03-verwaltung'); await bild(seite, '03-verwaltung');
await seite.goto(`${BASIS}/wochenplan`);
await seite.waitForSelector('h1');
await bild(seite, '04-wochenplan');
await abmelden(seite); await abmelden(seite);
// --- Azubi --- // --- Azubi ---
await anmelden(seite, AZUBI); await anmelden(seite, AZUBI);
await bild(seite, '10-azubi-start'); await bild(seite, '10-azubi-woche');
await seite.goto(`${BASIS}/handouts`); await seite.goto(`${BASIS}/handouts`);
await seite.waitForSelector('h1'); await seite.waitForSelector('h1');
+230
View File
@@ -0,0 +1,230 @@
import Link from 'next/link';
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import type { Wochensicht } from '@/lib/planung/sicht';
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
const statusText: Record<string, { text: string; ton: string; zeichen: string }> = {
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
};
/**
* Die Wochenansicht – für den Azubi die Startseite, für den Ausbilder dieselbe
* Woche in der Ansicht seines Azubis.
*/
export function WochenAnsicht({
sicht,
istAzubi,
wochenLink,
}: {
sicht: Wochensicht;
istAzubi: boolean;
/** Vorlage für den Verweis auf eine andere Woche, `{nr}` wird ersetzt. */
wochenLink: string;
}) {
const zurWoche = (nummer: number) => wochenLink.replace('{nr}', String(nummer));
const { woche, planung, fortschritt, tage } = sicht;
const status = statusText[planung.status] ?? statusText.geplant!;
return (
<div className="stapel">
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="wochen-nummer">Woche {woche.nummer}</span>
<span className="marke-status" data-ton={status.ton}>
{status.zeichen} {status.text}
</span>
<span className="leise">
{alsDatumText(planung.geplantVon)} – {alsDatumText(planung.geplantBis)} · {kalenderwocheText(planung.geplantVon)}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{woche.titel}</h1>
<p className="leitfrage">{woche.leitfrage}</p>
<p className="leise" style={{ marginBottom: 0 }}>{woche.ziel}</p>
</header>
{planung.tatsaechlichVon && planung.tatsaechlichVon !== planung.geplantVon ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
Tatsächlich begonnen am {alsDatumText(planung.tatsaechlichVon)}, geplant war der{' '}
{alsDatumText(planung.geplantVon)}.
</p>
) : null}
{planung.notiz ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
<strong>Notiz des Ausbilders:</strong> {planung.notiz}
</p>
) : null}
<section className="karte stapel">
<Fortschrittsbalken fortschritt={fortschritt} />
{fortschritt.offen.length > 0 ? (
<div>
<h2 style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
{istAzubi ? 'Das ist diese Woche noch offen' : 'Offen bei deinem Azubi'}
</h2>
<ul className="offen-liste">
{fortschritt.offen.map((bereich) => (
<li key={bereich.schluessel}>
<span>{bereich.titel}</span>
<span className="leise">
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
</span>
</li>
))}
</ul>
</div>
) : fortschritt.gesamt > 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Diese Woche ist vollständig abgearbeitet.
</p>
) : null}
</section>
<section aria-labelledby="tage" className="stapel">
<h2 id="tage">Die Woche</h2>
<ol className="wochentage">
{tage.map((tag) => (
<li key={tag.tag} className={`wochentag${tag.istHeute ? ' wochentag-heute' : ''}`}>
<div className="wochentag-kopf">
<strong>{tag.tag}</strong>
<span className="leise">{alsDatumText(tag.datum)}</span>
{tag.istHeute ? <span className="marke-status" data-ton="warnung">● heute</span> : null}
</div>
<div className="wochentag-inhalt">
<div>
<span className="wochentag-marke">Theorie</span>
{tag.theorie.length === 0 ? (
<span className="leise">–</span>
) : (
<ul className="theorie-liste">
{tag.theorie.map((handout) => {
const gelesen = sicht.handouts.find((h) => h.handout.id === handout.id)?.gelesen ?? false;
return (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`}>
<span className="handout-kuerzel">{handout.id}</span> {handout.titel}
</Link>
{gelesen ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : null}
</li>
);
})}
</ul>
)}
</div>
<div>
<span className="wochentag-marke">Praxis</span>
<span>{tag.praxis || '–'}</span>
</div>
</div>
</li>
))}
</ol>
</section>
<div className="raster">
<section className="karte" aria-labelledby="woche-handouts">
<h2 id="woche-handouts" style={{ fontSize: '1rem' }}>
Handouts dieser Woche
</h2>
{sicht.handouts.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{sicht.handouts.map(({ handout, gelesen }) => (
<li key={handout.id} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<span className="handout-kuerzel">{handout.id}</span>
<Link href={`/handouts/${handout.id}`} style={{ flex: 1 }}>
{handout.titel}
</Link>
<span aria-label={gelesen ? 'gelesen' : 'noch nicht gelesen'} title={gelesen ? 'gelesen' : 'offen'}>
{gelesen ? '✓' : '○'}
</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-aufgaben">
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
Praxisaufgaben
</h2>
{sicht.aufgaben.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<strong>{aufgabe.id}</strong> {aufgabe.titel}
<br />
<span className="leise">{aufgabe.zeitrahmen}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-checklisten">
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
Checklisten
</h2>
{sicht.checklisten.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.checklisten.map((liste) => (
<li key={liste.id}>{liste.titel}</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-kontrolle">
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
Lernzielkontrolle
</h2>
{sicht.kontrolle ? (
<p style={{ marginBottom: 0 }}>
{sicht.kontrolle.titel}
<br />
<span className="leise">
{sicht.kontrolle.fragen.length} Fragen ·{' '}
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
</span>
</p>
) : (
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
)}
</section>
</div>
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen">
{sicht.vorherige ? (
<Link href={zurWoche(sicht.vorherige)} className="knopf knopf-still">
← Woche {sicht.vorherige}
</Link>
) : (
<span />
)}
{sicht.naechste ? (
<Link href={zurWoche(sicht.naechste)} className="knopf knopf-still">
Woche {sicht.naechste} →
</Link>
) : (
<span />
)}
</nav>
</div>
);
}
+1
View File
@@ -14,6 +14,7 @@ const fuerAzubi: Navigationseintrag[] = [
const fuerAusbilder: Navigationseintrag[] = [ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' }, { pfad: '/', titel: 'Überblick' },
{ pfad: '/wochenplan', titel: 'Wochenplan' },
{ pfad: '/handouts', titel: 'Handouts' }, { pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' }, { pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' }, { pfad: '/verwaltung', titel: 'Verwaltung' },
+116 -37
View File
@@ -1,69 +1,148 @@
import Link from 'next/link'; import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte'; import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung'; import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { alsDatumText, kalenderwocheText, heute } from '@/lib/zeit'; import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
import { alsDatumText, heute, kalenderwocheText } from '@/lib/zeit';
import { WochenAnsicht } from './WochenAnsicht';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/**
* Vorläufige Startseite. Ab Baustein 3 steht hier der Wochenplan.
*/
export default async function StartSeite() { export default async function StartSeite() {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
if (benutzer.rolle === 'ausbilder') { return benutzer.rolle === 'ausbilder' ? (
const meine = ausbildungenVonAusbilder(benutzer.id); <AusbilderUeberblick ausbilderId={benutzer.id} name={benutzer.name} />
) : (
<AzubiWoche azubiId={benutzer.id} name={benutzer.name} />
);
}
// --- Azubi: der Wochenplan ist die Startseite ------------------------------
function AzubiWoche({ azubiId, name }: { azubiId: string; name: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) {
return ( return (
<div className="stapel"> <div className="stapel">
<div> <h1>Guten Tag, {name.split(' ')[0]}</h1>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1> <p className="hinweis hinweis-warnung">
<p className="leise"> Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid – ohne
{kalenderwocheText(heute())} · {alsDatumText(heute())} Ausbildungsverhältnis gibt es keinen Wochenplan.
</p> </p>
</div> </div>
<section className="karte stapel">
<h2 style={{ marginBottom: 0 }}>Ausbildungen</h2>
{meine.length === 0 ? (
<p style={{ marginBottom: 0 }}>
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{meine.map(({ ausbildung, azubi }) => (
<li key={ausbildung.id}>
<strong>{azubi.name}</strong> · seit {alsDatumText(ausbildung.beginn)}
</li>
))}
</ul>
)}
</section>
</div>
); );
} }
const ausbildung = ausbildungNachAzubi(benutzer.id); const sicht = aktuelleWochensicht(ausbildung.id, azubiId);
if (!sicht) {
return (
<div className="stapel">
<h1>Guten Tag, {name.split(' ')[0]}</h1>
<p className="hinweis hinweis-warnung">
Für deine Ausbildung ist noch kein Wochenplan angelegt. Dein Ausbilder kann ihn unter &bdquo;Wochenplan&ldquo;
erzeugen.
</p>
</div>
);
}
return <WochenAnsicht sicht={sicht} istAzubi wochenLink="/woche/{nr}" />;
}
// --- Ausbilder: wer steht wo -----------------------------------------------
function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
return ( return (
<div className="stapel"> <div className="stapel">
<div> <div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1> <h1>Guten Tag, {name.split(' ')[0]}</h1>
<p className="leise"> <p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())} {kalenderwocheText(heute())} · {alsDatumText(heute())}
</p> </p>
</div> </div>
<section className="karte"> {meine.length === 0 ? (
{ausbildung ? ( <p className="hinweis hinweis-warnung">
<p style={{ marginBottom: 0 }}> Noch kein Azubikonto angelegt. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
Deine Ausbildung läuft seit {alsDatumText(ausbildung.beginn)}
{ausbildung.probezeitBis ? ` (Probezeit bis ${alsDatumText(ausbildung.probezeitBis)})` : ''}.
</p> </p>
) : ( ) : (
<p style={{ marginBottom: 0 }}> <section className="stapel" aria-labelledby="stand">
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid. <h2 id="stand">Wo deine Auszubildenden stehen</h2>
{meine.map(({ ausbildung, azubi }) => {
const stand = kurzstand(ausbildung.id, azubi.id);
return (
<article key={ausbildung.id} className="karte stapel">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<div>
<h3 style={{ marginBottom: '0.1rem' }}>{azubi.name}</h3>
<p className="leise" style={{ margin: 0 }}>
Ausbildung seit {alsDatumText(ausbildung.beginn)}
{azubi.aktiv === 1 ? '' : ' · Konto gesperrt'}
</p>
</div>
<Link href={`/wochenplan?azubi=${azubi.id}`} className="knopf knopf-zweit">
Wochenplan
</Link>
</div>
{stand ? (
<>
<div className="karte-grund stapel-eng">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<strong>
Woche {stand.wochenNummer} · {stand.titel}
</strong>
<span className="leise">
{alsDatumText(stand.geplantVon)} – {alsDatumText(stand.geplantBis)}
</span>
</div>
<Fortschrittsbalken fortschritt={stand.fortschritt} beschriftung="Stand der Woche" />
</div>
{stand.fortschritt.offen.length > 0 ? (
<ul className="offen-liste">
{stand.fortschritt.offen.map((bereich) => (
<li key={bereich.schluessel}>
<span>{bereich.titel}</span>
<span className="leise">
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
</span>
</li>
))}
</ul>
) : null}
<Link href={`/woche/${stand.wochenNummer}?azubi=${azubi.id}`}>
Die Woche im Einzelnen ansehen →
</Link>
</>
) : (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
Für {azubi.name} ist noch kein Wochenplan angelegt.{' '}
<Link href={`/wochenplan?azubi=${azubi.id}`}>Jetzt erzeugen</Link>.
</p> </p>
)} )}
</article>
);
})}
</section>
)}
<section className="karte" aria-labelledby="wartet">
<h2 id="wartet" style={{ fontSize: '1rem' }}>
Was auf dich wartet
</h2>
<p className="leise" style={{ marginBottom: 0 }}>
Offene Abnahmen, unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte erscheinen
hier, sobald diese Bausteine stehen.
</p>
</section> </section>
</div> </div>
); );
+13 -2
View File
@@ -6,6 +6,8 @@ import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer'; import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung'; import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { alsDatumText } from '@/lib/zeit'; import { alsDatumText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { planAuffrischen } from '@/lib/repos/wochenplanung';
export type Rueckmeldung = { fehler?: string; erfolg?: string }; export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -31,7 +33,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' }; if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' };
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' }); const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
ausbildungAnlegen({ const neueAusbildung = ausbildungAnlegen({
azubiId: azubi.id, azubiId: azubi.id,
ausbilderId: ausbilder.id, ausbilderId: ausbilder.id,
beginn, beginn,
@@ -39,8 +41,17 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
ende: datumMuster.test(ende) ? ende : null, ende: datumMuster.test(ende) ? ende : null,
}); });
// Der Wochenplan entsteht gleich mit – ohne ihn ist die Startseite leer.
const wochen = inhalte().wochen.map((w) => w.nummer);
const eingeplant = planAuffrischen(neueAusbildung.id, beginn, wochen);
revalidatePath('/verwaltung'); revalidatePath('/verwaltung');
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` }; revalidatePath('/');
return {
erfolg:
`Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet. ` +
`${eingeplant} ${eingeplant === 1 ? 'Woche' : 'Wochen'} eingeplant.`,
};
} }
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
@@ -0,0 +1,65 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { wochensicht } from '@/lib/planung/sicht';
import { WochenAnsicht } from '../../WochenAnsicht';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ nummer: string }> }): Promise<Metadata> {
const { nummer } = await params;
return { title: `Woche ${nummer}` };
}
/**
* Eine bestimmte Woche. Der Azubi sieht nur seine eigene; der Ausbilder gibt
* über `?azubi=` an, wessen Stand er sehen will – geprüft wird das serverseitig.
*/
export default async function WochenSeite({
params,
searchParams,
}: {
params: Promise<{ nummer: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { nummer } = await params;
const { azubi: azubiParameter } = await searchParams;
const wochenNummer = Number(nummer);
if (!Number.isInteger(wochenNummer) || wochenNummer < 1) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
// Server – nicht die Oberfläche und nicht die Adresszeile.
erfordereZugriffAufAzubi(benutzer, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) notFound();
const sicht = wochensicht(ausbildung.id, azubiId, wochenNummer);
if (!sicht) notFound();
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? null : benutzerNachId(azubiId);
const anhang = istAzubi ? '' : `?azubi=${azubiId}`;
return (
<div className="stapel">
{azubi ? (
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/">Überblick</Link>
{' › '}
<Link href={`/wochenplan?azubi=${azubiId}`}>Wochenplan {azubi.name}</Link>
</nav>
) : null}
<WochenAnsicht sicht={sicht} istAzubi={istAzubi} wochenLink={`/woche/{nr}${anhang}`} />
</div>
);
}
@@ -0,0 +1,146 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { planErzeugen, wochenNotiz, wocheVerlegen, wochenStatusSetzen, type Rueckmeldung } from './aktionen';
export function PlanErzeugenFormular({ azubiId, text }: { azubiId: string; text: string }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(planErzeugen, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="azubiId" value={azubiId} />
<Absenden className="knopf knopf-akzent">{text}</Absenden>
</form>
);
}
export function VerlegenFormular({
azubiId,
wochen,
}: {
azubiId: string;
wochen: { nummer: number; titel: string; geplantVon: string }[];
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wocheVerlegen, {});
const erste = wochen[0];
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<input type="hidden" name="azubiId" value={azubiId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '2 1 240px' }}>
<label htmlFor="verlegen-woche">Woche</label>
<select id="verlegen-woche" name="wochenNummer" defaultValue={erste?.nummer}>
{wochen.map((w) => (
<option key={w.nummer} value={w.nummer}>
Woche {w.nummer} – {w.titel}
</option>
))}
</select>
</div>
<div style={{ flex: '1 1 170px' }}>
<label htmlFor="verlegen-datum">Neuer Beginn</label>
<input id="verlegen-datum" name="neuerMontag" type="date" defaultValue={erste?.geplantVon} required />
</div>
<Absenden className="knopf">Verlegen</Absenden>
</div>
<label className="ankreuzfeld">
<input type="checkbox" name="folgende" value="an" defaultChecked />
<span>
Folgende Wochen mitverschieben
<span className="leise" style={{ display: 'block' }}>
Der Normalfall: Fällt eine Woche aus, rutscht der ganze Rest hinterher. Ohne Haken wird nur diese
eine Woche verlegt – dann können sich Termine überlappen.
</span>
</span>
</label>
<p className="leise" style={{ margin: 0 }}>
Ein beliebiges Datum wird auf den Montag seiner Woche gerundet. Abgeschlossene Wochen bleiben stehen.
</p>
</form>
);
}
export function StatusFormular({
azubiId,
wochenNummer,
status,
}: {
azubiId: string;
wochenNummer: number;
status: 'geplant' | 'laufend' | 'abgeschlossen';
}) {
return (
<form action={wochenStatusSetzen} className="reihe" style={{ gap: '0.35rem' }}>
<input type="hidden" name="azubiId" value={azubiId} />
<input type="hidden" name="wochenNummer" value={wochenNummer} />
<button
type="submit"
name="status"
value="laufend"
className="knopf knopf-still"
disabled={status === 'laufend'}
>
Startet jetzt
</button>
<button
type="submit"
name="status"
value="abgeschlossen"
className="knopf knopf-still"
disabled={status === 'abgeschlossen'}
>
Abschließen
</button>
<button
type="submit"
name="status"
value="geplant"
className="knopf knopf-still"
disabled={status === 'geplant'}
>
Zurücksetzen
</button>
</form>
);
}
export function NotizFormular({
azubiId,
wochenNummer,
notiz,
}: {
azubiId: string;
wochenNummer: number;
notiz: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wochenNotiz, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="azubiId" value={azubiId} />
<input type="hidden" name="wochenNummer" value={wochenNummer} />
<label htmlFor={`notiz-${wochenNummer}`}>Notiz zu dieser Woche (sieht der Azubi)</label>
<textarea
id={`notiz-${wochenNummer}`}
name="notiz"
defaultValue={notiz}
rows={2}
maxLength={500}
placeholder="z. B. Dienstag Berufsschule, Mittwoch Kundeneinsatz Halle 3"
/>
<div>
<Absenden className="knopf knopf-zweit">Notiz speichern</Absenden>
</div>
</form>
);
}
@@ -0,0 +1,158 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { wocheSchieben } from './aktionen';
export type Planeintrag = {
wochenNummer: number;
titel: string;
geplantVon: string;
geplantBis: string;
geplantVonText: string;
geplantBisText: string;
kalenderwoche: string;
tatsaechlichVonText: string | null;
status: 'geplant' | 'laufend' | 'abgeschlossen';
notiz: string | null;
handouts: number;
aufgaben: number;
hatKontrolle: boolean;
};
const statusMarke: Record<Planeintrag['status'], { text: string; ton: string; zeichen: string }> = {
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
};
/**
* Der Wochenplan zum Umsortieren.
*
* Ziehen und Ablegen mit der Maus, und – genauso wichtig – mit der Tastatur:
* Eintrag anwählen, mit den Pfeiltasten verschieben. Reines Ziehen wäre für
* Tastaturbedienung eine Sackgasse.
*/
export function PlanListe({ eintraege, azubiId }: { eintraege: Planeintrag[]; azubiId: string }) {
const [gezogen, setzeGezogen] = useState<number | null>(null);
const [ueber, setzeUeber] = useState<number | null>(null);
const [laeuft, starte] = useTransition();
const router = useRouter();
function verschiebeAn(wochenNummer: number, neueStelle: number) {
if (neueStelle < 0 || neueStelle >= eintraege.length) return;
const daten = new FormData();
daten.set('azubiId', azubiId);
daten.set('wochenNummer', String(wochenNummer));
daten.set('neueStelle', String(neueStelle));
starte(async () => {
await wocheSchieben(daten);
router.refresh();
});
}
function beiTaste(ereignis: React.KeyboardEvent, wochenNummer: number, stelle: number) {
if (ereignis.key === 'ArrowUp' && (ereignis.altKey || ereignis.ctrlKey)) {
ereignis.preventDefault();
verschiebeAn(wochenNummer, stelle - 1);
}
if (ereignis.key === 'ArrowDown' && (ereignis.altKey || ereignis.ctrlKey)) {
ereignis.preventDefault();
verschiebeAn(wochenNummer, stelle + 1);
}
}
return (
<ol className={`planliste${laeuft ? ' planliste-wartet' : ''}`}>
{eintraege.map((eintrag, stelle) => {
const marke = statusMarke[eintrag.status];
const wirdGezogen = gezogen === eintrag.wochenNummer;
const istZiel = ueber === stelle && gezogen !== null && !wirdGezogen;
return (
<li
key={eintrag.wochenNummer}
className={`planzeile${wirdGezogen ? ' planzeile-gezogen' : ''}${istZiel ? ' planzeile-ziel' : ''}`}
draggable
onDragStart={() => setzeGezogen(eintrag.wochenNummer)}
onDragEnd={() => {
setzeGezogen(null);
setzeUeber(null);
}}
onDragOver={(e) => {
e.preventDefault();
setzeUeber(stelle);
}}
onDrop={(e) => {
e.preventDefault();
if (gezogen !== null && gezogen !== eintrag.wochenNummer) verschiebeAn(gezogen, stelle);
setzeGezogen(null);
setzeUeber(null);
}}
>
<div className="planzeile-griff" aria-hidden="true">
⠿
</div>
<div className="planzeile-inhalt">
<div className="reihe" style={{ gap: '0.5rem' }}>
<span className="wochen-nummer wochen-nummer-klein">Woche {eintrag.wochenNummer}</span>
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
</div>
<Link href={`/woche/${eintrag.wochenNummer}?azubi=${azubiId}`} className="planzeile-titel">
{eintrag.titel}
</Link>
<p className="leise" style={{ margin: 0 }}>
{eintrag.geplantVonText} – {eintrag.geplantBisText} · {eintrag.kalenderwoche}
{eintrag.tatsaechlichVonText ? ` · tatsächlich ab ${eintrag.tatsaechlichVonText}` : ''}
</p>
<p className="leise" style={{ margin: 0 }}>
{eintrag.handouts} Handouts · {eintrag.aufgaben} Aufgaben
{eintrag.hatKontrolle ? ' · Lernzielkontrolle' : ''}
</p>
{eintrag.notiz ? (
<p className="planzeile-notiz">
<strong>Notiz:</strong> {eintrag.notiz}
</p>
) : null}
</div>
<div className="planzeile-knoepfe">
<button
type="button"
className="knopf knopf-still"
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle - 1)}
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
disabled={stelle === 0 || laeuft}
title="Eine Woche früher (auch mit Alt + Pfeil hoch)"
>
<span aria-hidden="true">↑</span>
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach oben</span>
</button>
<button
type="button"
className="knopf knopf-still"
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle + 1)}
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
disabled={stelle === eintraege.length - 1 || laeuft}
title="Eine Woche später (auch mit Alt + Pfeil runter)"
>
<span aria-hidden="true">↓</span>
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach unten</span>
</button>
</div>
</li>
);
})}
</ol>
);
}
+112
View File
@@ -0,0 +1,112 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import {
notizSetzen,
planAuffrischen,
statusSetzen,
wocheUmsortieren,
wocheVerschieben,
} from '@/lib/repos/wochenplanung';
import { heute } from '@/lib/zeit';
import { wochenStatus, type WochenStatus } from '@/lib/db/schema';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Alle Aktionen hier prüfen zuerst, ob dieser Ausbilder für diesen Azubi
* zuständig ist. Die Azubikennung kommt aus dem Formular und ist damit
* grundsätzlich manipulierbar – deshalb wird sie nie geglaubt.
*/
async function zustaendigeAusbildung(azubiId: string) {
const ausbilder = await erfordereAusbilder();
erfordereBewertungsrecht(ausbilder, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) throw new Error('Zu diesem Konto gibt es keine Ausbildung.');
return ausbildung;
}
/** Alle Seiten auffrischen, auf denen die Planung sichtbar ist. */
function aktualisiere() {
revalidatePath('/wochenplan');
revalidatePath('/');
revalidatePath('/woche/[nummer]', 'page');
}
export async function planErzeugen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const nummern = inhalte().wochen.map((w) => w.nummer);
const angelegt = planAuffrischen(ausbildung.id, ausbildung.beginn, nummern);
aktualisiere();
return angelegt === 0
? { erfolg: 'Der Plan war schon vollständig – es gab nichts nachzutragen.' }
: { erfolg: `${angelegt} ${angelegt === 1 ? 'Woche' : 'Wochen'} eingeplant.` };
}
export async function wocheVerlegen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const neuerMontag = String(formular.get('neuerMontag') ?? '').trim();
const folgende = String(formular.get('folgende') ?? '') === 'an';
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
if (!datumMuster.test(neuerMontag)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
const geaendert = wocheVerschieben(ausbildung.id, wochenNummer, neuerMontag, folgende);
aktualisiere();
if (geaendert === 0) return { erfolg: 'Der Termin stand schon so.' };
return {
erfolg: folgende
? `Woche ${wochenNummer} verlegt, ${geaendert - 1} folgende ${geaendert - 1 === 1 ? 'Woche' : 'Wochen'} mitverschoben.`
: `Woche ${wochenNummer} verlegt.`,
};
}
export async function wocheSchieben(formular: FormData): Promise<void> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const neueStelle = Number(formular.get('neueStelle'));
if (!Number.isInteger(wochenNummer) || !Number.isInteger(neueStelle)) return;
wocheUmsortieren(ausbildung.id, wochenNummer, neueStelle);
aktualisiere();
}
export async function wochenStatusSetzen(formular: FormData): Promise<void> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const status = String(formular.get('status') ?? '') as WochenStatus;
if (!Number.isInteger(wochenNummer) || !wochenStatus.includes(status)) return;
statusSetzen(ausbildung.id, wochenNummer, status, heute());
aktualisiere();
}
export async function wochenNotiz(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const notiz = String(formular.get('notiz') ?? '');
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
if (notiz.length > 500) return { fehler: 'Die Notiz ist zu lang – höchstens 500 Zeichen.' };
notizSetzen(ausbildung.id, wochenNummer, notiz);
aktualisiere();
return { erfolg: notiz.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
}
+172
View File
@@ -0,0 +1,172 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { planVon } from '@/lib/repos/wochenplanung';
import { ueberschneidungen } from '@/lib/planung/wochen';
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
import { PlanListe, type Planeintrag } from './PlanListe';
import { NotizFormular, PlanErzeugenFormular, StatusFormular, VerlegenFormular } from './Formulare';
export const metadata: Metadata = { title: 'Wochenplan' };
export const dynamic = 'force-dynamic';
export default async function WochenplanSeite({ searchParams }: { searchParams: Promise<{ azubi?: string }> }) {
const ausbilder = await erfordereAusbilder();
const { azubi: azubiParameter } = await searchParams;
const meine = ausbildungenVonAusbilder(ausbilder.id);
if (meine.length === 0) {
return (
<div className="stapel">
<h1>Wochenplan</h1>
<p className="hinweis hinweis-warnung">
Es gibt noch kein Azubikonto. <Link href="/verwaltung">Erst eines anlegen</Link>.
</p>
</div>
);
}
// Ohne Auswahl den ersten Azubi nehmen – bei ein bis drei Azubis ist eine
// eigene Auswahlseite Aufwand ohne Nutzen.
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
if (!azubiParameter) redirect(`/wochenplan?azubi=${azubiId}`);
erfordereBewertungsrecht(ausbilder, azubiId);
const azubi = benutzerNachId(azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!azubi || !ausbildung) notFound();
const alle = inhalte();
const plan = planVon(ausbildung.id);
const fehlende = alle.wochen.filter((w) => !plan.some((z) => z.wochenNummer === w.nummer));
const kollisionen = ueberschneidungen(plan);
const eintraege: Planeintrag[] = plan.map((zeile) => {
const woche = alle.wocheNach.get(zeile.wochenNummer);
return {
wochenNummer: zeile.wochenNummer,
titel: woche?.titel ?? `Woche ${zeile.wochenNummer} (im Repository nicht mehr vorhanden)`,
geplantVon: zeile.geplantVon,
geplantBis: zeile.geplantBis,
geplantVonText: alsDatumText(zeile.geplantVon),
geplantBisText: alsDatumText(zeile.geplantBis),
kalenderwoche: kalenderwocheText(zeile.geplantVon),
tatsaechlichVonText: zeile.tatsaechlichVon ? alsDatumText(zeile.tatsaechlichVon) : null,
status: zeile.status,
notiz: zeile.notiz,
handouts: woche?.handouts.length ?? 0,
aufgaben: woche?.aufgaben.length ?? 0,
hatKontrolle: Boolean(woche?.lernzielkontrolle),
};
});
return (
<div className="stapel">
<div>
<h1>Wochenplan</h1>
<p className="leise">
{azubi.name} · Ausbildung seit {alsDatumText(ausbildung.beginn)}
</p>
</div>
{meine.length > 1 ? (
<nav className="reihe nicht-drucken" aria-label="Azubi wählen">
{meine.map(({ azubi: person }) => (
<Link
key={person.id}
href={`/wochenplan?azubi=${person.id}`}
className={`knopf ${person.id === azubiId ? '' : 'knopf-zweit'}`}
>
{person.name}
</Link>
))}
</nav>
) : null}
{plan.length === 0 ? (
<section className="karte stapel">
<h2 style={{ marginBottom: 0 }}>Noch kein Plan</h2>
<p style={{ marginBottom: 0 }}>
Für {azubi.name} ist noch keine Woche eingeplant. Der Plan legt die {alle.wochen.length} Wochen aus
dem Repository ab Montag der Beginnwoche ({alsDatumText(ausbildung.beginn)}) fortlaufend an. Termine
lassen sich danach jederzeit verschieben.
</p>
<PlanErzeugenFormular azubiId={azubiId} text={`${alle.wochen.length} Wochen einplanen`} />
</section>
) : (
<>
{fehlende.length > 0 ? (
<section className="karte stapel-eng">
<p style={{ margin: 0 }}>
Im Repository gibt es {fehlende.length} {fehlende.length === 1 ? 'Woche' : 'Wochen'}, die hier
noch nicht eingeplant {fehlende.length === 1 ? 'ist' : 'sind'}:{' '}
{fehlende.map((w) => `Woche ${w.nummer}`).join(', ')}.
</p>
<PlanErzeugenFormular azubiId={azubiId} text="Fehlende Wochen anhängen" />
</section>
) : null}
{kollisionen.length > 0 ? (
<p className="hinweis hinweis-warnung">
Diese Wochen liegen auf demselben Termin:{' '}
{kollisionen.map(([a, b]) => `${a} und ${b}`).join(', ')}. Das kann gewollt sein – wenn nicht,
verlege eine davon mit gesetztem Haken &bdquo;Folgende Wochen mitverschieben&ldquo;.
</p>
) : null}
<section className="karte stapel" aria-labelledby="verlegen">
<h2 id="verlegen" style={{ marginBottom: 0 }}>
Woche verlegen
</h2>
<VerlegenFormular
azubiId={azubiId}
wochen={eintraege.map((e) => ({ nummer: e.wochenNummer, titel: e.titel, geplantVon: e.geplantVon }))}
/>
</section>
<section className="stapel" aria-labelledby="reihenfolge">
<div>
<h2 id="reihenfolge" style={{ marginBottom: '0.2rem' }}>
Reihenfolge
</h2>
<p className="leise" style={{ margin: 0 }}>
Zieh eine Woche an ihre neue Stelle, oder nimm die Pfeile – mit der Tastatur geht es mit
Alt&nbsp;+&nbsp;Pfeil hoch/runter. Die Termine bleiben, wo sie sind; es ändert sich nur, welche
Woche wann liegt.
</p>
</div>
<PlanListe eintraege={eintraege} azubiId={azubiId} />
</section>
<section className="stapel" aria-labelledby="einzeln">
<h2 id="einzeln">Zustand und Notizen</h2>
{eintraege.map((eintrag) => (
<article key={eintrag.wochenNummer} className="karte stapel-eng">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<strong>
Woche {eintrag.wochenNummer} · {eintrag.titel}
</strong>
<span className="leise">
{eintrag.geplantVonText} – {eintrag.geplantBisText}
</span>
</div>
<StatusFormular azubiId={azubiId} wochenNummer={eintrag.wochenNummer} status={eintrag.status} />
<NotizFormular
azubiId={azubiId}
wochenNummer={eintrag.wochenNummer}
notiz={eintrag.notiz ?? ''}
/>
</article>
))}
</section>
</>
)}
</div>
);
}
+182
View File
@@ -696,3 +696,185 @@ a.glossarbegriff:hover {
.handout-zuordnung { border: 1px solid #999; } .handout-zuordnung { border: 1px solid #999; }
a.glossarbegriff { text-decoration: none; } a.glossarbegriff { text-decoration: none; }
} }
/* --- Wochenplan --- */
.wochen-nummer {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.7rem;
border-radius: var(--radius-klein);
background: var(--petrol);
color: var(--text-auf-petrol);
font-weight: 700;
font-size: 0.9rem;
}
.wochen-nummer-klein { font-size: 0.8rem; padding: 0.1rem 0.5rem; }
.leitfrage {
font-size: 1.1rem;
font-style: italic;
color: var(--petrol);
margin: 0 0 0.3rem;
}
:root[data-thema="dunkel"] .leitfrage { color: var(--petrol-hell); }
.offen-liste {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--rand-zart);
}
.offen-liste li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.45rem 0;
border-bottom: 1px solid var(--rand-zart);
}
.offen-liste li::before {
content: "○";
color: var(--text-leise);
margin-right: 0.4rem;
}
.offen-liste li > span:first-of-type { flex: 1; }
.wochentage {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.wochentag {
border: 1px solid var(--rand-zart);
border-left: 4px solid var(--rand);
border-radius: var(--radius);
background: var(--flaeche);
padding: 0.7rem 0.9rem;
}
.wochentag-heute {
border-left-color: var(--bernstein);
background: var(--warnung-flaeche);
}
.wochentag-kopf {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.4rem;
}
.wochentag-inhalt {
display: grid;
gap: 0.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 720px) {
.wochentag-inhalt { grid-template-columns: 1fr 1fr; gap: 1.25rem; }
}
.wochentag-marke {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 700;
color: var(--text-leise);
margin-bottom: 0.15rem;
}
.theorie-liste {
list-style: none;
margin: 0;
padding: 0;
}
.theorie-liste li {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.theorie-liste li + li { margin-top: 0.3rem; }
/* --- Planliste zum Umsortieren --- */
.planliste {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.planliste-wartet { opacity: 0.6; pointer-events: none; }
.planzeile {
display: flex;
align-items: flex-start;
gap: 0.6rem;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
}
.planzeile-gezogen { opacity: 0.45; }
.planzeile-ziel {
border-color: var(--bernstein);
box-shadow: inset 0 3px 0 var(--bernstein);
}
.planzeile-griff {
cursor: grab;
color: var(--text-leise);
font-size: 1.2rem;
line-height: 1.4;
user-select: none;
padding-top: 0.1rem;
}
.planzeile-inhalt { flex: 1; display: grid; gap: 0.25rem; }
.planzeile-titel {
font-weight: 650;
text-decoration: none;
}
.planzeile-titel:hover { text-decoration: underline; }
.planzeile-notiz {
margin: 0.2rem 0 0;
font-size: 0.9rem;
background: var(--warnung-flaeche);
border-left: 3px solid var(--bernstein);
padding: 0.3rem 0.6rem;
border-radius: 0 var(--radius-klein) var(--radius-klein) 0;
}
.planzeile-knoepfe {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.planzeile-knoepfe .knopf { min-height: 34px; padding: 0.1rem 0.5rem; }
.ankreuzfeld {
display: flex;
gap: 0.6rem;
align-items: flex-start;
font-weight: 400;
cursor: pointer;
}
.ankreuzfeld input[type="checkbox"] {
width: 20px;
height: 20px;
margin-top: 0.2rem;
flex-shrink: 0;
accent-color: var(--petrol);
}
.ankreuzfeld > span:last-child { font-weight: 600; font-size: 0.95rem; }
@media print {
.planzeile-griff,
.planzeile-knoepfe { display: none; }
}
+41
View File
@@ -0,0 +1,41 @@
import type { Fortschritt } from '@/lib/planung/fortschritt';
/**
* Fortschritt als Balken mit Beschriftung. Die Zahl steht immer daneben –
* ein Balken allein ist für jemanden mit Sehschwäche wertlos, und die
* Statusfarbe trägt hier bewusst keine eigene Bedeutung.
*/
export function Fortschrittsbalken({
fortschritt,
beschriftung,
}: {
fortschritt: Fortschritt;
beschriftung?: string;
}) {
const { erledigt, gesamt, prozent } = fortschritt;
if (gesamt === 0) {
return <p className="leise" style={{ margin: 0 }}>Für diese Woche ist nichts zum Abhaken hinterlegt.</p>;
}
return (
<div>
<div className="reihe" style={{ justifyContent: 'space-between', gap: '0.5rem', marginBottom: '0.3rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.92rem' }}>{beschriftung ?? 'Diese Woche erledigt'}</span>
<span className="leise">
{erledigt} von {gesamt} · {prozent} %
</span>
</div>
<div
className="balken"
role="progressbar"
aria-valuenow={prozent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${beschriftung ?? 'Diese Woche erledigt'}: ${erledigt} von ${gesamt}`}
>
<span style={{ width: `${prozent}%` }} />
</div>
</div>
);
}
@@ -0,0 +1,25 @@
-- Baustein 3: Wochenplanung.
--
-- Welche Woche des Lehrgangs liegt wann? Der Inhalt der Woche steht in
-- content/wochen/, hier steht nur die Terminlage – und die verschiebt sich
-- ständig: Berufsschule, Kundeneinsatz, Krankheit.
--
-- geplant_von/geplant_bis Montag und Freitag der eingeplanten Woche
-- tatsaechlich_von wann sie wirklich begonnen hat (erst beim Start gesetzt)
CREATE TABLE wochenplanung (
id TEXT PRIMARY KEY,
ausbildung_id TEXT NOT NULL REFERENCES ausbildung (id) ON DELETE CASCADE,
wochen_nummer INTEGER NOT NULL,
geplant_von TEXT NOT NULL,
geplant_bis TEXT NOT NULL,
tatsaechlich_von TEXT,
status TEXT NOT NULL DEFAULT 'geplant'
CHECK (status IN ('geplant', 'laufend', 'abgeschlossen')),
notiz TEXT,
geaendert_am TEXT NOT NULL
);
CREATE UNIQUE INDEX wochenplanung_woche_idx ON wochenplanung (ausbildung_id, wochen_nummer);
CREATE INDEX wochenplanung_termin_idx ON wochenplanung (ausbildung_id, geplant_von);
CREATE INDEX wochenplanung_status_idx ON wochenplanung (ausbildung_id, status);
+28 -1
View File
@@ -63,10 +63,37 @@ export const handoutGelesen = sqliteTable(
(t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)], (t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)],
); );
/** Zustände einer eingeplanten Woche – als Aufzählung, nicht als freier Text. */
export const wochenStatus = ['geplant', 'laufend', 'abgeschlossen'] as const;
export type WochenStatus = (typeof wochenStatus)[number];
export const wochenplanung = sqliteTable(
'wochenplanung',
{
id: text('id').primaryKey(),
ausbildungId: text('ausbildung_id')
.notNull()
.references(() => ausbildung.id, { onDelete: 'cascade' }),
wochenNummer: integer('wochen_nummer').notNull(),
geplantVon: text('geplant_von').notNull(),
geplantBis: text('geplant_bis').notNull(),
tatsaechlichVon: text('tatsaechlich_von'),
status: text('status', { enum: wochenStatus }).notNull().default('geplant'),
notiz: text('notiz'),
geaendertAm: text('geaendert_am').notNull(),
},
(t) => [
uniqueIndex('wochenplanung_woche_idx').on(t.ausbildungId, t.wochenNummer),
index('wochenplanung_termin_idx').on(t.ausbildungId, t.geplantVon),
index('wochenplanung_status_idx').on(t.ausbildungId, t.status),
],
);
export type Benutzer = typeof benutzer.$inferSelect; export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect; export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect; export type Ausbildung = typeof ausbildung.$inferSelect;
export type HandoutGelesen = typeof handoutGelesen.$inferSelect; export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
export type Wochenplanung = typeof wochenplanung.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */ /** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen }; export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen, wochenplanung };
+74
View File
@@ -0,0 +1,74 @@
/**
* Fortschritt einer Woche.
*
* Bewusst als reine Rechnung über Zählwerte: Die einzelnen Bausteine liefern
* ihre Zahlen (gelesene Handouts, abgenommene Aufgaben, abgeschlossene
* Checklisten …), hier entsteht daraus ein Balken und eine Liste dessen, was
* noch offen ist. So wächst die Rechnung mit, ohne sich zu verzweigen.
*/
export type Zaehlwerk = { erledigt: number; gesamt: number };
export const leer: Zaehlwerk = { erledigt: 0, gesamt: 0 };
export type Wochenstand = {
handouts: Zaehlwerk;
karten: Zaehlwerk;
aufgaben: Zaehlwerk;
checklisten: Zaehlwerk;
kontrolle: Zaehlwerk;
};
export type Teilbereich = { schluessel: keyof Wochenstand; titel: string; zaehlwerk: Zaehlwerk };
const bezeichnungen: Record<keyof Wochenstand, string> = {
handouts: 'Handouts gelesen',
karten: 'Merkkarten gelernt',
aufgaben: 'Praxisaufgaben abgenommen',
checklisten: 'Checklisten abgeschlossen',
kontrolle: 'Lernzielkontrolle abgegeben',
};
export type Fortschritt = {
erledigt: number;
gesamt: number;
prozent: number;
/** Nur die Teilbereiche, in denen es diese Woche überhaupt etwas zu tun gibt. */
bereiche: Teilbereich[];
offen: Teilbereich[];
vollstaendig: boolean;
};
export function wochenFortschritt(stand: Wochenstand): Fortschritt {
const bereiche: Teilbereich[] = (Object.keys(bezeichnungen) as (keyof Wochenstand)[])
.map((schluessel) => ({ schluessel, titel: bezeichnungen[schluessel], zaehlwerk: stand[schluessel] }))
.filter((b) => b.zaehlwerk.gesamt > 0);
const erledigt = bereiche.reduce((summe, b) => summe + Math.min(b.zaehlwerk.erledigt, b.zaehlwerk.gesamt), 0);
const gesamt = bereiche.reduce((summe, b) => summe + b.zaehlwerk.gesamt, 0);
return {
erledigt,
gesamt,
prozent: anteil(erledigt, gesamt),
bereiche,
offen: bereiche.filter((b) => b.zaehlwerk.erledigt < b.zaehlwerk.gesamt),
vollstaendig: gesamt > 0 && erledigt >= gesamt,
};
}
/**
* Anteil in Prozent, kaufmännisch gerundet.
*
* Zwei Feinheiten, die im Alltag auffallen würden:
* - Ohne etwas zu tun gibt es 100 Prozent, nicht 0 – eine leere Woche ist
* nicht „nichts geschafft", sondern „nichts zu tun".
* - 99 statt 100 wird nicht auf 100 gerundet. Wer einen vollen Balken sieht,
* aber noch eine offene Aufgabe hat, hält die Anzeige für kaputt.
*/
export function anteil(erledigt: number, gesamt: number): number {
if (gesamt <= 0) return 100;
if (erledigt <= 0) return 0;
if (erledigt >= gesamt) return 100;
return Math.min(99, Math.max(1, Math.round((erledigt / gesamt) * 100)));
}
+136
View File
@@ -0,0 +1,136 @@
import { inhalte, type Handout, type Aufgabe } from '../inhalte';
import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
import { gelesenSet } from '../repos/fortschritt';
import { planVon } from '../repos/wochenplanung';
import { heute } from '../zeit';
import { aktuelleWoche } from './wochen';
import { leer, wochenFortschritt, type Fortschritt } from './fortschritt';
import type { Wochenplanung } from '../db/schema';
/**
* Setzt aus Inhalt (Datei), Planung (Datenbank) und Fortschritt (Datenbank)
* die Wochenansicht zusammen. Alles, was die Startseite braucht, kommt hier her.
*/
export type Tagessicht = {
tag: string;
theorie: Handout[];
praxis: string;
/** Der Tag, der im Kalender zu diesem Wochentag gehört. */
datum: string;
istHeute: boolean;
};
export type Wochensicht = {
woche: Woche;
planung: Wochenplanung;
fortschritt: Fortschritt;
tage: Tagessicht[];
handouts: { handout: Handout; gelesen: boolean }[];
aufgaben: Aufgabe[];
checklisten: Checkliste[];
kontrolle: Kontrolle | null;
vorherige: number | null;
naechste: number | null;
};
export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer: number): Wochensicht | null {
const alle = inhalte();
const woche = alle.wocheNach.get(wochenNummer);
if (!woche) return null;
const plan = planVon(ausbildungId);
const planung = plan.find((z) => z.wochenNummer === wochenNummer);
if (!planung) return null;
const gelesen = gelesenSet(azubiId);
const heuteDatum = heute();
const handouts = woche.handouts
.map((id) => alle.handoutNach.get(id))
.filter((h) => h !== undefined)
.map((handout) => ({ handout, gelesen: gelesen.has(handout.id) }));
const tage: Tagessicht[] = woche.tage.map((tag, stelle) => {
const datum = tagVersetzt(planung.geplantVon, stelle);
return {
tag: tag.tag,
praxis: tag.praxis,
datum,
istHeute: datum === heuteDatum,
theorie: tag.theorie.map((id) => alle.handoutNach.get(id)).filter((h) => h !== undefined),
};
});
const aufgaben = woche.aufgaben.map((id) => alle.aufgabeNach.get(id)).filter((a) => a !== undefined);
const checklisten = woche.checklisten.map((id) => alle.checklisteNach.get(id)).filter((c) => c !== undefined);
const kontrolle = woche.lernzielkontrolle ? (alle.kontrolleNach.get(woche.lernzielkontrolle) ?? null) : null;
const fortschritt = wochenFortschritt({
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
karten: leer,
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
checklisten: { erledigt: 0, gesamt: checklisten.length },
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
});
const nummern = plan.map((z) => z.wochenNummer);
const stelle = plan.findIndex((z) => z.wochenNummer === wochenNummer);
return {
woche,
planung,
fortschritt,
tage,
handouts,
aufgaben,
checklisten,
kontrolle,
vorherige: stelle > 0 ? (nummern[stelle - 1] ?? null) : null,
naechste: stelle < nummern.length - 1 ? (nummern[stelle + 1] ?? null) : null,
};
}
/** Die Woche, die gerade dran ist. */
export function aktuelleWochensicht(ausbildungId: string, azubiId: string): Wochensicht | null {
const plan = planVon(ausbildungId);
const dran = aktuelleWoche(plan, heute());
if (!dran) return null;
return wochensicht(ausbildungId, azubiId, dran.wochenNummer);
}
/** Kurzstand für die Ausbilderübersicht – ohne den ganzen Wochenaufbau. */
export type Kurzstand = {
wochenNummer: number;
titel: string;
geplantVon: string;
geplantBis: string;
status: Wochenplanung['status'];
fortschritt: Fortschritt;
handoutsGelesen: number;
handoutsGesamt: number;
};
export function kurzstand(ausbildungId: string, azubiId: string): Kurzstand | null {
const sicht = aktuelleWochensicht(ausbildungId, azubiId);
if (!sicht) return null;
return {
wochenNummer: sicht.woche.nummer,
titel: sicht.woche.titel,
geplantVon: sicht.planung.geplantVon,
geplantBis: sicht.planung.geplantBis,
status: sicht.planung.status,
fortschritt: sicht.fortschritt,
handoutsGelesen: sicht.handouts.filter((h) => h.gelesen).length,
handoutsGesamt: sicht.handouts.length,
};
}
function tagVersetzt(montag: string, tage: number): string {
const d = new Date(`${montag}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + tage);
return d.toISOString().slice(0, 10);
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import { aktuelleWoche, erzeugePlan, sortiereUm, ueberschneidungen, verschiebe, type Planzeile } from './wochen';
import { anteil, leer, wochenFortschritt, type Wochenstand } from './fortschritt';
function plan(): Planzeile[] {
return erzeugePlan('2026-09-07', [1, 2, 3, 4]);
}
describe('Plan erzeugen', () => {
it('legt fortlaufende Wochen ab dem Montag des Beginns an', () => {
const zeilen = plan();
expect(zeilen.map((z) => z.geplantVon)).toEqual(['2026-09-07', '2026-09-14', '2026-09-21', '2026-09-28']);
expect(zeilen.map((z) => z.geplantBis)).toEqual(['2026-09-11', '2026-09-18', '2026-09-25', '2026-10-02']);
});
it('beginnt auch dann am Montag, wenn die Ausbildung mitten in der Woche startet', () => {
// 2026-09-09 ist ein Mittwoch.
expect(erzeugePlan('2026-09-09', [1])[0]!.geplantVon).toBe('2026-09-07');
});
it('sortiert die Wochennummern, egal in welcher Reihenfolge sie kommen', () => {
const zeilen = erzeugePlan('2026-09-07', [3, 1, 2]);
expect(zeilen.map((z) => z.wochenNummer)).toEqual([1, 2, 3]);
});
it('startet alles im Zustand geplant', () => {
expect(plan().every((z) => z.status === 'geplant' && z.tatsaechlichVon === null)).toBe(true);
});
});
describe('Woche verschieben', () => {
it('verschiebt nur die gewählte Woche, wenn nichts anderes verlangt ist', () => {
const verschoben = verschiebe(plan(), 2, '2026-09-21', false);
expect(termin(verschoben, 1)).toBe('2026-09-07');
expect(termin(verschoben, 2)).toBe('2026-09-21');
expect(termin(verschoben, 3)).toBe('2026-09-21'); // unverändert – Überschneidung in Kauf genommen
expect(termin(verschoben, 4)).toBe('2026-09-28');
});
it('schiebt auf Wunsch alle folgenden Wochen mit', () => {
const verschoben = verschiebe(plan(), 2, '2026-09-21', true);
expect(termin(verschoben, 1)).toBe('2026-09-07');
expect(termin(verschoben, 2)).toBe('2026-09-21');
expect(termin(verschoben, 3)).toBe('2026-09-28');
expect(termin(verschoben, 4)).toBe('2026-10-05');
});
it('zieht den Plan auch nach vorne', () => {
const verschoben = verschiebe(plan(), 3, '2026-09-14', true);
expect(termin(verschoben, 3)).toBe('2026-09-14');
expect(termin(verschoben, 4)).toBe('2026-09-21');
expect(termin(verschoben, 1)).toBe('2026-09-07'); // davor bleibt stehen
});
it('rundet ein beliebiges Datum auf den Montag', () => {
// 2026-09-24 ist ein Donnerstag.
const verschoben = verschiebe(plan(), 2, '2026-09-24', false);
expect(termin(verschoben, 2)).toBe('2026-09-21');
});
it('lässt abgeschlossene Wochen stehen, auch wenn sie danach liegen', () => {
const mitAbgeschlossener = plan().map((z) => (z.wochenNummer === 3 ? { ...z, status: 'abgeschlossen' as const } : z));
const verschoben = verschiebe(mitAbgeschlossener, 2, '2026-09-21', true);
expect(termin(verschoben, 3)).toBe('2026-09-21'); // steht, weil abgeschlossen
expect(termin(verschoben, 4)).toBe('2026-10-05'); // rutscht mit
});
it('ändert nichts, wenn das Ziel schon der aktuelle Termin ist', () => {
const vorher = plan();
expect(verschiebe(vorher, 2, '2026-09-14', true)).toEqual(vorher);
});
it('ändert nichts bei einer unbekannten Wochennummer', () => {
const vorher = plan();
expect(verschiebe(vorher, 99, '2026-10-05', true)).toEqual(vorher);
});
it('behält Zustand und tatsächlichen Beginn bei', () => {
const mitStand = plan().map((z) =>
z.wochenNummer === 2 ? { ...z, status: 'laufend' as const, tatsaechlichVon: '2026-09-15' } : z,
);
const verschoben = verschiebe(mitStand, 2, '2026-09-21', true);
const zwei = verschoben.find((z) => z.wochenNummer === 2)!;
expect(zwei.status).toBe('laufend');
expect(zwei.tatsaechlichVon).toBe('2026-09-15');
});
});
describe('Reihenfolge ändern (Ziehen und Ablegen)', () => {
it('setzt eine Woche nach hinten und schiebt die dazwischen nach vorn', () => {
const umsortiert = sortiereUm(plan(), 1, 2);
expect(reihenfolge(umsortiert)).toEqual([2, 3, 1, 4]);
// Die Terminfolge bleibt lückenlos dieselbe.
expect(termine(umsortiert)).toEqual(['2026-09-07', '2026-09-14', '2026-09-21', '2026-09-28']);
});
it('setzt eine Woche nach vorn', () => {
const umsortiert = sortiereUm(plan(), 4, 0);
expect(reihenfolge(umsortiert)).toEqual([4, 1, 2, 3]);
});
it('lässt die Termine unverändert – nur die Zuordnung wechselt', () => {
const vorher = plan();
const umsortiert = sortiereUm(vorher, 3, 0);
expect(termine(umsortiert)).toEqual(termine(vorher));
expect(termin(umsortiert, 3)).toBe('2026-09-07');
expect(termin(umsortiert, 1)).toBe('2026-09-14');
});
it('ändert nichts, wenn die Stelle dieselbe bleibt', () => {
const vorher = plan();
expect(sortiereUm(vorher, 2, 1)).toEqual(vorher);
});
it('fängt Stellen außerhalb des Plans ab', () => {
expect(reihenfolge(sortiereUm(plan(), 1, 99))).toEqual([2, 3, 4, 1]);
expect(reihenfolge(sortiereUm(plan(), 4, -5))).toEqual([4, 1, 2, 3]);
});
it('ändert nichts bei einer unbekannten Wochennummer', () => {
const vorher = plan();
expect(sortiereUm(vorher, 99, 0)).toEqual(vorher);
});
});
describe('Aktuelle Woche bestimmen', () => {
it('nimmt eine ausdrücklich laufende Woche, auch wenn ihr Termin vorbei ist', () => {
const zeilen = plan().map((z) => (z.wochenNummer === 1 ? { ...z, status: 'laufend' as const } : z));
expect(aktuelleWoche(zeilen, '2026-09-30')!.wochenNummer).toBe(1);
});
it('nimmt die Woche, in die das heutige Datum fällt', () => {
expect(aktuelleWoche(plan(), '2026-09-16')!.wochenNummer).toBe(2);
});
it('nimmt am Wochenende die Woche, in der der Freitag lag – nicht die nächste', () => {
// Samstag, 2026-09-12: liegt hinter dem Freitag der Woche 1.
expect(aktuelleWoche(plan(), '2026-09-12')!.wochenNummer).toBe(2);
});
it('nimmt vor dem Beginn die erste Woche', () => {
expect(aktuelleWoche(plan(), '2026-08-01')!.wochenNummer).toBe(1);
});
it('nimmt am Ende die letzte offene Woche statt einer abgeschlossenen', () => {
const zeilen = plan().map((z) => (z.wochenNummer <= 3 ? { ...z, status: 'abgeschlossen' as const } : z));
expect(aktuelleWoche(zeilen, '2026-12-01')!.wochenNummer).toBe(4);
});
it('nimmt die letzte Woche, wenn alles abgeschlossen ist', () => {
const zeilen = plan().map((z) => ({ ...z, status: 'abgeschlossen' as const }));
expect(aktuelleWoche(zeilen, '2026-12-01')!.wochenNummer).toBe(4);
});
it('kommt mit einem leeren Plan zurecht', () => {
expect(aktuelleWoche([], '2026-09-10')).toBeNull();
});
});
describe('Überschneidungen finden', () => {
it('meldet nichts bei einem lückenlosen Plan', () => {
expect(ueberschneidungen(plan())).toEqual([]);
});
it('meldet zwei Wochen auf demselben Termin', () => {
const verschoben = verschiebe(plan(), 2, '2026-09-21', false);
expect(ueberschneidungen(verschoben)).toContainEqual([2, 3]);
});
});
describe('Fortschritt einer Woche', () => {
const nichts: Wochenstand = { handouts: leer, karten: leer, aufgaben: leer, checklisten: leer, kontrolle: leer };
it('zählt nur Bereiche, in denen es etwas zu tun gibt', () => {
const stand = wochenFortschritt({ ...nichts, handouts: { erledigt: 2, gesamt: 4 } });
expect(stand.bereiche.map((b) => b.schluessel)).toEqual(['handouts']);
expect(stand.gesamt).toBe(4);
expect(stand.prozent).toBe(50);
});
it('fasst mehrere Bereiche zusammen', () => {
const stand = wochenFortschritt({
...nichts,
handouts: { erledigt: 4, gesamt: 4 },
aufgaben: { erledigt: 0, gesamt: 1 },
kontrolle: { erledigt: 1, gesamt: 1 },
});
expect(stand.erledigt).toBe(5);
expect(stand.gesamt).toBe(6);
expect(stand.offen.map((b) => b.schluessel)).toEqual(['aufgaben']);
expect(stand.vollstaendig).toBe(false);
});
it('erkennt eine vollständig erledigte Woche', () => {
const stand = wochenFortschritt({ ...nichts, handouts: { erledigt: 3, gesamt: 3 } });
expect(stand.vollstaendig).toBe(true);
expect(stand.prozent).toBe(100);
});
it('nennt eine Woche ohne Inhalt nicht unvollständig', () => {
const stand = wochenFortschritt(nichts);
expect(stand.prozent).toBe(100);
expect(stand.offen).toEqual([]);
expect(stand.vollstaendig).toBe(false); // es gibt nichts zu erledigen
});
it('lässt sich durch zu hohe Zählwerte nicht über 100 treiben', () => {
const stand = wochenFortschritt({ ...nichts, handouts: { erledigt: 9, gesamt: 3 } });
expect(stand.erledigt).toBe(3);
expect(stand.prozent).toBe(100);
});
});
describe('Anteil in Prozent', () => {
it('rundet kaufmännisch', () => {
expect(anteil(1, 3)).toBe(33);
expect(anteil(2, 3)).toBe(67);
});
it('zeigt nie 100, solange noch etwas offen ist', () => {
expect(anteil(199, 200)).toBe(99);
expect(anteil(200, 200)).toBe(100);
});
it('zeigt nie 0, sobald etwas erledigt ist', () => {
expect(anteil(1, 500)).toBe(1);
expect(anteil(0, 500)).toBe(0);
});
it('gibt bei nichts zu tun 100 zurück', () => {
expect(anteil(0, 0)).toBe(100);
});
});
// --- Hilfsmittel ---
function termin(zeilen: Planzeile[], nummer: number): string {
return zeilen.find((z) => z.wochenNummer === nummer)!.geplantVon;
}
function reihenfolge(zeilen: Planzeile[]): number[] {
return [...zeilen].sort((a, b) => a.geplantVon.localeCompare(b.geplantVon)).map((z) => z.wochenNummer);
}
function termine(zeilen: Planzeile[]): string[] {
return [...zeilen].map((z) => z.geplantVon).sort();
}
+160
View File
@@ -0,0 +1,160 @@
import { montagDerWoche, plusTage } from '../zeit';
import type { WochenStatus } from '../db/schema';
/**
* Die Rechenregeln der Wochenplanung – ohne Datenbank, damit sie sich
* vollständig testen lassen. Die Repository-Schicht holt die Datensätze,
* ruft hier die Rechnung auf und schreibt das Ergebnis zurück.
*/
export type Planzeile = {
wochenNummer: number;
geplantVon: string;
geplantBis: string;
tatsaechlichVon: string | null;
status: WochenStatus;
};
/**
* Erzeugt einen Plan aus fortlaufenden Kalenderwochen ab dem Ausbildungsbeginn.
* Jede Woche beginnt am Montag – auch wenn die Ausbildung an einem Mittwoch
* anfängt, denn der Wochenplan ist auf Montag bis Freitag gebaut.
*/
export function erzeugePlan(beginn: string, wochenNummern: number[]): Planzeile[] {
const ersterMontag = montagDerWoche(beginn);
return [...wochenNummern]
.sort((a, b) => a - b)
.map((nummer, laufend) => {
const von = plusTage(ersterMontag, laufend * 7);
return {
wochenNummer: nummer,
geplantVon: von,
geplantBis: plusTage(von, 4),
tatsaechlichVon: null,
status: 'geplant' as const,
};
});
}
/**
* Verschiebt eine Woche auf einen neuen Montag.
*
* `folgendeMitverschieben` ist der Normalfall im Alltag: Fällt eine Woche aus,
* rutscht der ganze Rest hinterher. Ohne das Kennzeichen wird nur diese eine
* Woche verlegt – dann können sich Termine überlappen, was der Aufrufer
* hinnehmen muss (manchmal ist genau das gewollt).
*/
export function verschiebe(
plan: Planzeile[],
wochenNummer: number,
neuerMontag: string,
folgendeMitverschieben: boolean,
): Planzeile[] {
const betroffen = plan.find((z) => z.wochenNummer === wochenNummer);
if (!betroffen) return plan;
const ziel = montagDerWoche(neuerMontag);
const verschiebungTage = tageZwischen(betroffen.geplantVon, ziel);
if (verschiebungTage === 0) return plan;
const nachSortiert = [...plan].sort(nachTermin);
const stelleBetroffen = nachSortiert.findIndex((z) => z.wochenNummer === wochenNummer);
return plan.map((zeile) => {
const stelle = nachSortiert.findIndex((z) => z.wochenNummer === zeile.wochenNummer);
const istBetroffen = zeile.wochenNummer === wochenNummer;
const istSpaeter = stelle > stelleBetroffen;
if (!istBetroffen && !(folgendeMitverschieben && istSpaeter)) return zeile;
// Abgeschlossene Wochen bleiben stehen – sie sind Vergangenheit.
if (!istBetroffen && zeile.status === 'abgeschlossen') return zeile;
const von = plusTage(zeile.geplantVon, verschiebungTage);
return { ...zeile, geplantVon: von, geplantBis: plusTage(von, 4) };
});
}
/**
* Setzt eine Woche an eine andere Stelle der Reihenfolge – das ist die
* Rechnung hinter dem Ziehen und Ablegen.
*
* Die Termine selbst bleiben, wo sie sind: Es gibt eine feste Folge von
* Kalenderwochen, und die Wochen des Lehrgangs werden neu darauf verteilt.
* So bleibt der Plan lückenlos, egal wie oft umsortiert wird.
*/
export function sortiereUm(plan: Planzeile[], wochenNummer: number, neueStelle: number): Planzeile[] {
const nachSortiert = [...plan].sort(nachTermin);
const alteStelle = nachSortiert.findIndex((z) => z.wochenNummer === wochenNummer);
if (alteStelle === -1) return plan;
const ziel = Math.max(0, Math.min(neueStelle, nachSortiert.length - 1));
if (ziel === alteStelle) return plan;
// Die Terminfolge ist der feste Rahmen.
const termine = nachSortiert.map((z) => ({ von: z.geplantVon, bis: z.geplantBis }));
const umsortiert = [...nachSortiert];
const [herausgenommen] = umsortiert.splice(alteStelle, 1);
umsortiert.splice(ziel, 0, herausgenommen!);
return umsortiert.map((zeile, stelle) => ({
...zeile,
geplantVon: termine[stelle]!.von,
geplantBis: termine[stelle]!.bis,
}));
}
/**
* Welche Woche ist gerade dran?
*
* Reihenfolge der Überlegung:
* 1. Läuft eine Woche ausdrücklich? Dann die.
* 2. Fällt heute in eine geplante Woche? Dann die.
* 3. Sonst die nächste, die noch nicht abgeschlossen ist.
* 4. Sonst die letzte – die Ausbildung ist durch.
*/
export function aktuelleWoche(plan: Planzeile[], heute: string): Planzeile | null {
if (plan.length === 0) return null;
const nachSortiert = [...plan].sort(nachTermin);
const laufend = nachSortiert.find((z) => z.status === 'laufend');
if (laufend) return laufend;
const heuteDrin = nachSortiert.find((z) => z.geplantVon <= heute && heute <= z.geplantBis);
if (heuteDrin) return heuteDrin;
const naechste = nachSortiert.find((z) => z.status !== 'abgeschlossen' && z.geplantVon > heute);
if (naechste) return naechste;
const offeneVergangene = nachSortiert.filter((z) => z.status !== 'abgeschlossen');
if (offeneVergangene.length > 0) return offeneVergangene.at(-1)!;
return nachSortiert.at(-1)!;
}
/** Termine im Plan, die sich überlappen – nach dem Verschieben einer einzelnen Woche möglich. */
export function ueberschneidungen(plan: Planzeile[]): [number, number][] {
const nachSortiert = [...plan].sort(nachTermin);
const treffer: [number, number][] = [];
for (let i = 1; i < nachSortiert.length; i += 1) {
const vorher = nachSortiert[i - 1]!;
const jetzt = nachSortiert[i]!;
if (jetzt.geplantVon <= vorher.geplantBis) {
treffer.push([vorher.wochenNummer, jetzt.wochenNummer]);
}
}
return treffer;
}
function nachTermin(a: Planzeile, b: Planzeile): number {
if (a.geplantVon !== b.geplantVon) return a.geplantVon.localeCompare(b.geplantVon);
return a.wochenNummer - b.wochenNummer;
}
function tageZwischen(von: string, bis: string): number {
const a = Date.UTC(Number(von.slice(0, 4)), Number(von.slice(5, 7)) - 1, Number(von.slice(8, 10)));
const b = Date.UTC(Number(bis.slice(0, 4)), Number(bis.slice(5, 7)) - 1, Number(bis.slice(8, 10)));
return Math.round((b - a) / (24 * 3600 * 1000));
}
+151
View File
@@ -0,0 +1,151 @@
import crypto from 'node:crypto';
import { and, asc, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { jetzt, plusTage } from '../zeit';
import { erzeugePlan, sortiereUm, verschiebe, type Planzeile } from '../planung/wochen';
import type { Wochenplanung, WochenStatus } from '../db/schema';
/** Zugriff auf die Wochenplanung. Die Rechnung steckt in ../planung/wochen. */
export function planVon(ausbildungId: string): Wochenplanung[] {
return datenbank()
.select()
.from(schema.wochenplanung)
.where(eq(schema.wochenplanung.ausbildungId, ausbildungId))
.orderBy(asc(schema.wochenplanung.geplantVon), asc(schema.wochenplanung.wochenNummer))
.all();
}
export function zeileVon(ausbildungId: string, wochenNummer: number): Wochenplanung | null {
return (
datenbank()
.select()
.from(schema.wochenplanung)
.where(
and(
eq(schema.wochenplanung.ausbildungId, ausbildungId),
eq(schema.wochenplanung.wochenNummer, wochenNummer),
),
)
.get() ?? null
);
}
/**
* Legt fehlende Planzeilen an. Wird beim Anlegen einer Ausbildung aufgerufen
* und noch einmal, wenn im Repository neue Wochen dazugekommen sind.
*
* Bestehende Zeilen werden nie angefasst – sonst wäre jede Terminverschiebung
* beim nächsten Start wieder weg.
*/
export function planAuffrischen(ausbildungId: string, beginn: string, wochenNummern: number[]): number {
const db = datenbank();
const bestand = planVon(ausbildungId);
const vorhanden = new Set(bestand.map((z) => z.wochenNummer));
const fehlende = wochenNummern.filter((n) => !vorhanden.has(n));
if (fehlende.length === 0) return 0;
// Neue Wochen hängen sich hinten an den bestehenden Plan an, statt ihn
// umzuwerfen. Gibt es noch nichts, ist der Ausbildungsbeginn der Anker.
const letzterMontag = bestand.at(-1)?.geplantVon;
const anker = letzterMontag ? plusTage(letzterMontag, 7) : beginn;
const zuSchreiben = erzeugePlan(anker, fehlende);
const wann = jetzt();
db.insert(schema.wochenplanung)
.values(
zuSchreiben.map((zeile) => ({
id: crypto.randomUUID(),
ausbildungId,
wochenNummer: zeile.wochenNummer,
geplantVon: zeile.geplantVon,
geplantBis: zeile.geplantBis,
tatsaechlichVon: null,
status: 'geplant' as const,
notiz: null,
geaendertAm: wann,
})),
)
.run();
return zuSchreiben.length;
}
/** Schreibt einen gerechneten Plan zurück – nur die Zeilen, die sich geändert haben. */
function schreibeZurueck(ausbildungId: string, vorher: Wochenplanung[], nachher: Planzeile[]): number {
const db = datenbank();
const nachNummer = new Map(nachher.map((z) => [z.wochenNummer, z]));
const wann = jetzt();
let geaendert = 0;
for (const alt of vorher) {
const neu = nachNummer.get(alt.wochenNummer);
if (!neu) continue;
if (neu.geplantVon === alt.geplantVon && neu.geplantBis === alt.geplantBis) continue;
db.update(schema.wochenplanung)
.set({ geplantVon: neu.geplantVon, geplantBis: neu.geplantBis, geaendertAm: wann })
.where(eq(schema.wochenplanung.id, alt.id))
.run();
geaendert += 1;
}
return geaendert;
}
export function wocheVerschieben(
ausbildungId: string,
wochenNummer: number,
neuerMontag: string,
folgendeMitverschieben: boolean,
): number {
const bestand = planVon(ausbildungId);
const gerechnet = verschiebe(bestand, wochenNummer, neuerMontag, folgendeMitverschieben);
return schreibeZurueck(ausbildungId, bestand, gerechnet);
}
export function wocheUmsortieren(ausbildungId: string, wochenNummer: number, neueStelle: number): number {
const bestand = planVon(ausbildungId);
const gerechnet = sortiereUm(bestand, wochenNummer, neueStelle);
return schreibeZurueck(ausbildungId, bestand, gerechnet);
}
export function statusSetzen(
ausbildungId: string,
wochenNummer: number,
status: WochenStatus,
heuteDatum: string,
): void {
const zeile = zeileVon(ausbildungId, wochenNummer);
if (!zeile) return;
const db = datenbank();
// Es läuft immer höchstens eine Woche.
if (status === 'laufend') {
db.update(schema.wochenplanung)
.set({ status: 'geplant', geaendertAm: jetzt() })
.where(and(eq(schema.wochenplanung.ausbildungId, ausbildungId), eq(schema.wochenplanung.status, 'laufend')))
.run();
}
db.update(schema.wochenplanung)
.set({
status,
tatsaechlichVon: status === 'laufend' ? (zeile.tatsaechlichVon ?? heuteDatum) : zeile.tatsaechlichVon,
geaendertAm: jetzt(),
})
.where(eq(schema.wochenplanung.id, zeile.id))
.run();
}
export function notizSetzen(ausbildungId: string, wochenNummer: number, notiz: string): void {
const zeile = zeileVon(ausbildungId, wochenNummer);
if (!zeile) return;
datenbank()
.update(schema.wochenplanung)
.set({ notiz: notiz.trim() || null, geaendertAm: jetzt() })
.where(eq(schema.wochenplanung.id, zeile.id))
.run();
}