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