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:
@@ -40,6 +40,7 @@ function RepetitionBorder({
|
||||
left,
|
||||
width,
|
||||
top = 0,
|
||||
height,
|
||||
isLocked,
|
||||
allPhases,
|
||||
times,
|
||||
@@ -47,11 +48,15 @@ function RepetitionBorder({
|
||||
repId,
|
||||
onMouseDown,
|
||||
isDragging = false,
|
||||
dragOffset = { x: 0 }
|
||||
dragOffset = { x: 0 },
|
||||
extendLeft = false,
|
||||
extendRight = false,
|
||||
children
|
||||
}: {
|
||||
left: number
|
||||
width: number
|
||||
top?: number
|
||||
height: number
|
||||
isLocked: boolean
|
||||
allPhases: string
|
||||
times: string
|
||||
@@ -60,9 +65,23 @@ function RepetitionBorder({
|
||||
onMouseDown?: (e: React.MouseEvent) => void
|
||||
isDragging?: boolean
|
||||
dragOffset?: { x: number }
|
||||
extendLeft?: boolean
|
||||
extendRight?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
// Build border style based on extensions
|
||||
|
||||
// Border radius: top-left top-right bottom-right bottom-left
|
||||
const borderRadius = extendLeft && extendRight
|
||||
? '0px' // No radius if extending both sides
|
||||
: extendLeft
|
||||
? '0 8px 8px 0' // No radius on left side (markers extend to past)
|
||||
: extendRight
|
||||
? '8px 0 0 8px' // No radius on right side (markers extend to future)
|
||||
: '8px' // Full radius (default)
|
||||
|
||||
return (
|
||||
<div
|
||||
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`,
|
||||
width: `${width}px`,
|
||||
top: `${top}px`,
|
||||
height: '100%',
|
||||
height: `${height}px`,
|
||||
border: '2px solid',
|
||||
borderLeft: extendLeft ? 'none' : '2px solid',
|
||||
borderRight: extendRight ? 'none' : '2px solid',
|
||||
borderColor: isHovered
|
||||
? (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)'),
|
||||
borderRadius: '8px',
|
||||
borderRadius: borderRadius,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: isDragging ? 10 : 2,
|
||||
opacity: isDragging ? 0.7 : 1,
|
||||
transition: isDragging ? 'none' : 'left 0.2s ease-out, border-color 0.2s ease-in-out',
|
||||
pointerEvents: 'auto'
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onMouseDown={onMouseDown}
|
||||
title={`Repetition ${repId}: ${allPhases} at ${times}${assignedCount > 0 ? ` (${assignedCount} conductors assigned)` : ''}`}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,9 +123,9 @@ export function HorizontalTimelineCalendar({
|
||||
onMarkerDrag,
|
||||
onMarkerAssignConductors,
|
||||
onMarkerLockToggle,
|
||||
timeStep = 15, // 15 minutes per time slot
|
||||
minHour = 6,
|
||||
maxHour = 22,
|
||||
timeStep = 60, // 60 minutes (1 hour) per time slot for 24 divisions
|
||||
minHour = 0,
|
||||
maxHour = 24,
|
||||
dayWidth // Width per day in pixels (optional - if not provided, will be calculated)
|
||||
}: HorizontalTimelineCalendarProps) {
|
||||
const CONDUCTOR_NAME_COLUMN_WIDTH = 128 // Width of conductor name column (w-32 = 128px)
|
||||
@@ -114,6 +139,7 @@ export function HorizontalTimelineCalendar({
|
||||
const [assignmentPanelPosition, setAssignmentPanelPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [hoveredAvailability, setHoveredAvailability] = useState<string | null>(null) // Format: "conductorId-availIndex"
|
||||
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 [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 })
|
||||
const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position
|
||||
@@ -283,16 +309,14 @@ export function HorizontalTimelineCalendar({
|
||||
}
|
||||
}, [selectedMarker, assignmentPanelPosition])
|
||||
|
||||
// Generate time slots for a day
|
||||
// Generate time slots for a day - 24 hours, one slot per hour
|
||||
const timeSlots = useMemo(() => {
|
||||
const slots: string[] = []
|
||||
for (let hour = minHour; hour < maxHour; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += timeStep) {
|
||||
slots.push(`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`)
|
||||
}
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
slots.push(`${hour.toString().padStart(2, '0')}:00`)
|
||||
}
|
||||
return slots
|
||||
}, [minHour, maxHour, timeStep])
|
||||
}, [])
|
||||
|
||||
// Calculate pixel position for a given date/time
|
||||
const getTimePosition = useCallback((date: Date): number => {
|
||||
@@ -302,12 +326,12 @@ export function HorizontalTimelineCalendar({
|
||||
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 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
|
||||
}, [days, timeSlots, minHour, timeStep, effectiveDayWidth])
|
||||
@@ -315,13 +339,13 @@ export function HorizontalTimelineCalendar({
|
||||
// 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 slotWidth = effectiveDayWidth / 24 // 24 hours per day
|
||||
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)
|
||||
result.setMinutes(result.getMinutes() + minutes)
|
||||
|
||||
@@ -428,12 +452,12 @@ export function HorizontalTimelineCalendar({
|
||||
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)
|
||||
|
||||
@@ -491,125 +515,23 @@ export function HorizontalTimelineCalendar({
|
||||
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 className="relative">
|
||||
{/* Vertical lines layer - positioned outside overflow containers */}
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 45 }}>
|
||||
{(() => {
|
||||
// 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 (
|
||||
{/* Tooltip for vertical lines */}
|
||||
{verticalLineTooltip && (
|
||||
<div
|
||||
key={`vertical-line-${marker.id}`}
|
||||
className="absolute pointer-events-auto cursor-pointer transition-all duration-200 ease-in-out"
|
||||
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"
|
||||
style={{
|
||||
left: `${CONDUCTOR_NAME_COLUMN_WIDTH + lineX}px`,
|
||||
top: `${lineTop}px`,
|
||||
width: isVerticalLineHovered ? '4px' : '2px',
|
||||
height: `${lineHeight}px`,
|
||||
left: `${verticalLineTooltip.x}px`,
|
||||
top: `${verticalLineTooltip.y - 35}px`,
|
||||
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)}
|
||||
title={moment(marker.startTime).format('h:mm A')}
|
||||
>
|
||||
{verticalLineTooltip.time}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
@@ -738,7 +660,7 @@ export function HorizontalTimelineCalendar({
|
||||
}}
|
||||
/>
|
||||
{/* 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 */}
|
||||
<div className="relative" style={{ width: `${days.length * effectiveDayWidth}px`, height: '100%' }}>
|
||||
{days.map((day, dayIndex) => (
|
||||
@@ -749,26 +671,43 @@ 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<string, typeof visibleMarkers> = {}
|
||||
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<string, typeof phaseMarkers> = {}
|
||||
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<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 repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => {
|
||||
const positions = markers.map(m => {
|
||||
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()
|
||||
)
|
||||
@@ -776,41 +715,76 @@ export function HorizontalTimelineCalendar({
|
||||
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<typeof d> => d !== null)
|
||||
|
||||
// Calculate vertical stacking positions
|
||||
// Sort repetitions by left position to process them in order
|
||||
const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left)
|
||||
const ROW_HEIGHT = 40 // Height allocated per row
|
||||
const repetitionRows: Array<Array<typeof repetitionData[0]>> = []
|
||||
|
||||
sortedRepetitions.forEach(rep => {
|
||||
// Find the first row where this repetition doesn't overlap
|
||||
let placed = false
|
||||
for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) {
|
||||
const row = repetitionRows[rowIndex]
|
||||
// Check if this repetition overlaps with any in this row
|
||||
// Two repetitions overlap if they share any horizontal space
|
||||
// They don't overlap if one is completely to the left or right of the other
|
||||
const hasOverlap = row.some(existingRep => {
|
||||
// Add a small threshold to avoid edge cases
|
||||
const threshold = 1
|
||||
return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right)
|
||||
})
|
||||
@@ -822,180 +796,91 @@ export function HorizontalTimelineCalendar({
|
||||
}
|
||||
}
|
||||
|
||||
// If no row found, create a new row
|
||||
if (!placed) {
|
||||
repetitionRows.push([rep])
|
||||
}
|
||||
})
|
||||
|
||||
// Render all repetitions with their vertical positions
|
||||
// Use flexbox to stack rows and share vertical space equally
|
||||
// Render all repetitions - items stick to top, not distributed
|
||||
return (
|
||||
<div
|
||||
key="repetition-rows-container"
|
||||
style={{
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{repetitionRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
minHeight: 0 // Allow flex items to shrink below content size
|
||||
minHeight: ROW_HEIGHT,
|
||||
height: 'auto'
|
||||
}}
|
||||
>
|
||||
{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
|
||||
|
||||
return (
|
||||
<RepetitionBorder
|
||||
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>
|
||||
)
|
||||
})()}
|
||||
// Calculate height based on markers (just enough for markers)
|
||||
const repHeight = MARKER_HEIGHT
|
||||
|
||||
{/* Phase markers - positioned relative to their repetition's row */}
|
||||
{(() => {
|
||||
// Group only visible markers by repetition to find their row positions
|
||||
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)
|
||||
// Calculate if dragging
|
||||
const isDragging = draggingRepetition === rep.repId
|
||||
const currentLeft = isDragging && dragPosition ? dragPosition.x : rep.left
|
||||
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<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) => {
|
||||
// Render markers inside the repetition border
|
||||
const markerElements = rep.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
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
// 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 (
|
||||
<div
|
||||
key={marker.id}
|
||||
data-marker-id={marker.id}
|
||||
className={`absolute ${marker.locked ? 'cursor-not-allowed' : 'cursor-grab'} active:cursor-grabbing transition-all ${
|
||||
isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
className={`absolute ${marker.locked ? 'cursor-not-allowed' : 'cursor-grab'} active:cursor-grabbing transition-all ${isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
} ${isDragging ? 'opacity-50 z-50' : 'z-40'}`}
|
||||
style={{
|
||||
left: `${markerLeft}px`,
|
||||
top: `${topOffset}px`,
|
||||
left: `${markerLeftRelative}px`,
|
||||
top: `${MARKER_TOP_OFFSET}px`,
|
||||
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)}
|
||||
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)` : ''}`}
|
||||
>
|
||||
{/* 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 */}
|
||||
<div
|
||||
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}
|
||||
</span>
|
||||
|
||||
{/* Assign Conductors button - top right corner */}
|
||||
{/* Assign Conductors button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -1035,7 +960,7 @@ export function HorizontalTimelineCalendar({
|
||||
if (containerRect) {
|
||||
setSelectedMarker(marker.id)
|
||||
setAssignmentPanelPosition({
|
||||
x: markerRect.right - containerRect.left + 8, // 8px offset to the right
|
||||
x: markerRect.right - containerRect.left + 8,
|
||||
y: markerRect.top - containerRect.top
|
||||
})
|
||||
}
|
||||
@@ -1049,19 +974,22 @@ export function HorizontalTimelineCalendar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection indicators on assigned conductors (shown as dots on the vertical line) */}
|
||||
{/* Connection indicators on assigned conductors */}
|
||||
{marker.assignedConductors.map((conductorId, lineIndex) => {
|
||||
const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId)
|
||||
if (conductorIndex === -1) return null
|
||||
|
||||
const CONDUCTOR_ROW_HEIGHT = 36 // Height of each conductor row
|
||||
const HEADER_ROW_HEIGHT = 60 // Approximate height of header row
|
||||
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)
|
||||
|
||||
// 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)
|
||||
const markerCenterFromTop = HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + MARKER_TOP_OFFSET + (MARKER_ICON_SIZE / 2)
|
||||
const dotY = -(markerCenterFromTop - conductorRowCenter)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1085,6 +1013,33 @@ export function HorizontalTimelineCalendar({
|
||||
</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>
|
||||
|
||||
@@ -477,10 +477,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()))
|
||||
}
|
||||
|
||||
0
scripts/docker-compose-reset.sh
Normal file → Executable file
0
scripts/docker-compose-reset.sh
Normal file → Executable file
Reference in New Issue
Block a user