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 <noreply@anthropic.com>
This commit is contained in:
parent
dcbcb77dba
commit
d52e780967
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"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": {
|
||||||
|
|||||||
@ -3111,6 +3111,18 @@ h3 {
|
|||||||
box-sizing: border-box;
|
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 {
|
.time-slot-event:has(.event-icons-row) .event-title-row {
|
||||||
padding-right: 34px;
|
padding-right: 34px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -127,7 +127,7 @@ import { getPriorityBadge } from "./weekly/priority-badge";
|
|||||||
export { getPriorityBadge };
|
export { getPriorityBadge };
|
||||||
import type {
|
import type {
|
||||||
RecurringTaskException, ViewStyle, KanbanStage, Task,
|
RecurringTaskException, ViewStyle, KanbanStage, Task,
|
||||||
CalendarEvent, SomedayList, CellDuration,
|
CalendarEvent, CalendarEventSegment, SomedayList, CellDuration,
|
||||||
} from "@/lib/weekly/types";
|
} from "@/lib/weekly/types";
|
||||||
// Re-Export: SettingsSidebar und GridTaskBlock importieren diese Typen historisch von hier
|
// Re-Export: SettingsSidebar und GridTaskBlock importieren diese Typen historisch von hier
|
||||||
export type { RecurringTaskException, ViewStyle, KanbanStage, Task, CalendarEvent, SomedayList, CellDuration };
|
export type { RecurringTaskException, ViewStyle, KanbanStage, Task, CalendarEvent, SomedayList, CellDuration };
|
||||||
@ -179,6 +179,7 @@ import {
|
|||||||
getStartOfWeek, formatDateHeader, getDayName, isSameDay, formatDateToISO,
|
getStartOfWeek, formatDateHeader, getDayName, isSameDay, formatDateToISO,
|
||||||
parseCalendarDate, formatHour, getTimeSlots, getHourFromSlot, getWeekNumber,
|
parseCalendarDate, formatHour, getTimeSlots, getHourFromSlot, getWeekNumber,
|
||||||
getCWReferenceDate, formatCustomHeader, getSelectedDay,
|
getCWReferenceDate, formatCustomHeader, getSelectedDay,
|
||||||
|
groupEventSegmentsByDay, localDayKey, segmentStartMinutes, segmentEndMinutes,
|
||||||
} from "@/lib/weekly/date-utils";
|
} from "@/lib/weekly/date-utils";
|
||||||
|
|
||||||
|
|
||||||
@ -2618,123 +2619,89 @@ export default function WeeklyView() {
|
|||||||
[tasks, effectiveCellDuration, effectiveShowCompletedTasks, activeProjectFilter],
|
[tasks, effectiveCellDuration, effectiveShowCompletedTasks, activeProjectFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get calendar events for a specific date
|
// Termine ueber Mitternacht werden je Kalendertag in Abschnitte geteilt:
|
||||||
const getEventsForDate = useCallback(
|
// 22:00-24:00 am Starttag, 00:00-02:00 am Folgetag (siehe splitEventByDay).
|
||||||
(date: Date): CalendarEvent[] => {
|
// Waehrend eines Drags zaehlen die Zeiten aus dem Drag-Zustand, damit sich
|
||||||
return calendarEvents.filter((event) => {
|
// die Abschnitte live mitbewegen.
|
||||||
// Skip all-day events (handled separately)
|
const eventSegmentsByDay = useMemo(() => {
|
||||||
if (isAllDayEvent(event)) return false;
|
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]);
|
||||||
|
|
||||||
const eventDate = new Date(event.startTime);
|
// Get calendar event segments for a specific date (all-day events handled separately)
|
||||||
return isSameDay(eventDate, date);
|
const getEventsForDate = useCallback(
|
||||||
});
|
(date: Date): CalendarEventSegment[] => eventSegmentsByDay.get(localDayKey(date)) ?? [],
|
||||||
},
|
[eventSegmentsByDay],
|
||||||
[calendarEvents],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get calendar events for a specific time slot
|
// Get calendar event segments starting within a specific time slot
|
||||||
const getEventsForSlot = useCallback(
|
const getEventsForSlot = useCallback(
|
||||||
(date: Date, slot: string): CalendarEvent[] => {
|
(date: Date, slot: string): CalendarEventSegment[] => {
|
||||||
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 [slotHour, slotMinute] = slot.split(":").map(Number);
|
||||||
const slotStart = slotHour * 60 + slotMinute;
|
const slotStart = slotHour * 60 + slotMinute;
|
||||||
const slotEnd = slotStart + effectiveCellDuration;
|
const slotEnd = slotStart + effectiveCellDuration;
|
||||||
const eventStart = eventHour * 60 + eventMinute;
|
return getEventsForDate(date).filter((segment) => {
|
||||||
|
const start = segmentStartMinutes(segment);
|
||||||
return eventStart >= slotStart && eventStart < slotEnd;
|
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 eventOverlapLayout = useMemo(() => {
|
||||||
const layout: Record<string, { column: number; totalColumns: number }> = {};
|
const layout: Record<string, { column: number; totalColumns: number }> = {};
|
||||||
// Group events by day
|
for (const daySegments of eventSegmentsByDay.values()) {
|
||||||
const dayMap = new Map<string, CalendarEvent[]>();
|
|
||||||
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()) {
|
|
||||||
// Sort by start time, then by duration (longer first)
|
// 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();
|
const diff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
return (new Date(b.endTime).getTime() - new Date(b.startTime).getTime()) -
|
return (new Date(b.endTime).getTime() - new Date(b.startTime).getTime()) -
|
||||||
(new Date(a.endTime).getTime() - new Date(a.startTime).getTime());
|
(new Date(a.endTime).getTime() - new Date(a.startTime).getTime());
|
||||||
});
|
});
|
||||||
// Build overlap groups using a greedy column assignment
|
// Greedy column assignment: first column whose last entry ended before this start
|
||||||
const columns: { end: number; eventId: string }[][] = [];
|
const columns: CalendarEventSegment[][] = [];
|
||||||
for (const event of events) {
|
for (const segment of segments) {
|
||||||
const start = new Date(event.startTime).getTime();
|
const start = new Date(segment.startTime).getTime();
|
||||||
const end = new Date(event.endTime).getTime();
|
const free = columns.find(
|
||||||
// Find first column where this event doesn't overlap
|
(col) => new Date(col[col.length - 1].endTime).getTime() <= start,
|
||||||
let placed = false;
|
);
|
||||||
for (let col = 0; col < columns.length; col++) {
|
if (free) {
|
||||||
const lastInCol = columns[col][columns[col].length - 1];
|
free.push(segment);
|
||||||
if (lastInCol.end <= start) {
|
} else {
|
||||||
columns[col].push({ end, eventId: event.id });
|
columns.push([segment]);
|
||||||
placed = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!placed) {
|
// Group width = number of columns that actually overlap this segment
|
||||||
columns.push([{ end, eventId: event.id }]);
|
for (const segment of segments) {
|
||||||
}
|
const start = new Date(segment.startTime).getTime();
|
||||||
}
|
const end = new Date(segment.endTime).getTime();
|
||||||
// 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
|
|
||||||
let overlappingCols = 0;
|
let overlappingCols = 0;
|
||||||
for (const col of columns) {
|
let segmentCol = 0;
|
||||||
for (const item of col) {
|
columns.forEach((col, index) => {
|
||||||
const itemStart = calendarEvents.find(e => e.id === item.eventId);
|
const overlaps = col.some(
|
||||||
if (itemStart) {
|
(other) =>
|
||||||
const iStart = new Date(itemStart.startTime).getTime();
|
new Date(other.startTime).getTime() < end &&
|
||||||
const iEnd = new Date(itemStart.endTime).getTime();
|
new Date(other.endTime).getTime() > start,
|
||||||
if (iStart < end && iEnd > start) {
|
);
|
||||||
overlappingCols++;
|
if (overlaps) overlappingCols++;
|
||||||
break;
|
if (col.includes(segment)) segmentCol = index;
|
||||||
}
|
});
|
||||||
}
|
layout[segment.segmentKey] = {
|
||||||
}
|
column: segmentCol,
|
||||||
}
|
totalColumns: Math.max(overlappingCols, 1),
|
||||||
// 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) };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return layout;
|
return layout;
|
||||||
}, [calendarEvents]);
|
}, [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 => {
|
||||||
@ -3368,25 +3335,19 @@ export default function WeeklyView() {
|
|||||||
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
||||||
const slotStart = slotHour * 60 + slotMinute;
|
const slotStart = slotHour * 60 + slotMinute;
|
||||||
|
|
||||||
return calendarEvents.some((event) => {
|
return getEventsForDate(date).some((segment) => {
|
||||||
if (isAllDayEvent(event)) return false;
|
|
||||||
// Skip events that have been unlocked by the user
|
// 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);
|
// Only protect if the slot start time falls within the segment's
|
||||||
if (!isSameDay(eventDate, date)) return false;
|
// duration on this day; a segment ending at midnight covers up to 24:00.
|
||||||
|
return (
|
||||||
const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes();
|
slotStart >= segmentStartMinutes(segment) &&
|
||||||
const eventEndDate = new Date(event.endTime);
|
slotStart < segmentEndMinutes(segment)
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[protectEventTimes, calendarEvents, unlockedEvents],
|
[protectEventTimes, getEventsForDate, unlockedEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if a slot is occupied by any task (to prevent stacking)
|
// 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 */}
|
{/* Protection overlays - render at exact event positions */}
|
||||||
{protectEventTimes &&
|
{protectEventTimes &&
|
||||||
getEventsForDate(date)
|
getEventsForDate(date)
|
||||||
.filter((e) => !isAllDayEvent(e))
|
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const eventStart = new Date(event.startTime);
|
const eventStart = new Date(event.startTime);
|
||||||
const eventEnd = new Date(event.endTime);
|
const eventEnd = new Date(event.endTime);
|
||||||
@ -7243,7 +7203,7 @@ export default function WeeklyView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`protection-${event.id}`}
|
key={`protection-${event.segmentKey}`}
|
||||||
className="event-protection-overlay"
|
className="event-protection-overlay"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@ -7450,15 +7410,27 @@ export default function WeeklyView() {
|
|||||||
)}
|
)}
|
||||||
{/* Calendar Events in time slot */}
|
{/* Calendar Events in time slot */}
|
||||||
{slotEvents.map((event) => {
|
{slotEvents.map((event) => {
|
||||||
// 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 isDragging = eventDragState?.eventId === event.id && eventDragState?.hasMoved;
|
||||||
const displayStartTime = isDragging ? new Date(eventDragState!.currentStartTime) : new Date(event.startTime);
|
const displayStartTime = new Date(event.startTime);
|
||||||
const displayEndTime = isDragging ? new Date(eventDragState!.currentEndTime) : new Date(event.endTime);
|
const displayEndTime = new Date(event.endTime);
|
||||||
const durationMs = displayEndTime.getTime() - displayStartTime.getTime();
|
const durationMs = displayEndTime.getTime() - displayStartTime.getTime();
|
||||||
const durationMinutes = durationMs / (1000 * 60);
|
const durationMinutes = durationMs / (1000 * 60);
|
||||||
const pxPerMin = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
const pxPerMin = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||||||
const eventHeight = Math.max(durationMinutes * pxPerMin, 15);
|
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
|
// Calculate offset within the slot based on event start time
|
||||||
const [slotHour, slotMinute] = slot
|
const [slotHour, slotMinute] = slot
|
||||||
@ -7484,15 +7456,15 @@ export default function WeeklyView() {
|
|||||||
? `color-mix(in srgb, ${eventColor} 30%, #0f172a)`
|
? `color-mix(in srgb, ${eventColor} 30%, #0f172a)`
|
||||||
: borderColor;
|
: 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 colWidth = 100 / overlap.totalColumns;
|
||||||
const colLeft = overlap.column * colWidth;
|
const colLeft = overlap.column * colWidth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.segmentKey}
|
||||||
className={`time-slot-event${isDragging ? ' event-dragging' : ''}`}
|
className={`time-slot-event${isDragging ? ' event-dragging' : ''}${event.continuesFromPreviousDay ? ' event-continued' : ''}${event.continuesOnNextDay ? ' event-continues' : ''}`}
|
||||||
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
|
title={`${event.calendarTitle}: ${event.title}\n${event.segmentCount > 1 ? fullTimeStr : timeStr}`}
|
||||||
style={{
|
style={{
|
||||||
height: `${eventHeight}px`,
|
height: `${eventHeight}px`,
|
||||||
minHeight: `15px`,
|
minHeight: `15px`,
|
||||||
@ -7520,23 +7492,23 @@ export default function WeeklyView() {
|
|||||||
if (event.editable) {
|
if (event.editable) {
|
||||||
setCalendarEventModal({
|
setCalendarEventModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
event: event,
|
event: event.sourceEvent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (event.editable && e.button === 0) {
|
if (event.editable && e.button === 0) {
|
||||||
handleEventDragStart(e, event, 'move');
|
handleEventDragStart(e, event.sourceEvent, 'move');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top resize handle */}
|
{/* Top resize handle - nur am tatsaechlichen Terminbeginn */}
|
||||||
{event.editable && (
|
{event.editable && !event.continuesFromPreviousDay && (
|
||||||
<div
|
<div
|
||||||
className="event-resize-handle event-resize-handle-top"
|
className="event-resize-handle event-resize-handle-top"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation();
|
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' }}
|
style={{ fontSize: '0.6rem' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Bottom resize handle */}
|
{/* Bottom resize handle - nur am tatsaechlichen Terminende */}
|
||||||
{event.editable && (
|
{event.editable && !event.continuesOnNextDay && (
|
||||||
<div
|
<div
|
||||||
className="event-resize-handle event-resize-handle-bottom"
|
className="event-resize-handle event-resize-handle-bottom"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEventDragStart(e, event, 'resize-bottom');
|
handleEventDragStart(e, event.sourceEvent, 'resize-bottom');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -7651,14 +7623,14 @@ export default function WeeklyView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.segmentKey}
|
||||||
className="weekly-calendar-event"
|
className="weekly-calendar-event"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (event.editable) {
|
if (event.editable) {
|
||||||
setCalendarEventModal({
|
setCalendarEventModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
event: event,
|
event: event.sourceEvent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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.
|
* 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
|
// If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day
|
||||||
return durationHours >= 24 && (isLocalMidnight || isUTCMidnight);
|
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<string, CalendarEventSegment[]> {
|
||||||
|
const byDay = new Map<string, CalendarEventSegment[]>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -95,3 +95,26 @@ export interface SomedayList {
|
|||||||
|
|
||||||
// Time grid configuration options
|
// Time grid configuration options
|
||||||
export type CellDuration = 15 | 20 | 30 | 60;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import assert from "node:assert/strict";
|
|||||||
import {
|
import {
|
||||||
getStartOfWeek, getDayName, isSameDay, formatDateToISO, parseCalendarDate,
|
getStartOfWeek, getDayName, isSameDay, formatDateToISO, parseCalendarDate,
|
||||||
formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, getCWReferenceDate,
|
formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, getCWReferenceDate,
|
||||||
getSelectedDay,
|
getSelectedDay, splitEventByDay, groupEventSegmentsByDay,
|
||||||
|
segmentStartMinutes, segmentEndMinutes,
|
||||||
} from "../../src/lib/weekly/date-utils.ts";
|
} 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");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user