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",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -108,6 +108,7 @@ import {
|
|||||||
stripHtml, DEFAULT_SOMEDAY_SLOT_COUNT, getSomedaySlotCount, invertColor,
|
stripHtml, DEFAULT_SOMEDAY_SLOT_COUNT, getSomedaySlotCount, invertColor,
|
||||||
} from "@/lib/weekly/format";
|
} from "@/lib/weekly/format";
|
||||||
import { isAllDayEvent } from "@/lib/weekly/date-utils";
|
import { isAllDayEvent } from "@/lib/weekly/date-utils";
|
||||||
|
import { computeEventOverlapLayout } from "@/lib/weekly/event-layout";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import UserMenu from "./UserMenu";
|
import UserMenu from "./UserMenu";
|
||||||
@ -2655,53 +2656,11 @@ export default function WeeklyView() {
|
|||||||
[getEventsForDate, effectiveCellDuration],
|
[getEventsForDate, effectiveCellDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Precompute overlap layout per day: { [segmentKey]: { column, totalColumns } }
|
// Spaltenaufteilung ueberlappender Termine je Tag (siehe lib/weekly/event-layout).
|
||||||
const eventOverlapLayout = useMemo(() => {
|
const eventOverlapLayout = useMemo(
|
||||||
const layout: Record<string, { column: number; totalColumns: number }> = {};
|
() => computeEventOverlapLayout(eventSegmentsByDay),
|
||||||
for (const daySegments of eventSegmentsByDay.values()) {
|
[eventSegmentsByDay],
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Calculate event duration in pixels for proper height display
|
// Calculate event duration in pixels for proper height display
|
||||||
const getEventDuration = (event: CalendarEvent): number => {
|
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