diff --git a/package.json b/package.json index de7249d..b0feeb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.121.1", + "version": "1.121.2", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index f1852fe..93cb7f6 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -108,6 +108,7 @@ import { stripHtml, DEFAULT_SOMEDAY_SLOT_COUNT, getSomedaySlotCount, invertColor, } from "@/lib/weekly/format"; import { isAllDayEvent } from "@/lib/weekly/date-utils"; +import { computeEventOverlapLayout } from "@/lib/weekly/event-layout"; // Types import UserMenu from "./UserMenu"; @@ -2655,53 +2656,11 @@ export default function WeeklyView() { [getEventsForDate, effectiveCellDuration], ); - // Precompute overlap layout per day: { [segmentKey]: { column, totalColumns } } - const eventOverlapLayout = useMemo(() => { - const layout: Record = {}; - for (const daySegments of eventSegmentsByDay.values()) { - // Sort by start time, then by duration (longer first) - const segments = [...daySegments].sort((a, b) => { - const diff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime(); - if (diff !== 0) return diff; - return (new Date(b.endTime).getTime() - new Date(b.startTime).getTime()) - - (new Date(a.endTime).getTime() - new Date(a.startTime).getTime()); - }); - // Greedy column assignment: first column whose last entry ended before this start - const columns: CalendarEventSegment[][] = []; - for (const segment of segments) { - const start = new Date(segment.startTime).getTime(); - const free = columns.find( - (col) => new Date(col[col.length - 1].endTime).getTime() <= start, - ); - if (free) { - free.push(segment); - } else { - columns.push([segment]); - } - } - // Group width = number of columns that actually overlap this segment - for (const segment of segments) { - const start = new Date(segment.startTime).getTime(); - const end = new Date(segment.endTime).getTime(); - let overlappingCols = 0; - let segmentCol = 0; - columns.forEach((col, index) => { - const overlaps = col.some( - (other) => - new Date(other.startTime).getTime() < end && - new Date(other.endTime).getTime() > start, - ); - if (overlaps) overlappingCols++; - if (col.includes(segment)) segmentCol = index; - }); - layout[segment.segmentKey] = { - column: segmentCol, - totalColumns: Math.max(overlappingCols, 1), - }; - } - } - return layout; - }, [eventSegmentsByDay]); + // Spaltenaufteilung ueberlappender Termine je Tag (siehe lib/weekly/event-layout). + const eventOverlapLayout = useMemo( + () => computeEventOverlapLayout(eventSegmentsByDay), + [eventSegmentsByDay], + ); // Calculate event duration in pixels for proper height display const getEventDuration = (event: CalendarEvent): number => { diff --git a/src/lib/weekly/event-layout.ts b/src/lib/weekly/event-layout.ts new file mode 100644 index 0000000..2e1655e --- /dev/null +++ b/src/lib/weekly/event-layout.ts @@ -0,0 +1,79 @@ +import type { CalendarEventSegment } from "@/lib/weekly/types"; + +/** + * Spaltenaufteilung sich ueberlappender Termine im Tagesraster. + * + * Reine Funktionen ohne Komponenten-State und ohne Laufzeit-Importe, damit die + * Regeln mit node:test pruefbar bleiben — lagen bis v1.121.2 in WeeklyView.tsx. + */ + +export interface EventColumnPlacement { + /** 0-basierte Spalte innerhalb der Ueberlappungsgruppe. */ + column: number; + /** Breite der Gruppe in Spalten; mindestens 1. */ + totalColumns: number; +} + +const startMs = (segment: CalendarEventSegment): number => + new Date(segment.startTime).getTime(); +const endMs = (segment: CalendarEventSegment): number => + new Date(segment.endTime).getTime(); + +/** + * Ordnet die Abschnitte eines Tages in Spalten an. + * + * Frueher Start zuerst, bei gleichem Start der laengere Termin. Jeder Abschnitt + * kommt in die erste Spalte, deren letzter Eintrag bereits beendet ist. Die + * Gruppenbreite ist die Anzahl der Spalten, die den Abschnitt zeitlich schneiden. + */ +export function layoutDaySegments( + daySegments: CalendarEventSegment[], +): Record { + const layout: Record = {}; + + const segments = [...daySegments].sort((a, b) => { + const byStart = startMs(a) - startMs(b); + if (byStart !== 0) return byStart; + return endMs(b) - startMs(b) - (endMs(a) - startMs(a)); + }); + + const columns: CalendarEventSegment[][] = []; + for (const segment of segments) { + const free = columns.find((col) => endMs(col[col.length - 1]) <= startMs(segment)); + if (free) { + free.push(segment); + } else { + columns.push([segment]); + } + } + + for (const segment of segments) { + const start = startMs(segment); + const end = endMs(segment); + let overlappingCols = 0; + let segmentCol = 0; + columns.forEach((col, index) => { + if (col.some((other) => startMs(other) < end && endMs(other) > start)) { + overlappingCols++; + } + if (col.includes(segment)) segmentCol = index; + }); + layout[segment.segmentKey] = { + column: segmentCol, + totalColumns: Math.max(overlappingCols, 1), + }; + } + + return layout; +} + +/** Spaltenaufteilung fuer alle Tage, gebuendelt nach segmentKey. */ +export function computeEventOverlapLayout( + segmentsByDay: Map, +): Record { + const layout: Record = {}; + for (const daySegments of segmentsByDay.values()) { + Object.assign(layout, layoutDaySegments(daySegments)); + } + return layout; +} diff --git a/tests/unit/event-layout.test.ts b/tests/unit/event-layout.test.ts new file mode 100644 index 0000000..75b8a90 --- /dev/null +++ b/tests/unit/event-layout.test.ts @@ -0,0 +1,74 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { layoutDaySegments, computeEventOverlapLayout } from "../../src/lib/weekly/event-layout.ts"; +import type { CalendarEventSegment } from "../../src/lib/weekly/types.ts"; + +/** + * Charakterisierungstests der Spaltenaufteilung im Tagesraster. + * + * Beobachtbares Verhalten: Termine, die sich zeitlich überschneiden, stehen + * nebeneinander statt übereinander; Termine, die sich nicht berühren, bekommen + * die volle Breite. + */ + +const at = (h: number, min = 0) => new Date(2026, 8, 10, h, min, 0, 0).toISOString(); + +const segment = (key: string, startHour: number, endHour: number): CalendarEventSegment => ({ + id: key, + title: key, + startTime: at(startHour), + endTime: at(endHour), + source: "google", + segmentKey: key, + segmentIndex: 0, + segmentCount: 1, + continuesFromPreviousDay: false, + continuesOnNextDay: false, + sourceEvent: { id: key, title: key, startTime: at(startHour), endTime: at(endHour), source: "google" }, +}); + +describe("layoutDaySegments", () => { + test("ein einzelner Termin nimmt die volle Breite ein", () => { + const layout = layoutDaySegments([segment("a", 9, 10)]); + assert.deepEqual(layout.a, { column: 0, totalColumns: 1 }); + }); + + test("zwei überschneidende Termine teilen sich zwei Spalten", () => { + const layout = layoutDaySegments([segment("a", 9, 11), segment("b", 10, 12)]); + assert.equal(layout.a.totalColumns, 2); + assert.equal(layout.b.totalColumns, 2); + assert.notEqual(layout.a.column, layout.b.column); + }); + + test("aufeinanderfolgende Termine bekommen dieselbe Spalte und volle Breite", () => { + const layout = layoutDaySegments([segment("a", 9, 10), segment("b", 10, 11)]); + assert.deepEqual(layout.a, { column: 0, totalColumns: 1 }); + assert.deepEqual(layout.b, { column: 0, totalColumns: 1 }); + }); + + test("drei gleichzeitige Termine ergeben drei Spalten", () => { + const layout = layoutDaySegments([segment("a", 9, 12), segment("b", 9, 12), segment("c", 9, 12)]); + const columns = ["a", "b", "c"].map((k) => layout[k].column).sort(); + assert.deepEqual(columns, [0, 1, 2]); + assert.equal(layout.a.totalColumns, 3); + }); + + test("die Reihenfolge der Eingabe ändert das Ergebnis nicht", () => { + const forward = layoutDaySegments([segment("a", 9, 11), segment("b", 10, 12)]); + const backward = layoutDaySegments([segment("b", 10, 12), segment("a", 9, 11)]); + assert.deepEqual(forward, backward); + }); +}); + +describe("computeEventOverlapLayout", () => { + test("fasst mehrere Tage in einer Zuordnung zusammen", () => { + const layout = computeEventOverlapLayout( + new Map([ + ["2026-09-10", [segment("a", 9, 11), segment("b", 10, 12)]], + ["2026-09-11", [segment("c", 9, 10)]], + ]), + ); + assert.equal(layout.a.totalColumns, 2); + assert.equal(layout.c.totalColumns, 1); + }); +});