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:
parent
00010059a2
commit
85667b68c1
@ -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": {
|
||||
|
||||
@ -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 => {
|
||||
|
||||
79
src/lib/weekly/event-layout.ts
Normal file
79
src/lib/weekly/event-layout.ts
Normal 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;
|
||||
}
|
||||
74
tests/unit/event-layout.test.ts
Normal file
74
tests/unit/event-layout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user