From 0c434e7e7f6b1669043a907303cf275cb4b96582 Mon Sep 17 00:00:00 2001 From: salirezav Date: Tue, 13 Jan 2026 14:41:48 -0500 Subject: [PATCH] Refactor HorizontalTimelineCalendar and Scheduling components for improved functionality - Updated HorizontalTimelineCalendar to support full 24-hour scheduling with enhanced marker positioning and dragging capabilities. - Introduced extendLeft and extendRight properties in RepetitionBorder for better visual representation of markers extending beyond their borders. - Enhanced tooltip functionality for vertical lines in the timeline, providing clearer time information. - Modified Scheduling component to allow scheduling from midnight to midnight, improving user experience in time selection. - Adjusted Docker script permissions for better execution control. --- .../components/HorizontalTimelineCalendar.tsx | 861 +++++++++--------- .../src/components/Scheduling.tsx | 5 +- scripts/docker-compose-reset.sh | 0 3 files changed, 411 insertions(+), 455 deletions(-) mode change 100644 => 100755 scripts/docker-compose-reset.sh diff --git a/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx b/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx index 6f2744f..1d11b49 100644 --- a/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx +++ b/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx @@ -36,22 +36,27 @@ interface HorizontalTimelineCalendarProps { } // Repetition border component with hover state and drag support -function RepetitionBorder({ - left, - width, +function RepetitionBorder({ + left, + width, top = 0, - isLocked, - allPhases, - times, - assignedCount, + height, + isLocked, + allPhases, + times, + assignedCount, repId, onMouseDown, isDragging = false, - dragOffset = { x: 0 } + dragOffset = { x: 0 }, + extendLeft = false, + extendRight = false, + children }: { left: number width: number top?: number + height: number isLocked: boolean allPhases: string times: string @@ -60,9 +65,23 @@ function RepetitionBorder({ onMouseDown?: (e: React.MouseEvent) => void isDragging?: boolean dragOffset?: { x: number } + extendLeft?: boolean + extendRight?: boolean + children?: React.ReactNode }) { const [isHovered, setIsHovered] = useState(false) + // Build border style based on extensions + + // Border radius: top-left top-right bottom-right bottom-left + const borderRadius = extendLeft && extendRight + ? '0px' // No radius if extending both sides + : extendLeft + ? '0 8px 8px 0' // No radius on left side (markers extend to past) + : extendRight + ? '8px 0 0 8px' // No radius on right side (markers extend to future) + : '8px' // Full radius (default) + return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onMouseDown={onMouseDown} title={`Repetition ${repId}: ${allPhases} at ${times}${assignedCount > 0 ? ` (${assignedCount} conductors assigned)` : ''}`} - /> + > + {children} +
) } @@ -98,15 +123,15 @@ export function HorizontalTimelineCalendar({ onMarkerDrag, onMarkerAssignConductors, onMarkerLockToggle, - timeStep = 15, // 15 minutes per time slot - minHour = 6, - maxHour = 22, + timeStep = 60, // 60 minutes (1 hour) per time slot for 24 divisions + minHour = 0, + maxHour = 24, dayWidth // Width per day in pixels (optional - if not provided, will be calculated) }: HorizontalTimelineCalendarProps) { const CONDUCTOR_NAME_COLUMN_WIDTH = 128 // Width of conductor name column (w-32 = 128px) const MIN_DAY_WIDTH = 150 // Minimum width per day column const DEFAULT_DAY_WIDTH = 200 // Default width per day column - + const [draggingMarker, setDraggingMarker] = useState(null) const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const [hoveredMarker, setHoveredMarker] = useState(null) @@ -114,6 +139,7 @@ export function HorizontalTimelineCalendar({ const [assignmentPanelPosition, setAssignmentPanelPosition] = useState<{ x: number; y: number } | null>(null) const [hoveredAvailability, setHoveredAvailability] = useState(null) // Format: "conductorId-availIndex" const [hoveredVerticalLine, setHoveredVerticalLine] = useState(null) // Marker ID + const [verticalLineTooltip, setVerticalLineTooltip] = useState<{ markerId: string; x: number; y: number; time: string } | null>(null) // Tooltip position and data const [draggingRepetition, setDraggingRepetition] = useState(null) // Repetition ID being dragged const [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 }) const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position @@ -141,12 +167,12 @@ export function HorizontalTimelineCalendar({ return phaseMarkers.filter(marker => { const markerDate = new Date(marker.startTime) markerDate.setHours(0, 0, 0, 0) // Compare by date only - + const start = new Date(startDate) start.setHours(0, 0, 0, 0) const end = new Date(endDate) end.setHours(0, 0, 0, 0) - + // Check if marker's date falls within the visible date range return markerDate >= start && markerDate <= end }) @@ -158,17 +184,17 @@ export function HorizontalTimelineCalendar({ if (containerWidth === 0 || days.length === 0) { return DEFAULT_DAY_WIDTH } - + // Available width = container width - conductor name column width const availableWidth = containerWidth - CONDUCTOR_NAME_COLUMN_WIDTH - + // Ensure we have positive available width if (availableWidth <= 0) { return DEFAULT_DAY_WIDTH } - + const calculatedWidth = availableWidth / days.length - + // Use calculated width if it's above minimum, otherwise use minimum return Math.max(calculatedWidth, MIN_DAY_WIDTH) }, [containerWidth, days.length]) @@ -199,12 +225,12 @@ export function HorizontalTimelineCalendar({ setTimeout(tryMeasure, 10) } } - + // Start measurement after a brief delay const timeoutId = setTimeout(tryMeasure, 0) - + window.addEventListener('resize', updateWidth) - + // Use ResizeObserver for more accurate tracking const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { @@ -217,7 +243,7 @@ export function HorizontalTimelineCalendar({ } } }) - + // Observe after a brief delay to ensure ref is attached const observeTimeout = setTimeout(() => { if (containerRef.current) { @@ -283,48 +309,46 @@ export function HorizontalTimelineCalendar({ } }, [selectedMarker, assignmentPanelPosition]) - // Generate time slots for a day + // Generate time slots for a day - 24 hours, one slot per hour const timeSlots = useMemo(() => { const slots: string[] = [] - for (let hour = minHour; hour < maxHour; hour++) { - for (let minute = 0; minute < 60; minute += timeStep) { - slots.push(`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`) - } + for (let hour = 0; hour < 24; hour++) { + slots.push(`${hour.toString().padStart(2, '0')}:00`) } return slots - }, [minHour, maxHour, timeStep]) + }, []) // Calculate pixel position for a given date/time const getTimePosition = useCallback((date: Date): number => { - const dayIndex = days.findIndex(d => + const dayIndex = days.findIndex(d => d.toDateString() === date.toDateString() ) if (dayIndex === -1) return 0 const dayStart = new Date(date) - dayStart.setHours(minHour, 0, 0, 0) - + dayStart.setHours(0, 0, 0, 0) // Start at midnight + const minutesFromStart = (date.getTime() - dayStart.getTime()) / (1000 * 60) - const slotIndex = minutesFromStart / timeStep // Use fractional for smoother positioning - - const slotWidth = effectiveDayWidth / (timeSlots.length) - + const slotIndex = minutesFromStart / 60 // 60 minutes per hour slot + + const slotWidth = effectiveDayWidth / 24 // 24 hours per day + return dayIndex * effectiveDayWidth + slotIndex * slotWidth }, [days, timeSlots, minHour, timeStep, effectiveDayWidth]) // Convert pixel position to date/time const getTimeFromPosition = useCallback((x: number, dayIndex: number): Date => { const dayStart = new Date(days[dayIndex]) - dayStart.setHours(minHour, 0, 0, 0) - + dayStart.setHours(0, 0, 0, 0) // Start at midnight + const relativeX = x - (dayIndex * effectiveDayWidth) - const slotWidth = effectiveDayWidth / timeSlots.length - const slotIndex = Math.max(0, Math.min(timeSlots.length - 1, Math.floor(relativeX / slotWidth))) - - const minutes = slotIndex * timeStep + const slotWidth = effectiveDayWidth / 24 // 24 hours per day + const slotIndex = Math.max(0, Math.min(23, Math.floor(relativeX / slotWidth))) + + const minutes = slotIndex * 60 // 60 minutes per hour const result = new Date(dayStart) result.setMinutes(result.getMinutes() + minutes) - + return result }, [days, timeSlots, minHour, timeStep, effectiveDayWidth]) @@ -340,22 +364,22 @@ export function HorizontalTimelineCalendar({ if (markers.some(m => m.locked)) return if (!timelineRef.current) return - + const scrollLeft = timelineRef.current.scrollLeft const timelineRect = timelineRef.current.getBoundingClientRect() - + // Get the leftmost marker position to use as reference - const leftmostMarker = markers.reduce((prev, curr) => + const leftmostMarker = markers.reduce((prev, curr) => new Date(prev.startTime).getTime() < new Date(curr.startTime).getTime() ? prev : curr ) const leftmostX = getTimePosition(leftmostMarker.startTime) const borderPadding = 20 const borderLeft = leftmostX - borderPadding - + // Calculate offset from mouse to border left edge in timeline coordinates const mouseXInTimeline = e.clientX - timelineRect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft const offsetX = mouseXInTimeline - borderLeft - + setDraggingRepetition(marker.repetitionId) setRepetitionDragOffset({ x: offsetX @@ -367,21 +391,21 @@ export function HorizontalTimelineCalendar({ const handleRepetitionMouseDown = useCallback((e: React.MouseEvent, repetitionId: string) => { e.preventDefault() e.stopPropagation() - + const markers = phaseMarkers.filter(m => m.repetitionId === repetitionId) if (markers.length === 0 || markers.some(m => m.locked)) return if (!timelineRef.current) return - + const borderElement = e.currentTarget as HTMLElement const borderLeft = parseFloat(borderElement.style.left) || 0 const scrollLeft = timelineRef.current.scrollLeft const timelineRect = timelineRef.current.getBoundingClientRect() - + // Calculate offset from mouse to left edge of border in timeline coordinates const mouseXInTimeline = e.clientX - timelineRect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft const offsetX = mouseXInTimeline - borderLeft - + setDraggingRepetition(repetitionId) setRepetitionDragOffset({ x: offsetX @@ -400,11 +424,11 @@ export function HorizontalTimelineCalendar({ const scrollContainer = timelineRef.current const rect = scrollContainer.getBoundingClientRect() const scrollLeft = scrollContainer.scrollLeft - + // Drag entire repetition horizontally only - only update visual position const mouseXInTimeline = e.clientX - rect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft const borderX = mouseXInTimeline - repetitionDragOffset.x - + // Update visual position during drag (don't call onMarkerDrag here to avoid conflicts) setDragPosition({ x: borderX }) } @@ -417,32 +441,32 @@ export function HorizontalTimelineCalendar({ const scrollLeft = scrollContainer.scrollLeft const mouseXInTimeline = e.clientX - rect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft const borderX = mouseXInTimeline - repetitionDragOffset.x - + const markers = visibleMarkers.filter(m => m.repetitionId === draggingRepetition) if (markers.length > 0) { - const leftmostMarker = markers.reduce((prev, curr) => + const leftmostMarker = markers.reduce((prev, curr) => new Date(prev.startTime).getTime() < new Date(curr.startTime).getTime() ? prev : curr ) - + const borderPadding = 20 const leftmostMarkerNewX = borderX + borderPadding const dayIndex = Math.max(0, Math.min(days.length - 1, Math.floor(leftmostMarkerNewX / effectiveDayWidth))) const relativeX = leftmostMarkerNewX - (dayIndex * effectiveDayWidth) - const slotWidth = effectiveDayWidth / timeSlots.length - const slotIndex = Math.max(0, Math.min(timeSlots.length - 1, Math.round(relativeX / slotWidth))) - + const slotWidth = effectiveDayWidth / 24 // 24 hours per day + const slotIndex = Math.max(0, Math.min(23, Math.round(relativeX / slotWidth))) + const dayStart = new Date(days[dayIndex]) - dayStart.setHours(minHour, 0, 0, 0) - const minutes = slotIndex * timeStep + dayStart.setHours(0, 0, 0, 0) // Start at midnight + const minutes = slotIndex * 60 // 60 minutes per hour const finalTime = new Date(dayStart) finalTime.setMinutes(finalTime.getMinutes() + minutes) - + // Update only once on mouse up console.log('Updating marker position:', leftmostMarker.id, finalTime) onMarkerDrag(leftmostMarker.id, finalTime) } } - + // Clear drag state setDraggingRepetition(null) setRepetitionDragOffset({ x: 0 }) @@ -491,125 +515,23 @@ export function HorizontalTimelineCalendar({ return (
- {/* Vertical lines layer - positioned outside overflow containers */} -
- {(() => { - // Group markers by repetition to calculate row positions - const markersByRepetition: Record = {} - phaseMarkers.forEach(marker => { - if (!markersByRepetition[marker.repetitionId]) { - markersByRepetition[marker.repetitionId] = [] - } - markersByRepetition[marker.repetitionId].push(marker) - }) - - const borderPadding = 20 - const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => { - const positions = markers.map(m => { - const dayIndex = days.findIndex(d => - d.toDateString() === new Date(m.startTime).toDateString() - ) - if (dayIndex === -1) return null - return getTimePosition(m.startTime) - }).filter((p): p is number => p !== null) - - if (positions.length === 0) return null - - const leftmost = Math.min(...positions) - const rightmost = Math.max(...positions) - const borderLeft = leftmost - borderPadding - const borderRight = borderLeft + (rightmost - leftmost) + (borderPadding * 2) - - return { repId, markers, left: borderLeft, right: borderRight } - }).filter((d): d is NonNullable => d !== null) - - const ROW_HEIGHT = 40 - const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left) - const repetitionRows: Array> = [] - - sortedRepetitions.forEach(rep => { - let placed = false - for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) { - const row = repetitionRows[rowIndex] - const hasOverlap = row.some(existingRep => { - const threshold = 1 - return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right) - }) - - if (!hasOverlap) { - row.push(rep) - placed = true - break - } - } - - if (!placed) { - repetitionRows.push([rep]) - } - }) - - const repIdToRowIndex: Record = {} - repetitionRows.forEach((row, rowIndex) => { - row.forEach(rep => { - repIdToRowIndex[rep.repId] = rowIndex - }) - }) - - return visibleMarkers.map((marker) => { - const style = getPhaseStyle(marker.phase) - const dayIndex = days.findIndex(d => - d.toDateString() === new Date(marker.startTime).toDateString() - ) - if (dayIndex === -1) return null - - const absoluteX = getTimePosition(marker.startTime) - const isDragging = draggingRepetition === marker.repetitionId - const isVerticalLineHovered = hoveredVerticalLine === marker.id - const rowIndex = repIdToRowIndex[marker.repetitionId] || 0 - - const HEADER_ROW_HEIGHT = 60 - const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36 - const MARKER_TOP_OFFSET = 10 - const MARKER_ICON_SIZE = 32 - const markerCenterY = MARKER_TOP_OFFSET + (MARKER_ICON_SIZE / 2) - const markerRowTop = HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) - const markerCenterAbsoluteY = markerRowTop + markerCenterY - const lineTop = HEADER_ROW_HEIGHT - const lineHeight = markerCenterAbsoluteY - HEADER_ROW_HEIGHT - - // Calculate line position - if dragging, adjust based on drag position - let lineX = absoluteX - if (isDragging && dragPosition) { - const repData = repetitionData.find(r => r.repId === marker.repetitionId) - if (repData) { - const offsetFromLeftmost = absoluteX - (repData.left + borderPadding) - lineX = dragPosition.x + borderPadding + offsetFromLeftmost - } - } - - return ( -
setHoveredVerticalLine(marker.id)} - onMouseLeave={() => setHoveredVerticalLine(null)} - title={moment(marker.startTime).format('h:mm A')} - /> - ) - }) - })()} -
+ {/* Tooltip for vertical lines */} + {verticalLineTooltip && ( +
+ {verticalLineTooltip.time} + {/* Tooltip arrow */} +
+
+ )} {/* Row 1: Day Headers */}
@@ -674,7 +596,7 @@ export function HorizontalTimelineCalendar({ }} /> ))} - + {/* Render availability lines across all days */} {conductor.availability.map((avail, availIndex) => { // Get absolute positions from start of timeline @@ -730,7 +652,7 @@ export function HorizontalTimelineCalendar({ {/* Row 3: Phase Markers - with multiple sub-rows for stacking */}
{/* Fixed spacer to align with conductor names column */} -
{/* Scrollable background time grid */} -
+
{/* Fixed width based only on visible days - never extends */}
{days.map((day, dayIndex) => ( @@ -749,128 +671,369 @@ export function HorizontalTimelineCalendar({ left: `${dayIndex * effectiveDayWidth}px`, width: `${effectiveDayWidth}px`, height: '100%', - backgroundImage: `repeating-linear-gradient(to right, transparent, transparent ${(effectiveDayWidth / timeSlots.length) - 1}px, #e5e7eb ${(effectiveDayWidth / timeSlots.length) - 1}px, #e5e7eb ${effectiveDayWidth / timeSlots.length}px)` + backgroundImage: `repeating-linear-gradient(to right, transparent, transparent ${(effectiveDayWidth / 24) - 1}px, #e5e7eb ${(effectiveDayWidth / 24) - 1}px, #e5e7eb ${effectiveDayWidth / 24}px)` }} /> ))} {/* Group markers by repetition ID and calculate vertical stacking */} {(() => { - // Group only visible markers by repetition ID - const markersByRepetition: Record = {} - visibleMarkers.forEach(marker => { - if (!markersByRepetition[marker.repetitionId]) { - markersByRepetition[marker.repetitionId] = [] + // Group ALL markers (not just visible) by repetition ID to check boundaries + const allMarkersByRepetition: Record = {} + phaseMarkers.forEach(marker => { + if (!allMarkersByRepetition[marker.repetitionId]) { + allMarkersByRepetition[marker.repetitionId] = [] } - markersByRepetition[marker.repetitionId].push(marker) + allMarkersByRepetition[marker.repetitionId].push(marker) + }) + + // Group visible markers by repetition ID for rendering + const visibleMarkersByRepetition: Record = {} + visibleMarkers.forEach(marker => { + if (!visibleMarkersByRepetition[marker.repetitionId]) { + visibleMarkersByRepetition[marker.repetitionId] = [] + } + visibleMarkersByRepetition[marker.repetitionId].push(marker) }) - // Calculate positions for each repetition const borderPadding = 20 - const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => { - const positions = markers.map(m => { - const dayIndex = days.findIndex(d => + const MARKER_ICON_SIZE = 32 + const MARKER_TOP_OFFSET = 10 + const MARKER_HEIGHT = MARKER_ICON_SIZE + (MARKER_TOP_OFFSET * 2) // Total height for a marker row + const ROW_HEIGHT = 40 // Minimum row height + + // Calculate positions for each repetition using ALL markers + const repetitionData = Object.entries(visibleMarkersByRepetition).map(([repId, visibleMarkers]) => { + const allMarkers = allMarkersByRepetition[repId] || [] + + // Get positions of visible markers + const visiblePositions = visibleMarkers.map(m => { + const dayIndex = days.findIndex(d => d.toDateString() === new Date(m.startTime).toDateString() ) if (dayIndex === -1) return null return getTimePosition(m.startTime) }).filter((p): p is number => p !== null) - if (positions.length === 0) return null + if (visiblePositions.length === 0) return null - const leftmost = Math.min(...positions) - const rightmost = Math.max(...positions) - const borderLeft = leftmost - borderPadding - const borderWidth = (rightmost - leftmost) + (borderPadding * 2) - const borderRight = borderLeft + borderWidth + // Get positions of ALL markers (including those outside viewport) + const allPositions = allMarkers.map(m => { + // Check if marker is in viewport date range + const markerDate = new Date(m.startTime) + markerDate.setHours(0, 0, 0, 0) + const start = new Date(startDate) + start.setHours(0, 0, 0, 0) + const end = new Date(endDate) + end.setHours(0, 0, 0, 0) + + if (markerDate >= start && markerDate <= end) { + const dayIndex = days.findIndex(d => + d.toDateString() === markerDate.toDateString() + ) + if (dayIndex === -1) return null + return getTimePosition(m.startTime) + } + // Marker is outside viewport - calculate if it's before or after + if (markerDate < start) { + return -Infinity // Before viewport + } + return Infinity // After viewport + }) + + const visibleLeftmost = Math.min(...visiblePositions) + const visibleRightmost = Math.max(...visiblePositions) + + // Check if markers extend beyond viewport + const hasMarkersBefore = allPositions.some(p => p === -Infinity) + const hasMarkersAfter = allPositions.some(p => p === Infinity) + + // Calculate border width + let borderLeft = visibleLeftmost - borderPadding + let borderWidth = (visibleRightmost - visibleLeftmost) + (borderPadding * 2) + + // If markers extend beyond, extend border to viewport edge + const viewportLeft = 0 + const viewportRight = days.length * effectiveDayWidth + + if (hasMarkersBefore) { + borderLeft = viewportLeft + borderWidth = (visibleRightmost - viewportLeft) + borderPadding + } + if (hasMarkersAfter) { + borderWidth = (viewportRight - borderLeft) + borderPadding + } return { repId, - markers, + visibleMarkers, + allMarkers, left: borderLeft, - right: borderRight, width: borderWidth, - leftmostMarkerPos: leftmost, - rightmostMarkerPos: rightmost + right: borderLeft + borderWidth, + extendLeft: hasMarkersBefore, + extendRight: hasMarkersAfter } }).filter((d): d is NonNullable => d !== null) // Calculate vertical stacking positions - // Sort repetitions by left position to process them in order const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left) - const ROW_HEIGHT = 40 // Height allocated per row const repetitionRows: Array> = [] sortedRepetitions.forEach(rep => { - // Find the first row where this repetition doesn't overlap let placed = false for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) { const row = repetitionRows[rowIndex] - // Check if this repetition overlaps with any in this row - // Two repetitions overlap if they share any horizontal space - // They don't overlap if one is completely to the left or right of the other const hasOverlap = row.some(existingRep => { - // Add a small threshold to avoid edge cases const threshold = 1 return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right) }) - + if (!hasOverlap) { row.push(rep) placed = true break } } - - // If no row found, create a new row + if (!placed) { repetitionRows.push([rep]) } }) - // Render all repetitions with their vertical positions - // Use flexbox to stack rows and share vertical space equally + // Render all repetitions - items stick to top, not distributed return ( -
{repetitionRows.map((row, rowIndex) => (
{row.map(rep => { - const firstMarker = rep.markers[0] - const allPhases = rep.markers.map(m => getPhaseStyle(m.phase).label).join(', ') - const times = rep.markers.map(m => moment(m.startTime).format('h:mm A')).join(', ') - const totalAssigned = new Set(rep.markers.flatMap(m => m.assignedConductors)).size + const firstMarker = rep.visibleMarkers[0] + const allPhases = rep.visibleMarkers.map(m => getPhaseStyle(m.phase).label).join(', ') + const times = rep.visibleMarkers.map(m => moment(m.startTime).format('h:mm A')).join(', ') + const totalAssigned = new Set(rep.visibleMarkers.flatMap(m => m.assignedConductors)).size + + // Calculate height based on markers (just enough for markers) + const repHeight = MARKER_HEIGHT + + // Calculate if dragging + const isDragging = draggingRepetition === rep.repId + const currentLeft = isDragging && dragPosition ? dragPosition.x : rep.left + const borderPadding = 20 + + // Render markers inside the repetition border + const markerElements = rep.visibleMarkers.map((marker) => { + const style = getPhaseStyle(marker.phase) + const absoluteX = getTimePosition(marker.startTime) + const isSelected = selectedMarker === marker.id + const isVerticalLineHovered = hoveredVerticalLine === marker.id + + // Calculate marker position relative to repetition border's left edge + // The repetition border starts at currentLeft, and markers are positioned relative to that + let markerLeftRelative = absoluteX - currentLeft + + // If dragging, maintain relative position to the original border position + if (isDragging && dragPosition) { + // Calculate offset from the original leftmost position + const originalLeftmost = Math.min(...rep.visibleMarkers.map(m => getTimePosition(m.startTime))) + const offsetFromLeftmost = absoluteX - originalLeftmost + markerLeftRelative = offsetFromLeftmost + } + + // Calculate vertical line dimensions + const HEADER_ROW_HEIGHT = 60 + const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36 + const ROW_HEIGHT = 40 + const MARKER_TOP_OFFSET = 10 + const MARKER_ICON_SIZE = 32 + const rowIndex = repetitionRows.findIndex(r => r.includes(rep)) + + const markerCenterY = MARKER_TOP_OFFSET + (MARKER_ICON_SIZE / 2) + const lineTop = -(HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + MARKER_TOP_OFFSET) + const lineHeight = HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + MARKER_TOP_OFFSET + markerCenterY + + const formattedTime = moment(marker.startTime).format('h:mm A') + const formattedDate = moment(marker.startTime).format('MMM D, YYYY') + const fullTimeString = `${formattedDate} at ${formattedTime}` + + return ( +
handleMarkerMouseDown(e, marker.id)} + onMouseEnter={() => { + setHoveredMarker(marker.id) + setHoveredVerticalLine(marker.id) + }} + onMouseLeave={() => { + setHoveredMarker(null) + setHoveredVerticalLine(null) + }} + title={`${style.label} - ${moment(marker.startTime).format('MMM D, h:mm A')}${marker.assignedConductors.length > 0 ? ` (${marker.assignedConductors.length} assigned)` : ''}`} + > + {/* Vertical line extending from header to marker */} +
{ + setHoveredVerticalLine(marker.id) + const rect = e.currentTarget.getBoundingClientRect() + setVerticalLineTooltip({ + markerId: marker.id, + x: rect.left + rect.width / 2, + y: rect.top, + time: fullTimeString + }) + }} + onMouseLeave={() => { + setHoveredVerticalLine(null) + setVerticalLineTooltip(null) + }} + onMouseMove={(e) => { + const rect = e.currentTarget.getBoundingClientRect() + setVerticalLineTooltip({ + markerId: marker.id, + x: rect.left + rect.width / 2, + y: rect.top, + time: fullTimeString + }) + }} + /> + + {/* Small icon marker */} +
+ + {style.icon} + + + {/* Assign Conductors button */} + +
+ + {/* Connection indicators on assigned conductors */} + {marker.assignedConductors.map((conductorId, lineIndex) => { + const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId) + if (conductorIndex === -1) return null + + const CONDUCTOR_ROW_HEIGHT = 36 + const HEADER_ROW_HEIGHT = 60 + const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36 + const ROW_HEIGHT = 40 + const MARKER_TOP_OFFSET = 10 + const MARKER_ICON_SIZE = 32 + + const conductorRowTop = HEADER_ROW_HEIGHT + (conductorIndex * CONDUCTOR_ROW_HEIGHT) + const conductorRowCenter = conductorRowTop + (CONDUCTOR_ROW_HEIGHT / 2) + const markerCenterFromTop = HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + MARKER_TOP_OFFSET + (MARKER_ICON_SIZE / 2) + const dotY = -(markerCenterFromTop - conductorRowCenter) + + return ( +
+ ) + })} +
+ ) + }) return ( handleRepetitionMouseDown(e, rep.repId)} - isDragging={draggingRepetition === rep.repId} + isDragging={isDragging} dragOffset={repetitionDragOffset} - /> + extendLeft={rep.extendLeft} + extendRight={rep.extendRight} + > + {markerElements} + ) })}
@@ -878,220 +1041,12 @@ export function HorizontalTimelineCalendar({
) })()} - - {/* Phase markers - positioned relative to their repetition's row */} - {(() => { - // Group only visible markers by repetition to find their row positions - const markersByRepetition: Record = {} - visibleMarkers.forEach(marker => { - if (!markersByRepetition[marker.repetitionId]) { - markersByRepetition[marker.repetitionId] = [] - } - markersByRepetition[marker.repetitionId].push(marker) - }) - - // Calculate repetition positions and rows (same logic as borders) - const borderPadding = 20 - const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => { - const positions = markers.map(m => { - const dayIndex = days.findIndex(d => - d.toDateString() === new Date(m.startTime).toDateString() - ) - if (dayIndex === -1) return null - return getTimePosition(m.startTime) - }).filter((p): p is number => p !== null) - - if (positions.length === 0) return null - - const leftmost = Math.min(...positions) - const rightmost = Math.max(...positions) - const borderLeft = leftmost - borderPadding - const borderWidth = (rightmost - leftmost) + (borderPadding * 2) - const borderRight = borderLeft + borderWidth - - return { - repId, - markers, - left: borderLeft, - right: borderRight, - width: borderWidth - } - }).filter((d): d is NonNullable => d !== null) - - // Calculate vertical stacking (same as borders) - const ROW_HEIGHT = 40 - const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left) - const repetitionRows: Array> = [] - - sortedRepetitions.forEach(rep => { - let placed = false - for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) { - const row = repetitionRows[rowIndex] - const hasOverlap = row.some(existingRep => { - const threshold = 1 - return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right) - }) - - if (!hasOverlap) { - row.push(rep) - placed = true - break - } - } - - if (!placed) { - repetitionRows.push([rep]) - } - }) - - // Create a map of repetition ID to row index - const repIdToRowIndex: Record = {} - repetitionRows.forEach((row, rowIndex) => { - row.forEach(rep => { - repIdToRowIndex[rep.repId] = rowIndex - }) - }) - - return visibleMarkers.map((marker) => { - const style = getPhaseStyle(marker.phase) - const dayIndex = days.findIndex(d => - d.toDateString() === new Date(marker.startTime).toDateString() - ) - if (dayIndex === -1) return null - - // Get absolute position from start of timeline (includes day offset) - const absoluteX = getTimePosition(marker.startTime) - const isDragging = draggingRepetition === marker.repetitionId - const isSelected = selectedMarker === marker.id - const rowIndex = repIdToRowIndex[marker.repetitionId] || 0 - const topOffset = rowIndex * ROW_HEIGHT + 10 // 10px padding from top of row - - const isVerticalLineHovered = hoveredVerticalLine === marker.id - - // Calculate marker position - if dragging, maintain relative position to border - let markerLeft = absoluteX - if (isDragging && dragPosition) { - const repData = repetitionData.find(r => r.repId === marker.repetitionId) - if (repData) { - // Calculate offset from leftmost marker - const leftmostMarker = repData.markers.reduce((prev, curr) => - getTimePosition(prev.startTime) < getTimePosition(curr.startTime) ? prev : curr - ) - const leftmostX = getTimePosition(leftmostMarker.startTime) - const offsetFromLeftmost = absoluteX - leftmostX - // Position relative to dragged border - markerLeft = dragPosition.x + borderPadding + offsetFromLeftmost - } - } - - return ( -
handleMarkerMouseDown(e, marker.id)} - onMouseEnter={() => { - setHoveredMarker(marker.id) - setHoveredVerticalLine(marker.id) - }} - onMouseLeave={() => { - setHoveredMarker(null) - setHoveredVerticalLine(null) - }} - title={`${style.label} - ${moment(marker.startTime).format('MMM D, h:mm A')}${marker.assignedConductors.length > 0 ? ` (${marker.assignedConductors.length} assigned)` : ''}`} - > - {/* Small icon marker */} -
- - {style.icon} - - - {/* Assign Conductors button - top right corner */} - -
- - {/* Connection indicators on assigned conductors (shown as dots on the vertical line) */} - {marker.assignedConductors.map((conductorId, lineIndex) => { - const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId) - if (conductorIndex === -1) return null - - const CONDUCTOR_ROW_HEIGHT = 36 // Height of each conductor row - const HEADER_ROW_HEIGHT = 60 // Approximate height of header row - const conductorRowTop = HEADER_ROW_HEIGHT + (conductorIndex * CONDUCTOR_ROW_HEIGHT) - const conductorRowCenter = conductorRowTop + (CONDUCTOR_ROW_HEIGHT / 2) - - // Position dot at conductor row center (negative because it's above the marker) - // Distance from marker center to conductor row center - const dotY = -(totalHeightToMarker - conductorRowCenter) - - return ( -
- ) - })} -
- ) - }) - })()}
{/* Conductor assignment panel (shown when marker is selected) */} {selectedMarker && assignmentPanelPosition && ( -