WIP: integrate-old-refactors-of-github #1
@@ -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 && (
|
||||||
{(() => {
|
<div
|
||||||
// Group markers by repetition to calculate row positions
|
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"
|
||||||
const markersByRepetition: Record<string, typeof phaseMarkers> = {}
|
style={{
|
||||||
phaseMarkers.forEach(marker => {
|
left: `${verticalLineTooltip.x}px`,
|
||||||
if (!markersByRepetition[marker.repetitionId]) {
|
top: `${verticalLineTooltip.y - 35}px`,
|
||||||
markersByRepetition[marker.repetitionId] = []
|
transform: 'translateX(-50%)',
|
||||||
}
|
}}
|
||||||
markersByRepetition[marker.repetitionId].push(marker)
|
>
|
||||||
})
|
{verticalLineTooltip.time}
|
||||||
|
{/* Tooltip arrow */}
|
||||||
const borderPadding = 20
|
<div
|
||||||
const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => {
|
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"
|
||||||
const positions = markers.map(m => {
|
/>
|
||||||
const dayIndex = days.findIndex(d =>
|
</div>
|
||||||
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
|
|
||||||
key={`vertical-line-${marker.id}`}
|
|
||||||
className="absolute pointer-events-auto cursor-pointer transition-all duration-200 ease-in-out"
|
|
||||||
style={{
|
|
||||||
left: `${CONDUCTOR_NAME_COLUMN_WIDTH + lineX}px`,
|
|
||||||
top: `${lineTop}px`,
|
|
||||||
width: isVerticalLineHovered ? '4px' : '2px',
|
|
||||||
height: `${lineHeight}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')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</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,55 +796,244 @@ 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
|
||||||
|
|
||||||
|
// Calculate height based on markers (just enough for markers)
|
||||||
|
const repHeight = MARKER_HEIGHT
|
||||||
|
|
||||||
|
// Calculate if dragging
|
||||||
|
const isDragging = draggingRepetition === rep.repId
|
||||||
|
const currentLeft = isDragging && dragPosition ? dragPosition.x : rep.left
|
||||||
|
const borderPadding = 20
|
||||||
|
|
||||||
|
// Render markers inside the repetition border
|
||||||
|
const markerElements = rep.visibleMarkers.map((marker) => {
|
||||||
|
const style = getPhaseStyle(marker.phase)
|
||||||
|
const absoluteX = getTimePosition(marker.startTime)
|
||||||
|
const isSelected = selectedMarker === marker.id
|
||||||
|
const isVerticalLineHovered = hoveredVerticalLine === marker.id
|
||||||
|
|
||||||
|
// Calculate marker position relative to repetition border's left edge
|
||||||
|
// The repetition border starts at currentLeft, and markers are positioned relative to that
|
||||||
|
let markerLeftRelative = absoluteX - currentLeft
|
||||||
|
|
||||||
|
// If dragging, maintain relative position to the original border position
|
||||||
|
if (isDragging && dragPosition) {
|
||||||
|
// Calculate offset from the original leftmost position
|
||||||
|
const originalLeftmost = Math.min(...rep.visibleMarkers.map(m => getTimePosition(m.startTime)))
|
||||||
|
const offsetFromLeftmost = absoluteX - originalLeftmost
|
||||||
|
markerLeftRelative = offsetFromLeftmost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate vertical line dimensions
|
||||||
|
const HEADER_ROW_HEIGHT = 60
|
||||||
|
const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36
|
||||||
|
const ROW_HEIGHT = 40
|
||||||
|
const MARKER_TOP_OFFSET = 10
|
||||||
|
const MARKER_ICON_SIZE = 32
|
||||||
|
const rowIndex = repetitionRows.findIndex(r => r.includes(rep))
|
||||||
|
|
||||||
|
const markerCenterY = MARKER_TOP_OFFSET + (MARKER_ICON_SIZE / 2)
|
||||||
|
const lineTop = -(HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + MARKER_TOP_OFFSET)
|
||||||
|
const lineHeight = HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + MARKER_TOP_OFFSET + markerCenterY
|
||||||
|
|
||||||
|
const formattedTime = moment(marker.startTime).format('h:mm A')
|
||||||
|
const formattedDate = moment(marker.startTime).format('MMM D, YYYY')
|
||||||
|
const fullTimeString = `${formattedDate} at ${formattedTime}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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' : ''
|
||||||
|
} ${isDragging ? 'opacity-50 z-50' : 'z-40'}`}
|
||||||
|
style={{
|
||||||
|
left: `${markerLeftRelative}px`,
|
||||||
|
top: `${MARKER_TOP_OFFSET}px`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
transition: isDragging ? 'none' : 'left 0.2s ease-out',
|
||||||
|
overflow: 'visible'
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => handleMarkerMouseDown(e, marker.id)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHoveredMarker(marker.id)
|
||||||
|
setHoveredVerticalLine(marker.id)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoveredMarker(null)
|
||||||
|
setHoveredVerticalLine(null)
|
||||||
|
}}
|
||||||
|
title={`${style.label} - ${moment(marker.startTime).format('MMM D, h:mm A')}${marker.assignedConductors.length > 0 ? ` (${marker.assignedConductors.length} assigned)` : ''}`}
|
||||||
|
>
|
||||||
|
{/* Vertical line extending from header to marker */}
|
||||||
|
<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"
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
backgroundColor: marker.locked ? '#9ca3af' : style.color,
|
||||||
|
border: `2px solid ${marker.locked ? '#6b7280' : style.color}`,
|
||||||
|
boxShadow: isSelected ? `0 0 0 3px ${style.color}40` : '0 2px 4px rgba(0,0,0,0.2)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-lg" style={{ filter: marker.locked ? 'grayscale(100%)' : 'none' }}>
|
||||||
|
{style.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Assign Conductors button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
const markerElement = e.currentTarget.closest('[data-marker-id]') as HTMLElement
|
||||||
|
if (markerElement) {
|
||||||
|
const markerRect = markerElement.getBoundingClientRect()
|
||||||
|
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||||
|
if (containerRect) {
|
||||||
|
setSelectedMarker(marker.id)
|
||||||
|
setAssignmentPanelPosition({
|
||||||
|
x: markerRect.right - containerRect.left + 8,
|
||||||
|
y: markerRect.top - containerRect.top
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center text-xs shadow-md z-50"
|
||||||
|
style={{ fontSize: '10px' }}
|
||||||
|
title="Assign Conductors"
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection indicators on assigned conductors */}
|
||||||
|
{marker.assignedConductors.map((conductorId, lineIndex) => {
|
||||||
|
const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId)
|
||||||
|
if (conductorIndex === -1) return null
|
||||||
|
|
||||||
|
const CONDUCTOR_ROW_HEIGHT = 36
|
||||||
|
const HEADER_ROW_HEIGHT = 60
|
||||||
|
const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36
|
||||||
|
const ROW_HEIGHT = 40
|
||||||
|
const MARKER_TOP_OFFSET = 10
|
||||||
|
const MARKER_ICON_SIZE = 32
|
||||||
|
|
||||||
|
const conductorRowTop = HEADER_ROW_HEIGHT + (conductorIndex * CONDUCTOR_ROW_HEIGHT)
|
||||||
|
const conductorRowCenter = conductorRowTop + (CONDUCTOR_ROW_HEIGHT / 2)
|
||||||
|
const markerCenterFromTop = HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + MARKER_TOP_OFFSET + (MARKER_ICON_SIZE / 2)
|
||||||
|
const dotY = -(markerCenterFromTop - conductorRowCenter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lineIndex}
|
||||||
|
className="absolute pointer-events-none rounded-full"
|
||||||
|
style={{
|
||||||
|
left: '50%',
|
||||||
|
top: `${dotY}px`,
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
backgroundColor: conductorAvailabilities[conductorIndex]?.color || '#3b82f6',
|
||||||
|
border: '2px solid white',
|
||||||
|
boxShadow: '0 0 0 1px rgba(0,0,0,0.1)',
|
||||||
|
zIndex: 15
|
||||||
|
}}
|
||||||
|
title={conductorAvailabilities[conductorIndex]?.conductorName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepetitionBorder
|
<RepetitionBorder
|
||||||
key={rep.repId}
|
key={rep.repId}
|
||||||
left={draggingRepetition === rep.repId && dragPosition ? dragPosition.x : rep.left}
|
left={currentLeft}
|
||||||
width={rep.width}
|
width={rep.width}
|
||||||
top={0}
|
top={0}
|
||||||
|
height={repHeight}
|
||||||
isLocked={firstMarker.locked}
|
isLocked={firstMarker.locked}
|
||||||
allPhases={allPhases}
|
allPhases={allPhases}
|
||||||
times={times}
|
times={times}
|
||||||
assignedCount={totalAssigned}
|
assignedCount={totalAssigned}
|
||||||
repId={rep.repId}
|
repId={rep.repId}
|
||||||
onMouseDown={(e) => handleRepetitionMouseDown(e, rep.repId)}
|
onMouseDown={(e) => handleRepetitionMouseDown(e, rep.repId)}
|
||||||
isDragging={draggingRepetition === rep.repId}
|
isDragging={isDragging}
|
||||||
dragOffset={repetitionDragOffset}
|
dragOffset={repetitionDragOffset}
|
||||||
/>
|
extendLeft={rep.extendLeft}
|
||||||
|
extendRight={rep.extendRight}
|
||||||
|
>
|
||||||
|
{markerElements}
|
||||||
|
</RepetitionBorder>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -878,214 +1041,6 @@ export function HorizontalTimelineCalendar({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 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)
|
|
||||||
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) => {
|
|
||||||
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 (
|
|
||||||
<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' : ''
|
|
||||||
} ${isDragging ? 'opacity-50 z-50' : 'z-40'}`}
|
|
||||||
style={{
|
|
||||||
left: `${markerLeft}px`,
|
|
||||||
top: `${topOffset}px`,
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
transition: isDragging ? 'none' : 'left 0.2s ease-out'
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => 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 */}
|
|
||||||
<div
|
|
||||||
className="rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 relative"
|
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
backgroundColor: marker.locked ? '#9ca3af' : style.color,
|
|
||||||
border: `2px solid ${marker.locked ? '#6b7280' : style.color}`,
|
|
||||||
boxShadow: isSelected ? `0 0 0 3px ${style.color}40` : '0 2px 4px rgba(0,0,0,0.2)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-lg" style={{ filter: marker.locked ? 'grayscale(100%)' : 'none' }}>
|
|
||||||
{style.icon}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Assign Conductors button - top right corner */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
const markerElement = e.currentTarget.closest('[data-marker-id]') as HTMLElement
|
|
||||||
if (markerElement) {
|
|
||||||
const markerRect = markerElement.getBoundingClientRect()
|
|
||||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
|
||||||
if (containerRect) {
|
|
||||||
setSelectedMarker(marker.id)
|
|
||||||
setAssignmentPanelPosition({
|
|
||||||
x: markerRect.right - containerRect.left + 8, // 8px offset to the right
|
|
||||||
y: markerRect.top - containerRect.top
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center text-xs shadow-md z-50"
|
|
||||||
style={{ fontSize: '10px' }}
|
|
||||||
title="Assign Conductors"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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 (
|
|
||||||
<div
|
|
||||||
key={lineIndex}
|
|
||||||
className="absolute pointer-events-none rounded-full"
|
|
||||||
style={{
|
|
||||||
left: '50%',
|
|
||||||
top: `${dotY}px`,
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
backgroundColor: conductorAvailabilities[conductorIndex]?.color || '#3b82f6',
|
|
||||||
border: '2px solid white',
|
|
||||||
boxShadow: '0 0 0 1px rgba(0,0,0,0.1)',
|
|
||||||
zIndex: 15
|
|
||||||
}}
|
|
||||||
title={conductorAvailabilities[conductorIndex]?.conductorName}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1149,10 +1104,10 @@ export function HorizontalTimelineCalendar({
|
|||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const marker = visibleMarkers.find(m => m.id === selectedMarker) || phaseMarkers.find(m => m.id === selectedMarker)
|
const marker = visibleMarkers.find(m => m.id === selectedMarker) || phaseMarkers.find(m => m.id === selectedMarker)
|
||||||
if (marker) {
|
if (marker) {
|
||||||
onMarkerLockToggle(selectedMarker)
|
onMarkerLockToggle(selectedMarker)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="text-xs px-3 py-1 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors"
|
className="text-xs px-3 py-1 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
0
scripts/docker-compose-reset.sh
Normal file → Executable file
Reference in New Issue
Block a user