WIP: integrate-old-refactors-of-github #1

Draft
hdh20267 wants to merge 140 commits from integrate-old-refactors-of-github into main
3 changed files with 411 additions and 455 deletions
Showing only changes of commit 0c434e7e7f - Show all commits

View File

@@ -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 (
<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>
{/* Tooltip for vertical lines */}
{verticalLineTooltip && (
<div
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: `${verticalLineTooltip.x}px`,
top: `${verticalLineTooltip.y - 35}px`,
transform: 'translateX(-50%)',
}}
>
{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,55 +796,244 @@ 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
// 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 (
<RepetitionBorder
key={rep.repId}
left={draggingRepetition === rep.repId && dragPosition ? dragPosition.x : rep.left}
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={draggingRepetition === rep.repId}
isDragging={isDragging}
dragOffset={repetitionDragOffset}
/>
extendLeft={rep.extendLeft}
extendRight={rep.extendRight}
>
{markerElements}
</RepetitionBorder>
)
})}
</div>
@@ -878,214 +1041,6 @@ export function HorizontalTimelineCalendar({
</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>
@@ -1149,10 +1104,10 @@ export function HorizontalTimelineCalendar({
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => {
const marker = visibleMarkers.find(m => m.id === selectedMarker) || phaseMarkers.find(m => m.id === selectedMarker)
if (marker) {
onMarkerLockToggle(selectedMarker)
}
const marker = visibleMarkers.find(m => m.id === selectedMarker) || phaseMarkers.find(m => m.id === selectedMarker)
if (marker) {
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"
>

View File

@@ -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
View File