Refactor HorizontalTimelineCalendar and Scheduling components for improved functionality

- Updated HorizontalTimelineCalendar to support full 24-hour scheduling with enhanced marker positioning and dragging capabilities.
- Introduced extendLeft and extendRight properties in RepetitionBorder for better visual representation of markers extending beyond their borders.
- Enhanced tooltip functionality for vertical lines in the timeline, providing clearer time information.
- Modified Scheduling component to allow scheduling from midnight to midnight, improving user experience in time selection.
- Adjusted Docker script permissions for better execution control.
This commit is contained in:
salirezav
2026-01-13 14:41:48 -05:00
parent a3f18b2186
commit 0c434e7e7f
3 changed files with 411 additions and 455 deletions

View File

@@ -40,6 +40,7 @@ function RepetitionBorder({
left, left,
width, width,
top = 0, top = 0,
height,
isLocked, isLocked,
allPhases, allPhases,
times, times,
@@ -47,11 +48,15 @@ function RepetitionBorder({
repId, repId,
onMouseDown, onMouseDown,
isDragging = false, isDragging = false,
dragOffset = { x: 0 } dragOffset = { x: 0 },
extendLeft = false,
extendRight = false,
children
}: { }: {
left: number left: number
width: number width: number
top?: number top?: number
height: number
isLocked: boolean isLocked: boolean
allPhases: string allPhases: string
times: string times: string
@@ -60,9 +65,23 @@ function RepetitionBorder({
onMouseDown?: (e: React.MouseEvent) => void onMouseDown?: (e: React.MouseEvent) => void
isDragging?: boolean isDragging?: boolean
dragOffset?: { x: number } dragOffset?: { x: number }
extendLeft?: boolean
extendRight?: boolean
children?: React.ReactNode
}) { }) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
// Build border style based on extensions
// Border radius: top-left top-right bottom-right bottom-left
const borderRadius = extendLeft && extendRight
? '0px' // No radius if extending both sides
: extendLeft
? '0 8px 8px 0' // No radius on left side (markers extend to past)
: extendRight
? '8px 0 0 8px' // No radius on right side (markers extend to future)
: '8px' // Full radius (default)
return ( return (
<div <div
className={`absolute transition-all duration-200 ease-in-out ${isLocked ? 'cursor-not-allowed' : 'cursor-grab'} active:cursor-grabbing`} className={`absolute transition-all duration-200 ease-in-out ${isLocked ? 'cursor-not-allowed' : 'cursor-grab'} active:cursor-grabbing`}
@@ -70,23 +89,29 @@ function RepetitionBorder({
left: `${left}px`, left: `${left}px`,
width: `${width}px`, width: `${width}px`,
top: `${top}px`, top: `${top}px`,
height: '100%', height: `${height}px`,
border: '2px solid', border: '2px solid',
borderLeft: extendLeft ? 'none' : '2px solid',
borderRight: extendRight ? 'none' : '2px solid',
borderColor: isHovered borderColor: isHovered
? (isLocked ? 'rgba(156, 163, 175, 0.9)' : 'rgba(59, 130, 246, 0.9)') ? (isLocked ? 'rgba(156, 163, 175, 0.9)' : 'rgba(59, 130, 246, 0.9)')
: (isLocked ? 'rgba(156, 163, 175, 0.3)' : 'rgba(59, 130, 246, 0.3)'), : (isLocked ? 'rgba(156, 163, 175, 0.3)' : 'rgba(59, 130, 246, 0.3)'),
borderRadius: '8px', borderRadius: borderRadius,
backgroundColor: 'transparent', backgroundColor: 'transparent',
zIndex: isDragging ? 10 : 2, zIndex: isDragging ? 10 : 2,
opacity: isDragging ? 0.7 : 1, opacity: isDragging ? 0.7 : 1,
transition: isDragging ? 'none' : 'left 0.2s ease-out, border-color 0.2s ease-in-out', transition: isDragging ? 'none' : 'left 0.2s ease-out, border-color 0.2s ease-in-out',
pointerEvents: 'auto' pointerEvents: 'auto',
position: 'relative',
overflow: 'visible'
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
title={`Repetition ${repId}: ${allPhases} at ${times}${assignedCount > 0 ? ` (${assignedCount} conductors assigned)` : ''}`} title={`Repetition ${repId}: ${allPhases} at ${times}${assignedCount > 0 ? ` (${assignedCount} conductors assigned)` : ''}`}
/> >
{children}
</div>
) )
} }
@@ -98,9 +123,9 @@ export function HorizontalTimelineCalendar({
onMarkerDrag, onMarkerDrag,
onMarkerAssignConductors, onMarkerAssignConductors,
onMarkerLockToggle, onMarkerLockToggle,
timeStep = 15, // 15 minutes per time slot timeStep = 60, // 60 minutes (1 hour) per time slot for 24 divisions
minHour = 6, minHour = 0,
maxHour = 22, maxHour = 24,
dayWidth // Width per day in pixels (optional - if not provided, will be calculated) dayWidth // Width per day in pixels (optional - if not provided, will be calculated)
}: HorizontalTimelineCalendarProps) { }: HorizontalTimelineCalendarProps) {
const CONDUCTOR_NAME_COLUMN_WIDTH = 128 // Width of conductor name column (w-32 = 128px) const CONDUCTOR_NAME_COLUMN_WIDTH = 128 // Width of conductor name column (w-32 = 128px)
@@ -114,6 +139,7 @@ export function HorizontalTimelineCalendar({
const [assignmentPanelPosition, setAssignmentPanelPosition] = useState<{ x: number; y: number } | null>(null) const [assignmentPanelPosition, setAssignmentPanelPosition] = useState<{ x: number; y: number } | null>(null)
const [hoveredAvailability, setHoveredAvailability] = useState<string | null>(null) // Format: "conductorId-availIndex" const [hoveredAvailability, setHoveredAvailability] = useState<string | null>(null) // Format: "conductorId-availIndex"
const [hoveredVerticalLine, setHoveredVerticalLine] = useState<string | null>(null) // Marker ID const [hoveredVerticalLine, setHoveredVerticalLine] = useState<string | null>(null) // Marker ID
const [verticalLineTooltip, setVerticalLineTooltip] = useState<{ markerId: string; x: number; y: number; time: string } | null>(null) // Tooltip position and data
const [draggingRepetition, setDraggingRepetition] = useState<string | null>(null) // Repetition ID being dragged const [draggingRepetition, setDraggingRepetition] = useState<string | null>(null) // Repetition ID being dragged
const [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 }) const [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 })
const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position
@@ -283,16 +309,14 @@ export function HorizontalTimelineCalendar({
} }
}, [selectedMarker, assignmentPanelPosition]) }, [selectedMarker, assignmentPanelPosition])
// Generate time slots for a day // Generate time slots for a day - 24 hours, one slot per hour
const timeSlots = useMemo(() => { const timeSlots = useMemo(() => {
const slots: string[] = [] const slots: string[] = []
for (let hour = minHour; hour < maxHour; hour++) { for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += timeStep) { slots.push(`${hour.toString().padStart(2, '0')}:00`)
slots.push(`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`)
}
} }
return slots return slots
}, [minHour, maxHour, timeStep]) }, [])
// Calculate pixel position for a given date/time // Calculate pixel position for a given date/time
const getTimePosition = useCallback((date: Date): number => { const getTimePosition = useCallback((date: Date): number => {
@@ -302,12 +326,12 @@ export function HorizontalTimelineCalendar({
if (dayIndex === -1) return 0 if (dayIndex === -1) return 0
const dayStart = new Date(date) 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 minutesFromStart = (date.getTime() - dayStart.getTime()) / (1000 * 60)
const slotIndex = minutesFromStart / timeStep // Use fractional for smoother positioning const slotIndex = minutesFromStart / 60 // 60 minutes per hour slot
const slotWidth = effectiveDayWidth / (timeSlots.length) const slotWidth = effectiveDayWidth / 24 // 24 hours per day
return dayIndex * effectiveDayWidth + slotIndex * slotWidth return dayIndex * effectiveDayWidth + slotIndex * slotWidth
}, [days, timeSlots, minHour, timeStep, effectiveDayWidth]) }, [days, timeSlots, minHour, timeStep, effectiveDayWidth])
@@ -315,13 +339,13 @@ export function HorizontalTimelineCalendar({
// Convert pixel position to date/time // Convert pixel position to date/time
const getTimeFromPosition = useCallback((x: number, dayIndex: number): Date => { const getTimeFromPosition = useCallback((x: number, dayIndex: number): Date => {
const dayStart = new Date(days[dayIndex]) 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 relativeX = x - (dayIndex * effectiveDayWidth)
const slotWidth = effectiveDayWidth / timeSlots.length const slotWidth = effectiveDayWidth / 24 // 24 hours per day
const slotIndex = Math.max(0, Math.min(timeSlots.length - 1, Math.floor(relativeX / slotWidth))) const slotIndex = Math.max(0, Math.min(23, Math.floor(relativeX / slotWidth)))
const minutes = slotIndex * timeStep const minutes = slotIndex * 60 // 60 minutes per hour
const result = new Date(dayStart) const result = new Date(dayStart)
result.setMinutes(result.getMinutes() + minutes) result.setMinutes(result.getMinutes() + minutes)
@@ -428,12 +452,12 @@ export function HorizontalTimelineCalendar({
const leftmostMarkerNewX = borderX + borderPadding const leftmostMarkerNewX = borderX + borderPadding
const dayIndex = Math.max(0, Math.min(days.length - 1, Math.floor(leftmostMarkerNewX / effectiveDayWidth))) const dayIndex = Math.max(0, Math.min(days.length - 1, Math.floor(leftmostMarkerNewX / effectiveDayWidth)))
const relativeX = leftmostMarkerNewX - (dayIndex * effectiveDayWidth) const relativeX = leftmostMarkerNewX - (dayIndex * effectiveDayWidth)
const slotWidth = effectiveDayWidth / timeSlots.length const slotWidth = effectiveDayWidth / 24 // 24 hours per day
const slotIndex = Math.max(0, Math.min(timeSlots.length - 1, Math.round(relativeX / slotWidth))) const slotIndex = Math.max(0, Math.min(23, Math.round(relativeX / slotWidth)))
const dayStart = new Date(days[dayIndex]) const dayStart = new Date(days[dayIndex])
dayStart.setHours(minHour, 0, 0, 0) dayStart.setHours(0, 0, 0, 0) // Start at midnight
const minutes = slotIndex * timeStep const minutes = slotIndex * 60 // 60 minutes per hour
const finalTime = new Date(dayStart) const finalTime = new Date(dayStart)
finalTime.setMinutes(finalTime.getMinutes() + minutes) finalTime.setMinutes(finalTime.getMinutes() + minutes)
@@ -491,125 +515,23 @@ export function HorizontalTimelineCalendar({
return ( return (
<div ref={containerRef} className="w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden" style={{ position: 'relative' }}> <div ref={containerRef} className="w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden" style={{ position: 'relative' }}>
<div className="relative"> <div className="relative">
{/* Vertical lines layer - positioned outside overflow containers */} {/* Tooltip for vertical lines */}
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 45 }}> {verticalLineTooltip && (
{(() => {
// Group markers by repetition to calculate row positions
const markersByRepetition: Record<string, typeof phaseMarkers> = {}
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<typeof d> => d !== null)
const ROW_HEIGHT = 40
const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left)
const repetitionRows: Array<Array<typeof repetitionData[0]>> = []
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<string, number> = {}
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 (
<div <div
key={`vertical-line-${marker.id}`} className="fixed pointer-events-none z-[10001] bg-gray-900 dark:bg-gray-700 text-white text-xs px-2 py-1 rounded shadow-lg whitespace-nowrap"
className="absolute pointer-events-auto cursor-pointer transition-all duration-200 ease-in-out"
style={{ style={{
left: `${CONDUCTOR_NAME_COLUMN_WIDTH + lineX}px`, left: `${verticalLineTooltip.x}px`,
top: `${lineTop}px`, top: `${verticalLineTooltip.y - 35}px`,
width: isVerticalLineHovered ? '4px' : '2px',
height: `${lineHeight}px`,
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
backgroundColor: marker.locked ? '#9ca3af' : style.color,
opacity: isVerticalLineHovered ? 0.7 : 0.4,
borderRadius: '2px',
transition: isDragging ? 'none' : 'width 0.2s ease-in-out, opacity 0.2s ease-in-out, left 0.2s ease-out'
}} }}
onMouseEnter={() => setHoveredVerticalLine(marker.id)} >
onMouseLeave={() => setHoveredVerticalLine(null)} {verticalLineTooltip.time}
title={moment(marker.startTime).format('h:mm A')} {/* Tooltip arrow */}
<div
className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
/> />
)
})
})()}
</div> </div>
)}
{/* Row 1: Day Headers */} {/* Row 1: Day Headers */}
<div className="sticky top-0 z-50 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 flex"> <div className="sticky top-0 z-50 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 flex">
@@ -738,7 +660,7 @@ export function HorizontalTimelineCalendar({
}} }}
/> />
{/* Scrollable background time grid */} {/* Scrollable background time grid */}
<div className="flex-1 relative overflow-x-auto scroll-sync" style={{ scrollBehavior: 'smooth' }}> <div className="flex-1 relative overflow-x-auto scroll-sync" style={{ scrollBehavior: 'smooth', overflow: 'visible' }}>
{/* Fixed width based only on visible days - never extends */} {/* Fixed width based only on visible days - never extends */}
<div className="relative" style={{ width: `${days.length * effectiveDayWidth}px`, height: '100%' }}> <div className="relative" style={{ width: `${days.length * effectiveDayWidth}px`, height: '100%' }}>
{days.map((day, dayIndex) => ( {days.map((day, dayIndex) => (
@@ -749,26 +671,43 @@ export function HorizontalTimelineCalendar({
left: `${dayIndex * effectiveDayWidth}px`, left: `${dayIndex * effectiveDayWidth}px`,
width: `${effectiveDayWidth}px`, width: `${effectiveDayWidth}px`,
height: '100%', 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 markers by repetition ID and calculate vertical stacking */}
{(() => { {(() => {
// Group only visible markers by repetition ID // Group ALL markers (not just visible) by repetition ID to check boundaries
const markersByRepetition: Record<string, typeof visibleMarkers> = {} const allMarkersByRepetition: Record<string, typeof phaseMarkers> = {}
visibleMarkers.forEach(marker => { phaseMarkers.forEach(marker => {
if (!markersByRepetition[marker.repetitionId]) { if (!allMarkersByRepetition[marker.repetitionId]) {
markersByRepetition[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<string, typeof visibleMarkers> = {}
visibleMarkers.forEach(marker => {
if (!visibleMarkersByRepetition[marker.repetitionId]) {
visibleMarkersByRepetition[marker.repetitionId] = []
}
visibleMarkersByRepetition[marker.repetitionId].push(marker)
}) })
// Calculate positions for each repetition
const borderPadding = 20 const borderPadding = 20
const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => { const MARKER_ICON_SIZE = 32
const positions = markers.map(m => { 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 => const dayIndex = days.findIndex(d =>
d.toDateString() === new Date(m.startTime).toDateString() d.toDateString() === new Date(m.startTime).toDateString()
) )
@@ -776,41 +715,76 @@ export function HorizontalTimelineCalendar({
return getTimePosition(m.startTime) return getTimePosition(m.startTime)
}).filter((p): p is number => p !== null) }).filter((p): p is number => p !== null)
if (positions.length === 0) return null if (visiblePositions.length === 0) return null
const leftmost = Math.min(...positions) // Get positions of ALL markers (including those outside viewport)
const rightmost = Math.max(...positions) const allPositions = allMarkers.map(m => {
const borderLeft = leftmost - borderPadding // Check if marker is in viewport date range
const borderWidth = (rightmost - leftmost) + (borderPadding * 2) const markerDate = new Date(m.startTime)
const borderRight = borderLeft + borderWidth 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 { return {
repId, repId,
markers, visibleMarkers,
allMarkers,
left: borderLeft, left: borderLeft,
right: borderRight,
width: borderWidth, width: borderWidth,
leftmostMarkerPos: leftmost, right: borderLeft + borderWidth,
rightmostMarkerPos: rightmost extendLeft: hasMarkersBefore,
extendRight: hasMarkersAfter
} }
}).filter((d): d is NonNullable<typeof d> => d !== null) }).filter((d): d is NonNullable<typeof d> => d !== null)
// Calculate vertical stacking positions // Calculate vertical stacking positions
// Sort repetitions by left position to process them in order
const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left) const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left)
const ROW_HEIGHT = 40 // Height allocated per row
const repetitionRows: Array<Array<typeof repetitionData[0]>> = [] const repetitionRows: Array<Array<typeof repetitionData[0]>> = []
sortedRepetitions.forEach(rep => { sortedRepetitions.forEach(rep => {
// Find the first row where this repetition doesn't overlap
let placed = false let placed = false
for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) { for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) {
const row = repetitionRows[rowIndex] const row = repetitionRows[rowIndex]
// Check if this repetition overlaps with any in this row
// Two repetitions overlap if they share any horizontal space
// They don't overlap if one is completely to the left or right of the other
const hasOverlap = row.some(existingRep => { const hasOverlap = row.some(existingRep => {
// Add a small threshold to avoid edge cases
const threshold = 1 const threshold = 1
return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right) return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right)
}) })
@@ -822,180 +796,91 @@ export function HorizontalTimelineCalendar({
} }
} }
// If no row found, create a new row
if (!placed) { if (!placed) {
repetitionRows.push([rep]) repetitionRows.push([rep])
} }
}) })
// Render all repetitions with their vertical positions // Render all repetitions - items stick to top, not distributed
// Use flexbox to stack rows and share vertical space equally
return ( return (
<div <div
key="repetition-rows-container" key="repetition-rows-container"
style={{ style={{
height: '100%', height: '100%',
position: 'relative', position: 'relative',
width: '100%', width: '100%'
display: 'flex',
flexDirection: 'column'
}} }}
> >
{repetitionRows.map((row, rowIndex) => ( {repetitionRows.map((row, rowIndex) => (
<div <div
key={`row-${rowIndex}`} key={`row-${rowIndex}`}
style={{ style={{
flex: 1,
position: 'relative', position: 'relative',
minHeight: 0 // Allow flex items to shrink below content size minHeight: ROW_HEIGHT,
height: 'auto'
}} }}
> >
{row.map(rep => { {row.map(rep => {
const firstMarker = rep.markers[0] const firstMarker = rep.visibleMarkers[0]
const allPhases = rep.markers.map(m => getPhaseStyle(m.phase).label).join(', ') const allPhases = rep.visibleMarkers.map(m => getPhaseStyle(m.phase).label).join(', ')
const times = rep.markers.map(m => moment(m.startTime).format('h:mm A')).join(', ') const times = rep.visibleMarkers.map(m => moment(m.startTime).format('h:mm A')).join(', ')
const totalAssigned = new Set(rep.markers.flatMap(m => m.assignedConductors)).size const totalAssigned = new Set(rep.visibleMarkers.flatMap(m => m.assignedConductors)).size
return ( // Calculate height based on markers (just enough for markers)
<RepetitionBorder const repHeight = MARKER_HEIGHT
key={rep.repId}
left={draggingRepetition === rep.repId && dragPosition ? dragPosition.x : rep.left}
width={rep.width}
top={0}
isLocked={firstMarker.locked}
allPhases={allPhases}
times={times}
assignedCount={totalAssigned}
repId={rep.repId}
onMouseDown={(e) => handleRepetitionMouseDown(e, rep.repId)}
isDragging={draggingRepetition === rep.repId}
dragOffset={repetitionDragOffset}
/>
)
})}
</div>
))}
</div>
)
})()}
{/* Phase markers - positioned relative to their repetition's row */} // Calculate if dragging
{(() => { const isDragging = draggingRepetition === rep.repId
// Group only visible markers by repetition to find their row positions const currentLeft = isDragging && dragPosition ? dragPosition.x : rep.left
const markersByRepetition: Record<string, typeof visibleMarkers> = {}
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 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 // Render markers inside the repetition border
const markerElements = rep.visibleMarkers.map((marker) => {
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<typeof d> => 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<Array<typeof repetitionData[0]>> = []
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<string, number> = {}
repetitionRows.forEach((row, rowIndex) => {
row.forEach(rep => {
repIdToRowIndex[rep.repId] = rowIndex
})
})
return visibleMarkers.map((marker) => {
const style = getPhaseStyle(marker.phase) 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 absoluteX = getTimePosition(marker.startTime)
const isDragging = draggingRepetition === marker.repetitionId
const isSelected = selectedMarker === marker.id 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 const isVerticalLineHovered = hoveredVerticalLine === marker.id
// Calculate marker position - if dragging, maintain relative position to border // Calculate marker position relative to repetition border's left edge
let markerLeft = absoluteX // 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) { if (isDragging && dragPosition) {
const repData = repetitionData.find(r => r.repId === marker.repetitionId) // Calculate offset from the original leftmost position
if (repData) { const originalLeftmost = Math.min(...rep.visibleMarkers.map(m => getTimePosition(m.startTime)))
// Calculate offset from leftmost marker const offsetFromLeftmost = absoluteX - originalLeftmost
const leftmostMarker = repData.markers.reduce((prev, curr) => markerLeftRelative = offsetFromLeftmost
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
}
} }
// 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 ( return (
<div <div
key={marker.id} key={marker.id}
data-marker-id={marker.id} data-marker-id={marker.id}
className={`absolute ${marker.locked ? 'cursor-not-allowed' : 'cursor-grab'} active:cursor-grabbing transition-all ${ className={`absolute ${marker.locked ? 'cursor-not-allowed' : 'cursor-grab'} active:cursor-grabbing transition-all ${isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''
isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''
} ${isDragging ? 'opacity-50 z-50' : 'z-40'}`} } ${isDragging ? 'opacity-50 z-50' : 'z-40'}`}
style={{ style={{
left: `${markerLeft}px`, left: `${markerLeftRelative}px`,
top: `${topOffset}px`, top: `${MARKER_TOP_OFFSET}px`,
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
transition: isDragging ? 'none' : 'left 0.2s ease-out' transition: isDragging ? 'none' : 'left 0.2s ease-out',
overflow: 'visible'
}} }}
onMouseDown={(e) => handleMarkerMouseDown(e, marker.id)} onMouseDown={(e) => handleMarkerMouseDown(e, marker.id)}
onMouseEnter={() => { onMouseEnter={() => {
@@ -1008,6 +893,46 @@ export function HorizontalTimelineCalendar({
}} }}
title={`${style.label} - ${moment(marker.startTime).format('MMM D, h:mm A')}${marker.assignedConductors.length > 0 ? ` (${marker.assignedConductors.length} assigned)` : ''}`} 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 */}
<div
className="absolute pointer-events-auto cursor-pointer transition-all duration-200 ease-in-out"
style={{
left: '50%',
top: `${lineTop}px`,
width: isVerticalLineHovered ? '4px' : '2px',
height: `${lineHeight}px`,
transform: 'translateX(-50%)',
backgroundColor: marker.locked ? '#9ca3af' : style.color,
opacity: isVerticalLineHovered ? 0.9 : 0.4,
borderRadius: '2px',
zIndex: isDragging ? 30 : 10000,
transition: isDragging ? 'none' : 'width 0.2s ease-in-out, opacity 0.2s ease-in-out'
}}
onMouseEnter={(e) => {
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 */} {/* Small icon marker */}
<div <div
className="rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 relative" className="rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 relative"
@@ -1023,7 +948,7 @@ export function HorizontalTimelineCalendar({
{style.icon} {style.icon}
</span> </span>
{/* Assign Conductors button - top right corner */} {/* Assign Conductors button */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -1035,7 +960,7 @@ export function HorizontalTimelineCalendar({
if (containerRect) { if (containerRect) {
setSelectedMarker(marker.id) setSelectedMarker(marker.id)
setAssignmentPanelPosition({ setAssignmentPanelPosition({
x: markerRect.right - containerRect.left + 8, // 8px offset to the right x: markerRect.right - containerRect.left + 8,
y: markerRect.top - containerRect.top y: markerRect.top - containerRect.top
}) })
} }
@@ -1049,19 +974,22 @@ export function HorizontalTimelineCalendar({
</button> </button>
</div> </div>
{/* Connection indicators on assigned conductors (shown as dots on the vertical line) */} {/* Connection indicators on assigned conductors */}
{marker.assignedConductors.map((conductorId, lineIndex) => { {marker.assignedConductors.map((conductorId, lineIndex) => {
const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId) const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId)
if (conductorIndex === -1) return null if (conductorIndex === -1) return null
const CONDUCTOR_ROW_HEIGHT = 36 // Height of each conductor row const CONDUCTOR_ROW_HEIGHT = 36
const HEADER_ROW_HEIGHT = 60 // Approximate height of header row 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 conductorRowTop = HEADER_ROW_HEIGHT + (conductorIndex * CONDUCTOR_ROW_HEIGHT)
const conductorRowCenter = conductorRowTop + (CONDUCTOR_ROW_HEIGHT / 2) 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)
// Position dot at conductor row center (negative because it's above the marker) const dotY = -(markerCenterFromTop - conductorRowCenter)
// Distance from marker center to conductor row center
const dotY = -(totalHeightToMarker - conductorRowCenter)
return ( return (
<div <div
@@ -1085,6 +1013,33 @@ export function HorizontalTimelineCalendar({
</div> </div>
) )
}) })
return (
<RepetitionBorder
key={rep.repId}
left={currentLeft}
width={rep.width}
top={0}
height={repHeight}
isLocked={firstMarker.locked}
allPhases={allPhases}
times={times}
assignedCount={totalAssigned}
repId={rep.repId}
onMouseDown={(e) => handleRepetitionMouseDown(e, rep.repId)}
isDragging={isDragging}
dragOffset={repetitionDragOffset}
extendLeft={rep.extendLeft}
extendRight={rep.extendRight}
>
{markerElements}
</RepetitionBorder>
)
})}
</div>
))}
</div>
)
})()} })()}
</div> </div>
</div> </div>

View File

@@ -477,10 +477,11 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
let newScheduled = { ...prev } let newScheduled = { ...prev }
const clampToReasonableHours = (d: Date) => { const clampToReasonableHours = (d: Date) => {
// Allow full 24 hours (midnight to midnight)
const min = new Date(d) const min = new Date(d)
min.setHours(5, 0, 0, 0) min.setHours(0, 0, 0, 0)
const max = new Date(d) const max = new Date(d)
max.setHours(23, 0, 0, 0) max.setHours(23, 59, 59, 999)
const t = d.getTime() const t = d.getTime()
return new Date(Math.min(Math.max(t, min.getTime()), max.getTime())) return new Date(Math.min(Math.max(t, min.getTime()), max.getTime()))
} }

0
scripts/docker-compose-reset.sh Normal file → Executable file
View File