From 325dc89c516cf044812e284d05073d79da87ed64 Mon Sep 17 00:00:00 2001 From: salirezav Date: Wed, 14 Jan 2026 16:04:51 -0500 Subject: [PATCH] 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 */}