From 0c434e7e7f6b1669043a907303cf275cb4b96582 Mon Sep 17 00:00:00 2001 From: salirezav Date: Tue, 13 Jan 2026 14:41:48 -0500 Subject: [PATCH 1/9] 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 && ( -
Date: Wed, 14 Jan 2026 16:04:39 -0500 Subject: [PATCH 3/9] Disable Vision API and Media API services in docker-compose.yml for development; add start-dev.sh script for scheduling-remote to streamline development workflow. --- docker-compose.yml | 231 +++++++++++++++++---------------- scheduling-remote/package.json | 2 +- scheduling-remote/start-dev.sh | 41 ++++++ 3 files changed, 162 insertions(+), 112 deletions(-) create mode 100755 scheduling-remote/start-dev.sh diff --git a/docker-compose.yml b/docker-compose.yml index 588eecf..1008918 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -299,71 +299,74 @@ services: # - usda-vision-network # restart: unless-stopped # - api: - container_name: usda-vision-api - build: - context: ./camera-management-api - dockerfile: Dockerfile - working_dir: /app - restart: unless-stopped # Automatically restart container if it fails or exits - healthcheck: - test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\").read()' || exit 1"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - volumes: - - ./camera-management-api:/app - - /mnt/nfs_share:/mnt/nfs_share - - /etc/localtime:/etc/localtime:ro - - /etc/timezone:/etc/timezone:ro - environment: - - PYTHONUNBUFFERED=1 - - LD_LIBRARY_PATH=/usr/local/lib:/lib:/usr/lib - - PYTHONPATH=/app:/app/camera_sdk - - TZ=America/New_York - - MEDIAMTX_HOST=localhost - - MEDIAMTX_RTSP_PORT=8554 - command: > - sh -lc " - set -e # Exit on error - - # Only install system packages if not already installed (check for ffmpeg) - if ! command -v ffmpeg &> /dev/null; then - echo 'Installing system dependencies...'; - apt-get update && apt-get install -y --no-install-recommends libusb-1.0-0-dev ffmpeg; - else - echo 'System dependencies already installed'; - fi + # ============================================================================ + # Vision API Service - DISABLED FOR DEVELOPMENT + # ============================================================================ + # api: + # container_name: usda-vision-api + # build: + # context: ./camera-management-api + # dockerfile: Dockerfile + # working_dir: /app + # restart: unless-stopped # Automatically restart container if it fails or exits + # healthcheck: + # test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\").read()' || exit 1"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 60s + # volumes: + # - ./camera-management-api:/app + # - /mnt/nfs_share:/mnt/nfs_share + # - /etc/localtime:/etc/localtime:ro + # - /etc/timezone:/etc/timezone:ro + # environment: + # - PYTHONUNBUFFERED=1 + # - LD_LIBRARY_PATH=/usr/local/lib:/lib:/usr/lib + # - PYTHONPATH=/app:/app/camera_sdk + # - TZ=America/New_York + # - MEDIAMTX_HOST=localhost + # - MEDIAMTX_RTSP_PORT=8554 + # command: > + # sh -lc " + # set -e # Exit on error + # + # # Only install system packages if not already installed (check for ffmpeg) + # if ! command -v ffmpeg &> /dev/null; then + # echo 'Installing system dependencies...'; + # apt-get update && apt-get install -y --no-install-recommends libusb-1.0-0-dev ffmpeg; + # else + # echo 'System dependencies already installed'; + # fi - # Install camera SDK if not already installed - if [ ! -f /lib/libMVSDK.so ] && [ -f 'camera_sdk/linuxSDK_V2.1.0.49(250108)/install.sh' ]; then - echo 'Installing camera SDK...'; - cd 'camera_sdk/linuxSDK_V2.1.0.49(250108)'; - chmod +x install.sh; - ./install.sh || echo 'Warning: Camera SDK installation may have failed'; - cd /app; - else - echo 'Camera SDK already installed or install script not found'; - fi; + # # Install camera SDK if not already installed + # if [ ! -f /lib/libMVSDK.so ] && [ -f 'camera_sdk/linuxSDK_V2.1.0.49(250108)/install.sh' ]; then + # echo 'Installing camera SDK...'; + # cd 'camera_sdk/linuxSDK_V2.1.0.49(250108)'; + # chmod +x install.sh; + # ./install.sh || echo 'Warning: Camera SDK installation may have failed'; + # cd /app; + # else + # echo 'Camera SDK already installed or install script not found'; + # fi; - # Install Python dependencies (only if requirements.txt changed or packages missing) - if [ -f requirements.txt ]; then - pip install --no-cache-dir -r requirements.txt || echo 'Warning: Some Python packages may have failed to install'; - else - pip install --no-cache-dir -e . || echo 'Warning: Package installation may have failed'; - fi; + # # Install Python dependencies (only if requirements.txt changed or packages missing) + # if [ -f requirements.txt ]; then + # pip install --no-cache-dir -r requirements.txt || echo 'Warning: Some Python packages may have failed to install'; + # else + # pip install --no-cache-dir -e . || echo 'Warning: Package installation may have failed'; + # fi; - # Start the application with error handling - echo 'Starting USDA Vision Camera System...'; - python main.py --config config.compose.json || { - echo 'Application exited with error code: $?'; - echo 'Waiting 5 seconds before exit...'; - sleep 5; - exit 1; - } - " - network_mode: host + # # Start the application with error handling + # echo 'Starting USDA Vision Camera System...'; + # python main.py --config config.compose.json || { + # echo 'Application exited with error code: $?'; + # echo 'Waiting 5 seconds before exit...'; + # sleep 5; + # exit 1; + # } + # " + # network_mode: host web: container_name: usda-vision-web @@ -421,28 +424,31 @@ services: networks: - usda-vision-network - vision-system-remote: - container_name: usda-vision-vision-system-remote - image: node:20-alpine - working_dir: /app - environment: - - CHOKIDAR_USEPOLLING=true - - TZ=America/New_York - # Use environment variable with fallback to localhost - - VITE_VISION_API_URL=${VITE_VISION_API_URL:-http://localhost:8000} - volumes: - - ./vision-system-remote:/app - command: > - sh -lc " - npm install; - npm run dev:watch - " - extra_hosts: - - "host.docker.internal:host-gateway" - ports: - - "3002:3002" - networks: - - usda-vision-network + # ============================================================================ + # Vision System Remote - DISABLED FOR DEVELOPMENT + # ============================================================================ + # vision-system-remote: + # container_name: usda-vision-vision-system-remote + # image: node:20-alpine + # working_dir: /app + # environment: + # - CHOKIDAR_USEPOLLING=true + # - TZ=America/New_York + # # Use environment variable with fallback to localhost + # - VITE_VISION_API_URL=${VITE_VISION_API_URL:-http://localhost:8000} + # volumes: + # - ./vision-system-remote:/app + # command: > + # sh -lc " + # npm install; + # npm run dev:watch + # " + # extra_hosts: + # - "host.docker.internal:host-gateway" + # ports: + # - "3002:3002" + # networks: + # - usda-vision-network scheduling-remote: container_name: usda-vision-scheduling-remote @@ -467,33 +473,36 @@ services: networks: - usda-vision-network - media-api: - container_name: usda-vision-media-api - build: - context: ./media-api - dockerfile: Dockerfile - environment: - - MEDIA_VIDEOS_DIR=/mnt/nfs_share - - MEDIA_THUMBS_DIR=/mnt/nfs_share/.thumbnails - - MAX_CONCURRENT_TRANSCODING=2 # Limit concurrent transcoding operations - volumes: - - /mnt/nfs_share:/mnt/nfs_share - ports: - - "8090:8090" - networks: - - usda-vision-network - deploy: - resources: - limits: - cpus: '4' # Limit to 4 CPU cores (adjust based on your system) - memory: 2G # Limit to 2GB RAM per container - reservations: - cpus: '1' # Reserve at least 1 CPU core - memory: 512M # Reserve at least 512MB RAM - # Alternative syntax for older Docker Compose versions: - # cpus: '4' - # mem_limit: 2g - # mem_reservation: 512m + # ============================================================================ + # Media API Service - DISABLED FOR DEVELOPMENT + # ============================================================================ + # media-api: + # container_name: usda-vision-media-api + # build: + # context: ./media-api + # dockerfile: Dockerfile + # environment: + # - MEDIA_VIDEOS_DIR=/mnt/nfs_share + # - MEDIA_THUMBS_DIR=/mnt/nfs_share/.thumbnails + # - MAX_CONCURRENT_TRANSCODING=2 # Limit concurrent transcoding operations + # volumes: + # - /mnt/nfs_share:/mnt/nfs_share + # ports: + # - "8090:8090" + # networks: + # - usda-vision-network + # deploy: + # resources: + # limits: + # cpus: '4' # Limit to 4 CPU cores (adjust based on your system) + # memory: 2G # Limit to 2GB RAM per container + # reservations: + # cpus: '1' # Reserve at least 1 CPU core + # memory: 512M # Reserve at least 512MB RAM + # # Alternative syntax for older Docker Compose versions: + # # cpus: '4' + # # mem_limit: 2g + # # mem_reservation: 512m mediamtx: container_name: usda-vision-mediamtx diff --git a/scheduling-remote/package.json b/scheduling-remote/package.json index 4d550a8..51f6fe0 100644 --- a/scheduling-remote/package.json +++ b/scheduling-remote/package.json @@ -9,7 +9,7 @@ "build:watch": "vite build --watch", "serve:dist": "serve -s dist -l 3003", "preview": "vite preview --port 3003", - "dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3003 --cors -c-1" + "dev:watch": "./start-dev.sh" }, "dependencies": { "@supabase/supabase-js": "^2.52.0", diff --git a/scheduling-remote/start-dev.sh b/scheduling-remote/start-dev.sh new file mode 100755 index 0000000..de4fbcd --- /dev/null +++ b/scheduling-remote/start-dev.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# Build the project first +echo "Building scheduling-remote..." +npm run build + +# Start build:watch in the background +echo "Starting build:watch in background..." +npm run build:watch & + +# Wait for remoteEntry.js to exist before starting the server +# Check both possible locations (root and assets folder) +echo "Waiting for remoteEntry.js to be generated..." +MAX_WAIT=30 +WAIT_COUNT=0 +REMOTE_ENTRY_ROOT="dist/remoteEntry.js" +REMOTE_ENTRY_ASSETS="dist/assets/remoteEntry.js" + +while [ $WAIT_COUNT -lt $MAX_WAIT ]; do + # Check if file exists in either location + if [ -f "$REMOTE_ENTRY_ROOT" ] || [ -f "$REMOTE_ENTRY_ASSETS" ]; then + echo "remoteEntry.js found! Starting http-server..." + break + fi + + sleep 1 + WAIT_COUNT=$((WAIT_COUNT + 1)) + if [ $((WAIT_COUNT % 5)) -eq 0 ]; then + echo "Still waiting for remoteEntry.js... (${WAIT_COUNT}s)" + fi +done + +# Final check +if [ ! -f "$REMOTE_ENTRY_ROOT" ] && [ ! -f "$REMOTE_ENTRY_ASSETS" ]; then + echo "ERROR: remoteEntry.js was not generated after ${MAX_WAIT} seconds!" + echo "Checked locations: $REMOTE_ENTRY_ROOT and $REMOTE_ENTRY_ASSETS" + exit 1 +fi + +echo "Starting http-server on port 3003..." +npx http-server dist -p 3003 --cors -c-1 From 0a4df9073c24e8113dea2845f998c308cc6a692f Mon Sep 17 00:00:00 2001 From: salirezav Date: Wed, 14 Jan 2026 16:04:45 -0500 Subject: [PATCH 4/9] Refactor Sidebar component to remove hover state management and adjust expansion logic - Removed isHovered and setIsHovered props from SidebarProps. - Updated sidebar expansion logic to rely solely on isExpanded and isMobileOpen. - Simplified rendering conditions for menu items and submenus based on the new state management. --- .../src/components/Sidebar.tsx | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/management-dashboard-web-app/src/components/Sidebar.tsx b/management-dashboard-web-app/src/components/Sidebar.tsx index 8a98f31..6673e15 100755 --- a/management-dashboard-web-app/src/components/Sidebar.tsx +++ b/management-dashboard-web-app/src/components/Sidebar.tsx @@ -7,8 +7,6 @@ interface SidebarProps { onViewChange: (view: string) => void isExpanded?: boolean isMobileOpen?: boolean - isHovered?: boolean - setIsHovered?: (hovered: boolean) => void } interface MenuItem { @@ -23,10 +21,8 @@ export function Sidebar({ user, currentView, onViewChange, - isExpanded = true, - isMobileOpen = false, - isHovered = false, - setIsHovered + isExpanded = false, + isMobileOpen = false }: SidebarProps) { const [openSubmenu, setOpenSubmenu] = useState(null) const [subMenuHeight, setSubMenuHeight] = useState>({}) @@ -170,7 +166,7 @@ export function Sidebar({ className={`menu-item group ${openSubmenu === index ? "menu-item-active" : "menu-item-inactive" - } cursor-pointer ${!isExpanded && !isHovered + } cursor-pointer ${!isExpanded ? "lg:justify-center" : "lg:justify-start" }`} @@ -183,10 +179,10 @@ export function Sidebar({ > {nav.icon} - {(isExpanded || isHovered || isMobileOpen) && ( + {(isExpanded || isMobileOpen) && ( {nav.name} )} - {(isExpanded || isHovered || isMobileOpen) && ( + {(isExpanded || isMobileOpen) && ( {nav.icon} - {(isExpanded || isHovered || isMobileOpen) && ( + {(isExpanded || isMobileOpen) && ( {nav.name} )} )} - {nav.subItems && (isExpanded || isHovered || isMobileOpen) && ( + {nav.subItems && (isExpanded || isMobileOpen) && (
{ subMenuRefs.current[`submenu-${index}`] = el @@ -265,21 +261,17 @@ export function Sidebar({ className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200 ${isExpanded || isMobileOpen ? "w-[290px]" - : isHovered - ? "w-[290px]" - : "w-[90px]" + : "w-[90px]" } ${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`} - onMouseEnter={() => !isExpanded && setIsHovered && setIsHovered(true)} - onMouseLeave={() => setIsHovered && setIsHovered(false)} >
- {isExpanded || isHovered || isMobileOpen ? ( + {isExpanded || isMobileOpen ? ( <>

Pecan Experiments

Research Dashboard

@@ -297,12 +289,12 @@ export function Sidebar({

- {isExpanded || isHovered || isMobileOpen ? ( + {isExpanded || isMobileOpen ? ( "Menu" ) : ( From 325dc89c516cf044812e284d05073d79da87ed64 Mon Sep 17 00:00:00 2001 From: salirezav Date: Wed, 14 Jan 2026 16:04:51 -0500 Subject: [PATCH 5/9] Enhance HorizontalTimelineCalendar and Scheduling components with repetition metadata and improved interaction - Added RepetitionMetadata interface to manage phase details for repetitions. - Implemented onScrollToRepetition and onScheduleRepetition callbacks for better user navigation and scheduling. - Updated HorizontalTimelineCalendar to display phase names, experiment numbers, and repetition numbers in the timeline. - Removed locked state management from Scheduling component, simplifying repetition handling. - Improved conductor assignment visibility and interaction within the scheduling interface. --- .../components/HorizontalTimelineCalendar.tsx | 393 +++++++++++------- .../src/components/Scheduling.tsx | 267 +++++++----- 2 files changed, 414 insertions(+), 246 deletions(-) diff --git a/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx b/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx index 1d11b49..e1061ff 100644 --- a/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx +++ b/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx @@ -18,7 +18,13 @@ interface PhaseMarker { phase: 'soaking' | 'airdrying' | 'cracking' startTime: Date assignedConductors: string[] // Array of conductor IDs - locked: boolean +} + +interface RepetitionMetadata { + phaseName: string + experimentNumber: number + repetitionNumber: number + experimentId: string } interface HorizontalTimelineCalendarProps { @@ -28,7 +34,9 @@ interface HorizontalTimelineCalendarProps { phaseMarkers: PhaseMarker[] onMarkerDrag: (markerId: string, newTime: Date) => void onMarkerAssignConductors: (markerId: string, conductorIds: string[]) => void - onMarkerLockToggle: (markerId: string) => void + repetitionMetadata?: Record // Map from repetitionId to metadata + onScrollToRepetition?: (repetitionId: string) => void // Callback to scroll to repetition in accordion + onScheduleRepetition?: (repId: string, experimentId: string) => void // Callback to schedule repetition timeStep?: number // Minutes per pixel or time unit minHour?: number maxHour?: number @@ -51,6 +59,14 @@ function RepetitionBorder({ dragOffset = { x: 0 }, extendLeft = false, extendRight = false, + phaseName, + experimentNumber, + repetitionNumber, + experimentId, + onScrollToRepetition, + onScheduleRepetition, + visibleMarkers, + getTimePosition, children }: { left: number @@ -67,10 +83,60 @@ function RepetitionBorder({ dragOffset?: { x: number } extendLeft?: boolean extendRight?: boolean + phaseName?: string + experimentNumber?: number + repetitionNumber?: number + experimentId?: string + onScrollToRepetition?: (repetitionId: string) => void + onScheduleRepetition?: (repId: string, experimentId: string) => void + visibleMarkers: Array<{ id: string; startTime: Date; assignedConductors: string[] }> + getTimePosition: (time: Date) => number children?: React.ReactNode }) { const [isHovered, setIsHovered] = useState(false) + const handleGoToRepetition = (e: React.MouseEvent) => { + e.stopPropagation() + if (onScrollToRepetition) { + onScrollToRepetition(repId) + } + } + + const handleSchedule = (e: React.MouseEvent) => { + e.stopPropagation() + if (onScheduleRepetition && experimentId) { + onScheduleRepetition(repId, experimentId) + } + } + + // Calculate positions for the text container + const firstMarker = visibleMarkers[0] + const secondMarker = visibleMarkers[1] + + let textContainerLeft = 0 + let textContainerWidth = 0 + + if (firstMarker) { + const firstMarkerX = getTimePosition(firstMarker.startTime) + const firstMarkerLeftRelative = firstMarkerX - left + const MARKER_ICON_SIZE = 32 + // Start after the first marker (marker center + half icon size + padding) + textContainerLeft = firstMarkerLeftRelative + (MARKER_ICON_SIZE / 2) + 8 + + if (secondMarker) { + // Span to the second marker + const secondMarkerX = getTimePosition(secondMarker.startTime) + const secondMarkerLeftRelative = secondMarkerX - left + textContainerWidth = secondMarkerLeftRelative - (MARKER_ICON_SIZE / 2) - textContainerLeft - 8 + } else { + // Span to the end of the repetition border + textContainerWidth = width - textContainerLeft - 8 + } + } + + // Check if all markers have at least one conductor assigned + const allMarkersHaveConductors = visibleMarkers.length > 0 && visibleMarkers.every(m => m.assignedConductors.length > 0) + // Build border style based on extensions // Border radius: top-left top-right bottom-right bottom-left @@ -98,7 +164,6 @@ function RepetitionBorder({ : (isLocked ? 'rgba(156, 163, 175, 0.3)' : 'rgba(59, 130, 246, 0.3)'), borderRadius: borderRadius, backgroundColor: 'transparent', - zIndex: isDragging ? 10 : 2, opacity: isDragging ? 0.7 : 1, transition: isDragging ? 'none' : 'left 0.2s ease-out, border-color 0.2s ease-in-out', pointerEvents: 'auto', @@ -108,9 +173,56 @@ function RepetitionBorder({ onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onMouseDown={onMouseDown} - title={`Repetition ${repId}: ${allPhases} at ${times}${assignedCount > 0 ? ` (${assignedCount} conductors assigned)` : ''}`} > {children} + + {/* Text container div spanning from after first marker to next marker */} + {firstMarker && textContainerWidth > 0 && (phaseName || experimentNumber !== undefined || repetitionNumber !== undefined) && ( +
+ {/* Text content (non-clickable) */} +
+ {phaseName &&
{phaseName}
} + {experimentNumber !== undefined &&
Exp {experimentNumber}
} + {repetitionNumber !== undefined &&
Rep {repetitionNumber}
} +
+ + {/* Go to repetition button */} + + + {/* Schedule button */} + {onScheduleRepetition && experimentId && ( + + )} +
+ )}

) } @@ -122,7 +234,9 @@ export function HorizontalTimelineCalendar({ phaseMarkers, onMarkerDrag, onMarkerAssignConductors, - onMarkerLockToggle, + repetitionMetadata = {}, + onScrollToRepetition, + onScheduleRepetition, timeStep = 60, // 60 minutes (1 hour) per time slot for 24 divisions minHour = 0, maxHour = 24, @@ -135,17 +249,16 @@ export function HorizontalTimelineCalendar({ const [draggingMarker, setDraggingMarker] = useState(null) const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const [hoveredMarker, setHoveredMarker] = useState(null) - const [selectedMarker, setSelectedMarker] = useState(null) - 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 [hoveredIntersection, setHoveredIntersection] = useState(null) // Format: "markerId-conductorId" const [draggingRepetition, setDraggingRepetition] = useState(null) // Repetition ID being dragged + const [showIntersections, setShowIntersections] = useState(true) // Control visibility of intersection buttons const [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 }) const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position const [containerWidth, setContainerWidth] = useState(0) const timelineRef = useRef(null) - const assignmentPanelRef = useRef(null) const scrollableContainersRef = useRef([]) const containerRef = useRef(null) @@ -286,28 +399,6 @@ export function HorizontalTimelineCalendar({ } }, [days.length]) // Re-run when days change - // Close assignment panel when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - selectedMarker && - assignmentPanelRef.current && - !assignmentPanelRef.current.contains(event.target as Node) && - !(event.target as HTMLElement).closest('[data-marker-id]') && - !(event.target as HTMLElement).closest('button[title="Assign Conductors"]') - ) { - setSelectedMarker(null) - setAssignmentPanelPosition(null) - } - } - - if (selectedMarker && assignmentPanelPosition) { - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - } - }, [selectedMarker, assignmentPanelPosition]) // Generate time slots for a day - 24 hours, one slot per hour const timeSlots = useMemo(() => { @@ -357,11 +448,14 @@ export function HorizontalTimelineCalendar({ e.preventDefault() e.stopPropagation() const marker = phaseMarkers.find(m => m.id === markerId) - if (!marker || marker.locked) return + if (!marker) return // Find all markers in this repetition const markers = phaseMarkers.filter(m => m.repetitionId === marker.repetitionId) - if (markers.some(m => m.locked)) return + + // Check if any marker in this repetition has assigned conductors - if so, disable drag + const hasAssignedConductors = markers.some(m => m.assignedConductors.length > 0) + if (hasAssignedConductors) return if (!timelineRef.current) return @@ -385,6 +479,8 @@ export function HorizontalTimelineCalendar({ x: offsetX }) setDragPosition({ x: borderLeft }) + // Hide intersections when dragging starts + setShowIntersections(false) }, [phaseMarkers, getTimePosition]) // Handle repetition border drag start @@ -393,7 +489,11 @@ export function HorizontalTimelineCalendar({ e.stopPropagation() const markers = phaseMarkers.filter(m => m.repetitionId === repetitionId) - if (markers.length === 0 || markers.some(m => m.locked)) return + if (markers.length === 0) return + + // Check if any marker in this repetition has assigned conductors - if so, disable drag + const hasAssignedConductors = markers.some(m => m.assignedConductors.length > 0) + if (hasAssignedConductors) return if (!timelineRef.current) return @@ -411,6 +511,8 @@ export function HorizontalTimelineCalendar({ x: offsetX }) setDragPosition({ x: borderLeft }) + // Hide intersections when dragging starts + setShowIntersections(false) }, [phaseMarkers]) // Handle mouse move during drag - only update visual position, save on mouse up @@ -471,6 +573,11 @@ export function HorizontalTimelineCalendar({ setDraggingRepetition(null) setRepetitionDragOffset({ x: 0 }) setDragPosition(null) + + // Show intersections again after animation completes (200ms transition duration) + setTimeout(() => { + setShowIntersections(true) + }, 200) } window.addEventListener('mousemove', handleMouseMove) @@ -626,7 +733,7 @@ export function HorizontalTimelineCalendar({ return (
) })} + + {/* Render intersection indicators for markers that intersect with this conductor's availability */} + {showIntersections && visibleMarkers.map((marker) => { + // Check if marker's time falls within any of this conductor's availability windows + const intersectingAvailability = conductor.availability.find(avail => { + return marker.startTime >= avail.start && marker.startTime <= avail.end + }) + + if (!intersectingAvailability) return null + + // Calculate marker's x position + const markerX = getTimePosition(marker.startTime) + + // Check if marker is within the availability line's bounds + const availStartPos = getTimePosition(intersectingAvailability.start) + const availEndPos = getTimePosition(intersectingAvailability.end) + + if (markerX < availStartPos || markerX > availEndPos) return null + + const intersectionKey = `${marker.id}-${conductor.conductorId}` + const isHovered = hoveredIntersection === intersectionKey + const isAssigned = marker.assignedConductors.includes(conductor.conductorId) + + return ( + + ) + })}
@@ -826,6 +1000,10 @@ export function HorizontalTimelineCalendar({ 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 + // Check if any marker has assigned conductors - if so, disable drag + const hasAssignedConductors = rep.visibleMarkers.some(m => m.assignedConductors.length > 0) + const isDraggable = !hasAssignedConductors + // Calculate height based on markers (just enough for markers) const repHeight = MARKER_HEIGHT @@ -838,9 +1016,11 @@ export function HorizontalTimelineCalendar({ 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 + // Check if this repetition has any assigned conductors (affects all markers) + const markerIsDraggable = !hasAssignedConductors + // 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 @@ -873,8 +1053,7 @@ export function HorizontalTimelineCalendar({
handleMarkerMouseDown(e, marker.id)} + onMouseDown={markerIsDraggable ? (e) => handleMarkerMouseDown(e, marker.id) : undefined} onMouseEnter={() => { setHoveredMarker(marker.id) setHoveredVerticalLine(marker.id) @@ -902,10 +1081,10 @@ export function HorizontalTimelineCalendar({ width: isVerticalLineHovered ? '4px' : '2px', height: `${lineHeight}px`, transform: 'translateX(-50%)', - backgroundColor: marker.locked ? '#9ca3af' : style.color, + backgroundColor: style.color, opacity: isVerticalLineHovered ? 0.9 : 0.4, borderRadius: '2px', - zIndex: isDragging ? 30 : 10000, + zIndex: 2, transition: isDragging ? 'none' : 'width 0.2s ease-in-out, opacity 0.2s ease-in-out' }} onMouseEnter={(e) => { @@ -939,46 +1118,32 @@ export function HorizontalTimelineCalendar({ style={{ width: '32px', height: '32px', - backgroundColor: marker.locked ? '#9ca3af' : style.color, - border: `2px solid ${marker.locked ? '#6b7280' : style.color}`, - boxShadow: isSelected ? `0 0 0 3px ${style.color}40` : '0 2px 4px rgba(0,0,0,0.2)' + backgroundColor: style.color, + border: `2px solid ${style.color}`, + boxShadow: '0 2px 4px rgba(0,0,0,0.2)' }} > - + {style.icon} - {/* Assign Conductors button */} -
{/* Connection indicators on assigned conductors */} + {/* Only show dots for conductors that don't have an intersection button visible */} {marker.assignedConductors.map((conductorId, lineIndex) => { const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId) if (conductorIndex === -1) return null + // Check if this conductor has an intersection button (i.e., their availability intersects with this marker) + const conductor = conductorAvailabilities[conductorIndex] + const hasIntersectionButton = conductor.availability.some(avail => { + return marker.startTime >= avail.start && marker.startTime <= avail.end + }) + + // Hide the dot if there's an intersection button for this conductor + if (hasIntersectionButton) return null + const CONDUCTOR_ROW_HEIGHT = 36 const HEADER_ROW_HEIGHT = 60 const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36 @@ -1014,6 +1179,14 @@ export function HorizontalTimelineCalendar({ ) }) + const metadata = repetitionMetadata[rep.repId] + // Convert visible markers to format needed by RepetitionBorder + const borderMarkers = rep.visibleMarkers.map(m => ({ + id: m.id, + startTime: m.startTime, + assignedConductors: m.assignedConductors + })) + return ( handleRepetitionMouseDown(e, rep.repId)} + onMouseDown={isDraggable ? (e) => handleRepetitionMouseDown(e, rep.repId) : undefined} isDragging={isDragging} dragOffset={repetitionDragOffset} extendLeft={rep.extendLeft} extendRight={rep.extendRight} + phaseName={metadata?.phaseName} + experimentNumber={metadata?.experimentNumber} + repetitionNumber={metadata?.repetitionNumber} + experimentId={metadata?.experimentId} + onScrollToRepetition={onScrollToRepetition} + onScheduleRepetition={onScheduleRepetition} + visibleMarkers={borderMarkers} + getTimePosition={getTimePosition} > {markerElements} @@ -1044,78 +1225,6 @@ export function HorizontalTimelineCalendar({
- {/* Conductor assignment panel (shown when marker is selected) */} - {selectedMarker && assignmentPanelPosition && ( -
-
-

- Assign Conductors -

- -
-
- {conductorAvailabilities.map((conductor) => { - const marker = phaseMarkers.find(m => m.id === selectedMarker) - const isAssigned = marker?.assignedConductors.includes(conductor.conductorId) || false - - return ( -
- )}
diff --git a/scheduling-remote/src/components/Scheduling.tsx b/scheduling-remote/src/components/Scheduling.tsx index 9785579..3b0fd8a 100644 --- a/scheduling-remote/src/components/Scheduling.tsx +++ b/scheduling-remote/src/components/Scheduling.tsx @@ -70,8 +70,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => // Track repetitions that have been dropped/moved and should show time points const [repetitionsWithTimes, setRepetitionsWithTimes] = useState>(new Set()) - // Track which repetitions are locked (prevent dragging) - const [lockedSchedules, setLockedSchedules] = useState>(new Set()) // Track which repetitions are currently being scheduled const [schedulingRepetitions, setSchedulingRepetitions] = useState>(new Set()) // Track conductor assignments for each phase marker (markerId -> conductorIds[]) @@ -269,28 +267,18 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => next.delete(repId) return next }) - setLockedSchedules(prev => { - const next = new Set(prev) - next.delete(repId) - return next - }) setSchedulingRepetitions(prev => { const next = new Set(prev) next.delete(repId) return next }) - // Re-stagger remaining repetitions - const remainingIds = Array.from(next).filter(id => id !== repId) - if (remainingIds.length > 0) { - reStaggerRepetitions(remainingIds) - } + // Don't re-stagger remaining repetitions - they should keep their positions } else { next.add(repId) // Auto-spawn when checked - pass the updated set to ensure correct stagger calculation + // spawnSingleRepetition will position the new repetition relative to existing ones + // without resetting existing positions spawnSingleRepetition(repId, next) - // Re-stagger all existing repetitions to prevent overlap - // Note: reStaggerRepetitions will automatically skip locked repetitions - reStaggerRepetitions([...next, repId]) } return next }) @@ -356,7 +344,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => // Re-stagger all repetitions to prevent overlap // IMPORTANT: Skip locked repetitions to prevent them from moving - const reStaggerRepetitions = useCallback((repIds: string[]) => { + const reStaggerRepetitions = useCallback((repIds: string[], onlyResetWithoutCustomTimes: boolean = false) => { const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1) tomorrow.setHours(9, 0, 0, 0) @@ -364,8 +352,11 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => setScheduledRepetitions(prev => { const newScheduled = { ...prev } - // Filter out locked repetitions - they should not be moved - const unlockedRepIds = repIds.filter(repId => !lockedSchedules.has(repId)) + // If onlyResetWithoutCustomTimes is true, filter out repetitions that have custom times set + let unlockedRepIds = repIds + if (onlyResetWithoutCustomTimes) { + unlockedRepIds = unlockedRepIds.filter(repId => !repetitionsWithTimes.has(repId)) + } // Calculate stagger index only for unlocked repetitions let staggerIndex = 0 @@ -407,7 +398,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => return newScheduled }) - }, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment]) + }, [repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment, repetitionsWithTimes]) // Spawn a single repetition in calendar const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set) => { @@ -537,13 +528,10 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => const repetition = repetitionsByExperiment[scheduled.experimentId]?.find(r => r.id === scheduled.repetitionId) if (experiment && repetition && scheduled.soakingStart) { - const isLocked = lockedSchedules.has(scheduled.repetitionId) - const lockIcon = isLocked ? '🔒' : '🔓' - // Soaking marker events.push({ id: `${scheduled.repetitionId}-soaking`, - title: `${lockIcon} 💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`, + title: `💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`, start: scheduled.soakingStart, end: new Date(scheduled.soakingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility resource: 'soaking' @@ -553,7 +541,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => if (scheduled.airdryingStart) { events.push({ id: `${scheduled.repetitionId}-airdrying`, - title: `${lockIcon} 🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`, + title: `🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`, start: scheduled.airdryingStart, end: new Date(scheduled.airdryingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility resource: 'airdrying' @@ -564,7 +552,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => if (scheduled.crackingStart) { events.push({ id: `${scheduled.repetitionId}-cracking`, - title: `${lockIcon} ⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`, + title: `⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`, start: scheduled.crackingStart, end: new Date(scheduled.crackingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility resource: 'cracking' @@ -574,7 +562,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => }) return events - }, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules]) + }, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment]) // Memoize the calendar events const calendarEvents = useMemo(() => { @@ -610,15 +598,16 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => return moment(date).format('MMM D, h:mm A') } - const toggleScheduleLock = (repId: string) => { - setLockedSchedules(prev => { - const next = new Set(prev) - if (next.has(repId)) { - next.delete(repId) - } else { - next.add(repId) - } - return next + // Remove all conductor assignments from a repetition + const removeRepetitionAssignments = (repId: string) => { + const markerIdPrefix = repId + setConductorAssignments(prev => { + const newAssignments = { ...prev } + // Remove assignments for all three phases + delete newAssignments[`${markerIdPrefix}-soaking`] + delete newAssignments[`${markerIdPrefix}-airdrying`] + delete newAssignments[`${markerIdPrefix}-cracking`] + return newAssignments }) } @@ -626,24 +615,16 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => // Only make repetition markers draggable, not availability events const resource = event.resource as string if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') { - // Check if the repetition is locked - const eventId = event.id as string - const repId = eventId.split('-')[0] - const isLocked = lockedSchedules.has(repId) - return !isLocked + return true } return false - }, [lockedSchedules]) + }, []) const eventPropGetter = useCallback((event: any) => { const resource = event.resource as string // Styling for repetition markers (foreground events) if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') { - const eventId = event.id as string - const repId = eventId.split('-')[0] - const isLocked = lockedSchedules.has(repId) - const colors = { soaking: '#3b82f6', // blue airdrying: '#10b981', // green @@ -653,8 +634,8 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => return { style: { - backgroundColor: isLocked ? '#9ca3af' : color, // gray if locked - borderColor: isLocked ? color : color, // border takes original color when locked + backgroundColor: color, + borderColor: color, color: 'white', borderRadius: '8px', border: '2px solid', @@ -675,17 +656,17 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - cursor: isLocked ? 'not-allowed' : 'grab', - boxShadow: isLocked ? '0 1px 2px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.2)', + cursor: 'grab', + boxShadow: '0 2px 4px rgba(0,0,0,0.2)', transition: 'all 0.2s ease', - opacity: isLocked ? 0.7 : 1 + opacity: 1 } } } // Default styling for other events return {} - }, [lockedSchedules]) + }, []) const scheduleRepetition = async (repId: string, experimentId: string) => { setSchedulingRepetitions(prev => new Set(prev).add(repId)) @@ -807,7 +788,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => phase: 'soaking' | 'airdrying' | 'cracking' startTime: Date assignedConductors: string[] - locked: boolean }> = [] Object.values(scheduledRepetitions).forEach(scheduled => { @@ -821,8 +801,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => experimentId: scheduled.experimentId, phase: 'soaking', startTime: scheduled.soakingStart, - assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || [], - locked: lockedSchedules.has(repId) + assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || [] }) } @@ -833,8 +812,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => experimentId: scheduled.experimentId, phase: 'airdrying', startTime: scheduled.airdryingStart, - assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || [], - locked: lockedSchedules.has(repId) + assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || [] }) } @@ -845,8 +823,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => experimentId: scheduled.experimentId, phase: 'cracking', startTime: scheduled.crackingStart, - assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || [], - locked: lockedSchedules.has(repId) + assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || [] }) } }) @@ -857,7 +834,59 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => conductorAvailabilities, phaseMarkers } - }, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, lockedSchedules, calendarStartDate, calendarZoom]) + }, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, calendarStartDate, calendarZoom]) + + // Build repetition metadata mapping for timeline display + const repetitionMetadata = useMemo(() => { + const metadata: Record = {} + + Object.values(scheduledRepetitions).forEach(scheduled => { + const repId = scheduled.repetitionId + const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === scheduled.experimentId) + const repetition = Object.values(repetitionsByExperiment).flat().find(r => r.id === repId) + const phase = phases.find(p => + Object.values(experimentsByPhase[p.id] || []).some(e => e.id === scheduled.experimentId) + ) + + if (experiment && repetition && phase) { + metadata[repId] = { + phaseName: phase.name, + experimentNumber: experiment.experiment_number, + repetitionNumber: repetition.repetition_number, + experimentId: scheduled.experimentId + } + } + }) + + return metadata + }, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, phases]) + + // Scroll to repetition in accordion + const handleScrollToRepetition = useCallback(async (repetitionId: string) => { + // First, expand the phase if it's collapsed + const repetition = Object.values(repetitionsByExperiment).flat().find(r => r.id === repetitionId) + if (repetition) { + const experiment = Object.values(experimentsByPhase).flat().find(e => + (repetitionsByExperiment[e.id] || []).some(r => r.id === repetitionId) + ) + if (experiment) { + const phase = phases.find(p => + (experimentsByPhase[p.id] || []).some(e => e.id === experiment.id) + ) + if (phase && !expandedPhaseIds.has(phase.id)) { + await togglePhaseExpand(phase.id) + // Wait a bit for the accordion to expand + await new Promise(resolve => setTimeout(resolve, 300)) + } + } + } + + // Then scroll to the element + const element = document.getElementById(`repetition-${repetitionId}`) + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, [repetitionsByExperiment, experimentsByPhase, phases, expandedPhaseIds, togglePhaseExpand]) // Handlers for horizontal calendar const handleHorizontalMarkerDrag = useCallback((markerId: string, newTime: Date) => { @@ -879,21 +908,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => })) }, []) - const handleHorizontalMarkerLockToggle = useCallback((markerId: string) => { - // Marker ID format: ${repId}-${phase} where repId is a UUID with hyphens - // Split by '-' and take all but the last segment as repId - const parts = markerId.split('-') - const repId = parts.slice(0, -1).join('-') - setLockedSchedules(prev => { - const next = new Set(prev) - if (next.has(repId)) { - next.delete(repId) - } else { - next.add(repId) - } - return next - }) - }, []) return ( @@ -1028,7 +1042,9 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => phaseMarkers={horizontalCalendarData.phaseMarkers} onMarkerDrag={handleHorizontalMarkerDrag} onMarkerAssignConductors={handleHorizontalMarkerAssignConductors} - onMarkerLockToggle={handleHorizontalMarkerLockToggle} + repetitionMetadata={repetitionMetadata} + onScrollToRepetition={handleScrollToRepetition} + onScheduleRepetition={scheduleRepetition} timeStep={15} minHour={6} maxHour={22} @@ -1197,11 +1213,21 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => const checked = selectedRepetitionIds.has(rep.id) const hasTimes = repetitionsWithTimes.has(rep.id) const scheduled = scheduledRepetitions[rep.id] - const isLocked = lockedSchedules.has(rep.id) const isScheduling = schedulingRepetitions.has(rep.id) + + // Check if there are any conductor assignments + const markerIdPrefix = rep.id + const soakingConductors = conductorAssignments[`${markerIdPrefix}-soaking`] || [] + const airdryingConductors = conductorAssignments[`${markerIdPrefix}-airdrying`] || [] + const crackingConductors = conductorAssignments[`${markerIdPrefix}-cracking`] || [] + const hasAssignments = soakingConductors.length > 0 || airdryingConductors.length > 0 || crackingConductors.length > 0 return ( -
+
{/* Checkbox row */}