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
+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();
}