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:
co-authored by
Claude Opus 5
parent
1af5613e9b
commit
103a603eb4
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user