diff --git a/management-dashboard-web-app/src/components/DashboardLayout.tsx b/management-dashboard-web-app/src/components/DashboardLayout.tsx index 3273303..adac4f1 100755 --- a/management-dashboard-web-app/src/components/DashboardLayout.tsx +++ b/management-dashboard-web-app/src/components/DashboardLayout.tsx @@ -26,9 +26,8 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [currentView, setCurrentView] = useState('dashboard') - const [isExpanded, setIsExpanded] = useState(true) + const [isExpanded, setIsExpanded] = useState(false) const [isMobileOpen, setIsMobileOpen] = useState(false) - const [isHovered, setIsHovered] = useState(false) // Valid dashboard views const validViews = ['dashboard', 'user-management', 'experiments', 'analytics', 'data-entry', 'vision-system', 'scheduling', 'video-library', 'profile'] @@ -53,6 +52,26 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps } } + // Save sidebar expanded state to localStorage + const saveSidebarState = (expanded: boolean) => { + try { + localStorage.setItem('sidebar-expanded', String(expanded)) + } catch (error) { + console.warn('Failed to save sidebar state to localStorage:', error) + } + } + + // Get saved sidebar state from localStorage + const getSavedSidebarState = (): boolean => { + try { + const saved = localStorage.getItem('sidebar-expanded') + return saved === 'true' + } catch (error) { + console.warn('Failed to get saved sidebar state from localStorage:', error) + return false + } + } + // Check if user has access to a specific view const hasAccessToView = (view: string): boolean => { if (!user) return false @@ -80,6 +99,9 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps useEffect(() => { fetchUserProfile() + // Load saved sidebar state + const savedSidebarState = getSavedSidebarState() + setIsExpanded(savedSidebarState) }, []) // Restore saved view when user is loaded @@ -144,7 +166,9 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps } const toggleSidebar = () => { - setIsExpanded(!isExpanded) + const newState = !isExpanded + setIsExpanded(newState) + saveSidebarState(newState) } const toggleMobileSidebar = () => { @@ -225,7 +249,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps ) case 'scheduling': return ( - + Loading scheduling module...}> @@ -300,8 +324,6 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps onViewChange={handleViewChange} isExpanded={isExpanded} isMobileOpen={isMobileOpen} - isHovered={isHovered} - setIsHovered={setIsHovered} /> {/* Backdrop for mobile */} {isMobileOpen && ( @@ -312,7 +334,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps )}
void showRetry?: boolean + autoRetry?: boolean + retryDelay?: number + maxRetries?: number } -type State = { hasError: boolean } +type State = { hasError: boolean; retryCount: number } export class ErrorBoundary extends Component { - state: State = { hasError: false } + private retryTimeoutId?: NodeJS.Timeout + + state: State = { hasError: false, retryCount: 0 } static getDerivedStateFromError() { return { hasError: true } } - componentDidCatch() {} + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // Auto-retry logic for module federation loading issues + const maxRetries = this.props.maxRetries || 3 + if (this.props.autoRetry !== false && this.state.retryCount < maxRetries) { + const delay = this.props.retryDelay || 2000 + this.retryTimeoutId = setTimeout(() => { + this.setState(prevState => ({ + hasError: false, + retryCount: prevState.retryCount + 1 + })) + if (this.props.onRetry) { + this.props.onRetry() + } + }, delay) + } + } + + componentDidUpdate(prevProps: Props, prevState: State) { + // Reset retry count if error is cleared and component successfully rendered + if (prevState.hasError && !this.state.hasError && this.state.retryCount > 0) { + // Give it a moment to see if it stays error-free + setTimeout(() => { + if (!this.state.hasError) { + this.setState({ retryCount: 0 }) + } + }, 1000) + } + } + + componentWillUnmount() { + if (this.retryTimeoutId) { + clearTimeout(this.retryTimeoutId) + } + } handleRetry = () => { - this.setState({ hasError: false }) + if (this.retryTimeoutId) { + clearTimeout(this.retryTimeoutId) + } + this.setState({ hasError: false, retryCount: 0 }) if (this.props.onRetry) { this.props.onRetry() } @@ -43,6 +84,11 @@ export class ErrorBoundary extends Component {

Something went wrong loading this section

An error occurred while loading this component. Please try reloading it.

+ {this.props.autoRetry !== false && this.state.retryCount < (this.props.maxRetries || 3) && ( +

+ Retrying automatically... (Attempt {this.state.retryCount + 1} of {(this.props.maxRetries || 3) + 1}) +

+ )}
{(this.props.showRetry !== false) && (
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" ) : ( diff --git a/scheduling-remote/package.json b/scheduling-remote/package.json index 4d550a8..ba2c731 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": "./wait-and-serve.sh" }, "dependencies": { "@supabase/supabase-js": "^2.52.0", diff --git a/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx b/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx index 6f2744f..33d2235 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 @@ -36,22 +44,36 @@ 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, + phaseName, + experimentNumber, + repetitionNumber, + experimentId, + onScrollToRepetition, + onScheduleRepetition, + visibleMarkers, + getTimePosition, + isScheduledInDb = false, + children }: { left: number width: number top?: number + height: number isLocked: boolean allPhases: string times: string @@ -60,9 +82,77 @@ function RepetitionBorder({ onMouseDown?: (e: React.MouseEvent) => void isDragging?: boolean 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 + isScheduledInDb?: boolean + 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 + 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) + + // Muted styling for repetitions that have been fully scheduled in DB (gray out, but don't collapse) + const isMuted = isScheduledInDb && !isHovered && !isDragging + return (
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) */} +
+ {isScheduledInDb ? ( + <> + {experimentNumber !== undefined &&
{`Exp ${experimentNumber}`}
} + {repetitionNumber !== undefined &&
{`Rep ${repetitionNumber}`}
} + + ) : ( + <> + {phaseName &&
{phaseName}
} + {experimentNumber !== undefined &&
Exp {experimentNumber}
} + {repetitionNumber !== undefined &&
Rep {repetitionNumber}
} + + )} +
+ + {/* Go to repetition button */} + + + {/* Schedule button */} + {onScheduleRepetition && experimentId && ( + + )} +
+ )} +
) } @@ -97,29 +251,31 @@ export function HorizontalTimelineCalendar({ phaseMarkers, onMarkerDrag, onMarkerAssignConductors, - onMarkerLockToggle, - timeStep = 15, // 15 minutes per time slot - minHour = 6, - maxHour = 22, + repetitionMetadata = {}, + onScrollToRepetition, + onScheduleRepetition, + 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) - 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) @@ -141,12 +297,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 +314,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 +355,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 +373,7 @@ export function HorizontalTimelineCalendar({ } } }) - + // Observe after a brief delay to ensure ref is attached const observeTimeout = setTimeout(() => { if (containerRef.current) { @@ -260,71 +416,47 @@ 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 + // 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]) @@ -333,60 +465,71 @@ 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 - + 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 }) setDragPosition({ x: borderLeft }) + // Hide intersections when dragging starts + setShowIntersections(false) }, [phaseMarkers, getTimePosition]) // Handle repetition border drag start 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 (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 - + 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 }) setDragPosition({ x: borderLeft }) + // Hide intersections when dragging starts + setShowIntersections(false) }, [phaseMarkers]) // Handle mouse move during drag - only update visual position, save on mouse up @@ -400,11 +543,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,36 +560,41 @@ 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 }) setDragPosition(null) + + // Show intersections again after animation completes (200ms transition duration) + setTimeout(() => { + setShowIntersections(true) + }, 200) } window.addEventListener('mousemove', handleMouseMove) @@ -491,125 +639,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 +720,7 @@ export function HorizontalTimelineCalendar({ }} /> ))} - + {/* Render availability lines across all days */} {conductor.availability.map((avail, availIndex) => { // Get absolute positions from start of timeline @@ -704,7 +750,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 ( + + ) + })}
@@ -730,7 +843,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 +862,383 @@ 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 + // Sort by left position to process from left to right 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 + // Try to place in existing rows first 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 + // Check if this repetition overlaps with ANY repetition in this row 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) + // Two repetitions overlap if they share any horizontal space + // Overlap occurs when: rep.left < existingRep.right AND rep.right > existingRep.left + return rep.left < existingRep.right && rep.right > existingRep.left }) - + + // If no overlap with any repetition in this row, we can place it here if (!hasOverlap) { row.push(rep) placed = true break } } - - // If no row found, create a new row + + // If couldn't place in any existing row (due to overlaps), 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 + + // 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 + + // 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 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 + + // 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) : undefined} + 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} + + +
+ + {/* 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 + 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 ( +
+ ) + })} +
+ ) + }) + + 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)} - isDragging={draggingRepetition === 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} + isScheduledInDb={metadata?.isScheduledInDb} + onScrollToRepetition={onScrollToRepetition} + onScheduleRepetition={onScheduleRepetition} + visibleMarkers={borderMarkers} + getTimePosition={getTimePosition} + > + {markerElements} + ) })}
@@ -878,289 +1246,9 @@ 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 && ( -
-
-

- 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 81c16ee..fcdd8cb 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[]) @@ -253,44 +251,22 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => } const toggleRepetition = (repId: string) => { + // Checking/unchecking should only control visibility on the timeline. + // It must NOT clear scheduling info or conductor assignments. setSelectedRepetitionIds(prev => { const next = new Set(prev) if (next.has(repId)) { + // Hide this repetition from the timeline next.delete(repId) - // Remove from scheduled repetitions when unchecked - setScheduledRepetitions(prevScheduled => { - const newScheduled = { ...prevScheduled } - delete newScheduled[repId] - return newScheduled - }) - // Clear all related state when unchecked - setRepetitionsWithTimes(prev => { - const next = new Set(prev) - 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) - } + // Keep scheduledRepetitions and repetitionsWithTimes intact so that + // re-checking the box restores the repetition in the correct spot. } else { + // Show this repetition on the timeline 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 }) @@ -305,20 +281,14 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => const allSelected = allRepetitions.every(rep => prev.has(rep.id)) if (allSelected) { - // Deselect all repetitions in this phase + // Deselect all repetitions in this phase (hide from timeline only) const next = new Set(prev) allRepetitions.forEach(rep => { next.delete(rep.id) - // Remove from scheduled repetitions - setScheduledRepetitions(prevScheduled => { - const newScheduled = { ...prevScheduled } - delete newScheduled[rep.id] - return newScheduled - }) }) return next } else { - // Select all repetitions in this phase + // Select all repetitions in this phase (show on timeline) const next = new Set(prev) allRepetitions.forEach(rep => { next.add(rep.id) @@ -356,7 +326,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 +334,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 +380,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) => { @@ -477,10 +450,11 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => let newScheduled = { ...prev } const clampToReasonableHours = (d: Date) => { + // Allow full 24 hours (midnight to midnight) const min = new Date(d) - min.setHours(5, 0, 0, 0) + min.setHours(0, 0, 0, 0) const max = new Date(d) - max.setHours(23, 0, 0, 0) + max.setHours(23, 59, 59, 999) const t = d.getTime() return new Date(Math.min(Math.max(t, min.getTime()), max.getTime())) } @@ -536,13 +510,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' @@ -552,7 +523,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' @@ -563,7 +534,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' @@ -573,7 +544,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(() => { @@ -609,15 +580,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 }) } @@ -625,24 +597,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 @@ -652,8 +616,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', @@ -674,17 +638,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)) @@ -756,6 +720,51 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => } } + // Unschedule a repetition: clear its scheduling info and unassign all conductors. + const unscheduleRepetition = async (repId: string, experimentId: string) => { + setSchedulingRepetitions(prev => new Set(prev).add(repId)) + + try { + // Remove all conductor assignments for this repetition + removeRepetitionAssignments(repId) + + // Clear scheduled_date on the repetition in local state + setRepetitionsByExperiment(prev => ({ + ...prev, + [experimentId]: prev[experimentId]?.map(r => + r.id === repId ? { ...r, scheduled_date: null } : r + ) || [] + })) + + // Clear scheduled times for this repetition so it disappears from the timeline + setScheduledRepetitions(prev => { + const next = { ...prev } + delete next[repId] + return next + }) + + // This repetition no longer has active times + setRepetitionsWithTimes(prev => { + const next = new Set(prev) + next.delete(repId) + return next + }) + + // Also clear scheduled_date in the database for this repetition + await repetitionManagement.updateRepetition(repId, { + scheduled_date: null + }) + } catch (error: any) { + setError(error?.message || 'Failed to unschedule repetition') + } finally { + setSchedulingRepetitions(prev => { + const next = new Set(prev) + next.delete(repId) + return next + }) + } + } + // Restore scroll position after scheduledRepetitions changes useEffect(() => { if (scrollPositionRef.current) { @@ -806,11 +815,15 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => phase: 'soaking' | 'airdrying' | 'cracking' startTime: Date assignedConductors: string[] - locked: boolean }> = [] Object.values(scheduledRepetitions).forEach(scheduled => { const repId = scheduled.repetitionId + // Only include markers for repetitions that are checked (selected) + if (!selectedRepetitionIds.has(repId)) { + return + } + const markerIdPrefix = repId if (scheduled.soakingStart) { @@ -820,8 +833,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`] || [] }) } @@ -832,8 +844,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`] || [] }) } @@ -844,8 +855,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`] || [] }) } }) @@ -856,7 +866,66 @@ 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, selectedRepetitionIds]) + + // Build repetition metadata mapping for timeline display + const repetitionMetadata = useMemo(() => { + const metadata: Record = {} + + Object.values(scheduledRepetitions).forEach(scheduled => { + const repId = scheduled.repetitionId + // Only include metadata for repetitions that are checked (selected) + if (!selectedRepetitionIds.has(repId)) { + return + } + + 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, + // Consider a repetition \"scheduled\" in DB if it has a non-null scheduled_date + isScheduledInDb: Boolean(repetition.scheduled_date) + } + } + }) + + return metadata + }, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, phases, selectedRepetitionIds]) + + // 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) => { @@ -878,21 +947,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 ( @@ -1027,7 +1081,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} @@ -1196,11 +1252,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 */} - {/* Time points (shown only if has been dropped/moved) */} - {hasTimes && scheduled && ( + {/* Time points (shown whenever the repetition has scheduled times) */} + {scheduled && (
-
- 💧 - Soaking: {formatTime(scheduled.soakingStart)} -
-
- 🌬️ - Airdrying: {formatTime(scheduled.airdryingStart)} -
-
- - Cracking: {formatTime(scheduled.crackingStart)} -
+ {(() => { + const repId = rep.id + const markerIdPrefix = repId + + // Get assigned conductors for each phase + const soakingConductors = conductorAssignments[`${markerIdPrefix}-soaking`] || [] + const airdryingConductors = conductorAssignments[`${markerIdPrefix}-airdrying`] || [] + const crackingConductors = conductorAssignments[`${markerIdPrefix}-cracking`] || [] + + // Helper to get conductor names + const getConductorNames = (conductorIds: string[]) => { + return conductorIds.map(id => { + const conductor = conductors.find(c => c.id === id) + if (!conductor) return null + return [conductor.first_name, conductor.last_name].filter(Boolean).join(' ') || conductor.email + }).filter(Boolean).join(', ') + } + + return ( + <> +
+ 💧 + Soaking: {formatTime(scheduled.soakingStart)} + {soakingConductors.length > 0 && ( + + ({getConductorNames(soakingConductors)}) + + )} +
+
+ 🌬️ + Airdrying: {formatTime(scheduled.airdryingStart)} + {airdryingConductors.length > 0 && ( + + ({getConductorNames(airdryingConductors)}) + + )} +
+
+ + Cracking: {formatTime(scheduled.crackingStart)} + {crackingConductors.length > 0 && ( + + ({getConductorNames(crackingConductors)}) + + )} +
+ + ) + })()} - {/* Lock checkbox and Schedule button */} + {/* Remove Assignments button and Schedule/Unschedule button */}
- - + {hasAssignments && ( + + )} + {rep.scheduled_date ? ( + + ) : ( + + )}
)} diff --git a/scheduling-remote/wait-and-serve.sh b/scheduling-remote/wait-and-serve.sh new file mode 100755 index 0000000..adfdce0 --- /dev/null +++ b/scheduling-remote/wait-and-serve.sh @@ -0,0 +1,57 @@ +#!/bin/sh + +# Build the project first +echo "Building scheduling-remote..." +npm run build + +# Verify the initial build created remoteEntry.js +REMOTE_ENTRY_PATH="dist/assets/remoteEntry.js" +if [ ! -f "$REMOTE_ENTRY_PATH" ]; then + echo "ERROR: Initial build did not create remoteEntry.js!" + exit 1 +fi + +echo "Initial build complete. remoteEntry.js exists." + +# Start build:watch in the background +echo "Starting build:watch in background..." +npm run build:watch & +BUILD_WATCH_PID=$! + +# Wait a moment for build:watch to start and potentially rebuild +echo "Waiting for build:watch to stabilize..." +sleep 3 + +# Verify remoteEntry.js still exists (build:watch might have rebuilt it) +MAX_WAIT=30 +WAIT_COUNT=0 +while [ ! -f "$REMOTE_ENTRY_PATH" ] && [ $WAIT_COUNT -lt $MAX_WAIT ]; do + sleep 1 + WAIT_COUNT=$((WAIT_COUNT + 1)) + if [ $((WAIT_COUNT % 5)) -eq 0 ]; then + echo "Waiting for remoteEntry.js after build:watch... (${WAIT_COUNT}s)" + fi +done + +if [ ! -f "$REMOTE_ENTRY_PATH" ]; then + echo "ERROR: remoteEntry.js was not available after ${MAX_WAIT} seconds!" + kill $BUILD_WATCH_PID 2>/dev/null || true + exit 1 +fi + +# Wait a bit more to ensure build:watch has finished any initial rebuild +echo "Ensuring build:watch has completed initial build..." +sleep 2 + +# Check file size to ensure it's not empty or being written +FILE_SIZE=$(stat -f%z "$REMOTE_ENTRY_PATH" 2>/dev/null || stat -c%s "$REMOTE_ENTRY_PATH" 2>/dev/null || echo "0") +if [ "$FILE_SIZE" -lt 100 ]; then + echo "WARNING: remoteEntry.js seems too small (${FILE_SIZE} bytes), waiting a bit more..." + sleep 2 +fi + +echo "remoteEntry.js is ready (${FILE_SIZE} bytes). Starting http-server..." + +# Start http-server and give it time to fully initialize +# Use a simple approach: start server and wait a moment for it to be ready +exec npx http-server dist -p 3003 --cors -c-1 diff --git a/scripts/docker-compose-reset.sh b/scripts/docker-compose-reset.sh index 8183614..853563b 100755 --- a/scripts/docker-compose-reset.sh +++ b/scripts/docker-compose-reset.sh @@ -60,57 +60,69 @@ echo "4. Rebuilding and starting all services in detached mode..." docker compose up --build -d echo "" -echo "5. Waiting for Supabase database to be ready..." -# Wait for database to be healthy -MAX_WAIT=60 -WAIT_COUNT=0 -while [ $WAIT_COUNT -lt $MAX_WAIT ]; do - if docker compose ps supabase-db | grep -q "healthy"; then - echo " ✓ Supabase database is healthy" - break - fi - echo " Waiting for database... ($WAIT_COUNT/$MAX_WAIT seconds)" - sleep 2 - WAIT_COUNT=$((WAIT_COUNT + 2)) -done +echo "5. Waiting for Supabase database to be ready (if configured)..." -if [ $WAIT_COUNT -ge $MAX_WAIT ]; then - echo " ⚠ Warning: Database may not be fully ready" +# Only wait for Supabase if the supabase-db service exists in docker-compose.yml +if docker compose config --services 2>/dev/null | grep -q "^supabase-db$"; then + # Wait for database to be healthy + MAX_WAIT=60 + WAIT_COUNT=0 + while [ $WAIT_COUNT -lt $MAX_WAIT ]; do + if docker compose ps supabase-db | grep -q "healthy"; then + echo " ✓ Supabase database is healthy" + break + fi + echo " Waiting for database... ($WAIT_COUNT/$MAX_WAIT seconds)" + sleep 2 + WAIT_COUNT=$((WAIT_COUNT + 2)) + done + + if [ $WAIT_COUNT -ge $MAX_WAIT ]; then + echo " ⚠ Warning: Database may not be fully ready" + fi +else + echo " - Supabase services are currently disabled in docker-compose.yml; skipping DB wait step." fi echo "" -echo "6. Waiting for Supabase migrations to complete..." -# Wait for migration container to complete (it has restart: "no", so it should exit when done) -MAX_WAIT=120 -WAIT_COUNT=0 -MIGRATE_CONTAINER="usda-vision-supabase-migrate" +echo "6. Waiting for Supabase migrations to complete (if configured)..." -while [ $WAIT_COUNT -lt $MAX_WAIT ]; do - # Check if container exists and its status - if docker ps -a --format "{{.Names}}\t{{.Status}}" | grep -q "^${MIGRATE_CONTAINER}"; then - CONTAINER_STATUS=$(docker ps -a --format "{{.Names}}\t{{.Status}}" | grep "^${MIGRATE_CONTAINER}" | awk '{print $2}') - - if echo "$CONTAINER_STATUS" | grep -q "Exited"; then - EXIT_CODE=$(docker inspect "$MIGRATE_CONTAINER" --format='{{.State.ExitCode}}' 2>/dev/null || echo "1") - if [ "$EXIT_CODE" = "0" ]; then - echo " ✓ Supabase migrations completed successfully" - break - else - echo " ⚠ Warning: Migrations may have failed (exit code: $EXIT_CODE)" - echo " Check logs with: docker compose logs supabase-migrate" - break +# Only wait for the migration container if the supabase-migrate service exists +if docker compose config --services 2>/dev/null | grep -q "^supabase-migrate$"; then + # Wait for migration container to complete (it has restart: "no", so it should exit when done) + MAX_WAIT=120 + WAIT_COUNT=0 + MIGRATE_CONTAINER="usda-vision-supabase-migrate" + + while [ $WAIT_COUNT -lt $MAX_WAIT ]; do + # Check if container exists and its status + if docker ps -a --format "{{.Names}}\t{{.Status}}" | grep -q "^${MIGRATE_CONTAINER}"; then + CONTAINER_STATUS=$(docker ps -a --format "{{.Names}}\t{{.Status}}" | grep "^${MIGRATE_CONTAINER}" | awk '{print $2}') + + if echo "$CONTAINER_STATUS" | grep -q "Exited"; then + EXIT_CODE=$(docker inspect "$MIGRATE_CONTAINER" --format='{{.State.ExitCode}}' 2>/dev/null || echo "1") + if [ "$EXIT_CODE" = "0" ]; then + echo " ✓ Supabase migrations completed successfully" + break + else + echo " ⚠ Warning: Migrations may have failed (exit code: $EXIT_CODE)" + echo " Check logs with: docker compose logs supabase-migrate" + break + fi fi fi - fi - - echo " Waiting for migrations... ($WAIT_COUNT/$MAX_WAIT seconds)" - sleep 2 - WAIT_COUNT=$((WAIT_COUNT + 2)) -done + + echo " Waiting for migrations... ($WAIT_COUNT/$MAX_WAIT seconds)" + sleep 2 + WAIT_COUNT=$((WAIT_COUNT + 2)) + done -if [ $WAIT_COUNT -ge $MAX_WAIT ]; then - echo " ⚠ Warning: Migration timeout - check logs with: docker compose logs supabase-migrate" - echo " Note: Migrations may still be running or the container may not have started yet" + if [ $WAIT_COUNT -ge $MAX_WAIT ]; then + echo " ⚠ Warning: Migration timeout - check logs with: docker compose logs supabase-migrate" + echo " Note: Migrations may still be running or the container may not have started yet" + fi +else + echo " - Supabase migration service is currently disabled in docker-compose.yml; skipping migration wait step." fi echo ""