Ein Termin von 22:00 bis 02:00 wurde bisher als ein ueberlanger Block in den Starttag gezeichnet und hat die Tagesspalte verlaengert. Jetzt wird er an der Tagesgrenze geteilt: 22:00-24:00 am Starttag, 00:00-02:00 am Folgetag -- so, wie es Kalender-Apps zeigen. Die Regel liegt als reine Funktion splitEventByDay in src/lib/weekly/date-utils.ts und ist mit node:test abgedeckt (Teilung, Mehrtagestermine, Ende exakt um Mitternacht, Ganztagstermine, ungueltige Zeiten). Die Wochenansicht arbeitet in Raster und Liste nur noch mit diesen Tagesabschnitten; Klick, Drag und Resize greifen weiterhin auf den ungeteilten Termin zu, damit Payloads unveraendert bleiben. Die Slot-Sperre (protectEventTimes) erfasst dadurch auch die Nachtstunden des Folgetags. v1.121.0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import { test, describe } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
getStartOfWeek, getDayName, isSameDay, formatDateToISO, parseCalendarDate,
|
|
formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, getCWReferenceDate,
|
|
getSelectedDay, splitEventByDay, groupEventSegmentsByDay,
|
|
segmentStartMinutes, segmentEndMinutes,
|
|
} from "../../src/lib/weekly/date-utils.ts";
|
|
|
|
/**
|
|
* Unit-Tests der Datums- und Zeitraster-Helfer.
|
|
*
|
|
* Läuft ohne Browser, ohne Datenbank, ohne laufende App: `npm run test:unit`.
|
|
*
|
|
* Die Erwartungen sind bewusst gegen unabhängig überprüfbare Fakten formuliert
|
|
* (Kalenderdaten, ISO-8601-Regeln) und nicht gegen die Implementierung — ein Test,
|
|
* der nur nachspricht, was der Code tut, findet keine Fehler.
|
|
*/
|
|
|
|
describe("getWeekNumber (ISO 8601)", () => {
|
|
// Die Wochenzahl wird prominent in der Kopfzeile angezeigt ("KW 34 | August 2026"),
|
|
// ein Fehler hier fällt sofort jedem Nutzer auf.
|
|
test("Donnerstag 01.01.2026 liegt in KW 1", () => {
|
|
assert.equal(getWeekNumber(new Date(2026, 0, 1)), 1);
|
|
});
|
|
|
|
test("Samstag 22.08.2026 liegt in KW 34", () => {
|
|
assert.equal(getWeekNumber(new Date(2026, 7, 22)), 34);
|
|
});
|
|
|
|
test("Mittwoch 02.09.2026 liegt in KW 36", () => {
|
|
assert.equal(getWeekNumber(new Date(2026, 8, 2)), 36);
|
|
});
|
|
|
|
test("Montag 01.01.2024 liegt in KW 1", () => {
|
|
assert.equal(getWeekNumber(new Date(2024, 0, 1)), 1);
|
|
});
|
|
|
|
test("Sonntag 01.01.2023 gehört noch zu KW 52 des Vorjahres", () => {
|
|
// ISO 8601: Die Woche gehört dem Jahr, in dem ihr Donnerstag liegt.
|
|
assert.equal(getWeekNumber(new Date(2023, 0, 1)), 52);
|
|
});
|
|
|
|
test("Freitag 01.01.2021 gehört zu KW 53 des Vorjahres", () => {
|
|
assert.equal(getWeekNumber(new Date(2021, 0, 1)), 53);
|
|
});
|
|
});
|
|
|
|
describe("getStartOfWeek", () => {
|
|
test("Wochenstart Montag: Donnerstag 03.09.2026 → Montag 31.08.2026", () => {
|
|
const r = getStartOfWeek(new Date(2026, 8, 3), 1);
|
|
assert.equal(formatDateToISO(r), "2026-08-31");
|
|
});
|
|
|
|
test("Wochenstart Montag: Sonntag gehört zur ablaufenden Woche", () => {
|
|
// Der klassische Stolperstein — Sonntag ist Tag 0, liegt aber am Wochenende.
|
|
const r = getStartOfWeek(new Date(2026, 8, 6), 1);
|
|
assert.equal(formatDateToISO(r), "2026-08-31");
|
|
});
|
|
|
|
test("Wochenstart Sonntag: Sonntag ist bereits der Wochenstart", () => {
|
|
const r = getStartOfWeek(new Date(2026, 8, 6), 0);
|
|
assert.equal(formatDateToISO(r), "2026-09-06");
|
|
});
|
|
|
|
test("Ein Tag, der bereits der Wochenstart ist, bleibt unverändert", () => {
|
|
const r = getStartOfWeek(new Date(2026, 7, 31), 1);
|
|
assert.equal(formatDateToISO(r), "2026-08-31");
|
|
});
|
|
|
|
test("verändert das übergebene Datum nicht", () => {
|
|
const input = new Date(2026, 8, 3);
|
|
getStartOfWeek(input, 1);
|
|
assert.equal(formatDateToISO(input), "2026-09-03");
|
|
});
|
|
});
|
|
|
|
describe("formatDateToISO", () => {
|
|
test("formatiert mit führenden Nullen", () => {
|
|
assert.equal(formatDateToISO(new Date(2026, 0, 5)), "2026-01-05");
|
|
});
|
|
|
|
test("nutzt die lokale Zeit, nicht UTC", () => {
|
|
// Kern der Sache: toISOString() würde bei einem späten Abendtermin in einer
|
|
// Zeitzone östlich von UTC auf den Folgetag rutschen. Genau daraus entstehen
|
|
// Termine, die im Kalender einen Tag daneben landen.
|
|
const spaeterAbend = new Date(2026, 8, 7, 23, 30, 0);
|
|
assert.equal(formatDateToISO(spaeterAbend), "2026-09-07");
|
|
});
|
|
|
|
test("früher Morgen bleibt am selben Tag", () => {
|
|
assert.equal(formatDateToISO(new Date(2026, 8, 7, 0, 30, 0)), "2026-09-07");
|
|
});
|
|
});
|
|
|
|
describe("parseCalendarDate", () => {
|
|
test("reines Datum wird als lokale Mitternacht gelesen", () => {
|
|
const d = parseCalendarDate("2026-09-07");
|
|
assert.equal(d.getFullYear(), 2026);
|
|
assert.equal(d.getMonth(), 8);
|
|
assert.equal(d.getDate(), 7);
|
|
assert.equal(d.getHours(), 0);
|
|
});
|
|
|
|
test("Round-Trip mit formatDateToISO ist verlustfrei", () => {
|
|
// Diese beiden Funktionen sind die Brücke zwischen Datenbank und Anzeige.
|
|
// Wenn sie auseinanderlaufen, verschieben sich Aufgaben um einen Tag.
|
|
for (const s of ["2026-01-01", "2026-06-15", "2026-12-31", "2026-02-28"]) {
|
|
assert.equal(formatDateToISO(parseCalendarDate(s)), s);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getTimeSlots", () => {
|
|
test("30-Minuten-Raster von 8 bis 10 Uhr", () => {
|
|
assert.deepEqual(getTimeSlots(30, 8, 10), ["08:00", "08:30", "09:00", "09:30"]);
|
|
});
|
|
|
|
test("15-Minuten-Raster ergibt vier Einträge pro Stunde", () => {
|
|
assert.deepEqual(getTimeSlots(15, 9, 10), ["09:00", "09:15", "09:30", "09:45"]);
|
|
});
|
|
|
|
test("volle Stunden über den ganzen Tag ergeben 24 Einträge", () => {
|
|
const slots = getTimeSlots(60, 0, 24);
|
|
assert.equal(slots.length, 24);
|
|
assert.equal(slots[0], "00:00");
|
|
assert.equal(slots[23], "23:00");
|
|
});
|
|
|
|
test("Endstunde ist exklusiv", () => {
|
|
assert.equal(getTimeSlots(60, 8, 9).length, 1);
|
|
});
|
|
|
|
test("leeres Ergebnis, wenn Ende nicht nach Start liegt", () => {
|
|
assert.deepEqual(getTimeSlots(30, 10, 10), []);
|
|
});
|
|
});
|
|
|
|
describe("formatHour", () => {
|
|
test("24h-Format, kurz, ohne Minuten", () => {
|
|
assert.equal(formatHour(9, 0, "short", "24h"), "9");
|
|
});
|
|
|
|
test("24h-Format mit Minuten wird zweistellig", () => {
|
|
assert.equal(formatHour(9, 30, "short", "24h"), "09:30");
|
|
});
|
|
|
|
test("12h-Format: Nachmittag", () => {
|
|
assert.equal(formatHour(13, 0, "short", "12h"), "1 PM");
|
|
});
|
|
|
|
test("12h-Format: Mitternacht ist 12 AM, nicht 0 AM", () => {
|
|
assert.equal(formatHour(0, 0, "short", "12h"), "12 AM");
|
|
});
|
|
|
|
test("12h-Format: Mittag ist 12 PM", () => {
|
|
assert.equal(formatHour(12, 0, "short", "12h"), "12 PM");
|
|
});
|
|
});
|
|
|
|
describe("getHourFromSlot", () => {
|
|
test("liest die Stunde aus dem Slot", () => {
|
|
assert.equal(getHourFromSlot("08:30"), 8);
|
|
assert.equal(getHourFromSlot("23:45"), 23);
|
|
});
|
|
|
|
test("führende Null wird nicht als Oktalzahl gelesen", () => {
|
|
assert.equal(getHourFromSlot("09:00"), 9);
|
|
});
|
|
});
|
|
|
|
describe("isSameDay", () => {
|
|
test("gleicher Tag, verschiedene Uhrzeit", () => {
|
|
assert.equal(isSameDay(new Date(2026, 8, 7, 8, 0), new Date(2026, 8, 7, 22, 0)), true);
|
|
});
|
|
|
|
test("aufeinanderfolgende Tage sind nicht gleich", () => {
|
|
assert.equal(isSameDay(new Date(2026, 8, 7), new Date(2026, 8, 8)), false);
|
|
});
|
|
|
|
test("gleicher Tag im anderen Jahr ist nicht gleich", () => {
|
|
assert.equal(isSameDay(new Date(2026, 8, 7), new Date(2025, 8, 7)), false);
|
|
});
|
|
});
|
|
|
|
describe("getCWReferenceDate", () => {
|
|
test("liefert den Montag der Woche des ersten sichtbaren Tages", () => {
|
|
const days = [new Date(2026, 8, 3), new Date(2026, 8, 4)]; // Do, Fr
|
|
assert.equal(formatDateToISO(getCWReferenceDate(days)), "2026-08-31");
|
|
});
|
|
|
|
test("Sonntag gehört zur ablaufenden Woche, nicht zur kommenden", () => {
|
|
const days = [new Date(2026, 8, 6)]; // Sonntag
|
|
assert.equal(formatDateToISO(getCWReferenceDate(days)), "2026-08-31");
|
|
});
|
|
|
|
test("leere Liste liefert ein Datum statt zu werfen", () => {
|
|
assert.ok(getCWReferenceDate([]) instanceof Date);
|
|
});
|
|
});
|
|
|
|
describe("getSelectedDay", () => {
|
|
test("ohne ausdrückliche Auswahl wird ein Tag aus der Liste geliefert", () => {
|
|
const days = [new Date(2026, 8, 7), new Date(2026, 8, 8)];
|
|
assert.ok(days.some((d) => isSameDay(d, getSelectedDay(days))));
|
|
});
|
|
|
|
test("eine ausdrückliche Auswahl aus der Liste wird übernommen", () => {
|
|
const days = [new Date(2026, 8, 7), new Date(2026, 8, 8)];
|
|
assert.equal(formatDateToISO(getSelectedDay(days, days[1])), "2026-09-08");
|
|
});
|
|
});
|
|
|
|
describe("getDayName", () => {
|
|
test("deutscher Wochentag, lang", () => {
|
|
assert.equal(getDayName(new Date(2026, 8, 7), "de-DE"), "Montag");
|
|
});
|
|
|
|
test("eigene Namen werden nach Wochentag zugeordnet", () => {
|
|
const namen = "So,Mo,Di,Mi,Do,Fr,Sa";
|
|
assert.equal(getDayName(new Date(2026, 8, 7), "de-DE", "custom", namen, 0), "Mo");
|
|
});
|
|
|
|
test("eigene Namen mit falscher Anzahl fallen auf das Gebietsschema zurück", () => {
|
|
assert.equal(getDayName(new Date(2026, 8, 7), "de-DE", "custom", "nur,drei,Namen"), "Montag");
|
|
});
|
|
|
|
test("Großschreibung ist einstellbar", () => {
|
|
assert.equal(getDayName(new Date(2026, 8, 7), "de-DE", undefined, undefined, 0, "uppercase"), "MONTAG");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Termine über Mitternacht.
|
|
*
|
|
* Erwartetes Nutzerverhalten: Ein Termin von 22:00 bis 02:00 verlängert nicht den
|
|
* Starttag, sondern wird an der Tagesgrenze geteilt und läuft am Folgetag weiter —
|
|
* so, wie es Kalender-Apps zeigen.
|
|
*/
|
|
describe("splitEventByDay", () => {
|
|
const iso = (y: number, m: number, d: number, h: number, min = 0) =>
|
|
new Date(y, m, d, h, min, 0, 0).toISOString();
|
|
|
|
const event = (startTime: string, endTime: string, id = "e1") => ({
|
|
id,
|
|
title: "Termin",
|
|
startTime,
|
|
endTime,
|
|
source: "google" as const,
|
|
});
|
|
|
|
test("Termin innerhalb eines Tages bleibt ein Abschnitt", () => {
|
|
const segments = splitEventByDay(event(iso(2026, 8, 10, 9), iso(2026, 8, 10, 10, 30)));
|
|
assert.equal(segments.length, 1);
|
|
assert.equal(segments[0].continuesFromPreviousDay, false);
|
|
assert.equal(segments[0].continuesOnNextDay, false);
|
|
assert.equal(segments[0].segmentKey, "e1");
|
|
});
|
|
|
|
test("22:00 bis 02:00 ergibt zwei Abschnitte an der Tagesgrenze", () => {
|
|
const segments = splitEventByDay(event(iso(2026, 8, 10, 22), iso(2026, 8, 11, 2)));
|
|
assert.equal(segments.length, 2);
|
|
|
|
const first = new Date(segments[0].startTime);
|
|
const firstEnd = new Date(segments[0].endTime);
|
|
assert.equal(first.getHours(), 22);
|
|
assert.equal(firstEnd.getDate(), 11);
|
|
assert.equal(firstEnd.getHours(), 0);
|
|
assert.equal(segments[0].continuesOnNextDay, true);
|
|
|
|
const second = new Date(segments[1].startTime);
|
|
const secondEnd = new Date(segments[1].endTime);
|
|
assert.equal(second.getDate(), 11);
|
|
assert.equal(second.getHours(), 0);
|
|
assert.equal(second.getMinutes(), 0);
|
|
assert.equal(secondEnd.getHours(), 2);
|
|
assert.equal(segments[1].continuesFromPreviousDay, true);
|
|
assert.equal(segments[1].continuesOnNextDay, false);
|
|
});
|
|
|
|
test("kein Abschnitt verliert Zeit: Summe der Abschnitte ist die Termindauer", () => {
|
|
const start = iso(2026, 8, 10, 22);
|
|
const end = iso(2026, 8, 13, 3, 15);
|
|
const segments = splitEventByDay(event(start, end));
|
|
const total = segments.reduce(
|
|
(sum, s) => sum + (new Date(s.endTime).getTime() - new Date(s.startTime).getTime()),
|
|
0,
|
|
);
|
|
assert.equal(total, new Date(end).getTime() - new Date(start).getTime());
|
|
});
|
|
|
|
test("Termin über drei Tage ergibt drei Abschnitte mit vollem Mitteltag", () => {
|
|
const segments = splitEventByDay(event(iso(2026, 8, 10, 23), iso(2026, 8, 12, 1)));
|
|
assert.equal(segments.length, 3);
|
|
const middle = segments[1];
|
|
assert.equal(new Date(middle.startTime).getHours(), 0);
|
|
assert.equal(new Date(middle.endTime).getDate(), 12);
|
|
assert.equal(middle.continuesFromPreviousDay, true);
|
|
assert.equal(middle.continuesOnNextDay, true);
|
|
});
|
|
|
|
test("Ende exakt um Mitternacht erzeugt keinen leeren Abschnitt am Folgetag", () => {
|
|
const segments = splitEventByDay(event(iso(2026, 8, 10, 22), iso(2026, 8, 11, 0)));
|
|
assert.equal(segments.length, 1);
|
|
assert.equal(segments[0].continuesOnNextDay, false);
|
|
});
|
|
|
|
test("Abschnitte eines Termins haben unterschiedliche Keys, aber dieselbe Event-Id", () => {
|
|
const segments = splitEventByDay(event(iso(2026, 8, 10, 22), iso(2026, 8, 11, 2)));
|
|
assert.notEqual(segments[0].segmentKey, segments[1].segmentKey);
|
|
assert.equal(segments[0].id, "e1");
|
|
assert.equal(segments[1].id, "e1");
|
|
assert.equal(segments[1].sourceEvent.startTime, iso(2026, 8, 10, 22));
|
|
assert.equal(segments[1].sourceEvent.endTime, iso(2026, 8, 11, 2));
|
|
});
|
|
|
|
test("Ganztagstermin wird nicht geteilt", () => {
|
|
const segments = splitEventByDay(event("2026-09-10", "2026-09-12"));
|
|
assert.equal(segments.length, 1);
|
|
assert.equal(segments[0].segmentCount, 1);
|
|
});
|
|
|
|
test("Ende vor Start bleibt ein Abschnitt und wirft nicht", () => {
|
|
const segments = splitEventByDay(event(iso(2026, 8, 10, 12), iso(2026, 8, 10, 11)));
|
|
assert.equal(segments.length, 1);
|
|
});
|
|
});
|
|
|
|
describe("Tagesgruppierung und Minutenumrechnung der Abschnitte", () => {
|
|
const iso = (y: number, m: number, d: number, h: number, min = 0) =>
|
|
new Date(y, m, d, h, min, 0, 0).toISOString();
|
|
|
|
test("Abschnitte landen im jeweiligen Tag", () => {
|
|
const byDay = groupEventSegmentsByDay([
|
|
{ id: "a", title: "Nacht", startTime: iso(2026, 8, 10, 22), endTime: iso(2026, 8, 11, 2), source: "google" },
|
|
{ id: "b", title: "Tag", startTime: iso(2026, 8, 11, 9), endTime: iso(2026, 8, 11, 10), source: "google" },
|
|
]);
|
|
assert.equal(byDay.get("2026-09-10")?.length, 1);
|
|
assert.equal(byDay.get("2026-09-11")?.length, 2);
|
|
});
|
|
|
|
test("bis Mitternacht laufender Abschnitt endet bei Minute 1440, nicht bei 0", () => {
|
|
const [first] = splitEventByDay({
|
|
id: "a", title: "Nacht", startTime: iso(2026, 8, 10, 22), endTime: iso(2026, 8, 11, 2), source: "google",
|
|
});
|
|
assert.equal(segmentStartMinutes(first), 22 * 60);
|
|
assert.equal(segmentEndMinutes(first), 24 * 60);
|
|
});
|
|
});
|