refactor: Spaltenaufteilung ueberlappender Termine aus WeeklyView herausloesen

Erster kleiner Schnitt aus Phase A des CodeScene-Auftrags: die Ueberlappungs-
Berechnung war ein rein funktionaler, aber verschachtelter Block mitten in der
Komponente. Sie liegt jetzt als layoutDaySegments/computeEventOverlapLayout in
src/lib/weekly/event-layout.ts und ist mit node:test abgedeckt (einzelner
Termin, Ueberschneidung, direkte Abfolge, drei gleichzeitige Termine,
Reihenfolgeunabhaengigkeit).

Kein sichtbares Verhalten geaendert; der Algorithmus ist unveraendert
uebernommen. WeeklyView.tsx: 9683 -> 9642 Zeilen.

v1.121.2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-12 09:30:29 +02:00
parent 00010059a2
commit 85667b68c1
4 changed files with 160 additions and 48 deletions

View File

@ -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": {

View File

@ -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<string, { column: number; totalColumns: number }> = {};
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,
// Spaltenaufteilung ueberlappender Termine je Tag (siehe lib/weekly/event-layout).
const eventOverlapLayout = useMemo(
() => computeEventOverlapLayout(eventSegmentsByDay),
[eventSegmentsByDay],
);
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]);
// Calculate event duration in pixels for proper height display
const getEventDuration = (event: CalendarEvent): number => {

View File

@ -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<string, EventColumnPlacement> {
const layout: Record<string, EventColumnPlacement> = {};
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<string, CalendarEventSegment[]>,
): Record<string, EventColumnPlacement> {
const layout: Record<string, EventColumnPlacement> = {};
for (const daySegments of segmentsByDay.values()) {
Object.assign(layout, layoutDaySegments(daySegments));
}
return layout;
}

View File

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