From d52e78096752fb529dc96ca23d73d8de1110c167 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 12 Sep 2026 09:25:12 +0200 Subject: [PATCH] feat: Termine ueber Mitternacht am Tageswechsel teilen 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 --- package.json | 2 +- src/app/globals.css | 12 ++ src/components/WeeklyView.tsx | 234 +++++++++++++++------------------- src/lib/weekly/date-utils.ts | 124 +++++++++++++++++- src/lib/weekly/types.ts | 23 ++++ tests/unit/date-utils.test.ts | 121 +++++++++++++++++- 6 files changed, 382 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index 8dd6a32..0097a5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.120.1", + "version": "1.121.0", "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/app/globals.css b/src/app/globals.css index 39efa88..819dbe7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3111,6 +3111,18 @@ h3 { box-sizing: border-box; } +/* Termine ueber Mitternacht: die Schnittkante bleibt offen, damit sichtbar ist, + dass der Termin am Nachbartag weiterlaeuft. */ +.time-slot-event.event-continues { + border-bottom-right-radius: 0; + border-bottom: 2px dashed currentColor; +} + +.time-slot-event.event-continued { + border-top-right-radius: 0; + border-top: 2px dashed currentColor; +} + .time-slot-event:has(.event-icons-row) .event-title-row { padding-right: 34px; } diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index f1373d2..f1852fe 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -127,7 +127,7 @@ import { getPriorityBadge } from "./weekly/priority-badge"; export { getPriorityBadge }; import type { RecurringTaskException, ViewStyle, KanbanStage, Task, - CalendarEvent, SomedayList, CellDuration, + CalendarEvent, CalendarEventSegment, SomedayList, CellDuration, } from "@/lib/weekly/types"; // Re-Export: SettingsSidebar und GridTaskBlock importieren diese Typen historisch von hier export type { RecurringTaskException, ViewStyle, KanbanStage, Task, CalendarEvent, SomedayList, CellDuration }; @@ -179,6 +179,7 @@ import { getStartOfWeek, formatDateHeader, getDayName, isSameDay, formatDateToISO, parseCalendarDate, formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, getCWReferenceDate, formatCustomHeader, getSelectedDay, + groupEventSegmentsByDay, localDayKey, segmentStartMinutes, segmentEndMinutes, } from "@/lib/weekly/date-utils"; @@ -2618,123 +2619,89 @@ export default function WeeklyView() { [tasks, effectiveCellDuration, effectiveShowCompletedTasks, activeProjectFilter], ); - // Get calendar events for a specific date + // Termine ueber Mitternacht werden je Kalendertag in Abschnitte geteilt: + // 22:00-24:00 am Starttag, 00:00-02:00 am Folgetag (siehe splitEventByDay). + // Waehrend eines Drags zaehlen die Zeiten aus dem Drag-Zustand, damit sich + // die Abschnitte live mitbewegen. + const eventSegmentsByDay = useMemo(() => { + const dragged = eventDragState?.hasMoved ? eventDragState : null; + const events = dragged + ? calendarEvents.map((event) => + event.id === dragged.eventId + ? { ...event, startTime: dragged.currentStartTime, endTime: dragged.currentEndTime } + : event, + ) + : calendarEvents; + return groupEventSegmentsByDay(events); + }, [calendarEvents, eventDragState]); + + // Get calendar event segments for a specific date (all-day events handled separately) const getEventsForDate = useCallback( - (date: Date): CalendarEvent[] => { - return calendarEvents.filter((event) => { - // Skip all-day events (handled separately) - if (isAllDayEvent(event)) return false; - - const eventDate = new Date(event.startTime); - return isSameDay(eventDate, date); - }); - }, - [calendarEvents], + (date: Date): CalendarEventSegment[] => eventSegmentsByDay.get(localDayKey(date)) ?? [], + [eventSegmentsByDay], ); - // Get calendar events for a specific time slot + // Get calendar event segments starting within a specific time slot const getEventsForSlot = useCallback( - (date: Date, slot: string): CalendarEvent[] => { - return calendarEvents.filter((event) => { - // Skip all-day events (handled separately) - const isAllDay = isAllDayEvent(event); - if (isAllDay) return false; - - // During drag, use the drag state's current start time for slot assignment - const isDraggedEvent = eventDragState?.eventId === event.id && eventDragState?.hasMoved && eventDragState?.mode === 'move'; - const startTime = isDraggedEvent ? eventDragState!.currentStartTime : event.startTime; - - const eventDate = new Date(startTime); - if (!isSameDay(eventDate, date)) return false; - - // Extract hour:minute from event start time and compare with slot - const eventHour = eventDate.getHours(); - const eventMinute = eventDate.getMinutes(); - - // Match if event starts within this slot - const [slotHour, slotMinute] = slot.split(":").map(Number); - const slotStart = slotHour * 60 + slotMinute; - const slotEnd = slotStart + effectiveCellDuration; - const eventStart = eventHour * 60 + eventMinute; - - return eventStart >= slotStart && eventStart < slotEnd; + (date: Date, slot: string): CalendarEventSegment[] => { + const [slotHour, slotMinute] = slot.split(":").map(Number); + const slotStart = slotHour * 60 + slotMinute; + const slotEnd = slotStart + effectiveCellDuration; + return getEventsForDate(date).filter((segment) => { + const start = segmentStartMinutes(segment); + return start >= slotStart && start < slotEnd; }); }, - [calendarEvents, effectiveCellDuration, eventDragState], + [getEventsForDate, effectiveCellDuration], ); - // Precompute overlap layout: { [eventId]: { column, totalColumns } } + // Precompute overlap layout per day: { [segmentKey]: { column, totalColumns } } const eventOverlapLayout = useMemo(() => { const layout: Record = {}; - // Group events by day - const dayMap = new Map(); - for (const event of calendarEvents) { - if (isAllDayEvent(event)) continue; - const d = new Date(event.startTime); - const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; - if (!dayMap.has(key)) dayMap.set(key, []); - dayMap.get(key)!.push(event); - } - for (const events of dayMap.values()) { + for (const daySegments of eventSegmentsByDay.values()) { // Sort by start time, then by duration (longer first) - events.sort((a, b) => { + 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()); }); - // Build overlap groups using a greedy column assignment - const columns: { end: number; eventId: string }[][] = []; - for (const event of events) { - const start = new Date(event.startTime).getTime(); - const end = new Date(event.endTime).getTime(); - // Find first column where this event doesn't overlap - let placed = false; - for (let col = 0; col < columns.length; col++) { - const lastInCol = columns[col][columns[col].length - 1]; - if (lastInCol.end <= start) { - columns[col].push({ end, eventId: event.id }); - placed = true; - break; - } - } - if (!placed) { - columns.push([{ end, eventId: event.id }]); + // 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]); } } - // Now find the max columns each event actually shares with - // For each event, find all events that overlap it and determine the group width - for (const event of events) { - const start = new Date(event.startTime).getTime(); - const end = new Date(event.endTime).getTime(); - // Count how many columns have events overlapping this time range + // 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; - for (const col of columns) { - for (const item of col) { - const itemStart = calendarEvents.find(e => e.id === item.eventId); - if (itemStart) { - const iStart = new Date(itemStart.startTime).getTime(); - const iEnd = new Date(itemStart.endTime).getTime(); - if (iStart < end && iEnd > start) { - overlappingCols++; - break; - } - } - } - } - // Find which column this event is in - let eventCol = 0; - for (let col = 0; col < columns.length; col++) { - if (columns[col].some(item => item.eventId === event.id)) { - eventCol = col; - break; - } - } - layout[event.id] = { column: eventCol, totalColumns: Math.max(overlappingCols, 1) }; + 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; - }, [calendarEvents]); + }, [eventSegmentsByDay]); // Calculate event duration in pixels for proper height display const getEventDuration = (event: CalendarEvent): number => { @@ -3368,25 +3335,19 @@ export default function WeeklyView() { const [slotHour, slotMinute] = slot.split(":").map(Number); const slotStart = slotHour * 60 + slotMinute; - return calendarEvents.some((event) => { - if (isAllDayEvent(event)) return false; + return getEventsForDate(date).some((segment) => { // Skip events that have been unlocked by the user - if (unlockedEvents.has(event.id)) return false; + if (unlockedEvents.has(segment.id)) return false; - const eventDate = new Date(event.startTime); - if (!isSameDay(eventDate, date)) return false; - - const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes(); - const eventEndDate = new Date(event.endTime); - const eventEndMinutes = - eventEndDate.getHours() * 60 + eventEndDate.getMinutes(); - - // Only protect if the slot start time falls within the event's actual duration - // This ensures protection matches exactly what the event covers - return slotStart >= eventStart && slotStart < eventEndMinutes; + // Only protect if the slot start time falls within the segment's + // duration on this day; a segment ending at midnight covers up to 24:00. + return ( + slotStart >= segmentStartMinutes(segment) && + slotStart < segmentEndMinutes(segment) + ); }); }, - [protectEventTimes, calendarEvents, unlockedEvents], + [protectEventTimes, getEventsForDate, unlockedEvents], ); // Check if a slot is occupied by any task (to prevent stacking) @@ -7208,7 +7169,6 @@ export default function WeeklyView() { {/* Protection overlays - render at exact event positions */} {protectEventTimes && getEventsForDate(date) - .filter((e) => !isAllDayEvent(e)) .map((event) => { const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); @@ -7243,7 +7203,7 @@ export default function WeeklyView() { return (
{ - // During drag, use the drag state's current times for position/height + // event ist ein Tagesabschnitt: bei Terminen ueber Mitternacht sind + // startTime/endTime auf diesen Tag beschnitten. Die Drag-Zeiten sind + // bereits in eventSegmentsByDay eingerechnet. const isDragging = eventDragState?.eventId === event.id && eventDragState?.hasMoved; - const displayStartTime = isDragging ? new Date(eventDragState!.currentStartTime) : new Date(event.startTime); - const displayEndTime = isDragging ? new Date(eventDragState!.currentEndTime) : new Date(event.endTime); + const displayStartTime = new Date(event.startTime); + const displayEndTime = new Date(event.endTime); const durationMs = displayEndTime.getTime() - displayStartTime.getTime(); const durationMinutes = durationMs / (1000 * 60); const pxPerMin = getSlotHeight(effectiveCellDuration) / effectiveCellDuration; const eventHeight = Math.max(durationMinutes * pxPerMin, 15); - const timeStr = `${displayStartTime.getHours().toString().padStart(2, "0")}:${displayStartTime.getMinutes().toString().padStart(2, "0")} - ${displayEndTime.getHours().toString().padStart(2, "0")}:${displayEndTime.getMinutes().toString().padStart(2, "0")}`; + const pad = (value: number) => value.toString().padStart(2, "0"); + const startLabel = event.continuesFromPreviousDay + ? "00:00" + : `${pad(displayStartTime.getHours())}:${pad(displayStartTime.getMinutes())}`; + const endLabel = event.continuesOnNextDay + ? "24:00" + : `${pad(displayEndTime.getHours())}:${pad(displayEndTime.getMinutes())}`; + const timeStr = `${startLabel} - ${endLabel}`; + const fullStart = new Date(event.sourceEvent.startTime); + const fullEnd = new Date(event.sourceEvent.endTime); + const fullTimeStr = `${pad(fullStart.getHours())}:${pad(fullStart.getMinutes())} - ${pad(fullEnd.getHours())}:${pad(fullEnd.getMinutes())}`; // Calculate offset within the slot based on event start time const [slotHour, slotMinute] = slot @@ -7484,15 +7456,15 @@ export default function WeeklyView() { ? `color-mix(in srgb, ${eventColor} 30%, #0f172a)` : borderColor; - const overlap = eventOverlapLayout[event.id] || { column: 0, totalColumns: 1 }; + const overlap = eventOverlapLayout[event.segmentKey] || { column: 0, totalColumns: 1 }; const colWidth = 100 / overlap.totalColumns; const colLeft = overlap.column * colWidth; return (
1 ? fullTimeStr : timeStr}`} style={{ height: `${eventHeight}px`, minHeight: `15px`, @@ -7520,23 +7492,23 @@ export default function WeeklyView() { if (event.editable) { setCalendarEventModal({ isOpen: true, - event: event, + event: event.sourceEvent, }); } }} onMouseDown={(e) => { if (event.editable && e.button === 0) { - handleEventDragStart(e, event, 'move'); + handleEventDragStart(e, event.sourceEvent, 'move'); } }} > - {/* Top resize handle */} - {event.editable && ( + {/* Top resize handle - nur am tatsaechlichen Terminbeginn */} + {event.editable && !event.continuesFromPreviousDay && (
{ e.stopPropagation(); - handleEventDragStart(e, event, 'resize-top'); + handleEventDragStart(e, event.sourceEvent, 'resize-top'); }} /> )} @@ -7562,13 +7534,13 @@ export default function WeeklyView() { style={{ fontSize: '0.6rem' }} />
- {/* Bottom resize handle */} - {event.editable && ( + {/* Bottom resize handle - nur am tatsaechlichen Terminende */} + {event.editable && !event.continuesOnNextDay && (
{ e.stopPropagation(); - handleEventDragStart(e, event, 'resize-bottom'); + handleEventDragStart(e, event.sourceEvent, 'resize-bottom'); }} /> )} @@ -7651,14 +7623,14 @@ export default function WeeklyView() { return (
{ e.stopPropagation(); if (event.editable) { setCalendarEventModal({ isOpen: true, - event: event, + event: event.sourceEvent, }); } }} diff --git a/src/lib/weekly/date-utils.ts b/src/lib/weekly/date-utils.ts index 7733d9c..cf76806 100644 --- a/src/lib/weekly/date-utils.ts +++ b/src/lib/weekly/date-utils.ts @@ -1,4 +1,4 @@ -import type { CellDuration, CalendarEvent } from "@/lib/weekly/types"; +import type { CellDuration, CalendarEvent, CalendarEventSegment } from "@/lib/weekly/types"; /** * Datums-, Wochen- und Zeitraster-Helfer der Wochenansicht. @@ -220,3 +220,125 @@ export const isAllDayEvent = (event: CalendarEvent): boolean => { // If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day return durationHours >= 24 && (isLocalMidnight || isUTCMidnight); }; + +/** + * Aufteilung von Terminen an der Tagesgrenze. + * + * Ein Termin von 22:00 bis 02:00 gehoert in der Wochenansicht nicht als ein + * ueberlanger Block in den Starttag, sondern als zwei Abschnitte: 22:00-24:00 + * am Starttag und 00:00-02:00 am Folgetag. Die Regeln stehen hier als reine + * Funktionen, damit sie ohne Browser testbar bleiben. + */ + +const MS_PER_MINUTE = 60 * 1000; + +/** Lokale Mitternacht des Tages, in dem `date` liegt. */ +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); +} + +/** Lokale Mitternacht des Folgetages; ueber Kalenderarithmetik, damit DST passt. */ +function startOfNextLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0); +} + +/** Stabiler Tagesschluessel in lokaler Zeit (YYYY-MM-DD). */ +export function localDayKey(date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; +} + +function makeSegment( + event: CalendarEvent, + segmentStart: Date, + segmentEnd: Date, + index: number, + count: number, +): CalendarEventSegment { + return { + ...event, + startTime: segmentStart.toISOString(), + endTime: segmentEnd.toISOString(), + segmentKey: count > 1 ? `${event.id}#${localDayKey(segmentStart)}` : event.id, + segmentIndex: index, + segmentCount: count, + continuesFromPreviousDay: index > 0, + continuesOnNextDay: index < count - 1, + sourceEvent: event, + }; +} + +/** + * Zerlegt einen Termin in Abschnitte je Kalendertag. + * + * Ganztagstermine, ungueltige Zeiten und Termine ohne positive Dauer ergeben + * genau einen Abschnitt mit den Originalzeiten. Ein Termin, der punktgenau um + * Mitternacht endet, erzeugt keinen leeren Abschnitt am Folgetag. + */ +export function splitEventByDay(event: CalendarEvent): CalendarEventSegment[] { + const start = parseCalendarDate(event.startTime); + const end = event.endTime ? parseCalendarDate(event.endTime) : start; + + const unsplittable = + isAllDayEvent(event) || + Number.isNaN(start.getTime()) || + Number.isNaN(end.getTime()) || + end.getTime() <= start.getTime(); + + if (unsplittable) { + return [makeSegment(event, start, end, 0, 1)]; + } + + // Grenzen einsammeln: Start, jede dazwischenliegende Mitternacht, Ende. + const boundaries: Date[] = [start]; + let cursor = startOfNextLocalDay(start); + while (cursor.getTime() < end.getTime()) { + boundaries.push(cursor); + cursor = startOfNextLocalDay(cursor); + } + boundaries.push(end); + + const count = boundaries.length - 1; + const segments: CalendarEventSegment[] = []; + for (let i = 0; i < count; i++) { + segments.push(makeSegment(event, boundaries[i], boundaries[i + 1], i, count)); + } + return segments; +} + +/** Abschnitte aller Termine ohne Ganztagstermine, gruppiert nach lokalem Tag. */ +export function groupEventSegmentsByDay( + events: CalendarEvent[], +): Map { + const byDay = new Map(); + for (const event of events) { + if (isAllDayEvent(event)) continue; + for (const segment of splitEventByDay(event)) { + const key = localDayKey(new Date(segment.startTime)); + const bucket = byDay.get(key); + if (bucket) { + bucket.push(segment); + } else { + byDay.set(key, [segment]); + } + } + } + return byDay; +} + +/** Minuten seit lokaler Mitternacht des Abschnittstages. */ +export function segmentStartMinutes(segment: CalendarEventSegment): number { + const start = new Date(segment.startTime); + return start.getHours() * 60 + start.getMinutes(); +} + +/** + * Ende des Abschnitts in Minuten ab Mitternacht des Abschnittstages. + * Ein bis Mitternacht laufender Abschnitt liefert 1440 statt 0. + */ +export function segmentEndMinutes(segment: CalendarEventSegment): number { + const start = new Date(segment.startTime); + const end = new Date(segment.endTime); + return Math.round((end.getTime() - startOfLocalDay(start).getTime()) / MS_PER_MINUTE); +} diff --git a/src/lib/weekly/types.ts b/src/lib/weekly/types.ts index 4bad497..77d106f 100644 --- a/src/lib/weekly/types.ts +++ b/src/lib/weekly/types.ts @@ -95,3 +95,26 @@ export interface SomedayList { // Time grid configuration options export type CellDuration = 15 | 20 | 30 | 60; + +/** + * Ein auf genau einen Kalendertag zugeschnittener Abschnitt eines Termins. + * + * Termine ueber Mitternacht werden in der Wochenansicht je Tag dargestellt: + * 22:00-24:00 am Starttag, 00:00-02:00 am Folgetag. startTime/endTime sind die + * beschnittenen Zeiten des Abschnitts; sourceEvent traegt den unveraenderten + * Termin fuer Klick, Drag und Speichern. + */ +export interface CalendarEventSegment extends CalendarEvent { + /** Eindeutig pro Abschnitt; als React-Key und fuer das Overlap-Layout. */ + segmentKey: string; + /** 0-basierter Index dieses Abschnitts innerhalb des Termins. */ + segmentIndex: number; + /** Anzahl der Abschnitte des Termins insgesamt. */ + segmentCount: number; + /** Termin hat bereits an einem frueheren Tag begonnen. */ + continuesFromPreviousDay: boolean; + /** Termin laeuft am Folgetag weiter. */ + continuesOnNextDay: boolean; + /** Der ungeteilte Termin mit den Originalzeiten. */ + sourceEvent: CalendarEvent; +} diff --git a/tests/unit/date-utils.test.ts b/tests/unit/date-utils.test.ts index aeb4df3..8f901ca 100644 --- a/tests/unit/date-utils.test.ts +++ b/tests/unit/date-utils.test.ts @@ -3,7 +3,8 @@ import assert from "node:assert/strict"; import { getStartOfWeek, getDayName, isSameDay, formatDateToISO, parseCalendarDate, formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, getCWReferenceDate, - getSelectedDay, + getSelectedDay, splitEventByDay, groupEventSegmentsByDay, + segmentStartMinutes, segmentEndMinutes, } from "../../src/lib/weekly/date-utils.ts"; /** @@ -228,3 +229,121 @@ describe("getDayName", () => { 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); + }); +});